- compileFieldTypes builds a per-boot field type registry (alias, core and cross-plugin collisions, element prefix, assets, option keys) passed down the compile functions; settings and relation forms refuse plugin aliases - fields.yaml accepts a plugin alias with the general keys plus the type's options (typeOptions, element, translatable in the schema); CheckOptions runs at boot; CompileFormWithTypes for plugin tests - save decodes plugin values inside the transaction (FormFieldError is a 422, other errors opaque 500) and answers meta.notices (FieldNotice) only when a type reported something; show and save serve values through Load - GET /field-types lists the types with versioned asset URLs; OpenAPI, schema.d.ts, README and docs/backend/field-types.md updated
274 lines
9.4 KiB
Go
274 lines
9.4 KiB
Go
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,
|
|
// A password is a virtual field, and only an admin controller lists those.
|
|
"password": true,
|
|
// A permission editor's options and storage belong to an admin controller.
|
|
"permissioneditor": 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, types *fieldTypeRegistry) 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, types)
|
|
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, types)
|
|
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, types)
|
|
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, types *fieldTypeRegistry) (*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, types.refusing())
|
|
} else {
|
|
fields, err = decodeFields(raw, types.refusing())
|
|
}
|
|
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 field.Preset != nil {
|
|
return nil, fail(fmt.Errorf("field %s: preset is not supported in a relation form (%s)", field.Name, 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
|
|
types *fieldTypeRegistry
|
|
}
|
|
|
|
func (m *pivotFieldMap) UnmarshalYAML(node ast.Node) error {
|
|
items, err := decodeFieldMapping(node, pivotFieldName, m.types)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.items = items
|
|
return nil
|
|
}
|
|
|
|
func decodePivotFields(raw []byte, types *fieldTypeRegistry) ([]FormField, error) {
|
|
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
|
|
doc := pivotFieldsFile{Fields: pivotFieldMap{types: types}}
|
|
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
|
|
}
|