Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
10 KiB
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-definitionsover the existingdb::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
:idroute (definitions are create+list only and identified bykey). - Create endpoint capability =
EditCatalogue(matches other catalogue writes; the existing GET usesViewInternal). - 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 returnsNonefor any inconsistent combination — a scalar carrying a binding, atermwithout a vocabulary (or with an authority kind), anauthoritycarrying 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:
- Parse
vocabulary_id(if present) toVocabularyId(→ 400 on malformed UUID) andauthority_kind(if present) toAuthorityKindby matchingperson|organisation|place(→ 400 otherwise). 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).- Build
NewFieldDefinition, runcreate_field_definitioninside a transaction, commit. - On a unique-violation (duplicate
key, Postgres SQLSTATE 23505) ⇒ 409; other db errors ⇒ 500. - Return
201 { key }(a smallCreatedField { 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 fromuseVocabularies(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:
keynon-empty (trimmed); EN label required (theLabelEditorguard); 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.rejectedalert. - 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; atermfield with a validvocabulary_id→ 201;termwithoutvocabulary_id→ 422; duplicatekey→ 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(+ afieldDefinitionsGET fixture with at least one grouped and one ungrouped entry). - Tests: list renders, grouped (incl. the "Other" group); creating a
textfield 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
/fieldswithReact.lazy+Suspense— as M2 did for the object forms — if it pushes over, then re-verify).
Acceptance criteria
POST /api/admin/field-definitionscreates definitions of all representative types, reusesFieldType::from_partsfor consistency validation (term/authority), isEditCatalogue-gated, returns 409 on duplicate key and 422 on inconsistent type/config.- 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). - A newly created field appears in the Fields list and in the M2 object authoring
form (shared
["field-definitions"]invalidation). - EN-required and term-needs-vocabulary client validation; backend 409/422 surfaced as a form-level error.
- Web + backend CI green (cargo test; web typecheck/lint/test/build, bundle ≤150 KB gz); en/sv parity.
- After this milestone, the app shell has no disabled nav stubs.
Out of scope / follow-ups
- Edit/delete field definitions — needs new
db::fieldsupdate/delete functions and a referential-integrity policy (block/handle deleting a field that objects reference, or that isrequired). 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
keyortypeafter creation (immutable for now).