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.
820 lines
32 KiB
Go
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"
|
|
}
|