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

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 ""
}