# 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).