Compare commits
15 Commits
ffcfb41c7e
...
873efe199f
| Author | SHA1 | Date | |
|---|---|---|---|
| 873efe199f | |||
| 27caaa9787 | |||
| c9120848f5 | |||
| 83ca506702 | |||
| 65ca79f2bd | |||
| 194f18c8ed | |||
| 282e6430d4 | |||
| 78c950d2ee | |||
| 3e7c6ad712 | |||
| 47240dafcc | |||
| 83a7202861 | |||
| 09baf2949f | |||
| f6053068be | |||
| e58b150ab2 | |||
| e7ae41362e |
@@ -3,18 +3,19 @@
|
|||||||
use auth::{Authorized, EditCatalogue, ViewInternal};
|
use auth::{Authorized, EditCatalogue, ViewInternal};
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
extract::{Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
routing::get,
|
routing::get,
|
||||||
};
|
};
|
||||||
use domain::{AuditActor, AuthorityKind, LocalizedLabel, NewAuthority};
|
use domain::{AuditActor, AuthorityId, AuthorityKind, LocalizedLabel, NewAuthority};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
AppState,
|
AppState,
|
||||||
admin_objects::LabelView,
|
admin_objects::LabelView,
|
||||||
admin_vocab::{CreatedId, LabelInput},
|
admin_vocab::{CreatedId, InUseView, LabelInput},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
@@ -129,9 +130,125 @@ pub(crate) async fn create_authority(
|
|||||||
Ok((StatusCode::CREATED, Json(CreatedId { id: id.to_string() })))
|
Ok((StatusCode::CREATED, Json(CreatedId { id: id.to_string() })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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> {
|
pub(crate) fn routes() -> Router<AppState> {
|
||||||
Router::new().route(
|
Router::new()
|
||||||
|
.route(
|
||||||
"/api/admin/authorities",
|
"/api/admin/authorities",
|
||||||
get(list_authorities).post(create_authority),
|
get(list_authorities).post(create_authority),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/admin/authorities/{id}",
|
||||||
|
axum::routing::patch(update_authority).delete(delete_authority),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use axum::{
|
|||||||
Json, Router,
|
Json, Router,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::{IntoResponse, Response},
|
||||||
routing::{get, put},
|
routing::{get, put},
|
||||||
};
|
};
|
||||||
use domain::{
|
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.
|
/// Field-level rejection detail for `set_fields`, so the UI can highlight the field.
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
pub(crate) struct FieldErrorView {
|
pub(crate) struct FieldErrorView {
|
||||||
@@ -609,4 +736,8 @@ pub(crate) fn routes() -> Router<AppState> {
|
|||||||
"/api/admin/field-definitions",
|
"/api/admin/field-definitions",
|
||||||
get(list_field_definitions).post(create_field_definition),
|
get(list_field_definitions).post(create_field_definition),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/admin/field-definitions/{key}",
|
||||||
|
axum::routing::patch(update_field_definition).delete(delete_field_definition),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ use axum::{
|
|||||||
Json, Router,
|
Json, Router,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
routing::get,
|
routing::get,
|
||||||
};
|
};
|
||||||
use domain::{AuditActor, LocalizedLabel, NewTerm, VocabularyId};
|
use domain::{AuditActor, LocalizedLabel, NewTerm, TermId, VocabularyId};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
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> {
|
pub(crate) fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route(
|
.route(
|
||||||
"/api/admin/vocabularies",
|
"/api/admin/vocabularies",
|
||||||
get(list_vocabularies).post(create_vocabulary),
|
get(list_vocabularies).post(create_vocabulary),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/admin/vocabularies/{id}",
|
||||||
|
axum::routing::patch(rename_vocabulary).delete(delete_vocabulary),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/admin/vocabularies/{id}/terms",
|
"/api/admin/vocabularies/{id}/terms",
|
||||||
get(list_terms).post(add_term),
|
get(list_terms).post(add_term),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/admin/vocabularies/{id}/terms/{term_id}",
|
||||||
|
axum::routing::patch(update_term).delete(delete_term),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,14 +26,22 @@ use crate::{
|
|||||||
admin_objects::delete_object,
|
admin_objects::delete_object,
|
||||||
admin_objects::list_field_definitions,
|
admin_objects::list_field_definitions,
|
||||||
admin_objects::create_field_definition,
|
admin_objects::create_field_definition,
|
||||||
|
admin_objects::update_field_definition,
|
||||||
|
admin_objects::delete_field_definition,
|
||||||
admin_objects::set_fields,
|
admin_objects::set_fields,
|
||||||
admin_vocab::list_vocabularies,
|
admin_vocab::list_vocabularies,
|
||||||
admin_vocab::create_vocabulary,
|
admin_vocab::create_vocabulary,
|
||||||
admin_vocab::list_terms,
|
admin_vocab::list_terms,
|
||||||
admin_vocab::add_term,
|
admin_vocab::add_term,
|
||||||
|
admin_vocab::update_term,
|
||||||
|
admin_vocab::delete_term,
|
||||||
|
admin_vocab::rename_vocabulary,
|
||||||
|
admin_vocab::delete_vocabulary,
|
||||||
admin_search::search_objects,
|
admin_search::search_objects,
|
||||||
admin_authorities::list_authorities,
|
admin_authorities::list_authorities,
|
||||||
admin_authorities::create_authority
|
admin_authorities::create_authority,
|
||||||
|
admin_authorities::update_authority,
|
||||||
|
admin_authorities::delete_authority
|
||||||
),
|
),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
config::ConfigView,
|
config::ConfigView,
|
||||||
@@ -52,6 +60,7 @@ use crate::{
|
|||||||
admin_objects::CreatedObject,
|
admin_objects::CreatedObject,
|
||||||
admin_objects::FieldDefinitionView,
|
admin_objects::FieldDefinitionView,
|
||||||
admin_objects::NewFieldDefinitionRequest,
|
admin_objects::NewFieldDefinitionRequest,
|
||||||
|
admin_objects::UpdateFieldDefinitionRequest,
|
||||||
admin_objects::CreatedField,
|
admin_objects::CreatedField,
|
||||||
admin_objects::FieldErrorView,
|
admin_objects::FieldErrorView,
|
||||||
admin_vocab::VocabularyView,
|
admin_vocab::VocabularyView,
|
||||||
@@ -60,10 +69,14 @@ use crate::{
|
|||||||
admin_vocab::LabelInput,
|
admin_vocab::LabelInput,
|
||||||
admin_vocab::TermView,
|
admin_vocab::TermView,
|
||||||
admin_vocab::CreatedId,
|
admin_vocab::CreatedId,
|
||||||
|
admin_vocab::UpdateTermRequest,
|
||||||
|
admin_vocab::InUseView,
|
||||||
|
admin_vocab::RenameVocabularyRequest,
|
||||||
admin_search::SearchHitView,
|
admin_search::SearchHitView,
|
||||||
admin_search::SearchResultsView,
|
admin_search::SearchResultsView,
|
||||||
admin_authorities::AuthorityView,
|
admin_authorities::AuthorityView,
|
||||||
admin_authorities::NewAuthorityRequest,
|
admin_authorities::NewAuthorityRequest,
|
||||||
|
admin_authorities::UpdateAuthorityRequest,
|
||||||
domain::Visibility,
|
domain::Visibility,
|
||||||
domain::AuthorityKind,
|
domain::AuthorityKind,
|
||||||
domain::DataType
|
domain::DataType
|
||||||
|
|||||||
@@ -333,3 +333,548 @@ async fn creating_a_vocabulary_writes_an_audit_entry(pool: PgPool) {
|
|||||||
"expected actor to be a user"
|
"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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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> {
|
fn map_authority(row: sqlx::postgres::PgRow) -> Result<Authority, sqlx::Error> {
|
||||||
let kind_str: String = row.try_get("kind")?;
|
let kind_str: String = row.try_get("kind")?;
|
||||||
let kind = AuthorityKind::from_db(&kind_str)
|
let kind = AuthorityKind::from_db(&kind_str)
|
||||||
|
|||||||
+118
-2
@@ -1,11 +1,15 @@
|
|||||||
//! Registry of flexible field definitions.
|
//! Registry of flexible field definitions.
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
AuthorityKind, FieldDefinition, FieldDefinitionId, FieldType, LocalizedLabel,
|
AuditAction, AuditActor, AuthorityKind, FieldDefinition, FieldDefinitionId, FieldType,
|
||||||
NewFieldDefinition, VocabularyId,
|
LocalizedLabel, NewAuditEvent, NewFieldDefinition, VocabularyId,
|
||||||
};
|
};
|
||||||
use sqlx::Row;
|
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.
|
/// 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) \
|
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)";
|
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,
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,17 @@ pub mod vocab;
|
|||||||
|
|
||||||
use sqlx::postgres::{PgPool, PgPoolOptions};
|
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.
|
/// A handle to the organization's PostgreSQL database.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Db {
|
pub struct Db {
|
||||||
|
|||||||
@@ -177,6 +177,204 @@ where
|
|||||||
Ok(found.map(|_| TermRef::new(term_id, vocabulary_id)))
|
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> {
|
fn map_vocabulary(row: sqlx::postgres::PgRow) -> Result<Vocabulary, sqlx::Error> {
|
||||||
Ok(Vocabulary {
|
Ok(Vocabulary {
|
||||||
id: VocabularyId::from_uuid(row.try_get("id")?),
|
id: VocabularyId::from_uuid(row.try_get("id")?),
|
||||||
|
|||||||
@@ -1,7 +1,23 @@
|
|||||||
use db::{Db, authority};
|
use db::{Db, authority, catalog, fields};
|
||||||
use domain::{AuditActor, AuthorityKind, LocalizedLabel, NewAuthority};
|
use domain::{
|
||||||
|
AuditActor, AuthorityKind, LocalizedLabel, NewAuthority, NewFieldDefinition, Visibility,
|
||||||
|
};
|
||||||
use sqlx::PgPool;
|
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 {
|
fn new_person(name_sv: &str, name_en: &str) -> NewAuthority {
|
||||||
NewAuthority {
|
NewAuthority {
|
||||||
kind: AuthorityKind::Person,
|
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_eq!(got.kind, AuthorityKind::Organisation);
|
||||||
assert!(got.labels.is_empty());
|
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
@@ -1,7 +1,24 @@
|
|||||||
use db::{Db, fields, vocab};
|
use db::{Db, DeleteOutcome, audit, catalog, fields, vocab};
|
||||||
use domain::{AuditActor, AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition};
|
use domain::{
|
||||||
|
AuditAction, AuditActor, AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition,
|
||||||
|
ObjectInput, Visibility,
|
||||||
|
};
|
||||||
use sqlx::PgPool;
|
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> {
|
fn labels() -> Vec<LocalizedLabel> {
|
||||||
vec![
|
vec![
|
||||||
LocalizedLabel {
|
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();
|
let keys: Vec<&str> = all.iter().map(|d| d.key.as_str()).collect();
|
||||||
assert_eq!(keys, vec!["donor", "on_display"]);
|
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
@@ -1,5 +1,8 @@
|
|||||||
use db::{Db, vocab};
|
use db::{Db, audit, catalog, fields, vocab};
|
||||||
use domain::{AuditActor, LocalizedLabel, NewTerm};
|
use domain::{
|
||||||
|
AuditAction, AuditActor, FieldType, LocalizedLabel, NewFieldDefinition, NewTerm, ObjectInput,
|
||||||
|
Visibility,
|
||||||
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
#[sqlx::test]
|
#[sqlx::test]
|
||||||
@@ -169,3 +172,234 @@ async fn resolve_term_checks_vocabulary_membership(pool: PgPool) {
|
|||||||
.is_none()
|
.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);
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,220 @@
|
|||||||
|
# Reference-Data Edit/Delete Lifecycle — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-05
|
||||||
|
**Status:** Approved (brainstorming) — ready for implementation planning.
|
||||||
|
**Issues:** #30 (vocabularies/terms/authorities edit+delete), #36 (field-definitions edit+delete).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The admin reference-data surface currently exposes **create + list only** for all four
|
||||||
|
entity kinds:
|
||||||
|
|
||||||
|
- Vocabularies & terms — `GET/POST /api/admin/vocabularies`,
|
||||||
|
`GET/POST /api/admin/vocabularies/{id}/terms`
|
||||||
|
- Authorities — `GET/POST /api/admin/authorities?kind=`
|
||||||
|
- Field definitions — `GET/POST /api/admin/field-definitions`
|
||||||
|
|
||||||
|
There is no way to rename a vocabulary, correct a term's labels/URI, fix a misspelled
|
||||||
|
authority, edit a field definition's labels/group/required flag, or delete any of them.
|
||||||
|
In a cataloguing system this is a real operational gap: reference data accrues mistakes
|
||||||
|
that today can only be fixed by direct DB edits. This milestone completes the CRUD
|
||||||
|
lifecycle — backend endpoints **and** frontend UI — for all four kinds.
|
||||||
|
|
||||||
|
### The integrity constraint that shapes everything
|
||||||
|
|
||||||
|
Object flexible-field values live in a **JSONB column on `object`** (migration
|
||||||
|
`0005_object_fields.sql`: `ALTER TABLE object ADD COLUMN fields JSONB NOT NULL DEFAULT
|
||||||
|
'{}'`), keyed by field-definition `key`, with `term`/`authority` references stored as
|
||||||
|
**UUID strings inside that JSON**. There is **no foreign key** from object data to
|
||||||
|
terms/authorities/field-definitions. Consequences:
|
||||||
|
|
||||||
|
- Deleting a **term, authority, or field-definition** is *not* blocked or cascaded by
|
||||||
|
the database — it would silently leave dangling UUIDs / orphaned keys in objects. Any
|
||||||
|
"is it referenced?" guard must be an explicit JSONB scan we write.
|
||||||
|
- Deleting a **vocabulary** *is* already protected: `term.vocabulary_id` and
|
||||||
|
`field_definition.vocabulary_id` are `REFERENCES vocabulary (id) ON DELETE RESTRICT`
|
||||||
|
(migrations `0002`, `0004`). So a non-empty/bound vocabulary delete fails at the FK
|
||||||
|
level — we catch that and surface a clean 409, rather than invent a check.
|
||||||
|
- `term_label`, `authority_label`, `field_definition_label` are all `ON DELETE CASCADE`
|
||||||
|
from their parent, so deleting a term/authority/field-def cleans up its own labels.
|
||||||
|
|
||||||
|
### Decisions settled in brainstorming
|
||||||
|
|
||||||
|
1. **Scope:** cohesive — backend **and** frontend edit/delete for all four kinds in one
|
||||||
|
milestone (no backend endpoints shipping without UI).
|
||||||
|
2. **Delete policy for referenced entities:** **block with 409 + count.** Never silently
|
||||||
|
alter catalogue data. The curator must clear/reassign the referencing object fields
|
||||||
|
first. (Cascade-scrub was rejected as too destructive / no undo.)
|
||||||
|
3. **Field-definition immutability:** `key`, `data_type`, and binding
|
||||||
|
(`vocabulary_id`/`authority_kind`) are **immutable**; `labels`, `group_key`,
|
||||||
|
`required` are editable.
|
||||||
|
4. **`required` toggled on:** allowed; governs validation on *future* object writes only
|
||||||
|
— no retroactive scan or block of existing objects.
|
||||||
|
5. **Vocabulary `key` rename:** allowed (cosmetic — terms and field-definitions bind by
|
||||||
|
vocabulary UUID, not key).
|
||||||
|
6. **Frontend affordance:** **in-place edit + `AlertDialog` delete** (Option A) — reuse
|
||||||
|
the existing two-pane form panes and the installed AlertDialog; no new shadcn deps.
|
||||||
|
7. **Storybook:** add co-located stories for components created or meaningfully changed
|
||||||
|
where it makes sense (forms, editable rows, dialogs) — not every trivial change.
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
All endpoints are gated by `EditCatalogue` and audited: every mutation runs the
|
||||||
|
insert/update/delete **and** `audit::record(&mut *conn, &NewAuditEvent { actor, action,
|
||||||
|
entity_type, entity_id, .. })` in **one transaction**, mirroring the #21 create-audit
|
||||||
|
pattern (`AuditActor::User(auth.user.id.to_uuid())`). Audit actions: `Updated` /
|
||||||
|
`Deleted`.
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
**Vocabularies / terms** (`crates/api/src/admin_vocab.rs`)
|
||||||
|
- `PATCH /api/admin/vocabularies/{id}` — rename `key` only.
|
||||||
|
- `DELETE /api/admin/vocabularies/{id}` — allowed only when the vocabulary has no terms
|
||||||
|
and is bound by no field-definition. Enforced by the existing FK `RESTRICT`; the
|
||||||
|
resulting `sqlx` FK error is mapped to **409** with a reason, not a 500.
|
||||||
|
- `PATCH /api/admin/vocabularies/{id}/terms/{term_id}` — edit labels + `external_uri`.
|
||||||
|
- `DELETE /api/admin/vocabularies/{id}/terms/{term_id}` — **409 + count** if any object
|
||||||
|
references the term; otherwise delete (its `term_label` rows cascade).
|
||||||
|
|
||||||
|
**Authorities** (`crates/api/src/admin_authorities.rs`)
|
||||||
|
- `PATCH /api/admin/authorities/{id}` — edit labels + `external_uri`. `kind` is
|
||||||
|
**immutable** (field bindings filter by kind).
|
||||||
|
- `DELETE /api/admin/authorities/{id}` — **409 + count** if referenced; otherwise delete
|
||||||
|
(`authority_label` cascades).
|
||||||
|
|
||||||
|
**Field definitions** (`crates/api/src/` field-definition handler)
|
||||||
|
- `PATCH /api/admin/field-definitions/{key}` — edit labels, `group_key`, `required`. The
|
||||||
|
PATCH body exposes **only** those three fields, so `key`/`data_type`/binding are
|
||||||
|
**structurally immutable** (they cannot be sent — no runtime reject needed). Invalid
|
||||||
|
values (empty label, empty `group_key` — both have `CHECK <> ''` constraints) return
|
||||||
|
**422**, consistent with the create path.
|
||||||
|
- `DELETE /api/admin/field-definitions/{key}` — **409 + count** if any object stores that
|
||||||
|
field key; otherwise delete (label cascades).
|
||||||
|
|
||||||
|
### Referenced-checks (JSONB scans)
|
||||||
|
|
||||||
|
- **Term / authority referenced:** count objects whose `fields` JSONB contains the UUID
|
||||||
|
as a value. Use a jsonpath value-match, scoped to the field keys whose definition is
|
||||||
|
`term`/`authority`-typed for that vocabulary/kind, to avoid false positives. Returns a
|
||||||
|
count (and the implementation may also collect a small sample of object numbers for the
|
||||||
|
UI message).
|
||||||
|
- **Field-definition referenced:** count objects where `fields ? '<key>'` (JSONB
|
||||||
|
key-exists operator) — simple and indexable.
|
||||||
|
|
||||||
|
### DB layer (`crates/db/src/{vocab,authority,fields}.rs`)
|
||||||
|
|
||||||
|
New functions, each taking `actor: AuditActor` and a `&mut PgConnection` and writing its
|
||||||
|
audit entry atomically (mirroring the existing create functions):
|
||||||
|
|
||||||
|
- `vocab::rename_vocabulary`, `vocab::delete_vocabulary`, `vocab::update_term`,
|
||||||
|
`vocab::delete_term`, `vocab::count_objects_referencing_term`
|
||||||
|
- `authority::update_authority`, `authority::delete_authority`,
|
||||||
|
`authority::count_objects_referencing_authority`
|
||||||
|
- `fields::update_field_definition`, `fields::delete_field_definition`,
|
||||||
|
`fields::count_objects_using_field`
|
||||||
|
|
||||||
|
`delete_vocabulary` maps the FK-restrict error to a typed "in use" error so the handler
|
||||||
|
can return 409. The three `count_*` helpers are read-only (no audit, no actor).
|
||||||
|
|
||||||
|
### API error shape
|
||||||
|
|
||||||
|
Reuse the existing typed-error conventions:
|
||||||
|
- **409 (referenced / in-use):** JSON body with the blocking count (e.g.
|
||||||
|
`{ "code": "in_use", "count": N }`) so the UI can render "used by N objects".
|
||||||
|
- **422 (invalid value):** the existing field-error shape
|
||||||
|
(`{ field, code }`, e.g. empty label / empty `group_key`), consistent with
|
||||||
|
`set_fields`' `FieldErrorView` and the create path. (Immutability is enforced
|
||||||
|
structurally by the PATCH DTO, not by a runtime 422.)
|
||||||
|
- **404:** entity not found.
|
||||||
|
|
||||||
|
OpenAPI is regenerated so `web/src/api/schema.d.ts` picks up the new endpoints and DTOs.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
**Affordance: in-place edit + `AlertDialog` delete**, per screen layout. Deletes
|
||||||
|
everywhere use the installed `AlertDialog`, mirroring the existing `DeleteObjectDialog`.
|
||||||
|
|
||||||
|
### Per-screen
|
||||||
|
|
||||||
|
**Fields** (`web/src/fields/`, two-pane `/fields`)
|
||||||
|
- Selecting a row in the left `FieldList` turns the right pane (`FieldForm`) into an
|
||||||
|
**edit form**: `labels` / `group_key` / `required` editable; `key` / `data_type` /
|
||||||
|
binding shown **disabled**. A "New field" button resets the pane to create mode.
|
||||||
|
- Each list row gets a delete affordance → `AlertDialog` confirm.
|
||||||
|
|
||||||
|
**Vocabularies** (`web/src/vocab/`, two-pane `/vocabularies/:id`)
|
||||||
|
- Left `VocabularyList` rows: rename-`key` (inline edit) + delete.
|
||||||
|
- Right-pane (`VocabularyTerms`) term rows: edit (labels/URI) + delete.
|
||||||
|
|
||||||
|
**Authorities** (`web/src/authorities/`, single-pane `/authorities/:kind`)
|
||||||
|
- Each authority row: inline-expand edit (labels/URI) + delete.
|
||||||
|
|
||||||
|
### Hooks (`web/src/api/queries.ts`)
|
||||||
|
|
||||||
|
Add, mirroring `useUpdateObject`/`useDeleteObject` and invalidating the correct list
|
||||||
|
query keys: `useRenameVocabulary`, `useDeleteVocabulary`, `useUpdateTerm`,
|
||||||
|
`useDeleteTerm`, `useUpdateAuthority`, `useDeleteAuthority`, `useUpdateFieldDefinition`,
|
||||||
|
`useDeleteFieldDefinition`. The **409 (referenced)** and **422 (immutable)** responses
|
||||||
|
parse into the existing `HttpError`/`FieldRejection` style.
|
||||||
|
|
||||||
|
### Delete-blocked UX
|
||||||
|
|
||||||
|
On a 409 the `AlertDialog` **stays open** and shows the blocking reason —
|
||||||
|
`t("actions.inUse", { count })` → e.g. *"Used by 7 objects — clear those fields first"* —
|
||||||
|
instead of closing.
|
||||||
|
|
||||||
|
### i18n (`web/src/i18n/{en,sv}.json`)
|
||||||
|
|
||||||
|
New action keys under `vocab.*` / `authorities.*` / `fields.*` plus a shared `actions.*`
|
||||||
|
namespace where it makes sense: `edit`, `delete`, `rename`, `save`, `cancel`, `inUse`
|
||||||
|
(count-interpolated). **en/sv parity** required.
|
||||||
|
|
||||||
|
### Storybook
|
||||||
|
|
||||||
|
Add co-located `*.stories.tsx` for the components that gain meaningful states: the
|
||||||
|
field-definition edit form, the editable term/authority row, and the delete-confirm
|
||||||
|
dialog wrapper. (Skip trivially-changed components.)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**Backend** (existing infra: compose Postgres on host 5442, Meili on 7700;
|
||||||
|
`#[sqlx::test]` provisions its own DB; mirror `crates/api/tests/admin_catalog.rs`):
|
||||||
|
- **db:** update/delete per entity; each `count_objects_referencing_*` returns the right
|
||||||
|
count; delete blocked when referenced; `delete_vocabulary` FK-restrict → typed in-use
|
||||||
|
error; an audit row (`Updated`/`Deleted`, correct actor) per mutation.
|
||||||
|
- **api:** each `PATCH`/`DELETE` — `EditCatalogue` required (401/403 without), happy path
|
||||||
|
(200/204), **409 + count** when referenced, **422** on an invalid value (empty
|
||||||
|
`group_key`/label), **404** when missing. (Immutables are absent from the PATCH DTO, so
|
||||||
|
there is no "immutable changed" path to test.)
|
||||||
|
- OpenAPI regenerated.
|
||||||
|
|
||||||
|
**Frontend** (Vitest + RTL + MSW, `onUnhandledRequest: "error"`):
|
||||||
|
- Mutation hooks invalidate the correct keys; a 409 parses into the typed error.
|
||||||
|
- Per screen: edit form populates and saves; delete confirms via `AlertDialog`; a 409
|
||||||
|
keeps the dialog open showing "used by N"; field-def edit shows key/type/binding
|
||||||
|
disabled.
|
||||||
|
- en/sv key-parity check (existing test).
|
||||||
|
- Storybook stories for the meaningfully-changed components run green under the
|
||||||
|
addon-vitest project.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
1. Update + delete endpoints for vocabulary (rename), term, authority, field-definition —
|
||||||
|
all `EditCatalogue`, all audited.
|
||||||
|
2. Referenced term/authority/field-def delete → **409 + count**; vocabulary delete →
|
||||||
|
**409** when it has terms or is bound.
|
||||||
|
3. Field-def `key`/`data_type`/binding immutable (absent from the PATCH DTO — cannot be
|
||||||
|
changed); `labels`/`group`/`required` editable; `required` not retroactively enforced.
|
||||||
|
4. In-place edit + `AlertDialog` delete on all three screens; a blocked delete shows
|
||||||
|
"used by N" without closing.
|
||||||
|
5. Storybook stories added for the meaningfully-changed components.
|
||||||
|
6. en/sv parity; no `any`/`eslint-disable`/`@ts-ignore`; codename ban; bundle ≤150 KB
|
||||||
|
gz; cargo + web typecheck/lint/test/build green; OpenAPI regenerated.
|
||||||
|
|
||||||
|
## Out of scope / follow-ups
|
||||||
|
|
||||||
|
- A "find/replace a reference across objects" bulk-reassign tool (would let a curator
|
||||||
|
clear references blocking a delete in one action) — file if the 409 friction is felt.
|
||||||
|
- Surfacing the referencing objects as clickable links in the 409 message (v1 shows a
|
||||||
|
count + optional sample, not a live list).
|
||||||
|
- Audit-entry coalescing for multi-step edits (#13, separate).
|
||||||
@@ -16,5 +16,9 @@ fmt:
|
|||||||
lint:
|
lint:
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
|
||||||
|
# Run Storybook (web component explorer) on http://localhost:6006
|
||||||
|
storybook:
|
||||||
|
cd web && pnpm storybook
|
||||||
|
|
||||||
# Format, lint, and test
|
# Format, lint, and test
|
||||||
check: fmt lint test
|
check: fmt lint test
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 UserView = components["schemas"]["UserView"];
|
||||||
type LoginRequest = components["schemas"]["LoginRequest"];
|
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"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+471
@@ -20,6 +20,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/admin/field-definitions": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -42,6 +58,30 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/admin/login": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -222,6 +262,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/admin/vocabularies/{id}/terms": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -238,6 +294,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/config": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -415,6 +487,11 @@ export interface components {
|
|||||||
/** @description The flexible-field key that was rejected. */
|
/** @description The flexible-field key that was rejected. */
|
||||||
field: string;
|
field: string;
|
||||||
};
|
};
|
||||||
|
/** @description 409 body: how many catalogue objects still reference the entity. */
|
||||||
|
InUseView: {
|
||||||
|
/** Format: int64 */
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
LabelInput: {
|
LabelInput: {
|
||||||
label: string;
|
label: string;
|
||||||
lang: string;
|
lang: string;
|
||||||
@@ -514,6 +591,9 @@ export interface components {
|
|||||||
/** @description `"ok"` when ready, `"degraded"` otherwise. */
|
/** @description `"ok"` when ready, `"degraded"` otherwise. */
|
||||||
status: string;
|
status: string;
|
||||||
};
|
};
|
||||||
|
RenameVocabularyRequest: {
|
||||||
|
key: string;
|
||||||
|
};
|
||||||
SearchHitView: {
|
SearchHitView: {
|
||||||
brief_description?: string | null;
|
brief_description?: string | null;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -532,6 +612,23 @@ export interface components {
|
|||||||
id: string;
|
id: string;
|
||||||
labels: components["schemas"]["LabelView"][];
|
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). */
|
/** @description A user as exposed on the admin surface (no password material). */
|
||||||
UserView: {
|
UserView: {
|
||||||
email: string;
|
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: {
|
list_field_definitions: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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: {
|
login: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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: {
|
list_terms: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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: {
|
get_config: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { components } from "../api/schema";
|
|||||||
import { useAuthorities, useCreateAuthority } from "../api/queries";
|
import { useAuthorities, useCreateAuthority } from "../api/queries";
|
||||||
import { LabelEditor } from "../components/label-editor";
|
import { LabelEditor } from "../components/label-editor";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { labelText } from "../lib/labels";
|
import { AuthorityRow } from "./authority-row";
|
||||||
|
|
||||||
type LabelInput = components["schemas"]["LabelInput"];
|
type LabelInput = components["schemas"]["LabelInput"];
|
||||||
|
|
||||||
@@ -72,9 +72,7 @@ export function AuthoritiesPage() {
|
|||||||
<li className="text-sm text-neutral-500">{t("authorities.empty")}</li>
|
<li className="text-sm text-neutral-500">{t("authorities.empty")}</li>
|
||||||
)}
|
)}
|
||||||
{authorities?.map((a) => (
|
{authorities?.map((a) => (
|
||||||
<li key={a.id} className="border-b py-1 text-sm">
|
<AuthorityRow key={a.id} authority={a} kind={currentKind} lang={lang} />
|
||||||
{labelText(a.labels, lang)}
|
|
||||||
</li>
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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()
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -2,7 +2,11 @@ import { useState, type FormEvent } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { components } from "../api/schema";
|
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 { LabelEditor } from "../components/label-editor";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -10,47 +14,52 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
type LabelInput = components["schemas"]["LabelInput"];
|
type LabelInput = components["schemas"]["LabelInput"];
|
||||||
|
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
|
||||||
|
|
||||||
const TYPES = ["text", "localized_text", "integer", "date", "boolean", "term", "authority"] as const;
|
const TYPES = ["text", "localized_text", "integer", "date", "boolean", "term", "authority"] as const;
|
||||||
const KINDS = ["person", "organisation", "place"] 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 { t } = useTranslation();
|
||||||
const create = useCreateFieldDefinition();
|
const create = useCreateFieldDefinition();
|
||||||
|
const update = useUpdateFieldDefinition();
|
||||||
const { data: vocabularies } = useVocabularies();
|
const { data: vocabularies } = useVocabularies();
|
||||||
|
|
||||||
const [key, setKey] = useState("");
|
const isEdit = editing !== null;
|
||||||
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 reset = () => {
|
const [key, setKey] = useState(editing?.key ?? "");
|
||||||
setKey("");
|
const [labels, setLabels] = useState<LabelInput[]>((editing?.labels as LabelInput[]) ?? []);
|
||||||
setLabels([]);
|
const [dataType, setDataType] = useState<string>(editing?.data_type ?? "text");
|
||||||
setDataType("text");
|
const [vocabularyId, setVocabularyId] = useState(editing?.vocabulary_id ?? "");
|
||||||
setVocabularyId("");
|
const [authorityKind, setAuthorityKind] = useState(editing?.authority_kind ?? "");
|
||||||
setAuthorityKind("");
|
const [group, setGroup] = useState(editing?.group ?? "");
|
||||||
setGroup("");
|
const [required, setRequired] = useState(editing?.required ?? false);
|
||||||
setRequired(false);
|
const [error, setError] = useState(false);
|
||||||
setError(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = (event: FormEvent) => {
|
const onSubmit = (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const hasLabel = labels.some((l) => l.label);
|
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);
|
setError(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setError(false);
|
setError(false);
|
||||||
|
|
||||||
|
if (isEdit) {
|
||||||
|
update.mutate(
|
||||||
|
{ key: editing.key, required, group: group.trim() || null, labels },
|
||||||
|
{ onSuccess: onDone },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
create.mutate(
|
create.mutate(
|
||||||
{
|
{
|
||||||
key: key.trim(),
|
key: key.trim(),
|
||||||
@@ -61,17 +70,47 @@ export function FieldForm() {
|
|||||||
group: group.trim() || null,
|
group: group.trim() || null,
|
||||||
labels,
|
labels,
|
||||||
},
|
},
|
||||||
{ onSuccess: reset },
|
{
|
||||||
|
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 (
|
return (
|
||||||
<form onSubmit={onSubmit} className="space-y-3 overflow-auto p-4">
|
<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">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="field-key">{t("fields.key")}</Label>
|
<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>
|
</div>
|
||||||
|
|
||||||
<LabelEditor value={labels} onChange={setLabels} />
|
<LabelEditor value={labels} onChange={setLabels} />
|
||||||
@@ -81,8 +120,9 @@ export function FieldForm() {
|
|||||||
<select
|
<select
|
||||||
id="field-type"
|
id="field-type"
|
||||||
value={dataType}
|
value={dataType}
|
||||||
|
disabled={isEdit}
|
||||||
onChange={(e) => setDataType(e.target.value)}
|
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) => (
|
{TYPES.map((type) => (
|
||||||
<option key={type} value={type}>
|
<option key={type} value={type}>
|
||||||
@@ -98,8 +138,9 @@ export function FieldForm() {
|
|||||||
<select
|
<select
|
||||||
id="field-vocab"
|
id="field-vocab"
|
||||||
value={vocabularyId}
|
value={vocabularyId}
|
||||||
|
disabled={isEdit}
|
||||||
onChange={(e) => setVocabularyId(e.target.value)}
|
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>
|
<option value="">{t("form.selectPlaceholder")}</option>
|
||||||
{vocabularies?.map((vocab) => (
|
{vocabularies?.map((vocab) => (
|
||||||
@@ -117,8 +158,9 @@ export function FieldForm() {
|
|||||||
<select
|
<select
|
||||||
id="field-kind"
|
id="field-kind"
|
||||||
value={authorityKind}
|
value={authorityKind}
|
||||||
|
disabled={isEdit}
|
||||||
onChange={(e) => setAuthorityKind(e.target.value)}
|
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>
|
<option value="">{t("fields.anyKind")}</option>
|
||||||
{KINDS.map((kind) => (
|
{KINDS.map((kind) => (
|
||||||
@@ -145,15 +187,19 @@ export function FieldForm() {
|
|||||||
{t("form.required")}
|
{t("form.required")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{create.isError && (
|
{failed && (
|
||||||
<p role="alert" className="text-xs text-red-600">
|
<p role="alert" className="text-xs text-red-600">
|
||||||
{t("form.rejected")}
|
{t("form.rejected")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button type="submit" size="sm" disabled={create.isPending}>
|
<Button type="submit" size="sm" disabled={pending}>
|
||||||
{t("fields.create")}
|
{isEdit ? t("actions.save") : t("fields.create")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function labelTextOrKey(def: FieldDefinitionView): string {
|
||||||
|
return def.labels[0]?.label ?? def.key;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { components } from "../api/schema";
|
import type { components } from "../api/schema";
|
||||||
import { useFieldDefinitions } from "../api/queries";
|
import { useFieldDefinitions, useDeleteFieldDefinition } from "../api/queries";
|
||||||
import { labelText } from "../lib/labels";
|
import { labelText } from "../lib/labels";
|
||||||
|
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
|
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 { t, i18n } = useTranslation();
|
||||||
const { data, isLoading, isError } = useFieldDefinitions();
|
const { data, isLoading, isError } = useFieldDefinitions();
|
||||||
|
const deleteField = useDeleteFieldDefinition();
|
||||||
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
|
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -50,7 +58,18 @@ export function FieldList() {
|
|||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
{defs.map((def) => (
|
{defs.map((def) => (
|
||||||
<li key={def.key} className="flex items-center gap-2 border-b px-3 py-2 text-sm">
|
<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="font-medium">{labelText(def.labels, lang)}</span>
|
||||||
<span className="text-xs text-neutral-400">{def.key}</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">
|
<span className="rounded bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-600">
|
||||||
@@ -65,6 +84,11 @@ export function FieldList() {
|
|||||||
*
|
*
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</button>
|
||||||
|
<DeleteConfirmDialog
|
||||||
|
description={t("actions.confirmDeleteField")}
|
||||||
|
onConfirm={() => deleteField.mutateAsync(def.key)}
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { components } from "../api/schema";
|
||||||
import { FieldList } from "./field-list";
|
import { FieldList } from "./field-list";
|
||||||
import { FieldForm } from "./field-form";
|
import { FieldForm } from "./field-form";
|
||||||
|
|
||||||
|
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
|
||||||
|
|
||||||
export function FieldsPage() {
|
export function FieldsPage() {
|
||||||
|
const [selected, setSelected] = useState<FieldDefinitionView | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full grid-cols-[20rem_1fr]">
|
<div className="grid h-full grid-cols-[20rem_1fr]">
|
||||||
<div className="overflow-hidden border-r">
|
<div className="overflow-hidden border-r">
|
||||||
<FieldList />
|
<FieldList selectedKey={selected?.key ?? null} onSelect={setSelected} />
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
<FieldForm />
|
<FieldForm
|
||||||
|
key={selected?.key ?? "create"}
|
||||||
|
editing={selected}
|
||||||
|
onDone={() => setSelected(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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" },
|
"fieldsLabels": { "objectNumber": "Object number", "objectName": "Name", "count": "Number of objects", "briefDescription": "Brief description", "currentLocation": "Current location", "currentOwner": "Current owner", "recorder": "Recorder", "recordingDate": "Recording date", "visibility": "Visibility", "flexible": "Catalogue fields" },
|
||||||
"visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" },
|
"visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" },
|
||||||
"form": { "selectPlaceholder": "— select —", "create": "Create object", "save": "Save", "cancel": "Cancel", "visibility": "Visibility", "draft": "Draft", "internal": "Internal", "required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields", "fieldRejected": "The field \"{{field}}\" was rejected — check its value", "flexibleHeading": "Catalogue fields" },
|
"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)" },
|
"labels": { "label": "Label", "externalUri": "External URI (optional)" },
|
||||||
"vocab": {
|
"vocab": {
|
||||||
"newVocabulary": "New vocabulary", "key": "Key",
|
"newVocabulary": "New vocabulary", "key": "Key",
|
||||||
|
|||||||
@@ -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" },
|
"fieldsLabels": { "objectNumber": "Föremålsnummer", "objectName": "Namn", "count": "Antal föremål", "briefDescription": "Kort beskrivning", "currentLocation": "Nuvarande plats", "currentOwner": "Nuvarande ägare", "recorder": "Registrerad av", "recordingDate": "Registreringsdatum", "visibility": "Synlighet", "flexible": "Katalogfält" },
|
||||||
"visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" },
|
"visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" },
|
||||||
"form": { "selectPlaceholder": "— välj —", "create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt", "visibility": "Synlighet", "draft": "Utkast", "internal": "Intern", "required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält", "fieldRejected": "Fältet \"{{field}}\" avvisades — kontrollera värdet", "flexibleHeading": "Katalogfält" },
|
"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)" },
|
"labels": { "label": "Etikett", "externalUri": "Extern URI (valfritt)" },
|
||||||
"vocab": {
|
"vocab": {
|
||||||
"newVocabulary": "Ny vokabulär", "key": "Nyckel",
|
"newVocabulary": "Ny vokabulär", "key": "Nyckel",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@ import { useState, type FormEvent } from "react";
|
|||||||
import { NavLink } from "react-router-dom";
|
import { NavLink } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -13,8 +14,12 @@ export function VocabularyList() {
|
|||||||
const { data, isLoading, isError } = useVocabularies();
|
const { data, isLoading, isError } = useVocabularies();
|
||||||
|
|
||||||
const create = useCreateVocabulary();
|
const create = useCreateVocabulary();
|
||||||
|
const renameVocabulary = useRenameVocabulary();
|
||||||
|
const deleteVocabulary = useDeleteVocabulary();
|
||||||
|
|
||||||
const [key, setKey] = useState("");
|
const [key, setKey] = useState("");
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [draftKey, setDraftKey] = useState("");
|
||||||
|
|
||||||
const onCreate = (event: FormEvent) => {
|
const onCreate = (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -56,15 +61,59 @@ export function VocabularyList() {
|
|||||||
<li className="p-3 text-sm text-neutral-500">{t("vocab.empty")}</li>
|
<li className="p-3 text-sm text-neutral-500">{t("vocab.empty")}</li>
|
||||||
)}
|
)}
|
||||||
{data?.map((v) => (
|
{data?.map((v) => (
|
||||||
<li key={v.id}>
|
<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
|
<NavLink
|
||||||
to={`/vocabularies/${v.id}`}
|
to={`/vocabularies/${v.id}`}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`block border-b px-3 py-2 text-sm ${isActive ? "bg-indigo-50" : "hover:bg-neutral-50"}`
|
`block flex-1 px-3 py-2 text-sm ${isActive ? "bg-indigo-50" : "hover:bg-neutral-50"}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{v.key}
|
{v.key}
|
||||||
</NavLink>
|
</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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { useTranslation } from "react-i18next";
|
|||||||
import type { components } from "../api/schema";
|
import type { components } from "../api/schema";
|
||||||
import { useTerms, useAddTerm } from "../api/queries";
|
import { useTerms, useAddTerm } from "../api/queries";
|
||||||
import { LabelEditor } from "../components/label-editor";
|
import { LabelEditor } from "../components/label-editor";
|
||||||
|
import { TermRow } from "./term-row";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { labelText } from "../lib/labels";
|
|
||||||
|
|
||||||
type LabelInput = components["schemas"]["LabelInput"];
|
type LabelInput = components["schemas"]["LabelInput"];
|
||||||
|
|
||||||
@@ -63,9 +63,7 @@ export function VocabularyTerms() {
|
|||||||
<li className="text-sm text-neutral-500">{t("vocab.noTerms")}</li>
|
<li className="text-sm text-neutral-500">{t("vocab.noTerms")}</li>
|
||||||
)}
|
)}
|
||||||
{terms?.map((term) => (
|
{terms?.map((term) => (
|
||||||
<li key={term.id} className="border-b py-1 text-sm">
|
<TermRow key={term.id} vocabularyId={id} term={term} lang={lang} />
|
||||||
{labelText(term.labels, lang)}
|
|
||||||
</li>
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<form onSubmit={onAdd} className="space-y-2 border-t pt-3">
|
<form onSubmit={onAdd} className="space-y-2 border-t pt-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user