Files
biggus-dickus/docs/superpowers/specs/2026-06-06-objects-table-and-shell-design.md
T
2026-06-06 21:50:13 +02:00

12 KiB
Raw Blame History

Objects Data-Overview Table + Responsive Shell — Design

Date: 2026-06-06 Status: Approved (brainstorming) — ready for implementation planning. Issues: #44 (object list → table); subsumes #58 (responsive layout) for the shell.

Context

The Objects screen is where curators triage hundreds of records daily, but today web/src/objects/object-list.tsx renders a thin 20rem list (object_number + name + visibility badge) inside a master/detail grid, with no columns, sort, or filter. The backend GET /api/admin/objects (list_objects_paged) takes only limit/offset and orders by object_number. A separate search-panel.tsx (Meilisearch full-text, infinite scroll, visibility filter) is a parallel browse UI with different ergonomics. Goal: a real, scannable, sortable, filterable data-overview table plus a shell that adapts to viewport width and gives every object a shareable URL.

Facts established during exploration

  • Timestamps already exist. The object table has created_at + updated_at (migrations/0003_object.sql); updated_at is set to now() on every write; the db layer already reads them into CatalogueObject. They are simply not exposed in AdminObjectView — so adding an "Updated" column needs no migration, just two fields on AdminObjectView::from_object.
  • Search is best-effort/optional (AppState.search: None → the search endpoint 503s). So the Postgres-backed list must remain the always-available browse surface; full-text search is a layer on top, not a replacement.
  • No new dependencies needed: lucide-react is already installed (nav icons); Base UI ships drawer, collapsible, and tooltip primitives (the slide-in detail + sidebar).

Decisions (from brainstorming)

  1. Layout: a Linear/email-style shell — collapsible icon sidebar; a full-width objects table as the overview; selecting a row opens detail as a right-hand pane on wide viewports / a slide-in drawer when narrow; /objects/:id is a canonical, shareable URL.
  2. Search: table-first. The table gets Postgres-backed sort + visibility filter + a quick text filter (object number/name). The dedicated Meilisearch Search screen stays as-is; folding full-text into the table's search box is a deferred follow-up.
  3. One milestone, built in phases (backend → table → shell/responsive/detail).
  4. Storybook stories for meaningful new components (per the standing preference).

1. Shell: collapsible icon sidebar + responsive frame

web/src/shell/app-shell.tsx:

  • The sidebar gains a collapse toggle; expanded = w-44 (icon + label), collapsed = an icon rail (~w-14, icon-only). State persisted in localStorage (e.g. sidebar-collapsed).
  • Each nav item (objects, vocabularies, authorities, search, fields) gets a lucide-react icon. When collapsed, the label is shown via a Base UI Tooltip on hover and as the aria-label/title for AT.
  • Below a width breakpoint the sidebar auto-collapses to the rail (the user can still toggle). Nav NavLink active state + focus-visible rings preserved/added.
  • This resolves #58 at the shell level (the per-screen master/detail responsiveness is handled in §3).

2. Objects table (/objects)

Replace the narrow list with a full-width table filling the main content area.

Columns (default): Object № (sortable) · Name (sortable) · Visibility (badge; filterable) · Current location · # objects · Updated (sortable). Real <table> semantics with scope="col" headers and aria-sort on the active sort column.

Toolbar (above the table):

  • A debounced quick text filter (q) — Postgres ILIKE on object_number + object_name (always available; distinct from the Meili Search screen which searches descriptions/fields).
  • Visibility filter chips (all / draft / internal / public), mirroring the search panel's pattern (honest <button aria-pressed>).
  • The New button (right-aligned).

