From 2058e453ff80c7e720ec93c902b20403e2debf96 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Thu, 11 Jun 2026 14:59:25 +0200 Subject: [PATCH 1/4] 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) => { From 10b981ad1db435e12ab384a6c9e1968c95424ec0 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Thu, 11 Jun 2026 15:39:49 +0200 Subject: [PATCH 2/4] feat: editable container selector to detach/re-parent a nested node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a node is nested in a container, the detail panel now shows a Container selector. Pick another container to move it, or "None" to detach it back to the canvas (clears parentId). - store: setNodeParent(childId, parentId|null) — attach/detach/re-parent via absolute coords, container_mode-only targets, history snapshot - DetailPanel: Container setNodeParent(node.id, e.target.value || null)} + className="mt-1.5 w-full bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground focus:outline-none focus:border-[#00d4ff]/50" + > + + {nodes + .filter((n) => n.id !== node.id && (n.data.container_mode === true || n.id === node.parentId)) + .map((n) => ( + + ))} + +
+ )} + {/* Properties section */}
diff --git a/frontend/src/components/panels/__tests__/GroupPanels.test.tsx b/frontend/src/components/panels/__tests__/GroupPanels.test.tsx index ca73963..707622e 100644 --- a/frontend/src/components/panels/__tests__/GroupPanels.test.tsx +++ b/frontend/src/components/panels/__tests__/GroupPanels.test.tsx @@ -43,6 +43,7 @@ const mockStore = { createGroup: vi.fn(), ungroup: vi.fn(), removeFromGroup: vi.fn(), + setNodeParent: vi.fn(), } function setupStore(overrides = {}) { @@ -138,6 +139,50 @@ describe('MultiSelectPanel', () => { }) }) +describe('DetailPanel container selector', () => { + beforeEach(() => vi.clearAllMocks()) + + function makeContainer(id: string, label: string) { + return { id, type: 'proxmox', position: { x: 0, y: 0 }, data: { label, type: 'proxmox', status: 'online', services: [], container_mode: true } } + } + + it('does not show the container selector for a top-level node', () => { + const n1 = makeNode('n1') + setupStore({ nodes: [n1], selectedNodeId: 'n1', selectedNodeIds: ['n1'] }) + renderPanel() + expect(screen.queryByLabelText('Container')).toBeNull() + }) + + it('shows the container selector for a nested node, defaulting to its parent', () => { + const px = makeContainer('px1', 'Proxmox') + const child = makeNode('n1', { parentId: 'px1' }) + setupStore({ nodes: [px, child], selectedNodeId: 'n1', selectedNodeIds: ['n1'] }) + renderPanel() + expect((screen.getByLabelText('Container') as HTMLSelectElement).value).toBe('px1') + }) + + it('detaches the node when "None" is selected', () => { + const setNodeParent = vi.fn() + const px = makeContainer('px1', 'Proxmox') + const child = makeNode('n1', { parentId: 'px1' }) + setupStore({ nodes: [px, child], selectedNodeId: 'n1', selectedNodeIds: ['n1'], setNodeParent }) + renderPanel() + fireEvent.change(screen.getByLabelText('Container'), { target: { value: '' } }) + expect(setNodeParent).toHaveBeenCalledWith('n1', null) + }) + + it('re-parents the node when another container is selected', () => { + const setNodeParent = vi.fn() + const px1 = makeContainer('px1', 'Proxmox A') + const px2 = makeContainer('px2', 'Proxmox B') + const child = makeNode('n1', { parentId: 'px1' }) + setupStore({ nodes: [px1, px2, child], selectedNodeId: 'n1', selectedNodeIds: ['n1'], setNodeParent }) + renderPanel() + fireEvent.change(screen.getByLabelText('Container'), { target: { value: 'px2' } }) + expect(setNodeParent).toHaveBeenCalledWith('n1', 'px2') + }) +}) + describe('GroupDetailPanel', () => { beforeEach(() => vi.clearAllMocks()) diff --git a/frontend/src/stores/__tests__/canvasStore.test.ts b/frontend/src/stores/__tests__/canvasStore.test.ts index bda3b00..1160541 100644 --- a/frontend/src/stores/__tests__/canvasStore.test.ts +++ b/frontend/src/stores/__tests__/canvasStore.test.ts @@ -651,6 +651,89 @@ describe('canvasStore', () => { expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) }) + // ── setNodeParent ─────────────────────────────────────────────────────────── + + it('setNodeParent(null) detaches a nested node and restores absolute coords', () => { + const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 100, y: 50 } } + const child = { ...makeNode('n1'), position: { x: 20, y: 30 }, parentId: 'px1', extent: 'parent' as const, data: { label: 'n1', type: 'server' as const, status: 'unknown' as const, services: [], parent_id: 'px1' } } + useCanvasStore.setState({ nodes: [container, child] }) + + useCanvasStore.getState().setNodeParent('n1', null) + + const moved = useCanvasStore.getState().nodes.find((n) => n.id === 'n1') + expect(moved?.parentId).toBeUndefined() + expect(moved?.extent).toBeUndefined() + expect(moved?.data.parent_id).toBeUndefined() + // 100+20, 50+30 + expect(moved?.position).toEqual({ x: 120, y: 80 }) + }) + + it('setNodeParent attaches a top-level node to a container with relative coords', () => { + const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 76, y: 52 } } + const child = { ...makeNode('n1'), position: { x: 300, y: 200 } } + useCanvasStore.setState({ nodes: [container, child] }) + + useCanvasStore.getState().setNodeParent('n1', 'px1') + + 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') + expect(moved?.position).toEqual({ x: 224, y: 148 }) + }) + + it('setNodeParent moves a node from one container to another via absolute coords', () => { + const px1 = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 100, y: 100 } } + const px2 = { ...makeNode('px2', { type: 'proxmox', container_mode: true }), position: { x: 500, y: 100 } } + const child = { ...makeNode('n1'), position: { x: 20, y: 20 }, parentId: 'px1', extent: 'parent' as const } + useCanvasStore.setState({ nodes: [px1, px2, child] }) + + useCanvasStore.getState().setNodeParent('n1', 'px2') + + const moved = useCanvasStore.getState().nodes.find((n) => n.id === 'n1') + // abs = 100+20=120 ; relative to px2 = 120-500 → clamped to 8 + expect(moved?.parentId).toBe('px2') + expect(moved?.position).toEqual({ x: 8, y: 20 }) + // parent must precede child + const nodes = useCanvasStore.getState().nodes + expect(nodes.findIndex((n) => n.id === 'px2')).toBeLessThan(nodes.findIndex((n) => n.id === 'n1')) + }) + + it('setNodeParent is a no-op when target is not a container', () => { + const notContainer = { ...makeNode('s1', { type: 'server' }), position: { x: 0, y: 0 } } + const child = { ...makeNode('n1'), position: { x: 50, y: 50 } } + useCanvasStore.setState({ nodes: [notContainer, child] }) + useCanvasStore.getState().markSaved() + + useCanvasStore.getState().setNodeParent('n1', 's1') + + expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBeUndefined() + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) + }) + + it('setNodeParent is a no-op when the parent is unchanged', () => { + const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } } + const child = { ...makeNode('n1'), position: { x: 10, y: 10 }, parentId: 'px1', extent: 'parent' as const } + useCanvasStore.setState({ nodes: [container, child] }) + useCanvasStore.getState().markSaved() + + useCanvasStore.getState().setNodeParent('n1', 'px1') + + expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) + }) + + it('setNodeParent 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: 10, y: 10 }, parentId: 'px1', extent: 'parent' as const } + useCanvasStore.setState({ nodes: [container, child] }) + useCanvasStore.getState().markSaved() + + useCanvasStore.getState().setNodeParent('n1', null) + + 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 c3ecec6..ff89745 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -70,6 +70,7 @@ interface CanvasState { ungroup: (groupId: string) => void addToGroup: (groupId: string, childId: string) => void addToContainer: (containerId: string, childId: string) => void + setNodeParent: (childId: string, parentId: string | null) => void removeFromGroup: (groupId: string, childId: string) => void markSaved: () => void markUnsaved: () => void @@ -672,6 +673,72 @@ export const useCanvasStore = create((set) => ({ } }), + // Re-parent a node from the detail-panel selector: attach it to a container + // (parentId = container id) or detach it back to the canvas (parentId = null). + // Handles container→container moves by going through absolute coordinates. + setNodeParent: (childId, parentId) => + set((state) => { + const child = state.nodes.find((n) => n.id === childId) + if (!child) return state + const currentParentId = child.parentId + if ((parentId ?? undefined) === currentParentId) return state + if (parentId === childId) return state + + const newParent = parentId ? state.nodes.find((n) => n.id === parentId) : null + // Only container_mode nodes may receive children via the selector. + if (parentId && (!newParent || newParent.data.container_mode !== true)) return state + + // Child's absolute position (its stored position is relative to its + // current parent, if any). + const curParent = currentParentId ? state.nodes.find((n) => n.id === currentParentId) : null + const absX = child.position.x + (curParent?.position.x ?? 0) + const absY = child.position.y + (curParent?.position.y ?? 0) + + const updatedNodes = state.nodes.map((n) => { + if (n.id !== childId) return n + if (!newParent) { + return { + ...n, + parentId: undefined, + extent: undefined, + position: { x: absX, y: absY }, + data: { ...n.data, parent_id: undefined }, + } + } + return { + ...n, + parentId: newParent.id, + extent: 'parent' as const, + position: { + x: Math.max(8, absX - newParent.position.x), + y: Math.max(8, absY - newParent.position.y), + }, + selected: false, + data: { ...n.data, parent_id: newParent.id }, + } + }) + + // When attaching, React Flow needs the parent before the child. + let nodes = updatedNodes + if (newParent) { + const others = updatedNodes.filter((n) => n.id !== childId) + const movedChild = updatedNodes.find((n) => n.id === childId)! + const parentIdx = others.findIndex((n) => n.id === newParent.id) + nodes = [ + ...others.slice(0, parentIdx + 1), + movedChild, + ...others.slice(parentIdx + 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) => { From f082c295fd6863dfe5e6bdbebf8c70bae0176bb2 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Thu, 11 Jun 2026 17:25:44 +0200 Subject: [PATCH 3/4] feat: edit container parent in the node modal instead of detail panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the detail-panel container selector with the existing "Parent Container" dropdown in the edit modal (double-click). The selector now shows for any node that is nested in — or can nest into — a container_mode node, not just lxc/vm/docker_container, so a dragged-in node can be re-targeted or detached ("None") from the modal. - NodeModal: container_mode candidates count as valid parents; selector shown for nested nodes; submit/type-change keep valid container parents - App: pass container_mode in parentCandidates - revert detail-panel selector + setNodeParent store action (updateNode already handles parent attach/detach) ha-relevant: yes --- frontend/src/App.tsx | 4 +- frontend/src/components/modals/NodeModal.tsx | 26 ++++-- .../modals/__tests__/NodeModal.test.tsx | 42 ++++++++++ .../src/components/panels/DetailPanel.tsx | 26 +----- .../panels/__tests__/GroupPanels.test.tsx | 45 ---------- .../src/stores/__tests__/canvasStore.test.ts | 83 ------------------- frontend/src/stores/canvasStore.ts | 67 --------------- 7 files changed, 64 insertions(+), 229 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1a485a9..9ed350e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -650,7 +650,7 @@ export default function App() { onClose={() => setAddNodeOpen(false)} onSubmit={handleAddNode} 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 */} @@ -677,7 +677,7 @@ export default function App() { } return nodes .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} /> diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index 529f076..f7e6b95 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -55,6 +55,8 @@ interface ParentCandidate { id: string label: string type: NodeType + /** True when the node has container mode on, so any node can nest inside it. */ + container_mode?: boolean } interface NodeModalProps { @@ -98,12 +100,14 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' const selectedType = (form.type ?? 'generic') as NodeType const canUseContainerMode = CONTAINER_MODE_TYPES.includes(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 - if (validParentTypes.length === 0) { - safeParentId = undefined - } else if (safeParentId) { + if (safeParentId) { const parent = parentCandidates.find((n) => n.id === safeParentId) - if (!parent || !validParentTypes.includes(parent.type)) safeParentId = undefined + if (!parent || !isValidParent(parent)) safeParentId = undefined } onSubmit({ ...form, @@ -130,7 +134,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' setForm((f) => { const next: Partial = { ...f, type: t } 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 }) }}> @@ -349,9 +358,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' {(() => { const childType = (form.type ?? 'generic') as NodeType 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( - (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 return ( diff --git a/frontend/src/components/modals/__tests__/NodeModal.test.tsx b/frontend/src/components/modals/__tests__/NodeModal.test.tsx index 1895e49..9f187ba 100644 --- a/frontend/src/components/modals/__tests__/NodeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/NodeModal.test.tsx @@ -353,6 +353,48 @@ describe('NodeModal', () => { 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).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).parent_id).toBeUndefined() + }) + // ── Appearance ──────────────────────────────────────────────────────── it('renders 3 color swatch labels (border, background, icon)', () => { diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx index d31aed7..6508675 100644 --- a/frontend/src/components/panels/DetailPanel.tsx +++ b/frontend/src/components/panels/DetailPanel.tsx @@ -21,7 +21,7 @@ type PropForm = { key: string; value: string; icon: string | null; visible: bool const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true } export function DetailPanel({ onEdit }: DetailPanelProps) { - const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup, setNodeParent } = useCanvasStore() + const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup } = useCanvasStore() const serviceStatuses = useCanvasStore((s) => s.serviceStatuses) const [addingForNode, setAddingForNode] = useState(null) @@ -255,30 +255,6 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { {data.last_seen && }
- {/* Container membership — only when the node is nested. Lets the user move - it to another container or detach it ("None"). */} - {node.parentId && ( -
- - -
- )} - {/* Properties section */}
diff --git a/frontend/src/components/panels/__tests__/GroupPanels.test.tsx b/frontend/src/components/panels/__tests__/GroupPanels.test.tsx index 707622e..ca73963 100644 --- a/frontend/src/components/panels/__tests__/GroupPanels.test.tsx +++ b/frontend/src/components/panels/__tests__/GroupPanels.test.tsx @@ -43,7 +43,6 @@ const mockStore = { createGroup: vi.fn(), ungroup: vi.fn(), removeFromGroup: vi.fn(), - setNodeParent: vi.fn(), } function setupStore(overrides = {}) { @@ -139,50 +138,6 @@ describe('MultiSelectPanel', () => { }) }) -describe('DetailPanel container selector', () => { - beforeEach(() => vi.clearAllMocks()) - - function makeContainer(id: string, label: string) { - return { id, type: 'proxmox', position: { x: 0, y: 0 }, data: { label, type: 'proxmox', status: 'online', services: [], container_mode: true } } - } - - it('does not show the container selector for a top-level node', () => { - const n1 = makeNode('n1') - setupStore({ nodes: [n1], selectedNodeId: 'n1', selectedNodeIds: ['n1'] }) - renderPanel() - expect(screen.queryByLabelText('Container')).toBeNull() - }) - - it('shows the container selector for a nested node, defaulting to its parent', () => { - const px = makeContainer('px1', 'Proxmox') - const child = makeNode('n1', { parentId: 'px1' }) - setupStore({ nodes: [px, child], selectedNodeId: 'n1', selectedNodeIds: ['n1'] }) - renderPanel() - expect((screen.getByLabelText('Container') as HTMLSelectElement).value).toBe('px1') - }) - - it('detaches the node when "None" is selected', () => { - const setNodeParent = vi.fn() - const px = makeContainer('px1', 'Proxmox') - const child = makeNode('n1', { parentId: 'px1' }) - setupStore({ nodes: [px, child], selectedNodeId: 'n1', selectedNodeIds: ['n1'], setNodeParent }) - renderPanel() - fireEvent.change(screen.getByLabelText('Container'), { target: { value: '' } }) - expect(setNodeParent).toHaveBeenCalledWith('n1', null) - }) - - it('re-parents the node when another container is selected', () => { - const setNodeParent = vi.fn() - const px1 = makeContainer('px1', 'Proxmox A') - const px2 = makeContainer('px2', 'Proxmox B') - const child = makeNode('n1', { parentId: 'px1' }) - setupStore({ nodes: [px1, px2, child], selectedNodeId: 'n1', selectedNodeIds: ['n1'], setNodeParent }) - renderPanel() - fireEvent.change(screen.getByLabelText('Container'), { target: { value: 'px2' } }) - expect(setNodeParent).toHaveBeenCalledWith('n1', 'px2') - }) -}) - describe('GroupDetailPanel', () => { beforeEach(() => vi.clearAllMocks()) diff --git a/frontend/src/stores/__tests__/canvasStore.test.ts b/frontend/src/stores/__tests__/canvasStore.test.ts index 1160541..bda3b00 100644 --- a/frontend/src/stores/__tests__/canvasStore.test.ts +++ b/frontend/src/stores/__tests__/canvasStore.test.ts @@ -651,89 +651,6 @@ describe('canvasStore', () => { expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true) }) - // ── setNodeParent ─────────────────────────────────────────────────────────── - - it('setNodeParent(null) detaches a nested node and restores absolute coords', () => { - const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 100, y: 50 } } - const child = { ...makeNode('n1'), position: { x: 20, y: 30 }, parentId: 'px1', extent: 'parent' as const, data: { label: 'n1', type: 'server' as const, status: 'unknown' as const, services: [], parent_id: 'px1' } } - useCanvasStore.setState({ nodes: [container, child] }) - - useCanvasStore.getState().setNodeParent('n1', null) - - const moved = useCanvasStore.getState().nodes.find((n) => n.id === 'n1') - expect(moved?.parentId).toBeUndefined() - expect(moved?.extent).toBeUndefined() - expect(moved?.data.parent_id).toBeUndefined() - // 100+20, 50+30 - expect(moved?.position).toEqual({ x: 120, y: 80 }) - }) - - it('setNodeParent attaches a top-level node to a container with relative coords', () => { - const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 76, y: 52 } } - const child = { ...makeNode('n1'), position: { x: 300, y: 200 } } - useCanvasStore.setState({ nodes: [container, child] }) - - useCanvasStore.getState().setNodeParent('n1', 'px1') - - 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') - expect(moved?.position).toEqual({ x: 224, y: 148 }) - }) - - it('setNodeParent moves a node from one container to another via absolute coords', () => { - const px1 = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 100, y: 100 } } - const px2 = { ...makeNode('px2', { type: 'proxmox', container_mode: true }), position: { x: 500, y: 100 } } - const child = { ...makeNode('n1'), position: { x: 20, y: 20 }, parentId: 'px1', extent: 'parent' as const } - useCanvasStore.setState({ nodes: [px1, px2, child] }) - - useCanvasStore.getState().setNodeParent('n1', 'px2') - - const moved = useCanvasStore.getState().nodes.find((n) => n.id === 'n1') - // abs = 100+20=120 ; relative to px2 = 120-500 → clamped to 8 - expect(moved?.parentId).toBe('px2') - expect(moved?.position).toEqual({ x: 8, y: 20 }) - // parent must precede child - const nodes = useCanvasStore.getState().nodes - expect(nodes.findIndex((n) => n.id === 'px2')).toBeLessThan(nodes.findIndex((n) => n.id === 'n1')) - }) - - it('setNodeParent is a no-op when target is not a container', () => { - const notContainer = { ...makeNode('s1', { type: 'server' }), position: { x: 0, y: 0 } } - const child = { ...makeNode('n1'), position: { x: 50, y: 50 } } - useCanvasStore.setState({ nodes: [notContainer, child] }) - useCanvasStore.getState().markSaved() - - useCanvasStore.getState().setNodeParent('n1', 's1') - - expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBeUndefined() - expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) - }) - - it('setNodeParent is a no-op when the parent is unchanged', () => { - const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } } - const child = { ...makeNode('n1'), position: { x: 10, y: 10 }, parentId: 'px1', extent: 'parent' as const } - useCanvasStore.setState({ nodes: [container, child] }) - useCanvasStore.getState().markSaved() - - useCanvasStore.getState().setNodeParent('n1', 'px1') - - expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) - }) - - it('setNodeParent 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: 10, y: 10 }, parentId: 'px1', extent: 'parent' as const } - useCanvasStore.setState({ nodes: [container, child] }) - useCanvasStore.getState().markSaved() - - useCanvasStore.getState().setNodeParent('n1', null) - - 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 ff89745..c3ecec6 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -70,7 +70,6 @@ interface CanvasState { ungroup: (groupId: string) => void addToGroup: (groupId: string, childId: string) => void addToContainer: (containerId: string, childId: string) => void - setNodeParent: (childId: string, parentId: string | null) => void removeFromGroup: (groupId: string, childId: string) => void markSaved: () => void markUnsaved: () => void @@ -673,72 +672,6 @@ export const useCanvasStore = create((set) => ({ } }), - // Re-parent a node from the detail-panel selector: attach it to a container - // (parentId = container id) or detach it back to the canvas (parentId = null). - // Handles container→container moves by going through absolute coordinates. - setNodeParent: (childId, parentId) => - set((state) => { - const child = state.nodes.find((n) => n.id === childId) - if (!child) return state - const currentParentId = child.parentId - if ((parentId ?? undefined) === currentParentId) return state - if (parentId === childId) return state - - const newParent = parentId ? state.nodes.find((n) => n.id === parentId) : null - // Only container_mode nodes may receive children via the selector. - if (parentId && (!newParent || newParent.data.container_mode !== true)) return state - - // Child's absolute position (its stored position is relative to its - // current parent, if any). - const curParent = currentParentId ? state.nodes.find((n) => n.id === currentParentId) : null - const absX = child.position.x + (curParent?.position.x ?? 0) - const absY = child.position.y + (curParent?.position.y ?? 0) - - const updatedNodes = state.nodes.map((n) => { - if (n.id !== childId) return n - if (!newParent) { - return { - ...n, - parentId: undefined, - extent: undefined, - position: { x: absX, y: absY }, - data: { ...n.data, parent_id: undefined }, - } - } - return { - ...n, - parentId: newParent.id, - extent: 'parent' as const, - position: { - x: Math.max(8, absX - newParent.position.x), - y: Math.max(8, absY - newParent.position.y), - }, - selected: false, - data: { ...n.data, parent_id: newParent.id }, - } - }) - - // When attaching, React Flow needs the parent before the child. - let nodes = updatedNodes - if (newParent) { - const others = updatedNodes.filter((n) => n.id !== childId) - const movedChild = updatedNodes.find((n) => n.id === childId)! - const parentIdx = others.findIndex((n) => n.id === newParent.id) - nodes = [ - ...others.slice(0, parentIdx + 1), - movedChild, - ...others.slice(parentIdx + 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) => { From 592e7865c508c64fd384ead1a205923aaad6a4a8 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Thu, 11 Jun 2026 18:24:59 +0200 Subject: [PATCH 4/4] fix(deps): bump zeroconf to 0.149.12 for CVE-2026-48045 pip-audit (Security workflow) flagged zeroconf 0.149.7 as vulnerable to CVE-2026-48045, fixed in 0.149.12. ha-relevant: no --- backend/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index a87ac2b..744c957 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,7 +15,7 @@ pyyaml==6.0.2 types-PyYAML==6.0.12.20240917 websockets==13.1 httpx==0.27.2 -zeroconf==0.149.7 +zeroconf==0.149.12 aiomqtt==2.3.0 # Dev