@nocobase/flow-engine 2.1.0-alpha.4 → 2.1.0-alpha.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/LICENSE +201 -661
  2. package/README.md +79 -10
  3. package/lib/FlowContextProvider.d.ts +5 -1
  4. package/lib/FlowContextProvider.js +9 -2
  5. package/lib/JSRunner.d.ts +10 -1
  6. package/lib/JSRunner.js +50 -5
  7. package/lib/ViewScopedFlowEngine.js +5 -1
  8. package/lib/components/FieldModelRenderer.js +2 -2
  9. package/lib/components/FlowModelRenderer.d.ts +3 -1
  10. package/lib/components/FlowModelRenderer.js +12 -6
  11. package/lib/components/FormItem.d.ts +6 -0
  12. package/lib/components/FormItem.js +11 -3
  13. package/lib/components/MobilePopup.js +6 -5
  14. package/lib/components/dnd/gridDragPlanner.d.ts +59 -2
  15. package/lib/components/dnd/gridDragPlanner.js +613 -21
  16. package/lib/components/dnd/index.d.ts +31 -2
  17. package/lib/components/dnd/index.js +244 -23
  18. package/lib/components/settings/wrappers/component/SelectWithTitle.d.ts +2 -1
  19. package/lib/components/settings/wrappers/component/SelectWithTitle.js +14 -12
  20. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.d.ts +3 -0
  21. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +76 -11
  22. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +23 -43
  23. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +352 -295
  24. package/lib/components/settings/wrappers/contextual/StepSettingsDialog.js +16 -2
  25. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.d.ts +36 -0
  26. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +274 -0
  27. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.d.ts +30 -0
  28. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +315 -0
  29. package/lib/components/subModel/AddSubModelButton.js +27 -1
  30. package/lib/components/subModel/LazyDropdown.js +293 -52
  31. package/lib/components/subModel/index.d.ts +1 -0
  32. package/lib/components/subModel/index.js +19 -0
  33. package/lib/components/subModel/utils.d.ts +1 -1
  34. package/lib/components/subModel/utils.js +9 -3
  35. package/lib/components/variables/VariableHybridInput.d.ts +27 -0
  36. package/lib/components/variables/VariableHybridInput.js +499 -0
  37. package/lib/components/variables/index.d.ts +2 -0
  38. package/lib/components/variables/index.js +3 -0
  39. package/lib/data-source/index.d.ts +84 -0
  40. package/lib/data-source/index.js +259 -5
  41. package/lib/executor/FlowExecutor.js +32 -9
  42. package/lib/flow-registry/DetachedFlowRegistry.d.ts +21 -0
  43. package/lib/flow-registry/DetachedFlowRegistry.js +80 -0
  44. package/lib/flow-registry/index.d.ts +1 -0
  45. package/lib/flow-registry/index.js +3 -1
  46. package/lib/flowContext.d.ts +3 -0
  47. package/lib/flowContext.js +46 -1
  48. package/lib/flowEngine.d.ts +151 -1
  49. package/lib/flowEngine.js +392 -18
  50. package/lib/flowI18n.js +2 -1
  51. package/lib/flowSettings.d.ts +14 -6
  52. package/lib/flowSettings.js +34 -6
  53. package/lib/index.d.ts +2 -0
  54. package/lib/index.js +7 -0
  55. package/lib/lazy-helper.d.ts +14 -0
  56. package/lib/lazy-helper.js +71 -0
  57. package/lib/locale/en-US.json +1 -0
  58. package/lib/locale/index.d.ts +2 -0
  59. package/lib/locale/zh-CN.json +1 -0
  60. package/lib/models/DisplayItemModel.d.ts +1 -1
  61. package/lib/models/EditableItemModel.d.ts +1 -1
  62. package/lib/models/FilterableItemModel.d.ts +1 -1
  63. package/lib/models/flowModel.d.ts +13 -10
  64. package/lib/models/flowModel.js +81 -21
  65. package/lib/provider.js +38 -23
  66. package/lib/reactive/observer.js +46 -16
  67. package/lib/runjs-context/registry.d.ts +1 -1
  68. package/lib/runjs-context/setup.js +20 -12
  69. package/lib/runjs-context/snippets/index.js +13 -2
  70. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.d.ts +11 -0
  71. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.js +50 -0
  72. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.d.ts +11 -0
  73. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.js +54 -0
  74. package/lib/scheduler/ModelOperationScheduler.d.ts +5 -1
  75. package/lib/scheduler/ModelOperationScheduler.js +3 -2
  76. package/lib/types.d.ts +50 -2
  77. package/lib/types.js +1 -0
  78. package/lib/utils/createCollectionContextMeta.js +6 -2
  79. package/lib/utils/index.d.ts +3 -2
  80. package/lib/utils/index.js +7 -0
  81. package/lib/utils/parsePathnameToViewParams.d.ts +5 -1
  82. package/lib/utils/parsePathnameToViewParams.js +29 -5
  83. package/lib/utils/randomId.d.ts +39 -0
  84. package/lib/utils/randomId.js +45 -0
  85. package/lib/utils/runjsTemplateCompat.js +1 -1
  86. package/lib/utils/runjsValue.js +41 -11
  87. package/lib/utils/schema-utils.d.ts +7 -1
  88. package/lib/utils/schema-utils.js +19 -0
  89. package/lib/views/FlowView.d.ts +7 -1
  90. package/lib/views/FlowView.js +11 -1
  91. package/lib/views/PageComponent.js +8 -6
  92. package/lib/views/ViewNavigation.d.ts +12 -2
  93. package/lib/views/ViewNavigation.js +28 -9
  94. package/lib/views/createViewMeta.js +114 -50
  95. package/lib/views/inheritLayoutContext.d.ts +10 -0
  96. package/lib/views/inheritLayoutContext.js +50 -0
  97. package/lib/views/runViewBeforeClose.d.ts +10 -0
  98. package/lib/views/runViewBeforeClose.js +45 -0
  99. package/lib/views/useDialog.d.ts +2 -1
  100. package/lib/views/useDialog.js +22 -3
  101. package/lib/views/useDrawer.d.ts +2 -1
  102. package/lib/views/useDrawer.js +22 -3
  103. package/lib/views/usePage.d.ts +5 -11
  104. package/lib/views/usePage.js +304 -144
  105. package/package.json +6 -5
  106. package/src/FlowContextProvider.tsx +9 -1
  107. package/src/JSRunner.ts +68 -4
  108. package/src/ViewScopedFlowEngine.ts +4 -0
  109. package/src/__tests__/JSRunner.test.ts +27 -1
  110. package/src/__tests__/createViewMeta.popup.test.ts +115 -1
  111. package/src/__tests__/flow-engine.test.ts +166 -0
  112. package/src/__tests__/flowContext.test.ts +82 -1
  113. package/src/__tests__/flowEngine.modelLoaders.test.ts +245 -0
  114. package/src/__tests__/flowEngine.removeModel.test.ts +47 -3
  115. package/src/__tests__/flowSettings.test.ts +94 -15
  116. package/src/__tests__/objectVariable.test.ts +24 -0
  117. package/src/__tests__/provider.test.tsx +24 -2
  118. package/src/__tests__/renderHiddenInConfig.test.tsx +6 -6
  119. package/src/__tests__/runjsContext.test.ts +16 -0
  120. package/src/__tests__/runjsContextRuntime.test.ts +2 -0
  121. package/src/__tests__/runjsPreprocessDefault.test.ts +23 -0
  122. package/src/__tests__/runjsSnippets.test.ts +21 -0
  123. package/src/__tests__/viewScopedFlowEngine.test.ts +3 -3
  124. package/src/components/FieldModelRenderer.tsx +2 -1
  125. package/src/components/FlowModelRenderer.tsx +18 -6
  126. package/src/components/FormItem.tsx +7 -1
  127. package/src/components/MobilePopup.tsx +4 -2
  128. package/src/components/__tests__/FlowModelRenderer.test.tsx +65 -2
  129. package/src/components/__tests__/FormItem.test.tsx +25 -0
  130. package/src/components/__tests__/dnd.test.ts +44 -0
  131. package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +20 -10
  132. package/src/components/__tests__/gridDragPlanner.test.ts +558 -3
  133. package/src/components/dnd/__tests__/DndProvider.test.tsx +98 -0
  134. package/src/components/dnd/gridDragPlanner.ts +758 -19
  135. package/src/components/dnd/index.tsx +305 -28
  136. package/src/components/settings/wrappers/component/SelectWithTitle.tsx +21 -9
  137. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +99 -11
  138. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +487 -440
  139. package/src/components/settings/wrappers/contextual/StepSettingsDialog.tsx +18 -2
  140. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +194 -5
  141. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +778 -0
  142. package/src/components/settings/wrappers/contextual/useFloatToolbarPortal.ts +360 -0
  143. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +361 -0
  144. package/src/components/subModel/AddSubModelButton.tsx +32 -2
  145. package/src/components/subModel/LazyDropdown.tsx +332 -56
  146. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +522 -37
  147. package/src/components/subModel/__tests__/utils.test.ts +24 -0
  148. package/src/components/subModel/index.ts +1 -0
  149. package/src/components/subModel/utils.ts +7 -1
  150. package/src/components/variables/VariableHybridInput.tsx +531 -0
  151. package/src/components/variables/index.ts +2 -0
  152. package/src/data-source/__tests__/collection.test.ts +41 -2
  153. package/src/data-source/__tests__/index.test.ts +68 -1
  154. package/src/data-source/index.ts +322 -6
  155. package/src/executor/FlowExecutor.ts +35 -10
  156. package/src/executor/__tests__/flowExecutor.test.ts +85 -0
  157. package/src/flow-registry/DetachedFlowRegistry.ts +46 -0
  158. package/src/flow-registry/__tests__/detachedFlowRegistry.test.ts +47 -0
  159. package/src/flow-registry/index.ts +1 -0
  160. package/src/flowContext.ts +50 -3
  161. package/src/flowEngine.ts +449 -14
  162. package/src/flowI18n.ts +2 -1
  163. package/src/flowSettings.ts +40 -6
  164. package/src/index.ts +2 -0
  165. package/src/lazy-helper.tsx +57 -0
  166. package/src/locale/en-US.json +1 -0
  167. package/src/locale/zh-CN.json +1 -0
  168. package/src/models/DisplayItemModel.tsx +1 -1
  169. package/src/models/EditableItemModel.tsx +1 -1
  170. package/src/models/FilterableItemModel.tsx +1 -1
  171. package/src/models/__tests__/dispatchEvent.when.test.ts +214 -0
  172. package/src/models/__tests__/flowEngine.resolveUse.test.ts +0 -15
  173. package/src/models/__tests__/flowModel.test.ts +80 -37
  174. package/src/models/flowModel.tsx +122 -36
  175. package/src/provider.tsx +41 -25
  176. package/src/reactive/__tests__/observer.test.tsx +82 -0
  177. package/src/reactive/observer.tsx +87 -25
  178. package/src/runjs-context/registry.ts +1 -1
  179. package/src/runjs-context/setup.ts +22 -12
  180. package/src/runjs-context/snippets/index.ts +12 -1
  181. package/src/runjs-context/snippets/scene/detail/set-field-style.snippet.ts +30 -0
  182. package/src/runjs-context/snippets/scene/table/set-cell-style.snippet.ts +34 -0
  183. package/src/scheduler/ModelOperationScheduler.ts +14 -3
  184. package/src/types.ts +62 -0
  185. package/src/utils/__tests__/createCollectionContextMeta.test.ts +48 -0
  186. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +28 -0
  187. package/src/utils/__tests__/runjsValue.test.ts +11 -0
  188. package/src/utils/__tests__/utils.test.ts +62 -0
  189. package/src/utils/createCollectionContextMeta.ts +6 -2
  190. package/src/utils/index.ts +5 -1
  191. package/src/utils/parsePathnameToViewParams.ts +47 -7
  192. package/src/utils/randomId.ts +48 -0
  193. package/src/utils/runjsTemplateCompat.ts +1 -1
  194. package/src/utils/runjsValue.ts +50 -11
  195. package/src/utils/schema-utils.ts +30 -1
  196. package/src/views/FlowView.tsx +22 -2
  197. package/src/views/PageComponent.tsx +7 -4
  198. package/src/views/ViewNavigation.ts +46 -9
  199. package/src/views/__tests__/FlowView.usePage.test.tsx +243 -3
  200. package/src/views/__tests__/ViewNavigation.test.ts +52 -0
  201. package/src/views/__tests__/inheritLayoutContext.test.ts +53 -0
  202. package/src/views/__tests__/runViewBeforeClose.test.ts +30 -0
  203. package/src/views/__tests__/useDialog.closeDestroy.test.tsx +13 -12
  204. package/src/views/createViewMeta.ts +106 -34
  205. package/src/views/inheritLayoutContext.ts +26 -0
  206. package/src/views/runViewBeforeClose.ts +19 -0
  207. package/src/views/useDialog.tsx +27 -3
  208. package/src/views/useDrawer.tsx +27 -3
  209. package/src/views/usePage.tsx +367 -179
