- 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
383 lines
12 KiB
Go
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())
|
|
}
|