fb80146430
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
188 lines
12 KiB
Markdown
188 lines
12 KiB
Markdown
# 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):** `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/: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.
|