docs(specs): frontend M5 search — endpoint + /search two-pane UI design
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<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).
|
||||
Reference in New Issue
Block a user