feat: editable container selector to detach/re-parent a nested node
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 <select>, shown only for nested nodes ha-relevant: yes
This commit is contained in:
@@ -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 }
|
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
|
||||||
|
|
||||||
export function DetailPanel({ onEdit }: DetailPanelProps) {
|
export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||||
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup } = useCanvasStore()
|
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup, setNodeParent } = useCanvasStore()
|
||||||
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
|
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
|
||||||
|
|
||||||
const [addingForNode, setAddingForNode] = useState<string | null>(null)
|
const [addingForNode, setAddingForNode] = useState<string | null>(null)
|
||||||
@@ -255,6 +255,30 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
{data.last_seen && <DetailRow label="Last Seen" value={new Date(/[Zz]|[+-]\d{2}:?\d{2}$/.test(data.last_seen) ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />}
|
{data.last_seen && <DetailRow label="Last Seen" value={new Date(/[Zz]|[+-]\d{2}:?\d{2}$/.test(data.last_seen) ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Container membership — only when the node is nested. Lets the user move
|
||||||
|
it to another container or detach it ("None"). */}
|
||||||
|
{node.parentId && (
|
||||||
|
<div className="px-4 py-3 border-t border-border">
|
||||||
|
<label htmlFor="node-container" className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">
|
||||||
|
Container
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="node-container"
|
||||||
|
aria-label="Container"
|
||||||
|
value={node.parentId}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
>
|
||||||
|
<option value="">None (detached)</option>
|
||||||
|
{nodes
|
||||||
|
.filter((n) => n.id !== node.id && (n.data.container_mode === true || n.id === node.parentId))
|
||||||
|
.map((n) => (
|
||||||
|
<option key={n.id} value={n.id}>{n.data.label ?? n.id}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Properties section */}
|
{/* Properties section */}
|
||||||
<div className="px-4 py-3 border-t border-border">
|
<div className="px-4 py-3 border-t border-border">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const mockStore = {
|
|||||||
createGroup: vi.fn(),
|
createGroup: vi.fn(),
|
||||||
ungroup: vi.fn(),
|
ungroup: vi.fn(),
|
||||||
removeFromGroup: vi.fn(),
|
removeFromGroup: vi.fn(),
|
||||||
|
setNodeParent: vi.fn(),
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupStore(overrides = {}) {
|
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', () => {
|
describe('GroupDetailPanel', () => {
|
||||||
beforeEach(() => vi.clearAllMocks())
|
beforeEach(() => vi.clearAllMocks())
|
||||||
|
|
||||||
|
|||||||
@@ -651,6 +651,89 @@ describe('canvasStore', () => {
|
|||||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
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 ─────────────────────────────────────────────────────────
|
// ── removeFromGroup ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('removeFromGroup releases the child to absolute coords and keeps the group', () => {
|
it('removeFromGroup releases the child to absolute coords and keeps the group', () => {
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ interface CanvasState {
|
|||||||
ungroup: (groupId: string) => void
|
ungroup: (groupId: string) => void
|
||||||
addToGroup: (groupId: string, childId: string) => void
|
addToGroup: (groupId: string, childId: string) => void
|
||||||
addToContainer: (containerId: string, childId: string) => void
|
addToContainer: (containerId: string, childId: string) => void
|
||||||
|
setNodeParent: (childId: string, parentId: string | null) => void
|
||||||
removeFromGroup: (groupId: string, childId: string) => void
|
removeFromGroup: (groupId: string, childId: string) => void
|
||||||
markSaved: () => void
|
markSaved: () => void
|
||||||
markUnsaved: () => void
|
markUnsaved: () => void
|
||||||
@@ -672,6 +673,72 @@ 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.
|
// Release a single child from a group back to the canvas. Group stays.
|
||||||
removeFromGroup: (groupId, childId) =>
|
removeFromGroup: (groupId, childId) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user