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