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
This commit is contained in:
Pouzor
2026-06-11 14:59:25 +02:00
parent b776b6b8f1
commit 2058e453ff
7 changed files with 221 additions and 17 deletions
+16 -2
View File
@@ -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<HTMLDivElement>(null)
const { isAuthenticated } = useAuthStore()
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
@@ -70,6 +70,7 @@ export default function App() {
const [editNodeId, setEditNodeId] = useState<string | null>(null)
const [pendingConnection, setPendingConnection] = useState<Connection | null>(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<string | null>(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)}
/>
</div>
@@ -810,7 +812,7 @@ export default function App() {
<ConfirmAddToGroupModal
open={!!pendingGroupAdd}
nodeLabel={pendingGroupAdd ? (nodes.find((n) => 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)}
/>
<ConfirmAddToGroupModal
open={!!pendingContainerAdd}
variant="container"
nodeLabel={pendingContainerAdd ? (nodes.find((n) => 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 && (
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
)}
@@ -31,10 +31,11 @@ interface CanvasContainerProps {
onNodeDoubleClick?: (node: Node<NodeData>) => 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<NonNullable<typeof onNodeDragStop>>((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 (
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }} onMouseMove={onMouseMove}>
@@ -207,6 +207,50 @@ describe('CanvasContainer', () => {
expect(onRequestAddToGroup).not.toHaveBeenCalled()
})
// ── Drag onto container node → onRequestAddToContainer ────────────────────
function containerNode(id: string, type: NodeData['type'] = 'proxmox'): Node<NodeData> {
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(<CanvasContainer onRequestAddToContainer={onRequestAddToContainer} />)
;(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(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} onRequestAddToContainer={onRequestAddToContainer} />)
;(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(<CanvasContainer onRequestAddToContainer={onRequestAddToContainer} />)
;(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(<CanvasContainer onRequestAddToContainer={onRequestAddToContainer} />)
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
expect(onRequestAddToContainer).not.toHaveBeenCalled()
})
// ── Canvas settings ───────────────────────────────────────────────────────
it('enables snapToGrid', () => {
@@ -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 (
<Dialog open={open} onOpenChange={(o) => { if (!o) onCancel() }}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Layers size={16} className="text-[#00d4ff]" />
Add to group
{action}
</DialogTitle>
<DialogDescription>
Add <span className="font-medium text-foreground">{nodeLabel}</span> to the group{' '}
<span className="font-medium text-foreground">{groupLabel}</span>?
Add <span className="font-medium text-foreground">{nodeLabel}</span> to the {noun}{' '}
<span className="font-medium text-foreground">{targetLabel}</span>?
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -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}
</Button>
</DialogFooter>
</DialogContent>
@@ -5,14 +5,14 @@ import { ConfirmAddToGroupModal } from '../ConfirmAddToGroupModal'
describe('ConfirmAddToGroupModal', () => {
it('renders nothing when closed', () => {
render(
<ConfirmAddToGroupModal open={false} nodeLabel="Router" groupLabel="DMZ" onConfirm={vi.fn()} onCancel={vi.fn()} />,
<ConfirmAddToGroupModal open={false} nodeLabel="Router" targetLabel="DMZ" onConfirm={vi.fn()} onCancel={vi.fn()} />,
)
expect(screen.queryByText('Add to group')).toBeNull()
})
it('shows node and group labels when open', () => {
render(
<ConfirmAddToGroupModal open nodeLabel="Router" groupLabel="DMZ" onConfirm={vi.fn()} onCancel={vi.fn()} />,
<ConfirmAddToGroupModal open nodeLabel="Router" targetLabel="DMZ" onConfirm={vi.fn()} onCancel={vi.fn()} />,
)
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(
<ConfirmAddToGroupModal open nodeLabel="Router" groupLabel="DMZ" onConfirm={onConfirm} onCancel={vi.fn()} />,
<ConfirmAddToGroupModal open nodeLabel="Router" targetLabel="DMZ" onConfirm={onConfirm} onCancel={vi.fn()} />,
)
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(
<ConfirmAddToGroupModal open nodeLabel="Router" groupLabel="DMZ" onConfirm={vi.fn()} onCancel={onCancel} />,
<ConfirmAddToGroupModal open nodeLabel="Router" targetLabel="DMZ" onConfirm={vi.fn()} onCancel={onCancel} />,
)
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
expect(onCancel).toHaveBeenCalledOnce()
})
it('uses container wording when variant is container', () => {
render(
<ConfirmAddToGroupModal open variant="container" nodeLabel="VM" targetLabel="Proxmox" onConfirm={vi.fn()} onCancel={vi.fn()} />,
)
expect(screen.getByRole('button', { name: /add to container/i })).toBeDefined()
expect(screen.queryByText('Add to group')).toBeNull()
})
})
@@ -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', () => {
+45
View File
@@ -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<CanvasState>((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) => {