Compare commits

...

41 Commits

Author SHA1 Message Date
logaritmisk 2d0b76ab34 merge: frontend M5 — full-text search (endpoint + /search UI)
CI / web (push) Has been cancelled
GET /api/admin/search backed by Meilisearch (highlighted hits, visibility
filter, offset/limit, estimated total; 503 when search unconfigured). /search
two-pane screen: debounced query, visibility pills, URL-synced + bookmarkable,
infinite 'Load more', XSS-safe sentinel highlighting, ObjectDetail reuse for
the detail pane. Search nav enabled (only Fields remains stubbed).

Backend search+api tests green; web 68 tests; bundle 145.1 KB gz.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:54:12 +02:00
logaritmisk 4dd00362b8 polish(web): search pill aria-pressed, keepPreviousData, plural result count, URL-hydration test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 12:47:38 +02:00
logaritmisk 358d793e44 feat(web): /search two-pane screen (debounced query, visibility filter, load more) + nav
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 12:40:46 +02:00
logaritmisk ee65b27595 feat(web): Highlight (XSS-safe) + SearchResultRow components 2026-06-04 12:34:27 +02:00
logaritmisk de830999d4 test(web): embed highlight sentinels in search fixture snippet 2026-06-04 12:32:23 +02:00
logaritmisk 18ed9bd947 feat(web): useSearch infinite query + useDebouncedValue + MSW search handler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 12:29:11 +02:00
logaritmisk 90a1539090 test(api): cover search visibility-filter narrowing; note pagination cap rationale
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 12:26:38 +02:00
logaritmisk a87501b902 feat(api): GET /api/admin/search endpoint + regenerated client types
Expose full-text search over catalogue objects via a new admin endpoint
backed by the Meilisearch SearchClient. Validates visibility filter values,
short-circuits on empty queries, clamps pagination, and returns 503 when
search is not configured. Registered in OpenAPI; schema.d.ts regenerated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 11:48:32 +02:00
logaritmisk 9b1771d584 refactor(search): document+guard visibility filter precondition; drop redundant dev-dep
- Remove serde_json from [dev-dependencies] (already in [dependencies])
- Add debug_assert! in search_objects visibility filter as defense-in-depth
- Extend search_objects doc-comment with visibility precondition
- Clarify estimated_total_hits.unwrap_or(0) is safe under offset/limit pagination
- Add brief comment on with_crop_length(20) explaining ~20-word context window

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 11:19:46 +02:00
logaritmisk 84c4c2807b feat(search): search_objects returns highlighted hits + estimated total
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 10:46:54 +02:00
logaritmisk 38e4525404 docs(plans): frontend M5 search — backend endpoint + /search UI, 6 tasks
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:27:59 +02:00
logaritmisk a9208f56fe docs(specs): frontend M5 search — endpoint + /search two-pane UI design
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:17:29 +02:00
logaritmisk 18a19eec16 merge: frontend M4 — vocabulary & authority management (create+list)
Vocabularies two-pane screen (list/create + per-vocab terms/add), Authorities
kind-tabbed screen (list/create), shared sv/en LabelEditor, 4 new query hooks,
nav enabled for both surfaces. 57 tests, bundle 143.1 KB gz.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:42:39 +02:00
logaritmisk 352d899fa5 fix(web): authorities unknown-kind redirect, extract labelText util, EN-required test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 09:39:15 +02:00
logaritmisk 38673e52ba feat(web): authorities kind-tabbed screen (list/create) + nav
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 09:32:57 +02:00
logaritmisk 02e4f34a1b fix(web): vocab form-level error states, newVocabulary heading, id guard, EN-required test 2026-06-04 09:29:51 +02:00
logaritmisk ac30eadbb2 feat(web): vocabularies two-pane screen (list/create + terms/add) + nav
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 09:22:38 +02:00
logaritmisk e8d173a18f refactor(web): LabelEditor ignores blank labels; revert gratuitous tsconfig ES2022 bump
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:19:27 +02:00
logaritmisk 8d2323ed95 feat(web): shared sv/en LabelEditor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 09:14:16 +02:00
logaritmisk 6afc358334 feat(web): vocabulary/term/authority list+create hooks + MSW handlers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 09:08:15 +02:00
logaritmisk 26e10704a9 docs(plan): frontend SPA milestone 4 — task-by-task implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:05:10 +02:00
logaritmisk 684b5449ca docs(spec): frontend SPA milestone 4 (vocabulary & authority management) design
Two-pane vocab (list/create + terms/add) + kind-tabbed authorities
(list/create); shared sv/en LabelEditor; create+list only (no backend
edit/delete yet); 4 new hooks; enables the nav stubs; Vitest+RTL+MSW.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 09:00:02 +02:00
logaritmisk 7a8e7ff2d7 feat(web): show the publish control on the object detail
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 08:41:32 +02:00
logaritmisk 34d4ed2fd6 fix(web): disable publish-confirm while pending; aria-current on stepper
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 08:39:20 +02:00
logaritmisk 39b7fc51e9 feat(web): PublishControl stepper (legal one-step moves, confirm-on-public, gate/illegal errors)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 08:35:02 +02:00
logaritmisk 01f757a239 feat(web): useSetVisibility hook + adjacentTransitions helper + MSW handler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 08:30:15 +02:00
logaritmisk 516ecf3e95 docs(plan): frontend SPA milestone 3 — task-by-task implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 08:25:53 +02:00
logaritmisk f206ee8995 docs(spec): frontend SPA milestone 3 (publishing workflow) design
Segmented Draft->Internal->Public stepper on the object detail; legal
one-step moves only; confirm on ->Public; surfaces the 422 publish-gate
(generic + Edit link) and 409 illegal-transition; useSetVisibility +
adjacentTransitions helper; Vitest+RTL+MSW.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:58:22 +02:00
logaritmisk bb05331a3f chore(web): remove unused shadcn Select (term/authority use native select)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:11:23 +02:00
logaritmisk 1cf36e39cc chore(web): finalize object authoring — i18n parity + verification
Lazy-load ObjectNewPage and ObjectEditForm routes to bring the initial
JS bundle under the 150 KB gz budget (was 159 KB, now 140 KB).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 01:06:59 +02:00
logaritmisk eedeb179e3 fix(web): handle delete failure in confirm dialog (no unhandled rejection)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:04:58 +02:00
logaritmisk 5087e34280 feat(web): delete object with confirm dialog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 01:00:57 +02:00
logaritmisk 9880f24dd2 feat(web): nested object routes + in-pane edit form + edit flow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 00:54:02 +02:00
logaritmisk 22b37c138b test(web): assert partial-create passes fieldsError state to the edit route 2026-06-04 00:50:43 +02:00
logaritmisk 30d851182e feat(web): new-object full-width page + create flow + /objects/new
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 00:46:49 +02:00
logaritmisk 616c232a22 feat(web): ObjectForm (core + dynamic flexible fields, RHF, validation)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 00:40:19 +02:00
logaritmisk cf0b34b254 refactor(web): FieldInput form type unknown (drop any); wire localized_text required; a11y/comment nits
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 00:36:04 +02:00
logaritmisk cb191225cc feat(web): dynamic FieldInput (text/integer/date/boolean/localized_text/term/authority)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 00:31:05 +02:00
logaritmisk b23a48c310 feat(web): authoring query/mutation hooks + MSW handlers + shadcn select/checkbox/alert-dialog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 00:22:59 +02:00
logaritmisk f3bab3336c docs(plan): frontend SPA milestone 2 — task-by-task implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:16:22 +02:00
logaritmisk 9f43793c4a docs(spec): frontend SPA milestone 2 (object authoring) design
Create (full-width /objects/new) + edit (in-pane /objects/:id/edit) +
delete; dynamic flexible-field form (all 7 field types incl. term/authority
Selects + sv/en localized text) via react-hook-form; replace-semantics
field save; client validation + partial-create recovery; Vitest+RTL+MSW.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 00:09:15 +02:00
71 changed files with 8243 additions and 51 deletions
+114
View File
@@ -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))
}
+2
View File
@@ -3,6 +3,7 @@
mod admin;
mod admin_authorities;
mod admin_objects;
mod admin_search;
mod admin_vocab;
mod health;
mod openapi;
@@ -63,6 +64,7 @@ pub fn build_app(state: AppState) -> Router {
.merge(admin::routes())
.merge(admin_objects::routes())
.merge(admin_vocab::routes())
.merge(admin_search::routes())
.merge(admin_authorities::routes())
.layer(session_layer)
.with_state(state)
+6 -1
View File
@@ -1,7 +1,9 @@
use axum::{Json, Router, extract::State, routing::get};
use utoipa::OpenApi;
use crate::{AppState, admin, admin_authorities, admin_objects, admin_vocab, health, public};
use crate::{
AppState, admin, admin_authorities, admin_objects, admin_search, admin_vocab, health, public,
};
#[derive(OpenApi)]
#[openapi(
@@ -26,6 +28,7 @@ use crate::{AppState, admin, admin_authorities, admin_objects, admin_vocab, heal
admin_vocab::create_vocabulary,
admin_vocab::list_terms,
admin_vocab::add_term,
admin_search::search_objects,
admin_authorities::list_authorities,
admin_authorities::create_authority
),
@@ -50,6 +53,8 @@ use crate::{AppState, admin, admin_authorities, admin_objects, admin_vocab, heal
admin_vocab::LabelInput,
admin_vocab::TermView,
admin_vocab::CreatedId,
admin_search::SearchHitView,
admin_search::SearchResultsView,
admin_authorities::AuthorityView,
admin_authorities::NewAuthorityRequest
)),
+307
View File
@@ -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);
}
+1 -1
View File
@@ -11,10 +11,10 @@ thiserror.workspace = true
domain = { path = "../domain" }
db = { path = "../db" }
sqlx.workspace = true
serde_json.workspace = true
[dev-dependencies]
tokio.workspace = true
uuid.workspace = true
serde_json.workspace = true
sqlx.workspace = true
domain = { path = "../domain" }
+134
View File
@@ -8,6 +8,7 @@
use db::Db;
use domain::{CatalogueObject, ObjectId};
use meilisearch_sdk::search::Selectors;
use meilisearch_sdk::tasks::Task;
use serde::{Deserialize, Serialize};
@@ -39,6 +40,31 @@ pub struct SearchDocument {
pub fields_text: Vec<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.
#[derive(Clone)]
pub struct SearchClient {
@@ -147,6 +173,79 @@ impl SearchClient {
.collect()
}
/// Full-text query returning display-ready hits with highlighted snippets and the
/// estimated total match count. `visibility`, when set, filters on the indexed
/// `visibility` attribute. Pagination is offset/limit.
///
/// # 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
/// commits: re-project and index it if it still exists, otherwise remove it. This
/// is the uniform on-write path for create/update/delete/field/visibility changes —
@@ -272,3 +371,38 @@ pub async fn build_document(
fields_text,
})
}
/// Pick the best snippet from Meilisearch's `_formatted` map: prefer a highlighted
/// `brief_description`, then a highlighted `fields_text` entry, then `object_name`;
/// fall back to an unhighlighted `brief_description` so a hit still shows context.
fn extract_snippet(formatted: &serde_json::Map<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
}
+50 -1
View File
@@ -1,4 +1,4 @@
use search::{SearchClient, SearchDocument};
use search::{self, SearchClient, SearchDocument};
fn meili() -> (String, String) {
(
@@ -51,6 +51,55 @@ async fn index_search_and_remove() {
assert!(client.search("wood").await.unwrap().is_empty());
}
#[tokio::test]
async fn search_objects_returns_hits_with_highlight_filter_and_paging() {
let (url, key) = meili();
let client = SearchClient::connect(&url, &key, &unique_index()).unwrap();
client.ensure_index().await.unwrap();
let a = domain::ObjectId::new();
let b = domain::ObjectId::new();
let c = domain::ObjectId::new();
let mut bronze_a = doc(
&a.to_string(),
"Bronze figurine",
&["cast bronze with green patina"],
);
bronze_a.visibility = "public".to_string();
let mut bronze_b = doc(&b.to_string(), "Ceremonial bowl", &["bronze alloy rim"]);
bronze_b.visibility = "public".to_string();
let mut bronze_c = doc(&c.to_string(), "Door fitting", &["bronze hinge"]);
bronze_c.visibility = "draft".to_string();
client.index_object(&bronze_a).await.unwrap();
client.index_object(&bronze_b).await.unwrap();
client.index_object(&bronze_c).await.unwrap();
let results = client.search_objects("bronze", None, 0, 20).await.unwrap();
assert_eq!(results.estimated_total, 3);
assert_eq!(results.hits.len(), 3);
let hit = results.hits.iter().find(|h| h.id == a.to_string()).unwrap();
assert_eq!(hit.object_name, "Bronze figurine");
assert_eq!(hit.object_number, format!("N-{a}"));
let snippet = hit.snippet.as_ref().expect("a matched snippet");
assert!(
snippet.contains(search::HL_PRE),
"snippet must mark the match"
);
assert!(snippet.contains(search::HL_POST));
let public = client
.search_objects("bronze", Some("public"), 0, 20)
.await
.unwrap();
assert_eq!(public.estimated_total, 2);
assert!(public.hits.iter().all(|h| h.visibility == "public"));
let page = client.search_objects("bronze", None, 0, 1).await.unwrap();
assert_eq!(page.hits.len(), 1);
assert_eq!(page.estimated_total, 3);
}
#[tokio::test]
async fn ensure_index_is_idempotent() {
let (url, key) = meili();
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 13. ✓
- 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 masterdetail (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 (M1M3 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 12 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 24 + Task 5 check. ✓
- Testing Vitest+RTL+MSW → Tasks 14. ✓
- 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 34; `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 34 + 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 12 (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 13 (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 masterdetail 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 14 (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 M2M4 — 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
masterdetail 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).
+1
View File
@@ -25,6 +25,7 @@
"openapi-fetch": "^0.17.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.77.0",
"react-i18next": "^17.0.8",
"react-router-dom": "^7.16.0",
"tailwind-merge": "^3.6.0",
+13
View File
@@ -38,6 +38,9 @@ importers:
react-dom:
specifier: ^19.1.0
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:
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)
@@ -2420,6 +2423,12 @@ packages:
peerDependencies:
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:
resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==}
peerDependencies:
@@ -5200,6 +5209,10 @@ snapshots:
react: 19.2.7
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):
dependencies:
'@babel/runtime': 7.29.7
+67
View File
@@ -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);
});
});
+25
View File
@@ -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
View File
@@ -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 type { components } from "./schema";
@@ -95,3 +95,251 @@ export function useLogout() {
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"] });
},
});
}
+38
View File
@@ -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 });
});
});
+50
View File
@@ -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");
});
});
+83
View File
@@ -168,6 +168,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/search": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["search_objects"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/users": {
parameters: {
query?: never;
@@ -430,6 +446,19 @@ export interface components {
/** @description `"ok"` when ready, `"degraded"` otherwise. */
status: string;
};
SearchHitView: {
brief_description?: string | null;
id: string;
object_name: string;
object_number: string;
snippet?: string | null;
visibility: string;
};
SearchResultsView: {
/** @description Meilisearch's estimate of the total number of matches. */
estimated_total: number;
hits: components["schemas"]["SearchHitView"][];
};
TermView: {
external_uri?: string | null;
id: string;
@@ -947,6 +976,60 @@ export interface operations {
};
};
};
search_objects: {
parameters: {
query: {
/** @description Search query text */
q: string;
/** @description Filter: draft|internal|public */
visibility?: string;
/** @description default 0 */
offset?: number;
/** @description 1..=50, default 20 */
limit?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SearchResultsView"];
};
};
/** @description Invalid visibility value */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Search is not configured */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
list_users: {
parameters: {
query?: never;
+51 -2
View File
@@ -1,9 +1,30 @@
import { lazy, Suspense } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { RequireAuth } from "./auth/require-auth";
import { LoginPage } from "./auth/login-page";
import { AppShell } from "./shell/app-shell";
import { ObjectsPage } from "./objects/objects-page";
import { ObjectDetail } from "./objects/object-detail";
import { SelectPrompt } from "./objects/select-prompt";
import { SearchPage } from "./search/search-page";
import { SelectSearchPrompt } from "./search/select-search-prompt";
import { VocabulariesPage } from "./vocab/vocabularies-page";
import { VocabularyTerms } from "./vocab/vocabulary-terms";
import { SelectVocabularyPrompt } from "./vocab/select-vocabulary-prompt";
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() {
return (
@@ -12,8 +33,36 @@ export function App() {
<Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth />}>
<Route element={<AppShell />}>
<Route path="/objects" element={<ObjectsPage />} />
<Route path="/objects/:id" element={<ObjectsPage />} />
<Route
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>
</Route>
+97
View File
@@ -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>
);
}
+57
View File
@@ -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();
});
+37
View File
@@ -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);
});
+47
View File
@@ -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>
);
}
+185
View File
@@ -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,
}
+29
View File
@@ -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
View File
@@ -2,7 +2,44 @@
"app": { "name": "Collection" },
"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" },
"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" },
"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
View File
@@ -2,7 +2,44 @@
"app": { "name": "Samling" },
"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" },
"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" },
"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."
}
}
+12
View File
@@ -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 ??
""
);
}
+24
View File
@@ -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");
});
+14
View File
@@ -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();
});
+65
View File
@@ -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>
);
}
+39
View File
@@ -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();
});
+270
View File
@@ -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>
);
}
+10 -1
View File
@@ -38,7 +38,9 @@ test("renders inventory-minimum fields, flexible values and visibility", async (
expect(await screen.findByText("Amphora")).toBeInTheDocument();
expect(screen.getByText("Vault 3")).toBeInTheDocument();
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 () => {
@@ -46,3 +48,10 @@ test("shows a not-found state for a missing object", async () => {
renderApp(tree(), { route: "/objects/does-not-exist" });
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();
});
+8 -1
View File
@@ -1,7 +1,9 @@
import { useParams } from "react-router-dom";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useObject, useFieldDefinitions } from "../api/queries";
import { DeleteObjectDialog } from "./delete-object-dialog";
import { PublishControl } from "./publish-control";
import { VisibilityBadge } from "./visibility-badge";
import { Skeleton } from "@/components/ui/skeleton";
@@ -57,6 +59,10 @@ export function ObjectDetail() {
<div className="mb-4 flex items-center gap-3">
<h2 className="text-xl font-semibold">{object.object_name}</h2>
<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>
<Field label={t("fieldsLabels.objectNumber")} value={object.object_number} />
<Field label={t("fieldsLabels.count")} value={object.number_of_objects} />
@@ -85,6 +91,7 @@ export function ObjectDetail() {
))}
</div>
)}
<PublishControl object={object} />
</div>
);
}
+49
View File
@@ -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");
});
+62
View File
@@ -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}`)}
/>
);
}
+49
View File
@@ -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();
});
+195
View File
@@ -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;
}
+25 -3
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { NavLink } from "react-router-dom";
import { Link, NavLink } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -15,22 +15,43 @@ export function ObjectList() {
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) {
return (
<div>
{header}
<div className="space-y-2 p-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-9 w-full" />
))}
</div>
</div>
);
}
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) {
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;
@@ -38,6 +59,7 @@ export function ObjectList() {
return (
<div className="flex h-full flex-col">
{header}
<ul className="flex-1 overflow-auto">
{data.items.map((object) => (
<li key={object.id}>
+71
View File
@@ -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());
});
+54
View File
@@ -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>
);
}
+6 -2
View File
@@ -4,12 +4,16 @@ import userEvent from "@testing-library/user-event";
import { Routes, Route } from "react-router-dom";
import { renderApp } from "../test/render";
import { ObjectsPage } from "./objects-page";
import { ObjectDetail } from "./object-detail";
import { SelectPrompt } from "./select-prompt";
function tree() {
return (
<Routes>
<Route path="/objects" element={<ObjectsPage />} />
<Route path="/objects/:id" element={<ObjectsPage />} />
<Route path="/objects" element={<ObjectsPage />}>
<Route index element={<SelectPrompt />} />
<Route path=":id" element={<ObjectDetail />} />
</Route>
</Routes>
);
}
+2 -13
View File
@@ -1,26 +1,15 @@
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Outlet } from "react-router-dom";
import { ObjectList } from "./object-list";
import { ObjectDetail } from "./object-detail";
export function ObjectsPage() {
const { t } = useTranslation();
const { id } = useParams();
return (
<div className="grid h-full grid-cols-[20rem_1fr]">
<div className="overflow-hidden border-r">
<ObjectList />
</div>
<div className="overflow-hidden">
{id ? (
<ObjectDetail />
) : (
<div className="flex h-full items-center justify-center p-4 text-sm text-neutral-400">
{t("objects.selectPrompt")}
</div>
)}
<Outlet />
</div>
</div>
);
+79
View File
@@ -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();
});
+139
View File
@@ -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>
);
}
+11
View File
@@ -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>
);
}
+13
View File
@@ -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" });
});
+14
View File
@@ -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" };
}
}
+16
View File
@@ -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();
});
+42
View File
@@ -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}</>;
}
+16
View File
@@ -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>
);
}
+129
View File
@@ -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>
);
}
+31
View File
@@ -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>
);
}
+90
View File
@@ -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");
});
+11
View File
@@ -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>
);
}
+3 -2
View File
@@ -29,8 +29,9 @@ test("shows active and disabled nav and renders the outlet", async () => {
renderApp(tree(), { route: "/objects" });
expect(await screen.findByText("objects outlet")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /objects/i })).toBeInTheDocument();
// later milestones are present but disabled
expect(screen.getByRole("button", { name: /search/i })).toBeDisabled();
// fields is still disabled; search is now a link
expect(screen.getByRole("link", { name: /search/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /fields/i })).toBeDisabled();
});
test("language switch toggles to Swedish", async () => {
+26 -2
View File
@@ -5,7 +5,7 @@ import { useLogout } from "../api/queries";
import { Button } from "@/components/ui/button";
import { LangSwitch } from "./lang-switch";
const FUTURE = ["vocabularies", "authorities", "fields", "search"] as const;
const DISABLED_NAV = ["fields"] as const;
export function AppShell() {
const { t } = useTranslation();
@@ -30,7 +30,31 @@ export function AppShell() {
>
{t("nav.objects")}
</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
key={key}
disabled
+58
View File
@@ -32,3 +32,61 @@ export const objectsPage: AdminObjectPage = {
limit: 50,
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
View File
@@ -1,6 +1,6 @@
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 = [
http.get("/api/admin/me", () =>
@@ -15,21 +15,57 @@ export const handlers = [
return found ? HttpResponse.json(found) : new HttpResponse(null, { status: 404 });
}),
http.get("/api/admin/field-definitions", () =>
HttpResponse.json([
{
key: "material",
data_type: "term",
vocabulary_id: "v1",
authority_kind: null,
required: false,
group: null,
labels: [{ lang: "en", label: "Material" }],
},
]),
http.get("/api/admin/field-definitions", () => HttpResponse.json(fieldDefinitions)),
http.get("/api/admin/vocabularies", () => HttpResponse.json(vocabularies)),
http.get("/api/admin/vocabularies/:id/terms", () => HttpResponse.json(materialTerms)),
http.get("/api/admin/authorities", ({ request }) => {
const kind = new URL(request.url).searchParams.get("kind");
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/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>
);
}
+16
View File
@@ -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>
);
}
+68
View File
@@ -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);
});
+73
View File
@@ -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>
);
}
+92
View File
@@ -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>
);
}