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.
992 lines
38 KiB
Go
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)
|
|
}
|
|
}
|