Files
summercms/modules/cabana/field_dependent.go
Jakub Zych 0cdff4c258 feat(17-01): menu item type registry and dependent dropdown binding
- 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
2026-10-12 15:09:14 +02:00

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