Merge pull request #202 from Pouzor/feat/node-into-container

feat: drop node onto container node to nest it
This commit is contained in:
Rémy
2026-06-11 18:36:48 +02:00
committed by GitHub
10 changed files with 285 additions and 27 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ pyyaml==6.0.2
types-PyYAML==6.0.12.20240917 types-PyYAML==6.0.12.20240917
websockets==13.1 websockets==13.1
httpx==0.27.2 httpx==0.27.2
zeroconf==0.149.7 zeroconf==0.149.12
aiomqtt==2.3.0 aiomqtt==2.3.0
# Dev # Dev
+18 -4
View File
@@ -43,7 +43,7 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STANDALONE_STORAGE_KEY = 'homelable_canvas' const STANDALONE_STORAGE_KEY = 'homelable_canvas'
export default function App() { 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 canvasRef = useRef<HTMLDivElement>(null)
const { isAuthenticated } = useAuthStore() const { isAuthenticated } = useAuthStore()
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore() const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
@@ -70,6 +70,7 @@ export default function App() {
const [editNodeId, setEditNodeId] = useState<string | null>(null) const [editNodeId, setEditNodeId] = useState<string | null>(null)
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null) const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
const [pendingGroupAdd, setPendingGroupAdd] = useState<{ nodeId: string; groupId: string } | 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 [editEdgeId, setEditEdgeId] = useState<string | null>(null)
const [scanConfigOpen, setScanConfigOpen] = useState(false) const [scanConfigOpen, setScanConfigOpen] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false)
@@ -634,6 +635,7 @@ export default function App() {
onNodeDoubleClick={handleNodeDoubleClick} onNodeDoubleClick={handleNodeDoubleClick}
onNodeDragStart={snapshotHistory} onNodeDragStart={snapshotHistory}
onRequestAddToGroup={setPendingGroupAdd} onRequestAddToGroup={setPendingGroupAdd}
onRequestAddToContainer={setPendingContainerAdd}
onOpenPending={(deviceId) => openPendingModal(deviceId)} onOpenPending={(deviceId) => openPendingModal(deviceId)}
/> />
</div> </div>
@@ -648,7 +650,7 @@ export default function App() {
onClose={() => setAddNodeOpen(false)} onClose={() => setAddNodeOpen(false)}
onSubmit={handleAddNode} onSubmit={handleAddNode}
title="Add Node" title="Add Node"
parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))} parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))}
/> />
{/* key forces re-mount when editing a different node, resetting form state */} {/* key forces re-mount when editing a different node, resetting form state */}
@@ -675,7 +677,7 @@ export default function App() {
} }
return nodes return nodes
.filter((n) => !descendants.has(n.id)) .filter((n) => !descendants.has(n.id))
.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type })) .map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))
})()} })()}
currentNodeId={editNodeId ?? undefined} currentNodeId={editNodeId ?? undefined}
/> />
@@ -810,7 +812,7 @@ export default function App() {
<ConfirmAddToGroupModal <ConfirmAddToGroupModal
open={!!pendingGroupAdd} open={!!pendingGroupAdd}
nodeLabel={pendingGroupAdd ? (nodes.find((n) => n.id === pendingGroupAdd.nodeId)?.data.label ?? '') : ''} 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={() => { onConfirm={() => {
if (pendingGroupAdd) addToGroup(pendingGroupAdd.groupId, pendingGroupAdd.nodeId) if (pendingGroupAdd) addToGroup(pendingGroupAdd.groupId, pendingGroupAdd.nodeId)
setPendingGroupAdd(null) setPendingGroupAdd(null)
@@ -818,6 +820,18 @@ export default function App() {
onCancel={() => setPendingGroupAdd(null)} 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 && ( {!STANDALONE && (
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} /> <SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
)} )}
@@ -31,10 +31,11 @@ interface CanvasContainerProps {
onNodeDoubleClick?: (node: Node<NodeData>) => void onNodeDoubleClick?: (node: Node<NodeData>) => void
onNodeDragStart?: () => void onNodeDragStart?: () => void
onRequestAddToGroup?: (payload: { nodeId: string; groupId: string }) => void onRequestAddToGroup?: (payload: { nodeId: string; groupId: string }) => void
onRequestAddToContainer?: (payload: { nodeId: string; containerId: string }) => void
onOpenPending?: (deviceId: 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 [lassoMode, setLassoMode] = useState(true)
const { const {
nodes, edges, 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 // 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. // before the alignment snap so detection uses the dropped position.
const handleNodeDragStop = useCallback<NonNullable<typeof onNodeDragStop>>((event, dragNode, dragNodes) => { 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') { dragNode.data.type !== 'group' && dragNode.data.type !== 'groupRect') {
const group = getIntersectingNodes(dragNode).find((n) => n.data.type === 'group') const intersecting = getIntersectingNodes(dragNode)
if (group) onRequestAddToGroup({ nodeId: dragNode.id, groupId: group.id }) 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) onNodeDragStop(event, dragNode, dragNodes)
}, [onRequestAddToGroup, getIntersectingNodes, onNodeDragStop]) }, [onRequestAddToGroup, onRequestAddToContainer, getIntersectingNodes, onNodeDragStop])
return ( return (
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }} onMouseMove={onMouseMove}> <div className="w-full h-full" style={{ background: theme.colors.canvasBackground }} onMouseMove={onMouseMove}>
@@ -207,6 +207,50 @@ describe('CanvasContainer', () => {
expect(onRequestAddToGroup).not.toHaveBeenCalled() 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 ─────────────────────────────────────────────────────── // ── Canvas settings ───────────────────────────────────────────────────────
it('enables snapToGrid', () => { it('enables snapToGrid', () => {
@@ -12,23 +12,35 @@ import { Button } from '@/components/ui/button'
interface ConfirmAddToGroupModalProps { interface ConfirmAddToGroupModalProps {
open: boolean open: boolean
nodeLabel: string 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 onConfirm: () => void
onCancel: () => 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 ( return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onCancel() }}> <Dialog open={open} onOpenChange={(o) => { if (!o) onCancel() }}>
<DialogContent className="max-w-sm"> <DialogContent className="max-w-sm">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Layers size={16} className="text-[#00d4ff]" /> <Layers size={16} className="text-[#00d4ff]" />
Add to group {action}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Add <span className="font-medium text-foreground">{nodeLabel}</span> to the group{' '} Add <span className="font-medium text-foreground">{nodeLabel}</span> to the {noun}{' '}
<span className="font-medium text-foreground">{groupLabel}</span>? <span className="font-medium text-foreground">{targetLabel}</span>?
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
@@ -38,7 +50,7 @@ export function ConfirmAddToGroupModal({ open, nodeLabel, groupLabel, onConfirm,
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
onClick={onConfirm} onClick={onConfirm}
> >
Add to group {action}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+19 -7
View File
@@ -55,6 +55,8 @@ interface ParentCandidate {
id: string id: string
label: string label: string
type: NodeType type: NodeType
/** True when the node has container mode on, so any node can nest inside it. */
container_mode?: boolean
} }
interface NodeModalProps { interface NodeModalProps {
@@ -98,12 +100,14 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
const selectedType = (form.type ?? 'generic') as NodeType const selectedType = (form.type ?? 'generic') as NodeType
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType) const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
const validParentTypes = getValidParentTypes(selectedType) const validParentTypes = getValidParentTypes(selectedType)
// A parent is valid either by the type rules (lxc/vm/docker_container) or
// because the candidate is a container-mode node (any child can nest in it).
const isValidParent = (p: ParentCandidate) =>
validParentTypes.includes(p.type) || p.container_mode === true
let safeParentId = form.parent_id let safeParentId = form.parent_id
if (validParentTypes.length === 0) { if (safeParentId) {
safeParentId = undefined
} else if (safeParentId) {
const parent = parentCandidates.find((n) => n.id === safeParentId) const parent = parentCandidates.find((n) => n.id === safeParentId)
if (!parent || !validParentTypes.includes(parent.type)) safeParentId = undefined if (!parent || !isValidParent(parent)) safeParentId = undefined
} }
onSubmit({ onSubmit({
...form, ...form,
@@ -130,7 +134,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
setForm((f) => { setForm((f) => {
const next: Partial<NodeData> = { ...f, type: t } const next: Partial<NodeData> = { ...f, type: t }
if (ZIGBEE_TYPES.includes(t)) next.check_method = 'none' as CheckMethod if (ZIGBEE_TYPES.includes(t)) next.check_method = 'none' as CheckMethod
if (getValidParentTypes(t).length === 0) next.parent_id = undefined // Drop the parent only if it's no longer a valid target for the
// new type — keep container-mode parents (any node can nest).
const parent = parentCandidates.find((n) => n.id === f.parent_id)
if (f.parent_id && !(parent && (getValidParentTypes(t).includes(parent.type) || parent.container_mode === true))) {
next.parent_id = undefined
}
return next return next
}) })
}}> }}>
@@ -349,9 +358,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
{(() => { {(() => {
const childType = (form.type ?? 'generic') as NodeType const childType = (form.type ?? 'generic') as NodeType
const validParentTypes = getValidParentTypes(childType) const validParentTypes = getValidParentTypes(childType)
if (validParentTypes.length === 0) return null // Candidates: type-based parents (lxc/vm/docker_container) plus any
// container-mode node. The current parent is always kept so an
// already-nested node can be re-targeted or detached here.
const validParents = parentCandidates.filter( const validParents = parentCandidates.filter(
(n) => n.id !== currentNodeId && validParentTypes.includes(n.type), (n) => n.id !== currentNodeId &&
(validParentTypes.includes(n.type) || n.container_mode === true || n.id === form.parent_id),
) )
if (validParents.length === 0) return null if (validParents.length === 0) return null
return ( return (
@@ -5,14 +5,14 @@ import { ConfirmAddToGroupModal } from '../ConfirmAddToGroupModal'
describe('ConfirmAddToGroupModal', () => { describe('ConfirmAddToGroupModal', () => {
it('renders nothing when closed', () => { it('renders nothing when closed', () => {
render( 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() expect(screen.queryByText('Add to group')).toBeNull()
}) })
it('shows node and group labels when open', () => { it('shows node and group labels when open', () => {
render( 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('Router')).toBeDefined()
expect(screen.getByText('DMZ')).toBeDefined() expect(screen.getByText('DMZ')).toBeDefined()
@@ -21,7 +21,7 @@ describe('ConfirmAddToGroupModal', () => {
it('calls onConfirm when the confirm button is clicked', () => { it('calls onConfirm when the confirm button is clicked', () => {
const onConfirm = vi.fn() const onConfirm = vi.fn()
render( 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 })) fireEvent.click(screen.getByRole('button', { name: /add to group/i }))
expect(onConfirm).toHaveBeenCalledOnce() expect(onConfirm).toHaveBeenCalledOnce()
@@ -30,9 +30,17 @@ describe('ConfirmAddToGroupModal', () => {
it('calls onCancel when the cancel button is clicked', () => { it('calls onCancel when the cancel button is clicked', () => {
const onCancel = vi.fn() const onCancel = vi.fn()
render( 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 })) fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
expect(onCancel).toHaveBeenCalledOnce() 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()
})
}) })
@@ -353,6 +353,48 @@ describe('NodeModal', () => {
expect(screen.getByText('Parent Container')).toBeDefined() expect(screen.getByText('Parent Container')).toBeDefined()
}) })
it('renders Parent Container for a plain node when a container-mode candidate exists', () => {
renderModal({
initial: { ...BASE, type: 'server' },
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: true }],
})
expect(screen.getByText('Parent Container')).toBeDefined()
})
it('still hides Parent Container for a plain node when the candidate is not in container mode', () => {
renderModal({
initial: { ...BASE, type: 'server' },
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: false }],
})
expect(screen.queryByText('Parent Container')).toBeNull()
})
it('renders Parent Container for an already-nested plain node so it can be detached', () => {
renderModal({
initial: { ...BASE, type: 'server', parent_id: 'px1' },
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: true }],
})
expect(screen.getByText('Parent Container')).toBeDefined()
})
it('keeps a container-mode parent_id on submit for a plain node', () => {
const { onSubmit } = renderModal({
initial: { ...BASE, type: 'server', parent_id: 'px1' },
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: true }],
})
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).parent_id).toBe('px1')
})
it('drops a parent_id that is not a valid container on submit', () => {
const { onSubmit } = renderModal({
initial: { ...BASE, type: 'server', parent_id: 'px1' },
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: false }],
})
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).parent_id).toBeUndefined()
})
// ── Appearance ──────────────────────────────────────────────────────── // ── Appearance ────────────────────────────────────────────────────────
it('renders 3 color swatch labels (border, background, icon)', () => { it('renders 3 color swatch labels (border, background, icon)', () => {
@@ -578,6 +578,79 @@ describe('canvasStore', () => {
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) 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 ───────────────────────────────────────────────────────── // ── removeFromGroup ─────────────────────────────────────────────────────────
it('removeFromGroup releases the child to absolute coords and keeps the group', () => { 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 createGroup: (nodeIds: string[], name: string) => void
ungroup: (groupId: string) => void ungroup: (groupId: string) => void
addToGroup: (groupId: string, childId: string) => void addToGroup: (groupId: string, childId: string) => void
addToContainer: (containerId: string, childId: string) => void
removeFromGroup: (groupId: string, childId: string) => void removeFromGroup: (groupId: string, childId: string) => void
markSaved: () => void markSaved: () => void
markUnsaved: () => 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. // Release a single child from a group back to the canvas. Group stays.
removeFromGroup: (groupId, childId) => removeFromGroup: (groupId, childId) =>
set((state) => { set((state) => {