feat: add multiselect field type (multi-value curated options) #131

Merged
dominik.polakovics merged 1 commit from afk/128 into main 2026-07-19 11:08:39 +02:00

Adds the tenth field type, multiselect — multiple values chosen from a curated options list — per the agent brief on the issue. First slice of the #77 stack; no news-bundle changes.

Engine

  • TypeMultiselect joins the field-type vocabulary. A new exported ParamSource (SourceElementParams / SourcePageTypeFields) replaces the string label on element.ParseParams, scoping the type: a page-type sidecar's fields: may declare it, an element sidecar declaring it fails to load with type "multiselect" is only allowed in page-type fields (shortcode params cannot hold lists).
  • options is required and follows select's exact rules (non-empty, unique, non-empty strings); an optional default: is a string list validated for membership and uniqueness (an empty default list is treated as absent, mirroring the harmless empty-string defaults).
  • ValidateValues gains a list arm: members must be strings among options, no duplicates; required = non-empty list, optional accepts empty. CoerceValues emits []string preserving order and drops an empty list, so clearing an optional multiselect deletes its front-matter key. The content layer already handled []string, so values land as native YAML string lists.
  • Settings read-back (pageops.fieldValue) converts a stored list member-tolerantly; a scalar or unconvertible value falls back to default/zero. Create-dialog seeding and default stamping covered by new tests.

Editor

  • New toggle-chip widget (one aria-pressed button per option) in the shared field-widget switch, styled on the existing pill vocabulary.
  • The client value plumbing widens from string | boolean to a shared FieldValue union carrying string[] across the values stores, payload builder, and wire types. Selections canonicalize to options order (unknown stored values dropped, duplicates collapsed), and dirty checks now compare through fieldValueEquals so arrays compare by value, not reference.
  • Empty optional selections omit the key from the save payload, matching the server's clear-deletes-key contract.

Docs

scaffold/site/AGENTS.md documents the type, its sidecar shape, and the page-type-fields-only restriction (drift test enforces the name); stale "eight/nine field types" counts in README.md and the package docs are refreshed to ten.

Verification

  • go test ./... green (with web/dist built), go vet ./... clean, gofmt clean on touched files; drift test TestScaffoldAgentsDocNamesEngineVocabulary passes.
  • Web: 973 tests across 50 files green including the new MultiselectField.test.tsx (chips render/pre-populate from scrambled server order, canonical payload, omit-when-empty, required blocks save, unknown values dropped, dirty tracking by array value); tsc --noEmit clean (CI does not typecheck); vite build succeeds.

Closes #128

🤖 Generated with Claude Code

Adds the tenth field type, `multiselect` — multiple values chosen from a curated `options` list — per the agent brief on the issue. First slice of the #77 stack; no news-bundle changes. ## Engine - `TypeMultiselect` joins the field-type vocabulary. A new exported `ParamSource` (`SourceElementParams` / `SourcePageTypeFields`) replaces the string label on `element.ParseParams`, scoping the type: a page-type sidecar's `fields:` may declare it, an element sidecar declaring it fails to load with `type "multiselect" is only allowed in page-type fields` (shortcode params cannot hold lists). - `options` is required and follows select's exact rules (non-empty, unique, non-empty strings); an optional `default:` is a string list validated for membership and uniqueness (an empty default list is treated as absent, mirroring the harmless empty-string defaults). - `ValidateValues` gains a list arm: members must be strings among `options`, no duplicates; required = non-empty list, optional accepts empty. `CoerceValues` emits `[]string` preserving order and drops an empty list, so clearing an optional multiselect deletes its front-matter key. The content layer already handled `[]string`, so values land as native YAML string lists. - Settings read-back (`pageops.fieldValue`) converts a stored list member-tolerantly; a scalar or unconvertible value falls back to default/zero. Create-dialog seeding and default stamping covered by new tests. ## Editor - New toggle-chip widget (one `aria-pressed` button per option) in the shared field-widget switch, styled on the existing pill vocabulary. - The client value plumbing widens from `string | boolean` to a shared `FieldValue` union carrying `string[]` across the values stores, payload builder, and wire types. Selections canonicalize to options order (unknown stored values dropped, duplicates collapsed), and dirty checks now compare through `fieldValueEquals` so arrays compare by value, not reference. - Empty optional selections omit the key from the save payload, matching the server's clear-deletes-key contract. ## Docs `scaffold/site/AGENTS.md` documents the type, its sidecar shape, and the page-type-fields-only restriction (drift test enforces the name); stale "eight/nine field types" counts in README.md and the package docs are refreshed to ten. ## Verification - `go test ./...` green (with web/dist built), `go vet ./...` clean, gofmt clean on touched files; drift test `TestScaffoldAgentsDocNamesEngineVocabulary` passes. - Web: 973 tests across 50 files green including the new `MultiselectField.test.tsx` (chips render/pre-populate from scrambled server order, canonical payload, omit-when-empty, required blocks save, unknown values dropped, dirty tracking by array value); `tsc --noEmit` clean (CI does not typecheck); `vite build` succeeds. Closes #128 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: add multiselect field type (multi-value curated options)
All checks were successful
ci / build-and-test (pull_request) Successful in 6m11s
8111d86673
Add a tenth field type, multiselect: multiple values chosen from a
curated options list, valid in page-type fields only. Engine side: a new
ParamSource on ParseParams scopes the vocabulary (an element sidecar
declaring multiselect fails to load — shortcode params cannot hold
lists), options follow select's rules, an optional list default is
validated against options, ValidateValues/CoerceValues gain list arms
(membership, uniqueness, required = non-empty; empty drops the key), and
the value round-trips settings read/save as a native YAML string list.

