diff --git a/backend/app/api/routes/liveview.py b/backend/app/api/routes/liveview.py index 6b9bad2..c6cd0f1 100644 --- a/backend/app/api/routes/liveview.py +++ b/backend/app/api/routes/liveview.py @@ -34,8 +34,10 @@ async def liveview_canvas( edges = (await db.execute(select(Edge))).scalars().all() state = await db.get(CanvasState, 1) viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1} + custom_style: dict[str, Any] | None = state.custom_style if state else None return CanvasStateResponse( nodes=[NodeResponse.model_validate(n) for n in nodes], edges=[EdgeResponse.model_validate(e) for e in edges], viewport=viewport, + custom_style=custom_style, ) diff --git a/backend/tests/test_liveview.py b/backend/tests/test_liveview.py index bf27b85..835fbe6 100644 --- a/backend/tests/test_liveview.py +++ b/backend/tests/test_liveview.py @@ -112,6 +112,28 @@ async def test_liveview_returns_saved_canvas(client: AsyncClient, auth_headers): assert nodes[0]["label"] == "Live Node" +# ── custom_style + theme propagation ───────────────────────────────────────── + +@pytest.mark.asyncio +async def test_liveview_returns_custom_style_and_theme(client: AsyncClient, auth_headers): + """custom_style and viewport.theme_id from a saved canvas surface in liveview.""" + settings.liveview_key = "test-key" + headers = await auth_headers() + payload = { + "nodes": [], + "edges": [], + "viewport": {"x": 0, "y": 0, "zoom": 1, "theme_id": "matrix"}, + "custom_style": {"fontFamily": "Inter", "nodeRadius": 12}, + } + await client.post("/api/v1/canvas/save", json=payload, headers=headers) + + res = await client.get("/api/v1/liveview?key=test-key") + assert res.status_code == 200 + body = res.json() + assert body["viewport"].get("theme_id") == "matrix" + assert body["custom_style"] == {"fontFamily": "Inter", "nodeRadius": 12} + + # ── Re-disable after enabling ───────────────────────────────────────────────── @pytest.mark.asyncio diff --git a/frontend/src/components/LiveView.tsx b/frontend/src/components/LiveView.tsx index 930fb5d..87bff74 100644 --- a/frontend/src/components/LiveView.tsx +++ b/frontend/src/components/LiveView.tsx @@ -29,7 +29,7 @@ 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 { liveviewApi } from '@/api/client' -import type { NodeData } from '@/types' +import type { NodeData, CustomStyleDef } from '@/types' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STORAGE_KEY = 'homelable_canvas' @@ -40,6 +40,8 @@ function LiveViewCanvas() { const { nodes, edges, loadCanvas, fitViewPending, clearFitViewPending } = useCanvasStore() const { fitView } = useReactFlow() const activeTheme = useThemeStore((s) => s.activeTheme) + const setTheme = useThemeStore((s) => s.setTheme) + const setCustomStyle = useThemeStore((s) => s.setCustomStyle) const theme = THEMES[activeTheme] // Derive initial view state synchronously (avoids calling setState inside an effect): // - standalone → always ready (localStorage, no key required) @@ -73,9 +75,12 @@ function LiveViewCanvas() { const { nodes: apiNodes, edges: apiEdges } = res.data const proxmoxMap = new Map( (apiNodes as ApiNode[]) - .filter((n: ApiNode) => n.type === 'proxmox' || n.type === 'group') - .map((n: ApiNode) => [n.id, n.type === 'group' ? true : n.container_mode !== false]) + .filter((n: ApiNode) => n.type === 'group' || n.container_mode === true) + .map((n: ApiNode) => [n.id, true]) ) + const savedTheme = res.data.viewport?.theme_id + if (savedTheme) setTheme(savedTheme) + if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef) loadCanvas( (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)), (apiEdges as ApiEdge[]).map(deserializeApiEdge), diff --git a/frontend/src/components/__tests__/LiveView.test.tsx b/frontend/src/components/__tests__/LiveView.test.tsx index 603d1fa..89c6b39 100644 --- a/frontend/src/components/__tests__/LiveView.test.tsx +++ b/frontend/src/components/__tests__/LiveView.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import { useCanvasStore } from '@/stores/canvasStore' +import { useThemeStore } from '@/stores/themeStore' // ── Mock heavy dependencies ──────────────────────────────────────────────── @@ -124,6 +125,55 @@ describe('LiveView (non-standalone)', () => { expect(nodes.find((n) => n.id === 'n1')).toBeDefined() }) + // ── Nested children (docker_container inside docker_host) ──────────────── + + it('nests docker_container under docker_host parent (container_mode=true)', async () => { + setSearch('?key=valid') + const nestedPayload = { + data: { + nodes: [ + { + id: 'host', type: 'docker', label: 'Docker Host', status: 'online', + services: [], pos_x: 0, pos_y: 0, container_mode: true, + created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', + }, + { + id: 'ctr', type: 'docker_container', label: 'nginx', status: 'online', + services: [], pos_x: 20, pos_y: 30, parent_id: 'host', + created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', + }, + ], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + } + vi.mocked(liveviewApi.load).mockResolvedValue(nestedPayload as never) + render() + await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined()) + const ctr = useCanvasStore.getState().nodes.find((n) => n.id === 'ctr') + expect(ctr?.parentId).toBe('host') + expect(ctr?.extent).toBe('parent') + }) + + // ── Theme + custom_style applied from payload ──────────────────────────── + + it('applies viewport.theme_id and custom_style from the payload', async () => { + setSearch('?key=valid') + const styledPayload = { + data: { + nodes: [], + edges: [], + viewport: { x: 0, y: 0, zoom: 1, theme_id: 'matrix' }, + custom_style: { fontFamily: 'Inter', nodeRadius: 12 }, + }, + } + vi.mocked(liveviewApi.load).mockResolvedValue(styledPayload as never) + render() + await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined()) + expect(useThemeStore.getState().activeTheme).toBe('matrix') + expect(useThemeStore.getState().customStyle).toEqual({ fontFamily: 'Inter', nodeRadius: 12 }) + }) + // ── No editing props passed ─────────────────────────────────────────────── it('does not show any Access Denied when key is valid', async () => {