feat: drop node onto container node to nest it
Dropping a top-level node over any container_mode node (proxmox, docker_host, ...) now pops a confirm modal that nests it as a child (sets parentId), mirroring the existing drop-onto-group flow. - store: addToContainer(containerId, childId) - CanvasContainer: detect container_mode intersection on drag stop (group still wins if both intersect) - generalize ConfirmAddToGroupModal with a container variant ha-relevant: yes
This commit is contained in:
@@ -31,10 +31,11 @@ interface CanvasContainerProps {
|
||||
onNodeDoubleClick?: (node: Node<NodeData>) => void
|
||||
onNodeDragStart?: () => void
|
||||
onRequestAddToGroup?: (payload: { nodeId: string; groupId: string }) => void
|
||||
onRequestAddToContainer?: (payload: { nodeId: string; containerId: 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 {
|
||||
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
|
||||
// before the alignment snap so detection uses the dropped position.
|
||||
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') {
|
||||
const group = getIntersectingNodes(dragNode).find((n) => n.data.type === 'group')
|
||||
if (group) onRequestAddToGroup({ nodeId: dragNode.id, groupId: group.id })
|
||||
const intersecting = getIntersectingNodes(dragNode)
|
||||
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)
|
||||
}, [onRequestAddToGroup, getIntersectingNodes, onNodeDragStop])
|
||||
}, [onRequestAddToGroup, onRequestAddToContainer, getIntersectingNodes, onNodeDragStop])
|
||||
|
||||
return (
|
||||
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }} onMouseMove={onMouseMove}>
|
||||
|
||||
@@ -207,6 +207,50 @@ describe('CanvasContainer', () => {
|
||||
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 ───────────────────────────────────────────────────────
|
||||
|
||||
it('enables snapToGrid', () => {
|
||||
|
||||
@@ -12,23 +12,35 @@ import { Button } from '@/components/ui/button'
|
||||
interface ConfirmAddToGroupModalProps {
|
||||
open: boolean
|
||||
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
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onCancel() }}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Layers size={16} className="text-[#00d4ff]" />
|
||||
Add to group
|
||||
{action}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add <span className="font-medium text-foreground">{nodeLabel}</span> to the group{' '}
|
||||
<span className="font-medium text-foreground">{groupLabel}</span>?
|
||||
Add <span className="font-medium text-foreground">{nodeLabel}</span> to the {noun}{' '}
|
||||
<span className="font-medium text-foreground">{targetLabel}</span>?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -38,7 +50,7 @@ export function ConfirmAddToGroupModal({ open, nodeLabel, groupLabel, onConfirm,
|
||||
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Add to group
|
||||
{action}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -5,14 +5,14 @@ import { ConfirmAddToGroupModal } from '../ConfirmAddToGroupModal'
|
||||
describe('ConfirmAddToGroupModal', () => {
|
||||
it('renders nothing when closed', () => {
|
||||
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()
|
||||
})
|
||||
|
||||
it('shows node and group labels when open', () => {
|
||||
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('DMZ')).toBeDefined()
|
||||
@@ -21,7 +21,7 @@ describe('ConfirmAddToGroupModal', () => {
|
||||
it('calls onConfirm when the confirm button is clicked', () => {
|
||||
const onConfirm = vi.fn()
|
||||
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 }))
|
||||
expect(onConfirm).toHaveBeenCalledOnce()
|
||||
@@ -30,9 +30,17 @@ describe('ConfirmAddToGroupModal', () => {
|
||||
it('calls onCancel when the cancel button is clicked', () => {
|
||||
const onCancel = vi.fn()
|
||||
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 }))
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user