-
Notifications
You must be signed in to change notification settings - Fork 2
Implement component reordering via drag-and-drop in designer canvas #5
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 all commits
f8608b9
e666c27
904b644
882db86
e2aa9b8
b0d9ab2
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,134 @@ | ||
| import { describe, it, expect, beforeEach } from 'vitest'; | ||
| import { render, screen, act } from '@testing-library/react'; | ||
| import { DesignerProvider, useDesigner } from '../context/DesignerContext'; | ||
| import type { SchemaNode } from '@object-ui/protocol'; | ||
| import React from 'react'; | ||
| import userEvent from '@testing-library/user-event'; | ||
|
|
||
| // Test component to access designer context | ||
| const TestComponent = () => { | ||
| const { | ||
| schema, | ||
| draggingNodeId, | ||
| setDraggingNodeId, | ||
| moveNode, | ||
| addNode | ||
| } = useDesigner(); | ||
|
|
||
| return ( | ||
| <div> | ||
| <div data-testid="schema">{JSON.stringify(schema)}</div> | ||
| <div data-testid="dragging-node-id">{draggingNodeId || 'null'}</div> | ||
| <button onClick={() => setDraggingNodeId('test-node')}>Set Dragging</button> | ||
| <button onClick={() => setDraggingNodeId(null)}>Clear Dragging</button> | ||
| <button onClick={() => { | ||
| const newNode: SchemaNode = { type: 'text', id: 'new-text' }; | ||
| addNode(schema.id, newNode); | ||
| }}>Add Node</button> | ||
| <button onClick={() => { | ||
| if (schema.body && Array.isArray(schema.body) && schema.body.length > 0) { | ||
| moveNode(schema.body[0].id!, schema.id, 1); | ||
| } | ||
| }}>Move Node</button> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| describe('Drag and Drop Functionality', () => { | ||
| const initialSchema: SchemaNode = { | ||
| type: 'div', | ||
| id: 'root', | ||
| body: [ | ||
| { type: 'card', id: 'card-1' }, | ||
| { type: 'card', id: 'card-2' } | ||
| ] | ||
| }; | ||
|
|
||
| it('should initialize draggingNodeId as null', () => { | ||
| render( | ||
| <DesignerProvider initialSchema={initialSchema}> | ||
| <TestComponent /> | ||
| </DesignerProvider> | ||
| ); | ||
|
|
||
| expect(screen.getByTestId('dragging-node-id').textContent).toBe('null'); | ||
| }); | ||
|
|
||
| it('should set and clear draggingNodeId', async () => { | ||
| const user = userEvent.setup(); | ||
|
|
||
| render( | ||
| <DesignerProvider initialSchema={initialSchema}> | ||
| <TestComponent /> | ||
| </DesignerProvider> | ||
| ); | ||
|
|
||
| const setButton = screen.getByText('Set Dragging'); | ||
| const clearButton = screen.getByText('Clear Dragging'); | ||
|
|
||
| // Set dragging node | ||
| await user.click(setButton); | ||
| expect(screen.getByTestId('dragging-node-id').textContent).toBe('test-node'); | ||
|
|
||
| // Clear dragging node | ||
| await user.click(clearButton); | ||
| expect(screen.getByTestId('dragging-node-id').textContent).toBe('null'); | ||
| }); | ||
|
|
||
| it('should move nodes within the schema', async () => { | ||
| const user = userEvent.setup(); | ||
|
|
||
| render( | ||
| <DesignerProvider initialSchema={initialSchema}> | ||
| <TestComponent /> | ||
| </DesignerProvider> | ||
| ); | ||
|
|
||
| const moveButton = screen.getByText('Move Node'); | ||
|
|
||
| // Get initial schema | ||
| const initialSchemaText = screen.getByTestId('schema').textContent; | ||
| const initial = JSON.parse(initialSchemaText || '{}'); | ||
| expect(initial.body[0].id).toBe('card-1'); | ||
| expect(initial.body[1].id).toBe('card-2'); | ||
|
|
||
| // Move first node to position 1 | ||
| await user.click(moveButton); | ||
|
|
||
| // Get updated schema | ||
| const updatedSchemaText = screen.getByTestId('schema').textContent; | ||
| const updated = JSON.parse(updatedSchemaText || '{}'); | ||
|
|
||
| // After moving card-1 to index 1, card-2 should be at index 0 | ||
| expect(updated.body[0].id).toBe('card-2'); | ||
| expect(updated.body[1].id).toBe('card-1'); | ||
| }); | ||
|
|
||
| it('should add nodes to the schema', async () => { | ||
| const user = userEvent.setup(); | ||
|
|
||
| render( | ||
| <DesignerProvider initialSchema={initialSchema}> | ||
| <TestComponent /> | ||
| </DesignerProvider> | ||
| ); | ||
|
|
||
| const addButton = screen.getByText('Add Node'); | ||
|
|
||
| // Get initial schema | ||
| const initialSchemaText = screen.getByTestId('schema').textContent; | ||
| const initial = JSON.parse(initialSchemaText || '{}'); | ||
| expect(initial.body.length).toBe(2); | ||
|
|
||
| // Add a new node | ||
| await user.click(addButton); | ||
|
|
||
| // Get updated schema | ||
| const updatedSchemaText = screen.getByTestId('schema').textContent; | ||
| const updated = JSON.parse(updatedSchemaText || '{}'); | ||
|
|
||
| // Should have 3 nodes now | ||
| expect(updated.body.length).toBe(3); | ||
| expect(updated.body[2].type).toBe('text'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,10 +16,14 @@ export const Canvas: React.FC<CanvasProps> = ({ className }) => { | |
| hoveredNodeId, | ||
| setHoveredNodeId, | ||
| draggingType, | ||
| draggingNodeId, | ||
| setDraggingNodeId, | ||
| addNode, | ||
| moveNode, | ||
| } = useDesigner(); | ||
|
|
||
| const [scale, setScale] = useState(1); | ||
| const canvasRef = React.useRef<HTMLDivElement>(null); | ||
|
|
||
| const handleClick = (e: React.MouseEvent) => { | ||
| // Find closest element with data-obj-id | ||
|
|
@@ -35,15 +39,18 @@ export const Canvas: React.FC<CanvasProps> = ({ className }) => { | |
| }; | ||
|
|
||
| const handleDragOver = (e: React.DragEvent) => { | ||
| if (!draggingType) return; | ||
| if (!draggingType && !draggingNodeId) return; | ||
| e.preventDefault(); | ||
|
|
||
| const target = (e.target as Element).closest('[data-obj-id]'); | ||
| if (target) { | ||
| e.stopPropagation(); | ||
| const id = target.getAttribute('data-obj-id'); | ||
| setHoveredNodeId(id); | ||
| e.dataTransfer.dropEffect = 'copy'; | ||
| // Don't allow dropping on the node being dragged | ||
| if (id !== draggingNodeId) { | ||
| setHoveredNodeId(id); | ||
| e.dataTransfer.dropEffect = draggingNodeId ? 'move' : 'copy'; | ||
| } | ||
| } else { | ||
| setHoveredNodeId(null); | ||
| } | ||
|
|
@@ -55,27 +62,85 @@ export const Canvas: React.FC<CanvasProps> = ({ className }) => { | |
|
|
||
| const handleDrop = (e: React.DragEvent) => { | ||
| e.preventDefault(); | ||
| if (!draggingType) return; | ||
|
|
||
|
|
||
| const target = (e.target as Element).closest('[data-obj-id]'); | ||
| const targetId = target?.getAttribute('data-obj-id'); | ||
|
|
||
| if (targetId) { | ||
| e.stopPropagation(); | ||
| const config = ComponentRegistry.getConfig(draggingType); | ||
| if (config) { | ||
| const newNode = { | ||
| type: draggingType, | ||
| ...(config.defaultProps || {}), | ||
| body: config.defaultChildren || undefined | ||
| }; | ||
| addNode(targetId, newNode); | ||
| } | ||
|
|
||
| // Handle moving existing component | ||
| if (draggingNodeId) { | ||
| // Don't allow dropping on itself | ||
| if (draggingNodeId !== targetId) { | ||
| // TODO: Calculate proper insertion index based on drop position | ||
| // For now, always insert at the beginning (index 0) | ||
| moveNode(draggingNodeId, targetId, 0); | ||
| } | ||
| setDraggingNodeId(null); | ||
| } | ||
| // Handle adding new component from palette | ||
| else if (draggingType) { | ||
| const config = ComponentRegistry.getConfig(draggingType); | ||
| if (config) { | ||
| const newNode = { | ||
| type: draggingType, | ||
| ...(config.defaultProps || {}), | ||
| body: config.defaultChildren || undefined | ||
| }; | ||
| addNode(targetId, newNode); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| setHoveredNodeId(null); | ||
| }; | ||
|
|
||
| // Make components in canvas draggable | ||
| React.useEffect(() => { | ||
| if (!canvasRef.current) return; | ||
|
|
||
| const handleDragStart = (e: DragEvent) => { | ||
| const target = (e.target as Element).closest('[data-obj-id]'); | ||
| if (target && target.getAttribute('data-obj-id')) { | ||
| const nodeId = target.getAttribute('data-obj-id'); | ||
| // Don't allow dragging the root node | ||
| if (nodeId === schema.id) { | ||
| e.preventDefault(); | ||
| return; | ||
| } | ||
| setDraggingNodeId(nodeId); | ||
| e.stopPropagation(); | ||
| if (e.dataTransfer) { | ||
| e.dataTransfer.effectAllowed = 'move'; | ||
| e.dataTransfer.setData('text/plain', nodeId || ''); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| const handleDragEnd = () => { | ||
| setDraggingNodeId(null); | ||
| }; | ||
|
|
||
| // Add draggable attribute and event listeners to all elements with data-obj-id within canvas | ||
| const elements = canvasRef.current.querySelectorAll('[data-obj-id]'); | ||
| elements.forEach(el => { | ||
| // Don't make root draggable | ||
| if (el.getAttribute('data-obj-id') !== schema.id) { | ||
| el.setAttribute('draggable', 'true'); | ||
| el.addEventListener('dragstart', handleDragStart as EventListener); | ||
| el.addEventListener('dragend', handleDragEnd as EventListener); | ||
| } | ||
| }); | ||
|
|
||
| return () => { | ||
| elements.forEach(el => { | ||
| el.removeEventListener('dragstart', handleDragStart as EventListener); | ||
| el.removeEventListener('dragend', handleDragEnd as EventListener); | ||
| }); | ||
| }; | ||
| }, [schema, setDraggingNodeId]); | ||
|
Comment on lines
+100
to
+142
|
||
|
|
||
| // Inject styles for selection/hover using dynamic CSS | ||
| // Using a more refined outline style | ||
| const highlightStyles = ` | ||
|
|
@@ -84,6 +149,14 @@ export const Canvas: React.FC<CanvasProps> = ({ className }) => { | |
| transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); | ||
| } | ||
|
|
||
| [data-obj-id]:not([data-obj-id="${schema.id}"]) { | ||
| cursor: grab; | ||
| } | ||
|
|
||
| [data-obj-id]:not([data-obj-id="${schema.id}"]):active { | ||
| cursor: grabbing; | ||
| } | ||
|
|
||
| [data-obj-id="${selectedNodeId}"] { | ||
| outline: 2px solid #3b82f6 !important; | ||
| outline-offset: -1px; | ||
|
|
@@ -113,7 +186,11 @@ export const Canvas: React.FC<CanvasProps> = ({ className }) => { | |
| outline: 2px dashed #60a5fa !important; | ||
| outline-offset: -2px; | ||
| background-color: rgba(59, 130, 246, 0.05); | ||
| cursor: copy; | ||
| cursor: ${draggingNodeId ? 'move' : 'copy'}; | ||
| } | ||
|
|
||
| [data-obj-id="${draggingNodeId}"] { | ||
| opacity: 0.5; | ||
| } | ||
| `; | ||
|
|
||
|
|
@@ -135,6 +212,7 @@ export const Canvas: React.FC<CanvasProps> = ({ className }) => { | |
| </div> | ||
|
|
||
| <div | ||
| ref={canvasRef} | ||
| className="flex-1 overflow-auto p-12 relative flex justify-center" | ||
| onClick={handleClick} | ||
| onDragOver={handleDragOver} | ||
|
|
||
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.
The fallback to empty string when
nodeIdis null/undefined is unnecessary sincenodeIdhas already been null-checked at line 108. The|| ''can be removed for cleaner code.