Merge pull request #202 from Pouzor/feat/node-into-container
feat: drop node onto container node to nest it
This commit is contained in:
@@ -15,7 +15,7 @@ pyyaml==6.0.2
|
|||||||
types-PyYAML==6.0.12.20240917
|
types-PyYAML==6.0.12.20240917
|
||||||
websockets==13.1
|
websockets==13.1
|
||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
zeroconf==0.149.7
|
zeroconf==0.149.12
|
||||||
aiomqtt==2.3.0
|
aiomqtt==2.3.0
|
||||||
|
|
||||||
# Dev
|
# Dev
|
||||||
|
|||||||
+18
-4
@@ -43,7 +43,7 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
|||||||
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup } = useCanvasStore()
|
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer } = useCanvasStore()
|
||||||
const canvasRef = useRef<HTMLDivElement>(null)
|
const canvasRef = useRef<HTMLDivElement>(null)
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
||||||
@@ -70,6 +70,7 @@ export default function App() {
|
|||||||
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
||||||
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
||||||
const [pendingGroupAdd, setPendingGroupAdd] = useState<{ nodeId: string; groupId: string } | null>(null)
|
const [pendingGroupAdd, setPendingGroupAdd] = useState<{ nodeId: string; groupId: string } | null>(null)
|
||||||
|
const [pendingContainerAdd, setPendingContainerAdd] = useState<{ nodeId: string; containerId: string } | null>(null)
|
||||||
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
||||||
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
@@ -634,6 +635,7 @@ export default function App() {
|
|||||||
onNodeDoubleClick={handleNodeDoubleClick}
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
onNodeDragStart={snapshotHistory}
|
onNodeDragStart={snapshotHistory}
|
||||||
onRequestAddToGroup={setPendingGroupAdd}
|
onRequestAddToGroup={setPendingGroupAdd}
|
||||||
|
onRequestAddToContainer={setPendingContainerAdd}
|
||||||
onOpenPending={(deviceId) => openPendingModal(deviceId)}
|
onOpenPending={(deviceId) => openPendingModal(deviceId)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -648,7 +650,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 }))}
|
parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* key forces re-mount when editing a different node, resetting form state */}
|
{/* key forces re-mount when editing a different node, resetting form state */}
|
||||||
@@ -675,7 +677,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
return nodes
|
return nodes
|
||||||
.filter((n) => !descendants.has(n.id))
|
.filter((n) => !descendants.has(n.id))
|
||||||
.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))
|
.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))
|
||||||
})()}
|
})()}
|
||||||
currentNodeId={editNodeId ?? undefined}
|
currentNodeId={editNodeId ?? undefined}
|
||||||
/>
|
/>
|
||||||
@@ -810,7 +812,7 @@ export default function App() {
|
|||||||
<ConfirmAddToGroupModal
|
<ConfirmAddToGroupModal
|
||||||
open={!!pendingGroupAdd}
|
open={!!pendingGroupAdd}
|
||||||
nodeLabel={pendingGroupAdd ? (nodes.find((n) => n.id === pendingGroupAdd.nodeId)?.data.label ?? '') : ''}
|
nodeLabel={pendingGroupAdd ? (nodes.find((n) => n.id === pendingGroupAdd.nodeId)?.data.label ?? '') : ''}
|
||||||
groupLabel={pendingGroupAdd ? (nodes.find((n) => n.id === pendingGroupAdd.groupId)?.data.label ?? '') : ''}
|
targetLabel={pendingGroupAdd ? (nodes.find((n) => n.id === pendingGroupAdd.groupId)?.data.label ?? '') : ''}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
if (pendingGroupAdd) addToGroup(pendingGroupAdd.groupId, pendingGroupAdd.nodeId)
|
if (pendingGroupAdd) addToGroup(pendingGroupAdd.groupId, pendingGroupAdd.nodeId)
|
||||||
setPendingGroupAdd(null)
|
setPendingGroupAdd(null)
|
||||||
@@ -818,6 +820,18 @@ export default function App() {
|
|||||||
onCancel={() => setPendingGroupAdd(null)}
|
onCancel={() => setPendingGroupAdd(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmAddToGroupModal
|
||||||
|
open={!!pendingContainerAdd}
|
||||||
|
variant="container"
|
||||||
|
nodeLabel={pendingContainerAdd ? (nodes.find((n) => n.id === pendingContainerAdd.nodeId)?.data.label ?? '') : ''}
|
||||||
|
targetLabel={pendingContainerAdd ? (nodes.find((n) => n.id === pendingContainerAdd.containerId)?.data.label ?? '') : ''}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (pendingContainerAdd) addToContainer(pendingContainerAdd.containerId, pendingContainerAdd.nodeId)
|
||||||
|
setPendingContainerAdd(null)
|
||||||
|
}}
|
||||||
|
onCancel={() => setPendingContainerAdd(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
{!STANDALONE && (
|
{!STANDALONE && (
|
||||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -31,10 +31,11 @@ interface CanvasContainerProps {
|
|||||||
onNodeDoubleClick?: (node: Node<NodeData>) => void
|
onNodeDoubleClick?: (node: Node<NodeData>) => void
|
||||||
onNodeDragStart?: () => void
|
onNodeDragStart?: () => void
|
||||||
onRequestAddToGroup?: (payload: { nodeId: string; groupId: string }) => void
|
onRequestAddToGroup?: (payload: { nodeId: string; groupId: string }) => void
|
||||||
|
onRequestAddToContainer?: (payload: { nodeId: string; containerId: string }) => void
|
||||||
onOpenPending?: (deviceId: string) => void
|
onOpenPending?: (deviceId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onRequestAddToGroup, onOpenPending }: CanvasContainerProps) {
|
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onRequestAddToGroup, onRequestAddToContainer, onOpenPending }: CanvasContainerProps) {
|
||||||
const [lassoMode, setLassoMode] = useState(true)
|
const [lassoMode, setLassoMode] = useState(true)
|
||||||
const {
|
const {
|
||||||
nodes, edges,
|
nodes, edges,
|
||||||
@@ -129,13 +130,20 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
// Drop a top-level node onto a group → ask App to confirm adding it. Runs
|
// Drop a top-level node onto a group → ask App to confirm adding it. Runs
|
||||||
// before the alignment snap so detection uses the dropped position.
|
// before the alignment snap so detection uses the dropped position.
|
||||||
const handleNodeDragStop = useCallback<NonNullable<typeof onNodeDragStop>>((event, dragNode, dragNodes) => {
|
const handleNodeDragStop = useCallback<NonNullable<typeof onNodeDragStop>>((event, dragNode, dragNodes) => {
|
||||||
if (onRequestAddToGroup && dragNode && !dragNode.parentId &&
|
if (dragNode && !dragNode.parentId &&
|
||||||
dragNode.data.type !== 'group' && dragNode.data.type !== 'groupRect') {
|
dragNode.data.type !== 'group' && dragNode.data.type !== 'groupRect') {
|
||||||
const group = getIntersectingNodes(dragNode).find((n) => n.data.type === 'group')
|
const intersecting = getIntersectingNodes(dragNode)
|
||||||
if (group) onRequestAddToGroup({ nodeId: dragNode.id, groupId: group.id })
|
const group = intersecting.find((n) => n.data.type === 'group')
|
||||||
|
if (group) {
|
||||||
|
onRequestAddToGroup?.({ nodeId: dragNode.id, groupId: group.id })
|
||||||
|
} else {
|
||||||
|
// Any node in container_mode (proxmox, docker_host, …) accepts children.
|
||||||
|
const container = intersecting.find((n) => n.id !== dragNode.id && n.data.container_mode === true)
|
||||||
|
if (container) onRequestAddToContainer?.({ nodeId: dragNode.id, containerId: container.id })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onNodeDragStop(event, dragNode, dragNodes)
|
onNodeDragStop(event, dragNode, dragNodes)
|
||||||
}, [onRequestAddToGroup, getIntersectingNodes, onNodeDragStop])
|
}, [onRequestAddToGroup, onRequestAddToContainer, getIntersectingNodes, onNodeDragStop])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }} onMouseMove={onMouseMove}>
|
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }} onMouseMove={onMouseMove}>
|
||||||
|
|||||||
@@ -207,6 +207,50 @@ describe('CanvasContainer', () => {
|
|||||||
expect(onRequestAddToGroup).not.toHaveBeenCalled()
|
expect(onRequestAddToGroup).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Drag onto container node → onRequestAddToContainer ────────────────────
|
||||||
|
|
||||||
|
function containerNode(id: string, type: NodeData['type'] = 'proxmox'): Node<NodeData> {
|
||||||
|
return { id, type, position: { x: 0, y: 0 }, data: { label: id, type, status: 'unknown', services: [], container_mode: true } }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('fires onRequestAddToContainer when a node is dropped over a container_mode node', () => {
|
||||||
|
const onRequestAddToContainer = vi.fn()
|
||||||
|
const node = makeNode('n1')
|
||||||
|
rf.intersecting = [containerNode('px1')]
|
||||||
|
render(<CanvasContainer onRequestAddToContainer={onRequestAddToContainer} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToContainer).toHaveBeenCalledWith({ nodeId: 'n1', containerId: 'px1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers a group over a container when both intersect', () => {
|
||||||
|
const onRequestAddToGroup = vi.fn()
|
||||||
|
const onRequestAddToContainer = vi.fn()
|
||||||
|
const node = makeNode('n1')
|
||||||
|
rf.intersecting = [containerNode('px1'), groupNode('g1')]
|
||||||
|
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} onRequestAddToContainer={onRequestAddToContainer} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToGroup).toHaveBeenCalledWith({ nodeId: 'n1', groupId: 'g1' })
|
||||||
|
expect(onRequestAddToContainer).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fire onRequestAddToContainer for an already-parented node', () => {
|
||||||
|
const onRequestAddToContainer = vi.fn()
|
||||||
|
const node = { ...makeNode('n1'), parentId: 'pxOther' }
|
||||||
|
rf.intersecting = [containerNode('px1')]
|
||||||
|
render(<CanvasContainer onRequestAddToContainer={onRequestAddToContainer} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToContainer).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fire onRequestAddToContainer when the target node is not in container_mode', () => {
|
||||||
|
const onRequestAddToContainer = vi.fn()
|
||||||
|
const node = makeNode('n1')
|
||||||
|
rf.intersecting = [makeNode('n2')]
|
||||||
|
render(<CanvasContainer onRequestAddToContainer={onRequestAddToContainer} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToContainer).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
// ── Canvas settings ───────────────────────────────────────────────────────
|
// ── Canvas settings ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('enables snapToGrid', () => {
|
it('enables snapToGrid', () => {
|
||||||
|
|||||||
@@ -12,23 +12,35 @@ import { Button } from '@/components/ui/button'
|
|||||||
interface ConfirmAddToGroupModalProps {
|
interface ConfirmAddToGroupModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
nodeLabel: string
|
nodeLabel: string
|
||||||
groupLabel: string
|
/** Label of the destination group/container. */
|
||||||
|
targetLabel: string
|
||||||
|
/** Destination kind — drives the wording. Defaults to 'group'. */
|
||||||
|
variant?: 'group' | 'container'
|
||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConfirmAddToGroupModal({ open, nodeLabel, groupLabel, onConfirm, onCancel }: ConfirmAddToGroupModalProps) {
|
export function ConfirmAddToGroupModal({
|
||||||
|
open,
|
||||||
|
nodeLabel,
|
||||||
|
targetLabel,
|
||||||
|
variant = 'group',
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: ConfirmAddToGroupModalProps) {
|
||||||
|
const action = variant === 'container' ? 'Add to container' : 'Add to group'
|
||||||
|
const noun = variant === 'container' ? 'container' : 'group'
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onCancel() }}>
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onCancel() }}>
|
||||||
<DialogContent className="max-w-sm">
|
<DialogContent className="max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Layers size={16} className="text-[#00d4ff]" />
|
<Layers size={16} className="text-[#00d4ff]" />
|
||||||
Add to group
|
{action}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Add <span className="font-medium text-foreground">{nodeLabel}</span> to the group{' '}
|
Add <span className="font-medium text-foreground">{nodeLabel}</span> to the {noun}{' '}
|
||||||
<span className="font-medium text-foreground">{groupLabel}</span>?
|
<span className="font-medium text-foreground">{targetLabel}</span>?
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@@ -38,7 +50,7 @@ export function ConfirmAddToGroupModal({ open, nodeLabel, groupLabel, onConfirm,
|
|||||||
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
>
|
>
|
||||||
Add to group
|
{action}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ interface ParentCandidate {
|
|||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
type: NodeType
|
type: NodeType
|
||||||
|
/** True when the node has container mode on, so any node can nest inside it. */
|
||||||
|
container_mode?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NodeModalProps {
|
interface NodeModalProps {
|
||||||
@@ -98,12 +100,14 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
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)
|
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
|
let safeParentId = form.parent_id
|
||||||
if (validParentTypes.length === 0) {
|
if (safeParentId) {
|
||||||
safeParentId = undefined
|
|
||||||
} else if (safeParentId) {
|
|
||||||
const parent = parentCandidates.find((n) => n.id === safeParentId)
|
const parent = parentCandidates.find((n) => n.id === safeParentId)
|
||||||
if (!parent || !validParentTypes.includes(parent.type)) safeParentId = undefined
|
if (!parent || !isValidParent(parent)) safeParentId = undefined
|
||||||
}
|
}
|
||||||
onSubmit({
|
onSubmit({
|
||||||
...form,
|
...form,
|
||||||
@@ -130,7 +134,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
setForm((f) => {
|
setForm((f) => {
|
||||||
const next: Partial<NodeData> = { ...f, type: t }
|
const next: Partial<NodeData> = { ...f, type: t }
|
||||||
if (ZIGBEE_TYPES.includes(t)) next.check_method = 'none' as CheckMethod
|
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
|
return next
|
||||||
})
|
})
|
||||||
}}>
|
}}>
|
||||||
@@ -349,9 +358,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
{(() => {
|
{(() => {
|
||||||
const childType = (form.type ?? 'generic') as NodeType
|
const childType = (form.type ?? 'generic') as NodeType
|
||||||
const validParentTypes = getValidParentTypes(childType)
|
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(
|
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
|
if (validParents.length === 0) return null
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ import { ConfirmAddToGroupModal } from '../ConfirmAddToGroupModal'
|
|||||||
describe('ConfirmAddToGroupModal', () => {
|
describe('ConfirmAddToGroupModal', () => {
|
||||||
it('renders nothing when closed', () => {
|
it('renders nothing when closed', () => {
|
||||||
render(
|
render(
|
||||||
<ConfirmAddToGroupModal open={false} nodeLabel="Router" groupLabel="DMZ" onConfirm={vi.fn()} onCancel={vi.fn()} />,
|
<ConfirmAddToGroupModal open={false} nodeLabel="Router" targetLabel="DMZ" onConfirm={vi.fn()} onCancel={vi.fn()} />,
|
||||||
)
|
)
|
||||||
expect(screen.queryByText('Add to group')).toBeNull()
|
expect(screen.queryByText('Add to group')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows node and group labels when open', () => {
|
it('shows node and group labels when open', () => {
|
||||||
render(
|
render(
|
||||||
<ConfirmAddToGroupModal open nodeLabel="Router" groupLabel="DMZ" onConfirm={vi.fn()} onCancel={vi.fn()} />,
|
<ConfirmAddToGroupModal open nodeLabel="Router" targetLabel="DMZ" onConfirm={vi.fn()} onCancel={vi.fn()} />,
|
||||||
)
|
)
|
||||||
expect(screen.getByText('Router')).toBeDefined()
|
expect(screen.getByText('Router')).toBeDefined()
|
||||||
expect(screen.getByText('DMZ')).toBeDefined()
|
expect(screen.getByText('DMZ')).toBeDefined()
|
||||||
@@ -21,7 +21,7 @@ describe('ConfirmAddToGroupModal', () => {
|
|||||||
it('calls onConfirm when the confirm button is clicked', () => {
|
it('calls onConfirm when the confirm button is clicked', () => {
|
||||||
const onConfirm = vi.fn()
|
const onConfirm = vi.fn()
|
||||||
render(
|
render(
|
||||||
<ConfirmAddToGroupModal open nodeLabel="Router" groupLabel="DMZ" onConfirm={onConfirm} onCancel={vi.fn()} />,
|
<ConfirmAddToGroupModal open nodeLabel="Router" targetLabel="DMZ" onConfirm={onConfirm} onCancel={vi.fn()} />,
|
||||||
)
|
)
|
||||||
fireEvent.click(screen.getByRole('button', { name: /add to group/i }))
|
fireEvent.click(screen.getByRole('button', { name: /add to group/i }))
|
||||||
expect(onConfirm).toHaveBeenCalledOnce()
|
expect(onConfirm).toHaveBeenCalledOnce()
|
||||||
@@ -30,9 +30,17 @@ describe('ConfirmAddToGroupModal', () => {
|
|||||||
it('calls onCancel when the cancel button is clicked', () => {
|
it('calls onCancel when the cancel button is clicked', () => {
|
||||||
const onCancel = vi.fn()
|
const onCancel = vi.fn()
|
||||||
render(
|
render(
|
||||||
<ConfirmAddToGroupModal open nodeLabel="Router" groupLabel="DMZ" onConfirm={vi.fn()} onCancel={onCancel} />,
|
<ConfirmAddToGroupModal open nodeLabel="Router" targetLabel="DMZ" onConfirm={vi.fn()} onCancel={onCancel} />,
|
||||||
)
|
)
|
||||||
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
|
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
|
||||||
expect(onCancel).toHaveBeenCalledOnce()
|
expect(onCancel).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses container wording when variant is container', () => {
|
||||||
|
render(
|
||||||
|
<ConfirmAddToGroupModal open variant="container" nodeLabel="VM" targetLabel="Proxmox" onConfirm={vi.fn()} onCancel={vi.fn()} />,
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('button', { name: /add to container/i })).toBeDefined()
|
||||||
|
expect(screen.queryByText('Add to group')).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -353,6 +353,48 @@ describe('NodeModal', () => {
|
|||||||
expect(screen.getByText('Parent Container')).toBeDefined()
|
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 ────────────────────────────────────────────────────────
|
// ── Appearance ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('renders 3 color swatch labels (border, background, icon)', () => {
|
it('renders 3 color swatch labels (border, background, icon)', () => {
|
||||||
|
|||||||
@@ -578,6 +578,79 @@ describe('canvasStore', () => {
|
|||||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── addToContainer ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('addToContainer nests a top-level node under a container_mode node', () => {
|
||||||
|
const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true, label: 'PX' }), position: { x: 76, y: 52 }, width: 448, height: 252 }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 300, y: 200 } }
|
||||||
|
useCanvasStore.setState({ nodes: [container, child] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToContainer('px1', 'n1')
|
||||||
|
|
||||||
|
const moved = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
|
||||||
|
expect(moved?.parentId).toBe('px1')
|
||||||
|
expect(moved?.extent).toBe('parent')
|
||||||
|
expect(moved?.data.parent_id).toBe('px1')
|
||||||
|
// 300-76=224, 200-52=148
|
||||||
|
expect(moved?.position).toEqual({ x: 224, y: 148 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addToContainer works for any container_mode type (docker_host)', () => {
|
||||||
|
const host = { ...makeNode('dh1', { type: 'docker_host', container_mode: true }), position: { x: 0, y: 0 } }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
|
||||||
|
useCanvasStore.setState({ nodes: [host, child] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToContainer('dh1', 'n1')
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBe('dh1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addToContainer places the container before the child in the array', () => {
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 300, y: 200 } }
|
||||||
|
const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } }
|
||||||
|
useCanvasStore.setState({ nodes: [child, container] })
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToContainer('px1', 'n1')
|
||||||
|
|
||||||
|
const { nodes } = useCanvasStore.getState()
|
||||||
|
expect(nodes.findIndex((n) => n.id === 'px1')).toBeLessThan(nodes.findIndex((n) => n.id === 'n1'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addToContainer is a no-op when target is not in container_mode', () => {
|
||||||
|
const notContainer = { ...makeNode('px1', { type: 'proxmox', container_mode: false }), position: { x: 0, y: 0 } }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
|
||||||
|
useCanvasStore.setState({ nodes: [notContainer, child] })
|
||||||
|
useCanvasStore.getState().markSaved()
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToContainer('px1', 'n1')
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBeUndefined()
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addToContainer is a no-op when child already belongs to the container', () => {
|
||||||
|
const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'px1', extent: 'parent' as const }
|
||||||
|
useCanvasStore.setState({ nodes: [container, child] })
|
||||||
|
useCanvasStore.getState().markSaved()
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToContainer('px1', 'n1')
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('addToContainer snapshots history and marks unsaved', () => {
|
||||||
|
const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } }
|
||||||
|
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
|
||||||
|
useCanvasStore.setState({ nodes: [container, child] })
|
||||||
|
useCanvasStore.getState().markSaved()
|
||||||
|
|
||||||
|
useCanvasStore.getState().addToContainer('px1', 'n1')
|
||||||
|
|
||||||
|
expect(useCanvasStore.getState().past).toHaveLength(1)
|
||||||
|
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
// ── removeFromGroup ─────────────────────────────────────────────────────────
|
// ── removeFromGroup ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('removeFromGroup releases the child to absolute coords and keeps the group', () => {
|
it('removeFromGroup releases the child to absolute coords and keeps the group', () => {
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ interface CanvasState {
|
|||||||
createGroup: (nodeIds: string[], name: string) => void
|
createGroup: (nodeIds: string[], name: string) => void
|
||||||
ungroup: (groupId: string) => void
|
ungroup: (groupId: string) => void
|
||||||
addToGroup: (groupId: string, childId: string) => void
|
addToGroup: (groupId: string, childId: string) => void
|
||||||
|
addToContainer: (containerId: string, childId: string) => void
|
||||||
removeFromGroup: (groupId: string, childId: string) => void
|
removeFromGroup: (groupId: string, childId: string) => void
|
||||||
markSaved: () => void
|
markSaved: () => void
|
||||||
markUnsaved: () => void
|
markUnsaved: () => void
|
||||||
@@ -627,6 +628,50 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Nest an existing top-level node inside a container node (proxmox /
|
||||||
|
// docker_host / … in container_mode). Mirrors addToGroup but the target is
|
||||||
|
// any node with data.container_mode === true rather than a group.
|
||||||
|
addToContainer: (containerId, childId) =>
|
||||||
|
set((state) => {
|
||||||
|
const container = state.nodes.find((n) => n.id === containerId)
|
||||||
|
const child = state.nodes.find((n) => n.id === childId)
|
||||||
|
if (!container || !child || container.data.container_mode !== true) return state
|
||||||
|
if (child.id === containerId || child.parentId === containerId) return state
|
||||||
|
|
||||||
|
const updatedNodes = state.nodes.map((n) => {
|
||||||
|
if (n.id !== childId) return n
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
parentId: containerId,
|
||||||
|
extent: 'parent' as const,
|
||||||
|
// Absolute → container-relative. Clamp so the node stays inside.
|
||||||
|
position: {
|
||||||
|
x: Math.max(8, n.position.x - container.position.x),
|
||||||
|
y: Math.max(8, n.position.y - container.position.y),
|
||||||
|
},
|
||||||
|
selected: false,
|
||||||
|
data: { ...n.data, parent_id: containerId },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// React Flow requires the parent to precede its children in the array.
|
||||||
|
const others = updatedNodes.filter((n) => n.id !== childId)
|
||||||
|
const movedChild = updatedNodes.find((n) => n.id === childId)!
|
||||||
|
const containerIdx = others.findIndex((n) => n.id === containerId)
|
||||||
|
const nodes = [
|
||||||
|
...others.slice(0, containerIdx + 1),
|
||||||
|
movedChild,
|
||||||
|
...others.slice(containerIdx + 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||||
|
future: [],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
// Release a single child from a group back to the canvas. Group stays.
|
// Release a single child from a group back to the canvas. Group stays.
|
||||||
removeFromGroup: (groupId, childId) =>
|
removeFromGroup: (groupId, childId) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user