Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
23 KiB
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-emptycreated_atandupdated_at(RFC3339 strings). Run → fails (fields absent). - Step 2: Add fields. In
AdminObjectViewadd:
/// 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):
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:
/// 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:
fn where_clause(visibility: Option<&str>, q: Option<&str>) -> (String, Vec<String>) {
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<Vec<CatalogueObject>, 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<i64, sqlx::Error> {
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_queryorders byobject_name DESC, filters byvisibility="draft", andqILIKE matches number/name;count_objects_queryreturns the filtered count. - Step 3: Handler query params. In
admin_objects.rs, add a deserialize struct (don't overload the sharedPagination):
#[derive(Deserialize)]
pub(crate) struct ObjectListParams {
pub limit: Option<i64>, pub offset: Option<i64>,
pub sort: Option<String>, pub order: Option<String>,
pub visibility: Option<String>, pub q: Option<String>,
}
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=fooreturns filtered+sorted items and a matchingtotal; no params → unchanged default (object_number asc). - Step 5: fmt + clippy +
cargo nextest run -p api -p db. Commitfeat: 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, thencd web && pnpm exec openapi-typescript http://localhost:8090/api-docs/openapi.json -o src/api/schema.d.ts. Verifycreated_at/updated_atappear onAdminObjectView;pnpm typecheck. Stop the server. Commitchore(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 andkeepPreviousData:
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 <table>; reuses VisibilityBadge; columns № / Name / Visibility / Location / # / Updated; sortable headers toggle sort+dir (with aria-sort); a row is a <tr> whose click navigates to /objects/:id preserving the current search string (so back restores state); pagination footer with prev/next + page-size <select> (or the future ui/select); a debounced quick-filter Input (q) and visibility chips live in a toolbar (Task 6 may own the toolbar — implement them here together to keep the table coherent).
- Step 1: Component. Concrete core (fill routine markup/classes to match the app; use token classes per #49 where easy, else existing patterns):
import { useSearchParams, useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { useObjectsPage } from "../api/queries";
import { useDebouncedValue } from "../lib/use-debounced-value";
import { VisibilityBadge } from "./visibility-badge";
// + ui/button, ui/input, ui/skeleton, lucide chevrons
const SORTABLE = ["object_number", "object_name", "updated_at"] as const;
const PAGE_SIZES = [25, 50, 100, 200];
const VIS = ["all", "draft", "internal", "public"] as const;
export function ObjectsTable() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const { id: selectedId } = useParams(); // highlight the open row
const [params, setParams] = useSearchParams();
const sort = params.get("sort") ?? "object_number";
const order = (params.get("order") === "desc" ? "desc" : "asc") as "asc" | "desc";
const visibility = params.get("visibility") ?? "all";
const limit = Number(params.get("limit")) || 50;
const offset = Number(params.get("offset")) || 0;
const qParam = params.get("q") ?? "";
const [qText, setQText] = useState(qParam);
const q = useDebouncedValue(qText, 300);
// sync debounced q → URL (reset offset)
useEffect(() => {
setParams((prev) => {
const next = new URLSearchParams(prev);
const term = q.trim();
if (term) next.set("q", term); else next.delete("q");
next.delete("offset");
return next;
}, { replace: true });
}, [q, setParams]);
const { data, isLoading, isError } = useObjectsPage({
limit, offset, sort, order,
visibility: visibility === "all" ? undefined : visibility,
q: q.trim() || undefined,
});
const setParam = (mutate: (n: URLSearchParams) => void) =>
setParams((prev) => { const n = new URLSearchParams(prev); mutate(n); return n; }, { replace: true });
const toggleSort = (col: string) =>
setParam((n) => {
const curOrder = n.get("order") === "desc" ? "desc" : "asc";
const curSort = n.get("sort") ?? "object_number";
const nextOrder = curSort === col && curOrder === "asc" ? "desc" : "asc";
n.set("sort", col); n.set("order", nextOrder); n.delete("offset");
});
// header cell: aria-sort = col===sort ? (order==='asc'?'ascending':'descending') : 'none'
// row: <tr onClick={() => 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 <button aria-pressed> pattern (set visibility param, delete offset). The "Updated" cell: format o.updated_at with Intl.DateTimeFormat(i18n.language, { dateStyle:'medium', timeZone: useConfig().default_timezone }) (or a relative-time helper) — keep it a small local helper. No any (cast page items as components["schemas"]["AdminObjectView"]).
- Step 2: i18n — add
objects.columns.{number,name,visibility,location,count,updated},objects.filter(quick-filter placeholder),objects.pageSize,objects.all(or reusesearch.all) to bothen.jsonandsv.json. - Step 3: Stories
objects-table.stories.tsx— render inside aMemoryRouter(the preview provides providers; add a router if needed) with MSW returning a small page:Default(rows render),Sorted(assertaria-sorton the active header),Empty. Mirror the visibility-badge story format. - Step 4: Unit test
objects-table.test.tsx(RTL + MSW + MemoryRouter): rows render the columns; clicking a sortable header updates the URLsort/orderand setsaria-sort; typing in the filter (debounced) setsq; a visibility chip setsvisibility; pagination next/prev changeoffset; page-size setslimit. Use the search-panel test as a reference for MSW + router wiring. - Step 5:
pnpm typecheck && pnpm lint && pnpm test -- objects-table. Commitfeat(web): full-width sortable/filterable objects table with URL state (#44).
Task 6: Wire the table into the page (table full-width; detail via Outlet placeholder)
Files: web/src/objects/objects-page.tsx (interim — full restructure in Phase 3).
- Make
ObjectsPagerenderObjectsTablefull-width for now, keeping the nested<Outlet/>available but not as a fixed 20rem column (Phase 3 makes it a pane/drawer). Interim acceptable state: table fills the area; if a:idchild route is active, render the detail below/over as a simple panel (Phase 3 makes it responsive). Remove theindex → SelectPromptroute's visual prominence (the table is the landing view). Verifypnpm test && pnpm build. Commitfeat(web): objects table as the /objects landing view (#44).
Note: Tasks 5–6 can be one commit if cleaner. The key is the table renders at
/objectsand row-click deep-links to/objects/:idwith preserved query state.
PHASE 3 — Shell & responsive detail
Task 7: useMediaQuery hook + ui/tooltip.tsx wrapper
Files: create web/src/lib/use-media-query.ts, web/src/components/ui/tooltip.tsx.
use-media-query.ts(tiny, SSR-safe, mirrorsuse-debounced-value):
import { useEffect, useState } from "react";
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() =>
typeof window !== "undefined" ? window.matchMedia(query).matches : false);
useEffect(() => {
const mql = window.matchMedia(query);
const on = () => setMatches(mql.matches);
on(); mql.addEventListener("change", on);
return () => mql.removeEventListener("change", on);
}, [query]);
return matches;
}
ui/tooltip.tsx— wrap@base-ui/react/tooltipparts (Provider/Root/Trigger/Portal/Positioner/Popup) in the establishedui/*style (mirrorui/alert-dialog.tsx:data-slot,cn,render=where a trigger delegates). Export a simple<Tooltip content=…>{trigger}</Tooltip>convenience plus the raw parts. RUN a quick story/test to confirm the Base UI composition (first tooltip in the repo — verify the part tree by running, like the combobox was). Noany.- Typecheck/lint. Commit
feat(web): useMediaQuery hook + Base UI tooltip wrapper (#44).
Task 8: Collapsible icon sidebar
Files: web/src/shell/app-shell.tsx (+ optional sidebar.stories.tsx).
- Add lucide icons to each nav item (e.g.
Boxes/BookMarked/Users/Search/Tags— pick sensible icons). Add a collapse toggle button; persistcollapsedtolocalStorage(sidebar-collapsed); auto-collapse whenuseMediaQuery("(max-width: 768px)"). Expanded: icon + label (w-44). Collapsed: icon only (~w-14) with the label via theui/tooltip(andaria-label/title). PreserveNavLinkactive styling; addfocus-visiblerings. - Story
app-shellsidebar or a extractedSidebarcomponent:Expanded/Collapsed(assert labels hidden + tooltips/aria-labelpresent). If extracting aSidebarcomponent fromapp-shellmakes it testable/storyable, do so (keepapp-shellthin). - Typecheck/lint/test. Commit
feat(web): collapsible icon sidebar (persisted, auto-collapse on narrow) (#44, #58).
Task 9: Responsive detail — right pane (wide) / Drawer (narrow) at canonical /objects/:id
Files: web/src/objects/objects-page.tsx; possibly a small object-detail-panel.tsx.
- Restructure
ObjectsPage: always renderObjectsTable; detect an active detail child withuseMatch("/objects/:id")/useMatch("/objects/:id/edit"). When matched:- Wide (
useMediaQuery("(min-width: 1024px)")): render a right-hand pane (e.g.grid-cols-[1fr_28rem]when open, else1fr) containing<Outlet/>, with a close control (navigate("/objects?"+params)). - Narrow: render
<Outlet/>inside a Base UIDrawer(swipeDirection="right", edge = right) over the table; closing the drawer navigates back to/objects(preserve query). RUN to confirm the Drawer part tree (Root/Portal/Backdrop/Popup/Close) — first Drawer in the repo; mirror the alert-dialog wrapper conventions. - Remove the
index → SelectPromptroute (the table is the landing view);SelectPromptcan be deleted if now unused (grep — it may also be used elsewhere; only remove if exclusively the objects index). :id/editcontinues to render through the same<Outlet/>(pane/drawer), preserving today's "edit in the right area" behavior.
- Wide (
- Test: with a mocked
matchMedia,/objects/:idrenders detail in a pane (wide) and in a portaled Drawer (narrow, query viawithin(document.body)); closing returns to/objectswith the table's query string intact; deep-linking/objects/:iddirectly renders table + open detail. - Typecheck/lint/test/build. Commit
feat(web): responsive object detail (pane/drawer) at canonical /objects/:id (#44, #58).
PHASE 4 — Verification
Task 10: Final verification
- Backend:
cargo +nightly fmt --check;cargo clippy --workspace --all-targets -- -D warnings;DATABASE_URL=… MEILI_URL=… MEILI_MASTER_KEY=… cargo nextest run --workspace(single clean run — don't run two concurrently; sqlx temp-DB contention produces fake failures). - Web:
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size(index ≤ 165 KB gz — lucide/tooltip/drawer land in the always-loaded shell; tree-shaken — verify and report the number). pnpm test -- i18n(en/sv parity for the newobjects.columns.*etc.);git grep -in 'biggus\|dickus' -- crates web/src || echo CLEAN;git status --shortclean.
Self-Review (completed)
Spec coverage: sort/filter/q + filtered total + timestamps (T1–T3); full-width table with columns/sort/filter/pagination/URL-state (T4–T6); collapsible icon sidebar (T8); responsive pane/drawer + canonical /objects/:id (T7,T9); stories (T5,T7,T8); bundle/parity/codename (T10). ✓ Out of scope (Meili unification, detail-content #45, multi-select) not included. ✓
Placeholder scan: load-bearing logic (SQL builder, sort enum, URL-state wiring, sort toggle, responsive routing, media-query/tooltip) is concrete; routine table markup/classes are described to match existing patterns; the two novel Base UI primitives (Tooltip, Drawer) carry explicit "verify the part tree by running" steps (same approach that worked for the combobox), with canonical trees from the spec. No "TBD"/"add error handling".
Type consistency: ObjectSort enum + ObjectQuery (db) ↔ ObjectListParams (api) ↔ useObjectsPage(ObjectListParams) (web) align on sort/order/visibility/q; AdminObjectView gains created_at/updated_at (T1) consumed by the table's Updated column (T5). URL param names (sort/order/visibility/q/limit/offset) consistent across table read/write and the hook.
Notes
lucide-react+ Base UI tooltip/drawer/collapsible are already deps → nopnpm-lockchurn.- No DB migration (timestamps already exist).
- Watch the bundle: icons/tooltip/drawer are in the always-loaded shell, not a lazy chunk — if
check:sizeexceeds 165, lazy-import the Drawer (only used at narrow widths) or trim.