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,