merge: reference-data edit/delete lifecycle (#30 + #36)
CI / web (push) Has been cancelled

Backend update/delete endpoints (audited, 409+count when referenced) and in-place
frontend edit/delete UI for vocabularies (rename), terms, authorities, and field
definitions. Shared DeleteConfirmDialog; Storybook stories. Closes #30, #36.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 22:30:00 +02:00
30 changed files with 3226 additions and 99 deletions
+125 -8
View File
@@ -3,18 +3,19 @@
use auth::{Authorized, EditCatalogue, ViewInternal};
use axum::{
Json, Router,
extract::{Query, State},
extract::{Path, Query, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
};
use domain::{AuditActor, AuthorityKind, LocalizedLabel, NewAuthority};
use domain::{AuditActor, AuthorityId, AuthorityKind, LocalizedLabel, NewAuthority};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::{
AppState,
admin_objects::LabelView,
admin_vocab::{CreatedId, LabelInput},
admin_vocab::{CreatedId, InUseView, LabelInput},
};
#[derive(Serialize, ToSchema)]
@@ -129,9 +130,125 @@ pub(crate) async fn create_authority(
Ok((StatusCode::CREATED, Json(CreatedId { id: id.to_string() })))
}
pub(crate) fn routes() -> Router<AppState> {
Router::new().route(
"/api/admin/authorities",
get(list_authorities).post(create_authority),
)
#[derive(Deserialize, ToSchema)]
pub(crate) struct UpdateAuthorityRequest {
pub external_uri: Option<String>,
pub labels: Vec<LabelInput>,
}
#[utoipa::path(
patch, path = "/api/admin/authorities/{id}",
request_body = UpdateAuthorityRequest,
params(("id" = String, Path, description = "Authority id (UUID)")),
responses(
(status = 204),
(status = 401),
(status = 403),
(status = 404)
)
)]
pub(crate) async fn update_authority(
auth: Authorized<EditCatalogue>,
State(state): State<AppState>,
Path(id): Path<String>,
Json(req): Json<UpdateAuthorityRequest>,
) -> Result<StatusCode, StatusCode> {
let id = id
.parse::<AuthorityId>()
.map_err(|_| StatusCode::NOT_FOUND)?;
let labels: Vec<LocalizedLabel> = req
.labels
.into_iter()
.map(|l| LocalizedLabel {
lang: l.lang,
label: l.label,
})
.collect();
let mut tx = state
.db
.pool()
.begin()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let existed = db::authority::update_authority(
&mut tx,
AuditActor::User(auth.user.id.to_uuid()),
id,
req.external_uri.as_deref(),
&labels,
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if existed {
tx.commit()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
} else {
let _ = tx.rollback().await;
Err(StatusCode::NOT_FOUND)
}
}
#[utoipa::path(
delete, path = "/api/admin/authorities/{id}",
params(("id" = String, Path, description = "Authority id (UUID)")),
responses(
(status = 204),
(status = 401),
(status = 403),
(status = 404),
(status = 409, body = InUseView, description = "Referenced by catalogue objects")
)
)]
pub(crate) async fn delete_authority(
auth: Authorized<EditCatalogue>,
State(state): State<AppState>,
Path(id): Path<String>,
) -> Response {
let Ok(id) = id.parse::<AuthorityId>() else {
return StatusCode::NOT_FOUND.into_response();
};
let Ok(mut tx) = state.db.pool().begin().await else {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
};
match db::authority::delete_authority(&mut tx, AuditActor::User(auth.user.id.to_uuid()), id)
.await
{
Ok(db::DeleteOutcome::Deleted) => match tx.commit().await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
},
Ok(db::DeleteOutcome::InUse { count }) => {
let _ = tx.rollback().await;
(StatusCode::CONFLICT, Json(InUseView { count })).into_response()
}
Ok(db::DeleteOutcome::NotFound) => {
let _ = tx.rollback().await;
StatusCode::NOT_FOUND.into_response()
}
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
pub(crate) fn routes() -> Router<AppState> {
Router::new()
.route(
"/api/admin/authorities",
get(list_authorities).post(create_authority),
)
.route(
"/api/admin/authorities/{id}",
axum::routing::patch(update_authority).delete(delete_authority),
)
}
+132 -1
View File
@@ -6,7 +6,7 @@ use axum::{
Json, Router,
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
response::{IntoResponse, Response},
routing::{get, put},
};
use domain::{
@@ -510,6 +510,133 @@ pub(crate) async fn create_field_definition(
}
}
/// Fields that may be changed on an existing field definition. `key`, `data_type`, and
/// binding are immutable and intentionally absent from this request.
#[derive(Deserialize, ToSchema)]
pub(crate) struct UpdateFieldDefinitionRequest {
pub required: bool,
pub group: Option<String>,
pub labels: Vec<LabelInput>,
}
/// Update a field definition's mutable attributes (labels, group, required).
/// `key`, `data_type`, and binding are immutable. Requires `EditCatalogue`.
#[utoipa::path(
patch, path = "/api/admin/field-definitions/{key}",
request_body = UpdateFieldDefinitionRequest,
params(("key" = String, Path, description = "Field definition key")),
responses(
(status = 204),
(status = 401),
(status = 403),
(status = 404),
(status = 422, description = "CHECK constraint violated (e.g. empty label)")
)
)]
pub(crate) async fn update_field_definition(
auth: Authorized<EditCatalogue>,
State(state): State<AppState>,
Path(key): Path<String>,
Json(req): Json<UpdateFieldDefinitionRequest>,
) -> Result<StatusCode, StatusCode> {
let labels: Vec<LocalizedLabel> = req
.labels
.into_iter()
.map(|l| LocalizedLabel {
lang: l.lang,
label: l.label,
})
.collect();
let mut tx = state
.db
.pool()
.begin()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let result = db::fields::update_field_definition(
&mut tx,
actor(&auth.user),
&key,
req.required,
req.group.as_deref(),
&labels,
)
.await;
match result {
Ok(true) => {
tx.commit()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
}
Ok(false) => {
let _ = tx.rollback().await;
Err(StatusCode::NOT_FOUND)
}
Err(err) => {
let _ = tx.rollback().await;
match err.as_database_error().and_then(|e| e.code()).as_deref() {
Some("23514") => Err(StatusCode::UNPROCESSABLE_ENTITY),
_ => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
}
}
/// Delete a field definition. Blocked (409) when catalogue objects store a value under
/// this key. Requires `EditCatalogue`.
#[utoipa::path(
delete, path = "/api/admin/field-definitions/{key}",
params(("key" = String, Path, description = "Field definition key")),
responses(
(status = 204),
(status = 401),
(status = 403),
(status = 404),
(status = 409, body = crate::admin_vocab::InUseView,
description = "Field is used by catalogue objects")
)
)]
pub(crate) async fn delete_field_definition(
auth: Authorized<EditCatalogue>,
State(state): State<AppState>,
Path(key): Path<String>,
) -> Response {
use crate::admin_vocab::InUseView;
let Ok(mut tx) = state.db.pool().begin().await else {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
};
match db::fields::delete_field_definition(&mut tx, actor(&auth.user), &key).await {
Ok(db::DeleteOutcome::Deleted) => match tx.commit().await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
},
Ok(db::DeleteOutcome::InUse { count }) => {
let _ = tx.rollback().await;
(StatusCode::CONFLICT, Json(InUseView { count })).into_response()
}
Ok(db::DeleteOutcome::NotFound) => {
let _ = tx.rollback().await;
StatusCode::NOT_FOUND.into_response()
}
Err(_) => {
let _ = tx.rollback().await;
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
/// Field-level rejection detail for `set_fields`, so the UI can highlight the field.
#[derive(Serialize, ToSchema)]
pub(crate) struct FieldErrorView {
@@ -609,4 +736,8 @@ pub(crate) fn routes() -> Router<AppState> {
"/api/admin/field-definitions",
get(list_field_definitions).post(create_field_definition),
)
.route(
"/api/admin/field-definitions/{key}",
axum::routing::patch(update_field_definition).delete(delete_field_definition),
)
}
+250 -1
View File
@@ -5,9 +5,10 @@ use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
};
use domain::{AuditActor, LocalizedLabel, NewTerm, VocabularyId};
use domain::{AuditActor, LocalizedLabel, NewTerm, TermId, VocabularyId};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
@@ -221,14 +222,262 @@ pub(crate) async fn add_term(
))
}
/// 409 body: how many catalogue objects still reference the entity.
#[derive(Serialize, ToSchema)]
pub(crate) struct InUseView {
pub count: i64,
}
#[derive(Deserialize, ToSchema)]
pub(crate) struct UpdateTermRequest {
pub external_uri: Option<String>,
pub labels: Vec<LabelInput>,
}
#[utoipa::path(
patch, path = "/api/admin/vocabularies/{id}/terms/{term_id}",
request_body = UpdateTermRequest,
params(
("id" = String, Path, description = "Vocabulary id (UUID)"),
("term_id" = String, Path, description = "Term id (UUID)")
),
responses(
(status = 204),
(status = 401),
(status = 403),
(status = 404)
)
)]
pub(crate) async fn update_term(
auth: Authorized<EditCatalogue>,
State(state): State<AppState>,
Path((id, term_id)): Path<(String, String)>,
Json(req): Json<UpdateTermRequest>,
) -> Result<StatusCode, StatusCode> {
let vocabulary_id = id
.parse::<VocabularyId>()
.map_err(|_| StatusCode::NOT_FOUND)?;
let term_id = term_id
.parse::<TermId>()
.map_err(|_| StatusCode::NOT_FOUND)?;
let labels: Vec<LocalizedLabel> = req
.labels
.into_iter()
.map(|l| LocalizedLabel {
lang: l.lang,
label: l.label,
})
.collect();
let mut tx = state
.db
.pool()
.begin()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let existed = db::vocab::update_term(
&mut tx,
AuditActor::User(auth.user.id.to_uuid()),
vocabulary_id,
term_id,
req.external_uri.as_deref(),
&labels,
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if existed {
tx.commit()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
} else {
let _ = tx.rollback().await;
Err(StatusCode::NOT_FOUND)
}
}
#[utoipa::path(
delete, path = "/api/admin/vocabularies/{id}/terms/{term_id}",
params(
("id" = String, Path, description = "Vocabulary id (UUID)"),
("term_id" = String, Path, description = "Term id (UUID)")
),
responses(
(status = 204),
(status = 401),
(status = 403),
(status = 404),
(status = 409, body = InUseView, description = "Referenced by catalogue objects")
)
)]
pub(crate) async fn delete_term(
auth: Authorized<EditCatalogue>,
State(state): State<AppState>,
Path((id, term_id)): Path<(String, String)>,
) -> Response {
let (Ok(vocab_id), Ok(term_id)) = (id.parse::<VocabularyId>(), term_id.parse::<TermId>())
else {
return StatusCode::NOT_FOUND.into_response();
};
let Ok(mut tx) = state.db.pool().begin().await else {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
};
let outcome = db::vocab::delete_term(
&mut tx,
AuditActor::User(auth.user.id.to_uuid()),
vocab_id,
term_id,
)
.await;
match outcome {
Ok(db::DeleteOutcome::Deleted) => match tx.commit().await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
},
Ok(db::DeleteOutcome::InUse { count }) => {
let _ = tx.rollback().await;
(StatusCode::CONFLICT, Json(InUseView { count })).into_response()
}
Ok(db::DeleteOutcome::NotFound) => {
let _ = tx.rollback().await;
StatusCode::NOT_FOUND.into_response()
}
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
#[derive(Deserialize, ToSchema)]
pub(crate) struct RenameVocabularyRequest {
pub key: String,
}
#[utoipa::path(
patch, path = "/api/admin/vocabularies/{id}",
request_body = RenameVocabularyRequest,
params(("id" = String, Path, description = "Vocabulary id (UUID)")),
responses(
(status = 204),
(status = 401),
(status = 403),
(status = 404),
(status = 409, description = "Key already in use")
)
)]
pub(crate) async fn rename_vocabulary(
auth: Authorized<EditCatalogue>,
State(state): State<AppState>,
Path(id): Path<String>,
Json(req): Json<RenameVocabularyRequest>,
) -> Result<StatusCode, StatusCode> {
let id = id
.parse::<VocabularyId>()
.map_err(|_| StatusCode::NOT_FOUND)?;
let mut tx = state
.db
.pool()
.begin()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let existed = db::vocab::rename_vocabulary(
&mut tx,
AuditActor::User(auth.user.id.to_uuid()),
id,
&req.key,
)
.await
.map_err(|err| {
if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23505") {
StatusCode::CONFLICT
} else {
StatusCode::INTERNAL_SERVER_ERROR
}
})?;
if existed {
tx.commit()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT)
} else {
let _ = tx.rollback().await;
Err(StatusCode::NOT_FOUND)
}
}
#[utoipa::path(
delete, path = "/api/admin/vocabularies/{id}",
params(("id" = String, Path, description = "Vocabulary id (UUID)")),
responses(
(status = 204),
(status = 401),
(status = 403),
(status = 404),
(status = 409, body = InUseView, description = "Has terms or is bound by a field")
)
)]
pub(crate) async fn delete_vocabulary(
auth: Authorized<EditCatalogue>,
State(state): State<AppState>,
Path(id): Path<String>,
) -> Response {
let Ok(id) = id.parse::<VocabularyId>() else {
return StatusCode::NOT_FOUND.into_response();
};
let Ok(mut tx) = state.db.pool().begin().await else {
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
};
match db::vocab::delete_vocabulary(&mut tx, AuditActor::User(auth.user.id.to_uuid()), id).await
{
Ok(db::DeleteOutcome::Deleted) => match tx.commit().await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
},
Ok(db::DeleteOutcome::InUse { count }) => {
let _ = tx.rollback().await;
(StatusCode::CONFLICT, Json(InUseView { count })).into_response()
}
Ok(db::DeleteOutcome::NotFound) => {
let _ = tx.rollback().await;
StatusCode::NOT_FOUND.into_response()
}
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
pub(crate) fn routes() -> Router<AppState> {
Router::new()
.route(
"/api/admin/vocabularies",
get(list_vocabularies).post(create_vocabulary),
)
.route(
"/api/admin/vocabularies/{id}",
axum::routing::patch(rename_vocabulary).delete(delete_vocabulary),
)
.route(
"/api/admin/vocabularies/{id}/terms",
get(list_terms).post(add_term),
)
.route(
"/api/admin/vocabularies/{id}/terms/{term_id}",
axum::routing::patch(update_term).delete(delete_term),
)
}
+14 -1
View File
@@ -26,14 +26,22 @@ use crate::{
admin_objects::delete_object,
admin_objects::list_field_definitions,
admin_objects::create_field_definition,
admin_objects::update_field_definition,
admin_objects::delete_field_definition,
admin_objects::set_fields,
admin_vocab::list_vocabularies,
admin_vocab::create_vocabulary,
admin_vocab::list_terms,
admin_vocab::add_term,
admin_vocab::update_term,
admin_vocab::delete_term,
admin_vocab::rename_vocabulary,
admin_vocab::delete_vocabulary,
admin_search::search_objects,
admin_authorities::list_authorities,
admin_authorities::create_authority
admin_authorities::create_authority,
admin_authorities::update_authority,
admin_authorities::delete_authority
),
components(schemas(
config::ConfigView,
@@ -52,6 +60,7 @@ use crate::{
admin_objects::CreatedObject,
admin_objects::FieldDefinitionView,
admin_objects::NewFieldDefinitionRequest,
admin_objects::UpdateFieldDefinitionRequest,
admin_objects::CreatedField,
admin_objects::FieldErrorView,
admin_vocab::VocabularyView,
@@ -60,10 +69,14 @@ use crate::{
admin_vocab::LabelInput,
admin_vocab::TermView,
admin_vocab::CreatedId,
admin_vocab::UpdateTermRequest,
admin_vocab::InUseView,
admin_vocab::RenameVocabularyRequest,
admin_search::SearchHitView,
admin_search::SearchResultsView,
admin_authorities::AuthorityView,
admin_authorities::NewAuthorityRequest,
admin_authorities::UpdateAuthorityRequest,
domain::Visibility,
domain::AuthorityKind,
domain::DataType
+545
View File
@@ -333,3 +333,548 @@ async fn creating_a_vocabulary_writes_an_audit_entry(pool: PgPool) {
"expected actor to be a user"
);
}
async fn send(
app: &axum::Router,
cookie: &str,
method: &str,
uri: &str,
body: Option<&str>,
) -> axum::http::Response<Body> {
let mut req = Request::builder()
.method(method)
.uri(uri)
.header(header::COOKIE, cookie);
if body.is_some() {
req = req.header(header::CONTENT_TYPE, "application/json");
}
let body = body
.map(|b| Body::from(b.to_owned()))
.unwrap_or_else(Body::empty);
app.clone().oneshot(req.body(body).unwrap()).await.unwrap()
}
#[sqlx::test(migrations = "../db/migrations")]
async fn edit_and_delete_term(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));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
let v = send(
&app,
&cookie,
"POST",
"/api/admin/vocabularies",
Some(r#"{"key":"material"}"#),
)
.await;
let vid: serde_json::Value =
serde_json::from_slice(&v.into_body().collect().await.unwrap().to_bytes()).unwrap();
let vid = vid["id"].as_str().unwrap().to_owned();
let t = send(
&app,
&cookie,
"POST",
&format!("/api/admin/vocabularies/{vid}/terms"),
Some(r#"{"external_uri":null,"labels":[{"lang":"sv","label":"Trä"}]}"#),
)
.await;
let tid: serde_json::Value =
serde_json::from_slice(&t.into_body().collect().await.unwrap().to_bytes()).unwrap();
let tid = tid["id"].as_str().unwrap().to_owned();
let patched = send(
&app,
&cookie,
"PATCH",
&format!("/api/admin/vocabularies/{vid}/terms/{tid}"),
Some(r#"{"external_uri":"https://x","labels":[{"lang":"sv","label":"Träslag"}]}"#),
)
.await;
assert_eq!(patched.status(), StatusCode::NO_CONTENT);
let deleted = send(
&app,
&cookie,
"DELETE",
&format!("/api/admin/vocabularies/{vid}/terms/{tid}"),
None,
)
.await;
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
let again = send(
&app,
&cookie,
"DELETE",
&format!("/api/admin/vocabularies/{vid}/terms/{tid}"),
None,
)
.await;
assert_eq!(again.status(), StatusCode::NOT_FOUND);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn term_edit_delete_requires_auth(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
let app = build_app(state(pool));
let term_uri = "/api/admin/vocabularies/00000000-0000-0000-0000-000000000000/terms/00000000-0000-0000-0000-000000000000";
let patch_resp = app
.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri(term_uri)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"labels":[]}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(patch_resp.status(), StatusCode::UNAUTHORIZED);
let delete_resp = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri(term_uri)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(delete_resp.status(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn vocabulary_edit_delete_requires_auth(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
let app = build_app(state(pool));
let vocab_uri = "/api/admin/vocabularies/00000000-0000-0000-0000-000000000000";
let patch_resp = app
.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri(vocab_uri)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"key":"x"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(patch_resp.status(), StatusCode::UNAUTHORIZED);
let delete_resp = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri(vocab_uri)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(delete_resp.status(), StatusCode::UNAUTHORIZED);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn rename_and_delete_vocabulary(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));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
let v = send(
&app,
&cookie,
"POST",
"/api/admin/vocabularies",
Some(r#"{"key":"old"}"#),
)
.await;
let vid: serde_json::Value =
serde_json::from_slice(&v.into_body().collect().await.unwrap().to_bytes()).unwrap();
let vid = vid["id"].as_str().unwrap().to_owned();
let renamed = send(
&app,
&cookie,
"PATCH",
&format!("/api/admin/vocabularies/{vid}"),
Some(r#"{"key":"new"}"#),
)
.await;
assert_eq!(renamed.status(), StatusCode::NO_CONTENT);
let deleted = send(
&app,
&cookie,
"DELETE",
&format!("/api/admin/vocabularies/{vid}"),
None,
)
.await;
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn delete_vocabulary_with_terms_is_409(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));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
let v = send(
&app,
&cookie,
"POST",
"/api/admin/vocabularies",
Some(r#"{"key":"material"}"#),
)
.await;
let vid: serde_json::Value =
serde_json::from_slice(&v.into_body().collect().await.unwrap().to_bytes()).unwrap();
let vid = vid["id"].as_str().unwrap().to_owned();
send(
&app,
&cookie,
"POST",
&format!("/api/admin/vocabularies/{vid}/terms"),
Some(r#"{"external_uri":null,"labels":[{"lang":"sv","label":"Trä"}]}"#),
)
.await;
let blocked = send(
&app,
&cookie,
"DELETE",
&format!("/api/admin/vocabularies/{vid}"),
None,
)
.await;
assert_eq!(blocked.status(), StatusCode::CONFLICT);
let body: serde_json::Value =
serde_json::from_slice(&blocked.into_body().collect().await.unwrap().to_bytes()).unwrap();
assert_eq!(body["count"], 1);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn delete_authority_referenced_is_409(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));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
// create an authority
let a = send(
&app,
&cookie,
"POST",
"/api/admin/authorities",
Some(r#"{"kind":"person","external_uri":null,"labels":[{"lang":"sv","label":"Astrid"}]}"#),
)
.await;
assert_eq!(a.status(), StatusCode::CREATED);
let aid: serde_json::Value =
serde_json::from_slice(&a.into_body().collect().await.unwrap().to_bytes()).unwrap();
let aid = aid["id"].as_str().unwrap().to_owned();
// create an authority-typed field definition
send(
&app,
&cookie,
"POST",
"/api/admin/field-definitions",
Some(
r#"{"key":"maker","data_type":"authority","vocabulary_id":null,"authority_kind":"person","required":false,"group":null,"labels":[{"lang":"sv","label":"Tillverkare"}]}"#,
),
)
.await;
// create an object
let obj = send(
&app,
&cookie,
"POST",
"/api/admin/objects",
Some(
r#"{"object_number":"T-1","object_name":"test object","number_of_objects":1,"visibility":"draft"}"#,
),
)
.await;
assert_eq!(obj.status(), StatusCode::CREATED);
let obj_json: serde_json::Value =
serde_json::from_slice(&obj.into_body().collect().await.unwrap().to_bytes()).unwrap();
let obj_id = obj_json["id"].as_str().unwrap().to_owned();
// set the object's maker field to the authority id
let fields_body = format!(r#"{{"maker":"{aid}"}}"#);
let set = send(
&app,
&cookie,
"PUT",
&format!("/api/admin/objects/{obj_id}/fields"),
Some(&fields_body),
)
.await;
assert_eq!(set.status(), StatusCode::NO_CONTENT);
// delete the authority — must be blocked
let blocked = send(
&app,
&cookie,
"DELETE",
&format!("/api/admin/authorities/{aid}"),
None,
)
.await;
assert_eq!(blocked.status(), StatusCode::CONFLICT);
let body: serde_json::Value =
serde_json::from_slice(&blocked.into_body().collect().await.unwrap().to_bytes()).unwrap();
assert_eq!(body["count"], 1);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn edit_and_delete_authority(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));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
let a = send(
&app,
&cookie,
"POST",
"/api/admin/authorities",
Some(r#"{"kind":"person","external_uri":null,"labels":[{"lang":"sv","label":"Anon"}]}"#),
)
.await;
let aid: serde_json::Value =
serde_json::from_slice(&a.into_body().collect().await.unwrap().to_bytes()).unwrap();
let aid = aid["id"].as_str().unwrap().to_owned();
let patched = send(
&app,
&cookie,
"PATCH",
&format!("/api/admin/authorities/{aid}"),
Some(r#"{"external_uri":"https://viaf.org/1","labels":[{"lang":"sv","label":"Astrid"}]}"#),
)
.await;
assert_eq!(patched.status(), StatusCode::NO_CONTENT);
let deleted = send(
&app,
&cookie,
"DELETE",
&format!("/api/admin/authorities/{aid}"),
None,
)
.await;
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn edit_and_delete_field_definition(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));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
// create a field definition
send(
&app,
&cookie,
"POST",
"/api/admin/field-definitions",
Some(r#"{"key":"weight","data_type":"integer","vocabulary_id":null,"authority_kind":null,"required":false,"group":null,"labels":[{"lang":"sv","label":"Vikt"}]}"#),
)
.await;
// PATCH: update required + group + labels
let patched = send(
&app,
&cookie,
"PATCH",
"/api/admin/field-definitions/weight",
Some(r#"{"required":true,"group":"Mått","labels":[{"lang":"sv","label":"Vikt (g)"}]}"#),
)
.await;
assert_eq!(patched.status(), StatusCode::NO_CONTENT);
// PATCH unknown key → 404
let missing = send(
&app,
&cookie,
"PATCH",
"/api/admin/field-definitions/nope",
Some(r#"{"required":false,"group":null,"labels":[]}"#),
)
.await;
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
// DELETE the (unreferenced) field definition
let deleted = send(
&app,
&cookie,
"DELETE",
"/api/admin/field-definitions/weight",
None,
)
.await;
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
// DELETE again → 404
let again = send(
&app,
&cookie,
"DELETE",
"/api/admin/field-definitions/weight",
None,
)
.await;
assert_eq!(again.status(), StatusCode::NOT_FOUND);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn delete_field_definition_referenced_is_409(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));
let cookie = login(&app, "ed@example.com", "pw-editor-123").await;
// create a field definition
send(
&app,
&cookie,
"POST",
"/api/admin/field-definitions",
Some(r#"{"key":"weight","data_type":"integer","vocabulary_id":null,"authority_kind":null,"required":false,"group":null,"labels":[{"lang":"sv","label":"Vikt"}]}"#),
)
.await;
// create an object and set the field
let obj = send(
&app,
&cookie,
"POST",
"/api/admin/objects",
Some(r#"{"object_number":"T-2","object_name":"test","number_of_objects":1,"visibility":"draft"}"#),
)
.await;
assert_eq!(obj.status(), StatusCode::CREATED);
let obj_json: serde_json::Value =
serde_json::from_slice(&obj.into_body().collect().await.unwrap().to_bytes()).unwrap();
let obj_id = obj_json["id"].as_str().unwrap().to_owned();
let set = send(
&app,
&cookie,
"PUT",
&format!("/api/admin/objects/{obj_id}/fields"),
Some(r#"{"weight":42}"#),
)
.await;
assert_eq!(set.status(), StatusCode::NO_CONTENT);
// delete the field definition — must be blocked
let blocked = send(
&app,
&cookie,
"DELETE",
"/api/admin/field-definitions/weight",
None,
)
.await;
assert_eq!(blocked.status(), StatusCode::CONFLICT);
let body: serde_json::Value =
serde_json::from_slice(&blocked.into_body().collect().await.unwrap().to_bytes()).unwrap();
assert_eq!(body["count"], 1);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn field_definition_edit_delete_requires_auth(pool: PgPool) {
migrate_sessions(&db::Db::from_pool(pool.clone()))
.await
.unwrap();
let app = build_app(state(pool));
let patch_resp = app
.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri("/api/admin/field-definitions/weight")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"required":false,"group":null,"labels":[]}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(patch_resp.status(), StatusCode::UNAUTHORIZED);
let delete_resp = app
.clone()
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/admin/field-definitions/weight")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(delete_resp.status(), StatusCode::UNAUTHORIZED);
}
+109
View File
@@ -124,6 +124,115 @@ where
}
}
/// Update an authority's `external_uri` and labels (full replace), recording an
/// `updated` audit entry. Returns `false` if no such authority. `kind` is immutable.
pub async fn update_authority(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
id: AuthorityId,
external_uri: Option<&str>,
labels: &[LocalizedLabel],
) -> Result<bool, sqlx::Error> {
let updated = sqlx::query("UPDATE authority SET external_uri = $2 WHERE id = $1")
.bind(id.to_uuid())
.bind(external_uri)
.execute(&mut *conn)
.await?
.rows_affected();
if updated == 0 {
return Ok(false);
}
sqlx::query("DELETE FROM authority_label WHERE authority_id = $1")
.bind(id.to_uuid())
.execute(&mut *conn)
.await?;
for label in labels {
sqlx::query("INSERT INTO authority_label (authority_id, lang, label) VALUES ($1, $2, $3)")
.bind(id.to_uuid())
.bind(&label.lang)
.bind(&label.label)
.execute(&mut *conn)
.await?;
}
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Updated,
entity_type: AUTHORITY_ENTITY_TYPE.to_owned(),
entity_id: id.to_uuid(),
changes: Vec::new(),
},
)
.await?;
Ok(true)
}
/// Count catalogue objects referencing `id` through an `authority`-typed field.
pub async fn count_objects_referencing_authority<'e, E>(
executor: E,
id: AuthorityId,
) -> Result<i64, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
sqlx::query_scalar(
"SELECT count(*) FROM object o WHERE EXISTS ( \
SELECT 1 FROM field_definition fd \
WHERE fd.data_type = 'authority' AND o.fields ->> fd.key = $1 )",
)
.bind(id.to_string())
.fetch_one(executor)
.await
}
/// Delete an authority (labels cascade) unless catalogue objects reference it,
/// recording a `deleted` audit entry.
pub async fn delete_authority(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
id: AuthorityId,
) -> Result<crate::DeleteOutcome, sqlx::Error> {
let exists = sqlx::query_scalar::<_, i32>("SELECT 1 FROM authority WHERE id = $1")
.bind(id.to_uuid())
.fetch_optional(&mut *conn)
.await?;
if exists.is_none() {
return Ok(crate::DeleteOutcome::NotFound);
}
let count = count_objects_referencing_authority(&mut *conn, id).await?;
if count > 0 {
return Ok(crate::DeleteOutcome::InUse { count });
}
sqlx::query("DELETE FROM authority WHERE id = $1")
.bind(id.to_uuid())
.execute(&mut *conn)
.await?;
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Deleted,
entity_type: AUTHORITY_ENTITY_TYPE.to_owned(),
entity_id: id.to_uuid(),
changes: Vec::new(),
},
)
.await?;
Ok(crate::DeleteOutcome::Deleted)
}
fn map_authority(row: sqlx::postgres::PgRow) -> Result<Authority, sqlx::Error> {
let kind_str: String = row.try_get("kind")?;
let kind = AuthorityKind::from_db(&kind_str)
+118 -2
View File
@@ -1,11 +1,15 @@
//! Registry of flexible field definitions.
use domain::{
AuthorityKind, FieldDefinition, FieldDefinitionId, FieldType, LocalizedLabel,
NewFieldDefinition, VocabularyId,
AuditAction, AuditActor, AuthorityKind, FieldDefinition, FieldDefinitionId, FieldType,
LocalizedLabel, NewAuditEvent, NewFieldDefinition, VocabularyId,
};
use sqlx::Row;
use crate::audit;
const FIELD_DEFINITION_ENTITY_TYPE: &str = "field_definition";
/// Labels aggregated per row as JSON, to read a definition and its labels in one query.
const LABELS_JSON: &str = "COALESCE(json_agg(json_build_object('lang', fdl.lang, 'label', fdl.label) \
ORDER BY fdl.lang) FILTER (WHERE fdl.field_definition_id IS NOT NULL), '[]'::json)";
@@ -121,3 +125,115 @@ fn map_field_definition(row: sqlx::postgres::PgRow) -> Result<FieldDefinition, s
labels: labels.0,
})
}
/// Update a field definition's mutable attributes (`required`, `group_key`, labels);
/// `key`, `data_type`, and binding are immutable and untouched. Records an `updated`
/// audit entry. Returns `false` if no such key. Pass a transaction connection.
pub async fn update_field_definition(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
key: &str,
required: bool,
group_key: Option<&str>,
labels: &[LocalizedLabel],
) -> Result<bool, sqlx::Error> {
let id: Option<uuid::Uuid> =
sqlx::query_scalar("SELECT id FROM field_definition WHERE key = $1")
.bind(key)
.fetch_optional(&mut *conn)
.await?;
let Some(id) = id else { return Ok(false) };
sqlx::query("UPDATE field_definition SET required = $2, group_key = $3 WHERE id = $1")
.bind(id)
.bind(required)
.bind(group_key)
.execute(&mut *conn)
.await?;
sqlx::query("DELETE FROM field_definition_label WHERE field_definition_id = $1")
.bind(id)
.execute(&mut *conn)
.await?;
for label in labels {
sqlx::query(
"INSERT INTO field_definition_label (field_definition_id, lang, label) \
VALUES ($1, $2, $3)",
)
.bind(id)
.bind(&label.lang)
.bind(&label.label)
.execute(&mut *conn)
.await?;
}
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Updated,
entity_type: FIELD_DEFINITION_ENTITY_TYPE.to_owned(),
entity_id: id,
changes: Vec::new(),
},
)
.await?;
Ok(true)
}
/// Count catalogue objects that store a value under field `key`.
pub async fn count_objects_using_field<'e, E>(executor: E, key: &str) -> Result<i64, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
sqlx::query_scalar("SELECT count(*) FROM object WHERE jsonb_exists(fields, $1)")
.bind(key)
.fetch_one(executor)
.await
}
/// Delete a field definition (labels cascade) unless catalogue objects use its key,
/// recording a `deleted` audit entry. Pass a transaction connection.
pub async fn delete_field_definition(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
key: &str,
) -> Result<crate::DeleteOutcome, sqlx::Error> {
let id: Option<uuid::Uuid> =
sqlx::query_scalar("SELECT id FROM field_definition WHERE key = $1")
.bind(key)
.fetch_optional(&mut *conn)
.await?;
let Some(id) = id else {
return Ok(crate::DeleteOutcome::NotFound);
};
let count = count_objects_using_field(&mut *conn, key).await?;
if count > 0 {
return Ok(crate::DeleteOutcome::InUse { count });
}
sqlx::query("DELETE FROM field_definition WHERE id = $1")
.bind(id)
.execute(&mut *conn)
.await?;
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Deleted,
entity_type: FIELD_DEFINITION_ENTITY_TYPE.to_owned(),
entity_id: id,
changes: Vec::new(),
},
)
.await?;
Ok(crate::DeleteOutcome::Deleted)
}
+11
View File
@@ -10,6 +10,17 @@ pub mod vocab;
use sqlx::postgres::{PgPool, PgPoolOptions};
/// Result of a delete that catalogue-object references may block.
#[derive(Debug, PartialEq, Eq)]
pub enum DeleteOutcome {
/// The row was deleted.
Deleted,
/// Refused: `count` catalogue objects still reference it.
InUse { count: i64 },
/// The row did not exist.
NotFound,
}
/// A handle to the organization's PostgreSQL database.
#[derive(Clone)]
pub struct Db {
+198
View File
@@ -177,6 +177,204 @@ where
Ok(found.map(|_| TermRef::new(term_id, vocabulary_id)))
}
/// Update a term's `external_uri` and labels (full replace), recording an `updated`
/// audit entry. Returns `false` if no such term or the term does not belong to
/// `vocabulary_id`. Pass a transaction connection.
pub async fn update_term(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
vocabulary_id: VocabularyId,
term_id: TermId,
external_uri: Option<&str>,
labels: &[LocalizedLabel],
) -> Result<bool, sqlx::Error> {
let updated =
sqlx::query("UPDATE term SET external_uri = $2 WHERE id = $1 AND vocabulary_id = $3")
.bind(term_id.to_uuid())
.bind(external_uri)
.bind(vocabulary_id.to_uuid())
.execute(&mut *conn)
.await?
.rows_affected();
if updated == 0 {
return Ok(false);
}
sqlx::query("DELETE FROM term_label WHERE term_id = $1")
.bind(term_id.to_uuid())
.execute(&mut *conn)
.await?;
for label in labels {
sqlx::query("INSERT INTO term_label (term_id, lang, label) VALUES ($1, $2, $3)")
.bind(term_id.to_uuid())
.bind(&label.lang)
.bind(&label.label)
.execute(&mut *conn)
.await?;
}
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Updated,
entity_type: TERM_ENTITY_TYPE.to_owned(),
entity_id: term_id.to_uuid(),
changes: Vec::new(),
},
)
.await?;
Ok(true)
}
/// Count catalogue objects that reference `term_id` through a `term`-typed field.
pub async fn count_objects_referencing_term<'e, E>(
executor: E,
term_id: TermId,
) -> Result<i64, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
{
sqlx::query_scalar(
"SELECT count(*) FROM object o WHERE EXISTS ( \
SELECT 1 FROM field_definition fd \
WHERE fd.data_type = 'term' AND o.fields ->> fd.key = $1 )",
)
.bind(term_id.to_string())
.fetch_one(executor)
.await
}
/// Delete a term (its labels cascade) unless catalogue objects reference it, recording a
/// `deleted` audit entry. Pass a transaction connection.
pub async fn delete_term(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
vocabulary_id: VocabularyId,
term_id: TermId,
) -> Result<crate::DeleteOutcome, sqlx::Error> {
let exists =
sqlx::query_scalar::<_, i32>("SELECT 1 FROM term WHERE id = $1 AND vocabulary_id = $2")
.bind(term_id.to_uuid())
.bind(vocabulary_id.to_uuid())
.fetch_optional(&mut *conn)
.await?;
if exists.is_none() {
return Ok(crate::DeleteOutcome::NotFound);
}
let count = count_objects_referencing_term(&mut *conn, term_id).await?;
if count > 0 {
return Ok(crate::DeleteOutcome::InUse { count });
}
sqlx::query("DELETE FROM term WHERE id = $1")
.bind(term_id.to_uuid())
.execute(&mut *conn)
.await?;
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Deleted,
entity_type: TERM_ENTITY_TYPE.to_owned(),
entity_id: term_id.to_uuid(),
changes: Vec::new(),
},
)
.await?;
Ok(crate::DeleteOutcome::Deleted)
}
/// Rename a vocabulary's key, recording an `updated` audit entry. Returns `false` if no
/// such vocabulary. A unique-key collision surfaces as the underlying sqlx error (23505).
pub async fn rename_vocabulary(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
id: VocabularyId,
key: &str,
) -> Result<bool, sqlx::Error> {
let updated = sqlx::query("UPDATE vocabulary SET key = $2 WHERE id = $1")
.bind(id.to_uuid())
.bind(key)
.execute(&mut *conn)
.await?
.rows_affected();
if updated == 0 {
return Ok(false);
}
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Updated,
entity_type: VOCABULARY_ENTITY_TYPE.to_owned(),
entity_id: id.to_uuid(),
changes: Vec::new(),
},
)
.await?;
Ok(true)
}
/// Delete a vocabulary unless it still has terms or is bound by a field definition
/// (both would otherwise hit the FK `RESTRICT`). Records a `deleted` audit entry.
pub async fn delete_vocabulary(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
id: VocabularyId,
) -> Result<crate::DeleteOutcome, sqlx::Error> {
let exists = sqlx::query_scalar::<_, i32>("SELECT 1 FROM vocabulary WHERE id = $1")
.bind(id.to_uuid())
.fetch_optional(&mut *conn)
.await?;
if exists.is_none() {
return Ok(crate::DeleteOutcome::NotFound);
}
let count: i64 = sqlx::query_scalar(
"SELECT (SELECT count(*) FROM term WHERE vocabulary_id = $1) \
+ (SELECT count(*) FROM field_definition WHERE vocabulary_id = $1)",
)
.bind(id.to_uuid())
.fetch_one(&mut *conn)
.await?;
if count > 0 {
return Ok(crate::DeleteOutcome::InUse { count });
}
sqlx::query("DELETE FROM vocabulary WHERE id = $1")
.bind(id.to_uuid())
.execute(&mut *conn)
.await?;
audit::record(
&mut *conn,
&NewAuditEvent {
actor,
action: AuditAction::Deleted,
entity_type: VOCABULARY_ENTITY_TYPE.to_owned(),
entity_id: id.to_uuid(),
changes: Vec::new(),
},
)
.await?;
Ok(crate::DeleteOutcome::Deleted)
}
fn map_vocabulary(row: sqlx::postgres::PgRow) -> Result<Vocabulary, sqlx::Error> {
Ok(Vocabulary {
id: VocabularyId::from_uuid(row.try_get("id")?),
+132 -2
View File
@@ -1,7 +1,23 @@
use db::{Db, authority};
use domain::{AuditActor, AuthorityKind, LocalizedLabel, NewAuthority};
use db::{Db, authority, catalog, fields};
use domain::{
AuditActor, AuthorityKind, LocalizedLabel, NewAuthority, NewFieldDefinition, Visibility,
};
use sqlx::PgPool;
fn sample_object_input() -> domain::ObjectInput {
domain::ObjectInput {
object_number: "X.1".into(),
object_name: "Test".into(),
number_of_objects: 1,
brief_description: None,
current_location: None,
current_owner: None,
recorder: None,
recording_date: None,
visibility: Visibility::Draft,
}
}
fn new_person(name_sv: &str, name_en: &str) -> NewAuthority {
NewAuthority {
kind: AuthorityKind::Person,
@@ -131,3 +147,117 @@ async fn authority_with_no_labels_round_trips_empty(pool: PgPool) {
assert_eq!(got.kind, AuthorityKind::Organisation);
assert!(got.labels.is_empty());
}
#[sqlx::test(migrations = "../db/migrations")]
async fn update_authority_changes_labels(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let id = authority::create_authority(
&mut tx,
AuditActor::System,
&NewAuthority {
kind: AuthorityKind::Person,
external_uri: None,
labels: vec![LocalizedLabel {
lang: "sv".into(),
label: "Anon".into(),
}],
},
)
.await
.unwrap();
let existed = authority::update_authority(
&mut tx,
AuditActor::System,
id,
Some("https://viaf.org/1"),
&[LocalizedLabel {
lang: "sv".into(),
label: "Astrid".into(),
}],
)
.await
.unwrap();
assert!(existed);
tx.commit().await.unwrap();
let a = authority::authority_by_id(db.pool(), id)
.await
.unwrap()
.unwrap();
assert_eq!(a.external_uri.as_deref(), Some("https://viaf.org/1"));
assert_eq!(a.labels[0].label, "Astrid");
}
#[sqlx::test(migrations = "../db/migrations")]
async fn delete_authority_blocks_when_referenced(pool: PgPool) {
use db::DeleteOutcome;
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let id = authority::create_authority(
&mut tx,
AuditActor::System,
&NewAuthority {
kind: AuthorityKind::Person,
external_uri: None,
labels: vec![LocalizedLabel {
lang: "sv".into(),
label: "Astrid".into(),
}],
},
)
.await
.unwrap();
fields::create_field_definition(
&mut tx,
&NewFieldDefinition {
key: "maker".into(),
field_type: domain::FieldType::Authority {
kind: Some(AuthorityKind::Person),
},
required: false,
group_key: None,
labels: vec![LocalizedLabel {
lang: "sv".into(),
label: "Tillverkare".into(),
}],
},
)
.await
.unwrap();
let obj = catalog::create_object(&mut tx, AuditActor::System, &sample_object_input())
.await
.unwrap();
let mut map = serde_json::Map::new();
map.insert("maker".into(), serde_json::Value::String(id.to_string()));
catalog::set_object_fields(&mut tx, AuditActor::System, obj, &map)
.await
.unwrap();
assert_eq!(
authority::delete_authority(&mut tx, AuditActor::System, id)
.await
.unwrap(),
DeleteOutcome::InUse { count: 1 }
);
catalog::set_object_fields(&mut tx, AuditActor::System, obj, &serde_json::Map::new())
.await
.unwrap();
assert_eq!(
authority::delete_authority(&mut tx, AuditActor::System, id)
.await
.unwrap(),
DeleteOutcome::Deleted
);
assert_eq!(
authority::delete_authority(&mut tx, AuditActor::System, id)
.await
.unwrap(),
DeleteOutcome::NotFound
);
}
+138 -2
View File
@@ -1,7 +1,24 @@
use db::{Db, fields, vocab};
use domain::{AuditActor, AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition};
use db::{Db, DeleteOutcome, audit, catalog, fields, vocab};
use domain::{
AuditAction, AuditActor, AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition,
ObjectInput, Visibility,
};
use sqlx::PgPool;
fn sample_object_input() -> ObjectInput {
ObjectInput {
object_number: "X.1".into(),
object_name: "Test".into(),
number_of_objects: 1,
brief_description: None,
current_location: None,
current_owner: None,
recorder: None,
recording_date: None,
visibility: Visibility::Draft,
}
}
fn labels() -> Vec<LocalizedLabel> {
vec![
LocalizedLabel {
@@ -171,3 +188,122 @@ async fn any_authority_scalar_and_zero_labels_round_trip(pool: PgPool) {
let keys: Vec<&str> = all.iter().map(|d| d.key.as_str()).collect();
assert_eq!(keys, vec!["donor", "on_display"]);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn update_field_definition_edits_labels_group_required(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
fields::create_field_definition(
&mut tx,
&NewFieldDefinition {
key: "weight".into(),
field_type: FieldType::Integer,
required: false,
group_key: None,
labels: vec![LocalizedLabel {
lang: "sv".into(),
label: "Vikt".into(),
}],
},
)
.await
.unwrap();
let existed = fields::update_field_definition(
&mut tx,
AuditActor::System,
"weight",
true,
Some("Mått"),
&[LocalizedLabel {
lang: "sv".into(),
label: "Vikt (g)".into(),
}],
)
.await
.unwrap();
assert!(existed);
tx.commit().await.unwrap();
let def = fields::field_definition_by_key(db.pool(), "weight")
.await
.unwrap()
.unwrap();
assert!(def.required);
assert_eq!(def.group_key.as_deref(), Some("Mått"));
assert_eq!(def.labels[0].label, "Vikt (g)");
}
#[sqlx::test(migrations = "../db/migrations")]
async fn delete_field_definition_blocks_when_objects_use_it(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
fields::create_field_definition(
&mut tx,
&NewFieldDefinition {
key: "weight".into(),
field_type: FieldType::Integer,
required: false,
group_key: None,
labels: vec![LocalizedLabel {
lang: "sv".into(),
label: "Vikt".into(),
}],
},
)
.await
.unwrap();
let field_def_id = fields::field_definition_by_key(&mut *tx, "weight")
.await
.unwrap()
.unwrap()
.id
.to_uuid();
let obj = catalog::create_object(&mut tx, AuditActor::System, &sample_object_input())
.await
.unwrap();
let mut map = serde_json::Map::new();
map.insert("weight".into(), serde_json::Value::from(42));
catalog::set_object_fields(&mut tx, AuditActor::System, obj, &map)
.await
.unwrap();
assert_eq!(
fields::delete_field_definition(&mut tx, AuditActor::System, "weight")
.await
.unwrap(),
DeleteOutcome::InUse { count: 1 }
);
catalog::set_object_fields(&mut tx, AuditActor::System, obj, &serde_json::Map::new())
.await
.unwrap();
assert_eq!(
fields::delete_field_definition(&mut tx, AuditActor::System, "weight")
.await
.unwrap(),
DeleteOutcome::Deleted
);
let history = audit::history_for(&mut *tx, "field_definition", field_def_id)
.await
.unwrap();
assert!(
history.iter().any(|e| e.action == AuditAction::Deleted),
"expected a Deleted audit entry for the field_definition"
);
assert_eq!(
fields::delete_field_definition(&mut tx, AuditActor::System, "weight")
.await
.unwrap(),
DeleteOutcome::NotFound
);
}
+236 -2
View File
@@ -1,5 +1,8 @@
use db::{Db, vocab};
use domain::{AuditActor, LocalizedLabel, NewTerm};
use db::{Db, audit, catalog, fields, vocab};
use domain::{
AuditAction, AuditActor, FieldType, LocalizedLabel, NewFieldDefinition, NewTerm, ObjectInput,
Visibility,
};
use sqlx::PgPool;
#[sqlx::test]
@@ -169,3 +172,234 @@ async fn resolve_term_checks_vocabulary_membership(pool: PgPool) {
.is_none()
);
}
fn sample_object_input() -> ObjectInput {
ObjectInput {
object_number: "X.1".into(),
object_name: "Test".into(),
number_of_objects: 1,
brief_description: None,
current_location: None,
current_owner: None,
recorder: None,
recording_date: None,
visibility: Visibility::Draft,
}
}
#[sqlx::test(migrations = "../db/migrations")]
async fn update_term_changes_labels_and_uri(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let vocab = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
.await
.unwrap();
let term_id = vocab::add_term(
&mut tx,
AuditActor::System,
&NewTerm {
vocabulary_id: vocab.id,
external_uri: None,
labels: vec![LocalizedLabel {
lang: "sv".into(),
label: "Trä".into(),
}],
},
)
.await
.unwrap();
let existed = vocab::update_term(
&mut tx,
AuditActor::System,
vocab.id,
term_id,
Some("https://example.org/wood"),
&[LocalizedLabel {
lang: "sv".into(),
label: "Träslag".into(),
}],
)
.await
.unwrap();
assert!(existed);
let history = audit::history_for(&mut *tx, "term", term_id.to_uuid())
.await
.unwrap();
assert!(
history.iter().any(|e| e.action == AuditAction::Updated),
"expected an Updated audit entry for the term"
);
tx.commit().await.unwrap();
let term = vocab::term_by_id(db.pool(), term_id)
.await
.unwrap()
.unwrap();
assert_eq!(
term.external_uri.as_deref(),
Some("https://example.org/wood")
);
assert_eq!(term.labels.len(), 1);
assert_eq!(term.labels[0].label, "Träslag");
}
#[sqlx::test(migrations = "../db/migrations")]
async fn delete_term_blocks_when_referenced_then_succeeds(pool: PgPool) {
use db::DeleteOutcome;
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let vocab = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
.await
.unwrap();
let term_id = vocab::add_term(
&mut tx,
AuditActor::System,
&NewTerm {
vocabulary_id: vocab.id,
external_uri: None,
labels: vec![LocalizedLabel {
lang: "sv".into(),
label: "Trä".into(),
}],
},
)
.await
.unwrap();
fields::create_field_definition(
&mut tx,
&NewFieldDefinition {
key: "material".into(),
field_type: FieldType::Term {
vocabulary_id: vocab.id,
},
required: false,
group_key: None,
labels: vec![LocalizedLabel {
lang: "sv".into(),
label: "Material".into(),
}],
},
)
.await
.unwrap();
let obj = catalog::create_object(&mut tx, AuditActor::System, &sample_object_input())
.await
.unwrap();
let mut map = serde_json::Map::new();
map.insert(
"material".into(),
serde_json::Value::String(term_id.to_string()),
);
catalog::set_object_fields(&mut tx, AuditActor::System, obj, &map)
.await
.unwrap();
let blocked = vocab::delete_term(&mut tx, AuditActor::System, vocab.id, term_id)
.await
.unwrap();
assert_eq!(blocked, DeleteOutcome::InUse { count: 1 });
catalog::set_object_fields(&mut tx, AuditActor::System, obj, &serde_json::Map::new())
.await
.unwrap();
let ok = vocab::delete_term(&mut tx, AuditActor::System, vocab.id, term_id)
.await
.unwrap();
assert_eq!(ok, DeleteOutcome::Deleted);
assert!(
vocab::term_by_id(&mut *tx, term_id)
.await
.unwrap()
.is_none()
);
let history = audit::history_for(&mut *tx, "term", term_id.to_uuid())
.await
.unwrap();
assert!(
history.iter().any(|e| e.action == AuditAction::Deleted),
"expected a Deleted audit entry for the term"
);
let gone = vocab::delete_term(&mut tx, AuditActor::System, vocab.id, term_id)
.await
.unwrap();
assert_eq!(gone, DeleteOutcome::NotFound);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn rename_vocabulary_changes_key(pool: PgPool) {
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "old")
.await
.unwrap();
let existed = vocab::rename_vocabulary(&mut tx, AuditActor::System, v.id, "new")
.await
.unwrap();
assert!(existed);
tx.commit().await.unwrap();
assert!(
vocab::vocabulary_by_key(db.pool(), "new")
.await
.unwrap()
.is_some()
);
assert!(
vocab::vocabulary_by_key(db.pool(), "old")
.await
.unwrap()
.is_none()
);
}
#[sqlx::test(migrations = "../db/migrations")]
async fn delete_vocabulary_blocks_when_it_has_terms(pool: PgPool) {
use db::DeleteOutcome;
let db = Db::from_pool(pool);
let mut tx = db.pool().begin().await.unwrap();
let v = vocab::create_vocabulary(&mut tx, AuditActor::System, "material")
.await
.unwrap();
vocab::add_term(
&mut tx,
AuditActor::System,
&NewTerm {
vocabulary_id: v.id,
external_uri: None,
labels: vec![LocalizedLabel {
lang: "sv".into(),
label: "Trä".into(),
}],
},
)
.await
.unwrap();
let blocked = vocab::delete_vocabulary(&mut tx, AuditActor::System, v.id)
.await
.unwrap();
assert_eq!(blocked, DeleteOutcome::InUse { count: 1 });
let empty = vocab::create_vocabulary(&mut tx, AuditActor::System, "empty")
.await
.unwrap();
assert_eq!(
vocab::delete_vocabulary(&mut tx, AuditActor::System, empty.id)
.await
.unwrap(),
DeleteOutcome::Deleted
);
let gone = vocab::delete_vocabulary(&mut tx, AuditActor::System, empty.id)
.await
.unwrap();
assert_eq!(gone, DeleteOutcome::NotFound);
}
+11
View File
@@ -0,0 +1,11 @@
import { describe, it, expect } from "vitest";
import { InUseError } from "./queries";
describe("InUseError", () => {
it("carries the count", () => {
const e = new InUseError(7);
expect(e.count).toBe(7);
expect(e).toBeInstanceOf(Error);
});
});
+164
View File
@@ -17,6 +17,13 @@ export class FieldRejection extends Error {
}
}
export class InUseError extends Error {
constructor(public readonly count: number) {
super(`in use: ${count}`);
this.name = "InUseError";
}
}
type UserView = components["schemas"]["UserView"];
type LoginRequest = components["schemas"]["LoginRequest"];
@@ -381,3 +388,160 @@ export function useSetVisibility() {
},
});
}
export function useUpdateTerm() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({
vocabularyId,
termId,
external_uri,
labels,
}: {
vocabularyId: string;
termId: string;
external_uri: string | null;
labels: LabelInput[];
}) => {
const { response } = await api.PATCH("/api/admin/vocabularies/{id}/terms/{term_id}", {
params: { path: { id: vocabularyId, term_id: termId } },
body: { external_uri, labels },
});
if (response.status !== 204) throw new Error("update term failed");
},
onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }),
});
}
export function useDeleteTerm() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ vocabularyId, termId }: { vocabularyId: string; termId: string }) => {
const { error, response } = await api.DELETE("/api/admin/vocabularies/{id}/terms/{term_id}", {
params: { path: { id: vocabularyId, term_id: termId } },
});
if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
if (response.status !== 204) throw new Error("delete term failed");
},
onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }),
});
}
export function useRenameVocabulary() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, key }: { id: string; key: string }) => {
const { response } = await api.PATCH("/api/admin/vocabularies/{id}", {
params: { path: { id } },
body: { key },
});
if (response.status !== 204) throw new Error("rename failed");
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }),
});
}
export function useDeleteVocabulary() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
const { error, response } = await api.DELETE("/api/admin/vocabularies/{id}", {
params: { path: { id } },
});
if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
if (response.status !== 204) throw new Error("delete vocabulary failed");
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }),
});
}
export function useUpdateAuthority() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({
id,
external_uri,
labels,
}: {
id: string;
kind: string;
external_uri: string | null;
labels: LabelInput[];
}) => {
const { response } = await api.PATCH("/api/admin/authorities/{id}", {
params: { path: { id } },
body: { external_uri, labels },
});
if (response.status !== 204) throw new Error("update authority failed");
},
onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: ["authorities", kind] }),
});
}
export function useDeleteAuthority() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id }: { id: string; kind: string }) => {
const { error, response } = await api.DELETE("/api/admin/authorities/{id}", {
params: { path: { id } },
});
if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
if (response.status !== 204) throw new Error("delete authority failed");
},
onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: ["authorities", kind] }),
});
}
export function useUpdateFieldDefinition() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({
key,
required,
group,
labels,
}: {
key: string;
required: boolean;
group: string | null;
labels: LabelInput[];
}) => {
const { response } = await api.PATCH("/api/admin/field-definitions/{key}", {
params: { path: { key } },
body: { required, group, labels },
});
if (response.status !== 204) throw new Error("update field failed");
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }),
});
}
export function useDeleteFieldDefinition() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (key: string) => {
const { error, response } = await api.DELETE("/api/admin/field-definitions/{key}", {
params: { path: { key } },
});
if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0);
if (response.status !== 204) throw new Error("delete field failed");
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }),
});
}
+471
View File
@@ -20,6 +20,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/authorities/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["delete_authority"];
options?: never;
head?: never;
patch: operations["update_authority"];
trace?: never;
};
"/api/admin/field-definitions": {
parameters: {
query?: never;
@@ -42,6 +58,30 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/field-definitions/{key}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/**
* Delete a field definition. Blocked (409) when catalogue objects store a value under
* this key. Requires `EditCatalogue`.
*/
delete: operations["delete_field_definition"];
options?: never;
head?: never;
/**
* Update a field definition's mutable attributes (labels, group, required).
* `key`, `data_type`, and binding are immutable. Requires `EditCatalogue`.
*/
patch: operations["update_field_definition"];
trace?: never;
};
"/api/admin/login": {
parameters: {
query?: never;
@@ -222,6 +262,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/vocabularies/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["delete_vocabulary"];
options?: never;
head?: never;
patch: operations["rename_vocabulary"];
trace?: never;
};
"/api/admin/vocabularies/{id}/terms": {
parameters: {
query?: never;
@@ -238,6 +294,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/vocabularies/{id}/terms/{term_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete: operations["delete_term"];
options?: never;
head?: never;
patch: operations["update_term"];
trace?: never;
};
"/api/config": {
parameters: {
query?: never;
@@ -415,6 +487,11 @@ export interface components {
/** @description The flexible-field key that was rejected. */
field: string;
};
/** @description 409 body: how many catalogue objects still reference the entity. */
InUseView: {
/** Format: int64 */
count: number;
};
LabelInput: {
label: string;
lang: string;
@@ -514,6 +591,9 @@ export interface components {
/** @description `"ok"` when ready, `"degraded"` otherwise. */
status: string;
};
RenameVocabularyRequest: {
key: string;
};
SearchHitView: {
brief_description?: string | null;
id: string;
@@ -532,6 +612,23 @@ export interface components {
id: string;
labels: components["schemas"]["LabelView"][];
};
UpdateAuthorityRequest: {
external_uri?: string | null;
labels: components["schemas"]["LabelInput"][];
};
/**
* @description Fields that may be changed on an existing field definition. `key`, `data_type`, and
* binding are immutable and intentionally absent from this request.
*/
UpdateFieldDefinitionRequest: {
group?: string | null;
labels: components["schemas"]["LabelInput"][];
required: boolean;
};
UpdateTermRequest: {
external_uri?: string | null;
labels: components["schemas"]["LabelInput"][];
};
/** @description A user as exposed on the admin surface (no password material). */
UserView: {
email: string;
@@ -641,6 +738,95 @@ export interface operations {
};
};
};
delete_authority: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Authority id (UUID) */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Referenced by catalogue objects */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["InUseView"];
};
};
};
};
update_authority: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Authority id (UUID) */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpdateAuthorityRequest"];
};
};
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
list_field_definitions: {
parameters: {
query?: never;
@@ -728,6 +914,102 @@ export interface operations {
};
};
};
delete_field_definition: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Field definition key */
key: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Field is used by catalogue objects */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["InUseView"];
};
};
};
};
update_field_definition: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Field definition key */
key: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpdateFieldDefinitionRequest"];
};
};
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description CHECK constraint violated (e.g. empty label) */
422: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
login: {
parameters: {
query?: never;
@@ -1258,6 +1540,102 @@ export interface operations {
};
};
};
delete_vocabulary: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Vocabulary id (UUID) */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Has terms or is bound by a field */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["InUseView"];
};
};
};
};
rename_vocabulary: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Vocabulary id (UUID) */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RenameVocabularyRequest"];
};
};
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Key already in use */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
list_terms: {
parameters: {
query?: never;
@@ -1342,6 +1720,99 @@ export interface operations {
};
};
};
delete_term: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Vocabulary id (UUID) */
id: string;
/** @description Term id (UUID) */
term_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Referenced by catalogue objects */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["InUseView"];
};
};
};
};
update_term: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Vocabulary id (UUID) */
id: string;
/** @description Term id (UUID) */
term_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpdateTermRequest"];
};
};
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
get_config: {
parameters: {
query?: never;
+2 -4
View File
@@ -6,7 +6,7 @@ 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";
import { AuthorityRow } from "./authority-row";
type LabelInput = components["schemas"]["LabelInput"];
@@ -72,9 +72,7 @@ export function AuthoritiesPage() {
<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>
<AuthorityRow key={a.id} authority={a} kind={currentKind} lang={lang} />
))}
</ul>
@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect, userEvent } from 'storybook/test'
import { AuthorityRow } from './authority-row'
const meta = {
component: AuthorityRow,
tags: ['ai-generated'],
args: {
kind: 'person',
lang: 'en',
authority: { id: 'a1', kind: 'person', external_uri: null, labels: [{ lang: 'en', label: 'Astrid Lindgren' }] },
},
} satisfies Meta<typeof AuthorityRow>
export default meta
type Story = StoryObj<typeof meta>
export const Display: Story = {
play: async ({ canvas }) => {
await expect(canvas.getByText('Astrid Lindgren')).toBeVisible()
},
}
export const TogglesEdit: Story = {
play: async ({ canvas }) => {
await userEvent.click(canvas.getByRole('button', { name: 'Edit' }))
await expect(canvas.getByRole('button', { name: 'Save' })).toBeVisible()
},
}
+77
View File
@@ -0,0 +1,77 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { useUpdateAuthority, useDeleteAuthority } from "../api/queries";
import { LabelEditor } from "../components/label-editor";
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { labelText } from "../lib/labels";
type AuthorityView = components["schemas"]["AuthorityView"];
type LabelInput = components["schemas"]["LabelInput"];
export function AuthorityRow({ authority, kind, lang }: { authority: AuthorityView; kind: string; lang: string }) {
const { t } = useTranslation();
const updateAuthority = useUpdateAuthority();
const deleteAuthority = useDeleteAuthority();
const [editing, setEditing] = useState(false);
const [labels, setLabels] = useState<LabelInput[]>(authority.labels as LabelInput[]);
const [uri, setUri] = useState(authority.external_uri ?? "");
if (editing) {
return (
<li className="space-y-2 border-b py-2">
<LabelEditor value={labels} onChange={setLabels} />
<div className="space-y-1">
<Label htmlFor={`auth-uri-${authority.id}`}>{t("labels.externalUri")}</Label>
<Input id={`auth-uri-${authority.id}`} value={uri} onChange={(e) => setUri(e.target.value)} />
</div>
<div className="flex gap-2">
<Button
type="button"
size="sm"
disabled={updateAuthority.isPending}
onClick={() =>
updateAuthority.mutate(
{ id: authority.id, kind, external_uri: uri.trim() || null, labels },
{ onSuccess: () => setEditing(false) },
)
}
>
{t("actions.save")}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)}>
{t("form.cancel")}
</Button>
</div>
</li>
);
}
return (
<li className="flex items-center gap-2 border-b py-1 text-sm">
<span className="flex-1">{labelText(authority.labels, lang)}</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setLabels(authority.labels as LabelInput[]);
setUri(authority.external_uri ?? "");
setEditing(true);
}}
>
{t("actions.edit")}
</Button>
<DeleteConfirmDialog
description={t("actions.confirmDeleteAuthority")}
onConfirm={() => deleteAuthority.mutateAsync({ id: authority.id, kind })}
/>
</li>
);
}
@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect, userEvent, fn, within } from 'storybook/test'
import { DeleteConfirmDialog } from './delete-confirm-dialog'
import { InUseError } from '../api/queries'
const meta = {
component: DeleteConfirmDialog,
tags: ['ai-generated'],
} satisfies Meta<typeof DeleteConfirmDialog>
export default meta
type Story = StoryObj<typeof meta>
export const Confirms: Story = {
args: { description: 'Delete this term? This cannot be undone.', onConfirm: fn() },
play: async ({ canvas, args }) => {
await userEvent.click(canvas.getByRole('button', { name: 'Delete' }))
const confirm = await within(document.body).findByRole('button', { name: 'Delete' })
await userEvent.click(confirm)
await expect(args.onConfirm).toHaveBeenCalled()
},
}
export const ShowsInUse: Story = {
args: {
description: 'Delete this term? This cannot be undone.',
onConfirm: async () => { throw new InUseError(7) },
},
play: async ({ canvas }) => {
await userEvent.click(canvas.getByRole('button', { name: 'Delete' }))
const confirm = await within(document.body).findByRole('button', { name: 'Delete' })
await userEvent.click(confirm)
await expect(await within(document.body).findByRole('alert')).toHaveTextContent(/used by 7/i)
},
}
@@ -0,0 +1,70 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { InUseError } 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 DeleteConfirmDialog({
description,
onConfirm,
triggerLabel,
}: {
/** Confirmation prompt, e.g. t("actions.confirmDeleteTerm"). */
description: string;
/** Performs the delete; may throw InUseError to surface the in-use count. */
onConfirm: () => Promise<void>;
/** Optional override for the trigger button text (defaults to actions.delete). */
triggerLabel?: string;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const confirm = async () => {
setMessage(null);
try {
await onConfirm();
} catch (err) {
// Keep the dialog open; show the blocking reason. Never let the rejected
// mutation escape as an unhandled rejection.
setMessage(err instanceof InUseError ? t("actions.inUse", { count: err.count }) : t("form.rejected"));
return;
}
setOpen(false);
};
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger
render={
<Button variant="ghost" size="sm" className="text-red-600">
{triggerLabel ?? t("actions.delete")}
</Button>
}
/>
<AlertDialogContent>
<AlertDialogTitle>{t("actions.delete")}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
{message && (
<p role="alert" className="text-sm text-red-600">
{message}
</p>
)}
<AlertDialogFooter>
<AlertDialogCancel>{t("form.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={confirm}>{t("actions.delete")}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect, fn } from 'storybook/test'
import { FieldForm } from './field-form'
const meta = {
component: FieldForm,
tags: ['ai-generated'],
} satisfies Meta<typeof FieldForm>
export default meta
type Story = StoryObj<typeof meta>
export const Create: Story = {
args: { editing: null, onDone: fn() },
play: async ({ canvas }) => {
await expect(canvas.getByLabelText('Key')).toBeEnabled()
},
}
export const Edit: Story = {
args: {
editing: {
key: 'material',
data_type: 'text',
vocabulary_id: null,
authority_kind: null,
required: true,
group: 'Identification',
labels: [{ lang: 'en', label: 'Material' }],
},
onDone: fn(),
},
play: async ({ canvas }) => {
await expect(canvas.getByLabelText('Key')).toBeDisabled()
await expect(canvas.getByRole('button', { name: 'Save' })).toBeVisible()
},
}
+88 -42
View File
@@ -2,7 +2,11 @@ import { useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { useCreateFieldDefinition, useVocabularies } from "../api/queries";
import {
useCreateFieldDefinition,
useUpdateFieldDefinition,
useVocabularies,
} from "../api/queries";
import { LabelEditor } from "../components/label-editor";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -10,68 +14,103 @@ import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
type LabelInput = components["schemas"]["LabelInput"];
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
const TYPES = ["text", "localized_text", "integer", "date", "boolean", "term", "authority"] as const;
const KINDS = ["person", "organisation", "place"] as const;
export function FieldForm() {
export function FieldForm({
editing,
onDone,
}: {
editing: FieldDefinitionView | null;
onDone: () => void;
}) {
const { t } = useTranslation();
const create = useCreateFieldDefinition();
const update = useUpdateFieldDefinition();
const { data: vocabularies } = useVocabularies();
const [key, setKey] = useState("");
const [labels, setLabels] = useState<LabelInput[]>([]);
const [dataType, setDataType] = useState<string>("text");
const [vocabularyId, setVocabularyId] = useState("");
const [authorityKind, setAuthorityKind] = useState("");
const [group, setGroup] = useState("");
const [required, setRequired] = useState(false);
const [error, setError] = useState(false);
const isEdit = editing !== null;
const reset = () => {
setKey("");
setLabels([]);
setDataType("text");
setVocabularyId("");
setAuthorityKind("");
setGroup("");
setRequired(false);
setError(false);
};
const [key, setKey] = useState(editing?.key ?? "");
const [labels, setLabels] = useState<LabelInput[]>((editing?.labels as LabelInput[]) ?? []);
const [dataType, setDataType] = useState<string>(editing?.data_type ?? "text");
const [vocabularyId, setVocabularyId] = useState(editing?.vocabulary_id ?? "");
const [authorityKind, setAuthorityKind] = useState(editing?.authority_kind ?? "");
const [group, setGroup] = useState(editing?.group ?? "");
const [required, setRequired] = useState(editing?.required ?? false);
const [error, setError] = useState(false);
const onSubmit = (event: FormEvent) => {
event.preventDefault();
const hasLabel = labels.some((l) => l.label);
const termNeedsVocab = dataType === "term" && !vocabularyId;
if (!key.trim() || !hasLabel || termNeedsVocab) {
if (!hasLabel || (!isEdit && !key.trim()) || (!isEdit && dataType === "term" && !vocabularyId)) {
setError(true);
return;
}
setError(false);
create.mutate(
{
key: key.trim(),
data_type: dataType,
vocabulary_id: dataType === "term" ? vocabularyId : null,
authority_kind: dataType === "authority" ? authorityKind || null : null,
required,
group: group.trim() || null,
labels,
},
{ onSuccess: reset },
);
if (isEdit) {
update.mutate(
{ key: editing.key, required, group: group.trim() || null, labels },
{ onSuccess: onDone },
);
} else {
create.mutate(
{
key: key.trim(),
data_type: dataType,
vocabulary_id: dataType === "term" ? vocabularyId : null,
authority_kind: dataType === "authority" ? authorityKind || null : null,
required,
group: group.trim() || null,
labels,
},
{
onSuccess: () => {
setKey("");
setLabels([]);
setDataType("text");
setVocabularyId("");
setAuthorityKind("");
setGroup("");
setRequired(false);
setError(false);
onDone();
},
},
);
}
};
const pending = isEdit ? update.isPending : create.isPending;
const failed = isEdit ? update.isError : create.isError;
return (
<form onSubmit={onSubmit} className="space-y-3 overflow-auto p-4">
<div className="text-sm font-medium">{t("fields.newField")}</div>
<div className="flex items-center justify-between">
<div className="text-sm font-medium">
{isEdit ? labelTextOrKey(editing) : t("fields.newField")}
</div>
{isEdit && (
<Button type="button" variant="ghost" size="sm" onClick={onDone}>
{t("form.cancel")}
</Button>
)}
</div>
<div className="space-y-1">
<Label htmlFor="field-key">{t("fields.key")}</Label>
<Input id="field-key" value={key} onChange={(e) => setKey(e.target.value)} />
<Input
id="field-key"
value={key}
disabled={isEdit}
onChange={(e) => setKey(e.target.value)}
/>
</div>
<LabelEditor value={labels} onChange={setLabels} />
@@ -81,8 +120,9 @@ export function FieldForm() {
<select
id="field-type"
value={dataType}
disabled={isEdit}
onChange={(e) => setDataType(e.target.value)}
className="w-full rounded border px-2 py-1 text-sm"
className="w-full rounded border px-2 py-1 text-sm disabled:opacity-60"
>
{TYPES.map((type) => (
<option key={type} value={type}>
@@ -98,8 +138,9 @@ export function FieldForm() {
<select
id="field-vocab"
value={vocabularyId}
disabled={isEdit}
onChange={(e) => setVocabularyId(e.target.value)}
className="w-full rounded border px-2 py-1 text-sm"
className="w-full rounded border px-2 py-1 text-sm disabled:opacity-60"
>
<option value="">{t("form.selectPlaceholder")}</option>
{vocabularies?.map((vocab) => (
@@ -117,8 +158,9 @@ export function FieldForm() {
<select
id="field-kind"
value={authorityKind}
disabled={isEdit}
onChange={(e) => setAuthorityKind(e.target.value)}
className="w-full rounded border px-2 py-1 text-sm"
className="w-full rounded border px-2 py-1 text-sm disabled:opacity-60"
>
<option value="">{t("fields.anyKind")}</option>
{KINDS.map((kind) => (
@@ -145,15 +187,19 @@ export function FieldForm() {
{t("form.required")}
</p>
)}
{create.isError && (
{failed && (
<p role="alert" className="text-xs text-red-600">
{t("form.rejected")}
</p>
)}
<Button type="submit" size="sm" disabled={create.isPending}>
{t("fields.create")}
<Button type="submit" size="sm" disabled={pending}>
{isEdit ? t("actions.save") : t("fields.create")}
</Button>
</form>
);
}
function labelTextOrKey(def: FieldDefinitionView): string {
return def.labels[0]?.label ?? def.key;
}
+40 -16
View File
@@ -1,15 +1,23 @@
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { useFieldDefinitions } from "../api/queries";
import { useFieldDefinitions, useDeleteFieldDefinition } from "../api/queries";
import { labelText } from "../lib/labels";
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
import { Skeleton } from "@/components/ui/skeleton";
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
export function FieldList() {
export function FieldList({
selectedKey,
onSelect,
}: {
selectedKey: string | null;
onSelect: (def: FieldDefinitionView) => void;
}) {
const { t, i18n } = useTranslation();
const { data, isLoading, isError } = useFieldDefinitions();
const deleteField = useDeleteFieldDefinition();
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
if (isLoading) {
@@ -50,21 +58,37 @@ export function FieldList() {
</div>
<ul>
{defs.map((def) => (
<li key={def.key} className="flex items-center gap-2 border-b px-3 py-2 text-sm">
<span className="font-medium">{labelText(def.labels, lang)}</span>
<span className="text-xs text-neutral-400">{def.key}</span>
<span className="rounded bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-600">
{t(`fields.types.${def.data_type}`)}
</span>
{def.required && (
<span
className="text-xs text-red-600"
title={t("fields.required")}
aria-label={t("fields.required")}
>
*
<li
key={def.key}
className={`flex items-center gap-2 border-b px-3 py-2 text-sm ${
def.key === selectedKey ? "bg-indigo-50" : ""
}`}
>
<button
type="button"
className="flex flex-1 items-center gap-2 text-left"
aria-pressed={def.key === selectedKey}
onClick={() => onSelect(def)}
>
<span className="font-medium">{labelText(def.labels, lang)}</span>
<span className="text-xs text-neutral-400">{def.key}</span>
<span className="rounded bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-600">
{t(`fields.types.${def.data_type}`)}
</span>
)}
{def.required && (
<span
className="text-xs text-red-600"
title={t("fields.required")}
aria-label={t("fields.required")}
>
*
</span>
)}
</button>
<DeleteConfirmDialog
description={t("actions.confirmDeleteField")}
onConfirm={() => deleteField.mutateAsync(def.key)}
/>
</li>
))}
</ul>
+13 -2
View File
@@ -1,14 +1,25 @@
import { useState } from "react";
import type { components } from "../api/schema";
import { FieldList } from "./field-list";
import { FieldForm } from "./field-form";
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
export function FieldsPage() {
const [selected, setSelected] = useState<FieldDefinitionView | null>(null);
return (
<div className="grid h-full grid-cols-[20rem_1fr]">
<div className="overflow-hidden border-r">
<FieldList />
<FieldList selectedKey={selected?.key ?? null} onSelect={setSelected} />
</div>
<div className="overflow-hidden">
<FieldForm />
<FieldForm
key={selected?.key ?? "create"}
editing={selected}
onDone={() => setSelected(null)}
/>
</div>
</div>
);
+1 -1
View File
@@ -6,7 +6,7 @@
"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" },
"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", "fieldRejected": "The field \"{{field}}\" was rejected — check its value", "flexibleHeading": "Catalogue fields" },
"actions": { "edit": "Edit", "delete": "Delete", "confirmDelete": "Delete this object? This cannot be undone." },
"actions": { "edit": "Edit", "delete": "Delete", "rename": "Rename", "save": "Save", "confirmDelete": "Delete this object? This cannot be undone.", "confirmDeleteTerm": "Delete this term? This cannot be undone.", "confirmDeleteAuthority": "Delete this authority? This cannot be undone.", "confirmDeleteField": "Delete this field definition? This cannot be undone.", "confirmDeleteVocabulary": "Delete this vocabulary? This cannot be undone.", "inUse": "Can't delete — used by {{count}} object(s). Clear those fields first." },
"labels": { "label": "Label", "externalUri": "External URI (optional)" },
"vocab": {
"newVocabulary": "New vocabulary", "key": "Key",
+1 -1
View File
@@ -6,7 +6,7 @@
"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" },
"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", "fieldRejected": "Fältet \"{{field}}\" avvisades — kontrollera värdet", "flexibleHeading": "Katalogfält" },
"actions": { "edit": "Redigera", "delete": "Ta bort", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras." },
"actions": { "edit": "Redigera", "delete": "Ta bort", "rename": "Byt namn", "save": "Spara", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras.", "confirmDeleteTerm": "Ta bort denna term? Detta kan inte ångras.", "confirmDeleteAuthority": "Ta bort denna auktoritet? Detta kan inte ångras.", "confirmDeleteField": "Ta bort denna fältdefinition? Detta kan inte ångras.", "confirmDeleteVocabulary": "Ta bort denna vokabulär? Detta kan inte ångras.", "inUse": "Kan inte ta bort — används av {{count}} föremål. Rensa de fälten först." },
"labels": { "label": "Etikett", "externalUri": "Extern URI (valfritt)" },
"vocab": {
"newVocabulary": "Ny vokabulär", "key": "Nyckel",
+38
View File
@@ -0,0 +1,38 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect, userEvent } from 'storybook/test'
import { TermRow } from './term-row'
const meta = {
component: TermRow,
tags: ['ai-generated'],
args: {
vocabularyId: 'v1',
lang: 'en',
term: { id: 't1', external_uri: null, labels: [{ lang: 'en', label: 'Wood' }] },
},
} satisfies Meta<typeof TermRow>
export default meta
type Story = StoryObj<typeof meta>
export const Display: Story = {
play: async ({ canvas }) => {
await expect(canvas.getByText('Wood')).toBeVisible()
},
}
export const TogglesEdit: Story = {
play: async ({ canvas }) => {
await userEvent.click(canvas.getByRole('button', { name: 'Edit' }))
await expect(canvas.getByRole('button', { name: 'Save' })).toBeVisible()
},
}
export const CancelsEdit: Story = {
play: async ({ canvas }) => {
await userEvent.click(canvas.getByRole('button', { name: 'Edit' }))
await userEvent.click(canvas.getByRole('button', { name: 'Cancel' }))
await expect(canvas.getByText('Wood')).toBeVisible()
},
}
+77
View File
@@ -0,0 +1,77 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { useUpdateTerm, useDeleteTerm } from "../api/queries";
import { LabelEditor } from "../components/label-editor";
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { labelText } from "../lib/labels";
type TermView = components["schemas"]["TermView"];
type LabelInput = components["schemas"]["LabelInput"];
export function TermRow({ vocabularyId, term, lang }: { vocabularyId: string; term: TermView; lang: string }) {
const { t } = useTranslation();
const updateTerm = useUpdateTerm();
const deleteTerm = useDeleteTerm();
const [editing, setEditing] = useState(false);
const [labels, setLabels] = useState<LabelInput[]>(term.labels as LabelInput[]);
const [uri, setUri] = useState(term.external_uri ?? "");
if (editing) {
return (
<li className="space-y-2 border-b py-2">
<LabelEditor value={labels} onChange={setLabels} />
<div className="space-y-1">
<Label htmlFor={`term-uri-${term.id}`}>{t("labels.externalUri")}</Label>
<Input id={`term-uri-${term.id}`} value={uri} onChange={(e) => setUri(e.target.value)} />
</div>
<div className="flex gap-2">
<Button
type="button"
size="sm"
disabled={updateTerm.isPending}
onClick={() =>
updateTerm.mutate(
{ vocabularyId, termId: term.id, external_uri: uri.trim() || null, labels },
{ onSuccess: () => setEditing(false) },
)
}
>
{t("actions.save")}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)}>
{t("form.cancel")}
</Button>
</div>
</li>
);
}
return (
<li className="flex items-center gap-2 border-b py-1 text-sm">
<span className="flex-1">{labelText(term.labels, lang)}</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setLabels(term.labels as LabelInput[]);
setUri(term.external_uri ?? "");
setEditing(true);
}}
>
{t("actions.edit")}
</Button>
<DeleteConfirmDialog
description={t("actions.confirmDeleteTerm")}
onConfirm={() => deleteTerm.mutateAsync({ vocabularyId, termId: term.id })}
/>
</li>
);
}
+59 -10
View File
@@ -2,7 +2,8 @@ import { useState, type FormEvent } from "react";
import { NavLink } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useVocabularies, useCreateVocabulary } from "../api/queries";
import { useVocabularies, useCreateVocabulary, useRenameVocabulary, useDeleteVocabulary } from "../api/queries";
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -13,8 +14,12 @@ export function VocabularyList() {
const { data, isLoading, isError } = useVocabularies();
const create = useCreateVocabulary();
const renameVocabulary = useRenameVocabulary();
const deleteVocabulary = useDeleteVocabulary();
const [key, setKey] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [draftKey, setDraftKey] = useState("");
const onCreate = (event: FormEvent) => {
event.preventDefault();
@@ -56,15 +61,59 @@ export function VocabularyList() {
<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 key={v.id} className="flex items-center gap-1 border-b pr-2">
{editingId === v.id ? (
<form
className="flex flex-1 flex-wrap gap-1 p-1"
onSubmit={(e) => {
e.preventDefault();
renameVocabulary.mutate(
{ id: v.id, key: draftKey.trim() },
{ onSuccess: () => setEditingId(null) },
);
}}
>
<Input
aria-label={t("vocab.key")}
value={draftKey}
onChange={(e) => setDraftKey(e.target.value)}
/>
<Button type="submit" size="sm" disabled={renameVocabulary.isPending}>
{t("actions.save")}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setEditingId(null)}>
{t("form.cancel")}
</Button>
{renameVocabulary.isError && (
<p role="alert" className="text-xs text-red-600">
{t("form.rejected")}
</p>
)}
</form>
) : (
<>
<NavLink
to={`/vocabularies/${v.id}`}
className={({ isActive }) =>
`block flex-1 px-3 py-2 text-sm ${isActive ? "bg-indigo-50" : "hover:bg-neutral-50"}`
}
>
{v.key}
</NavLink>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => { setEditingId(v.id); setDraftKey(v.key); }}
>
{t("actions.rename")}
</Button>
<DeleteConfirmDialog
description={t("actions.confirmDeleteVocabulary")}
onConfirm={() => deleteVocabulary.mutateAsync(v.id)}
/>
</>
)}
</li>
))}
</ul>
+2 -4
View File
@@ -5,10 +5,10 @@ import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { useTerms, useAddTerm } from "../api/queries";
import { LabelEditor } from "../components/label-editor";
import { TermRow } from "./term-row";
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"];
@@ -63,9 +63,7 @@ export function VocabularyTerms() {
<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>
<TermRow key={term.id} vocabularyId={id} term={term} lang={lang} />
))}
</ul>
<form onSubmit={onAdd} className="space-y-2 border-t pt-3">