feat(canvas): distinguish new user from cleared canvas; stop demo on backend error

Cleared canvas re-showed the demo, and backend errors silently fell back to
demo — hiding real outages and forcing users to wipe demo nodes before bulk
edits. Now:
- backend load reports `initialized` (CanvasState row exists = ever saved)
- decideCanvasLoad() picks real | empty | demo; empty is kept empty
- backend down/error shows an error banner + toast, never the demo
- data-new-user flag reserved as the Getting Started walkthrough hook

ha-relevant: yes
This commit is contained in:
Pouzor
2026-07-18 23:02:44 +02:00
parent 3f030959be
commit bf8551015c
6 changed files with 169 additions and 32 deletions
+3
View File
@@ -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,
)
+5
View File
@@ -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
+17
View File
@@ -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
+93 -32
View File
@@ -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<string | null>(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<NodeType | null>(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<string, boolean>(
(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<string, boolean>(
(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 (
<TooltipProvider>
<ReactFlowProvider>
<div className="flex h-screen w-screen overflow-hidden bg-[#0d1117]">
{/* data-new-user marks a first-time (demo) canvas — the hook the upcoming
Getting Started walkthrough keys off. */}
<div className="flex h-screen w-screen overflow-hidden bg-[#0d1117]" data-new-user={isNewUser}>
<Sidebar
onAddNode={() => setAddNodeOpen(true)}
onAddGroupRect={() => setAddGroupRectOpen(true)}
@@ -840,6 +886,21 @@ export default function App() {
onOpenPending={openPendingModal}
/>
<div className="flex flex-col flex-1 min-w-0">
{loadError && (
<div
role="alert"
className="flex items-center justify-between gap-3 bg-[#3d1418] border-b border-[#f85149] px-4 py-2 text-sm text-[#ffa198]"
>
<span>Backend not responding the canvas could not be loaded.</span>
<button
type="button"
onClick={handleRetryLoad}
className="rounded border border-[#f85149] px-2 py-0.5 text-[#ffa198] hover:bg-[#f85149]/20"
>
Retry
</button>
</div>
)}
<Toolbar
onSave={handleSave}
onAutoLayout={handleAutoLayout}
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest'
import { decideCanvasLoad, isNewUserCanvas } from '../canvasLoadDecision'
describe('decideCanvasLoad', () => {
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)
})
})
+25
View File
@@ -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'
}