Files
member-console/internal/forms/render_test.go
T
cgalo5758 113c672e3b Resume a marked grant when the plan that replaced it ends, and archive the change
An operator can mark a grant to resume. When a plan replaces a marked
grant and that plan later ends (the subscription ends, or a granted plan
is revoked or expires), the grant delivers again from its own row before
the org-type floor runs (core migration 00023, core.grant_waits).

- Grants: a resumes_after_supersession column, off by default and never
  on a default grant; delivery states gain On hold, with a badge hover
  that names what the grant waits on.
- Operator: one Manage action per grant opens a card under its row
  (Note, Valid until, Resumes; Save, Cancel, Revoke). A changed Valid
  until extends the grant; otherwise the note and mark change in place.
  An extension now keeps its predecessor's valid_from.
- Member: a held rung reads Granted and is reached by cancelling; the
  cancel confirmation names each ladder's outcome; Cancel plan sits
  beside Keep plan while a downgrade is scheduled.
- Smaller fixes from the screens reviews: 24-hour times everywhere,
  search placeholders that name what they match, a checkbox label above
  its box, and the org-types walkthrough restoring the default it
  changes.
2026-10-01 18:16:40 -05:00

1203 lines
46 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 opener is a row action that owns a panel: the same posting
// control, reporting aria-expanded, with the opener glyph before its label.
func TestRenderRowOpenerReportsExpanded(t *testing.T) {
v := batchSpec().Bind(stagedBinding())
closed, open := v.RowOpener("edit", "rule-12", "Edit", "secondary", false), v.RowOpener("cancel", "rule-12", "Edit", "secondary", true)
if !closed.Opener || closed.Expanded || !open.Opener || !open.Expanded {
t.Errorf("opener flags: closed %+v, open %+v", closed, open)
}
if !strings.Contains(string(closed.HX), `hx-vals='{"act":"edit","key":"rule-12"}'`) || !strings.Contains(string(open.HX), `hx-vals='{"act":"cancel","key":"rule-12"}'`) {
t.Errorf("an opener posts the act it was given: %q, %q", closed.HX, open.HX)
}
if v.RowAction("edit", "rule-12", "Edit", "secondary").Opener {
t.Error("a plain row action is an opener")
}
}
// 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)
}
}
// rowCommitSpec is a batch form that commits row by row (grant-resumption
// D15): the row actions post to Path and re-render the form's region, and
// the open row's own commit posts to the record's route.
func rowCommitSpec() FormSpec {
return FormSpec{
Name: "test.ledger",
Kind: KindBatch,
Family: Rows,
RowCommit: true,
Method: "POST",
Path: "/partials/test/ledger",
CommitAction: "/partials/test/records/{recordID}/save",
Commit: "Save",
Target: "#test-ledger",
Fields: []Field{
{Name: "note", Label: "Note", Control: Text, Optional: true},
{Name: "keep", Label: "Keep", Control: Checkbox},
},
}
}
func rowCommitBinding() Binding {
return Binding{
Mode: ModeRecord,
Rows: map[string]RowBinding{
"r-7": {Values: rowValues("note", "kept"), Errors: NewErrors(), Action: "/partials/test/records/r-7/save?tab=history"},
},
CommitTarget: "#operator-body",
CommitSwap: "innerHTML",
}
}
// A form that commits row by row renders no tray, no tray commit and no way
// out; it still writes which row is open, so the row's commit and every row
// action carry it.
func TestRenderRowCommitRendersNoTray(t *testing.T) {
v := rowCommitSpec().Bind(rowCommitBinding())
if v.Tray {
t.Error("a form that commits row by row rendered a tray")
}
if v.Apply.HX != "" || v.Discard.HX != "" {
t.Errorf("a form that commits row by row built a tray commit or way out: %+v, %+v", v.Apply, v.Discard)
}
if got, ok := hidden(v, "open"); !ok || got != "r-7" {
t.Errorf("open input = %q, %v", got, ok)
}
if fv := v.RowField("r-7", "note"); fv.Name != "rows.r-7.note" || fv.Value != "kept" {
t.Errorf("the open row's field = %q, %q", fv.Name, fv.Value)
}
}
// The open row's commit is the declaration's commit, posting to the row's
// own route and swapping what the binding's commit names, with the
// in-flight indicator and its own disable; the row actions keep posting to
// the form's path and swapping its region.
func TestRenderRowCommitPostsTheRowToItsOwnRoute(t *testing.T) {
v := rowCommitSpec().Bind(rowCommitBinding())
save := v.RowCommit("r-7")
if save.Label != "Save" || save.Act != "commit" || save.Key != "r-7" || !save.Indicator {
t.Errorf("the row's commit = %+v", save)
}
if save.Classes != "btn btn-sm btn-primary ms-1" {
t.Errorf("the row's commit classes = %q", save.Classes)
}
for _, want := range []string{
`hx-post="/partials/test/records/r-7/save?tab=history"`,
`hx-vals='{"act":"commit","key":"r-7"}'`,
`hx-include="closest form"`,
`hx-target="#operator-body"`,
`hx-swap="innerHTML"`,
`hx-disable="this"`,
} {
if !strings.Contains(string(save.HX), want) {
t.Errorf("the row's commit %q lacks %q", save.HX, want)
}
}
cancel := string(v.RowAction("cancel", "r-7", "Cancel", "secondary").HX)
for _, want := range []string{`hx-post="/partials/test/ledger"`, `hx-target="#test-ledger"`, `hx-swap="outerHTML"`} {
if !strings.Contains(cancel, want) {
t.Errorf("the row's Cancel %q lacks %q", cancel, want)
}
}
// Without a commit target of its own the commit swaps the form's region.
b := rowCommitBinding()
b.CommitTarget, b.CommitSwap = "", ""
if hx := string(rowCommitSpec().Bind(b).RowCommit("r-7").HX); !strings.Contains(hx, `hx-target="#test-ledger"`) || !strings.Contains(hx, `hx-swap="outerHTML"`) {
t.Errorf("the row's commit with no target of its own = %q", hx)
}
}
// A binding that does not fit how the form commits is a programming error,
// refused the first time the page renders.
func TestRenderRowCommitRefusesABindingThatDoesNotFit(t *testing.T) {
two := rowCommitBinding()
two.Rows["r-8"] = RowBinding{Values: NewValues(), Action: "/partials/test/records/r-8/save"}
noAction := rowCommitBinding()
noAction.Rows["r-7"] = RowBinding{Values: NewValues()}
staged := rowCommitBinding()
staged.Staged = []StagedDelta{{Verb: "edit", Key: "r-7"}}
tray := rowCommitBinding()
tray.Tray = true
batchWithAction := stagedBinding()
batchWithAction.Rows["rule-12"] = RowBinding{Values: NewValues(), Action: "/partials/test/rules/rule-12"}
for name, c := range map[string]struct {
spec FormSpec
b Binding
want string
}{
"two open rows": {rowCommitSpec(), two, "one row edits at a time"},
"a row with no action": {rowCommitSpec(), noAction, "carries no Action"},
"a staged batch": {rowCommitSpec(), staged, "stages nothing"},
"the tray": {rowCommitSpec(), tray, "has none"},
"a batch row's action": {batchSpec(), batchWithAction, "belongs to a form that commits row by row"},
} {
func() {
defer func() {
msg, _ := recover().(string)
if !strings.Contains(msg, c.want) {
t.Errorf("%s: refusal %q, want it to say %q", name, msg, c.want)
}
}()
c.spec.Bind(c.b)
}()
}
// The commit exists on the open row of a form that commits row by row,
// and nowhere else.
for name, call := range map[string]func(){
"a closed row": func() { rowCommitSpec().Bind(rowCommitBinding()).RowCommit("r-9") },
"a batch form": func() { batchSpec().Bind(stagedBinding()).RowCommit("rule-12") },
} {
func() {
defer func() {
if recover() == nil {
t.Errorf("RowCommit on %s rendered a commit", name)
}
}()
call()
}()
}
}
// A row's destructive act opens the shared confirm dialog with the
// trigger's words, route and hidden fields, escaped here, and renders
// outline-danger; it posts nothing itself (grant-resumption D9).
func TestRenderRowConfirmOpensTheDialog(t *testing.T) {
v := rowCommitSpec().Bind(rowCommitBinding())
revoke := v.RowConfirm("r-7", ConfirmTrigger{
Label: "Revoke", Title: "Revoke grant", Body: `Revoke "Plus" for this organization?`, ConfirmLabel: "Revoke grant",
URL: "/partials/test/r-7/revoke?tab=history", Target: "#operator-body", Fields: map[string]string{"org_id": "o1"},
})
if revoke.Label != "Revoke" || revoke.Classes != "btn btn-sm btn-outline-danger ms-1" || revoke.Indicator {
t.Errorf("the destructive act = %+v", revoke)
}
hx := string(revoke.HX)
for _, want := range []string{
`data-bs-toggle="modal" data-bs-target="#confirmActionModal"`,
`data-action-url="/partials/test/r-7/revoke?tab=history"`,
`data-action-method="post"`,
`data-action-target="#operator-body"`,
`data-action-title="Revoke grant"`,
`data-action-body="Revoke &#34;Plus&#34; for this organization?"`,
`data-action-confirm-label="Revoke grant"`,
`data-action-fields="{&#34;org_id&#34;:&#34;o1&#34;}"`,
} {
if !strings.Contains(hx, want) {
t.Errorf("the destructive act %q lacks %q", hx, want)
}
}
if strings.Contains(hx, "hx-post") {
t.Errorf("the destructive act posts itself: %q", hx)
}
}
// The inline options lay a row's field out in a card of its own: the label
// shows at every width, a checkbox's above its box, and the wrapper is a
// flex item, filling the line's free width when asked.
func TestRenderRowFieldInlineOptions(t *testing.T) {
spec := batchSpec()
spec.Fields = append(spec.Fields, Field{Name: "granted", Label: "Granted", Control: Checkbox})
v := spec.Bind(stagedBinding())
in := v.RowField("rule-12", "limit", "inline")
if in.ColClass != "app-inline-field" || in.LabelNarrowOnly || in.LabelHidden {
t.Errorf("inline: col class %q, label narrow-only %v, hidden %v", in.ColClass, in.LabelNarrowOnly, in.LabelHidden)
}
if fill := v.RowField("rule-12", "limit", "inline-fill"); fill.ColClass != "app-inline-field app-inline-field-fill" {
t.Errorf("inline-fill col class = %q", fill.ColClass)
}
if !v.RowField("rule-12", "granted", "inline").LabelAbove {
t.Error("an inline checkbox keeps its label beside the box")
}
if v.RowField("rule-12", "granted").LabelAbove {
t.Error("a cell's checkbox moved its label above the box")
}
}