From e7ff817c637bb8ea46efc688de2c3ccf697f5fdd Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sat, 6 Jun 2026 21:58:06 +0200 Subject: [PATCH] docs(plans): objects data-overview table + responsive shell (#44) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-06-objects-table-and-shell.md | 312 ++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-06-objects-table-and-shell.md diff --git a/docs/superpowers/plans/2026-06-06-objects-table-and-shell.md b/docs/superpowers/plans/2026-06-06-objects-table-and-shell.md new file mode 100644 index 0000000..cdb9a47 --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-objects-table-and-shell.md @@ -0,0 +1,312 @@ +# Objects Data-Overview Table + Responsive Shell — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Turn `/objects` into a full-width, sortable, filterable data table (backed by Postgres sort/filter + exposed timestamps), with a collapsible icon sidebar and a responsive detail pane/drawer at a canonical `/objects/:id` URL. + +**Architecture:** Phase 1 adds backend `sort`/`order`/`visibility`/`q` params (injection-safe) + a filtered count + exposes `created_at`/`updated_at`. Phase 2 replaces the narrow `ObjectList` with a full-width `ObjectsTable` whose state lives in the URL. Phase 3 makes the shell sidebar collapsible (lucide icons + Base UI tooltip) and renders detail as a right pane (wide) / Base UI `Drawer` (narrow) via the existing nested `/objects/:id` route. + +**Tech Stack:** Rust (axum, sqlx/Postgres, utoipa), React 19 + TS + pnpm, `@base-ui/react` (drawer/collapsible/tooltip — already a dep), `lucide-react` 1.17 (already a dep), react-router 7, TanStack Query, Vitest+RTL+MSW, Storybook 10. + +**Conventions:** `cargo +nightly fmt`; `cargo clippy --workspace --all-targets -- -D warnings`; tests via `cargo nextest run`; pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; component source double-quote+semicolon, stories single-quote+no-semicolon; en/sv parity for new keys; **no codename**; portal queries in tests via `within(document.body)`; `pnpm check:size` budget **165 KB gz**. Test infra: Postgres 5442, Meili 7700; `#[sqlx::test(migrations="../db/migrations")]`. + +**Spec:** `docs/superpowers/specs/2026-06-06-objects-table-and-shell-design.md` + +--- + +## File Structure +**Backend:** `crates/db/src/catalog.rs` (filtered list+count, sort enum), `crates/api/src/admin_objects.rs` (query params, `AdminObjectView` timestamps), `crates/api/src/openapi.rs` (if new schema types). **Frontend:** `web/src/api/queries.ts` (`useObjectsPage` params), new `web/src/objects/objects-table.tsx` (+ `.stories.tsx`, `.test.tsx`), `web/src/objects/objects-page.tsx` (restructure to table + responsive detail), `web/src/shell/app-shell.tsx` (collapsible sidebar), new `web/src/components/ui/tooltip.tsx`, new `web/src/lib/use-media-query.ts`, `web/src/i18n/{en,sv}.json`. `web/src/objects/object-list.tsx` is removed (replaced by the table). + +--- + +# PHASE 1 — Backend + +## Task 1: Expose `created_at` / `updated_at` on `AdminObjectView` +**Files:** `crates/api/src/admin_objects.rs`; test `crates/api/tests/admin_catalog.rs`. + +The domain `CatalogueObject` already carries `created_at`/`updated_at` (`time::OffsetDateTime`); only the API view omits them. No migration. + +- [ ] **Step 1: Failing API test** in `admin_catalog.rs`: create an object, `GET /api/admin/objects`, assert the item has non-empty `created_at` and `updated_at` (RFC3339 strings). Run → fails (fields absent). +- [ ] **Step 2: Add fields.** In `AdminObjectView` add: +```rust + /// RFC3339 UTC timestamp. + pub created_at: String, + /// RFC3339 UTC timestamp. + pub updated_at: String, +``` +In `from_object`, map them (the file already has a `format_date` for the `DATE`; for timestamps use RFC3339): +```rust + created_at: o.created_at.format(&time::format_description::well_known::Rfc3339).unwrap_or_default(), + updated_at: o.updated_at.format(&time::format_description::well_known::Rfc3339).unwrap_or_default(), +``` +(Confirm `time` is a dep of the `api` crate; it is used transitively — if not in `Cargo.toml`, add `time.workspace = true`. Verify the `CatalogueObject` field names `created_at`/`updated_at` and their `OffsetDateTime` type in `crates/db/src/catalog.rs:210-211`.) +- [ ] **Step 3:** `cargo +nightly fmt`; `cargo clippy -p api`; run the test (compose up): +``` +DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo nextest run -p api -E 'test(admin_catalog)' +``` +- [ ] **Step 4: Commit** `feat(api): expose object created_at/updated_at in AdminObjectView (#44)`. + +## Task 2: Server-side sort / order / visibility / quick-filter for the object list +**Files:** `crates/db/src/catalog.rs`, `crates/api/src/admin_objects.rs`; tests in `crates/db/tests/object.rs` (or wherever catalog list is tested) + `crates/api/tests/admin_catalog.rs`. + +- [ ] **Step 1: Define a sort enum + filtered db functions** in `crates/db/src/catalog.rs`. Add: +```rust +/// Whitelisted, injection-safe sort columns for the object list. +#[derive(Debug, Clone, Copy)] +pub enum ObjectSort { ObjectNumber, ObjectName, UpdatedAt, CreatedAt, Visibility } + +impl ObjectSort { + fn column(self) -> &'static str { + match self { + ObjectSort::ObjectNumber => "object_number", + ObjectSort::ObjectName => "object_name", + ObjectSort::UpdatedAt => "updated_at", + ObjectSort::CreatedAt => "created_at", + ObjectSort::Visibility => "visibility", + } + } +} + +/// Filters + ordering for a paged object query. `visibility`/`q` are optional. +pub struct ObjectQuery<'a> { + pub sort: ObjectSort, + pub descending: bool, + pub visibility: Option<&'a str>, + pub q: Option<&'a str>, +} +``` +Add `list_objects_query` + `count_objects_query` that build SQL from the **enum** (never a raw client string). Both share a WHERE builder. Example: +```rust +fn where_clause(visibility: Option<&str>, q: Option<&str>) -> (String, Vec) { + let mut clauses = Vec::new(); + let mut binds = Vec::new(); + if let Some(v) = visibility { binds.push(v.to_owned()); clauses.push(format!("visibility = ${}", binds.len())); } + if let Some(term) = q { + binds.push(format!("%{term}%")); + let p = binds.len(); + clauses.push(format!("(object_number ILIKE ${p} OR object_name ILIKE ${p})")); + } + let sql = if clauses.is_empty() { String::new() } else { format!(" WHERE {}", clauses.join(" AND ")) }; + (sql, binds) +} + +pub async fn list_objects_query( + pool: &sqlx::PgPool, query: &ObjectQuery<'_>, limit: i64, offset: i64, +) -> Result, sqlx::Error> { + let (where_sql, binds) = where_clause(query.visibility, query.q); + let dir = if query.descending { "DESC" } else { "ASC" }; + // Secondary key keeps ordering stable when the primary sort has ties. + let sql = format!( + "SELECT {OBJECT_COLUMNS} FROM object{where_sql} ORDER BY {} {dir}, object_number ASC LIMIT ${} OFFSET ${}", + query.sort.column(), binds.len() + 1, binds.len() + 2, + ); + let mut q = sqlx::query(&sql); + for b in &binds { q = q.bind(b); } + let rows = q.bind(limit).bind(offset).fetch_all(pool).await?; + rows.into_iter().map(map_object).collect() +} + +pub async fn count_objects_query( + pool: &sqlx::PgPool, visibility: Option<&str>, q: Option<&str>, +) -> Result { + let (where_sql, binds) = where_clause(visibility, q); + let sql = format!("SELECT count(*) AS n FROM object{where_sql}"); + let mut query = sqlx::query(&sql); + for b in &binds { query = query.bind(b); } + query.fetch_one(pool).await?.try_get("n") +} +``` +Keep the existing `list_objects_paged`/`count_objects` if other callers use them (grep; if only the handler calls them, you may replace — verify). The `ObjectColumns`/`map_object` already include the timestamp columns. +- [ ] **Step 2: db tests** in the catalog test file: seed objects with distinct names/visibilities; assert `list_objects_query` orders by `object_name DESC`, filters by `visibility="draft"`, and `q` ILIKE matches number/name; `count_objects_query` returns the filtered count. +- [ ] **Step 3: Handler query params.** In `admin_objects.rs`, add a deserialize struct (don't overload the shared `Pagination`): +```rust +#[derive(Deserialize)] +pub(crate) struct ObjectListParams { + pub limit: Option, pub offset: Option, + pub sort: Option, pub order: Option, + pub visibility: Option, pub q: Option, +} +``` +Parse `sort` → `ObjectSort` (unknown → default `ObjectNumber`), `order` → `descending = order == "desc"`, clamp limit (1..=200, default 50) / offset (>=0) like `Pagination`. Validate `visibility` against `domain::Visibility` (unknown → 422 or ignore — pick ignore-with-default for resilience to hand-edited URLs). Build `ObjectQuery`, call `list_objects_query` + `count_objects_query`. Update the `#[utoipa::path]` `params(...)` to document `sort`/`order`/`visibility`/`q`. +- [ ] **Step 4: API test** — `GET /api/admin/objects?sort=object_name&order=desc&visibility=draft&q=foo` returns filtered+sorted items and a matching `total`; no params → unchanged default (object_number asc). +- [ ] **Step 5:** fmt + clippy + `cargo nextest run -p api -p db`. **Commit** `feat: object list sort/filter/quick-search (server-side, injection-safe) (#44)`. + +## Task 3: Regenerate web API types +- [ ] Start the built server on an alt port (8080 may be taken): `BIND_ADDR=127.0.0.1:8090 DATABASE_URL=… MEILI_URL=… MEILI_MASTER_KEY=… ./target/debug/server`, then `cd web && pnpm exec openapi-typescript http://localhost:8090/api-docs/openapi.json -o src/api/schema.d.ts`. Verify `created_at`/`updated_at` appear on `AdminObjectView`; `pnpm typecheck`. Stop the server. **Commit** `chore(web): regenerate API types (object list params + timestamps)`. + +--- + +# PHASE 2 — The table + +## Task 4: `useObjectsPage` gains sort/filter params +**Files:** `web/src/api/queries.ts`. +- [ ] Replace the `(limit, offset)` signature with a params object and `keepPreviousData`: +```ts +import { keepPreviousData } from "@tanstack/react-query"; + +export type ObjectListParams = { + limit: number; offset: number; + sort?: string; order?: "asc" | "desc"; + visibility?: string; q?: string; +}; + +export function useObjectsPage(params: ObjectListParams) { + return useQuery({ + queryKey: ["objects", params], + placeholderData: keepPreviousData, + queryFn: async () => { + const { data, error } = await api.GET("/api/admin/objects", { + params: { query: { + limit: params.limit, offset: params.offset, + sort: params.sort, order: params.order, + visibility: params.visibility, q: params.q, + } }, + }); + if (error || !data) throw new Error("failed to load objects"); + return data; + }, + }); +} +``` +(openapi-fetch drops `undefined` query params, so omit-by-undefined is fine.) Update the existing call site in `object-list.tsx` — but that file is being replaced in Task 5; if Task 5 lands in the same branch, just ensure typecheck passes after Task 5. **Commit with Task 5** (or standalone if you prefer). Keep `useObject` unchanged. + +## Task 5: `ObjectsTable` — full-width table, URL-synced state, pagination, sort headers +**Files:** create `web/src/objects/objects-table.tsx`, `objects-table.stories.tsx`, `objects-table.test.tsx`; delete `web/src/objects/object-list.tsx`. + +Behavior: reads all state from the URL (`useSearchParams`) — `sort`, `order`, `q`, `visibility`, `offset`, `limit` (default sort `object_number`/`asc`, limit 50, offset 0). Renders a real ``; reuses `VisibilityBadge`; columns № / Name / Visibility / Location / # / Updated; sortable headers toggle sort+dir (with `aria-sort`); a row is a `` whose click navigates to `/objects/:id` **preserving the current search string** (so back restores state); pagination footer with prev/next + page-size ` navigate(`/objects/${o.id}?${params}`)} aria-selected={o.id===selectedId} ...> + // pagination: prev disabled offset===0; next disabled offset+limit>=total; page-size select sets limit + deletes offset + // ... +} +``` +Render loading via `Skeleton` rows; error → `objects.loadError`; empty → `objects.empty`. Visibility chips mirror the search-panel `