f6053068be
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2299 lines
94 KiB
Markdown
2299 lines
94 KiB
Markdown
# 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<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` (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<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()
|
||
}
|
||
}
|
||
```
|
||
|
||
```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<String>,
|
||
pub labels: Vec<LabelInput>,
|
||
}
|
||
```
|
||
- 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<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 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<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 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<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()`:
|
||
```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<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`:
|
||
```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<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()`:
|
||
```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<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`:
|
||
```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<EditCatalogue>`, `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<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:
|
||
```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<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 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<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.**
|
||
```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<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`) 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 <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 `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<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.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<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.**
|
||
```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 `<FieldList/>`/`<FieldsPage/>` to pass the new props or use `<FieldsPage/>` 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<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) 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<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 `TermRow` in `VocabularyTerms`** — replace the inline term `<li>` mapping with `<TermRow vocabularyId={id} term={term} lang={lang} />`. 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<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.**
|
||
```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 `<li>` 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<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 `AuthorityRow` in `AuthoritiesPage`** — replace the inline authority `<li>` mapping with `<AuthorityRow authority={a} kind={currentKind} lang={lang} />`. 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<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.**
|
||
```bash
|
||
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.**
|
||
```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.
|