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.
149 lines
4.7 KiB
Go
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
|
|
}
|