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
This commit is contained in:
+18
-1
@@ -561,7 +561,24 @@ export default function App() {
|
|||||||
onSubmit={handleUpdateNode}
|
onSubmit={handleUpdateNode}
|
||||||
initial={editNode?.data}
|
initial={editNode?.data}
|
||||||
title="Edit Node"
|
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<string>()
|
||||||
|
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}
|
currentNodeId={editNodeId ?? undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -95,8 +95,17 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
setLabelError(false)
|
setLabelError(false)
|
||||||
const selectedType = (form.type ?? 'generic') as NodeType
|
const selectedType = (form.type ?? 'generic') as NodeType
|
||||||
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
|
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({
|
onSubmit({
|
||||||
...form,
|
...form,
|
||||||
|
parent_id: safeParentId,
|
||||||
container_mode: canUseContainerMode ? !!form.container_mode : false,
|
container_mode: canUseContainerMode ? !!form.container_mode : false,
|
||||||
})
|
})
|
||||||
onClose()
|
onClose()
|
||||||
@@ -116,7 +125,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<Label className="text-xs text-muted-foreground">Type</Label>
|
<Label className="text-xs text-muted-foreground">Type</Label>
|
||||||
<Select value={form.type} onValueChange={(v) => {
|
<Select value={form.type} onValueChange={(v) => {
|
||||||
const t = v as NodeType
|
const t = v as NodeType
|
||||||
setForm((f) => ({ ...f, type: t, ...(ZIGBEE_TYPES.includes(t) ? { check_method: 'none' as CheckMethod } : {}) }))
|
setForm((f) => {
|
||||||
|
const next: Partial<NodeData> = { ...f, type: t }
|
||||||
|
if (ZIGBEE_TYPES.includes(t)) next.check_method = 'none' as CheckMethod
|
||||||
|
if (getValidParentTypes(t).length === 0) next.parent_id = undefined
|
||||||
|
return next
|
||||||
|
})
|
||||||
}}>
|
}}>
|
||||||
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 w-full cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Node type selector">
|
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 w-full cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Node type selector">
|
||||||
<SelectValue>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</SelectValue>
|
<SelectValue>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</SelectValue>
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
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', () => {
|
describe('resolveVirtualEdgeParent', () => {
|
||||||
it('nests lxc under proxmox (container-mode parent)', () => {
|
it('nests lxc under proxmox (container-mode parent)', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user