diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f80851f..073b7b9 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,25 @@ export default function App() { onSubmit={handleUpdateNode} initial={editNode?.data} title="Edit Node" + 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} /> = { 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) @@ -86,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() @@ -107,7 +125,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' set('parent_id', v === 'none' ? undefined : v)} + > + + + {form.parent_id + ? (validParents.find((n) => n.id === form.parent_id)?.label ?? 'None') + : 'None'} + + + + None + {validParents.map((n) => ( + {n.label} + ))} + + + + ) + })()} + {/* 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/__tests__/virtualEdgeParent.test.ts b/frontend/src/utils/__tests__/virtualEdgeParent.test.ts index 6308866..dae4707 100644 --- a/frontend/src/utils/__tests__/virtualEdgeParent.test.ts +++ b/frontend/src/utils/__tests__/virtualEdgeParent.test.ts @@ -1,5 +1,26 @@ import { describe, it, expect } from 'vitest' -import { resolveVirtualEdgeParent } from '../virtualEdgeParent' +import { resolveVirtualEdgeParent, getValidParentTypes } from '../virtualEdgeParent' + +describe('getValidParentTypes', () => { + it('returns container-mode types for lxc', () => { + expect(getValidParentTypes('lxc')).toEqual(['proxmox', 'vm', 'lxc', 'docker_host']) + }) + + it('returns container-mode types for vm', () => { + expect(getValidParentTypes('vm')).toEqual(['proxmox', 'vm', 'lxc', 'docker_host']) + }) + + it('returns docker_host/lxc/vm/proxmox for docker_container', () => { + expect(getValidParentTypes('docker_container')).toEqual(['docker_host', 'lxc', 'vm', 'proxmox']) + }) + + it('returns empty list for types that cannot have a parent', () => { + expect(getValidParentTypes('server')).toEqual([]) + expect(getValidParentTypes('router')).toEqual([]) + expect(getValidParentTypes('proxmox')).toEqual([]) + expect(getValidParentTypes('docker_host')).toEqual([]) + }) +}) describe('resolveVirtualEdgeParent', () => { it('nests lxc under proxmox (container-mode parent)', () => { 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,