Files
biggus-dickus/docs/superpowers/plans/2026-06-04-frontend-spa-milestone-5.md
T
2026-06-04 10:27:59 +02:00

1356 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 masterdetail 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 `<mark>` — 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<String>,
pub visibility: String,
pub snippet: Option<String>,
}
/// 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<SearchHit>,
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<SearchResults, SearchError> {
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<usize>)] =
&[("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::<SearchDocument>().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<String, serde_json::Value>) -> Option<String> {
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="<add>", 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<SearchClient>) -> 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<String>,
offset: Option<i64>,
limit: Option<i64>,
}
#[derive(Serialize, ToSchema)]
pub(crate) struct SearchHitView {
pub id: String,
pub object_number: String,
pub object_name: String,
pub brief_description: Option<String>,
pub visibility: String,
pub snippet: Option<String>,
}
#[derive(Serialize, ToSchema)]
pub(crate) struct SearchResultsView {
pub hits: Vec<SearchHitView>,
/// 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<String>, Query, description = "Filter: draft|internal|public"),
("offset" = Option<i64>, Query, description = "default 0"),
("limit" = Option<i64>, 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<ViewInternal>,
State(state): State<AppState>,
Query(params): Query<SearchParams>,
) -> Result<Json<SearchResultsView>, 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<AppState> {
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 (
<div>
<input aria-label="in" value={text} onChange={(e) => setText(e.target.value)} />
<span data-testid="out">{debounced}</span>
</div>
);
}
test("reflects the value after the delay", async () => {
renderApp(<Harness />);
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<T>(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 <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
}
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 <mark> and plain text around them", () => {
render(<Highlight text={"cast \u0002bronze\u0003 with patina"} />);
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(<Highlight text="no markers here" />);
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 <mark>, 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(
<mark key={key++} className="bg-yellow-200">
{rest.slice(start + PRE.length, end)}
</mark>,
);
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 (
<li>
<NavLink
to={`/search/${hit.id}`}
className={({ isActive }) =>
`block border-b px-3 py-2 ${isActive ? "bg-indigo-50" : "hover:bg-neutral-50"}`
}
>
<div className="text-sm font-semibold">{hit.object_name}</div>
<div className="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">
<span>{hit.object_number}</span>
<VisibilityBadge visibility={hit.visibility} />
</div>
{hit.snippet && (
<p className="mt-1 line-clamp-2 text-xs text-neutral-600">
<Highlight text={hit.snippet} />
</p>
)}
</NavLink>
</li>
);
}
```
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 (
<div className="flex h-full items-center justify-center p-4 text-sm text-neutral-400">
{t("search.selectPrompt")}
</div>
);
}
```
- [ ] **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 (
<div className="grid h-full grid-cols-[24rem_1fr]">
<div className="overflow-hidden border-r">
<SearchPanel />
</div>
<div className="overflow-hidden">
<Outlet />
</div>
</div>
);
}
```
- [ ] **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 (
<div className="flex h-full flex-col">
<div className="space-y-2 border-b p-3">
<Input
value={text}
onChange={(event) => setText(event.target.value)}
placeholder={t("search.placeholder")}
aria-label={t("search.placeholder")}
/>
<div className="flex gap-1 text-xs">
{VIS.map((value) => {
const active = (visibility ?? "all") === value;
return (
<button
key={value}
type="button"
onClick={() => setVisibility(value)}
className={`rounded px-2 py-0.5 ${active ? "bg-indigo-600 text-white" : "border"}`}
>
{value === "all" ? t("search.all") : t(`visibility.${value}`)}
</button>
);
})}
</div>
</div>
<div className="flex-1 overflow-auto">
{!hasQuery && <p className="p-4 text-sm text-neutral-400">{t("search.prompt")}</p>}
{hasQuery && search.isLoading && (
<div className="space-y-2 p-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{hasQuery && search.isError && (
<p className="p-4 text-sm text-red-600">{t("search.loadError")}</p>
)}
{hasQuery && !search.isLoading && !search.isError && hits.length === 0 && (
<p className="p-4 text-sm text-neutral-500">{t("search.empty")}</p>
)}
{hits.length > 0 && (
<>
<p className="px-3 pt-2 text-xs text-neutral-500">
{t("search.resultCount", { count: total })}
</p>
<ul>
{hits.map((hit) => (
<SearchResultRow key={hit.id} hit={hit} />
))}
</ul>
{search.hasNextPage && (
<div className="p-3 text-center">
<Button
variant="ghost"
size="sm"
disabled={search.isFetchingNextPage}
onClick={() => search.fetchNextPage()}
>
{t("search.loadMore")}
</Button>
</div>
)}
</>
)}
</div>
</div>
);
}
```
- [ ] **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 `<AppShell>` group (next to the `/objects` route group):
```tsx
<Route path="/search" element={<SearchPage />}>
<Route index element={<SelectSearchPrompt />} />
<Route path=":id" element={<ObjectDetail />} />
</Route>
```
- [ ] **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
<NavLink
to="/search"
className={({ isActive }) =>
`block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}`
}
>
{t("nav.search")}
</NavLink>
```
- [ ] **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 (
<Routes>
<Route path="/search" element={<SearchPage />}>
<Route index element={<SelectSearchPrompt />} />
<Route path=":id" element={<ObjectDetail />} />
</Route>
</Routes>
);
}
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 `<Suspense fallback={<FormFallback />}>`), 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 12 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.