From 9823be9d78183201795f6d4d4c00b5a808dca898 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 29 May 2026 09:27:45 +0200 Subject: [PATCH] fix(liveview): apply collapse filter in read-only canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LiveView.tsx passed raw nodes/edges from the store straight to ReactFlow, bypassing the collapse pipeline that CanvasContainer applies in the editor. Result: a group or zone marked collapsed in edit mode still showed all its contents on /view?key=... and in standalone live view. The flag was being persisted and read correctly — only the read-only canvas ignored it. Reuse the same memoized computeCollapseInfo + rewireEdgesForCollapse pipeline. Add two regression tests that load a /liveview payload with a collapsed group and assert the children never reach ReactFlow. --- frontend/src/components/LiveView.tsx | 19 +++- .../__tests__/LiveView.collapse.test.tsx | 104 ++++++++++++++++++ .../group.collapse.integration.test.ts | 43 ++++++++ 3 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/__tests__/LiveView.collapse.test.tsx create mode 100644 frontend/src/utils/__tests__/group.collapse.integration.test.ts 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/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) + }) +})