Skip to content

Commit 3679594

Browse files
Copilothotlong
andcommitted
Fix code review issues: deep clone, expansion logic, unused imports, edge cases
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent c54506c commit 3679594

File tree

5 files changed

+112
-14
lines changed

5 files changed

+112
-14
lines changed

packages/designer/src/__tests__/keyboard-shortcuts.test.tsx

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, beforeEach } from 'vitest';
2-
import { render, screen, fireEvent } from '@testing-library/react';
2+
import { render, fireEvent } from '@testing-library/react';
33
import { DesignerProvider, useDesigner } from '../context/DesignerContext';
44
import { SchemaNode } from '@object-ui/core';
55
import React from 'react';
@@ -31,6 +31,9 @@ const TestComponent = () => {
3131
<button onClick={() => pasteNode('root')} data-testid="paste">Paste</button>
3232
<button onClick={() => moveNodeUp('child-2')} data-testid="move-up">Move Up</button>
3333
<button onClick={() => moveNodeDown('child-1')} data-testid="move-down">Move Down</button>
34+
<button onClick={() => moveNodeUp('child-1')} data-testid="move-up-first">Move Up First</button>
35+
<button onClick={() => moveNodeDown('child-3')} data-testid="move-down-last">Move Down Last</button>
36+
<button onClick={() => cutNode('nonexistent-id')} data-testid="cut-nonexistent">Cut Nonexistent</button>
3437
</div>
3538
);
3639
};
@@ -98,6 +101,64 @@ describe('Keyboard Shortcuts and Navigation', () => {
98101
// Schema should have an extra node
99102
const schema = JSON.parse(getByTestId('schema').textContent || '{}');
100103
expect(schema.body.length).toBe(initialLength + 1);
104+
105+
// The duplicated node should have the same type as the original
106+
expect(schema.body[1].type).toBe('text');
107+
expect(schema.body[1].content).toBe('First');
108+
109+
// The duplicated node should be positioned right after the original (at index 1)
110+
expect(schema.body[1].id).not.toBe('child-1'); // Should have a new ID
111+
});
112+
113+
it('should handle moving a node up when already at top', () => {
114+
const { getByTestId } = render(
115+
<DesignerProvider initialSchema={initialSchema}>
116+
<TestComponent />
117+
</DesignerProvider>
118+
);
119+
120+
const schemaBeforeMove = JSON.parse(getByTestId('schema').textContent || '{}');
121+
122+
// Try to move the first node up (should do nothing)
123+
fireEvent.click(getByTestId('move-up-first'));
124+
125+
const schemaAfterMove = JSON.parse(getByTestId('schema').textContent || '{}');
126+
// Schema should remain unchanged
127+
expect(schemaAfterMove.body[0].id).toBe(schemaBeforeMove.body[0].id);
128+
});
129+
130+
it('should handle moving a node down when already at bottom', () => {
131+
const { getByTestId } = render(
132+
<DesignerProvider initialSchema={initialSchema}>
133+
<TestComponent />
134+
</DesignerProvider>
135+
);
136+
137+
const schemaBeforeMove = JSON.parse(getByTestId('schema').textContent || '{}');
138+
139+
// Try to move the last node down (should do nothing)
140+
fireEvent.click(getByTestId('move-down-last'));
141+
142+
const schemaAfterMove = JSON.parse(getByTestId('schema').textContent || '{}');
143+
// Schema should remain unchanged
144+
expect(schemaAfterMove.body[2].id).toBe(schemaBeforeMove.body[2].id);
145+
});
146+
147+
it('should handle cutting a non-existent node gracefully', () => {
148+
const { getByTestId } = render(
149+
<DesignerProvider initialSchema={initialSchema}>
150+
<TestComponent />
151+
</DesignerProvider>
152+
);
153+
154+
const initialLength = JSON.parse(getByTestId('schema').textContent || '{}').body.length;
155+
156+
// Try to cut a node that doesn't exist
157+
fireEvent.click(getByTestId('cut-nonexistent'));
158+
159+
const schema = JSON.parse(getByTestId('schema').textContent || '{}');
160+
// Schema should remain unchanged
161+
expect(schema.body.length).toBe(initialLength);
101162
});
102163

