- RelationContract gains Kind (empty is belongsToMany) and ForeignKey, with kind-aware boot checks
- manage.form, view.form and pivot.form compile against the related or pivot model; $/ paths resolve inside the plugin
- view toolbarButtons accept create|update|delete|link|unlink, each the capability of its routes
- POST .../relations/{name}/records creates a child through the manage form; the server sets the hasMany key
- relation schema carries kind, deferrable and the localized forms; 17 new relation message keys in en and pl
266 lines
8.9 KiB
Go
266 lines
8.9 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,
|
|
}
|
|
|
|
// 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
|
|
}
|