38e4525404
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1356 lines
50 KiB
Markdown
1356 lines
50 KiB
Markdown
# Frontend SPA Milestone 5 (Search) Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Ship full-text search end-to-end — a `GET /api/admin/search` endpoint backed by the existing Meilisearch index, and a `/search` two-pane screen (debounced query + visibility filter + paginated, highlighted results on the left; the selected object's full detail on the right).
|
||
|
||
**Architecture:** Backend grows a `SearchClient::search_objects` method that returns rich hits (metadata + highlighted snippet) straight from Meilisearch, exposed via a new auth-required admin handler. The frontend reuses the Objects master–detail pattern: a results panel keyed on a debounced query string drives a TanStack `useInfiniteQuery`; clicking a hit routes to `/search/:id` which reuses the existing `ObjectDetail`. Highlight markers are non-HTML sentinel chars, split client-side into `<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 1–2 required a fix (e.g. lazy-loading or a parity fix):
|
||
|
||
```bash
|
||
git add web
|
||
git commit -m "chore(web): m5 search verification — bundle/i18n fix-ups"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review (completed)
|
||
|
||
**Spec coverage:**
|
||
- Backend `search` crate `SearchHit`/`SearchResults` + `search_objects` (filter, paging, highlight, estimated total) → Task 1. ✓
|
||
- `GET /api/admin/search` (auth, q-trim/empty short-circuit, visibility validation, offset/limit clamp, 503 when disabled, XSS-safe sentinel highlight) + OpenAPI + regenerated client → Task 2. ✓
|
||
- `useSearch` (`useInfiniteQuery`, `getNextPageParam` from estimated_total) + `useDebouncedValue` + MSW handler/fixture → Task 3. ✓
|
||
- XSS-safe `Highlight` (sentinel split, no `dangerouslySetInnerHTML`) + rich `SearchResultRow` (name, number, visibility badge, snippet) → Task 4. ✓
|
||
- `/search` two-pane screen, debounced query, visibility pills, URL sync (`q`+`visibility`, replace), result count, Load more, loading/empty/error/idle states; route with `ObjectDetail` reuse; Search nav enabled (`DISABLED_NAV = ["fields"]`) → Task 5. ✓
|
||
- i18n sv/en parity + bundle ≤150 KB + full backend & frontend verification → Task 6. ✓
|
||
- Create-only / read-only respected (search is read-only); omnibox, faceting, public search endpoint left out per spec follow-ups. ✓
|
||
|
||
**Placeholder scan:** none — every code step contains complete code; verification steps give exact commands + expected output. The two "confirm the prop name / confirm the import path" notes are concrete verification instructions against named files, not deferred work.
|
||
|
||
**Type consistency:** `SearchHit`/`SearchResults` (search crate) ↔ `SearchHitView`/`SearchResultsView` (api crate, the OpenAPI/TS boundary) ↔ `components["schemas"]["SearchHitView"]` (frontend) are used consistently; `useSearch(q, visibility)` signature matches its consumer in `SearchPanel`; the `["search", term, visibility]` query key, `estimated_total` field, `HL_PRE`/`HL_POST` sentinels (`\u{2}`/`\u{3}` backend, `\u0002`/`\u0003` frontend — same code points), and the `/search/:id` → `ObjectDetail` route are consistent across tasks.
|
||
|
||
## Notes for follow-on
|
||
- ⌘K global omnibox / command palette (deferred per spec) — file a frontend follow-up when M5 lands.
|
||
- Richer faceting (owner, has-images, date ranges) + facet counts; relevance tuning (synonyms, ranking rules); a public `/api/public/search` endpoint; search query analytics.
|