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:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+73
-12
@@ -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,10 +159,21 @@ export default function App() {
|
||||
})
|
||||
|
||||
const loadCanvasFromApi = useCallback(async (designId?: string) => {
|
||||
let res
|
||||
try {
|
||||
const res = await canvasApi.load(designId)
|
||||
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
|
||||
if (apiNodes.length > 0) {
|
||||
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)
|
||||
@@ -172,34 +191,51 @@ export default function App() {
|
||||
// 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)
|
||||
}
|
||||
} catch {
|
||||
setFloorMap(null)
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
} finally {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user