@nocobase/flow-engine 2.0.45 → 2.0.47

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.
@@ -104,7 +104,9 @@ const Droppable = /* @__PURE__ */ __name(({ model, children }) => {
104
104
  const DndProvider = /* @__PURE__ */ __name(({
105
105
  persist = true,
106
106
  children,
107
+ onDragStart,
107
108
  onDragEnd,
109
+ onDragCancel,
108
110
  ...restProps
109
111
  }) => {
110
112
  const [activeId, setActiveId] = (0, import_react.useState)(null);
@@ -112,10 +114,10 @@ const DndProvider = /* @__PURE__ */ __name(({
112
114
  return /* @__PURE__ */ import_react.default.createElement(
113
115
  import_core.DndContext,
114
116
  {
117
+ ...restProps,
115
118
  onDragStart: (event) => {
116
- var _a;
117
119
  setActiveId(event.active.id);
118
- (_a = restProps.onDragStart) == null ? void 0 : _a.call(restProps, event);
120
+ onDragStart == null ? void 0 : onDragStart(event);
119
121
  },
120
122
  onDragEnd: (event) => {
121
123
  setActiveId(null);
@@ -127,13 +129,17 @@ const DndProvider = /* @__PURE__ */ __name(({
127
129
  onDragEnd(event);
128
130
  }
129
131
  },
130
- ...restProps
132
+ onDragCancel: (event) => {
133
+ setActiveId(null);
134
+ onDragCancel == null ? void 0 : onDragCancel(event);
135
+ }
131
136
  },
132
137
  children,
133
138
  (0, import_react_dom.createPortal)(
134
139
  /* @__PURE__ */ import_react.default.createElement(import_core.DragOverlay, { dropAnimation: null, zIndex: 2e3 }, activeId && /* @__PURE__ */ import_react.default.createElement(
135
140
  "span",
136
141
  {
142
+ "data-testid": "flow-drag-preview",
137
143
  style: {
138
144
  display: "inline-flex",
139
145
  alignItems: "center",
@@ -143,7 +149,6 @@ const DndProvider = /* @__PURE__ */ __name(({
143
149
  borderRadius: 4,
144
150
  padding: "4px 12px",
145
151
  color: "#1890ff",
146
- // fontSize: 18,
147
152
  boxShadow: "0 2px 8px rgba(0,0,0,0.15)"
148
153
  }
149
154
  },
@@ -32,6 +32,10 @@ __export(createCollectionContextMeta_exports, {
32
32
  module.exports = __toCommonJS(createCollectionContextMeta_exports);
33
33
  const RELATION_FIELD_TYPES = ["belongsTo", "hasOne", "hasMany", "belongsToMany", "belongsToArray"];
34
34
  const NUMERIC_FIELD_TYPES = ["integer", "float", "double", "decimal"];
35
+ function shouldShowFieldInMeta(field, includeNonFilterable) {
36
+ return Boolean(field.interface && (includeNonFilterable || field.filterable));
37
+ }
38
+ __name(shouldShowFieldInMeta, "shouldShowFieldInMeta");
35
39
  function createFieldMetadata(field, includeNonFilterable) {
36
40
  const baseProperties = createMetaBaseProperties(field);
37
41
  if (field.isAssociationField()) {
@@ -49,7 +53,7 @@ function createFieldMetadata(field, includeNonFilterable) {
49
53
  properties: /* @__PURE__ */ __name(async () => {
50
54
  const subProperties = {};
51
55
  targetCollection.fields.forEach((subField) => {
52
- if (includeNonFilterable || subField.filterable) {
56
+ if (shouldShowFieldInMeta(subField, includeNonFilterable)) {
53
57
  subProperties[subField.name] = createFieldMetadata(subField, includeNonFilterable);
54
58
  }
55
59
  });
@@ -104,7 +108,7 @@ function createCollectionContextMeta(collectionOrFactory, title, includeNonFilte
104
108
  properties: /* @__PURE__ */ __name(async () => {
105
109
  const properties = {};
106
110
  collection.fields.forEach((field) => {
107
- if (includeNonFilterable || field.filterable) {
111
+ if (shouldShowFieldInMeta(field, includeNonFilterable)) {
108
112
  properties[field.name] = createFieldMetadata(field, includeNonFilterable);
109
113
  }
110
114
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nocobase/flow-engine",
3
- "version": "2.0.45",
3
+ "version": "2.0.47",
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.0.45",
12
- "@nocobase/shared": "2.0.45",
11
+ "@nocobase/sdk": "2.0.47",
12
+ "@nocobase/shared": "2.0.47",
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": "9b720e650924098938f8c3c50978273748082cb7"
40
+ "gitHead": "c2ac5cf739e1160ca5af4ad933498b70e771abd3"
41
41
  }
@@ -26,6 +26,7 @@ function setupEngineWithCollections() {
26
26
  fields: [
27
27
  { name: 'id', type: 'integer', interface: 'number' },
28
28
  { name: 'name', type: 'string', interface: 'text' },
29
+ { name: 'rawUserPayload', type: 'json', filterable: true },
29
30
  ],
30
31
  });
31
32
  ds.addCollection({
@@ -41,6 +42,8 @@ function setupEngineWithCollections() {
41
42
  filterTargetKey: 'id',
42
43
  fields: [
43
44
  { name: 'title', type: 'string', interface: 'text' },
45
+ { name: 'internalName', type: 'string', interface: 'text' },
46
+ { name: 'rawPostPayload', type: 'json', filterable: true },
44
47
  { name: 'author', type: 'belongsTo', target: 'users', interface: 'm2o' },
45
48
  { name: 'tags', type: 'belongsToMany', target: 'tags', interface: 'm2m' },
46
49
  ],
@@ -91,6 +94,27 @@ describe('objectVariable utilities', () => {
91
94
  });
92
95
  });
93
96
 
97
+ it('createAssociationAwareObjectMetaFactory should hide fields without interface from object variable meta', async () => {
98
+ const { collection } = setupEngineWithCollections();
99
+ const obj = { title: 'hello', internalName: 'internal', rawPostPayload: { secret: true }, author: 1 };
100
+ const metaFactory = createAssociationAwareObjectMetaFactory(
101
+ () => collection,
102
+ 'Current object',
103
+ () => obj,
104
+ );
105
+
106
+ const meta = await metaFactory();
107
+ const props = await (meta?.properties as any)?.();
108
+ const authorFields = await props?.author?.properties?.();
109
+
110
+ expect(props).toHaveProperty('title');
111
+ expect(props).toHaveProperty('internalName');
112
+ expect(props).toHaveProperty('author');
113
+ expect(props).not.toHaveProperty('rawPostPayload');
114
+ expect(authorFields).toHaveProperty('name');
115
+ expect(authorFields).not.toHaveProperty('rawUserPayload');
116
+ });
117
+
94
118
  it('integrates with FlowContext.resolveJsonTemplate to call variables:resolve with flattened contextParams', async () => {
95
119
  const { engine, collection } = setupEngineWithCollections();
96
120
  const obj = { author: 1 };
@@ -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
  });