Files
cgalo5758 f3fa8e7456 Confirm a plan switch in the shared dialog
#291. A plan switch (Upgrade, Downgrade, or Pay for this plan on a
granted tier's card) was confirmed in a panel at the top of the Plans
section, above the cards and away from the button that opened it. It
now confirms in the same dialog as Cancel plan and Resume plan. The
button opens the dialog at once, titled with the panel's question
("Switch to Pro?", or "Pay for Pro?" on the granted tier's own card),
and the dialog's body loads from the switch's preview route, because the
charge it states comes from Stripe at that moment. Keep current plan
closes the dialog and sends nothing.

An answer that belongs in the dialog stays there: a declined card, a
payment awaiting the bank's approval, stale figures; focus moves to the
new form. An answer that lands outside closes it: the switch made or
refused, or a plan withdrawn after the page loaded. A load that fails
closes the dialog with the error toast; a commit that fails (403 or
5xx, which the pages' htmx config does not swap) leaves it open with
the toast. Closing the dialog while its body loads aborts the load, and
the aborted load shows no toast. The shared dialog gains a region the
loaded form lives in; the forms library renders a preview that leaves
by the dialog without a box of its own; the lint accepts
`data-action-load` as a trigger's route. No string is added or
reworded. docs/design-system.md, docs/operator-ux-conventions.md and
docs/stripe.md say the same.

Tests: TestSwitchAnswersNameTheirRegion, TestPlanMoveTitle,
TestSwitchControlOpensTheSharedDialog,
TestConfirmModalCarriesTheLoadedRegion,
TestPreviewInTheDialogRendersNoBox, and the walkthrough
TestSwitchDialogWalkthrough (seven steps in a browser, among them a
load answered offline, a load closed mid-flight, and a commit answered
503 by the browser so nothing reaches Stripe). The walkthrough resets
the application database, so it runs only with SWITCH_DIALOG_WALK=1, as
the merchant-disclosure sheet runs only when asked; make test skips it.
35 goldens change: every member plans golden loses one blank line, the
switch controls of 12 lose their hx-* attributes for the dialog's, and
the plan move form's golden loses its box.

Full suite on reset databases: 5249 passed, 16 skipped, none failed.
Sandbox walk on this commit, all twelve steps passed: the dialog opens
at once and loads Stripe's figure ($20.00 equal to the preview's
amount_due); Keep current plan sends nothing; Cancel plan shows its own
form; offline the dialog closes with the toast; stale figures, a
declined card and a payment awaiting approval stay in the dialog with
focus on the new form; the made switch closes it and Max is current;
a plan withdrawn in another session refuses in the Plans region; Pay
for this plan reads "Pay for Max?".

make screens: no page this commit touches changed. Three operator
captures differ for other reasons: person detail and invoice detail
show new ids, because the demo snapshot was rebuilt when the previous
landing added a FedWiki migration, and the domains list's age column
counts one more day (#120).

Not walked by the walkthrough, with the reason: a request in flight at a
chosen moment (the dialog closed while the commit runs, another switch
opened) and a page restored from the browser's cache; a load answered
400 and a commit the switch refuses answer through the same close rule
as the withdrawn plan and the made switch that the walkthrough and the
hand walk cover.

Breaks, all CAUGHT (19 test records):
- own card titled as a switch (title test)
- own card titled as a switch (control test)
- setMove leaves the title unset
- control loads without the price (control test)
- control loads without the price (render test)
- control loads the switch's route
- heading left in the message
- current tier's name unescaped
- refused preview left in the dialog
- confirmation answered into the Plans region (a declined switch)
- confirmation answered into the Plans region (a stale switch)
- confirmation answered into the Plans region (a switch awaiting
  approval)
- decline answered 200
- way out a link again
- commit's description dropped
- region renamed in the partial
- box rule removed
- box rule widened to every kind
- box rule widened to every way out

Breaks of the walkthrough, all CAUGHT (8 records, each building and
booting the app from the edited tree):
- the close not deferred
- the abort on close dropped
- the cancelled mark ignored by the toast
- the load not sent
- the loaded region left showing under a static trigger
- the close on an answer outside the dialog removed
- the failed commit closing the dialog
- the way out a link again

Seen by no test: renderConfirmation without its describeCommit call
(the database tests' handlers carry no Stripe key fingerprint, so the
line is never shown there; describeCommit itself is pinned);
data-action-load dropped from the lint's condition (no template
interpolates a route into it); the spinner and aria-busy not set, and
the focus after a stay-open answer not moved, which only the hand walk
reaches, because the walkthrough stops before any Stripe write.
2026-10-30 05:42:38 -05:00

1309 lines
52 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")
}
}
// An option that shows the hint can carry its own line: the option renders it
// as data-hint, the line carries the declared hint as its default, and a
// refused submission shows the chosen option's line. An option that does not
// show the hint renders none, and a select none of whose options carries a line
// renders no default (grant-names-every-emptied-ladder D3).
func TestRenderHintOnChoiceShowsTheChosenOptionsOwnHint(t *testing.T) {
spec := base()
spec.Fields[0] = Field{Name: "kind", Label: "Kind", Control: Select, RuntimeOptions: true, HintOnChoice: true, Hint: "B is special."}
opts := map[string][]Option{"kind": {
{Value: "a", Label: "A"},
{Value: "b", Label: "B", ShowsHint: true},
{Value: "c", Label: "C", ShowsHint: true, Hint: "C is more special."},
{Value: "d", Label: "D", Hint: "D is never shown."},
}}
refused := func(v string, o map[string][]Option) FieldView {
values := NewValues()
values.Set("kind", v)
return Render(spec, Binding{Mode: ModeSubmission, Values: values, Options: o}).Fields[0]
}
f := Render(spec, Binding{Mode: ModeUnbound, Options: opts}).Fields[0]
if got := f.Options[2].Hint; got != "C is more special." {
t.Errorf("option C renders data-hint %q, want %q", got, "C is more special.")
}
if got := f.Options[1].Hint; got != "" {
t.Errorf("option B renders data-hint %q, want none", got)
}
if got := f.Options[3].Hint; got != "" {
t.Errorf("an option that does not show the hint renders data-hint %q, want none", got)
}
if f.HintDefault != "B is special." {
t.Errorf("the hint line's default = %q, want the declared hint", f.HintDefault)
}
for _, row := range []struct{ value, label, want string }{{"c", "C", "C is more special."}, {"b", "B", "B is special."}} {
if g := refused(row.value, opts); g.Hint != row.want || g.HintHidden {
t.Errorf("a refused submission that chose %s renders the hint %q hidden %v, want %q shown", row.label, g.Hint, g.HintHidden, row.want)
}
}
plain := map[string][]Option{"kind": {{Value: "a", Label: "A"}, {Value: "b", Label: "B", ShowsHint: true}}}
if g := Render(spec, Binding{Mode: ModeUnbound, Options: plain}).Fields[0]; g.HintDefault != "" {
t.Errorf("with no option's own hint the line carries default %q, want none", g.HintDefault)
}
}
// A HintOnChoice select renders its declared hint hidden unless the chosen
// option shows it, names the hint it controls, and renders no hint when no
// option it offers shows it.
func TestRenderHintOnChoiceFollowsTheChosenOption(t *testing.T) {
spec := base()
spec.Fields[0] = Field{Name: "kind", Label: "Kind", Control: Select, RuntimeOptions: true, HintOnChoice: true, Hint: "B is special."}
opts := map[string][]Option{"kind": {ChooseOption("a kind"), {Value: "a", Label: "A"}, {Value: "b", Label: "B", ShowsHint: true}}}
chosen := func(v string) FieldView {
values := NewValues()
values.Set("kind", v)
return Render(spec, Binding{Mode: ModeSubmission, Values: values, Options: opts}).Fields[0]
}
if f := Render(spec, Binding{Mode: ModeUnbound, Options: opts}).Fields[0]; !f.HintHidden || f.Hint != "B is special." || !strings.Contains(string(f.Attrs), `data-hint-toggle="`+f.HintID+`"`) || strings.Contains(string(f.Attrs), "aria-describedby") {
t.Errorf("at rest, the hint = %q hidden %v attrs %q, want the text, hidden, named by the select and not described", f.Hint, f.HintHidden, f.Attrs)
}
if f := chosen("b"); f.HintHidden || !strings.Contains(string(f.Attrs), `aria-describedby="`+f.HintID+`"`) || !f.Options[2].ShowsHint {
t.Errorf("after choosing the option that shows it, hidden %v attrs %q, want shown and described", f.HintHidden, f.Attrs)
}
if f := chosen("a"); !f.HintHidden {
t.Error("after choosing an option that does not show it, the hint is shown")
}
plain := map[string][]Option{"kind": {ChooseOption("a kind"), {Value: "a", Label: "A"}, {Value: "b", Label: "B"}}}
if f := Render(spec, Binding{Mode: ModeUnbound, Options: plain}).Fields[0]; f.Hint != "" || f.HintID != "" || strings.Contains(string(f.Attrs), "data-hint-toggle") {
t.Errorf("with no option that shows it the field renders hint %q attrs %q", f.Hint, f.Attrs)
}
}
// A preview that leaves by closing the dialog sits in the dialog, which is
// its box; every other stacked form keeps its own (design D4).
func TestPreviewInTheDialogRendersNoBox(t *testing.T) {
preview := func(wayOut WayOut) FormSpec {
return FormSpec{
Name: "test.preview", Kind: KindPreview, Family: Stacked,
Method: "POST", Path: "/partials/test",
Fields: []Field{{Name: "ladder_id", Label: "Ladder", Control: Hidden}},
Commit: "Confirm", WayOut: wayOut,
}
}
rows := []struct {
name string
spec FormSpec
want string
}{
{name: "a preview leaving by the dialog", spec: preview(DismissModal("Keep")), want: ""},
{name: "a preview leaving by Discard", spec: preview(Discard("Discard", "/partials/test")), want: "alert"},
{name: "a create form leaving by the dialog", want: "card", spec: FormSpec{
Name: "test.create", Kind: KindCreate, Family: Stacked,
Method: "POST", Path: "/partials/test",
Fields: []Field{{Name: "name", Label: "Name", Control: Text}},
Commit: "Create", WayOut: DismissModal("Cancel"),
}},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
v := Render(row.spec, Binding{Mode: ModeRecord, Message: "<p>Message</p>"})
if v.Box != row.want {
t.Errorf("%s: box = %q, want %q", row.name, v.Box, row.want)
}
})
}
}