diff --git a/crates/api/src/admin_authorities.rs b/crates/api/src/admin_authorities.rs
index 22e1e45..deba5f1 100644
--- a/crates/api/src/admin_authorities.rs
+++ b/crates/api/src/admin_authorities.rs
@@ -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 {
- Router::new().route(
- "/api/admin/authorities",
- get(list_authorities).post(create_authority),
- )
+#[derive(Deserialize, ToSchema)]
+pub(crate) struct UpdateAuthorityRequest {
+ pub external_uri: Option,
+ pub labels: Vec,
+}
+
+#[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,
+ State(state): State,
+ Path(id): Path,
+ Json(req): Json,
+) -> Result {
+ let id = id
+ .parse::()
+ .map_err(|_| StatusCode::NOT_FOUND)?;
+
+ let labels: Vec = 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,
+ State(state): State,
+ Path(id): Path,
+) -> Response {
+ let Ok(id) = id.parse::() 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 {
+ 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),
+ )
}
diff --git a/crates/api/src/admin_objects.rs b/crates/api/src/admin_objects.rs
index c053d85..ef32f2d 100644
--- a/crates/api/src/admin_objects.rs
+++ b/crates/api/src/admin_objects.rs
@@ -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,
+ pub labels: Vec,
+}
+
+/// 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,
+ State(state): State,
+ Path(key): Path,
+ Json(req): Json,
+) -> Result {
+ let labels: Vec = 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,
+ State(state): State,
+ Path(key): Path,
+) -> 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 {
"/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),
+ )
}
diff --git a/crates/api/src/admin_vocab.rs b/crates/api/src/admin_vocab.rs
index 0b714e4..a0c3551 100644
--- a/crates/api/src/admin_vocab.rs
+++ b/crates/api/src/admin_vocab.rs
@@ -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,
+ pub labels: Vec,
+}
+
+#[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,
+ State(state): State,
+ Path((id, term_id)): Path<(String, String)>,
+ Json(req): Json,
+) -> Result {
+ let vocabulary_id = id
+ .parse::()
+ .map_err(|_| StatusCode::NOT_FOUND)?;
+
+ let term_id = term_id
+ .parse::()
+ .map_err(|_| StatusCode::NOT_FOUND)?;
+
+ let labels: Vec = 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,
+ State(state): State,
+ Path((id, term_id)): Path<(String, String)>,
+) -> Response {
+ let (Ok(vocab_id), Ok(term_id)) = (id.parse::(), term_id.parse::())
+ 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,
+ State(state): State,
+ Path(id): Path,
+ Json(req): Json,
+) -> Result {
+ let id = id
+ .parse::()
+ .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,
+ State(state): State,
+ Path(id): Path,
+) -> Response {
+ let Ok(id) = id.parse::() 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 {
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),
+ )
}
diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs
index b625cba..1827dec 100644
--- a/crates/api/src/openapi.rs
+++ b/crates/api/src/openapi.rs
@@ -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
diff --git a/crates/api/tests/admin_catalog.rs b/crates/api/tests/admin_catalog.rs
index 04aec01..a8d957c 100644
--- a/crates/api/tests/admin_catalog.rs
+++ b/crates/api/tests/admin_catalog.rs
@@ -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 {
+ 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);
+}
diff --git a/crates/db/src/authority.rs b/crates/db/src/authority.rs
index dc2468f..617f406 100644
--- a/crates/db/src/authority.rs
+++ b/crates/db/src/authority.rs
@@ -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 {
+ 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
+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 {
+ 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 {
let kind_str: String = row.try_get("kind")?;
let kind = AuthorityKind::from_db(&kind_str)
diff --git a/crates/db/src/fields.rs b/crates/db/src/fields.rs
index d07ba6e..83dd6a2 100644
--- a/crates/db/src/fields.rs
+++ b/crates/db/src/fields.rs
@@ -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,
+ labels: &[LocalizedLabel],
+) -> Result {
+ let id: Option =
+ 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
+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 {
+ let id: Option =
+ 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)
+}
diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs
index 0f1c2fa..6db903f 100644
--- a/crates/db/src/lib.rs
+++ b/crates/db/src/lib.rs
@@ -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 {
diff --git a/crates/db/src/vocab.rs b/crates/db/src/vocab.rs
index 9bc558a..4c8f9a9 100644
--- a/crates/db/src/vocab.rs
+++ b/crates/db/src/vocab.rs
@@ -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 {
+ 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
+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 {
+ 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 {
+ 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 {
+ 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 {
Ok(Vocabulary {
id: VocabularyId::from_uuid(row.try_get("id")?),
diff --git a/crates/db/tests/authority.rs b/crates/db/tests/authority.rs
index c7ebc51..36bff06 100644
--- a/crates/db/tests/authority.rs
+++ b/crates/db/tests/authority.rs
@@ -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
+ );
+}
diff --git a/crates/db/tests/fields.rs b/crates/db/tests/fields.rs
index faf8ecf..35ef8b1 100644
--- a/crates/db/tests/fields.rs
+++ b/crates/db/tests/fields.rs
@@ -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 {
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
+ );
+}
diff --git a/crates/db/tests/vocab.rs b/crates/db/tests/vocab.rs
index d3edabb..3b64c6d 100644
--- a/crates/db/tests/vocab.rs
+++ b/crates/db/tests/vocab.rs
@@ -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);
+}
diff --git a/web/src/api/queries.test.ts b/web/src/api/queries.test.ts
new file mode 100644
index 0000000..bd45710
--- /dev/null
+++ b/web/src/api/queries.test.ts
@@ -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);
+ });
+});
diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts
index 5741123..c4c82fe 100644
--- a/web/src/api/queries.ts
+++ b/web/src/api/queries.ts
@@ -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"] }),
+ });
+}
diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts
index 4e49a0d..dedd2bf 100644
--- a/web/src/api/schema.d.ts
+++ b/web/src/api/schema.d.ts
@@ -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;
diff --git a/web/src/authorities/authorities-page.tsx b/web/src/authorities/authorities-page.tsx
index 6b97aa4..676f92c 100644
--- a/web/src/authorities/authorities-page.tsx
+++ b/web/src/authorities/authorities-page.tsx
@@ -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() {
{t("authorities.empty")}
)}
{authorities?.map((a) => (
-
- {labelText(a.labels, lang)}
-
+
))}
diff --git a/web/src/authorities/authority-row.stories.tsx b/web/src/authorities/authority-row.stories.tsx
new file mode 100644
index 0000000..ac5ec5b
--- /dev/null
+++ b/web/src/authorities/authority-row.stories.tsx
@@ -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
+
+export default meta
+type Story = StoryObj
+
+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()
+ },
+}
diff --git a/web/src/authorities/authority-row.tsx b/web/src/authorities/authority-row.tsx
new file mode 100644
index 0000000..fe06c1b
--- /dev/null
+++ b/web/src/authorities/authority-row.tsx
@@ -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(authority.labels as LabelInput[]);
+ const [uri, setUri] = useState(authority.external_uri ?? "");
+
+ if (editing) {
+ return (
+
+
+
+
+ setUri(e.target.value)} />
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {labelText(authority.labels, lang)}
+
+ deleteAuthority.mutateAsync({ id: authority.id, kind })}
+ />
+
+ );
+}
diff --git a/web/src/components/delete-confirm-dialog.stories.tsx b/web/src/components/delete-confirm-dialog.stories.tsx
new file mode 100644
index 0000000..6665f80
--- /dev/null
+++ b/web/src/components/delete-confirm-dialog.stories.tsx
@@ -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
+
+export default meta
+type Story = StoryObj
+
+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)
+ },
+}
diff --git a/web/src/components/delete-confirm-dialog.tsx b/web/src/components/delete-confirm-dialog.tsx
new file mode 100644
index 0000000..190d2ff
--- /dev/null
+++ b/web/src/components/delete-confirm-dialog.tsx
@@ -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;
+ /** 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(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 (
+
+
+ {triggerLabel ?? t("actions.delete")}
+
+ }
+ />
+
+ {t("actions.delete")}
+ {description}
+ {message && (
+
+ {message}
+
+ )}
+
+ {t("form.cancel")}
+ {t("actions.delete")}
+
+
+
+ );
+}
diff --git a/web/src/fields/field-form.stories.tsx b/web/src/fields/field-form.stories.tsx
new file mode 100644
index 0000000..662983d
--- /dev/null
+++ b/web/src/fields/field-form.stories.tsx
@@ -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
+
+export default meta
+type Story = StoryObj
+
+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()
+ },
+}
diff --git a/web/src/fields/field-form.tsx b/web/src/fields/field-form.tsx
index 8177fd7..c153128 100644
--- a/web/src/fields/field-form.tsx
+++ b/web/src/fields/field-form.tsx
@@ -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([]);
- const [dataType, setDataType] = useState("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((editing?.labels as LabelInput[]) ?? []);
+ const [dataType, setDataType] = useState(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 (
)}
- {create.isError && (
+ {failed && (
{t("form.rejected")}
)}
-