Merge pull request #154 from Pouzor/feat/docker-container-lxc-parent
feat(node-modal): sanitize parent_id on type change and exclude descendants
This commit is contained in:
@@ -550,6 +550,7 @@ export default function App() {
|
|||||||
onClose={() => setAddNodeOpen(false)}
|
onClose={() => setAddNodeOpen(false)}
|
||||||
onSubmit={handleAddNode}
|
onSubmit={handleAddNode}
|
||||||
title="Add Node"
|
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 */}
|
{/* key forces re-mount when editing a different node, resetting form state */}
|
||||||
@@ -560,6 +561,25 @@ export default function App() {
|
|||||||
onSubmit={handleUpdateNode}
|
onSubmit={handleUpdateNode}
|
||||||
initial={editNode?.data}
|
initial={editNode?.data}
|
||||||
title="Edit Node"
|
title="Edit Node"
|
||||||
|
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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EdgeModal
|
<EdgeModal
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { resolveNodeColors } from '@/utils/nodeColors'
|
|||||||
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
|
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
|
||||||
import { BrandIconPicker } from './BrandIconPicker'
|
import { BrandIconPicker } from './BrandIconPicker'
|
||||||
import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils'
|
import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils'
|
||||||
|
import { getValidParentTypes } from '@/utils/virtualEdgeParent'
|
||||||
|
|
||||||
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||||
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
||||||
@@ -48,17 +49,25 @@ const DEFAULT_DATA: Partial<NodeData> = {
|
|||||||
custom_icon: undefined,
|
custom_icon: undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ParentCandidate {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
type: NodeType
|
||||||
|
}
|
||||||
|
|
||||||
interface NodeModalProps {
|
interface NodeModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSubmit: (data: Partial<NodeData>) => void
|
onSubmit: (data: Partial<NodeData>) => void
|
||||||
initial?: Partial<NodeData>
|
initial?: Partial<NodeData>
|
||||||
title?: string
|
title?: string
|
||||||
|
parentCandidates?: ParentCandidate[]
|
||||||
|
currentNodeId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NodeModal is always mounted with a key that changes on open/edit, so useState
|
// NodeModal is always mounted with a key that changes on open/edit, so useState
|
||||||
// initial value is enough - no need for a reset effect.
|
// 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 }
|
const merged = { ...DEFAULT_DATA, ...initial }
|
||||||
if (ZIGBEE_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
|
if (ZIGBEE_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
|
||||||
const [form, setForm] = useState<Partial<NodeData>>(merged)
|
const [form, setForm] = useState<Partial<NodeData>>(merged)
|
||||||
@@ -86,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()
|
||||||
@@ -107,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>
|
||||||
@@ -320,6 +343,40 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<div className="flex flex-col gap-1.5 col-span-2">
|
||||||
|
<Label className="text-xs text-muted-foreground">Parent Container</Label>
|
||||||
|
<Select
|
||||||
|
value={form.parent_id ?? 'none'}
|
||||||
|
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Parent container selector">
|
||||||
|
<SelectValue>
|
||||||
|
{form.parent_id
|
||||||
|
? (validParents.find((n) => n.id === form.parent_id)?.label ?? 'None')
|
||||||
|
: 'None'}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
|
<SelectItem value="none" className="text-sm">None</SelectItem>
|
||||||
|
{validParents.map((n) => (
|
||||||
|
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Container mode */}
|
{/* Container mode */}
|
||||||
{CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
|
{CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
|
||||||
<div className="flex items-center justify-between col-span-2 py-1">
|
<div className="flex items-center justify-between col-span-2 py-1">
|
||||||
|
|||||||
@@ -311,18 +311,48 @@ describe('NodeModal', () => {
|
|||||||
expect(screen.queryByText('Reset to defaults')).toBeNull()
|
expect(screen.queryByText('Reset to defaults')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Parent Container selector removed ────────────────────────────────
|
// ── Parent Container selector ─────────────────────────────────────────
|
||||||
|
|
||||||
it('does not render the Parent Container selector', () => {
|
it('does not render Parent Container for non-child types', () => {
|
||||||
renderModal({ initial: BASE })
|
renderModal({
|
||||||
|
initial: BASE,
|
||||||
|
parentCandidates: [{ id: 'p1', label: 'Proxmox', type: 'proxmox' }],
|
||||||
|
})
|
||||||
expect(screen.queryByText('Parent Container')).toBeNull()
|
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not render Parent Container for docker_container either', () => {
|
it('does not render Parent Container when no valid candidates exist', () => {
|
||||||
renderModal({ initial: { ...BASE, type: 'docker_container' } })
|
renderModal({
|
||||||
|
initial: { ...BASE, type: 'docker_container' },
|
||||||
|
parentCandidates: [],
|
||||||
|
})
|
||||||
expect(screen.queryByText('Parent Container')).toBeNull()
|
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 ────────────────────────────────────────────────────────
|
// ── Appearance ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('renders 3 color swatch labels (border, background, icon)', () => {
|
it('renders 3 color swatch labels (border, background, icon)', () => {
|
||||||
|
|||||||
@@ -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)', () => {
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ export interface ParentAssignment {
|
|||||||
parentId: string
|
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(
|
export function resolveVirtualEdgeParent(
|
||||||
source: VirtualEdgeEndpoint,
|
source: VirtualEdgeEndpoint,
|
||||||
target: VirtualEdgeEndpoint,
|
target: VirtualEdgeEndpoint,
|
||||||
|
|||||||
Reference in New Issue
Block a user