#291. A plan switch (Upgrade, Downgrade, or Pay for this plan on a granted tier's card) was confirmed in a panel at the top of the Plans section, above the cards and away from the button that opened it. It now confirms in the same dialog as Cancel plan and Resume plan. The button opens the dialog at once, titled with the panel's question ("Switch to Pro?", or "Pay for Pro?" on the granted tier's own card), and the dialog's body loads from the switch's preview route, because the charge it states comes from Stripe at that moment. Keep current plan closes the dialog and sends nothing. An answer that belongs in the dialog stays there: a declined card, a payment awaiting the bank's approval, stale figures; focus moves to the new form. An answer that lands outside closes it: the switch made or refused, or a plan withdrawn after the page loaded. A load that fails closes the dialog with the error toast; a commit that fails (403 or 5xx, which the pages' htmx config does not swap) leaves it open with the toast. Closing the dialog while its body loads aborts the load, and the aborted load shows no toast. The shared dialog gains a region the loaded form lives in; the forms library renders a preview that leaves by the dialog without a box of its own; the lint accepts `data-action-load` as a trigger's route. No string is added or reworded. docs/design-system.md, docs/operator-ux-conventions.md and docs/stripe.md say the same. Tests: TestSwitchAnswersNameTheirRegion, TestPlanMoveTitle, TestSwitchControlOpensTheSharedDialog, TestConfirmModalCarriesTheLoadedRegion, TestPreviewInTheDialogRendersNoBox, and the walkthrough TestSwitchDialogWalkthrough (seven steps in a browser, among them a load answered offline, a load closed mid-flight, and a commit answered 503 by the browser so nothing reaches Stripe). The walkthrough resets the application database, so it runs only with SWITCH_DIALOG_WALK=1, as the merchant-disclosure sheet runs only when asked; make test skips it. 35 goldens change: every member plans golden loses one blank line, the switch controls of 12 lose their hx-* attributes for the dialog's, and the plan move form's golden loses its box. Full suite on reset databases: 5249 passed, 16 skipped, none failed. Sandbox walk on this commit, all twelve steps passed: the dialog opens at once and loads Stripe's figure ($20.00 equal to the preview's amount_due); Keep current plan sends nothing; Cancel plan shows its own form; offline the dialog closes with the toast; stale figures, a declined card and a payment awaiting approval stay in the dialog with focus on the new form; the made switch closes it and Max is current; a plan withdrawn in another session refuses in the Plans region; Pay for this plan reads "Pay for Max?". make screens: no page this commit touches changed. Three operator captures differ for other reasons: person detail and invoice detail show new ids, because the demo snapshot was rebuilt when the previous landing added a FedWiki migration, and the domains list's age column counts one more day (#120). Not walked by the walkthrough, with the reason: a request in flight at a chosen moment (the dialog closed while the commit runs, another switch opened) and a page restored from the browser's cache; a load answered 400 and a commit the switch refuses answer through the same close rule as the withdrawn plan and the made switch that the walkthrough and the hand walk cover. Breaks, all CAUGHT (19 test records): - own card titled as a switch (title test) - own card titled as a switch (control test) - setMove leaves the title unset - control loads without the price (control test) - control loads without the price (render test) - control loads the switch's route - heading left in the message - current tier's name unescaped - refused preview left in the dialog - confirmation answered into the Plans region (a declined switch) - confirmation answered into the Plans region (a stale switch) - confirmation answered into the Plans region (a switch awaiting approval) - decline answered 200 - way out a link again - commit's description dropped - region renamed in the partial - box rule removed - box rule widened to every kind - box rule widened to every way out Breaks of the walkthrough, all CAUGHT (8 records, each building and booting the app from the edited tree): - the close not deferred - the abort on close dropped - the cancelled mark ignored by the toast - the load not sent - the loaded region left showing under a static trigger - the close on an answer outside the dialog removed - the failed commit closing the dialog - the way out a link again Seen by no test: renderConfirmation without its describeCommit call (the database tests' handlers carry no Stripe key fingerprint, so the line is never shown there; describeCommit itself is pinned); data-action-load dropped from the lint's condition (no template interpolates a route into it); the spinner and aria-busy not set, and the focus after a stay-open answer not moved, which only the hand walk reaches, because the walkthrough stops before any Stripe write.
849 lines
34 KiB
Go
849 lines
34 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), or, on a form that declares
|
|
// RowCommit, one open row is committed by its own Save (grant-resumption
|
|
// D15). It is never submitted empty by the capture utility, because its
|
|
// commit applies a batch, or a row, 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. A form that declares RowCommit
|
|
// has no tray: one row edits at a time and carries its own commit.
|
|
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), 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
|
|
}
|
|
|
|
// 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
|
|
// ShowsHint marks an option whose choice shows its select's hint
|
|
// (Field.HintOnChoice); it renders as data-shows-hint.
|
|
ShowsHint bool
|
|
// Hint is the line an option that carries ShowsHint shows in place of the
|
|
// select's declared Hint while it is chosen; it renders as data-hint and
|
|
// is read only with ShowsHint.
|
|
Hint string
|
|
}
|
|
|
|
// 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
|
|
// HintOnChoice makes this select's Hint wait for a choice: the line
|
|
// renders only when an option the select offers has ShowsHint, hidden
|
|
// until such an option is chosen, and shown on a refused submission
|
|
// that chose one. The select names the hint it controls
|
|
// (data-hint-toggle), and hint-on-choice.js, which the operator shell
|
|
// loads, follows later choices. The field declares the Hint it shows
|
|
// and no Notice. An option that carries ShowsHint can carry its own
|
|
// Option.Hint, which the line shows in place of the declared Hint while
|
|
// that option is chosen; the declared Hint is then also the line's
|
|
// default (data-hint-default).
|
|
HintOnChoice bool
|
|
// 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), or for a line the handler
|
|
// decides per render (the product's Entitlement set). 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. On a form that
|
|
// declares RowCommit it is the record's route pattern, which each open
|
|
// row's binding fills in for its own commit (RowBinding.Action).
|
|
CommitAction string
|
|
// RowCommit makes a batch form commit one record at a time instead of
|
|
// applying a staged batch (grant-resumption D15, the grants ledger's
|
|
// Manage): nothing is staged and there is no tray, at most one row
|
|
// edits at a time, and that row's own commit, which the body renders
|
|
// through FormView.RowCommit, posts the row's fields to the record's
|
|
// route and re-renders whatever the page's commit renders. The row
|
|
// actions still post to Path and re-render the form's region, so opening
|
|
// a row and cancelling it write nothing. Batch forms only.
|
|
RowCommit bool
|
|
// 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"
|
|
}
|