Editor side: a toggle-chip widget over the fixed options, the field
value plumbing widened from string | boolean to a shared FieldValue
union carrying string[], selections canonicalized to options order, and
dirty checks compare arrays by value.

Documents the type in the scaffolded AGENTS.md (drift-test enforced) and
refreshes stale field-type counts in README and package docs.

Closes #128

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Owner

This was generated by AI while landing a PR.

Landing audit — PASS

Verification signal relied on: Forgejo Actions CI build-and-testgreen, 6m11s (run 119). It runs unconditionally on every PR and covers npm ci / npm run build / npm test, then go vet ./..., go test ./..., go build. Not re-run.

Gap closed by hand: CI does not typecheck (npm test is a bare vitest run). Since the central move here is widening the client value union from string | boolean to FieldValue with string[], tsc --noEmit was run against the PR head in a detached worktree — clean under strict + noUnusedLocals + noUnusedParameters across all of web/src. The web suite was also re-run independently: 973 tests / 50 files green.

Conventions: title is Conventional Commits (feat:); head afk/128 carries a valid Closes #128. Branch is level with main and merges cleanly — no conflict resolution needed.

Engine review — no findings

  • Source scoping is airtight. ParseParams has exactly two call sites (SourceElementParams, SourcePageTypeFields), and element items: reuse another element schema, so multiselect cannot reach a shortcode param by any route.
  • The []any-only assumption in ValidateValues/CoerceValues is safe: the sole constructor of CreateParams is the HTTP server, so seeded fields are always JSON-decoded. Sidecar []string defaults bypass both and go straight to content.Set, which already handles string lists.
  • Empty-list semantics are consistent end to end — absent default, required rejects, optional clears the front-matter key — and mirror the existing empty-string convention rather than inventing a new one.
  • Docs obligation (CLAUDE.md) met: scaffold/site/AGENTS.md documents the type, its sidecar shape, and the fields-only restriction; stale field-type counts in README and package docs corrected.

Web review — no findings

  • Every as string cast in fields.tsx sits behind a param.type guard a multiselect cannot reach; the multiselect arms continue/Match first.
  • All three baseline-comparison sites (FormPane.tsx:266, FormPane.tsx:364, PageSettings.tsx:152) were converted to fieldValueEquals; no fourth site exists. The aliasing hazard in setFieldsBaseline({ ...values }) is not reachable — solid-js updatePath excludes arrays from mergeStoreNode and takes the replace path, so the baseline array is never mutated in place.
  • Required validation judges canonicalMultiselectValue(...).length === 0, not !value, so an empty array correctly blocks save. Verified live in the create-dialog surface too, which the PR does not cover with tests.
  • canonicalMultiselectValue derives order from options, collapses duplicates by construction, drops unknowns, and always returns a fresh array.
  • The omit-when-empty arm ends in continue; the bool/number/string/link arms are byte-identical to main, so no other field key can be dropped.

