From 10b981ad1db435e12ab384a6c9e1968c95424ec0 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Thu, 11 Jun 2026 15:39:49 +0200 Subject: [PATCH] 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) => {