# 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).