Skip to content

Commit a0b132d

Browse files
[AF-388] Fix copy-paste to handle groupId correctly based on whether … (#491)
* [AF-388] Fix copy-paste to handle groupId correctly based on whether group is also copied * update changelog
1 parent bb8e757 commit a0b132d

4 files changed

Lines changed: 160 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2727
- Fixed drag-snapping issues with different snapping configurations. The issue still occurred when dragging multiple nodes at the same hierarchy level (i.e., nodes without groups) ([#470](https://github.com/synergycodes/ng-diagram/pull/470))
2828
- Fixed incorrectly computed measuredBounds for nodes ([#486]https://github.com/synergycodes/ng-diagram/pull/486)
2929
- Fixed missing edge arrowheads in Safari. Safari doesn't support `context-stroke` in SVG markers, so a fallback using inline markers with `currentColor` substitution is now used for Safari compatibility ([#487](https://github.com/synergycodes/ng-diagram/pull/487))
30+
- Fixed copy-paste retaining `groupId` when pasting nodes outside their group. Now `groupId` is only preserved when the group is also copied, with the reference updated to the new group's ID ([#491](https://github.com/synergycodes/ng-diagram/pull/491))
3031

3132
### Deprecated
3233

apps/docs/src/content/docs/changelog.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3232
- Fixed drag-snapping issues with different snapping configurations. The issue still occurred when dragging multiple nodes at the same hierarchy level (i.e., nodes without groups) ([#470](https://github.com/synergycodes/ng-diagram/pull/470))
3333
- Fixed incorrectly computed measuredBounds for nodes ([#486]https://github.com/synergycodes/ng-diagram/pull/486)
3434
- Fixed missing edge arrowheads in Safari. Safari doesn't support `context-stroke` in SVG markers, so a fallback using inline markers with `currentColor` substitution is now used for Safari compatibility ([#487](https://github.com/synergycodes/ng-diagram/pull/487))
35+
- Fixed copy-paste retaining `groupId` when pasting nodes outside their group. Now `groupId` is only preserved when the group is also copied, with the reference updated to the new group's ID ([#491](https://github.com/synergycodes/ng-diagram/pull/491))
3536

3637
### Deprecated
3738

packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/__tests__/copy-paste.test.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,5 +454,156 @@ describe('Copy-Paste Commands', () => {
454454
expect(updateCall).not.toHaveBeenCalled();
455455
});
456456
});
457+
458+
describe('groupId handling', () => {
459+
it('should remove groupId when copying node without its group', async () => {
460+
commandHandler.flowCore.getState = () => ({
461+
nodes: [
462+
{ ...mockNode, id: 'group1', selected: false }, // Group not selected
463+
{ ...mockNode, id: 'node1', groupId: 'group1', selected: true }, // Child node selected
464+
],
465+
edges: [],
466+
metadata: mockMetadata,
467+
});
468+
469+
await copy(commandHandler);
470+
await paste(commandHandler, { name: 'paste' });
471+
472+
const updateCall = commandHandler.flowCore.applyUpdate as unknown as ReturnType<typeof vi.fn>;
473+
const [update] = updateCall.mock.calls[0];
474+
475+
expect(update.nodesToAdd).toHaveLength(1);
476+
const pastedNode = update.nodesToAdd[0];
477+
expect(pastedNode.groupId).toBeUndefined();
478+
});
479+
480+
it('should update groupId to new group ID when copying node with its group', async () => {
481+
commandHandler.flowCore.getState = () => ({
482+
nodes: [
483+
{ ...mockNode, id: 'group1', selected: true }, // Group selected
484+
{ ...mockNode, id: 'node1', groupId: 'group1', selected: true }, // Child node selected
485+
],
486+
edges: [],
487+
metadata: mockMetadata,
488+
});
489+
490+
await copy(commandHandler);
491+
await paste(commandHandler, { name: 'paste' });
492+
493+
const updateCall = commandHandler.flowCore.applyUpdate as unknown as ReturnType<typeof vi.fn>;
494+
const [update] = updateCall.mock.calls[0];
495+
496+
expect(update.nodesToAdd).toHaveLength(2);
497+
498+
// Find the pasted group and child node
499+
const pastedGroup = update.nodesToAdd.find((node: Node) => !node.groupId);
500+
const pastedChild = update.nodesToAdd.find((node: Node) => node.groupId);
501+
502+
expect(pastedGroup).toBeDefined();
503+
expect(pastedChild).toBeDefined();
504+
// Child's groupId should reference the new group's ID
505+
expect(pastedChild!.groupId).toBe(pastedGroup!.id);
506+
// New IDs should be generated
507+
expect(pastedGroup!.id).not.toBe('group1');
508+
expect(pastedChild!.id).not.toBe('node1');
509+
});
510+
511+
it('should update groupId for multiple children when copying with their group', async () => {
512+
commandHandler.flowCore.getState = () => ({
513+
nodes: [
514+
{ ...mockNode, id: 'group1', selected: true },
515+
{ ...mockNode, id: 'node1', groupId: 'group1', selected: true },
516+
{ ...mockNode, id: 'node2', groupId: 'group1', selected: true },
517+
],
518+
edges: [],
519+
metadata: mockMetadata,
520+
});
521+
522+
await copy(commandHandler);
523+
await paste(commandHandler, { name: 'paste' });
524+
525+
const updateCall = commandHandler.flowCore.applyUpdate as unknown as ReturnType<typeof vi.fn>;
526+
const [update] = updateCall.mock.calls[0];
527+
528+
expect(update.nodesToAdd).toHaveLength(3);
529+
530+
const pastedGroup = update.nodesToAdd.find((node: Node) => !node.groupId);
531+
const pastedChildren = update.nodesToAdd.filter((node: Node) => node.groupId);
532+
533+
expect(pastedGroup).toBeDefined();
534+
expect(pastedChildren).toHaveLength(2);
535+
// All children should reference the same new group ID
536+
pastedChildren.forEach((child: Node) => {
537+
expect(child.groupId).toBe(pastedGroup!.id);
538+
});
539+
});
540+
541+
it('should handle nested groups correctly', async () => {
542+
commandHandler.flowCore.getState = () => ({
543+
nodes: [
544+
{ ...mockNode, id: 'outerGroup', selected: true },
545+
{ ...mockNode, id: 'innerGroup', groupId: 'outerGroup', selected: true },
546+
{ ...mockNode, id: 'node1', groupId: 'innerGroup', selected: true },
547+
],
548+
edges: [],
549+
metadata: mockMetadata,
550+
});
551+
552+
await copy(commandHandler);
553+
await paste(commandHandler, { name: 'paste' });
554+
555+
const updateCall = commandHandler.flowCore.applyUpdate as unknown as ReturnType<typeof vi.fn>;
556+
const [update] = updateCall.mock.calls[0];
557+
558+
expect(update.nodesToAdd).toHaveLength(3);
559+
560+
// Find nodes by their groupId relationships
561+
const pastedOuterGroup = update.nodesToAdd.find((node: Node) => !node.groupId);
562+
const pastedInnerGroup = update.nodesToAdd.find(
563+
(node: Node) =>
564+
node.groupId === pastedOuterGroup?.id && update.nodesToAdd.some((n: Node) => n.groupId === node.id)
565+
);
566+
const pastedNode = update.nodesToAdd.find((node: Node) => node.groupId === pastedInnerGroup?.id);
567+
568+
expect(pastedOuterGroup).toBeDefined();
569+
expect(pastedInnerGroup).toBeDefined();
570+
expect(pastedNode).toBeDefined();
571+
572+
// Verify the hierarchy is preserved with new IDs
573+
expect(pastedInnerGroup!.groupId).toBe(pastedOuterGroup!.id);
574+
expect(pastedNode!.groupId).toBe(pastedInnerGroup!.id);
575+
});
576+
577+
it('should remove groupId when copying only inner group without outer group', async () => {
578+
commandHandler.flowCore.getState = () => ({
579+
nodes: [
580+
{ ...mockNode, id: 'outerGroup', selected: false }, // Outer group NOT selected
581+
{ ...mockNode, id: 'innerGroup', groupId: 'outerGroup', selected: true },
582+
{ ...mockNode, id: 'node1', groupId: 'innerGroup', selected: true },
583+
],
584+
edges: [],
585+
metadata: mockMetadata,
586+
});
587+
588+
await copy(commandHandler);
589+
await paste(commandHandler, { name: 'paste' });
590+
591+
const updateCall = commandHandler.flowCore.applyUpdate as unknown as ReturnType<typeof vi.fn>;
592+
const [update] = updateCall.mock.calls[0];
593+
594+
expect(update.nodesToAdd).toHaveLength(2);
595+
596+
// Find the pasted inner group (should have no groupId since outer wasn't copied)
597+
const pastedInnerGroup = update.nodesToAdd.find(
598+
(node: Node) => !node.groupId && update.nodesToAdd.some((n: Node) => n.groupId === node.id)
599+
);
600+
const pastedNode = update.nodesToAdd.find((node: Node) => node.groupId);
601+
602+
expect(pastedInnerGroup).toBeDefined();
603+
expect(pastedInnerGroup!.groupId).toBeUndefined(); // Inner group should no longer reference outer
604+
expect(pastedNode).toBeDefined();
605+
expect(pastedNode!.groupId).toBe(pastedInnerGroup!.id); // Node should still reference inner group
606+
});
607+
});
457608
});
458609
});

packages/ng-diagram/projects/ng-diagram/src/core/src/command-handler/commands/copy-paste.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,13 +90,19 @@ const createPastedNodes = (
9090
offset: Point,
9191
nodeIdMap: Map<string, string>
9292
): Node[] => {
93-
return copiedNodes.map((node) => {
93+
copiedNodes.forEach((node) => {
9494
const newNodeId = config.computeNodeId();
9595
nodeIdMap.set(node.id, newNodeId);
96+
});
97+
98+
return copiedNodes.map((node) => {
99+
const newNodeId = nodeIdMap.get(node.id)!;
100+
const newGroupId = node.groupId ? nodeIdMap.get(node.groupId) : undefined;
96101

97102
let newNode: Node = {
98103
...node,
99104
id: newNodeId,
105+
groupId: newGroupId,
100106
position: {
101107
x: node.position.x + offset.x,
102108
y: node.position.y + offset.y,

0 commit comments

Comments
 (0)