docs(specs): objects data-overview table + responsive shell (#44, subsumes #58)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 21:50:13 +02:00
parent b49699175d
commit fb80146430
@@ -0,0 +1,187 @@
# 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
(`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_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.