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.
1386 lines
52 KiB
Go
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), "'", "'"), 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))
|
|
}
|