Files
biggus-dickus/docs/superpowers/specs/2026-06-04-fields-management-design.md
2026-06-04 13:35:18 +02:00

10 KiB
Raw Permalink Blame History

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)None422 (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).