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

188 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.