// 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) }