Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
12 KiB
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
/searchroute, 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+visibilitysynced to the URL (replace) so searches are bookmarkable/shareable. - Visibility filter (All / Draft / Internal / Public) —
visibilityis 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 whenvisibilityisSome;attributes_to_highlight+attributes_to_crop(withcrop_length) onobject_name,brief_description,fields_text. - Reads
estimated_total_hits(MeiliestimatedTotalHits) intoestimated_total. - Builds
snippetfrom the best_formattedfield that actually contains a highlight marker (preferbrief_description, then a matchingfields_textentry, thenobject_name);Noneif no match context.
- Meili query:
- 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 nodangerouslySetInnerHTMLis ever needed. - The existing thin
search(&self, query) -> Vec<ObjectId>is checked for references (insiktfind_references): if unused, replace it withsearch_objects; if used (e.g. a test or CLI), keep it and addsearch_objectsalongside.sync_object/reindex_all/index_object/remove_objectare unchanged.
api crate
- New handler module
crates/api/src/admin_search.rs:GET /api/admin/search?q=&visibility=&offset=&limit=, auth-required via theAuthUserextractor (same as other admin routes).q: trimmed. Emptyq→ returnSearchResults { hits: [], estimated_total: 0 }without calling Meili.visibility: optional; validated againstdraft|internal|public(reuse the domainVisibilityparse). Invalid value →400.offset: default 0,≥ 0;limit: default 20, max 50 (reusepagination.rsclamping 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 inadminrouter and schema registered incrates/api/src/openapi.rs(addSearchHit,SearchResults).
OpenAPI / typed client
- Regenerate
web/src/api/schema.d.ts(openapi-typescript) so the typed client gains the/api/admin/searchpath and theSearchHit/SearchResultscomponent 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 > 0queryFn({ pageParam = 0 })→GET /api/admin/search?q=&visibility=&offset=pageParam&limit=20initialPageParam: 0getNextPageParam(lastPage, allPages)→loaded = allPages.flatMap(p => p.hits).length; returnloaded < lastPage.estimated_total ? loaded : undefined.- Throws on non-200 (a
503/500surfaces asisError→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
searchcrate test against thecms-test-meilicontainer (host port 7701,MEILI_MASTER_KEY=masterKey): seed a few documents, assertsearch_objectsreturns matching hits with a non-emptysnippetcarrying sentinels, that thevisibilityfilter narrows results, thatoffset/limitpage correctly, and thatestimated_totalis populated.apihandler tests: unauthenticated →401; valid query →200with results; invalidvisibility→400;limitclamped to 50; search-disabled state →503.
Frontend (Vitest + RTL + MSW, onUnhandledRequest: "error")
- MSW handler for
GET /api/admin/searchreturning a pagedSearchResultsfixture (hits with a sentinel-marked snippet; anestimated_totallarger than one page). - Tests: debounced typing issues a request with
?q=; a visibility pill click changes thevisibilityparam 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/503states; clicking a hit navigates to/search/:id; the Search nav item is an enabled link whilefieldsstays 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
/searchpushes the main chunk over, lazy-load the route withReact.lazy+Suspense(as M2 did for the object forms) and re-verify.
Acceptance criteria (Milestone 5 "done")
GET /api/admin/searchreturns index-backedSearchResults(hits +estimated_total), supportsq/visibility/offset/limit, is auth-required, returns503when search is not configured, and emits XSS-safe (sentinel, non-HTML) highlight snippets.- 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. - The visibility filter narrows results; the URL reflects
q+visibilityand is shareable/bookmarkable. - "Load more" appends the next page; the estimated total is shown.
- Clicking a result shows the full, fresh object in the detail pane.
- 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).