From 38e452540470e084547ddc51b52be3be3c715647 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 10:27:59 +0200 Subject: [PATCH] =?UTF-8?q?docs(plans):=20frontend=20M5=20search=20?= =?UTF-8?q?=E2=80=94=20backend=20endpoint=20+=20/search=20UI,=206=20tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-04-frontend-spa-milestone-5.md | 1355 +++++++++++++++++ 1 file changed, 1355 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-04-frontend-spa-milestone-5.md diff --git a/docs/superpowers/plans/2026-06-04-frontend-spa-milestone-5.md b/docs/superpowers/plans/2026-06-04-frontend-spa-milestone-5.md new file mode 100644 index 0000000..df6e8d4 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-frontend-spa-milestone-5.md @@ -0,0 +1,1355 @@ +# Frontend SPA Milestone 5 (Search) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship full-text search end-to-end — a `GET /api/admin/search` endpoint backed by the existing Meilisearch index, and a `/search` two-pane screen (debounced query + visibility filter + paginated, highlighted results on the left; the selected object's full detail on the right). + +**Architecture:** Backend grows a `SearchClient::search_objects` method that returns rich hits (metadata + highlighted snippet) straight from Meilisearch, exposed via a new auth-required admin handler. The frontend reuses the Objects master–detail pattern: a results panel keyed on a debounced query string drives a TanStack `useInfiniteQuery`; clicking a hit routes to `/search/:id` which reuses the existing `ObjectDetail`. Highlight markers are non-HTML sentinel chars, split client-side into `` — no `dangerouslySetInnerHTML`. + +**Tech Stack:** Rust (axum 0.8, meilisearch-sdk 0.33, utoipa), React 19 + TS, TanStack Query v5 (`useInfiniteQuery`), react-router-dom 7, react-i18next (sv/en), Vitest + RTL + MSW. + +**Spec:** `docs/superpowers/specs/2026-06-04-frontend-spa-milestone-5-design.md` + +**Conventions (apply to every task):** +- Run Rust fmt with **nightly** (`cargo +nightly fmt`); lint with `cargo clippy`. +- Frontend: no `any` / `eslint-disable` / `@ts-ignore`; en/sv i18n key parity; the codename "biggus"/"dickus" must appear nowhere. +- Test infra (already running as docker containers; start them if down): + - Postgres: `DATABASE_URL=postgres://postgres:postgres@localhost:5433/cms_dev` + - Meilisearch: `MEILI_URL=http://localhost:7701`, `MEILI_MASTER_KEY=masterKey` + - Backend tests need both exported; `#[sqlx::test]` provisions its own DB from `DATABASE_URL`. +- Run web commands from `web/`; run cargo commands from the repo root. + +--- + +## Task 1: `search` crate — `SearchHit` / `SearchResults` + `search_objects` + +Enrich the search capability so a query returns hit metadata + a highlighted snippet + an estimated total, instead of bare object ids. + +**Files:** +- Modify: `crates/search/src/lib.rs` +- Test: `crates/search/tests/search.rs` + +- [ ] **Step 1: Add the result types** — in `crates/search/src/lib.rs`, after the `SearchDocument` struct (around line 40), add: + +```rust +/// Non-HTML highlight markers. These ASCII control characters cannot occur in +/// catalogue text, so the frontend can safely split on them to render matches — +/// no HTML ever crosses the API boundary. +pub const HL_PRE: &str = "\u{2}"; +pub const HL_POST: &str = "\u{3}"; + +/// One search result: display metadata projected from the index, plus an optional +/// snippet of matched text with [`HL_PRE`]/[`HL_POST`] markers around the matches. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchHit { + pub id: String, + pub object_number: String, + pub object_name: String, + pub brief_description: Option, + pub visibility: String, + pub snippet: Option, +} + +/// A page of search results plus Meilisearch's estimate of the total match count. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchResults { + pub hits: Vec, + pub estimated_total: usize, +} +``` + +- [ ] **Step 2: Add the import** — at the top of `crates/search/src/lib.rs`, change the `meilisearch_sdk` import to also bring in `Selectors`: + +```rust +use meilisearch_sdk::search::Selectors; +use meilisearch_sdk::tasks::Task; +``` + +- [ ] **Step 3: Write the failing test** — append to `crates/search/tests/search.rs`: + +```rust +#[tokio::test] +async fn search_objects_returns_hits_with_highlight_filter_and_paging() { + let (url, key) = meili(); + let client = SearchClient::connect(&url, &key, &unique_index()).unwrap(); + client.ensure_index().await.unwrap(); + + // Two public "bronze" objects and one draft, so we can test the visibility filter. + let a = domain::ObjectId::new(); + let b = domain::ObjectId::new(); + let c = domain::ObjectId::new(); + let mut bronze_a = doc(&a.to_string(), "Bronze figurine", &["cast bronze with green patina"]); + bronze_a.visibility = "public".to_string(); + let mut bronze_b = doc(&b.to_string(), "Ceremonial bowl", &["bronze alloy rim"]); + bronze_b.visibility = "public".to_string(); + let mut bronze_c = doc(&c.to_string(), "Door fitting", &["bronze hinge"]); + bronze_c.visibility = "draft".to_string(); + client.index_object(&bronze_a).await.unwrap(); + client.index_object(&bronze_b).await.unwrap(); + client.index_object(&bronze_c).await.unwrap(); + + // Unfiltered: all three match "bronze", with an estimated total of 3. + let results = client.search_objects("bronze", None, 0, 20).await.unwrap(); + assert_eq!(results.estimated_total, 3); + assert_eq!(results.hits.len(), 3); + + // Every hit carries display metadata and a highlighted snippet around "bronze". + let hit = results.hits.iter().find(|h| h.id == a.to_string()).unwrap(); + assert_eq!(hit.object_name, "Bronze figurine"); + assert_eq!(hit.object_number, format!("N-{a}")); + let snippet = hit.snippet.as_ref().expect("a matched snippet"); + assert!(snippet.contains(search::HL_PRE), "snippet must mark the match"); + assert!(snippet.contains(search::HL_POST)); + + // Visibility filter narrows to the two public ones. + let public = client + .search_objects("bronze", Some("public"), 0, 20) + .await + .unwrap(); + assert_eq!(public.estimated_total, 2); + assert!(public.hits.iter().all(|h| h.visibility == "public")); + + // Paging: limit 1 returns one hit but reports the full estimated total. + let page = client.search_objects("bronze", None, 0, 1).await.unwrap(); + assert_eq!(page.hits.len(), 1); + assert_eq!(page.estimated_total, 3); +} +``` + +- [ ] **Step 4: Run the test to confirm it fails to compile** — + +```bash +MEILI_URL=http://localhost:7701 MEILI_MASTER_KEY=masterKey \ + cargo test -p search search_objects_returns_hits -- --nocapture +``` +Expected: compile error — `no method named search_objects` / `cannot find ... HL_PRE`. + +- [ ] **Step 5: Implement `search_objects`** — in `crates/search/src/lib.rs`, inside `impl SearchClient`, add this method right after the existing `search` method (do not remove `search` — see Step 7): + +```rust + /// Full-text query returning display-ready hits with highlighted snippets and the + /// estimated total match count. `visibility`, when set, filters on the indexed + /// `visibility` attribute. Pagination is offset/limit. + pub async fn search_objects( + &self, + query: &str, + visibility: Option<&str>, + offset: usize, + limit: usize, + ) -> Result { + let index = self.client.index(&self.index_uid); + + // Bind borrowed inputs so they outlive the query builder. + let filter = visibility.map(|v| format!("visibility = \"{v}\"")); + let highlight: &[&str] = &["object_name", "brief_description", "fields_text"]; + let crop: &[(&str, Option)] = + &[("brief_description", None), ("fields_text", None)]; + + let mut search = index.search(); + search + .with_query(query) + .with_offset(offset) + .with_limit(limit) + .with_attributes_to_highlight(Selectors::Some(highlight)) + .with_attributes_to_crop(Selectors::Some(crop)) + .with_crop_length(20) + .with_highlight_pre_tag(HL_PRE) + .with_highlight_post_tag(HL_POST); + + if let Some(filter) = &filter { + search.with_filter(filter); + } + + let results = search.execute::().await?; + + let hits = results + .hits + .into_iter() + .map(|hit| { + let snippet = hit.formatted_result.as_ref().and_then(extract_snippet); + let doc = hit.result; + + SearchHit { + id: doc.id, + object_number: doc.object_number, + object_name: doc.object_name, + brief_description: doc.brief_description, + visibility: doc.visibility, + snippet, + } + }) + .collect(); + + Ok(SearchResults { + hits, + estimated_total: results.estimated_total_hits.unwrap_or(0), + }) + } +``` + +- [ ] **Step 6: Add the snippet extractor** — at the bottom of `crates/search/src/lib.rs` (a free function, after `build_document`): + +```rust +/// Pick the best snippet from Meilisearch's `_formatted` map: prefer a highlighted +/// `brief_description`, then a highlighted `fields_text` entry, then `object_name`; +/// fall back to an unhighlighted `brief_description` so a hit still shows context. +fn extract_snippet(formatted: &serde_json::Map) -> Option { + let has_mark = |s: &str| s.contains(HL_PRE); + + if let Some(serde_json::Value::String(s)) = formatted.get("brief_description") { + if has_mark(s) { + return Some(s.clone()); + } + } + + if let Some(serde_json::Value::Array(items)) = formatted.get("fields_text") { + for item in items { + if let Some(s) = item.as_str() { + if has_mark(s) { + return Some(s.to_owned()); + } + } + } + } + + if let Some(serde_json::Value::String(s)) = formatted.get("object_name") { + if has_mark(s) { + return Some(s.clone()); + } + } + + if let Some(serde_json::Value::String(s)) = formatted.get("brief_description") { + return Some(s.clone()); + } + + None +} +``` + +`serde_json` is already an indirect dependency via the SDK; if `cargo build` complains it is not a direct dependency of the `search` crate, add it: from the repo root run +`gateway_invoke(server="cargo-mcp", tool="", arguments={"name":"serde_json"})` against `crates/search`, or `cargo add -p search serde_json`. + +- [ ] **Step 7: Check whether the old `search` method is still used** — run: + +```bash +grep -rn "\.search(" crates/ | grep -v search_objects +``` +`crates/search/tests/search.rs` uses `client.search("…")` in the two existing tests, and `crates/api/tests/reindex.rs` uses `observer.search("…")`. **Keep `search` as-is** — those tests rely on it and it is a fine low-level helper. Do not delete it. + +- [ ] **Step 8: Run the test to confirm it passes** — + +```bash +MEILI_URL=http://localhost:7701 MEILI_MASTER_KEY=masterKey \ + cargo test -p search -- --nocapture +``` +Expected: PASS (the new test plus the two existing ones). + +- [ ] **Step 9: Format, lint, commit** — + +```bash +cargo +nightly fmt +cargo clippy -p search --all-targets +git add crates/search +git commit -m "feat(search): search_objects returns highlighted hits + estimated total" +``` + +--- + +## Task 2: `api` crate — `GET /api/admin/search` + OpenAPI + regenerated client types + +Expose the search capability as an auth-required admin endpoint, register it in the OpenAPI document, and regenerate the typed web client. + +**Files:** +- Create: `crates/api/src/admin_search.rs` +- Modify: `crates/api/src/lib.rs`, `crates/api/src/openapi.rs` +- Test: `crates/api/tests/admin_search.rs` +- Regenerate: `web/src/api/schema.d.ts` + +- [ ] **Step 1: Write the failing API test** — create `crates/api/tests/admin_search.rs`: + +```rust +use api::{AppState, build_app, migrate_sessions}; +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use db::users; +use domain::{AuditActor, Email, NewUser, Role}; +use http_body_util::BodyExt; +use search::SearchClient; +use sqlx::PgPool; +use tower::ServiceExt; + +fn meili() -> (String, String) { + ( + std::env::var("MEILI_URL").expect("MEILI_URL must be set"), + std::env::var("MEILI_MASTER_KEY").expect("MEILI_MASTER_KEY must be set"), + ) +} + +fn unique_index() -> String { + format!("api_search_test_{}", uuid::Uuid::new_v4().simple()) +} + +fn state(pool: PgPool, search: Option) -> AppState { + AppState { + db: db::Db::from_pool(pool), + app_name: "Test".into(), + cookie_secure: false, + search, + } +} + +async fn seed_user(pool: &PgPool, email: &str, password: &str, role: Role) { + let db = db::Db::from_pool(pool.clone()); + let mut tx = db.pool().begin().await.unwrap(); + users::create_user( + &mut tx, + AuditActor::System, + &NewUser { + email: Email::parse(email).unwrap(), + password_hash: auth::hash_password(password).unwrap(), + role, + }, + ) + .await + .unwrap(); + tx.commit().await.unwrap(); +} + +async fn login(app: &axum::Router, email: &str, password: &str) -> String { + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/login") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(format!( + r#"{{"email":"{email}","password":"{password}"}}"# + ))) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + resp.headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_owned() +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn search_requires_auth(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + let (url, key) = meili(); + let search = SearchClient::connect(&url, &key, &unique_index()).unwrap(); + search.ensure_index().await.unwrap(); + let app = build_app(state(pool, Some(search))); + + let resp = app + .oneshot( + Request::builder() + .uri("/api/admin/search?q=bronze") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn search_returns_results_and_validates_params(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await; + + let (url, key) = meili(); + let search = SearchClient::connect(&url, &key, &unique_index()).unwrap(); + search.ensure_index().await.unwrap(); + let app = build_app(state(pool.clone(), Some(search))); + let cookie = login(&app, "ed@example.com", "pw-editor-123").await; + + // Index an object via the admin API (also exercises on-write sync). + let create = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/objects") + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"object_number":"R-1","object_name":"astrolabe","number_of_objects":1,"visibility":"internal"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(create.status(), StatusCode::CREATED); + + // A matching query returns the object. + let resp = app + .clone() + .oneshot( + Request::builder() + .uri("/api/admin/search?q=astrolabe") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body: serde_json::Value = + serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap(); + assert_eq!(body["estimated_total"], 1); + assert_eq!(body["hits"][0]["object_name"], "astrolabe"); + + // An empty query short-circuits to zero results (no Meili call). + let empty = app + .clone() + .oneshot( + Request::builder() + .uri("/api/admin/search?q=") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(empty.status(), StatusCode::OK); + let empty_body: serde_json::Value = + serde_json::from_slice(&empty.into_body().collect().await.unwrap().to_bytes()).unwrap(); + assert_eq!(empty_body["estimated_total"], 0); + + // An invalid visibility value is rejected. + let bad = app + .oneshot( + Request::builder() + .uri("/api/admin/search?q=astrolabe&visibility=bogus") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(bad.status(), StatusCode::BAD_REQUEST); +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn search_unavailable_when_not_configured(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await; + let app = build_app(state(pool, None)); // search disabled + let cookie = login(&app, "ed@example.com", "pw-editor-123").await; + + let resp = app + .oneshot( + Request::builder() + .uri("/api/admin/search?q=bronze") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); +} +``` + +- [ ] **Step 2: Run the test to confirm it fails** — + +```bash +MEILI_URL=http://localhost:7701 MEILI_MASTER_KEY=masterKey \ + cargo test -p api --test admin_search +``` +Expected: compile error — the `/api/admin/search` route does not exist yet (or 404s). + +- [ ] **Step 3: Implement the handler** — create `crates/api/src/admin_search.rs`: + +```rust +//! Admin full-text search over catalogue objects. Read capability: `ViewInternal` +//! (admins search across all visibility levels). Backed by the Meilisearch index. + +use auth::{Authorized, ViewInternal}; +use axum::{ + Json, Router, + extract::{Query, State}, + http::StatusCode, + routing::get, +}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::AppState; + +#[derive(Deserialize)] +pub(crate) struct SearchParams { + #[serde(default)] + q: String, + visibility: Option, + offset: Option, + limit: Option, +} + +#[derive(Serialize, ToSchema)] +pub(crate) struct SearchHitView { + pub id: String, + pub object_number: String, + pub object_name: String, + pub brief_description: Option, + pub visibility: String, + pub snippet: Option, +} + +#[derive(Serialize, ToSchema)] +pub(crate) struct SearchResultsView { + pub hits: Vec, + /// Meilisearch's estimate of the total number of matches. + pub estimated_total: usize, +} + +#[utoipa::path( + get, path = "/api/admin/search", + params( + ("q" = String, Query, description = "Search query text"), + ("visibility" = Option, Query, description = "Filter: draft|internal|public"), + ("offset" = Option, Query, description = "default 0"), + ("limit" = Option, Query, description = "1..=50, default 20") + ), + responses( + (status = 200, body = SearchResultsView), + (status = 400, description = "Invalid visibility value"), + (status = 401), + (status = 403), + (status = 503, description = "Search is not configured") + ) +)] +pub(crate) async fn search_objects( + _auth: Authorized, + State(state): State, + Query(params): Query, +) -> Result, StatusCode> { + let Some(search) = &state.search else { + return Err(StatusCode::SERVICE_UNAVAILABLE); + }; + + // Validate the optional visibility filter against the known values. + let visibility = match params.visibility.as_deref() { + None | Some("") => None, + Some(v @ ("draft" | "internal" | "public")) => Some(v), + Some(_) => return Err(StatusCode::BAD_REQUEST), + }; + + let q = params.q.trim(); + if q.is_empty() { + return Ok(Json(SearchResultsView { + hits: Vec::new(), + estimated_total: 0, + })); + } + + let offset = params.offset.unwrap_or(0).max(0) as usize; + let limit = params.limit.unwrap_or(20).clamp(1, 50) as usize; + + let results = search + .search_objects(q, visibility, offset, limit) + .await + .map_err(|err| { + tracing::error!(?err, "search query failed"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(SearchResultsView { + hits: results + .hits + .into_iter() + .map(|h| SearchHitView { + id: h.id, + object_number: h.object_number, + object_name: h.object_name, + brief_description: h.brief_description, + visibility: h.visibility, + snippet: h.snippet, + }) + .collect(), + estimated_total: results.estimated_total, + })) +} + +pub(crate) fn routes() -> Router { + Router::new().route("/api/admin/search", get(search_objects)) +} +``` + +- [ ] **Step 4: Register the module + route** — in `crates/api/src/lib.rs`: + - add `mod admin_search;` with the other `mod` lines (keep alphabetical: after `mod admin_objects;`). + - in `build_app`, add `.merge(admin_search::routes())` to the router chain (after `.merge(admin_vocab::routes())`). + +- [ ] **Step 5: Register in OpenAPI** — in `crates/api/src/openapi.rs`: + - add `admin_search` to the `use crate::{…}` import list. + - add `admin_search::search_objects` to the `paths(…)` list. + - add `admin_search::SearchHitView` and `admin_search::SearchResultsView` to the `components(schemas(…))` list. + +- [ ] **Step 6: Run the API test to confirm it passes** — + +```bash +MEILI_URL=http://localhost:7701 MEILI_MASTER_KEY=masterKey \ + cargo test -p api --test admin_search +``` +Expected: PASS (all four cases — auth, results+validation, empty query, 503). + +- [ ] **Step 7: Regenerate the typed web client** — start the server against the test infra, regenerate, then stop it: + +```bash +# from repo root — build once so startup is fast +cargo build -p server +DATABASE_URL=postgres://postgres:postgres@localhost:5433/cms_dev \ + MEILI_URL=http://localhost:7701 MEILI_MASTER_KEY=masterKey \ + ./target/debug/server & +SERVER_PID=$! +sleep 2 # wait for the bind on :8080 +( cd web && pnpm gen:api ) # writes web/src/api/schema.d.ts +kill "$SERVER_PID" +``` +Verify `web/src/api/schema.d.ts` now contains a `"/api/admin/search"` path and `SearchHitView` / `SearchResultsView` schemas: +```bash +grep -n "api/admin/search\|SearchHitView\|SearchResultsView" web/src/api/schema.d.ts +``` +If running the server is impractical in your environment, hand-edit `web/src/api/schema.d.ts` to add the path + operation + two schemas matching the structs above (mirror the exact shape of the existing `list_objects` operation and `AdminObjectPage` schema: query params `q`/`visibility`/`offset`/`limit`, a `200` returning `SearchResultsView`, and `400/401/403/503` empty responses). Then run `cd web && pnpm typecheck` to confirm the types are well-formed. + +- [ ] **Step 8: Format, lint, commit** — + +```bash +cargo +nightly fmt +cargo clippy -p api --all-targets +git add crates/api web/src/api/schema.d.ts +git commit -m "feat(api): GET /api/admin/search endpoint + regenerated client types" +``` + +--- + +## Task 3: Frontend data layer — `useDebouncedValue` + `useSearch` + MSW fixture/handler + +**Files:** +- Create: `web/src/lib/use-debounced-value.ts` +- Modify: `web/src/api/queries.ts`, `web/src/test/fixtures.ts`, `web/src/test/handlers.ts` +- Test: `web/src/lib/use-debounced-value.test.tsx`, `web/src/api/queries.search.test.tsx` + +- [ ] **Step 1: Write the failing debounce test** — create `web/src/lib/use-debounced-value.test.tsx`: + +```tsx +import { useState } from "react"; +import { expect, test } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderApp } from "../test/render"; +import { useDebouncedValue } from "./use-debounced-value"; + +function Harness() { + const [text, setText] = useState(""); + const debounced = useDebouncedValue(text, 150); + return ( +
+ setText(e.target.value)} /> + {debounced} +
+ ); +} + +test("reflects the value after the delay", async () => { + renderApp(); + await userEvent.type(screen.getByLabelText("in"), "bronze"); + // After the debounce settles, the output catches up to the input. + await screen.findByText("bronze"); + expect(screen.getByTestId("out")).toHaveTextContent("bronze"); +}); +``` + +- [ ] **Step 2: Run it to confirm it fails** — `cd web && pnpm test src/lib/use-debounced-value.test.tsx` → FAIL (module not found). + +- [ ] **Step 3: Implement the hook** — create `web/src/lib/use-debounced-value.ts`: + +```ts +import { useEffect, useState } from "react"; + +/** Returns `value` delayed by `delayMs`; resets the timer on each change. */ +export function useDebouncedValue(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const id = setTimeout(() => setDebounced(value), delayMs); + + return () => clearTimeout(id); + }, [value, delayMs]); + + return debounced; +} +``` + +- [ ] **Step 4: Run it to confirm it passes** — `pnpm test src/lib/use-debounced-value.test.tsx` → PASS. + +- [ ] **Step 5: Add the search fixture** — in `web/src/test/fixtures.ts`, add (reuse `amphora`'s id for the first hit so the detail-pane navigation test resolves a real object; `amphora` is already defined in this file — keep this block after it): + +```ts +import type { components } from "../api/schema"; + +type SearchHitView = components["schemas"]["SearchHitView"]; + +// 25 hits so "Load more" (page size 20) is exercised. The first reuses amphora's id +// and carries a sentinel-highlighted snippet for the highlight + navigation tests. +export const searchHits: SearchHitView[] = [ + { + id: amphora.id, + object_number: "2019.4.12", + object_name: "Bronze figurine", + brief_description: "A small cast figure.", + visibility: "public", + snippet: "cast \u0002bronze\u0003 with green patina", + }, + ...Array.from({ length: 24 }, (_, i) => ({ + id: `s-${i + 2}`, + object_number: `N-${i + 2}`, + object_name: `Object ${i + 2}`, + brief_description: null, + visibility: "internal", + snippet: null, + })), +]; +``` +If `import type { components }` is already imported at the top of `fixtures.ts`, do not duplicate the import — add only the `SearchHitView` type alias and the `searchHits` const. + +- [ ] **Step 6: Add the MSW handler** — in `web/src/test/handlers.ts`, import `searchHits` from `./fixtures` and add this handler to the `handlers` array (it honors `q`/`offset`/`limit` so paging works): + +```ts + http.get("/api/admin/search", ({ request }) => { + const url = new URL(request.url); + const q = (url.searchParams.get("q") ?? "").trim(); + const offset = Number(url.searchParams.get("offset") ?? 0); + const limit = Number(url.searchParams.get("limit") ?? 20); + + if (!q) return HttpResponse.json({ hits: [], estimated_total: 0 }); + + return HttpResponse.json({ + hits: searchHits.slice(offset, offset + limit), + estimated_total: searchHits.length, + }); + }), +``` + +- [ ] **Step 7: Write the failing query test** — create `web/src/api/queries.search.test.tsx`: + +```tsx +import { expect, test } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { useSearch } from "./queries"; + +function wrapper({ children }: { children: React.ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +} + +test("useSearch fetches a page and reports more pages available", async () => { + const { result } = renderHook(() => useSearch("bronze", null), { wrapper }); + + await waitFor(() => expect(result.current.data).toBeDefined()); + + const first = result.current.data!.pages[0]; + expect(first.hits[0].object_name).toBe("Bronze figurine"); + expect(first.estimated_total).toBe(25); + expect(result.current.hasNextPage).toBe(true); +}); + +test("useSearch is disabled for an empty query", () => { + const { result } = renderHook(() => useSearch(" ", null), { wrapper }); + expect(result.current.fetchStatus).toBe("idle"); +}); +``` + +- [ ] **Step 8: Run it to confirm it fails** — `pnpm test src/api/queries.search.test.tsx` → FAIL (no `useSearch`). + +- [ ] **Step 9: Implement `useSearch`** — in `web/src/api/queries.ts`: + - change the import line to also pull in `useInfiniteQuery`: + `import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";` + - append: + +```ts +const SEARCH_PAGE = 20; + +export function useSearch(q: string, visibility: string | null) { + const term = q.trim(); + + return useInfiniteQuery({ + queryKey: ["search", term, visibility], + enabled: term.length > 0, + initialPageParam: 0, + queryFn: async ({ pageParam }) => { + const { data, error } = await api.GET("/api/admin/search", { + params: { + query: { + q: term, + ...(visibility ? { visibility } : {}), + offset: pageParam, + limit: SEARCH_PAGE, + }, + }, + }); + + if (error || !data) throw new Error("search failed"); + + return data; + }, + getNextPageParam: (lastPage, allPages) => { + const loaded = allPages.reduce((n, page) => n + page.hits.length, 0); + + return loaded < lastPage.estimated_total ? loaded : undefined; + }, + }); +} +``` + +- [ ] **Step 10: Run it to confirm it passes** — `pnpm test src/api/queries.search.test.tsx` → PASS. + +- [ ] **Step 11: Commit** — + +```bash +cd /Users/olsson/Laboratory/biggus-dickus +git add web +git commit -m "feat(web): useSearch infinite query + useDebouncedValue + MSW search handler" +``` + +--- + +## Task 4: Frontend — `Highlight` + `SearchResultRow` + +**Files:** +- Create: `web/src/search/highlight.tsx`, `web/src/search/search-result-row.tsx` +- Test: `web/src/search/highlight.test.tsx` + +- [ ] **Step 1: Write the failing highlight test** — create `web/src/search/highlight.test.tsx`: + +```tsx +import { expect, test } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { Highlight } from "./highlight"; + +test("renders matched segments as and plain text around them", () => { + render(); + const mark = screen.getByText("bronze"); + expect(mark.tagName).toBe("MARK"); + // The surrounding text is present (split across nodes). + expect(document.body).toHaveTextContent("cast bronze with patina"); +}); + +test("renders plain text unchanged when there are no markers", () => { + render(); + expect(document.body).toHaveTextContent("no markers here"); + expect(screen.queryByRole("mark")).toBeNull(); +}); +``` + +- [ ] **Step 2: Run it to confirm it fails** — `pnpm test src/search/highlight.test.tsx` → FAIL (module not found). + +- [ ] **Step 3: Implement `Highlight`** — create `web/src/search/highlight.tsx`: + +```tsx +import type { ReactNode } from "react"; + +// Must match the backend's search::HL_PRE / HL_POST sentinel chars (U+0002 / U+0003). +const PRE = "\u0002"; +const POST = "\u0003"; + +/** Renders a sentinel-marked snippet: matched spans become , the rest is text. + * Pure string handling — no HTML is injected, so this is XSS-safe. */ +export function Highlight({ text }: { text: string }) { + const nodes: ReactNode[] = []; + let rest = text; + let key = 0; + + while (rest.length > 0) { + const start = rest.indexOf(PRE); + + if (start === -1) { + nodes.push(rest); + break; + } + + if (start > 0) nodes.push(rest.slice(0, start)); + + const end = rest.indexOf(POST, start + PRE.length); + + if (end === -1) { + // Malformed: no closing marker. Emit the remainder verbatim, minus the marker. + nodes.push(rest.slice(start + PRE.length)); + break; + } + + nodes.push( + + {rest.slice(start + PRE.length, end)} + , + ); + rest = rest.slice(end + POST.length); + } + + return <>{nodes}; +} +``` + +- [ ] **Step 4: Run it to confirm it passes** — `pnpm test src/search/highlight.test.tsx` → PASS. + +- [ ] **Step 5: Implement `SearchResultRow`** — create `web/src/search/search-result-row.tsx` (reuses the existing `VisibilityBadge`): + +```tsx +import { NavLink } from "react-router-dom"; + +import type { components } from "../api/schema"; +import { VisibilityBadge } from "../objects/visibility-badge"; +import { Highlight } from "./highlight"; + +type SearchHitView = components["schemas"]["SearchHitView"]; + +export function SearchResultRow({ hit }: { hit: SearchHitView }) { + return ( +
  • + + `block border-b px-3 py-2 ${isActive ? "bg-indigo-50" : "hover:bg-neutral-50"}` + } + > +
    {hit.object_name}
    +
    + {hit.object_number} + +
    + {hit.snippet && ( +

    + +

    + )} +
    +
  • + ); +} +``` +Confirm the `VisibilityBadge` prop name by opening `web/src/objects/visibility-badge.tsx`; it takes `visibility: string` (the Objects list passes `object.visibility`). Match whatever the file declares. + +- [ ] **Step 6: Typecheck + commit** — + +```bash +cd web && pnpm typecheck && pnpm lint && cd .. +git add web +git commit -m "feat(web): Highlight (XSS-safe) + SearchResultRow components" +``` + +--- + +## Task 5: Frontend — `/search` screen, route, nav, i18n + +**Files:** +- Create: `web/src/search/search-page.tsx`, `web/src/search/search-panel.tsx`, `web/src/search/select-search-prompt.tsx`, `web/src/search/search.test.tsx` +- Modify: `web/src/app.tsx`, `web/src/shell/app-shell.tsx`, `web/src/i18n/{en,sv}.json` + +- [ ] **Step 1: i18n** — merge a `search` namespace. In `web/src/i18n/en.json`: + +```json +"search": { + "placeholder": "Search the collection…", + "all": "All", + "prompt": "Type to search", + "empty": "No results", + "loadError": "Search is unavailable", + "loadMore": "Load more", + "resultCount": "{{count}} results", + "selectPrompt": "Select a result to see the full record" +} +``` +In `web/src/i18n/sv.json`: + +```json +"search": { + "placeholder": "Sök i samlingen…", + "all": "Alla", + "prompt": "Skriv för att söka", + "empty": "Inga träffar", + "loadError": "Sök är inte tillgängligt", + "loadMore": "Visa fler", + "resultCount": "{{count}} träffar", + "selectPrompt": "Välj en träff för att se hela posten" +} +``` +The visibility pill labels reuse the existing top-level `visibility.{draft,internal,public}` keys, so nothing to add there. Keep en/sv at parity. + +- [ ] **Step 2: Implement `SelectSearchPrompt`** — create `web/src/search/select-search-prompt.tsx`: + +```tsx +import { useTranslation } from "react-i18next"; + +export function SelectSearchPrompt() { + const { t } = useTranslation(); + + return ( +
    + {t("search.selectPrompt")} +
    + ); +} +``` + +- [ ] **Step 3: Implement `SearchPage`** — create `web/src/search/search-page.tsx`: + +```tsx +import { Outlet } from "react-router-dom"; + +import { SearchPanel } from "./search-panel"; + +export function SearchPage() { + return ( +
    +
    + +
    +
    + +
    +
    + ); +} +``` + +- [ ] **Step 4: Implement `SearchPanel`** — create `web/src/search/search-panel.tsx`: + +```tsx +import { useEffect, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; + +import { useSearch } from "../api/queries"; +import { useDebouncedValue } from "../lib/use-debounced-value"; +import { SearchResultRow } from "./search-result-row"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; + +const VIS = ["all", "draft", "internal", "public"] as const; + +export function SearchPanel() { + const { t } = useTranslation(); + const [params, setParams] = useSearchParams(); + const [text, setText] = useState(() => params.get("q") ?? ""); + const visibility = params.get("visibility"); // null == "all" + const debounced = useDebouncedValue(text, 300); + + // Mirror the debounced query into the URL (replace — no history spam). + useEffect(() => { + setParams( + (prev) => { + const next = new URLSearchParams(prev); + const term = debounced.trim(); + + if (term) next.set("q", term); + else next.delete("q"); + + return next; + }, + { replace: true }, + ); + }, [debounced, setParams]); + + const search = useSearch(debounced, visibility); + + const setVisibility = (value: string) => + setParams( + (prev) => { + const next = new URLSearchParams(prev); + + if (value === "all") next.delete("visibility"); + else next.set("visibility", value); + + return next; + }, + { replace: true }, + ); + + const hits = search.data?.pages.flatMap((page) => page.hits) ?? []; + const total = search.data?.pages[0]?.estimated_total ?? 0; + const hasQuery = debounced.trim().length > 0; + + return ( +
    +
    + setText(event.target.value)} + placeholder={t("search.placeholder")} + aria-label={t("search.placeholder")} + /> +
    + {VIS.map((value) => { + const active = (visibility ?? "all") === value; + + return ( + + ); + })} +
    +
    + +
    + {!hasQuery &&

    {t("search.prompt")}

    } + + {hasQuery && search.isLoading && ( +
    + {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
    + )} + + {hasQuery && search.isError && ( +

    {t("search.loadError")}

    + )} + + {hasQuery && !search.isLoading && !search.isError && hits.length === 0 && ( +

    {t("search.empty")}

    + )} + + {hits.length > 0 && ( + <> +

    + {t("search.resultCount", { count: total })} +

    +
      + {hits.map((hit) => ( + + ))} +
    + {search.hasNextPage && ( +
    + +
    + )} + + )} +
    +
    + ); +} +``` + +- [ ] **Step 5: Wire the route** — in `web/src/app.tsx`: + - import the new screens at the top: + `import { SearchPage } from "./search/search-page";` + `import { SelectSearchPrompt } from "./search/select-search-prompt";` + (`ObjectDetail` is already imported.) + - add this nested route inside the `` group (next to the `/objects` route group): + +```tsx +}> + } /> + } /> + +``` + +- [ ] **Step 6: Enable the Search nav** — in `web/src/shell/app-shell.tsx`: + - change `const DISABLED_NAV = ["fields", "search"] as const;` to `const DISABLED_NAV = ["fields"] as const;` + - add a `NavLink` for Search alongside the others (after the Authorities `NavLink`, before the `DISABLED_NAV.map(...)`): + +```tsx + + `block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}` + } + > + {t("nav.search")} + +``` + +- [ ] **Step 7: Write the integration test** — create `web/src/search/search.test.tsx`: + +```tsx +import { expect, test } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { Route, Routes } from "react-router-dom"; + +import { server } from "../test/server"; +import { renderApp } from "../test/render"; +import { SearchPage } from "./search-page"; +import { SelectSearchPrompt } from "./select-search-prompt"; +import { ObjectDetail } from "../objects/object-detail"; + +function tree() { + return ( + + }> + } /> + } /> + + + ); +} + +test("typing searches and renders highlighted rich rows", async () => { + renderApp(tree(), { route: "/search" }); + await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze"); + + expect(await screen.findByText("Bronze figurine")).toBeInTheDocument(); + // The snippet's matched term is highlighted. + const mark = await screen.findByText("bronze"); + expect(mark.tagName).toBe("MARK"); + // Result count is shown. + expect(screen.getByText(/25 results/i)).toBeInTheDocument(); +}); + +test("Load more appends the next page", async () => { + renderApp(tree(), { route: "/search" }); + await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze"); + await screen.findByText("Bronze figurine"); + + // Page 2 content not present yet (page size 20, 25 total). + expect(screen.queryByText("Object 21")).toBeNull(); + await userEvent.click(screen.getByRole("button", { name: /load more/i })); + expect(await screen.findByText("Object 21")).toBeInTheDocument(); +}); + +test("the visibility filter adds the param to the request", async () => { + let lastVisibility: string | null = "unset"; + server.use( + http.get("/api/admin/search", ({ request }) => { + lastVisibility = new URL(request.url).searchParams.get("visibility"); + return HttpResponse.json({ hits: [], estimated_total: 0 }); + }), + ); + renderApp(tree(), { route: "/search" }); + await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze"); + await userEvent.click(screen.getByRole("button", { name: /^draft$/i })); + + await waitFor(() => expect(lastVisibility).toBe("draft")); +}); + +test("empty query shows the prompt; zero results shows empty", async () => { + renderApp(tree(), { route: "/search" }); + // Idle prompt before typing. + expect(screen.getByText(/type to search/i)).toBeInTheDocument(); + + server.use( + http.get("/api/admin/search", () => HttpResponse.json({ hits: [], estimated_total: 0 })), + ); + await userEvent.type(screen.getByLabelText(/search the collection/i), "zzz"); + expect(await screen.findByText(/no results/i)).toBeInTheDocument(); +}); + +test("clicking a result shows the object in the detail pane", async () => { + renderApp(tree(), { route: "/search" }); + await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze"); + await userEvent.click(await screen.findByText("Bronze figurine")); + + // amphora's id backs the first hit; ObjectDetail fetches and renders it. + // amphora.object_name appears in the detail pane heading. + expect(await screen.findByRole("heading", { level: 2 })).toBeInTheDocument(); +}); +``` +If `web/src/test/server.ts` is not the correct path for the MSW `server`, match the import used by the existing `web/src/vocab/vocabularies.test.tsx` (it imports `{ server }` from `../test/server`). The detail-pane assertion relies on `amphora` being served by the existing `GET /api/admin/objects/:id` handler — confirm `amphora.id` is what `searchHits[0].id` was set to in Task 3. + +- [ ] **Step 8: Run the search tests** — `pnpm test src/search/search.test.tsx` → all PASS. Fix any selector mismatches (e.g. the heading level in `ObjectDetail` is `h2`; adjust the final assertion to the actual object name via `screen.findByText(amphora.object_name)` if you prefer a precise check — import the fixture). + +- [ ] **Step 9: Update the app-shell test** — open `web/src/shell/app-shell.test.tsx`. If it asserts that `search` is a disabled button, change that to assert Search is now a link (`screen.getByRole("link", { name: /search/i })`), and keep asserting `fields` is still disabled. Run `pnpm test src/shell/app-shell.test.tsx` → PASS. + +- [ ] **Step 10: Commit** — + +```bash +cd /Users/olsson/Laboratory/biggus-dickus +git add web +git commit -m "feat(web): /search two-pane screen (debounced query, visibility filter, load more) + nav" +``` + +--- + +## Task 6: i18n parity + full verification + +**Files:** none expected (verification); fix-ups only if a check fails. + +- [ ] **Step 1: i18n parity check** — + +```bash +cd web +node -e "const a=require('./src/i18n/en.json'),b=require('./src/i18n/sv.json');const keys=o=>Object.entries(o).flatMap(([k,v])=>typeof v==='object'?keys(v).map(s=>k+'.'+s):[k]);const ka=keys(a).sort(),kb=keys(b).sort();console.log(JSON.stringify(ka)===JSON.stringify(kb)?'PARITY OK':'MISMATCH:'+JSON.stringify({onlyEn:ka.filter(k=>!kb.includes(k)),onlySv:kb.filter(k=>!ka.includes(k))}))" +``` +Expected `PARITY OK`; fix any mismatch. + +- [ ] **Step 2: Full frontend verification** — + +```bash +cd web +pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size +``` +Expected: clean; all tests pass; **bundle ≤150 KB gz** (report the number). Current headroom is ~7 KB. If `check:size` exceeds the budget, lazy-load `/search` in `web/src/app.tsx` (mirror the M2 form lazy-loading: `const SearchPage = lazy(() => import("./search/search-page").then((m) => ({ default: m.SearchPage })))` and wrap the route element in `}>`), then re-run `pnpm build && pnpm check:size`. + +- [ ] **Step 3: Full backend verification** — + +```bash +cd /Users/olsson/Laboratory/biggus-dickus +DATABASE_URL=postgres://postgres:postgres@localhost:5433/cms_dev \ + MEILI_URL=http://localhost:7701 MEILI_MASTER_KEY=masterKey \ + cargo test -p search -p api +cargo clippy --workspace --all-targets +cargo +nightly fmt --check +``` +Expected: all tests pass; clippy clean; fmt clean. + +- [ ] **Step 4: Commit** — only if Steps 1–2 required a fix (e.g. lazy-loading or a parity fix): + +```bash +git add web +git commit -m "chore(web): m5 search verification — bundle/i18n fix-ups" +``` + +--- + +## Self-Review (completed) + +**Spec coverage:** +- Backend `search` crate `SearchHit`/`SearchResults` + `search_objects` (filter, paging, highlight, estimated total) → Task 1. ✓ +- `GET /api/admin/search` (auth, q-trim/empty short-circuit, visibility validation, offset/limit clamp, 503 when disabled, XSS-safe sentinel highlight) + OpenAPI + regenerated client → Task 2. ✓ +- `useSearch` (`useInfiniteQuery`, `getNextPageParam` from estimated_total) + `useDebouncedValue` + MSW handler/fixture → Task 3. ✓ +- XSS-safe `Highlight` (sentinel split, no `dangerouslySetInnerHTML`) + rich `SearchResultRow` (name, number, visibility badge, snippet) → Task 4. ✓ +- `/search` two-pane screen, debounced query, visibility pills, URL sync (`q`+`visibility`, replace), result count, Load more, loading/empty/error/idle states; route with `ObjectDetail` reuse; Search nav enabled (`DISABLED_NAV = ["fields"]`) → Task 5. ✓ +- i18n sv/en parity + bundle ≤150 KB + full backend & frontend verification → Task 6. ✓ +- Create-only / read-only respected (search is read-only); omnibox, faceting, public search endpoint left out per spec follow-ups. ✓ + +**Placeholder scan:** none — every code step contains complete code; verification steps give exact commands + expected output. The two "confirm the prop name / confirm the import path" notes are concrete verification instructions against named files, not deferred work. + +**Type consistency:** `SearchHit`/`SearchResults` (search crate) ↔ `SearchHitView`/`SearchResultsView` (api crate, the OpenAPI/TS boundary) ↔ `components["schemas"]["SearchHitView"]` (frontend) are used consistently; `useSearch(q, visibility)` signature matches its consumer in `SearchPanel`; the `["search", term, visibility]` query key, `estimated_total` field, `HL_PRE`/`HL_POST` sentinels (`\u{2}`/`\u{3}` backend, `\u0002`/`\u0003` frontend — same code points), and the `/search/:id` → `ObjectDetail` route are consistent across tasks. + +## Notes for follow-on +- ⌘K global omnibox / command palette (deferred per spec) — file a frontend follow-up when M5 lands. +- Richer faceting (owner, has-images, date ranges) + facet counts; relevance tuning (synonyms, ranking rules); a public `/api/public/search` endpoint; search query analytics.