feat(12.2-03): add hasMany relation contracts, relation forms and child create
- 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
This commit is contained in:
265
modules/cabana/relation_form.go
Normal file
265
modules/cabana/relation_form.go
Normal file
@@ -0,0 +1,265 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user