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

50 KiB
Raw Permalink Blame History

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:

/// 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:
use meilisearch_sdk::search::Selectors;
use meilisearch_sdk::tasks::Task;
  • Step 3: Write the failing test — append to crates/search/tests/search.rs:
#[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
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):
    /// 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):
/// 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:
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
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
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:

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
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:
//! 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

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:
# 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:

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
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:

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 failscd 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:

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 passespnpm 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):

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):
  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:
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 failspnpm 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:
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 passespnpm test src/api/queries.search.test.tsx → PASS.

  • Step 11: Commit

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:

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 failspnpm test src/search/highlight.test.tsx → FAIL (module not found).

  • Step 3: Implement Highlight — create web/src/search/highlight.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 passespnpm test src/search/highlight.test.tsx → PASS.

  • Step 5: Implement SearchResultRow — create web/src/search/search-result-row.tsx (reuses the existing VisibilityBadge):

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
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:

"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:

"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:
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:
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:
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):
<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(...)):
          <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:
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 testspnpm 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

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
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
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
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):
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/:idObjectDetail 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.