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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 91c0cd7..9ed350e 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)} /> @@ -648,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 */} @@ -675,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} /> @@ -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/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__/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/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/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) => {