Skip to content

Commit be81dbb

Browse files
committed
fix(demo): avoid duplicate IDs within a paste batch
1 parent 7a309c4 commit be81dbb

7 files changed

Lines changed: 364 additions & 7 deletions

File tree

‎apps/demo-free-layout/package.json‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@
2828
"lint:fix": "eslint ./src --fix",
2929
"ts-check": "tsc --noEmit",
3030
"start": "cross-env NODE_ENV=development rsbuild dev --open",
31-
"test": "exit",
32-
"test:cov": "exit",
31+
"test": "vitest run",
32+
"test:cov": "vitest run --coverage",
3333
"watch": "exit 0"
3434
},
3535
"dependencies": {
@@ -68,7 +68,9 @@
6868
"@types/styled-components": "^5",
6969
"typescript": "^5.8.3",
7070
"eslint": "^9.0.0",
71-
"cross-env": "~7.0.3"
71+
"cross-env": "~7.0.3",
72+
"vitest": "^3.2.4",
73+
"@vitest/coverage-v8": "^3.2.4"
7274
},
7375
"publishConfig": {
7476
"access": "public",
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
3+
* SPDX-License-Identifier: MIT
4+
*/
5+
6+
import { beforeEach, describe, expect, it, vi } from 'vitest';
7+
import type { WorkflowJSON, WorkflowNodeJSON } from '@flowgram.ai/free-layout-editor';
8+
9+
import { generateUniqueWorkflow } from './unique-workflow';
10+
11+
const { generateId } = vi.hoisted(() => ({ generateId: vi.fn() }));
12+
13+
vi.mock('nanoid', () => ({ customAlphabet: () => generateId }));
14+
15+
const node = (id: string): WorkflowNodeJSON => ({
16+
id,
17+
type: 'test',
18+
meta: { position: { x: 0, y: 0 } },
19+
data: { title: id },
20+
});
21+
22+
beforeEach(() => {
23+
generateId.mockReset();
24+
generateId.mockImplementation(() => {
25+
throw new Error('Unexpected ID generation');
26+
});
27+
});
28+
29+
describe('generateUniqueWorkflow', () => {
30+
it('retries IDs already assigned to another copied node and preserves edge endpoints', () => {
31+
generateId
32+
.mockReturnValueOnce('111111')
33+
.mockReturnValueOnce('111111')
34+
.mockReturnValueOnce('222222');
35+
const json: WorkflowJSON = {
36+
nodes: [node('a'), node('b')],
37+
edges: [{ sourceNodeID: 'a', targetNodeID: 'b' }],
38+
};
39+
40+
generateUniqueWorkflow({ json, isUniqueId: (id) => !['a', 'b'].includes(id) });
41+
42+
expect(json.nodes.map((item) => item.id)).toEqual(['111111', '222222']);
43+
expect(json.nodes.map((item) => item.data?.title)).toEqual(['a', 'b']);
44+
expect(json.edges).toEqual([{ sourceNodeID: '111111', targetNodeID: '222222' }]);
45+
expect(generateId).toHaveBeenCalledTimes(3);
46+
});
47+
48+
it.each([false, true])(
49+
'reserves unchanged clipboard IDs before allocation (retained first: %s)',
50+
(retainedFirst) => {
51+
generateId.mockReturnValueOnce('111111').mockReturnValueOnce('222222');
52+
const nodes = retainedFirst ? [node('111111'), node('a')] : [node('a'), node('111111')];
53+
const json: WorkflowJSON = { nodes, edges: [] };
54+
55+
generateUniqueWorkflow({ json, isUniqueId: (id) => id !== 'a' });
56+
57+
expect(json.nodes.map((item) => item.id)).toEqual(
58+
retainedFirst ? ['111111', '222222'] : ['222222', '111111']
59+
);
60+
expect(generateId).toHaveBeenCalledTimes(2);
61+
}
62+
);
63+
64+
it('keeps IDs and the input object unchanged when they are already unique', () => {
65+
const json: WorkflowJSON = {
66+
nodes: [node('a'), node('b')],
67+
edges: [{ sourceNodeID: 'a', targetNodeID: 'b' }],
68+
};
69+
const original = structuredClone(json);
70+
71+
expect(generateUniqueWorkflow({ json, isUniqueId: () => true })).toBe(json);
72+
expect(json).toEqual(original);
73+
expect(generateId).not.toHaveBeenCalled();
74+
});
75+
76+
it('continues to reject IDs occupied by the destination canvas', () => {
77+
generateId.mockReturnValueOnce('occupied').mockReturnValueOnce('111111');
78+
const json: WorkflowJSON = { nodes: [node('a')], edges: [] };
79+
80+
generateUniqueWorkflow({ json, isUniqueId: (id) => !['a', 'occupied'].includes(id) });
81+
82+
expect(json.nodes[0].id).toBe('111111');
83+
expect(generateId).toHaveBeenCalledTimes(2);
84+
});
85+
86+
it('uses distinct IDs across nested blocks while preserving their edges and variable references', () => {
87+
generateId
88+
.mockReturnValueOnce('111111')
89+
.mockReturnValueOnce('111111')
90+
.mockReturnValueOnce('222222')
91+
.mockReturnValueOnce('222222')
92+
.mockReturnValueOnce('333333');
93+
const parent = node('parent');
94+
parent.blocks = [node('a'), node('b')];
95+
parent.edges = [{ sourceNodeID: 'a', targetNodeID: 'b' }];
96+
parent.data = {
97+
internal: { source: 'block-output', blockID: 'a', name: 'value' },
98+
external: { source: 'block-output', blockID: 'outside', name: 'value' },
99+
};
100+
const json: WorkflowJSON = { nodes: [parent], edges: [] };
101+
102+
generateUniqueWorkflow({ json, isUniqueId: (id) => !['parent', 'a', 'b'].includes(id) });
103+
104+
expect(parent.id).toBe('111111');
105+
expect(parent.blocks.map((item) => item.id)).toEqual(['222222', '333333']);
106+
expect(parent.edges).toEqual([{ sourceNodeID: '222222', targetNodeID: '333333' }]);
107+
expect(parent.data.internal.blockID).toBe('222222');
108+
expect(parent.data.external.blockID).toBe('outside');
109+
expect(generateId).toHaveBeenCalledTimes(5);
110+
});
111+
});

‎apps/demo-free-layout/src/shortcuts/paste/unique-workflow.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,17 @@ namespace UniqueWorkflowUtils {
3131
isUniqueId: (id: string) => boolean
3232
): Map<string, string> => {
3333
const nodeReplaceMap = new Map<string, string>(); // create map for id replacement - 创建ID替换映射
34+
// Reserve IDs that later clipboard nodes may keep, as well as newly generated IDs.
35+
const reservedIds = new Set(nodeIds);
3436
nodeIds.forEach((id) => {
3537
if (isUniqueId(id)) {
3638
nodeReplaceMap.set(id, id); // keep original id if unique - 如果ID唯一则保持不变
3739
} else {
3840
let newId: string;
3941
do {
4042
newId = generateUniqueId(); // generate new id until unique - 生成新ID直到唯一
41-
} while (!isUniqueId(newId));
43+
} while (!isUniqueId(newId) || reservedIds.has(newId));
44+
reservedIds.add(newId);
4245
nodeReplaceMap.set(id, newId);
4346
}
4447
});

‎apps/demo-nextjs-antd/package.json‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323
"start": "next start",
2424
"lint": "eslint ./src --cache",
2525
"lint:fix": "eslint ./src --fix",
26-
"ts-check": "tsc --noEmit"
26+
"ts-check": "tsc --noEmit",
27+
"test": "vitest run",
28+
"test:cov": "vitest run --coverage"
2729
},
2830
"dependencies": {
2931
"antd": "^5.25.4",
@@ -63,7 +65,9 @@
6365
"eslint-plugin-next": "0.0.0",
6466
"eslint-config-next": "^15.3.1",
6567
"@eslint/eslintrc": "3.3.3",
66-
"sass": "^1.89.1"
68+
"sass": "^1.89.1",
69+
"vitest": "^3.2.4",
70+
"@vitest/coverage-v8": "^3.2.4"
6771
},
6872
"publishConfig": {
6973
"access": "public",
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
3+
* SPDX-License-Identifier: MIT
4+
*/
5+
6+
import { beforeEach, describe, expect, it, vi } from 'vitest';
7+
import type { WorkflowJSON, WorkflowNodeJSON } from '@flowgram.ai/free-layout-editor';
8+
9+
import { generateUniqueWorkflow } from './unique-workflow';
10+
11+
const { generateId } = vi.hoisted(() => ({ generateId: vi.fn() }));
12+
13+
vi.mock('nanoid', () => ({ customAlphabet: () => generateId }));
14+
15+
const node = (id: string): WorkflowNodeJSON => ({
16+
id,
17+
type: 'test',
18+
meta: { position: { x: 0, y: 0 } },
19+
data: { title: id },
20+
});
21+
22+
beforeEach(() => {
23+
generateId.mockReset();
24+
generateId.mockImplementation(() => {
25+
throw new Error('Unexpected ID generation');
26+
});
27+
});
28+
29+
describe('generateUniqueWorkflow', () => {
30+
it('retries IDs already assigned to another copied node and preserves edge endpoints', () => {
31+
generateId
32+
.mockReturnValueOnce('111111')
33+
.mockReturnValueOnce('111111')
34+
.mockReturnValueOnce('222222');
35+
const json: WorkflowJSON = {
36+
nodes: [node('a'), node('b')],
37+
edges: [{ sourceNodeID: 'a', targetNodeID: 'b' }],
38+
};
39+
40+
generateUniqueWorkflow({ json, isUniqueId: (id) => !['a', 'b'].includes(id) });
41+
42+
expect(json.nodes.map((item) => item.id)).toEqual(['111111', '222222']);
43+
expect(json.nodes.map((item) => item.data?.title)).toEqual(['a', 'b']);
44+
expect(json.edges).toEqual([{ sourceNodeID: '111111', targetNodeID: '222222' }]);
45+
expect(generateId).toHaveBeenCalledTimes(3);
46+
});
47+
48+
it.each([false, true])(
49+
'reserves unchanged clipboard IDs before allocation (retained first: %s)',
50+
(retainedFirst) => {
51+
generateId.mockReturnValueOnce('111111').mockReturnValueOnce('222222');
52+
const nodes = retainedFirst ? [node('111111'), node('a')] : [node('a'), node('111111')];
53+
const json: WorkflowJSON = { nodes, edges: [] };
54+
55+
generateUniqueWorkflow({ json, isUniqueId: (id) => id !== 'a' });
56+
57+
expect(json.nodes.map((item) => item.id)).toEqual(
58+
retainedFirst ? ['111111', '222222'] : ['222222', '111111']
59+
);
60+
expect(generateId).toHaveBeenCalledTimes(2);
61+
}
62+
);
63+
64+
it('keeps IDs and the input object unchanged when they are already unique', () => {
65+
const json: WorkflowJSON = {
66+
nodes: [node('a'), node('b')],
67+
edges: [{ sourceNodeID: 'a', targetNodeID: 'b' }],
68+
};
69+
const original = structuredClone(json);
70+
71+
expect(generateUniqueWorkflow({ json, isUniqueId: () => true })).toBe(json);
72+
expect(json).toEqual(original);
73+
expect(generateId).not.toHaveBeenCalled();
74+
});
75+
76+
it('continues to reject IDs occupied by the destination canvas', () => {
77+
generateId.mockReturnValueOnce('occupied').mockReturnValueOnce('111111');
78+
const json: WorkflowJSON = { nodes: [node('a')], edges: [] };
79+
80+
generateUniqueWorkflow({ json, isUniqueId: (id) => !['a', 'occupied'].includes(id) });
81+
82+
expect(json.nodes[0].id).toBe('111111');
83+
expect(generateId).toHaveBeenCalledTimes(2);
84+
});
85+
86+
it('uses distinct IDs across nested blocks while preserving their edges and variable references', () => {
87+
generateId
88+
.mockReturnValueOnce('111111')
89+
.mockReturnValueOnce('111111')
90+
.mockReturnValueOnce('222222')
91+
.mockReturnValueOnce('222222')
92+
.mockReturnValueOnce('333333');
93+
const parent = node('parent');
94+
parent.blocks = [node('a'), node('b')];
95+
parent.edges = [{ sourceNodeID: 'a', targetNodeID: 'b' }];
96+
parent.data = {
97+
internal: { source: 'block-output', blockID: 'a', name: 'value' },
98+
external: { source: 'block-output', blockID: 'outside', name: 'value' },
99+
};
100+
const json: WorkflowJSON = { nodes: [parent], edges: [] };
101+
102+
generateUniqueWorkflow({ json, isUniqueId: (id) => !['parent', 'a', 'b'].includes(id) });
103+
104+
expect(parent.id).toBe('111111');
105+
expect(parent.blocks.map((item) => item.id)).toEqual(['222222', '333333']);
106+
expect(parent.edges).toEqual([{ sourceNodeID: '222222', targetNodeID: '333333' }]);
107+
expect(parent.data.internal.blockID).toBe('222222');
108+
expect(parent.data.external.blockID).toBe('outside');
109+
expect(generateId).toHaveBeenCalledTimes(5);
110+
});
111+
});

‎apps/demo-nextjs-antd/src/editor/shortcuts/paste/unique-workflow.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,17 @@ namespace UniqueWorkflowUtils {
3131
isUniqueId: (id: string) => boolean
3232
): Map<string, string> => {
3333
const nodeReplaceMap = new Map<string, string>(); // create map for id replacement - 创建ID替换映射
34+
// Reserve IDs that later clipboard nodes may keep, as well as newly generated IDs.
35+
const reservedIds = new Set(nodeIds);
3436
nodeIds.forEach((id) => {
3537
if (isUniqueId(id)) {
3638
nodeReplaceMap.set(id, id); // keep original id if unique - 如果ID唯一则保持不变
3739
} else {
3840
let newId: string;
3941
do {
4042
newId = generateUniqueId(); // generate new id until unique - 生成新ID直到唯一
41-
} while (!isUniqueId(newId));
43+
} while (!isUniqueId(newId) || reservedIds.has(newId));
44+
reservedIds.add(newId);
4245
nodeReplaceMap.set(id, newId);
4346
}
4447
});

0 commit comments

Comments
 (0)