Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
12 KiB
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
objecttable hascreated_at+updated_at(migrations/0003_object.sql);updated_atis set tonow()on every write; the db layer already reads them intoCatalogueObject. They are simply not exposed inAdminObjectView— so adding an "Updated" column needs no migration, just two fields onAdminObjectView::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-reactis already installed (nav icons); Base UI shipsdrawer,collapsible, andtooltipprimitives (the slide-in detail + sidebar).
Decisions (from brainstorming)
- 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/:idis a canonical, shareable URL. - 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.
- One milestone, built in phases (backend → table → shell/responsive/detail).
- 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 inlocalStorage(e.g.sidebar-collapsed). - Each nav item (
objects,vocabularies,authorities,search,fields) gets alucide-reacticon. When collapsed, the label is shown via a Base UITooltipon hover and as thearia-label/titlefor AT. - Below a width breakpoint the sidebar auto-collapses to the rail (the user can still
toggle). Nav
NavLinkactive 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) — PostgresILIKEonobject_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): from–to 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/:idis 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
Drawerover the table, with a backdrop. A close affordance returns to/objects, table state preserved via the URL. - Implementation: nested routing — the
/objectsroute renders the table; an:idchild controls the pane/drawer (presence of:idopens it). The pane-vs-drawer switch is by viewport width (CSS breakpoint / amatchMediahook); theDraweris used only at narrow widths. - Reuses the existing
ObjectDetail. Its content improvements (resolving term/authority/localized_textto 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; defaultobject_number),order(asc|desc; defaultasc),visibility(optional filter: draft|internal|public),q(optional text). All optional; absent → today's behavior. list_objects_pagedextended to accept the sort column + direction + filters. BuildORDER BYfrom the whitelisted enum (never interpolate a raw client string — SQL-injection safe) andWHEREclauses forvisibility = $and/or(object_number ILIKE $q OR object_name ILIKE $q).count_objectstakes the same filters so the total reflects the filtered set.- Expose timestamps: add
created_at+updated_at(RFC3339 strings) toAdminObjectViewandAdminObjectView::from_object(values already present on the domain object). No migration. - Gated by
ViewInternalas today. Regenerateweb/src/api/schema.d.ts.
5. Frontend data layer (web/src/api/queries.ts)
useObjectsPagegains{ sort, order, visibility, q, limit, offset }; the query key includes them; useplaceholderData: keepPreviousDataso sorting/paging/filtering doesn't flash empty.- A small
use-media-query/matchMediahook 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
Skeletonloading per #53 if convenient, else keep current). - Invalid
sort/order/visibilityfrom 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/:idfor a missing/deleted object: existing 404 handling (useObject→objects.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_objectshonorssort+order(e.g. byobject_name desc, byupdated_at),visibilityfilter, andqILIKE; thetotalreflects the filter; default (no params) matches today'sobject_number asc; an unknownsortvalue is rejected/falls back.AdminObjectViewincludescreated_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 witharia-sort; visibility chips + quick filter update the URL and query (debounced); pagination + page-size update offset/limit; row click navigates to/objects/:idand 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
Drawerper a mockedmatchMediawidth. - 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
/objectsis 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.- Pagination has prev/next + a page-size selector + a true (filtered) total.
- The sidebar collapses to an icon rail (persisted) and auto-collapses on narrow viewports.
- Selecting a row opens detail as a right pane (wide) or slide-in drawer (narrow);
/objects/:idis a canonical shareable URL that opens that object directly. - Backend exposes
created_at/updated_atand supportssort/order/visibility/q(injection-safe, filtered total); OpenAPI regenerated. - 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)
- Backend:
sort/order/visibility/qparams + filtered count + expose timestamps + OpenAPI regen. - Table: full-width table, columns, sortable headers, filters, pagination + page-size,
URL-synced state,
useObjectsPageparams (+ stories). - Shell & detail: collapsible icon sidebar (lucide + tooltip + persistence + auto-collapse),
responsive detail pane/drawer, canonical
/objects/:idrouting (+ 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.