Files
biggus-dickus/docs/superpowers/specs/2026-06-04-frontend-spa-milestone-5-design.md
2026-06-04 10:17:29 +02:00

217 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Frontend SPA — Milestone 5 (Search) — Design
**Date:** 2026-06-04
**Status:** Approved (brainstorming) — ready for implementation planning.
## Context
Milestones 14 (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 M2M4 — 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<ObjectId>` 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
masterdetail 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<String>, visibility: String, snippet: Option<String> }`
- `SearchResults { hits: Vec<SearchHit>, estimated_total: usize }`
- New method
`SearchClient::search_objects(query: &str, visibility: Option<&str>, offset: usize, limit: usize) -> Result<SearchResults, SearchError>`:
- Meili query: `with_query(query)`, `with_offset(offset)`, `with_limit(limit)`;
`with_filter("visibility = <v>")` 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 `<mark>`. **No HTML crosses the
API boundary**, so no `dangerouslySetInnerHTML` is ever needed.
- The existing thin `search(&self, query) -> Vec<ObjectId>` 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, <Outlet/> 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 + <Outlet/>
search-panel.tsx debounced query <Input>; 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 <Highlight text> — splits on the sentinel chars, renders
plain segments as text and matched segments as <mark>
select-search-prompt.tsx idle detail-pane prompt
web/src/lib/use-debounced-value.ts generic useDebouncedValue<T>(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`)
`<Highlight text={snippet} />` splits `text` on the sentinel pair (`\u{2}`…`\u{3}`) and
maps segments to React nodes — plain strings for unmatched text, `<mark>` 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 `<mark>` 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).