feat: add multiselect field type (multi-value curated options) #131
No reviewers
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/deckle!131
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "afk/128"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Adds the tenth field type,
multiselect— multiple values chosen from a curatedoptionslist — per the agent brief on the issue. First slice of the #77 stack; no news-bundle changes.Engine
TypeMultiselectjoins the field-type vocabulary. A new exportedParamSource(SourceElementParams/SourcePageTypeFields) replaces the string label onelement.ParseParams, scoping the type: a page-type sidecar'sfields:may declare it, an element sidecar declaring it fails to load withtype "multiselect" is only allowed in page-type fields(shortcode params cannot hold lists).optionsis required and follows select's exact rules (non-empty, unique, non-empty strings); an optionaldefault:is a string list validated for membership and uniqueness (an empty default list is treated as absent, mirroring the harmless empty-string defaults).ValidateValuesgains a list arm: members must be strings amongoptions, no duplicates; required = non-empty list, optional accepts empty.CoerceValuesemits[]stringpreserving 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.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
aria-pressedbutton per option) in the shared field-widget switch, styled on the existing pill vocabulary.string | booleanto a sharedFieldValueunion carryingstring[]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 throughfieldValueEqualsso arrays compare by value, not reference.Docs
scaffold/site/AGENTS.mddocuments 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 testTestScaffoldAgentsDocNamesEngineVocabularypasses.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 --noEmitclean (CI does not typecheck);vite buildsucceeds.Closes #128
🤖 Generated with Claude Code
Landing audit — PASS
Verification signal relied on: Forgejo Actions CI
build-and-test— green, 6m11s (run 119). It runs unconditionally on every PR and coversnpm ci/npm run build/npm test, thengo vet ./...,go test ./...,go build. Not re-run.Gap closed by hand: CI does not typecheck (
npm testis a barevitest run). Since the central move here is widening the client value union fromstring | booleantoFieldValuewithstring[],tsc --noEmitwas run against the PR head in a detached worktree — clean understrict+noUnusedLocals+noUnusedParametersacross all ofweb/src. The web suite was also re-run independently: 973 tests / 50 files green.Conventions: title is Conventional Commits (
feat:); headafk/128carries a validCloses #128. Branch is level withmainand merges cleanly — no conflict resolution needed.Engine review — no findings
ParseParamshas exactly two call sites (SourceElementParams,SourcePageTypeFields), and elementitems:reuse another element schema, somultiselectcannot reach a shortcode param by any route.[]any-only assumption inValidateValues/CoerceValuesis safe: the sole constructor ofCreateParamsis the HTTP server, so seeded fields are always JSON-decoded. Sidecar[]stringdefaults bypass both and go straight tocontent.Set, which already handles string lists.scaffold/site/AGENTS.mddocuments the type, its sidecar shape, and the fields-only restriction; stale field-type counts in README and package docs corrected.Web review — no findings
as stringcast infields.tsxsits behind aparam.typeguard a multiselect cannot reach; the multiselect armscontinue/Matchfirst.FormPane.tsx:266,FormPane.tsx:364,PageSettings.tsx:152) were converted tofieldValueEquals; no fourth site exists. The aliasing hazard insetFieldsBaseline({ ...values })is not reachable — solid-jsupdatePathexcludes arrays frommergeStoreNodeand takes the replace path, so the baseline array is never mutated in place.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.canonicalMultiselectValuederives order fromoptions, collapses duplicates by construction, drops unknowns, and always returns a fresh array.continue; the bool/number/string/link arms are byte-identical tomain, so no other field key can be dropped.Non-blocking observations
internal/element/doc.go:48still readsoptions: … # required for type select; forbidden otherwisewithout naming multiselect. The prose two paragraphs below is correct; only the inline comment is stale.optionsis dropped silently, and the next save removes it. That is the stated contract, but the server chose the opposite remedy for the analogous stale-backendLayoutcase — splicing the stale value intoOptionsso 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.