# 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` — add `pub enum DeleteOutcome { Deleted, InUse { count: i64 }, NotFound }` (shared by all delete fns). - `crates/db/src/vocab.rs` — add `update_term`, `delete_term`, `count_objects_referencing_term`, `rename_vocabulary`, `delete_vocabulary`. - `crates/db/src/authority.rs` — add `update_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; shared `InUseView`. - `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; define `pub(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 + `InUseError` class. - `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` + new `term-row.tsx` (+ stories) — rename + term edit/delete. - `web/src/authorities/authorities-page.tsx` + new `authority-row.tsx` (+ stories) — edit/delete. **Shared db delete contract (defined in Task 1, used by all):** ```rust /// 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` (add `DeleteOutcome`) - 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 `DeleteOutcome` enum.** In `crates/db/src/lib.rs`, add near the top (after the existing `pub mod`/`pub use` lines): ```rust /// 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 build `Db`, create a vocabulary + term via `vocab::create_vocabulary`/`vocab::add_term` inside a tx with `AuditActor::System`). Reference how `set_object_fields` is used in `crates/db/tests/object_fields.rs` to attach a term value to an object. ```rust #[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): ```rust 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 the `use` line `DeleteOutcome` is in the `db` crate root, so reference it as `crate::DeleteOutcome`. Add: ```rust /// 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 { 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 where E: sqlx::PgExecutor<'e>, { sqlx::query_scalar( "SELECT count(*) FROM object o WHERE EXISTS ( \ SELECT 1 FROM field_definition fd \ WHERE fd.data_type = 'term' AND o.fields ->> fd.key = $1 )", ) .bind(term_id.to_string()) .fetch_one(executor) .await } /// Delete a term (its labels cascade) unless catalogue objects reference it, recording a /// `deleted` audit entry. Pass a transaction connection. pub async fn delete_term( conn: &mut sqlx::PgConnection, actor: AuditActor, vocabulary_id: VocabularyId, term_id: TermId, ) -> Result { let exists = sqlx::query_scalar::<_, i32>( "SELECT 1 FROM term WHERE id = $1 AND vocabulary_id = $2", ) .bind(term_id.to_uuid()) .bind(vocabulary_id.to_uuid()) .fetch_optional(&mut *conn) .await?; if exists.is_none() { return Ok(crate::DeleteOutcome::NotFound); } let count = count_objects_referencing_term(&mut *conn, term_id).await?; if count > 0 { return Ok(crate::DeleteOutcome::InUse { count }); } sqlx::query("DELETE FROM term WHERE id = $1") .bind(term_id.to_uuid()) .execute(&mut *conn) .await?; audit::record( &mut *conn, &NewAuditEvent { actor, action: AuditAction::Deleted, entity_type: TERM_ENTITY_TYPE.to_owned(), entity_id: term_id.to_uuid(), changes: Vec::new(), }, ) .await?; Ok(crate::DeleteOutcome::Deleted) } ``` - [ ] **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` (mirror `create_list_vocabulary_and_terms` for setup: `seed_user(Role::Editor)`, `build_app(state(pool))`, `login(...)`, then `oneshot` requests with the session cookie). Add a small PATCH/DELETE helper if none exists: ```rust async fn send(app: &axum::Router, cookie: &str, method: &str, uri: &str, body: Option<&str>) -> axum::http::Response { let mut req = Request::builder().method(method).uri(uri).header(header::COOKIE, cookie); if 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() } } ``` ```rust #[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::routing` already imported via `get`. Add `domain::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: ```rust #[derive(Deserialize, ToSchema)] pub(crate) struct UpdateTermRequest { pub external_uri: Option, pub labels: Vec, } ``` - Add handlers: ```rust #[utoipa::path( patch, path = "/api/admin/vocabularies/{id}/terms/{term_id}", request_body = UpdateTermRequest, params(("id" = String, Path, description = "Vocabulary id (UUID)"), ("term_id" = String, Path, description = "Term id (UUID)")), responses((status = 204), (status = 401), (status = 403), (status = 404)) )] pub(crate) async fn update_term( auth: Authorized, State(state): State, Path((_id, term_id)): Path<(String, String)>, Json(req): Json, ) -> Result { let term_id = term_id.parse::().map_err(|_| StatusCode::NOT_FOUND)?; let labels: Vec = req.labels.into_iter() .map(|l| LocalizedLabel { lang: l.lang, label: l.label }).collect(); let mut tx = state.db.pool().begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let existed = db::vocab::update_term( &mut tx, AuditActor::User(auth.user.id.to_uuid()), 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, State(state): State, Path((id, term_id)): Path<(String, String)>, ) -> Response { let (Ok(vocab_id), Ok(term_id)) = (id.parse::(), term_id.parse::()) else { return StatusCode::NOT_FOUND.into_response(); }; let Ok(mut tx) = state.db.pool().begin().await else { return StatusCode::INTERNAL_SERVER_ERROR.into_response(); }; let outcome = db::vocab::delete_term(&mut tx, AuditActor::User(auth.user.id.to_uuid()), vocab_id, term_id).await; match outcome { Ok(db::DeleteOutcome::Deleted) => match tx.commit().await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), }, Ok(db::DeleteOutcome::InUse { count }) => (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 in `admin_vocab.rs` instead and have Task 4 import it from here: ```rust /// 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()`: ```rust .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 to `paths(...)`: `admin_vocab::update_term,` and `admin_vocab::delete_term,`. Add to `components(schemas(...))`: `admin_vocab::UpdateTermRequest,` and `admin_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.** ```bash 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`: ```rust #[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`: ```rust /// Rename a vocabulary's key, recording an `updated` audit entry. Returns `false` if no /// such vocabulary. A unique-key collision surfaces as the underlying sqlx error (23505). pub async fn rename_vocabulary( conn: &mut sqlx::PgConnection, actor: AuditActor, id: VocabularyId, key: &str, ) -> Result { let updated = sqlx::query("UPDATE vocabulary SET key = $2 WHERE id = $1") .bind(id.to_uuid()) .bind(key) .execute(&mut *conn) .await? .rows_affected(); if updated == 0 { return Ok(false); } audit::record(&mut *conn, &NewAuditEvent { actor, action: AuditAction::Updated, entity_type: VOCABULARY_ENTITY_TYPE.to_owned(), entity_id: id.to_uuid(), changes: Vec::new(), }).await?; Ok(true) } /// Delete a vocabulary unless it still has terms or is bound by a field definition /// (both would otherwise hit the FK `RESTRICT`). Records a `deleted` audit entry. pub async fn delete_vocabulary( conn: &mut sqlx::PgConnection, actor: AuditActor, id: VocabularyId, ) -> Result { let exists = sqlx::query_scalar::<_, i32>("SELECT 1 FROM vocabulary WHERE id = $1") .bind(id.to_uuid()) .fetch_optional(&mut *conn) .await?; if exists.is_none() { return Ok(crate::DeleteOutcome::NotFound); } let count: i64 = sqlx::query_scalar( "SELECT (SELECT count(*) FROM term WHERE vocabulary_id = $1) \ + (SELECT count(*) FROM field_definition WHERE vocabulary_id = $1)", ) .bind(id.to_uuid()) .fetch_one(&mut *conn) .await?; if count > 0 { return Ok(crate::DeleteOutcome::InUse { count }); } sqlx::query("DELETE FROM vocabulary WHERE id = $1") .bind(id.to_uuid()) .execute(&mut *conn) .await?; audit::record(&mut *conn, &NewAuditEvent { actor, action: AuditAction::Deleted, entity_type: VOCABULARY_ENTITY_TYPE.to_owned(), entity_id: id.to_uuid(), changes: Vec::new(), }).await?; Ok(crate::DeleteOutcome::Deleted) } ``` 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 the `send` helper from Task 1): ```rust #[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`: ```rust #[derive(Deserialize, ToSchema)] pub(crate) struct RenameVocabularyRequest { pub key: String, } #[utoipa::path( patch, path = "/api/admin/vocabularies/{id}", request_body = RenameVocabularyRequest, params(("id" = String, Path, description = "Vocabulary id (UUID)")), responses((status = 204), (status = 401), (status = 403), (status = 404), (status = 409, description = "Key already in use")) )] pub(crate) async fn rename_vocabulary( auth: Authorized, State(state): State, Path(id): Path, Json(req): Json, ) -> Result { let id = id.parse::().map_err(|_| StatusCode::NOT_FOUND)?; let mut tx = state.db.pool().begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let existed = db::vocab::rename_vocabulary(&mut tx, AuditActor::User(auth.user.id.to_uuid()), id, &req.key) .await .map_err(|err| { // 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, State(state): State, Path(id): Path, ) -> Response { let Ok(id) = id.parse::() else { return StatusCode::NOT_FOUND.into_response() }; let Ok(mut tx) = state.db.pool().begin().await else { return StatusCode::INTERNAL_SERVER_ERROR.into_response() }; match db::vocab::delete_vocabulary(&mut tx, AuditActor::User(auth.user.id.to_uuid()), id).await { Ok(db::DeleteOutcome::Deleted) => match tx.commit().await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), }, Ok(db::DeleteOutcome::InUse { count }) => (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()`: ```rust .route( "/api/admin/vocabularies/{id}", axum::routing::patch(rename_vocabulary).delete(delete_vocabulary), ) ``` - [ ] **Step 7: Register in OpenAPI.** Add `admin_vocab::rename_vocabulary,` and `admin_vocab::delete_vocabulary,` to `paths(...)`, and `admin_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.** ```bash 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_authority` in a tx). For the referenced case, create an `authority`-typed field definition + an object whose `fields` stores the authority id, via `fields::create_field_definition` + `catalog::set_object_fields` (see Task 1 Step 2 for the pattern; use `domain::FieldType::Authority { kind }`). ```rust #[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 imports `AuditAction, AuditActor, NewAuditEvent`): ```rust /// Update an authority's `external_uri` and labels (full replace), recording an /// `updated` audit entry. Returns `false` if no such authority. `kind` is immutable. pub async fn update_authority( conn: &mut sqlx::PgConnection, actor: AuditActor, id: AuthorityId, external_uri: Option<&str>, labels: &[LocalizedLabel], ) -> Result { let updated = sqlx::query("UPDATE authority SET external_uri = $2 WHERE id = $1") .bind(id.to_uuid()) .bind(external_uri) .execute(&mut *conn) .await? .rows_affected(); if updated == 0 { return Ok(false); } sqlx::query("DELETE FROM authority_label WHERE authority_id = $1") .bind(id.to_uuid()) .execute(&mut *conn) .await?; for label in labels { sqlx::query("INSERT INTO authority_label (authority_id, lang, label) VALUES ($1, $2, $3)") .bind(id.to_uuid()) .bind(&label.lang) .bind(&label.label) .execute(&mut *conn) .await?; } audit::record(&mut *conn, &NewAuditEvent { actor, action: AuditAction::Updated, entity_type: AUTHORITY_ENTITY_TYPE.to_owned(), entity_id: id.to_uuid(), changes: Vec::new(), }).await?; Ok(true) } /// Count catalogue objects referencing `id` through an `authority`-typed field. pub async fn count_objects_referencing_authority<'e, E>( executor: E, id: AuthorityId, ) -> Result where E: sqlx::PgExecutor<'e>, { sqlx::query_scalar( "SELECT count(*) FROM object o WHERE EXISTS ( \ SELECT 1 FROM field_definition fd \ WHERE fd.data_type = 'authority' AND o.fields ->> fd.key = $1 )", ) .bind(id.to_string()) .fetch_one(executor) .await } /// Delete an authority (labels cascade) unless catalogue objects reference it, /// recording a `deleted` audit entry. pub async fn delete_authority( conn: &mut sqlx::PgConnection, actor: AuditActor, id: AuthorityId, ) -> Result { let exists = sqlx::query_scalar::<_, i32>("SELECT 1 FROM authority WHERE id = $1") .bind(id.to_uuid()) .fetch_optional(&mut *conn) .await?; if exists.is_none() { return Ok(crate::DeleteOutcome::NotFound); } let count = count_objects_referencing_authority(&mut *conn, id).await?; if count > 0 { return Ok(crate::DeleteOutcome::InUse { count }); } sqlx::query("DELETE FROM authority WHERE id = $1") .bind(id.to_uuid()) .execute(&mut *conn) .await?; audit::record(&mut *conn, &NewAuditEvent { actor, action: AuditAction::Deleted, entity_type: AUTHORITY_ENTITY_TYPE.to_owned(), entity_id: id.to_uuid(), changes: Vec::new(), }).await?; Ok(crate::DeleteOutcome::Deleted) } ``` - [ ] **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`: ```rust #[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 `AuthorityId` to the `domain` import, `axum::response::{IntoResponse, Response}`, and `use crate::admin_vocab::InUseView;`. - DTO + handlers: ```rust #[derive(Deserialize, ToSchema)] pub(crate) struct UpdateAuthorityRequest { pub external_uri: Option, pub labels: Vec, } #[utoipa::path( patch, path = "/api/admin/authorities/{id}", request_body = UpdateAuthorityRequest, params(("id" = String, Path, description = "Authority id (UUID)")), responses((status = 204), (status = 401), (status = 403), (status = 404)) )] pub(crate) async fn update_authority( auth: Authorized, State(state): State, axum::extract::Path(id): axum::extract::Path, Json(req): Json, ) -> Result { let id = id.parse::().map_err(|_| StatusCode::NOT_FOUND)?; let labels: Vec = req.labels.into_iter() .map(|l| LocalizedLabel { lang: l.lang, label: l.label }).collect(); let mut tx = state.db.pool().begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let existed = db::authority::update_authority(&mut tx, AuditActor::User(auth.user.id.to_uuid()), id, req.external_uri.as_deref(), &labels) .await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 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, State(state): State, axum::extract::Path(id): axum::extract::Path, ) -> Response { let Ok(id) = id.parse::() else { return StatusCode::NOT_FOUND.into_response() }; let Ok(mut tx) = state.db.pool().begin().await else { return StatusCode::INTERNAL_SERVER_ERROR.into_response() }; match db::authority::delete_authority(&mut tx, AuditActor::User(auth.user.id.to_uuid()), id).await { Ok(db::DeleteOutcome::Deleted) => match tx.commit().await { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), }, Ok(db::DeleteOutcome::InUse { count }) => (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()`: ```rust .route( "/api/admin/authorities/{id}", axum::routing::patch(update_authority).delete(delete_authority), ) ``` - [ ] **Step 7: Register in OpenAPI.** Add `admin_authorities::update_authority,` and `admin_authorities::delete_authority,` to `paths(...)`, and `admin_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.** ```bash 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.rs`** with failing tests (mirror the `Db::from_pool` + tx pattern; reuse a local `sample_object_input()` helper): ```rust 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: ```rust 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: ```rust /// 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 { let id: Option = sqlx::query_scalar("SELECT id FROM field_definition WHERE key = $1") .bind(key) .fetch_optional(&mut *conn) .await?; let Some(id) = id else { return Ok(false) }; sqlx::query("UPDATE field_definition SET required = $2, group_key = $3 WHERE id = $1") .bind(id) .bind(required) .bind(group_key) .execute(&mut *conn) .await?; sqlx::query("DELETE FROM field_definition_label WHERE field_definition_id = $1") .bind(id) .execute(&mut *conn) .await?; for label in labels { sqlx::query( "INSERT INTO field_definition_label (field_definition_id, lang, label) VALUES ($1, $2, $3)", ) .bind(id) .bind(&label.lang) .bind(&label.label) .execute(&mut *conn) .await?; } audit::record(&mut *conn, &NewAuditEvent { actor, action: AuditAction::Updated, entity_type: FIELD_DEFINITION_ENTITY_TYPE.to_owned(), entity_id: id, changes: Vec::new(), }).await?; Ok(true) } /// Count catalogue objects that store a value under field `key`. pub async fn count_objects_using_field<'e, E>(executor: E, key: &str) -> Result where E: sqlx::PgExecutor<'e>, { sqlx::query_scalar("SELECT count(*) FROM object WHERE jsonb_exists(fields, $1)") .bind(key) .fetch_one(executor) .await } /// Delete a field definition (labels cascade) unless catalogue objects use its key, /// recording a `deleted` audit entry. pub async fn delete_field_definition( conn: &mut sqlx::PgConnection, actor: AuditActor, key: &str, ) -> Result { let id: Option = sqlx::query_scalar("SELECT id FROM field_definition WHERE key = $1") .bind(key) .fetch_optional(&mut *conn) .await?; let Some(id) = id else { return Ok(crate::DeleteOutcome::NotFound) }; let count = count_objects_using_field(&mut *conn, key).await?; if count > 0 { return Ok(crate::DeleteOutcome::InUse { count }); } sqlx::query("DELETE FROM field_definition WHERE id = $1") .bind(id) .execute(&mut *conn) .await?; audit::record(&mut *conn, &NewAuditEvent { actor, action: AuditAction::Deleted, entity_type: FIELD_DEFINITION_ENTITY_TYPE.to_owned(), entity_id: id, changes: Vec::new(), }).await?; Ok(crate::DeleteOutcome::Deleted) } ``` - [ ] **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`: ```rust #[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 has `actor(&auth.user)`, `Authorized`, `LabelView`, `LabelInput` (imported via `admin_vocab`). Add `axum::response::{IntoResponse, Response}` and `use crate::admin_vocab::InUseView;`. Add: ```rust #[derive(serde::Deserialize, utoipa::ToSchema)] pub(crate) struct UpdateFieldDefinitionRequest { pub required: bool, pub group: Option, pub labels: Vec, } #[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, State(state): State, Path(key): Path, Json(req): Json, ) -> Result { let labels: Vec = req.labels.into_iter() .map(|l| LocalizedLabel { lang: l.lang, label: l.label }).collect(); let mut tx = state.db.pool().begin().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let 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, State(state): State, Path(key): Path, ) -> 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: ```rust .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,` and `admin_objects::delete_field_definition,` to `paths(...)`, and `admin_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.** ```bash 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:api` can read `/api-docs/openapi.json`: ```bash 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.** ```bash cd web && pnpm gen:api ``` - [ ] **Step 3: Verify the new endpoints/DTOs landed.** ```bash 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).** ```bash cd web && pnpm typecheck ``` Expected: PASS. - [ ] **Step 5: Commit.** ```bash 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 existing `actions` object (currently `{ "edit", "delete", "confirmDelete" }`) and add reference-data confirm strings + the in-use message: ```jsonc "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): ```jsonc "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 from `web/src/test/handlers.ts` and a `QueryClientProvider` + `renderHook` wrapper — copy the wrapper from any existing `*.test.tsx` that calls `renderHook`). Minimal: ```ts 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.** ```bash cd web && pnpm test -- queries.test ``` Expected: FAIL — `InUseError` not exported. - [ ] **Step 4: Implement hooks + `InUseError`** in `web/src/api/queries.ts`. Add the error class near `HttpError`/`FieldRejection`: ```ts 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: ```ts 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.** ```bash 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.** ```bash 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`: ```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; /** Optional override for the trigger button text (defaults to actions.delete). */ triggerLabel?: string; }) { const { t } = useTranslation(); const [open, setOpen] = useState(false); const [message, setMessage] = useState(null); const confirm = async () => { setMessage(null); try { await onConfirm(); } catch (err) { // Keep the dialog open; show the blocking reason. Never let the rejected // mutation escape as an unhandled rejection. setMessage(err instanceof InUseError ? t("actions.inUse", { count: err.count }) : t("form.rejected")); return; } setOpen(false); }; return ( {triggerLabel ?? t("actions.delete")} } /> {t("actions.delete")} {description} {message && (