Sorting: clicking a sortable header toggles sort column + direction (server-side); default object_number asc (today's order). Reflected in aria-sort.

"Updated" rendering: relative ("2d", "1w") with an absolute tooltip, formatted in the instance timezone/locale via Intl (useConfig().default_timezone + active language).

Pagination (footer): fromto of total, prev/next, and a page-size selector (25/50/100/200 — backend caps at 200). Keep the offset model (it supports sort + a true total cleanly; infinite scroll does not).

URL-synced state: q, visibility, sort, order, and the page offset live in the URL query string (the search panel already does this for q/visibility). This makes the table shareable, back-button-friendly, and preserves position across the row→detail→back round-trip.

Row interaction: click navigates to /objects/:id (canonical); the selected row is highlighted; keyboard-navigable.

3. Detail presentation + canonical URL (/objects/:id)

  • /objects/:id is the canonical, shareable address — opening the link loads the table and reveals that object's detail.
  • Wide viewport: detail renders as a right-hand pane beside the (compressed) table. Narrow viewport: detail slides in from the right as a Base UI Drawer over the table, with a backdrop. A close affordance returns to /objects, table state preserved via the URL.
  • Implementation: nested routing — the /objects route renders the table; an :id child controls the pane/drawer (presence of :id opens it). The pane-vs-drawer switch is by viewport width (CSS breakpoint / a matchMedia hook); the Drawer is used only at narrow widths.
  • Reuses the existing ObjectDetail. Its content improvements (resolving term/authority/localized_text to labels, grouping by field group) are issue #45 and explicitly out of scope here — this milestone changes where/how detail is presented, not its internals.

4. Backend contract (crates/api/src/admin_objects.rs, crates/db/src/catalog.rs)

  • Query params on GET /api/admin/objects: sort (enum: object_number | object_name | updated_at | created_at | visibility; default object_number), order (asc | desc; default asc), visibility (optional filter: draft|internal|public), q (optional text). All optional; absent → today's behavior.
  • list_objects_paged extended to accept the sort column + direction + filters. Build ORDER BY from the whitelisted enum (never interpolate a raw client string — SQL-injection safe) and WHERE clauses for visibility = $ and/or (object_number ILIKE $q OR object_name ILIKE $q). count_objects takes the same filters so the total reflects the filtered set.
  • Expose timestamps: add created_at + updated_at (RFC3339 strings) to AdminObjectView and AdminObjectView::from_object (values already present on the domain object). No migration.
  • Gated by ViewInternal as today. Regenerate web/src/api/schema.d.ts.

5. Frontend data layer (web/src/api/queries.ts)

  • useObjectsPage gains { sort, order, visibility, q, limit, offset }; the query key includes them; use placeholderData: keepPreviousData so sorting/paging/filtering doesn't flash empty.
  • A small use-media-query/matchMedia hook for the pane-vs-drawer breakpoint (if one doesn't already exist).

Data flow

/objects?sort=…&order=…&visibility=…&q=…&offset=…useObjectsPage(params)GET /api/admin/objects?… (Postgres, sorted/filtered, with filtered total) → table renders columns + aria-sort + pagination. Row click → /objects/:id (URL carries the table state) → detail pane (wide) or Drawer (narrow) over the table → close → /objects?… restored.

Error handling / edges

  • List load error / empty: reuse the existing error + empty states (standardized on Skeleton loading per #53 if convenient, else keep current).
  • Invalid sort/order/visibility from a hand-edited URL: backend rejects unknown enum values (422) or the handler falls back to defaults; the frontend clamps to known values.
  • Quick filter with no matches: empty-state message; pagination shows 0 of 0.
  • Deep-linking /objects/:id for a missing/deleted object: existing 404 handling (useObjectobjects.notFound); the table still renders behind/beside.
  • Narrow→wide resize while detail open: the pane/drawer swaps presentation without losing the selected :id.

Testing

Backend (#[sqlx::test], mirror crates/api/tests/admin_catalog.rs):

  • list_objects honors sort+order (e.g. by object_name desc, by updated_at), visibility filter, and q ILIKE; the total reflects the filter; default (no params) matches today's object_number asc; an unknown sort value is rejected/falls back.
  • AdminObjectView includes created_at/updated_at.
  • OpenAPI regenerated.

Frontend (Vitest + RTL + MSW):

  • Table renders the columns from a mocked page; a sortable header click updates the URL (sort/order) and re-queries with aria-sort; visibility chips + quick filter update the URL and query (debounced); pagination + page-size update offset/limit; row click navigates to /objects/:id and the table state (URL) is preserved on back.
  • Sidebar collapse toggles + persists to localStorage; collapsed rail shows tooltips/labels.
  • Detail presents as a pane vs Drawer per a mocked matchMedia width.
  • en/sv parity for new keys; no any/eslint-disable; no codename.

Storybook (per the standing preference — meaningful interactive components):

  • The table row (default / selected / various visibility), the sortable column header (idle / asc / desc), the pagination control, and the collapsible sidebar (expanded / collapsed). Mirror the established story format.

Bundle: pnpm check:size — index chunk ≤ 165 KB gz (lucide icons + any newly-used Base UI primitives land in the always-loaded shell; tree-shaken lucide imports keep this small — verify).

Acceptance criteria

  1. /objects is a full-width, scannable table (№, name, visibility, location, count, updated) with server-side sort, a visibility filter, and a quick text filter — all state in the URL.
  2. Pagination has prev/next + a page-size selector + a true (filtered) total.
  3. The sidebar collapses to an icon rail (persisted) and auto-collapses on narrow viewports.
  4. Selecting a row opens detail as a right pane (wide) or slide-in drawer (narrow); /objects/:id is a canonical shareable URL that opens that object directly.
  5. Backend exposes created_at/updated_at and supports sort/order/visibility/q (injection-safe, filtered total); OpenAPI regenerated.
  6. Storybook stories for the row/header/pagination/sidebar; cargo + web typecheck/lint/test/build green; index ≤ 165 KB gz; en/sv parity; no codename.

Phasing (for the plan)

  1. Backend: sort/order/visibility/q params + filtered count + expose timestamps + OpenAPI regen.
  2. Table: full-width table, columns, sortable headers, filters, pagination + page-size, URL-synced state, useObjectsPage params (+ stories).
  3. Shell & detail: collapsible icon sidebar (lucide + tooltip + persistence + auto-collapse), responsive detail pane/drawer, canonical /objects/:id routing (+ stories).

Out of scope → follow-ups

  • Meilisearch full-text unified into the table's search box (graceful fallback when search disabled) — deferred; the dedicated Search screen stays for now.
  • Object detail content (term/authority/localized_text → labels, group-by-group) — #45.
  • Multi-select / bulk actions (e.g. bulk visibility change); saved views/filters.
  • Per-screen responsive work beyond the Objects shell (other master/detail screens) — remainder of #58.