# 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 `