From 7ea1fc2d6919a4d55bccb7a23941405a2e1618f5 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Wed, 10 Jun 2026 16:50:36 +0200 Subject: [PATCH 1/2] feat: editable node groups (add/remove members, description) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make Node Groups mutable instead of fixed-at-creation: - removeFromGroup / addToGroup store actions (inverse pair, history-aware) - right panel: per-member remove button + editable group description (reuses data.notes, no backend/serializer change) - drag a node over a group → confirm modal to add it (getIntersectingNodes) Tests: +21 (store, panel, canvas detection, modal). ha-relevant: yes --- frontend/src/App.tsx | 16 ++- .../src/components/canvas/CanvasContainer.tsx | 18 ++- .../canvas/__tests__/CanvasContainer.test.tsx | 55 ++++++++- .../modals/ConfirmAddToGroupModal.tsx | 47 ++++++++ .../__tests__/ConfirmAddToGroupModal.test.tsx | 38 +++++++ .../src/components/panels/DetailPanel.tsx | 61 ++++++++-- .../panels/__tests__/GroupPanels.test.tsx | 42 +++++++ .../src/stores/__tests__/canvasStore.test.ts | 107 ++++++++++++++++++ frontend/src/stores/canvasStore.ts | 73 ++++++++++++ 9 files changed, 443 insertions(+), 14 deletions(-) create mode 100644 frontend/src/components/modals/ConfirmAddToGroupModal.tsx create mode 100644 frontend/src/components/modals/__tests__/ConfirmAddToGroupModal.test.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index afca1b5..91c0cd7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,6 +28,7 @@ import { ThemeModal } from '@/components/modals/ThemeModal' import { SearchModal } from '@/components/modals/SearchModal' import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal' import { ShortcutsModal } from '@/components/modals/ShortcutsModal' +import { ConfirmAddToGroupModal } from '@/components/modals/ConfirmAddToGroupModal' import { useCanvasStore } from '@/stores/canvasStore' import { useDesignStore } from '@/stores/designStore' import { useAuthStore } from '@/stores/authStore' @@ -42,7 +43,7 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE_STORAGE_KEY = 'homelable_canvas' 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 } = 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 } = useCanvasStore() const canvasRef = useRef(null) const { isAuthenticated } = useAuthStore() const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore() @@ -68,6 +69,7 @@ export default function App() { const [addTextOpen, setAddTextOpen] = useState(false) const [editNodeId, setEditNodeId] = useState(null) const [pendingConnection, setPendingConnection] = useState(null) + const [pendingGroupAdd, setPendingGroupAdd] = useState<{ nodeId: string; groupId: string } | null>(null) const [editEdgeId, setEditEdgeId] = useState(null) const [scanConfigOpen, setScanConfigOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false) @@ -631,6 +633,7 @@ export default function App() { onEdgeDoubleClick={handleEdgeDoubleClick} onNodeDoubleClick={handleNodeDoubleClick} onNodeDragStart={snapshotHistory} + onRequestAddToGroup={setPendingGroupAdd} onOpenPending={(deviceId) => openPendingModal(deviceId)} /> @@ -804,6 +807,17 @@ export default function App() { /> setShortcutsOpen(false)} /> + n.id === pendingGroupAdd.nodeId)?.data.label ?? '') : ''} + groupLabel={pendingGroupAdd ? (nodes.find((n) => n.id === pendingGroupAdd.groupId)?.data.label ?? '') : ''} + onConfirm={() => { + if (pendingGroupAdd) addToGroup(pendingGroupAdd.groupId, pendingGroupAdd.nodeId) + setPendingGroupAdd(null) + }} + onCancel={() => setPendingGroupAdd(null)} + /> + {!STANDALONE && ( setSettingsOpen(false)} /> )} diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index b789680..320576a 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -30,10 +30,11 @@ interface CanvasContainerProps { onEdgeDoubleClick?: (edge: Edge) => void onNodeDoubleClick?: (node: Node) => void onNodeDragStart?: () => void + onRequestAddToGroup?: (payload: { nodeId: string; groupId: string }) => void onOpenPending?: (deviceId: string) => void } -export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) { +export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onRequestAddToGroup, onOpenPending }: CanvasContainerProps) { const [lassoMode, setLassoMode] = useState(true) const { nodes, edges, @@ -42,7 +43,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o fitViewPending, clearFitViewPending, copySelectedNodes, pasteNodes, } = useCanvasStore() - const { fitView, screenToFlowPosition } = useReactFlow() + const { fitView, screenToFlowPosition, getIntersectingNodes } = useReactFlow>() // Track the last cursor position over the canvas so paste lands under it. const cursorRef = useRef<{ x: number; y: number } | null>(null) @@ -125,6 +126,17 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides() + // 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>((event, dragNode, dragNodes) => { + if (onRequestAddToGroup && 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 }) + } + onNodeDragStop(event, dragNode, dragNodes) + }, [onRequestAddToGroup, getIntersectingNodes, onNodeDragStop]) + return (
= {} +// Hoisted holder so the mock factory can read the configurable intersection set. +const rf = vi.hoisted(() => ({ intersecting: [] as unknown[] })) + vi.mock('@xyflow/react', () => ({ ReactFlow: (props: Record) => { rfProps = props @@ -21,7 +24,13 @@ vi.mock('@xyflow/react', () => ({ ConnectionMode: { Loose: 'loose' }, SelectionMode: { Partial: 'partial' }, Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' }, - useReactFlow: () => ({ fitView: vi.fn() }), + useReactFlow: () => ({ + fitView: vi.fn(), + screenToFlowPosition: vi.fn(), + getIntersectingNodes: () => rf.intersecting, + setNodes: vi.fn(), + getNodes: () => [], + }), })) vi.mock('@xyflow/react/dist/style.css', () => ({})) @@ -42,6 +51,7 @@ function makeEdge(id: string): Edge { describe('CanvasContainer', () => { beforeEach(() => { rfProps = {} + rf.intersecting = [] useCanvasStore.setState({ nodes: [], edges: [], selectedNodeId: null }) useThemeStore.setState({ activeTheme: 'default' }) }) @@ -154,6 +164,49 @@ describe('CanvasContainer', () => { expect(rfProps.onNodeDragStart).toBe(onNodeDragStart) }) + // ── Drag onto group → onRequestAddToGroup ───────────────────────────────── + + function groupNode(id: string): Node { + return { id, type: 'group', position: { x: 0, y: 0 }, data: { label: id, type: 'group', status: 'unknown', services: [] } } + } + + it('fires onRequestAddToGroup when a node is dropped over a group', () => { + const onRequestAddToGroup = vi.fn() + const node = makeNode('n1') + const group = groupNode('g1') + rf.intersecting = [group] + render() + ;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node]) + expect(onRequestAddToGroup).toHaveBeenCalledWith({ nodeId: 'n1', groupId: 'g1' }) + }) + + it('does not fire onRequestAddToGroup when no group is under the node', () => { + const onRequestAddToGroup = vi.fn() + const node = makeNode('n1') + rf.intersecting = [makeNode('n2')] + render() + ;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node]) + expect(onRequestAddToGroup).not.toHaveBeenCalled() + }) + + it('does not fire onRequestAddToGroup for an already-parented node', () => { + const onRequestAddToGroup = vi.fn() + const node = { ...makeNode('n1'), parentId: 'gOther' } + rf.intersecting = [groupNode('g1')] + render() + ;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node]) + expect(onRequestAddToGroup).not.toHaveBeenCalled() + }) + + it('does not fire onRequestAddToGroup when the dragged node is itself a group', () => { + const onRequestAddToGroup = vi.fn() + const node = groupNode('g2') + rf.intersecting = [groupNode('g1')] + render() + ;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node]) + expect(onRequestAddToGroup).not.toHaveBeenCalled() + }) + // ── Canvas settings ─────────────────────────────────────────────────────── it('enables snapToGrid', () => { diff --git a/frontend/src/components/modals/ConfirmAddToGroupModal.tsx b/frontend/src/components/modals/ConfirmAddToGroupModal.tsx new file mode 100644 index 0000000..c5f3913 --- /dev/null +++ b/frontend/src/components/modals/ConfirmAddToGroupModal.tsx @@ -0,0 +1,47 @@ +import { Layers } from 'lucide-react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' + +interface ConfirmAddToGroupModalProps { + open: boolean + nodeLabel: string + groupLabel: string + onConfirm: () => void + onCancel: () => void +} + +export function ConfirmAddToGroupModal({ open, nodeLabel, groupLabel, onConfirm, onCancel }: ConfirmAddToGroupModalProps) { + return ( + { if (!o) onCancel() }}> + + + + + Add to group + + + Add {nodeLabel} to the group{' '} + {groupLabel}? + + + + + + + + + ) +} diff --git a/frontend/src/components/modals/__tests__/ConfirmAddToGroupModal.test.tsx b/frontend/src/components/modals/__tests__/ConfirmAddToGroupModal.test.tsx new file mode 100644 index 0000000..04c6a39 --- /dev/null +++ b/frontend/src/components/modals/__tests__/ConfirmAddToGroupModal.test.tsx @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { ConfirmAddToGroupModal } from '../ConfirmAddToGroupModal' + +describe('ConfirmAddToGroupModal', () => { + it('renders nothing when closed', () => { + render( + , + ) + expect(screen.queryByText('Add to group')).toBeNull() + }) + + it('shows node and group labels when open', () => { + render( + , + ) + expect(screen.getByText('Router')).toBeDefined() + expect(screen.getByText('DMZ')).toBeDefined() + }) + + it('calls onConfirm when the confirm button is clicked', () => { + const onConfirm = vi.fn() + render( + , + ) + fireEvent.click(screen.getByRole('button', { name: /add to group/i })) + expect(onConfirm).toHaveBeenCalledOnce() + }) + + it('calls onCancel when the cancel button is clicked', () => { + const onCancel = vi.fn() + render( + , + ) + fireEvent.click(screen.getByRole('button', { name: /cancel/i })) + expect(onCancel).toHaveBeenCalledOnce() + }) +}) diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx index 190731e..5fefe6f 100644 --- a/frontend/src/components/panels/DetailPanel.tsx +++ b/frontend/src/components/panels/DetailPanel.tsx @@ -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 } = useCanvasStore() + const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup } = useCanvasStore() const serviceStatuses = useCanvasStore((s) => s.serviceStatuses) const [addingForNode, setAddingForNode] = useState(null) @@ -65,6 +65,11 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { node={node} nodes={nodes} onUngroup={() => { ungroup(node.id) }} + onRemoveChild={(id) => { snapshotHistory(); removeFromGroup(node.id, id) }} + onChangeDescription={(value) => { + snapshotHistory() + updateNode(node.id, { notes: value }) + }} onToggleBorder={() => { snapshotHistory() updateNode(node.id, { @@ -426,17 +431,27 @@ interface GroupDetailPanelProps { node: Node nodes: Node[] onUngroup: () => void + onRemoveChild: (id: string) => void + onChangeDescription: (value: string) => void onToggleBorder: () => void onClose: () => void onSelectChild: (id: string) => void } -function GroupDetailPanel({ node, nodes, onUngroup, onToggleBorder, onClose, onSelectChild }: GroupDetailPanelProps) { +function GroupDetailPanel({ node, nodes, onUngroup, onRemoveChild, onChangeDescription, onToggleBorder, onClose, onSelectChild }: GroupDetailPanelProps) { const children = nodes.filter((n) => n.parentId === node.id) const onlineCount = children.filter((n) => n.data.status === 'online').length const offlineCount = children.filter((n) => n.data.status === 'offline').length const showBorder = node.data.custom_colors?.show_border !== false + // Description reuses data.notes, which already round-trips to the backend. + // Uncontrolled textarea (keyed by node id) so we only snapshot history on blur, + // not on every keystroke. `key` resets the field when switching groups. + const commitDescription = (value: string) => { + if (value === (node.data.notes ?? '')) return + onChangeDescription(value) + } + const handleUngroup = () => { if (confirm(`Ungroup "${node.data.label}"? Nodes will be released to the canvas.`)) { onUngroup() @@ -462,20 +477,48 @@ function GroupDetailPanel({ node, nodes, onUngroup, onToggleBorder, onClose, onS {offlineCount > 0 && ● {offlineCount} offline}
+ {/* Description */} +
+ +