Governed operator lists (organizations, grants, people, billing×4) gain
server-side search, status filters, and 50-row pages with true totals
from count(*) OVER(); state is URL-addressable, out-of-range pages
clamp,
and no-match is distinct from true-empty.
People is the eighth flat sidebar entry: /operator/persons lists persons
newest-joined first (excluding the reserved system person), rows linking
to the existing detail.
Billing gains an operator invoice detail at
/operator/billing/invoices/{invoiceID} reusing the member projection;
open invoices past due present as Overdue (derived, filterable, stored
status untouched); all four views lead with the linked organization and
mute object IDs.
Grants filter over the derived Live/Superseded/Inactive state, the SQL
HAVING predicate pinned to the Go derivation by test. Embedded lists
(org composite ledger, Tier changes) adopt the shared controls under
namespaced params with sibling-state-preserving URLs and scoped htmx
swaps that hold the viewport.
Review corrections: blocked ladder Delete renders disabled with tooltip
and mutations fire toasts; collapse triggers paint their open state;
sections use outside headings; plan topology drops the orphan-product
check; domains policy collapses behind a disclosure.
321 lines
11 KiB
Go
321 lines
11 KiB
Go
package server
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
)
|
||
|
||
// operatorListPageSize is the fixed page size for every governed operator
|
||
// list (operator-list-scale: "Governed lists paginate with a true total
|
||
// count"). One constant so no list can quietly diverge.
|
||
const operatorListPageSize = 50
|
||
|
||
// ListParams is the parsed URL state of a governed operator list: the
|
||
// search term (`q`), one capability-appropriate facet value, and the
|
||
// 1-based page. It uses exactly the query-parameter shape
|
||
// operator-panel-navigation reserved ("Browse routes reserve
|
||
// query-parameter real estate for filters"), so list state is
|
||
// URL-addressable and a copied URL reproduces the view.
|
||
type ListParams struct {
|
||
Q string
|
||
Facet string
|
||
Page int
|
||
// PerPage is the page size; 0 means operatorListPageSize. Embedded
|
||
// lists default lower (the composite's ledger and Tier changes use 10)
|
||
// and let the operator pick from perPageOptions via ParsePerPage.
|
||
PerPage int
|
||
}
|
||
|
||
// ParseListParams reads q, the named facet parameter, and page from the
|
||
// request. A malformed or non-positive page clamps to 1 (the
|
||
// "malformed page values clamp" scenario); values the list does not
|
||
// implement are the caller's to ignore. facetParam may be "" for lists
|
||
// with no filter.
|
||
func ParseListParams(r *http.Request, facetParam string) ListParams {
|
||
return ParseListParamsNS(r, "", facetParam)
|
||
}
|
||
|
||
// ParseListParamsNS is ParseListParams with a parameter-name prefix, for
|
||
// governed lists embedded in a page that hosts more than one (each list
|
||
// reads and writes <prefix>q / <prefix>page so siblings cannot collide).
|
||
// facetParam is always the full parameter name.
|
||
func ParseListParamsNS(r *http.Request, prefix, facetParam string) ListParams {
|
||
p := ListParams{Q: r.URL.Query().Get(prefix + "q"), Page: 1}
|
||
if facetParam != "" {
|
||
p.Facet = r.URL.Query().Get(facetParam)
|
||
}
|
||
if n, err := strconv.Atoi(r.URL.Query().Get(prefix + "page")); err == nil && n > 1 {
|
||
p.Page = n
|
||
}
|
||
return p
|
||
}
|
||
|
||
// Limit and Offset are the SQL window for the current page.
|
||
func (p ListParams) Limit() int32 {
|
||
if p.PerPage > 0 {
|
||
return int32(p.PerPage)
|
||
}
|
||
return int32(operatorListPageSize)
|
||
}
|
||
func (p ListParams) Offset() int32 { return int32(p.Page-1) * p.Limit() }
|
||
|
||
// perPageOptions is the page-size set an operator can pick from on lists
|
||
// that offer the picker (embedded lists; the composite's ledger and Tier
|
||
// changes default to embeddedListDefaultPerPage, the smallest).
|
||
var perPageOptions = []int{10, 25, 50}
|
||
|
||
// embeddedListDefaultPerPage keeps embedded lists short by default
|
||
// (maintainer 2026-08-24); the picker offers the larger sizes.
|
||
const embeddedListDefaultPerPage = 10
|
||
|
||
// ParsePerPage reads <prefix>per from the request, returning def unless
|
||
// the value is one of perPageOptions — an unknown size is ignored, like
|
||
// an unknown facet.
|
||
func ParsePerPage(r *http.Request, prefix string, def int) int {
|
||
if n, err := strconv.Atoi(r.URL.Query().Get(prefix + "per")); err == nil {
|
||
for _, o := range perPageOptions {
|
||
if n == o {
|
||
return n
|
||
}
|
||
}
|
||
}
|
||
return def
|
||
}
|
||
|
||
// FetchPage runs load for the params' page and clamps back to page 1 when
|
||
// the requested page is past the end (an offset beyond the last row
|
||
// returns zero rows, and with them zero knowledge of the total — the
|
||
// re-run answers with a real page instead of an empty table). load
|
||
// returns the page's rows plus the true total for the whole filtered set
|
||
// (`count(*) OVER()` in the paged queries, design D2).
|
||
func FetchPage[R any](p *ListParams, load func(limit, offset int32) ([]R, int64, error)) ([]R, int64, error) {
|
||
rows, total, err := load(p.Limit(), p.Offset())
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if len(rows) == 0 && p.Page > 1 {
|
||
p.Page = 1
|
||
rows, total, err = load(p.Limit(), 0)
|
||
}
|
||
return rows, total, err
|
||
}
|
||
|
||
// FacetOption is one value of a list's status filter.
|
||
type FacetOption struct {
|
||
Value string
|
||
Label string
|
||
}
|
||
|
||
// ListNav is the view model for the shared list-controls partial
|
||
// (operator_list_controls.html: "listControls", "listPager",
|
||
// "listNoMatch"). Handlers fill the static shape (BasePath, placeholder,
|
||
// facet vocabulary) and the current state (Q/Facet/Page/Total); the
|
||
// methods derive everything the template renders, including URLs that
|
||
// preserve the rest of the state per the URL-addressable requirement.
|
||
type ListNav struct {
|
||
BasePath string
|
||
SearchPlaceholder string
|
||
FacetParam string
|
||
FacetOptions []FacetOption
|
||
Q string
|
||
Facet string
|
||
Page int
|
||
Total int64
|
||
// ParamPrefix namespaces this list's q/page parameter names when the
|
||
// hosting page embeds more than one governed list; "" on standalone
|
||
// pages. FacetParam is always the full name.
|
||
ParamPrefix string
|
||
// Extra is carried verbatim into every URL this nav builds (and the
|
||
// search form's hidden inputs), so an embedded list's links preserve
|
||
// its sibling lists' state on the shared URL.
|
||
Extra url.Values
|
||
// PerPage is the effective page size (0 → operatorListPageSize);
|
||
// DefaultPerPage is the list's default (the <prefix>per parameter is
|
||
// omitted at that value so canonical views keep canonical URLs); a
|
||
// non-empty PerPageOptions makes listPager render the size picker.
|
||
PerPage int
|
||
DefaultPerPage int
|
||
PerPageOptions []int
|
||
// Target is the CSS selector of the panel wrapping an EMBEDDED list.
|
||
// When set, every control the shared defines emit (tab, pager,
|
||
// page-size and clear links, and the search form) issues a scoped
|
||
// htmx request — hx-get with hx-target/hx-select on this selector,
|
||
// outerHTML swap, URL pushed — so only the panel re-renders and the
|
||
// operator's scroll position is untouched (maintainer 2026-08-24:
|
||
// interacting with an embedded box must not jump to the top of the
|
||
// page). Plain hrefs remain as the no-JS fallback. Empty on
|
||
// standalone pages, whose controls stay ordinary boosted navigations.
|
||
Target string
|
||
// SyncSelect is the hx-select-oob value naming the SIBLING panels to
|
||
// refresh out-of-band from the same response. Without it a sibling's
|
||
// links go stale: they carry the URL state of their own last render,
|
||
// so after this list changes state (say the ledger switches to
|
||
// History) the sibling's pager would push a URL missing that state.
|
||
// Refreshing every embedded panel on every interaction keeps all
|
||
// their links carrying the full current URL state.
|
||
SyncSelect string
|
||
}
|
||
|
||
// QParam, PageParam, and PerParam are the list's parameter names, prefix
|
||
// applied; the search form's input names must match what
|
||
// ParseListParamsNS / ParsePerPage read.
|
||
func (n ListNav) QParam() string { return n.ParamPrefix + "q" }
|
||
func (n ListNav) PageParam() string { return n.ParamPrefix + "page" }
|
||
func (n ListNav) PerParam() string { return n.ParamPrefix + "per" }
|
||
|
||
// EffectivePerPage is the page size all derived math uses.
|
||
func (n ListNav) EffectivePerPage() int {
|
||
if n.PerPage > 0 {
|
||
return n.PerPage
|
||
}
|
||
return operatorListPageSize
|
||
}
|
||
|
||
// PerPageActive reports whether v is the current page size (for the
|
||
// picker's non-link rendering of the active option).
|
||
func (n ListNav) PerPageActive(v int) bool { return n.EffectivePerPage() == v }
|
||
|
||
// PerPageOffDefault reports whether the current size must ride along as a
|
||
// hidden input on the search form (a GET submit replaces the query
|
||
// string).
|
||
func (n ListNav) PerPageOffDefault() bool {
|
||
def := n.DefaultPerPage
|
||
if def == 0 {
|
||
def = operatorListPageSize
|
||
}
|
||
return n.EffectivePerPage() != def
|
||
}
|
||
|
||
// PageSize is the current page size, exposed for templates and tests.
|
||
func (n ListNav) PageSize() int { return n.EffectivePerPage() }
|
||
|
||
// Pages is the number of pages for the current filtered total (at least 1).
|
||
func (n ListNav) Pages() int {
|
||
size := int64(n.EffectivePerPage())
|
||
pages := int((n.Total + size - 1) / size)
|
||
if pages < 1 {
|
||
pages = 1
|
||
}
|
||
return pages
|
||
}
|
||
|
||
// From and To are the 1-based display range of the current page
|
||
// ("Showing From–To of Total"); both 0 when the list is empty.
|
||
func (n ListNav) From() int {
|
||
if n.Total == 0 {
|
||
return 0
|
||
}
|
||
return (n.Page-1)*n.EffectivePerPage() + 1
|
||
}
|
||
|
||
func (n ListNav) To() int {
|
||
to := n.Page * n.EffectivePerPage()
|
||
if int64(to) > n.Total {
|
||
to = int(n.Total)
|
||
}
|
||
return to
|
||
}
|
||
|
||
// Filtered reports whether any narrowing state is active — it decides
|
||
// no-match (search/filter matched nothing) versus the list's true-empty
|
||
// state, which empty-state-guidance owns.
|
||
func (n ListNav) Filtered() bool { return n.Q != "" || n.Facet != "" }
|
||
|
||
func (n ListNav) HasPrev() bool { return n.Page > 1 }
|
||
func (n ListNav) HasNext() bool { return n.Page < n.Pages() }
|
||
|
||
// ownParam reports whether k is one of this list's own parameter names.
|
||
func (n ListNav) ownParam(k string) bool {
|
||
return k == n.QParam() || k == n.PageParam() || k == n.PerParam() || (n.FacetParam != "" && k == n.FacetParam)
|
||
}
|
||
|
||
// url assembles BasePath?<prefix>q=...&<facet>=...&<prefix>page=N&
|
||
// <prefix>per=S, omitting empty and default values so canonical views
|
||
// have canonical URLs. Extra params ride along unchanged (own keys
|
||
// excluded, so a stale caller-supplied copy of this list's state cannot
|
||
// shadow it).
|
||
func (n ListNav) url(q, facet string, page, per int) string {
|
||
v := url.Values{}
|
||
for k, vals := range n.Extra {
|
||
if n.ownParam(k) {
|
||
continue
|
||
}
|
||
v[k] = vals
|
||
}
|
||
if q != "" {
|
||
v.Set(n.QParam(), q)
|
||
}
|
||
if facet != "" && n.FacetParam != "" {
|
||
v.Set(n.FacetParam, facet)
|
||
}
|
||
if page > 1 {
|
||
v.Set(n.PageParam(), strconv.Itoa(page))
|
||
}
|
||
def := n.DefaultPerPage
|
||
if def == 0 {
|
||
def = operatorListPageSize
|
||
}
|
||
if per > 0 && per != def {
|
||
v.Set(n.PerParam(), strconv.Itoa(per))
|
||
}
|
||
if len(v) == 0 {
|
||
return n.BasePath
|
||
}
|
||
return n.BasePath + "?" + v.Encode()
|
||
}
|
||
|
||
// ExtraInputs is Extra minus this list's own keys, for the search form's
|
||
// hidden inputs (a GET form replaces the whole query string, so sibling
|
||
// state must be re-submitted explicitly).
|
||
func (n ListNav) ExtraInputs() url.Values {
|
||
v := url.Values{}
|
||
for k, vals := range n.Extra {
|
||
if n.ownParam(k) {
|
||
continue
|
||
}
|
||
v[k] = vals
|
||
}
|
||
return v
|
||
}
|
||
|
||
func (n ListNav) PrevURL() string { return n.url(n.Q, n.Facet, n.Page-1, n.PerPage) }
|
||
func (n ListNav) NextURL() string { return n.url(n.Q, n.Facet, n.Page+1, n.PerPage) }
|
||
|
||
// FacetURL switches to the given facet value (empty clears the filter),
|
||
// preserving the search and page size and resetting to page 1.
|
||
func (n ListNav) FacetURL(value string) string { return n.url(n.Q, value, 1, n.PerPage) }
|
||
|
||
// ClearSearchURL drops the search term, preserving the facet and size.
|
||
func (n ListNav) ClearSearchURL() string { return n.url("", n.Facet, 1, n.PerPage) }
|
||
|
||
// PerPageURL switches the page size, preserving search and facet and
|
||
// resetting to page 1 (a size change re-windows the whole set).
|
||
func (n ListNav) PerPageURL(v int) string { return n.url(n.Q, n.Facet, 1, v) }
|
||
|
||
// FacetActive reports whether value is the active filter.
|
||
func (n ListNav) FacetActive(value string) bool { return n.Facet == value }
|
||
|
||
// ValidFacet returns facet unchanged when it is one of the allowed
|
||
// values, "" otherwise — a reserved-but-unimplemented or mistyped value
|
||
// is ignored rather than rejected (operator-panel-navigation: "Unknown
|
||
// filter params do not error").
|
||
func ValidFacet(facet string, options []FacetOption) string {
|
||
for _, o := range options {
|
||
if o.Value == facet {
|
||
return facet
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// Showing is the "Showing X–Y of N" line, empty when the list is empty
|
||
// (the no-match or empty state speaks instead).
|
||
func (n ListNav) Showing() string {
|
||
if n.Total == 0 {
|
||
return ""
|
||
}
|
||
return fmt.Sprintf("Showing %d–%d of %d", n.From(), n.To(), n.Total)
|
||
}
|