The Rules section is one record table grouped by kind, Limit then Boolean, on fixed columns, edited in place: Edit opens a row's controls in their columns, Add rule opens a dense row above the table, and every change is staged into a tray that lists the deltas with Undo and applies them as one rule-change act. The reduction policy is a column of the rule beside its limit. History shows counts only. Group rows are a quiet heading rather than a divider, the maintainer's pick from four rounds of outside-model ideation. Dense rows align to the top and render each error under its control in every form family (design D16), replacing the below-row error block; the forms library gains the batch form (rows plus one tray) and the RowField dense and label-hidden options. Migration 00019 records the governing reduction policy on effect rows. Archive staged-rule-changes with its spec updates (entitlement-set- management, entitlement-set-history, entitlements, form-library, form-conventions, ui-quality-gate). Screens accepted 2026-09-19.
443 lines
20 KiB
Go
443 lines
20 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package server
|
|
|
|
// Render tests for the two form parts themselves (spec form-library "The
|
|
// form part owns the container"; spec form-conventions). They execute
|
|
// ui_form.html and ui_form_field.html against forms.Render's view, so the
|
|
// markup every migrated form will produce is pinned once, here, rather
|
|
// than re-asserted per form. No DB needed.
|
|
|
|
import (
|
|
"bytes"
|
|
"html/template"
|
|
"io/fs"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
|
|
)
|
|
|
|
// renderFormPart executes the form part against one rendered declaration.
|
|
func renderFormPart(t *testing.T, view forms.FormView) string {
|
|
t.Helper()
|
|
tmpl := template.New("parts").Funcs(template.FuncMap{"helpIcon": helpIcon})
|
|
tmpl, err := web.ParseUIPartials(tmpl)
|
|
if err != nil {
|
|
t.Fatalf("ParseUIPartials: %v", err)
|
|
}
|
|
if _, err := fs.Sub(embeds.Templates, "templates"); err != nil {
|
|
t.Fatalf("fs.Sub: %v", err)
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := tmpl.ExecuteTemplate(&buf, "form", view); err != nil {
|
|
t.Fatalf("execute the form part: %v", err)
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
// A checkbox renders as Bootstrap's form-check with the label after the
|
|
// box, because the label names the state the box turns on, and the help
|
|
// icon after the label as its sibling, never inside it (findings FA-37,
|
|
// FA-50).
|
|
func TestFormPartCheckboxLabelSitsAfterTheBox(t *testing.T) {
|
|
spec := forms.FormSpec{
|
|
Name: "test.checkbox", Kind: forms.KindEdit, Family: forms.Stacked,
|
|
Method: "PUT", Path: "/partials/test", Commit: "Save",
|
|
Fields: []forms.Field{{
|
|
Name: "visibility", Label: "Listed", Control: forms.Checkbox, Value: "public",
|
|
Help: forms.Help("Listed", "Shown in the member catalog."),
|
|
}},
|
|
}
|
|
out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeUnbound}))
|
|
|
|
box := strings.Index(out, `type="checkbox"`)
|
|
label := strings.Index(out, `class="form-check-label"`)
|
|
help := strings.Index(out, `aria-label="Help: Listed"`)
|
|
if box < 0 || label < 0 || help < 0 {
|
|
t.Fatalf("checkbox, label or help icon missing:\n%s", out)
|
|
}
|
|
if !(box < label && label < help) {
|
|
t.Errorf("expected the order box, label, help icon; got %d, %d, %d:\n%s", box, label, help, out)
|
|
}
|
|
if !strings.Contains(out, `<div class="form-check">`) {
|
|
t.Errorf("expected Bootstrap's form-check wrapper:\n%s", out)
|
|
}
|
|
// The help icon is the label's sibling: no button inside the label.
|
|
if strings.Contains(out, `<label class="form-check-label" for="form-test.checkbox-visibility-control">Listed <button`) {
|
|
t.Errorf("the help icon must not sit inside the <label>:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// A dense row is one row of small controls with the commit at its end, at
|
|
// its natural width, never stretched to its column (finding FA-18).
|
|
func TestFormPartDenseRowCommitIsNaturalWidth(t *testing.T) {
|
|
spec := forms.FormSpec{
|
|
Name: "test.dense", Kind: forms.KindSubRecord, Family: forms.Dense,
|
|
Method: "POST", Path: "/partials/test", Commit: "Add price",
|
|
WayOut: forms.ClosePanel("Cancel", "addPricePanel"),
|
|
Fields: []forms.Field{
|
|
{Name: "amount", Label: "Amount", Control: forms.Number,
|
|
Help: forms.Help("Amount", "In the deployment's currency.")},
|
|
{Name: "interval", Label: "Interval", Control: forms.Text, Width: forms.WidthNarrower},
|
|
{Name: "prorate", Label: "Prorate", Control: forms.Checkbox, Value: "true"},
|
|
},
|
|
}
|
|
out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeUnbound}))
|
|
|
|
if strings.Contains(out, "w-100") {
|
|
t.Errorf("a dense commit is never stretched to its column:\n%s", out)
|
|
}
|
|
for _, want := range []string{
|
|
`class="app-form app-form-dense"`,
|
|
`<div class="row g-2 align-items-start app-form-dense-row">`,
|
|
`<div class="col-auto app-form-commit app-form-unlabeled">`,
|
|
`class="btn btn-primary btn-sm"`,
|
|
`class="btn btn-outline-secondary btn-sm" data-bs-toggle="collapse" data-bs-target="#addPricePanel"`,
|
|
`class="form-control form-control-sm"`,
|
|
`class="form-label form-label-sm"`,
|
|
// A checkbox is its own content, not a fraction of the row: below
|
|
// the extra-large breakpoint it still takes its own line (design
|
|
// D3, D5). It has no label line of its own, so it carries the
|
|
// label line's height and sits on the control line (D16).
|
|
`class="col-12 col-xl-auto app-form-unlabeled app-form-field"`,
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("dense row missing %q:\n%s", want, out)
|
|
}
|
|
}
|
|
// No line under any control in a dense row: the invariants refuse a
|
|
// hint or a notice there, so the only form-text a dense row can carry
|
|
// would be a bug in the part.
|
|
if strings.Contains(out, `class="form-text"`) {
|
|
t.Errorf("a dense row carries no line under its controls:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// A one-sided field's declared reason is documentation, not copy: the part
|
|
// renders it nowhere. Only a declared Notice reaches the page, and a field
|
|
// never shows both a hint and a notice (maintainer, 2026-09-03).
|
|
func TestFormPartRendersNoticeAndNeverTheReason(t *testing.T) {
|
|
spec := forms.FormSpec{
|
|
Name: "test.sides", Kind: forms.KindCreate, Family: forms.Stacked,
|
|
Method: "POST", Path: "/partials/test", Commit: "Create",
|
|
Fields: []forms.Field{
|
|
{Name: "name", Label: "Name", Control: forms.Text, Hint: "As it appears on invoices."},
|
|
{Name: "seed", Label: "Seed", Control: forms.Text, Optional: true,
|
|
// One-sided, and the notice is the only thing a
|
|
// person is told about it.
|
|
Only: forms.CreateOnly, Notice: "Set now; this cannot be changed later."},
|
|
},
|
|
}
|
|
out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeUnbound, Side: forms.CreateOnly}))
|
|
|
|
if strings.Contains(out, "It cannot be changed after creation.") {
|
|
t.Errorf("the declared reason must never render:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, `<div id="form-test.sides-seed-notice" class="form-text">Set now; this cannot be changed later.</div>`) {
|
|
t.Errorf("a declared notice must render as one line under the control:\n%s", out)
|
|
}
|
|
if n := strings.Count(out, `class="form-text"`); n != 2 {
|
|
t.Errorf("expected one line under each of the two fields, found %d:\n%s", n, out)
|
|
}
|
|
}
|
|
|
|
// Every form whose refusal is a 422 carries novalidate, so the refusal a
|
|
// person sees is the server's, under the control (maintainer, 2026-09-03).
|
|
func TestFormPartEmitsNoValidateOnMutationForms(t *testing.T) {
|
|
spec := forms.FormSpec{
|
|
Name: "test.server-refusal", Kind: forms.KindCreate, Family: forms.Stacked,
|
|
Method: "POST", Path: "/partials/test", Commit: "Create",
|
|
Fields: []forms.Field{{Name: "name", Label: "Name", Control: forms.Text}},
|
|
}
|
|
out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeUnbound}))
|
|
if !strings.Contains(out, ` novalidate `) && !strings.Contains(out, ` novalidate>`) {
|
|
t.Errorf("expected novalidate on the form tag:\n%s", out)
|
|
}
|
|
// The constraint stays on the control, for assistive technology and
|
|
// for :invalid styling.
|
|
if !strings.Contains(out, `required`) {
|
|
t.Errorf("the constraint attributes must survive novalidate:\n%s", out)
|
|
}
|
|
|
|
spec.Kind = forms.KindSearch
|
|
spec.Method = "GET"
|
|
out = renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeUnbound}))
|
|
if strings.Contains(out, "novalidate") {
|
|
t.Errorf("a search form navigates; it keeps the browser's validation:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// The part owns the section box, and a stacked box hugs its fields: it
|
|
// carries the cap, so it ends where the fields end rather than running the
|
|
// content column's width (maintainer, 2026-09-04: "Hugging definitely").
|
|
// A dense row gets no box, because its panel is one already; a preview
|
|
// gets the muted alert the preview idiom uses (design D10).
|
|
func TestFormPartOwnsTheSectionBox(t *testing.T) {
|
|
spec := forms.FormSpec{
|
|
Name: "test.box", Kind: forms.KindCreate, Family: forms.Stacked,
|
|
Method: "POST", Path: "/partials/test", Commit: "Create",
|
|
Fields: []forms.Field{{Name: "name", Label: "Name", Control: forms.Text}},
|
|
}
|
|
out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeUnbound}))
|
|
if !strings.Contains(out, `<div class="card app-form-box mb-3"><div class="card-body">`) {
|
|
t.Errorf("a stacked create form renders its own capped card:\n%s", out)
|
|
}
|
|
|
|
spec.Kind = forms.KindEdit
|
|
if out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeRecord})); !strings.Contains(out, "app-form-box") {
|
|
t.Errorf("an edit form renders the same box as its create page:\n%s", out)
|
|
}
|
|
|
|
spec.Kind = forms.KindPreview
|
|
if out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeUnbound})); !strings.Contains(out, `<div class="alert alert-secondary app-form-box mb-3" role="status">`) {
|
|
t.Errorf("a preview renders the muted alert box:\n%s", out)
|
|
}
|
|
|
|
spec.Kind = forms.KindSubRecord
|
|
spec.Family = forms.Dense
|
|
if out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeUnbound})); strings.Contains(out, "app-form-box") {
|
|
t.Errorf("a dense row's panel is its box; the part adds none:\n%s", out)
|
|
}
|
|
|
|
spec.Kind = forms.KindSearch
|
|
spec.Family = forms.Bar
|
|
spec.Method = "GET"
|
|
if out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeUnbound})); strings.Contains(out, "app-form-box") {
|
|
t.Errorf("a search form sits in a list header; the part adds no box:\n%s", out)
|
|
}
|
|
|
|
spec.Kind = forms.KindSettings
|
|
spec.Family = forms.Table
|
|
spec.Method = "POST"
|
|
if out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeRecord})); strings.Contains(out, "app-form-box") {
|
|
t.Errorf("a settings table is its own shape; the part adds no box:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// A search renders as one input group with a leading magnifier, an outline
|
|
// commit and, when a query is active, its Clear in the same group: the
|
|
// shape a search actually has (design D20). Round 3 rendered it stacked
|
|
// and every list page showed a narrow input with the button wrapped onto
|
|
// the next line.
|
|
func TestFormPartBarIsOneInputGroup(t *testing.T) {
|
|
spec := forms.FormSpec{
|
|
Name: "test.bar", Kind: forms.KindSearch, Family: forms.Bar,
|
|
Method: "GET", Path: "/operator/products", Commit: "Search",
|
|
WayOut: forms.LinkOut("Clear", "/operator/products"),
|
|
Fields: []forms.Field{
|
|
{Name: "q", Label: "Search this list", HideLabel: true, Control: forms.Text, Optional: true, Autocomplete: "off"},
|
|
{Name: "per", Label: "Page size", Control: forms.Hidden, Optional: true},
|
|
},
|
|
}
|
|
out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeRecord, Boosted: true}))
|
|
|
|
for _, want := range []string{
|
|
`class="app-form app-form-bar"`,
|
|
`<div class="input-group">`,
|
|
`<span class="input-group-text"><svg`,
|
|
`aria-hidden="true"`,
|
|
`fill="currentColor"`,
|
|
`class="form-label visually-hidden"`,
|
|
`<input type="hidden" name="per"`,
|
|
`class="btn btn-outline-secondary"><span class="spinner-border`,
|
|
`<a href="/operator/products" class="btn btn-outline-secondary">Clear</a>`,
|
|
`role="search"`,
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("bar missing %q:\n%s", want, out)
|
|
}
|
|
}
|
|
for _, banned := range []string{"btn-primary", "(optional)", "app-form-box", "app-form-fields"} {
|
|
if strings.Contains(out, banned) {
|
|
t.Errorf("bar rendered %q, which no search carries:\n%s", banned, out)
|
|
}
|
|
}
|
|
// The magnifier precedes the control, which precedes the commit.
|
|
svg, control, commit := strings.Index(out, "<svg"), strings.Index(out, `name="q"`), strings.Index(out, `type="submit"`)
|
|
if !(svg >= 0 && svg < control && control < commit) {
|
|
t.Errorf("expected the order magnifier, control, commit; got %d, %d, %d:\n%s", svg, control, commit, out)
|
|
}
|
|
|
|
spec.Wide = true
|
|
if out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeRecord, Boosted: true})); !strings.Contains(out, `class="app-form app-form-bar-wide"`) {
|
|
t.Errorf("a wide bar drops the width cap:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// A settings form renders as one table: the label and its hint in the
|
|
// first column, the page's own cells next, the control last, and one
|
|
// commit below the table (design D21).
|
|
func TestFormPartTableIsOneTable(t *testing.T) {
|
|
spec := forms.FormSpec{
|
|
Name: "test.table", Kind: forms.KindSettings, Family: forms.Table,
|
|
Method: "POST", Path: "/partials/test", Commit: "Save",
|
|
LabelColumn: "Key", ControlColumn: "Override",
|
|
Columns: []string{"Effective value", "Source"},
|
|
Fields: []forms.Field{
|
|
{Name: "widget-url", Label: "widget-url", Control: forms.Text, Optional: true, Hint: "Widget service URL"},
|
|
{Name: "widget-token", Label: "widget-token", Control: forms.Static,
|
|
Value: "Set widget-token (or widget-token-file) in the environment."},
|
|
},
|
|
}
|
|
values := forms.NewValues()
|
|
values.Set("widget-url", "https://widgets.example.test")
|
|
out := renderFormPart(t, forms.Render(spec, forms.Binding{
|
|
Mode: forms.ModeRecord,
|
|
Values: values,
|
|
Cells: map[string][]template.HTML{
|
|
"widget-url": {`<code>https://widgets.example.test</code>`, `<span class="badge">Environment</span>`},
|
|
"widget-token": {`<span class="badge">Not set</span>`},
|
|
},
|
|
}))
|
|
|
|
for _, want := range []string{
|
|
`class="app-form app-form-table"`,
|
|
`<div class="table-responsive">`,
|
|
`<table class="table align-middle">`,
|
|
`<th scope="col">Key</th>`,
|
|
`<th scope="col">Effective value</th>`,
|
|
`<th scope="col">Source</th>`,
|
|
`<th scope="col">Override</th>`,
|
|
`<td><code>https://widgets.example.test</code></td>`,
|
|
`<div id="form-test.table-widget-url-hint" class="form-text">Widget service URL</div>`,
|
|
`value="https://widgets.example.test"`,
|
|
`<span class="text-muted small">Set widget-token (or widget-token-file) in the environment.</span>`,
|
|
`<div class="d-flex flex-wrap gap-2 app-form-commit">`,
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("settings table missing %q:\n%s", want, out)
|
|
}
|
|
}
|
|
// A static row carries no name at all: nothing is submitted for it.
|
|
if strings.Contains(out, `name="widget-token"`) {
|
|
t.Errorf("a static row must not submit anything:\n%s", out)
|
|
}
|
|
// Never "(optional)": every setting is optional by nature.
|
|
if strings.Contains(out, "(optional)") {
|
|
t.Errorf("a settings table never renders the optional marker:\n%s", out)
|
|
}
|
|
// A row short of cells is padded, so its columns still line up.
|
|
if n := strings.Count(out, "<td>"); n != 6 {
|
|
t.Errorf("expected six cells (two rows, two supplied columns plus the control), found %d:\n%s", n, out)
|
|
}
|
|
}
|
|
|
|
// A dense form's field errors render under their controls, inside the
|
|
// field's own column, and the row aligns its columns to the top under one
|
|
// label line: a refused column grows alone, and the commit column carries
|
|
// the label line's height so it sits on the control line (design D16 of
|
|
// staged-rule-changes, which retired D22's error block below the row).
|
|
func TestFormPartDenseErrorsRenderUnderTheirControls(t *testing.T) {
|
|
spec := forms.FormSpec{
|
|
Name: "test.dense-errors", Kind: forms.KindSubRecord, Family: forms.Dense,
|
|
Method: "POST", Path: "/partials/test", Commit: "Add rule",
|
|
WayOut: forms.ClosePanel("Cancel", "addRulePanel"),
|
|
Fields: []forms.Field{
|
|
{Name: "resource_key", Label: "Resource key", Control: forms.Select, HideLabel: true,
|
|
Options: []forms.Option{forms.ChooseOption("a resource key"), {Value: "seats", Label: "Seats"}}},
|
|
{Name: "limit", Label: "Limit", Control: forms.Number},
|
|
{Name: "per_unit", Label: "Per unit", Control: forms.Checkbox},
|
|
},
|
|
}
|
|
errs := forms.NewErrors()
|
|
errs.Field("resource_key", "Choose a resource key.")
|
|
errs.Field("limit", "Enter a limit.")
|
|
out := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeSubmission, Errors: errs}))
|
|
|
|
for _, want := range []string{
|
|
`<div class="row g-2 align-items-start app-form-dense-row">`,
|
|
`<div class="col-auto app-form-commit app-form-unlabeled">`,
|
|
`<div id="form-test.dense-errors-resource_key-error" class="invalid-feedback d-block">Choose a resource key.</div>`,
|
|
`<div id="form-test.dense-errors-limit-error" class="invalid-feedback d-block">Enter a limit.</div>`,
|
|
`aria-describedby="form-test.dense-errors-resource_key-error"`,
|
|
`aria-invalid="true"`,
|
|
`is-invalid`,
|
|
// A checkbox has no label line of its own, so its column sits on
|
|
// the control line through the same padding as the commit.
|
|
`col-12 col-xl-auto app-form-unlabeled app-form-field`,
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("dense refusal missing %q:\n%s", want, out)
|
|
}
|
|
}
|
|
for _, reject := range []string{"app-form-errors", "align-items-end", "Limit: Enter a limit."} {
|
|
if strings.Contains(out, reject) {
|
|
t.Errorf("dense refusal must not carry %q:\n%s", reject, out)
|
|
}
|
|
}
|
|
// Each error sits inside its own field's wrapper, after its control.
|
|
limit := out[strings.Index(out, `id="form-test.dense-errors-limit"`):]
|
|
limit = limit[:strings.Index(limit, `app-form-commit`)]
|
|
if strings.Index(limit, `name="limit"`) > strings.Index(limit, "Enter a limit.") {
|
|
t.Errorf("the Limit error must follow its control inside its column:\n%s", limit)
|
|
}
|
|
if n := strings.Count(out, "invalid-feedback"); n != 2 {
|
|
t.Errorf("expected one error per errored field, found %d:\n%s", n, out)
|
|
}
|
|
}
|
|
|
|
// A preview at rest has nothing to apply and nothing to discard, so it
|
|
// renders no commit row at all; a message brings both back, after the
|
|
// field the declaration names (design D23).
|
|
func TestFormPartPreviewAtRestHasNoCommitRow(t *testing.T) {
|
|
spec := forms.FormSpec{
|
|
Name: "test.preview", Kind: forms.KindPreview, Family: forms.Stacked,
|
|
Method: "POST", Path: "/partials/test", Commit: "Apply change",
|
|
WayOut: forms.Discard("Discard", "/partials/test"),
|
|
MessageAfter: "candidate",
|
|
Fields: []forms.Field{
|
|
{Name: "candidate", Label: "Change default to", Control: forms.Select,
|
|
Options: []forms.Option{forms.ChooseOption("a new default"), {Value: "l1", Label: "Hosting"}}},
|
|
},
|
|
}
|
|
rest := renderFormPart(t, forms.Render(spec, forms.Binding{Mode: forms.ModeRecord}))
|
|
for _, banned := range []string{"Apply change", "Discard", "app-form-commit"} {
|
|
if strings.Contains(rest, banned) {
|
|
t.Errorf("a preview at rest renders %q with nothing to apply:\n%s", banned, rest)
|
|
}
|
|
}
|
|
if !strings.Contains(rest, `name="candidate"`) {
|
|
t.Errorf("a preview at rest still renders its fields:\n%s", rest)
|
|
}
|
|
|
|
chosen := renderFormPart(t, forms.Render(spec, forms.Binding{
|
|
Mode: forms.ModeRecord, Message: template.HTML(`<p>Two organizations move.</p>`),
|
|
}))
|
|
for _, want := range []string{"Apply change", "Discard", "Two organizations move."} {
|
|
if !strings.Contains(chosen, want) {
|
|
t.Errorf("a preview with a message missing %q:\n%s", want, chosen)
|
|
}
|
|
}
|
|
// The message is the consequence of the select, so it follows it.
|
|
if strings.Index(chosen, `name="candidate"`) > strings.Index(chosen, "Two organizations move.") {
|
|
t.Errorf("MessageAfter must put the message below the field it names:\n%s", chosen)
|
|
}
|
|
}
|
|
|
|
// A disabled commit carries the classes the enabled commit would have, so
|
|
// it and the way out beside it are the same height (design D23).
|
|
func TestFormPartDisabledCommitTakesTheEnabledCommitsClasses(t *testing.T) {
|
|
spec := forms.FormSpec{
|
|
Name: "test.disabled-commit", Kind: forms.KindPreview, Family: forms.Stacked,
|
|
Method: "POST", Path: "/partials/test", Commit: "Apply change",
|
|
WayOut: forms.Discard("Discard", "/partials/test"),
|
|
Fields: []forms.Field{{Name: "candidate", Label: "Candidate", Control: forms.Text}},
|
|
}
|
|
view := forms.Render(spec, forms.Binding{Mode: forms.ModeRecord, Message: template.HTML("<p>Something changes.</p>")})
|
|
view.CommitState = &forms.DisabledControlView{
|
|
ID: "why-not", Label: "Apply change", Reason: "Choose a disposition first.",
|
|
Classes: "btn btn-link btn-sm", // a caller's value; the render overrides it
|
|
}
|
|
out := renderFormPart(t, view)
|
|
if !strings.Contains(out, `<button type="button" class="btn btn-primary" disabled aria-describedby="why-not">`) {
|
|
t.Errorf("the disabled commit must carry the enabled commit's classes:\n%s", out)
|
|
}
|
|
if strings.Contains(out, "btn-link") || strings.Contains(out, "btn-sm") {
|
|
t.Errorf("a caller's classes must not reach the rendered commit:\n%s", out)
|
|
}
|
|
}
|