Files
summercms/modules/cabana/form_schema.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

745 lines
22 KiB
Go

package cabana
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/fs"
"path"
"strconv"
"strings"
"context"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
"github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/ast"
)
var (
formFieldTypes = map[string]struct{}{
"text": {}, "textarea": {}, "number": {}, "checkbox": {},
"switch": {}, "dropdown": {}, "relation": {}, "relation-manager": {},
"widget": {}, "partial": {}, "fileupload": {}, "datepicker": {},
}
formSpans = map[string]struct{}{
"left": {}, "right": {}, "full": {}, "auto": {}, "row": {},
}
formSizes = map[string]struct{}{
"tiny": {}, "small": {}, "large": {}, "huge": {}, "giant": {},
}
formFieldKeys = map[string]struct{}{
"label": {}, "comment": {}, "span": {}, "type": {}, "required": {},
"tab": {}, "context": {}, "attributes": {}, "size": {}, "default": {},
"nameFrom": {}, "emptyOption": {}, "options": {}, "relation": {},
"widget": {}, "action": {}, "fill": {}, "path": {},
"mode": {}, "fileTypes": {}, "mimeTypes": {}, "maxFilesize": {},
"maxFiles": {}, "imageWidth": {}, "imageHeight": {},
"thumbOptions": {}, "useCaption": {}, "prompt": {},
"format": {}, "minDate": {}, "maxDate": {}, "yearRange": {},
"firstDay": {}, "twelveHour": {}, "ignoreTimezone": {},
}
// widgetKeys are valid only on `type: widget` (D-06).
widgetKeys = []string{"widget", "action", "fill"}
)
type formConfigDocument struct {
Name string `yaml:"name"`
Form string `yaml:"form"`
ModelClass string `yaml:"modelClass"`
DefaultRedirect string `yaml:"defaultRedirect"`
Create *formRedirects `yaml:"create"`
Update *formRedirects `yaml:"update"`
Messages *formMessageKeys `yaml:"messages"`
}
type formRedirects struct {
Redirect string `yaml:"redirect"`
RedirectClose string `yaml:"redirectClose"`
}
type formFieldsFile struct {
Fields fieldMap `yaml:"fields"`
}
type fieldMap struct {
items []FormField
}
// CompileForm compiles config_form.yaml and the model fields.yaml it names.
// The returned schema is safe to cache: labels stay as source keys.
func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSchema, error) {
if ctl == nil {
return nil, fmt.Errorf("cabana: admin schema %s: controller is nil", pluginID)
}
dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/")
if dir == "." || strings.HasPrefix(dir, "..") {
return nil, bootErr(pluginID, ctl.ID(), ctl.ConfigDir(), fmt.Errorf("config directory escapes the plugin"))
}
cfgPath := dir + "/config_form.yaml"
raw, err := readAsset(fsys, cfgPath)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
}
var doc formConfigDocument
if err := decodeStrict(raw, &doc); err != nil {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
}
if doc.ModelClass != ctl.ModelName() {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("modelClass %q does not match %q", doc.ModelClass, ctl.ModelName()))
}
if strings.TrimSpace(doc.Form) == "" {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("form file is empty"))
}
fieldsPath, err := assetPath(pluginID, doc.Form)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
}
fieldsRaw, err := readAsset(fsys, fieldsPath)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err)
}
fields, err := decodeFields(fieldsRaw)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err)
}
if fields == nil {
fields = []FormField{}
}
if err := requireDropdownProvider(ctl, fields); err != nil {
return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err)
}
schema := &FormSchema{
Name: doc.Name,
ModelClass: doc.ModelClass,
Fields: fields,
redirects: FormRedirects{Default: doc.DefaultRedirect},
fieldsPath: fieldsPath,
}
if doc.Messages != nil {
schema.messageKeys = *doc.Messages
}
if doc.Create != nil {
schema.redirects.Create = FormRedirect{Redirect: doc.Create.Redirect, RedirectClose: doc.Create.RedirectClose}
}
if doc.Update != nil {
schema.redirects.Update = FormRedirect{Redirect: doc.Update.Redirect, RedirectClose: doc.Update.RedirectClose}
}
return schema, nil
}
// Localize copies the cached schema and resolves display strings for one request.
// It does not mutate s. Option values keep the scalar captured at boot.
func (s *FormSchema) Localize(ctx context.Context, tr *phrasebook.Translator, provider pact.DropdownOptionsProvider) (*FormView, error) {
if s == nil {
return nil, fmt.Errorf("cabana: nil form schema")
}
if ctx == nil {
ctx = context.Background()
}
locale := schemaLocale(ctx, tr)
ctx = towel.WithLocale(ctx, locale)
fields := make([]FormField, len(s.Fields))
for i, src := range s.Fields {
field := src
field.Label = translateKey(ctx, tr, src.Label)
field.Comment = translateKey(ctx, tr, src.Comment)
field.Tab = translateKey(ctx, tr, src.Tab)
field.EmptyOption = translateKey(ctx, tr, src.EmptyOption)
field.ActionLabel = translateKey(ctx, tr, src.ActionLabel)
field.Prompt = translateKey(ctx, tr, src.Prompt)
field.Fill = append([]string(nil), src.Fill...)
field.FileTypes = append([]string(nil), src.FileTypes...)
field.MimeTypes = append([]string(nil), src.MimeTypes...)
field.YearRange = append([]int(nil), src.YearRange...)
options, err := localizeOptions(ctx, tr, src, provider)
if err != nil {
return nil, err
}
field.Options = options
fields[i] = field
}
if fields == nil {
fields = []FormField{}
}
return &FormView{
Name: translateKey(ctx, tr, s.Name),
ModelClass: s.ModelClass,
Fields: fields,
Messages: localizeMessages[formMessageKeys, FormMessages](ctx, tr, s.formMessageKeySet()),
Redirects: s.redirects,
Meta: FormMeta{Locale: locale},
}, nil
}
// CanonicalJSON is one localized schema response.
func (v *FormView) CanonicalJSON() ([]byte, error) {
if v == nil {
return nil, fmt.Errorf("cabana: nil form view")
}
out := *v
if out.Fields == nil {
out.Fields = []FormField{}
}
return json.Marshal(out)
}
// CanonicalJSON is the stable, locale-neutral form contract.
func (s *FormSchema) CanonicalJSON() ([]byte, error) {
if s == nil {
return nil, fmt.Errorf("cabana: nil form schema")
}
out := *s
if out.Fields == nil {
out.Fields = []FormField{}
}
return json.Marshal(out)
}
func requireDropdownProvider(ctl pact.AdminController, fields []FormField) error {
var method string
for _, field := range fields {
if field.optionsMethod != "" {
method = field.optionsMethod
break
}
}
if method == "" || dropdownProvider(ctl) != nil {
return nil
}
return fmt.Errorf("dropdown method %s requires DropdownOptions", method)
}
func dropdownProvider(ctl pact.AdminController) pact.DropdownOptionsProvider {
if ctl == nil {
return nil
}
if p, ok := ctl.(pact.DropdownOptionsProvider); ok && p != nil {
return p
}
src, ok := ctl.(pact.AdminRecordSource)
if !ok || src == nil {
return nil
}
rec := src.NewRecord()
p, ok := rec.(pact.DropdownOptionsProvider)
if !ok || p == nil {
return nil
}
return p
}
func schemaLocale(ctx context.Context, tr *phrasebook.Translator) string {
if loc, ok := towel.Locale(ctx); ok {
if primary := primaryLanguage(loc); primary != "" {
return primary
}
}
if tr != nil {
if loc := strings.TrimSpace(tr.Locale()); loc != "" {
return loc
}
}
return "en"
}
func primaryLanguage(header string) string {
header = strings.TrimSpace(header)
if header == "" {
return ""
}
if i := strings.IndexAny(header, ",;"); i >= 0 {
header = strings.TrimSpace(header[:i])
}
return header
}
func translateKey(ctx context.Context, tr *phrasebook.Translator, key string) string {
if key == "" || tr == nil {
return key
}
return tr.Get(ctx, key, nil)
}
func localizeOptions(ctx context.Context, tr *phrasebook.Translator, src FormField, provider pact.DropdownOptionsProvider) ([]FormOption, error) {
if src.optionsMethod != "" {
if provider == nil {
return nil, fmt.Errorf("cabana: dropdown method %s requires DropdownOptions", src.optionsMethod)
}
provided := provider.DropdownOptions(src.Name)
opts := make([]FormOption, len(provided))
for i, opt := range provided {
raw, err := json.Marshal(opt.Value)
if err != nil {
return nil, err
}
opts[i] = FormOption{Value: jsonScalar{raw: raw}, Label: translateKey(ctx, tr, opt.Label)}
}
return opts, nil
}
if len(src.Options) == 0 {
return nil, nil
}
opts := make([]FormOption, len(src.Options))
for i, opt := range src.Options {
opt.Label = translateKey(ctx, tr, opt.Label)
opts[i] = opt
}
return opts, nil
}
func decodeFields(raw []byte) ([]FormField, error) {
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
var doc formFieldsFile
if err := dec.Decode(&doc); err != nil {
return nil, normalizeYAMLError(err)
}
if doc.Fields.items == nil {
return []FormField{}, nil
}
return doc.Fields.items, nil
}
func (m *fieldMap) UnmarshalYAML(node ast.Node) error {
items, err := decodeFieldMapping(node, nil)
if err != nil {
return err
}
m.items = items
return nil
}
// decodeFieldMapping compiles a fields mapping in source order. rename, when
// set, maps a raw field name (such as WinterCMS's pivot[role]) to the name
// that must be an identifier.
func decodeFieldMapping(node ast.Node, rename func(string) string) ([]FormField, error) {
node = unwrapNode(node)
if _, ok := node.(*ast.NullNode); ok || node == nil {
return []FormField{}, nil
}
mapping, ok := node.(*ast.MappingNode)
if !ok {
return nil, fmt.Errorf("fields must be a mapping")
}
items := make([]FormField, 0, len(mapping.Values))
seen := map[string]struct{}{}
for _, entry := range mapping.Values {
name, err := nodeString(unwrapNode(entry.Key))
if err == nil && rename != nil {
name = rename(name)
}
if err != nil || !identifier(name) {
return nil, fmt.Errorf("field name %q is not an identifier", nodeText(entry.Key))
}
if _, dup := seen[name]; dup {
return nil, fmt.Errorf("duplicate field %s", name)
}
seen[name] = struct{}{}
field, err := compileFieldNode(name, unwrapNode(entry.Value))
if err != nil {
return nil, fmt.Errorf("field %s: %w", name, err)
}
items = append(items, field)
}
return items, nil
}
func compileFieldNode(name string, node ast.Node) (FormField, error) {
mapping, ok := node.(*ast.MappingNode)
if !ok {
return FormField{}, fmt.Errorf("must be a mapping")
}
values := map[string]ast.Node{}
for _, entry := range mapping.Values {
key, err := nodeString(unwrapNode(entry.Key))
if err != nil {
return FormField{}, err
}
if _, known := formFieldKeys[key]; !known {
return FormField{}, fmt.Errorf("unknown field %s", key)
}
if _, dup := values[key]; dup {
return FormField{}, fmt.Errorf("duplicate field %s", key)
}
values[key] = unwrapNode(entry.Value)
}
typ, err := nodeString(values["type"])
if err != nil || typ == "" {
return FormField{}, fmt.Errorf("type is required")
}
if _, ok := formFieldTypes[typ]; !ok {
return FormField{}, fmt.Errorf("unsupported type %s", typ)
}
field := FormField{Name: name, Type: typ}
if node, ok := values["label"]; ok {
field.Label, err = nodeString(node)
if err != nil {
return FormField{}, fmt.Errorf("label: %w", err)
}
}
if node, ok := values["comment"]; ok {
field.Comment, err = nodeString(node)
if err != nil {
return FormField{}, fmt.Errorf("comment: %w", err)
}
}
if node, ok := values["span"]; ok {
field.Span, err = nodeString(node)
if err != nil {
return FormField{}, fmt.Errorf("span: %w", err)
}
if _, ok := formSpans[field.Span]; !ok {
return FormField{}, fmt.Errorf("unsupported span %s", field.Span)
}
}
if node, ok := values["tab"]; ok {
field.Tab, err = nodeString(node)
if err != nil {
return FormField{}, fmt.Errorf("tab: %w", err)
}
}
if node, ok := values["size"]; ok {
field.Size, err = nodeString(node)
if err != nil {
return FormField{}, fmt.Errorf("size: %w", err)
}
if _, ok := formSizes[field.Size]; !ok {
return FormField{}, fmt.Errorf("unsupported size %s", field.Size)
}
}
if node, ok := values["context"]; ok {
ctx, err := compileContext(node)
if err != nil {
return FormField{}, err
}
field.Context = ctx
}
if node, ok := values["nameFrom"]; ok {
field.NameFrom, err = nodeString(node)
if err != nil || !identifier(field.NameFrom) {
return FormField{}, fmt.Errorf("nameFrom %q is not an identifier", field.NameFrom)
}
}
if node, ok := values["emptyOption"]; ok {
field.EmptyOption, err = nodeString(node)
if err != nil {
return FormField{}, fmt.Errorf("emptyOption: %w", err)
}
}
if node, ok := values["relation"]; ok {
field.Relation, err = nodeString(node)
if err != nil || !identifier(field.Relation) {
return FormField{}, fmt.Errorf("relation %q is not an identifier", field.Relation)
}
}
if typ == "relation-manager" && field.Relation == "" {
return FormField{}, fmt.Errorf("relation is required")
}
if err := compileWidgetKeys(typ, values, &field); err != nil {
return FormField{}, err
}
if err := compilePartialPath(typ, values, &field); err != nil {
return FormField{}, err
}
if err := compileFileuploadKeys(typ, values, &field); err != nil {
return FormField{}, err
}
if err := compileDatepickerKeys(typ, values, &field); err != nil {
return FormField{}, err
}
if node, ok := values["required"]; ok {
field.Required, err = nodeBool(node)
if err != nil {
return FormField{}, fmt.Errorf("required: %w", err)
}
}
if node, ok := values["default"]; ok {
scalar, err := nodeScalar(node)
if err != nil {
return FormField{}, fmt.Errorf("default: %w", err)
}
field.Default = &scalar
}
if node, ok := values["attributes"]; ok {
attrs, err := compileAttributes(node)
if err != nil {
return FormField{}, fmt.Errorf("attributes: %w", err)
}
field.Attributes = attrs
}
if node, ok := values["options"]; ok {
if err := compileOptions(node, &field); err != nil {
return FormField{}, fmt.Errorf("options: %w", err)
}
}
return field, nil
}
// compileWidgetKeys decodes the D-06 widget keys. They are refused on any
// other type; a widget needs a custom-element tag and an action name, and may
// list the fields its action writes back. The tag prefix, the registered
// action and the fill fields are checked against the controller in
// compileExtension.
func compileWidgetKeys(typ string, values map[string]ast.Node, field *FormField) error {
if typ != "widget" {
for _, key := range widgetKeys {
if _, ok := values[key]; ok {
return fmt.Errorf("%s is only valid on type: widget", key)
}
}
return nil
}
tag, err := nodeString(values["widget"])
if err != nil || strings.TrimSpace(tag) == "" {
return fmt.Errorf("widget (the custom-element tag) is required on type: widget")
}
field.Widget = tag
action, err := nodeString(values["action"])
if err != nil || !identifier(action) {
return fmt.Errorf("action %q is not an identifier (type: widget needs a registered action)", nodeText(values["action"]))
}
field.Action = action
if node, ok := values["fill"]; ok {
seq, ok := node.(*ast.SequenceNode)
if !ok {
return fmt.Errorf("fill must be a list of field names")
}
items := sequenceValues(seq)
fill := make([]string, 0, len(items))
seen := map[string]struct{}{}
for _, item := range items {
name, err := nodeString(unwrapNode(item))
if err != nil || !identifier(name) {
return fmt.Errorf("fill %q is not a field name", nodeText(item))
}
if _, dup := seen[name]; dup {
return fmt.Errorf("fill: duplicate field %s", name)
}
seen[name] = struct{}{}
fill = append(fill, name)
}
field.Fill = fill
}
return nil
}
// partialPathHint is the D-11 path rule shared by every partial path error.
const partialPathHint = "path must be a partial name such as summary (resolves to CONFIG_DIR/_summary.htm); Winter $/ and ~/ paths are not supported"
// compilePartialPath decodes the path of a `type: partial` field (D-09). It
// is refused on any other type and must be a bare partial name, so no
// free-form path ever reaches the plugin's file tree. The template itself is
// read and parsed in compileExtension.
func compilePartialPath(typ string, values map[string]ast.Node, field *FormField) error {
node, ok := values["path"]
if typ != "partial" {
if ok {
return fmt.Errorf("path is only valid on type: partial")
}
return nil
}
if !ok {
return fmt.Errorf("type partial needs a path: %s", partialPathHint)
}
name, err := nodeString(node)
if err != nil || !identifier(name) {
return fmt.Errorf("partial %q: %s", nodeText(node), partialPathHint)
}
field.Path = name
return nil
}
func compileContext(node ast.Node) (*fieldContext, error) {
switch n := node.(type) {
case *ast.StringNode:
if !identifier(n.Value) {
return nil, fmt.Errorf("context %q is not an identifier", n.Value)
}
return &fieldContext{single: true, values: []string{n.Value}}, nil
case *ast.SequenceNode:
values := sequenceValues(n)
out := make([]string, 0, len(values))
for _, item := range values {
text, err := nodeString(unwrapNode(item))
if err != nil || !identifier(text) {
return nil, fmt.Errorf("context %q is not an identifier", nodeText(item))
}
out = append(out, text)
}
return &fieldContext{values: out}, nil
default:
return nil, fmt.Errorf("context must be a string or a list")
}
}
func compileAttributes(node ast.Node) (map[string]jsonScalar, error) {
mapping, ok := node.(*ast.MappingNode)
if !ok {
return nil, fmt.Errorf("must be a mapping")
}
out := make(map[string]jsonScalar, len(mapping.Values))
for _, entry := range mapping.Values {
key, err := nodeString(unwrapNode(entry.Key))
if err != nil || !identifier(key) {
return nil, fmt.Errorf("key %q is not an identifier", nodeText(entry.Key))
}
scalar, err := nodeScalar(unwrapNode(entry.Value))
if err != nil {
return nil, fmt.Errorf("%s: %w", key, err)
}
out[key] = scalar
}
return out, nil
}
func compileOptions(node ast.Node, field *FormField) error {
switch n := node.(type) {
case *ast.StringNode:
if !identifier(n.Value) {
return fmt.Errorf("method %q is not an identifier", n.Value)
}
field.optionsMethod = n.Value
return nil
case *ast.MappingNode:
options := make([]FormOption, 0, len(n.Values))
for _, entry := range n.Values {
value, err := nodeScalar(unwrapNode(entry.Key))
if err != nil {
return fmt.Errorf("value: %w", err)
}
label, err := nodeString(unwrapNode(entry.Value))
if err != nil {
return fmt.Errorf("label: %w", err)
}
options = append(options, FormOption{Value: value, Label: label})
}
field.Options = options
return nil
default:
return fmt.Errorf("must be a map or a method name")
}
}
func sequenceValues(n *ast.SequenceNode) []ast.Node {
if len(n.Values) > 0 {
return n.Values
}
out := make([]ast.Node, 0, len(n.Entries))
for _, entry := range n.Entries {
if entry != nil && entry.Value != nil {
out = append(out, entry.Value)
}
}
return out
}
func unwrapNode(node ast.Node) ast.Node {
for node != nil {
switch n := node.(type) {
case *ast.MappingKeyNode:
node = n.Value
case *ast.TagNode:
node = n.Value
case *ast.AnchorNode:
node = n.Value
default:
return node
}
}
return nil
}
func nodeString(node ast.Node) (string, error) {
switch n := unwrapNode(node).(type) {
case *ast.StringNode:
return n.Value, nil
case nil:
return "", fmt.Errorf("missing string")
default:
return "", fmt.Errorf("want a string, got %T", n)
}
}
func nodeBool(node ast.Node) (bool, error) {
n, ok := unwrapNode(node).(*ast.BoolNode)
if !ok {
return false, fmt.Errorf("want a boolean")
}
return n.Value, nil
}
func nodeScalar(node ast.Node) (jsonScalar, error) {
switch n := unwrapNode(node).(type) {
case *ast.StringNode:
raw, err := json.Marshal(n.Value)
return jsonScalar{raw: raw}, err
case *ast.BoolNode:
if n.Value {
return jsonScalar{raw: []byte("true")}, nil
}
return jsonScalar{raw: []byte("false")}, nil
case *ast.IntegerNode:
return integerScalar(n.Value)
case *ast.FloatNode:
if n.Value == float64(int64(n.Value)) {
return jsonScalar{raw: []byte(strconv.FormatInt(int64(n.Value), 10))}, nil
}
raw, err := json.Marshal(n.Value)
return jsonScalar{raw: raw}, err
default:
return jsonScalar{}, fmt.Errorf("want a scalar, got %T", n)
}
}
func integerScalar(v any) (jsonScalar, error) {
switch n := v.(type) {
case int64:
return jsonScalar{raw: []byte(strconv.FormatInt(n, 10))}, nil
case uint64:
return jsonScalar{raw: []byte(strconv.FormatUint(n, 10))}, nil
case int:
return jsonScalar{raw: []byte(strconv.Itoa(n))}, nil
default:
return jsonScalar{}, fmt.Errorf("want an integer, got %T", v)
}
}
func nodeText(node ast.Node) string {
if node == nil {
return ""
}
if token := node.GetToken(); token != nil {
return token.Value
}
return ""
}
func normalizeYAMLError(err error) error {
if err == nil {
return nil
}
msg := err.Error()
if strings.Contains(msg, "already defined") {
return fmt.Errorf("duplicate field: %w", err)
}
return err
}
func compileFormIfPresent(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSchema, error) {
dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/")
if dir == "." || strings.HasPrefix(dir, "..") {
return nil, bootErr(pluginID, ctl.ID(), ctl.ConfigDir(), fmt.Errorf("config directory escapes the plugin"))
}
cfgPath := path.Join(dir, "config_form.yaml")
if _, err := fs.Stat(fsys, cfgPath); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
}
return CompileForm(pluginID, ctl, fsys)
}