From 19408f628227966f1b238bb1502de543ffc4aea0 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 13:35:18 +0200 Subject: [PATCH] =?UTF-8?q?docs(specs):=20fields=20management=20=E2=80=94?= =?UTF-8?q?=20POST=20field-definitions=20+=20/fields=20two-pane=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-04-fields-management-design.md | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-04-fields-management-design.md diff --git a/docs/superpowers/specs/2026-06-04-fields-management-design.md b/docs/superpowers/specs/2026-06-04-fields-management-design.md new file mode 100644 index 0000000..66a49e5 --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-fields-management-design.md @@ -0,0 +1,196 @@ +# Fields Management — Design + +**Date:** 2026-06-04 +**Status:** Approved (brainstorming) — ready for implementation planning. + +## Context + +Milestones 1–5 (merged to `main` at `2d0b76a`) delivered the SPA foundation, object +authoring, publishing, vocabulary/authority management, and search. The app shell has +**one disabled nav stub left: Fields.** This milestone enables it — managing the +*flexible field definitions* that form the catalogue's extensible schema (the fields the +M2 object authoring form renders beyond the fixed core columns). + +Like Search (M5), this is **not** a pure-frontend milestone. `GET /api/admin/field-definitions` +exists (read-only), and the db layer has `db::fields::create_field_definition`, but there +is **no HTTP write route**. So Fields is a combined backend + frontend slice. + +After this milestone, **every nav item is live** — zero stubs. + +## Decisions (settled during brainstorming) + +- **Combined backend + frontend, create + list only.** Expose `POST /api/admin/field-definitions` + over the existing `db::fields::create_field_definition`; build the list + create UI. + New field definitions are purely additive (they only add optional schema), so create is + safe and useful standalone. **Edit/delete is deferred** — it needs new db functions and a + referential-integrity policy (a field definition drives existing object data; the same + concern as issue #30). File a follow-up when this lands. +- **Two-pane layout** (consistent with Objects/Vocabularies): grouped list of existing + definitions on the left, a persistent create form on the right. No per-item detail view + or `:id` route (definitions are create+list only and identified by `key`). +- **Create endpoint capability = `EditCatalogue`** (matches other catalogue writes; the + existing GET uses `ViewInternal`). +- **Ungrouped fields** render under a localized "Other" heading. + +## Backend contract (to build) + +### Domain (already present — reuse, no change) + +`FieldType` (`crates/domain/src/field_definition.rs`) is a discriminated union: +`Text | LocalizedText | Integer | Date | Boolean | Term { vocabulary_id } | Authority { kind: Option }`. +- `FieldType::from_parts(data_type: &str, vocabulary_id: Option, authority_kind: Option) -> Option` + reconstructs the type from the three stored columns and **returns `None` for any + inconsistent combination** — a scalar carrying a binding, a `term` without a vocabulary + (or with an authority kind), an `authority` carrying a vocabulary. This is the single + validation chokepoint the handler reuses. +- `NewFieldDefinition { key, field_type, required, group_key: Option, labels: Vec }`. +- `db::fields::create_field_definition(conn: &mut PgConnection, new: &NewFieldDefinition) -> Result` + (multi-statement — must be passed a transaction connection `&mut *tx`). + +### `api` crate — new write handler + +`POST /api/admin/field-definitions`, capability **`EditCatalogue`**. Request body: + +``` +NewFieldDefinitionRequest { + key: String, + data_type: String, // text|localized_text|integer|date|boolean|term|authority + vocabulary_id: Option, // required iff data_type == "term" + authority_kind: Option, // person|organisation|place; only for "authority" (optional) + required: bool, + group: Option, + labels: Vec, // { lang, label } — reuse the existing LabelInput schema +} +``` + +Handler logic: +1. Parse `vocabulary_id` (if present) to `VocabularyId` (→ 400 on malformed UUID) and + `authority_kind` (if present) to `AuthorityKind` by matching `person|organisation|place` + (→ 400 otherwise). +2. `FieldType::from_parts(&data_type, vocabulary_id, authority_kind)` → `None` ⇒ **422** + (covers "term without vocabulary", "authority with vocabulary", unknown type, stray + binding on a scalar — all in one check). +3. Build `NewFieldDefinition`, run `create_field_definition` inside a transaction, commit. +4. On a unique-violation (duplicate `key`, Postgres SQLSTATE 23505) ⇒ **409**; other db + errors ⇒ 500. +5. Return **`201 { key }`** (a small `CreatedField { key }` view). + +Register in `crates/api/src/openapi.rs` (path + the `NewFieldDefinitionRequest` and +`CreatedField` schemas). The route is added to the admin router (likely a new +`crates/api/src/admin_fields.rs`, or alongside the existing field-definition GET in +`admin_objects.rs` — implementer's call, following the module that already owns +`list_field_definitions`). + +### Typed client + +Regenerate `web/src/api/schema.d.ts` (run the server against the test infra + +`pnpm gen:api`) so the path, `NewFieldDefinitionRequest`, and `CreatedField` appear. + +## Frontend architecture + +### Routes & navigation + +``` +/fields → FieldsPage (FieldList left, FieldForm right) — no nested :id route +``` + +Added under the protected `AppShell`. In `app-shell.tsx`, **Fields** becomes the last +active `NavLink`; `DISABLED_NAV` becomes empty (the disabled-button block renders nothing +/ is removed). All nav items are now live. + +### Components / files + +``` +web/src/fields/ + fields-page.tsx two-pane grid (FieldList left, FieldForm right) + field-list.tsx useFieldDefinitions, grouped by `group` (ungrouped → "Other"); rows + show localized label + key + data_type badge + required marker; with + loading / empty / error states + field-form.tsx the create form (key, LabelEditor sv/en, type Select, conditional + Vocabulary/authority-kind, group, required) → useCreateFieldDefinition +web/src/api/queries.ts + useCreateFieldDefinition (POST; invalidates ["field-definitions"]) +web/src/app.tsx + the /fields route +web/src/shell/app-shell.tsx enable Fields NavLink; DISABLED_NAV = [] +web/src/i18n/{en,sv}.json + fields.* namespace +``` + +### `FieldForm` — the discriminated-union create form + +- `key` — text Input (machine identifier). +- Labels — the **shared `LabelEditor`** (sv/en; EN required), reused from M4. +- `type` — Select over the 7 data types. +- **Conditional:** when type=`term`, reveal a **Vocabulary** Select populated from + `useVocabularies` (reused); when type=`authority`, reveal an **authority-kind** Select + (Any / Person / Organisation / Place). All other types show no extra config. +- `group` — optional text Input. +- `required` — Checkbox. +- Submit → `useCreateFieldDefinition` → on success invalidate `["field-definitions"]` and + clear the form. + +### Data layer + +`useCreateFieldDefinition()` → `POST /api/admin/field-definitions` with the request body; +invalidates `["field-definitions"]`. `useFieldDefinitions()` and `useVocabularies()` +already exist and are reused. + +## Data flow + +`useFieldDefinitions` is the **same cached query the M2 object authoring form consumes**. +Creating a field definition and invalidating `["field-definitions"]` makes the new field +appear both in the Fields list **and** immediately as an available field in the object +editor. That shared-cache effect is the milestone's main payoff. + +## Validation & error handling + +- **Client:** `key` non-empty (trimmed); EN label required (the `LabelEditor` guard); + if type=`term`, a vocabulary must be chosen — all block submit with inline messages. +- **Backend (source of truth):** key uniqueness (409) and type/binding consistency (422), + surfaced as a form-level `form.rejected` alert. +- The list has loading / empty / error states (reuse the M1 list-state patterns). + +## Testing + +### Backend (`crates/api/tests/`) +- POST creates a scalar field (e.g. `integer`) → 201; a `term` field with a valid + `vocabulary_id` → 201; `term` without `vocabulary_id` → 422; duplicate `key` → 409; + unauthenticated → 401. (Mirror the existing admin test harness — seed an editor, login, + oneshot requests.) + +### Frontend (Vitest + RTL + MSW, `onUnhandledRequest:"error"`) +- New MSW handler `POST /api/admin/field-definitions` (+ a `fieldDefinitions` GET fixture + with at least one grouped and one ungrouped entry). +- Tests: list renders, grouped (incl. the "Other" group); creating a `text` field posts + the expected body and clears the form; selecting **Term** reveals the vocabulary picker + and blocks submit until one is chosen; the EN-required guard blocks submit; create + invalidates `["field-definitions"]`; the Fields nav item is an enabled link (and no + disabled nav buttons remain). + +### Project constraints +- en/sv i18n key parity (authority-kind labels reuse existing `authorities.*`). +- No `any` / `eslint-disable` / `@ts-ignore`. Codename ban. +- Bundle ≤150 KB gz (current headroom ~5 KB; lazy-load `/fields` with `React.lazy` + + `Suspense` — as M2 did for the object forms — if it pushes over, then re-verify). + +## Acceptance criteria + +1. `POST /api/admin/field-definitions` creates definitions of all representative types, + reuses `FieldType::from_parts` for consistency validation (term/authority), is + `EditCatalogue`-gated, returns 409 on duplicate key and 422 on inconsistent type/config. +2. The Fields nav item is enabled and routes to `/fields`; the grouped list renders; the + create form shows the conditional type config (vocabulary for term, kind for authority). +3. A newly created field appears in the Fields list **and** in the M2 object authoring + form (shared `["field-definitions"]` invalidation). +4. EN-required and term-needs-vocabulary client validation; backend 409/422 surfaced as a + form-level error. +5. Web + backend CI green (cargo test; web typecheck/lint/test/build, bundle ≤150 KB gz); + en/sv parity. +6. After this milestone, the app shell has **no disabled nav stubs**. + +## Out of scope / follow-ups + +- **Edit/delete field definitions** — needs new `db::fields` update/delete functions and a + referential-integrity policy (block/handle deleting a field that objects reference, or + that is `required`). File a backend follow-up when this milestone lands. +- Per-field validation rules (min/max, length, regex) — already tracked as **#11**. +- Field reordering and group management (renaming/reordering groups). +- Changing a field's `key` or `type` after creation (immutable for now).