Files
summercms/modules/cabana/relation_form.go
Jakub Zych 48a5b8045a 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
2026-10-02 18:37:13 +02:00

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
}