@nocobase/flow-engine 2.1.0-beta.23 → 2.1.0-beta.24

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.
@@ -85,7 +85,7 @@ const resolveDraggableHostNode = /* @__PURE__ */ __name((activatorNode) => {
85
85
  `[data-has-float-menu="true"][data-float-menu-model-uid="${toolbarModelUid}"]`
86
86
  )
87
87
  );
88
- const popupRoot = floatToolbarContainer.closest(MENU_SUBMENU_POPUP_SELECTOR);
88
+ const popupRoot = floatToolbarContainer == null ? void 0 : floatToolbarContainer.closest(MENU_SUBMENU_POPUP_SELECTOR);
89
89
  if (popupRoot) {
90
90
  return matchedHosts.find((hostNode) => hostNode.closest(MENU_SUBMENU_POPUP_SELECTOR) === popupRoot) || activatorNode;
91
91
  }
@@ -275,7 +275,9 @@ const Droppable = /* @__PURE__ */ __name(({ model, children }) => {
275
275
  const DndProvider = /* @__PURE__ */ __name(({
276
276
  persist = true,
277
277
  children,
278
+ onDragStart,
278
279
  onDragEnd,
280
+ onDragCancel,
279
281
  ...restProps
280
282
  }) => {
281
283
  const [activeId, setActiveId] = (0, import_react.useState)(null);
@@ -311,10 +313,10 @@ const DndProvider = /* @__PURE__ */ __name(({
311
313
  return /* @__PURE__ */ import_react.default.createElement(
312
314
  import_core.DndContext,
313
315
  {
316
+ ...restProps,
314
317
  onDragStart: (event) => {
315
- var _a;
316
318
  setActiveId(event.active.id);
317
- (_a = restProps.onDragStart) == null ? void 0 : _a.call(restProps, event);
319
+ onDragStart == null ? void 0 : onDragStart(event);
318
320
  },
319
321
  onDragEnd: (event) => {
320
322
  setActiveId(null);
@@ -328,10 +330,9 @@ const DndProvider = /* @__PURE__ */ __name(({
328
330
  }
329
331
  },
330
332
  onDragCancel: (event) => {
331
- var _a;
332
333
  setActiveId(null);
333
334
  setDragAnchorPoint(null);
334
- (_a = restProps.onDragCancel) == null ? void 0 : _a.call(restProps, event);
335
+ onDragCancel == null ? void 0 : onDragCancel(event);
335
336
  },
336
337
  ...restProps
337
338
  },
@@ -348,6 +349,7 @@ const DndProvider = /* @__PURE__ */ __name(({
348
349
  activeId && /* @__PURE__ */ import_react.default.createElement(
349
350
  "span",
350
351
  {
352
+ "data-testid": "flow-drag-preview",
351
353
  style: {
352
354
  display: "inline-flex",
353
355
  alignItems: "center",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/flow-engine",
3
- "version": "2.1.0-beta.23",
3
+ "version": "2.1.0-beta.24",
4
4
  "private": false,
5
5
  "description": "A standalone flow engine for NocoBase, managing workflows, models, and actions.",
6
6
  "main": "lib/index.js",
@@ -8,8 +8,8 @@
8
8
  "dependencies": {
9
9
  "@formily/antd-v5": "1.x",
10
10
  "@formily/reactive": "2.x",
11
- "@nocobase/sdk": "2.1.0-beta.23",
12
- "@nocobase/shared": "2.1.0-beta.23",
11
+ "@nocobase/sdk": "2.1.0-beta.24",
12
+ "@nocobase/shared": "2.1.0-beta.24",
13
13
  "ahooks": "^3.7.2",
14
14
  "axios": "^1.7.0",
15
15
  "dayjs": "^1.11.9",
@@ -37,5 +37,5 @@
37
37
  ],
38
38
  "author": "NocoBase Team",
39
39
  "license": "Apache-2.0",
40
- "gitHead": "bb4c0d3551bf9eff505b63756dd24a0813231f16"
40
+ "gitHead": "f77b85530a2d127d9bfe4dca3a26fbb02c1139ba"
41
41
  }
@@ -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,112 @@ 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('treats dragging an item to its own item-edge as no-op', () => {
833
+ const layout = createLayout(
834
+ {
835
+ rowA: [['two']],
836
+ },
837
+ {
838
+ rowA: [24],
839
+ },
840
+ );
841
+ layout.layout = normalizeGridLayout({
842
+ rows: layout.rows,
843
+ sizes: layout.sizes,
844
+ rowOrder: ['rowA'],
845
+ itemUids: ['two'],
846
+ });
847
+
848
+ const slot: LayoutSlot = {
849
+ type: 'item-edge',
850
+ rowId: 'rowA',
851
+ columnIndex: 0,
852
+ itemIndex: 0,
853
+ itemUid: 'two',
854
+ direction: 'right',
855
+ path: [{ rowId: 'rowA', cellId: 'rowA:cell:0' }],
856
+ rect,
857
+ };
858
+
859
+ const result = simulateLayoutForSlot({ slot, sourceUid: 'two', layout });
860
+ expect(result.layout).toEqual(layout.layout);
861
+ });
862
+
487
863
  it('handles row-gap slot below position', () => {
488
864
  const layout = createLayout(
489
865
  {
@@ -719,4 +1095,49 @@ describe('simulateLayoutForSlot', () => {
719
1095
  const total = result.sizes.rowA.reduce((sum, size) => sum + size, 0);
720
1096
  expect(total).toBe(24);
721
1097
  });
1098
+
1099
+ it('preserves explicit empty v2 cells when moving another item', () => {
1100
+ const layout: GridLayoutData = {
1101
+ rows: {},
1102
+ sizes: {},
1103
+ layout: {
1104
+ version: 2,
1105
+ rows: [
1106
+ {
1107
+ id: 'rowA',
1108
+ cells: [
1109
+ { id: 'empty-cell', items: [] },
1110
+ { id: 'source-cell', items: ['source'] },
1111
+ { id: 'target-cell', items: ['target'] },
1112
+ ],
1113
+ sizes: [8, 8, 8],
1114
+ },
1115
+ ],
1116
+ },
1117
+ };
1118
+
1119
+ const slot: LayoutSlot = {
1120
+ type: 'column-edge',
1121
+ rowId: 'rowA',
1122
+ columnIndex: 2,
1123
+ direction: 'right',
1124
+ rect,
1125
+ path: [{ rowId: 'rowA', cellId: 'target-cell' }],
1126
+ };
1127
+
1128
+ const result = simulateLayoutForSlot({
1129
+ slot,
1130
+ sourceUid: 'source',
1131
+ layout,
1132
+ generateId: (key) => key,
1133
+ });
1134
+
1135
+ expect(result.layout?.rows[0].cells).toEqual([
1136
+ { id: 'empty-cell', items: [] },
1137
+ { id: 'target-cell', items: ['target'] },
1138
+ { id: 'column-edge:rowA:2:right:cell', items: ['source'] },
1139
+ ]);
1140
+ expect(result.layout?.rows[0].sizes).toHaveLength(3);
1141
+ expect(result.layout?.rows[0].sizes.reduce((sum, size) => sum + size, 0)).toBe(24);
1142
+ });
722
1143
  });
@@ -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
+ });