docs(specs): fields management — POST field-definitions + /fields two-pane UI

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 13:35:18 +02:00
parent 2d0b76ab34
commit 19408f6282
@@ -0,0 +1,196 @@
# Fields Management — Design
**Date:** 2026-06-04
**Status:** Approved (brainstorming) — ready for implementation planning.
## Context
Milestones 15 (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).