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