diff --git a/frontend/src/components/LiveView.tsx b/frontend/src/components/LiveView.tsx index b53d3ab..381a7aa 100644 --- a/frontend/src/components/LiveView.tsx +++ b/frontend/src/components/LiveView.tsx @@ -10,7 +10,7 @@ * Clicking a node with an IP opens http:// in a new tab. */ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { ReactFlowProvider, ReactFlow, @@ -28,6 +28,7 @@ import { THEMES } from '@/utils/themes' import { nodeTypes } from '@/components/canvas/nodes/nodeTypes' import { edgeTypes } from '@/components/canvas/edges/edgeTypes' import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer' +import { computeCollapseInfo, rewireEdgesForCollapse } from '@/utils/collapseFilter' import { liveviewApi } from '@/api/client' import type { NodeData, CustomStyleDef } from '@/types' @@ -108,6 +109,18 @@ function LiveViewCanvas() { if (ip) window.open(`http://${ip}`, '_blank', 'noopener,noreferrer') }, []) + // Apply collapse-state filtering — same pipeline the editor canvas uses, + // so a collapsed group/zone hides its contents in live view too. + const collapseInfo = useMemo(() => computeCollapseInfo(nodes), [nodes]) + const visibleNodes = useMemo( + () => nodes.filter((n) => collapseInfo.visibleIds.has(n.id)), + [nodes, collapseInfo], + ) + const visibleEdges = useMemo( + () => rewireEdgesForCollapse(edges, nodes, collapseInfo.visibleIds, collapseInfo.hiddenBy), + [edges, nodes, collapseInfo], + ) + if (viewState === 'loading') { return (
@@ -136,8 +149,8 @@ function LiveViewCanvas() { return (
({ + ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + ReactFlow: (props: unknown) => { + rfPropsSpy(props) + return
+ }, + Background: () => null, + Controls: () => null, + BackgroundVariant: { Dots: 'dots' }, + ConnectionMode: { Loose: 'loose' }, + Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' }, + useReactFlow: () => ({ fitView: vi.fn() }), +})) +vi.mock('@xyflow/react/dist/style.css', () => ({})) +vi.mock('@/api/client', () => ({ liveviewApi: { load: vi.fn() } })) + +import { liveviewApi } from '@/api/client' +import LiveView from '../LiveView' + +function setSearch(params: string) { + Object.defineProperty(window, 'location', { + writable: true, + value: { ...window.location, search: params, pathname: '/view' }, + }) +} + +/** Build a /liveview API response with the given nodes/edges. */ +const apiResponse = (nodes: unknown[], edges: unknown[] = []) => ({ + data: { nodes, edges, viewport: { x: 0, y: 0, zoom: 1 } }, +}) + +const apiNode = ( + id: string, + parent_id?: string, + collapsed?: boolean, + type = 'server', +) => ({ + id, + type, + label: id, + status: 'online', + services: [], + pos_x: 0, + pos_y: 0, + parent_id: parent_id ?? null, + container_mode: type === 'group', + custom_colors: collapsed !== undefined ? { collapsed } : null, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', +}) + +describe('LiveView — applies collapse filter to the rendered canvas', () => { + beforeEach(() => { + rfPropsSpy.mockClear() + setSearch('?key=valid') + vi.mocked(liveviewApi.load).mockReset() + }) + + it('hides children of a collapsed group container in view-only mode', async () => { + vi.mocked(liveviewApi.load).mockResolvedValue( + apiResponse([apiNode('g1', undefined, true, 'group'), apiNode('c1', 'g1')]), + ) + render() + await waitFor(() => { + const last = rfPropsSpy.mock.calls[rfPropsSpy.mock.calls.length - 1]?.[0] as + | { nodes: Node[] } + | undefined + expect(last?.nodes.length).toBeGreaterThan(0) + }) + const last = rfPropsSpy.mock.calls[rfPropsSpy.mock.calls.length - 1][0] as { + nodes: Node[] + edges: Edge[] + } + const ids = last.nodes.map((n) => n.id) + expect(ids).toContain('g1') + expect(ids).not.toContain('c1') + }) + + it('shows children when the group is expanded', async () => { + vi.mocked(liveviewApi.load).mockResolvedValue( + apiResponse([apiNode('g1', undefined, false, 'group'), apiNode('c1', 'g1')]), + ) + render() + await waitFor(() => { + const last = rfPropsSpy.mock.calls[rfPropsSpy.mock.calls.length - 1]?.[0] as + | { nodes: Node[] } + | undefined + expect(last?.nodes.length).toBeGreaterThan(1) + }) + const last = rfPropsSpy.mock.calls[rfPropsSpy.mock.calls.length - 1][0] as { + nodes: Node[] + } + const ids = last.nodes.map((n) => n.id) + expect(ids).toContain('g1') + expect(ids).toContain('c1') + }) +}) diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index 7433492..be30641 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { ReactFlow, Background, @@ -17,6 +17,7 @@ import '@xyflow/react/dist/style.css' import { useCanvasStore } from '@/stores/canvasStore' import { useThemeStore } from '@/stores/themeStore' import { THEMES } from '@/utils/themes' +import { computeCollapseInfo, rewireEdgesForCollapse } from '@/utils/collapseFilter' import { nodeTypes } from './nodes/nodeTypes' import { edgeTypes } from './edges/edgeTypes' import { SearchBar } from './SearchBar' @@ -55,6 +56,17 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o const activeTheme = useThemeStore((s) => s.activeTheme) const theme = THEMES[activeTheme] + // Filter nodes and edges based on collapsed state (memoized — O(n)). + const collapseInfo = useMemo(() => computeCollapseInfo(nodes), [nodes]) + const visibleNodes = useMemo( + () => nodes.filter((n) => collapseInfo.visibleIds.has(n.id)), + [nodes, collapseInfo], + ) + const visibleEdges = useMemo( + () => rewireEdgesForCollapse(edges, nodes, collapseInfo.visibleIds, collapseInfo.hiddenBy), + [edges, nodes, collapseInfo], + ) + const onNodeClick = useCallback((e: React.MouseEvent, node: Node) => { if (e.ctrlKey || e.metaKey) { setSelectedNode(null) @@ -90,8 +102,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o return (
[0]> = {}, s nodes: storeNodes, updateNode: vi.fn(), snapshotHistory: vi.fn(), + toggleNodeCollapsed: vi.fn(), } as unknown as ReturnType) return render( @@ -134,4 +135,51 @@ describe('GroupNode', () => { renderGroupNode() expect(screen.queryByText(/●/)).toBeNull() }) + + it('renders a collapse toggle when the group has parentId children', () => { + const storeNodes = [ + { id: 'c1', parentId: 'g1', data: { status: 'online' } }, + { id: 'c2', parentId: 'g1', data: { status: 'online' } }, + ] + renderGroupNode({}, storeNodes) + expect(screen.getByTitle('Hide 2 items')).toBeDefined() + }) + + it('flips the toggle title when collapsed', () => { + const storeNodes = [ + { id: 'c1', parentId: 'g1', data: { status: 'online' } }, + ] + renderGroupNode({ data: makeGroupNode({ collapsed: true }).data }, storeNodes) + expect(screen.getByTitle('Show 1 hidden items')).toBeDefined() + }) + + it('calls toggleNodeCollapsed when the toggle is clicked', () => { + const toggleNodeCollapsed = vi.fn() + const storeNodes = [{ id: 'c1', parentId: 'g1', data: { status: 'online' } }] + vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ + nodes: storeNodes, + updateNode: vi.fn(), + snapshotHistory: vi.fn(), + toggleNodeCollapsed, + } as unknown as ReturnType) + render( + , + ) + fireEvent.click(screen.getByTitle('Hide 1 items')) + expect(toggleNodeCollapsed).toHaveBeenCalledWith('g1') + }) + + it('does not render the toggle when the group has no children', () => { + renderGroupNode() + expect(screen.queryByTitle(/Hide.*items|Show.*hidden/)).toBeNull() + }) }) diff --git a/frontend/src/components/canvas/nodes/GroupNode.tsx b/frontend/src/components/canvas/nodes/GroupNode.tsx index 0b3c058..34fa0ce 100644 --- a/frontend/src/components/canvas/nodes/GroupNode.tsx +++ b/frontend/src/components/canvas/nodes/GroupNode.tsx @@ -1,13 +1,14 @@ import { useState } from 'react' import { type NodeProps, type Node, NodeResizer, Handle, Position } from '@xyflow/react' -import { Layers, Pencil, Check, X } from 'lucide-react' +import { Layers, Pencil, Check, X, ChevronDown } from 'lucide-react' import { useCanvasStore } from '@/stores/canvasStore' import { useThemeStore } from '@/stores/themeStore' import { THEMES } from '@/utils/themes' import { STATUS_COLORS, type NodeData } from '@/types' export function GroupNode({ id, data, selected }: NodeProps>) { - const { nodes, updateNode, snapshotHistory } = useCanvasStore() + const { nodes, updateNode, snapshotHistory, toggleNodeCollapsed } = useCanvasStore() + const isCollapsed = data.collapsed ?? false const activeTheme = useThemeStore((s) => s.activeTheme) const theme = THEMES[activeTheme] const showBorder = data.custom_colors?.show_border !== false @@ -138,6 +139,28 @@ export function GroupNode({ id, data, selected }: NodeProps>) { )} + {/* Collapse / expand toggle */} + {children.length > 0 && ( + + )} + {/* Status summary */} {children.length > 0 && (
diff --git a/frontend/src/components/canvas/nodes/GroupRectNode.tsx b/frontend/src/components/canvas/nodes/GroupRectNode.tsx index 574145e..7a780a2 100644 --- a/frontend/src/components/canvas/nodes/GroupRectNode.tsx +++ b/frontend/src/components/canvas/nodes/GroupRectNode.tsx @@ -1,6 +1,8 @@ import { useState } from 'react' import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react' +import { ChevronDown } from 'lucide-react' import { useCanvasStore } from '@/stores/canvasStore' +import { getZoneSpatialChildren } from '@/utils/collapseFilter' import type { NodeData, TextPosition } from '@/types' const FONT_FAMILIES: Record = { @@ -36,9 +38,12 @@ const HANDLE_SIDES = [ export function GroupRectNode({ id, data, selected }: NodeProps>) { const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId) + const toggleNodeCollapsed = useCanvasStore((s) => s.toggleNodeCollapsed) + const nodes = useCanvasStore((s) => s.nodes) const [hovered, setHovered] = useState(false) const rc = data.custom_colors ?? {} + const isCollapsed = data.collapsed ?? false const borderColor = rc.border ?? '#00d4ff' const borderStyle = rc.border_style ?? 'solid' const borderWidth = rc.border_width ?? 2 @@ -50,6 +55,13 @@ export function GroupRectNode({ id, data, selected }: NodeProps>) const textPos = (rc.text_position ?? 'top-left') as TextPosition const posStyle = POSITION_STYLES[textPos] + // Count children for collapse badge — groupRect zones don't parent their + // contents via React Flow parentId, so we hit-test by spatial containment. + const selfNode = (nodes ?? []).find((n) => n.id === id) + const childrenCount = selfNode + ? getZoneSpatialChildren(selfNode, nodes ?? []).length + : 0 + const outsideJustify = textPos.includes('right') ? 'flex-end' : (textPos.includes('center') || textPos === 'center') ? 'center' : 'flex-start' @@ -118,6 +130,8 @@ export function GroupRectNode({ id, data, selected }: NodeProps>) borderRadius: 10, boxSizing: 'border-box', cursor: 'default', + transition: 'opacity 0.2s ease-out, filter 0.2s ease-out', + opacity: isCollapsed ? 0.6 : 1, }} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} @@ -126,6 +140,51 @@ export function GroupRectNode({ id, data, selected }: NodeProps>) setEditingGroupRectId(id) }} > + {childrenCount > 0 && ( + + )} + {isCollapsed && childrenCount > 0 && ( + + +{childrenCount} + + )} {labelPosition === 'outside' && data.label && ( { + it('accepts a boolean collapsed flag as a first-class field', () => { + const nodeData: NodeData = { + label: 'Test Zone', + type: 'groupRect', + status: 'online', + services: [], + collapsed: true, + } + expect(nodeData.collapsed).toBe(true) + }) + + it('treats a missing flag as expanded', () => { + const nodeData: NodeData = { + label: 'Test Zone', + type: 'groupRect', + status: 'online', + services: [], + } + expect(nodeData.collapsed).toBeUndefined() + }) +}) diff --git a/frontend/src/stores/__tests__/canvasStore.collapse.test.ts b/frontend/src/stores/__tests__/canvasStore.collapse.test.ts new file mode 100644 index 0000000..b2169df --- /dev/null +++ b/frontend/src/stores/__tests__/canvasStore.collapse.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useCanvasStore } from '../canvasStore' +import type { Node } from '@xyflow/react' +import type { NodeData } from '@/types' + +describe('canvasStore - toggleNodeCollapsed', () => { + it('toggles collapsed state on a zone node', () => { + const { result } = renderHook(() => useCanvasStore()) + + const node: Node = { + id: 'zone-1', + data: { label: 'Test Zone', type: 'groupRect', status: 'online', services: [] }, + position: { x: 0, y: 0 }, + } + + act(() => { + result.current.addNode(node) + }) + + let zone = result.current.nodes.find((n) => n.id === 'zone-1') + expect(zone?.data.collapsed).toBeUndefined() + + act(() => { + result.current.toggleNodeCollapsed('zone-1') + }) + + zone = result.current.nodes.find((n) => n.id === 'zone-1') + expect(zone?.data.collapsed).toBe(true) + + act(() => { + result.current.toggleNodeCollapsed('zone-1') + }) + + zone = result.current.nodes.find((n) => n.id === 'zone-1') + expect(zone?.data.collapsed).toBe(false) + }) + + it('marks canvas as unsaved when toggling collapse', () => { + const { result } = renderHook(() => useCanvasStore()) + + const node: Node = { + id: 'zone-1', + data: { label: 'Test Zone', type: 'groupRect', status: 'online', services: [] }, + position: { x: 0, y: 0 }, + } + + act(() => { + result.current.addNode(node) + result.current.markSaved() + }) + + expect(result.current.hasUnsavedChanges).toBe(false) + + act(() => { + result.current.toggleNodeCollapsed('zone-1') + }) + + expect(result.current.hasUnsavedChanges).toBe(true) + }) +}) diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index 8f48d99..ef28809 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -52,6 +52,7 @@ interface CanvasState { setEditingGroupRectId: (id: string | null) => void editingTextId: string | null setEditingTextId: (id: string | null) => void + toggleNodeCollapsed: (id: string) => void createGroup: (nodeIds: string[], name: string) => void ungroup: (groupId: string) => void markSaved: () => void @@ -374,6 +375,16 @@ export const useCanvasStore = create((set) => ({ setEditingTextId: (id) => set({ editingTextId: id }), + toggleNodeCollapsed: (id) => + set((state) => ({ + nodes: state.nodes.map((n) => + n.id === id + ? { ...n, data: { ...n.data, collapsed: !n.data.collapsed } } + : n + ), + hasUnsavedChanges: true, + })), + createGroup: (nodeIds, name) => set((state) => { const PADDING_H = 24 diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 8e79fa6..37939a8 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -97,6 +97,12 @@ export interface NodeData extends Record { width?: number height?: number } + /** + * Collapsible zone state (type === 'groupRect'). When true, the zone hides + * its descendants on the canvas. Persisted via `custom_colors.collapsed` + * round-trip for back-compat with older saves. + */ + collapsed?: boolean custom_icon?: string /** Number of bottom connection points, 1..48. Default 1 (centered). */ bottom_handles?: number diff --git a/frontend/src/utils/__tests__/canvasSerializer.collapse.test.ts b/frontend/src/utils/__tests__/canvasSerializer.collapse.test.ts new file mode 100644 index 0000000..7b5b6f2 --- /dev/null +++ b/frontend/src/utils/__tests__/canvasSerializer.collapse.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect } from 'vitest' +import type { Node } from '@xyflow/react' +import type { NodeData } from '@/types' +import { serializeNode, deserializeApiNode, type ApiNode } from '@/utils/canvasSerializer' + +/** + * Persistence contract for the collapse flag on groupRect nodes: + * + * 1. Serialize stashes `data.collapsed` into `custom_colors.collapsed` + * so the existing API blob shape can carry it without a schema change. + * 2. Deserialize hoists it back to the first-class `data.collapsed` field. + * 3. Legacy saves that already had `custom_colors.collapsed` (the original + * shape from PR #158 before the field was promoted) still load + * correctly. + */ + +function makeGroupRectRfNode(collapsed?: boolean): Node { + return { + id: 'zone-1', + type: 'groupRect', + position: { x: 0, y: 0 }, + data: { + label: 'Zigbee Mesh', + type: 'groupRect', + status: 'unknown', + services: [], + ...(collapsed !== undefined ? { collapsed } : {}), + }, + } +} + +describe('canvasSerializer — groupRect collapse', () => { + it('stashes data.collapsed=true into custom_colors on serialize', () => { + const rf = makeGroupRectRfNode(true) + const api = serializeNode(rf) as Record + const cc = api.custom_colors as Record + expect(cc.collapsed).toBe(true) + }) + + it('writes collapsed=false when the flag is missing (explicit default)', () => { + const rf = makeGroupRectRfNode(undefined) + const api = serializeNode(rf) as Record + const cc = api.custom_colors as Record + expect(cc.collapsed).toBe(false) + }) + + it('hoists custom_colors.collapsed back to data.collapsed on deserialize', () => { + const apiNode: ApiNode = { + id: 'zone-1', + type: 'groupRect', + label: 'Zone', + pos_x: 0, + pos_y: 0, + status: 'unknown', + services: [], + custom_colors: { collapsed: true, width: 360, height: 240 }, + } + const rf = deserializeApiNode(apiNode, new Map()) + expect(rf.data.collapsed).toBe(true) + }) + + it('treats missing custom_colors.collapsed as false on deserialize', () => { + const apiNode: ApiNode = { + id: 'zone-1', + type: 'groupRect', + label: 'Zone', + pos_x: 0, + pos_y: 0, + status: 'unknown', + services: [], + custom_colors: { width: 360, height: 240 }, + } + const rf = deserializeApiNode(apiNode, new Map()) + expect(rf.data.collapsed).toBe(false) + }) + + it('round-trips the collapse flag through serialize → deserialize', () => { + const rf = makeGroupRectRfNode(true) + const api = serializeNode(rf) as unknown as ApiNode + const back = deserializeApiNode(api, new Map()) + expect(back.data.collapsed).toBe(true) + }) +}) + +describe('canvasSerializer — collapse on non-groupRect node types', () => { + it('stashes data.collapsed into custom_colors for a group container', () => { + const rf: Node = { + id: 'g1', + type: 'group', + position: { x: 0, y: 0 }, + data: { + label: 'Container', + type: 'group', + status: 'unknown', + services: [], + custom_colors: { show_border: true }, + collapsed: true, + }, + } + const api = serializeNode(rf) as Record + const cc = api.custom_colors as Record + expect(cc.collapsed).toBe(true) + // Existing custom_colors keys are preserved alongside the stash. + expect(cc.show_border).toBe(true) + }) + + it('leaves custom_colors null when neither flag nor colors are set', () => { + const rf: Node = { + id: 's1', + type: 'server', + position: { x: 0, y: 0 }, + data: { label: 'Server', type: 'server', status: 'online', services: [] }, + } + const api = serializeNode(rf) as Record + expect(api.custom_colors).toBeNull() + }) + + it('hoists custom_colors.collapsed to data.collapsed for a group container', () => { + const apiNode: ApiNode = { + id: 'g1', + type: 'group', + label: 'Container', + pos_x: 0, + pos_y: 0, + status: 'unknown', + services: [], + custom_colors: { show_border: true, collapsed: true }, + } + const rf = deserializeApiNode(apiNode, new Map()) + expect(rf.data.collapsed).toBe(true) + }) + + it('round-trips collapse on a group container', () => { + const rf: Node = { + id: 'g1', + type: 'group', + position: { x: 0, y: 0 }, + data: { + label: 'Container', + type: 'group', + status: 'unknown', + services: [], + custom_colors: { show_border: true }, + collapsed: true, + }, + } + const api = serializeNode(rf) as unknown as ApiNode + const back = deserializeApiNode(api, new Map()) + expect(back.data.collapsed).toBe(true) + }) +}) diff --git a/frontend/src/utils/__tests__/collapseFilter.test.ts b/frontend/src/utils/__tests__/collapseFilter.test.ts new file mode 100644 index 0000000..aa2fe54 --- /dev/null +++ b/frontend/src/utils/__tests__/collapseFilter.test.ts @@ -0,0 +1,262 @@ +import { describe, it, expect } from 'vitest' +import type { Edge, Node } from '@xyflow/react' +import { + getVisibleNodeIds, + rewireEdgesForCollapse, + getZoneSpatialChildren, + computeCollapseInfo, +} from '../collapseFilter' +import type { EdgeData, NodeData } from '@/types' + +interface MkOpts { + parentId?: string + collapsed?: boolean + position?: { x: number; y: number } + width?: number + height?: number + type?: NodeData['type'] +} + +// Outside the default 360x240 zone bbox at origin — used by tests that need +// a node that must NOT be spatially captured by a collapsed zone. +const FAR = { x: 10000, y: 0 } + +const mkNode = (id: string, opts: MkOpts = {}): Node => ({ + id, + position: opts.position ?? { x: 0, y: 0 }, + ...(opts.width !== undefined ? { width: opts.width } : {}), + ...(opts.height !== undefined ? { height: opts.height } : {}), + ...(opts.parentId ? { parentId: opts.parentId } : {}), + data: { + label: id, + type: opts.type ?? (opts.parentId ? 'server' : 'groupRect'), + status: 'online', + services: [], + ...(opts.collapsed !== undefined ? { collapsed: opts.collapsed } : {}), + }, +}) + +const mkEdge = (id: string, source: string, target: string): Edge => ({ + id, + source, + target, +}) + +describe('getVisibleNodeIds — parentId cascade', () => { + it('returns all nodes when nothing is collapsed', () => { + const nodes = [ + mkNode('zone'), + mkNode('child-a', { parentId: 'zone' }), + mkNode('child-b', { parentId: 'zone' }), + ] + expect(getVisibleNodeIds(nodes)).toEqual(new Set(['zone', 'child-a', 'child-b'])) + }) + + it('hides direct children of a collapsed parent but keeps the parent itself', () => { + const nodes = [ + mkNode('zone', { collapsed: true }), + mkNode('child-a', { parentId: 'zone' }), + mkNode('child-b', { parentId: 'zone' }), + mkNode('outside', { position: FAR }), + ] + expect(getVisibleNodeIds(nodes)).toEqual(new Set(['zone', 'outside'])) + }) + + it('hides the entire subtree when an ancestor is collapsed (multi-level)', () => { + const nodes = [ + mkNode('root', { collapsed: true }), + mkNode('mid', { parentId: 'root', collapsed: false }), + mkNode('leaf', { parentId: 'mid' }), + ] + const v = getVisibleNodeIds(nodes) + expect(v.has('root')).toBe(true) + expect(v.has('mid')).toBe(false) + expect(v.has('leaf')).toBe(false) + }) + + it('hides only the nested subtree when an inner zone is collapsed', () => { + const nodes = [ + mkNode('root', { collapsed: false }), + mkNode('inner', { parentId: 'root', collapsed: true }), + mkNode('leaf', { parentId: 'inner' }), + mkNode('sibling', { parentId: 'root' }), + ] + expect(getVisibleNodeIds(nodes)).toEqual(new Set(['root', 'inner', 'sibling'])) + }) + + it('handles a zone with no children', () => { + expect(getVisibleNodeIds([mkNode('empty-zone', { collapsed: true })])) + .toEqual(new Set(['empty-zone'])) + }) + + it('returns an empty set for empty input', () => { + expect(getVisibleNodeIds([])).toEqual(new Set()) + }) + + it('treats nodes with no collapsed flag as expanded', () => { + const nodes = [mkNode('zone'), mkNode('child', { parentId: 'zone' })] + expect(getVisibleNodeIds(nodes)).toEqual(new Set(['zone', 'child'])) + }) + + it('is independent of insertion order (children declared before parent)', () => { + const nodes = [ + mkNode('child', { parentId: 'zone' }), + mkNode('zone', { collapsed: true }), + ] + expect(getVisibleNodeIds(nodes)).toEqual(new Set(['zone'])) + }) +}) + +describe('getZoneSpatialChildren', () => { + it('picks up top-level nodes whose centre lies inside the zone bbox', () => { + const zone = mkNode('zone', { position: { x: 0, y: 0 }, width: 400, height: 300 }) + const inside = mkNode('inside', { position: { x: 100, y: 50 }, type: 'server' }) + const outside = mkNode('outside', { position: { x: 500, y: 0 }, type: 'server' }) + expect(getZoneSpatialChildren(zone, [zone, inside, outside])).toEqual(['inside']) + }) + + it('ignores the zone itself', () => { + const zone = mkNode('zone', { width: 400, height: 300 }) + expect(getZoneSpatialChildren(zone, [zone])).toEqual([]) + }) + + it('ignores nodes with a parentId (handled via parentId cascade)', () => { + const zone = mkNode('zone', { width: 400, height: 300 }) + const child = mkNode('child', { parentId: 'other', type: 'server' }) + expect(getZoneSpatialChildren(zone, [zone, child])).toEqual([]) + }) + + it('uses fallback dimensions for nodes with no width/height set', () => { + const zone = mkNode('zone', { width: 400, height: 300 }) + // No width/height → defaults (200, 80). Centre at (100, 40), inside. + const n = mkNode('n', { type: 'server' }) + expect(getZoneSpatialChildren(zone, [zone, n])).toEqual(['n']) + }) +}) + +describe('computeCollapseInfo — spatial collapse via groupRect zones', () => { + it('hides nodes spatially inside a collapsed zone and records hiddenBy', () => { + const zone = mkNode('zone', { collapsed: true, width: 400, height: 300 }) + const inside = mkNode('inside', { position: { x: 50, y: 50 }, type: 'server' }) + const outside = mkNode('outside', { position: FAR, type: 'server' }) + const info = computeCollapseInfo([zone, inside, outside]) + expect(info.visibleIds).toEqual(new Set(['zone', 'outside'])) + expect(info.hiddenBy.get('inside')).toBe('zone') + }) + + it('cascades parentId descendants of spatially-hidden nodes', () => { + // Proxmox host sitting inside a collapsed zone — its VMs (parentId) + // must also be hidden even though they live at relative coords. + const zone = mkNode('zone', { collapsed: true, width: 400, height: 300 }) + const px = mkNode('px', { position: { x: 50, y: 50 }, type: 'proxmox' }) + const vm = mkNode('vm', { parentId: 'px', type: 'vm' }) + const info = computeCollapseInfo([zone, px, vm]) + expect(info.visibleIds).toEqual(new Set(['zone'])) + expect(info.hiddenBy.get('vm')).toBe('zone') + }) + + it('a nested groupRect inside a collapsed outer zone is also hidden', () => { + const outer = mkNode('outer', { collapsed: true, width: 600, height: 400 }) + const inner = mkNode('inner', { position: { x: 100, y: 100 }, width: 200, height: 150 }) + const leaf = mkNode('leaf', { position: { x: 150, y: 150 }, type: 'server' }) + const info = computeCollapseInfo([outer, inner, leaf]) + expect(info.visibleIds).toEqual(new Set(['outer'])) + }) + + it('does not affect nodes outside every collapsed zone', () => { + const a = mkNode('a', { collapsed: true, width: 300, height: 200 }) + const b = mkNode('b', { position: { x: 1000, y: 1000 }, width: 300, height: 200 }) + const free = mkNode('free', { position: { x: 2000, y: 2000 }, type: 'server' }) + const info = computeCollapseInfo([a, b, free]) + expect(info.visibleIds.has('free')).toBe(true) + }) +}) + +describe('rewireEdgesForCollapse', () => { + it('keeps edges between two visible nodes unchanged (same reference)', () => { + const nodes = [mkNode('a'), mkNode('b', { position: FAR })] + const edges = [mkEdge('e1', 'a', 'b')] + const info = computeCollapseInfo(nodes) + const out = rewireEdgesForCollapse(edges, nodes, info.visibleIds, info.hiddenBy) + expect(out).toHaveLength(1) + expect(out[0]).toBe(edges[0]) + }) + + it('reroutes a cross-boundary edge to the collapsed parentId ancestor', () => { + const nodes = [ + mkNode('zone', { collapsed: true }), + mkNode('leaf', { parentId: 'zone' }), + mkNode('outside', { position: FAR }), + ] + const info = computeCollapseInfo(nodes) + const edges = [mkEdge('e1', 'outside', 'leaf')] + const out = rewireEdgesForCollapse(edges, nodes, info.visibleIds, info.hiddenBy) + expect(out[0].source).toBe('outside') + expect(out[0].target).toBe('zone') + expect(out[0].sourceHandle).toBeNull() + expect(out[0].targetHandle).toBeNull() + }) + + it('reroutes a cross-boundary edge to a collapsed groupRect zone (spatial)', () => { + const zone = mkNode('zone', { collapsed: true, width: 400, height: 300 }) + const inside = mkNode('inside', { position: { x: 50, y: 50 }, type: 'server' }) + const outside = mkNode('outside', { position: FAR, type: 'server' }) + const nodes = [zone, inside, outside] + const info = computeCollapseInfo(nodes) + const edges = [mkEdge('e1', 'outside', 'inside')] + const out = rewireEdgesForCollapse(edges, nodes, info.visibleIds, info.hiddenBy) + expect(out[0].source).toBe('outside') + expect(out[0].target).toBe('zone') + }) + + it('drops an edge between two siblings inside the same collapsed zone (self-loop)', () => { + const nodes = [ + mkNode('zone', { collapsed: true }), + mkNode('a', { parentId: 'zone' }), + mkNode('b', { parentId: 'zone' }), + ] + const info = computeCollapseInfo(nodes) + expect(rewireEdgesForCollapse([mkEdge('e1', 'a', 'b')], nodes, info.visibleIds, info.hiddenBy)) + .toEqual([]) + }) + + it('de-dupes parallel cross-boundary edges that rewire to the same pair', () => { + const nodes = [ + mkNode('zone', { collapsed: true }), + mkNode('coord', { position: FAR }), + ...Array.from({ length: 5 }, (_, i) => mkNode(`leaf-${i}`, { parentId: 'zone' })), + ] + const info = computeCollapseInfo(nodes) + const edges = Array.from({ length: 5 }, (_, i) => mkEdge(`e-${i}`, 'coord', `leaf-${i}`)) + const out = rewireEdgesForCollapse(edges, nodes, info.visibleIds, info.hiddenBy) + expect(out).toHaveLength(1) + expect(out[0].source).toBe('coord') + expect(out[0].target).toBe('zone') + }) + + it('walks the chain to the nearest visible ancestor (nested collapse)', () => { + const nodes = [ + mkNode('root', { collapsed: true }), + mkNode('mid', { parentId: 'root' }), + mkNode('leaf', { parentId: 'mid' }), + mkNode('outside', { position: FAR }), + ] + const info = computeCollapseInfo(nodes) + const out = rewireEdgesForCollapse( + [mkEdge('e1', 'outside', 'leaf')], + nodes, + info.visibleIds, + info.hiddenBy, + ) + expect(out[0].target).toBe('root') + }) + + it('drops an edge whose endpoint has no visible ancestor', () => { + const edges = [mkEdge('e1', 'ghost', 'also-ghost')] + expect(rewireEdgesForCollapse(edges, [], new Set(), new Map())).toEqual([]) + }) + + it('returns an empty array for empty input', () => { + expect(rewireEdgesForCollapse([], [], new Set(), new Map())).toEqual([]) + }) +}) diff --git a/frontend/src/utils/__tests__/group.collapse.integration.test.ts b/frontend/src/utils/__tests__/group.collapse.integration.test.ts new file mode 100644 index 0000000..2371f03 --- /dev/null +++ b/frontend/src/utils/__tests__/group.collapse.integration.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useCanvasStore } from '../../stores/canvasStore' +import { computeCollapseInfo } from '../collapseFilter' +import type { Node } from '@xyflow/react' +import type { NodeData } from '@/types' + +const mk = (id: string, type: NodeData['type'] = 'server'): Node => ({ + id, + type, + position: { x: 100, y: 100 }, + data: { label: id, type, status: 'online', services: [] }, +}) + +describe('integration — createGroup + toggleNodeCollapsed hides children', () => { + it('hides parentId children of a collapsed group container', () => { + const { result } = renderHook(() => useCanvasStore()) + act(() => { + result.current.addNode(mk('c1')) + result.current.addNode(mk('c2')) + result.current.createGroup(['c1', 'c2'], 'My Group') + }) + // Find the auto-generated group id. + const grp = result.current.nodes.find((n) => n.type === 'group')! + expect(grp).toBeDefined() + expect(result.current.nodes.find((n) => n.id === 'c1')!.parentId).toBe(grp.id) + + // Pre-collapse: all visible. + let info = computeCollapseInfo(result.current.nodes) + expect(info.visibleIds.has('c1')).toBe(true) + expect(info.visibleIds.has('c2')).toBe(true) + + // Collapse the group via the store action. + act(() => result.current.toggleNodeCollapsed(grp.id)) + expect(result.current.nodes.find((n) => n.id === grp.id)!.data.collapsed).toBe(true) + + info = computeCollapseInfo(result.current.nodes) + expect(info.visibleIds.has(grp.id)).toBe(true) + expect(info.visibleIds.has('c1')).toBe(false) + expect(info.visibleIds.has('c2')).toBe(false) + expect(info.hiddenBy.get('c1')).toBe(grp.id) + }) +}) diff --git a/frontend/src/utils/canvasSerializer.ts b/frontend/src/utils/canvasSerializer.ts index ccd85ae..435e06b 100644 --- a/frontend/src/utils/canvasSerializer.ts +++ b/frontend/src/utils/canvasSerializer.ts @@ -76,6 +76,9 @@ export function serializeNode(n: Node): Record { ...n.data.custom_colors, width: n.measured?.width ?? n.width ?? 360, height: n.measured?.height ?? n.height ?? 240, + // Stash collapse state inside custom_colors so the API/YAML blob does + // not need a new column. Hoisted back to `data.collapsed` on load. + collapsed: n.data.collapsed ?? false, }, } } @@ -94,7 +97,13 @@ export function serializeNode(n: Node): Record { notes: n.data.notes ?? null, parent_id: n.data.parent_id ?? null, container_mode: n.data.container_mode ?? false, - custom_colors: n.data.custom_colors ?? null, + // Stash collapse state inside the custom_colors blob so the backend's + // dict[str, Any] column carries it without a schema change. Hoisted + // back to `data.collapsed` on load. Applies to every node type — group + // containers, Proxmox hosts, etc. — not just groupRect zones. + custom_colors: n.data.collapsed !== undefined + ? { ...(n.data.custom_colors ?? {}), collapsed: n.data.collapsed } + : (n.data.custom_colors ?? null), custom_icon: n.data.custom_icon ?? null, cpu_count: n.data.cpu_count ?? null, cpu_model: n.data.cpu_model ?? null, @@ -139,11 +148,15 @@ export function deserializeApiNode( const w = (n.custom_colors?.width as number | undefined) ?? 360 const h = (n.custom_colors?.height as number | undefined) ?? 240 const z = (n.custom_colors?.z_order as number | undefined) ?? 1 + // Hoist persisted collapse flag from the custom_colors stash to a + // first-class field on NodeData. Tolerates legacy saves that already had + // it there from before the type was promoted. + const collapsed = Boolean(n.custom_colors?.collapsed) return { id: n.id, type: 'groupRect', position: { x: n.pos_x, y: n.pos_y }, - data: n as unknown as NodeData, + data: { ...(n as unknown as NodeData), collapsed }, width: w, height: h, zIndex: z - 10, @@ -155,7 +168,14 @@ export function deserializeApiNode( id: n.id, type: normalizedType, position: { x: n.pos_x, y: n.pos_y }, - data: { ...n, type: normalizedType, bottom_handles: clampBottomHandles(n.bottom_handles ?? 1) } as unknown as NodeData, + // Hoist persisted collapse flag from the custom_colors stash (matches + // the symmetric serialize step). Applies to every node type. + data: { + ...n, + type: normalizedType, + bottom_handles: clampBottomHandles(n.bottom_handles ?? 1), + collapsed: Boolean(n.custom_colors?.collapsed), + } as unknown as NodeData, ...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}), ...(['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) && n.container_mode !== false ? { width: n.width ?? 300, height: n.height ?? 200 } diff --git a/frontend/src/utils/collapseFilter.ts b/frontend/src/utils/collapseFilter.ts new file mode 100644 index 0000000..ed66b5a --- /dev/null +++ b/frontend/src/utils/collapseFilter.ts @@ -0,0 +1,196 @@ +import type { Edge, Node } from '@xyflow/react' +import type { EdgeData, NodeData } from '@/types' + +/** + * Collapse model + * ────────────── + * Two ways a node can collapse and hide what it "contains": + * + * 1. parentId hierarchy — `type: 'group'` containers (createGroup) and + * Proxmox container_mode children. Setting `data.collapsed = true` on + * such a node hides every node in its parentId subtree. + * + * 2. Spatial containment — `type: 'groupRect'` decorative zones drawn + * around nodes. Zones do not parent their contents in React Flow, so + * we hit-test every top-level node's centre against the zone bbox to + * decide what is "inside". Collapsing a zone hides every node whose + * centre lies inside the zone (plus the parentId subtrees of those + * nodes, so e.g. a Proxmox host inside a collapsed zone also takes its + * VMs/LXCs with it). + * + * `hiddenBy` records which collapsed ancestor hid each node — used by edge + * rewiring to redirect a vanished endpoint to the visible zone the user is + * actually looking at. + */ + +interface BBox { x: number; y: number; w: number; h: number } + +const DEFAULT_NODE_W = 200 +const DEFAULT_NODE_H = 80 +const DEFAULT_ZONE_W = 360 +const DEFAULT_ZONE_H = 240 + +function bboxOf(n: Node, fallbackW: number, fallbackH: number): BBox { + return { + x: n.position.x, + y: n.position.y, + w: n.width ?? fallbackW, + h: n.height ?? fallbackH, + } +} + +function centerInside(n: Node, b: BBox): boolean { + const w = n.width ?? DEFAULT_NODE_W + const h = n.height ?? DEFAULT_NODE_H + const cx = n.position.x + w / 2 + const cy = n.position.y + h / 2 + return cx >= b.x && cx <= b.x + b.w && cy >= b.y && cy <= b.y + b.h +} + +/** + * Node ids whose centre lies inside the given zone, excluding the zone + * itself and any node that is a React Flow child (parentId set — those are + * positioned relative to their parent, not in absolute canvas coordinates). + */ +export function getZoneSpatialChildren( + zone: Node, + nodes: Node[], +): string[] { + const zb = bboxOf(zone, DEFAULT_ZONE_W, DEFAULT_ZONE_H) + const out: string[] = [] + for (const n of nodes) { + if (n.id === zone.id) continue + if (n.parentId) continue + if (centerInside(n, zb)) out.push(n.id) + } + return out +} + +function buildChildrenByParent(nodes: Node[]): Map { + const m = new Map() + for (const n of nodes) { + if (!n.parentId) continue + const arr = m.get(n.parentId) + if (arr) arr.push(n.id) + else m.set(n.parentId, [n.id]) + } + return m +} + +export interface CollapseInfo { + /** Ids the canvas should render. */ + visibleIds: Set + /** For each hidden id, the id of the collapsed ancestor that hid it. */ + hiddenBy: Map +} + +/** + * Single source of truth for visibility under collapse. O(n) over nodes + * (the spatial pass is O(z·n) where z is the number of collapsed zones). + */ +export function computeCollapseInfo(nodes: Node[]): CollapseInfo { + const childrenByParent = buildChildrenByParent(nodes) + const hidden = new Set() + const hiddenBy = new Map() + + const hideSubtree = (rootId: string, hider: string) => { + const queue = [...(childrenByParent.get(rootId) ?? [])] + while (queue.length > 0) { + const id = queue.shift()! + if (hidden.has(id)) continue + hidden.add(id) + if (!hiddenBy.has(id)) hiddenBy.set(id, hider) + const sub = childrenByParent.get(id) + if (sub) queue.push(...sub) + } + } + + // Pass 1 — parentId-based collapse (real containers). + for (const n of nodes) { + if (n.data.collapsed) hideSubtree(n.id, n.id) + } + + // Pass 2 — spatial collapse (groupRect zones). + for (const n of nodes) { + if (n.data.type !== 'groupRect') continue + if (!n.data.collapsed) continue + const contained = getZoneSpatialChildren(n, nodes) + for (const id of contained) { + if (!hidden.has(id)) { + hidden.add(id) + if (!hiddenBy.has(id)) hiddenBy.set(id, n.id) + } + hideSubtree(id, n.id) + } + } + + const visibleIds = new Set() + for (const n of nodes) { + if (!hidden.has(n.id)) visibleIds.add(n.id) + } + return { visibleIds, hiddenBy } +} + +/** + * Convenience wrapper kept for call sites that only need the visible set. + */ +export function getVisibleNodeIds(nodes: Node[]): Set { + return computeCollapseInfo(nodes).visibleIds +} + +/** + * Rewire edges so that any endpoint inside a collapsed subtree (parentId or + * spatial) is replaced with the nearest visible ancestor. See module + * docstring for the full rationale. + * + * - Both endpoints visible → edge kept as-is. + * - One endpoint hidden → endpoint replaced by its nearest + * visible ancestor; edge surfaces + * as a stub on the collapsed zone. + * - Both endpoints hidden under the + * same visible ancestor → dropped (would be a self-loop). + * - Parallel rewires to the same pair → de-duplicated; one stub kept. + * (Prevents a 20-device mesh from rendering 20 stacked stubs.) + * - Endpoint with no visible ancestor → dropped. + */ +export function rewireEdgesForCollapse( + edges: Edge[], + nodes: Node[], + visibleIds: Set, + hiddenBy?: Map, +): Edge[] { + // If the caller already computed hiddenBy (CanvasContainer path), reuse + // it. Otherwise recompute — keeps the helper callable from tests without + // forcing them to thread the second map through. + const hb = hiddenBy ?? computeCollapseInfo(nodes).hiddenBy + + const nearestVisible = (id: string): string | null => { + let cur: string | undefined = id + const guard = new Set() + while (cur !== undefined) { + if (visibleIds.has(cur)) return cur + if (guard.has(cur)) return null + guard.add(cur) + cur = hb.get(cur) + } + return null + } + + const seen = new Set() + const out: Edge[] = [] + for (const e of edges) { + const src = nearestVisible(e.source) + const tgt = nearestVisible(e.target) + if (src === null || tgt === null) continue + if (src === tgt) continue + const key = `${src}->${tgt}` + if (seen.has(key)) continue + seen.add(key) + if (src === e.source && tgt === e.target) { + out.push(e) + } else { + out.push({ ...e, source: src, target: tgt, sourceHandle: null, targetHandle: null }) + } + } + return out +}