@nocobase/flow-engine 2.1.0-beta.8 → 2.2.0-alpha.1

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 (215) hide show
  1. package/lib/FlowContextProvider.d.ts +5 -1
  2. package/lib/FlowContextProvider.js +9 -2
  3. package/lib/components/FieldModelRenderer.js +2 -2
  4. package/lib/components/FlowModelRenderer.d.ts +3 -1
  5. package/lib/components/FlowModelRenderer.js +12 -6
  6. package/lib/components/FormItem.d.ts +6 -0
  7. package/lib/components/FormItem.js +11 -3
  8. package/lib/components/MobilePopup.js +6 -5
  9. package/lib/components/dnd/gridDragPlanner.d.ts +59 -2
  10. package/lib/components/dnd/gridDragPlanner.js +607 -19
  11. package/lib/components/dnd/index.d.ts +31 -2
  12. package/lib/components/dnd/index.js +244 -23
  13. package/lib/components/settings/wrappers/component/SelectWithTitle.d.ts +2 -1
  14. package/lib/components/settings/wrappers/component/SelectWithTitle.js +14 -12
  15. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.d.ts +3 -0
  16. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +152 -42
  17. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +23 -43
  18. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +352 -295
  19. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.d.ts +36 -0
  20. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +274 -0
  21. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.d.ts +30 -0
  22. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +315 -0
  23. package/lib/components/subModel/AddSubModelButton.js +12 -1
  24. package/lib/components/subModel/LazyDropdown.js +301 -52
  25. package/lib/components/subModel/index.d.ts +1 -0
  26. package/lib/components/subModel/index.js +19 -0
  27. package/lib/components/subModel/utils.d.ts +2 -1
  28. package/lib/components/subModel/utils.js +15 -5
  29. package/lib/components/variables/VariableHybridInput.d.ts +27 -0
  30. package/lib/components/variables/VariableHybridInput.js +499 -0
  31. package/lib/components/variables/index.d.ts +2 -0
  32. package/lib/components/variables/index.js +3 -0
  33. package/lib/data-source/index.d.ts +84 -0
  34. package/lib/data-source/index.js +269 -7
  35. package/lib/executor/FlowExecutor.js +6 -3
  36. package/lib/flow-registry/DetachedFlowRegistry.d.ts +21 -0
  37. package/lib/flow-registry/DetachedFlowRegistry.js +80 -0
  38. package/lib/flow-registry/index.d.ts +1 -0
  39. package/lib/flow-registry/index.js +3 -1
  40. package/lib/flowContext.d.ts +9 -1
  41. package/lib/flowContext.js +77 -6
  42. package/lib/flowEngine.d.ts +136 -4
  43. package/lib/flowEngine.js +429 -51
  44. package/lib/flowI18n.js +2 -1
  45. package/lib/flowSettings.d.ts +14 -6
  46. package/lib/flowSettings.js +34 -6
  47. package/lib/index.d.ts +2 -0
  48. package/lib/index.js +7 -0
  49. package/lib/lazy-helper.d.ts +14 -0
  50. package/lib/lazy-helper.js +71 -0
  51. package/lib/locale/en-US.json +1 -0
  52. package/lib/locale/index.d.ts +2 -0
  53. package/lib/locale/zh-CN.json +1 -0
  54. package/lib/models/DisplayItemModel.d.ts +1 -1
  55. package/lib/models/EditableItemModel.d.ts +1 -1
  56. package/lib/models/FilterableItemModel.d.ts +1 -1
  57. package/lib/models/flowModel.d.ts +13 -10
  58. package/lib/models/flowModel.js +126 -34
  59. package/lib/provider.js +38 -23
  60. package/lib/reactive/observer.js +46 -16
  61. package/lib/runjs-context/contexts/FormJSFieldItemRunJSContext.js +4 -3
  62. package/lib/runjs-context/contexts/JSBlockRunJSContext.js +4 -15
  63. package/lib/runjs-context/contexts/JSColumnRunJSContext.js +5 -2
  64. package/lib/runjs-context/contexts/JSEditableFieldRunJSContext.js +5 -8
  65. package/lib/runjs-context/contexts/JSFieldRunJSContext.js +4 -3
  66. package/lib/runjs-context/contexts/JSItemRunJSContext.js +4 -3
  67. package/lib/runjs-context/contexts/base.js +464 -29
  68. package/lib/runjs-context/contexts/elementDoc.d.ts +11 -0
  69. package/lib/runjs-context/contexts/elementDoc.js +152 -0
  70. package/lib/runjs-context/setup.js +1 -0
  71. package/lib/runjs-context/snippets/index.js +13 -2
  72. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.d.ts +11 -0
  73. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.js +50 -0
  74. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.d.ts +11 -0
  75. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.js +54 -0
  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/loadedPageCache.d.ts +24 -0
  82. package/lib/utils/loadedPageCache.js +139 -0
  83. package/lib/utils/parsePathnameToViewParams.d.ts +5 -1
  84. package/lib/utils/parsePathnameToViewParams.js +28 -4
  85. package/lib/utils/randomId.d.ts +39 -0
  86. package/lib/utils/randomId.js +45 -0
  87. package/lib/utils/runjsTemplateCompat.js +1 -1
  88. package/lib/utils/runjsValue.js +41 -11
  89. package/lib/utils/schema-utils.d.ts +7 -1
  90. package/lib/utils/schema-utils.js +19 -0
  91. package/lib/views/FlowView.d.ts +7 -1
  92. package/lib/views/FlowView.js +11 -1
  93. package/lib/views/PageComponent.js +8 -6
  94. package/lib/views/ViewNavigation.d.ts +12 -2
  95. package/lib/views/ViewNavigation.js +28 -9
  96. package/lib/views/createViewMeta.js +114 -50
  97. package/lib/views/inheritLayoutContext.d.ts +10 -0
  98. package/lib/views/inheritLayoutContext.js +50 -0
  99. package/lib/views/runViewBeforeClose.d.ts +10 -0
  100. package/lib/views/runViewBeforeClose.js +45 -0
  101. package/lib/views/useDialog.d.ts +2 -1
  102. package/lib/views/useDialog.js +12 -3
  103. package/lib/views/useDrawer.d.ts +2 -1
  104. package/lib/views/useDrawer.js +12 -3
  105. package/lib/views/usePage.d.ts +5 -11
  106. package/lib/views/usePage.js +304 -144
  107. package/package.json +5 -4
  108. package/src/FlowContextProvider.tsx +9 -1
  109. package/src/__tests__/createViewMeta.popup.test.ts +115 -1
  110. package/src/__tests__/flow-engine.test.ts +166 -0
  111. package/src/__tests__/flowContext.test.ts +105 -1
  112. package/src/__tests__/flowEngine.modelLoaders.test.ts +245 -0
  113. package/src/__tests__/flowEngine.moveModel.test.ts +81 -1
  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 +21 -0
  120. package/src/__tests__/runjsContextImplementations.test.ts +9 -2
  121. package/src/__tests__/runjsContextRuntime.test.ts +2 -0
  122. package/src/__tests__/runjsLocales.test.ts +6 -5
  123. package/src/__tests__/runjsSnippets.test.ts +21 -0
  124. package/src/__tests__/viewScopedFlowEngine.test.ts +136 -3
  125. package/src/components/FieldModelRenderer.tsx +2 -1
  126. package/src/components/FlowModelRenderer.tsx +18 -6
  127. package/src/components/FormItem.tsx +7 -1
  128. package/src/components/MobilePopup.tsx +4 -2
  129. package/src/components/__tests__/FlowModelRenderer.test.tsx +65 -2
  130. package/src/components/__tests__/FormItem.test.tsx +25 -0
  131. package/src/components/__tests__/dnd.test.ts +44 -0
  132. package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +20 -10
  133. package/src/components/__tests__/gridDragPlanner.test.ts +472 -5
  134. package/src/components/dnd/__tests__/DndProvider.test.tsx +98 -0
  135. package/src/components/dnd/gridDragPlanner.ts +750 -17
  136. package/src/components/dnd/index.tsx +305 -28
  137. package/src/components/settings/wrappers/component/SelectWithTitle.tsx +21 -9
  138. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +178 -48
  139. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +487 -440
  140. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +344 -8
  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 +16 -2
  145. package/src/components/subModel/LazyDropdown.tsx +341 -56
  146. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +524 -38
  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 +13 -2
  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 +69 -2
  154. package/src/data-source/index.ts +332 -8
  155. package/src/executor/FlowExecutor.ts +6 -3
  156. package/src/executor/__tests__/flowExecutor.test.ts +57 -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 +85 -6
  161. package/src/flowEngine.ts +484 -45
  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__/flowEngine.resolveUse.test.ts +0 -15
  172. package/src/models/__tests__/flowModel.test.ts +65 -37
  173. package/src/models/flowModel.tsx +184 -65
  174. package/src/provider.tsx +41 -25
  175. package/src/reactive/__tests__/observer.test.tsx +82 -0
  176. package/src/reactive/observer.tsx +87 -25
  177. package/src/runjs-context/contexts/FormJSFieldItemRunJSContext.ts +4 -3
  178. package/src/runjs-context/contexts/JSBlockRunJSContext.ts +4 -15
  179. package/src/runjs-context/contexts/JSColumnRunJSContext.ts +4 -2
  180. package/src/runjs-context/contexts/JSEditableFieldRunJSContext.ts +5 -9
  181. package/src/runjs-context/contexts/JSFieldRunJSContext.ts +4 -3
  182. package/src/runjs-context/contexts/JSItemRunJSContext.ts +4 -3
  183. package/src/runjs-context/contexts/base.ts +467 -31
  184. package/src/runjs-context/contexts/elementDoc.ts +130 -0
  185. package/src/runjs-context/setup.ts +1 -0
  186. package/src/runjs-context/snippets/index.ts +12 -1
  187. package/src/runjs-context/snippets/scene/detail/set-field-style.snippet.ts +30 -0
  188. package/src/runjs-context/snippets/scene/table/set-cell-style.snippet.ts +34 -0
  189. package/src/types.ts +62 -0
  190. package/src/utils/__tests__/createCollectionContextMeta.test.ts +48 -0
  191. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +21 -0
  192. package/src/utils/__tests__/runjsValue.test.ts +11 -0
  193. package/src/utils/__tests__/utils.test.ts +62 -0
  194. package/src/utils/createCollectionContextMeta.ts +6 -2
  195. package/src/utils/index.ts +5 -1
  196. package/src/utils/loadedPageCache.ts +147 -0
  197. package/src/utils/parsePathnameToViewParams.ts +45 -5
  198. package/src/utils/randomId.ts +48 -0
  199. package/src/utils/runjsTemplateCompat.ts +1 -1
  200. package/src/utils/runjsValue.ts +50 -11
  201. package/src/utils/schema-utils.ts +30 -1
  202. package/src/views/FlowView.tsx +22 -2
  203. package/src/views/PageComponent.tsx +7 -4
  204. package/src/views/ViewNavigation.ts +46 -9
  205. package/src/views/__tests__/FlowView.usePage.test.tsx +243 -3
  206. package/src/views/__tests__/ViewNavigation.test.ts +52 -0
  207. package/src/views/__tests__/inheritLayoutContext.test.ts +53 -0
  208. package/src/views/__tests__/runViewBeforeClose.test.ts +30 -0
  209. package/src/views/__tests__/useDialog.closeDestroy.test.tsx +12 -12
  210. package/src/views/createViewMeta.ts +106 -34
  211. package/src/views/inheritLayoutContext.ts +26 -0
  212. package/src/views/runViewBeforeClose.ts +19 -0
  213. package/src/views/useDialog.tsx +13 -3
  214. package/src/views/useDrawer.tsx +13 -3
  215. package/src/views/usePage.tsx +367 -180
