From 2058e453ff80c7e720ec93c902b20403e2debf96 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Thu, 11 Jun 2026 14:59:25 +0200 Subject: [PATCH] feat: drop node onto container node to nest it Dropping a top-level node over any container_mode node (proxmox, docker_host, ...) now pops a confirm modal that nests it as a child (sets parentId), mirroring the existing drop-onto-group flow. - store: addToContainer(containerId, childId) - CanvasContainer: detect container_mode intersection on drag stop (group still wins if both intersect) - generalize ConfirmAddToGroupModal with a container variant ha-relevant: yes --- frontend/src/App.tsx | 18 ++++- .../src/components/canvas/CanvasContainer.tsx | 18 +++-- .../canvas/__tests__/CanvasContainer.test.tsx | 44 +++++++++++ .../modals/ConfirmAddToGroupModal.tsx | 24 ++++-- .../__tests__/ConfirmAddToGroupModal.test.tsx | 16 +++- .../src/stores/__tests__/canvasStore.test.ts | 73 +++++++++++++++++++ frontend/src/stores/canvasStore.ts | 45 ++++++++++++ 7 files changed, 221 insertions(+), 17 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 91c0cd7..1a485a9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -43,7 +43,7 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE_STORAGE_KEY = 'homelable_canvas' export default function App() { - const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup } = useCanvasStore() + const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer } = useCanvasStore() const canvasRef = useRef(null) const { isAuthenticated } = useAuthStore() const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore() @@ -70,6 +70,7 @@ export default function App() { const [editNodeId, setEditNodeId] = useState(null) const [pendingConnection, setPendingConnection] = useState(null) const [pendingGroupAdd, setPendingGroupAdd] = useState<{ nodeId: string; groupId: string } | null>(null) + const [pendingContainerAdd, setPendingContainerAdd] = useState<{ nodeId: string; containerId: string } | null>(null) const [editEdgeId, setEditEdgeId] = useState(null) const [scanConfigOpen, setScanConfigOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false) @@ -634,6 +635,7 @@ export default function App() { onNodeDoubleClick={handleNodeDoubleClick} onNodeDragStart={snapshotHistory} onRequestAddToGroup={setPendingGroupAdd} + onRequestAddToContainer={setPendingContainerAdd} onOpenPending={(deviceId) => openPendingModal(deviceId)} /> @@ -810,7 +812,7 @@ export default function App() { n.id === pendingGroupAdd.nodeId)?.data.label ?? '') : ''} - groupLabel={pendingGroupAdd ? (nodes.find((n) => n.id === pendingGroupAdd.groupId)?.data.label ?? '') : ''} + targetLabel={pendingGroupAdd ? (nodes.find((n) => n.id === pendingGroupAdd.groupId)?.data.label ?? '') : ''} onConfirm={() => { if (pendingGroupAdd) addToGroup(pendingGroupAdd.groupId, pendingGroupAdd.nodeId) setPendingGroupAdd(null) @@ -818,6 +820,18 @@ export default function App() { onCancel={() => setPendingGroupAdd(null)} /> + n.id === pendingContainerAdd.nodeId)?.data.label ?? '') : ''} + targetLabel={pendingContainerAdd ? (nodes.find((n) => n.id === pendingContainerAdd.containerId)?.data.label ?? '') : ''} + onConfirm={() => { + if (pendingContainerAdd) addToContainer(pendingContainerAdd.containerId, pendingContainerAdd.nodeId) + setPendingContainerAdd(null) + }} + onCancel={() => setPendingContainerAdd(null)} + /> + {!STANDALONE && ( setSettingsOpen(false)} /> )} diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index 320576a..d5e92f0 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -31,10 +31,11 @@ interface CanvasContainerProps { onNodeDoubleClick?: (node: Node) => void onNodeDragStart?: () => void onRequestAddToGroup?: (payload: { nodeId: string; groupId: string }) => void + onRequestAddToContainer?: (payload: { nodeId: string; containerId: string }) => void onOpenPending?: (deviceId: string) => void } -export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onRequestAddToGroup, onOpenPending }: CanvasContainerProps) { +export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onRequestAddToGroup, onRequestAddToContainer, onOpenPending }: CanvasContainerProps) { const [lassoMode, setLassoMode] = useState(true) const { nodes, edges, @@ -129,13 +130,20 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o // Drop a top-level node onto a group → ask App to confirm adding it. Runs // before the alignment snap so detection uses the dropped position. const handleNodeDragStop = useCallback>((event, dragNode, dragNodes) => { - if (onRequestAddToGroup && dragNode && !dragNode.parentId && + if (dragNode && !dragNode.parentId && dragNode.data.type !== 'group' && dragNode.data.type !== 'groupRect') { - const group = getIntersectingNodes(dragNode).find((n) => n.data.type === 'group') - if (group) onRequestAddToGroup({ nodeId: dragNode.id, groupId: group.id }) + const intersecting = getIntersectingNodes(dragNode) + const group = intersecting.find((n) => n.data.type === 'group') + if (group) { + onRequestAddToGroup?.({ nodeId: dragNode.id, groupId: group.id }) + } else { + // Any node in container_mode (proxmox, docker_host, …) accepts children. + const container = intersecting.find((n) => n.id !== dragNode.id && n.data.container_mode === true) + if (container) onRequestAddToContainer?.({ nodeId: dragNode.id, containerId: container.id }) + } } onNodeDragStop(event, dragNode, dragNodes) - }, [onRequestAddToGroup, getIntersectingNodes, onNodeDragStop]) + }, [onRequestAddToGroup, onRequestAddToContainer, getIntersectingNodes, onNodeDragStop]) return (
diff --git a/frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx b/frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx index 318bb76..c0ea486 100644 --- a/frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx +++ b/frontend/src/components/canvas/__tests__/CanvasContainer.test.tsx @@ -207,6 +207,50 @@ describe('CanvasContainer', () => { expect(onRequestAddToGroup).not.toHaveBeenCalled() }) + // ── Drag onto container node → onRequestAddToContainer ──────────────────── + + function containerNode(id: string, type: NodeData['type'] = 'proxmox'): Node { + return { id, type, position: { x: 0, y: 0 }, data: { label: id, type, status: 'unknown', services: [], container_mode: true } } + } + + it('fires onRequestAddToContainer when a node is dropped over a container_mode node', () => { + const onRequestAddToContainer = vi.fn() + const node = makeNode('n1') + rf.intersecting = [containerNode('px1')] + render() + ;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node]) + expect(onRequestAddToContainer).toHaveBeenCalledWith({ nodeId: 'n1', containerId: 'px1' }) + }) + + it('prefers a group over a container when both intersect', () => { + const onRequestAddToGroup = vi.fn() + const onRequestAddToContainer = vi.fn() + const node = makeNode('n1') + rf.intersecting = [containerNode('px1'), groupNode('g1')] + render() + ;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node]) + expect(onRequestAddToGroup).toHaveBeenCalledWith({ nodeId: 'n1', groupId: 'g1' }) + expect(onRequestAddToContainer).not.toHaveBeenCalled() + }) + + it('does not fire onRequestAddToContainer for an already-parented node', () => { + const onRequestAddToContainer = vi.fn() + const node = { ...makeNode('n1'), parentId: 'pxOther' } + rf.intersecting = [containerNode('px1')] + render() + ;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node]) + expect(onRequestAddToContainer).not.toHaveBeenCalled() + }) + + it('does not fire onRequestAddToContainer when the target node is not in container_mode', () => { + const onRequestAddToContainer = vi.fn() + const node = makeNode('n1') + rf.intersecting = [makeNode('n2')] + render() + ;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node]) + expect(onRequestAddToContainer).not.toHaveBeenCalled() + }) + // ── Canvas settings ─────────────────────────────────────────────────────── it('enables snapToGrid', () => { diff --git a/frontend/src/components/modals/ConfirmAddToGroupModal.tsx b/frontend/src/components/modals/ConfirmAddToGroupModal.tsx index c5f3913..bcdfabb 100644 --- a/frontend/src/components/modals/ConfirmAddToGroupModal.tsx +++ b/frontend/src/components/modals/ConfirmAddToGroupModal.tsx @@ -12,23 +12,35 @@ import { Button } from '@/components/ui/button' interface ConfirmAddToGroupModalProps { open: boolean nodeLabel: string - groupLabel: string + /** Label of the destination group/container. */ + targetLabel: string + /** Destination kind — drives the wording. Defaults to 'group'. */ + variant?: 'group' | 'container' onConfirm: () => void onCancel: () => void } -export function ConfirmAddToGroupModal({ open, nodeLabel, groupLabel, onConfirm, onCancel }: ConfirmAddToGroupModalProps) { +export function ConfirmAddToGroupModal({ + open, + nodeLabel, + targetLabel, + variant = 'group', + onConfirm, + onCancel, +}: ConfirmAddToGroupModalProps) { + const action = variant === 'container' ? 'Add to container' : 'Add to group' + const noun = variant === 'container' ? 'container' : 'group' return ( { if (!o) onCancel() }}> - Add to group + {action} - Add {nodeLabel} to the group{' '} - {groupLabel}? + Add {nodeLabel} to the {noun}{' '} + {targetLabel}? @@ -38,7 +50,7 @@ export function ConfirmAddToGroupModal({ open, nodeLabel, groupLabel, onConfirm, className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" onClick={onConfirm} > - Add to group + {action} diff --git a/frontend/src/components/modals/__tests__/ConfirmAddToGroupModal.test.tsx b/frontend/src/components/modals/__tests__/ConfirmAddToGroupModal.test.tsx index 04c6a39..5bc7d40 100644 --- a/frontend/src/components/modals/__tests__/ConfirmAddToGroupModal.test.tsx +++ b/frontend/src/components/modals/__tests__/ConfirmAddToGroupModal.test.tsx @@ -5,14 +5,14 @@ import { ConfirmAddToGroupModal } from '../ConfirmAddToGroupModal' describe('ConfirmAddToGroupModal', () => { it('renders nothing when closed', () => { render( - , + , ) expect(screen.queryByText('Add to group')).toBeNull() }) it('shows node and group labels when open', () => { render( - , + , ) expect(screen.getByText('Router')).toBeDefined() expect(screen.getByText('DMZ')).toBeDefined() @@ -21,7 +21,7 @@ describe('ConfirmAddToGroupModal', () => { it('calls onConfirm when the confirm button is clicked', () => { const onConfirm = vi.fn() render( - , + , ) fireEvent.click(screen.getByRole('button', { name: /add to group/i })) expect(onConfirm).toHaveBeenCalledOnce() @@ -30,9 +30,17 @@ describe('ConfirmAddToGroupModal', () => { it('calls onCancel when the cancel button is clicked', () => { const onCancel = vi.fn() render( - , + , ) fireEvent.click(screen.getByRole('button', { name: /cancel/i })) expect(onCancel).toHaveBeenCalledOnce() }) + + it('uses container wording when variant is container', () => { + render( + , + ) + expect(screen.getByRole('button', { name: /add to container/i })).toBeDefined() + expect(screen.queryByText('Add to group')).toBeNull() + }) }) diff --git a/frontend/src/stores/__tests__/canvasStore.test.ts b/frontend/src/stores/__tests__/canvasStore.test.ts index c08c8eb..bda3b00 100644 --- a/frontend/src/stores/__tests__/canvasStore.test.ts +++ b/frontend/src/stores/__tests__/canvasStore.test.ts @@ -578,6 +578,79 @@ describe('canvasStore', () => { expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) }) + // ── addToContainer ────────────────────────────────────────────────────────── + + it('addToContainer nests a top-level node under a container_mode node', () => { + const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true, label: 'PX' }), position: { x: 76, y: 52 }, width: 448, height: 252 } + const child = { ...makeNode('n1'), position: { x: 300, y: 200 } } + useCanvasStore.setState({ nodes: [container, child] }) + + useCanvasStore.getState().addToContainer('px1', 'n1') + + const moved = useCanvasStore.getState().nodes.find((n) => n.id === 'n1') + expect(moved?.parentId).toBe('px1') + expect(moved?.extent).toBe('parent') + expect(moved?.data.parent_id).toBe('px1') + // 300-76=224, 200-52=148 + expect(moved?.position).toEqual({ x: 224, y: 148 }) + }) + + it('addToContainer works for any container_mode type (docker_host)', () => { + const host = { ...makeNode('dh1', { type: 'docker_host', container_mode: true }), position: { x: 0, y: 0 } } + const child = { ...makeNode('n1'), position: { x: 50, y: 50 } } + useCanvasStore.setState({ nodes: [host, child] }) + + useCanvasStore.getState().addToContainer('dh1', 'n1') + + expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBe('dh1') + }) + + it('addToContainer places the container before the child in the array', () => { + const child = { ...makeNode('n1'), position: { x: 300, y: 200 } } + const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } } + useCanvasStore.setState({ nodes: [child, container] }) + + useCanvasStore.getState().addToContainer('px1', 'n1') + + const { nodes } = useCanvasStore.getState() + expect(nodes.findIndex((n) => n.id === 'px1')).toBeLessThan(nodes.findIndex((n) => n.id === 'n1')) + }) + + it('addToContainer is a no-op when target is not in container_mode', () => { + const notContainer = { ...makeNode('px1', { type: 'proxmox', container_mode: false }), position: { x: 0, y: 0 } } + const child = { ...makeNode('n1'), position: { x: 50, y: 50 } } + useCanvasStore.setState({ nodes: [notContainer, child] }) + useCanvasStore.getState().markSaved() + + useCanvasStore.getState().addToContainer('px1', 'n1') + + expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBeUndefined() + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) + }) + + it('addToContainer is a no-op when child already belongs to the container', () => { + const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } } + const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'px1', extent: 'parent' as const } + useCanvasStore.setState({ nodes: [container, child] }) + useCanvasStore.getState().markSaved() + + useCanvasStore.getState().addToContainer('px1', 'n1') + + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) + }) + + it('addToContainer snapshots history and marks unsaved', () => { + const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } } + const child = { ...makeNode('n1'), position: { x: 50, y: 50 } } + useCanvasStore.setState({ nodes: [container, child] }) + useCanvasStore.getState().markSaved() + + useCanvasStore.getState().addToContainer('px1', 'n1') + + expect(useCanvasStore.getState().past).toHaveLength(1) + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) + }) + // ── removeFromGroup ───────────────────────────────────────────────────────── it('removeFromGroup releases the child to absolute coords and keeps the group', () => { diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index 309c137..c3ecec6 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -69,6 +69,7 @@ interface CanvasState { createGroup: (nodeIds: string[], name: string) => void ungroup: (groupId: string) => void addToGroup: (groupId: string, childId: string) => void + addToContainer: (containerId: string, childId: string) => void removeFromGroup: (groupId: string, childId: string) => void markSaved: () => void markUnsaved: () => void @@ -627,6 +628,50 @@ export const useCanvasStore = create((set) => ({ } }), + // Nest an existing top-level node inside a container node (proxmox / + // docker_host / … in container_mode). Mirrors addToGroup but the target is + // any node with data.container_mode === true rather than a group. + addToContainer: (containerId, childId) => + set((state) => { + const container = state.nodes.find((n) => n.id === containerId) + const child = state.nodes.find((n) => n.id === childId) + if (!container || !child || container.data.container_mode !== true) return state + if (child.id === containerId || child.parentId === containerId) return state + + const updatedNodes = state.nodes.map((n) => { + if (n.id !== childId) return n + return { + ...n, + parentId: containerId, + extent: 'parent' as const, + // Absolute → container-relative. Clamp so the node stays inside. + position: { + x: Math.max(8, n.position.x - container.position.x), + y: Math.max(8, n.position.y - container.position.y), + }, + selected: false, + data: { ...n.data, parent_id: containerId }, + } + }) + + // React Flow requires the parent to precede its children in the array. + const others = updatedNodes.filter((n) => n.id !== childId) + const movedChild = updatedNodes.find((n) => n.id === childId)! + const containerIdx = others.findIndex((n) => n.id === containerId) + const nodes = [ + ...others.slice(0, containerIdx + 1), + movedChild, + ...others.slice(containerIdx + 1), + ] + + return { + nodes, + hasUnsavedChanges: true, + past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }], + future: [], + } + }), + // Release a single child from a group back to the canvas. Group stays. removeFromGroup: (groupId, childId) => set((state) => {