Introduce a commercial license option alongside AGPL-3.0-only, require a CLA for contributors, and document the terms in COMMERCIAL.md and NOTICE. Add a script to stamp SPDX headers on Go files and apply it across the tree.
122 lines
4.4 KiB
Go
122 lines
4.4 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package server
|
|
|
|
import (
|
|
"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",
|
|
},
|
|
}
|
|
for k, vs := range n.ExtraInputs() {
|
|
if len(vs) == 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) }
|