103164
it('should paste a copied node', () => {

packages/designer/src/components/ComponentTree.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -222,10 +222,16 @@ export const ComponentTree: React.FC<ComponentTreeProps> = React.memo(({ classNa
222222
return () => window.removeEventListener('keydown', handleKeyDown);
223223
}, [selectedNodeId, moveNodeUp, moveNodeDown]);
224224

225-
const expansionContextValue = useMemo(() => ({
226-
expandAll: expandTrigger > 0,
227-
collapseAll: collapseTrigger > 0
228-
}), [expandTrigger, collapseTrigger]);
225+
const expansionContextValue = useMemo(() => {
226+
if (expandTrigger > collapseTrigger) {
227+
return { expandAll: true, collapseAll: false };
228+
}
229+
if (collapseTrigger > expandTrigger) {
230+
return { expandAll: false, collapseAll: true };
231+
}
232+
// Initial or neutral state: no global expand/collapse action
233+
return { expandAll: false, collapseAll: false };
234+
}, [expandTrigger, collapseTrigger]);
229235

230236
return (
231237
<TreeExpansionContext.Provider value={expansionContextValue}>

packages/designer/src/components/ContextMenu.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({ position, targetNodeId
3838
removeNode,
3939
moveNodeUp,
4040
moveNodeDown,
41-
canPaste,
42-
selectedNodeId
41+
canPaste
4342
} = useDesigner();
4443

4544
const handleCopy = useCallback(() => {

packages/designer/src/components/KeyboardShortcutsHelp.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
} from "@object-ui/components";
99
import { Button } from '@object-ui/components';
1010
import { Keyboard, Command } from 'lucide-react';
11-
import { cn } from '@object-ui/components';
1211

1312
interface ShortcutItem {
1413
keys: string[];

packages/designer/src/context/DesignerContext.tsx

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,39 @@ const findNodeById = (node: SchemaNode, id: string): SchemaNode | null => {
163163
return null;
164164
};
165165

166-
// Find Parent and Index of a node
166+
/**
167+
* Deep clone a SchemaNode, creating new objects for all nested properties.
168+
* This ensures that modifications to the cloned node don't affect the original.
169+
*
170+
* @param node - The node to clone
171+
* @returns A deep copy of the node with all nested children cloned
172+
*/
173+
const deepCloneNode = (node: SchemaNode): SchemaNode => {
174+
const cloned: SchemaNode = { ...node };
175+
176+
// Deep clone the body if it exists
177+
if (node.body) {
178+
if (Array.isArray(node.body)) {
179+
cloned.body = node.body.map(child => deepCloneNode(child));
180+
} else if (typeof node.body === 'object') {
181+
cloned.body = deepCloneNode(node.body as SchemaNode);
182+
}
183+
}
184+
185+
return cloned;
186+
};
187+
188+
/**
189+
* Find the parent node and index of a target node in the schema tree.
190+
* This is used for operations that need to know a node's position within its parent,
191+
* such as moving nodes up/down or duplicating nodes as siblings.
192+
*
193+
* @param root - The root node to search from
194+
* @param targetId - The ID of the node to find
195+
* @param parent - Internal parameter for recursion, tracks the parent during traversal
196+
* @returns An object with the parent node and the index of the target within the parent's body,
197+
* or null if the target is not found or is the root node
198+
*/
167199
const findParentAndIndex = (
168200
root: SchemaNode,
169201
targetId: string,
@@ -390,8 +422,9 @@ export const DesignerProvider: React.FC<DesignerProviderProps> = ({
390422
const cutNode = useCallback((id: string) => {
391423
const node = findNodeById(schema, id);
392424
if (node) {
393-
// Copy the node to clipboard
394-
const { id: originalId, ...nodeWithoutId } = node;
425+
// Deep clone the node to clipboard to avoid reference issues
426+
const clonedNode = deepCloneNode(node);
427+
const { id: originalId, ...nodeWithoutId } = clonedNode;
395428
setClipboard(nodeWithoutId as SchemaNode);
396429
// Then remove it from the tree
397430
removeNode(id);
@@ -401,9 +434,9 @@ export const DesignerProvider: React.FC<DesignerProviderProps> = ({
401434
const duplicateNode = useCallback((id: string) => {
402435
const node = findNodeById(schema, id);
403436
if (node) {
404-
// Create a deep copy without the ID
405-
const { id: originalId, ...nodeWithoutId } = node;
406-
setClipboard(nodeWithoutId as SchemaNode);
437+
// Deep clone the node without modifying clipboard
438+
const clonedNode = deepCloneNode(node);
439+
const { id: originalId, ...nodeWithoutId } = clonedNode;
407440

408441
// Find the parent to paste into
409442
const parentInfo = findParentAndIndex(schema, id);

0 commit comments

Comments
 (0)