GET /api/admin/search backed by Meilisearch (highlighted hits, visibility filter, offset/limit, estimated total; 503 when search unconfigured). /search two-pane screen: debounced query, visibility pills, URL-synced + bookmarkable, infinite 'Load more', XSS-safe sentinel highlighting, ObjectDetail reuse for the detail pane. Search nav enabled (only Fields remains stubbed). Backend search+api tests green; web 68 tests; bundle 145.1 KB gz. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,114 @@
|
|||||||
|
//! 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
mod admin;
|
mod admin;
|
||||||
mod admin_authorities;
|
mod admin_authorities;
|
||||||
mod admin_objects;
|
mod admin_objects;
|
||||||
|
mod admin_search;
|
||||||
mod admin_vocab;
|
mod admin_vocab;
|
||||||
mod health;
|
mod health;
|
||||||
mod openapi;
|
mod openapi;
|
||||||
@@ -63,6 +64,7 @@ pub fn build_app(state: AppState) -> Router {
|
|||||||
.merge(admin::routes())
|
.merge(admin::routes())
|
||||||
.merge(admin_objects::routes())
|
.merge(admin_objects::routes())
|
||||||
.merge(admin_vocab::routes())
|
.merge(admin_vocab::routes())
|
||||||
|
.merge(admin_search::routes())
|
||||||
.merge(admin_authorities::routes())
|
.merge(admin_authorities::routes())
|
||||||
.layer(session_layer)
|
.layer(session_layer)
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
use axum::{Json, Router, extract::State, routing::get};
|
use axum::{Json, Router, extract::State, routing::get};
|
||||||
use utoipa::OpenApi;
|
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)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
@@ -26,6 +28,7 @@ use crate::{AppState, admin, admin_authorities, admin_objects, admin_vocab, heal
|
|||||||
admin_vocab::create_vocabulary,
|
admin_vocab::create_vocabulary,
|
||||||
admin_vocab::list_terms,
|
admin_vocab::list_terms,
|
||||||
admin_vocab::add_term,
|
admin_vocab::add_term,
|
||||||
|
admin_search::search_objects,
|
||||||
admin_authorities::list_authorities,
|
admin_authorities::list_authorities,
|
||||||
admin_authorities::create_authority
|
admin_authorities::create_authority
|
||||||
),
|
),
|
||||||
@@ -50,6 +53,8 @@ use crate::{AppState, admin, admin_authorities, admin_objects, admin_vocab, heal
|
|||||||
admin_vocab::LabelInput,
|
admin_vocab::LabelInput,
|
||||||
admin_vocab::TermView,
|
admin_vocab::TermView,
|
||||||
admin_vocab::CreatedId,
|
admin_vocab::CreatedId,
|
||||||
|
admin_search::SearchHitView,
|
||||||
|
admin_search::SearchResultsView,
|
||||||
admin_authorities::AuthorityView,
|
admin_authorities::AuthorityView,
|
||||||
admin_authorities::NewAuthorityRequest
|
admin_authorities::NewAuthorityRequest
|
||||||
)),
|
)),
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
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_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()))
|
||||||
|
.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);
|
||||||
|
}
|
||||||
@@ -11,10 +11,10 @@ thiserror.workspace = true
|
|||||||
domain = { path = "../domain" }
|
domain = { path = "../domain" }
|
||||||
db = { path = "../db" }
|
db = { path = "../db" }
|
||||||
sqlx.workspace = true
|
sqlx.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
serde_json.workspace = true
|
|
||||||
sqlx.workspace = true
|
sqlx.workspace = true
|
||||||
domain = { path = "../domain" }
|
domain = { path = "../domain" }
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
use db::Db;
|
use db::Db;
|
||||||
use domain::{CatalogueObject, ObjectId};
|
use domain::{CatalogueObject, ObjectId};
|
||||||
|
use meilisearch_sdk::search::Selectors;
|
||||||
use meilisearch_sdk::tasks::Task;
|
use meilisearch_sdk::tasks::Task;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -39,6 +40,31 @@ pub struct SearchDocument {
|
|||||||
pub fields_text: Vec<String>,
|
pub fields_text: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
|
||||||
/// A Meilisearch-backed search client scoped to one index.
|
/// A Meilisearch-backed search client scoped to one index.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct SearchClient {
|
pub struct SearchClient {
|
||||||
@@ -147,6 +173,79 @@ impl SearchClient {
|
|||||||
.collect()
|
.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.
|
||||||
|
///
|
||||||
|
/// # 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,
|
||||||
|
visibility: Option<&str>,
|
||||||
|
offset: usize,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<SearchResults, SearchError> {
|
||||||
|
let index = self.client.index(&self.index_uid);
|
||||||
|
|
||||||
|
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<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))
|
||||||
|
// ~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);
|
||||||
|
|
||||||
|
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_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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Sync a single object's index entry with the database after a catalogue write
|
/// 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
|
/// 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 —
|
/// is the uniform on-write path for create/update/delete/field/visibility changes —
|
||||||
@@ -272,3 +371,38 @@ pub async fn build_document(
|
|||||||
fields_text,
|
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<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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use search::{SearchClient, SearchDocument};
|
use search::{self, SearchClient, SearchDocument};
|
||||||
|
|
||||||
fn meili() -> (String, String) {
|
fn meili() -> (String, String) {
|
||||||
(
|
(
|
||||||
@@ -51,6 +51,55 @@ async fn index_search_and_remove() {
|
|||||||
assert!(client.search("wood").await.unwrap().is_empty());
|
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]
|
#[tokio::test]
|
||||||
async fn ensure_index_is_idempotent() {
|
async fn ensure_index_is_idempotent() {
|
||||||
let (url, key) = meili();
|
let (url, key) = meili();
|
||||||
|
|||||||
@@ -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 <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");
|
||||||
|
});
|
||||||
+35
-1
@@ -1,4 +1,4 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
import type { components } from "./schema";
|
import type { components } from "./schema";
|
||||||
@@ -281,6 +281,40 @@ 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;
|
||||||
|
},
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
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";
|
type Visibility = "draft" | "internal" | "public";
|
||||||
|
|
||||||
/** Error carrying the HTTP status so callers can branch 422-gate vs 409-illegal. */
|
/** Error carrying the HTTP status so callers can branch 422-gate vs 409-illegal. */
|
||||||
|
|||||||
Vendored
+83
@@ -168,6 +168,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/admin/users": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -430,6 +446,19 @@ export interface components {
|
|||||||
/** @description `"ok"` when ready, `"degraded"` otherwise. */
|
/** @description `"ok"` when ready, `"degraded"` otherwise. */
|
||||||
status: string;
|
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: {
|
TermView: {
|
||||||
external_uri?: string | null;
|
external_uri?: string | null;
|
||||||
id: string;
|
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: {
|
list_users: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { AppShell } from "./shell/app-shell";
|
|||||||
import { ObjectsPage } from "./objects/objects-page";
|
import { ObjectsPage } from "./objects/objects-page";
|
||||||
import { ObjectDetail } from "./objects/object-detail";
|
import { ObjectDetail } from "./objects/object-detail";
|
||||||
import { SelectPrompt } from "./objects/select-prompt";
|
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 { VocabulariesPage } from "./vocab/vocabularies-page";
|
||||||
import { VocabularyTerms } from "./vocab/vocabulary-terms";
|
import { VocabularyTerms } from "./vocab/vocabulary-terms";
|
||||||
import { SelectVocabularyPrompt } from "./vocab/select-vocabulary-prompt";
|
import { SelectVocabularyPrompt } from "./vocab/select-vocabulary-prompt";
|
||||||
@@ -55,6 +57,10 @@ export function App() {
|
|||||||
<Route index element={<SelectVocabularyPrompt />} />
|
<Route index element={<SelectVocabularyPrompt />} />
|
||||||
<Route path=":id" element={<VocabularyTerms />} />
|
<Route path=":id" element={<VocabularyTerms />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="/search" element={<SearchPage />}>
|
||||||
|
<Route index element={<SelectSearchPrompt />} />
|
||||||
|
<Route path=":id" element={<ObjectDetail />} />
|
||||||
|
</Route>
|
||||||
<Route path="/authorities" element={<Navigate to="/authorities/person" replace />} />
|
<Route path="/authorities" element={<Navigate to="/authorities/person" replace />} />
|
||||||
<Route path="/authorities/:kind" element={<AuthoritiesPage />} />
|
<Route path="/authorities/:kind" element={<AuthoritiesPage />} />
|
||||||
<Route path="/" element={<Navigate to="/objects" replace />} />
|
<Route path="/" element={<Navigate to="/objects" replace />} />
|
||||||
|
|||||||
@@ -18,6 +18,17 @@
|
|||||||
"title": "Authorities", "person": "Person", "organisation": "Organisation", "place": "Place",
|
"title": "Authorities", "person": "Person", "organisation": "Organisation", "place": "Place",
|
||||||
"new": "New", "create": "Create", "empty": "No authorities yet", "loadError": "Could not load"
|
"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_one": "{{count}} result",
|
||||||
|
"resultCount_other": "{{count}} results",
|
||||||
|
"selectPrompt": "Select a result to see the full record"
|
||||||
|
},
|
||||||
"publish": {
|
"publish": {
|
||||||
"heading": "Visibility",
|
"heading": "Visibility",
|
||||||
"advanceInternal": "Advance to internal",
|
"advanceInternal": "Advance to internal",
|
||||||
|
|||||||
@@ -18,6 +18,17 @@
|
|||||||
"title": "Auktoriteter", "person": "Person", "organisation": "Organisation", "place": "Plats",
|
"title": "Auktoriteter", "person": "Person", "organisation": "Organisation", "place": "Plats",
|
||||||
"new": "Ny", "create": "Skapa", "empty": "Inga auktoriteter ännu", "loadError": "Kunde inte ladda"
|
"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_one": "{{count}} träff",
|
||||||
|
"resultCount_other": "{{count}} träffar",
|
||||||
|
"selectPrompt": "Välj en träff för att se hela posten"
|
||||||
|
},
|
||||||
"publish": {
|
"publish": {
|
||||||
"heading": "Synlighet",
|
"heading": "Synlighet",
|
||||||
"advanceInternal": "Gör intern",
|
"advanceInternal": "Gör intern",
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<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");
|
||||||
|
await screen.findByText("bronze");
|
||||||
|
expect(screen.getByTestId("out")).toHaveTextContent("bronze");
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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 <mark> and plain text around them", () => {
|
||||||
|
render(<Highlight text={"cast \x02bronze\x03 with patina"} />);
|
||||||
|
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(<Highlight text="no markers here" />);
|
||||||
|
expect(document.body).toHaveTextContent("no markers here");
|
||||||
|
expect(screen.queryByRole("mark")).toBeNull();
|
||||||
|
});
|
||||||
@@ -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 <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}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
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 (
|
||||||
|
<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"
|
||||||
|
aria-pressed={active}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { screen, waitFor, within } 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 (
|
||||||
|
<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();
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hydrates query and visibility from the initial URL", async () => {
|
||||||
|
renderApp(tree(), { route: "/search?q=bronze" });
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/search the collection/i)).toHaveValue("bronze");
|
||||||
|
expect(await screen.findByText("Bronze figurine")).toBeInTheDocument();
|
||||||
|
|
||||||
|
const { container } = renderApp(tree(), { route: "/search?q=bronze&visibility=internal" });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
within(container).getByRole("button", { name: /^internal$/i }),
|
||||||
|
).toHaveAttribute("aria-pressed", "true");
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,8 +29,9 @@ test("shows active and disabled nav and renders the outlet", async () => {
|
|||||||
renderApp(tree(), { route: "/objects" });
|
renderApp(tree(), { route: "/objects" });
|
||||||
expect(await screen.findByText("objects outlet")).toBeInTheDocument();
|
expect(await screen.findByText("objects outlet")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("link", { name: /objects/i })).toBeInTheDocument();
|
expect(screen.getByRole("link", { name: /objects/i })).toBeInTheDocument();
|
||||||
// later milestones are present but disabled
|
// fields is still disabled; search is now a link
|
||||||
expect(screen.getByRole("button", { name: /search/i })).toBeDisabled();
|
expect(screen.getByRole("link", { name: /search/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /fields/i })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("language switch toggles to Swedish", async () => {
|
test("language switch toggles to Swedish", async () => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useLogout } from "../api/queries";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { LangSwitch } from "./lang-switch";
|
import { LangSwitch } from "./lang-switch";
|
||||||
|
|
||||||
const DISABLED_NAV = ["fields", "search"] as const;
|
const DISABLED_NAV = ["fields"] as const;
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -46,6 +46,14 @@ export function AppShell() {
|
|||||||
>
|
>
|
||||||
{t("nav.authorities")}
|
{t("nav.authorities")}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/search"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("nav.search")}
|
||||||
|
</NavLink>
|
||||||
{DISABLED_NAV.map((key) => (
|
{DISABLED_NAV.map((key) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
|
|||||||
@@ -63,6 +63,27 @@ export const personAuthorities: AuthorityView[] = [
|
|||||||
{ id: "a-ada", kind: "person", external_uri: null, labels: [{ lang: "en", label: "Ada Lovelace" }] },
|
{ 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 type VocabularyView = components["schemas"]["VocabularyView"];
|
||||||
|
|
||||||
export const vocabularies: VocabularyView[] = [
|
export const vocabularies: VocabularyView[] = [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { http, HttpResponse } from "msw";
|
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 = [
|
export const handlers = [
|
||||||
http.get("/api/admin/me", () =>
|
http.get("/api/admin/me", () =>
|
||||||
@@ -49,6 +49,20 @@ export const handlers = [
|
|||||||
|
|
||||||
http.delete("/api/admin/objects/:id", () => new HttpResponse(null, { status: 204 })),
|
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/login", () => new HttpResponse(null, { status: 204 })),
|
||||||
|
|
||||||
http.post("/api/admin/logout", () => new HttpResponse(null, { status: 204 })),
|
http.post("/api/admin/logout", () => new HttpResponse(null, { status: 204 })),
|
||||||
|
|||||||
Reference in New Issue
Block a user