Files
member-console/internal/forms/render.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

1386 lines
52 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package forms
import (
"encoding/json"
"fmt"
"html"
"html/template"
"sort"
"strings"
)
// Rendering (design D5, D9, D11, D12; spec form-library "One declaration
// renders create, edit and the refused submission"). One declaration, one
// render, three inputs: unbound (a create page, defaults only), bound to a
// record (an edit form: values, no errors), bound to a submission (a 422:
// the raw submitted strings, every field's error, the form-level error).
// The create page and the edit form of one entity therefore cannot drift,
// because there is one field list and the mode is the only difference
// (lessons L§1, L§2; finding FA-12).
//
// Everything the two parts render is computed here, in Go: ids, the
// described-by wiring, the constraint attributes, the option list's
// selected entry, the commit row. A template never composes an attribute,
// so no caller can render a control without its label or its error slot
// (lessons L§9, L§10).
// Mode is which of the three inputs a render carries.
type Mode string
const (
// ModeUnbound is a create page: defaults only, no values, no errors.
ModeUnbound Mode = "unbound"
// ModeRecord is an edit form bound to a record: the record's values
// and no errors. An edit form never opens showing errors (L§6, L§7).
ModeRecord Mode = "record"
// ModeSubmission is a 422: the raw submitted strings and every error.
ModeSubmission Mode = "submission"
)
// Binding is one render's input.
type Binding struct {
// Mode is which of the three inputs this is.
Mode Mode
// Side is the side being rendered, which decides whether a one-sided
// field appears. Empty renders every declared field, which is right
// for a form that has only one side (a sub-record panel, a settings
// form, a search form).
Side Side
// Action is the concrete URL the form submits to, with any route
// placeholders already filled. Empty falls back to the declaration's
// Path (or EditPath on the edit side).
Action string
// Method overrides the declaration's method for this render. Empty
// falls back to the declaration's Method (or EditMethod on the edit
// side).
Method string
// Values are the record's or the submission's values.
Values Values
// Errors are the submission's refusals; nil in the other two modes.
Errors *Errors
// Options carry the option sets of the fields whose options are
// loaded per request. The same map is passed to ParseWith, so what
// the control offered and what the handler accepts are one list.
Options map[string][]Option
// Slots carry the first content of a field's declared slot region
// (the product name's duplicate warning at page load).
Slots map[string]template.HTML
// Cells carry a Table-family row's middle cells, keyed by field name,
// in FormSpec.Columns order. The page pre-renders them because what
// they hold is the page's (a source badge, a Not set badge, an
// effective value in a <code>), not the library's (design D21). A
// field with no entry renders empty cells; a family other than Table
// ignores them.
Cells map[string][]template.HTML
// ControlFooters carry a Table-family row's own content under its
// control, in the same cell, keyed by field name; page-owned for the
// same reason Cells is (design D9, typed-config-keys): the integration
// settings page's Clear action and Pending restart badge describe the
// stored override, not the running value, so they render here rather
// than in one of Cells' columns. A field with no entry renders nothing
// extra; a family other than Table ignores it.
ControlFooters map[string]template.HTML
// Instance disambiguates a form rendered more than once on one page: a
// sub-record form repeated per row (Extend tier, once per delivery) or
// a form repeated once per record on a listing (the org-type
// default-change form, once per type's card). It is suffixed onto
// every id the render computes, so a page with several instances
// carries no duplicate DOM id; data-form stays the declaration's own
// name, so the capture and coverage tooling still see one form.
// Wrapper ids stay stable within one instance across its three modes
// (design D11), which is what the contract is for. Empty for a form
// that renders once.
Instance string
// Target overrides the declaration's Target for this render, for a
// form whose swap target is per-instance (the org-type card's id
// carries the type). Empty falls back to the declaration's Target.
Target string
// WayOut overrides the declaration's WayOut for this render, for a
// way out that carries a per-instance id (Extend's Cancel closes the
// panel named after its own delivery). Nil falls back to the
// declaration's WayOut.
WayOut *WayOut
// Message is one block of prose rendered inside the section box, above
// the fields: a preview's explanation of what it is about to do, which
// belongs to the whole form rather than to one field (design D10's
// preview idiom). Empty for every other kind, and for a preview with
// nothing to say beyond its fields.
Message template.HTML
// HXSelect and HXSelectOOB narrow a Search form's request to one
// embedded list panel (operator-list-scale's ListNav.Target/SyncSelect):
// HXSelect carves the panel's own markup out of the GET response,
// HXSelectOOB refreshes sibling panels from the same response. Both
// empty for a standalone list's search, which sets Boosted instead
// (form-conventions, "Every mutation form's no-JS behavior is a
// recorded decision").
HXSelect string
HXSelectOOB string
// Body is the caller's own fragment the Rows family renders in place of
// a section box: the table of records whose cells hold the
// declaration's controls (spec form-library "A batch form renders the
// caller's rows and one tray"). A body fragment usually needs the view
// it will sit inside, because its cells call FormView.RowField, so the
// common order is Bind, render the fragment with the view in scope, set
// FormView.Body, render the form part. Setting it here instead is the
// same thing for a body that needs nothing from the view. Refused on
// any other family.
Body template.HTML
// Staged is the batch the tray shows, in the order the tray lists it;
// the index is the delta's identity in the hidden fields the render
// writes and ParseBatch reads back. Refused outside the Rows family.
Staged []StagedDelta
// Rows are the open editors, keyed by the record key the caller passes
// to RowField as the instance. A record with no entry renders at rest,
// which is the caller's own markup and none of the library's business.
// Refused outside the Rows family.
Rows map[string]RowBinding
// Autofocus names the control that takes focus on a successful render
// (design D9; spec form-library "A binding names the control that takes
// focus"). A refusal ignores it and focuses the first invalid control
// instead, as every other family does.
Autofocus Focus
// CommitTarget is the hx-target of the Rows family's commit, which
// applies the batch and therefore usually re-renders more than the form
// region a row action re-renders. Empty falls back to the form's own
// target.
CommitTarget string
// CommitSwap is the hx-swap of that commit; "outerHTML" when empty, as
// it is for every other control of the form. A commit whose target is a
// container the page keeps (an operator body whose response is its
// contents rather than a wrapper) sets "innerHTML" here, because
// replacing the container itself would leave the next request with no
// target to swap into.
CommitSwap string
// Tray renders the tray with no staged delta, for a batch form whose
// tray says something at rest. Empty batch and no Tray renders no tray,
// no commit and no way out.
Tray bool
// Boosted renders a Search form with no explicit htmx attributes at
// all, relying entirely on the page's own hx-boost inheritance for
// both the scripted behaviour and the native no-JS fallback: the right
// choice for a standalone list's own search, whose target is the page
// itself and not a selector this declaration could name. An embedded
// list sets HXSelect/HXSelectOOB instead, never this.
Boosted bool
}
// StagedDelta is one staged change to one record: what the tray lists, what
// the hidden fields carry, and what ParseBatch reads back (design D1's
// "Rows family contract", D3). Verb and Key are opaque to the library: it
// writes them out, reads them back, and never interprets either.
type StagedDelta struct {
// Verb is the caller's word for what this delta does ("add", "edit",
// "remove").
Verb string
// Key is the record key: a record's identifier, or the caller's own
// name for a record that does not exist yet.
Key string
// Values are the delta's field values, keyed by the declaration's bare
// field names. A field the tray renders as a control (PlaceDelta)
// renders from here; every other declared field present here rides as a
// hidden input, so the whole batch survives every request.
Values Values
// Line is the delta's narrative on its tray line, rendered by the
// caller: the library knows verbs and keys, not what a change means.
Line template.HTML
// Error is a refusal on this delta, rendered on its line with the rest
// of the batch intact (design D3: every request re-validates the batch).
Error string
// Options are this delta's runtime option sets, keyed by field name,
// for a control whose choices depend on the record (the rule change's
// policy select offers a dormant stored value as its own option).
// A field with no entry here falls back to Binding.Options.
Options map[string][]Option
}
// RowBinding is one open editor: the values its controls carry and the
// refusals on them, keyed by the declaration's bare field names.
type RowBinding struct {
Values Values
Errors *Errors
// Options are this row's runtime option sets, keyed by field name, for a
// control whose choices depend on the record (the rule row's policy
// select offers a dormant stored value as its own option). A field with
// no entry here falls back to Binding.Options.
Options map[string][]Option
}
// Focus names the control a successful render gives focus to (design D9).
// The zero value focuses nothing, which is what every render did before
// this existed.
type Focus struct {
// Field is the declared field's name.
Field string
// Instance is the row key the field renders under, or "staged.<i>" for
// a delta's own control. Empty names a field the library renders once
// (a tray field).
Instance string
// Tray focuses the tray's message region instead of a control; the
// region renders tabindex="-1" so it can take focus at all.
Tray bool
// Act and Key name a posting control instead of a control that carries
// a value: the row's own Edit after a Cancel, say, so focus returns to
// the button the person pressed to open the editor rather than falling
// to the top of the swapped region.
Act string
Key string
}
// HiddenInput is one name and value the Rows family writes out so a request
// carries the whole page state: the open editors' keys and the staged
// batch. The part writes them from here and composes no name of its own.
type HiddenInput struct {
Name string
Value string
}
// RowActionView is one posting control of a batch form: a row's Edit or
// Remove, a delta's Undo, the commit, the way out. Every one of them posts
// the whole form, so each request carries every open editor's values and
// the whole staged batch (design D2).
type RowActionView struct {
Act string
Key string
Label string
Variant string
// Classes are the button's classes, decided by the variant.
Classes string
// Indicator renders the in-flight spinner inside the button; the commit
// carries one, a row action does not.
Indicator bool
// HX carries hx-post, hx-vals, hx-include, hx-target and hx-swap, built
// here so no template composes a posting control by hand.
HX template.HTMLAttr
}
// DeltaView is one staged delta's tray line.
type DeltaView struct {
Index int
Verb string
Key string
Line template.HTML
Error string
ErrorID string
// Fields are the delta's own controls (Placement PlaceDelta), named
// "staged.<i>.<field>".
Fields []FieldView
// Undo drops this delta from the batch.
Undo RowActionView
}
// OptionView is one rendered option.
type OptionView struct {
Value string
Label string
Disabled bool
Selected bool
}
// RadioView is one rendered radio in a group.
type RadioView struct {
ID string
Value string
Label string
Checked bool
Attrs template.HTMLAttr
}
// FieldView is one rendered field: everything the formField part writes.
type FieldView struct {
Name string
Label string
Control Control
// InputType is the type attribute for the controls that are inputs.
InputType string
// LabelHidden renders the label visually hidden, for a dense row or a
// bar with no room for it. The label is never absent (finding FA-51).
LabelHidden bool
// LabelNarrowOnly renders the label d-lg-none: a Rows body's editing
// cells stack below lg, where the column headers that label them are
// off to the side, and hide their own labels again from lg up where the
// headers do the work (design D11). Set by the label-hidden option of
// FormView.RowField, and false everywhere else.
LabelNarrowOnly bool
// Optional renders the "(optional)" suffix after the label. A bar and
// a table never do: their fields are optional by nature, so the marker
// states nothing (design D20, D21).
Optional bool
// WrapperID is the stable per-field id, "form-<spec>-<field>", so
// inline validation can be added later without rewriting the DOM
// (lessons L§18, L§19).
WrapperID string
// ControlID is what the label points at.
ControlID string
// ColClass is the dense row's grid column; empty in every other family.
// Small renders the -sm control a dense row and a table row use.
ColClass string
Small bool
Value string
// Checked is a checkbox's state; CheckboxValue is what it submits.
Checked bool
CheckboxValue string
Placeholder string
Rows int
Options []OptionView
Radios []RadioView
Help HelpIcon
Hint string
HintID string
Error string
ErrorID string
// Cells are the Table family's middle cells for this row, in the
// declaration's Columns order, pre-rendered by the page
// (Binding.Cells). Empty in every other family.
Cells []template.HTML
// ControlFooter is the Table family's own content under this row's
// control, in the control's own cell, pre-rendered by the page
// (Binding.ControlFooters). Empty in every other family, and for a
// field with nothing to show there.
ControlFooter template.HTML
// Notice is the declared line under the control for a one-sidedness a
// person would be surprised by, and NoticeID is what the control's
// aria-describedby names it by. A one-sided field says nothing about
// its side unless it declares a Notice.
Notice string
NoticeID string
// SlotID and SlotHTML are the field's declared live region.
SlotID string
SlotHTML template.HTML
// Attrs carries every attribute derived from the declaration:
// required, maxlength, min, max, pattern, inputmode, autocomplete,
// aria-describedby, aria-invalid, autofocus, and the data-* and hx-*
// escape hatch. Built here so a template cannot write one by hand and
// the browser's constraints and the server's rules cannot differ.
Attrs template.HTMLAttr
}
// Invalid reports whether the field was refused.
func (f FieldView) Invalid() bool { return f.Error != "" }
// HasErrors reports whether this render carries any refusal: a field error
// or the form-level error. A page uses it to force a collapsed sub-record
// panel open, so a refused submission's errors are never hidden behind a
// closed panel (design D9).
func (v FormView) HasErrors() bool {
if v.FormError != "" {
return true
}
for _, f := range v.Fields {
if f.Error != "" {
return true
}
}
// A batch form's refusals mostly live where its controls do: on an open
// editor's field or on a staged delta's line, neither of which is in
// Fields (the body renders the one, the tray the other).
for _, d := range v.Deltas {
if d.Error != "" {
return true
}
}
for _, rb := range v.binding.Rows {
if rb.Errors.Any() {
return true
}
}
return false
}
// WayOutView is the rendered way out.
type WayOutView struct {
Kind WayOutKind
Label string
URL string
PanelID string
HXTarget string
HXSwap string
}
// Shown reports whether the form offers a way out at all.
func (w WayOutView) Shown() bool { return w.Kind != WayOutNone && w.Label != "" }
// DisabledControlView is the shape the shared disabledControl part reads
// (internal/server.DisabledControl carries the same fields; internal/forms
// cannot import internal/server, which imports it, so this is the same
// shape declared on this side of the boundary). Set on FormView.CommitState
// to gate a preview's commit on a precondition the declaration cannot check
// itself (design D10; org-types "The commit waits for dispositions").
type DisabledControlView struct {
ID string
Label string
Classes string
Reason string
Visible bool
}
// FormView is the whole rendered form: what the form part writes.
type FormView struct {
Name string
// ID is the outer element's id, "form-<name>".
ID string
Kind Kind
// Dense, Bar, Table and Rows report the layout family, so the part can
// pick its shape; all four false is Stacked (design D10, D20, D21, and
// D1 of staged-rule-changes).
Dense bool
Bar bool
Table bool
Rows bool
// Wide drops the bar's width cap (FormSpec.Wide). Bar only.
Wide bool
// Columns, LabelColumn and ControlColumn are the Table family's
// headings: the label column first, the declaration's Columns next,
// the control column last.
Columns []string
LabelColumn string
ControlColumn string
Fields []FieldView
Commit string
WayOut WayOutView
// FormError is the refusal that belongs to no field. Its slot renders
// on every form and is empty when unused, so a refusal without a
// field name always has somewhere to land (findings FA-22, FA-26).
FormError string
FormErrorID string
// Native reports a search form, which renders a real method and
// action and keeps a true no-JavaScript fallback (spec
// form-conventions "Every mutation form's no-JS behavior is a
// recorded decision").
Native bool
// Method and Action are the native attributes, set when Native.
Method string
Action string
// NoValidate emits the form's `novalidate` attribute, so the refusal a
// person sees is the server's, rendered under the control, and not
// the browser's transient bubble (maintainer, 2026-09-03: pressing
// Save on a cleared name produced no feedback at all).
NoValidate bool
// Box names the section box the part renders around the form (spec
// form-library, "The form part owns the container"): "card" for a
// create page or an edit form, "alert" for the preview idiom's muted
// box (design D10), and empty for a dense sub-record row, whose panel
// is already its box, for a bar, which sits in a list's header, and
// for a settings table, which is its own shape. A stacked box is capped at the
// readable measure and hugs its fields, so the box ends where the
// fields end rather than running the width of the content column
// (maintainer, 2026-09-04: "Hugging definitely").
Box string
// HX carries the htmx attributes: the verb and its path, the target,
// the swap, and hx-disable on the commit so a double submit is
// impossible (finding FA-20).
HX template.HTMLAttr
// Target is the resolved hx-target (the declaration's, or Binding.
// Target), for a WayOutDiscard button to reuse as its own hx-target
// and hx-select: it re-fetches the page that owns this target and
// carves the same region back out.
Target string
// CommitState, when non-nil, replaces the ordinary submit button with
// the shared disabled-control idiom: a preview whose commit waits on a
// precondition the declaration cannot itself check (design D10;
// org-types "The commit waits for dispositions"). Nil renders the
// ordinary enabled commit. Its Classes are the render's, never the
// caller's: the part reads them through CommitControl, which overwrites
// whatever was set with the classes the enabled commit would carry, so
// a disabled commit and the way out beside it are the same height in
// every state (design D23).
CommitState *DisabledControlView
// CommitHidden renders no commit row at all, neither the commit nor
// the way out: a Preview form at rest has nothing to apply and nothing
// to discard, so it shows only its fields until a choice produces a
// message (design D23; maintainer, 2026-09-04: Discard with nothing to
// discard).
CommitHidden bool
// Message is prose rendered inside the section box (Binding.Message's
// doc), before the fields, or after the field MessageAfter names.
Message template.HTML
// MessageAfter is the field name the message follows
// (FormSpec.MessageAfter). Empty puts the message before the fields.
MessageAfter string
// MessageID is the message region's id. Every other kind renders the
// region only when Message is set; a Confirm form renders it always,
// empty at rest (app.css collapses an empty one), because
// confirm-action-modal.js writes each trigger's body text into it after
// the page has already loaded (design D4's Confirm kind; task 5.6).
MessageID string
// Danger reports a Confirm form's commit, which renders btn-danger by
// default rather than btn-primary (design D12: "A destructive commit
// renders btn-danger only inside the confirm modal"). The JS
// (confirm-action-modal.js) still reassigns the class per trigger on
// every open, as it does today; this is only the rendered default.
Danger bool
// The Rows family's own view (design D1's "Rows family contract"). Body
// is the caller's table, settable after Bind because the fragment that
// produces it calls RowField on this view; everything else the library
// builds.
Body template.HTML
// Hiddens are the open editors' keys and the staged batch, as hidden
// inputs the part writes and ParseBatch reads back.
Hiddens []HiddenInput
// Deltas are the tray's lines, one per staged delta.
Deltas []DeltaView
// Tray reports whether the tray renders at all: an empty batch with no
// Binding.Tray renders no tray, no commit and no way out.
Tray bool
// TrayFocus renders the tray's message region focusable and focused,
// for the render that follows a staging or an undo (design D9).
TrayFocus bool
// Apply and Discard are the tray's commit and way out, emitted through
// the same row-action part as every other posting control of the form.
Apply RowActionView
Discard RowActionView
// spec and binding are what RowField and RowAction read; focus is the
// one control this render gives autofocus to, resolved once so at most
// one control can carry it however many rows the body renders.
spec FormSpec
binding Binding
formID string
focus focusTarget
}
// focusTarget is the resolved answer to "which control carries autofocus":
// a field under an instance, the tray's message region, or nothing.
type focusTarget struct {
field string
instance string
act string
key string
action bool
tray bool
set bool
}
// matches reports whether this field, under this instance, is the one.
func (t focusTarget) matches(instance, field string) bool {
return t.set && !t.tray && !t.action && t.instance == instance && t.field == field
}
// matchesAction reports whether this posting control is the one.
func (t focusTarget) matchesAction(act, key string) bool {
return t.set && t.action && t.act == act && t.key == key
}
// CommitClasses are the commit button's classes: filled by default,
// btn-danger inside the confirm modal, outline in a bar (a search is not
// a commit, so every bar's button is outline, the landing surface's "Look
// up" included; design D20), and btn-sm only in a dense row.
func (v FormView) CommitClasses() string {
classes := "btn btn-primary"
switch {
case v.Danger:
classes = "btn btn-danger"
case v.Bar:
classes = "btn btn-outline-secondary"
}
if v.Dense {
classes += " btn-sm"
}
return classes
}
// CommitControl is the disabled commit the part renders in place of the
// submit button, with the classes the enabled commit would have carried
// (design D23): the caller declares the id, the label and the reason; the
// weight and the size are the family's, so Apply change and Discard are
// the same height whether the commit is live or waiting.
func (v FormView) CommitControl() *DisabledControlView {
if v.CommitState == nil {
return nil
}
control := *v.CommitState
control.Classes = v.CommitClasses()
return &control
}
// Render builds the view the two parts write, from one declaration and
// one binding.
func Render(spec FormSpec, b Binding) FormView {
checkRowsBinding(spec, b)
side := b.Side
method, action := spec.Method, spec.Path
if side == EditOnly && spec.EditPath != "" {
action = spec.EditPath
if spec.EditMethod != "" {
method = spec.EditMethod
}
}
if b.Method != "" {
method = b.Method
}
if b.Action != "" {
action = b.Action
}
// One declaration serves a record's create page and its edit form, so
// the rendered kind follows the side: the capture utility and the
// sheet then name what is on screen rather than what the declaration
// was filed under.
kind := spec.Kind
commit := spec.Commit
declaredWayOut := spec.WayOut
if b.WayOut != nil {
declaredWayOut = *b.WayOut
}
wayOut := WayOutView(declaredWayOut)
if side == EditOnly {
if kind == KindCreate {
kind = KindEdit
}
if spec.CommitEdit != "" {
commit = spec.CommitEdit
}
wayOut = WayOutView{}
}
// formID disambiguates a form rendered more than once on one page
// (Binding.Instance); data-form stays spec.Name so the capture and
// coverage tooling see one declaration regardless of how many
// instances render.
formID := spec.Name
if b.Instance != "" {
formID = formID + "-" + b.Instance
}
target := spec.target()
if b.Target != "" {
target = b.Target
}
swap := spec.swap()
view := FormView{
Name: spec.Name,
ID: "form-" + formID,
Kind: kind,
Dense: spec.Family == Dense,
Bar: spec.Family == Bar,
Table: spec.Family == Table,
Rows: spec.Family == Rows,
Wide: spec.Wide,
Columns: spec.Columns,
LabelColumn: spec.LabelColumn,
ControlColumn: spec.ControlColumn,
Commit: commit,
WayOut: wayOut,
FormError: b.Errors.FormError(),
FormErrorID: "form-" + formID + "-error",
Method: method,
Action: action,
NoValidate: kind.ServerValidated(),
Box: boxFor(spec.Family, kind),
Target: target,
Message: b.Message,
MessageAfter: spec.MessageAfter,
Danger: spec.Kind == KindConfirm,
}
if view.Table {
if view.LabelColumn == "" {
view.LabelColumn = "Field"
}
if view.ControlColumn == "" {
view.ControlColumn = "Value"
}
}
// A preview at rest has nothing to apply and nothing to discard, so it
// renders no commit row at all: the org-type card at rest is its
// "Change default to" select alone, and choosing a candidate is what
// brings Apply change and Discard (design D23).
if spec.Kind == KindPreview && b.Message == "" {
view.CommitHidden = true
}
if spec.Kind == KindConfirm {
view.MessageID = "form-" + formID + "-message"
}
if spec.Kind == KindSearch {
view.Native = true
if !b.Boosted {
hx := fmt.Sprintf(`hx-get=%q hx-target=%q hx-swap=%q hx-push-url="true"`, action, target, swap)
if b.HXSelect != "" {
hx += fmt.Sprintf(` hx-select=%q`, b.HXSelect)
}
if b.HXSelectOOB != "" {
hx += fmt.Sprintf(` hx-select-oob=%q`, b.HXSelectOOB)
}
view.HX = template.HTMLAttr(hx)
}
} else {
view.HX = template.HTMLAttr(fmt.Sprintf(
`%s=%q hx-target=%q hx-swap=%q hx-disable="find button[type=submit]" hx-indicator="find .htmx-indicator"`,
hxVerb(method), action, target, swap))
}
view.spec = spec
view.binding = b
view.formID = formID
if view.Rows {
view.focus = resolveRowsFocus(spec, b)
buildRows(&view, spec, b, action, target)
}
autofocused := b.Mode != ModeSubmission
for _, f := range spec.Fields {
if side != BothSides && !f.RendersOn(side) {
continue
}
if view.Rows && f.Placement != PlaceTray {
// The body renders a row's fields and the tray renders a
// delta's; Fields is what the library itself writes, which in
// this family is the tray's own fields alone.
continue
}
if !f.Shown(b.Values) {
continue
}
fv := renderField(formID, spec.Family, f, b)
if view.Rows {
if view.focus.matches("", f.Name) {
fv.Attrs = template.HTMLAttr(string(fv.Attrs) + ` autofocus`)
}
view.Fields = append(view.Fields, fv)
continue
}
if view.Table {
// Every row carries exactly one cell per declared column, so a
// page that supplied too few (or none at all) still renders a
// table whose rows line up with its headings.
for len(fv.Cells) < len(spec.Columns) {
fv.Cells = append(fv.Cells, "")
}
fv.Cells = fv.Cells[:len(spec.Columns)]
}
if !autofocused && fv.Invalid() {
fv.Attrs = template.HTMLAttr(string(fv.Attrs) + ` autofocus`)
autofocused = true
}
view.Fields = append(view.Fields, fv)
}
return view
}
// boxFor decides the section box the part renders around the form. A
// dense row's box is the panel its page already opened, a bar's is the
// list header it sits in, and a table is its own shape; a stacked form
// owns its own, so a page writes no card of its own and every stacked
// form is the same width on every surface.
func boxFor(family Family, kind Kind) string {
if family != Stacked {
return ""
}
switch kind {
case KindPreview:
return "alert"
case KindConfirm:
return ""
}
return "card"
}
// hxVerb maps an HTTP method to its htmx attribute.
func hxVerb(method string) string {
switch strings.ToUpper(method) {
case "GET":
return "hx-get"
case "PUT":
return "hx-put"
case "PATCH":
return "hx-patch"
case "DELETE":
return "hx-delete"
}
return "hx-post"
}
// denseColClass is a field's grid column in a dense row (design D2): a
// hidden field takes no column, because the template renders it with no
// wrapper at all; a checkbox, radio or static field takes its own content's
// width; every other control takes a fraction of the row from its rung and
// its declared Width.
func denseColClass(f Field) string {
switch f.Control {
case Hidden:
return ""
case Checkbox, Radio, Static:
return "col-12 col-xl-auto"
}
fraction, _ := FieldFraction(f.Control, f.Width)
return fraction.ColClass()
}
// renderField builds one field's view. formID is spec.Name, suffixed with
// Binding.Instance when the form renders more than once on one page;
// family decides the column class, the control size, whether a hidden
// label is honoured, whether the "(optional)" marker renders at all, and
// where the error goes.
func renderField(formID string, family Family, f Field, b Binding) FieldView {
return renderFieldAs(formID, "", f.Name, family, f, b)
}
// renderFieldAs is renderField with the two things a Rows form changes: the
// suffix that makes a wrapper id unique per row or per delta, and the
// submitted name, which carries the row or the delta it belongs to. Every
// id the view computes derives from the wrapper, so one suffix moves the
// control's id, its hint's, its notice's, its error's, its slot's and each
// radio's together, and the described-by wiring follows.
func renderFieldAs(formID, suffix, name string, family Family, f Field, b Binding) FieldView {
dense := family == Dense
wrapper := "form-" + formID + "-" + f.Name + suffix
fv := FieldView{
Name: name,
Label: f.Label,
Control: f.Control,
InputType: f.Control.InputType(),
// A bar and a dense row have no room for a visible label; a
// stacked form and a table always do, so a declaration that asks
// to hide one there is ignored rather than obeyed (form-
// conventions, "Its input SHALL carry a label, visually hidden
// where the row has no room").
LabelHidden: (dense || family == Bar) && f.HideLabel,
// Neither a bar nor a table renders "(optional)": a bar's one
// field is optional by nature and its label is hidden anyway, and
// every setting is optional by nature, so the marker on every row
// said nothing (maintainer, 2026-09-04: "kinda useless"; design
// D20, D21).
Optional: f.Optional && family != Bar && family != Table,
WrapperID: wrapper,
ControlID: wrapper + "-control",
Placeholder: f.Placeholder,
Rows: f.Rows,
Help: f.Help,
Hint: f.Hint,
Notice: f.Notice,
CheckboxValue: f.CheckboxValue(),
Cells: b.Cells[f.Name],
ControlFooter: b.ControlFooters[f.Name],
}
if family != Table {
fv.Cells = nil
fv.ControlFooter = ""
}
if fv.Rows == 0 {
fv.Rows = 3
}
if dense {
fv.ColClass = denseColClass(f)
fv.Small = true
}
if family == Table || family == Rows {
// A table row is one line tall; small controls fit it and full-size
// ones would set every row's height from the control alone. A Rows
// body's controls sit in the caller's table cells, where the same
// holds, and its tray's fields sit beside small buttons.
fv.Small = true
}
// Values. An unbound render carries none; the other two carry the
// record's or the submission's, and a refused field keeps its raw
// text so nothing an operator typed is thrown away (finding FA-23).
if b.Mode != ModeUnbound {
fv.Value = b.Values.Raw(f.Name)
if f.Control == Checkbox {
fv.Checked = b.Values.Raw(f.Name) == f.CheckboxValue()
}
}
if f.Control == Static {
// A static row states a fact rather than carrying one: its text is
// the declaration's, never the binding's, and nothing is submitted.
fv.Value = f.Value
}
if b.Errors != nil {
fv.Error = b.Errors.Get(f.Name)
}
opts := f.Options
if runtime, ok := b.Options[f.Name]; ok {
opts = runtime
}
switch f.Control {
case Select:
// present distinguishes "bound to an explicit value" (Values.Set
// was called, even with "") from "nothing chosen yet" (the name was
// never set): a select can offer more than one empty-valued option
// at once (a disabled "Choose a ..." placeholder alongside an
// enabled "None" that deliberately clears the column, the org-type
// default-change select), and fv.Value == "" alone cannot tell them
// apart, so a deliberately-chosen "None" would otherwise render the
// placeholder selected instead of the choice the operator made.
present := b.Mode != ModeUnbound && b.Values.Present(f.Name)
chosen := false
for _, o := range opts {
selected := present && o.Value == fv.Value
if selected {
chosen = true
}
fv.Options = append(fv.Options, OptionView{
Value: o.Value, Label: o.Label, Disabled: o.Disabled, Selected: selected,
})
}
// With no value chosen the browser selects the first option, which
// is the placeholder or, on the product create page, the default
// that creates the set with the product (design D14). Marking it
// explicitly keeps the rendered markup honest about what will be
// submitted.
if !chosen && len(fv.Options) > 0 && !present {
fv.Options[0].Selected = true
}
case Radio:
for i, o := range opts {
fv.Radios = append(fv.Radios, RadioView{
ID: fmt.Sprintf("%s-%d", wrapper, i),
Value: o.Value,
Label: o.Label,
Checked: o.Value == fv.Value,
})
}
}
// The described-by wiring: the hint keeps its id and stays visible
// beside the error, and the error carries its own id, so a screen
// reader hears why a control was refused and not only that it was
// (findings FA-48, FA-49).
var describedBy []string
if f.Hint != "" {
fv.HintID = wrapper + "-hint"
describedBy = append(describedBy, fv.HintID)
}
if fv.Notice != "" {
fv.NoticeID = wrapper + "-notice"
describedBy = append(describedBy, fv.NoticeID)
}
if fv.Error != "" {
fv.ErrorID = wrapper + "-error"
describedBy = append(describedBy, fv.ErrorID)
}
if f.Slot != "" {
fv.SlotID = wrapper + "-" + f.Slot
fv.SlotHTML = b.Slots[f.Name]
describedBy = append(describedBy, fv.SlotID)
}
fv.Attrs = template.HTMLAttr(strings.Join(fieldAttrs(f, fv, opts, describedBy), " "))
return fv
}
// fieldAttrs builds the attribute list the control carries, in one place,
// from the declaration and from nowhere else (spec form-library
// "Constraint attributes derive from the declaration").
func fieldAttrs(f Field, fv FieldView, opts []Option, describedBy []string) []string {
var attrs []string
add := func(key, value string) {
attrs = append(attrs, fmt.Sprintf(`%s="%s"`, key, html.EscapeString(value)))
}
switch f.Control {
case Checkbox, Hidden, Static:
// A checkbox is never required: its absence is false, and the
// invariants refuse the combination. A hidden field carries no
// constraint the browser could enforce, and a static row carries
// no control at all.
case Select:
// A select's requiredness is carried by its option set: an empty
// value is submittable exactly when an option offers one, so the
// attribute follows the same fact the server checks.
if !optionExists(opts, "") {
attrs = append(attrs, "required")
}
case Radio:
// A radio group has no "choose nothing" member the way a select's
// enabled empty option does — there is no convention for an empty
// radio button — so its requiredness is the field's own Optional
// flag, like every other control (parse.go mirrors this).
if !f.Optional {
attrs = append(attrs, "required")
}
default:
if !f.Optional {
attrs = append(attrs, "required")
}
if f.MaxLen > 0 {
add("maxlength", fmt.Sprint(f.MaxLen))
}
if f.Min != "" {
add("min", f.Min)
}
if f.Max != "" {
add("max", f.Max)
}
if f.Step != "" {
add("step", f.Step)
}
if f.Pattern != "" {
add("pattern", f.Pattern)
}
if f.Inputmode != "" {
add("inputmode", f.Inputmode)
}
}
if f.Autocomplete != "" && f.Control != Checkbox && f.Control != Hidden && f.Control != Static {
add("autocomplete", f.Autocomplete)
}
if len(describedBy) > 0 {
add("aria-describedby", strings.Join(describedBy, " "))
}
if fv.Error != "" {
add("aria-invalid", "true")
}
// The escape hatch, last, in a stable order so the rendered markup is
// the same on every run (the capture utility compares pixels).
if len(f.Attrs) > 0 {
keys := make([]string, 0, len(f.Attrs))
for k := range f.Attrs {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
add(k, f.Attrs[k])
}
}
return attrs
}
// The Rows family (design D1's "Rows family contract"; spec form-library "A
// batch form renders the caller's rows and one tray"). The library owns the
// form element, the hidden page state, the tray and every posting control;
// the caller owns the table between them and renders each cell's control
// through RowField and each row's actions through RowAction, so a batch
// form's markup is still the declaration's everywhere a control appears.
// checkRowsBinding refuses a binding that carries the Rows family's own
// input on a form of another family. It panics rather than rendering
// something half right: a body, a batch or an open editor on a stacked form
// is a programming error, caught the first time the page renders, the way
// two forms registering under one name are caught at boot.
func checkRowsBinding(spec FormSpec, b Binding) {
if spec.Family == Rows {
return
}
switch {
case b.Body != "":
panic(fmt.Sprintf("forms: %s is a %s form and its binding carries a Body; a body fragment belongs to the rows family", spec.Name, spec.Family))
case len(b.Staged) > 0:
panic(fmt.Sprintf("forms: %s is a %s form and its binding carries Staged; a staged batch belongs to the rows family", spec.Name, spec.Family))
case len(b.Rows) > 0:
panic(fmt.Sprintf("forms: %s is a %s form and its binding carries Rows; open editors belong to the rows family", spec.Name, spec.Family))
}
}
// rowsRefused reports whether this render is a refusal. A batch form's
// refusals land in three places, because its state does: the form level and
// its tray fields on Binding.Errors, an open editor's fields on its own
// RowBinding, and a delta's on its line.
func rowsRefused(b Binding) bool {
if b.Errors.Any() {
return true
}
for _, rb := range b.Rows {
if rb.Errors.Any() {
return true
}
}
for _, d := range b.Staged {
if d.Error != "" {
return true
}
}
return false
}
// resolveRowsFocus decides the one control that carries autofocus. A
// refusal focuses the first invalid control and ignores what the binding
// asked for, as every other family does; a successful render honours the
// binding; neither one focuses anything it was not told to (spec
// form-library "A binding names the control that takes focus").
//
// "First" is the open editors in key order, each read in the declaration's
// field order, then the tray's own fields: a map has no order of its own, so
// the render picks one and keeps it, and the same refusal focuses the same
// control on every run.
func resolveRowsFocus(spec FormSpec, b Binding) focusTarget {
if rowsRefused(b) {
for _, instance := range sortedRowKeys(b.Rows) {
rb := b.Rows[instance]
for _, f := range spec.Fields {
if f.Placement != PlaceRow || !f.Shown(rb.Values) {
continue
}
if rb.Errors.Get(f.Name) != "" {
return focusTarget{field: f.Name, instance: instance, set: true}
}
}
}
for _, f := range spec.Fields {
if f.Placement == PlaceTray && b.Errors.Get(f.Name) != "" {
return focusTarget{field: f.Name, set: true}
}
}
return focusTarget{}
}
if b.Autofocus.Tray {
return focusTarget{tray: true, set: true}
}
if b.Autofocus.Act != "" {
return focusTarget{act: b.Autofocus.Act, key: b.Autofocus.Key, action: true, set: true}
}
if b.Autofocus.Field != "" {
return focusTarget{field: b.Autofocus.Field, instance: b.Autofocus.Instance, set: true}
}
return focusTarget{}
}
// sortedRowKeys orders the open editors, so the hidden inputs, the focus
// rule and the tests all read one order rather than the map's.
func sortedRowKeys(rows map[string]RowBinding) []string {
keys := make([]string, 0, len(rows))
for k := range rows {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// stagedPrefix is a delta's name and instance prefix. The index is the
// delta's identity across a request: the render writes it, ParseBatch reads
// it back, and nothing else numbers a batch.
func stagedPrefix(i int) string { return fmt.Sprintf("staged.%d", i) }
// buildRows fills the view's Rows-family parts: the hidden page state, the
// tray's lines, and the three controls the tray carries.
func buildRows(view *FormView, spec FormSpec, b Binding, action, target string) {
view.Body = b.Body
// Every posting control here is a <button>, so the library's default
// target, "this", would resolve to the button rather than to the form.
// Name the form's own element instead, instance suffix included.
target = rowActionTarget(target, view.ID)
view.Tray = len(b.Staged) > 0 || b.Tray
view.TrayFocus = view.Tray && view.focus.set && view.focus.tray
// One hidden input per open editor, so the next request knows which
// rows were open and reads their values (design D2: every action posts
// the whole form).
for _, instance := range sortedRowKeys(b.Rows) {
view.Hiddens = append(view.Hiddens, HiddenInput{Name: "open", Value: instance})
}
for i, d := range b.Staged {
prefix := stagedPrefix(i)
dv := DeltaView{Index: i, Verb: d.Verb, Key: d.Key, Line: d.Line, Error: d.Error}
if d.Error != "" {
dv.ErrorID = "form-" + view.formID + "-" + prefix + "-error"
}
view.Hiddens = append(view.Hiddens,
HiddenInput{Name: prefix + ".verb", Value: d.Verb},
HiddenInput{Name: prefix + ".key", Value: d.Key})
for _, f := range spec.Fields {
if f.Placement == PlaceTray {
continue
}
if f.Placement == PlaceDelta && f.Shown(d.Values) {
fb := Binding{Mode: ModeRecord, Values: d.Values, Options: deltaOptions(b, d)}
fv := renderFieldAs(view.formID, "-"+prefix, prefix+"."+f.Name, Rows, f, fb)
if view.focus.matches(prefix, f.Name) {
fv.Attrs = template.HTMLAttr(string(fv.Attrs) + ` autofocus`)
}
dv.Fields = append(dv.Fields, fv)
continue
}
// Every other value the delta carries rides as a hidden input,
// so a batch of any size survives a request whether or not the
// tray shows the value as a control.
if d.Values.Present(f.Name) {
view.Hiddens = append(view.Hiddens, HiddenInput{Name: prefix + "." + f.Name, Value: d.Values.Raw(f.Name)})
}
}
dv.Undo = view.focus.focused(rowActionView("undo", d.Key, undoLabel, "secondary", action, target, "outerHTML", false))
view.Deltas = append(view.Deltas, dv)
}
commitTarget := rowActionTarget(b.CommitTarget, view.ID)
if commitTarget == "" {
commitTarget = target
}
wayOut := discardLabel
if spec.WayOut.Label != "" {
wayOut = spec.WayOut.Label
}
commitSwap := b.CommitSwap
if commitSwap == "" {
commitSwap = "outerHTML"
}
view.Apply = view.focus.focused(rowActionView("apply", "", view.Commit, "primary", spec.CommitAction, commitTarget, commitSwap, true))
view.Discard = view.focus.focused(rowActionView("discard", "", wayOut, "secondary", action, target, "outerHTML", false))
}
// deltaOptions are the option sets one delta's controls offer: its own
// where it declares them, the render's otherwise. A select whose choices
// depend on the record it changes (a stored value the console offers back
// but never writes by itself) needs the former.
func deltaOptions(b Binding, d StagedDelta) map[string][]Option {
return mergeOptions(b.Options, d.Options)
}
// mergeOptions lays own over base: a row's or a delta's option sets win over
// the render's for the fields they name, and the rest fall through.
func mergeOptions(base, own map[string][]Option) map[string][]Option {
if len(own) == 0 {
return base
}
merged := map[string][]Option{}
for k, v := range base {
merged[k] = v
}
for k, v := range own {
merged[k] = v
}
return merged
}
// The library's own words on a batch form's three standing controls. A
// declaration names the commit and may name the way out; nothing names
// Undo, because a delta's line is the library's.
const (
undoLabel = "Undo"
discardLabel = "Discard all"
)
// RowField returns one field of one record's row, for the caller's own cell
// to render through the field part: the declaration's label, its constraint
// attributes and its error wiring, under a name and a wrapper id that carry
// the row. The values and the refusals are that row's (Binding.Rows), so a
// refused staging renders every open editor's own text back.
//
// Two options. "label-hidden" renders the label d-lg-none: the column
// header labels the cell from lg up, and below lg, where the cells stack,
// the label is the only thing that does (design D11). "dense" is for a field
// the body lays out in a dense row of its own beside its table (the add
// form of staged-rule-changes, design D8): the wrapper takes the dense
// family's grid column from the control's rung and the declared Width; its
// error renders under the control as everywhere, and the row aligns to the
// top so a refused column grows alone (design D16).
func (v FormView) RowField(instance, field string, options ...string) FieldView {
if v.spec.Family != Rows {
panic(fmt.Sprintf("forms: %s is a %s form; RowField exists in the rows family", v.spec.Name, v.spec.Family))
}
f, ok := v.spec.Field(field)
if !ok {
panic(fmt.Sprintf("forms: %s declares no field %q", v.spec.Name, field))
}
if f.Placement != PlaceRow {
panic(fmt.Sprintf("forms: %s's field %q is placed in the %s, not in a row", v.spec.Name, field, f.Placement))
}
labelNarrowOnly, dense := false, false
for _, o := range options {
switch o {
case "label-hidden":
labelNarrowOnly = true
case "dense":
dense = true
default:
panic(fmt.Sprintf("forms: %s's RowField was passed the option %q; the options are \"label-hidden\" and \"dense\"", v.spec.Name, o))
}
}
rb := v.binding.Rows[instance]
b := Binding{
Mode: ModeRecord,
Values: rb.Values,
Errors: rb.Errors,
Options: mergeOptions(v.binding.Options, rb.Options),
Slots: v.binding.Slots,
}
fv := renderFieldAs(v.formID, "-"+instance, "rows."+instance+"."+field, Rows, f, b)
fv.LabelNarrowOnly = labelNarrowOnly
if dense {
fv.ColClass = denseColClass(f)
}
if v.focus.matches(instance, field) {
fv.Attrs = template.HTMLAttr(string(fv.Attrs) + ` autofocus`)
}
return fv
}
// RowAction returns one of a row's posting controls, for the caller's own
// cell to render through the row-action part. Every one of them posts the
// whole form to the declaration's path, carrying the act, the row's key,
// every open editor's values and the whole staged batch, and swaps the
// form's own region back (design D2). The variants are primary, secondary
// and danger; secondary and danger render outline, because solid secondary
// is not in the console's palette and a row's Remove stages a change rather
// than making one.
func (v FormView) RowAction(act, key, label, variant string) RowActionView {
if v.spec.Family != Rows {
panic(fmt.Sprintf("forms: %s is a %s form; RowAction exists in the rows family", v.spec.Name, v.spec.Family))
}
return v.focus.focused(rowActionView(act, key, label, variant, v.Action, rowActionTarget(v.Target, v.ID), "outerHTML", false))
}
// rowActionView builds one posting control. hx-vals is JSON built here, so
// no template composes one and a key carrying a quote cannot break out of
// the attribute.
func rowActionView(act, key, label, variant, action, target, swap string, indicator bool) RowActionView {
vals, err := json.Marshal(struct {
Act string `json:"act"`
Key string `json:"key"`
}{Act: act, Key: key})
if err != nil {
panic(fmt.Sprintf("forms: row action %q could not be written: %v", act, err))
}
hx := fmt.Sprintf(`hx-post=%q hx-vals='%s' hx-include="closest form" hx-target=%q hx-swap=%q`,
action, strings.ReplaceAll(string(vals), "'", "&#39;"), target, swap)
if indicator {
// The commit is the one control a double press could apply twice,
// and the form tag's own hx-disable names a submit button this
// family has none of: every control here is a type="button".
hx += ` hx-indicator="find .htmx-indicator" hx-disable="this"`
}
return RowActionView{
Act: act,
Key: key,
Label: label,
Variant: variant,
Classes: rowActionClasses(variant),
Indicator: indicator,
HX: template.HTMLAttr(hx),
}
}
// focused gives a posting control the autofocus attribute when the render
// named it: a row's Edit after a Cancel returns focus to the button the
// person pressed, rather than dropping it at the top of the swapped region
// (design D9). At most one control in a render is ever named, so at most
// one carries the attribute.
func (t focusTarget) focused(a RowActionView) RowActionView {
if t.matchesAction(a.Act, a.Key) {
a.HX = template.HTMLAttr(string(a.HX) + ` autofocus`)
}
return a
}
// rowActionTarget resolves a row action's hx-target. "this" on a form means
// the form element; on the button that carries the attribute it would mean
// the button, so the form's own id stands in for it (formID is FormView.ID,
// which already carries Binding.Instance).
func rowActionTarget(target, formID string) string {
if target == "this" {
return "#" + formID
}
return target
}
// rowActionClasses are the button's classes for one variant: one size for
// every control in the family, outline for the two that are not the commit.
func rowActionClasses(variant string) string {
switch variant {
case "primary":
return "btn btn-sm btn-primary ms-1"
case "secondary":
return "btn btn-sm btn-outline-secondary ms-1"
case "danger":
return "btn btn-sm btn-outline-danger ms-1"
}
panic(fmt.Sprintf("forms: %q is not a row action variant; they are primary, secondary and danger", variant))
}