feat: edit container parent in the node modal instead of detail panel
Replaces the detail-panel container selector with the existing "Parent
Container" dropdown in the edit modal (double-click). The selector now
shows for any node that is nested in — or can nest into — a container_mode
node, not just lxc/vm/docker_container, so a dragged-in node can be
re-targeted or detached ("None") from the modal.
- NodeModal: container_mode candidates count as valid parents; selector
shown for nested nodes; submit/type-change keep valid container parents
- App: pass container_mode in parentCandidates
- revert detail-panel selector + setNodeParent store action (updateNode
already handles parent attach/detach)
ha-relevant: yes
This commit is contained in:
@@ -55,6 +55,8 @@ interface ParentCandidate {
|
||||
id: string
|
||||
label: string
|
||||
type: NodeType
|
||||
/** True when the node has container mode on, so any node can nest inside it. */
|
||||
container_mode?: boolean
|
||||
}
|
||||
|
||||
interface NodeModalProps {
|
||||
@@ -98,12 +100,14 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
const selectedType = (form.type ?? 'generic') as NodeType
|
||||
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
|
||||
const validParentTypes = getValidParentTypes(selectedType)
|
||||
// A parent is valid either by the type rules (lxc/vm/docker_container) or
|
||||
// because the candidate is a container-mode node (any child can nest in it).
|
||||
const isValidParent = (p: ParentCandidate) =>
|
||||
validParentTypes.includes(p.type) || p.container_mode === true
|
||||
let safeParentId = form.parent_id
|
||||
if (validParentTypes.length === 0) {
|
||||
safeParentId = undefined
|
||||
} else if (safeParentId) {
|
||||
if (safeParentId) {
|
||||
const parent = parentCandidates.find((n) => n.id === safeParentId)
|
||||
if (!parent || !validParentTypes.includes(parent.type)) safeParentId = undefined
|
||||
if (!parent || !isValidParent(parent)) safeParentId = undefined
|
||||
}
|
||||
onSubmit({
|
||||
...form,
|
||||
@@ -130,7 +134,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
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
|
||||
// Drop the parent only if it's no longer a valid target for the
|
||||
// new type — keep container-mode parents (any node can nest).
|
||||
const parent = parentCandidates.find((n) => n.id === f.parent_id)
|
||||
if (f.parent_id && !(parent && (getValidParentTypes(t).includes(parent.type) || parent.container_mode === true))) {
|
||||
next.parent_id = undefined
|
||||
}
|
||||
return next
|
||||
})
|
||||
}}>
|
||||
@@ -349,9 +358,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
{(() => {
|
||||
const childType = (form.type ?? 'generic') as NodeType
|
||||
const validParentTypes = getValidParentTypes(childType)
|
||||
if (validParentTypes.length === 0) return null
|
||||
// Candidates: type-based parents (lxc/vm/docker_container) plus any
|
||||
// container-mode node. The current parent is always kept so an
|
||||
// already-nested node can be re-targeted or detached here.
|
||||
const validParents = parentCandidates.filter(
|
||||
(n) => n.id !== currentNodeId && validParentTypes.includes(n.type),
|
||||
(n) => n.id !== currentNodeId &&
|
||||
(validParentTypes.includes(n.type) || n.container_mode === true || n.id === form.parent_id),
|
||||
)
|
||||
if (validParents.length === 0) return null
|
||||
return (
|
||||
|
||||
@@ -353,6 +353,48 @@ describe('NodeModal', () => {
|
||||
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders Parent Container for a plain node when a container-mode candidate exists', () => {
|
||||
renderModal({
|
||||
initial: { ...BASE, type: 'server' },
|
||||
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: true }],
|
||||
})
|
||||
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||
})
|
||||
|
||||
it('still hides Parent Container for a plain node when the candidate is not in container mode', () => {
|
||||
renderModal({
|
||||
initial: { ...BASE, type: 'server' },
|
||||
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: false }],
|
||||
})
|
||||
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders Parent Container for an already-nested plain node so it can be detached', () => {
|
||||
renderModal({
|
||||
initial: { ...BASE, type: 'server', parent_id: 'px1' },
|
||||
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: true }],
|
||||
})
|
||||
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps a container-mode parent_id on submit for a plain node', () => {
|
||||
const { onSubmit } = renderModal({
|
||||
initial: { ...BASE, type: 'server', parent_id: 'px1' },
|
||||
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: true }],
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).parent_id).toBe('px1')
|
||||
})
|
||||
|
||||
it('drops a parent_id that is not a valid container on submit', () => {
|
||||
const { onSubmit } = renderModal({
|
||||
initial: { ...BASE, type: 'server', parent_id: 'px1' },
|
||||
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: false }],
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).parent_id).toBeUndefined()
|
||||
})
|
||||
|
||||
// ── Appearance ────────────────────────────────────────────────────────
|
||||
|
||||
it('renders 3 color swatch labels (border, background, icon)', () => {
|
||||
|
||||
@@ -21,7 +21,7 @@ type PropForm = { key: string; value: string; icon: string | null; visible: bool
|
||||
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
|
||||
|
||||
export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup, setNodeParent } = useCanvasStore()
|
||||
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup } = useCanvasStore()
|
||||
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
|
||||
|
||||
const [addingForNode, setAddingForNode] = useState<string | null>(null)
|
||||
@@ -255,30 +255,6 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
{data.last_seen && <DetailRow label="Last Seen" value={new Date(/[Zz]|[+-]\d{2}:?\d{2}$/.test(data.last_seen) ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />}
|
||||
</div>
|
||||
|
||||
{/* Container membership — only when the node is nested. Lets the user move
|
||||
it to another container or detach it ("None"). */}
|
||||
{node.parentId && (
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<label htmlFor="node-container" className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">
|
||||
Container
|
||||
</label>
|
||||
<select
|
||||
id="node-container"
|
||||
aria-label="Container"
|
||||
value={node.parentId}
|
||||
onChange={(e) => setNodeParent(node.id, e.target.value || null)}
|
||||
className="mt-1.5 w-full bg-[#21262d] border border-[#30363d] rounded-md text-xs h-7 px-1.5 text-foreground focus:outline-none focus:border-[#00d4ff]/50"
|
||||
>
|
||||
<option value="">None (detached)</option>
|
||||
{nodes
|
||||
.filter((n) => n.id !== node.id && (n.data.container_mode === true || n.id === node.parentId))
|
||||
.map((n) => (
|
||||
<option key={n.id} value={n.id}>{n.data.label ?? n.id}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Properties section */}
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
|
||||
@@ -43,7 +43,6 @@ const mockStore = {
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
removeFromGroup: vi.fn(),
|
||||
setNodeParent: vi.fn(),
|
||||
}
|
||||
|
||||
function setupStore(overrides = {}) {
|
||||
@@ -139,50 +138,6 @@ describe('MultiSelectPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('DetailPanel container selector', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
function makeContainer(id: string, label: string) {
|
||||
return { id, type: 'proxmox', position: { x: 0, y: 0 }, data: { label, type: 'proxmox', status: 'online', services: [], container_mode: true } }
|
||||
}
|
||||
|
||||
it('does not show the container selector for a top-level node', () => {
|
||||
const n1 = makeNode('n1')
|
||||
setupStore({ nodes: [n1], selectedNodeId: 'n1', selectedNodeIds: ['n1'] })
|
||||
renderPanel()
|
||||
expect(screen.queryByLabelText('Container')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the container selector for a nested node, defaulting to its parent', () => {
|
||||
const px = makeContainer('px1', 'Proxmox')
|
||||
const child = makeNode('n1', { parentId: 'px1' })
|
||||
setupStore({ nodes: [px, child], selectedNodeId: 'n1', selectedNodeIds: ['n1'] })
|
||||
renderPanel()
|
||||
expect((screen.getByLabelText('Container') as HTMLSelectElement).value).toBe('px1')
|
||||
})
|
||||
|
||||
it('detaches the node when "None" is selected', () => {
|
||||
const setNodeParent = vi.fn()
|
||||
const px = makeContainer('px1', 'Proxmox')
|
||||
const child = makeNode('n1', { parentId: 'px1' })
|
||||
setupStore({ nodes: [px, child], selectedNodeId: 'n1', selectedNodeIds: ['n1'], setNodeParent })
|
||||
renderPanel()
|
||||
fireEvent.change(screen.getByLabelText('Container'), { target: { value: '' } })
|
||||
expect(setNodeParent).toHaveBeenCalledWith('n1', null)
|
||||
})
|
||||
|
||||
it('re-parents the node when another container is selected', () => {
|
||||
const setNodeParent = vi.fn()
|
||||
const px1 = makeContainer('px1', 'Proxmox A')
|
||||
const px2 = makeContainer('px2', 'Proxmox B')
|
||||
const child = makeNode('n1', { parentId: 'px1' })
|
||||
setupStore({ nodes: [px1, px2, child], selectedNodeId: 'n1', selectedNodeIds: ['n1'], setNodeParent })
|
||||
renderPanel()
|
||||
fireEvent.change(screen.getByLabelText('Container'), { target: { value: 'px2' } })
|
||||
expect(setNodeParent).toHaveBeenCalledWith('n1', 'px2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GroupDetailPanel', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user