From 84c4c2807b322a2bd8d4fb1570539b82db0f1c17 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 10:46:54 +0200 Subject: [PATCH 1/9] feat(search): search_objects returns highlighted hits + estimated total Co-Authored-By: Claude Sonnet 4.6 --- crates/search/Cargo.toml | 1 + crates/search/src/lib.rs | 118 ++++++++++++++++++++++++++++++++++ crates/search/tests/search.rs | 51 ++++++++++++++- 3 files changed, 169 insertions(+), 1 deletion(-) diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml index 858a735..a5da593 100644 --- a/crates/search/Cargo.toml +++ b/crates/search/Cargo.toml @@ -11,6 +11,7 @@ thiserror.workspace = true domain = { path = "../domain" } db = { path = "../db" } sqlx.workspace = true +serde_json.workspace = true [dev-dependencies] tokio.workspace = true diff --git a/crates/search/src/lib.rs b/crates/search/src/lib.rs index 8dbe066..a74f202 100644 --- a/crates/search/src/lib.rs +++ b/crates/search/src/lib.rs @@ -8,6 +8,7 @@ use db::Db; use domain::{CatalogueObject, ObjectId}; +use meilisearch_sdk::search::Selectors; use meilisearch_sdk::tasks::Task; use serde::{Deserialize, Serialize}; @@ -39,6 +40,31 @@ pub struct SearchDocument { pub fields_text: Vec, } +/// Non-HTML highlight markers. These ASCII control characters cannot occur in +/// catalogue text, so the frontend can safely split on them to render matches — +/// no HTML ever crosses the API boundary. +pub const HL_PRE: &str = "\u{2}"; +pub const HL_POST: &str = "\u{3}"; + +/// One search result: display metadata projected from the index, plus an optional +/// snippet of matched text with [`HL_PRE`]/[`HL_POST`] markers around the matches. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchHit { + pub id: String, + pub object_number: String, + pub object_name: String, + pub brief_description: Option, + pub visibility: String, + pub snippet: Option, +} + +/// A page of search results plus Meilisearch's estimate of the total match count. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchResults { + pub hits: Vec, + pub estimated_total: usize, +} + /// A Meilisearch-backed search client scoped to one index. #[derive(Clone)] pub struct SearchClient { @@ -147,6 +173,63 @@ impl SearchClient { .collect() } + /// Full-text query returning display-ready hits with highlighted snippets and the + /// estimated total match count. `visibility`, when set, filters on the indexed + /// `visibility` attribute. Pagination is offset/limit. + pub async fn search_objects( + &self, + query: &str, + visibility: Option<&str>, + offset: usize, + limit: usize, + ) -> Result { + let index = self.client.index(&self.index_uid); + + let filter = visibility.map(|v| format!("visibility = \"{v}\"")); + let highlight: &[&str] = &["object_name", "brief_description", "fields_text"]; + let crop: &[(&str, Option)] = &[("brief_description", None), ("fields_text", None)]; + + let mut search = index.search(); + search + .with_query(query) + .with_offset(offset) + .with_limit(limit) + .with_attributes_to_highlight(Selectors::Some(highlight)) + .with_attributes_to_crop(Selectors::Some(crop)) + .with_crop_length(20) + .with_highlight_pre_tag(HL_PRE) + .with_highlight_post_tag(HL_POST); + + if let Some(filter) = &filter { + search.with_filter(filter); + } + + let results = search.execute::().await?; + + let hits = results + .hits + .into_iter() + .map(|hit| { + let snippet = hit.formatted_result.as_ref().and_then(extract_snippet); + let doc = hit.result; + + SearchHit { + id: doc.id, + object_number: doc.object_number, + object_name: doc.object_name, + brief_description: doc.brief_description, + visibility: doc.visibility, + snippet, + } + }) + .collect(); + + Ok(SearchResults { + hits, + estimated_total: results.estimated_total_hits.unwrap_or(0), + }) + } + /// Sync a single object's index entry with the database after a catalogue write /// commits: re-project and index it if it still exists, otherwise remove it. This /// is the uniform on-write path for create/update/delete/field/visibility changes — @@ -272,3 +355,38 @@ pub async fn build_document( fields_text, }) } + +/// Pick the best snippet from Meilisearch's `_formatted` map: prefer a highlighted +/// `brief_description`, then a highlighted `fields_text` entry, then `object_name`; +/// fall back to an unhighlighted `brief_description` so a hit still shows context. +fn extract_snippet(formatted: &serde_json::Map) -> Option { + let has_mark = |s: &str| s.contains(HL_PRE); + + if let Some(serde_json::Value::String(s)) = formatted.get("brief_description") { + if has_mark(s) { + return Some(s.clone()); + } + } + + if let Some(serde_json::Value::Array(items)) = formatted.get("fields_text") { + for item in items { + if let Some(s) = item.as_str() { + if has_mark(s) { + return Some(s.to_owned()); + } + } + } + } + + if let Some(serde_json::Value::String(s)) = formatted.get("object_name") { + if has_mark(s) { + return Some(s.clone()); + } + } + + if let Some(serde_json::Value::String(s)) = formatted.get("brief_description") { + return Some(s.clone()); + } + + None +} diff --git a/crates/search/tests/search.rs b/crates/search/tests/search.rs index dcac090..dec4543 100644 --- a/crates/search/tests/search.rs +++ b/crates/search/tests/search.rs @@ -1,4 +1,4 @@ -use search::{SearchClient, SearchDocument}; +use search::{self, SearchClient, SearchDocument}; fn meili() -> (String, String) { ( @@ -51,6 +51,55 @@ async fn index_search_and_remove() { assert!(client.search("wood").await.unwrap().is_empty()); } +#[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(); + + 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(); + + let results = client.search_objects("bronze", None, 0, 20).await.unwrap(); + assert_eq!(results.estimated_total, 3); + assert_eq!(results.hits.len(), 3); + + 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)); + + 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")); + + let page = client.search_objects("bronze", None, 0, 1).await.unwrap(); + assert_eq!(page.hits.len(), 1); + assert_eq!(page.estimated_total, 3); +} + #[tokio::test] async fn ensure_index_is_idempotent() { let (url, key) = meili(); From 9b1771d5848e9b3742c66367e31d26d25bf8eae3 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 11:19:46 +0200 Subject: [PATCH 2/9] refactor(search): document+guard visibility filter precondition; drop redundant dev-dep - Remove serde_json from [dev-dependencies] (already in [dependencies]) - Add debug_assert! in search_objects visibility filter as defense-in-depth - Extend search_objects doc-comment with visibility precondition - Clarify estimated_total_hits.unwrap_or(0) is safe under offset/limit pagination - Add brief comment on with_crop_length(20) explaining ~20-word context window Co-Authored-By: Claude Sonnet 4.6 --- crates/search/Cargo.toml | 1 - crates/search/src/lib.rs | 18 +++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml index a5da593..2b28ab5 100644 --- a/crates/search/Cargo.toml +++ b/crates/search/Cargo.toml @@ -16,6 +16,5 @@ serde_json.workspace = true [dev-dependencies] tokio.workspace = true uuid.workspace = true -serde_json.workspace = true sqlx.workspace = true domain = { path = "../domain" } diff --git a/crates/search/src/lib.rs b/crates/search/src/lib.rs index a74f202..286a1c3 100644 --- a/crates/search/src/lib.rs +++ b/crates/search/src/lib.rs @@ -176,6 +176,12 @@ impl SearchClient { /// 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. + /// + /// # Preconditions + /// + /// When `visibility` is `Some`, the value must be one of `"draft"`, `"internal"`, or + /// `"public"`. The caller owns this validation (the API layer enforces it); this + /// method `debug_assert!`s the constraint as defense-in-depth. pub async fn search_objects( &self, query: &str, @@ -185,7 +191,14 @@ impl SearchClient { ) -> Result { let index = self.client.index(&self.index_uid); - let filter = visibility.map(|v| format!("visibility = \"{v}\"")); + let filter = visibility.map(|v| { + debug_assert!( + matches!(v, "draft" | "internal" | "public"), + "visibility filter must be a known value; got {v:?}" + ); + + format!("visibility = \"{v}\"") + }); let highlight: &[&str] = &["object_name", "brief_description", "fields_text"]; let crop: &[(&str, Option)] = &[("brief_description", None), ("fields_text", None)]; @@ -196,6 +209,7 @@ impl SearchClient { .with_limit(limit) .with_attributes_to_highlight(Selectors::Some(highlight)) .with_attributes_to_crop(Selectors::Some(crop)) + // ~20 words gives enough catalogue-description context around a match. .with_crop_length(20) .with_highlight_pre_tag(HL_PRE) .with_highlight_post_tag(HL_POST); @@ -226,6 +240,8 @@ impl SearchClient { Ok(SearchResults { hits, + // estimated_total_hits is always present for offset/limit pagination; + // None only under page-based mode, which we don't use. estimated_total: results.estimated_total_hits.unwrap_or(0), }) } From a87501b9027ec03db8522dba3d3dcc5071e42a4c Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 11:48:32 +0200 Subject: [PATCH 3/9] feat(api): GET /api/admin/search endpoint + regenerated client types Expose full-text search over catalogue objects via a new admin endpoint backed by the Meilisearch SearchClient. Validates visibility filter values, short-circuits on empty queries, clamps pagination, and returns 503 when search is not configured. Registered in OpenAPI; schema.d.ts regenerated. Co-Authored-By: Claude Sonnet 4.6 --- crates/api/src/admin_search.rs | 112 ++++++++++++++++ crates/api/src/lib.rs | 2 + crates/api/src/openapi.rs | 7 +- crates/api/tests/admin_search.rs | 215 +++++++++++++++++++++++++++++++ web/src/api/schema.d.ts | 83 ++++++++++++ 5 files changed, 418 insertions(+), 1 deletion(-) create mode 100644 crates/api/src/admin_search.rs create mode 100644 crates/api/tests/admin_search.rs diff --git a/crates/api/src/admin_search.rs b/crates/api/src/admin_search.rs new file mode 100644 index 0000000..a232db3 --- /dev/null +++ b/crates/api/src/admin_search.rs @@ -0,0 +1,112 @@ +//! Admin full-text search over catalogue objects. Read capability: `ViewInternal` +//! (admins search across all visibility levels). Backed by the Meilisearch index. + +use auth::{Authorized, ViewInternal}; +use axum::{ + Json, Router, + extract::{Query, State}, + http::StatusCode, + routing::get, +}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::AppState; + +#[derive(Deserialize)] +pub(crate) struct SearchParams { + #[serde(default)] + q: String, + visibility: Option, + offset: Option, + limit: Option, +} + +#[derive(Serialize, ToSchema)] +pub(crate) struct SearchHitView { + pub id: String, + pub object_number: String, + pub object_name: String, + pub brief_description: Option, + pub visibility: String, + pub snippet: Option, +} + +#[derive(Serialize, ToSchema)] +pub(crate) struct SearchResultsView { + pub hits: Vec, + /// Meilisearch's estimate of the total number of matches. + pub estimated_total: usize, +} + +#[utoipa::path( + get, path = "/api/admin/search", + params( + ("q" = String, Query, description = "Search query text"), + ("visibility" = Option, Query, description = "Filter: draft|internal|public"), + ("offset" = Option, Query, description = "default 0"), + ("limit" = Option, Query, description = "1..=50, default 20") + ), + responses( + (status = 200, body = SearchResultsView), + (status = 400, description = "Invalid visibility value"), + (status = 401), + (status = 403), + (status = 503, description = "Search is not configured") + ) +)] +pub(crate) async fn search_objects( + _auth: Authorized, + State(state): State, + Query(params): Query, +) -> Result, StatusCode> { + let Some(search) = &state.search else { + return Err(StatusCode::SERVICE_UNAVAILABLE); + }; + + let visibility = match params.visibility.as_deref() { + None | Some("") => None, + Some(v @ ("draft" | "internal" | "public")) => Some(v), + Some(_) => return Err(StatusCode::BAD_REQUEST), + }; + + let q = params.q.trim(); + + if q.is_empty() { + return Ok(Json(SearchResultsView { + hits: Vec::new(), + estimated_total: 0, + })); + } + + let offset = params.offset.unwrap_or(0).max(0) as usize; + let limit = params.limit.unwrap_or(20).clamp(1, 50) as usize; + + let results = search + .search_objects(q, visibility, offset, limit) + .await + .map_err(|err| { + tracing::error!(?err, "search query failed"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(SearchResultsView { + hits: results + .hits + .into_iter() + .map(|h| SearchHitView { + id: h.id, + object_number: h.object_number, + object_name: h.object_name, + brief_description: h.brief_description, + visibility: h.visibility, + snippet: h.snippet, + }) + .collect(), + estimated_total: results.estimated_total, + })) +} + +pub(crate) fn routes() -> Router { + Router::new().route("/api/admin/search", get(search_objects)) +} diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index f5f3bfa..ebf0c9b 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -3,6 +3,7 @@ mod admin; mod admin_authorities; mod admin_objects; +mod admin_search; mod admin_vocab; mod health; mod openapi; @@ -63,6 +64,7 @@ pub fn build_app(state: AppState) -> Router { .merge(admin::routes()) .merge(admin_objects::routes()) .merge(admin_vocab::routes()) + .merge(admin_search::routes()) .merge(admin_authorities::routes()) .layer(session_layer) .with_state(state) diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index 2e55f50..7d08eae 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -1,7 +1,9 @@ use axum::{Json, Router, extract::State, routing::get}; use utoipa::OpenApi; -use crate::{AppState, admin, admin_authorities, admin_objects, admin_vocab, health, public}; +use crate::{ + AppState, admin, admin_authorities, admin_objects, admin_search, admin_vocab, health, public, +}; #[derive(OpenApi)] #[openapi( @@ -26,6 +28,7 @@ use crate::{AppState, admin, admin_authorities, admin_objects, admin_vocab, heal admin_vocab::create_vocabulary, admin_vocab::list_terms, admin_vocab::add_term, + admin_search::search_objects, admin_authorities::list_authorities, admin_authorities::create_authority ), @@ -50,6 +53,8 @@ use crate::{AppState, admin, admin_authorities, admin_objects, admin_vocab, heal admin_vocab::LabelInput, admin_vocab::TermView, admin_vocab::CreatedId, + admin_search::SearchHitView, + admin_search::SearchResultsView, admin_authorities::AuthorityView, admin_authorities::NewAuthorityRequest )), diff --git a/crates/api/tests/admin_search.rs b/crates/api/tests/admin_search.rs new file mode 100644 index 0000000..c2541e7 --- /dev/null +++ b/crates/api/tests/admin_search.rs @@ -0,0 +1,215 @@ +use api::{AppState, build_app, migrate_sessions}; +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use db::users; +use domain::{AuditActor, Email, NewUser, Role}; +use http_body_util::BodyExt; +use search::SearchClient; +use sqlx::PgPool; +use tower::ServiceExt; + +fn meili() -> (String, String) { + ( + std::env::var("MEILI_URL").expect("MEILI_URL must be set"), + std::env::var("MEILI_MASTER_KEY").expect("MEILI_MASTER_KEY must be set"), + ) +} + +fn unique_index() -> String { + format!("api_search_test_{}", uuid::Uuid::new_v4().simple()) +} + +fn state(pool: PgPool, search: Option) -> AppState { + AppState { + db: db::Db::from_pool(pool), + app_name: "Test".into(), + cookie_secure: false, + search, + } +} + +async fn seed_user(pool: &PgPool, email: &str, password: &str, role: Role) { + let db = db::Db::from_pool(pool.clone()); + let mut tx = db.pool().begin().await.unwrap(); + + users::create_user( + &mut tx, + AuditActor::System, + &NewUser { + email: Email::parse(email).unwrap(), + password_hash: auth::hash_password(password).unwrap(), + role, + }, + ) + .await + .unwrap(); + + tx.commit().await.unwrap(); +} + +async fn login(app: &axum::Router, email: &str, password: &str) -> String { + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/login") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(format!( + r#"{{"email":"{email}","password":"{password}"}}"# + ))) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + resp.headers() + .get(header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_owned() +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn search_requires_auth(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + + let (url, key) = meili(); + let search = SearchClient::connect(&url, &key, &unique_index()).unwrap(); + + search.ensure_index().await.unwrap(); + + let app = build_app(state(pool, Some(search))); + + let resp = app + .oneshot( + Request::builder() + .uri("/api/admin/search?q=bronze") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[sqlx::test(migrations = "../db/migrations")] +async fn search_returns_results_and_validates_params(pool: PgPool) { + migrate_sessions(&db::Db::from_pool(pool.clone())) + .await + .unwrap(); + + seed_user(&pool, "ed@example.com", "pw-editor-123", Role::Editor).await; + + let (url, key) = meili(); + let search = SearchClient::connect(&url, &key, &unique_index()).unwrap(); + + search.ensure_index().await.unwrap(); + + let app = build_app(state(pool.clone(), Some(search))); + let cookie = login(&app, "ed@example.com", "pw-editor-123").await; + + 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); + + 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"); + + 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); + + 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)); + 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); +} diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index 4e271b6..6959d49 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -168,6 +168,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["search_objects"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/users": { parameters: { query?: never; @@ -430,6 +446,19 @@ export interface components { /** @description `"ok"` when ready, `"degraded"` otherwise. */ status: string; }; + SearchHitView: { + brief_description?: string | null; + id: string; + object_name: string; + object_number: string; + snippet?: string | null; + visibility: string; + }; + SearchResultsView: { + /** @description Meilisearch's estimate of the total number of matches. */ + estimated_total: number; + hits: components["schemas"]["SearchHitView"][]; + }; TermView: { external_uri?: string | null; id: string; @@ -947,6 +976,60 @@ export interface operations { }; }; }; + search_objects: { + parameters: { + query: { + /** @description Search query text */ + q: string; + /** @description Filter: draft|internal|public */ + visibility?: string; + /** @description default 0 */ + offset?: number; + /** @description 1..=50, default 20 */ + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SearchResultsView"]; + }; + }; + /** @description Invalid visibility value */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Search is not configured */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; list_users: { parameters: { query?: never; From 90a1539090405401c39177d5c45d88f528bd0a09 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 12:26:38 +0200 Subject: [PATCH 4/9] test(api): cover search visibility-filter narrowing; note pagination cap rationale Co-Authored-By: Claude Sonnet 4.6 --- crates/api/src/admin_search.rs | 2 + crates/api/tests/admin_search.rs | 92 ++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/crates/api/src/admin_search.rs b/crates/api/src/admin_search.rs index a232db3..2245f9c 100644 --- a/crates/api/src/admin_search.rs +++ b/crates/api/src/admin_search.rs @@ -79,6 +79,8 @@ pub(crate) async fn search_objects( })); } + // Search uses a tighter default/cap (20, max 50) than the shared `Pagination` + // (default 50, max 200): result pages are slower to scan than a raw object list. let offset = params.offset.unwrap_or(0).max(0) as usize; let limit = params.limit.unwrap_or(20).clamp(1, 50) as usize; diff --git a/crates/api/tests/admin_search.rs b/crates/api/tests/admin_search.rs index c2541e7..e552599 100644 --- a/crates/api/tests/admin_search.rs +++ b/crates/api/tests/admin_search.rs @@ -189,6 +189,98 @@ async fn search_returns_results_and_validates_params(pool: PgPool) { assert_eq!(bad.status(), StatusCode::BAD_REQUEST); } +#[sqlx::test(migrations = "../db/migrations")] +async fn search_visibility_filter_narrows_results(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; + + let create_internal = 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-2","object_name":"astrolabe-internal","number_of_objects":1,"visibility":"internal"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(create_internal.status(), StatusCode::CREATED); + + let create_draft = 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-3","object_name":"astrolabe-draft","number_of_objects":1,"visibility":"draft"}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(create_draft.status(), StatusCode::CREATED); + + let all = app + .clone() + .oneshot( + Request::builder() + .uri("/api/admin/search?q=astrolabe") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(all.status(), StatusCode::OK); + + let all_body: serde_json::Value = + serde_json::from_slice(&all.into_body().collect().await.unwrap().to_bytes()).unwrap(); + + assert_eq!(all_body["estimated_total"], 2); + + let filtered = app + .clone() + .oneshot( + Request::builder() + .uri("/api/admin/search?q=astrolabe&visibility=internal") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(filtered.status(), StatusCode::OK); + + let filtered_body: serde_json::Value = + serde_json::from_slice(&filtered.into_body().collect().await.unwrap().to_bytes()).unwrap(); + + assert_eq!(filtered_body["estimated_total"], 1); + assert_eq!(filtered_body["hits"][0]["visibility"], "internal"); +} + #[sqlx::test(migrations = "../db/migrations")] async fn search_unavailable_when_not_configured(pool: PgPool) { migrate_sessions(&db::Db::from_pool(pool.clone())) From 18ed9bd94707e47b46388fd13ae1b1fb2dcf89b9 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 12:29:11 +0200 Subject: [PATCH 5/9] feat(web): useSearch infinite query + useDebouncedValue + MSW search handler Co-Authored-By: Claude Sonnet 4.6 --- web/src/api/queries.search.test.tsx | 25 +++++++++++++++++ web/src/api/queries.ts | 35 +++++++++++++++++++++++- web/src/lib/use-debounced-value.test.tsx | 24 ++++++++++++++++ web/src/lib/use-debounced-value.ts | 14 ++++++++++ web/src/test/fixtures.ts | 21 ++++++++++++++ web/src/test/handlers.ts | 16 ++++++++++- 6 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 web/src/api/queries.search.test.tsx create mode 100644 web/src/lib/use-debounced-value.test.tsx create mode 100644 web/src/lib/use-debounced-value.ts diff --git a/web/src/api/queries.search.test.tsx b/web/src/api/queries.search.test.tsx new file mode 100644 index 0000000..f005861 --- /dev/null +++ b/web/src/api/queries.search.test.tsx @@ -0,0 +1,25 @@ +import { expect, test } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { useSearch } from "./queries"; + +function wrapper({ children }: { children: React.ReactNode }) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +} + +test("useSearch fetches a page and reports more pages available", async () => { + const { result } = renderHook(() => useSearch("bronze", null), { wrapper }); + + await waitFor(() => expect(result.current.data).toBeDefined()); + + const first = result.current.data!.pages[0]; + expect(first.hits[0].object_name).toBe("Bronze figurine"); + expect(first.estimated_total).toBe(25); + expect(result.current.hasNextPage).toBe(true); +}); + +test("useSearch is disabled for an empty query", () => { + const { result } = renderHook(() => useSearch(" ", null), { wrapper }); + expect(result.current.fetchStatus).toBe("idle"); +}); diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts index a23651c..69cf773 100644 --- a/web/src/api/queries.ts +++ b/web/src/api/queries.ts @@ -1,4 +1,4 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "./client"; import type { components } from "./schema"; @@ -281,6 +281,39 @@ export function useCreateAuthority() { }); } +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; + }, + }); +} + type Visibility = "draft" | "internal" | "public"; /** Error carrying the HTTP status so callers can branch 422-gate vs 409-illegal. */ diff --git a/web/src/lib/use-debounced-value.test.tsx b/web/src/lib/use-debounced-value.test.tsx new file mode 100644 index 0000000..2b715df --- /dev/null +++ b/web/src/lib/use-debounced-value.test.tsx @@ -0,0 +1,24 @@ +import { useState } from "react"; +import { expect, test } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderApp } from "../test/render"; +import { useDebouncedValue } from "./use-debounced-value"; + +function Harness() { + const [text, setText] = useState(""); + const debounced = useDebouncedValue(text, 150); + return ( +
+ setText(e.target.value)} /> + {debounced} +
+ ); +} + +test("reflects the value after the delay", async () => { + renderApp(); + await userEvent.type(screen.getByLabelText("in"), "bronze"); + await screen.findByText("bronze"); + expect(screen.getByTestId("out")).toHaveTextContent("bronze"); +}); diff --git a/web/src/lib/use-debounced-value.ts b/web/src/lib/use-debounced-value.ts new file mode 100644 index 0000000..ffc1345 --- /dev/null +++ b/web/src/lib/use-debounced-value.ts @@ -0,0 +1,14 @@ +import { useEffect, useState } from "react"; + +/** Returns `value` delayed by `delayMs`; resets the timer on each change. */ +export function useDebouncedValue(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const id = setTimeout(() => setDebounced(value), delayMs); + + return () => clearTimeout(id); + }, [value, delayMs]); + + return debounced; +} diff --git a/web/src/test/fixtures.ts b/web/src/test/fixtures.ts index a53fdad..2c10b42 100644 --- a/web/src/test/fixtures.ts +++ b/web/src/test/fixtures.ts @@ -63,6 +63,27 @@ export const personAuthorities: AuthorityView[] = [ { id: "a-ada", kind: "person", external_uri: null, labels: [{ lang: "en", label: "Ada Lovelace" }] }, ]; +export type SearchHitView = components["schemas"]["SearchHitView"]; + +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 bronze 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, + })), +]; + export type VocabularyView = components["schemas"]["VocabularyView"]; export const vocabularies: VocabularyView[] = [ diff --git a/web/src/test/handlers.ts b/web/src/test/handlers.ts index db0599f..81d0d89 100644 --- a/web/src/test/handlers.ts +++ b/web/src/test/handlers.ts @@ -1,6 +1,6 @@ import { http, HttpResponse } from "msw"; -import { amphora, fibula, fieldDefinitions, materialTerms, objectsPage, personAuthorities, vocabularies } from "./fixtures"; +import { amphora, fibula, fieldDefinitions, materialTerms, objectsPage, personAuthorities, searchHits, vocabularies } from "./fixtures"; export const handlers = [ http.get("/api/admin/me", () => @@ -49,6 +49,20 @@ export const handlers = [ http.delete("/api/admin/objects/:id", () => new HttpResponse(null, { status: 204 })), + 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, + }); + }), + http.post("/api/admin/login", () => new HttpResponse(null, { status: 204 })), http.post("/api/admin/logout", () => new HttpResponse(null, { status: 204 })), From de830999d4e34a3f96911bdd6ad76e336a809bb5 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 12:32:23 +0200 Subject: [PATCH 6/9] test(web): embed highlight sentinels in search fixture snippet --- web/src/test/fixtures.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/test/fixtures.ts b/web/src/test/fixtures.ts index 2c10b42..1fcd269 100644 --- a/web/src/test/fixtures.ts +++ b/web/src/test/fixtures.ts @@ -72,7 +72,7 @@ export const searchHits: SearchHitView[] = [ object_name: "Bronze figurine", brief_description: "A small cast figure.", visibility: "public", - snippet: "cast bronze with green patina", + snippet: "cast bronze with green patina", }, ...Array.from({ length: 24 }, (_, i) => ({ id: `s-${i + 2}`, From ee65b2759578f9cb51e5c05301515695a5e012af Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 12:34:27 +0200 Subject: [PATCH 7/9] feat(web): Highlight (XSS-safe) + SearchResultRow components --- web/src/search/highlight.test.tsx | 16 +++++++++++ web/src/search/highlight.tsx | 42 ++++++++++++++++++++++++++++ web/src/search/search-result-row.tsx | 31 ++++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 web/src/search/highlight.test.tsx create mode 100644 web/src/search/highlight.tsx create mode 100644 web/src/search/search-result-row.tsx diff --git a/web/src/search/highlight.test.tsx b/web/src/search/highlight.test.tsx new file mode 100644 index 0000000..4595944 --- /dev/null +++ b/web/src/search/highlight.test.tsx @@ -0,0 +1,16 @@ +import { expect, test } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { Highlight } from "./highlight"; + +test("renders matched segments as and plain text around them", () => { + render(); + const mark = screen.getByText("bronze"); + expect(mark.tagName).toBe("MARK"); + expect(document.body).toHaveTextContent("cast bronze with patina"); +}); + +test("renders plain text unchanged when there are no markers", () => { + render(); + expect(document.body).toHaveTextContent("no markers here"); + expect(screen.queryByRole("mark")).toBeNull(); +}); diff --git a/web/src/search/highlight.tsx b/web/src/search/highlight.tsx new file mode 100644 index 0000000..e8af6d6 --- /dev/null +++ b/web/src/search/highlight.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; + +// Must match the backend's search::HL_PRE / HL_POST sentinel characters +// (U+0002 / U+0003). Written as escapes so they survive copy-paste. +const PRE = "\x02"; +const POST = "\x03"; + +/** Renders a sentinel-marked snippet: matched spans become , the rest is text. + * Pure string handling — no HTML is injected, so this is XSS-safe. */ +export function Highlight({ text }: { text: string }) { + const nodes: ReactNode[] = []; + let rest = text; + let key = 0; + + while (rest.length > 0) { + const start = rest.indexOf(PRE); + + if (start === -1) { + nodes.push(rest); + break; + } + + if (start > 0) nodes.push(rest.slice(0, start)); + + const end = rest.indexOf(POST, start + PRE.length); + + if (end === -1) { + // Malformed: no closing marker. Emit the remainder verbatim, minus the marker. + nodes.push(rest.slice(start + PRE.length)); + break; + } + + nodes.push( + + {rest.slice(start + PRE.length, end)} + , + ); + rest = rest.slice(end + POST.length); + } + + return <>{nodes}; +} diff --git a/web/src/search/search-result-row.tsx b/web/src/search/search-result-row.tsx new file mode 100644 index 0000000..372c785 --- /dev/null +++ b/web/src/search/search-result-row.tsx @@ -0,0 +1,31 @@ +import { NavLink } from "react-router-dom"; + +import type { components } from "../api/schema"; +import { VisibilityBadge } from "../objects/visibility-badge"; +import { Highlight } from "./highlight"; + +type SearchHitView = components["schemas"]["SearchHitView"]; + +export function SearchResultRow({ hit }: { hit: SearchHitView }) { + return ( +
  • + + `block border-b px-3 py-2 ${isActive ? "bg-indigo-50" : "hover:bg-neutral-50"}` + } + > +
    {hit.object_name}
    +
    + {hit.object_number} + +
    + {hit.snippet && ( +

    + +

    + )} +
    +
  • + ); +} From 358d793e44e753da9bbc042a46b69642da4cc2f7 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 12:40:46 +0200 Subject: [PATCH 8/9] feat(web): /search two-pane screen (debounced query, visibility filter, load more) + nav Co-Authored-By: Claude Sonnet 4.6 --- web/src/app.tsx | 6 ++ web/src/i18n/en.json | 10 ++ web/src/i18n/sv.json | 10 ++ web/src/search/search-page.tsx | 16 +++ web/src/search/search-panel.tsx | 128 ++++++++++++++++++++++++ web/src/search/search.test.tsx | 77 ++++++++++++++ web/src/search/select-search-prompt.tsx | 11 ++ web/src/shell/app-shell.test.tsx | 5 +- web/src/shell/app-shell.tsx | 10 +- 9 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 web/src/search/search-page.tsx create mode 100644 web/src/search/search-panel.tsx create mode 100644 web/src/search/search.test.tsx create mode 100644 web/src/search/select-search-prompt.tsx diff --git a/web/src/app.tsx b/web/src/app.tsx index e882e83..952925d 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -7,6 +7,8 @@ import { AppShell } from "./shell/app-shell"; import { ObjectsPage } from "./objects/objects-page"; import { ObjectDetail } from "./objects/object-detail"; import { SelectPrompt } from "./objects/select-prompt"; +import { SearchPage } from "./search/search-page"; +import { SelectSearchPrompt } from "./search/select-search-prompt"; import { VocabulariesPage } from "./vocab/vocabularies-page"; import { VocabularyTerms } from "./vocab/vocabulary-terms"; import { SelectVocabularyPrompt } from "./vocab/select-vocabulary-prompt"; @@ -55,6 +57,10 @@ export function App() { } /> } /> + }> + } /> + } /> + } /> } /> } /> diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index eb1d289..9706f75 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -18,6 +18,16 @@ "title": "Authorities", "person": "Person", "organisation": "Organisation", "place": "Place", "new": "New", "create": "Create", "empty": "No authorities yet", "loadError": "Could not load" }, + "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" + }, "publish": { "heading": "Visibility", "advanceInternal": "Advance to internal", diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index 56e4f7e..574c995 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -18,6 +18,16 @@ "title": "Auktoriteter", "person": "Person", "organisation": "Organisation", "place": "Plats", "new": "Ny", "create": "Skapa", "empty": "Inga auktoriteter ännu", "loadError": "Kunde inte ladda" }, + "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" + }, "publish": { "heading": "Synlighet", "advanceInternal": "Gör intern", diff --git a/web/src/search/search-page.tsx b/web/src/search/search-page.tsx new file mode 100644 index 0000000..41ac0ed --- /dev/null +++ b/web/src/search/search-page.tsx @@ -0,0 +1,16 @@ +import { Outlet } from "react-router-dom"; + +import { SearchPanel } from "./search-panel"; + +export function SearchPage() { + return ( +
    +
    + +
    +
    + +
    +
    + ); +} diff --git a/web/src/search/search-panel.tsx b/web/src/search/search-panel.tsx new file mode 100644 index 0000000..e540b57 --- /dev/null +++ b/web/src/search/search-panel.tsx @@ -0,0 +1,128 @@ +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); + + useEffect(() => { + setParams( + (prev) => { + const next = new URLSearchParams(prev); + const term = debounced.trim(); + + if (term) next.set("q", term); + else next.delete("q"); + + return next; + }, + { replace: true }, + ); + }, [debounced, setParams]); + + const search = useSearch(debounced, visibility); + + const setVisibility = (value: string) => + setParams( + (prev) => { + const next = new URLSearchParams(prev); + + if (value === "all") next.delete("visibility"); + else next.set("visibility", value); + + return next; + }, + { replace: true }, + ); + + const hits = search.data?.pages.flatMap((page) => page.hits) ?? []; + const total = search.data?.pages[0]?.estimated_total ?? 0; + const hasQuery = debounced.trim().length > 0; + + return ( +
    +
    + setText(event.target.value)} + placeholder={t("search.placeholder")} + aria-label={t("search.placeholder")} + /> +
    + {VIS.map((value) => { + const active = (visibility ?? "all") === value; + + return ( + + ); + })} +
    +
    + +
    + {!hasQuery &&

    {t("search.prompt")}

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

    {t("search.loadError")}

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

    {t("search.empty")}

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

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

    +
      + {hits.map((hit) => ( + + ))} +
    + {search.hasNextPage && ( +
    + +
    + )} + + )} +
    +
    + ); +} diff --git a/web/src/search/search.test.tsx b/web/src/search/search.test.tsx new file mode 100644 index 0000000..22a2f19 --- /dev/null +++ b/web/src/search/search.test.tsx @@ -0,0 +1,77 @@ +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 { amphora } from "../test/fixtures"; +import { SearchPage } from "./search-page"; +import { SelectSearchPrompt } from "./select-search-prompt"; +import { ObjectDetail } from "../objects/object-detail"; + +function tree() { + return ( + + }> + } /> + } /> + + + ); +} + +test("typing searches and renders highlighted rich rows", async () => { + renderApp(tree(), { route: "/search" }); + await userEvent.type(screen.getByLabelText(/search the collection/i), "bronze"); + + expect(await screen.findByText("Bronze figurine")).toBeInTheDocument(); + const mark = await screen.findByText("bronze"); + expect(mark.tagName).toBe("MARK"); + 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"); + + 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" }); + 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")); + + expect(await screen.findByText(amphora.object_name)).toBeInTheDocument(); +}); diff --git a/web/src/search/select-search-prompt.tsx b/web/src/search/select-search-prompt.tsx new file mode 100644 index 0000000..d5aa715 --- /dev/null +++ b/web/src/search/select-search-prompt.tsx @@ -0,0 +1,11 @@ +import { useTranslation } from "react-i18next"; + +export function SelectSearchPrompt() { + const { t } = useTranslation(); + + return ( +
    + {t("search.selectPrompt")} +
    + ); +} diff --git a/web/src/shell/app-shell.test.tsx b/web/src/shell/app-shell.test.tsx index 1e58ec9..9cc1ca2 100644 --- a/web/src/shell/app-shell.test.tsx +++ b/web/src/shell/app-shell.test.tsx @@ -29,8 +29,9 @@ test("shows active and disabled nav and renders the outlet", async () => { renderApp(tree(), { route: "/objects" }); expect(await screen.findByText("objects outlet")).toBeInTheDocument(); expect(screen.getByRole("link", { name: /objects/i })).toBeInTheDocument(); - // later milestones are present but disabled - expect(screen.getByRole("button", { name: /search/i })).toBeDisabled(); + // fields is still disabled; search is now a link + expect(screen.getByRole("link", { name: /search/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /fields/i })).toBeDisabled(); }); test("language switch toggles to Swedish", async () => { diff --git a/web/src/shell/app-shell.tsx b/web/src/shell/app-shell.tsx index 1ab6a0c..4a1aa13 100644 --- a/web/src/shell/app-shell.tsx +++ b/web/src/shell/app-shell.tsx @@ -5,7 +5,7 @@ import { useLogout } from "../api/queries"; import { Button } from "@/components/ui/button"; import { LangSwitch } from "./lang-switch"; -const DISABLED_NAV = ["fields", "search"] as const; +const DISABLED_NAV = ["fields"] as const; export function AppShell() { const { t } = useTranslation(); @@ -46,6 +46,14 @@ export function AppShell() { > {t("nav.authorities")} + + `block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}` + } + > + {t("nav.search")} + {DISABLED_NAV.map((key) => (