Files
summercms/modules/cabana/field_dependent.go
Jakub Zych 46835fd064 feat(17-01): repeater field type and the dependent dropdown save recheck
- cabana: type repeater with nested form.fields (text, textarea, number,
  checkbox, switch, dropdown, richeditor), prompt, minItems, maxItems,
  titleFrom; groups, style, nested repeaters and other types fail boot
- rows decoded before projection: only nested keys, coerced scalars,
  per-row required (field.index.nested), row bounds; ProjectWritableFields
  exempts repeater lists only, nestedValue unchanged
- nested richeditor values sanitized with notices under the row path
- dependent dropdowns rechecked on save, top level and per row; null when
  the sibling takes no choice, 422 form.dependent_invalid otherwise
- dependent-options accepts repeater.field; schema carries RepeaterForm
- backend lang repeater.max_items, repeater.min_items,
  form.dependent_invalid; TestCompanionsRepeaterSmoke; docs
2026-10-12 15:24:01 +02:00

383 lines
12 KiB
Go

package cabana
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"reflect"
"strconv"
"strings"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"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
}
if field.Form != nil && usesDependsOn(field.Form.Fields) {
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())
}
if field.Form != nil {
for _, inner := range field.Form.Fields {
if inner.DependsOn != "" {
return fmt.Errorf("field %s.%s: dependsOn needs controller %s to implement pact.DependentOptionsProvider", field.Name, inner.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.Type == richEditorType || field.Type == repeaterType {
return fmt.Errorf("%s is supported in controller forms only", field.Type)
}
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.
// dependentField is the dependsOn dropdown of the controller's form named
// by path: a top-level field name, or repeater.nested for a dropdown of a
// repeater row. The returned field carries the path as its Name.
func dependentField(cc *CompiledController, path string) (FormField, bool) {
if cc == nil || cc.Form == nil {
return FormField{}, false
}
outer, inner, nested := strings.Cut(path, ".")
for _, field := range cc.Form.Fields {
if field.Name != outer {
continue
}
if !nested {
return field, field.DependsOn != ""
}
if field.Form == nil {
return FormField{}, false
}
for _, sub := range field.Form.Fields {
if sub.Name == inner && sub.DependsOn != "" {
sub.Name = path
return sub, 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 json.Number:
return t.String()
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})
})
}
// msgDependentInvalid is the 422 message of a dependent dropdown value that
// is not among the options for the submitted sibling value (backend::lang).
const msgDependentInvalid = "backend::lang.form.dependent_invalid"
// checkDependentValues rechecks every dependsOn dropdown of a save (D-08):
// at the top level when the dropdown or its sibling is written, and in every
// row of a written repeater. The controller's provider is asked with the
// sibling's submitted value (the stored one when only the dropdown is
// written). When the answer does not apply, the dropdown is stored null; when
// it applies, a value that is not among the options is a 422
// form.dependent_invalid on the field (field.index.nested in a row). Null is
// accepted here; required is checked by the rules.
func checkDependentValues(ctx context.Context, cc *CompiledController, tr *phrasebook.Translator, target any, projected map[string]any, op string) error {
if cc == nil || cc.Form == nil || !usesDependsOn(cc.Form.Fields) {
return nil
}
provider, ok := cc.Controller.(pact.DependentOptionsProvider)
if !ok || provider == nil {
return fmt.Errorf("cabana: controller %s has no DependentOptions", controllerID(cc))
}
cache := map[string]pact.DependentOptionSet{}
ask := func(path, sibling string, value any) (pact.DependentOptionSet, error) {
v := dependentValue(value)
key := path + "\x00" + fmt.Sprint(v)
if set, ok := cache[key]; ok {
return set, nil
}
set, err := provider.DependentOptions(ctx, path, map[string]any{sibling: v})
if err != nil {
return pact.DependentOptionSet{}, err
}
cache[key] = set
return set, nil
}
locCtx := towel.WithLocale(ctx, schemaLocale(ctx, tr))
message := translateKey(locCtx, tr, msgDependentInvalid)
details := map[string]any{}
for _, field := range cc.Form.Fields {
if field.DependsOn != "" && contextAllows(cc, field.Name, op) {
key, siblingKey := fillKeyOf(cc, field.Name), fillKeyOf(cc, field.DependsOn)
value, hasValue := projected[key]
sibling, hasSibling := projected[siblingKey]
if hasValue || hasSibling {
if !hasValue {
value = modelValue(target, key)
}
if !hasSibling {
sibling = modelValue(target, siblingKey)
}
set, err := ask(field.Name, field.DependsOn, sibling)
if err != nil {
return err
}
switch {
case !set.Applies:
projected[key] = nil
case !amongOptions(set, value):
details[field.Name] = []string{message}
}
}
}
if field.Type != repeaterType || field.Form == nil {
continue
}
rows, ok := repeaterRows(projected, fillKeyOf(cc, field.Name))
if !ok {
continue
}
for i, item := range rows {
row, ok := item.(map[string]any)
if !ok {
continue
}
for _, inner := range field.Form.Fields {
if inner.DependsOn == "" {
continue
}
set, err := ask(field.Name+"."+inner.Name, inner.DependsOn, row[inner.DependsOn])
if err != nil {
return err
}
switch {
case !set.Applies:
row[inner.Name] = nil
case !amongOptions(set, row[inner.Name]):
details[fmt.Sprintf("%s.%d.%s", field.Name, i, inner.Name)] = []string{message}
}
}
}
}
if len(details) > 0 {
return &ValidationError{Details: details}
}
return nil
}
// amongOptions reports whether value is null or one of the set's option
// values.
func amongOptions(set pact.DependentOptionSet, value any) bool {
v := dependentValue(value)
if v == nil {
return true
}
text, _ := v.(string)
for _, opt := range set.Options {
if opt.Value == text {
return true
}
}
return false
}
// modelValue is the current value of the model's column, dereferenced; nil
// when the column is missing or a nil pointer.
func modelValue(model any, column string) any {
v := reflect.ValueOf(model)
for v.Kind() == reflect.Pointer {
if v.IsNil() {
return nil
}
v = v.Elem()
}
f := fieldByColumn(v, column)
if !f.IsValid() || !f.CanInterface() {
return nil
}
return derefValue(f.Interface())
}