Files
biggus-dickus/docs/superpowers/plans/2026-06-06-objects-table-and-shell.md
T
2026-06-06 21:58:06 +02:00

23 KiB
Raw Blame History

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:
    /// 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_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):
#[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 sortObjectSort (unknown → default ObjectNumber), orderdescending = 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 testGET /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:
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 reuse search.all) to both en.json and sv.json.
  • Step 3: Stories objects-table.stories.tsx — render inside a MemoryRouter (the preview provides providers; add a router if needed) with MSW returning a small page: Default (rows render), Sorted (assert aria-sort on 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 URL sort/order and sets aria-sort; typing in the filter (debounced) sets q; a visibility chip sets visibility; pagination next/prev change offset; page-size sets limit. Use the search-panel test as a reference for MSW + router wiring.
  • Step 5: pnpm typecheck && pnpm lint && pnpm test -- objects-table. Commit feat(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 ObjectsPage render ObjectsTable full-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 :id child route is active, render the detail below/over as a simple panel (Phase 3 makes it responsive). Remove the index → SelectPrompt route's visual prominence (the table is the landing view). Verify pnpm test && pnpm build. Commit feat(web): objects table as the /objects landing view (#44).

Note: Tasks 56 can be one commit if cleaner. The key is the table renders at /objects and row-click deep-links to /objects/:id with 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, mirrors use-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/tooltip parts (Provider/Root/Trigger/Portal/Positioner/Popup) in the established ui/* style (mirror ui/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). No any.
  • 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; persist collapsed to localStorage (sidebar-collapsed); auto-collapse when useMediaQuery("(max-width: 768px)"). Expanded: icon + label (w-44). Collapsed: icon only (~w-14) with the label via the ui/tooltip (and aria-label/title). Preserve NavLink active styling; add focus-visible rings.
  • Story app-shell sidebar or a extracted Sidebar component: Expanded / Collapsed (assert labels hidden + tooltips/aria-label present). If extracting a Sidebar component from app-shell makes it testable/storyable, do so (keep app-shell thin).
  • 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 render ObjectsTable; detect an active detail child with useMatch("/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, else 1fr) containing <Outlet/>, with a close control (navigate("/objects?"+params)).
    • Narrow: render <Outlet/> inside a Base UI Drawer (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 → SelectPrompt route (the table is the landing view); SelectPrompt can be deleted if now unused (grep — it may also be used elsewhere; only remove if exclusively the objects index).
    • :id/edit continues to render through the same <Outlet/> (pane/drawer), preserving today's "edit in the right area" behavior.
  • Test: with a mocked matchMedia, /objects/:id renders detail in a pane (wide) and in a portaled Drawer (narrow, query via within(document.body)); closing returns to /objects with the table's query string intact; deep-linking /objects/:id directly 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 new objects.columns.* etc.); git grep -in 'biggus\|dickus' -- crates web/src || echo CLEAN; git status --short clean.

Self-Review (completed)

Spec coverage: sort/filter/q + filtered total + timestamps (T1T3); full-width table with columns/sort/filter/pagination/URL-state (T4T6); 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 → no pnpm-lock churn.
  • No DB migration (timestamps already exist).
  • Watch the bundle: icons/tooltip/drawer are in the always-loaded shell, not a lazy chunk — if check:size exceeds 165, lazy-import the Drawer (only used at narrow widths) or trim.