Files
cgalo5758 ab1c0dbb69 Write the list search's hidden sibling inputs in sorted order
An embedded list's search form re-submits its sibling lists' state as
hidden inputs. listSearchForm added them by ranging over a map, and a
GET form submits its inputs in document order, so with two or more
siblings the parameters of the URL the search pushes came out in a
different order from one render to the next. They are now added in the
sorted order of their keys, so the order is the same on every render.
With one sibling, or none, the HTML is unchanged.

TestListSearchFormCarriesSiblingInputsInSortedOrder renders the form
20 times with three siblings and checks each render for key order. It
failed in 3 of 3 runs at the parent and passed in 3 of 3 at this
commit. The new file
internal/server/testdata/forms/per-request/list-search-three-siblings.html
pins the three-sibling HTML as the other per-request files do, and the
comment in forms_golden_test.go that explained why the list search was
rendered with one sibling is removed. No existing golden file changes.
No assertion of TestFormsHTML changed; it gains one case.

How it was shown to change no other output: the names and results of
./internal/server (the whole package, on the test databases) are equal
at the parent and at this commit apart from the two added lines, the
new test and the new TestFormsHTML/per-request/list-search-three-siblings
case, none failing. One break (the sorted keys walked backward) is
caught by the new test. The demo-state walkthroughs give 16 pass and
6 skip at the parent and at this commit.

Not probed: that a browser submits the inputs in document order. The
HTML specification says it does, and no test here drives a browser's
form submission.
2026-10-11 21:47:51 -05:00

127 lines
4.6 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"maps"
"slices"
"strconv"
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
)
// The governed list's search (spec operator-panel-navigation "The landing
// surface's lookup is a search form"; spec form-conventions "Every mutation
// form's no-JS behavior is a recorded decision"; design D4). One
// declaration, rendered once per governed list with its own field name
// (ListNav.QParam, prefix applied) and, for an embedded list sharing a page
// with siblings, its own hidden carry-along state (design D2's ShowIf-style
// per-request field list, the same shape operator.integration.settings
// uses: the field the person types into is always "the list's own search
// box"; which hidden state rides with it depends on which list this is).
//
// This file is the form's home; the registry is only the index (design D1).
// It sits beside list_scale.go, which owns ListNav.
const listControlsFormName = "operator.list.controls"
// listControlsForm is the registered declaration: the contract every
// governed list's search shares (kind, family, method, commit).
// listSearchForm builds the real per-request declaration, whose field name
// and hidden state depend on the ListNav the list handed it.
//
// The Bar family is the only one a Search form may declare (design D20):
// one input group with a leading magnifier, no box, and an outline button,
// so the list's own "New …" link stays the page's one filled control
// (form-conventions' button-weight rule). Round 3 forced this into Stacked
// and every list page rendered a narrow input with its button wrapped onto
// the next line (maintainer, 2026-09-04).
var listControlsForm = forms.Register(forms.FormSpec{
Name: listControlsFormName,
Kind: forms.KindSearch,
Family: forms.Bar,
Method: "GET",
Path: "/operator",
Fields: []forms.Field{
{Name: "q", Label: "Search this list", HideLabel: true, Control: forms.Text, Optional: true, Autocomplete: "off"},
},
Commit: "Search",
})
// listSearchForm builds and renders the search declaration for one list's
// current state. A standalone list (Target unset) renders Boosted: no
// explicit htmx attributes, relying on the page's own hx-boost inheritance
// for both the scripted behaviour and the native no-JS fallback, because
// its target is the page itself, not a selector this declaration could
// name. An embedded list (Target set) narrows the request to its own panel
// with HXSelect, and refreshes any sibling panels with HXSelectOOB.
func listSearchForm(n ListNav) forms.FormView {
spec := listControlsForm
spec.Path = n.BasePath
spec.Target = n.Target
spec.Fields = []forms.Field{
{
Name: n.QParam(),
Label: "Search this list",
HideLabel: true,
Control: forms.Text,
Optional: true,
Placeholder: n.SearchPlaceholder,
Autocomplete: "off",
},
}
// Sorted, so the hidden inputs, and with them the parameters of the URL
// the search pushes, come out in one order on every render.
extra := n.ExtraInputs()
for _, k := range slices.Sorted(maps.Keys(extra)) {
if len(extra[k]) == 0 {
continue
}
spec.Fields = append(spec.Fields, forms.Field{Name: k, Label: k, Control: forms.Hidden, Optional: true})
}
if n.Facet != "" && n.FacetParam != "" {
spec.Fields = append(spec.Fields, forms.Field{Name: n.FacetParam, Label: "Filter", Control: forms.Hidden, Optional: true})
}
if n.PerPageOffDefault() {
spec.Fields = append(spec.Fields, forms.Field{Name: n.PerParam(), Label: "Page size", Control: forms.Hidden, Optional: true})
}
if n.Q != "" {
spec.WayOut = forms.ScopedLinkOut("Clear", n.ClearSearchURL(), n.Target)
if n.Target == "" {
// A standalone list's Clear is an ordinary boosted navigation,
// the same as the search itself.
spec.WayOut = forms.LinkOut("Clear", n.ClearSearchURL())
}
}
values := forms.NewValues()
values.Set(n.QParam(), n.Q)
for k, vs := range n.ExtraInputs() {
if len(vs) > 0 {
values.Set(k, vs[0])
}
}
if n.Facet != "" && n.FacetParam != "" {
values.Set(n.FacetParam, n.Facet)
}
if n.PerPageOffDefault() {
values.Set(n.PerParam(), strconv.Itoa(n.EffectivePerPage()))
}
b := forms.Binding{Mode: forms.ModeRecord, Values: values}
if n.Target != "" {
b.HXSelect = n.Target
if n.SyncSelect != "" {
b.HXSelectOOB = n.SyncSelect
}
} else {
b.Boosted = true
}
return forms.Render(spec, b)
}
// Form is listSearchForm(n), for the template call {{ template "form"
// .Form }} inside operator_list_controls.html's listSearch define.
func (n ListNav) Form() forms.FormView { return listSearchForm(n) }