Files
member-console/internal/forms/forms.go
T
cgalo5758 f8a3478f2a Rebuild the entitlement set Rules surface as a staged batch
The Rules section is one record table grouped by kind, Limit then
Boolean, on fixed columns, edited in place: Edit opens a row's controls
in their columns, Add rule opens a dense row above the table, and every
change is staged into a tray that lists the deltas with Undo and applies
them as one rule-change act. The reduction policy is a column of the
rule beside its limit. History shows counts only. Group rows are a quiet
heading rather than a divider, the maintainer's pick from four rounds of
outside-model ideation.

Dense rows align to the top and render each error under its control in
every form family (design D16), replacing the below-row error block; the
forms library gains the batch form (rows plus one tray) and the RowField
dense and label-hidden options. Migration 00019 records the governing
reduction policy on effect rows.

Archive staged-rule-changes with its spec updates (entitlement-set-
management, entitlement-set-history, entitlements, form-library,
form-conventions, ui-quality-gate). Screens accepted 2026-09-19.
2026-09-19 19:46:09 -05:00

820 lines
32 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// Package forms is the console's form library (spec form-library; design
// D1 to D14 of the forms-library change). Every form on the member and
// operator surfaces, and on an integration-owned page, is one declared
// value: a FormSpec with a name, a kind, a layout family, a method, a
// path, an ordered list of Field values, a commit label and a way out.
// One part renders the declaration, one Parse reads a request through it,
// and one registry indexes every declaration so tests can hold the whole
// console to the same rules.
//
// The package sits below internal/server and below the integrations' web
// packages so both can import it without a cycle; it knows nothing about
// handlers, databases or the domain. Each form is declared in the package
// that serves it, in a *_forms.go file beside the handler, the way
// anatomy.go builds page headers per page, and registers itself with
// Register. The registry is the index, not the home.
//
// The option set is small on purpose (Primer's side of the tradeoff):
// accessible and consistent forms by default, customisation limited so a
// caller cannot render a control without its label, its hint wiring or its
// error slot. A form that cannot be expressed here is a change to the
// library or a recorded exception, never a hand-built form; the one escape
// hatch is the data-* and hx-* attribute map, which cannot override an
// attribute the part owns.
package forms
// Control is the closed set of controls the library renders and reads. A
// form that needs a control this set lacks needs a change to the library,
// with its rendering and its reading added together, not an improvised
// template (lessons L§12, L§26).
type Control string
const (
Text Control = "text"
Textarea Control = "textarea"
Number Control = "number"
Email Control = "email"
URL Control = "url"
Date Control = "date"
DateTime Control = "datetime"
Select Control = "select"
Checkbox Control = "checkbox"
Radio Control = "radio"
// Hidden carries an identifier a sub-record form needs (a parent id).
// A hidden field is parsed only because it is declared, and the
// handler must still check it against the route: the route is the
// authority, the field is a convenience (finding FA-10).
Hidden Control = "hidden"
// Static is a row that states a fact instead of offering a control: it
// renders Field.Value as muted text where the control would be, emits
// no name, and is never parsed. It exists for the Table family, whose
// rows are the whole settings surface and must therefore hold the keys
// an operator cannot set here at all (a secret key, set in the
// environment): one table, one Save, no second table below it
// (design D21).
Static Control = "static"
)
// InputType is the type attribute the control renders with, for the
// controls that are <input> elements. Empty for the three that are not.
func (c Control) InputType() string {
switch c {
case Text:
return "text"
case Number:
return "number"
case Email:
return "email"
case URL:
return "url"
case Date:
return "date"
case DateTime:
return "datetime-local"
case Checkbox:
return "checkbox"
case Radio:
return "radio"
case Hidden:
return "hidden"
}
return ""
}
// Valid reports whether c is one of the declared controls.
func (c Control) Valid() bool {
switch c {
case Text, Textarea, Number, Email, URL, Date, DateTime, Select, Checkbox, Radio, Hidden, Static:
return true
}
return false
}
// Kind decides a form's outcome contract (design D9) and what the capture
// utility does with it (design D16): create, edit, sub-record and settings
// forms are submitted empty for the refusal capture; search, preview and
// confirm forms are never submitted, because they navigate or mutate.
type Kind string
const (
KindCreate Kind = "create"
KindEdit Kind = "edit"
KindSubRecord Kind = "sub-record"
KindSettings Kind = "settings"
KindSearch Kind = "search"
KindPreview Kind = "preview"
KindConfirm Kind = "confirm"
// KindBatch is a table of records edited in place: the declaration's
// fields are one row's, the caller's table is the form's body, and a
// staged batch of changes is applied by one commit in a tray under the
// table (design D1, staged-rule-changes). It is never submitted empty
// by the capture utility, because its commit applies a batch that an
// empty submission does not have.
KindBatch Kind = "batch"
)
// Valid reports whether k is one of the declared kinds.
func (k Kind) Valid() bool {
switch k {
case KindCreate, KindEdit, KindSubRecord, KindSettings, KindSearch, KindPreview, KindConfirm, KindBatch:
return true
}
return false
}
// Submittable reports whether the capture utility submits a form of this
// kind empty to photograph its refusal (design D16).
func (k Kind) Submittable() bool {
switch k {
case KindCreate, KindEdit, KindSubRecord, KindSettings:
return true
}
return false
}
// ServerValidated reports whether the form's refusal is the server's, and
// only the server's. The part emits `novalidate` on these, so pressing the
// commit with an empty required field reaches the handler and swaps back
// the 422 with the message under the control, rather than stopping at the
// browser's transient bubble, which cannot be captured, cannot be styled,
// disappears on the next keystroke, and says nothing the server's own
// message does not (GOV.UK Frontend does the same). The constraint
// attributes stay on the controls for assistive technology and for
// :invalid styling; they are a convenience, never the control.
//
// A Search form keeps the browser's validation, because it navigates
// rather than refusing, and a Confirm form has nothing to validate.
func (k Kind) ServerValidated() bool {
switch k {
case KindCreate, KindEdit, KindSubRecord, KindSettings, KindPreview, KindBatch:
return true
}
return false
}
// Family is the layout, of which there are five and no sixth (design D10,
// D20, D21; D1 of staged-rule-changes added the fifth). Kind says what a
// form is for; family says how it is laid out, and the pairing is fixed:
// the declaration names both so the registry table and the docs read the
// same way, but the choice is not the declaring file's to make (FamilyFor,
// and the invariants that hold every declaration to it).
//
// - Stacked: one control per row, labels above, in a section box capped
// at a readable width; create pages, edit forms, previews, the confirm
// modal.
// - Dense: one row of small controls with the commit at its end;
// sub-record panels only.
// - Bar: one input group with a leading magnifier, the control, the
// outline commit and the way out; searches only. Round 3 forced a
// search into Stacked and every list page rendered a narrow input with
// its button wrapped onto the next line (maintainer, 2026-09-04).
// - Table: one table row per field, the label and its hint in the first
// column, the page's own pre-rendered cells next, the control last;
// settings only. Round 3 forced settings into Stacked and both
// settings pages lost the effective value, the source and the status
// the table had carried.
// - Rows: the caller's own table of records as the form's body, the
// declaration's controls rendered into the caller's cells through the
// field part, and one tray under the table at the body's width holding
// the staged batch and the commit; batch forms only. A Table's rows are
// fields, one per row; a Rows body's rows are records, each carrying
// several of the declaration's fields.
type Family string
const (
Stacked Family = "stacked"
Dense Family = "dense"
Bar Family = "bar"
Table Family = "table"
Rows Family = "rows"
)
// Valid reports whether f is one of the five families.
func (f Family) Valid() bool {
switch f {
case Stacked, Dense, Bar, Table, Rows:
return true
}
return false
}
// FamilyFor is the family a kind is laid out in. The pairing is fixed, and
// CheckInvariants refuses any declaration that names a different one
// (design D10 as corrected in round 4).
func FamilyFor(k Kind) Family {
switch k {
case KindSearch:
return Bar
case KindSettings:
return Table
case KindSubRecord:
return Dense
case KindBatch:
return Rows
}
return Stacked
}
// Placement is where in a Rows form a field renders (design D1's "Rows
// family contract"). It is meaningless in every other family, and the
// invariants refuse anything but the zero value outside a batch form.
type Placement string
const (
// PlaceRow is the zero value: a field of one record's row, rendered by
// the caller's own body through FormView.RowField.
PlaceRow Placement = ""
// PlaceDelta renders the field once per staged delta, on that delta's
// tray line, with the delta's index as its instance: a decision whose
// consequence is in view only once the change is staged (the rule
// change's reduction policy).
PlaceDelta Placement = "delta"
// PlaceTray renders the field once in the tray, before the commit: the
// batch's own field rather than any record's (the note).
PlaceTray Placement = "tray"
)
// Valid reports whether p is one of the declared placements.
func (p Placement) Valid() bool {
switch p {
case PlaceRow, PlaceDelta, PlaceTray:
return true
}
return false
}
// Side names the render side a field exists on. A field that exists on one
// side only declares that side and a reason, and the part renders the
// reason as one short line at the field on the side where it exists
// (design D8; spec form-conventions "Create and edit forms agree or
// disclose their difference").
type Side string
const (
// BothSides is the zero value: the field renders in every mode.
BothSides Side = ""
// CreateOnly renders on the create page (unbound mode) only.
CreateOnly Side = "create"
// EditOnly renders on the edit form (record-bound mode) only.
EditOnly Side = "edit"
)
// Valid reports whether s is one of the declared sides.
func (s Side) Valid() bool {
switch s {
case BothSides, CreateOnly, EditOnly:
return true
}
return false
}
// Fraction is a field's column in a dense row: one of five fractions of
// the twelve-column grid that divide it evenly, held as an ordered ladder
// from FractionSixth to FractionFull (design D1). The zero value,
// FractionNone, is not a rung on the ladder: it names a control with
// nothing to step from (a hidden field's no column; a checkbox's, a radio
// group's and a static value's own content).
type Fraction int
const (
FractionNone Fraction = iota
FractionSixth
FractionQuarter
FractionThird
FractionHalf
FractionFull
)
// Fraction is the control's natural rung: the lowest fraction that holds
// its widest ordinary value at a laptop width, where one twelfth of the
// content column is about 80px (design D2). Checkbox, Radio and Static are
// their own content, not a fraction of the row; Hidden takes no column at
// all, which is why both resolve to the zero value here and are handled by
// control alone wherever that distinction matters.
func (c Control) Fraction() Fraction {
switch c {
case Number, Date:
return FractionSixth
case DateTime:
return FractionQuarter
case Select, Text, Email, URL:
return FractionThird
case Textarea:
return FractionFull
}
return FractionNone
}
// Width steps a field's column one rung from its control's natural
// fraction (design D2); ignored in every family but Dense. The zero value
// means the control's own rung, and no declaration writes any other
// constant for it: a declaration sets Width only where the field's values
// are shorter or longer than its control's usual ones.
type Width string
const (
// WidthNarrower moves the field's column one rung down the ladder.
WidthNarrower Width = "narrower"
// WidthWider moves the field's column one rung up the ladder.
WidthWider Width = "wider"
)
// Valid reports whether w is one of the declared widths, the zero value
// included.
func (w Width) Valid() bool {
switch w {
case "", WidthNarrower, WidthWider:
return true
}
return false
}
// FieldFraction resolves a field's rung from its control's natural
// fraction and its declared Width, and reports whether the result stays on
// the ladder (design D2). ok is false when c has nothing to step from
// (hidden, checkbox, radio, static and textarea all stand on no rung for
// this purpose, a textarea's full row being the control's own rather than
// a step), when a narrower step would leave the ladder below a sixth, or
// when a wider step would reach a full row.
func FieldFraction(c Control, w Width) (fraction Fraction, ok bool) {
natural := c.Fraction()
if w == "" {
return natural, true
}
if natural < FractionSixth || natural > FractionHalf {
return natural, false
}
return stepFraction(natural, w)
}
// stepFraction moves natural one rung by w and reports whether the result
// stays on the ladder: a narrower step below a sixth or a wider step above
// a half leaves it (design D2). Split out from FieldFraction so the
// ladder's two edges are each one comparison, testable on their own.
func stepFraction(natural Fraction, w Width) (Fraction, bool) {
switch w {
case WidthNarrower:
if natural <= FractionSixth {
return natural, false
}
return natural - 1, true
case WidthWider:
if natural >= FractionHalf {
return natural, false
}
return natural + 1, true
}
return natural, false
}
// ColClass is the dense row's grid class for this fraction (design D3,
// D5): below the extra-large breakpoint every field takes its own line,
// and the fractions apply from 1200px, where the content column beside
// the sidebar is wide enough for them. The zero value renders no class; a
// content column (checkbox, radio, static) is col-12 col-xl-auto instead,
// decided by control rather than by fraction.
func (fr Fraction) ColClass() string {
switch fr {
case FractionSixth:
return "col-12 col-xl-2"
case FractionQuarter:
return "col-12 col-xl-3"
case FractionThird:
return "col-12 col-xl-4"
case FractionHalf:
return "col-12 col-xl-6"
case FractionFull:
return "col-12"
}
return ""
}
// WayOutKind names how a form is left without submitting it.
type WayOutKind string
const (
// WayOutNone is an always-open edit form: nothing is left by
// cancelling, so no control is offered (finding FA-19).
WayOutNone WayOutKind = ""
// WayOutLink navigates away, typically a create page back to its list.
WayOutLink WayOutKind = "link"
// WayOutClosePanel collapses the sub-record panel the form sits in.
WayOutClosePanel WayOutKind = "close-panel"
// WayOutDismissModal closes the confirm modal the form sits in.
WayOutDismissModal WayOutKind = "dismiss-modal"
// WayOutDiscard re-fetches the form's own resting state with a GET to
// URL and swaps it into the same target the form itself posts to,
// carving that one region out of the GET's full-page response with the
// same selector (design D10's preview idiom: a preview writes nothing,
// so "Discard" has nothing to undo, only a pending choice to drop). Use
// this over WayOutLink when the form renders more than once on its
// page, so discarding one instance leaves its siblings alone.
WayOutDiscard WayOutKind = "discard"
)
// WayOut is a form's way out. It renders btn-outline-secondary after the
// commit; solid btn-secondary is not in the console's palette (design D12).
type WayOut struct {
Kind WayOutKind
Label string
// URL is the destination for WayOutLink, and the GET source for
// WayOutDiscard.
URL string
// PanelID is the collapse element the WayOutClosePanel button closes.
PanelID string
// HXTarget and HXSwap turn a WayOutLink into a scoped htmx GET instead
// of a plain navigation: the response at URL is swapped into HXTarget
// with HXSwap (default "innerHTML") rather than the browser navigating.
// For a form whose container is itself an htmx-swapped region, not a
// routable page (the workspace create panel's "Back" to the list, the
// member plan-move preview's "Keep current plan"), a plain <a href>
// would fall through to the page's own hx-boost and swap the wrong
// region. Both empty renders a plain <a href>, which is what a create
// page's Cancel to its own list needs.
HXTarget string
HXSwap string
}
// NoWayOut is the always-open edit form's way out: none.
func NoWayOut() WayOut { return WayOut{} }
// LinkOut leaves the form by navigating, the create page's Cancel.
func LinkOut(label, url string) WayOut {
return WayOut{Kind: WayOutLink, Label: label, URL: url}
}
// ScopedLinkOut leaves the form by a scoped htmx GET whose response swaps
// into target, for a way out that stays inside the htmx-swapped region the
// form itself occupies rather than navigating the page.
func ScopedLinkOut(label, url, target string) WayOut {
return WayOut{Kind: WayOutLink, Label: label, URL: url, HXTarget: target, HXSwap: "innerHTML"}
}
// ClosePanel leaves the form by collapsing the panel it sits in.
func ClosePanel(label, panelID string) WayOut {
return WayOut{Kind: WayOutClosePanel, Label: label, PanelID: panelID}
}
// DismissModal leaves the form by closing the modal it sits in.
func DismissModal(label string) WayOut {
return WayOut{Kind: WayOutDismissModal, Label: label}
}
// Discard leaves a preview form by re-fetching its resting state with a GET
// to url and swapping the same target the form posts to, carving that one
// region out of the GET's full-page response (design D10's preview idiom).
func Discard(label, url string) WayOut {
return WayOut{Kind: WayOutDiscard, Label: label, URL: url}
}
// Option is one entry of a select or a radio group.
type Option struct {
Value string
Label string
Disabled bool
}
// ChooseOption is the first option of a select whose value must be chosen:
// disabled, selected, empty-valued, so the form cannot submit a value the
// person never chose (spec form-conventions "A select that needs a choice
// opens with a placeholder option"). thing completes "Choose ...", so pass
// "an entitlement set" or "a product".
func ChooseOption(thing string) Option {
return Option{Value: "", Label: "Choose " + thing, Disabled: true}
}
// NoneOption is the first option of an optional select: an empty value the
// person may deliberately choose, which is how a select unsets a column.
func NoneOption() Option {
return Option{Value: "", Label: "None"}
}
// HelpIcon is the click-opened popover the shared helpIcon part renders
// after a field's label, as its sibling and never inside the label
// (findings FA-37, FA-50). Its field names match the part's pipeline, so a
// forms.HelpIcon is passed to {{ template "helpIcon" ... }} directly.
type HelpIcon struct {
Label string
Text string
}
// Help builds a field's help popover.
func Help(label, text string) HelpIcon { return HelpIcon{Label: label, Text: text} }
// Set reports whether the field carries help.
func (h HelpIcon) Set() bool { return h.Text != "" }
// Field is one control's whole declaration: what it submits, what it says,
// how it is read, and where it exists. Nothing about a field lives in a
// template, and nothing a template would need is left undeclared.
type Field struct {
// Name is the submitted name. It is stable and never prefixed: the
// data-form id namespaces the DOM ids instead.
Name string
// Label is the visible label. Every control has one; in a dense row
// with no room it renders visually hidden, never absent (FA-51).
Label string
// HideLabel renders the label visually hidden, which the part honours
// in a dense row and in a bar only, the two families with no room for
// one. An aria-label never substitutes for a label.
HideLabel bool
// Control is the control this field renders and is read as.
Control Control
// Options are a select's or a radio group's entries. A form whose
// options are loaded per request declares RuntimeOptions instead and
// supplies them at render and at parse.
Options []Option
// RuntimeOptions declares that this select's or radio group's options
// come from the request (an entitlement-set picker, a product picker),
// so the invariants accept an empty Options and Parse is given the set
// through ParseWith.
RuntimeOptions bool
// Hint is one short line under the control. It survives an error
// (GOV.UK's side of the split; findings FA-48, FA-49). A field carries
// a Hint or a Notice, never both: one visible line under a control is
// the whole budget, and a second line reads as clutter (maintainer,
// 2026-09-03: "Can we remove all additional obvious text that doesn't
// need to be there?"). A dense row carries neither; its explanation
// goes behind the help icon.
Hint string
// Notice is the one-sidedness a person would otherwise be surprised
// by, as one line under the control: a create-only value that cannot
// be changed later, say. It is not the default for a one-sided field.
// Most differences are self-explanatory once the field is simply
// absent from the other side, and saying so out loud is the
// superfluous text the maintainer struck (2026-09-03).
Notice string
// Help is the click popover after the label.
Help HelpIcon
// Placeholder is a format example only, never a label, a state or an
// instruction (finding FA-35). The invariants refuse a sentence.
Placeholder string
// Optional marks the label with the "(optional)" suffix and lets an
// empty value through. On a select, requiredness is carried by the
// option set instead: an empty value is accepted exactly when an
// option with an empty value exists, so a select's Optional only
// governs the label's marker. On a radio group, by contrast, Optional
// governs the required attribute and Parse's requiredness check
// directly, like every other control except Select: a radio group has
// no "choose nothing" member the way a Select's enabled empty option
// does, so there is no equivalent convention available to carry the
// fact (fieldAttrs and the Select/Radio membership check in parse.go
// both give Radio its own case for this reason).
Optional bool
// MaxLen caps the value's length; it renders as maxlength and Parse
// refuses a longer value, so the browser's rule and the server's rule
// cannot differ.
MaxLen int
// Min and Max bound a number, a date or a date-time. They render as
// the min and max attributes and Parse applies them.
Min, Max string
// Step is a Number field's granularity ("0.01" for a currency amount),
// rendered as the step attribute. Parse does not apply it: a step
// mismatch is cosmetic (the constraint attribute is a convenience,
// never the control, design D2), and the amounts this library reads
// are already integer-cents or otherwise range-checked by the
// handler's own domain rule.
Step string
// Pattern is a regular expression the value must match, rendered as
// the pattern attribute and applied by Parse. PatternHint is the
// refusal's message, because a regular expression is not an error
// message.
Pattern string
PatternHint string
// Inputmode is the virtual keyboard hint (numeric, decimal, email).
Inputmode string
// Autocomplete is a real autofill token (name, email, url,
// organization) or "off" for keys, search boxes and one-time values.
// The part emits it from here and from nowhere else, and never writes
// it on the form tag (finding FA-52).
Autocomplete string
// Rows is a textarea's height in lines; 3 when unset.
Rows int
// Value is a checkbox's submitted value when it is ticked ("public",
// "true"). "true" when unset. A checkbox's absence always reads as
// false, and the invariants refuse a required checkbox. On a Static
// field it is instead the sentence the row states where its control
// would be, and nothing is submitted at all.
Value string
// Width steps the field's column one rung from its control's natural
// fraction (design D2); ignored when stacked. The zero value is the
// control's own rung.
Width Width
// Only names the side this field exists on. It carries no
// justification string: no surveyed framework does (Django's
// add_fieldsets and Filament's hiddenOn declare the side and nothing
// else), and a reason nothing renders and nothing reads is noise in
// the registry (maintainer, 2026-09-04). Why a field is one-sided is
// a Go comment beside the declaration, like any other comment; what a
// person needs to be told is a Notice.
Only Side
// Slot renders an empty container after the control, with the id
// "<wrapper id>-<Slot>", for a region a scoped htmx request swaps into
// (the product name's duplicate warning). The render supplies its
// first content through Binding.Slots.
Slot string
// ShowIf makes this field's rendering and parsing conditional on
// another field's bound value (the entitlement-set rule form's Limit
// and "Per unit" fields depend on the selected resource key's kind;
// design D2, entitlement-set-management "the kind-dependent shape
// SHALL be a declared field set"). The zero value
// always shows. The condition reads Values, not the database: a
// handler that derives visibility from data the library cannot see
// (a resource key's kind) writes that derived value into a Values
// entry itself, typically a Hidden field the render fills in, so the
// same submitted state governs the next render and the next parse
// (see ShowIf's doc for the trust model).
ShowIf ShowIf
// Placement is where a Rows form renders this field: with the record's
// row (the zero value, rendered by the caller's body), on a staged
// delta's tray line, or once in the tray before the commit. Ignored,
// and refused by the invariants, in every other family.
Placement Placement
// Attrs is the one escape hatch: data-* and hx-* keys only, and never
// a key the part owns. Anything else fails the invariants.
Attrs map[string]string
}
// ShowIf names another declared field and the raw values it must carry for
// this field to render and be parsed. It is the library's answer to "the
// field set depends on a value" (design D2): a small, general mechanism,
// not a rule form's private hack, so any future form with the same shape
// (a picker whose choice reveals different fields) can declare it too.
//
// The condition is checked against Values, which Parse builds field by
// field in declaration order: a field referenced by another field's ShowIf
// must therefore be declared earlier in Fields. The referenced field need
// not itself be visible to the operator; a Hidden field the render sets
// from server-derived data (never from user choice) is the common case,
// and the trust model follows from that: whatever governs visibility is
// exactly what was rendered, so it is self-consistent across a refused
// resubmission, but it is never authoritative for a domain decision (the
// entitlement-set rule's actual type is still re-derived from the
// database, never read back from this field) because the value did
// round-trip through the client.
type ShowIf struct {
// Field is the name of the field this condition reads.
Field string
// Equals is the set of that field's raw values for which this field
// is shown; empty Field means always shown.
Equals []string
}
// set reports whether the condition is declared at all.
func (s ShowIf) set() bool { return s.Field != "" }
// matches reports whether raw is one of the condition's accepted values.
func (s ShowIf) matches(raw string) bool {
for _, v := range s.Equals {
if v == raw {
return true
}
}
return false
}
// Shown reports whether f renders and is parsed, given the values built so
// far. A field with no ShowIf is always shown.
func (f Field) Shown(values Values) bool {
if !f.ShowIf.set() {
return true
}
return f.ShowIf.matches(values.Raw(f.ShowIf.Field))
}
// On returns a copy of f declared for one side, so a shared field value
// can be included by reference and still exist on one side only
// (design D3, D8).
func (f Field) On(side Side) Field {
f.Only = side
return f
}
// WithHint returns a copy of f carrying hint, for the rare case where one
// shared field value needs a different line in one form. A different rule
// is a different field with a different name, never a loosened copy.
func (f Field) WithHint(hint string) Field {
f.Hint = hint
return f
}
// RendersOn reports whether the field renders in the given mode. A
// submission re-renders the side it was submitted from, which the binding
// carries.
func (f Field) RendersOn(side Side) bool {
return f.Only == BothSides || f.Only == side
}
// CheckboxValue is the value a ticked checkbox submits.
func (f Field) CheckboxValue() string {
if f.Value != "" {
return f.Value
}
return "true"
}
// FormSpec is one form. It is declared once, rendered in three modes, and
// parsed through in the handler, so a field the handler reads and no
// template renders cannot exist.
type FormSpec struct {
// Name is the data-form id and the registry key, unique across the
// console, e.g. "operator.product".
Name string
// Kind decides the outcome contract and the capture behaviour.
Kind Kind
// Family decides the layout.
Family Family
// Method and Path are the route the form submits to. For a form whose
// create and edit sides are different routes, Path is the create
// route and EditMethod and EditPath are the record's.
Method string
Path string
// EditMethod and EditPath are the record-bound side's route when it
// differs from Method and Path (a create form posts to the
// collection, an edit form puts to the record). Empty when the form
// has one route. Both are route patterns; the render is given the
// concrete URL.
EditMethod string
EditPath string
// Fields is the ordered field list; the order is the render order.
Fields []Field
// Commit is the commit button's label. CommitEdit is the record
// side's label when it differs ("Create product" against "Save
// changes"); empty means both sides commit under one label.
Commit string
CommitEdit string
// WayOut is how the form is left without submitting it, on the create
// side. The record side never carries one: an always-open edit form
// leaves nothing by cancelling, so the part renders none there
// whatever the declaration says (spec form-conventions "A form's way
// out follows its container"; finding FA-19).
WayOut WayOut
// Target is the swap target; "this" (the form's own outer element)
// when unset. Swap is the swap strategy; "outerHTML" when unset.
Target string
Swap string
// Wide drops the Bar family's width cap, so the bar spans its content
// column instead of stopping at 30rem: for a search that is its page's
// primary control and whose width should say so (the landing surface's
// lookup). Bar only.
Wide bool
// Columns are the Table family's middle columns, in order: the headings
// of the cells the page supplies per row through Binding.Cells (for
// integration settings, "Effective value" and "Source"). The badges
// those cells hold belong to the page, not to the library, which is why
// they arrive pre-rendered. Table only.
Columns []string
// LabelColumn and ControlColumn are the Table family's first and last
// column headings; "Field" and "Value" when unset. Table only.
LabelColumn string
ControlColumn string
// CommitAction is the path the Rows family's commit posts to, which is
// not the path its row actions post to: every row action, Undo and the
// way out re-render the form's own region from Path, while the commit
// applies the batch and re-renders whatever the page's commit renders
// (design D2). Batch forms only, and required on one.
CommitAction string
// MessageAfter names the field the render's Message follows, for a
// preview whose message is the consequence of one control and reads
// wrong above it (the org-type card; maintainer, 2026-09-04: "The
// following text appears above the form element instead of below
// it"). Empty puts the message before the fields, which is right for a
// preview whose narrative introduces the choice. Preview only.
MessageAfter string
}
// Bind renders this declaration against one binding. It is Render written
// as a method, so a caller that must hold the view before it can render the
// body it hands back (the Rows family: the body fragment calls RowField on
// the view) reads as one expression.
func (s FormSpec) Bind(b Binding) FormView { return Render(s, b) }
// Field returns the declared field with this name, and whether it exists.
func (s FormSpec) Field(name string) (Field, bool) {
for _, f := range s.Fields {
if f.Name == name {
return f, true
}
}
return Field{}, false
}
// target is the effective hx-target.
func (s FormSpec) target() string {
if s.Target != "" {
return s.Target
}
return "this"
}
// swap is the effective hx-swap.
func (s FormSpec) swap() string {
if s.Swap != "" {
return s.Swap
}
return "outerHTML"
}