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:
@@ -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<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).
|
||||
Reference in New Issue
Block a user