package cabana import ( "bytes" "errors" "fmt" "io/fs" "regexp" "slices" "strings" "git.golem15.com/golem15/summercms/modules/pact" "github.com/goccy/go-yaml" "github.com/goccy/go-yaml/ast" ) // relationFormRefusedTypes are the field types a relation form (manage.form, // view.form, pivot.form) may not use (D-23): pickers, nested managers and // plugin extensions need a parent record scope a child modal does not have. var relationFormRefusedTypes = map[string]bool{ "relation": true, "relation-manager": true, "widget": true, "partial": true, } // pivotFieldPattern is WinterCMS's pivot form field name, pivot[column]. var pivotFieldPattern = regexp.MustCompile(`^pivot\[([A-Za-z_][A-Za-z0-9_]*)\]$`) // pivotFieldName normalizes pivot[x] to x; any other name is unchanged. func pivotFieldName(name string) string { if m := pivotFieldPattern.FindStringSubmatch(name); m != nil { return m[1] } return name } // relationModelController stands in for an admin controller around a // relation form's model (the related model, or the pivot model), so the // controller form pipeline (writable fields, file and date fields, fill, // validate, projection) runs against that model. Its ID is the owning // controller's, so boot errors and lifecycle failures name it. type relationModelController struct { id string newRecord func() any } func (c relationModelController) ID() string { return c.id } func (c relationModelController) ModelName() string { return "" } func (c relationModelController) ConfigDir() string { return "" } func (c relationModelController) NewRecord() any { return c.newRecord() } // compileRelationForms compiles a relation's forms (D-11, D-14, D-23): the // manage form (manage.form, else the top-level form) used to create and // update children, the view form (view.form, else the top-level form) used // to preview them, and the pivot form (pivot.form, belongsToMany only). It // also enforces the toolbar rules that depend on them: create and update // need a manage form, and unlink on a hasMany needs a nullable ForeignKey. func compileRelationForms(pluginID string, ctl pact.AdminController, fsys fs.FS, file string, doc relationDocument, cr *CompiledRelation) error { name := cr.Contract.Name fail := func(err error) error { return bootErr(pluginID, ctl.ID(), file, fmt.Errorf("relation %s: %w", name, err)) } buttons := cr.Schema.View.ToolbarButtons writes := slices.Contains(buttons, "create") || slices.Contains(buttons, "update") manageRef := strings.TrimSpace(doc.Manage.Form) if manageRef == "" { manageRef = strings.TrimSpace(doc.Form) } viewRef := strings.TrimSpace(doc.View.Form) if viewRef == "" { viewRef = strings.TrimSpace(doc.Form) } if writes && manageRef == "" { return fail(errors.New("toolbar buttons create and update need manage.form (or a top-level form)")) } if cr.hasMany() && slices.Contains(buttons, "unlink") && !cr.deferrable { return fail(fmt.Errorf("unlink needs a nullable ForeignKey %s (a pointer field)", cr.Contract.ForeignKey)) } cr.Schema.relatedModel = cr.Contract.NewRelated if manageRef != "" { child, err := compileRelationForm(pluginID, ctl, fsys, file, manageRef, cr, "manage", writes) if err != nil { return err } cr.child = child cr.Schema.manageForm = child.Form cr.Schema.ManageForm = child.Form.Fields } if viewRef != "" { view := cr.child if viewRef != manageRef || view == nil { compiled, err := compileRelationForm(pluginID, ctl, fsys, file, viewRef, cr, "view", false) if err != nil { return err } view = compiled } cr.view = view cr.Schema.viewForm = view.Form cr.Schema.ViewForm = view.Form.Fields } if doc.Pivot != nil { ref := strings.TrimSpace(doc.Pivot.Form) if ref == "" { return fail(errors.New("pivot needs a form")) } if cr.hasMany() { return fail(errors.New("pivot.form is only valid on a belongsToMany relation")) } pivot, err := compileRelationForm(pluginID, ctl, fsys, file, ref, cr, "pivot", false) if err != nil { return err } cr.pivot = pivot cr.Schema.pivotForm = pivot.Form cr.Schema.PivotForm = pivot.Form.Fields cr.Schema.pivotModel = cr.Contract.NewPivot } return nil } // compileRelationForm reads one relation form file and binds it to its // model: the related model for the manage and view forms, the pivot model // for the pivot form. writable additionally requires the model to implement // lagoon.HasFillable and Rules (create and update fill and validate it). func compileRelationForm(pluginID string, ctl pact.AdminController, fsys fs.FS, cfgFile, ref string, cr *CompiledRelation, purpose string, writable bool) (*CompiledController, error) { relation := cr.Contract.Name path, err := assetPath(pluginID, ref) if err != nil { return nil, bootErr(pluginID, ctl.ID(), cfgFile, fmt.Errorf("relation %s %s form: %w", relation, purpose, err)) } fail := func(err error) error { return bootErr(pluginID, ctl.ID(), path, fmt.Errorf("relation %s %s form: %w", relation, purpose, err)) } raw, err := readAsset(fsys, path) if err != nil { return nil, fail(err) } var fields []FormField if purpose == "pivot" { fields, err = decodePivotFields(raw) } else { fields, err = decodeFields(raw) } if err != nil { return nil, fail(err) } newModel := cr.Contract.NewRelated if purpose == "pivot" { newModel = cr.Contract.NewPivot } model := newModel() for _, field := range fields { if relationFormRefusedTypes[field.Type] { return nil, fail(fmt.Errorf("field %s: type %s is not supported in a relation form (%s)", field.Name, field.Type, purpose)) } if cr.hasMany() && field.Name == cr.Contract.ForeignKey { return nil, fail(fmt.Errorf("field %s is the relation's ForeignKey; the server sets it", field.Name)) } if purpose == "pivot" { if !scalarFormField(field.Type) { return nil, fail(fmt.Errorf("field %s: type %s is not supported in a pivot form", field.Name, field.Type)) } if protectedPivotColumn(field.Name, cr.Contract) || slices.Contains(cr.Contract.HookPivotColumns, field.Name) { return nil, fail(fmt.Errorf("field %s is a server-owned pivot column", field.Name)) } } if field.optionsMethod != "" && modelDropdownProvider(model) == nil { return nil, fail(fmt.Errorf("dropdown method %s requires DropdownOptions on the %s model", field.optionsMethod, purpose)) } } cc := &CompiledController{ PluginID: pluginID, Controller: relationModelController{id: ctl.ID(), newRecord: newModel}, Form: &FormSchema{Fields: fields, fieldsPath: path}, } if err := BindWritableFields(cc); err != nil { return nil, fail(errors.New(strings.TrimPrefix(err.Error(), "cabana: controller "+ctl.ID()+": "))) } if writable { if _, err := newWritableModel(cc); err != nil { return nil, fail(errors.New("the related model must implement lagoon.HasFillable and Rules")) } } if purpose == "pivot" { if err := checkFormDates(pluginID, cc); err != nil { return nil, err } return cc, nil } if err := compileFileFields(pluginID, cc); err != nil { return nil, err } if writable { if err := compileDateFields(pluginID, cc); err != nil { return nil, err } return cc, nil } if err := checkFormDates(pluginID, cc); err != nil { return nil, err } return cc, nil } // checkFormDates runs the D-19 Go type check of every datepicker field and // records them, without requiring a fillable model (read-only and pivot // forms). func checkFormDates(pluginID string, cc *CompiledController) error { model := cc.Controller.(pact.AdminRecordSource).NewRecord() for _, field := range cc.Form.Fields { if field.Type != "datepicker" { continue } if err := checkDateType(model, field); err != nil { return bootErr(pluginID, controllerID(cc), cc.Form.fieldsPath, err) } if cc.dates == nil { cc.dates = map[string]*compiledDate{} } cc.dates[field.Name] = &compiledDate{ name: field.Name, mode: field.Mode, min: field.MinDate, max: field.MaxDate, ignoreTimezone: field.IgnoreTimezone, } } return nil } // pivotFieldsFile is a pivot form's fields.yaml: WinterCMS names its fields // pivot[column], which compile to the bare column name. type pivotFieldsFile struct { Fields pivotFieldMap `yaml:"fields"` } type pivotFieldMap struct { items []FormField } func (m *pivotFieldMap) UnmarshalYAML(node ast.Node) error { items, err := decodeFieldMapping(node, pivotFieldName) if err != nil { return err } m.items = items return nil } func decodePivotFields(raw []byte) ([]FormField, error) { dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField()) var doc pivotFieldsFile if err := dec.Decode(&doc); err != nil { return nil, normalizeYAMLError(err) } if doc.Fields.items == nil { return []FormField{}, nil } return doc.Fields.items, nil } // modelDropdownProvider is model as a pact.DropdownOptionsProvider, or nil. func modelDropdownProvider(model any) pact.DropdownOptionsProvider { if p, ok := model.(pact.DropdownOptionsProvider); ok && p != nil { return p } return nil }