feat: edit container parent in the node modal instead of detail panel
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
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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<CanvasState>((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) => {
|
||||
|
||||
Reference in New Issue
Block a user