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

992 lines
38 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package forms
import (
"strings"
"testing"
)
// The three modes and the rules the render decides (design D5, D8, D9,
// D11, D12). The markup itself is asserted where the parts execute
// (internal/server's render tests); these assert the view the parts are
// handed, which is where every decision is made.
func sided() FormSpec {
return FormSpec{
Name: "test.sided",
Kind: KindCreate,
Family: Stacked,
Method: "POST",
Path: "/partials/test",
EditMethod: "PUT",
EditPath: "/partials/test/{id}",
Commit: "Create",
CommitEdit: "Save changes",
WayOut: LinkOut("Cancel", "/list"),
Fields: []Field{
{Name: "name", Label: "Name", Control: Text},
// A record is created active, so Active is edit-only; the
// why is this comment, not a string in the declaration.
{Name: "is_active", Label: "Active", Control: Checkbox, Value: "true", Only: EditOnly},
{Name: "seed", Label: "Seed", Control: Text, Optional: true,
Only: CreateOnly, Notice: "Set now; this cannot be changed later."},
},
}
}
func fieldNames(v FormView) []string {
out := make([]string, 0, len(v.Fields))
for _, f := range v.Fields {
out = append(out, f.Name)
}
return out
}
func TestRenderSidesCarryTheirOwnFields(t *testing.T) {
create := Render(sided(), Binding{Mode: ModeUnbound, Side: CreateOnly})
if got := strings.Join(fieldNames(create), ","); got != "name,seed" {
t.Errorf("create side fields = %q", got)
}
edit := Render(sided(), Binding{Mode: ModeRecord, Side: EditOnly})
if got := strings.Join(fieldNames(edit), ","); got != "name,is_active" {
t.Errorf("edit side fields = %q", got)
}
// The declared reason documents the difference for the registry and
// the reviewer; it is never copy, so no field renders it (maintainer,
// 2026-09-03). Only a declared Notice reaches the page.
for _, f := range edit.Fields {
if f.Name == "is_active" && f.Notice != "" {
t.Errorf("a one-sided field renders nothing unless it declares a notice, got %q", f.Notice)
}
}
for _, f := range create.Fields {
if f.Name == "seed" && f.Notice != "Set now; this cannot be changed later." {
t.Errorf("a declared notice must render, got %q", f.Notice)
}
}
}
// The create page and the edit form are one declaration in two modes: the
// kind, the commit label, the route and the way out follow the side, and
// nothing else does (design D5; finding FA-19).
func TestRenderSideDecidesKindCommitRouteAndWayOut(t *testing.T) {
create := Render(sided(), Binding{Mode: ModeUnbound, Side: CreateOnly})
if create.Kind != KindCreate || create.Commit != "Create" || !create.WayOut.Shown() {
t.Errorf("create side = %+v", create)
}
if !strings.Contains(string(create.HX), `hx-post="/partials/test"`) {
t.Errorf("create side verb = %q", create.HX)
}
edit := Render(sided(), Binding{Mode: ModeRecord, Side: EditOnly, Action: "/partials/test/7"})
if edit.Kind != KindEdit || edit.Commit != "Save changes" {
t.Errorf("edit side = %+v", edit)
}
if edit.WayOut.Shown() {
t.Error("an always-open edit form carries no way out")
}
if !strings.Contains(string(edit.HX), `hx-put="/partials/test/7"`) {
t.Errorf("edit side verb = %q", edit.HX)
}
}
// Every mutation form locks its commit and shows the request in flight
// (finding FA-20).
func TestRenderEveryMutationDisablesItsCommit(t *testing.T) {
v := Render(sided(), Binding{Mode: ModeUnbound, Side: CreateOnly})
for _, want := range []string{`hx-disable="find button[type=submit]"`, `hx-target="this"`, `hx-swap="outerHTML"`} {
if !strings.Contains(string(v.HX), want) {
t.Errorf("form attributes %q missing %q", v.HX, want)
}
}
}
func TestRenderIdsAreStable(t *testing.T) {
v := Render(sided(), Binding{Mode: ModeUnbound, Side: CreateOnly})
if v.ID != "form-test.sided" {
t.Errorf("form id = %q", v.ID)
}
if v.Fields[0].WrapperID != "form-test.sided-name" {
t.Errorf("wrapper id = %q", v.Fields[0].WrapperID)
}
if v.FormErrorID != "form-test.sided-error" {
t.Errorf("form error id = %q", v.FormErrorID)
}
}
// The hint survives the error, both carry ids the control names, and the
// first errored control takes focus (findings FA-48, FA-49; lesson L§41).
func TestRenderSubmissionWiresHintErrorAndFocus(t *testing.T) {
spec := sided()
spec.Fields[0].Hint = "As it appears on invoices."
values := NewValues()
values.Set("name", "typed")
errs := NewErrors()
errs.Field("name", "Enter a name.")
v := Render(spec, Binding{Mode: ModeSubmission, Side: CreateOnly, Values: values, Errors: errs})
f := v.Fields[0]
if f.Value != "typed" {
t.Errorf("the submitted value must be carried back, got %q", f.Value)
}
if f.Hint == "" || f.HintID == "" {
t.Error("the hint must survive the error, with its id")
}
if f.ErrorID == "" || f.Error == "" {
t.Error("the error must render with its id")
}
attrs := string(f.Attrs)
if !strings.Contains(attrs, `aria-describedby="form-test.sided-name-hint form-test.sided-name-error"`) {
t.Errorf("described-by wiring = %q", attrs)
}
if !strings.Contains(attrs, `aria-invalid="true"`) {
t.Errorf("aria-invalid missing from %q", attrs)
}
if !strings.HasSuffix(attrs, "autofocus") {
t.Errorf("the first errored control must take focus, got %q", attrs)
}
// Only the first errored control takes it.
if strings.Count(attrs+string(v.Fields[1].Attrs), "autofocus") != 1 {
t.Error("exactly one control carries autofocus")
}
}
// An edit form never opens showing errors (lessons L§6, L§7).
func TestRenderRecordModeHasNoErrors(t *testing.T) {
values := NewValues()
values.Set("name", "Pro")
v := Render(sided(), Binding{Mode: ModeRecord, Side: EditOnly, Values: values})
for _, f := range v.Fields {
if f.Invalid() {
t.Errorf("field %q renders an error in record mode", f.Name)
}
if strings.Contains(string(f.Attrs), "autofocus") {
t.Errorf("field %q takes focus in record mode", f.Name)
}
}
if v.FormError != "" {
t.Error("record mode renders no form-level error")
}
}
// The constraint attributes come from the declaration and from nowhere
// else, so the browser's rule and the server's rule cannot differ (spec
// form-library "Constraint attributes derive from the declaration").
func TestRenderConstraintAttributesFollowTheDeclaration(t *testing.T) {
spec := base()
spec.Fields[0] = Field{
Name: "code", Label: "Code", Control: Text, MaxLen: 8,
Pattern: "^[a-z]+$", PatternHint: "Lowercase letters only.",
Inputmode: "text", Autocomplete: "off",
}
v := Render(spec, Binding{Mode: ModeUnbound})
attrs := string(v.Fields[0].Attrs)
for _, want := range []string{`required`, `maxlength="8"`, `pattern="^[a-z]+$"`, `inputmode="text"`, `autocomplete="off"`} {
if !strings.Contains(attrs, want) {
t.Errorf("attributes %q missing %q", attrs, want)
}
}
}
// A currency amount's Step ("0.01") renders alongside Min so the browser's
// spinner and the server's cents-precision rule agree.
func TestRenderStepAttribute(t *testing.T) {
spec := base()
spec.Fields[0] = Field{Name: "amount", Label: "Amount", Control: Number, Min: "0.01", Step: "0.01"}
v := Render(spec, Binding{Mode: ModeUnbound})
attrs := string(v.Fields[0].Attrs)
if !strings.Contains(attrs, `step="0.01"`) {
t.Errorf("attributes %q missing step", attrs)
}
}
// A select's requiredness is its option set: a "Choose a ..." placeholder
// is disabled, so the control is required; an enabled "None" makes the
// empty value a choice, so it is not (design D12).
func TestRenderSelectRequirednessFollowsItsOptions(t *testing.T) {
spec := base()
spec.Fields[0] = Field{Name: "kind", Label: "Kind", Control: Select, RuntimeOptions: true}
needed := Render(spec, Binding{Mode: ModeUnbound, Options: map[string][]Option{
"kind": {ChooseOption("a kind"), {Value: "a", Label: "A"}},
}})
if !strings.Contains(string(needed.Fields[0].Attrs), "required") {
t.Errorf("a select that needs a choice is required, got %q", needed.Fields[0].Attrs)
}
if !needed.Fields[0].Options[0].Selected || !needed.Fields[0].Options[0].Disabled {
t.Error("the placeholder opens disabled and selected")
}
optional := Render(spec, Binding{Mode: ModeUnbound, Options: map[string][]Option{
"kind": {NoneOption(), {Value: "a", Label: "A"}},
}})
if strings.Contains(string(optional.Fields[0].Attrs), "required") {
t.Errorf("an optional select is not required, got %q", optional.Fields[0].Attrs)
}
}
// A radio group has no "choose nothing" member the way a Select's enabled
// empty option does, so its requiredness follows Optional directly, like
// every other control except Select (forms.go's Field.Optional doc).
func TestRenderRadioRequirednessFollowsOptional(t *testing.T) {
spec := base()
spec.Fields[0] = Field{Name: "disposition", Label: "Disposition", Control: Radio,
Options: []Option{{Value: "a", Label: "A"}, {Value: "b", Label: "B"}}}
required := Render(spec, Binding{Mode: ModeUnbound})
if !strings.Contains(string(required.Fields[0].Attrs), "required") {
t.Errorf("a required radio group carries required, got %q", required.Fields[0].Attrs)
}
spec.Fields[0].Optional = true
optional := Render(spec, Binding{Mode: ModeUnbound})
if strings.Contains(string(optional.Fields[0].Attrs), "required") {
t.Errorf("an optional radio group is not required, got %q", optional.Fields[0].Attrs)
}
}
// A checkbox reads its state back from the bound values, so a refusal
// cannot re-tick a box the operator cleared (finding FA-23).
func TestRenderCheckboxCarriesItsStateBack(t *testing.T) {
spec := base()
spec.Fields[0] = Field{Name: "public", Label: "Public", Control: Checkbox, Value: "public"}
values := NewValues()
values.SetBool(spec.Fields[0], false)
v := Render(spec, Binding{Mode: ModeSubmission, Values: values, Errors: NewErrors()})
if v.Fields[0].Checked {
t.Error("an unticked checkbox must render unticked on the refusal")
}
values.SetBool(spec.Fields[0], true)
v = Render(spec, Binding{Mode: ModeSubmission, Values: values, Errors: NewErrors()})
if !v.Fields[0].Checked {
t.Error("a ticked checkbox must render ticked on the refusal")
}
}
// A dense form is one row of small controls with each field in its
// declared column (design D10).
func TestRenderDenseFamily(t *testing.T) {
spec := base()
spec.Family = Dense
spec.Fields[0].Width = WidthNarrower
spec.Fields[0].HideLabel = true
v := Render(spec, Binding{Mode: ModeUnbound})
if !v.Dense {
t.Error("the view must report the dense family")
}
// A Text control's natural rung is a third; narrower steps it to a
// quarter.
if v.Fields[0].ColClass != "col-12 col-xl-3" || !v.Fields[0].Small || !v.Fields[0].LabelHidden {
t.Errorf("dense field = %+v", v.Fields[0])
}
}
// ColClass follows the resolved rung for every control the library renders
// in a dense row (design D3, D5): each control's natural rung with no
// declared Width, and the two declarations the codebase actually makes (a
// narrower select, a wider text).
func TestRenderDenseColClass(t *testing.T) {
oneField := func(f Field) FormSpec {
spec := denseBase()
spec.Fields = []Field{f}
return spec
}
for _, tc := range []struct {
name string
field Field
want string
}{
{"number", Field{Name: "n", Label: "N", Control: Number}, "col-12 col-xl-2"},
{"date", Field{Name: "d", Label: "D", Control: Date}, "col-12 col-xl-2"},
{"date-time", Field{Name: "dt", Label: "DT", Control: DateTime}, "col-12 col-xl-3"},
{"select", Field{Name: "s", Label: "S", Control: Select, Options: []Option{{Value: "a", Label: "A"}}}, "col-12 col-xl-4"},
{"text", Field{Name: "t", Label: "T", Control: Text}, "col-12 col-xl-4"},
{"textarea", Field{Name: "ta", Label: "TA", Control: Textarea}, "col-12"},
{"checkbox", Field{Name: "c", Label: "C", Control: Checkbox, Value: "true"}, "col-12 col-xl-auto"},
{"hidden", Field{Name: "h", Label: "H", Control: Hidden, Optional: true}, ""},
{"narrower select", Field{Name: "s2", Label: "S2", Control: Select, Options: []Option{{Value: "a", Label: "A"}}, Width: WidthNarrower}, "col-12 col-xl-3"},
{"wider text", Field{Name: "t2", Label: "T2", Control: Text, Width: WidthWider}, "col-12 col-xl-6"},
} {
t.Run(tc.name, func(t *testing.T) {
v := Render(oneField(tc.field), Binding{Mode: ModeUnbound})
if got := v.Fields[0].ColClass; got != tc.want {
t.Errorf("ColClass = %q, want %q", got, tc.want)
}
})
}
}
// The escape hatch renders in a stable order, because the capture utility
// compares pixels between runs.
func TestRenderEscapeHatchIsOrdered(t *testing.T) {
spec := base()
spec.Fields[0].Attrs = map[string]string{"hx-trigger": "input", "hx-get": "/check", "data-role": "x"}
first := string(Render(spec, Binding{Mode: ModeUnbound}).Fields[0].Attrs)
for i := 0; i < 20; i++ {
if got := string(Render(spec, Binding{Mode: ModeUnbound}).Fields[0].Attrs); got != first {
t.Fatalf("attribute order is not stable:\n%s\n%s", first, got)
}
}
if !strings.Contains(first, `data-role="x" hx-get="/check" hx-trigger="input"`) {
t.Errorf("attributes = %q", first)
}
}
// A search form keeps a real method and action, which is the recorded
// no-JavaScript answer for list controls and the lookup.
func TestRenderSearchIsNative(t *testing.T) {
spec := base()
spec.Kind = KindSearch
spec.Method = "GET"
spec.Path = "/operator/lookup"
v := Render(spec, Binding{Mode: ModeUnbound})
if !v.Native || v.Method != "GET" || v.Action != "/operator/lookup" {
t.Errorf("search form = %+v", v)
}
if strings.Contains(string(v.HX), "hx-disable") {
t.Errorf("a search form locks nothing, got %q", v.HX)
}
}
// Every form whose refusal is a 422 carries novalidate, so pressing the
// commit with an empty required field reaches the handler and the message
// a person reads is the server's (maintainer, 2026-09-03: pressing Save on
// a cleared name produced no feedback at all). The constraint attributes
// stay on the controls.
func TestRenderServerValidatedFormsCarryNoValidate(t *testing.T) {
for _, kind := range []Kind{KindCreate, KindEdit, KindSubRecord, KindSettings, KindPreview} {
spec := base()
spec.Kind = kind
if !Render(spec, Binding{Mode: ModeUnbound}).NoValidate {
t.Errorf("a %s form must carry novalidate", kind)
}
}
for _, kind := range []Kind{KindSearch, KindConfirm} {
spec := base()
spec.Kind = kind
if kind == KindSearch {
spec.Method = "GET"
}
if Render(spec, Binding{Mode: ModeUnbound}).NoValidate {
t.Errorf("a %s form navigates or confirms; it keeps the browser's validation", kind)
}
}
// The rule does not take the constraints off the control.
spec := base()
spec.Fields[0].MaxLen = 8
attrs := string(Render(spec, Binding{Mode: ModeUnbound}).Fields[0].Attrs)
if !strings.Contains(attrs, "required") || !strings.Contains(attrs, `maxlength="8"`) {
t.Errorf("constraint attributes must survive novalidate, got %q", attrs)
}
}
// The edit side's kind decides the attribute too, since one declaration
// filed as a create form renders both sides.
func TestRenderNoValidateOnBothSides(t *testing.T) {
if !Render(sided(), Binding{Mode: ModeUnbound, Side: CreateOnly}).NoValidate {
t.Error("the create side must carry novalidate")
}
if !Render(sided(), Binding{Mode: ModeRecord, Side: EditOnly}).NoValidate {
t.Error("the edit side must carry novalidate")
}
}
// showIfSpec is the field-set-depends-on-a-value shape entitlement-set rule
// authoring needs: a Hidden marker a handler sets from server-derived data,
// and a field that shows only for one of the marker's values (design D2,
// ShowIf).
func showIfSpec() FormSpec {
return FormSpec{
Name: "test.showif", Kind: KindSubRecord, Family: Dense,
Method: "POST", Path: "/partials/test", Commit: "Add",
Fields: []Field{
{Name: "kind", Label: "Kind", Control: Hidden, Optional: true},
{Name: "amount", Label: "Amount", Control: Number, Optional: true,
ShowIf: ShowIf{Field: "kind", Equals: []string{"numeric"}}},
},
}
}
func TestRenderShowIfHidesAFieldByDefault(t *testing.T) {
view := Render(showIfSpec(), Binding{Mode: ModeUnbound})
if got := fieldNames(view); len(got) != 1 || got[0] != "kind" {
t.Errorf("with no marker value set, only the marker field renders, got %v", got)
}
}
func TestRenderShowIfRevealsAFieldWhenItsConditionMatches(t *testing.T) {
values := NewValues()
values.Set("kind", "numeric")
view := Render(showIfSpec(), Binding{Mode: ModeRecord, Values: values})
if got := fieldNames(view); len(got) != 2 || got[1] != "amount" {
t.Errorf("with the marker set to a matching value, the dependent field renders, got %v", got)
}
}
func TestRenderShowIfHidesAFieldWhenItsConditionDoesNotMatch(t *testing.T) {
values := NewValues()
values.Set("kind", "boolean")
view := Render(showIfSpec(), Binding{Mode: ModeRecord, Values: values})
if got := fieldNames(view); len(got) != 1 {
t.Errorf("with the marker set to a non-matching value, the dependent field is absent, got %v", got)
}
}
// A preview form's Discard returns to the specific record that produced
// it, which the declaration cannot name statically; Binding.WayOut
// overrides the declared way out for one render (a tier preview's ladder
// id, via forms.Discard).
func TestRenderBindingWayOutOverridesTheDeclaredOne(t *testing.T) {
spec := base()
spec.WayOut = LinkOut("Cancel", "/list")
discard := Discard("Discard", "/records/42")
view := Render(spec, Binding{Mode: ModeUnbound, WayOut: &discard})
if view.WayOut.Kind != WayOutDiscard || view.WayOut.URL != "/records/42" {
t.Errorf("Binding.WayOut must override the declared way out, got %+v", view.WayOut)
}
}
func TestRenderBindingWayOutNilKeepsTheDeclaredOne(t *testing.T) {
spec := base()
spec.WayOut = LinkOut("Cancel", "/list")
view := Render(spec, Binding{Mode: ModeUnbound})
if view.WayOut.Kind != WayOutLink || view.WayOut.URL != "/list" {
t.Errorf("a nil Binding.WayOut must not disturb the declared one, got %+v", view.WayOut)
}
}
// The Rows family (design D1's "Rows family contract"; spec form-library "A
// batch form renders the caller's rows and one tray", "A binding names the
// control that takes focus"). The markup is asserted where the parts
// execute; these assert the view the parts are handed, which is where the
// hidden page state, the tray's shape and the one autofocus are decided.
func batchSpec() FormSpec {
return FormSpec{
Name: "test.batch",
Kind: KindBatch,
Family: Rows,
Method: "POST",
Path: "/partials/test/rules",
CommitAction: "/partials/test/rules/apply",
Commit: "Apply changes",
WayOut: WayOut{Label: "Discard all"},
Target: "#test-rules",
Fields: []Field{
{Name: "resource_key", Label: "Resource", Control: Select, Options: []Option{
ChooseOption("a resource"), {Value: "seats", Label: "Seats"}, {Value: "sites", Label: "Sites"},
}},
{Name: "kind", Label: "Kind", Control: Hidden},
{Name: "limit", Label: "Limit", Control: Number, Min: "0",
ShowIf: ShowIf{Field: "kind", Equals: []string{"numeric"}}},
{Name: "policy", Label: "Reduction policy", Control: Select, Placement: PlaceDelta, Options: []Option{
{Value: "clamp", Label: "Clamp"}, {Value: "force_reduce", Label: "Force reduce"},
}},
{Name: "note", Label: "Note", Control: Textarea, Optional: true, Placement: PlaceTray},
},
}
}
// rowValues builds one record's values the way a handler binds them.
func rowValues(pairs ...string) Values {
v := NewValues()
for i := 0; i+1 < len(pairs); i += 2 {
v.Set(pairs[i], pairs[i+1])
}
return v
}
// hidden returns the value of one hidden input, and whether it was written.
func hidden(v FormView, name string) (string, bool) {
for _, h := range v.Hiddens {
if h.Name == name {
return h.Value, true
}
}
return "", false
}
func stagedBinding() Binding {
return Binding{
Rows: map[string]RowBinding{
"rule-12": {Values: rowValues("resource_key", "seats", "kind", "numeric", "limit", "5"), Errors: NewErrors()},
},
Staged: []StagedDelta{{
Verb: "edit",
Key: "rule-9",
Values: rowValues("resource_key", "sites", "kind", "numeric", "limit", "3", "policy", "force_reduce"),
Line: "Sites: 1 becomes 3.",
}},
}
}
// Every action posts the whole form, so the whole page state rides with it:
// which editors are open, and every delta of the batch (design D2, D3).
func TestRenderRowsWritesTheWholePageStateAsHiddenInputs(t *testing.T) {
v := batchSpec().Bind(stagedBinding())
if got, ok := hidden(v, "open"); !ok || got != "rule-12" {
t.Errorf("open input = %q, %v", got, ok)
}
for name, want := range map[string]string{
"staged.0.verb": "edit",
"staged.0.key": "rule-9",
"staged.0.resource_key": "sites",
"staged.0.kind": "numeric",
"staged.0.limit": "3",
} {
if got, ok := hidden(v, name); !ok || got != want {
t.Errorf("hidden %s = %q, %v; want %q", name, got, ok, want)
}
}
// The delta's own control carries its value, so a hidden input of the
// same name would submit twice and the first would win.
if got, ok := hidden(v, "staged.0.policy"); ok {
t.Errorf("staged.0.policy rides as a hidden input (%q) as well as a control", got)
}
if len(v.Deltas) != 1 || len(v.Deltas[0].Fields) != 1 || v.Deltas[0].Fields[0].Name != "staged.0.policy" {
t.Fatalf("delta fields = %+v", v.Deltas)
}
if v.Deltas[0].Fields[0].Value != "force_reduce" {
t.Errorf("the delta's policy control = %q", v.Deltas[0].Fields[0].Value)
}
if v.Deltas[0].Line != "Sites: 1 becomes 3." {
t.Errorf("delta line = %q", v.Deltas[0].Line)
}
}
// A delta's own option set: a control whose choices depend on the record it
// changes offers them per delta, not per render (design D7).
func TestRenderRowsDeltaOptionsOverrideTheRendersOwn(t *testing.T) {
b := stagedBinding()
b.Staged[0].Values.Set("policy", "block")
b.Staged[0].Options = map[string][]Option{"policy": {
{Value: "clamp", Label: "Clamp"},
{Value: "force_reduce", Label: "Force reduce"},
{Value: "block", Label: "Block"},
}}
v := batchSpec().Bind(b)
opts := v.Deltas[0].Fields[0].Options
if len(opts) != 3 || !opts[2].Selected || opts[2].Value != "block" {
t.Errorf("the delta's policy options = %+v", opts)
}
}
// A row's own option set: a control whose choices depend on the record it
// edits offers them per row, not per render (design D7: the rule row's
// policy select offers a dormant stored value as its own option).
func TestRenderRowFieldRowOptionsOverrideTheRendersOwn(t *testing.T) {
b := stagedBinding()
rb := b.Rows["rule-12"]
rb.Values.Set("resource_key", "pages")
rb.Options = map[string][]Option{"resource_key": {
{Value: "seats", Label: "Seats"}, {Value: "pages", Label: "Pages"},
}}
b.Rows["rule-12"] = rb
v := batchSpec().Bind(b)
opts := v.RowField("rule-12", "resource_key").Options
if len(opts) != 2 || !opts[1].Selected || opts[1].Value != "pages" {
t.Errorf("the row's resource options = %+v", opts)
}
// Another row without its own set keeps the declaration's.
if other := v.RowField("rule-77", "resource_key").Options; len(other) != 3 {
t.Errorf("a row without its own options keeps the declaration's, got %+v", other)
}
}
func TestRenderRowsEmptyBatchRendersNoTray(t *testing.T) {
v := batchSpec().Bind(Binding{})
if v.Tray {
t.Error("an empty batch renders a tray")
}
if len(v.Deltas) != 0 || len(v.Hiddens) != 0 {
t.Errorf("an empty batch wrote %d deltas and %d hidden inputs", len(v.Deltas), len(v.Hiddens))
}
asked := batchSpec().Bind(Binding{Tray: true})
if !asked.Tray {
t.Error("the binding asked for the tray and none rendered")
}
}
// The tray's commit and way out are the family's own posting controls, not
// a submit button: the commit has a path of its own and a target of its own
// (design D2).
func TestRenderRowsTrayCommitAndWayOut(t *testing.T) {
b := stagedBinding()
b.CommitTarget = "#operator-body"
v := batchSpec().Bind(b)
if v.Apply.Label != "Apply changes" || v.Apply.Act != "apply" {
t.Errorf("apply = %+v", v.Apply)
}
if !strings.Contains(string(v.Apply.HX), `hx-post="/partials/test/rules/apply"`) {
t.Errorf("apply posts to %q", v.Apply.HX)
}
if !strings.Contains(string(v.Apply.HX), `hx-target="#operator-body"`) {
t.Errorf("apply targets %q", v.Apply.HX)
}
if !v.Apply.Indicator {
t.Error("the commit carries no in-flight indicator")
}
if v.Discard.Label != "Discard all" || v.Discard.Act != "discard" {
t.Errorf("discard = %+v", v.Discard)
}
if !strings.Contains(string(v.Discard.HX), `hx-post="/partials/test/rules"`) {
t.Errorf("discard posts to %q", v.Discard.HX)
}
if v.Deltas[0].Undo.Label != "Undo" || v.Deltas[0].Undo.Key != "rule-9" {
t.Errorf("undo = %+v", v.Deltas[0].Undo)
}
// The commit falls back to the form's own target when the binding names
// none, so a declaration that renders in place needs to say nothing.
plain := batchSpec().Bind(stagedBinding())
if !strings.Contains(string(plain.Apply.HX), `hx-target="#test-rules"`) {
t.Errorf("apply's default target = %q", plain.Apply.HX)
}
}
// A row's control is the declaration's control: same label, same
// constraints, same markup, under a name and a wrapper id carrying its row
// (spec form-library "The body's controls are the declaration's").
func TestRenderRowFieldCarriesTheDeclarationAndTheRow(t *testing.T) {
v := batchSpec().Bind(stagedBinding())
fv := v.RowField("rule-12", "limit")
if fv.Name != "rows.rule-12.limit" {
t.Errorf("name = %q", fv.Name)
}
if fv.WrapperID != "form-test.batch-limit-rule-12" {
t.Errorf("wrapper id = %q", fv.WrapperID)
}
if fv.ControlID != fv.WrapperID+"-control" {
t.Errorf("control id = %q", fv.ControlID)
}
if fv.Label != "Limit" || fv.Value != "5" {
t.Errorf("label %q, value %q", fv.Label, fv.Value)
}
if !strings.Contains(string(fv.Attrs), `required`) || !strings.Contains(string(fv.Attrs), `min="0"`) {
t.Errorf("attrs = %q", fv.Attrs)
}
if fv.LabelNarrowOnly {
t.Error("the label hides itself with nothing asked for")
}
if got := v.RowField("rule-12", "limit", "label-hidden"); !got.LabelNarrowOnly {
t.Error("the label-hidden option did not reach the view")
}
// A second row's control is the same control under its own ids, so two
// open editors carry no duplicate id between them.
other := v.RowField("rule-77", "limit")
if other.WrapperID == fv.WrapperID {
t.Errorf("two rows share the wrapper id %q", other.WrapperID)
}
if other.Value != "" {
t.Errorf("a row with no binding carried the value %q", other.Value)
}
}
// The dense option lays a row's field out as a dense row's column, for a
// body's own dense row beside its table (the add form); its error keeps
// its wiring and renders under the control as everywhere (design D16).
// Without the option a row's field renders as a cell's: no column.
func TestRenderRowFieldDenseOption(t *testing.T) {
b := stagedBinding()
errs := NewErrors()
errs.Field("limit", "Enter a limit.")
b.Rows["rule-12"] = RowBinding{Values: b.Rows["rule-12"].Values, Errors: errs}
v := batchSpec().Bind(b)
if cell := v.RowField("rule-12", "limit"); cell.ColClass != "" {
t.Errorf("a cell's field carries the column class %q", cell.ColClass)
}
dense := v.RowField("rule-12", "limit", "dense")
if dense.ColClass != "col-12 col-xl-2" {
t.Errorf("a number's dense column = %q", dense.ColClass)
}
if dense.Error != "Enter a limit." || dense.ErrorID != "form-test.batch-limit-rule-12-error" {
t.Errorf("the dense field keeps its error wiring: %q, %q", dense.Error, dense.ErrorID)
}
if got := v.RowField("rule-12", "resource_key", "dense", "label-hidden"); got.ColClass != "col-12 col-xl-4" || !got.LabelNarrowOnly {
t.Errorf("both options together: col class %q, label narrow-only %v", got.ColClass, got.LabelNarrowOnly)
}
}
// Every posting control of the form posts the whole form, names the act and
// the row it was pressed on, and swaps the form's own region back.
func TestRenderRowActionPostsTheWholeForm(t *testing.T) {
v := batchSpec().Bind(stagedBinding())
act := v.RowAction("edit", "rule-12", "Edit", "secondary")
hx := string(act.HX)
for _, want := range []string{
`hx-post="/partials/test/rules"`,
`hx-vals='{"act":"edit","key":"rule-12"}'`,
`hx-include="closest form"`,
`hx-target="#test-rules"`,
`hx-swap="outerHTML"`,
} {
if !strings.Contains(hx, want) {
t.Errorf("row action attributes %q lack %q", hx, want)
}
}
if act.Classes != "btn btn-sm btn-outline-secondary ms-1" {
t.Errorf("secondary classes = %q", act.Classes)
}
if got := v.RowAction("stage", "rule-12", "Stage change", "primary").Classes; got != "btn btn-sm btn-primary ms-1" {
t.Errorf("primary classes = %q", got)
}
if got := v.RowAction("remove", "rule-12", "Remove", "danger").Classes; got != "btn btn-sm btn-outline-danger ms-1" {
t.Errorf("danger classes = %q", got)
}
}
// A successful render focuses what the binding names, and nothing else
// (spec form-library "A successful render focuses the named control").
func TestRenderRowsAutofocusFollowsTheBinding(t *testing.T) {
b := stagedBinding()
b.Autofocus = Focus{Field: "limit", Instance: "rule-12"}
v := batchSpec().Bind(b)
if !strings.Contains(string(v.RowField("rule-12", "limit").Attrs), "autofocus") {
t.Error("the named control carries no autofocus")
}
if strings.Contains(string(v.RowField("rule-12", "resource_key").Attrs), "autofocus") {
t.Error("a control the binding did not name carries autofocus")
}
if v.TrayFocus {
t.Error("the tray took focus the binding gave to a control")
}
for _, f := range v.Fields {
if strings.Contains(string(f.Attrs), "autofocus") {
t.Errorf("the tray field %q carries autofocus", f.Name)
}
}
}
func TestRenderRowsAutofocusTrayHeading(t *testing.T) {
b := stagedBinding()
b.Autofocus = Focus{Tray: true}
v := batchSpec().Bind(b)
if !v.TrayFocus {
t.Error("the tray heading did not take focus")
}
if strings.Contains(string(v.RowField("rule-12", "limit").Attrs), "autofocus") {
t.Error("a control took focus the binding gave to the tray")
}
// Nothing staged and no tray: there is nothing to focus.
empty := batchSpec().Bind(Binding{Autofocus: Focus{Tray: true}})
if empty.TrayFocus {
t.Error("a tray that does not render took focus")
}
}
// A refusal focuses the first invalid control and ignores what the binding
// asked for (spec form-library "A refusal overrides the binding").
func TestRenderRowsRefusalOverridesTheBinding(t *testing.T) {
b := stagedBinding()
b.Autofocus = Focus{Tray: true}
errs := NewErrors()
errs.Field("limit", "Limit must be 0 or more.")
b.Rows["rule-12"] = RowBinding{Values: b.Rows["rule-12"].Values, Errors: errs}
v := batchSpec().Bind(b)
if v.TrayFocus {
t.Error("the tray took focus on a refusal")
}
fv := v.RowField("rule-12", "limit")
if !strings.Contains(string(fv.Attrs), "autofocus") {
t.Errorf("the invalid control carries no autofocus: %q", fv.Attrs)
}
if fv.Error == "" || !strings.Contains(string(fv.Attrs), `aria-invalid="true"`) {
t.Errorf("the invalid control is not marked invalid: %+v", fv)
}
if !v.HasErrors() {
t.Error("a refused row does not report errors")
}
}
// A delta's refusal renders on its own line with the batch intact
// (design D3).
func TestRenderRowsDeltaRefusalKeepsTheBatch(t *testing.T) {
b := stagedBinding()
b.Staged[0].Error = "That rule is no longer active."
v := batchSpec().Bind(b)
if v.Deltas[0].Error != "That rule is no longer active." || v.Deltas[0].ErrorID == "" {
t.Errorf("delta = %+v", v.Deltas[0])
}
if _, ok := hidden(v, "staged.0.key"); !ok {
t.Error("a refused delta dropped out of the hidden batch")
}
if !v.HasErrors() {
t.Error("a refused delta does not report errors")
}
}
// The Rows family's own binding on any other family is a programming
// error, refused the first time the page renders (spec form-library "A body
// belongs to a batch form only").
func TestRenderRefusesRowsBindingOutsideTheFamily(t *testing.T) {
for name, b := range map[string]Binding{
"body": {Body: "<table></table>"},
"staged": {Staged: []StagedDelta{{Verb: "edit", Key: "x"}}},
"rows": {Rows: map[string]RowBinding{"x": {}}},
} {
func() {
defer func() {
r := recover()
if r == nil {
t.Errorf("%s: a stacked form rendered the rows family's binding", name)
return
}
msg, _ := r.(string)
if !strings.Contains(msg, "test.sided") || !strings.Contains(msg, "rows family") {
t.Errorf("%s: refusal %q names neither the form nor the family", name, msg)
}
}()
Render(sided(), b)
}()
}
}
// The body is the caller's, set on the view because the fragment that
// produces it calls RowField on that same view.
func TestRenderRowsBodyIsTheCallers(t *testing.T) {
v := batchSpec().Bind(Binding{Body: "<table id=\"bound\"></table>"})
if v.Body != `<table id="bound"></table>` {
t.Errorf("body from the binding = %q", v.Body)
}
v.Body = "<table id=\"set\"></table>"
if v.Body != `<table id="set"></table>` {
t.Errorf("body set after Bind = %q", v.Body)
}
// Fields is what the library itself renders, which here is the tray's
// own field and neither a row's nor a delta's.
if len(v.Fields) != 1 || v.Fields[0].Name != "note" {
t.Errorf("library-rendered fields = %v", fieldNames(v))
}
}
func TestBindIsRender(t *testing.T) {
b := Binding{Mode: ModeUnbound, Side: CreateOnly}
if got, want := sided().Bind(b).ID, Render(sided(), b).ID; got != want {
t.Errorf("Bind = %q, Render = %q", got, want)
}
}
// Every posting control of a batch form is a <button>, so the library's
// default target, "this", would resolve to the button that was pressed
// rather than to the form. A declaration that names no target still swaps
// its whole form back (design D2).
func TestRenderRowActionsTargetTheFormWhenNoneIsNamed(t *testing.T) {
spec := batchSpec()
spec.Target = ""
v := spec.Bind(Binding{Instance: "set-4", Staged: stagedBinding().Staged})
want := `hx-target="#` + v.ID + `"`
if v.ID != "form-test.batch-set-4" {
t.Fatalf("form id = %q", v.ID)
}
for name, hx := range map[string]string{
"edit": string(v.RowAction("edit", "rule-12", "Edit", "secondary").HX),
"undo": string(v.Deltas[0].Undo.HX),
"apply": string(v.Apply.HX),
"discard": string(v.Discard.HX),
} {
if strings.Contains(hx, `hx-target="this"`) || !strings.Contains(hx, want) {
t.Errorf("%s targets %q, want %q", name, hx, want)
}
}
}
// 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.
func TestRenderRowsCommitDisablesItself(t *testing.T) {
v := batchSpec().Bind(stagedBinding())
if !strings.Contains(string(v.Apply.HX), `hx-disable="this"`) {
t.Errorf("the commit does not disable itself: %q", v.Apply.HX)
}
for name, hx := range map[string]string{
"undo": string(v.Deltas[0].Undo.HX),
"discard": string(v.Discard.HX),
"edit": string(v.RowAction("edit", "rule-12", "Edit", "secondary").HX),
} {
if strings.Contains(hx, "hx-disable") {
t.Errorf("%s disables itself: %q", name, hx)
}
}
}
// A row's own Edit can take focus, so a Cancel returns focus to the button
// the person pressed rather than dropping it at the top of the swapped
// region (design D9).
func TestRenderRowsAutofocusNamesARowAction(t *testing.T) {
b := stagedBinding()
b.Autofocus = Focus{Act: "edit", Key: "rule-12"}
v := batchSpec().Bind(b)
if !strings.Contains(string(v.RowAction("edit", "rule-12", "Edit", "secondary").HX), "autofocus") {
t.Error("the named row action carries no autofocus")
}
for name, hx := range map[string]string{
"another row's Edit": string(v.RowAction("edit", "rule-77", "Edit", "secondary").HX),
"the same row's Remove": string(v.RowAction("remove", "rule-12", "Remove", "danger").HX),
"undo": string(v.Deltas[0].Undo.HX),
"apply": string(v.Apply.HX),
"discard": string(v.Discard.HX),
} {
if strings.Contains(hx, "autofocus") {
t.Errorf("%s carries autofocus", name)
}
}
if strings.Contains(string(v.RowField("rule-12", "limit").Attrs), "autofocus") {
t.Error("a control took the focus a row action was given")
}
if v.TrayFocus {
t.Error("the tray took the focus a row action was given")
}
// A refusal still wins: the first invalid control takes focus and the
// binding's name is ignored, whatever kind of thing it named.
errs := NewErrors()
errs.Field("limit", "Limit must be 0 or more.")
b.Rows["rule-12"] = RowBinding{Values: b.Rows["rule-12"].Values, Errors: errs}
refused := batchSpec().Bind(b)
if strings.Contains(string(refused.RowAction("edit", "rule-12", "Edit", "secondary").HX), "autofocus") {
t.Error("a row action took focus on a refusal")
}
if !strings.Contains(string(refused.RowField("rule-12", "limit").Attrs), "autofocus") {
t.Error("the invalid control did not take focus on a refusal")
}
}
// A row checkbox hides its own label the same way every other row control
// does: visually, from lg up, where the column header labels it (D11).
func TestRenderRowCheckboxHonoursTheLabelHiddenOption(t *testing.T) {
spec := batchSpec()
spec.Fields = append(spec.Fields, Field{Name: "granted", Label: "Granted", Control: Checkbox})
v := spec.Bind(stagedBinding())
if !v.RowField("rule-12", "granted", "label-hidden").LabelNarrowOnly {
t.Error("a checkbox row ignored the label-hidden option")
}
if v.RowField("rule-12", "granted").LabelNarrowOnly {
t.Error("a checkbox row hid its label with nothing asked for")
}
}
// The commit alone may swap differently: its target is often a container
// the page keeps, whose response is that container's contents rather than a
// wrapper, so replacing the element itself would leave the next request
// with nothing to swap into.
func TestRenderRowsCommitSwapIsTheBindingsAlone(t *testing.T) {
b := stagedBinding()
b.CommitTarget = "#operator-body"
b.CommitSwap = "innerHTML"
v := batchSpec().Bind(b)
if !strings.Contains(string(v.Apply.HX), `hx-swap="innerHTML"`) {
t.Errorf("apply swaps %q", v.Apply.HX)
}
for name, hx := range map[string]string{
"undo": string(v.Deltas[0].Undo.HX),
"discard": string(v.Discard.HX),
"edit": string(v.RowAction("edit", "rule-12", "Edit", "secondary").HX),
} {
if !strings.Contains(hx, `hx-swap="outerHTML"`) {
t.Errorf("%s swaps %q", name, hx)
}
}
if plain := batchSpec().Bind(stagedBinding()); !strings.Contains(string(plain.Apply.HX), `hx-swap="outerHTML"`) {
t.Errorf("apply's default swap = %q", plain.Apply.HX)
}
}