Skip to content

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

  1. One layout doc per owner. db/init.sql gains a dashboard_layouts table with (layout_id, owner UNIQUE, doc JSONB, updated_at). backend/app/dashboard/layout.py defines DashboardLayoutDoc with version, sections, kpi_strip, heatmap_row, detail_row, my_widgets. The single demo user jane.smith@asurion.com is the only owner today; multi-user ordering is a follow-up.
  2. 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 in backend/app/dashboard/layout.py and frontend/src/dashboard/layoutRegistry.ts — both files must agree.
  3. Strict validation, lenient backfill. PUT /v1/dashboard/layout rejects 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 live widgets table on every GET, so newly-created widgets surface immediately without re-saving.
  4. Cross-row movement is intentionally out of scope. A panel in heatmap_row cannot be dragged into detail_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.
  5. One DndContext, one persisted doc. frontend/src/dashboard/sections.tsx wraps the entire dashboard <main> in a single @dnd-kit/core DndContext with multiple SortableContexts underneath — onDragEnd discriminates by active.id membership and dispatches to the right reorder callback. Optimistic local update + 250 ms debounced PUT /v1/dashboard/layout + rollback on non-2xx, all in frontend/src/dashboard/useDashboardLayout.ts.
  6. 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_widgets on 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 had scripts/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 owner column has a UNIQUE constraint 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