diff --git a/backend/app/api/routes/canvas.py b/backend/app/api/routes/canvas.py index 6742ca9..c78e3b2 100644 --- a/backend/app/api/routes/canvas.py +++ b/backend/app/api/routes/canvas.py @@ -37,6 +37,9 @@ async def load_canvas( edges=[EdgeResponse.model_validate(e) for e in edges], viewport=viewport, custom_style=state.custom_style if state else None, + # A CanvasState row exists only after a save (or explicit design create), + # so its presence marks an intentional canvas vs. a never-touched one. + initialized=state is not None, ) diff --git a/backend/app/schemas/canvas.py b/backend/app/schemas/canvas.py index 9a62230..f2b689c 100644 --- a/backend/app/schemas/canvas.py +++ b/backend/app/schemas/canvas.py @@ -84,3 +84,8 @@ class CanvasStateResponse(BaseModel): edges: list[EdgeResponse] viewport: dict[str, Any] custom_style: dict[str, Any] | None = None + # True once this design's canvas has ever been persisted (a CanvasState row + # exists). Lets the frontend tell a brand-new user (show demo) apart from one + # who intentionally cleared their canvas (keep it empty). False also for a + # missing/uninitialized design. + initialized: bool = False diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py index 435fc92..abb51a6 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -22,6 +22,23 @@ async def test_load_canvas_empty(client: AsyncClient, headers: dict): assert data["viewport"] == {"x": 0, "y": 0, "zoom": 1} +async def test_load_canvas_uninitialized_reports_initialized_false(client: AsyncClient, headers: dict): + # A never-saved canvas has no CanvasState row → initialized False. The frontend + # uses this to show the demo canvas only to genuinely new users. + data = (await client.get("/api/v1/canvas", headers=headers)).json() + assert data["initialized"] is False + + +async def test_load_canvas_initialized_true_after_save(client: AsyncClient, headers: dict): + # Saving an EMPTY canvas still creates a CanvasState row, so a subsequently + # loaded empty canvas is reported initialized — the user cleared it on purpose + # and must not get the demo re-seeded. + await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}}, headers=headers) + data = (await client.get("/api/v1/canvas", headers=headers)).json() + assert data["nodes"] == [] + assert data["initialized"] is True + + async def test_load_canvas_requires_auth(client: AsyncClient): res = await client.get("/api/v1/canvas") assert res.status_code == 401 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d262891..5395ae8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -45,6 +45,7 @@ import { useThemeStore } from '@/stores/themeStore' import { canvasApi, designsApi, liveviewApi } from '@/api/client' import * as standaloneStorage from '@/utils/standaloneStorage' import { demoNodes, demoEdges } from '@/utils/demoData' +import { decideCanvasLoad, isNewUserCanvas } from '@/utils/canvasLoadDecision' import { useStatusPolling } from '@/hooks/useStatusPolling' import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig, NodeType } from '@/types' import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types' @@ -74,6 +75,13 @@ export default function App() { const loadedDesignIdRef = useRef(loadedDesignId) useEffect(() => { loadedDesignIdRef.current = loadedDesignId }, [loadedDesignId]) + // True while the last canvas load failed (backend down/error). Drives the error + // banner and keeps us from masking a real failure with the demo canvas. + const [loadError, setLoadError] = useState(false) + // True when the loaded canvas is the demo (a brand-new user). Reserved as the + // entry signal for the upcoming Getting Started walkthrough. + const [isNewUser, setIsNewUser] = useState(false) + const [themeModalOpen, setThemeModalOpen] = useState(false) const [styleEditorType, setStyleEditorType] = useState(null) const [searchOpen, setSearchOpen] = useState(false) @@ -151,55 +159,83 @@ export default function App() { }) const loadCanvasFromApi = useCallback(async (designId?: string) => { + let res try { - const res = await canvasApi.load(designId) - const { nodes: apiNodes, edges: apiEdges } = res.data - if (apiNodes.length > 0) { - const proxmoxContainerMap = new Map( - (apiNodes as ApiNode[]) - .filter((n) => n.type === 'group' || n.container_mode === true) - .map((n) => [n.id, true]) - ) - const { nodes: rfNodes, edges: rfEdges } = migrateClusterHandles( - (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)), - (apiEdges as ApiEdge[]).map(deserializeApiEdge), - ) - const savedTheme = res.data.viewport?.theme_id - if (savedTheme) setTheme(savedTheme) - if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef) - const savedFloorMap = res.data.viewport?.floor_map as FloorMapConfig | undefined - // Clear when the target design has no floor plan, so it doesn't bleed - // across canvases when switching designs. - setFloorMap(savedFloorMap ?? null) - loadCanvas(rfNodes, rfEdges) - } else { - setFloorMap(null) - loadCanvas(demoNodes, demoEdges) - } + res = await canvasApi.load(designId) } catch { + // Backend down / errored. Surface it and STOP — never fall back to the demo + // canvas, which would hide the real error and look like a fresh account. + // Leave the on-screen canvas and provenance untouched so an autosave can't + // clobber real data with an empty canvas. + setLoadError(true) + toast.error('Could not load canvas — backend not responding') + return + } + const { nodes: apiNodes, edges: apiEdges } = res.data + const mode = decideCanvasLoad(apiNodes.length > 0, res.data.initialized === true) + if (mode === 'real') { + const proxmoxContainerMap = new Map( + (apiNodes as ApiNode[]) + .filter((n) => n.type === 'group' || n.container_mode === true) + .map((n) => [n.id, true]) + ) + const { nodes: rfNodes, edges: rfEdges } = migrateClusterHandles( + (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)), + (apiEdges as ApiEdge[]).map(deserializeApiEdge), + ) + const savedTheme = res.data.viewport?.theme_id + if (savedTheme) setTheme(savedTheme) + if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef) + const savedFloorMap = res.data.viewport?.floor_map as FloorMapConfig | undefined + // Clear when the target design has no floor plan, so it doesn't bleed + // across canvases when switching designs. + setFloorMap(savedFloorMap ?? null) + loadCanvas(rfNodes, rfEdges) + } else if (mode === 'empty') { + // Initialized but no nodes: the user cleared this canvas on purpose — respect + // it and keep it empty instead of re-seeding the demo. + const savedTheme = res.data.viewport?.theme_id + if (savedTheme) setTheme(savedTheme) + if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef) + setFloorMap(null) + loadCanvas([], []) + } else { + // Brand-new, never-saved canvas: seed the demo. setFloorMap(null) loadCanvas(demoNodes, demoEdges) - } finally { - // Record provenance so autosave writes back under the design just loaded. - setLoadedDesignId(designId ?? null) } + setLoadError(false) + setIsNewUser(isNewUserCanvas(mode)) + // Record provenance so autosave writes back under the design just loaded. + setLoadedDesignId(designId ?? null) }, [loadCanvas, setTheme, setCustomStyle, setFloorMap]) // Standalone counterpart of loadCanvasFromApi — reads a design's canvas from // localStorage, falling back to the demo canvas when it has never been saved. const loadStandaloneCanvas = useCallback((designId: string) => { const saved = standaloneStorage.loadCanvas(designId) - if (saved && saved.nodes.length > 0) { + // A stored entry (even with zero nodes) means the user has saved this canvas, + // so treat it as initialized and don't re-seed the demo on top of a canvas + // they deliberately cleared. + const mode = decideCanvasLoad((saved?.nodes.length ?? 0) > 0, saved !== null) + if (mode === 'real' && saved) { if (saved.theme_id) setTheme(saved.theme_id) if (saved.custom_style) setCustomStyle(saved.custom_style) // Floor plans are backend-only; keep the store clear in standalone mode. setFloorMap(null) const migrated = migrateClusterHandles(saved.nodes, saved.edges) loadCanvas(migrated.nodes, migrated.edges) + } else if (mode === 'empty' && saved) { + if (saved.theme_id) setTheme(saved.theme_id) + if (saved.custom_style) setCustomStyle(saved.custom_style) + setFloorMap(null) + loadCanvas([], []) } else { setFloorMap(null) loadCanvas(demoNodes, demoEdges) } + setLoadError(false) + setIsNewUser(isNewUserCanvas(mode)) // Record provenance so autosave writes back under the design just loaded. setLoadedDesignId(designId) }, [loadCanvas, setTheme, setCustomStyle, setFloorMap]) @@ -231,16 +267,24 @@ export default function App() { await loadCanvasFromApi(targetId) } } catch { - // If API fails (e.g. fresh DB with no designs), fall back to demo data - loadCanvas(demoNodes, demoEdges) + // Backend unreachable/errored — surface it. Do NOT seed the demo: that would + // hide a real outage behind a fake "new account" canvas. + setLoadError(true) + toast.error('Could not reach backend — check the server and retry') } - }, [setDesigns, setActiveDesign, loadCanvasFromApi, loadStandaloneCanvas, activeDesignId, loadCanvas]) + }, [setDesigns, setActiveDesign, loadCanvasFromApi, loadStandaloneCanvas, activeDesignId]) // Keep a ref so the auth effect can call the latest loader without listing it // as a dependency (which would re-fire on every design switch). const loadDesignsAndCanvasRef = useRef(loadDesignsAndCanvas) useEffect(() => { loadDesignsAndCanvasRef.current = loadDesignsAndCanvas }, [loadDesignsAndCanvas]) + // Retry a failed load from the error banner. + const handleRetryLoad = useCallback(() => { + setLoadError(false) + void loadDesignsAndCanvasRef.current() + }, []) + // Load designs + canvas on auth (or immediately in standalone mode, which has // no auth gate). useEffect(() => { @@ -825,7 +869,9 @@ export default function App() { return ( -
+ {/* data-new-user marks a first-time (demo) canvas — the hook the upcoming + Getting Started walkthrough keys off. */} +
setAddNodeOpen(true)} onAddGroupRect={() => setAddGroupRectOpen(true)} @@ -840,6 +886,21 @@ export default function App() { onOpenPending={openPendingModal} />
+ {loadError && ( +
+ Backend not responding — the canvas could not be loaded. + +
+ )} { + it('renders real nodes when the canvas has any', () => { + expect(decideCanvasLoad(true, true)).toBe('real') + // hasNodes wins even if the initialized flag is somehow false + expect(decideCanvasLoad(true, false)).toBe('real') + }) + + it('keeps an initialized-but-empty canvas empty (user cleared it)', () => { + expect(decideCanvasLoad(false, true)).toBe('empty') + }) + + it('shows the demo only for a brand-new, uninitialized canvas', () => { + expect(decideCanvasLoad(false, false)).toBe('demo') + }) +}) + +describe('isNewUserCanvas', () => { + it('is true only for the demo mode', () => { + expect(isNewUserCanvas('demo')).toBe(true) + expect(isNewUserCanvas('empty')).toBe(false) + expect(isNewUserCanvas('real')).toBe(false) + }) +}) diff --git a/frontend/src/utils/canvasLoadDecision.ts b/frontend/src/utils/canvasLoadDecision.ts new file mode 100644 index 0000000..498a1c8 --- /dev/null +++ b/frontend/src/utils/canvasLoadDecision.ts @@ -0,0 +1,25 @@ +/** + * Decides what to render when a design's canvas loads. + * + * Three cases must stay distinct (issue: clearing a canvas re-showed the demo): + * - `real` → the canvas has saved nodes; render them. + * - `empty` → the canvas is initialized (ever saved / explicitly created) but + * has no nodes; the user intentionally cleared it — keep it empty. + * - `demo` → brand-new, never-initialized canvas; seed the demo so first-time + * users see an example (and, later, the Getting Started walkthrough). + * + * Backend errors are handled by the caller, NOT here — a failed load must show an + * error and must never fall back to `demo` (that would hide the real failure). + */ +export type CanvasLoadMode = 'real' | 'empty' | 'demo' + +export function decideCanvasLoad(hasNodes: boolean, initialized: boolean): CanvasLoadMode { + if (hasNodes) return 'real' + if (initialized) return 'empty' + return 'demo' +} + +/** A new user is one who lands on the demo canvas — the Getting Started hook. */ +export function isNewUserCanvas(mode: CanvasLoadMode): boolean { + return mode === 'demo' +}