@@ -15,6 +15,10 @@ import {
15
15
  getSlotKey,
16
16
  resolveDropIntent,
17
17
  Point,
18
+ buildLayoutSnapshot,
19
+ normalizeGridLayout,
20
+ projectLayoutToLegacyRows,
21
+ replaceUidInGridLayout,
18
22
  } from '../dnd/gridDragPlanner';
19
23
 
20
24
  const rect = { top: 0, left: 0, width: 100, height: 100 };
@@ -29,6 +33,183 @@ const createLayout = (
29
33
  rowOrder,
30
34
  });
31
35
 
36
+ const createDomRect = ({ top, left, width, height }: { top: number; left: number; width: number; height: number }) => {
37
+ return {
38
+ top,
39
+ left,
40
+ width,
41
+ height,
42
+ right: left + width,
43
+ bottom: top + height,
44
+ x: left,
45
+ y: top,
46
+ toJSON: () => ({}),
47
+ } as DOMRect;
48
+ };
49
+
50
+ const mockRect = (
51
+ element: Element,
52
+ rect: {
53
+ top: number;
54
+ left: number;
55
+ width: number;
56
+ height: number;
57
+ },
58
+ ) => {
59
+ Object.defineProperty(element, 'getBoundingClientRect', {
60
+ configurable: true,
61
+ value: () => createDomRect(rect),
62
+ });
63
+ };
64
+
65
+ describe('buildLayoutSnapshot', () => {
66
+ it('should ignore nested grid columns/items even when rowId is duplicated', () => {
67
+ const container = document.createElement('div');
68
+ const row = document.createElement('div');
69
+ row.setAttribute('data-grid-row-id', 'row-1');
70
+ container.appendChild(row);
71
+
72
+ const column = document.createElement('div');
73
+ column.setAttribute('data-grid-column-row-id', 'row-1');
74
+ column.setAttribute('data-grid-column-index', '0');
75
+ row.appendChild(column);
76
+
77
+ const item = document.createElement('div');
78
+ item.setAttribute('data-grid-item-row-id', 'row-1');
79
+ item.setAttribute('data-grid-column-index', '0');
80
+ item.setAttribute('data-grid-item-index', '0');
81
+ column.appendChild(item);
82
+
83
+ // 在外层 item 内构建一个嵌套 grid,并复用相同 rowId/columnIndex
84
+ const nestedRow = document.createElement('div');
85
+ nestedRow.setAttribute('data-grid-row-id', 'row-1');
86
+ item.appendChild(nestedRow);
87
+
88
+ const nestedColumn = document.createElement('div');
89
+ nestedColumn.setAttribute('data-grid-column-row-id', 'row-1');
90
+ nestedColumn.setAttribute('data-grid-column-index', '0');
91
+ nestedRow.appendChild(nestedColumn);
92
+
93
+ const nestedItem = document.createElement('div');
94
+ nestedItem.setAttribute('data-grid-item-row-id', 'row-1');
95
+ nestedItem.setAttribute('data-grid-column-index', '0');
96
+ nestedItem.setAttribute('data-grid-item-index', '0');
97
+ nestedColumn.appendChild(nestedItem);
98
+
99
+ mockRect(container, { top: 0, left: 0, width: 600, height: 600 });
100
+ mockRect(row, { top: 10, left: 10, width: 320, height: 120 });
101
+ mockRect(column, { top: 10, left: 10, width: 320, height: 120 });
102
+ mockRect(item, { top: 20, left: 20, width: 300, height: 80 });
103
+
104
+ // 嵌套 grid 给一个明显偏离的位置,用于判断是否被错误命中
105
+ mockRect(nestedRow, { top: 360, left: 360, width: 200, height: 120 });
106
+ mockRect(nestedColumn, { top: 360, left: 360, width: 200, height: 120 });
107
+ mockRect(nestedItem, { top: 370, left: 370, width: 180, height: 90 });
108
+
109
+ const snapshot = buildLayoutSnapshot({ container });
110
+ const columnEdgeSlots = snapshot.slots.filter((slot) => slot.type === 'column-edge');
111
+ const columnSlots = snapshot.slots.filter((slot) => slot.type === 'column');
112
+ const itemEdgeSlots = snapshot.slots.filter((slot) => slot.type === 'item-edge');
113
+
114
+ // 外层单行单列单项应只有 8 个 slot:上/下 row-gap + 左/右 column-edge + before/after column + 左/右 item-edge
115
+ expect(snapshot.slots).toHaveLength(8);
116
+ expect(columnEdgeSlots).toHaveLength(2);
117
+ expect(columnSlots).toHaveLength(2);
118
+ expect(itemEdgeSlots).toHaveLength(2);
119
+
120
+ // 不应混入嵌套 grid(其 top >= 360)
121
+ expect(snapshot.slots.every((slot) => slot.rect.top < 300)).toBe(true);
122
+ });
123
+
124
+ it('does not create an empty-column slot for a cell that contains nested rows', () => {
125
+ const container = document.createElement('div');
126
+ container.setAttribute('data-grid-root', '');
127
+ const row = document.createElement('div');
128
+ row.setAttribute('data-grid-row-id', 'outer');
129
+ row.setAttribute('data-grid-path', JSON.stringify([{ rowId: 'outer' }]));
130
+ container.appendChild(row);
131
+
132
+ const leftColumn = document.createElement('div');
133
+ leftColumn.setAttribute('data-grid-column-row-id', 'outer');
134
+ leftColumn.setAttribute('data-grid-column-index', '0');
135
+ leftColumn.setAttribute('data-grid-path', JSON.stringify([{ rowId: 'outer', cellId: 'outer-left' }]));
136
+ row.appendChild(leftColumn);
137
+
138
+ const leftItem = document.createElement('div');
139
+ leftItem.setAttribute('data-grid-item-row-id', 'outer');
140
+ leftItem.setAttribute('data-grid-column-index', '0');
141
+ leftItem.setAttribute('data-grid-item-index', '0');
142
+ leftItem.setAttribute('data-grid-item-uid', 'left');
143
+ leftColumn.appendChild(leftItem);
144
+
145
+ const rightColumn = document.createElement('div');
146
+ rightColumn.setAttribute('data-grid-column-row-id', 'outer');
147
+ rightColumn.setAttribute('data-grid-column-index', '1');
148
+ rightColumn.setAttribute('data-grid-path', JSON.stringify([{ rowId: 'outer', cellId: 'outer-right' }]));
149
+ row.appendChild(rightColumn);
150
+
151
+ const nestedRow = document.createElement('div');
152
+ nestedRow.setAttribute('data-grid-row-id', 'nested');
153
+ nestedRow.setAttribute(
154
+ 'data-grid-path',
155
+ JSON.stringify([{ rowId: 'outer', cellId: 'outer-right' }, { rowId: 'nested' }]),
156
+ );
157
+ rightColumn.appendChild(nestedRow);
158
+
159
+ const nestedColumn = document.createElement('div');
160
+ nestedColumn.setAttribute('data-grid-column-row-id', 'nested');
161
+ nestedColumn.setAttribute('data-grid-column-index', '0');
162
+ nestedColumn.setAttribute(
163
+ 'data-grid-path',
164
+ JSON.stringify([
165
+ { rowId: 'outer', cellId: 'outer-right' },
166
+ { rowId: 'nested', cellId: 'nested-cell' },
167
+ ]),
168
+ );
169
+ nestedRow.appendChild(nestedColumn);
170
+
171
+ const nestedItem = document.createElement('div');
172
+ nestedItem.setAttribute('data-grid-item-row-id', 'nested');
173
+ nestedItem.setAttribute('data-grid-column-index', '0');
174
+ nestedItem.setAttribute('data-grid-item-index', '0');
175
+ nestedItem.setAttribute('data-grid-item-uid', 'nested-item');
176
+ nestedColumn.appendChild(nestedItem);
177
+
178
+ mockRect(container, { top: 0, left: 0, width: 1200, height: 500 });
179
+ mockRect(row, { top: 10, left: 10, width: 1180, height: 420 });
180
+ mockRect(leftColumn, { top: 10, left: 10, width: 480, height: 420 });
181
+ mockRect(leftItem, { top: 20, left: 20, width: 460, height: 380 });
182
+ mockRect(rightColumn, { top: 10, left: 500, width: 690, height: 420 });
183
+ mockRect(nestedRow, { top: 20, left: 510, width: 670, height: 120 });
184
+ mockRect(nestedColumn, { top: 20, left: 510, width: 670, height: 120 });
185
+ mockRect(nestedItem, { top: 30, left: 520, width: 650, height: 90 });
186
+
187
+ const snapshot = buildLayoutSnapshot({ container });
188
+
189
+ expect(
190
+ snapshot.slots.some(
191
+ (slot) =>
192
+ slot.type === 'empty-column' &&
193
+ slot.rowId === 'outer' &&
194
+ slot.columnIndex === 1 &&
195
+ slot.rect.left === 500 &&
196
+ slot.rect.width === 690,
197
+ ),
198
+ ).toBe(false);
199
+ expect(snapshot.slots.some((slot) => slot.type === 'column' && slot.rowId === 'nested')).toBe(true);
200
+
201
+ const nestedBelowGap = snapshot.slots.find(
202
+ (slot) => slot.type === 'row-gap' && slot.targetRowId === 'nested' && slot.position === 'below',
203
+ );
204
+ expect(nestedBelowGap?.rect).toEqual({
205
+ top: 140,
206
+ left: 500,
207
+ width: 690,
208
+ height: 48,
209
+ });
210
+ });
211
+ });
212
+
32
213
  describe('getSlotKey', () => {
33
214
  it('should generate unique key for column slot', () => {
34
215
  const slot: LayoutSlot = {
@@ -90,6 +271,184 @@ describe('getSlotKey', () => {
90
271
  const key = getSlotKey(slot);
91
272
  expect(key).toBe('empty-column:row1:0');
92
273
  });
274
+
275
+ it('should generate unique key for item-edge slot', () => {
276
+ const slot: LayoutSlot = {
277
+ type: 'item-edge',
278
+ rowId: 'row1',
279
+ columnIndex: 0,
280
+ itemIndex: 1,
281
+ itemUid: 'block-2',
282
+ direction: 'right',
283
+ rect,
284
+ };
285
+
286
+ const key = getSlotKey(slot);
287
+ expect(key).toBe('item-edge:row1:0:block-2:right');
288
+ });
289
+ });
290
+
291
+ describe('GridLayoutV2 helpers', () => {
292
+ it('converts legacy rows to stable v2 layout and legacy projection', () => {
293
+ const layout = normalizeGridLayout({
294
+ rows: {
295
+ rowA: [['a'], ['b', 'c']],
296
+ },
297
+ sizes: {
298
+ rowA: [8, 16],
299
+ },
300
+ rowOrder: ['rowA'],
301
+ itemUids: ['a', 'b', 'c'],
302
+ });
303
+
304
+ expect(layout).toMatchObject({
305
+ version: 2,
306
+ rows: [
307
+ {
308
+ id: 'rowA',
309
+ cells: [
310
+ { id: 'rowA:cell:0', items: ['a'] },
311
+ { id: 'rowA:cell:1', items: ['b', 'c'] },
312
+ ],
313
+ sizes: [8, 16],
314
+ },
315
+ ],
316
+ });
317
+
318
+ const projected = projectLayoutToLegacyRows(layout);
319
+ expect(projected.rows.rowA).toEqual([['a'], ['b', 'c']]);
320
+ expect(projected.sizes.rowA).toEqual([8, 16]);
321
+ });
322
+
323
+ it('deduplicates items, removes invalid uids, appends missing items, and fixes sizes', () => {
324
+ const layout = normalizeGridLayout({
325
+ layout: {
326
+ version: 2,
327
+ rows: [
328
+ {
329
+ id: 'rowA',
330
+ cells: [
331
+ { id: 'cellA', items: ['a', 'b', 'a', 'ghost'] },
332
+ { id: 'cellB', items: ['c'] },
333
+ ],
334
+ sizes: [5],
335
+ },
336
+ ],
337
+ },
338
+ itemUids: ['a', 'b', 'c', 'd'],
339
+ generateId: () => 'row-missing',
340
+ });
341
+
342
+ expect(layout.rows[0].cells[0]).toMatchObject({ id: 'cellA', items: ['a', 'b'] });
343
+ expect(layout.rows[0].cells[1]).toMatchObject({ id: 'cellB', items: ['c'] });
344
+ expect(layout.rows[0].sizes).toHaveLength(2);
345
+ expect(layout.rows[0].sizes.reduce((sum, value) => sum + value, 0)).toBe(24);
346
+ expect(layout.rows[1]).toMatchObject({
347
+ id: 'row-missing',
348
+ cells: [{ id: 'row-missing:cell:0', items: ['d'] }],
349
+ sizes: [24],
350
+ });
351
+ });
352
+
353
+ it('preserves explicit empty v2 cells while removing invalid-only cells', () => {
354
+ const layout = normalizeGridLayout({
355
+ layout: {
356
+ version: 2,
357
+ rows: [
358
+ {
359
+ id: 'rowA',
360
+ cells: [
361
+ { id: 'empty-cell', items: [] },
362
+ { id: 'invalid-cell', items: ['ghost'] },
363
+ { id: 'valid-cell', items: ['a'] },
364
+ ],
365
+ sizes: [8, 8, 8],
366
+ },
367
+ ],
368
+ },
369
+ itemUids: ['a'],
370
+ });
371
+
372
+ expect(layout.rows[0].cells).toEqual([
373
+ { id: 'empty-cell', items: [] },
374
+ { id: 'valid-cell', items: ['a'] },
375
+ ]);
376
+ expect(layout.rows[0].sizes).toEqual([12, 12]);
377
+ });
378
+
379
+ it('replaces uid in nested layout', () => {
380
+ const layout = normalizeGridLayout({
381
+ layout: {
382
+ version: 2,
383
+ rows: [
384
+ {
385
+ id: 'rowA',
386
+ cells: [
387
+ {
388
+ id: 'cellA',
389
+ rows: [
390
+ {
391
+ id: 'nested',
392
+ cells: [{ id: 'nested-cell', items: ['old'] }],
393
+ sizes: [24],
394
+ },
395
+ ],
396
+ },
397
+ ],
398
+ sizes: [24],
399
+ },
400
+ ],
401
+ },
402
+ itemUids: ['old'],
403
+ });
404
+
405
+ const replaced = replaceUidInGridLayout(layout, 'old', 'new');
406
+ expect(projectLayoutToLegacyRows(replaced).rows.rowA).toEqual([['new']]);
407
+ });
408
+
409
+ it('projects nested layout without duplicating nested items', () => {
410
+ const layout = normalizeGridLayout({
411
+ layout: {
412
+ version: 2,
413
+ rows: [
414
+ {
415
+ id: 'rowA',
416
+ cells: [
417
+ {
418
+ id: 'cellA',
419
+ rows: [
420
+ {
421
+ id: 'nestedA',
422
+ cells: [{ id: 'nestedA:cell:0', items: ['a'] }],
423
+ sizes: [24],
424
+ },
425
+ {
426
+ id: 'nestedB',
427
+ cells: [
428
+ { id: 'nestedB:cell:0', items: ['two'] },
429
+ { id: 'nestedB:cell:1', items: ['four'] },
430
+ ],
431
+ sizes: [12, 12],
432
+ },
433
+ {
434
+ id: 'nestedC',
435
+ cells: [{ id: 'nestedC:cell:0', items: ['three'] }],
436
+ sizes: [24],
437
+ },
438
+ ],
439
+ },
440
+ ],
441
+ sizes: [24],
442
+ },
443
+ ],
444
+ },
445
+ itemUids: ['a', 'two', 'four', 'three'],
446
+ });
447
+
448
+ const projected = projectLayoutToLegacyRows(layout);
449
+ expect(Object.keys(projected.rows)).toEqual(['rowA']);
450
+ expect(projected.rows.rowA).toEqual([['a', 'two', 'four', 'three']]);
451
+ });
93
452
  });
94
453
 
95
454
  describe('resolveDropIntent', () => {
@@ -143,7 +502,7 @@ describe('resolveDropIntent', () => {
143
502
  expect(result).toEqual(targetSlot);
144
503
  });
145
504
 
146
- it('should return first matching slot when multiple slots contain the point', () => {
505
+ it('should return highest-priority matching slot when multiple slots contain the point', () => {
147
506
  const firstSlot: LayoutSlot = {
148
507
  type: 'empty-row',
149
508
  rect: { top: 0, left: 0, width: 500, height: 500 },
@@ -162,8 +521,7 @@ describe('resolveDropIntent', () => {
162
521
 
163
522
  const point: Point = { x: 125, y: 110 };
164
523
  const result = resolveDropIntent(point, slots);
165
- // Returns first slot that contains the point
166
- expect(result).toEqual(firstSlot);
524
+ expect(result).toEqual(secondSlot);
167
525
  });
168
526
 
169
527
  it('should handle empty-column slot correctly', () => {
@@ -396,6 +754,158 @@ describe('simulateLayoutForSlot', () => {
396
754
  expect(result.rows.rowA[2]).toEqual(['c']);
397
755
  });
398
756
 
757
+ it('preserves remaining cell size ratios after removing a non-last source cell in v2 layout', () => {
758
+ const layout = createLayout(
759
+ {
760
+ rowA: [['a'], ['b'], ['c']],
761
+ },
762
+ {
763
+ rowA: [4, 8, 12],
764
+ },
765
+ );
766
+ layout.layout = normalizeGridLayout({
767
+ rows: layout.rows,
768
+ sizes: layout.sizes,
769
+ rowOrder: ['rowA'],
770
+ itemUids: ['a', 'b', 'c'],
771
+ });
772
+
773
+ const slot: LayoutSlot = {
774
+ type: 'column',
775
+ rowId: 'rowA',
776
+ columnIndex: 2,
777
+ insertIndex: 1,
778
+ position: 'after',
779
+ path: [{ rowId: 'rowA', cellId: 'rowA:cell:2' }],
780
+ rect,
781
+ };
782
+
783
+ const result = simulateLayoutForSlot({ slot, sourceUid: 'a', layout });
784
+
785
+ expect(result.layout!.rows[0].cells.map((cell) => cell.items)).toEqual([['b'], ['c', 'a']]);
786
+ expect(result.layout!.rows[0].sizes).toEqual([10, 14]);
787
+ });
788
+
789
+ it('splits a stacked cell around item-edge right with stable generated ids', () => {
790
+ const layout = createLayout(
791
+ {
792
+ rowA: [['a', 'two', 'three', 'd']],
793
+ },
794
+ {
795
+ rowA: [24],
796
+ },
797
+ );
798
+ layout.layout = normalizeGridLayout({
799
+ rows: layout.rows,
800
+ sizes: layout.sizes,
801
+ rowOrder: ['rowA'],
802
+ itemUids: ['a', 'two', 'three', 'd', 'four'],
803
+ });
804
+
805
+ const slot: LayoutSlot = {
806
+ type: 'item-edge',
807
+ rowId: 'rowA',
808
+ columnIndex: 0,
809
+ itemIndex: 1,
810
+ itemUid: 'two',
811
+ direction: 'right',
812
+ path: [{ rowId: 'rowA', cellId: 'rowA:cell:0' }],
813
+ rect,
814
+ };
815
+
816
+ const generatedIds = new Map<string, string>();
817
+ const generateId = (key: string) => `id:${key}`;
818
+ const first = simulateLayoutForSlot({ slot, sourceUid: 'four', layout, generatedIds, generateId });
819
+ const second = simulateLayoutForSlot({ slot, sourceUid: 'four', layout, generatedIds, generateId });
820
+
821
+ expect(first.layout).toEqual(second.layout);
822
+ const nestedRows = first.layout!.rows[0].cells[0].rows!;
823
+ expect(nestedRows.map((row) => row.cells.map((cell) => cell.items))).toEqual([
824
+ [['a']],
825
+ [['two'], ['four']],
826
+ [['three']],
827
+ [['d']],
828
+ ]);
829
+ expect(nestedRows[1].sizes).toEqual([12, 12]);
830
+ });
831
+
832
+ it('keeps nested column insertion target when removing a sibling collapses the original path', () => {
833
+ const layout = createLayout(
834
+ {
835
+ vyvfw2jw071: [['6ad3ccaabd5', 'ff8b4b57f65']],
836
+ ablhoqw51gb: [['21b422021b8']],
837
+ },
838
+ {
839
+ vyvfw2jw071: [24],
840
+ ablhoqw51gb: [24],
841
+ },
842
+ ['vyvfw2jw071', 'ablhoqw51gb'],
843
+ );
844
+ layout.layout = normalizeGridLayout({
845
+ rows: layout.rows,
846
+ sizes: layout.sizes,
847
+ rowOrder: layout.rowOrder,
848
+ itemUids: ['6ad3ccaabd5', 'ff8b4b57f65', '21b422021b8'],
849
+ });
850
+
851
+ const slot: LayoutSlot = {
852
+ type: 'column',
853
+ rowId: 'll5vo5pzj3u',
854
+ columnIndex: 0,
855
+ insertIndex: 1,
856
+ position: 'after',
857
+ path: [
858
+ { rowId: 'vyvfw2jw071', cellId: 'vyvfw2jw071:cell:0' },
859
+ { rowId: 'll5vo5pzj3u', cellId: 'ghy612j5zzg' },
860
+ ],
861
+ rect,
862
+ };
863
+
864
+ const result = simulateLayoutForSlot({ slot, sourceUid: 'ff8b4b57f65', layout });
865
+
866
+ expect(result.layout!.rows).toMatchObject([
867
+ {
868
+ id: 'vyvfw2jw071',
869
+ cells: [{ items: ['6ad3ccaabd5', 'ff8b4b57f65'] }],
870
+ },
871
+ {
872
+ id: 'ablhoqw51gb',
873
+ cells: [{ items: ['21b422021b8'] }],
874
+ },
875
+ ]);
876
+ });
877
+
878
+ it('treats dragging an item to its own item-edge as no-op', () => {
879
+ const layout = createLayout(
880
+ {
881
+ rowA: [['two']],
882
+ },
883
+ {
884
+ rowA: [24],
885
+ },
886
+ );
887
+ layout.layout = normalizeGridLayout({
888
+ rows: layout.rows,
889
+ sizes: layout.sizes,
890
+ rowOrder: ['rowA'],
891
+ itemUids: ['two'],
892
+ });
893
+
894
+ const slot: LayoutSlot = {
895
+ type: 'item-edge',
896
+ rowId: 'rowA',
897
+ columnIndex: 0,
898
+ itemIndex: 0,
899
+ itemUid: 'two',
900
+ direction: 'right',
901
+ path: [{ rowId: 'rowA', cellId: 'rowA:cell:0' }],
902
+ rect,
903
+ };
904
+
905
+ const result = simulateLayoutForSlot({ slot, sourceUid: 'two', layout });
906
+ expect(result.layout).toEqual(layout.layout);
907
+ });
908
+
399
909
  it('handles row-gap slot below position', () => {
400
910
  const layout = createLayout(
401
911
  {
@@ -631,4 +1141,49 @@ describe('simulateLayoutForSlot', () => {
631
1141
  const total = result.sizes.rowA.reduce((sum, size) => sum + size, 0);
632
1142
  expect(total).toBe(24);
633
1143
  });
1144
+
1145
+ it('preserves explicit empty v2 cells when moving another item', () => {
1146
+ const layout: GridLayoutData = {
1147
+ rows: {},
1148
+ sizes: {},
1149
+ layout: {
1150
+ version: 2,
1151
+ rows: [
1152
+ {
1153
+ id: 'rowA',
1154
+ cells: [
1155
+ { id: 'empty-cell', items: [] },
1156
+ { id: 'source-cell', items: ['source'] },
1157
+ { id: 'target-cell', items: ['target'] },
1158
+ ],
1159
+ sizes: [8, 8, 8],
1160
+ },
1161
+ ],
1162
+ },
1163
+ };
1164
+
1165
+ const slot: LayoutSlot = {
1166
+ type: 'column-edge',
1167
+ rowId: 'rowA',
1168
+ columnIndex: 2,
1169
+ direction: 'right',
1170
+ rect,
1171
+ path: [{ rowId: 'rowA', cellId: 'target-cell' }],
1172
+ };
1173
+
1174
+ const result = simulateLayoutForSlot({
1175
+ slot,
1176
+ sourceUid: 'source',
1177
+ layout,
1178
+ generateId: (key) => key,
1179
+ });
1180
+
1181
+ expect(result.layout?.rows[0].cells).toEqual([
1182
+ { id: 'empty-cell', items: [] },
1183
+ { id: 'target-cell', items: ['target'] },
1184
+ { id: 'column-edge:rowA:2:right:cell', items: ['source'] },
1185
+ ]);
1186
+ expect(result.layout?.rows[0].sizes).toHaveLength(3);
1187
+ expect(result.layout?.rows[0].sizes.reduce((sum, size) => sum + size, 0)).toBe(24);
1188
+ });
634
1189
  });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import React from 'react';
11
+ import { act, render, screen } from '@testing-library/react';
12
+ import { afterEach, describe, expect, it, vi } from 'vitest';
13
+ import { FlowEngine } from '../../../flowEngine';
14
+ import { FlowEngineProvider } from '../../../provider';
15
+ import { DndProvider } from '../index';
16
+
17
+ let latestDndProps: any = null;
18
+
19
+ vi.mock('@dnd-kit/core', () => ({
20
+ DndContext: ({ children, ...props }: any) => {
21
+ latestDndProps = props;
22
+ return <div data-testid="dnd-context">{children}</div>;
23
+ },
24
+ DragOverlay: ({ children }: any) => <div data-testid="drag-overlay">{children}</div>,
25
+ useDraggable: () => ({
26
+ attributes: {},
27
+ listeners: {},
28
+ setNodeRef: vi.fn(),
29
+ }),
30
+ useDroppable: () => ({
31
+ active: null,
32
+ isOver: false,
33
+ setNodeRef: vi.fn(),
34
+ }),
35
+ }));
36
+
37
+ const renderDndProvider = (
38
+ props: React.ComponentProps<typeof DndProvider> = {},
39
+ setupEngine?: (engine: FlowEngine) => void,
40
+ ) => {
41
+ const engine = new FlowEngine();
42
+ setupEngine?.(engine);
43
+ return render(
44
+ <FlowEngineProvider engine={engine}>
45
+ <DndProvider {...props}>
46
+ <div>content</div>
47
+ </DndProvider>
48
+ </FlowEngineProvider>,
49
+ );
50
+ };
51
+
52
+ describe('DndProvider', () => {
53
+ afterEach(() => {
54
+ latestDndProps = null;
55
+ });
56
+
57
+ it('keeps the drag overlay visible when a custom onDragStart is provided', () => {
58
+ const onDragStart = vi.fn();
59
+ renderDndProvider({ onDragStart });
60
+
61
+ act(() => {
62
+ latestDndProps.onDragStart({ active: { id: 'block-1' } });
63
+ });
64
+
65
+ expect(onDragStart).toHaveBeenCalledWith({ active: { id: 'block-1' } });
66
+ expect(screen.getByTestId('flow-drag-preview')).toBeInTheDocument();
67
+ expect(screen.getByText('Dragging')).toBeInTheDocument();
68
+ });
69
+
70
+ it('clears the drag overlay when custom drag callbacks finish', () => {
71
+ const onDragStart = vi.fn();
72
+ const onDragEnd = vi.fn();
73
+ const onDragCancel = vi.fn();
74
+ renderDndProvider({ onDragStart, onDragEnd, onDragCancel });
75
+
76
+ act(() => {
77
+ latestDndProps.onDragStart({ active: { id: 'block-1' } });
78
+ });
79
+ expect(screen.getByText('Dragging')).toBeInTheDocument();
80
+
81
+ act(() => {
82
+ latestDndProps.onDragEnd({ active: { id: 'block-1' }, over: null });
83
+ });
84
+ expect(onDragEnd).toHaveBeenCalledWith({ active: { id: 'block-1' }, over: null });
85
+ expect(screen.queryByText('Dragging')).not.toBeInTheDocument();
86
+
87
+ act(() => {
88
+ latestDndProps.onDragStart({ active: { id: 'block-1' } });
89
+ });
90
+ expect(screen.getByText('Dragging')).toBeInTheDocument();
91
+
92
+ act(() => {
93
+ latestDndProps.onDragCancel({ active: { id: 'block-1' } });
94
+ });
95
+ expect(onDragCancel).toHaveBeenCalledWith({ active: { id: 'block-1' } });
96
+ expect(screen.queryByText('Dragging')).not.toBeInTheDocument();
97
+ });
98
+ });