ADR-009: Drag-reorder dashboard layout via a single JSONB layout doc¶
Status: Accepted Source: prd.md §19
Context¶
Edit mode (ADR-005's deletion gating) only revealed a Remove button. Operators asked for the obvious next thing: drag widgets to change their position so the dashboard reflects how they actually use it. The user-built widgets in the rail are addressed by widget_id UUIDs, but the canonical surfaces (KPI tiles, heatmap row, detail row, top-level sections) are JSX in frontend/src/App.tsx with no IDs and no persistence. Two model choices were on the table: (a) add a position INT column to widgets for the rail and a separate JSONB column for canonical sections, or (b) one document covering all four levels of ordering at once.
Decision¶
- One layout doc per owner.
db/init.sqlgains adashboard_layoutstable with(layout_id, owner UNIQUE, doc JSONB, updated_at).backend/app/dashboard/layout.pydefinesDashboardLayoutDocwithversion, sections, kpi_strip, heatmap_row, detail_row, my_widgets. The single demo userjane.smith@asurion.comis the onlyownertoday; multi-user ordering is a follow-up. - Canonical IDs are the contract. Five top-level section IDs (
kpi_strip,my_widgets,heatmap_row,detail_row,connected_systems); five KPI tile IDs (active_issues,claims_in_progress,nba_taken_pct,cost_avoided_mtd,csat); three heatmap-row panel IDs (heatmap,top_issues,recommendations); three detail-row panel IDs (timeline,customer_device,alerts). The canonical sets live inbackend/app/dashboard/layout.pyandfrontend/src/dashboard/layoutRegistry.ts— both files must agree. - Strict validation, lenient backfill.
PUT /v1/dashboard/layoutrejects unknown section/panel/KPI IDs with HTTP 422 (caller bug, fail loud). It accepts shorter-than-canonical lists by appending the missing IDs at the tail in canonical order — so when a future PR adds a new KPI tile, every saved layout automatically renders it (just at the end) instead of dropping it. Widget UUIDs receive the same lenient-backfill treatment from the livewidgetstable on every GET, so newly-created widgets surface immediately without re-saving. - Cross-row movement is intentionally out of scope. A panel in
heatmap_rowcannot be dragged intodetail_row. Each row's column grammar (12-column grid with fixed per-panel spans) is a meaningful UX constraint we do not relax here; allowing cross-row movement would require unifying the column grammar across all rows. - One DndContext, one persisted doc.
frontend/src/dashboard/sections.tsxwraps the entire dashboard<main>in a single@dnd-kit/coreDndContextwith multipleSortableContexts underneath —onDragEnddiscriminates byactive.idmembership and dispatches to the right reorder callback. Optimistic local update + 250 ms debouncedPUT /v1/dashboard/layout+ rollback on non-2xx, all infrontend/src/dashboard/useDashboardLayout.ts. - Drag handles are edit-mode-only and visually distinct. Per the Edit-mode gates destructive controls lesson: in view mode, no handle is rendered and the dashboard looks exactly like before. In edit mode, each tile/panel/card gets a small grip-icon button — top-left for widget cards (Remove drops just below it; MetricInfoBadge stays top-right), top-right for KPI tiles, top-center floating above for top-level sections (so the section handle never collides with inner corner handles).
Consequences¶
- One round-trip persists any reorder (sections, KPI tiles, panels, widgets). New widgets created via the Add Widget Clarifier are immediately appended to
my_widgetson the next GET — without requiring the client to re-save. - The validator's lenient backfill makes the schema forward-compatible: shipping a new canonical KPI tile or panel does not invalidate any saved layout. The strict-on-unknown side keeps the doc honest — a typo'd ID still fails loudly.
- We added a real test layer for both sides (
backend/tests/with pytest,frontend/src/**/__tests__/with Vitest + React Testing Library) — previously the repo only hadscripts/verify-acceptance.sh. Future work gets to keep the runner instead of re-bootstrapping it. - The hackathon-scoped non-goal is multi-user ordering. The
ownercolumn has aUNIQUEconstraint and a single-row demo seed; turning it into "per-user" is a column rename + a session/auth lookup — a straightforward future change.
Cross-references¶
- ADR-005, ADR-007 — the widget surfaces this layout doc orders.
- Lessons-learned: Edit-mode gates destructive controls, Keyboard-sensor reorder needs real layout.
- Implementation: backend/app/dashboard/, frontend/src/dashboard/.