Compare commits
41 Commits
0a2398f507
...
2d0b76ab34
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d0b76ab34 | |||
| 4dd00362b8 | |||
| 358d793e44 | |||
| ee65b27595 | |||
| de830999d4 | |||
| 18ed9bd947 | |||
| 90a1539090 | |||
| a87501b902 | |||
| 9b1771d584 | |||
| 84c4c2807b | |||
| 38e4525404 | |||
| a9208f56fe | |||
| 18a19eec16 | |||
| 352d899fa5 | |||
| 38673e52ba | |||
| 02e4f34a1b | |||
| ac30eadbb2 | |||
| e8d173a18f | |||
| 8d2323ed95 | |||
| 6afc358334 | |||
| 26e10704a9 | |||
| 684b5449ca | |||
| 7a8e7ff2d7 | |||
| 34d4ed2fd6 | |||
| 39b7fc51e9 | |||
| 01f757a239 | |||
| 516ecf3e95 | |||
| f206ee8995 | |||
| bb05331a3f | |||
| 1cf36e39cc | |||
| eedeb179e3 | |||
| 5087e34280 | |||
| 9880f24dd2 | |||
| 22b37c138b | |||
| 30d851182e | |||
| 616c232a22 | |||
| cf0b34b254 | |||
| cb191225cc | |||
| b23a48c310 | |||
| f3bab3336c | |||
| 9f43793c4a |
@@ -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();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,482 @@
|
|||||||
|
# Frontend SPA — Milestone 3 (Publishing Workflow) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Drive a record through the stepwise Draft→Internal→Public visibility pipeline from the SPA via a segmented stepper on the object detail, with confirm-on-publish and the publish-gate surfaced.
|
||||||
|
|
||||||
|
**Architecture:** A pure `adjacentTransitions(visibility)` helper encodes the legal one-step moves; a `useSetVisibility` mutation POSTs to the existing `/api/admin/objects/{id}/visibility` endpoint (throwing a status-carrying error so the UI can branch 422-gate vs 409-illegal); a `PublishControl` component renders a 3-segment stepper + the legal step buttons, confirms only on →Public (reusing the M2 AlertDialog), surfaces the gate/illegal errors inline, and relies on query invalidation to refresh. Rendered on the object detail read view.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19, react-router-dom 7, @tanstack/react-query 5, openapi-fetch typed client, shadcn AlertDialog, react-i18next, Vitest + RTL + MSW.
|
||||||
|
|
||||||
|
**Reference spec:** `docs/superpowers/specs/2026-06-04-frontend-spa-milestone-3-design.md`
|
||||||
|
|
||||||
|
**Baseline (M1+M2, merged @ `f206ee8`):** `web/src/api/queries.ts` has the object/authoring hooks (`useObject`, `useObjectsPage`, mutations) and the `api` typed client; `web/src/objects/object-detail.tsx` renders the read view with a `VisibilityBadge` in its header; `web/src/objects/visibility-badge.tsx` maps `draft|internal|public` → an i18n'd badge; `renderApp` helper (MemoryRouter + QueryClient); MSW harness (`web/src/test/{server,handlers,fixtures}.ts`, `onUnhandledRequest:"error"`); i18n `web/src/i18n/{en,sv}.json` with `visibility.{draft,internal,public}`, `form.cancel`, `form.rejected`; shadcn AlertDialog at `@/components/ui/alert-dialog`. 34 tests green; bundle ~140 KB gz (budget 150). Run web commands from `web/`.
|
||||||
|
|
||||||
|
**Conventions:** i18n every user-facing string via `t()`, en/sv key parity; NO `any`/`eslint-disable`/`@ts-ignore` (codebase has none); codename "biggus"/"dickus" NOWHERE; each task ends green (`pnpm test`, `pnpm typecheck`, `pnpm lint`, `pnpm build`).
|
||||||
|
|
||||||
|
**Backend contract (verify against `web/src/api/schema.d.ts`):**
|
||||||
|
- `POST /api/admin/objects/{id}/visibility` body `VisibilityRequest { visibility }` → `204`; `404` missing; `409` illegal transition; `422` publish-gate (missing required fields, bare body).
|
||||||
|
- State machine: `Draft↔Internal`, `Internal↔Public` (one step); `Draft→Public`/`Public→Draft` illegal. Gate (422) only on `Internal→Public`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: `adjacentTransitions` helper + `useSetVisibility` hook + MSW handler
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/src/objects/transitions.ts`, `web/src/objects/transitions.test.ts`
|
||||||
|
- Modify: `web/src/api/queries.ts`, `web/src/test/handlers.ts`
|
||||||
|
- Test: `web/src/api/queries.visibility.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing transitions test** `web/src/objects/transitions.test.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { adjacentTransitions } from "./transitions";
|
||||||
|
|
||||||
|
test("draft can only go forward to internal", () => {
|
||||||
|
expect(adjacentTransitions("draft")).toEqual({ forward: "internal" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("internal can go forward to public and back to draft", () => {
|
||||||
|
expect(adjacentTransitions("internal")).toEqual({ forward: "public", back: "draft" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("public can only go back to internal", () => {
|
||||||
|
expect(adjacentTransitions("public")).toEqual({ back: "internal" });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails** — `pnpm test src/objects/transitions.test.ts` → FAIL (no module).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** `web/src/objects/transitions.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type Visibility = "draft" | "internal" | "public";
|
||||||
|
|
||||||
|
/** The legal one-step visibility moves from `v`, per the backend state machine
|
||||||
|
* (Draft<->Internal, Internal<->Public; no skipping). */
|
||||||
|
export function adjacentTransitions(v: Visibility): { forward?: Visibility; back?: Visibility } {
|
||||||
|
switch (v) {
|
||||||
|
case "draft":
|
||||||
|
return { forward: "internal" };
|
||||||
|
case "internal":
|
||||||
|
return { forward: "public", back: "draft" };
|
||||||
|
case "public":
|
||||||
|
return { back: "internal" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes** — `pnpm test src/objects/transitions.test.ts` → PASS (3).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add the MSW handler** — append to the `handlers` array in `web/src/test/handlers.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
http.post("/api/admin/objects/:id/visibility", () => new HttpResponse(null, { status: 204 })),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Write the failing hook test** `web/src/api/queries.visibility.test.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { http, HttpResponse } from "msw";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { server } from "../test/server";
|
||||||
|
import { useSetVisibility } from "./queries";
|
||||||
|
|
||||||
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useSetVisibility", () => {
|
||||||
|
test("POSTs the target visibility and resolves on 204", async () => {
|
||||||
|
let body: unknown;
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/objects/:id/visibility", async ({ request }) => {
|
||||||
|
body = await request.json();
|
||||||
|
return new HttpResponse(null, { status: 204 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useSetVisibility(), { wrapper });
|
||||||
|
await result.current.mutateAsync({ id: "o1", visibility: "internal" });
|
||||||
|
expect((body as { visibility: string }).visibility).toBe("internal");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws a status-carrying error on 422 (publish gate)", async () => {
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/objects/:id/visibility", () => new HttpResponse(null, { status: 422 })),
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useSetVisibility(), { wrapper });
|
||||||
|
await expect(
|
||||||
|
result.current.mutateAsync({ id: "o1", visibility: "public" }),
|
||||||
|
).rejects.toMatchObject({ status: 422 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run to verify it fails** — `pnpm test src/api/queries.visibility.test.tsx` → FAIL (no `useSetVisibility`).
|
||||||
|
|
||||||
|
- [ ] **Step 8: Implement** — append to `web/src/api/queries.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { Visibility } from "../objects/transitions";
|
||||||
|
|
||||||
|
/** Error carrying the HTTP status so callers can branch 422-gate vs 409-illegal. */
|
||||||
|
export class VisibilityError extends Error {
|
||||||
|
constructor(public status: number) {
|
||||||
|
super(`visibility change failed (${status})`);
|
||||||
|
this.name = "VisibilityError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetVisibility() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, visibility }: { id: string; visibility: Visibility }) => {
|
||||||
|
const { response } = await api.POST("/api/admin/objects/{id}/visibility", {
|
||||||
|
params: { path: { id } },
|
||||||
|
body: { visibility },
|
||||||
|
});
|
||||||
|
if (response.status !== 204) throw new VisibilityError(response.status);
|
||||||
|
},
|
||||||
|
onSuccess: (_result, { id }) => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ["object", id] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ["objects"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(Confirm the generated body type for `VisibilityRequest`: if `visibility` is typed as the `Visibility` union the literal works directly; if it's typed as a bare `string`, the union is still assignable. The path key is literally `/api/admin/objects/{id}/visibility`. Reuse the existing `useMutation`/`useQueryClient`/`api`/`components` imports at the top of queries.ts. If importing `Visibility` from `../objects/transitions` creates an undesirable api→objects import direction, instead define the union inline as `"draft" | "internal" | "public"` in queries.ts and keep `transitions.ts`'s `Visibility` separate — pick whichever keeps imports clean; the union value is the contract.)
|
||||||
|
|
||||||
|
- [ ] **Step 9: Run** — `pnpm test src/api/queries.visibility.test.tsx` → PASS (2). Then full `pnpm test`, `pnpm typecheck`, `pnpm lint`, `pnpm build` → clean.
|
||||||
|
|
||||||
|
- [ ] **Step 10: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ..
|
||||||
|
git add web
|
||||||
|
git commit -m "feat(web): useSetVisibility hook + adjacentTransitions helper + MSW handler"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `PublishControl` stepper component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/src/objects/publish-control.tsx`, `web/src/objects/publish-control.test.tsx`
|
||||||
|
- Modify: `web/src/i18n/{en,sv}.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add i18n `publish.*` keys** — merge into `web/src/i18n/en.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"publish": {
|
||||||
|
"heading": "Visibility",
|
||||||
|
"advanceInternal": "Advance to internal",
|
||||||
|
"publish": "Publish →",
|
||||||
|
"backToDraft": "← Back to draft",
|
||||||
|
"unpublishInternal": "Unpublish to internal",
|
||||||
|
"confirmTitle": "Publish to public?",
|
||||||
|
"confirmBody": "This will make the record visible on the public API.",
|
||||||
|
"confirm": "Publish",
|
||||||
|
"gateError": "Can't publish — required fields are missing.",
|
||||||
|
"editLink": "Edit the record",
|
||||||
|
"illegalError": "That visibility change isn't allowed."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
and `web/src/i18n/sv.json`:
|
||||||
|
```json
|
||||||
|
"publish": {
|
||||||
|
"heading": "Synlighet",
|
||||||
|
"advanceInternal": "Gör intern",
|
||||||
|
"publish": "Publicera →",
|
||||||
|
"backToDraft": "← Tillbaka till utkast",
|
||||||
|
"unpublishInternal": "Avpublicera till intern",
|
||||||
|
"confirmTitle": "Publicera publikt?",
|
||||||
|
"confirmBody": "Detta gör posten synlig via det publika API:et.",
|
||||||
|
"confirm": "Publicera",
|
||||||
|
"gateError": "Kan inte publicera — obligatoriska fält saknas.",
|
||||||
|
"editLink": "Redigera posten",
|
||||||
|
"illegalError": "Den synlighetsändringen är inte tillåten."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(Stepper segment labels reuse the existing `visibility.{draft,internal,public}` keys; the dialog Cancel reuses `form.cancel`; the generic error reuses `form.rejected`. Keep en/sv parity.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing test** `web/src/objects/publish-control.test.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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 { server } from "../test/server";
|
||||||
|
import { renderApp } from "../test/render";
|
||||||
|
import { PublishControl } from "./publish-control";
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
|
||||||
|
type AdminObjectView = components["schemas"]["AdminObjectView"];
|
||||||
|
|
||||||
|
function objectWith(visibility: string): AdminObjectView {
|
||||||
|
return {
|
||||||
|
id: "o-1", object_number: "A-1", object_name: "Amphora", number_of_objects: 1,
|
||||||
|
brief_description: null, current_location: null, current_owner: null,
|
||||||
|
recorder: null, recording_date: null, visibility, fields: {},
|
||||||
|
} as AdminObjectView;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("internal: shows publish (forward) and back-to-draft buttons", async () => {
|
||||||
|
renderApp(<PublishControl object={objectWith("internal")} />);
|
||||||
|
expect(screen.getByRole("button", { name: /publish/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /back to draft/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("draft: forward to internal posts immediately (no confirm)", async () => {
|
||||||
|
let body: unknown;
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/objects/:id/visibility", async ({ request }) => {
|
||||||
|
body = await request.json();
|
||||||
|
return new HttpResponse(null, { status: 204 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderApp(<PublishControl object={objectWith("draft")} />);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /advance to internal/i }));
|
||||||
|
await waitFor(() => expect((body as { visibility: string })?.visibility).toBe("internal"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("public: back to internal posts immediately", async () => {
|
||||||
|
let body: unknown;
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/objects/:id/visibility", async ({ request }) => {
|
||||||
|
body = await request.json();
|
||||||
|
return new HttpResponse(null, { status: 204 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderApp(<PublishControl object={objectWith("public")} />);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /unpublish to internal/i }));
|
||||||
|
await waitFor(() => expect((body as { visibility: string })?.visibility).toBe("internal"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("internal -> public requires confirmation, then posts public", async () => {
|
||||||
|
let body: unknown;
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/objects/:id/visibility", async ({ request }) => {
|
||||||
|
body = await request.json();
|
||||||
|
return new HttpResponse(null, { status: 204 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderApp(<PublishControl object={objectWith("internal")} />);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /publish/i }));
|
||||||
|
const dialog = await screen.findByRole("alertdialog");
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: /publish/i }));
|
||||||
|
await waitFor(() => expect((body as { visibility: string })?.visibility).toBe("public"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("publish gate (422) shows an inline error with an edit link", async () => {
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/objects/:id/visibility", () => new HttpResponse(null, { status: 422 })),
|
||||||
|
);
|
||||||
|
renderApp(<PublishControl object={objectWith("internal")} />);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /publish/i }));
|
||||||
|
const dialog = await screen.findByRole("alertdialog");
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: /publish/i }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/required fields are missing/i)).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("link", { name: /edit the record/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run to verify it fails** — `pnpm test src/objects/publish-control.test.tsx` → FAIL (no component).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement** — `web/src/objects/publish-control.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
import { useSetVisibility, VisibilityError } from "../api/queries";
|
||||||
|
import { adjacentTransitions, type Visibility } from "./transitions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
AlertDialog, AlertDialogTrigger, AlertDialogContent, AlertDialogTitle,
|
||||||
|
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
type AdminObjectView = components["schemas"]["AdminObjectView"];
|
||||||
|
const STEPS: Visibility[] = ["draft", "internal", "public"];
|
||||||
|
|
||||||
|
export function PublishControl({ object }: { object: AdminObjectView }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const current = object.visibility as Visibility;
|
||||||
|
const { forward, back } = adjacentTransitions(current);
|
||||||
|
const setVisibility = useSetVisibility();
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [errorKind, setErrorKind] = useState<"gate" | "illegal" | "other" | null>(null);
|
||||||
|
|
||||||
|
const go = (visibility: Visibility) => {
|
||||||
|
setErrorKind(null);
|
||||||
|
setVisibility.mutate(
|
||||||
|
{ id: object.id, visibility },
|
||||||
|
{
|
||||||
|
onError: (err) => {
|
||||||
|
const status = err instanceof VisibilityError ? err.status : 0;
|
||||||
|
setErrorKind(status === 422 ? "gate" : status === 409 ? "illegal" : "other");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentIndex = STEPS.indexOf(current);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="border-t p-4">
|
||||||
|
<div className="mb-2 text-xs font-medium uppercase text-neutral-500">{t("publish.heading")}</div>
|
||||||
|
|
||||||
|
<div className="mb-3 flex">
|
||||||
|
{STEPS.map((step, i) => (
|
||||||
|
<div key={step}
|
||||||
|
className={`flex-1 border px-2 py-1 text-center text-xs ${
|
||||||
|
i === currentIndex ? "bg-neutral-800 font-semibold text-white"
|
||||||
|
: i < currentIndex ? "bg-neutral-100 text-neutral-600" : "text-neutral-400"}`}>
|
||||||
|
{t(`visibility.${step}`)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{back && (
|
||||||
|
<Button variant="ghost" size="sm" disabled={setVisibility.isPending}
|
||||||
|
onClick={() => go(back)}>
|
||||||
|
{back === "draft" ? t("publish.backToDraft") : t("publish.unpublishInternal")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{forward === "internal" && (
|
||||||
|
<Button size="sm" disabled={setVisibility.isPending} onClick={() => go("internal")}>
|
||||||
|
{t("publish.advanceInternal")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{forward === "public" && (
|
||||||
|
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button size="sm" disabled={setVisibility.isPending}>{t("publish.publish")}</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogTitle>{t("publish.confirmTitle")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{t("publish.confirmBody")}</AlertDialogDescription>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t("form.cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => go("public")}>{t("publish.confirm")}</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorKind === "gate" && (
|
||||||
|
<p role="alert" className="mt-2 text-sm text-red-600">
|
||||||
|
{t("publish.gateError")}{" "}
|
||||||
|
<Link to={`/objects/${object.id}/edit`} className="underline">{t("publish.editLink")}</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{errorKind === "illegal" && (
|
||||||
|
<p role="alert" className="mt-2 text-sm text-red-600">{t("publish.illegalError")}</p>
|
||||||
|
)}
|
||||||
|
{errorKind === "other" && (
|
||||||
|
<p role="alert" className="mt-2 text-sm text-red-600">{t("form.rejected")}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
NOTES:
|
||||||
|
- The AlertDialog is composed exactly like M2's `delete-object-dialog.tsx` (Base UI "base-nova" registry — `AlertDialogTrigger render={<Button>}`, controlled `open`/`onOpenChange`). Match that file's working composition; adapt names if the generated exports differ.
|
||||||
|
- The confirm button text (`publish.confirm` = "Publish") and the trigger (`publish.publish` = "Publish →") both match `/publish/i`; the test scopes the confirm click with `within(dialog)`, same pattern as the delete dialog test.
|
||||||
|
- `STEPS.indexOf(current)` drives done/current/pending styling.
|
||||||
|
- The button label for `back` depends on whether it returns to draft or internal.
|
||||||
|
- `VisibilityError` is imported from `queries.ts` (Task 1).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run** — `pnpm test src/objects/publish-control.test.tsx` → PASS (5). Then full `pnpm test`, `pnpm typecheck`, `pnpm lint`, `pnpm build` → clean.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ..
|
||||||
|
git add web
|
||||||
|
git commit -m "feat(web): PublishControl stepper (legal one-step moves, confirm-on-public, gate/illegal errors)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Wire into the object detail + full verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/objects/object-detail.tsx`, `web/src/objects/object-detail.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Render `PublishControl` in the detail** — in `web/src/objects/object-detail.tsx`, import it and render it after the inventory-minimum + flexible-field sections (a new section at the bottom of the detail body). Keep the existing `VisibilityBadge` in the header:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { PublishControl } from "./publish-control";
|
||||||
|
// ... at the end of the detail body, after the flexible-fields block:
|
||||||
|
<PublishControl object={object} />
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Extend the detail test to assert the control shows** — append to `web/src/objects/object-detail.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
test("detail shows the publish control with the current visibility stepper", async () => {
|
||||||
|
// default GET /api/admin/objects/:id handler returns amphora (visibility "public")
|
||||||
|
renderApp(tree(), { route: "/objects/11111111-1111-1111-1111-111111111111" });
|
||||||
|
// the stepper renders all three stages; public => an unpublish (back) button is offered
|
||||||
|
expect(await screen.findByText(/visibility/i)).toBeInTheDocument();
|
||||||
|
expect(await screen.findByRole("button", { name: /unpublish to internal/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
(Use the existing `tree()` / route + the default `amphora` fixture — confirm `amphora.visibility` is `"public"` in `fixtures.ts`; it is. If the detail test file's structure differs, adapt to render `ObjectDetail` at the amphora id and assert the stepper heading + the public→back button. The default MSW `POST .../visibility` handler returns 204 so no unhandled-request error even if not clicked.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run** — `pnpm test src/objects/object-detail.test.tsx` → PASS (existing + new). Then full `pnpm test`, `pnpm typecheck`, `pnpm lint`, `pnpm build`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: i18n parity + bundle check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
node -e "const a=require('./src/i18n/en.json'),b=require('./src/i18n/sv.json');const keys=o=>Object.entries(o).flatMap(([k,v])=>typeof v==='object'?keys(v).map(s=>k+'.'+s):[k]);const ka=keys(a).sort(),kb=keys(b).sort();console.log(JSON.stringify(ka)===JSON.stringify(kb)?'PARITY OK':'MISMATCH:'+JSON.stringify({onlyEn:ka.filter(k=>!kb.includes(k)),onlySv:kb.filter(k=>!ka.includes(k))}))"
|
||||||
|
pnpm build && pnpm check:size
|
||||||
|
```
|
||||||
|
Expected: `PARITY OK`; bundle ≤150 KB gz (report the number; PublishControl is small — should stay well under).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ..
|
||||||
|
git add web
|
||||||
|
git commit -m "feat(web): show the publish control on the object detail"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (completed)
|
||||||
|
|
||||||
|
**Spec coverage:**
|
||||||
|
- Segmented stepper on the detail, current highlighted, legal one-step buttons → Tasks 2, 3. ✓
|
||||||
|
- `adjacentTransitions` (draft→internal; internal↔public/draft; public→internal) → Task 1. ✓
|
||||||
|
- `useSetVisibility` POST + status-carrying error (422/409/other) → Task 1. ✓
|
||||||
|
- Confirm only on →Public (AlertDialog) → Task 2. ✓
|
||||||
|
- 422 gate → inline message + Edit link; 409 illegal → inline (defensive); other → form.rejected → Task 2. ✓
|
||||||
|
- Invalidate object + list on success (badge/stepper refresh) → Task 1. ✓
|
||||||
|
- VisibilityBadge stays in header; control is a new detail section → Task 3. ✓
|
||||||
|
- i18n sv/en parity → Tasks 2, 3. ✓
|
||||||
|
- Testing Vitest+RTL+MSW (helper + component + detail) → Tasks 1–3. ✓
|
||||||
|
- Bundle budget → Task 3. ✓
|
||||||
|
|
||||||
|
**Placeholder scan:** none — complete code in every step; the "adapt to generated VisibilityRequest type / base-nova AlertDialog exports" notes are verification instructions with fixed contracts.
|
||||||
|
|
||||||
|
**Type consistency:** `Visibility` union defined in `transitions.ts` (Task 1) and used by `useSetVisibility` + `PublishControl`; `VisibilityError` defined in `queries.ts` (Task 1) and consumed in `PublishControl` (Task 2); the `{ id, visibility }` mutation arg shape consistent; the AlertDialog composition mirrors the existing `delete-object-dialog.tsx`; route `/objects/:id/edit` (the Edit link) matches the M2 route.
|
||||||
|
|
||||||
|
## Notes for follow-on
|
||||||
|
- Per-field gate detail needs the backend 422 to carry field info (#28) — until then the gate message is generic.
|
||||||
|
- A visibility-change history/audit view is a later milestone (the backend already audits transitions).
|
||||||
@@ -0,0 +1,727 @@
|
|||||||
|
# Frontend SPA — Milestone 4 (Vocabulary & Authority Management) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Enable the Vocabularies and Authorities admin screens — create/list controlled vocabularies (+ their terms) and authority records (by kind) — with a shared sv/en label editor.
|
||||||
|
|
||||||
|
**Architecture:** Two new screens under the app shell (the previously-disabled nav stubs become active). Vocabularies is a two-pane master–detail (vocab list + create on the left; the selected vocab's terms + add-term on the right) via nested routes like Objects. Authorities is a kind-tabbed list + create at `/authorities/:kind`. A shared controlled `LabelEditor` (sv/en) produces `LabelInput[]`. Four new TanStack Query hooks (one list query + three create mutations) consume the existing admin endpoints; create mutations invalidate the matching list query keys. Create-only (the backend exposes no update/delete for these). Lean forms use local `useState` + inline validation (EN label / vocab key required).
|
||||||
|
|
||||||
|
**Tech Stack:** React 19, react-router-dom 7, @tanstack/react-query 5, openapi-fetch typed client, react-i18next, Vitest + RTL + MSW. (No new deps.)
|
||||||
|
|
||||||
|
**Reference spec:** `docs/superpowers/specs/2026-06-04-frontend-spa-milestone-4-design.md`
|
||||||
|
|
||||||
|
**Baseline (M1–M3 merged @ `684b544`):** `web/src/api/queries.ts` has `useTerms(vocabularyId)` (key `["terms",vocabularyId]`) + `useAuthorities(kind)` (key `["authorities",kind]`) plus the object/visibility hooks and the `api` client; nested-route two-pane pattern in `web/src/objects/{objects-page,object-detail}.tsx` + `web/src/objects/select-prompt.tsx`; `web/src/shell/app-shell.tsx` renders Objects as a `NavLink` and `["vocabularies","authorities","fields","search"]` as **disabled** buttons; `renderApp` helper (MemoryRouter + QueryClient); MSW harness (`web/src/test/{server,handlers,fixtures}.ts`, `onUnhandledRequest:"error"`); i18n `web/src/i18n/{en,sv}.json` with `nav.*`, `form.cancel`, `form.rejected`, `visibility.*`. shadcn Button/Input/Label. 45 tests green, ~141 KB gz. Run web commands from `web/`.
|
||||||
|
|
||||||
|
**Conventions:** i18n every user-facing string via `t()`, en/sv key parity; NO `any`/`eslint-disable`/`@ts-ignore`; codename "biggus"/"dickus" NOWHERE; each task ends green (`pnpm test`, `pnpm typecheck`, `pnpm lint`, `pnpm build`).
|
||||||
|
|
||||||
|
**Backend contract (verify against `web/src/api/schema.d.ts`):**
|
||||||
|
- `GET /api/admin/vocabularies` → `VocabularyView[]` (`{id,key}`); `POST` body `NewVocabularyRequest {key}` → `201 VocabularyView`.
|
||||||
|
- `GET /api/admin/vocabularies/{id}/terms` → `TermView[]`; `POST` body `NewTermRequest {external_uri?,labels}` → `201 CreatedId`.
|
||||||
|
- `GET /api/admin/authorities?kind=` → `AuthorityView[]`; `POST` body `NewAuthorityRequest {kind,external_uri?,labels}` → `201 CreatedId`.
|
||||||
|
- `LabelInput`/`LabelView` = `{lang,label}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Data layer — list + 3 create hooks + MSW handlers + fixture
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/api/queries.ts`, `web/src/test/handlers.ts`, `web/src/test/fixtures.ts`
|
||||||
|
- Test: `web/src/api/queries.vocab.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add a vocabularies fixture** — append to `web/src/test/fixtures.ts`:
|
||||||
|
```ts
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
export type VocabularyView = components["schemas"]["VocabularyView"];
|
||||||
|
|
||||||
|
export const vocabularies: VocabularyView[] = [
|
||||||
|
{ id: "v-material", key: "material" },
|
||||||
|
{ id: "v-technique", key: "technique" },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
(`materialTerms` and `personAuthorities` already exist from M2.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the MSW handlers** — in `web/src/test/handlers.ts`, add a GET for the vocabularies list and POST handlers (the GET terms/authorities handlers already exist from M2; do NOT duplicate them). Add:
|
||||||
|
```ts
|
||||||
|
import { vocabularies } from "./fixtures";
|
||||||
|
// in the handlers array:
|
||||||
|
http.get("/api/admin/vocabularies", () => HttpResponse.json(vocabularies)),
|
||||||
|
http.post("/api/admin/vocabularies", () =>
|
||||||
|
HttpResponse.json({ id: "v-new", key: "new" }, { status: 201 })),
|
||||||
|
http.post("/api/admin/vocabularies/:id/terms", () =>
|
||||||
|
HttpResponse.json({ id: "t-new" }, { status: 201 })),
|
||||||
|
http.post("/api/admin/authorities", () =>
|
||||||
|
HttpResponse.json({ id: "a-new" }, { status: 201 })),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write the failing hook test** `web/src/api/queries.vocab.test.tsx`
|
||||||
|
```tsx
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { http, HttpResponse } from "msw";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { server } from "../test/server";
|
||||||
|
import { useVocabularies, useCreateVocabulary, useAddTerm, useCreateAuthority } from "./queries";
|
||||||
|
|
||||||
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("vocab/authority hooks", () => {
|
||||||
|
test("useVocabularies lists vocabularies", async () => {
|
||||||
|
const { result } = renderHook(() => useVocabularies(), { wrapper });
|
||||||
|
await waitFor(() => expect(result.current.data?.length).toBe(2));
|
||||||
|
expect(result.current.data?.[0].key).toBe("material");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("useCreateVocabulary POSTs the key", async () => {
|
||||||
|
let body: unknown;
|
||||||
|
server.use(http.post("/api/admin/vocabularies", async ({ request }) => {
|
||||||
|
body = await request.json();
|
||||||
|
return HttpResponse.json({ id: "v-x", key: "colour" }, { status: 201 });
|
||||||
|
}));
|
||||||
|
const { result } = renderHook(() => useCreateVocabulary(), { wrapper });
|
||||||
|
await result.current.mutateAsync({ key: "colour" });
|
||||||
|
expect((body as { key: string }).key).toBe("colour");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("useAddTerm POSTs labels to the vocabulary", async () => {
|
||||||
|
let body: unknown;
|
||||||
|
server.use(http.post("/api/admin/vocabularies/:id/terms", async ({ request }) => {
|
||||||
|
body = await request.json();
|
||||||
|
return HttpResponse.json({ id: "t-x" }, { status: 201 });
|
||||||
|
}));
|
||||||
|
const { result } = renderHook(() => useAddTerm(), { wrapper });
|
||||||
|
await result.current.mutateAsync({
|
||||||
|
vocabularyId: "v-material", external_uri: null,
|
||||||
|
labels: [{ lang: "en", label: "Red" }],
|
||||||
|
});
|
||||||
|
expect((body as { labels: { label: string }[] }).labels[0].label).toBe("Red");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("useCreateAuthority POSTs kind + labels", async () => {
|
||||||
|
let body: unknown;
|
||||||
|
server.use(http.post("/api/admin/authorities", async ({ request }) => {
|
||||||
|
body = await request.json();
|
||||||
|
return HttpResponse.json({ id: "a-x" }, { status: 201 });
|
||||||
|
}));
|
||||||
|
const { result } = renderHook(() => useCreateAuthority(), { wrapper });
|
||||||
|
await result.current.mutateAsync({
|
||||||
|
kind: "person", external_uri: null, labels: [{ lang: "en", label: "Ada" }],
|
||||||
|
});
|
||||||
|
expect((body as { kind: string }).kind).toBe("person");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it fails** — `pnpm test src/api/queries.vocab.test.tsx` → FAIL (hooks missing).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Implement the hooks** — append to `web/src/api/queries.ts`:
|
||||||
|
```ts
|
||||||
|
type NewVocabularyRequest = components["schemas"]["NewVocabularyRequest"];
|
||||||
|
type LabelInput = components["schemas"]["LabelInput"];
|
||||||
|
|
||||||
|
export function useVocabularies() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["vocabularies"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await api.GET("/api/admin/vocabularies");
|
||||||
|
if (error || !data) throw new Error("failed to load vocabularies");
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateVocabulary() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: NewVocabularyRequest) => {
|
||||||
|
const { data, error } = await api.POST("/api/admin/vocabularies", { body });
|
||||||
|
if (error || !data) throw new Error("create vocabulary failed");
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddTerm() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ vocabularyId, external_uri, labels }: {
|
||||||
|
vocabularyId: string; external_uri: string | null; labels: LabelInput[];
|
||||||
|
}) => {
|
||||||
|
const { response } = await api.POST("/api/admin/vocabularies/{id}/terms", {
|
||||||
|
params: { path: { id: vocabularyId } },
|
||||||
|
body: { external_uri, labels },
|
||||||
|
});
|
||||||
|
if (response.status !== 201) throw new Error("add term failed");
|
||||||
|
},
|
||||||
|
onSuccess: (_r, { vocabularyId }) =>
|
||||||
|
qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateAuthority() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ kind, external_uri, labels }: {
|
||||||
|
kind: string; external_uri: string | null; labels: LabelInput[];
|
||||||
|
}) => {
|
||||||
|
const { response } = await api.POST("/api/admin/authorities", {
|
||||||
|
body: { kind, external_uri, labels },
|
||||||
|
});
|
||||||
|
if (response.status !== 201) throw new Error("create authority failed");
|
||||||
|
},
|
||||||
|
onSuccess: (_r, { kind }) =>
|
||||||
|
qc.invalidateQueries({ queryKey: ["authorities", kind] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(Verify path keys + body types against `schema.d.ts`. `useQuery`/`useMutation`/`useQueryClient`/`api`/`components` are already imported. The `["terms",vocabularyId]`/`["authorities",kind]` keys MUST match the existing `useTerms`/`useAuthorities` keys so invalidation refetches — confirm by reading those two hooks. If `NewTermRequest`/`NewAuthorityRequest` require non-null `external_uri`, pass `null` is fine since they're `string | null`.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run** — `pnpm test src/api/queries.vocab.test.tsx` → PASS (4). Full `pnpm test`, `pnpm typecheck`, `pnpm lint`, `pnpm build` → clean.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
```bash
|
||||||
|
cd ..
|
||||||
|
git add web
|
||||||
|
git commit -m "feat(web): vocabulary/term/authority list+create hooks + MSW handlers"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Shared `LabelEditor` (sv/en)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/src/components/label-editor.tsx`, `web/src/components/label-editor.test.tsx`
|
||||||
|
- Modify: `web/src/i18n/{en,sv}.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: i18n** — merge a `labels` namespace into `en.json`: `"labels": { "en": "Label (EN)", "sv": "Label (SV)", "externalUri": "External URI (optional)" }`; `sv.json`: `"labels": { "en": "Etikett (EN)", "sv": "Etikett (SV)", "externalUri": "Extern URI (valfritt)" }`. Keep parity.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing test** `web/src/components/label-editor.test.tsx`
|
||||||
|
```tsx
|
||||||
|
import { expect, test, vi } from "vitest";
|
||||||
|
import { screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { renderApp } from "../test/render";
|
||||||
|
import { LabelEditor } from "./label-editor";
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
type LabelInput = components["schemas"]["LabelInput"];
|
||||||
|
|
||||||
|
function Harness({ onChange }: { onChange: (v: LabelInput[]) => void }) {
|
||||||
|
return <LabelEditor value={[]} onChange={onChange} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("typing EN and SV emits both labels; empty langs are omitted", async () => {
|
||||||
|
const seen: LabelInput[][] = [];
|
||||||
|
renderApp(<Harness onChange={(v) => seen.push(v)} />);
|
||||||
|
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Bronze");
|
||||||
|
await userEvent.type(screen.getByLabelText(/label \(sv\)/i), "Brons");
|
||||||
|
const last = seen.at(-1)!;
|
||||||
|
expect(last).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ lang: "en", label: "Bronze" },
|
||||||
|
{ lang: "sv", label: "Brons" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
// an editor with only EN filled emits just the EN entry
|
||||||
|
expect(seen.some((v) => v.length === 1 && v[0].lang === "en")).toBe(true);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement** — `web/src/components/label-editor.tsx`
|
||||||
|
```tsx
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
type LabelInput = components["schemas"]["LabelInput"];
|
||||||
|
|
||||||
|
/** Controlled sv/en label editor. Emits LabelInput[] with only the non-empty langs. */
|
||||||
|
export function LabelEditor({
|
||||||
|
value, onChange,
|
||||||
|
}: {
|
||||||
|
value: LabelInput[];
|
||||||
|
onChange: (labels: LabelInput[]) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const valueFor = (lang: string) => value.find((l) => l.lang === lang)?.label ?? "";
|
||||||
|
|
||||||
|
const set = (lang: string, label: string) => {
|
||||||
|
const others = value.filter((l) => l.lang !== lang);
|
||||||
|
onChange(label ? [...others, { lang, label }] : others);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="label-en">{t("labels.en")}</Label>
|
||||||
|
<Input id="label-en" value={valueFor("en")} onChange={(e) => set("en", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="label-sv">{t("labels.sv")}</Label>
|
||||||
|
<Input id="label-sv" value={valueFor("sv")} onChange={(e) => set("sv", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(Controlled: parent owns the `value` array. `set` replaces the entry for that lang or drops it when cleared, so empty langs never appear in the emitted array.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run** — `pnpm test src/components/label-editor.test.tsx` → PASS. Full `pnpm test`/typecheck/lint/build clean.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
```bash
|
||||||
|
cd ..
|
||||||
|
git add web
|
||||||
|
git commit -m "feat(web): shared sv/en LabelEditor"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Vocabularies screen (two-pane) + route + nav enable
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/src/vocab/vocabularies-page.tsx`, `web/src/vocab/vocabulary-list.tsx`, `web/src/vocab/vocabulary-terms.tsx`, `web/src/vocab/vocabularies.test.tsx`
|
||||||
|
- Modify: `web/src/app.tsx`, `web/src/shell/app-shell.tsx`, `web/src/i18n/{en,sv}.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: i18n** — merge a `vocab` namespace into `en.json`:
|
||||||
|
```json
|
||||||
|
"vocab": {
|
||||||
|
"title": "Vocabularies", "newVocabulary": "New vocabulary", "key": "Key",
|
||||||
|
"create": "Create", "selectPrompt": "Select a vocabulary to manage its terms",
|
||||||
|
"terms": "Terms", "addTerm": "Add term", "empty": "No vocabularies yet",
|
||||||
|
"noTerms": "No terms yet", "loadError": "Could not load"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`sv.json`:
|
||||||
|
```json
|
||||||
|
"vocab": {
|
||||||
|
"title": "Vokabulär", "newVocabulary": "Ny vokabulär", "key": "Nyckel",
|
||||||
|
"create": "Skapa", "selectPrompt": "Välj en vokabulär för att hantera dess termer",
|
||||||
|
"terms": "Termer", "addTerm": "Lägg till term", "empty": "Inga vokabulärer ännu",
|
||||||
|
"noTerms": "Inga termer ännu", "loadError": "Kunde inte ladda"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Keep parity.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing test** `web/src/vocab/vocabularies.test.tsx`
|
||||||
|
```tsx
|
||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { http, HttpResponse } from "msw";
|
||||||
|
import { Routes, Route } from "react-router-dom";
|
||||||
|
import { server } from "../test/server";
|
||||||
|
import { renderApp } from "../test/render";
|
||||||
|
import { VocabulariesPage } from "./vocabularies-page";
|
||||||
|
import { VocabularyTerms } from "./vocabulary-terms";
|
||||||
|
import { SelectPrompt } from "../objects/select-prompt";
|
||||||
|
|
||||||
|
function tree() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/vocabularies" element={<VocabulariesPage />}>
|
||||||
|
<Route index element={<div>pick a vocabulary</div>} />
|
||||||
|
<Route path=":id" element={<VocabularyTerms />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("lists vocabularies and creates one", async () => {
|
||||||
|
let body: unknown;
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/vocabularies", async ({ request }) => {
|
||||||
|
body = await request.json();
|
||||||
|
return HttpResponse.json({ id: "v-c", key: "colour" }, { status: 201 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderApp(tree(), { route: "/vocabularies" });
|
||||||
|
expect(await screen.findByText("material")).toBeInTheDocument();
|
||||||
|
await userEvent.type(screen.getByLabelText(/key/i), "colour");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||||
|
await waitFor(() => expect((body as { key: string })?.key).toBe("colour"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selecting a vocabulary shows its terms and adds one", async () => {
|
||||||
|
let termBody: unknown;
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/vocabularies/:id/terms", async ({ request }) => {
|
||||||
|
termBody = await request.json();
|
||||||
|
return HttpResponse.json({ id: "t-c" }, { status: 201 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderApp(tree(), { route: "/vocabularies/v-material" });
|
||||||
|
// material terms come from the default MSW handler (materialTerms: Bronze, Wood)
|
||||||
|
expect(await screen.findByText("Bronze")).toBeInTheDocument();
|
||||||
|
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Stone");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /add term/i }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect((termBody as { labels: { label: string }[] })?.labels[0].label).toBe("Stone"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `VocabulariesPage`** — `web/src/vocab/vocabularies-page.tsx`
|
||||||
|
```tsx
|
||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { VocabularyList } from "./vocabulary-list";
|
||||||
|
|
||||||
|
export function VocabulariesPage() {
|
||||||
|
return (
|
||||||
|
<div className="grid h-full grid-cols-[20rem_1fr]">
|
||||||
|
<div className="overflow-hidden border-r">
|
||||||
|
<VocabularyList />
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement `VocabularyList`** — `web/src/vocab/vocabulary-list.tsx`
|
||||||
|
```tsx
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { NavLink } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useVocabularies, useCreateVocabulary } from "../api/queries";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
export function VocabularyList() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data, isLoading, isError } = useVocabularies();
|
||||||
|
const create = useCreateVocabulary();
|
||||||
|
const [key, setKey] = useState("");
|
||||||
|
|
||||||
|
const onCreate = (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!key.trim()) return;
|
||||||
|
create.mutate({ key: key.trim() }, { onSuccess: () => setKey("") });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<form onSubmit={onCreate} className="space-y-1 border-b p-3">
|
||||||
|
<Label htmlFor="vocab-key">{t("vocab.key")}</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input id="vocab-key" value={key} onChange={(e) => setKey(e.target.value)} />
|
||||||
|
<Button type="submit" size="sm" disabled={create.isPending}>{t("vocab.create")}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<ul className="flex-1 overflow-auto">
|
||||||
|
{isLoading && <li className="p-3 text-sm text-neutral-400">…</li>}
|
||||||
|
{isError && <li className="p-3 text-sm text-red-600">{t("vocab.loadError")}</li>}
|
||||||
|
{data?.length === 0 && <li className="p-3 text-sm text-neutral-500">{t("vocab.empty")}</li>}
|
||||||
|
{data?.map((v) => (
|
||||||
|
<li key={v.id}>
|
||||||
|
<NavLink to={`/vocabularies/${v.id}`}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`block border-b px-3 py-2 text-sm ${isActive ? "bg-indigo-50" : "hover:bg-neutral-50"}`}>
|
||||||
|
{v.key}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Implement `VocabularyTerms`** — `web/src/vocab/vocabulary-terms.tsx`
|
||||||
|
```tsx
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
import { useTerms, useAddTerm } from "../api/queries";
|
||||||
|
import { LabelEditor } from "../components/label-editor";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
type LabelInput = components["schemas"]["LabelInput"];
|
||||||
|
type LabelView = components["schemas"]["LabelView"];
|
||||||
|
|
||||||
|
function labelText(labels: LabelView[], lang: string): string {
|
||||||
|
return labels.find((l) => l.lang === lang)?.label ?? labels.find((l) => l.lang === "en")?.label ?? labels[0]?.label ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VocabularyTerms() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const { id } = useParams();
|
||||||
|
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
|
||||||
|
const { data: terms } = useTerms(id);
|
||||||
|
const addTerm = useAddTerm();
|
||||||
|
const [labels, setLabels] = useState<LabelInput[]>([]);
|
||||||
|
const [uri, setUri] = useState("");
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
const onAdd = (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!labels.some((l) => l.lang === "en" && l.label)) { setError(true); return; }
|
||||||
|
setError(false);
|
||||||
|
addTerm.mutate(
|
||||||
|
{ vocabularyId: id!, external_uri: uri.trim() || null, labels },
|
||||||
|
{ onSuccess: () => { setLabels([]); setUri(""); } },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto p-4">
|
||||||
|
<h3 className="mb-2 text-sm font-medium uppercase text-neutral-500">{t("vocab.terms")}</h3>
|
||||||
|
<ul className="mb-4">
|
||||||
|
{terms?.length === 0 && <li className="text-sm text-neutral-500">{t("vocab.noTerms")}</li>}
|
||||||
|
{terms?.map((term) => (
|
||||||
|
<li key={term.id} className="border-b py-1 text-sm">{labelText(term.labels, lang)}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<form onSubmit={onAdd} className="space-y-2 border-t pt-3">
|
||||||
|
<div className="text-sm font-medium">{t("vocab.addTerm")}</div>
|
||||||
|
<LabelEditor value={labels} onChange={setLabels} />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="term-uri">{t("labels.externalUri")}</Label>
|
||||||
|
<Input id="term-uri" value={uri} onChange={(e) => setUri(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
{error && <p role="alert" className="text-xs text-red-600">{t("form.required")}</p>}
|
||||||
|
<Button type="submit" size="sm" disabled={addTerm.isPending}>{t("vocab.addTerm")}</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(`form.required` exists from M2. The EN-required check reads the `labels` array. `useTerms(id)` reuses the existing hook + key.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Wire the route + enable the Vocabularies nav**
|
||||||
|
|
||||||
|
In `web/src/app.tsx`, add inside the protected `AppShell` group:
|
||||||
|
```tsx
|
||||||
|
<Route path="/vocabularies" element={<VocabulariesPage />}>
|
||||||
|
<Route index element={<SelectVocabularyPrompt />} />
|
||||||
|
<Route path=":id" element={<VocabularyTerms />} />
|
||||||
|
</Route>
|
||||||
|
```
|
||||||
|
For the index prompt, reuse a small prompt — either import the Objects `SelectPrompt` or add a `vocab`-specific one. Simplest: create `web/src/vocab/select-vocabulary-prompt.tsx` rendering `t("vocab.selectPrompt")` (mirror `objects/select-prompt.tsx`), import as `SelectVocabularyPrompt`. (Adjust the test's index element to match if you reference it.)
|
||||||
|
|
||||||
|
In `web/src/shell/app-shell.tsx`, change the nav so `vocabularies` is an active `NavLink` to `/vocabularies` (like the Objects link), removing it from the disabled `FUTURE` list. Keep `authorities`, `fields`, `search` disabled for now (authorities is enabled in Task 4). E.g. render Objects + Vocabularies as `NavLink`s and `["authorities","fields","search"]` as disabled buttons.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run** — `pnpm test src/vocab/vocabularies.test.tsx` → PASS (2). Update the app-shell test if it asserted `vocabularies` was a disabled button (it asserted `search` is disabled — unaffected; but if it checked vocabularies specifically, update it). Full `pnpm test`, typecheck, lint, build clean.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
```bash
|
||||||
|
cd ..
|
||||||
|
git add web
|
||||||
|
git commit -m "feat(web): vocabularies two-pane screen (list/create + terms/add) + nav"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Authorities screen (kind tabs) + route + nav enable
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/src/authorities/authorities-page.tsx`, `web/src/authorities/authorities.test.tsx`
|
||||||
|
- Modify: `web/src/app.tsx`, `web/src/shell/app-shell.tsx`, `web/src/i18n/{en,sv}.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: i18n** — merge an `authorities` namespace into `en.json`:
|
||||||
|
```json
|
||||||
|
"authorities": {
|
||||||
|
"title": "Authorities", "person": "Person", "organisation": "Organisation", "place": "Place",
|
||||||
|
"new": "New", "create": "Create", "empty": "No authorities yet", "loadError": "Could not load"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`sv.json`:
|
||||||
|
```json
|
||||||
|
"authorities": {
|
||||||
|
"title": "Auktoriteter", "person": "Person", "organisation": "Organisation", "place": "Plats",
|
||||||
|
"new": "Ny", "create": "Skapa", "empty": "Inga auktoriteter ännu", "loadError": "Kunde inte ladda"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Keep parity.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing test** `web/src/authorities/authorities.test.tsx`
|
||||||
|
```tsx
|
||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { http, HttpResponse } from "msw";
|
||||||
|
import { Routes, Route } from "react-router-dom";
|
||||||
|
import { server } from "../test/server";
|
||||||
|
import { renderApp } from "../test/render";
|
||||||
|
import { AuthoritiesPage } from "./authorities-page";
|
||||||
|
|
||||||
|
function tree() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/authorities/:kind" element={<AuthoritiesPage />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("lists authorities for the kind and creates one", async () => {
|
||||||
|
let body: unknown;
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/authorities", async ({ request }) => {
|
||||||
|
body = await request.json();
|
||||||
|
return HttpResponse.json({ id: "a-c" }, { status: 201 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderApp(tree(), { route: "/authorities/person" });
|
||||||
|
// default MSW handler returns personAuthorities (Ada Lovelace) for kind=person
|
||||||
|
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
|
||||||
|
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Carl von Linné");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||||
|
await waitFor(() => expect((body as { kind: string })?.kind).toBe("person"));
|
||||||
|
expect((body as { labels: { label: string }[] }).labels[0].label).toBe("Carl von Linné");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("kind tabs link to the other kinds", async () => {
|
||||||
|
renderApp(tree(), { route: "/authorities/person" });
|
||||||
|
expect(await screen.findByRole("link", { name: /place/i })).toHaveAttribute("href", "/authorities/place");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `AuthoritiesPage`** — `web/src/authorities/authorities-page.tsx`
|
||||||
|
```tsx
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { NavLink, useParams } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
import { useAuthorities, useCreateAuthority } from "../api/queries";
|
||||||
|
import { LabelEditor } from "../components/label-editor";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
type LabelInput = components["schemas"]["LabelInput"];
|
||||||
|
type LabelView = components["schemas"]["LabelView"];
|
||||||
|
const KINDS = ["person", "organisation", "place"] as const;
|
||||||
|
|
||||||
|
function labelText(labels: LabelView[], lang: string): string {
|
||||||
|
return labels.find((l) => l.lang === lang)?.label ?? labels.find((l) => l.lang === "en")?.label ?? labels[0]?.label ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthoritiesPage() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const { kind = "person" } = useParams();
|
||||||
|
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
|
||||||
|
const { data: authorities } = useAuthorities(kind);
|
||||||
|
const create = useCreateAuthority();
|
||||||
|
const [labels, setLabels] = useState<LabelInput[]>([]);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
const onCreate = (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!labels.some((l) => l.lang === "en" && l.label)) { setError(true); return; }
|
||||||
|
setError(false);
|
||||||
|
create.mutate(
|
||||||
|
{ kind, external_uri: null, labels },
|
||||||
|
{ onSuccess: () => setLabels([]) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto p-4">
|
||||||
|
<div className="mb-3 flex gap-2">
|
||||||
|
{KINDS.map((k) => (
|
||||||
|
<NavLink key={k} to={`/authorities/${k}`}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`rounded px-3 py-1 text-sm ${isActive ? "bg-neutral-800 text-white" : "border"}`}>
|
||||||
|
{t(`authorities.${k}`)}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ul className="mb-4">
|
||||||
|
{authorities?.length === 0 && <li className="text-sm text-neutral-500">{t("authorities.empty")}</li>}
|
||||||
|
{authorities?.map((a) => (
|
||||||
|
<li key={a.id} className="border-b py-1 text-sm">{labelText(a.labels, lang)}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<form onSubmit={onCreate} className="space-y-2 border-t pt-3">
|
||||||
|
<div className="text-sm font-medium">{t("authorities.new")} · {t(`authorities.${kind}`)}</div>
|
||||||
|
<LabelEditor value={labels} onChange={setLabels} />
|
||||||
|
{error && <p role="alert" className="text-xs text-red-600">{t("form.required")}</p>}
|
||||||
|
<Button type="submit" size="sm" disabled={create.isPending}>{t("authorities.create")}</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(`useAuthorities(kind)` reuses the existing hook + key. The kind comes from the route param. Unknown-kind validation is handled by the route redirect in Step 4.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Wire routes + enable the Authorities nav**
|
||||||
|
|
||||||
|
In `web/src/app.tsx`, add inside `AppShell`:
|
||||||
|
```tsx
|
||||||
|
<Route path="/authorities" element={<Navigate to="/authorities/person" replace />} />
|
||||||
|
<Route path="/authorities/:kind" element={<AuthoritiesPage />} />
|
||||||
|
```
|
||||||
|
(`Navigate` is already imported in app.tsx.)
|
||||||
|
|
||||||
|
In `web/src/shell/app-shell.tsx`, make `authorities` an active `NavLink` to `/authorities` (alongside Objects + Vocabularies); keep `fields` + `search` disabled.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run** — `pnpm test src/authorities/authorities.test.tsx` → PASS (2). Full `pnpm test`, typecheck, lint, build clean. (Update the app-shell test if it asserted authorities was disabled.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
```bash
|
||||||
|
cd ..
|
||||||
|
git add web
|
||||||
|
git commit -m "feat(web): authorities kind-tabbed screen (list/create) + nav"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: i18n parity + full verification
|
||||||
|
|
||||||
|
**Files:** none expected (verification); fix-ups only if a check fails.
|
||||||
|
|
||||||
|
- [ ] **Step 1: i18n parity check** —
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
node -e "const a=require('./src/i18n/en.json'),b=require('./src/i18n/sv.json');const keys=o=>Object.entries(o).flatMap(([k,v])=>typeof v==='object'?keys(v).map(s=>k+'.'+s):[k]);const ka=keys(a).sort(),kb=keys(b).sort();console.log(JSON.stringify(ka)===JSON.stringify(kb)?'PARITY OK':'MISMATCH:'+JSON.stringify({onlyEn:ka.filter(k=>!kb.includes(k)),onlySv:kb.filter(k=>!ka.includes(k))}))"
|
||||||
|
```
|
||||||
|
Expected `PARITY OK`; fix any mismatch.
|
||||||
|
|
||||||
|
- [ ] **Step 2: app-shell nav test** — confirm `web/src/shell/app-shell.test.tsx` still passes; the Vocabularies + Authorities items are now `NavLink`s (role=link) and `fields`/`search` remain disabled buttons. If the existing test asserted vocabularies/authorities were disabled, update those assertions to expect links; keep asserting `search`/`fields` disabled.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Full verification** —
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size
|
||||||
|
```
|
||||||
|
Expected: clean; all tests pass; bundle ≤150 KB gz (report the number — the new screens are small; if it exceeds, lazy-load the vocab/authorities routes via `React.lazy` in `app.tsx` like the M2 forms, and re-verify).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit** — only if Steps 1–2 required a fix:
|
||||||
|
```bash
|
||||||
|
cd ..
|
||||||
|
git add web
|
||||||
|
git commit -m "chore(web): m4 i18n parity + nav test updates"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (completed)
|
||||||
|
|
||||||
|
**Spec coverage:**
|
||||||
|
- Nav stubs enabled + routes → Tasks 3, 4. ✓
|
||||||
|
- Vocabularies list/create + terms list/add (two-pane) → Task 3. ✓
|
||||||
|
- Authorities kind-tabbed list/create → Task 4. ✓
|
||||||
|
- Shared sv/en `LabelEditor`, EN-required → Task 2 (+ EN-required enforced in Tasks 3, 4 forms). ✓
|
||||||
|
- 4 new hooks + invalidation of the existing `["terms",id]`/`["authorities",kind]`/`["vocabularies"]` keys → Task 1. ✓
|
||||||
|
- Create-only (no edit/delete) → respected throughout. ✓
|
||||||
|
- Error/loading/empty states → Tasks 3, 4. ✓
|
||||||
|
- i18n sv/en parity → Tasks 2–4 + Task 5 check. ✓
|
||||||
|
- Testing Vitest+RTL+MSW → Tasks 1–4. ✓
|
||||||
|
- Bundle budget → Task 5. ✓
|
||||||
|
|
||||||
|
**Placeholder scan:** none — complete code in every step; the "verify path/body types against schema.d.ts" and "reuse SelectPrompt or add a vocab prompt" notes are concrete verification/choice instructions.
|
||||||
|
|
||||||
|
**Type consistency:** `LabelInput`/`LabelView` used consistently; hooks `useVocabularies`/`useCreateVocabulary`/`useAddTerm`/`useCreateAuthority` defined in Task 1 and consumed in Tasks 3–4; `useAddTerm` takes `{vocabularyId, external_uri, labels}` and `useCreateAuthority` `{kind, external_uri, labels}` consistently across plan + tests; `LabelEditor` `value`/`onChange` contract consistent; invalidation keys (`["terms",vocabularyId]`, `["authorities",kind]`, `["vocabularies"]`) match the existing read hooks; routes (`/vocabularies`, `/vocabularies/:id`, `/authorities/:kind`) consistent across Tasks 3–4 + app.tsx.
|
||||||
|
|
||||||
|
## Notes for follow-on
|
||||||
|
- Edit/delete of vocab/term/authority needs backend endpoints — file a backend follow-up when M4 lands.
|
||||||
|
- Audit of vocab/authority creation (#21); searchable pickers (#27); enum typing (#29).
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
|||||||
|
# Frontend SPA — Milestone 2 (Object Authoring) — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-04
|
||||||
|
**Status:** Approved (brainstorming) — ready for implementation planning.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Milestone 1 (merged to `main` at `0a2398f`) delivered the SPA foundation: typed client,
|
||||||
|
TanStack Query hooks, app shell, sv/en i18n, login/session guard, and a read-only
|
||||||
|
two-pane Objects screen (paginated list + detail). Milestone 2 adds **authoring** —
|
||||||
|
create, edit, and delete catalogue objects, including the **dynamic flexible-field
|
||||||
|
form** driven by the field-definition registry.
|
||||||
|
|
||||||
|
This is **pure frontend**: every endpoint already exists on the admin surface
|
||||||
|
(`POST/PUT/DELETE /api/admin/objects`, `PUT /api/admin/objects/{id}/fields`,
|
||||||
|
`GET /api/admin/field-definitions`, `GET /api/admin/vocabularies/{id}/terms`,
|
||||||
|
`GET /api/admin/authorities?kind=`).
|
||||||
|
|
||||||
|
Milestone roadmap (from M1): M2 authoring (this) → M3 publish workflow → M4
|
||||||
|
vocabulary/authority management → M5 search.
|
||||||
|
|
||||||
|
## Decisions (settled during brainstorming)
|
||||||
|
|
||||||
|
- **Create/edit flow shapes:** **edit in-place** in the right pane (`/objects/:id/edit`,
|
||||||
|
keeps the inspector + list context); **new** as a **full-width route** (`/objects/new`,
|
||||||
|
room for the full field set).
|
||||||
|
- **Reference fields (term/authority):** plain `<Select>` populated from the relevant
|
||||||
|
endpoint (all options client-side). Acceptable while vocabularies are small; a
|
||||||
|
searchable combobox (+ a future server-side term-search) is a later refinement.
|
||||||
|
- **All field types in scope:** text, integer, date, boolean, localized_text (sv+en),
|
||||||
|
term, authority.
|
||||||
|
- **Form library:** **react-hook-form**, used directly with the existing shadcn
|
||||||
|
Input/Label/Select (Controller for Select/Checkbox) — no shadcn Form wrapper (leaner).
|
||||||
|
- Validation client-side; query invalidation after writes; bundle stays within the
|
||||||
|
150 KB gz budget (current ≈120 KB; RHF ≈9 KB gz).
|
||||||
|
|
||||||
|
## Scope (YAGNI)
|
||||||
|
|
||||||
|
**In:** New (full-width), Edit (in-pane), Delete (confirm dialog); the dynamic
|
||||||
|
flexible-field form covering all field types; client-side validation incl. required
|
||||||
|
fields; create/edit/delete + set-fields mutations with cache invalidation.
|
||||||
|
|
||||||
|
**Out:** visibility/publish transitions (M3 — `new` offers only Draft/Internal, edit
|
||||||
|
never changes visibility); vocabulary/authority management UI (M4); search (M5); media;
|
||||||
|
searchable combobox; bulk/import.
|
||||||
|
|
||||||
|
## Backend contract (already shipped — verify against `web/src/api/schema.d.ts`)
|
||||||
|
|
||||||
|
- `POST /api/admin/objects` body `ObjectCreateRequest` (object_number, object_name,
|
||||||
|
number_of_objects, optional brief_description/current_location/current_owner/recorder,
|
||||||
|
recording_date `YYYY-MM-DD`, visibility) → `201 CreatedObject {id}`. Rejects
|
||||||
|
`visibility=public` and bad dates with `422`.
|
||||||
|
- `PUT /api/admin/objects/{id}` body `ObjectUpdateRequest` (same minimum fields, **no
|
||||||
|
visibility**) → `204`; `404` if missing.
|
||||||
|
- `DELETE /api/admin/objects/{id}` → `204`; `404` if missing.
|
||||||
|
- `PUT /api/admin/objects/{id}/fields` body = JSON map of field key → value, **replace
|
||||||
|
semantics** (the body is the complete desired set) → `204`; `404` object missing;
|
||||||
|
`422` unknown field / type mismatch / unresolved reference (bare — no field detail).
|
||||||
|
- `GET /api/admin/field-definitions` → `FieldDefinitionView[]` (`key`, `data_type`,
|
||||||
|
`vocabulary_id?`, `authority_kind?`, `required`, `group?`, `labels:[{lang,label}]`).
|
||||||
|
`data_type` ∈ {text, localized_text, integer, date, boolean, term, authority}.
|
||||||
|
- `GET /api/admin/vocabularies/{id}/terms` → `TermView[]` (`id`, `external_uri?`,
|
||||||
|
`labels`).
|
||||||
|
- `GET /api/admin/authorities?kind=person|organisation|place` → `AuthorityView[]`
|
||||||
|
(`id`, `kind`, `external_uri?`, `labels`).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Routes
|
||||||
|
|
||||||
|
```
|
||||||
|
AppShell (protected)
|
||||||
|
/objects/new → ObjectNewPage (full-width; sibling, static beats :id)
|
||||||
|
/objects → ObjectsPage (two-pane: list left + <Outlet/> right)
|
||||||
|
index → select-prompt placeholder
|
||||||
|
:id → ObjectDetail (read view; gains Edit/Delete actions)
|
||||||
|
:id/edit → ObjectEditForm (in-pane edit form)
|
||||||
|
```
|
||||||
|
|
||||||
|
`/objects/new` is a sibling of `/objects` (full-width, not the two-pane). React Router
|
||||||
|
ranks the static `new` segment above the dynamic `:id`, so `/objects/new` never matches
|
||||||
|
the detail route. Edit and detail are **children** of `ObjectsPage`, which renders the
|
||||||
|
list plus an `<Outlet/>` for the right pane.
|
||||||
|
|
||||||
|
### Components / files
|
||||||
|
|
||||||
|
```
|
||||||
|
web/src/objects/
|
||||||
|
object-form.tsx shared form body: core (inventory-minimum) + dynamic flexible
|
||||||
|
fields; RHF; used by both new and edit. Props: defaults,
|
||||||
|
mode ("create" | "edit"), onSubmit(values), onCancel.
|
||||||
|
field-input.tsx <FieldInput definition control/>: switch on data_type →
|
||||||
|
the right control. Term/authority variants fetch options.
|
||||||
|
object-new-page.tsx full-width /objects/new: empty ObjectForm (mode=create,
|
||||||
|
visibility select Draft/Internal) → create flow.
|
||||||
|
object-edit-form.tsx in-pane /objects/:id/edit: loads the object, pre-fills
|
||||||
|
ObjectForm (mode=edit, no visibility) → edit flow.
|
||||||
|
delete-object-dialog.tsx AlertDialog confirm → delete flow.
|
||||||
|
object-detail.tsx (modify) add Edit + Delete actions.
|
||||||
|
objects-page.tsx (modify) render list + <Outlet/> for the right pane.
|
||||||
|
object-list.tsx (modify) add a "New object" action → /objects/new.
|
||||||
|
web/src/api/queries.ts (modify) + useTerms, useAuthorities, useCreateObject,
|
||||||
|
useUpdateObject, useSetFields, useDeleteObject.
|
||||||
|
web/src/app.tsx (modify) nested routes above.
|
||||||
|
web/src/i18n/{en,sv}.json (modify) form labels, field-type controls, actions, errors.
|
||||||
|
web/src/components/ui/ shadcn adds: select, checkbox, alert-dialog.
|
||||||
|
```
|
||||||
|
|
||||||
|
`object-form.tsx` is the one unit that could grow large; keep `FieldInput` and the
|
||||||
|
term/authority option hooks in `field-input.tsx` so the form file stays focused on
|
||||||
|
layout + submission, and the per-type rendering lives separately.
|
||||||
|
|
||||||
|
### Dynamic field rendering (`FieldInput`)
|
||||||
|
|
||||||
|
Switch on `definition.data_type`:
|
||||||
|
|
||||||
|
| data_type | control | value shape |
|
||||||
|
|---|---|---|
|
||||||
|
| `text` | `<Input type="text">` | string |
|
||||||
|
| `integer` | `<Input type="number">` | number |
|
||||||
|
| `date` | `<Input type="date">` | `YYYY-MM-DD` string |
|
||||||
|
| `boolean` | `<Checkbox>` | boolean |
|
||||||
|
| `localized_text` | sv + en `<Input>`s | `{ sv?, en? }` (omit empty langs) |
|
||||||
|
| `term` | `<Select>` of `useTerms(definition.vocabulary_id)` | term id (string) |
|
||||||
|
| `authority` | `<Select>` of `useAuthorities(definition.authority_kind)` | authority id (string) |
|
||||||
|
|
||||||
|
Option labels render in the active locale (fall back to English, then the raw key —
|
||||||
|
same rule as M1's detail view). Fields render **grouped by `definition.group`** (a
|
||||||
|
group heading per non-empty group; ungrouped fields under no heading), preserving the
|
||||||
|
field-definitions API order within each group. `definition.required` drives a
|
||||||
|
client-side required rule.
|
||||||
|
|
||||||
|
### Data flow
|
||||||
|
|
||||||
|
- **Create** (`ObjectNewPage`): `useCreateObject` → `POST /objects` (core + visibility) →
|
||||||
|
`{id}`; if any flexible values are set, `useSetFields(id, values)` → `PUT
|
||||||
|
/objects/:id/fields`; invalidate `["objects"]`; navigate `/objects/:id`.
|
||||||
|
- **Edit** (`ObjectEditForm`): `useUpdateObject(id)` → `PUT /objects/:id` (core);
|
||||||
|
`useSetFields(id, values)` → `PUT /objects/:id/fields` (replace); invalidate
|
||||||
|
`["object", id]` + `["objects"]`; navigate `/objects/:id`.
|
||||||
|
- **Delete** (`DeleteObjectDialog`): `useDeleteObject(id)` → `DELETE /objects/:id`;
|
||||||
|
invalidate `["objects"]`; navigate `/objects`.
|
||||||
|
|
||||||
|
Flexible-field submission uses **replace semantics**: the form sends the complete
|
||||||
|
desired field map. Cleared optional fields are omitted (removed); set fields are
|
||||||
|
included with their current value.
|
||||||
|
|
||||||
|
### Error handling
|
||||||
|
|
||||||
|
- **Client validation** (RHF): required fields present; integer is numeric; date is a
|
||||||
|
valid `YYYY-MM-DD`. Blocks submit with inline messages. Prevents most server 422s.
|
||||||
|
- **Server `422`** (bad date on create; `set_fields` unknown/type/unresolved — bare):
|
||||||
|
surface a **form-level alert** ("The server rejected the changes — check the
|
||||||
|
highlighted and referenced fields"). The bare `set_fields` 422 carries no per-field
|
||||||
|
detail, so client validation is the primary guard.
|
||||||
|
- **Partial create** (create `POST` succeeds, fields `PUT` fails): the core record now
|
||||||
|
exists as Draft. Rather than lose it, navigate to `/objects/:id/edit` with an error
|
||||||
|
banner so the user can retry the field values. (Documented behavior, tested.)
|
||||||
|
- **Edit a since-deleted object** (`404`): show the not-found state.
|
||||||
|
- Visibility is never sent on edit; `new` offers only Draft/Internal (never Public).
|
||||||
|
|
||||||
|
### Testing (Vitest + RTL + MSW)
|
||||||
|
|
||||||
|
- `FieldInput`: renders the correct control for each `data_type`; term/authority
|
||||||
|
selects populate options from MSW handlers (labels in active locale; value = id).
|
||||||
|
- **New flow**: fill core + one flexible field → submit → asserts `POST /objects` then
|
||||||
|
`PUT /objects/:id/fields` were called (MSW) → navigates to the detail route.
|
||||||
|
- **Edit flow**: form pre-fills from the loaded object → change a field → save →
|
||||||
|
`PUT /objects/:id` + `PUT .../fields` → returns to detail.
|
||||||
|
- **Delete**: confirm dialog → `DELETE` → navigates to the list.
|
||||||
|
- **Validation**: a required field left empty blocks submit and shows an error.
|
||||||
|
- **Visibility**: the `new` form's visibility select offers only Draft and Internal.
|
||||||
|
- **Partial create**: create OK but fields PUT 422 → lands on `/objects/:id/edit` with
|
||||||
|
an error banner.
|
||||||
|
- New MSW handlers: terms, authorities, `POST/PUT/DELETE /objects`, `PUT .../fields`.
|
||||||
|
|
||||||
|
## Acceptance criteria (Milestone 2 "done")
|
||||||
|
|
||||||
|
1. From the list, "New object" opens the full-width form; filling core + flexible fields
|
||||||
|
and submitting creates the object (Draft/Internal) and lands on its detail view.
|
||||||
|
2. From a record's detail, "Edit" opens the in-pane form pre-filled with current core +
|
||||||
|
flexible values; saving persists both and returns to the detail view.
|
||||||
|
3. All seven field types render and round-trip (incl. term/authority selects and sv/en
|
||||||
|
localized text).
|
||||||
|
4. Required fields are enforced client-side; the `new` form cannot set Public.
|
||||||
|
5. "Delete" confirms, deletes, and returns to the list; the list no longer shows it.
|
||||||
|
6. Partial-create failure lands on the edit page with the core record preserved.
|
||||||
|
7. Web CI green (typecheck, lint, tests, build, bundle ≤150 KB gz).
|
||||||
|
|
||||||
|
## Out of scope / follow-ups
|
||||||
|
|
||||||
|
- Searchable combobox + server-side term search for large vocabularies (later).
|
||||||
|
- Publish/visibility transitions (M3).
|
||||||
|
- Surfacing per-field server errors would require the backend `set_fields` 422 to carry
|
||||||
|
field detail (currently bare — see the admin 422 bodies note in issue #16's follow-up).
|
||||||
|
- `fields` map typing still relies on the `Record<string, unknown>` cast pending
|
||||||
|
issue #24 (open-map OpenAPI typing).
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# Frontend SPA — Milestone 3 (Publishing Workflow) — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-04
|
||||||
|
**Status:** Approved (brainstorming) — ready for implementation planning.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Milestones 1–2 (merged to `main` at `bb05331`) delivered the SPA foundation, the
|
||||||
|
read-only two-pane Objects screen, and full object authoring (create/edit/delete +
|
||||||
|
the dynamic flexible-field form). Milestone 3 adds the **publishing workflow** — driving
|
||||||
|
a record through the stepwise `Draft → Internal → Public` visibility pipeline.
|
||||||
|
|
||||||
|
This is **pure frontend**: the backend `POST /api/admin/objects/{id}/visibility`
|
||||||
|
endpoint and the stepwise state machine already exist (the publish gate was added under
|
||||||
|
issue #16).
|
||||||
|
|
||||||
|
Milestone roadmap: M1 foundation → M2 authoring → **M3 publish workflow (this)** → M4
|
||||||
|
vocabulary/authority management → M5 search.
|
||||||
|
|
||||||
|
## Decisions (settled during brainstorming)
|
||||||
|
|
||||||
|
- **Publish control = a segmented stepper** (Draft → Internal → Public) on the object
|
||||||
|
**detail read view**, with the current stage highlighted; it offers only **legal
|
||||||
|
one-step** moves (forward/back).
|
||||||
|
- **Confirm only on → Public** (an `AlertDialog`, reusing the M2 shadcn one), since
|
||||||
|
publishing makes the record externally visible; all other steps fire immediately.
|
||||||
|
- The **422 publish-gate** failure shows a generic inline message + an Edit link (the
|
||||||
|
backend 422 is bare; per-field detail is deferred to issue #28).
|
||||||
|
- Keep the existing `VisibilityBadge` in the detail header alongside the new stepper.
|
||||||
|
|
||||||
|
## Backend contract (already shipped — verify against `web/src/api/schema.d.ts`)
|
||||||
|
|
||||||
|
- `POST /api/admin/objects/{id}/visibility` body `VisibilityRequest { visibility }` →
|
||||||
|
`204` on success; `404` if the object is missing; `409` on an illegal transition
|
||||||
|
(skipping a step); `422` if publishing to Public with required fields missing (the
|
||||||
|
publish gate). The 422 body is **bare** (no per-field detail).
|
||||||
|
- **State machine** (`domain::Visibility::can_transition_to`): legal moves are
|
||||||
|
`Draft↔Internal` and `Internal↔Public` (one step each). `Draft→Public` and
|
||||||
|
`Public→Draft` are illegal. Setting to the current value is a no-op (allowed).
|
||||||
|
- The publish **gate** (422) fires only on a transition **into Public**
|
||||||
|
(`Internal → Public`). Backward/internal moves have no gate.
|
||||||
|
|
||||||
|
## Scope (YAGNI)
|
||||||
|
|
||||||
|
**In:** the `PublishControl` stepper on the object detail; `useSetVisibility` mutation;
|
||||||
|
the `adjacentTransitions` helper; confirm-on-→Public; inline surfacing of the 422 gate
|
||||||
|
(with an Edit link) and the 409 illegal-transition (defensive); cache invalidation so
|
||||||
|
the badge/stepper refresh after a transition.
|
||||||
|
|
||||||
|
**Out:** per-field gate detail (bare 422 → generic message, tracked by #28); visibility
|
||||||
|
controls in the edit form (visibility stays separate from core/flexible editing, per
|
||||||
|
the M2 design); vocabulary/authority management (M4); search (M5).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Transition adjacency (pure helper)
|
||||||
|
|
||||||
|
`web/src/objects/transitions.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type Visibility = "draft" | "internal" | "public";
|
||||||
|
|
||||||
|
/** The legal one-step moves from a given visibility, per the backend state machine. */
|
||||||
|
export function adjacentTransitions(v: Visibility): { forward?: Visibility; back?: Visibility } {
|
||||||
|
switch (v) {
|
||||||
|
case "draft": return { forward: "internal" };
|
||||||
|
case "internal": return { forward: "public", back: "draft" };
|
||||||
|
case "public": return { back: "internal" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `forward` to `public` is the only move that is gated + confirmed.
|
||||||
|
- Unit-tested in isolation (all three states).
|
||||||
|
|
||||||
|
### Data layer
|
||||||
|
|
||||||
|
`web/src/api/queries.ts` + `useSetVisibility`:
|
||||||
|
|
||||||
|
- `POST /api/admin/objects/{id}/visibility` with `{ visibility }`.
|
||||||
|
- On non-`204`, throw an error that carries the **status** so the UI can distinguish:
|
||||||
|
`409` (illegal), `422` (gate), other. (e.g. `throw Object.assign(new Error("visibility"), { status })`, or a small typed error class.)
|
||||||
|
- `onSuccess`: invalidate `["object", id]` and `["objects"]`.
|
||||||
|
|
||||||
|
### Component — `web/src/objects/publish-control.tsx`
|
||||||
|
|
||||||
|
`PublishControl({ object })`:
|
||||||
|
|
||||||
|
- Reads `object.visibility` and computes `adjacentTransitions`.
|
||||||
|
- Renders a **3-segment stepper** (Draft / Internal / Public): segments before the
|
||||||
|
current = "done", current = highlighted, after = pending.
|
||||||
|
- Renders the legal step buttons with contextual labels:
|
||||||
|
- forward to Internal → "Advance to internal" (or "→ Internal")
|
||||||
|
- forward to Public → "Publish →" (opens the confirm dialog)
|
||||||
|
- back to Draft → "← Back to draft"
|
||||||
|
- back to Internal → "Unpublish to internal"
|
||||||
|
- **Confirm on → Public:** clicking "Publish →" opens an `AlertDialog`
|
||||||
|
("This will make the record publicly visible…") with Cancel + a confirm Action that
|
||||||
|
fires `useSetVisibility.mutate({ id, visibility: "public" })`. All other buttons fire
|
||||||
|
the mutation immediately.
|
||||||
|
- **Mutation states:** while pending, disable the buttons. On error, branch on
|
||||||
|
`error.status`:
|
||||||
|
- `422` → inline message "Can't publish — required fields are missing." + a
|
||||||
|
`<Link to={/objects/:id/edit}>` Edit link.
|
||||||
|
- `409` → inline "That visibility change isn't allowed." (defensive; the UI only
|
||||||
|
offers legal steps).
|
||||||
|
- other → the generic `form.rejected` message.
|
||||||
|
- On success, query invalidation refreshes the object → the stepper + the header
|
||||||
|
`VisibilityBadge` reflect the new state automatically.
|
||||||
|
|
||||||
|
Rendered in `object-detail.tsx` as a new section below the header. The existing
|
||||||
|
`VisibilityBadge` remains in the header.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
| Outcome | UI |
|
||||||
|
|---|---|
|
||||||
|
| `204` | invalidate → stepper + badge update to the new state |
|
||||||
|
| `422` (gate, only on →Public) | inline error + Edit link; state unchanged |
|
||||||
|
| `409` (illegal) | inline error (defensive) |
|
||||||
|
| pending | step buttons disabled |
|
||||||
|
| `404` | (object already absent) generic error; the detail itself would 404 on reload |
|
||||||
|
|
||||||
|
## Testing (Vitest + RTL + MSW)
|
||||||
|
|
||||||
|
- `transitions.test.ts` — `adjacentTransitions` for draft/internal/public.
|
||||||
|
- `publish-control.test.tsx`:
|
||||||
|
- Stepper renders the current stage highlighted.
|
||||||
|
- Draft → only a forward (Internal) button, no back.
|
||||||
|
- Internal → forward (Publish) + back (Draft) buttons.
|
||||||
|
- Public → only a back (Internal) button.
|
||||||
|
- Draft → Internal: clicking forward POSTs `visibility=internal` (204) — assert the
|
||||||
|
request body; success path.
|
||||||
|
- Internal → Public: clicking "Publish →" opens the confirm dialog; confirming POSTs
|
||||||
|
`visibility=public`.
|
||||||
|
- **Gate:** POST `visibility=public` → `422` → inline gate error + Edit link visible;
|
||||||
|
no navigation.
|
||||||
|
- Public → Internal: clicking back POSTs `visibility=internal` immediately (no
|
||||||
|
confirm dialog).
|
||||||
|
- MSW: a configurable `POST /api/admin/objects/:id/visibility` handler (default 204;
|
||||||
|
per-test overrides for 422/409).
|
||||||
|
|
||||||
|
## Acceptance criteria (Milestone 3 "done")
|
||||||
|
|
||||||
|
1. The object detail shows a Draft→Internal→Public stepper with the current stage
|
||||||
|
highlighted and only legal one-step buttons.
|
||||||
|
2. Advancing/retracting a step (except →Public) immediately POSTs the new visibility and
|
||||||
|
the badge/stepper update.
|
||||||
|
3. Publishing to Public requires a confirmation; confirming POSTs `visibility=public`.
|
||||||
|
4. A publish-gate failure (422) shows an inline "required fields missing" message + an
|
||||||
|
Edit link, leaving the record unchanged.
|
||||||
|
5. The UI never offers an illegal (skip) transition; a 409 is handled defensively.
|
||||||
|
6. Web CI green (typecheck, lint, tests, build, bundle ≤150 KB gz).
|
||||||
|
|
||||||
|
## Out of scope / follow-ups
|
||||||
|
|
||||||
|
- Per-field publish-gate detail requires the backend 422 to carry field info (#28).
|
||||||
|
- Audit/history view of visibility changes (a later milestone; the backend already
|
||||||
|
audits transitions).
|
||||||
|
- A public-facing collection site is post-MVP (the public read API exists; no UI here).
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# Frontend SPA — Milestone 4 (Vocabulary & Authority Management) — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-04
|
||||||
|
**Status:** Approved (brainstorming) — ready for implementation planning.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Milestones 1–3 (merged to `main` at `7a8e7ff`) delivered the SPA foundation, object
|
||||||
|
read/authoring, and the publishing workflow. The app shell's nav has **Vocabularies**
|
||||||
|
and **Authorities** items rendered as disabled stubs. Milestone 4 enables them: managing
|
||||||
|
the controlled vocabularies (and their terms) and the authority records that catalogue
|
||||||
|
fields reference.
|
||||||
|
|
||||||
|
Pure frontend — the admin endpoints already exist (built in the backend admin-CRUD
|
||||||
|
phase): `GET/POST /api/admin/vocabularies`, `GET/POST /api/admin/vocabularies/{id}/terms`,
|
||||||
|
`GET/POST /api/admin/authorities?kind=`.
|
||||||
|
|
||||||
|
Milestone roadmap: M1 foundation → M2 authoring → M3 publish → **M4 vocab/authority
|
||||||
|
(this)** → M5 search.
|
||||||
|
|
||||||
|
## Decisions (settled during brainstorming)
|
||||||
|
|
||||||
|
- **One milestone for both surfaces** (vocabularies+terms and authorities), sharing a
|
||||||
|
`LabelEditor` and a create-form pattern.
|
||||||
|
- **Two-pane master–detail layout** (consistent with the Objects inspector): the
|
||||||
|
Vocabularies screen is vocab-list-left / terms-right; Authorities is kind-tabs + list.
|
||||||
|
- **Create + list only.** The backend exposes only create and list for vocabularies,
|
||||||
|
terms, and authorities — no update/delete — so M4 is create + list. Editing/deleting
|
||||||
|
reference data is a later milestone (needs backend endpoints first).
|
||||||
|
- **Fixed sv/en `LabelEditor`** (not arbitrary languages), matching the app's sv/en MVP
|
||||||
|
scope and M2's `localized_text` field; produces `LabelInput[]` of non-empty langs.
|
||||||
|
- **EN label required, SV optional** (canonical English), consistent with M2.
|
||||||
|
|
||||||
|
## Backend contract (already shipped — verify against `web/src/api/schema.d.ts`)
|
||||||
|
|
||||||
|
- `GET /api/admin/vocabularies` → `VocabularyView[]` (`{ id, key }`).
|
||||||
|
- `POST /api/admin/vocabularies` body `NewVocabularyRequest { key }` → `201 VocabularyView`.
|
||||||
|
- `GET /api/admin/vocabularies/{id}/terms` → `TermView[]` (`{ id, external_uri?, labels }`).
|
||||||
|
- `POST /api/admin/vocabularies/{id}/terms` body `NewTermRequest { external_uri?, labels }`
|
||||||
|
→ `201 CreatedId`.
|
||||||
|
- `GET /api/admin/authorities?kind=person|organisation|place` → `AuthorityView[]`
|
||||||
|
(`{ id, kind, external_uri?, labels }`).
|
||||||
|
- `POST /api/admin/authorities` body `NewAuthorityRequest { kind, external_uri?, labels }`
|
||||||
|
→ `201 CreatedId`.
|
||||||
|
- `LabelInput` / `LabelView` = `{ lang, label }`.
|
||||||
|
|
||||||
|
(Existing hooks from M2: `useTerms(vocabularyId)`, `useAuthorities(kind)`.)
|
||||||
|
|
||||||
|
## Scope (YAGNI)
|
||||||
|
|
||||||
|
**In:** Vocabularies screen (list + create vocabulary; per-vocab terms list + add term);
|
||||||
|
Authorities screen (kind-tabbed list + create authority); shared `LabelEditor` (sv/en);
|
||||||
|
4 new hooks; the two nav stubs enabled; client validation; list invalidation on create.
|
||||||
|
|
||||||
|
**Out:** update/delete of vocab/term/authority (no backend endpoints — later milestone);
|
||||||
|
audit of vocab/authority creation (backend follow-up #21); searchable pickers (#27);
|
||||||
|
search UI (M5); per-language beyond sv/en.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Routes & navigation
|
||||||
|
|
||||||
|
Enable the `Vocabularies` and `Authorities` nav items in `app-shell.tsx` (currently
|
||||||
|
disabled buttons → active `NavLink`s). Routes under the protected `AppShell`, two-pane
|
||||||
|
via nested `<Outlet/>` like Objects:
|
||||||
|
|
||||||
|
```
|
||||||
|
/vocabularies → VocabulariesPage (list + create left; <Outlet/> right)
|
||||||
|
index → "select a vocabulary" prompt
|
||||||
|
:id → VocabularyTerms (the vocab's terms + add-term form)
|
||||||
|
/authorities → redirect to /authorities/person
|
||||||
|
/authorities/:kind → AuthoritiesPage (kind tabs + list + create), kind ∈ person|organisation|place
|
||||||
|
```
|
||||||
|
|
||||||
|
`/authorities/:kind` validates the kind param (unknown → redirect to `person`).
|
||||||
|
|
||||||
|
### Components / files
|
||||||
|
|
||||||
|
```
|
||||||
|
web/src/vocab/
|
||||||
|
vocabularies-page.tsx two-pane: VocabularyList (+ create) left, <Outlet/> right
|
||||||
|
vocabulary-list.tsx useVocabularies list + NewVocabularyForm
|
||||||
|
vocabulary-terms.tsx (:id) useTerms list + AddTermForm
|
||||||
|
web/src/authorities/
|
||||||
|
authorities-page.tsx kind tabs + AuthorityList(kind) + NewAuthorityForm(kind)
|
||||||
|
web/src/components/
|
||||||
|
label-editor.tsx shared sv/en label editor (RHF-controlled), -> LabelInput[]
|
||||||
|
web/src/api/queries.ts + useVocabularies, useCreateVocabulary, useAddTerm, useCreateAuthority
|
||||||
|
web/src/app.tsx + the routes above
|
||||||
|
web/src/shell/app-shell.tsx enable the Vocabularies + Authorities nav links
|
||||||
|
web/src/i18n/{en,sv}.json + vocab.* / authorities.* keys
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep each page focused; the create forms (`NewVocabularyForm`, `AddTermForm`,
|
||||||
|
`NewAuthorityForm`) are small and may live in their page files or as siblings — the
|
||||||
|
shared piece that must be its own unit is `LabelEditor`.
|
||||||
|
|
||||||
|
### `LabelEditor`
|
||||||
|
|
||||||
|
A controlled editor rendering an **English** input and a **Swedish** input. Given/produces
|
||||||
|
`LabelInput[]` (`{ lang, label }`). On change it emits the array with only the non-empty
|
||||||
|
langs (so an empty SV is omitted). Used by `AddTermForm` and `NewAuthorityForm`.
|
||||||
|
Validation: the EN label is required (the parent form wires `required` on the EN field);
|
||||||
|
SV optional. (Mirrors M2's `localized_text` handling and the existing detail/edit label
|
||||||
|
rendering.)
|
||||||
|
|
||||||
|
### Data layer (new hooks in `queries.ts`)
|
||||||
|
|
||||||
|
- `useVocabularies()` → `GET /api/admin/vocabularies` → `VocabularyView[]`.
|
||||||
|
- `useCreateVocabulary()` → `POST /api/admin/vocabularies` `{ key }`; invalidate
|
||||||
|
`["vocabularies"]`.
|
||||||
|
- `useAddTerm()` → `POST /api/admin/vocabularies/{id}/terms` `{ external_uri?, labels }`;
|
||||||
|
invalidate `["terms", vocabularyId]`.
|
||||||
|
- `useCreateAuthority()` → `POST /api/admin/authorities` `{ kind, external_uri?, labels }`;
|
||||||
|
invalidate `["authorities", kind]`.
|
||||||
|
|
||||||
|
(`useTerms`/`useAuthorities` already use keys `["terms", vocabularyId]` /
|
||||||
|
`["authorities", kind]`; the mutations invalidate those exact keys.)
|
||||||
|
|
||||||
|
### Data flow
|
||||||
|
|
||||||
|
- **Create vocabulary:** form (`key`) → `useCreateVocabulary` → invalidate list; clear form.
|
||||||
|
- **Add term:** form (sv/en labels + optional uri) on `/vocabularies/:id` →
|
||||||
|
`useAddTerm({ id, labels, external_uri })` → invalidate `["terms", id]`; clear form.
|
||||||
|
- **Create authority:** form on `/authorities/:kind` (labels + optional uri) →
|
||||||
|
`useCreateAuthority({ kind, labels, external_uri })` → invalidate `["authorities", kind]`;
|
||||||
|
clear form.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
Create failures → a form-level error (reuse `form.rejected`). Lists show loading /
|
||||||
|
empty / error states (reuse the M1 list-state patterns). Required validation (vocab key;
|
||||||
|
EN label) blocks submit with inline messages. Unknown authority kind in the route →
|
||||||
|
redirect to `person`.
|
||||||
|
|
||||||
|
## Testing (Vitest + RTL + MSW)
|
||||||
|
|
||||||
|
- `LabelEditor` — entering EN+SV produces `[{lang:"en",...},{lang:"sv",...}]`; empty SV
|
||||||
|
omitted.
|
||||||
|
- Vocabularies: list renders; create a vocabulary → POST `{key}` (assert body) → list
|
||||||
|
invalidated/refetched shows it; selecting a vocab shows its terms; add a term →
|
||||||
|
POST with the labels body (assert) → terms refetch.
|
||||||
|
- Authorities: kind tabs switch the list (`?kind=`); create an authority for the active
|
||||||
|
kind → POST `{kind, labels}` (assert) → list refetch; required EN label blocks submit.
|
||||||
|
- Nav: the Vocabularies + Authorities nav items are enabled links (not disabled).
|
||||||
|
- New MSW handlers: `POST /api/admin/vocabularies`, `POST /api/admin/vocabularies/:id/terms`,
|
||||||
|
`POST /api/admin/authorities` (the GET handlers + the existing `?kind=` filter handler
|
||||||
|
are already present from M2).
|
||||||
|
|
||||||
|
## Acceptance criteria (Milestone 4 "done")
|
||||||
|
|
||||||
|
1. The Vocabularies and Authorities nav items are enabled and route to their screens.
|
||||||
|
2. A vocabulary can be created (key) and appears in the list; selecting it shows its
|
||||||
|
terms; a term can be added with sv/en labels (+ optional URI) and appears.
|
||||||
|
3. Authorities can be filtered by kind via tabs; an authority can be created for the
|
||||||
|
active kind with sv/en labels and appears in that kind's list.
|
||||||
|
4. The shared `LabelEditor` produces `LabelInput[]` with only non-empty langs; EN is
|
||||||
|
required.
|
||||||
|
5. Create failures surface a form-level error; lists have loading/empty/error states.
|
||||||
|
6. Web CI green (typecheck, lint, tests, build, bundle ≤150 KB gz).
|
||||||
|
|
||||||
|
## Out of scope / follow-ups
|
||||||
|
|
||||||
|
- Edit/delete of vocabularies, terms, authorities — needs backend endpoints first
|
||||||
|
(file a backend follow-up when this milestone lands).
|
||||||
|
- Audit of vocab/term/authority creation (#21).
|
||||||
|
- Searchable pickers / large-vocabulary handling (#27).
|
||||||
|
- Arbitrary (non sv/en) label languages.
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
# Frontend SPA — Milestone 5 (Search) — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-04
|
||||||
|
**Status:** Approved (brainstorming) — ready for implementation planning.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Milestones 1–4 (merged to `main` at `18a19ee`) delivered the SPA foundation, object
|
||||||
|
authoring, the publishing workflow, and vocabulary/authority management. The app shell's
|
||||||
|
nav still has two disabled stubs: **Search** and **Fields**. Milestone 5 enables Search.
|
||||||
|
|
||||||
|
Unlike M2–M4 — which were pure-frontend because the admin endpoints already existed —
|
||||||
|
search has **no HTTP endpoint yet**. The `search` crate holds the *capability*
|
||||||
|
(`SearchClient::search(query) -> Vec<ObjectId>` against Meilisearch) and on-write index
|
||||||
|
sync is wired at the API layer, but no route exposes querying, and the current method
|
||||||
|
discards everything but the object id. **M5 is therefore one combined vertical slice:**
|
||||||
|
a backend search endpoint plus the frontend search UI, in a single spec/plan.
|
||||||
|
|
||||||
|
Milestone roadmap: M1 foundation → M2 authoring → M3 publish → M4 vocab/authority →
|
||||||
|
**M5 search (this)**. After M5, only the **Fields** nav stub remains disabled.
|
||||||
|
|
||||||
|
## Decisions (settled during brainstorming)
|
||||||
|
|
||||||
|
- **One combined milestone** — backend search endpoint + frontend UI together (the
|
||||||
|
frontend is meaningless without the contract, and the backend piece is small).
|
||||||
|
- **Dedicated `/search` route, two-pane** — query + visibility filter + paginated results
|
||||||
|
on the left, the selected object's full detail on the right; mirrors the Objects
|
||||||
|
master–detail layout. The **⌘K global omnibox is deferred** to a follow-up.
|
||||||
|
- **Debounced search-as-you-type** (~300 ms); `q` + `visibility` synced to the URL
|
||||||
|
(`replace`) so searches are bookmarkable/shareable.
|
||||||
|
- **Visibility filter** (All / Draft / Internal / Public) — `visibility` is already a
|
||||||
|
filterable attribute on the index, and complements the M3 publish workflow.
|
||||||
|
- **Index-backed hits** — the endpoint returns hit metadata + a highlighted snippet
|
||||||
|
straight from Meilisearch (no per-hit Postgres round trip / no N+1). The detail pane
|
||||||
|
fetches the full, authoritative record on click. List rows are thus eventually
|
||||||
|
consistent with the DB (acceptable); the detail pane is always fresh.
|
||||||
|
- **"Load more" pagination** (`useInfiniteQuery`), 20 per page, estimated total shown.
|
||||||
|
- **Rich result rows** — bold object name; a meta line with object number + a visibility
|
||||||
|
badge; a two-line highlighted snippet.
|
||||||
|
|
||||||
|
## Backend contract (to build)
|
||||||
|
|
||||||
|
### `search` crate
|
||||||
|
|
||||||
|
- New serializable types:
|
||||||
|
- `SearchHit { id: String, object_number: String, object_name: String,
|
||||||
|
brief_description: Option<String>, visibility: String, snippet: Option<String> }`
|
||||||
|
- `SearchResults { hits: Vec<SearchHit>, estimated_total: usize }`
|
||||||
|
- New method
|
||||||
|
`SearchClient::search_objects(query: &str, visibility: Option<&str>, offset: usize, limit: usize) -> Result<SearchResults, SearchError>`:
|
||||||
|
- Meili query: `with_query(query)`, `with_offset(offset)`, `with_limit(limit)`;
|
||||||
|
`with_filter("visibility = <v>")` only when `visibility` is `Some`;
|
||||||
|
`attributes_to_highlight` + `attributes_to_crop` (with `crop_length`) on
|
||||||
|
`object_name`, `brief_description`, `fields_text`.
|
||||||
|
- Reads `estimated_total_hits` (Meili `estimatedTotalHits`) into `estimated_total`.
|
||||||
|
- Builds `snippet` from the best `_formatted` field that actually contains a
|
||||||
|
highlight marker (prefer `brief_description`, then a matching `fields_text` entry,
|
||||||
|
then `object_name`); `None` if no match context.
|
||||||
|
- **XSS-safe highlighting:** Meili is configured with **non-HTML sentinel highlight tags**
|
||||||
|
— `highlight_pre_tag = "\u{2}"`, `highlight_post_tag = "\u{3}"` (control chars that
|
||||||
|
cannot occur in catalogue text). The snippet is returned as a plain string carrying
|
||||||
|
these sentinels; the frontend splits on them to render `<mark>`. **No HTML crosses the
|
||||||
|
API boundary**, so no `dangerouslySetInnerHTML` is ever needed.
|
||||||
|
- The existing thin `search(&self, query) -> Vec<ObjectId>` is checked for references
|
||||||
|
(insikt `find_references`): if unused, replace it with `search_objects`; if used
|
||||||
|
(e.g. a test or CLI), keep it and add `search_objects` alongside. `sync_object` /
|
||||||
|
`reindex_all` / `index_object` / `remove_object` are unchanged.
|
||||||
|
|
||||||
|
### `api` crate
|
||||||
|
|
||||||
|
- New handler module `crates/api/src/admin_search.rs`:
|
||||||
|
`GET /api/admin/search?q=&visibility=&offset=&limit=`, **auth-required** via the
|
||||||
|
`AuthUser` extractor (same as other admin routes).
|
||||||
|
- `q`: trimmed. Empty `q` → return `SearchResults { hits: [], estimated_total: 0 }`
|
||||||
|
**without** calling Meili.
|
||||||
|
- `visibility`: optional; validated against `draft|internal|public` (reuse the domain
|
||||||
|
`Visibility` parse). Invalid value → `400`.
|
||||||
|
- `offset`: default 0, `≥ 0`; `limit`: default 20, **max 50** (reuse `pagination.rs`
|
||||||
|
clamping helpers).
|
||||||
|
- Search not configured (`AppState.search == None`) → **`503 Service Unavailable`**.
|
||||||
|
- Meili error → `500` (logged via tracing, consistent with the on-write sync logging).
|
||||||
|
- Returns `200 SearchResults`.
|
||||||
|
- utoipa-annotated (`#[utoipa::path(...)]`), route registered in `admin` router and
|
||||||
|
schema registered in `crates/api/src/openapi.rs` (add `SearchHit`, `SearchResults`).
|
||||||
|
|
||||||
|
### OpenAPI / typed client
|
||||||
|
|
||||||
|
- Regenerate `web/src/api/schema.d.ts` (openapi-typescript) so the typed client gains the
|
||||||
|
`/api/admin/search` path and the `SearchHit` / `SearchResults` component schemas.
|
||||||
|
|
||||||
|
## Frontend architecture
|
||||||
|
|
||||||
|
### Routes & navigation
|
||||||
|
|
||||||
|
```
|
||||||
|
/search → SearchPage (SearchPanel left, <Outlet/> right)
|
||||||
|
index → SelectSearchPrompt ("Select a result")
|
||||||
|
:id → ObjectDetail (reused unchanged from web/src/objects/)
|
||||||
|
```
|
||||||
|
|
||||||
|
Added under the protected `AppShell` group in `web/src/app.tsx`. In
|
||||||
|
`web/src/shell/app-shell.tsx`, **Search** becomes an active `NavLink` to `/search`;
|
||||||
|
`DISABLED_NAV` shrinks to `["fields"]`.
|
||||||
|
|
||||||
|
`ObjectDetail` is reused as-is: it reads `useParams().id`, fetches via `useObject(id)`,
|
||||||
|
and its edit link is already absolute (`/objects/:id/edit`), so editing from a search
|
||||||
|
result navigates into the Objects edit flow correctly.
|
||||||
|
|
||||||
|
### Components / files
|
||||||
|
|
||||||
|
```
|
||||||
|
web/src/search/
|
||||||
|
search-page.tsx two-pane grid (grid-cols-[20rem_1fr]); SearchPanel + <Outlet/>
|
||||||
|
search-panel.tsx debounced query <Input>; visibility pills; result count;
|
||||||
|
results list; "Load more"; loading/empty/error states
|
||||||
|
search-result-row.tsx rich row → NavLink to /search/:id (active highlight)
|
||||||
|
highlight.tsx <Highlight text> — splits on the sentinel chars, renders
|
||||||
|
plain segments as text and matched segments as <mark>
|
||||||
|
select-search-prompt.tsx idle detail-pane prompt
|
||||||
|
web/src/lib/use-debounced-value.ts generic useDebouncedValue<T>(value, delayMs)
|
||||||
|
web/src/api/queries.ts + useSearch(q, visibility)
|
||||||
|
web/src/app.tsx + the /search nested route
|
||||||
|
web/src/shell/app-shell.tsx enable Search NavLink; DISABLED_NAV = ["fields"]
|
||||||
|
web/src/i18n/{en,sv}.json + search.* namespace
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data layer
|
||||||
|
|
||||||
|
- `useSearch(q: string, visibility: string | null)` — `useInfiniteQuery`:
|
||||||
|
- `queryKey: ["search", q, visibility]`
|
||||||
|
- `enabled: q.trim().length > 0`
|
||||||
|
- `queryFn({ pageParam = 0 })` → `GET /api/admin/search?q=&visibility=&offset=pageParam&limit=20`
|
||||||
|
- `initialPageParam: 0`
|
||||||
|
- `getNextPageParam(lastPage, allPages)` → `loaded = allPages.flatMap(p => p.hits).length`;
|
||||||
|
return `loaded < lastPage.estimated_total ? loaded : undefined`.
|
||||||
|
- Throws on non-200 (a `503`/`500` surfaces as `isError` → `search.loadError`).
|
||||||
|
|
||||||
|
### URL state
|
||||||
|
|
||||||
|
`search-panel.tsx` owns a controlled input string and the active visibility. A
|
||||||
|
`useDebouncedValue` of the input (300 ms) drives both `useSearch` and a
|
||||||
|
`useSearchParams` write (`setSearchParams(..., { replace: true })`) for `q`; the
|
||||||
|
visibility pill writes `visibility` immediately. Initial state hydrates from the URL on
|
||||||
|
mount so `/search?q=bronze&visibility=draft` loads pre-populated.
|
||||||
|
|
||||||
|
### Highlight rendering (`highlight.tsx`)
|
||||||
|
|
||||||
|
`<Highlight text={snippet} />` splits `text` on the sentinel pair (`\u{2}`…`\u{3}`) and
|
||||||
|
maps segments to React nodes — plain strings for unmatched text, `<mark>` for matched
|
||||||
|
spans. Pure string handling; no HTML injection.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
Type → `useDebouncedValue` (300 ms) → `useSearch(["search", q, visibility])` →
|
||||||
|
`GET /api/admin/search` → render rich rows from the index payload (no per-hit DB call) →
|
||||||
|
"Load more" fetches next offset and appends → click a row → `/search/:id` →
|
||||||
|
`ObjectDetail` fetches the full fresh record via `useObject`.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- Empty `q` → idle prompt (`search.prompt`, "Type to search"); no request fired.
|
||||||
|
- In-flight → loading indicator (skeleton rows, consistent with the Objects list).
|
||||||
|
- Zero hits → `search.empty` ("No results").
|
||||||
|
- Query error or `503` → `search.loadError` ("Search is unavailable") in the results pane.
|
||||||
|
- Detail pane retains `ObjectDetail`'s own loading/error/empty behavior.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- `search` crate test against the `cms-test-meili` container (host port 7701,
|
||||||
|
`MEILI_MASTER_KEY=masterKey`): seed a few documents, assert `search_objects` returns
|
||||||
|
matching hits with a non-empty `snippet` carrying sentinels, that the `visibility`
|
||||||
|
filter narrows results, that `offset`/`limit` page correctly, and that
|
||||||
|
`estimated_total` is populated.
|
||||||
|
- `api` handler tests: unauthenticated → `401`; valid query → `200` with results; invalid
|
||||||
|
`visibility` → `400`; `limit` clamped to 50; search-disabled state → `503`.
|
||||||
|
|
||||||
|
### Frontend (Vitest + RTL + MSW, `onUnhandledRequest: "error"`)
|
||||||
|
- MSW handler for `GET /api/admin/search` returning a paged `SearchResults` fixture
|
||||||
|
(hits with a sentinel-marked snippet; an `estimated_total` larger than one page).
|
||||||
|
- Tests: debounced typing issues a request with `?q=`; a visibility pill click changes
|
||||||
|
the `visibility` param and refetches; rows render the object name, object number, a
|
||||||
|
visibility badge, and a `<mark>` from the snippet; "Load more" appends the next page and
|
||||||
|
hides when exhausted; empty-query idle prompt, zero-results, loading, and error/`503`
|
||||||
|
states; clicking a hit navigates to `/search/:id`; the Search nav item is an enabled
|
||||||
|
link while `fields` stays disabled.
|
||||||
|
|
||||||
|
### Project constraints
|
||||||
|
- en/sv i18n key parity (reuse existing visibility labels where present).
|
||||||
|
- No `any` / `eslint-disable` / `@ts-ignore`. Codename ban (no "biggus"/"dickus").
|
||||||
|
- Bundle ≤150 KB gz. Current headroom is ~7 KB; if `/search` pushes the main chunk over,
|
||||||
|
lazy-load the route with `React.lazy` + `Suspense` (as M2 did for the object forms) and
|
||||||
|
re-verify.
|
||||||
|
|
||||||
|
## Acceptance criteria (Milestone 5 "done")
|
||||||
|
|
||||||
|
1. `GET /api/admin/search` returns index-backed `SearchResults` (hits + `estimated_total`),
|
||||||
|
supports `q`/`visibility`/`offset`/`limit`, is auth-required, returns `503` when search
|
||||||
|
is not configured, and emits XSS-safe (sentinel, non-HTML) highlight snippets.
|
||||||
|
2. The Search nav item is enabled and routes to `/search`; debounced typing shows rich
|
||||||
|
result rows with a highlighted snippet, the object number, and a visibility badge.
|
||||||
|
3. The visibility filter narrows results; the URL reflects `q` + `visibility` and is
|
||||||
|
shareable/bookmarkable.
|
||||||
|
4. "Load more" appends the next page; the estimated total is shown.
|
||||||
|
5. Clicking a result shows the full, fresh object in the detail pane.
|
||||||
|
6. Web + backend CI green (cargo test; web typecheck, lint, tests, build, bundle ≤150 KB
|
||||||
|
gz); en/sv parity.
|
||||||
|
|
||||||
|
## Out of scope / follow-ups
|
||||||
|
|
||||||
|
- **⌘K global omnibox / command palette** — file a frontend follow-up when M5 lands.
|
||||||
|
- Richer faceting (object name, owner, has-images, date ranges) and Meili facet
|
||||||
|
distribution counts.
|
||||||
|
- A public-facing search endpoint (`/api/public/search`) for an eventual public site.
|
||||||
|
- Search analytics / query logging.
|
||||||
|
- Relevance tuning (ranking rules, synonyms, typo tolerance configuration).
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
"openapi-fetch": "^0.17.0",
|
"openapi-fetch": "^0.17.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-hook-form": "^7.77.0",
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.8",
|
||||||
"react-router-dom": "^7.16.0",
|
"react-router-dom": "^7.16.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
|
|||||||
Generated
+13
@@ -38,6 +38,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.1.0
|
specifier: ^19.1.0
|
||||||
version: 19.2.7(react@19.2.7)
|
version: 19.2.7(react@19.2.7)
|
||||||
|
react-hook-form:
|
||||||
|
specifier: ^7.77.0
|
||||||
|
version: 7.77.0(react@19.2.7)
|
||||||
react-i18next:
|
react-i18next:
|
||||||
specifier: ^17.0.8
|
specifier: ^17.0.8
|
||||||
version: 17.0.8(i18next@26.3.0(typescript@5.8.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.8.3)
|
version: 17.0.8(i18next@26.3.0(typescript@5.8.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.8.3)
|
||||||
@@ -2420,6 +2423,12 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.7
|
react: ^19.2.7
|
||||||
|
|
||||||
|
react-hook-form@7.77.0:
|
||||||
|
resolution: {integrity: sha512-Sslh9YDYc0GDlWT/lxasnIduNo4v3yyvqRGvmGKUre5AFjDs/HV9/OafHGD8d+sB2yoL4UIL9L8X9i0WlZZebg==}
|
||||||
|
engines: {node: '>=18.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||||
|
|
||||||
react-i18next@17.0.8:
|
react-i18next@17.0.8:
|
||||||
resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==}
|
resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -5200,6 +5209,10 @@ snapshots:
|
|||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
scheduler: 0.27.0
|
scheduler: 0.27.0
|
||||||
|
|
||||||
|
react-hook-form@7.77.0(react@19.2.7):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.7
|
||||||
|
|
||||||
react-i18next@17.0.8(i18next@26.3.0(typescript@5.8.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.8.3):
|
react-i18next@17.0.8(i18next@26.3.0(typescript@5.8.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.8.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.29.7
|
'@babel/runtime': 7.29.7
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useAuthorities,
|
||||||
|
useCreateObject,
|
||||||
|
useDeleteObject,
|
||||||
|
useSetFields,
|
||||||
|
useTerms,
|
||||||
|
useUpdateObject,
|
||||||
|
} from "./queries";
|
||||||
|
|
||||||
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
|
||||||
|
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("authoring hooks", () => {
|
||||||
|
test("useTerms loads a vocabulary's terms", async () => {
|
||||||
|
const { result } = renderHook(() => useTerms("v-material"), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.data).toBeDefined());
|
||||||
|
expect(result.current.data?.[0].id).toBe("t-bronze");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("useAuthorities loads by kind", async () => {
|
||||||
|
const { result } = renderHook(() => useAuthorities("person"), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.data?.length).toBe(1));
|
||||||
|
expect(result.current.data?.[0].id).toBe("a-ada");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("useCreateObject returns the new id", async () => {
|
||||||
|
const { result } = renderHook(() => useCreateObject(), { wrapper });
|
||||||
|
|
||||||
|
const created = await result.current.mutateAsync({
|
||||||
|
object_number: "A-1",
|
||||||
|
object_name: "x",
|
||||||
|
number_of_objects: 1,
|
||||||
|
visibility: "draft",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(created.id).toBe("11111111-1111-1111-1111-111111111111");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("useSetFields / useUpdateObject / useDeleteObject resolve", async () => {
|
||||||
|
const setFields = renderHook(() => useSetFields(), { wrapper });
|
||||||
|
|
||||||
|
await setFields.result.current.mutateAsync({ id: "o1", fields: { inscription: "hi" } });
|
||||||
|
|
||||||
|
const update = renderHook(() => useUpdateObject(), { wrapper });
|
||||||
|
|
||||||
|
await update.result.current.mutateAsync({
|
||||||
|
id: "o1",
|
||||||
|
body: { object_number: "A-1", object_name: "x", number_of_objects: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const del = renderHook(() => useDeleteObject(), { wrapper });
|
||||||
|
|
||||||
|
await del.result.current.mutateAsync("o1");
|
||||||
|
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
+249
-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";
|
||||||
@@ -95,3 +95,251 @@ export function useLogout() {
|
|||||||
onSuccess: () => qc.setQueryData(["me"], null),
|
onSuccess: () => qc.setQueryData(["me"], null),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ObjectCreateRequest = components["schemas"]["ObjectCreateRequest"];
|
||||||
|
type ObjectUpdateRequest = components["schemas"]["ObjectUpdateRequest"];
|
||||||
|
|
||||||
|
export function useTerms(vocabularyId: string | null | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["terms", vocabularyId],
|
||||||
|
enabled: !!vocabularyId,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await api.GET("/api/admin/vocabularies/{id}/terms", {
|
||||||
|
params: { path: { id: vocabularyId! } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error || !data) throw new Error("failed to load terms");
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuthorities(kind: string | null | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["authorities", kind],
|
||||||
|
enabled: !!kind,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await api.GET("/api/admin/authorities", {
|
||||||
|
params: { query: { kind: kind! } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error || !data) throw new Error("failed to load authorities");
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateObject() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: ObjectCreateRequest) => {
|
||||||
|
const { data, error } = await api.POST("/api/admin/objects", { body });
|
||||||
|
|
||||||
|
if (error || !data) throw new Error("create failed");
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["objects"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateObject() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, body }: { id: string; body: ObjectUpdateRequest }) => {
|
||||||
|
const { response } = await api.PUT("/api/admin/objects/{id}", {
|
||||||
|
params: { path: { id } },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status !== 204) throw new Error("update failed");
|
||||||
|
},
|
||||||
|
onSuccess: (_d, { id }) => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ["objects"] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ["object", id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetFields() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, fields }: { id: string; fields: Record<string, unknown> }) => {
|
||||||
|
const { response } = await api.PUT("/api/admin/objects/{id}/fields", {
|
||||||
|
params: { path: { id } },
|
||||||
|
body: fields as Record<string, never>,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status !== 204) throw new Error("set fields failed");
|
||||||
|
},
|
||||||
|
onSuccess: (_d, { id }) => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ["object", id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteObject() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
const { response } = await api.DELETE("/api/admin/objects/{id}", {
|
||||||
|
params: { path: { id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status !== 204) throw new Error("delete failed");
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["objects"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type NewVocabularyRequest = components["schemas"]["NewVocabularyRequest"];
|
||||||
|
type LabelInput = components["schemas"]["LabelInput"];
|
||||||
|
|
||||||
|
export function useVocabularies() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["vocabularies"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, error } = await api.GET("/api/admin/vocabularies");
|
||||||
|
|
||||||
|
if (error || !data) throw new Error("failed to load vocabularies");
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateVocabulary() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: NewVocabularyRequest) => {
|
||||||
|
const { data, error } = await api.POST("/api/admin/vocabularies", { body });
|
||||||
|
|
||||||
|
if (error || !data) throw new Error("create vocabulary failed");
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddTerm() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
vocabularyId,
|
||||||
|
external_uri,
|
||||||
|
labels,
|
||||||
|
}: {
|
||||||
|
vocabularyId: string;
|
||||||
|
external_uri: string | null;
|
||||||
|
labels: LabelInput[];
|
||||||
|
}) => {
|
||||||
|
const { response } = await api.POST("/api/admin/vocabularies/{id}/terms", {
|
||||||
|
params: { path: { id: vocabularyId } },
|
||||||
|
body: { external_uri, labels },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status !== 201) throw new Error("add term failed");
|
||||||
|
},
|
||||||
|
onSuccess: (_result, { vocabularyId }) =>
|
||||||
|
qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateAuthority() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
kind,
|
||||||
|
external_uri,
|
||||||
|
labels,
|
||||||
|
}: {
|
||||||
|
kind: string;
|
||||||
|
external_uri: string | null;
|
||||||
|
labels: LabelInput[];
|
||||||
|
}) => {
|
||||||
|
const { response } = await api.POST("/api/admin/authorities", {
|
||||||
|
body: { kind, external_uri, labels },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status !== 201) throw new Error("create authority failed");
|
||||||
|
},
|
||||||
|
onSuccess: (_result, { kind }) =>
|
||||||
|
qc.invalidateQueries({ queryKey: ["authorities", kind] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
/** Error carrying the HTTP status so callers can branch 422-gate vs 409-illegal. */
|
||||||
|
export class VisibilityError extends Error {
|
||||||
|
constructor(public status: number) {
|
||||||
|
super(`visibility change failed (${status})`);
|
||||||
|
this.name = "VisibilityError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetVisibility() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, visibility }: { id: string; visibility: Visibility }) => {
|
||||||
|
const { response } = await api.POST("/api/admin/objects/{id}/visibility", {
|
||||||
|
params: { path: { id } },
|
||||||
|
body: { visibility },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status !== 204) throw new VisibilityError(response.status);
|
||||||
|
},
|
||||||
|
onSuccess: (_result, { id }) => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ["object", id] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ["objects"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { http, HttpResponse } from "msw";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { server } from "../test/server";
|
||||||
|
import { useSetVisibility } from "./queries";
|
||||||
|
|
||||||
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useSetVisibility", () => {
|
||||||
|
test("POSTs the target visibility and resolves on 204", async () => {
|
||||||
|
let body: unknown;
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/objects/:id/visibility", async ({ request }) => {
|
||||||
|
body = await request.json();
|
||||||
|
return new HttpResponse(null, { status: 204 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useSetVisibility(), { wrapper });
|
||||||
|
await result.current.mutateAsync({ id: "o1", visibility: "internal" });
|
||||||
|
expect((body as { visibility: string }).visibility).toBe("internal");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws a status-carrying error on 422 (publish gate)", async () => {
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/objects/:id/visibility", () => new HttpResponse(null, { status: 422 })),
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useSetVisibility(), { wrapper });
|
||||||
|
await expect(
|
||||||
|
result.current.mutateAsync({ id: "o1", visibility: "public" }),
|
||||||
|
).rejects.toMatchObject({ status: 422 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { http, HttpResponse } from "msw";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { server } from "../test/server";
|
||||||
|
import { useVocabularies, useCreateVocabulary, useAddTerm, useCreateAuthority } from "./queries";
|
||||||
|
|
||||||
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("vocab/authority hooks", () => {
|
||||||
|
test("useVocabularies lists vocabularies", async () => {
|
||||||
|
const { result } = renderHook(() => useVocabularies(), { wrapper });
|
||||||
|
await waitFor(() => expect(result.current.data?.length).toBe(2));
|
||||||
|
expect(result.current.data?.[0].key).toBe("material");
|
||||||
|
});
|
||||||
|
test("useCreateVocabulary POSTs the key", async () => {
|
||||||
|
let body: unknown;
|
||||||
|
server.use(http.post("/api/admin/vocabularies", async ({ request }) => {
|
||||||
|
body = await request.json();
|
||||||
|
return HttpResponse.json({ id: "v-x", key: "colour" }, { status: 201 });
|
||||||
|
}));
|
||||||
|
const { result } = renderHook(() => useCreateVocabulary(), { wrapper });
|
||||||
|
await result.current.mutateAsync({ key: "colour" });
|
||||||
|
expect((body as { key: string }).key).toBe("colour");
|
||||||
|
});
|
||||||
|
test("useAddTerm POSTs labels to the vocabulary", async () => {
|
||||||
|
let body: unknown;
|
||||||
|
server.use(http.post("/api/admin/vocabularies/:id/terms", async ({ request }) => {
|
||||||
|
body = await request.json();
|
||||||
|
return HttpResponse.json({ id: "t-x" }, { status: 201 });
|
||||||
|
}));
|
||||||
|
const { result } = renderHook(() => useAddTerm(), { wrapper });
|
||||||
|
await result.current.mutateAsync({ vocabularyId: "v-material", external_uri: null, labels: [{ lang: "en", label: "Red" }] });
|
||||||
|
expect((body as { labels: { label: string }[] }).labels[0].label).toBe("Red");
|
||||||
|
});
|
||||||
|
test("useCreateAuthority POSTs kind + labels", async () => {
|
||||||
|
let body: unknown;
|
||||||
|
server.use(http.post("/api/admin/authorities", async ({ request }) => {
|
||||||
|
body = await request.json();
|
||||||
|
return HttpResponse.json({ id: "a-x" }, { status: 201 });
|
||||||
|
}));
|
||||||
|
const { result } = renderHook(() => useCreateAuthority(), { wrapper });
|
||||||
|
await result.current.mutateAsync({ kind: "person", external_uri: null, labels: [{ lang: "en", label: "Ada" }] });
|
||||||
|
expect((body as { kind: string }).kind).toBe("person");
|
||||||
|
});
|
||||||
|
});
|
||||||
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;
|
||||||
|
|||||||
+51
-2
@@ -1,9 +1,30 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
|
||||||
import { RequireAuth } from "./auth/require-auth";
|
import { RequireAuth } from "./auth/require-auth";
|
||||||
import { LoginPage } from "./auth/login-page";
|
import { LoginPage } from "./auth/login-page";
|
||||||
import { AppShell } from "./shell/app-shell";
|
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 { 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";
|
||||||
|
import { AuthoritiesPage } from "./authorities/authorities-page";
|
||||||
|
|
||||||
|
const ObjectNewPage = lazy(() =>
|
||||||
|
import("./objects/object-new-page").then((m) => ({ default: m.ObjectNewPage })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const ObjectEditForm = lazy(() =>
|
||||||
|
import("./objects/object-edit-form").then((m) => ({ default: m.ObjectEditForm })),
|
||||||
|
);
|
||||||
|
|
||||||
|
function FormFallback() {
|
||||||
|
return <div role="status" className="p-4 text-sm text-neutral-400">Loading…</div>;
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
@@ -12,8 +33,36 @@ export function App() {
|
|||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route element={<RequireAuth />}>
|
<Route element={<RequireAuth />}>
|
||||||
<Route element={<AppShell />}>
|
<Route element={<AppShell />}>
|
||||||
<Route path="/objects" element={<ObjectsPage />} />
|
<Route
|
||||||
<Route path="/objects/:id" element={<ObjectsPage />} />
|
path="/objects/new"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<FormFallback />}>
|
||||||
|
<ObjectNewPage />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/objects" element={<ObjectsPage />}>
|
||||||
|
<Route index element={<SelectPrompt />} />
|
||||||
|
<Route path=":id" element={<ObjectDetail />} />
|
||||||
|
<Route
|
||||||
|
path=":id/edit"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<FormFallback />}>
|
||||||
|
<ObjectEditForm />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route path="/vocabularies" element={<VocabulariesPage />}>
|
||||||
|
<Route index element={<SelectVocabularyPrompt />} />
|
||||||
|
<Route path=":id" element={<VocabularyTerms />} />
|
||||||
|
</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/:kind" element={<AuthoritiesPage />} />
|
||||||
<Route path="/" element={<Navigate to="/objects" replace />} />
|
<Route path="/" element={<Navigate to="/objects" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { NavLink, Navigate, useParams } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
import { useAuthorities, useCreateAuthority } from "../api/queries";
|
||||||
|
import { LabelEditor } from "../components/label-editor";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { labelText } from "../lib/labels";
|
||||||
|
|
||||||
|
type LabelInput = components["schemas"]["LabelInput"];
|
||||||
|
|
||||||
|
const KINDS = ["person", "organisation", "place"] as const;
|
||||||
|
|
||||||
|
export function AuthoritiesPage() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const { kind } = useParams();
|
||||||
|
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
|
||||||
|
|
||||||
|
const isValidKind = (KINDS as readonly string[]).includes(kind ?? "");
|
||||||
|
|
||||||
|
const { data: authorities } = useAuthorities(isValidKind ? (kind as string) : "person");
|
||||||
|
const create = useCreateAuthority();
|
||||||
|
|
||||||
|
const [labels, setLabels] = useState<LabelInput[]>([]);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
if (!isValidKind) return <Navigate to="/authorities/person" replace />;
|
||||||
|
|
||||||
|
const onCreate = (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!labels.some((l) => l.lang === "en" && l.label)) {
|
||||||
|
setError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(false);
|
||||||
|
create.mutate(
|
||||||
|
{ kind: kind as string, external_uri: null, labels },
|
||||||
|
{ onSuccess: () => setLabels([]) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto p-4">
|
||||||
|
<div className="mb-3 flex gap-2">
|
||||||
|
{KINDS.map((k) => (
|
||||||
|
<NavLink
|
||||||
|
key={k}
|
||||||
|
to={`/authorities/${k}`}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`rounded px-3 py-1 text-sm ${isActive ? "bg-neutral-800 text-white" : "border"}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(`authorities.${k}`)}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="mb-4">
|
||||||
|
{authorities?.length === 0 && (
|
||||||
|
<li className="text-sm text-neutral-500">{t("authorities.empty")}</li>
|
||||||
|
)}
|
||||||
|
{authorities?.map((a) => (
|
||||||
|
<li key={a.id} className="border-b py-1 text-sm">
|
||||||
|
{labelText(a.labels, lang)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form onSubmit={onCreate} className="space-y-2 border-t pt-3">
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{t("authorities.new")} · {t(`authorities.${kind}`)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LabelEditor value={labels} onChange={setLabels} />
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-xs text-red-600">
|
||||||
|
{t("form.required")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{create.isError && (
|
||||||
|
<p role="alert" className="text-xs text-red-600">
|
||||||
|
{t("form.rejected")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" size="sm" disabled={create.isPending}>
|
||||||
|
{t("authorities.create")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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 { Routes, Route } from "react-router-dom";
|
||||||
|
import { server } from "../test/server";
|
||||||
|
import { renderApp } from "../test/render";
|
||||||
|
import { AuthoritiesPage } from "./authorities-page";
|
||||||
|
|
||||||
|
function tree() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/authorities/:kind" element={<AuthoritiesPage />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("lists authorities for the kind and creates one", async () => {
|
||||||
|
let body: unknown;
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/authorities", async ({ request }) => {
|
||||||
|
body = await request.json();
|
||||||
|
return HttpResponse.json({ id: "a-c" }, { status: 201 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderApp(tree(), { route: "/authorities/person" });
|
||||||
|
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
|
||||||
|
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Carl von Linné");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||||
|
await waitFor(() => expect((body as { kind: string })?.kind).toBe("person"));
|
||||||
|
expect((body as { labels: { label: string }[] }).labels[0].label).toBe("Carl von Linné");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("kind tabs link to the other kinds", async () => {
|
||||||
|
renderApp(tree(), { route: "/authorities/person" });
|
||||||
|
expect(await screen.findByRole("link", { name: /place/i })).toHaveAttribute("href", "/authorities/place");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("create without EN label shows required alert and does not POST", async () => {
|
||||||
|
let posted = false;
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/authorities", () => {
|
||||||
|
posted = true;
|
||||||
|
return HttpResponse.json({ id: "a-x" }, { status: 201 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderApp(tree(), { route: "/authorities/person" });
|
||||||
|
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||||
|
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||||
|
expect(posted).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown kind redirects to person list", async () => {
|
||||||
|
renderApp(tree(), { route: "/authorities/banana" });
|
||||||
|
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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 { LabelEditor } from "./label-editor";
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
|
||||||
|
type LabelInput = components["schemas"]["LabelInput"];
|
||||||
|
|
||||||
|
function Harness({ onChange }: { onChange: (v: LabelInput[]) => void }) {
|
||||||
|
const [value, setValue] = useState<LabelInput[]>([]);
|
||||||
|
return (
|
||||||
|
<LabelEditor
|
||||||
|
value={value}
|
||||||
|
onChange={(v) => {
|
||||||
|
setValue(v);
|
||||||
|
onChange(v);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("typing EN and SV emits both labels; empty langs are omitted", async () => {
|
||||||
|
const seen: LabelInput[][] = [];
|
||||||
|
renderApp(<Harness onChange={(v) => seen.push(v)} />);
|
||||||
|
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Bronze");
|
||||||
|
await userEvent.type(screen.getByLabelText(/label \(sv\)/i), "Brons");
|
||||||
|
const last = seen[seen.length - 1]!;
|
||||||
|
expect(last).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ lang: "en", label: "Bronze" },
|
||||||
|
{ lang: "sv", label: "Brons" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(seen.some((v) => v.length === 1 && v[0].lang === "en")).toBe(true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
type LabelInput = components["schemas"]["LabelInput"];
|
||||||
|
|
||||||
|
/** Controlled sv/en label editor. Emits LabelInput[] with only the non-empty langs. */
|
||||||
|
export function LabelEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: LabelInput[];
|
||||||
|
onChange: (labels: LabelInput[]) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const valueFor = (lang: string) => value.find((l) => l.lang === lang)?.label ?? "";
|
||||||
|
|
||||||
|
const set = (lang: string, label: string) => {
|
||||||
|
const others = value.filter((l) => l.lang !== lang);
|
||||||
|
|
||||||
|
onChange(label.trim() ? [...others, { lang, label }] : others);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="label-en">{t("labels.en")}</Label>
|
||||||
|
<Input
|
||||||
|
id="label-en"
|
||||||
|
value={valueFor("en")}
|
||||||
|
onChange={(e) => set("en", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="label-sv">{t("labels.sv")}</Label>
|
||||||
|
<Input
|
||||||
|
id="label-sv"
|
||||||
|
value={valueFor("sv")}
|
||||||
|
onChange={(e) => set("sv", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Backdrop
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Popup.Props & {
|
||||||
|
size?: "default" | "sm"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Popup
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn(
|
||||||
|
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogMedia({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-media"
|
||||||
|
className={cn(
|
||||||
|
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="alert-dialog-action"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Close.Props &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Close
|
||||||
|
data-slot="alert-dialog-cancel"
|
||||||
|
className={cn(className)}
|
||||||
|
render={<Button variant={variant} size={size} />}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
+39
-2
@@ -2,7 +2,44 @@
|
|||||||
"app": { "name": "Collection" },
|
"app": { "name": "Collection" },
|
||||||
"nav": { "objects": "Objects", "vocabularies": "Vocabularies", "authorities": "Authorities", "fields": "Fields", "search": "Search", "soon": "Coming soon" },
|
"nav": { "objects": "Objects", "vocabularies": "Vocabularies", "authorities": "Authorities", "fields": "Fields", "search": "Search", "soon": "Coming soon" },
|
||||||
"auth": { "email": "Email", "password": "Password", "signIn": "Sign in", "signOut": "Sign out", "invalid": "Invalid email or password", "networkError": "Could not reach the server" },
|
"auth": { "email": "Email", "password": "Password", "signIn": "Sign in", "signOut": "Sign out", "invalid": "Invalid email or password", "networkError": "Could not reach the server" },
|
||||||
"objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "selectPrompt": "Select an object to view its details", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of" },
|
"objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "selectPrompt": "Select an object to view its details", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of", "new": "New object" },
|
||||||
"fieldsLabels": { "objectNumber": "Object number", "objectName": "Name", "count": "Number of objects", "briefDescription": "Brief description", "currentLocation": "Current location", "currentOwner": "Current owner", "recorder": "Recorder", "recordingDate": "Recording date", "visibility": "Visibility", "flexible": "Catalogue fields" },
|
"fieldsLabels": { "objectNumber": "Object number", "objectName": "Name", "count": "Number of objects", "briefDescription": "Brief description", "currentLocation": "Current location", "currentOwner": "Current owner", "recorder": "Recorder", "recordingDate": "Recording date", "visibility": "Visibility", "flexible": "Catalogue fields" },
|
||||||
"visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" }
|
"visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" },
|
||||||
|
"form": { "selectPlaceholder": "— select —", "create": "Create object", "save": "Save", "cancel": "Cancel", "visibility": "Visibility", "draft": "Draft", "internal": "Internal", "required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields", "flexibleHeading": "Catalogue fields" },
|
||||||
|
"actions": { "edit": "Edit", "delete": "Delete", "confirmDelete": "Delete this object? This cannot be undone." },
|
||||||
|
"labels": { "en": "Label (EN)", "sv": "Label (SV)", "externalUri": "External URI (optional)" },
|
||||||
|
"vocab": {
|
||||||
|
"title": "Vocabularies", "newVocabulary": "New vocabulary", "key": "Key",
|
||||||
|
"create": "Create", "selectPrompt": "Select a vocabulary to manage its terms",
|
||||||
|
"terms": "Terms", "addTerm": "Add term", "empty": "No vocabularies yet",
|
||||||
|
"noTerms": "No terms yet", "loadError": "Could not load"
|
||||||
|
},
|
||||||
|
"authorities": {
|
||||||
|
"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_one": "{{count}} result",
|
||||||
|
"resultCount_other": "{{count}} results",
|
||||||
|
"selectPrompt": "Select a result to see the full record"
|
||||||
|
},
|
||||||
|
"publish": {
|
||||||
|
"heading": "Visibility",
|
||||||
|
"advanceInternal": "Advance to internal",
|
||||||
|
"publish": "Publish →",
|
||||||
|
"backToDraft": "← Back to draft",
|
||||||
|
"unpublishInternal": "Unpublish to internal",
|
||||||
|
"confirmTitle": "Publish to public?",
|
||||||
|
"confirmBody": "This will make the record visible on the public API.",
|
||||||
|
"confirm": "Publish",
|
||||||
|
"gateError": "Can't publish — required fields are missing.",
|
||||||
|
"editLink": "Edit the record",
|
||||||
|
"illegalError": "That visibility change isn't allowed."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-2
@@ -2,7 +2,44 @@
|
|||||||
"app": { "name": "Samling" },
|
"app": { "name": "Samling" },
|
||||||
"nav": { "objects": "Föremål", "vocabularies": "Vokabulär", "authorities": "Auktoriteter", "fields": "Fält", "search": "Sök", "soon": "Kommer snart" },
|
"nav": { "objects": "Föremål", "vocabularies": "Vokabulär", "authorities": "Auktoriteter", "fields": "Fält", "search": "Sök", "soon": "Kommer snart" },
|
||||||
"auth": { "email": "E-post", "password": "Lösenord", "signIn": "Logga in", "signOut": "Logga ut", "invalid": "Fel e-post eller lösenord", "networkError": "Kunde inte nå servern" },
|
"auth": { "email": "E-post", "password": "Lösenord", "signIn": "Logga in", "signOut": "Logga ut", "invalid": "Fel e-post eller lösenord", "networkError": "Kunde inte nå servern" },
|
||||||
"objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "selectPrompt": "Välj ett föremål för att se detaljer", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av" },
|
"objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "selectPrompt": "Välj ett föremål för att se detaljer", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av", "new": "Nytt föremål" },
|
||||||
"fieldsLabels": { "objectNumber": "Föremålsnummer", "objectName": "Namn", "count": "Antal föremål", "briefDescription": "Kort beskrivning", "currentLocation": "Nuvarande plats", "currentOwner": "Nuvarande ägare", "recorder": "Registrerad av", "recordingDate": "Registreringsdatum", "visibility": "Synlighet", "flexible": "Katalogfält" },
|
"fieldsLabels": { "objectNumber": "Föremålsnummer", "objectName": "Namn", "count": "Antal föremål", "briefDescription": "Kort beskrivning", "currentLocation": "Nuvarande plats", "currentOwner": "Nuvarande ägare", "recorder": "Registrerad av", "recordingDate": "Registreringsdatum", "visibility": "Synlighet", "flexible": "Katalogfält" },
|
||||||
"visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" }
|
"visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" },
|
||||||
|
"form": { "selectPlaceholder": "— välj —", "create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt", "visibility": "Synlighet", "draft": "Utkast", "internal": "Intern", "required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält", "flexibleHeading": "Katalogfält" },
|
||||||
|
"actions": { "edit": "Redigera", "delete": "Ta bort", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras." },
|
||||||
|
"labels": { "en": "Etikett (EN)", "sv": "Etikett (SV)", "externalUri": "Extern URI (valfritt)" },
|
||||||
|
"vocab": {
|
||||||
|
"title": "Vokabulär", "newVocabulary": "Ny vokabulär", "key": "Nyckel",
|
||||||
|
"create": "Skapa", "selectPrompt": "Välj en vokabulär för att hantera dess termer",
|
||||||
|
"terms": "Termer", "addTerm": "Lägg till term", "empty": "Inga vokabulärer ännu",
|
||||||
|
"noTerms": "Inga termer ännu", "loadError": "Kunde inte ladda"
|
||||||
|
},
|
||||||
|
"authorities": {
|
||||||
|
"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_one": "{{count}} träff",
|
||||||
|
"resultCount_other": "{{count}} träffar",
|
||||||
|
"selectPrompt": "Välj en träff för att se hela posten"
|
||||||
|
},
|
||||||
|
"publish": {
|
||||||
|
"heading": "Synlighet",
|
||||||
|
"advanceInternal": "Gör intern",
|
||||||
|
"publish": "Publicera →",
|
||||||
|
"backToDraft": "← Tillbaka till utkast",
|
||||||
|
"unpublishInternal": "Avpublicera till intern",
|
||||||
|
"confirmTitle": "Publicera publikt?",
|
||||||
|
"confirmBody": "Detta gör posten synlig via det publika API:et.",
|
||||||
|
"confirm": "Publicera",
|
||||||
|
"gateError": "Kan inte publicera — obligatoriska fält saknas.",
|
||||||
|
"editLink": "Redigera posten",
|
||||||
|
"illegalError": "Den synlighetsändringen är inte tillåten."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { components } from "../api/schema";
|
||||||
|
|
||||||
|
type LabelView = components["schemas"]["LabelView"];
|
||||||
|
|
||||||
|
export function labelText(labels: LabelView[], lang: string): string {
|
||||||
|
return (
|
||||||
|
labels.find((l) => l.lang === lang)?.label ??
|
||||||
|
labels.find((l) => l.lang === "en")?.label ??
|
||||||
|
labels[0]?.label ??
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,64 @@
|
|||||||
|
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 { Routes, Route } from "react-router-dom";
|
||||||
|
import { server } from "../test/server";
|
||||||
|
import { renderApp } from "../test/render";
|
||||||
|
import { DeleteObjectDialog } from "./delete-object-dialog";
|
||||||
|
|
||||||
|
function tree() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/objects/:id" element={<DeleteObjectDialog id="o-1" />} />
|
||||||
|
<Route path="/objects" element={<div>objects list</div>} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("confirm delete: DELETE then navigate to the list", async () => {
|
||||||
|
let deleted = false;
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.delete("/api/admin/objects/:id", () => {
|
||||||
|
deleted = true;
|
||||||
|
return new HttpResponse(null, { status: 204 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderApp(tree(), { route: "/objects/o-1" });
|
||||||
|
|
||||||
|
// open the dialog via the trigger
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: /delete/i }));
|
||||||
|
|
||||||
|
// confirm inside the dialog
|
||||||
|
const dialog = await screen.findByRole("alertdialog");
|
||||||
|
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: /delete/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("objects list")).toBeInTheDocument());
|
||||||
|
|
||||||
|
expect(deleted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancel does not delete", async () => {
|
||||||
|
let deleted = false;
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.delete("/api/admin/objects/:id", () => {
|
||||||
|
deleted = true;
|
||||||
|
return new HttpResponse(null, { status: 204 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderApp(tree(), { route: "/objects/o-1" });
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: /delete/i }));
|
||||||
|
|
||||||
|
const dialog = await screen.findByRole("alertdialog");
|
||||||
|
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: /cancel/i }));
|
||||||
|
|
||||||
|
expect(deleted).toBe(false);
|
||||||
|
expect(screen.queryByText("objects list")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useDeleteObject } from "../api/queries";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogAction,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export function DeleteObjectDialog({ id }: { id: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const del = useDeleteObject();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
const onConfirm = async () => {
|
||||||
|
setError(false);
|
||||||
|
try {
|
||||||
|
await del.mutateAsync(id);
|
||||||
|
} catch {
|
||||||
|
// Keep the dialog open so the user can retry or cancel; never let the
|
||||||
|
// rejected mutation escape as an unhandled promise rejection.
|
||||||
|
setError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate("/objects");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="ghost" size="sm" className="text-red-600">
|
||||||
|
{t("actions.delete")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogTitle>{t("actions.delete")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{t("actions.confirmDelete")}</AlertDialogDescription>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-sm text-red-600">
|
||||||
|
{t("form.rejected")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t("form.cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={onConfirm}>
|
||||||
|
{t("actions.delete")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { screen } from "@testing-library/react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { renderApp } from "../test/render";
|
||||||
|
import { FieldInput } from "./field-input";
|
||||||
|
import { fieldDefinitions } from "../test/fixtures";
|
||||||
|
|
||||||
|
function Harness({ defKey }: { defKey: string }) {
|
||||||
|
const def = fieldDefinitions.find((d) => d.key === defKey)!;
|
||||||
|
const form = useForm({ defaultValues: { fields: {} as Record<string, unknown> } });
|
||||||
|
|
||||||
|
return <FieldInput definition={def} form={form} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("text field renders a text input labelled in the active locale", async () => {
|
||||||
|
renderApp(<Harness defKey="inscription" />);
|
||||||
|
expect(await screen.findByLabelText("Inscription")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("boolean field renders a checkbox", async () => {
|
||||||
|
renderApp(<Harness defKey="is_fragment" />);
|
||||||
|
expect(await screen.findByRole("checkbox", { name: /is fragment/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("localized_text renders sv and en inputs", async () => {
|
||||||
|
renderApp(<Harness defKey="title_ml" />);
|
||||||
|
expect(await screen.findByLabelText(/title.*\(en\)/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/title.*\(sv\)/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("term field renders a select populated from the vocabulary", async () => {
|
||||||
|
renderApp(<Harness defKey="material" />);
|
||||||
|
expect(await screen.findByText("Bronze")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("authority field renders a select populated by kind", async () => {
|
||||||
|
renderApp(<Harness defKey="maker" />);
|
||||||
|
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { Controller, type Path, type UseFormReturn } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
import { useAuthorities, useTerms } from "../api/queries";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
|
||||||
|
type LabelView = components["schemas"]["LabelView"];
|
||||||
|
|
||||||
|
type FieldForm<TValues extends { fields: Record<string, unknown> }> = UseFormReturn<TValues>;
|
||||||
|
|
||||||
|
function fieldPath<TValues extends { fields: Record<string, unknown> }>(
|
||||||
|
key: string,
|
||||||
|
): Path<TValues> {
|
||||||
|
return `fields.${key}` as Path<TValues>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelIn(labels: LabelView[], lang: string): string {
|
||||||
|
return (
|
||||||
|
labels.find((l) => l.lang === lang)?.label ??
|
||||||
|
labels.find((l) => l.lang === "en")?.label ??
|
||||||
|
labels[0]?.label ??
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A native <select> keeps the bundle lean and is fully accessible; the shadcn Select
|
||||||
|
// can replace it later without changing the value contract (option value = id).
|
||||||
|
function OptionsSelect({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
lang,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
options: { id: string; labels: LabelView[] }[];
|
||||||
|
lang: string;
|
||||||
|
placeholder: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
id={id}
|
||||||
|
className="w-full rounded border px-2 py-1 text-sm"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">{placeholder}</option>
|
||||||
|
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>
|
||||||
|
{labelIn(o.labels, lang)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldInput<TValues extends { fields: Record<string, unknown> }>({
|
||||||
|
definition,
|
||||||
|
form,
|
||||||
|
}: {
|
||||||
|
definition: FieldDefinitionView;
|
||||||
|
form: FieldForm<TValues>;
|
||||||
|
}) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
|
||||||
|
const label = labelIn(definition.labels, lang);
|
||||||
|
const name = fieldPath<TValues>(definition.key);
|
||||||
|
const placeholder = t("form.selectPlaceholder");
|
||||||
|
|
||||||
|
switch (definition.data_type) {
|
||||||
|
case "integer":
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor={definition.key}>{label}</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id={definition.key}
|
||||||
|
type="number"
|
||||||
|
{...form.register(name, {
|
||||||
|
valueAsNumber: true,
|
||||||
|
required: definition.required,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "date":
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor={definition.key}>{label}</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id={definition.key}
|
||||||
|
type="date"
|
||||||
|
{...form.register(name, { required: definition.required })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "boolean":
|
||||||
|
// A checkbox always has a boolean value, so `required` is a no-op here.
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name={name}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Checkbox
|
||||||
|
id={definition.key}
|
||||||
|
checked={!!field.value}
|
||||||
|
onCheckedChange={(checked) => field.onChange(checked === true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Label htmlFor={definition.key}>{label}</Label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "localized_text":
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium">{label}</div>
|
||||||
|
|
||||||
|
<Label
|
||||||
|
htmlFor={`${definition.key}-en`}
|
||||||
|
className="text-xs text-neutral-500"
|
||||||
|
>
|
||||||
|
{label} (EN)
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id={`${definition.key}-en`}
|
||||||
|
{...form.register(fieldPath<TValues>(`${definition.key}.en`), { required: definition.required })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Label
|
||||||
|
htmlFor={`${definition.key}-sv`}
|
||||||
|
className="text-xs text-neutral-500"
|
||||||
|
>
|
||||||
|
{label} (SV)
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id={`${definition.key}-sv`}
|
||||||
|
{...form.register(fieldPath<TValues>(`${definition.key}.sv`))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "term":
|
||||||
|
return (
|
||||||
|
<TermField
|
||||||
|
definition={definition}
|
||||||
|
form={form}
|
||||||
|
label={label}
|
||||||
|
lang={lang}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "authority":
|
||||||
|
return (
|
||||||
|
<AuthorityField
|
||||||
|
definition={definition}
|
||||||
|
form={form}
|
||||||
|
label={label}
|
||||||
|
lang={lang}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "text":
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor={definition.key}>{label}</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id={definition.key}
|
||||||
|
{...form.register(name, { required: definition.required })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function TermField<TValues extends { fields: Record<string, unknown> }>({
|
||||||
|
definition,
|
||||||
|
form,
|
||||||
|
label,
|
||||||
|
lang,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
definition: FieldDefinitionView;
|
||||||
|
form: FieldForm<TValues>;
|
||||||
|
label: string;
|
||||||
|
lang: string;
|
||||||
|
placeholder: string;
|
||||||
|
}) {
|
||||||
|
const { data: terms } = useTerms(definition.vocabulary_id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor={definition.key}>{label}</Label>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name={fieldPath<TValues>(definition.key)}
|
||||||
|
rules={{ required: definition.required }}
|
||||||
|
render={({ field }) => (
|
||||||
|
<OptionsSelect
|
||||||
|
id={definition.key}
|
||||||
|
value={(field.value as string) ?? ""}
|
||||||
|
onChange={field.onChange}
|
||||||
|
options={terms ?? []}
|
||||||
|
lang={lang}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthorityField<TValues extends { fields: Record<string, unknown> }>({
|
||||||
|
definition,
|
||||||
|
form,
|
||||||
|
label,
|
||||||
|
lang,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
definition: FieldDefinitionView;
|
||||||
|
form: FieldForm<TValues>;
|
||||||
|
label: string;
|
||||||
|
lang: string;
|
||||||
|
placeholder: string;
|
||||||
|
}) {
|
||||||
|
const { data: authorities } = useAuthorities(definition.authority_kind);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor={definition.key}>{label}</Label>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name={fieldPath<TValues>(definition.key)}
|
||||||
|
rules={{ required: definition.required }}
|
||||||
|
render={({ field }) => (
|
||||||
|
<OptionsSelect
|
||||||
|
id={definition.key}
|
||||||
|
value={(field.value as string) ?? ""}
|
||||||
|
onChange={field.onChange}
|
||||||
|
options={authorities ?? []}
|
||||||
|
lang={lang}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -38,7 +38,9 @@ test("renders inventory-minimum fields, flexible values and visibility", async (
|
|||||||
expect(await screen.findByText("Amphora")).toBeInTheDocument();
|
expect(await screen.findByText("Amphora")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Vault 3")).toBeInTheDocument();
|
expect(screen.getByText("Vault 3")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Bronze")).toBeInTheDocument(); // flexible field value
|
expect(screen.getByText("Bronze")).toBeInTheDocument(); // flexible field value
|
||||||
expect(screen.getByText("Public")).toBeInTheDocument();
|
// "Public" appears in both the VisibilityBadge and the PublishControl stepper;
|
||||||
|
// scope the assertion to the badge element to avoid ambiguity.
|
||||||
|
expect(document.querySelector("[data-slot='badge']")).toHaveTextContent("Public");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows a not-found state for a missing object", async () => {
|
test("shows a not-found state for a missing object", async () => {
|
||||||
@@ -46,3 +48,10 @@ test("shows a not-found state for a missing object", async () => {
|
|||||||
renderApp(tree(), { route: "/objects/does-not-exist" });
|
renderApp(tree(), { route: "/objects/does-not-exist" });
|
||||||
expect(await screen.findByText(/object not found/i)).toBeInTheDocument();
|
expect(await screen.findByText(/object not found/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("detail shows the publish control with the current visibility stepper", async () => {
|
||||||
|
// default GET /api/admin/objects/:id handler returns amphora (visibility "public")
|
||||||
|
renderApp(tree(), { route: "/objects/11111111-1111-1111-1111-111111111111" });
|
||||||
|
expect(await screen.findByText(/visibility/i)).toBeInTheDocument();
|
||||||
|
expect(await screen.findByRole("button", { name: /unpublish to internal/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { useObject, useFieldDefinitions } from "../api/queries";
|
import { useObject, useFieldDefinitions } from "../api/queries";
|
||||||
|
import { DeleteObjectDialog } from "./delete-object-dialog";
|
||||||
|
import { PublishControl } from "./publish-control";
|
||||||
import { VisibilityBadge } from "./visibility-badge";
|
import { VisibilityBadge } from "./visibility-badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
@@ -57,6 +59,10 @@ export function ObjectDetail() {
|
|||||||
<div className="mb-4 flex items-center gap-3">
|
<div className="mb-4 flex items-center gap-3">
|
||||||
<h2 className="text-xl font-semibold">{object.object_name}</h2>
|
<h2 className="text-xl font-semibold">{object.object_name}</h2>
|
||||||
<VisibilityBadge visibility={object.visibility} />
|
<VisibilityBadge visibility={object.visibility} />
|
||||||
|
<Link to={`/objects/${object.id}/edit`} className="text-sm font-medium text-indigo-600">
|
||||||
|
{t("actions.edit")}
|
||||||
|
</Link>
|
||||||
|
<DeleteObjectDialog id={object.id} />
|
||||||
</div>
|
</div>
|
||||||
<Field label={t("fieldsLabels.objectNumber")} value={object.object_number} />
|
<Field label={t("fieldsLabels.objectNumber")} value={object.object_number} />
|
||||||
<Field label={t("fieldsLabels.count")} value={object.number_of_objects} />
|
<Field label={t("fieldsLabels.count")} value={object.number_of_objects} />
|
||||||
@@ -85,6 +91,7 @@ export function ObjectDetail() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<PublishControl object={object} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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 { Routes, Route } from "react-router-dom";
|
||||||
|
import { server } from "../test/server";
|
||||||
|
import { renderApp } from "../test/render";
|
||||||
|
import { ObjectEditForm } from "./object-edit-form";
|
||||||
|
import { amphora } from "../test/fixtures";
|
||||||
|
|
||||||
|
function tree() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/objects/:id/edit" element={<ObjectEditForm />} />
|
||||||
|
<Route path="/objects/:id" element={<div>detail view</div>} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("edit: prefilled, save -> PUT core + PUT fields -> back to detail", async () => {
|
||||||
|
let putCore: unknown;
|
||||||
|
let putFields: unknown;
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get("/api/admin/objects/:id", () =>
|
||||||
|
HttpResponse.json({ ...amphora, fields: { inscription: "old" } }),
|
||||||
|
),
|
||||||
|
http.put("/api/admin/objects/:id", async ({ request }) => {
|
||||||
|
putCore = await request.json();
|
||||||
|
return new HttpResponse(null, { status: 204 });
|
||||||
|
}),
|
||||||
|
http.put("/api/admin/objects/:id/fields", async ({ request }) => {
|
||||||
|
putFields = await request.json();
|
||||||
|
return new HttpResponse(null, { status: 204 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderApp(tree(), { route: `/objects/${amphora.id}/edit` });
|
||||||
|
|
||||||
|
const name = await screen.findByDisplayValue("Amphora");
|
||||||
|
|
||||||
|
await userEvent.clear(name);
|
||||||
|
await userEvent.type(name, "Big amphora");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("detail view")).toBeInTheDocument());
|
||||||
|
expect((putCore as { object_name: string }).object_name).toBe("Big amphora");
|
||||||
|
expect((putFields as { inscription: string }).inscription).toBe("old");
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useObject, useUpdateObject, useSetFields } from "../api/queries";
|
||||||
|
import { ObjectForm, type ObjectCore, type ObjectFormValues } from "./object-form";
|
||||||
|
|
||||||
|
export function ObjectEditForm() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const { data: object, isLoading } = useObject(id!);
|
||||||
|
const update = useUpdateObject();
|
||||||
|
const setFields = useSetFields();
|
||||||
|
|
||||||
|
const [error, setError] = useState<string | null>(
|
||||||
|
(location.state as { fieldsError?: boolean } | null)?.fieldsError ? t("form.rejected") : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) return <div className="p-4" role="status" aria-label="loading" />;
|
||||||
|
|
||||||
|
if (!object) return <p className="p-4 text-sm text-neutral-500">{t("objects.notFound")}</p>;
|
||||||
|
|
||||||
|
const core: ObjectCore = {
|
||||||
|
object_number: object.object_number,
|
||||||
|
object_name: object.object_name,
|
||||||
|
number_of_objects: object.number_of_objects,
|
||||||
|
brief_description: object.brief_description ?? null,
|
||||||
|
current_location: object.current_location ?? null,
|
||||||
|
current_owner: object.current_owner ?? null,
|
||||||
|
recorder: object.recorder ?? null,
|
||||||
|
recording_date: object.recording_date ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaults = { core, fields: object.fields as Record<string, unknown> };
|
||||||
|
|
||||||
|
const onSubmit = async (values: ObjectFormValues) => {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await update.mutateAsync({ id: id!, body: values.core });
|
||||||
|
await setFields.mutateAsync({ id: id!, fields: values.fields });
|
||||||
|
} catch {
|
||||||
|
setError(t("form.rejected"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(`/objects/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ObjectForm
|
||||||
|
mode="edit"
|
||||||
|
defaults={defaults}
|
||||||
|
formError={error}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onCancel={() => navigate(`/objects/${id}`)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { expect, test, vi } from "vitest";
|
||||||
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { renderApp } from "../test/render";
|
||||||
|
import { ObjectForm } from "./object-form";
|
||||||
|
|
||||||
|
test("create mode: shows visibility (draft/internal only) and submits assembled values", async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
renderApp(<ObjectForm mode="create" onSubmit={onSubmit} onCancel={() => {}} />);
|
||||||
|
|
||||||
|
await userEvent.type(await screen.findByLabelText(/object number/i), "A-9");
|
||||||
|
await userEvent.type(screen.getByLabelText(/^name/i), "Amphora");
|
||||||
|
await userEvent.type(screen.getByLabelText(/inscription/i), "To the gods");
|
||||||
|
|
||||||
|
const visibility = screen.getByLabelText(/visibility/i) as HTMLSelectElement;
|
||||||
|
expect([...visibility.options].map((o) => o.value)).toEqual(expect.arrayContaining(["draft", "internal"]));
|
||||||
|
expect([...visibility.options].map((o) => o.value)).not.toContain("public");
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /create object/i }));
|
||||||
|
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
|
||||||
|
const values = onSubmit.mock.calls[0][0];
|
||||||
|
expect(values.core.object_number).toBe("A-9");
|
||||||
|
expect(values.visibility).toBe("draft");
|
||||||
|
expect(values.fields.inscription).toBe("To the gods");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("required core + required flexible field block submit", async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
renderApp(<ObjectForm mode="create" onSubmit={onSubmit} onCancel={() => {}} />);
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: /create object/i }));
|
||||||
|
await waitFor(() => expect(screen.getAllByText(/required/i).length).toBeGreaterThan(0));
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("edit mode: no visibility control, save button, prefilled values", async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
renderApp(
|
||||||
|
<ObjectForm mode="edit" onSubmit={onSubmit} onCancel={() => {}}
|
||||||
|
defaults={{
|
||||||
|
core: { object_number: "A-1", object_name: "Amphora", number_of_objects: 1,
|
||||||
|
brief_description: null, current_location: "Vault 3", current_owner: null,
|
||||||
|
recorder: null, recording_date: null },
|
||||||
|
fields: { inscription: "hi" },
|
||||||
|
}} />,
|
||||||
|
);
|
||||||
|
expect(await screen.findByDisplayValue("Amphora")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText(/visibility/i)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /save/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useFieldDefinitions } from "../api/queries";
|
||||||
|
import { FieldInput } from "./field-input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
export type ObjectCore = {
|
||||||
|
object_number: string;
|
||||||
|
object_name: string;
|
||||||
|
number_of_objects: number;
|
||||||
|
brief_description: string | null;
|
||||||
|
current_location: string | null;
|
||||||
|
current_owner: string | null;
|
||||||
|
recorder: string | null;
|
||||||
|
recording_date: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ObjectFormValues = {
|
||||||
|
core: ObjectCore;
|
||||||
|
visibility?: "draft" | "internal";
|
||||||
|
fields: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormShape = {
|
||||||
|
core: ObjectCore;
|
||||||
|
visibility: "draft" | "internal";
|
||||||
|
fields: Record<string, unknown>;
|
||||||
|
} & Record<string, unknown>;
|
||||||
|
|
||||||
|
const EMPTY_CORE: ObjectCore = {
|
||||||
|
object_number: "",
|
||||||
|
object_name: "",
|
||||||
|
number_of_objects: 1,
|
||||||
|
brief_description: null,
|
||||||
|
current_location: null,
|
||||||
|
current_owner: null,
|
||||||
|
recorder: null,
|
||||||
|
recording_date: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ObjectForm({
|
||||||
|
mode,
|
||||||
|
defaults,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
formError,
|
||||||
|
}: {
|
||||||
|
mode: "create" | "edit";
|
||||||
|
defaults?: { core: ObjectCore; fields: Record<string, unknown> };
|
||||||
|
onSubmit: (values: ObjectFormValues) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
formError?: string | null;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { data: definitions } = useFieldDefinitions();
|
||||||
|
|
||||||
|
const form = useForm<FormShape>({
|
||||||
|
defaultValues: {
|
||||||
|
core: defaults?.core ?? EMPTY_CORE,
|
||||||
|
visibility: "draft",
|
||||||
|
fields: defaults?.fields ?? {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { register, handleSubmit, formState: { errors } } = form;
|
||||||
|
|
||||||
|
const submit = handleSubmit((data) => {
|
||||||
|
const fields = pruneFields(data.fields);
|
||||||
|
|
||||||
|
onSubmit(
|
||||||
|
mode === "create"
|
||||||
|
? { core: data.core, visibility: data.visibility, fields }
|
||||||
|
: { core: data.core, fields },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const coreField = (
|
||||||
|
key: keyof ObjectCore,
|
||||||
|
labelKey: string,
|
||||||
|
opts?: { type?: string; required?: boolean },
|
||||||
|
) => (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor={key}>{t(`fieldsLabels.${labelKey}`)}</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id={key}
|
||||||
|
type={opts?.type ?? "text"}
|
||||||
|
{...register(
|
||||||
|
`core.${key}` as const,
|
||||||
|
opts?.type === "number"
|
||||||
|
? { valueAsNumber: true, required: opts?.required }
|
||||||
|
: { required: opts?.required },
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{errors.core?.[key] && (
|
||||||
|
<p role="alert" className="text-xs text-red-600">
|
||||||
|
{t("form.required")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} className="space-y-4 overflow-auto p-4">
|
||||||
|
{formError && (
|
||||||
|
<p role="alert" className="text-sm text-red-600">
|
||||||
|
{formError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{coreField("object_number", "objectNumber", { required: true })}
|
||||||
|
{coreField("object_name", "objectName", { required: true })}
|
||||||
|
{coreField("number_of_objects", "count", { type: "number", required: true })}
|
||||||
|
{coreField("brief_description", "briefDescription")}
|
||||||
|
{coreField("current_location", "currentLocation")}
|
||||||
|
{coreField("current_owner", "currentOwner")}
|
||||||
|
{coreField("recorder", "recorder")}
|
||||||
|
{coreField("recording_date", "recordingDate", { type: "date" })}
|
||||||
|
|
||||||
|
{mode === "create" && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="visibility">{t("form.visibility")}</Label>
|
||||||
|
|
||||||
|
<select
|
||||||
|
id="visibility"
|
||||||
|
className="w-full rounded border px-2 py-1 text-sm"
|
||||||
|
{...register("visibility")}
|
||||||
|
>
|
||||||
|
<option value="draft">{t("form.draft")}</option>
|
||||||
|
<option value="internal">{t("form.internal")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{definitions && definitions.length > 0 && (
|
||||||
|
<fieldset className="space-y-3 border-t pt-3">
|
||||||
|
<legend className="text-xs font-medium uppercase text-neutral-500">
|
||||||
|
{t("form.flexibleHeading")}
|
||||||
|
</legend>
|
||||||
|
|
||||||
|
{definitions.map((def) => (
|
||||||
|
<div key={def.key}>
|
||||||
|
<FieldInput definition={def} form={form} />
|
||||||
|
|
||||||
|
{errors.fields?.[def.key] && (
|
||||||
|
<p role="alert" className="text-xs text-red-600">
|
||||||
|
{t("form.required")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 pt-2">
|
||||||
|
<Button type="submit">
|
||||||
|
{mode === "create" ? t("form.create") : t("form.save")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="button" variant="ghost" onClick={onCancel}>
|
||||||
|
{t("form.cancel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneFields(fields: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(fields)) {
|
||||||
|
if (value === undefined || value === null || value === "") continue;
|
||||||
|
|
||||||
|
if (typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
const inner = Object.fromEntries(
|
||||||
|
Object.entries(value as Record<string, unknown>).filter(
|
||||||
|
([, v]) => v !== undefined && v !== null && v !== "",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Object.keys(inner).length > 0) out[key] = inner;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
out[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { NavLink } from "react-router-dom";
|
import { Link, NavLink } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -15,22 +15,43 @@ export function ObjectList() {
|
|||||||
|
|
||||||
const { data, isLoading, isError } = useObjectsPage(LIMIT, offset);
|
const { data, isLoading, isError } = useObjectsPage(LIMIT, offset);
|
||||||
|
|
||||||
|
const header = (
|
||||||
|
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||||
|
<Link to="/objects/new" className="text-sm font-medium text-indigo-600">
|
||||||
|
{t("objects.new")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 p-3">
|
<div>
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{header}
|
||||||
<Skeleton key={i} className="h-9 w-full" />
|
<div className="space-y-2 p-3">
|
||||||
))}
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-9 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return <p className="p-4 text-sm text-red-600">{t("objects.loadError")}</p>;
|
return (
|
||||||
|
<div>
|
||||||
|
{header}
|
||||||
|
<p className="p-4 text-sm text-red-600">{t("objects.loadError")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data || data.items.length === 0) {
|
if (!data || data.items.length === 0) {
|
||||||
return <p className="p-4 text-sm text-neutral-500">{t("objects.empty")}</p>;
|
return (
|
||||||
|
<div>
|
||||||
|
{header}
|
||||||
|
<p className="p-4 text-sm text-neutral-500">{t("objects.empty")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const from = data.total === 0 ? 0 : offset + 1;
|
const from = data.total === 0 ? 0 : offset + 1;
|
||||||
@@ -38,6 +59,7 @@ export function ObjectList() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
|
{header}
|
||||||
<ul className="flex-1 overflow-auto">
|
<ul className="flex-1 overflow-auto">
|
||||||
{data.items.map((object) => (
|
{data.items.map((object) => (
|
||||||
<li key={object.id}>
|
<li key={object.id}>
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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 { Routes, Route, useLocation } from "react-router-dom";
|
||||||
|
import { server } from "../test/server";
|
||||||
|
import { renderApp } from "../test/render";
|
||||||
|
import { ObjectNewPage } from "./object-new-page";
|
||||||
|
|
||||||
|
function EditStub() {
|
||||||
|
const location = useLocation();
|
||||||
|
const flagged = (location.state as { fieldsError?: boolean } | null)?.fieldsError === true;
|
||||||
|
return <div>edit page{flagged ? " (fields error)" : ""}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tree() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/objects/new" element={<ObjectNewPage />} />
|
||||||
|
<Route path="/objects/:id" element={<div>detail view</div>} />
|
||||||
|
<Route path="/objects/:id/edit" element={<EditStub />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("create: POST then PUT fields, then navigate to the new object's detail", async () => {
|
||||||
|
let postBody: unknown;
|
||||||
|
let fieldsBody: unknown;
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/objects", async ({ request }) => {
|
||||||
|
postBody = await request.json();
|
||||||
|
return HttpResponse.json({ id: "new-id-1" }, { status: 201 });
|
||||||
|
}),
|
||||||
|
http.put("/api/admin/objects/:id/fields", async ({ request }) => {
|
||||||
|
fieldsBody = await request.json();
|
||||||
|
return new HttpResponse(null, { status: 204 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderApp(tree(), { route: "/objects/new" });
|
||||||
|
|
||||||
|
await userEvent.type(await screen.findByLabelText(/object number/i), "A-9");
|
||||||
|
await userEvent.type(screen.getByLabelText(/^name/i), "Amphora");
|
||||||
|
await userEvent.type(screen.getByLabelText(/inscription/i), "To the gods");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /create object/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("detail view")).toBeInTheDocument());
|
||||||
|
expect((postBody as { object_number: string }).object_number).toBe("A-9");
|
||||||
|
expect((fieldsBody as { inscription: string }).inscription).toBe("To the gods");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("partial create: fields PUT fails -> navigate to edit with an error banner", async () => {
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/objects", () =>
|
||||||
|
HttpResponse.json({ id: "new-id-2" }, { status: 201 }),
|
||||||
|
),
|
||||||
|
http.put("/api/admin/objects/:id/fields", () =>
|
||||||
|
new HttpResponse(null, { status: 422 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderApp(tree(), { route: "/objects/new" });
|
||||||
|
|
||||||
|
await userEvent.type(await screen.findByLabelText(/object number/i), "A-9");
|
||||||
|
await userEvent.type(screen.getByLabelText(/^name/i), "Amphora");
|
||||||
|
await userEvent.type(screen.getByLabelText(/inscription/i), "x");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /create object/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText(/edit page \(fields error\)/i)).toBeInTheDocument());
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { ObjectForm, type ObjectFormValues } from "./object-form";
|
||||||
|
import { useCreateObject, useSetFields } from "../api/queries";
|
||||||
|
|
||||||
|
export function ObjectNewPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const create = useCreateObject();
|
||||||
|
const setFields = useSetFields();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const onSubmit = async (values: ObjectFormValues) => {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
let id: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await create.mutateAsync({
|
||||||
|
...values.core,
|
||||||
|
visibility: values.visibility ?? "draft",
|
||||||
|
});
|
||||||
|
|
||||||
|
id = created.id;
|
||||||
|
} catch {
|
||||||
|
setError(t("form.rejected"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(values.fields).length > 0) {
|
||||||
|
try {
|
||||||
|
await setFields.mutateAsync({ id, fields: values.fields });
|
||||||
|
} catch {
|
||||||
|
navigate(`/objects/${id}/edit`, { state: { fieldsError: true } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(`/objects/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl">
|
||||||
|
<ObjectForm
|
||||||
|
mode="create"
|
||||||
|
formError={error}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onCancel={() => navigate("/objects")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,12 +4,16 @@ import userEvent from "@testing-library/user-event";
|
|||||||
import { Routes, Route } from "react-router-dom";
|
import { Routes, Route } from "react-router-dom";
|
||||||
import { renderApp } from "../test/render";
|
import { renderApp } from "../test/render";
|
||||||
import { ObjectsPage } from "./objects-page";
|
import { ObjectsPage } from "./objects-page";
|
||||||
|
import { ObjectDetail } from "./object-detail";
|
||||||
|
import { SelectPrompt } from "./select-prompt";
|
||||||
|
|
||||||
function tree() {
|
function tree() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/objects" element={<ObjectsPage />} />
|
<Route path="/objects" element={<ObjectsPage />}>
|
||||||
<Route path="/objects/:id" element={<ObjectsPage />} />
|
<Route index element={<SelectPrompt />} />
|
||||||
|
<Route path=":id" element={<ObjectDetail />} />
|
||||||
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,15 @@
|
|||||||
import { useParams } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { ObjectList } from "./object-list";
|
import { ObjectList } from "./object-list";
|
||||||
import { ObjectDetail } from "./object-detail";
|
|
||||||
|
|
||||||
export function ObjectsPage() {
|
export function ObjectsPage() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { id } = useParams();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full grid-cols-[20rem_1fr]">
|
<div className="grid h-full grid-cols-[20rem_1fr]">
|
||||||
<div className="overflow-hidden border-r">
|
<div className="overflow-hidden border-r">
|
||||||
<ObjectList />
|
<ObjectList />
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
{id ? (
|
<Outlet />
|
||||||
<ObjectDetail />
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full items-center justify-center p-4 text-sm text-neutral-400">
|
|
||||||
{t("objects.selectPrompt")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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 { server } from "../test/server";
|
||||||
|
import { renderApp } from "../test/render";
|
||||||
|
import { PublishControl } from "./publish-control";
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
|
||||||
|
type AdminObjectView = components["schemas"]["AdminObjectView"];
|
||||||
|
|
||||||
|
function objectWith(visibility: string): AdminObjectView {
|
||||||
|
return {
|
||||||
|
id: "o-1", object_number: "A-1", object_name: "Amphora", number_of_objects: 1,
|
||||||
|
brief_description: null, current_location: null, current_owner: null,
|
||||||
|
recorder: null, recording_date: null, visibility, fields: {},
|
||||||
|
} as AdminObjectView;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("internal: shows publish (forward) and back-to-draft buttons", async () => {
|
||||||
|
renderApp(<PublishControl object={objectWith("internal")} />);
|
||||||
|
expect(screen.getByRole("button", { name: /publish/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /back to draft/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("draft: forward to internal posts immediately (no confirm)", async () => {
|
||||||
|
let body: unknown;
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/objects/:id/visibility", async ({ request }) => {
|
||||||
|
body = await request.json();
|
||||||
|
return new HttpResponse(null, { status: 204 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderApp(<PublishControl object={objectWith("draft")} />);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /advance to internal/i }));
|
||||||
|
await waitFor(() => expect((body as { visibility: string })?.visibility).toBe("internal"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("public: back to internal posts immediately", async () => {
|
||||||
|
let body: unknown;
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/objects/:id/visibility", async ({ request }) => {
|
||||||
|
body = await request.json();
|
||||||
|
return new HttpResponse(null, { status: 204 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderApp(<PublishControl object={objectWith("public")} />);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /unpublish to internal/i }));
|
||||||
|
await waitFor(() => expect((body as { visibility: string })?.visibility).toBe("internal"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("internal -> public requires confirmation, then posts public", async () => {
|
||||||
|
let body: unknown;
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/objects/:id/visibility", async ({ request }) => {
|
||||||
|
body = await request.json();
|
||||||
|
return new HttpResponse(null, { status: 204 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderApp(<PublishControl object={objectWith("internal")} />);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /publish/i }));
|
||||||
|
const dialog = await screen.findByRole("alertdialog");
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: /publish/i }));
|
||||||
|
await waitFor(() => expect((body as { visibility: string })?.visibility).toBe("public"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("publish gate (422) shows an inline error with an edit link", async () => {
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/objects/:id/visibility", () => new HttpResponse(null, { status: 422 })),
|
||||||
|
);
|
||||||
|
renderApp(<PublishControl object={objectWith("internal")} />);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /publish/i }));
|
||||||
|
const dialog = await screen.findByRole("alertdialog");
|
||||||
|
await userEvent.click(within(dialog).getByRole("button", { name: /publish/i }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/required fields are missing/i)).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("link", { name: /edit the record/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
import { useSetVisibility, VisibilityError } from "../api/queries";
|
||||||
|
import { adjacentTransitions, type Visibility } from "./transitions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogAction,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
type AdminObjectView = components["schemas"]["AdminObjectView"];
|
||||||
|
|
||||||
|
const STEPS: Visibility[] = ["draft", "internal", "public"];
|
||||||
|
|
||||||
|
export function PublishControl({ object }: { object: AdminObjectView }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const current = object.visibility as Visibility;
|
||||||
|
const { forward, back } = adjacentTransitions(current);
|
||||||
|
const setVisibility = useSetVisibility();
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [errorKind, setErrorKind] = useState<"gate" | "illegal" | "other" | null>(null);
|
||||||
|
|
||||||
|
const go = (visibility: Visibility) => {
|
||||||
|
setErrorKind(null);
|
||||||
|
setVisibility.mutate(
|
||||||
|
{ id: object.id, visibility },
|
||||||
|
{
|
||||||
|
onSuccess: () => setConfirmOpen(false),
|
||||||
|
onError: (err) => {
|
||||||
|
setConfirmOpen(false);
|
||||||
|
const status = err instanceof VisibilityError ? err.status : 0;
|
||||||
|
setErrorKind(status === 422 ? "gate" : status === 409 ? "illegal" : "other");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentIndex = STEPS.indexOf(current);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="border-t p-4">
|
||||||
|
<div className="mb-2 text-xs font-medium uppercase text-neutral-500">
|
||||||
|
{t("publish.heading")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3 flex">
|
||||||
|
{STEPS.map((step, i) => (
|
||||||
|
<div
|
||||||
|
key={step}
|
||||||
|
aria-current={i === currentIndex ? "step" : undefined}
|
||||||
|
className={`flex-1 border px-2 py-1 text-center text-xs ${
|
||||||
|
i === currentIndex
|
||||||
|
? "bg-neutral-800 font-semibold text-white"
|
||||||
|
: i < currentIndex
|
||||||
|
? "bg-neutral-100 text-neutral-600"
|
||||||
|
: "text-neutral-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`visibility.${step}`)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{back && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={setVisibility.isPending}
|
||||||
|
onClick={() => go(back)}
|
||||||
|
>
|
||||||
|
{back === "draft" ? t("publish.backToDraft") : t("publish.unpublishInternal")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{forward === "internal" && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={setVisibility.isPending}
|
||||||
|
onClick={() => go("internal")}
|
||||||
|
>
|
||||||
|
{t("publish.advanceInternal")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{forward === "public" && (
|
||||||
|
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||||
|
<AlertDialogTrigger
|
||||||
|
render={
|
||||||
|
<Button size="sm" disabled={setVisibility.isPending}>
|
||||||
|
{t("publish.publish")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogTitle>{t("publish.confirmTitle")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{t("publish.confirmBody")}</AlertDialogDescription>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t("form.cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
disabled={setVisibility.isPending}
|
||||||
|
onClick={() => go("public")}
|
||||||
|
>
|
||||||
|
{t("publish.confirm")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorKind === "gate" && (
|
||||||
|
<p role="alert" className="mt-2 text-sm text-red-600">
|
||||||
|
{t("publish.gateError")}{" "}
|
||||||
|
<Link to={`/objects/${object.id}/edit`} className="underline">
|
||||||
|
{t("publish.editLink")}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{errorKind === "illegal" && (
|
||||||
|
<p role="alert" className="mt-2 text-sm text-red-600">
|
||||||
|
{t("publish.illegalError")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{errorKind === "other" && (
|
||||||
|
<p role="alert" className="mt-2 text-sm text-red-600">
|
||||||
|
{t("form.rejected")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export function SelectPrompt() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center p-4 text-sm text-neutral-400">
|
||||||
|
{t("objects.selectPrompt")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { adjacentTransitions } from "./transitions";
|
||||||
|
|
||||||
|
test("draft can only go forward to internal", () => {
|
||||||
|
expect(adjacentTransitions("draft")).toEqual({ forward: "internal" });
|
||||||
|
});
|
||||||
|
test("internal can go forward to public and back to draft", () => {
|
||||||
|
expect(adjacentTransitions("internal")).toEqual({ forward: "public", back: "draft" });
|
||||||
|
});
|
||||||
|
test("public can only go back to internal", () => {
|
||||||
|
expect(adjacentTransitions("public")).toEqual({ back: "internal" });
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export type Visibility = "draft" | "internal" | "public";
|
||||||
|
|
||||||
|
/** The legal one-step visibility moves from `v`, per the backend state machine
|
||||||
|
* (Draft<->Internal, Internal<->Public; no skipping). */
|
||||||
|
export function adjacentTransitions(v: Visibility): { forward?: Visibility; back?: Visibility } {
|
||||||
|
switch (v) {
|
||||||
|
case "draft":
|
||||||
|
return { forward: "internal" };
|
||||||
|
case "internal":
|
||||||
|
return { forward: "public", back: "draft" };
|
||||||
|
case "public":
|
||||||
|
return { back: "internal" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 FUTURE = ["vocabularies", "authorities", "fields", "search"] as const;
|
const DISABLED_NAV = ["fields"] as const;
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -30,7 +30,31 @@ export function AppShell() {
|
|||||||
>
|
>
|
||||||
{t("nav.objects")}
|
{t("nav.objects")}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
{FUTURE.map((key) => (
|
<NavLink
|
||||||
|
to="/vocabularies"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("nav.vocabularies")}
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/authorities"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("nav.authorities")}
|
||||||
|
</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) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
disabled
|
disabled
|
||||||
|
|||||||
@@ -32,3 +32,61 @@ export const objectsPage: AdminObjectPage = {
|
|||||||
limit: 50,
|
limit: 50,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
|
||||||
|
export type TermView = components["schemas"]["TermView"];
|
||||||
|
export type AuthorityView = components["schemas"]["AuthorityView"];
|
||||||
|
|
||||||
|
export const fieldDefinitions: FieldDefinitionView[] = [
|
||||||
|
{ key: "inscription", data_type: "text", vocabulary_id: null, authority_kind: null,
|
||||||
|
required: true, group: "Description", labels: [{ lang: "en", label: "Inscription" }, { lang: "sv", label: "Inskription" }] },
|
||||||
|
{ key: "count_seen", data_type: "integer", vocabulary_id: null, authority_kind: null,
|
||||||
|
required: false, group: null, labels: [{ lang: "en", label: "Count seen" }] },
|
||||||
|
{ key: "made_on", data_type: "date", vocabulary_id: null, authority_kind: null,
|
||||||
|
required: false, group: null, labels: [{ lang: "en", label: "Made on" }] },
|
||||||
|
{ key: "is_fragment", data_type: "boolean", vocabulary_id: null, authority_kind: null,
|
||||||
|
required: false, group: null, labels: [{ lang: "en", label: "Is fragment" }] },
|
||||||
|
{ key: "title_ml", data_type: "localized_text", vocabulary_id: null, authority_kind: null,
|
||||||
|
required: false, group: null, labels: [{ lang: "en", label: "Title" }] },
|
||||||
|
{ key: "material", data_type: "term", vocabulary_id: "v-material", authority_kind: null,
|
||||||
|
required: false, group: null, labels: [{ lang: "en", label: "Material" }] },
|
||||||
|
{ key: "maker", data_type: "authority", vocabulary_id: null, authority_kind: "person",
|
||||||
|
required: false, group: null, labels: [{ lang: "en", label: "Maker" }] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const materialTerms: TermView[] = [
|
||||||
|
{ id: "t-bronze", external_uri: null, labels: [{ lang: "en", label: "Bronze" }, { lang: "sv", label: "Brons" }] },
|
||||||
|
{ id: "t-wood", external_uri: null, labels: [{ lang: "en", label: "Wood" }] },
|
||||||
|
];
|
||||||
|
|
||||||
|
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[] = [
|
||||||
|
{ id: "v-material", key: "material" },
|
||||||
|
{ id: "v-technique", key: "technique" },
|
||||||
|
];
|
||||||
|
|||||||
+49
-13
@@ -1,6 +1,6 @@
|
|||||||
import { http, HttpResponse } from "msw";
|
import { http, HttpResponse } from "msw";
|
||||||
|
|
||||||
import { amphora, fibula, objectsPage } 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", () =>
|
||||||
@@ -15,21 +15,57 @@ export const handlers = [
|
|||||||
return found ? HttpResponse.json(found) : new HttpResponse(null, { status: 404 });
|
return found ? HttpResponse.json(found) : new HttpResponse(null, { status: 404 });
|
||||||
}),
|
}),
|
||||||
|
|
||||||
http.get("/api/admin/field-definitions", () =>
|
http.get("/api/admin/field-definitions", () => HttpResponse.json(fieldDefinitions)),
|
||||||
HttpResponse.json([
|
|
||||||
{
|
http.get("/api/admin/vocabularies", () => HttpResponse.json(vocabularies)),
|
||||||
key: "material",
|
|
||||||
data_type: "term",
|
http.get("/api/admin/vocabularies/:id/terms", () => HttpResponse.json(materialTerms)),
|
||||||
vocabulary_id: "v1",
|
|
||||||
authority_kind: null,
|
http.get("/api/admin/authorities", ({ request }) => {
|
||||||
required: false,
|
const kind = new URL(request.url).searchParams.get("kind");
|
||||||
group: null,
|
|
||||||
labels: [{ lang: "en", label: "Material" }],
|
return HttpResponse.json(kind === "person" ? personAuthorities : []);
|
||||||
},
|
}),
|
||||||
]),
|
|
||||||
|
http.post("/api/admin/vocabularies", () =>
|
||||||
|
HttpResponse.json({ id: "v-new", key: "new" }, { status: 201 }),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
http.post("/api/admin/vocabularies/:id/terms", () =>
|
||||||
|
HttpResponse.json({ id: "t-new" }, { status: 201 }),
|
||||||
|
),
|
||||||
|
|
||||||
|
http.post("/api/admin/authorities", () =>
|
||||||
|
HttpResponse.json({ id: "a-new" }, { status: 201 }),
|
||||||
|
),
|
||||||
|
|
||||||
|
http.post("/api/admin/objects", () =>
|
||||||
|
HttpResponse.json({ id: "11111111-1111-1111-1111-111111111111" }, { status: 201 }),
|
||||||
|
),
|
||||||
|
|
||||||
|
http.put("/api/admin/objects/:id", () => new HttpResponse(null, { status: 204 })),
|
||||||
|
|
||||||
|
http.put("/api/admin/objects/:id/fields", () => 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 })),
|
||||||
|
|
||||||
|
http.post("/api/admin/objects/:id/visibility", () => new HttpResponse(null, { status: 204 })),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export function SelectVocabularyPrompt() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center p-4 text-sm text-neutral-400">
|
||||||
|
{t("vocab.selectPrompt")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
import { VocabularyList } from "./vocabulary-list";
|
||||||
|
|
||||||
|
export function VocabulariesPage() {
|
||||||
|
return (
|
||||||
|
<div className="grid h-full grid-cols-[20rem_1fr]">
|
||||||
|
<div className="overflow-hidden border-r">
|
||||||
|
<VocabularyList />
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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 { Routes, Route } from "react-router-dom";
|
||||||
|
import { server } from "../test/server";
|
||||||
|
import { renderApp } from "../test/render";
|
||||||
|
import { VocabulariesPage } from "./vocabularies-page";
|
||||||
|
import { VocabularyTerms } from "./vocabulary-terms";
|
||||||
|
import { SelectVocabularyPrompt } from "./select-vocabulary-prompt";
|
||||||
|
|
||||||
|
function tree() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/vocabularies" element={<VocabulariesPage />}>
|
||||||
|
<Route index element={<SelectVocabularyPrompt />} />
|
||||||
|
<Route path=":id" element={<VocabularyTerms />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("lists vocabularies and creates one", async () => {
|
||||||
|
let body: unknown;
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/vocabularies", async ({ request }) => {
|
||||||
|
body = await request.json();
|
||||||
|
return HttpResponse.json({ id: "v-c", key: "colour" }, { status: 201 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderApp(tree(), { route: "/vocabularies" });
|
||||||
|
expect(await screen.findByText("material")).toBeInTheDocument();
|
||||||
|
await userEvent.type(screen.getByLabelText(/key/i), "colour");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||||
|
await waitFor(() => expect((body as { key: string })?.key).toBe("colour"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selecting a vocabulary shows its terms and adds one", async () => {
|
||||||
|
let termBody: unknown;
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/vocabularies/:id/terms", async ({ request }) => {
|
||||||
|
termBody = await request.json();
|
||||||
|
return HttpResponse.json({ id: "t-c" }, { status: 201 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderApp(tree(), { route: "/vocabularies/v-material" });
|
||||||
|
expect(await screen.findByText("Bronze")).toBeInTheDocument();
|
||||||
|
await userEvent.type(screen.getByLabelText(/label \(en\)/i), "Stone");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /add term/i }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect((termBody as { labels: { label: string }[] })?.labels[0].label).toBe("Stone"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("add term without EN label shows required alert and does not POST", async () => {
|
||||||
|
let posted = false;
|
||||||
|
server.use(
|
||||||
|
http.post("/api/admin/vocabularies/:id/terms", () => {
|
||||||
|
posted = true;
|
||||||
|
return HttpResponse.json({ id: "t-x" }, { status: 201 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderApp(tree(), { route: "/vocabularies/v-material" });
|
||||||
|
expect(await screen.findByText("Bronze")).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /add term/i }));
|
||||||
|
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||||
|
expect(posted).toBe(false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { NavLink } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useVocabularies, useCreateVocabulary } from "../api/queries";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
export function VocabularyList() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useVocabularies();
|
||||||
|
|
||||||
|
const create = useCreateVocabulary();
|
||||||
|
|
||||||
|
const [key, setKey] = useState("");
|
||||||
|
|
||||||
|
const onCreate = (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!key.trim()) return;
|
||||||
|
|
||||||
|
create.mutate({ key: key.trim() }, { onSuccess: () => setKey("") });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<form onSubmit={onCreate} className="space-y-1 border-b p-3">
|
||||||
|
<div className="text-sm font-medium">{t("vocab.newVocabulary")}</div>
|
||||||
|
<Label htmlFor="vocab-key">{t("vocab.key")}</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="vocab-key"
|
||||||
|
value={key}
|
||||||
|
onChange={(e) => setKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="sm" disabled={create.isPending}>
|
||||||
|
{t("vocab.create")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{create.isError && (
|
||||||
|
<p role="alert" className="text-xs text-red-600">
|
||||||
|
{t("form.rejected")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
<ul className="flex-1 overflow-auto">
|
||||||
|
{isLoading && (
|
||||||
|
<li className="p-3 text-sm text-neutral-400">…</li>
|
||||||
|
)}
|
||||||
|
{isError && (
|
||||||
|
<li className="p-3 text-sm text-red-600">{t("vocab.loadError")}</li>
|
||||||
|
)}
|
||||||
|
{data?.length === 0 && (
|
||||||
|
<li className="p-3 text-sm text-neutral-500">{t("vocab.empty")}</li>
|
||||||
|
)}
|
||||||
|
{data?.map((v) => (
|
||||||
|
<li key={v.id}>
|
||||||
|
<NavLink
|
||||||
|
to={`/vocabularies/${v.id}`}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`block border-b px-3 py-2 text-sm ${isActive ? "bg-indigo-50" : "hover:bg-neutral-50"}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{v.key}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import type { components } from "../api/schema";
|
||||||
|
import { useTerms, useAddTerm } from "../api/queries";
|
||||||
|
import { LabelEditor } from "../components/label-editor";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { labelText } from "../lib/labels";
|
||||||
|
|
||||||
|
type LabelInput = components["schemas"]["LabelInput"];
|
||||||
|
|
||||||
|
export function VocabularyTerms() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
|
const { id } = useParams();
|
||||||
|
|
||||||
|
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
|
||||||
|
|
||||||
|
const { data: terms } = useTerms(id);
|
||||||
|
|
||||||
|
const addTerm = useAddTerm();
|
||||||
|
|
||||||
|
const [labels, setLabels] = useState<LabelInput[]>([]);
|
||||||
|
|
||||||
|
const [uri, setUri] = useState("");
|
||||||
|
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
const onAdd = (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (!labels.some((l) => l.lang === "en" && l.label)) {
|
||||||
|
setError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(false);
|
||||||
|
|
||||||
|
addTerm.mutate(
|
||||||
|
{ vocabularyId: id, external_uri: uri.trim() || null, labels },
|
||||||
|
{ onSuccess: () => { setLabels([]); setUri(""); } },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto p-4">
|
||||||
|
<h3 className="mb-2 text-sm font-medium uppercase text-neutral-500">
|
||||||
|
{t("vocab.terms")}
|
||||||
|
</h3>
|
||||||
|
<ul className="mb-4">
|
||||||
|
{terms?.length === 0 && (
|
||||||
|
<li className="text-sm text-neutral-500">{t("vocab.noTerms")}</li>
|
||||||
|
)}
|
||||||
|
{terms?.map((term) => (
|
||||||
|
<li key={term.id} className="border-b py-1 text-sm">
|
||||||
|
{labelText(term.labels, lang)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<form onSubmit={onAdd} className="space-y-2 border-t pt-3">
|
||||||
|
<div className="text-sm font-medium">{t("vocab.addTerm")}</div>
|
||||||
|
<LabelEditor value={labels} onChange={setLabels} />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="term-uri">{t("labels.externalUri")}</Label>
|
||||||
|
<Input
|
||||||
|
id="term-uri"
|
||||||
|
value={uri}
|
||||||
|
onChange={(e) => setUri(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-xs text-red-600">
|
||||||
|
{t("form.required")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{addTerm.isError && (
|
||||||
|
<p role="alert" className="text-xs text-red-600">
|
||||||
|
{t("form.rejected")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button type="submit" size="sm" disabled={addTerm.isPending}>
|
||||||
|
{t("vocab.addTerm")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user