{message}

)} {t("form.cancel")} {t("actions.delete")}
); } ``` - [ ] **Step 2: Write the story** `web/src/components/delete-confirm-dialog.stories.tsx` (mirror the format of `web/src/objects/visibility-badge.stories.tsx` — `@storybook/react-vite`, `expect` from `storybook/test`, `tags: ['ai-generated']`, single quotes, no semicolons): ```tsx 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 export default meta type Story = StoryObj export const Confirms: Story = { args: { description: 'Delete this term? This cannot be undone.', onConfirm: fn() }, play: async ({ canvas, args }) => { await userEvent.click(canvas.getByRole('button', { name: 'Delete' })) // 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.** ```bash 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.** ```bash 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`): ```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(null); return (
setSelected(null)} />
); } ``` - [ ] **Step 2: Extend `FieldList`** (`web/src/fields/field-list.tsx`) with `onSelect`/`selectedKey` props, a clickable row (selects → edit), and a delete affordance using `DeleteConfirmDialog` + `useDeleteFieldDefinition`. Change the component signature and the row markup: ```tsx 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
  • with: // {defs.map((def) => ( //
  • // // del.mutateAsync(def.key)} // /> //
  • // ))} } ``` (Keep the existing group sorting/skeleton code unchanged — only the row markup and the props/imports change.) - [ ] **Step 3: Make `FieldForm` create-or-edit** (`web/src/fields/field-form.tsx`). Add `editing`/`onDone` props; when `editing` is set, hydrate state from it, disable `key`/type/binding inputs, and submit via `useUpdateFieldDefinition`; otherwise behave as today. ```tsx 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([]); const [dataType, setDataType] = useState("text"); const [vocabularyId, setVocabularyId] = useState(""); const [authorityKind, setAuthorityKind] = useState(""); const [group, setGroup] = useState(""); const [required, setRequired] = useState(false); const [error, setError] = useState(false); // 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 (
    {isEdit ? labelTextOrKey(editing) : t("fields.newField")}
    {isEdit && ( )}
    setKey(e.target.value)} />
    {dataType === "term" && (
    )} {dataType === "authority" && (
    )}
    setGroup(e.target.value)} />
    {error &&

    {t("form.required")}

    } {failed &&

    {t("form.rejected")}

    } ); } 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.tsx` already supplies): ```tsx import type { Meta, StoryObj } from '@storybook/react-vite' import { expect, fn } from 'storybook/test' import { FieldForm } from './field-form' const meta = { component: FieldForm, tags: ['ai-generated'], } satisfies Meta export default meta type Story = StoryObj export const Create: Story = { args: { editing: null, onDone: fn() }, play: async ({ canvas }) => { await expect(canvas.getByLabelText('Key')).toBeEnabled() }, } export const Edit: Story = { args: { editing: { key: 'material', data_type: 'text', vocabulary_id: null, authority_kind: null, required: true, group: 'Identification', labels: [{ lang: 'en', label: 'Material' }], }, onDone: fn(), }, play: async ({ canvas }) => { await expect(canvas.getByLabelText('Key')).toBeDisabled() await expect(canvas.getByRole('button', { name: 'Save' })).toBeVisible() }, } ``` - [ ] **Step 5: Run the relevant tests + typecheck + lint.** ```bash 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 ``/`` to pass the new props or use `` which wires them.) - [ ] **Step 6: Commit.** ```bash 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 `VocabularyList`** rows. Each row keeps the `NavLink` but gains an inline rename (toggle an `Input` + Save) using `useRenameVocabulary`, and a `DeleteConfirmDialog` using `useDeleteVocabulary`. Add local `editingId`/`draftKey` state: ```tsx // 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(null); const [draftKey, setDraftKey] = useState(""); // row markup (replace the existing
  • body): // {data?.map((v) => ( //
  • // {editingId === v.id ? ( //
    { e.preventDefault(); // rename.mutate({ id: v.id, key: draftKey.trim() }, // { onSuccess: () => setEditingId(null) }); }}> // setDraftKey(e.target.value)} /> // //
    // ) : ( // <> // // `block flex-1 px-3 py-2 text-sm ${isActive ? "bg-indigo-50" : "hover:bg-neutral-50"}`}> // {v.key} // // // del.mutateAsync(v.id)} /> // // )} //
  • // ))} ``` (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) via `useUpdateTerm`, plus a `DeleteConfirmDialog` via `useDeleteTerm`: ```tsx 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(term.labels as LabelInput[]); const [uri, setUri] = useState(term.external_uri ?? ""); if (editing) { return (
  • setUri(e.target.value)} />
  • ); } return (
  • {labelText(term.labels, lang)} del.mutateAsync({ vocabularyId, termId: term.id })} />
  • ); } ``` - [ ] **Step 3: Use `TermRow` in `VocabularyTerms`** — replace the inline term `
  • ` mapping with ``. Keep the add-term form unchanged. Add `import { 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): ```tsx 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 export default meta type Story = StoryObj 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.** ```bash cd web && pnpm test -- term-row vocab && pnpm typecheck && pnpm lint ``` Expected: PASS. Update any existing vocab test that asserts the old plain term `
  • ` markup. - [ ] **Step 6: Commit.** ```bash 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`) mirroring `TermRow` but using `useUpdateAuthority`/`useDeleteAuthority` (both need `kind` for query invalidation): ```tsx 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(authority.labels as LabelInput[]); const [uri, setUri] = useState(authority.external_uri ?? ""); if (editing) { return (
  • setUri(e.target.value)} />
  • ); } return (
  • {labelText(authority.labels, lang)} del.mutateAsync({ id: authority.id, kind })} />
  • ); } ``` - [ ] **Step 2: Use `AuthorityRow` in `AuthoritiesPage`** — replace the inline authority `
  • ` mapping with ``. Add `import { AuthorityRow } from "./authority-row";`. - [ ] **Step 3: Write a story** `web/src/authorities/authority-row.stories.tsx`: ```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 export default meta type Story = StoryObj export const Display: Story = { play: async ({ canvas }) => { await expect(canvas.getByText('Astrid Lindgren')).toBeVisible() }, } export const TogglesEdit: Story = { play: async ({ canvas }) => { await userEvent.click(canvas.getByRole('button', { name: 'Edit' })) await expect(canvas.getByRole('button', { name: 'Save' })).toBeVisible() }, } ``` - [ ] **Step 4: Run tests + typecheck + lint.** ```bash cd web && pnpm test -- authority-row authorities && pnpm typecheck && pnpm lint ``` Expected: PASS. Update any existing authorities test asserting the old `
  • ` markup. - [ ] **Step 5: Commit.** ```bash 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.** ```bash 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.** ```bash 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 both `en.json` and `sv.json` with identical structure. ```bash cd web && pnpm test -- i18n ``` Expected: PASS. - [ ] **Step 4: Codename scan.** ```bash 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.lock` change (no new deps were added in this milestone, but verify): ```bash 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's `required`; 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-scoped `fields ->> key`), `count_objects_using_field` (`jsonb_exists`), vocab delete (terms + bindings count). ✓ - Field-def immutability — `UpdateFieldDefinitionRequest` exposes only `required`/`group`/`labels`; key/type/binding inputs disabled in the edit form. ✓ - `required` not retroactively enforced — update just sets the column; no object re-validation. ✓ - Vocabulary key rename allowed; unique collision → 409. ✓ - Audited — every db mutation records `Updated`/`Deleted` in-tx. ✓ - Frontend in-place edit + AlertDialog delete; 409 keeps dialog open with "used by N" (`DeleteConfirmDialog`). ✓ - Storybook — `DeleteConfirmDialog` (T7), `FieldForm` edit (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.lock` churn expected; if any appears, stage it in the same commit (lesson from a prior milestone). - `actor(&auth.user)` exists only in `admin_objects.rs`; `admin_vocab.rs`/`admin_authorities.rs` inline `AuditActor::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.