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:
Pouzor
2026-06-11 15:39:49 +02:00
parent 2058e453ff
commit 10b981ad1d
4 changed files with 220 additions and 1 deletions
+25 -1
View File
@@ -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 } = useCanvasStore()
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup, setNodeParent } = useCanvasStore()
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
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()} />}
</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 */}
<div className="px-4 py-3 border-t border-border">
<div className="flex items-center justify-between mb-2">
@@ -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())