@@ -16,6 +16,9 @@ import {
16
16
  resolveDropIntent,
17
17
  Point,
18
18
  buildLayoutSnapshot,
19
+ normalizeGridLayout,
20
+ projectLayoutToLegacyRows,
21
+ replaceUidInGridLayout,
19
22
  } from '../dnd/gridDragPlanner';
20
23
 
21
24
  const rect = { top: 0, left: 0, width: 100, height: 100 };
@@ -106,15 +109,105 @@ describe('buildLayoutSnapshot', () => {
106
109
  const snapshot = buildLayoutSnapshot({ container });
107
110
  const columnEdgeSlots = snapshot.slots.filter((slot) => slot.type === 'column-edge');
108
111
  const columnSlots = snapshot.slots.filter((slot) => slot.type === 'column');
112
+ const itemEdgeSlots = snapshot.slots.filter((slot) => slot.type === 'item-edge');
109
113
 
110
- // 外层单行单列单项应只有 6 个 slot:上/下 row-gap + 左/右 column-edge + before/after column
111
- expect(snapshot.slots).toHaveLength(6);
114
+ // 外层单行单列单项应只有 8 个 slot:上/下 row-gap + 左/右 column-edge + before/after column + 左/右 item-edge
115
+ expect(snapshot.slots).toHaveLength(8);
112
116
  expect(columnEdgeSlots).toHaveLength(2);
113
117
  expect(columnSlots).toHaveLength(2);
118
+ expect(itemEdgeSlots).toHaveLength(2);
114
119
 
115
120
  // 不应混入嵌套 grid(其 top >= 360)
116
121
  expect(snapshot.slots.every((slot) => slot.rect.top < 300)).toBe(true);
117
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
+ });
118
211
  });
