Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
94 KiB
Reference-Data Edit/Delete Lifecycle Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add update + delete across the admin reference-data surface — vocabularies (rename), terms, authorities, and field definitions — with backend endpoints (audited, referenced-entity-safe) and in-place frontend edit/delete UI.
Architecture: Each backend task adds db-layer functions (mutation + atomic audit::record, plus a read-only referenced-count) and the matching axum handlers. Deletes that would orphan object data are blocked with 409 + count; the count comes from a JSONB scan of object.fields (object flexible-field values are stored as a JSONB blob with no FK to reference data). The frontend mirrors the existing useUpdateObject/useDeleteObject hooks and the DeleteObjectDialog/AlertDialog pattern, editing in place (no modal dialogs, no new shadcn components).
Tech Stack: Rust (axum 0.8, sqlx 0.8 + Postgres, utoipa 5, thiserror), React 19 + TypeScript + pnpm, TanStack Query v5, react-i18next, shadcn/ui (Base UI "base-nova"), Vitest + RTL + MSW, Storybook 10.
Conventions: cargo +nightly fmt; cargo clippy --workspace --all-targets -- -D warnings; no any/eslint-disable/@ts-ignore; en/sv i18n parity; storage always UTC; codename ban (never write "biggus"/"dickus"). Spec: docs/superpowers/specs/2026-06-05-reference-data-edit-delete-design.md.
Test infra: compose Postgres on host 5442, Meili on 7700. Backend tests:
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev \
MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test ...
#[sqlx::test(migrations = "../db/migrations")] provisions its own temp DB. Run docker compose up -d first and confirm pg_isready. Web tests: cd web && pnpm test (jsdom + storybook projects), pnpm typecheck, pnpm lint, pnpm build.
File Structure
Backend
crates/db/src/lib.rs— addpub enum DeleteOutcome { Deleted, InUse { count: i64 }, NotFound }(shared by all delete fns).crates/db/src/vocab.rs— addupdate_term,delete_term,count_objects_referencing_term,rename_vocabulary,delete_vocabulary.crates/db/src/authority.rs— addupdate_authority,delete_authority,count_objects_referencing_authority.crates/db/src/fields.rs— add audit wiring +update_field_definition,delete_field_definition,count_objects_using_field.crates/api/src/admin_vocab.rs— PATCH/DELETE term + PATCH/DELETE vocabulary handlers, DTOs, routes; sharedInUseView.crates/api/src/admin_authorities.rs— PATCH/DELETE authority handler, DTO, routes.crates/api/src/admin_objects.rs— PATCH/DELETE field-definition handlers, DTOs, routes; definepub(crate) struct InUseView.crates/api/src/openapi.rs— register all new paths + schemas.- Tests:
crates/db/tests/vocab.rs,crates/db/tests/authority.rs,crates/db/tests/fields.rs(new),crates/api/tests/admin_catalog.rs.
Frontend
web/src/api/schema.d.ts— regenerated (Task 5).web/src/api/queries.ts— 8 mutation hooks +InUseErrorclass.web/src/i18n/en.json,web/src/i18n/sv.json— action/in-use keys.web/src/components/delete-confirm-dialog.tsx(new) +.stories.tsx— generic delete confirm with in-use handling.web/src/fields/field-form.tsx,field-list.tsx,fields-page.tsx(+ stories) — edit mode + delete.web/src/vocab/vocabulary-list.tsx,vocabulary-terms.tsx+ newterm-row.tsx(+ stories) — rename + term edit/delete.web/src/authorities/authorities-page.tsx+ newauthority-row.tsx(+ stories) — edit/delete.
Shared db delete contract (defined in Task 1, used by all):
/// Result of a delete that may be blocked by references from catalogue objects.
pub enum DeleteOutcome {
Deleted,
InUse { count: i64 },
NotFound,
}
Task 1: Term edit/delete (db + api)
Files:
-
Modify:
crates/db/src/lib.rs(addDeleteOutcome) -
Modify:
crates/db/src/vocab.rs -
Modify:
crates/api/src/admin_vocab.rs,crates/api/src/openapi.rs -
Test:
crates/db/tests/vocab.rs,crates/api/tests/admin_catalog.rs -
Step 1: Add the shared
DeleteOutcomeenum. Incrates/db/src/lib.rs, add near the top (after the existingpub mod/pub uselines):
/// 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,
}
- Step 2: Write failing db tests for term update/delete. Append to
crates/db/tests/vocab.rs(mirror the existing tests' setup — they buildDb, create a vocabulary + term viavocab::create_vocabulary/vocab::add_terminside a tx withAuditActor::System). Reference howset_object_fieldsis used incrates/db/tests/object_fields.rsto attach a term value to an object.
#[sqlx::test(migrations = "../db/migrations")]
async fn update_term_changes_labels_and_uri(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();
let existed = vocab::update_term(
&mut tx,
AuditActor::System,
term_id,
Some("https://example.org/wood"),
&[LocalizedLabel { lang: "sv".into(), label: "Träslag".into() }],
)
.await
.unwrap();
assert!(existed);
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");
let _ = DeleteOutcome::Deleted; // import smoke
}
#[sqlx::test(migrations = "../db/migrations")]
async fn delete_term_blocks_when_referenced_then_succeeds(pool: PgPool) {
use db::{DeleteOutcome, catalog, fields};
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();
// A field definition of type `term` + an object using this term.
fields::create_field_definition(&mut tx, &NewFieldDefinition {
key: "material".into(),
field_type: domain::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();
// Referenced → blocked with a count of 1.
let blocked = vocab::delete_term(&mut tx, AuditActor::System, vocab.id, term_id).await.unwrap();
assert_eq!(blocked, DeleteOutcome::InUse { count: 1 });
// Clear the reference, then delete succeeds.
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());
// Deleting again → NotFound.
let gone = vocab::delete_term(&mut tx, AuditActor::System, vocab.id, term_id).await.unwrap();
assert_eq!(gone, DeleteOutcome::NotFound);
}
Add a small helper at the bottom of the test file if one does not already exist (copy field names from crates/db/tests/object_fields.rs's object setup):
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: domain::Visibility::Draft,
}
}
Ensure the test file's use includes db::{vocab}, domain::{AuditActor, LocalizedLabel, NewTerm, NewFieldDefinition}, sqlx::PgPool, and db::Db.
- Step 3: Run the tests to confirm they fail.
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p db --test vocab
Expected: FAIL — update_term/delete_term not found.
- Step 4: Implement the db functions. In
crates/db/src/vocab.rs, add to theuselineDeleteOutcomeis in thedbcrate root, so reference it ascrate::DeleteOutcome. Add:
/// Update a term's `external_uri` and labels (full replace), recording an `updated`
/// audit entry. Returns `false` if no such term. Pass a transaction connection.
pub async fn update_term(
conn: &mut sqlx::PgConnection,
actor: AuditActor,
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")
.bind(term_id.to_uuid())
.bind(external_uri)
.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)
}
- Step 5: Run the db tests to confirm they pass.
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p db --test vocab
Expected: PASS.
- Step 6: Write failing api tests in
crates/api/tests/admin_catalog.rs(mirrorcreate_list_vocabulary_and_termsfor setup:seed_user(Role::Editor),build_app(state(pool)),login(...), thenoneshotrequests with the session cookie). Add a small PATCH/DELETE helper if none exists:
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 let Some(b) = body {
req = req.header(header::CONTENT_TYPE, "application/json");
app.clone().oneshot(req.body(Body::from(b.to_owned())).unwrap()).await.unwrap()
} else {
app.clone().oneshot(req.body(Body::empty()).unwrap()).await.unwrap()
}
}
#[sqlx::test(migrations = "../db/migrations")]
async fn edit_and_delete_term(pool: PgPool) {
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 vocabulary + term
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();
// PATCH the term
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);
// DELETE the (unreferenced) term
let deleted = send(&app, &cookie, "DELETE",
&format!("/api/admin/vocabularies/{vid}/terms/{tid}"), None).await;
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
// DELETE again → 404
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) {
let app = build_app(state(pool));
let r = app.clone().oneshot(
Request::builder().method("DELETE")
.uri("/api/admin/vocabularies/00000000-0000-0000-0000-000000000000/terms/00000000-0000-0000-0000-000000000000")
.body(Body::empty()).unwrap()).await.unwrap();
assert_eq!(r.status(), StatusCode::UNAUTHORIZED);
}
- Step 7: Run to confirm failure.
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api --test admin_catalog edit_and_delete_term
Expected: FAIL (routes 404 / method not allowed).
- Step 8: Implement the api handlers, DTOs, and routes. In
crates/api/src/admin_vocab.rs:- Add imports:
axum::response::{IntoResponse, Response},axum::routingalready imported viaget. Adddomain::TermId. Import the shared in-use view:use crate::admin_objects::InUseView;(defined in Task 4 / or define here if Task 4 not yet done — see note). - Add DTOs:
- Add imports:
#[derive(Deserialize, ToSchema)]
pub(crate) struct UpdateTermRequest {
pub external_uri: Option<String>,
pub labels: Vec<LabelInput>,
}
- Add handlers:
#[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 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()),
term_id, req.external_uri.as_deref(), &labels,
).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if existed { Ok(StatusCode::NO_CONTENT) } else { 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 }) => (StatusCode::CONFLICT, Json(InUseView { count })).into_response(),
Ok(db::DeleteOutcome::NotFound) => StatusCode::NOT_FOUND.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
- Note on
InUseView: it is owned by Task 4 (admin_objects.rs). To keep tasks independently compilable, define it here inadmin_vocab.rsinstead and have Task 4 import it from here:
/// 409 body: how many catalogue objects still reference the entity.
#[derive(Serialize, ToSchema)]
pub(crate) struct InUseView {
pub count: i64,
}
Then drop the use crate::admin_objects::InUseView; import above and reference the local InUseView. (Adjust the File Structure note accordingly: InUseView lives in admin_vocab.rs.)
- Extend
routes():
.route(
"/api/admin/vocabularies/{id}/terms/{term_id}",
axum::routing::patch(update_term).delete(delete_term),
)
-
Step 9: Register in OpenAPI. In
crates/api/src/openapi.rs, add topaths(...):admin_vocab::update_term,andadmin_vocab::delete_term,. Add tocomponents(schemas(...)):admin_vocab::UpdateTermRequest,andadmin_vocab::InUseView,. -
Step 10: Run api tests + fmt + clippy.
cargo +nightly fmt
cargo clippy -p api -p db --all-targets -- -D warnings
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api -p db
Expected: all PASS, clippy clean.
- Step 11: Commit.
git add crates/db crates/api
git commit -m "feat: edit/delete terms — audited, blocked when referenced (#30)"
Task 2: Vocabulary rename/delete (db + api)
Files:
-
Modify:
crates/db/src/vocab.rs,crates/api/src/admin_vocab.rs,crates/api/src/openapi.rs -
Test:
crates/db/tests/vocab.rs,crates/api/tests/admin_catalog.rs -
Step 1: Write failing db tests in
crates/db/tests/vocab.rs:
#[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 });
// Empty vocabulary deletes cleanly.
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);
}
- Step 2: Run to confirm failure.
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p db --test vocab rename_vocabulary delete_vocabulary
Expected: FAIL.
- Step 3: Implement. In
crates/db/src/vocab.rs:
/// 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)
}
Add const VOCABULARY_ENTITY_TYPE: &str = "vocabulary"; already exists in this file (it does).
- Step 4: Run db tests — expect PASS.
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p db --test vocab
- Step 5: Write failing api tests in
crates/api/tests/admin_catalog.rs(reuse thesendhelper from Task 1):
#[sqlx::test(migrations = "../db/migrations")]
async fn rename_and_delete_vocabulary(pool: PgPool) {
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) {
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);
}
- Step 6: Implement handlers + routes in
crates/api/src/admin_vocab.rs:
#[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| {
// Unique-key collision → 409.
if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23505") {
StatusCode::CONFLICT
} else {
StatusCode::INTERNAL_SERVER_ERROR
}
})?;
tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if existed { Ok(StatusCode::NO_CONTENT) } else { 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 }) => (StatusCode::CONFLICT, Json(InUseView { count })).into_response(),
Ok(db::DeleteOutcome::NotFound) => StatusCode::NOT_FOUND.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
Extend routes():
.route(
"/api/admin/vocabularies/{id}",
axum::routing::patch(rename_vocabulary).delete(delete_vocabulary),
)
-
Step 7: Register in OpenAPI. Add
admin_vocab::rename_vocabulary,andadmin_vocab::delete_vocabulary,topaths(...), andadmin_vocab::RenameVocabularyRequest,to schemas. -
Step 8: fmt, clippy, test.
cargo +nightly fmt && cargo clippy -p api -p db --all-targets -- -D warnings
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api -p db
Expected: all PASS.
- Step 9: Commit.
git add crates/db crates/api
git commit -m "feat: rename + delete vocabularies, blocked when in use (#30)"
Task 3: Authority edit/delete (db + api)
Files:
-
Modify:
crates/db/src/authority.rs,crates/api/src/admin_authorities.rs,crates/api/src/openapi.rs -
Test:
crates/db/tests/authority.rs,crates/api/tests/admin_catalog.rs -
Step 1: Write failing db tests in
crates/db/tests/authority.rs(mirror existing setup:authority::create_authorityin a tx). For the referenced case, create anauthority-typed field definition + an object whosefieldsstores the authority id, viafields::create_field_definition+catalog::set_object_fields(see Task 1 Step 2 for the pattern; usedomain::FieldType::Authority { kind }).
#[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, catalog, fields};
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: 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, &super::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);
}
(If sample_object_input() lives in vocab.rs tests, duplicate the tiny helper into authority.rs tests rather than cross-referencing — test crates don't share modules. Define it locally.)
- Step 2: Run to confirm failure.
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p db --test authority
- Step 3: Implement in
crates/db/src/authority.rs(the file already importsAuditAction, AuditActor, NewAuditEvent):
/// 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)
}
- Step 4: Run db tests — PASS.
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p db --test authority
- Step 5: Write failing api tests in
crates/api/tests/admin_catalog.rs:
#[sqlx::test(migrations = "../db/migrations")]
async fn edit_and_delete_authority(pool: PgPool) {
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);
}
- Step 6: Implement handlers + routes in
crates/api/src/admin_authorities.rs:- Imports: add
AuthorityIdto thedomainimport,axum::response::{IntoResponse, Response}, anduse crate::admin_vocab::InUseView;. - DTO + handlers:
- Imports: add
#[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>,
axum::extract::Path(id): axum::extract::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)?;
tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if existed { Ok(StatusCode::NO_CONTENT) } else { 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))
)]
pub(crate) async fn delete_authority(
auth: Authorized<EditCatalogue>,
State(state): State<AppState>,
axum::extract::Path(id): axum::extract::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 }) => (StatusCode::CONFLICT, Json(InUseView { count })).into_response(),
Ok(db::DeleteOutcome::NotFound) => StatusCode::NOT_FOUND.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
- Extend
routes():
.route(
"/api/admin/authorities/{id}",
axum::routing::patch(update_authority).delete(delete_authority),
)
-
Step 7: Register in OpenAPI. Add
admin_authorities::update_authority,andadmin_authorities::delete_authority,topaths(...), andadmin_authorities::UpdateAuthorityRequest,to schemas. -
Step 8: fmt, clippy, test.
cargo +nightly fmt && cargo clippy -p api -p db --all-targets -- -D warnings
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test -p api -p db
- Step 9: Commit.
git add crates/db crates/api
git commit -m "feat: edit/delete authorities, blocked when referenced (#30)"
Task 4: Field-definition edit/delete (db + api)
Files:
-
Modify:
crates/db/src/fields.rs,crates/api/src/admin_objects.rs,crates/api/src/openapi.rs -
Test:
crates/db/tests/fields.rs(new),crates/api/tests/admin_catalog.rs -
Step 1: Create
crates/db/tests/fields.rswith failing tests (mirror theDb::from_pool+ tx pattern; reuse a localsample_object_input()helper):
use db::{Db, DeleteOutcome, catalog, fields};
use domain::{AuditActor, AuthorityKind, FieldType, LocalizedLabel, NewFieldDefinition};
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: domain::Visibility::Draft,
}
}
#[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 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);
assert_eq!(fields::delete_field_definition(&mut tx, AuditActor::System, "weight").await.unwrap(),
DeleteOutcome::NotFound);
}
(Confirm domain::FieldType::Integer is the correct variant name by reading crates/domain/src/field_definition.rs; adjust if the integer variant is spelled differently.)
- Step 2: Run to confirm failure.
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p db --test fields
- Step 3: Implement in
crates/db/src/fields.rs. Add audit imports + entity const at the top:
use domain::{
AuditAction, AuditActor, AuthorityKind, FieldDefinition, FieldDefinitionId, FieldType,
LocalizedLabel, NewAuditEvent, NewFieldDefinition, VocabularyId,
};
// ...existing `use sqlx::Row;`
use crate::audit;
const FIELD_DEFINITION_ENTITY_TYPE: &str = "field_definition";
Then add the functions:
/// Update a field definition's mutable attributes (labels, group, required); `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.
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)
}
- Step 4: Run db tests — PASS.
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev cargo test -p db --test fields
- Step 5: Write failing api tests in
crates/api/tests/admin_catalog.rs:
#[sqlx::test(migrations = "../db/migrations")]
async fn edit_and_delete_field_definition(pool: PgPool) {
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;
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;
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);
let deleted = send(&app, &cookie, "DELETE", "/api/admin/field-definitions/weight", None).await;
assert_eq!(deleted.status(), StatusCode::NO_CONTENT);
let again = send(&app, &cookie, "DELETE", "/api/admin/field-definitions/weight", None).await;
assert_eq!(again.status(), StatusCode::NOT_FOUND);
}
- Step 6: Implement handlers + routes in
crates/api/src/admin_objects.rs. The file already hasactor(&auth.user),Authorized<EditCatalogue>,LabelView,LabelInput(imported viaadmin_vocab). Addaxum::response::{IntoResponse, Response}anduse crate::admin_vocab::InUseView;. Add:
#[derive(serde::Deserialize, utoipa::ToSchema)]
pub(crate) struct UpdateFieldDefinitionRequest {
pub required: bool,
pub group: Option<String>,
pub labels: Vec<LabelInput>,
}
#[utoipa::path(
patch, path = "/api/admin/field-definitions/{key}",
request_body = UpdateFieldDefinitionRequest,
params(("key" = String, Path, description = "Field key")),
responses((status = 204), (status = 401), (status = 403), (status = 404),
(status = 422, description = "Invalid value (e.g. empty label/group)"))
)]
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 existed = db::fields::update_field_definition(
&mut tx, actor(&auth.user), &key, req.required, req.group.as_deref(), &labels,
).await.map_err(|err| {
// CHECK constraint (empty label / group_key) → 422.
if err.as_database_error().and_then(|e| e.code()).as_deref() == Some("23514") {
StatusCode::UNPROCESSABLE_ENTITY
} else {
StatusCode::INTERNAL_SERVER_ERROR
}
})?;
tx.commit().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if existed { Ok(StatusCode::NO_CONTENT) } else { Err(StatusCode::NOT_FOUND) }
}
#[utoipa::path(
delete, path = "/api/admin/field-definitions/{key}",
params(("key" = String, Path, description = "Field key")),
responses((status = 204), (status = 401), (status = 403), (status = 404),
(status = 409, body = InUseView))
)]
pub(crate) async fn delete_field_definition(
auth: Authorized<EditCatalogue>,
State(state): State<AppState>,
Path(key): Path<String>,
) -> Response {
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 }) => (StatusCode::CONFLICT, Json(InUseView { count })).into_response(),
Ok(db::DeleteOutcome::NotFound) => StatusCode::NOT_FOUND.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
Find where field-definition routes are registered (search field-definitions in admin_objects.rs's routes()), and extend that route:
.route(
"/api/admin/field-definitions/{key}",
axum::routing::patch(update_field_definition).delete(delete_field_definition),
)
(If there is no existing /{key} route, add it as a new .route(...).)
-
Step 7: Register in OpenAPI. Add
admin_objects::update_field_definition,andadmin_objects::delete_field_definition,topaths(...), andadmin_objects::UpdateFieldDefinitionRequest,to schemas. -
Step 8: fmt, clippy, full backend test.
cargo +nightly fmt && cargo clippy --workspace --all-targets -- -D warnings
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test --workspace
Expected: all PASS, clippy clean.
- Step 9: Commit.
git add crates/db crates/api
git commit -m "feat: edit/delete field definitions — audited, blocked when in use (#36)"
Task 5: Regenerate the web API types
Files: web/src/api/schema.d.ts (generated)
- Step 1: Start the stack and server. Ensure compose is up, then run the server so
pnpm gen:apican read/api-docs/openapi.json:
docker compose up -d # postgres + meilisearch
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev \
MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey \
cargo run -p server & # background; wait until it logs "listening"
- Step 2: Regenerate.
cd web && pnpm gen:api
- Step 3: Verify the new endpoints/DTOs landed.
grep -c "InUseView" web/src/api/schema.d.ts # >= 1
grep -c "UpdateTermRequest" web/src/api/schema.d.ts # >= 1
grep -c "/api/admin/field-definitions/{key}" web/src/api/schema.d.ts # >= 1
Expected: each ≥ 1. Stop the background server afterward (kill %1 or Ctrl-C).
- Step 4: typecheck (no app code changed yet, just the schema).
cd web && pnpm typecheck
Expected: PASS.
- Step 5: Commit.
git add web/src/api/schema.d.ts
git commit -m "chore(web): regenerate API types for reference-data edit/delete"
Task 6: Frontend data layer — mutation hooks + i18n
Files:
-
Modify:
web/src/api/queries.ts -
Modify:
web/src/i18n/en.json,web/src/i18n/sv.json -
Test:
web/src/api/queries.test.ts(new, or extend an existing queries test if present) -
Step 1: Add i18n keys. In
web/src/i18n/en.json, extend the existingactionsobject (currently{ "edit", "delete", "confirmDelete" }) and add reference-data confirm strings + the in-use message:
"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."
},
In web/src/i18n/sv.json, mirror with Swedish (keep the same keys):
"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."
},
- Step 2: Write a failing test for the in-use parsing, in
web/src/api/queries.test.ts(mirror existing query tests' use of MSW handlers fromweb/src/test/handlers.tsand aQueryClientProvider+renderHookwrapper — copy the wrapper from any existing*.test.tsxthat callsrenderHook). Minimal:
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);
});
});
- Step 3: Run to confirm failure.
cd web && pnpm test -- queries.test
Expected: FAIL — InUseError not exported.
- Step 4: Implement hooks +
InUseErrorinweb/src/api/queries.ts. Add the error class nearHttpError/FieldRejection:
export class InUseError extends Error {
constructor(public readonly count: number) {
super(`in use: ${count}`);
this.name = "InUseError";
}
}
Add the 8 hooks (mirroring useUpdateObject/useDeleteObject and the create hooks). Use the existing LabelInput type alias:
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"] }),
});
}
- Step 5: Run test, typecheck, lint.
cd web && pnpm test -- queries.test && pnpm typecheck && pnpm lint
Expected: PASS, no any lint errors (note the (error as { count?: number }) cast is a typed cast, not any).
- Step 6: Commit.
git add web/src/api/queries.ts web/src/api/queries.test.ts web/src/i18n/en.json web/src/i18n/sv.json
git commit -m "feat(web): mutation hooks + InUseError + i18n for reference-data edit/delete"
Task 7: Generic delete-confirm dialog (component + story)
Files:
- Create:
web/src/components/delete-confirm-dialog.tsx,web/src/components/delete-confirm-dialog.stories.tsx - Test: covered by the story
play+ reused in Tasks 8–10.
This is a reusable component mirroring DeleteObjectDialog but parameterized and aware of InUseError (shows the in-use message and keeps the dialog open instead of navigating).
- Step 1: Write the component.
web/src/components/delete-confirm-dialog.tsx:
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>
);
}
- Step 2: Write the story
web/src/components/delete-confirm-dialog.stories.tsx(mirror the format ofweb/src/objects/visibility-badge.stories.tsx—@storybook/react-vite,expectfromstorybook/test,tags: ['ai-generated'], single quotes, no semicolons):
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect, userEvent, fn } 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' }))
// The dialog content renders in a portal; query the document body.
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)
},
}
Add within to the import from storybook/test (i.e. import { expect, userEvent, fn, within } from 'storybook/test'). If AlertDialog does not portal in the test environment, query via canvas instead of within(document.body) — run the story to confirm which.
- Step 3: Run the stories.
cd web && pnpm test -- delete-confirm-dialog
Expected: PASS (both stories). If the portal query fails, switch to canvas and re-run.
- Step 4: typecheck + lint + commit.
cd web && pnpm typecheck && pnpm lint
git add web/src/components/delete-confirm-dialog.tsx web/src/components/delete-confirm-dialog.stories.tsx
git commit -m "feat(web): reusable DeleteConfirmDialog with in-use handling + stories"
Task 8: Fields screen — edit/delete UI + stories
Files:
- Modify:
web/src/fields/fields-page.tsx,web/src/fields/field-list.tsx,web/src/fields/field-form.tsx - Create:
web/src/fields/field-form.stories.tsx - Test: story
play+ existing field tests.
The right pane (FieldForm) becomes create-or-edit; the left list selects a row and offers delete.
- Step 1: Lift selection state into
FieldsPage(web/src/fields/fields-page.tsx):
import { useState } from "react";
import type { components } from "../api/schema";
import { FieldList } from "./field-list";
import { FieldForm } from "./field-form";
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
export function FieldsPage() {
const [selected, setSelected] = useState<FieldDefinitionView | null>(null);
return (
<div className="grid h-full grid-cols-[20rem_1fr]">
<div className="overflow-hidden border-r">
<FieldList selectedKey={selected?.key ?? null} onSelect={setSelected} />
</div>
<div className="overflow-hidden">
<FieldForm editing={selected} onDone={() => setSelected(null)} />
</div>
</div>
);
}
- Step 2: Extend
FieldList(web/src/fields/field-list.tsx) withonSelect/selectedKeyprops, a clickable row (selects → edit), and a delete affordance usingDeleteConfirmDialog+useDeleteFieldDefinition. Change the component signature and the row markup:
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { useFieldDefinitions, useDeleteFieldDefinition } from "../api/queries";
import { labelText } from "../lib/labels";
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
import { Skeleton } from "@/components/ui/skeleton";
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
export function FieldList({
selectedKey,
onSelect,
}: {
selectedKey: string | null;
onSelect: (def: FieldDefinitionView) => void;
}) {
const { t, i18n } = useTranslation();
const { data, isLoading, isError } = useFieldDefinitions();
const del = useDeleteFieldDefinition();
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
// ...keep the existing loading / error / empty branches and group-building logic...
// Replace the inner row <li> with:
// {defs.map((def) => (
// <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"
// onClick={() => onSelect(def)}>
// <span className="font-medium">{labelText(def.labels, lang)}</span>
// <span className="text-xs text-neutral-400">{def.key}</span>
// <span className="rounded bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-600">
// {t(`fields.types.${def.data_type}`)}
// </span>
// {def.required && (
// <span className="text-xs text-red-600" title={t("fields.required")}
// aria-label={t("fields.required")}>*</span>
// )}
// </button>
// <DeleteConfirmDialog
// description={t("actions.confirmDeleteField")}
// onConfirm={() => del.mutateAsync(def.key)}
// />
// </li>
// ))}
}
(Keep the existing group sorting/skeleton code unchanged — only the row markup and the props/imports change.)
- Step 3: Make
FieldFormcreate-or-edit (web/src/fields/field-form.tsx). Addediting/onDoneprops; wheneditingis set, hydrate state from it, disablekey/type/binding inputs, and submit viauseUpdateFieldDefinition; otherwise behave as today.
import { useEffect, useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import {
useCreateFieldDefinition,
useUpdateFieldDefinition,
useVocabularies,
} from "../api/queries";
import { LabelEditor } from "../components/label-editor";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
type LabelInput = components["schemas"]["LabelInput"];
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
const TYPES = ["text", "localized_text", "integer", "date", "boolean", "term", "authority"] as const;
const KINDS = ["person", "organisation", "place"] as const;
export function FieldForm({
editing,
onDone,
}: {
editing: FieldDefinitionView | null;
onDone: () => void;
}) {
const { t } = useTranslation();
const create = useCreateFieldDefinition();
const update = useUpdateFieldDefinition();
const { data: vocabularies } = useVocabularies();
const isEdit = editing !== null;
const [key, setKey] = useState("");
const [labels, setLabels] = useState<LabelInput[]>([]);
const [dataType, setDataType] = useState<string>("text");
const [vocabularyId, setVocabularyId] = useState("");
const [authorityKind, setAuthorityKind] = useState("");
const [group, setGroup] = useState("");
const [required, setRequired] = useState(false);
const [error, setError] = useState(false);
// Hydrate when the selected definition changes (or reset to create mode).
useEffect(() => {
if (editing) {
setKey(editing.key);
setLabels(editing.labels as LabelInput[]);
setDataType(editing.data_type);
setVocabularyId(editing.vocabulary_id ?? "");
setAuthorityKind(editing.authority_kind ?? "");
setGroup(editing.group ?? "");
setRequired(editing.required);
setError(false);
} else {
setKey(""); setLabels([]); setDataType("text"); setVocabularyId("");
setAuthorityKind(""); setGroup(""); setRequired(false); setError(false);
}
}, [editing]);
const onSubmit = (event: FormEvent) => {
event.preventDefault();
const hasLabel = labels.some((l) => l.label);
if (!hasLabel || (!isEdit && !key.trim()) || (!isEdit && dataType === "term" && !vocabularyId)) {
setError(true);
return;
}
setError(false);
if (isEdit) {
update.mutate(
{ key: editing.key, required, group: group.trim() || null, labels },
{ onSuccess: onDone },
);
} else {
create.mutate(
{
key: key.trim(), data_type: dataType,
vocabulary_id: dataType === "term" ? vocabularyId : null,
authority_kind: dataType === "authority" ? authorityKind || null : null,
required, group: group.trim() || null, labels,
},
{ onSuccess: onDone },
);
}
};
const pending = isEdit ? update.isPending : create.isPending;
const failed = isEdit ? update.isError : create.isError;
return (
<form onSubmit={onSubmit} className="space-y-3 overflow-auto p-4">
<div className="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("fields.newField")}
</Button>
)}
</div>
<div className="space-y-1">
<Label htmlFor="field-key">{t("fields.key")}</Label>
<Input id="field-key" value={key} disabled={isEdit} onChange={(e) => setKey(e.target.value)} />
</div>
<LabelEditor value={labels} onChange={setLabels} />
<div className="space-y-1">
<Label htmlFor="field-type">{t("fields.type")}</Label>
<select id="field-type" value={dataType} disabled={isEdit}
onChange={(e) => setDataType(e.target.value)}
className="w-full rounded border px-2 py-1 text-sm disabled:opacity-60">
{TYPES.map((type) => (
<option key={type} value={type}>{t(`fields.types.${type}`)}</option>
))}
</select>
</div>
{dataType === "term" && (
<div className="space-y-1">
<Label htmlFor="field-vocab">{t("fields.vocabulary")}</Label>
<select id="field-vocab" value={vocabularyId} disabled={isEdit}
onChange={(e) => setVocabularyId(e.target.value)}
className="w-full rounded border px-2 py-1 text-sm disabled:opacity-60">
<option value="">{t("form.selectPlaceholder")}</option>
{vocabularies?.map((vocab) => (
<option key={vocab.id} value={vocab.id}>{vocab.key}</option>
))}
</select>
</div>
)}
{dataType === "authority" && (
<div className="space-y-1">
<Label htmlFor="field-kind">{t("fields.authorityKind")}</Label>
<select id="field-kind" value={authorityKind} disabled={isEdit}
onChange={(e) => setAuthorityKind(e.target.value)}
className="w-full rounded border px-2 py-1 text-sm disabled:opacity-60">
<option value="">{t("fields.anyKind")}</option>
{KINDS.map((kind) => (
<option key={kind} value={kind}>{t(`authorities.${kind}`)}</option>
))}
</select>
</div>
)}
<div className="space-y-1">
<Label htmlFor="field-group">{t("fields.group")}</Label>
<Input id="field-group" value={group} onChange={(e) => setGroup(e.target.value)} />
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={required} onCheckedChange={(checked) => setRequired(checked === true)} />
{t("fields.required")}
</label>
{error && <p role="alert" className="text-xs text-red-600">{t("form.required")}</p>}
{failed && <p role="alert" className="text-xs text-red-600">{t("form.rejected")}</p>}
<Button type="submit" size="sm" disabled={pending}>
{isEdit ? t("actions.save") : t("fields.create")}
</Button>
</form>
);
}
function labelTextOrKey(def: FieldDefinitionView): string {
return def.labels[0]?.label ?? def.key;
}
- Step 4: Write the story
web/src/fields/field-form.stories.tsx— assert edit mode disables the key input and shows a Save button (the story mirrors the established format; the form needs the provider tree, which.storybook/preview.tsxalready supplies):
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()
},
}
- Step 5: Run the relevant tests + typecheck + lint.
cd web && pnpm test -- field-form fields && pnpm typecheck && pnpm lint
Expected: PASS. (If existing field-list/fields-page tests exist, update any that render <FieldList/>/<FieldsPage/> to pass the new props or use <FieldsPage/> which wires them.)
- Step 6: Commit.
git add web/src/fields
git commit -m "feat(web): edit/delete field definitions on /fields (in-place edit pane) (#36)"
Task 9: Vocabularies screen — rename + term edit/delete + stories
Files:
-
Modify:
web/src/vocab/vocabulary-list.tsx,web/src/vocab/vocabulary-terms.tsx -
Create:
web/src/vocab/term-row.tsx,web/src/vocab/term-row.stories.tsx -
Test: story
play+ existing vocab tests. -
Step 1: Add rename + delete to
VocabularyListrows. Each row keeps theNavLinkbut gains an inline rename (toggle anInput+ Save) usinguseRenameVocabulary, and aDeleteConfirmDialogusinguseDeleteVocabulary. Add localeditingId/draftKeystate:
// imports add:
import { useVocabularies, useCreateVocabulary, useRenameVocabulary, useDeleteVocabulary } from "../api/queries";
import { DeleteConfirmDialog } from "../components/delete-confirm-dialog";
// inside component:
const rename = useRenameVocabulary();
const del = useDeleteVocabulary();
const [editingId, setEditingId] = useState<string | null>(null);
const [draftKey, setDraftKey] = useState("");
// row markup (replace the existing <li> body):
// {data?.map((v) => (
// <li key={v.id} className="flex items-center gap-1 border-b pr-2">
// {editingId === v.id ? (
// <form className="flex flex-1 gap-1 p-1"
// onSubmit={(e) => { e.preventDefault();
// rename.mutate({ id: v.id, key: draftKey.trim() },
// { onSuccess: () => setEditingId(null) }); }}>
// <Input value={draftKey} onChange={(e) => setDraftKey(e.target.value)} />
// <Button type="submit" size="sm">{t("actions.save")}</Button>
// </form>
// ) : (
// <>
// <NavLink to={`/vocabularies/${v.id}`}
// className={({ isActive }) =>
// `block flex-1 px-3 py-2 text-sm ${isActive ? "bg-indigo-50" : "hover:bg-neutral-50"}`}>
// {v.key}
// </NavLink>
// <Button type="button" variant="ghost" size="sm"
// onClick={() => { setEditingId(v.id); setDraftKey(v.key); }}>
// {t("actions.rename")}
// </Button>
// <DeleteConfirmDialog description={t("actions.confirmDeleteVocabulary")}
// onConfirm={() => del.mutateAsync(v.id)} />
// </>
// )}
// </li>
// ))}
(Keep the existing create form above the list unchanged.)
- Step 2: Extract
TermRow(web/src/vocab/term-row.tsx) — a display row that toggles to an inline edit form (LabelEditor + URI + Save/Cancel) viauseUpdateTerm, plus aDeleteConfirmDialogviauseDeleteTerm:
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 update = useUpdateTerm();
const del = 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={update.isPending}
onClick={() => update.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={() => setEditing(true)}>
{t("actions.edit")}
</Button>
<DeleteConfirmDialog description={t("actions.confirmDeleteTerm")}
onConfirm={() => del.mutateAsync({ vocabularyId, termId: term.id })} />
</li>
);
}
-
Step 3: Use
TermRowinVocabularyTerms— replace the inline term<li>mapping with<TermRow vocabularyId={id} term={term} lang={lang} />. Keep the add-term form unchanged. Addimport { TermRow } from "./term-row";. -
Step 4: Write a story
web/src/vocab/term-row.stories.tsx(the row uses hooks that hit the API; rely on the preview MSW handlers — assert the display + edit toggle which need no network):
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()
},
}
- Step 5: Run tests + typecheck + lint.
cd web && pnpm test -- term-row vocab && pnpm typecheck && pnpm lint
Expected: PASS. Update any existing vocab test that asserts the old plain term <li> markup.
- Step 6: Commit.
git add web/src/vocab
git commit -m "feat(web): rename vocabularies + edit/delete terms in place (#30)"
Task 10: Authorities screen — edit/delete + stories
Files:
-
Modify:
web/src/authorities/authorities-page.tsx -
Create:
web/src/authorities/authority-row.tsx,web/src/authorities/authority-row.stories.tsx -
Test: story
play+ existing authority tests. -
Step 1: Extract
AuthorityRow(web/src/authorities/authority-row.tsx) mirroringTermRowbut usinguseUpdateAuthority/useDeleteAuthority(both needkindfor query invalidation):
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 update = useUpdateAuthority();
const del = 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={update.isPending}
onClick={() => update.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={() => setEditing(true)}>
{t("actions.edit")}
</Button>
<DeleteConfirmDialog description={t("actions.confirmDeleteAuthority")}
onConfirm={() => del.mutateAsync({ id: authority.id, kind })} />
</li>
);
}
-
Step 2: Use
AuthorityRowinAuthoritiesPage— replace the inline authority<li>mapping with<AuthorityRow authority={a} kind={currentKind} lang={lang} />. Addimport { AuthorityRow } from "./authority-row";. -
Step 3: Write a story
web/src/authorities/authority-row.stories.tsx:
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()
},
}
- Step 4: Run tests + typecheck + lint.
cd web && pnpm test -- authority-row authorities && pnpm typecheck && pnpm lint
Expected: PASS. Update any existing authorities test asserting the old <li> markup.
- Step 5: Commit.
git add web/src/authorities
git commit -m "feat(web): edit/delete authorities in place (#30)"
Task 11: Final verification
Files: none (verification only).
- Step 1: Full backend suite + lint + fmt.
cargo +nightly fmt --check
cargo clippy --workspace --all-targets -- -D warnings
DATABASE_URL=postgres://postgres:postgres@localhost:5442/cms_dev \
MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=masterKey cargo test --workspace
Expected: all green.
- Step 2: Full web suite.
cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build
Expected: all green; bundle ≤ 150 KB gz (check pnpm check:size if defined).
- Step 3: en/sv i18n parity. Run the existing parity test (it lives in the web suite) and confirm the new
actions.*keys exist in bothen.jsonandsv.jsonwith identical structure.
cd web && pnpm test -- i18n
Expected: PASS.
- Step 4: Codename scan.
git grep -in 'biggus\|dickus' -- crates web/src | grep -v node_modules || echo "clean"
Expected: clean.
- Step 5: Cargo.lock hygiene. Confirm no dangling
Cargo.lockchange (no new deps were added in this milestone, but verify):
git status --short
Expected: clean working tree after all task commits.
- Step 6: Manual smoke (optional but recommended). With the stack up and
cargo run -p server+cd web && pnpm dev, log in as an editor and verify: rename a vocabulary; edit a term and an authority; toggle a field'srequired; attempt to delete a term that an object uses → see the "used by N" message; clear the object's field → delete succeeds.
Self-Review (completed)
1. Spec coverage:
- Endpoints — term PATCH/DELETE (T1), vocabulary PATCH/DELETE (T2), authority PATCH/DELETE (T3), field-def PATCH/DELETE (T4). ✓
- Block-with-409+count via JSONB scans —
count_objects_referencing_term/authority(term/authority data_type-scopedfields ->> key),count_objects_using_field(jsonb_exists), vocab delete (terms + bindings count). ✓ - Field-def immutability —
UpdateFieldDefinitionRequestexposes onlyrequired/group/labels; key/type/binding inputs disabled in the edit form. ✓ requirednot retroactively enforced — update just sets the column; no object re-validation. ✓- Vocabulary key rename allowed; unique collision → 409. ✓
- Audited — every db mutation records
Updated/Deletedin-tx. ✓ - Frontend in-place edit + AlertDialog delete; 409 keeps dialog open with "used by N" (
DeleteConfirmDialog). ✓ - Storybook —
DeleteConfirmDialog(T7),FieldFormedit (T8),TermRow(T9),AuthorityRow(T10). ✓ - OpenAPI regenerated (T5); en/sv parity (T6, T11). ✓
2. Placeholder scan: Row-markup blocks in T8/T9/T1 are shown as commented JSX to splice into existing loops (the surrounding component code is unchanged and already in the repo); all new functions/handlers/components are complete. No "TBD"/"add error handling"/"similar to". The few "confirm the variant name" notes (e.g. FieldType::Integer) are verification steps, not deferred implementation.
3. Type consistency: DeleteOutcome { Deleted, InUse { count: i64 }, NotFound } defined once (T1) and matched in every handler. InUseView { count: i64 } defined once in admin_vocab.rs and imported by admin_authorities.rs/admin_objects.rs. Hook input shapes match the handler path params and request bodies ({vocabularyId, termId, ...}, {id, kind, ...}, {key, required, group, labels}). InUseError(count: number) parsed from the 409 JSON {count} in every delete hook and consumed by DeleteConfirmDialog.
Notes
- No new crates/deps → no
Cargo.lockchurn expected; if any appears, stage it in the same commit (lesson from a prior milestone). actor(&auth.user)exists only inadmin_objects.rs;admin_vocab.rs/admin_authorities.rsinlineAuditActor::User(auth.user.id.to_uuid())(existing convention) — both are used as-is.- Field-definition create remains unaudited (pre-existing gap, out of scope); this milestone audits only the new update/delete paths.