@nocobase/flow-engine 2.1.0-alpha.3 → 2.1.0-alpha.31

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 (165) hide show
  1. package/LICENSE +201 -661
  2. package/README.md +79 -10
  3. package/lib/JSRunner.d.ts +10 -1
  4. package/lib/JSRunner.js +50 -5
  5. package/lib/ViewScopedFlowEngine.js +5 -1
  6. package/lib/components/FieldModelRenderer.js +2 -2
  7. package/lib/components/FlowModelRenderer.d.ts +3 -1
  8. package/lib/components/FlowModelRenderer.js +12 -6
  9. package/lib/components/FormItem.d.ts +6 -0
  10. package/lib/components/FormItem.js +11 -3
  11. package/lib/components/MobilePopup.js +6 -5
  12. package/lib/components/dnd/gridDragPlanner.d.ts +59 -2
  13. package/lib/components/dnd/gridDragPlanner.js +613 -21
  14. package/lib/components/dnd/index.d.ts +19 -1
  15. package/lib/components/dnd/index.js +243 -23
  16. package/lib/components/settings/wrappers/component/SelectWithTitle.d.ts +2 -1
  17. package/lib/components/settings/wrappers/component/SelectWithTitle.js +14 -12
  18. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.d.ts +3 -0
  19. package/lib/components/settings/wrappers/contextual/DefaultSettingsIcon.js +68 -10
  20. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.d.ts +23 -43
  21. package/lib/components/settings/wrappers/contextual/FlowsFloatContextMenu.js +352 -295
  22. package/lib/components/settings/wrappers/contextual/StepSettingsDialog.js +16 -2
  23. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.d.ts +36 -0
  24. package/lib/components/settings/wrappers/contextual/useFloatToolbarPortal.js +274 -0
  25. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.d.ts +30 -0
  26. package/lib/components/settings/wrappers/contextual/useFloatToolbarVisibility.js +315 -0
  27. package/lib/components/subModel/AddSubModelButton.js +27 -1
  28. package/lib/components/subModel/index.d.ts +1 -0
  29. package/lib/components/subModel/index.js +19 -0
  30. package/lib/components/subModel/utils.d.ts +1 -1
  31. package/lib/components/subModel/utils.js +2 -2
  32. package/lib/data-source/index.d.ts +75 -0
  33. package/lib/data-source/index.js +246 -4
  34. package/lib/executor/FlowExecutor.js +31 -8
  35. package/lib/flowContext.d.ts +2 -0
  36. package/lib/flowContext.js +31 -1
  37. package/lib/flowEngine.d.ts +151 -1
  38. package/lib/flowEngine.js +389 -15
  39. package/lib/flowI18n.js +2 -1
  40. package/lib/flowSettings.d.ts +14 -6
  41. package/lib/flowSettings.js +34 -6
  42. package/lib/lazy-helper.d.ts +14 -0
  43. package/lib/lazy-helper.js +71 -0
  44. package/lib/locale/en-US.json +1 -0
  45. package/lib/locale/index.d.ts +2 -0
  46. package/lib/locale/zh-CN.json +1 -0
  47. package/lib/models/DisplayItemModel.d.ts +1 -1
  48. package/lib/models/EditableItemModel.d.ts +1 -1
  49. package/lib/models/FilterableItemModel.d.ts +1 -1
  50. package/lib/models/flowModel.d.ts +13 -10
  51. package/lib/models/flowModel.js +78 -18
  52. package/lib/provider.js +38 -23
  53. package/lib/reactive/observer.js +46 -16
  54. package/lib/runjs-context/registry.d.ts +1 -1
  55. package/lib/runjs-context/setup.js +20 -12
  56. package/lib/runjs-context/snippets/index.js +13 -2
  57. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.d.ts +11 -0
  58. package/lib/runjs-context/snippets/scene/detail/set-field-style.snippet.js +50 -0
  59. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.d.ts +11 -0
  60. package/lib/runjs-context/snippets/scene/table/set-cell-style.snippet.js +54 -0
  61. package/lib/scheduler/ModelOperationScheduler.d.ts +5 -1
  62. package/lib/scheduler/ModelOperationScheduler.js +3 -2
  63. package/lib/types.d.ts +47 -1
  64. package/lib/utils/createCollectionContextMeta.js +6 -2
  65. package/lib/utils/index.d.ts +2 -2
  66. package/lib/utils/index.js +4 -0
  67. package/lib/utils/parsePathnameToViewParams.js +1 -1
  68. package/lib/utils/runjsTemplateCompat.js +1 -1
  69. package/lib/utils/runjsValue.js +41 -11
  70. package/lib/utils/schema-utils.d.ts +7 -1
  71. package/lib/utils/schema-utils.js +19 -0
  72. package/lib/views/FlowView.d.ts +7 -1
  73. package/lib/views/runViewBeforeClose.d.ts +10 -0
  74. package/lib/views/runViewBeforeClose.js +45 -0
  75. package/lib/views/useDialog.d.ts +2 -1
  76. package/lib/views/useDialog.js +20 -3
  77. package/lib/views/useDrawer.d.ts +2 -1
  78. package/lib/views/useDrawer.js +20 -3
  79. package/lib/views/usePage.d.ts +2 -1
  80. package/lib/views/usePage.js +10 -3
  81. package/package.json +6 -5
  82. package/src/JSRunner.ts +68 -4
  83. package/src/ViewScopedFlowEngine.ts +4 -0
  84. package/src/__tests__/JSRunner.test.ts +27 -1
  85. package/src/__tests__/flow-engine.test.ts +166 -0
  86. package/src/__tests__/flowContext.test.ts +65 -1
  87. package/src/__tests__/flowEngine.modelLoaders.test.ts +245 -0
  88. package/src/__tests__/flowSettings.test.ts +94 -15
  89. package/src/__tests__/objectVariable.test.ts +24 -0
  90. package/src/__tests__/provider.test.tsx +24 -2
  91. package/src/__tests__/renderHiddenInConfig.test.tsx +6 -6
  92. package/src/__tests__/runjsContext.test.ts +16 -0
  93. package/src/__tests__/runjsContextRuntime.test.ts +2 -0
  94. package/src/__tests__/runjsPreprocessDefault.test.ts +23 -0
  95. package/src/__tests__/runjsSnippets.test.ts +21 -0
  96. package/src/__tests__/viewScopedFlowEngine.test.ts +3 -3
  97. package/src/components/FieldModelRenderer.tsx +2 -1
  98. package/src/components/FlowModelRenderer.tsx +18 -6
  99. package/src/components/FormItem.tsx +7 -1
  100. package/src/components/MobilePopup.tsx +4 -2
  101. package/src/components/__tests__/FlowModelRenderer.test.tsx +65 -2
  102. package/src/components/__tests__/FormItem.test.tsx +25 -0
  103. package/src/components/__tests__/dnd.test.ts +44 -0
  104. package/src/components/__tests__/flow-model-render-error-fallback.test.tsx +20 -10
  105. package/src/components/__tests__/gridDragPlanner.test.ts +558 -3
  106. package/src/components/dnd/__tests__/DndProvider.test.tsx +98 -0
  107. package/src/components/dnd/gridDragPlanner.ts +758 -19
  108. package/src/components/dnd/index.tsx +291 -27
  109. package/src/components/settings/wrappers/component/SelectWithTitle.tsx +21 -9
  110. package/src/components/settings/wrappers/contextual/DefaultSettingsIcon.tsx +88 -10
  111. package/src/components/settings/wrappers/contextual/FlowsFloatContextMenu.tsx +487 -440
  112. package/src/components/settings/wrappers/contextual/StepSettingsDialog.tsx +18 -2
  113. package/src/components/settings/wrappers/contextual/__tests__/DefaultSettingsIcon.test.tsx +189 -3
  114. package/src/components/settings/wrappers/contextual/__tests__/FlowsFloatContextMenu.test.tsx +778 -0
  115. package/src/components/settings/wrappers/contextual/useFloatToolbarPortal.ts +360 -0
  116. package/src/components/settings/wrappers/contextual/useFloatToolbarVisibility.ts +361 -0
  117. package/src/components/subModel/AddSubModelButton.tsx +32 -2
  118. package/src/components/subModel/__tests__/AddSubModelButton.test.tsx +142 -32
  119. package/src/components/subModel/index.ts +1 -0
  120. package/src/components/subModel/utils.ts +1 -1
  121. package/src/data-source/__tests__/collection.test.ts +41 -2
  122. package/src/data-source/__tests__/index.test.ts +68 -1
  123. package/src/data-source/index.ts +303 -5
  124. package/src/executor/FlowExecutor.ts +34 -9
  125. package/src/executor/__tests__/flowExecutor.test.ts +57 -0
  126. package/src/flowContext.ts +37 -3
  127. package/src/flowEngine.ts +445 -11
  128. package/src/flowI18n.ts +2 -1
  129. package/src/flowSettings.ts +40 -6
  130. package/src/lazy-helper.tsx +57 -0
  131. package/src/locale/en-US.json +1 -0
  132. package/src/locale/zh-CN.json +1 -0
  133. package/src/models/DisplayItemModel.tsx +1 -1
  134. package/src/models/EditableItemModel.tsx +1 -1
  135. package/src/models/FilterableItemModel.tsx +1 -1
  136. package/src/models/__tests__/dispatchEvent.when.test.ts +214 -0
  137. package/src/models/__tests__/flowModel.test.ts +19 -3
  138. package/src/models/flowModel.tsx +119 -33
  139. package/src/provider.tsx +41 -25
  140. package/src/reactive/__tests__/observer.test.tsx +82 -0
  141. package/src/reactive/observer.tsx +87 -25
  142. package/src/runjs-context/registry.ts +1 -1
  143. package/src/runjs-context/setup.ts +22 -12
  144. package/src/runjs-context/snippets/index.ts +12 -1
  145. package/src/runjs-context/snippets/scene/detail/set-field-style.snippet.ts +30 -0
  146. package/src/runjs-context/snippets/scene/table/set-cell-style.snippet.ts +34 -0
  147. package/src/scheduler/ModelOperationScheduler.ts +14 -3
  148. package/src/types.ts +60 -0
  149. package/src/utils/__tests__/createCollectionContextMeta.test.ts +48 -0
  150. package/src/utils/__tests__/parsePathnameToViewParams.test.ts +7 -0
  151. package/src/utils/__tests__/runjsValue.test.ts +11 -0
  152. package/src/utils/__tests__/utils.test.ts +62 -0
  153. package/src/utils/createCollectionContextMeta.ts +6 -2
  154. package/src/utils/index.ts +2 -1
  155. package/src/utils/parsePathnameToViewParams.ts +2 -2
  156. package/src/utils/runjsTemplateCompat.ts +1 -1
  157. package/src/utils/runjsValue.ts +50 -11
  158. package/src/utils/schema-utils.ts +30 -1
  159. package/src/views/FlowView.tsx +11 -1
  160. package/src/views/__tests__/runViewBeforeClose.test.ts +30 -0
  161. package/src/views/__tests__/useDialog.closeDestroy.test.tsx +13 -12
  162. package/src/views/runViewBeforeClose.ts +19 -0
  163. package/src/views/useDialog.tsx +25 -3
  164. package/src/views/useDrawer.tsx +25 -3
  165. package/src/views/usePage.tsx +12 -3
@@ -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
+ });