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

12 KiB
Raw Permalink Blame History

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 tagshighlight_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 isErrorsearch.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/:idObjectDetail 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 503search.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 visibility400; 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).