Files
member-console/internal/forms/registry.go
T
cgalo5758 88db730fcc Add dual licensing and SPDX headers
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.
2026-09-06 02:29:42 -05:00

149 lines
4.7 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package forms
import (
"fmt"
"sort"
"sync"
)
// The registry (design D7; spec form-library "The registry holds every
// form and is checked"). Every FormSpec registers into one package-level
// index at construction, the way the screens manifest is the one list the
// screen-coverage rule reads. The index is the console's list of forms,
// not their home: each form is still declared beside the handler that
// serves it. Four consumers read it and nothing enters it that none of
// them reads: the invariants test, the route test, the capture-coverage
// test, and the design system's registered-forms table.
var (
registryMu sync.RWMutex
registry = map[string]FormSpec{}
triggers = map[string]ActionTrigger{}
)
// Register indexes one form. A duplicate name panics at boot, because two
// forms answering to one name would make every derived check ambiguous.
// Registration is idempotent for an identical re-registration, so a
// handler constructed twice in one process (a test set beside the real
// one) does not bring the process down.
func Register(spec FormSpec) FormSpec {
registryMu.Lock()
defer registryMu.Unlock()
if existing, ok := registry[spec.Name]; ok {
if !sameSpec(existing, spec) {
panic(fmt.Sprintf("forms: two different forms registered as %q", spec.Name))
}
return spec
}
registry[spec.Name] = spec
return spec
}
// All returns every registered form, ordered by name.
func All() []FormSpec {
registryMu.RLock()
defer registryMu.RUnlock()
out := make([]FormSpec, 0, len(registry))
for _, s := range registry {
out = append(out, s)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Lookup returns one registered form by name.
func Lookup(name string) (FormSpec, bool) {
registryMu.RLock()
defer registryMu.RUnlock()
s, ok := registry[name]
return s, ok
}
// sameSpec reports whether two registrations describe the same form
// closely enough to be one registration. Names, routes and field names are
// the parts every derived check reads.
func sameSpec(a, b FormSpec) bool {
if a.Kind != b.Kind || a.Family != b.Family || a.Method != b.Method || a.Path != b.Path ||
a.EditMethod != b.EditMethod || a.EditPath != b.EditPath || len(a.Fields) != len(b.Fields) {
return false
}
for i := range a.Fields {
if a.Fields[i].Name != b.Fields[i].Name {
return false
}
}
return true
}
// ActionTrigger is a mutation the console fires from something that is not
// a form: a row's Revoke button, a panel's Sync control, a sortable
// table's reorder. These are the other half of the route-level
// template-versus-handler check (findings FA-6, FA-7, FA-9): the route
// test asserts every registered mutation route is either a form's path or
// one of these, so a handler cannot grow a route nothing renders and no
// declaration names.
type ActionTrigger struct {
// Label is what the control reads, for the test's failure message and
// for a reader looking for the control on the page.
Label string
// Method and Path are the registered route.
Method string
Path string
// Modal reports whether the shared confirm modal guards the trigger.
Modal bool
// Note records anything a reader needs that the label does not say
// (the trigger is the sortable table itself, for instance).
Note string
}
// Key is the trigger's registry key, "METHOD /path".
func (t ActionTrigger) Key() string { return t.Method + " " + t.Path }
// RegisterTrigger declares one out-of-form mutation trigger. Two triggers
// may share a route (the same mutation fired from two places); the first
// registration wins and the second is ignored, so a route is declared once
// however many controls fire it.
func RegisterTrigger(t ActionTrigger) {
registryMu.Lock()
defer registryMu.Unlock()
if _, ok := triggers[t.Key()]; ok {
return
}
triggers[t.Key()] = t
}
// ActionTriggers returns every declared out-of-form mutation trigger,
// ordered by route.
func ActionTriggers() []ActionTrigger {
registryMu.RLock()
defer registryMu.RUnlock()
out := make([]ActionTrigger, 0, len(triggers))
for _, t := range triggers {
out = append(out, t)
}
sort.Slice(out, func(i, j int) bool { return out[i].Key() < out[j].Key() })
return out
}
// Routes returns every route a registered form submits to, as
// "METHOD /path", including the record side's route where it differs.
func Routes() map[string]string {
registryMu.RLock()
defer registryMu.RUnlock()
out := map[string]string{}
for _, s := range registry {
out[s.Method+" "+s.Path] = s.Name
if s.EditPath != "" {
method := s.EditMethod
if method == "" {
method = s.Method
}
out[method+" "+s.EditPath] = s.Name
}
}
return out
}