From 3a4df578ecdfde3b814c0a6c118e23185856003a Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sat, 16 May 2026 17:08:06 +0200 Subject: [PATCH 1/2] feat(node-modal): restore Parent Container selector for child types Adds back the Parent Container selector in NodeModal, shown when: - child type is lxc or vm (parents: proxmox/vm/lxc/docker_host) - child type is docker_container (parents: docker_host/lxc/vm/proxmox) Selector hidden when no valid candidate node exists on the canvas. Centralizes the parent-type allowlist in getValidParentTypes (shared with the virtual-edge auto-parent helper). ha-relevant: yes --- frontend/src/App.tsx | 3 ++ frontend/src/components/modals/NodeModal.tsx | 45 ++++++++++++++++++- .../modals/__tests__/NodeModal.test.tsx | 40 ++++++++++++++--- frontend/src/utils/virtualEdgeParent.ts | 10 +++++ 4 files changed, 92 insertions(+), 6 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f80851f..eaee103 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -550,6 +550,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 }))} /> {/* key forces re-mount when editing a different node, resetting form state */} @@ -560,6 +561,8 @@ export default function App() { onSubmit={handleUpdateNode} initial={editNode?.data} title="Edit Node" + parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))} + currentNodeId={editNodeId ?? undefined} /> = { custom_icon: undefined, } +interface ParentCandidate { + id: string + label: string + type: NodeType +} + interface NodeModalProps { open: boolean onClose: () => void onSubmit: (data: Partial) => void initial?: Partial title?: string + parentCandidates?: ParentCandidate[] + currentNodeId?: string } // NodeModal is always mounted with a key that changes on open/edit, so useState // initial value is enough - no need for a reset effect. -export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' }: NodeModalProps) { +export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentCandidates = [], currentNodeId }: NodeModalProps) { const merged = { ...DEFAULT_DATA, ...initial } if (ZIGBEE_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none' const [form, setForm] = useState>(merged) @@ -320,6 +329,40 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' )} + {/* Parent Container */} + {(() => { + const childType = (form.type ?? 'generic') as NodeType + const validParentTypes = getValidParentTypes(childType) + if (validParentTypes.length === 0) return null + const validParents = parentCandidates.filter( + (n) => n.id !== currentNodeId && validParentTypes.includes(n.type), + ) + if (validParents.length === 0) return null + return ( +
+ + +
+ ) + })()} + {/* Container mode */} {CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
diff --git a/frontend/src/components/modals/__tests__/NodeModal.test.tsx b/frontend/src/components/modals/__tests__/NodeModal.test.tsx index ca74316..7319526 100644 --- a/frontend/src/components/modals/__tests__/NodeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/NodeModal.test.tsx @@ -311,18 +311,48 @@ describe('NodeModal', () => { expect(screen.queryByText('Reset to defaults')).toBeNull() }) - // ── Parent Container selector removed ──────────────────────────────── + // ── Parent Container selector ───────────────────────────────────────── - it('does not render the Parent Container selector', () => { - renderModal({ initial: BASE }) + it('does not render Parent Container for non-child types', () => { + renderModal({ + initial: BASE, + parentCandidates: [{ id: 'p1', label: 'Proxmox', type: 'proxmox' }], + }) expect(screen.queryByText('Parent Container')).toBeNull() }) - it('does not render Parent Container for docker_container either', () => { - renderModal({ initial: { ...BASE, type: 'docker_container' } }) + it('does not render Parent Container when no valid candidates exist', () => { + renderModal({ + initial: { ...BASE, type: 'docker_container' }, + parentCandidates: [], + }) expect(screen.queryByText('Parent Container')).toBeNull() }) + it('renders Parent Container for docker_container when docker_host candidate exists', () => { + renderModal({ + initial: { ...BASE, type: 'docker_container' }, + parentCandidates: [{ id: 'dh1', label: 'Docker Host', type: 'docker_host' }], + }) + expect(screen.getByText('Parent Container')).toBeDefined() + }) + + it('renders Parent Container for docker_container when only an LXC candidate exists', () => { + renderModal({ + initial: { ...BASE, type: 'docker_container' }, + parentCandidates: [{ id: 'lxc1', label: 'My LXC', type: 'lxc' }], + }) + expect(screen.getByText('Parent Container')).toBeDefined() + }) + + it('renders Parent Container for lxc when proxmox candidate exists', () => { + renderModal({ + initial: { ...BASE, type: 'lxc' }, + parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox' }], + }) + expect(screen.getByText('Parent Container')).toBeDefined() + }) + // ── Appearance ──────────────────────────────────────────────────────── it('renders 3 color swatch labels (border, background, icon)', () => { diff --git a/frontend/src/utils/virtualEdgeParent.ts b/frontend/src/utils/virtualEdgeParent.ts index b239822..9cef306 100644 --- a/frontend/src/utils/virtualEdgeParent.ts +++ b/frontend/src/utils/virtualEdgeParent.ts @@ -15,6 +15,16 @@ export interface ParentAssignment { parentId: string } +export function getValidParentTypes(childType: NodeType): NodeType[] { + if (childType === 'lxc' || childType === 'vm') { + return ['proxmox', 'vm', 'lxc', 'docker_host'] + } + if (childType === 'docker_container') { + return ['docker_host', 'lxc', 'vm', 'proxmox'] + } + return [] +} + export function resolveVirtualEdgeParent( source: VirtualEdgeEndpoint, target: VirtualEdgeEndpoint, From 8b97f578c6eacb95be642a3f7a0d176cc41b403f Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sat, 16 May 2026 17:41:58 +0200 Subject: [PATCH 2/2] feat(node-modal): sanitize parent_id on type change and exclude descendants - App.tsx: filter descendants of edited node from parentCandidates to prevent picking a child as parent (would create a cycle). - NodeModal.tsx: clear parent_id when switching to a type with no valid parents, and revalidate parent_id on submit against the type's allowlist. - Tests: cover getValidParentTypes for lxc/vm/docker_container and types that disallow parents. ha-relevant: yes --- frontend/src/App.tsx | 19 ++++++++++++++- frontend/src/components/modals/NodeModal.tsx | 16 ++++++++++++- .../utils/__tests__/virtualEdgeParent.test.ts | 23 ++++++++++++++++++- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index eaee103..073b7b9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -561,7 +561,24 @@ export default function App() { onSubmit={handleUpdateNode} initial={editNode?.data} title="Edit Node" - parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))} + parentCandidates={(() => { + const descendants = new Set() + if (editNodeId) { + const queue = [editNodeId] + while (queue.length) { + const id = queue.shift()! + for (const n of nodes) { + if (n.data.parent_id === id && !descendants.has(n.id)) { + descendants.add(n.id) + queue.push(n.id) + } + } + } + } + return nodes + .filter((n) => !descendants.has(n.id)) + .map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type })) + })()} currentNodeId={editNodeId ?? undefined} /> diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index 1b91cf3..d5b851f 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -95,8 +95,17 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' setLabelError(false) const selectedType = (form.type ?? 'generic') as NodeType const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType) + const validParentTypes = getValidParentTypes(selectedType) + let safeParentId = form.parent_id + if (validParentTypes.length === 0) { + safeParentId = undefined + } else if (safeParentId) { + const parent = parentCandidates.find((n) => n.id === safeParentId) + if (!parent || !validParentTypes.includes(parent.type)) safeParentId = undefined + } onSubmit({ ...form, + parent_id: safeParentId, container_mode: canUseContainerMode ? !!form.container_mode : false, }) onClose() @@ -116,7 +125,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'