diff --git a/docs/superpowers/specs/2026-06-04-frontend-spa-milestone-5-design.md b/docs/superpowers/specs/2026-06-04-frontend-spa-milestone-5-design.md new file mode 100644 index 0000000..730ab34 --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-frontend-spa-milestone-5-design.md @@ -0,0 +1,216 @@ +# Frontend SPA — Milestone 5 (Search) — Design + +**Date:** 2026-06-04 +**Status:** Approved (brainstorming) — ready for implementation planning. + +## Context + +Milestones 1–4 (merged to `main` at `18a19ee`) delivered the SPA foundation, object +authoring, the publishing workflow, and vocabulary/authority management. The app shell's +nav still has two disabled stubs: **Search** and **Fields**. Milestone 5 enables Search. + +Unlike M2–M4 — which were pure-frontend because the admin endpoints already existed — +search has **no HTTP endpoint yet**. The `search` crate holds the *capability* +(`SearchClient::search(query) -> Vec` against Meilisearch) and on-write index +sync is wired at the API layer, but no route exposes querying, and the current method +discards everything but the object id. **M5 is therefore one combined vertical slice:** +a backend search endpoint plus the frontend search UI, in a single spec/plan. + +Milestone roadmap: M1 foundation → M2 authoring → M3 publish → M4 vocab/authority → +**M5 search (this)**. After M5, only the **Fields** nav stub remains disabled. + +## Decisions (settled during brainstorming) + +- **One combined milestone** — backend search endpoint + frontend UI together (the + frontend is meaningless without the contract, and the backend piece is small). +- **Dedicated `/search` route, two-pane** — query + visibility filter + paginated results + on the left, the selected object's full detail on the right; mirrors the Objects + master–detail layout. The **⌘K global omnibox is deferred** to a follow-up. +- **Debounced search-as-you-type** (~300 ms); `q` + `visibility` synced to the URL + (`replace`) so searches are bookmarkable/shareable. +- **Visibility filter** (All / Draft / Internal / Public) — `visibility` is already a + filterable attribute on the index, and complements the M3 publish workflow. +- **Index-backed hits** — the endpoint returns hit metadata + a highlighted snippet + straight from Meilisearch (no per-hit Postgres round trip / no N+1). The detail pane + fetches the full, authoritative record on click. List rows are thus eventually + consistent with the DB (acceptable); the detail pane is always fresh. +- **"Load more" pagination** (`useInfiniteQuery`), 20 per page, estimated total shown. +- **Rich result rows** — bold object name; a meta line with object number + a visibility + badge; a two-line highlighted snippet. + +## Backend contract (to build) + +### `search` crate + +- New serializable types: + - `SearchHit { id: String, object_number: String, object_name: String, + brief_description: Option, visibility: String, snippet: Option }` + - `SearchResults { hits: Vec, estimated_total: usize }` +- New method + `SearchClient::search_objects(query: &str, visibility: Option<&str>, offset: usize, limit: usize) -> Result`: + - Meili query: `with_query(query)`, `with_offset(offset)`, `with_limit(limit)`; + `with_filter("visibility = ")` only when `visibility` is `Some`; + `attributes_to_highlight` + `attributes_to_crop` (with `crop_length`) on + `object_name`, `brief_description`, `fields_text`. + - Reads `estimated_total_hits` (Meili `estimatedTotalHits`) into `estimated_total`. + - Builds `snippet` from the best `_formatted` field that actually contains a + highlight marker (prefer `brief_description`, then a matching `fields_text` entry, + then `object_name`); `None` if no match context. +- **XSS-safe highlighting:** Meili is configured with **non-HTML sentinel highlight tags** + — `highlight_pre_tag = "\u{2}"`, `highlight_post_tag = "\u{3}"` (control chars that + cannot occur in catalogue text). The snippet is returned as a plain string carrying + these sentinels; the frontend splits on them to render ``. **No HTML crosses the + API boundary**, so no `dangerouslySetInnerHTML` is ever needed. +- The existing thin `search(&self, query) -> Vec` is checked for references + (insikt `find_references`): if unused, replace it with `search_objects`; if used + (e.g. a test or CLI), keep it and add `search_objects` alongside. `sync_object` / + `reindex_all` / `index_object` / `remove_object` are unchanged. + +### `api` crate + +- New handler module `crates/api/src/admin_search.rs`: + `GET /api/admin/search?q=&visibility=&offset=&limit=`, **auth-required** via the + `AuthUser` extractor (same as other admin routes). + - `q`: trimmed. Empty `q` → return `SearchResults { hits: [], estimated_total: 0 }` + **without** calling Meili. + - `visibility`: optional; validated against `draft|internal|public` (reuse the domain + `Visibility` parse). Invalid value → `400`. + - `offset`: default 0, `≥ 0`; `limit`: default 20, **max 50** (reuse `pagination.rs` + clamping helpers). + - Search not configured (`AppState.search == None`) → **`503 Service Unavailable`**. + - Meili error → `500` (logged via tracing, consistent with the on-write sync logging). +- Returns `200 SearchResults`. +- utoipa-annotated (`#[utoipa::path(...)]`), route registered in `admin` router and + schema registered in `crates/api/src/openapi.rs` (add `SearchHit`, `SearchResults`). + +### OpenAPI / typed client + +- Regenerate `web/src/api/schema.d.ts` (openapi-typescript) so the typed client gains the + `/api/admin/search` path and the `SearchHit` / `SearchResults` component schemas. + +## Frontend architecture + +### Routes & navigation + +``` +/search → SearchPage (SearchPanel left, right) + index → SelectSearchPrompt ("Select a result") + :id → ObjectDetail (reused unchanged from web/src/objects/) +``` + +Added under the protected `AppShell` group in `web/src/app.tsx`. In +`web/src/shell/app-shell.tsx`, **Search** becomes an active `NavLink` to `/search`; +`DISABLED_NAV` shrinks to `["fields"]`. + +`ObjectDetail` is reused as-is: it reads `useParams().id`, fetches via `useObject(id)`, +and its edit link is already absolute (`/objects/:id/edit`), so editing from a search +result navigates into the Objects edit flow correctly. + +### Components / files + +``` +web/src/search/ + search-page.tsx two-pane grid (grid-cols-[20rem_1fr]); SearchPanel + + search-panel.tsx debounced query ; visibility pills; result count; + results list; "Load more"; loading/empty/error states + search-result-row.tsx rich row → NavLink to /search/:id (active highlight) + highlight.tsx — splits on the sentinel chars, renders + plain segments as text and matched segments as + select-search-prompt.tsx idle detail-pane prompt +web/src/lib/use-debounced-value.ts generic useDebouncedValue(value, delayMs) +web/src/api/queries.ts + useSearch(q, visibility) +web/src/app.tsx + the /search nested route +web/src/shell/app-shell.tsx enable Search NavLink; DISABLED_NAV = ["fields"] +web/src/i18n/{en,sv}.json + search.* namespace +``` + +### Data layer + +- `useSearch(q: string, visibility: string | null)` — `useInfiniteQuery`: + - `queryKey: ["search", q, visibility]` + - `enabled: q.trim().length > 0` + - `queryFn({ pageParam = 0 })` → `GET /api/admin/search?q=&visibility=&offset=pageParam&limit=20` + - `initialPageParam: 0` + - `getNextPageParam(lastPage, allPages)` → `loaded = allPages.flatMap(p => p.hits).length`; + return `loaded < lastPage.estimated_total ? loaded : undefined`. + - Throws on non-200 (a `503`/`500` surfaces as `isError` → `search.loadError`). + +### URL state + +`search-panel.tsx` owns a controlled input string and the active visibility. A +`useDebouncedValue` of the input (300 ms) drives both `useSearch` and a +`useSearchParams` write (`setSearchParams(..., { replace: true })`) for `q`; the +visibility pill writes `visibility` immediately. Initial state hydrates from the URL on +mount so `/search?q=bronze&visibility=draft` loads pre-populated. + +### Highlight rendering (`highlight.tsx`) + +`` splits `text` on the sentinel pair (`\u{2}`…`\u{3}`) and +maps segments to React nodes — plain strings for unmatched text, `` for matched +spans. Pure string handling; no HTML injection. + +## Data flow + +Type → `useDebouncedValue` (300 ms) → `useSearch(["search", q, visibility])` → +`GET /api/admin/search` → render rich rows from the index payload (no per-hit DB call) → +"Load more" fetches next offset and appends → click a row → `/search/:id` → +`ObjectDetail` fetches the full fresh record via `useObject`. + +## Error handling + +- Empty `q` → idle prompt (`search.prompt`, "Type to search"); no request fired. +- In-flight → loading indicator (skeleton rows, consistent with the Objects list). +- Zero hits → `search.empty` ("No results"). +- Query error or `503` → `search.loadError` ("Search is unavailable") in the results pane. +- Detail pane retains `ObjectDetail`'s own loading/error/empty behavior. + +## Testing + +### Backend +- `search` crate test against the `cms-test-meili` container (host port 7701, + `MEILI_MASTER_KEY=masterKey`): seed a few documents, assert `search_objects` returns + matching hits with a non-empty `snippet` carrying sentinels, that the `visibility` + filter narrows results, that `offset`/`limit` page correctly, and that + `estimated_total` is populated. +- `api` handler tests: unauthenticated → `401`; valid query → `200` with results; invalid + `visibility` → `400`; `limit` clamped to 50; search-disabled state → `503`. + +### Frontend (Vitest + RTL + MSW, `onUnhandledRequest: "error"`) +- MSW handler for `GET /api/admin/search` returning a paged `SearchResults` fixture + (hits with a sentinel-marked snippet; an `estimated_total` larger than one page). +- Tests: debounced typing issues a request with `?q=`; a visibility pill click changes + the `visibility` param and refetches; rows render the object name, object number, a + visibility badge, and a `` from the snippet; "Load more" appends the next page and + hides when exhausted; empty-query idle prompt, zero-results, loading, and error/`503` + states; clicking a hit navigates to `/search/:id`; the Search nav item is an enabled + link while `fields` stays disabled. + +### Project constraints +- en/sv i18n key parity (reuse existing visibility labels where present). +- No `any` / `eslint-disable` / `@ts-ignore`. Codename ban (no "biggus"/"dickus"). +- Bundle ≤150 KB gz. Current headroom is ~7 KB; if `/search` pushes the main chunk over, + lazy-load the route with `React.lazy` + `Suspense` (as M2 did for the object forms) and + re-verify. + +## Acceptance criteria (Milestone 5 "done") + +1. `GET /api/admin/search` returns index-backed `SearchResults` (hits + `estimated_total`), + supports `q`/`visibility`/`offset`/`limit`, is auth-required, returns `503` when search + is not configured, and emits XSS-safe (sentinel, non-HTML) highlight snippets. +2. The Search nav item is enabled and routes to `/search`; debounced typing shows rich + result rows with a highlighted snippet, the object number, and a visibility badge. +3. The visibility filter narrows results; the URL reflects `q` + `visibility` and is + shareable/bookmarkable. +4. "Load more" appends the next page; the estimated total is shown. +5. Clicking a result shows the full, fresh object in the detail pane. +6. Web + backend CI green (cargo test; web typecheck, lint, tests, build, bundle ≤150 KB + gz); en/sv parity. + +## Out of scope / follow-ups + +- **⌘K global omnibox / command palette** — file a frontend follow-up when M5 lands. +- Richer faceting (object name, owner, has-images, date ranges) and Meili facet + distribution counts. +- A public-facing search endpoint (`/api/public/search`) for an eventual public site. +- Search analytics / query logging. +- Relevance tuning (ranking rules, synonyms, typo tolerance configuration).