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.
603 lines
19 KiB
Go
603 lines
19 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package forms
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Parsing (design D6; spec form-library "The handler parses through the
|
|
// declaration"). A handler reads a mutation's body through the
|
|
// declaration and never through r.FormValue, which merges the query string
|
|
// into the body and hides which one a value came from. Parse reads only
|
|
// declared names, so the declaration is also the write allowlist: the CSRF
|
|
// token, htmx's own parameters and anything else a body carries are
|
|
// ignored (Django's fields warning, Rails' permit! warning; lesson L§15).
|
|
//
|
|
// Parse never builds a domain object. It returns the values and the
|
|
// errors; the handler adds its own checks by field name or at the form
|
|
// level and builds the object only once the error set is empty (L§16).
|
|
|
|
// Values holds one form's values: the raw submitted string, whether the
|
|
// name was present at all, and the typed value where the field's rules
|
|
// passed. Go cannot tell an absent string from an empty one, so presence
|
|
// is recorded beside the value (lesson L§4).
|
|
type Values struct {
|
|
raw map[string]string
|
|
present map[string]bool
|
|
typed map[string]any
|
|
}
|
|
|
|
// NewValues returns an empty value set, for binding a record to a form.
|
|
func NewValues() Values {
|
|
return Values{
|
|
raw: map[string]string{},
|
|
present: map[string]bool{},
|
|
typed: map[string]any{},
|
|
}
|
|
}
|
|
|
|
func (v *Values) init() {
|
|
if v.raw == nil {
|
|
v.raw = map[string]string{}
|
|
v.present = map[string]bool{}
|
|
v.typed = map[string]any{}
|
|
}
|
|
}
|
|
|
|
// Set records a field's raw value, marking it present. Binding a record to
|
|
// a form goes through here, so the record-bound render and the
|
|
// submission-bound render read the same way.
|
|
func (v *Values) Set(name, raw string) {
|
|
v.init()
|
|
v.raw[name] = raw
|
|
v.present[name] = true
|
|
}
|
|
|
|
// SetBool records a checkbox's state. A ticked box carries the field's
|
|
// submitted value; an unticked one carries nothing, which is how the
|
|
// browser submits it. Either way, the typed bool is recorded too, so a
|
|
// bound checkbox answers Bool the same way a parsed one does (design D1).
|
|
func (v *Values) SetBool(f Field, on bool) {
|
|
v.init()
|
|
if on {
|
|
v.Set(f.Name, f.CheckboxValue())
|
|
v.setTyped(f.Name, true)
|
|
return
|
|
}
|
|
v.raw[f.Name] = ""
|
|
v.present[f.Name] = false
|
|
v.setTyped(f.Name, false)
|
|
}
|
|
|
|
// Raw returns the submitted (or bound) string, trimmed as Parse trimmed it.
|
|
func (v Values) Raw(name string) string { return v.raw[name] }
|
|
|
|
// Present reports whether the name was submitted at all, which is not the
|
|
// same as carrying a value.
|
|
func (v Values) Present(name string) bool { return v.present[name] }
|
|
|
|
// String returns the value as text.
|
|
func (v Values) String(name string) string { return v.raw[name] }
|
|
|
|
// Int returns the value as a whole number, and false when the field is
|
|
// absent, empty, or was refused.
|
|
func (v Values) Int(name string) (int, bool) {
|
|
n, ok := v.typed[name].(int)
|
|
return n, ok
|
|
}
|
|
|
|
// Bool returns a checkbox's state. An absent checkbox is false, never an
|
|
// error (spec form-conventions "A checkbox is never required").
|
|
func (v Values) Bool(name string) bool {
|
|
b, _ := v.typed[name].(bool)
|
|
return b
|
|
}
|
|
|
|
// Time returns the value as an instant, and false when the field is
|
|
// absent, empty, or was refused.
|
|
func (v Values) Time(name string) (time.Time, bool) {
|
|
t, ok := v.typed[name].(time.Time)
|
|
return t, ok
|
|
}
|
|
|
|
// UUID returns the value as an identifier, and false when the field is
|
|
// absent, empty, or was refused.
|
|
func (v Values) UUID(name string) (uuid.UUID, bool) {
|
|
id, ok := v.typed[name].(uuid.UUID)
|
|
return id, ok
|
|
}
|
|
|
|
// setTyped records a field's typed value; an errored field never gets one,
|
|
// so a handler that reads a typed value is reading a value that passed.
|
|
func (v *Values) setTyped(name string, val any) {
|
|
v.init()
|
|
v.typed[name] = val
|
|
}
|
|
|
|
// Errors is one form's refusals: a message per field, plus the form-level
|
|
// message for a refusal that belongs to no field. The form-level slot is
|
|
// reached by the same call with an empty field name (lesson L§11).
|
|
type Errors struct {
|
|
fields map[string]string
|
|
form string
|
|
}
|
|
|
|
// NewErrors returns an empty error set.
|
|
func NewErrors() *Errors { return &Errors{fields: map[string]string{}} }
|
|
|
|
// Field records a refusal under one field. The last message wins: the
|
|
// convention is one concise message per field, not a list.
|
|
func (e *Errors) Field(name, msg string) {
|
|
if e.fields == nil {
|
|
e.fields = map[string]string{}
|
|
}
|
|
if name == "" {
|
|
e.form = msg
|
|
return
|
|
}
|
|
e.fields[name] = msg
|
|
}
|
|
|
|
// Form records a refusal that belongs to no field.
|
|
func (e *Errors) Form(msg string) { e.form = msg }
|
|
|
|
// Get returns one field's message, or the empty string.
|
|
func (e *Errors) Get(name string) string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
return e.fields[name]
|
|
}
|
|
|
|
// FormError returns the form-level message, or the empty string.
|
|
func (e *Errors) FormError() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
return e.form
|
|
}
|
|
|
|
// Has reports whether one field was refused.
|
|
func (e *Errors) Has(name string) bool {
|
|
if e == nil {
|
|
return false
|
|
}
|
|
_, ok := e.fields[name]
|
|
return ok
|
|
}
|
|
|
|
// Any reports whether anything was refused. This is the handler's branch
|
|
// point: build the domain object only when it is false.
|
|
func (e *Errors) Any() bool {
|
|
if e == nil {
|
|
return false
|
|
}
|
|
return len(e.fields) > 0 || e.form != ""
|
|
}
|
|
|
|
// Count returns how many fields were refused, the form level included.
|
|
func (e *Errors) Count() int {
|
|
if e == nil {
|
|
return 0
|
|
}
|
|
n := len(e.fields)
|
|
if e.form != "" {
|
|
n++
|
|
}
|
|
return n
|
|
}
|
|
|
|
// Parse reads r through the declaration and returns the values and the
|
|
// refusals. It reads r.PostForm for a mutation and r.URL.Query() for a
|
|
// search form, never r.FormValue, so the query string cannot shadow a
|
|
// body field. Repeated names on a scalar field take the first value, as
|
|
// Go's own form parsing does; a multi-valued control does not exist.
|
|
func (s FormSpec) Parse(r *http.Request) (Values, *Errors) {
|
|
return s.ParseSide(r, BothSides, nil)
|
|
}
|
|
|
|
// ParseWith is Parse with the option sets of the fields whose options are
|
|
// loaded per request (a picker of the deployment's entitlement sets). A
|
|
// submitted value outside a field's options is a refusal, never a silent
|
|
// default (finding FA-8).
|
|
func (s FormSpec) ParseWith(r *http.Request, options map[string][]Option) (Values, *Errors) {
|
|
return s.ParseSide(r, BothSides, options)
|
|
}
|
|
|
|
// ParseSide is ParseWith restricted to the fields that render on one side,
|
|
// so a create submission cannot carry an edit-only field and an edit
|
|
// submission cannot carry a create-only one. The side the form rendered is
|
|
// the side it is read as; BothSides reads every declared field, which is
|
|
// right for a form with one side.
|
|
func (s FormSpec) ParseSide(r *http.Request, side Side, options map[string][]Option) (Values, *Errors) {
|
|
values := NewValues()
|
|
errs := NewErrors()
|
|
|
|
var source map[string][]string
|
|
if s.Kind == KindSearch {
|
|
source = r.URL.Query()
|
|
} else {
|
|
if err := r.ParseForm(); err != nil {
|
|
errs.Form("The form could not be read. Please try again.")
|
|
return values, errs
|
|
}
|
|
source = r.PostForm
|
|
}
|
|
|
|
fields := make([]Field, 0, len(s.Fields))
|
|
for _, f := range s.Fields {
|
|
if side != BothSides && !f.RendersOn(side) {
|
|
continue
|
|
}
|
|
if f.Control == Static {
|
|
// A static row states a fact where a control would be; it
|
|
// carries no name, submits nothing, and is never parsed
|
|
// (design D21). Reading one would invent a value the page
|
|
// never offered.
|
|
continue
|
|
}
|
|
fields = append(fields, f)
|
|
}
|
|
s.parseFields(source, fields, "", "", options, &values, errs)
|
|
return values, errs
|
|
}
|
|
|
|
// parseFields reads one group of fields out of source and applies the
|
|
// declaration's rules to each. namePrefix is what the submitted names carry
|
|
// ahead of the field name and errPrefix what the error keys do: both empty
|
|
// for an ordinary form, "rows.<instance>." or "staged.<i>." for one group
|
|
// of a batch, so one set of rules reads a form, a row and a delta alike and
|
|
// none of them can drift from the others.
|
|
func (s FormSpec) parseFields(source map[string][]string, fields []Field, namePrefix, errPrefix string, options map[string][]Option, out *Values, errs *Errors) {
|
|
values := out
|
|
for _, f := range fields {
|
|
key := errPrefix + f.Name
|
|
submitted := namePrefix + f.Name
|
|
if !f.Shown(*values) {
|
|
// A field the operator never saw (its ShowIf condition on an
|
|
// earlier field's value did not match) is neither validated
|
|
// nor carried into Values: the control was not on the page,
|
|
// so nothing was there to type (design D2, ShowIf's doc on
|
|
// the trust model). ShowIf.Field must therefore be declared
|
|
// earlier in Fields, so its value is already in Values here.
|
|
continue
|
|
}
|
|
vals, present := source[submitted]
|
|
raw := ""
|
|
if present && len(vals) > 0 {
|
|
raw = vals[0]
|
|
}
|
|
raw = strings.TrimSpace(raw)
|
|
|
|
if f.Control == Checkbox {
|
|
// A checkbox's absence is false and never an error; a ticked
|
|
// box submits the declared value (lessons L§21, L§34).
|
|
on := present && raw == f.CheckboxValue()
|
|
values.raw[f.Name] = raw
|
|
values.present[f.Name] = present
|
|
values.setTyped(f.Name, on)
|
|
continue
|
|
}
|
|
|
|
values.raw[f.Name] = raw
|
|
values.present[f.Name] = present
|
|
|
|
opts := f.Options
|
|
if runtime, ok := options[f.Name]; ok {
|
|
opts = runtime
|
|
}
|
|
|
|
if f.Control == Select || f.Control == Radio {
|
|
if !optionExists(opts, raw) {
|
|
if raw == "" {
|
|
// A Radio group has no "choose nothing" member the way a
|
|
// Select's enabled empty option does (render.go's
|
|
// fieldAttrs mirrors this): an Optional radio group left
|
|
// untouched is a legitimate empty submission, not a
|
|
// missing choice.
|
|
if f.Control == Radio && f.Optional {
|
|
continue
|
|
}
|
|
errs.Field(key, chooseMessage(f))
|
|
} else {
|
|
errs.Field(key, "Choose "+article(f.Label)+" from the list.")
|
|
}
|
|
continue
|
|
}
|
|
if raw == "" {
|
|
// A deliberate empty option (the edit side's "None") is a
|
|
// legitimate submission that clears the column.
|
|
continue
|
|
}
|
|
s.assignTyped(values, f, key, raw, errs)
|
|
continue
|
|
}
|
|
|
|
if raw == "" {
|
|
if !f.Optional {
|
|
errs.Field(key, enterMessage(f))
|
|
}
|
|
continue
|
|
}
|
|
if f.MaxLen > 0 && len([]rune(raw)) > f.MaxLen {
|
|
errs.Field(key, fmt.Sprintf("%s must be %d characters or fewer.", f.Label, f.MaxLen))
|
|
continue
|
|
}
|
|
if f.Pattern != "" {
|
|
re, err := regexp.Compile(f.Pattern)
|
|
if err != nil || !re.MatchString(raw) {
|
|
msg := f.PatternHint
|
|
if msg == "" {
|
|
msg = f.Label + " is not in the expected format."
|
|
}
|
|
errs.Field(key, msg)
|
|
continue
|
|
}
|
|
}
|
|
s.assignTyped(values, f, key, raw, errs)
|
|
}
|
|
}
|
|
|
|
// assignTyped converts one passing raw value and applies the range rules
|
|
// the control has. A field that fails here keeps its raw text for the
|
|
// re-render and gets no typed value (lesson L§11, finding FA-23).
|
|
func (s FormSpec) assignTyped(values *Values, f Field, key, raw string, errs *Errors) {
|
|
switch f.Control {
|
|
case Number:
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
errs.Field(key, f.Label+" must be a whole number.")
|
|
return
|
|
}
|
|
if f.Min != "" {
|
|
if min, err := strconv.Atoi(f.Min); err == nil && n < min {
|
|
errs.Field(key, fmt.Sprintf("%s must be %d or more.", f.Label, min))
|
|
return
|
|
}
|
|
}
|
|
if f.Max != "" {
|
|
if max, err := strconv.Atoi(f.Max); err == nil && n > max {
|
|
errs.Field(key, fmt.Sprintf("%s must be %d or fewer.", f.Label, max))
|
|
return
|
|
}
|
|
}
|
|
values.setTyped(f.Name, n)
|
|
case Date, DateTime:
|
|
layout := "2006-01-02"
|
|
if f.Control == DateTime {
|
|
layout = "2006-01-02T15:04"
|
|
}
|
|
t, err := time.Parse(layout, raw)
|
|
if err != nil {
|
|
errs.Field(key, f.Label+" is not a valid date.")
|
|
return
|
|
}
|
|
if f.Min != "" {
|
|
if min, err := time.Parse(layout, f.Min); err == nil && t.Before(min) {
|
|
errs.Field(key, f.Label+" is earlier than this form allows.")
|
|
return
|
|
}
|
|
}
|
|
if f.Max != "" {
|
|
if max, err := time.Parse(layout, f.Max); err == nil && t.After(max) {
|
|
errs.Field(key, f.Label+" is later than this form allows.")
|
|
return
|
|
}
|
|
}
|
|
values.setTyped(f.Name, t)
|
|
case Select, Radio, Hidden:
|
|
// A picker's values are usually identifiers, read back with
|
|
// Values.UUID. Parsing here keeps the handler from parsing a
|
|
// second time and disagreeing about what a bad value means; a
|
|
// value that is not an identifier simply has no typed form, which
|
|
// is correct for the pickers whose values are enum strings.
|
|
if id, err := uuid.Parse(raw); err == nil {
|
|
values.setTyped(f.Name, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// optionExists reports whether value is one the control offered as a
|
|
// choice. A disabled option is not one: it is the "Choose a ..."
|
|
// placeholder, which a browser will not submit and the server must not
|
|
// accept, so a select that needs a choice refuses the empty value while an
|
|
// optional select, whose first option is an enabled "None", accepts it.
|
|
func optionExists(options []Option, value string) bool {
|
|
for _, o := range options {
|
|
if o.Disabled {
|
|
continue
|
|
}
|
|
if o.Value == value {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// enterMessage is the refusal for a required text-shaped field.
|
|
func enterMessage(f Field) string {
|
|
return "Enter " + article(f.Label) + "."
|
|
}
|
|
|
|
// chooseMessage is the refusal for a select or a radio group.
|
|
func chooseMessage(f Field) string {
|
|
return "Choose " + article(f.Label) + "."
|
|
}
|
|
|
|
// article lowercases a label and prefixes the indefinite article, so
|
|
// "Entitlement set" becomes "an entitlement set" and the two refusal
|
|
// messages read as sentences rather than as field names.
|
|
func article(label string) string {
|
|
if label == "" {
|
|
return "a value"
|
|
}
|
|
lower := strings.ToLower(label)
|
|
switch lower[0] {
|
|
case 'a', 'e', 'i', 'o', 'u':
|
|
return "an " + lower
|
|
}
|
|
return "a " + lower
|
|
}
|
|
|
|
// Batch is one request through a batch form's declaration (design D1's
|
|
// "Rows family contract"; spec form-library "A batch form renders the
|
|
// caller's rows and one tray"). Every posting control of a Rows form sends
|
|
// the whole form, so one request carries what was pressed, every open
|
|
// editor's typed values, the whole staged batch and the tray's own fields;
|
|
// ParseBatch reads all four through the declaration and nothing else.
|
|
//
|
|
// ParseBatch validates shape, not domain: the handler decides what Act
|
|
// means, re-reads the records the batch names, and refuses a delta whose
|
|
// record has moved under it (design D3).
|
|
type Batch struct {
|
|
// Act is what was pressed and Key the record it was pressed on, from
|
|
// the row action's own hx-vals. Both are the library's names, not
|
|
// declared fields: a declaration names controls, and these name the
|
|
// press.
|
|
Act string
|
|
Key string
|
|
// Open are the record keys whose editors were open, in the order the
|
|
// hidden inputs carried them.
|
|
Open []string
|
|
// Rows are those editors' values, keyed by record key, each read
|
|
// through the row fields' own rules.
|
|
Rows map[string]Values
|
|
// Staged is the batch as submitted, in index order, each delta's values
|
|
// read through the row and delta fields' rules. Line and Error are the
|
|
// render's, never the request's, so both are empty here.
|
|
Staged []StagedDelta
|
|
// Tray holds the tray's own fields.
|
|
Tray Values
|
|
// Errors are the refusals, keyed "rows.<instance>.<field>",
|
|
// "staged.<i>.<field>", the tray field's own name, and the form level.
|
|
Errors *Errors
|
|
}
|
|
|
|
// Row returns one open editor as the render wants it: its values, and its
|
|
// refusals under the declaration's bare field names, so the handler hands
|
|
// back what it parsed without re-keying anything by hand.
|
|
func (b Batch) Row(instance string) RowBinding {
|
|
rb := RowBinding{Values: b.Rows[instance], Errors: NewErrors()}
|
|
prefix := "rows." + instance + "."
|
|
for key, msg := range b.Errors.fields {
|
|
if strings.HasPrefix(key, prefix) {
|
|
rb.Errors.Field(strings.TrimPrefix(key, prefix), msg)
|
|
}
|
|
}
|
|
return rb
|
|
}
|
|
|
|
// maxStagedDeltas caps how many deltas one request may carry. The render
|
|
// writes one group per staged change and a set holds tens of rules, so the
|
|
// cap is out of reach in use; it exists because the groups arrive as
|
|
// numbered names a forged body could number as high as it likes.
|
|
const maxStagedDeltas = 200
|
|
|
|
// ParseBatch reads a batch form's request through its declaration.
|
|
func (s FormSpec) ParseBatch(r *http.Request) Batch {
|
|
return s.ParseBatchWith(r, nil)
|
|
}
|
|
|
|
// ParseBatchWith is ParseBatch with the option sets of the fields whose
|
|
// options are loaded per request, exactly as ParseWith is to Parse: a
|
|
// submitted value outside a field's options is a refusal. A batch form
|
|
// whose select offers a per-record option (a stored value the console
|
|
// offers back rather than writes) passes every value it ever renders here,
|
|
// because one request carries every row and every delta at once.
|
|
func (s FormSpec) ParseBatchWith(r *http.Request, options map[string][]Option) Batch {
|
|
if s.Family != Rows {
|
|
panic(fmt.Sprintf("forms: %s is a %s form; ParseBatch reads a batch form", s.Name, s.Family))
|
|
}
|
|
batch := Batch{Rows: map[string]Values{}, Tray: NewValues(), Errors: NewErrors()}
|
|
if err := r.ParseForm(); err != nil {
|
|
batch.Errors.Form("The form could not be read. Please try again.")
|
|
return batch
|
|
}
|
|
source := r.PostForm
|
|
|
|
batch.Act = strings.TrimSpace(firstValue(source, "act"))
|
|
batch.Key = strings.TrimSpace(firstValue(source, "key"))
|
|
|
|
seen := map[string]bool{}
|
|
for _, raw := range source["open"] {
|
|
instance := strings.TrimSpace(raw)
|
|
if instance == "" || seen[instance] {
|
|
continue
|
|
}
|
|
seen[instance] = true
|
|
batch.Open = append(batch.Open, instance)
|
|
}
|
|
|
|
rowFields := s.placed(PlaceRow)
|
|
deltaFields := s.placed(PlaceRow, PlaceDelta)
|
|
trayFields := s.placed(PlaceTray)
|
|
|
|
// A row that is not open submits nothing and is not read: its record is
|
|
// on the page as text, and requiredness applies to what someone typed.
|
|
for _, instance := range batch.Open {
|
|
prefix := "rows." + instance + "."
|
|
values := NewValues()
|
|
s.parseFields(source, rowFields, prefix, prefix, options, &values, batch.Errors)
|
|
batch.Rows[instance] = values
|
|
}
|
|
|
|
for i := 0; ; i++ {
|
|
prefix := stagedPrefix(i) + "."
|
|
_, hasVerb := source[prefix+"verb"]
|
|
_, hasKey := source[prefix+"key"]
|
|
if !hasVerb && !hasKey {
|
|
break
|
|
}
|
|
if i >= maxStagedDeltas {
|
|
batch.Errors.Form(fmt.Sprintf("A batch holds at most %d changes.", maxStagedDeltas))
|
|
break
|
|
}
|
|
values := NewValues()
|
|
s.parseFields(source, deltaFields, prefix, prefix, options, &values, batch.Errors)
|
|
batch.Staged = append(batch.Staged, StagedDelta{
|
|
Verb: strings.TrimSpace(firstValue(source, prefix+"verb")),
|
|
Key: strings.TrimSpace(firstValue(source, prefix+"key")),
|
|
Values: values,
|
|
})
|
|
}
|
|
|
|
s.parseFields(source, trayFields, "", "", options, &batch.Tray, batch.Errors)
|
|
return batch
|
|
}
|
|
|
|
// placed returns the declared fields with one of these placements, in
|
|
// declaration order, with the static rows dropped for the same reason Parse
|
|
// drops them: a row that states a fact submits nothing.
|
|
func (s FormSpec) placed(placements ...Placement) []Field {
|
|
want := map[Placement]bool{}
|
|
for _, p := range placements {
|
|
want[p] = true
|
|
}
|
|
out := make([]Field, 0, len(s.Fields))
|
|
for _, f := range s.Fields {
|
|
if f.Control == Static || !want[f.Placement] {
|
|
continue
|
|
}
|
|
out = append(out, f)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// firstValue returns the first submitted value under a name, as Go's own
|
|
// form parsing does for a scalar field.
|
|
func firstValue(source map[string][]string, name string) string {
|
|
if vals, ok := source[name]; ok && len(vals) > 0 {
|
|
return vals[0]
|
|
}
|
|
return ""
|
|
}
|