-
Notifications
You must be signed in to change notification settings - Fork 2
Add cut/duplicate, tree navigation, and keyboard shortcuts to designer #45
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
02ecf78
2ac7812
1addb98
2cea64b
c54506c
3679594
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| import { describe, it, expect, beforeEach } from 'vitest'; | ||
| import { render, screen, fireEvent } from '@testing-library/react'; | ||
| import { DesignerProvider, useDesigner } from '../context/DesignerContext'; | ||
| import { SchemaNode } from '@object-ui/core'; | ||
| import React from 'react'; | ||
|
|
||
| // Test component to access designer context | ||
| const TestComponent = () => { | ||
| const { | ||
| schema, | ||
| selectedNodeId, | ||
| setSelectedNodeId, | ||
| copyNode, | ||
| cutNode, | ||
| duplicateNode, | ||
| pasteNode, | ||
| moveNodeUp, | ||
| moveNodeDown, | ||
| canPaste | ||
| } = useDesigner(); | ||
|
|
||
| return ( | ||
| <div> | ||
| <div data-testid="schema">{JSON.stringify(schema)}</div> | ||
| <div data-testid="selected">{selectedNodeId || 'none'}</div> | ||
| <div data-testid="can-paste">{canPaste ? 'yes' : 'no'}</div> | ||
| <button onClick={() => setSelectedNodeId('child-1')} data-testid="select-child-1">Select Child 1</button> | ||
| <button onClick={() => copyNode('child-1')} data-testid="copy">Copy</button> | ||
| <button onClick={() => cutNode('child-1')} data-testid="cut">Cut</button> | ||
| <button onClick={() => duplicateNode('child-1')} data-testid="duplicate">Duplicate</button> | ||
| <button onClick={() => pasteNode('root')} data-testid="paste">Paste</button> | ||
| <button onClick={() => moveNodeUp('child-2')} data-testid="move-up">Move Up</button> | ||
| <button onClick={() => moveNodeDown('child-1')} data-testid="move-down">Move Down</button> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| describe('Keyboard Shortcuts and Navigation', () => { | ||
| const initialSchema: SchemaNode = { | ||
| type: 'div', | ||
| id: 'root', | ||
| body: [ | ||
| { type: 'text', id: 'child-1', content: 'First' }, | ||
| { type: 'text', id: 'child-2', content: 'Second' }, | ||
| { type: 'text', id: 'child-3', content: 'Third' } | ||
| ] | ||
| }; | ||
|
|
||
| beforeEach(() => { | ||
| // Reset any global state if needed | ||
| }); | ||
|
|
||
| it('should copy a node', () => { | ||
| const { getByTestId } = render( | ||
| <DesignerProvider initialSchema={initialSchema}> | ||
| <TestComponent /> | ||
| </DesignerProvider> | ||
| ); | ||
|
|
||
| expect(getByTestId('can-paste').textContent).toBe('no'); | ||
|
|
||
| fireEvent.click(getByTestId('copy')); | ||
|
|
||
| expect(getByTestId('can-paste').textContent).toBe('yes'); | ||
| }); | ||
|
|
||
| it('should cut a node and allow paste', () => { | ||
|
||
| const { getByTestId } = render( | ||
| <DesignerProvider initialSchema={initialSchema}> | ||
| <TestComponent /> | ||
| </DesignerProvider> | ||
| ); | ||
|
|
||
| expect(getByTestId('can-paste').textContent).toBe('no'); | ||
|
|
||
| fireEvent.click(getByTestId('cut')); | ||
|
|
||
| // Should be able to paste after cut | ||
| expect(getByTestId('can-paste').textContent).toBe('yes'); | ||
|
|
||
| // The schema should have the node removed | ||
| const schema = JSON.parse(getByTestId('schema').textContent || '{}'); | ||
| expect(schema.body).toHaveLength(2); // One node was cut | ||
| }); | ||
|
|
||
| it('should duplicate a node', () => { | ||
| const { getByTestId } = render( | ||
| <DesignerProvider initialSchema={initialSchema}> | ||
| <TestComponent /> | ||
| </DesignerProvider> | ||
| ); | ||
|
|
||
| const initialBody = JSON.parse(getByTestId('schema').textContent || '{}').body; | ||
| const initialLength = initialBody.length; | ||
|
|
||
| fireEvent.click(getByTestId('duplicate')); | ||
|
|
||
| // Schema should have an extra node | ||
| const schema = JSON.parse(getByTestId('schema').textContent || '{}'); | ||
| expect(schema.body.length).toBe(initialLength + 1); | ||
| }); | ||
|
Comment on lines
+89
to
+162
|
||
|
|
||
| it('should paste a copied node', () => { | ||
| const { getByTestId } = render( | ||
| <DesignerProvider initialSchema={initialSchema}> | ||
| <TestComponent /> | ||
| </DesignerProvider> | ||
| ); | ||
|
|
||
| fireEvent.click(getByTestId('copy')); | ||
| fireEvent.click(getByTestId('paste')); | ||
|
|
||
| // Schema should have an extra node | ||
| const schema = JSON.parse(getByTestId('schema').textContent || '{}'); | ||
| expect(schema.body.length).toBeGreaterThan(3); | ||
| }); | ||
|
|
||
| it('should move a node up', () => { | ||
| const { getByTestId } = render( | ||
| <DesignerProvider initialSchema={initialSchema}> | ||
| <TestComponent /> | ||
| </DesignerProvider> | ||
| ); | ||
|
|
||
| fireEvent.click(getByTestId('move-up')); | ||
|
|
||
| const schema = JSON.parse(getByTestId('schema').textContent || '{}'); | ||
| // child-2 should now be at index 0 after moving up | ||
| expect(schema.body[0].id).toBe('child-2'); | ||
| }); | ||
|
|
||
| it('should move a node down', () => { | ||
| const { getByTestId } = render( | ||
| <DesignerProvider initialSchema={initialSchema}> | ||
| <TestComponent /> | ||
| </DesignerProvider> | ||
| ); | ||
|
|
||
| fireEvent.click(getByTestId('move-down')); | ||
|
|
||
| const schema = JSON.parse(getByTestId('schema').textContent || '{}'); | ||
| // child-1 should now be at index 1 after moving down | ||
| expect(schema.body[1].id).toBe('child-1'); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,4 +1,4 @@ | ||||||||||||||||||||||||||||||
| import React, { useCallback, useMemo, useState } from 'react'; | ||||||||||||||||||||||||||||||
| import React, { useCallback, useMemo, useState, createContext, useContext } from 'react'; | ||||||||||||||||||||||||||||||
| import { useDesigner } from '../context/DesignerContext'; | ||||||||||||||||||||||||||||||
| import { ScrollArea } from '@object-ui/components'; | ||||||||||||||||||||||||||||||
| import { Button } from '@object-ui/components'; | ||||||||||||||||||||||||||||||
|
|
@@ -29,10 +29,31 @@ interface TreeNodeProps { | |||||||||||||||||||||||||||||
| onSelect: (id: string) => void; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Context for controlling expansion state globally | ||||||||||||||||||||||||||||||
| interface TreeExpansionContextValue { | ||||||||||||||||||||||||||||||
| expandAll: boolean; | ||||||||||||||||||||||||||||||
| collapseAll: boolean; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const TreeExpansionContext = createContext<TreeExpansionContextValue>({ | ||||||||||||||||||||||||||||||
| expandAll: false, | ||||||||||||||||||||||||||||||
| collapseAll: false | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const TreeNode: React.FC<TreeNodeProps> = React.memo(({ node, level, isSelected, selectedNodeId, onSelect }) => { | ||||||||||||||||||||||||||||||
| const expansionContext = useContext(TreeExpansionContext); | ||||||||||||||||||||||||||||||
| const [isExpanded, setIsExpanded] = useState(true); | ||||||||||||||||||||||||||||||
| const [isVisible, setIsVisible] = useState(true); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Effect to handle expand/collapse all from parent | ||||||||||||||||||||||||||||||
| React.useEffect(() => { | ||||||||||||||||||||||||||||||
| if (expansionContext.expandAll) { | ||||||||||||||||||||||||||||||
| setIsExpanded(true); | ||||||||||||||||||||||||||||||
| } else if (expansionContext.collapseAll) { | ||||||||||||||||||||||||||||||
| setIsExpanded(false); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| }, [expansionContext.expandAll, expansionContext.collapseAll]); | ||||||||||||||||||||||||||||||
|
Comment on lines
+49
to
+55
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const hasChildren = useMemo(() => { | ||||||||||||||||||||||||||||||
| if (!node.body) return false; | ||||||||||||||||||||||||||||||
| if (Array.isArray(node.body)) return node.body.length > 0; | ||||||||||||||||||||||||||||||
|
|
@@ -156,50 +177,98 @@ const TreeNode: React.FC<TreeNodeProps> = React.memo(({ node, level, isSelected, | |||||||||||||||||||||||||||||
| TreeNode.displayName = 'TreeNode'; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| export const ComponentTree: React.FC<ComponentTreeProps> = React.memo(({ className }) => { | ||||||||||||||||||||||||||||||
| const { schema, selectedNodeId, setSelectedNodeId } = useDesigner(); | ||||||||||||||||||||||||||||||
| const { schema, selectedNodeId, setSelectedNodeId, moveNodeUp, moveNodeDown } = useDesigner(); | ||||||||||||||||||||||||||||||
| const [expandTrigger, setExpandTrigger] = useState(0); | ||||||||||||||||||||||||||||||
| const [collapseTrigger, setCollapseTrigger] = useState(0); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const handleSelect = useCallback((id: string) => { | ||||||||||||||||||||||||||||||
| setSelectedNodeId(id); | ||||||||||||||||||||||||||||||
| }, [setSelectedNodeId]); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||
| <div className={cn("flex flex-col h-full bg-white", className)}> | ||||||||||||||||||||||||||||||
| <ScrollArea className="flex-1"> | ||||||||||||||||||||||||||||||
| <div className="p-2"> | ||||||||||||||||||||||||||||||
| {schema && ( | ||||||||||||||||||||||||||||||
| <TreeNode | ||||||||||||||||||||||||||||||
| node={schema} | ||||||||||||||||||||||||||||||
| level={0} | ||||||||||||||||||||||||||||||
| isSelected={selectedNodeId === schema.id} | ||||||||||||||||||||||||||||||
| selectedNodeId={selectedNodeId} | ||||||||||||||||||||||||||||||
| onSelect={handleSelect} | ||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||
| </ScrollArea> | ||||||||||||||||||||||||||||||
| const handleExpandAll = useCallback(() => { | ||||||||||||||||||||||||||||||
| setExpandTrigger(prev => prev + 1); | ||||||||||||||||||||||||||||||
| }, []); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const handleCollapseAll = useCallback(() => { | ||||||||||||||||||||||||||||||
| setCollapseTrigger(prev => prev + 1); | ||||||||||||||||||||||||||||||
| }, []); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Keyboard navigation for tree | ||||||||||||||||||||||||||||||
| React.useEffect(() => { | ||||||||||||||||||||||||||||||
| const handleKeyDown = (e: KeyboardEvent) => { | ||||||||||||||||||||||||||||||
| // Only handle keyboard events when tree is focused and not in an input | ||||||||||||||||||||||||||||||
| const target = e.target as HTMLElement; | ||||||||||||||||||||||||||||||
| const isEditing = | ||||||||||||||||||||||||||||||
| target.tagName === 'INPUT' || | ||||||||||||||||||||||||||||||
| target.tagName === 'TEXTAREA' || | ||||||||||||||||||||||||||||||
| target.tagName === 'SELECT' || | ||||||||||||||||||||||||||||||
| target.isContentEditable; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| {/* Tree Actions */} | ||||||||||||||||||||||||||||||
| <div className="px-4 py-2 border-t shrink-0 bg-gray-50"> | ||||||||||||||||||||||||||||||
| <div className="flex gap-1"> | ||||||||||||||||||||||||||||||
| <Button | ||||||||||||||||||||||||||||||
| variant="ghost" | ||||||||||||||||||||||||||||||
| size="sm" | ||||||||||||||||||||||||||||||
| className="h-7 text-xs flex-1" | ||||||||||||||||||||||||||||||
| title="Expand all nodes" | ||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||
| Expand All | ||||||||||||||||||||||||||||||
| </Button> | ||||||||||||||||||||||||||||||
| <Button | ||||||||||||||||||||||||||||||
| variant="ghost" | ||||||||||||||||||||||||||||||
| size="sm" | ||||||||||||||||||||||||||||||
| className="h-7 text-xs flex-1" | ||||||||||||||||||||||||||||||
| title="Collapse all nodes" | ||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||
| Collapse All | ||||||||||||||||||||||||||||||
| </Button> | ||||||||||||||||||||||||||||||
| if (isEditing || !selectedNodeId) return; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Arrow Up: Move component up in tree (reorder) | ||||||||||||||||||||||||||||||
| if (e.key === 'ArrowUp' && (e.ctrlKey || e.metaKey)) { | ||||||||||||||||||||||||||||||
| e.preventDefault(); | ||||||||||||||||||||||||||||||
| moveNodeUp(selectedNodeId); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| // Arrow Down: Move component down in tree (reorder) | ||||||||||||||||||||||||||||||
| else if (e.key === 'ArrowDown' && (e.ctrlKey || e.metaKey)) { | ||||||||||||||||||||||||||||||
| e.preventDefault(); | ||||||||||||||||||||||||||||||
| moveNodeDown(selectedNodeId); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| window.addEventListener('keydown', handleKeyDown); | ||||||||||||||||||||||||||||||
| return () => window.removeEventListener('keydown', handleKeyDown); | ||||||||||||||||||||||||||||||
| }, [selectedNodeId, moveNodeUp, moveNodeDown]); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const expansionContextValue = useMemo(() => ({ | ||||||||||||||||||||||||||||||
| expandAll: expandTrigger > 0, | ||||||||||||||||||||||||||||||
| collapseAll: collapseTrigger > 0 | ||||||||||||||||||||||||||||||
| }), [expandTrigger, collapseTrigger]); | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
| const expansionContextValue = useMemo(() => ({ | |
| expandAll: expandTrigger > 0, | |
| collapseAll: collapseTrigger > 0 | |
| }), [expandTrigger, collapseTrigger]); | |
| const expansionContextValue = useMemo(() => { | |
| if (expandTrigger > collapseTrigger) { | |
| return { expandAll: true, collapseAll: false }; | |
| } | |
| if (collapseTrigger > expandTrigger) { | |
| return { expandAll: false, collapseAll: true }; | |
| } | |
| // Initial or neutral state: no global expand/collapse action | |
| return { expandAll: false, collapseAll: false }; | |
| }, [expandTrigger, collapseTrigger]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in commit 3679594. Changed to discriminated union approach that compares trigger values to determine which action is active, preventing simultaneous expand/collapse states.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unused import screen.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removed in commit 3679594.