119
212
 
120
213
  describe('getSlotKey', () => {
@@ -178,6 +271,184 @@ describe('getSlotKey', () => {
178
271
  const key = getSlotKey(slot);
179
272
  expect(key).toBe('empty-column:row1:0');
180
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
+ });
181
452
  });
182
453
 
183
454
  describe('resolveDropIntent', () => {
@@ -231,7 +502,7 @@ describe('resolveDropIntent', () => {
231
502
  expect(result).toEqual(targetSlot);
232
503
  });
233
504
 
234
- 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', () => {
235
506
  const firstSlot: LayoutSlot = {
236
507
  type: 'empty-row',
237
508
  rect: { top: 0, left: 0, width: 500, height: 500 },
@@ -250,8 +521,7 @@ describe('resolveDropIntent', () => {
250
521
 
251
522
  const point: Point = { x: 125, y: 110 };
252
523
  const result = resolveDropIntent(point, slots);
253
- // Returns first slot that contains the point
254
- expect(result).toEqual(firstSlot);
524
+ expect(result).toEqual(secondSlot);
255
525
  });
256
526
 
257
527
  it('should handle empty-column slot correctly', () => {
@@ -484,6 +754,158 @@ describe('simulateLayoutForSlot', () => {
484
754
  expect(result.rows.rowA[2]).toEqual(['c']);
485
755
  });
486
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
+
487
909
  it('handles row-gap slot below position', () => {
488
910
  const layout = createLayout(
489
911
  {
@@ -719,4 +1141,49 @@ describe('simulateLayoutForSlot', () => {
719
1141
  const total = result.sizes.rowA.reduce((sum, size) => sum + size, 0);
720
1142
  expect(total).toBe(24);
721
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
+ });
722
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
+ });