Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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):** `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.
|
||||||
Reference in New Issue
Block a user