// 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: "