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[]=. 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()) }