Non-blocking observations

  1. internal/element/doc.go:48 still reads options: … # required for type select; forbidden otherwise without naming multiselect. The prose two paragraphs below is correct; only the inline comment is stale.
  2. A stored front-matter value outside options is dropped silently, and the next save removes it. That is the stated contract, but the server chose the opposite remedy for the analogous stale-backendLayout case — splicing the stale value into Options so it stays keepable (internal/pageops/settings.go:263-287). Worth a conscious decision on the follow-up slices rather than a divergence by accident.

Neither blocks the merge.

> *This was generated by AI while landing a PR.* ## Landing audit — PASS **Verification signal relied on:** Forgejo Actions CI `build-and-test` — **green**, 6m11s ([run 119](/Cloonar/deckle/actions/runs/119/jobs/0)). It runs unconditionally on every PR and covers `npm ci` / `npm run build` / `npm test`, then `go vet ./...`, `go test ./...`, `go build`. Not re-run. **Gap closed by hand:** CI does *not* typecheck (`npm test` is a bare `vitest run`). Since the central move here is widening the client value union from `string | boolean` to `FieldValue` with `string[]`, `tsc --noEmit` was run against the PR head in a detached worktree — **clean** under `strict` + `noUnusedLocals` + `noUnusedParameters` across all of `web/src`. The web suite was also re-run independently: 973 tests / 50 files green. **Conventions:** title is Conventional Commits (`feat:`); head `afk/128` carries a valid `Closes #128`. Branch is level with `main` and merges cleanly — no conflict resolution needed. ### Engine review — no findings - Source scoping is airtight. `ParseParams` has exactly two call sites (`SourceElementParams`, `SourcePageTypeFields`), and element `items:` reuse another *element* schema, so `multiselect` cannot reach a shortcode param by any route. - The `[]any`-only assumption in `ValidateValues`/`CoerceValues` is safe: the sole constructor of `CreateParams` is the HTTP server, so seeded fields are always JSON-decoded. Sidecar `[]string` defaults bypass both and go straight to `content.Set`, which already handles string lists. - Empty-list semantics are consistent end to end — absent default, required rejects, optional clears the front-matter key — and mirror the existing empty-string convention rather than inventing a new one. - Docs obligation (CLAUDE.md) met: `scaffold/site/AGENTS.md` documents the type, its sidecar shape, and the fields-only restriction; stale field-type counts in README and package docs corrected. ### Web review — no findings - Every `as string` cast in `fields.tsx` sits behind a `param.type` guard a multiselect cannot reach; the multiselect arms `continue`/`Match` first. - All three baseline-comparison sites (`FormPane.tsx:266`, `FormPane.tsx:364`, `PageSettings.tsx:152`) were converted to `fieldValueEquals`; no fourth site exists. The aliasing hazard in `setFieldsBaseline({ ...values })` is not reachable — solid-js `updatePath` excludes arrays from `mergeStoreNode` and takes the replace path, so the baseline array is never mutated in place. - Required validation judges `canonicalMultiselectValue(...).length === 0`, not `!value`, so an empty array correctly blocks save. Verified live in the create-dialog surface too, which the PR does not cover with tests. - `canonicalMultiselectValue` derives order from `options`, collapses duplicates by construction, drops unknowns, and always returns a fresh array. - The omit-when-empty arm ends in `continue`; the bool/number/string/link arms are byte-identical to `main`, so no other field key can be dropped. ### Non-blocking observations 1. `internal/element/doc.go:48` still reads `options: … # required for type select; forbidden otherwise` without naming multiselect. The prose two paragraphs below is correct; only the inline comment is stale. 2. A stored front-matter value outside `options` is dropped silently, and the next save removes it. That is the stated contract, but the server chose the *opposite* remedy for the analogous stale-`backendLayout` case — splicing the stale value into `Options` so it stays keepable (`internal/pageops/settings.go:263-287`). Worth a conscious decision on the follow-up slices rather than a divergence by accident. Neither blocks the merge.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/deckle!131
No description provided.