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

197 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).