fix: DEL key deletes nodes and deletion is now undoable
- Add 'Delete' to deleteKeyCode so both Backspace and Delete remove nodes - Call snapshotHistory() in onBeforeDelete (keyboard) and in DetailPanel handleDelete (button) so deletions can be undone with Ctrl+Z
This commit is contained in:
@@ -27,7 +27,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
const {
|
const {
|
||||||
nodes, edges,
|
nodes, edges,
|
||||||
onNodesChange, onEdgesChange,
|
onNodesChange, onEdgesChange,
|
||||||
setSelectedNode,
|
setSelectedNode, snapshotHistory,
|
||||||
} = useCanvasStore()
|
} = useCanvasStore()
|
||||||
|
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
@@ -59,6 +59,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
onNodeDragStart={onNodeDragStart}
|
onNodeDragStart={onNodeDragStart}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
|
deleteKeyCode={['Backspace', 'Delete']}
|
||||||
|
onBeforeDelete={async () => { snapshotHistory(); return true }}
|
||||||
snapToGrid
|
snapToGrid
|
||||||
snapGrid={[16, 16]}
|
snapGrid={[16, 16]}
|
||||||
fitView
|
fitView
|
||||||
|
|||||||
@@ -143,4 +143,22 @@ describe('CanvasContainer', () => {
|
|||||||
render(<CanvasContainer />)
|
render(<CanvasContainer />)
|
||||||
expect(rfProps.snapGrid).toEqual([16, 16])
|
expect(rfProps.snapGrid).toEqual([16, 16])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Delete key ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('sets deleteKeyCode to include both Backspace and Delete', () => {
|
||||||
|
render(<CanvasContainer />)
|
||||||
|
expect(rfProps.deleteKeyCode).toEqual(['Backspace', 'Delete'])
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── onBeforeDelete snapshot ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('onBeforeDelete calls snapshotHistory and returns true', async () => {
|
||||||
|
const snapshotHistory = vi.fn()
|
||||||
|
useCanvasStore.setState({ snapshotHistory } as unknown as Parameters<typeof useCanvasStore.setState>[0])
|
||||||
|
render(<CanvasContainer />)
|
||||||
|
const result = await (rfProps.onBeforeDelete as () => Promise<boolean>)()
|
||||||
|
expect(snapshotHistory).toHaveBeenCalledOnce()
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ type SvcForm = { port: string; protocol: 'tcp' | 'udp'; service_name: string }
|
|||||||
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' }
|
const EMPTY_FORM: SvcForm = { port: '', protocol: 'tcp', service_name: '' }
|
||||||
|
|
||||||
export function DetailPanel({ onEdit }: DetailPanelProps) {
|
export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||||
const { nodes, selectedNodeId, setSelectedNode, deleteNode, updateNode } = useCanvasStore()
|
const { nodes, selectedNodeId, setSelectedNode, deleteNode, updateNode, snapshotHistory } = useCanvasStore()
|
||||||
const node = nodes.find((n) => n.id === selectedNodeId)
|
const node = nodes.find((n) => n.id === selectedNodeId)
|
||||||
|
|
||||||
const [addingForNode, setAddingForNode] = useState<string | null>(null)
|
const [addingForNode, setAddingForNode] = useState<string | null>(null)
|
||||||
@@ -35,6 +35,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (confirm(`Delete "${data.label}"?`)) {
|
if (confirm(`Delete "${data.label}"?`)) {
|
||||||
|
snapshotHistory()
|
||||||
deleteNode(node.id)
|
deleteNode(node.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ function setupStore(nodeData: Partial<NodeData> = {}) {
|
|||||||
setSelectedNode: vi.fn(),
|
setSelectedNode: vi.fn(),
|
||||||
deleteNode: vi.fn(),
|
deleteNode: vi.fn(),
|
||||||
updateNode: vi.fn(),
|
updateNode: vi.fn(),
|
||||||
|
snapshotHistory: vi.fn(),
|
||||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ describe('DetailPanel', () => {
|
|||||||
setSelectedNode: vi.fn(),
|
setSelectedNode: vi.fn(),
|
||||||
deleteNode: vi.fn(),
|
deleteNode: vi.fn(),
|
||||||
updateNode: vi.fn(),
|
updateNode: vi.fn(),
|
||||||
|
snapshotHistory: vi.fn(),
|
||||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -125,6 +127,7 @@ describe('DetailPanel', () => {
|
|||||||
setSelectedNode,
|
setSelectedNode,
|
||||||
deleteNode: vi.fn(),
|
deleteNode: vi.fn(),
|
||||||
updateNode: vi.fn(),
|
updateNode: vi.fn(),
|
||||||
|
snapshotHistory: vi.fn(),
|
||||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
render(<DetailPanel onEdit={vi.fn()} />)
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
fireEvent.click(screen.getByLabelText('Close panel'))
|
fireEvent.click(screen.getByLabelText('Close panel'))
|
||||||
@@ -139,33 +142,39 @@ describe('DetailPanel', () => {
|
|||||||
expect(onEdit).toHaveBeenCalledWith('n1')
|
expect(onEdit).toHaveBeenCalledWith('n1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls deleteNode when delete confirmed', () => {
|
it('calls snapshotHistory then deleteNode when delete confirmed', () => {
|
||||||
const deleteNode = vi.fn()
|
const deleteNode = vi.fn()
|
||||||
|
const snapshotHistory = vi.fn()
|
||||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||||
nodes: [makeNode({ label: 'My Server' })],
|
nodes: [makeNode({ label: 'My Server' })],
|
||||||
selectedNodeId: 'n1',
|
selectedNodeId: 'n1',
|
||||||
setSelectedNode: vi.fn(),
|
setSelectedNode: vi.fn(),
|
||||||
deleteNode,
|
deleteNode,
|
||||||
updateNode: vi.fn(),
|
updateNode: vi.fn(),
|
||||||
|
snapshotHistory,
|
||||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
render(<DetailPanel onEdit={vi.fn()} />)
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
fireEvent.click(screen.getByLabelText('Delete node'))
|
fireEvent.click(screen.getByLabelText('Delete node'))
|
||||||
|
expect(snapshotHistory).toHaveBeenCalledOnce()
|
||||||
expect(deleteNode).toHaveBeenCalledWith('n1')
|
expect(deleteNode).toHaveBeenCalledWith('n1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not call deleteNode when delete is cancelled', () => {
|
it('does not call deleteNode or snapshotHistory when delete is cancelled', () => {
|
||||||
const deleteNode = vi.fn()
|
const deleteNode = vi.fn()
|
||||||
|
const snapshotHistory = vi.fn()
|
||||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||||
nodes: [makeNode({})],
|
nodes: [makeNode({})],
|
||||||
selectedNodeId: 'n1',
|
selectedNodeId: 'n1',
|
||||||
setSelectedNode: vi.fn(),
|
setSelectedNode: vi.fn(),
|
||||||
deleteNode,
|
deleteNode,
|
||||||
updateNode: vi.fn(),
|
updateNode: vi.fn(),
|
||||||
|
snapshotHistory,
|
||||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
vi.spyOn(window, 'confirm').mockReturnValue(false)
|
vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||||
render(<DetailPanel onEdit={vi.fn()} />)
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
fireEvent.click(screen.getByLabelText('Delete node'))
|
fireEvent.click(screen.getByLabelText('Delete node'))
|
||||||
|
expect(snapshotHistory).not.toHaveBeenCalled()
|
||||||
expect(deleteNode).not.toHaveBeenCalled()
|
expect(deleteNode).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -186,6 +195,7 @@ describe('DetailPanel', () => {
|
|||||||
setSelectedNode: vi.fn(),
|
setSelectedNode: vi.fn(),
|
||||||
deleteNode: vi.fn(),
|
deleteNode: vi.fn(),
|
||||||
updateNode,
|
updateNode,
|
||||||
|
snapshotHistory: vi.fn(),
|
||||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
render(<DetailPanel onEdit={vi.fn()} />)
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
fireEvent.click(screen.getByText('Add'))
|
fireEvent.click(screen.getByText('Add'))
|
||||||
@@ -207,6 +217,7 @@ describe('DetailPanel', () => {
|
|||||||
setSelectedNode: vi.fn(),
|
setSelectedNode: vi.fn(),
|
||||||
deleteNode: vi.fn(),
|
deleteNode: vi.fn(),
|
||||||
updateNode,
|
updateNode,
|
||||||
|
snapshotHistory: vi.fn(),
|
||||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
render(<DetailPanel onEdit={vi.fn()} />)
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
fireEvent.click(screen.getByTitle('Remove service'))
|
fireEvent.click(screen.getByTitle('Remove service'))
|
||||||
@@ -221,6 +232,7 @@ describe('DetailPanel', () => {
|
|||||||
setSelectedNode: vi.fn(),
|
setSelectedNode: vi.fn(),
|
||||||
deleteNode: vi.fn(),
|
deleteNode: vi.fn(),
|
||||||
updateNode: vi.fn(),
|
updateNode: vi.fn(),
|
||||||
|
snapshotHistory: vi.fn(),
|
||||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
expect(() => render(<DetailPanel onEdit={vi.fn()} />)).not.toThrow()
|
expect(() => render(<DetailPanel onEdit={vi.fn()} />)).not.toThrow()
|
||||||
})
|
})
|
||||||
@@ -249,6 +261,7 @@ describe('DetailPanel', () => {
|
|||||||
setSelectedNode: vi.fn(),
|
setSelectedNode: vi.fn(),
|
||||||
deleteNode: vi.fn(),
|
deleteNode: vi.fn(),
|
||||||
updateNode,
|
updateNode,
|
||||||
|
snapshotHistory: vi.fn(),
|
||||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
|
|
||||||
render(<DetailPanel onEdit={vi.fn()} />)
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
@@ -271,6 +284,7 @@ describe('DetailPanel', () => {
|
|||||||
setSelectedNode: vi.fn(),
|
setSelectedNode: vi.fn(),
|
||||||
deleteNode: vi.fn(),
|
deleteNode: vi.fn(),
|
||||||
updateNode,
|
updateNode,
|
||||||
|
snapshotHistory: vi.fn(),
|
||||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
|
|
||||||
render(<DetailPanel onEdit={vi.fn()} />)
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
|||||||
Reference in New Issue
Block a user