# 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<AuthorityKind> }`.
- `FieldType::from_parts(data_type: &str, vocabulary_id: Option<VocabularyId>, authority_kind: Option<AuthorityKind>) -> Option<FieldType>`
  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<String>, labels: Vec<LocalizedLabel> }`.
- `db::fields::create_field_definition(conn: &mut PgConnection, new: &NewFieldDefinition) -> Result<FieldDefinitionId, sqlx::Error>`
  (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<String>,     // required iff data_type == "term"
  authority_kind: Option<String>,    // person|organisation|place; only for "authority" (optional)
  required: bool,
  group: Option<String>,
  labels: Vec<LabelInput>,           // { 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).
