- pact: HasMenuItemTypes, MenuItemType, MenuItemReference, MenuItemResolve,
ResolvedMenuItem, MenuItemAlternate, DependentOptionsProvider,
DependentOptionSet; Option gains Depth (omitempty)
- signpost (new module): boot-time registry with fail-loud key and pattern
checks, layered URL patterns incl. PatternFrom for list types, host
overrides at menu_items.patterns, Resolve and ReferenceOptions
- party: signpost.Activate after postcard, before Boot
- cabana: dependsOn on dropdowns (boot checks, controller-only), GET
fields/{field}/dependent-options with swag, inventories, conformance case
- companions fixture, TestSignpostSmoke, TestCompanionsDependentSmoke, docs
219 lines
7.2 KiB
Go
219 lines
7.2 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/towel"
|
|
"github.com/goccy/go-yaml/ast"
|
|
)
|
|
|
|
// compileDependsOn decodes a field's `dependsOn` (D-08): the name of the
|
|
// sibling field whose value selects the dropdown's options. It is valid on
|
|
// type dropdown only, and such a dropdown takes no `options`: its choices
|
|
// come from the controller's pact.DependentOptionsProvider. The sibling is
|
|
// checked against the form in checkDependsOn.
|
|
func compileDependsOn(typ string, values map[string]ast.Node, field *FormField) error {
|
|
node, ok := values["dependsOn"]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if typ != "dropdown" {
|
|
return fmt.Errorf("dependsOn is only valid on type: dropdown")
|
|
}
|
|
name, err := nodeString(node)
|
|
if err != nil || !identifier(name) {
|
|
return fmt.Errorf("dependsOn %q is not a field name", nodeText(node))
|
|
}
|
|
if _, has := values["options"]; has {
|
|
return fmt.Errorf("a dropdown with dependsOn takes no options (its controller's DependentOptions supplies them)")
|
|
}
|
|
field.DependsOn = name
|
|
return nil
|
|
}
|
|
|
|
// checkDependsOn checks every dependsOn of fields (one form, or one repeater
|
|
// row) against its siblings: the named field must be another field of the
|
|
// same fields with a scalar value. owner, when set, prefixes the field names
|
|
// of the error (a repeater's nested fields).
|
|
func checkDependsOn(fields []FormField, owner string) error {
|
|
byName := make(map[string]FormField, len(fields))
|
|
for _, field := range fields {
|
|
byName[field.Name] = field
|
|
}
|
|
for _, field := range fields {
|
|
if field.DependsOn == "" {
|
|
continue
|
|
}
|
|
name := field.Name
|
|
if owner != "" {
|
|
name = owner + "." + field.Name
|
|
}
|
|
if field.DependsOn == field.Name {
|
|
return fmt.Errorf("field %s: dependsOn names the field itself", name)
|
|
}
|
|
sibling, ok := byName[field.DependsOn]
|
|
if !ok {
|
|
where := "this form"
|
|
if owner != "" {
|
|
where = "the same repeater row"
|
|
}
|
|
return fmt.Errorf("field %s: dependsOn %s is not a field of %s", name, field.DependsOn, where)
|
|
}
|
|
if !scalarFormField(sibling.Type) {
|
|
return fmt.Errorf("field %s: dependsOn %s must be a field with a scalar value, not type %s", name, field.DependsOn, sibling.Type)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// usesDependsOn reports whether any field of the form, top level or nested,
|
|
// declares dependsOn.
|
|
func usesDependsOn(fields []FormField) bool {
|
|
for _, field := range fields {
|
|
if field.DependsOn != "" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// requireDependentProvider fails the boot when the form declares dependsOn
|
|
// and the controller does not implement pact.DependentOptionsProvider.
|
|
func requireDependentProvider(ctl pact.AdminController, fields []FormField) error {
|
|
if err := checkDependsOn(fields, ""); err != nil {
|
|
return err
|
|
}
|
|
if !usesDependsOn(fields) {
|
|
return nil
|
|
}
|
|
if _, ok := ctl.(pact.DependentOptionsProvider); ok {
|
|
return nil
|
|
}
|
|
for _, field := range fields {
|
|
if field.DependsOn != "" {
|
|
return fmt.Errorf("field %s: dependsOn needs controller %s to implement pact.DependentOptionsProvider", field.Name, ctl.ID())
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// controllerFormOnly refuses, on a settings form or a relation form, a field
|
|
// that only an admin controller's save path supports: its checks run in the
|
|
// controller save and nowhere else.
|
|
func controllerFormOnly(field FormField) error {
|
|
if field.DependsOn != "" {
|
|
return fmt.Errorf("dependsOn is supported in controller forms only")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// dependentField is the dependsOn dropdown name of the controller's form, or
|
|
// false.
|
|
func dependentField(cc *CompiledController, name string) (FormField, bool) {
|
|
if cc == nil || cc.Form == nil {
|
|
return FormField{}, false
|
|
}
|
|
for _, field := range cc.Form.Fields {
|
|
if field.Name == name && field.DependsOn != "" {
|
|
return field, true
|
|
}
|
|
}
|
|
return FormField{}, false
|
|
}
|
|
|
|
// dependentValue normalizes a sibling value for pact.DependentOptionsProvider:
|
|
// a scalar becomes its text and an empty value nil, so the options route
|
|
// (query strings) and a save (JSON values) hand the provider the same thing.
|
|
func dependentValue(v any) any {
|
|
switch t := v.(type) {
|
|
case nil:
|
|
return nil
|
|
case string:
|
|
if t == "" {
|
|
return nil
|
|
}
|
|
return t
|
|
case bool:
|
|
return strconv.FormatBool(t)
|
|
case float64:
|
|
return strconv.FormatFloat(t, 'f', -1, 64)
|
|
case int:
|
|
return strconv.Itoa(t)
|
|
case int64:
|
|
return strconv.FormatInt(t, 10)
|
|
case uint:
|
|
return strconv.FormatUint(uint64(t), 10)
|
|
default:
|
|
return fmt.Sprint(t)
|
|
}
|
|
}
|
|
|
|
// dependentOptions asks the controller's provider for the options of field
|
|
// given the sibling's value.
|
|
func dependentOptions(ctx context.Context, cc *CompiledController, path, sibling string, value any) (pact.DependentOptionSet, error) {
|
|
provider, ok := cc.Controller.(pact.DependentOptionsProvider)
|
|
if !ok || provider == nil {
|
|
return pact.DependentOptionSet{}, fmt.Errorf("cabana: controller %s has no DependentOptions", controllerID(cc))
|
|
}
|
|
return provider.DependentOptions(ctx, path, map[string]any{sibling: dependentValue(value)})
|
|
}
|
|
|
|
// AdminDependentOption is one choice of a dependent dropdown: the value, the
|
|
// label in the request locale and the nesting level (0 at the top) of a
|
|
// choice taken from a tree.
|
|
type AdminDependentOption struct {
|
|
Value string `json:"value"`
|
|
Label string `json:"label"`
|
|
Depth int `json:"depth"`
|
|
}
|
|
|
|
// AdminDependentOptionsResult is the data of the dependent-options route.
|
|
// Applies is false when the sibling's value takes no choice at all (the field
|
|
// then stores null); Options is then empty. With Applies true, an empty
|
|
// Options means there is nothing to choose yet.
|
|
type AdminDependentOptionsResult struct {
|
|
Applies bool `json:"applies"`
|
|
Options []AdminDependentOption `json:"options"`
|
|
}
|
|
|
|
// dependentOptionsRoute serves GET {vendor}/{plugin}/{controller}/fields/{field}/dependent-options
|
|
// (D-08): the options of a dropdown with dependsOn, for the sibling value the
|
|
// query passes as values[<sibling>]=<value>. It is guarded like the form
|
|
// schema: a backend principal with the controller's permissions. A field that
|
|
// is not a dependsOn dropdown of the controller's form answers 404.
|
|
func (s *service) dependentOptionsRoute(w http.ResponseWriter, r *http.Request) {
|
|
s.protect(w, r, func(cc *CompiledController) {
|
|
field, ok := dependentField(cc, r.PathValue("field"))
|
|
if !ok {
|
|
writeNotFound(w, r)
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
var value any
|
|
if raw, present := q["values["+field.DependsOn+"]"]; present && len(raw) > 0 {
|
|
value = raw[0]
|
|
}
|
|
set, err := dependentOptions(r.Context(), cc, field.Name, field.DependsOn, value)
|
|
if err != nil {
|
|
slog.Error("cabana: dependent options failed", "controller", controllerID(cc), "field", field.Name, "error", err)
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
return
|
|
}
|
|
tr := s.translator()
|
|
locale := schemaLocale(r.Context(), tr)
|
|
ctx := towel.WithLocale(r.Context(), locale)
|
|
out := AdminDependentOptionsResult{Applies: set.Applies, Options: []AdminDependentOption{}}
|
|
if set.Applies {
|
|
for _, opt := range set.Options {
|
|
out.Options = append(out.Options, AdminDependentOption{Value: opt.Value, Label: translateKey(ctx, tr, opt.Label), Depth: max(opt.Depth, 0)})
|
|
}
|
|
}
|
|
WriteData(w, http.StatusOK, out, map[string]any{"locale": locale})
|
|
})
|
|
}
|