diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d755085..0f833c2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -498,7 +498,10 @@ export default function App() { // Otherwise fetch the configured live view key and build /view?key=...&design=. const handleViewOnly = useCallback(async () => { if (STANDALONE) { - window.open('/view', '_blank', 'noopener,noreferrer') + // Standalone reads canvas from localStorage; pass the active design id so + // the read-only tab renders the same canvas the user is viewing. + const url = activeDesignId ? `/view?design=${encodeURIComponent(activeDesignId)}` : '/view' + window.open(url, '_blank', 'noopener,noreferrer') return } try { @@ -933,9 +936,9 @@ export default function App() { onCancel={() => setPendingContainerAdd(null)} /> - {!STANDALONE && ( - setSettingsOpen(false)} /> - )} + {/* Mounted in standalone too: status-check settings are hidden inside, + but canvas prefs (snap, hide-IP) still apply. */} + setSettingsOpen(false)} /> { if (STANDALONE) { - try { - const saved = localStorage.getItem(STORAGE_KEY) - if (saved) { - const { nodes: savedNodes, edges: savedEdges } = JSON.parse(saved) - loadCanvas(savedNodes, savedEdges) - } - } catch { - // empty canvas on parse error — show empty canvas + // ?design= selects which canvas to render; fall back to the first + // design when omitted. Standalone stores full React Flow nodes/edges, so + // no API deserialization is needed. + const designId = new URLSearchParams(window.location.search).get('design') + ?? standaloneStorage.listDesigns()[0]?.id + const saved = designId ? standaloneStorage.loadCanvas(designId) : null + if (saved) { + if (saved.theme_id) setTheme(saved.theme_id) + if (saved.custom_style) setCustomStyle(saved.custom_style) + loadCanvas(saved.nodes, saved.edges) } return } diff --git a/frontend/src/components/__tests__/LiveView.test.tsx b/frontend/src/components/__tests__/LiveView.test.tsx index 8a8bd4f..f230501 100644 --- a/frontend/src/components/__tests__/LiveView.test.tsx +++ b/frontend/src/components/__tests__/LiveView.test.tsx @@ -2,6 +2,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' +import * as standaloneStorage from '@/utils/standaloneStorage' // ── Mock heavy dependencies ──────────────────────────────────────────────── @@ -213,9 +214,13 @@ describe('LiveView (non-standalone)', () => { // ── Standalone mode ──────────────────────────────────────────────────────── +// Captures props from the re-imported (resetModules) ReactFlow so standalone +// tests can assert which nodes were rendered without reaching into the fresh +// canvas-store module instance. +let standaloneRfProps: Record = {} const XYFLOW_MOCK = { ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}, - ReactFlow: () =>
, + ReactFlow: (props: Record) => { standaloneRfProps = props; return
}, Background: () => null, Controls: () => null, BackgroundVariant: { Dots: 'dots' }, @@ -235,16 +240,45 @@ describe('LiveView (standalone — localStorage)', () => { vi.unstubAllEnvs() }) - it('loads canvas from localStorage without calling the API', async () => { - const stored = { + it('loads the active design canvas from localStorage without calling the API', async () => { + const design = standaloneStorage.createDesign('Main') + standaloneStorage.saveCanvas(design.id, { nodes: [{ id: 'ls-node', type: 'router', position: { x: 10, y: 20 }, data: { label: 'Router', type: 'router', status: 'unknown', services: [] }, }], edges: [], - } - localStorage.setItem('homelable_canvas', JSON.stringify(stored)) + }) + + vi.stubEnv('VITE_STANDALONE', 'true') + vi.resetModules() + const mockLoad = vi.fn() + vi.doMock('@xyflow/react', () => XYFLOW_MOCK) + vi.doMock('@xyflow/react/dist/style.css', () => ({})) + vi.doMock('@/api/client', () => ({ liveviewApi: { load: mockLoad } })) + const { default: LiveViewStandalone } = await import('../LiveView') + + setSearch(`?design=${design.id}`) + render() + + await waitFor(() => { + expect(screen.getByTestId('react-flow')).toBeDefined() + }) + expect((standaloneRfProps.nodes as { id: string }[]).map((n) => n.id)).toContain('ls-node') + expect(mockLoad).not.toHaveBeenCalled() + }) + + it('falls back to the first design when no ?design= param is given', async () => { + const design = standaloneStorage.createDesign('Only') + standaloneStorage.saveCanvas(design.id, { + nodes: [{ + id: 'fb-node', type: 'server', + position: { x: 0, y: 0 }, + data: { label: 'Srv', type: 'server', status: 'unknown', services: [] }, + }], + edges: [], + }) vi.stubEnv('VITE_STANDALONE', 'true') vi.resetModules() @@ -260,6 +294,7 @@ describe('LiveView (standalone — localStorage)', () => { await waitFor(() => { expect(screen.getByTestId('react-flow')).toBeDefined() }) + expect((standaloneRfProps.nodes as { id: string }[]).map((n) => n.id)).toContain('fb-node') expect(mockLoad).not.toHaveBeenCalled() })