- pact.RecordSaved/RecordDeleted/RecordChange, fired as pointers after commit - pact.ContentDescriptor and request/result types; MenuItemType.Content - AdminFormSave.Changes carries the owner's changes to extension Save - cabana.EmitRecordSaved/EmitRecordDeleted; admin save snapshots columns and translated values and fires RecordSaved through lagoon.AfterCommit - signpost validates Content and gains ContentTypes, ContentTypeForModel, Describe
1513 lines
50 KiB
Go
1513 lines
50 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"path"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
|
"github.com/goccy/go-yaml/ast"
|
|
"github.com/goccy/go-yaml/parser"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// defaultTabKey is the name a form extension uses for the tab of the fields
|
|
// without a tab: key (D-91 P-04).
|
|
const defaultTabKey = "default"
|
|
|
|
// extensionFieldTypes are the built-in field types a form extension may add:
|
|
// a value its Save stores, a widget or a partial. The others bind to the
|
|
// owner's model (fileupload, relation, relation-manager, repeater, money,
|
|
// taglist, datepicker, permissioneditor, richeditor, password) and are
|
|
// refused; a plugin field type is always accepted.
|
|
var extensionFieldTypes = map[string]bool{
|
|
"text": true, "textarea": true, "number": true, "checkbox": true, "switch": true,
|
|
"dropdown": true, "markdown": true, "mltext": true, "mltextarea": true,
|
|
"mlmarkdown": true, "widget": true, "partial": true,
|
|
}
|
|
|
|
// compiledFormExtension is one plugin's contribution to one controller's
|
|
// form (pact.AdminActionExtension.Form, D-91) after the boot checks.
|
|
type compiledFormExtension struct {
|
|
pluginID string
|
|
def pact.AdminFormExtension
|
|
// fields are the names of the fields the extension adds, in compiled
|
|
// order; own holds them by name.
|
|
fields []string
|
|
own map[string]FormField
|
|
// tabs are the tab keys the extension declares.
|
|
tabs []string
|
|
// actions are the widget actions of its widget fields keyed by name;
|
|
// partials its parsed partial templates keyed by name.
|
|
actions map[string]pact.AdminAction
|
|
partials map[string]*compiledPartial
|
|
}
|
|
|
|
// valueField reports whether the extension's field name carries a value
|
|
// (anything but a widget or a partial).
|
|
func (ext *compiledFormExtension) valueField(name string) bool {
|
|
field, ok := ext.own[name]
|
|
return ok && field.Type != "widget" && field.Type != "partial"
|
|
}
|
|
|
|
// formContribution is one extension waiting to be compiled onto a target.
|
|
type formContribution struct {
|
|
plugin party.Plugin
|
|
def pact.AdminFormExtension
|
|
}
|
|
|
|
// extensionDoc is a parsed form extension fields document: the new tabs and
|
|
// the fields with their placement keys, in document order.
|
|
type extensionDoc struct {
|
|
tabs []extensionTab
|
|
fields []extensionField
|
|
}
|
|
|
|
// extensionTab is one entry of a fields document's tabs: list.
|
|
type extensionTab struct {
|
|
name, before, after, zone string
|
|
}
|
|
|
|
// extensionField is one compiled field of a fields document with its
|
|
// before:, after: and zone: keys.
|
|
type extensionField struct {
|
|
field FormField
|
|
before, after, zone string
|
|
}
|
|
|
|
// placementKeys are the keys a form extension's field takes next to the
|
|
// fields.yaml keys; they are taken out before the field is compiled.
|
|
var placementKeys = map[string]bool{"before": true, "after": true, "zone": true}
|
|
|
|
// compileFormExtensions merges the form fields and tabs plugins add through
|
|
// pact.AdminActionExtension.Form (D-91) into the compiled forms of reg, in
|
|
// plugin slice (activation) order. It must run after every controller is
|
|
// compiled. An extension's fields document is compiled like a fields.yaml
|
|
// (every type, option and plugin field type check applies), then its tabs
|
|
// and fields are placed at their anchors. Every failure stops boot and names
|
|
// the plugin, the controller and the offending name.
|
|
func compileFormExtensions(reg *Registry, plugins []party.Plugin) error {
|
|
if reg == nil {
|
|
return fmt.Errorf("cabana: registry is nil")
|
|
}
|
|
targets := map[string][]formContribution{}
|
|
requires := map[string]map[string]bool{}
|
|
var order []string
|
|
for _, plugin := range plugins {
|
|
if plugin == nil {
|
|
continue
|
|
}
|
|
src, ok := plugin.(pact.HasAdminActionExtensions)
|
|
if !ok || src == nil {
|
|
continue
|
|
}
|
|
needs := map[string]bool{}
|
|
for _, id := range plugin.Requires() {
|
|
needs[id] = true
|
|
}
|
|
requires[plugin.ID()] = needs
|
|
for _, ext := range src.AdminActionExtensions() {
|
|
if ext.Form == nil {
|
|
continue
|
|
}
|
|
cc, ok := reg.byID[ext.Controller]
|
|
if !ok || cc == nil {
|
|
return fmt.Errorf("cabana: plugin %s extends the form of unknown admin controller %q", plugin.ID(), ext.Controller)
|
|
}
|
|
if cc.Form == nil {
|
|
return fmt.Errorf("cabana: plugin %s extends the form of admin controller %s, which has no form (config_form.yaml)", plugin.ID(), ext.Controller)
|
|
}
|
|
if _, seen := targets[ext.Controller]; !seen {
|
|
order = append(order, ext.Controller)
|
|
}
|
|
targets[ext.Controller] = append(targets[ext.Controller], formContribution{plugin: plugin, def: *ext.Form})
|
|
}
|
|
}
|
|
for _, id := range order {
|
|
if err := applyFormExtensions(reg, reg.byID[id], targets[id], requires); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// extErr is a form extension boot error naming the plugin and the controller.
|
|
func extErr(pluginID, controller, format string, args ...any) error {
|
|
return fmt.Errorf("cabana: plugin %s: controller %q: "+format, append([]any{pluginID, controller}, args...)...)
|
|
}
|
|
|
|
// applyFormExtensions compiles the contributions of one target controller.
|
|
func applyFormExtensions(reg *Registry, cc *CompiledController, contributions []formContribution, requires map[string]map[string]bool) error {
|
|
controller := cc.Controller.ID()
|
|
docs := make([]extensionDoc, len(contributions))
|
|
layout := false
|
|
for i, c := range contributions {
|
|
doc, err := loadExtensionDoc(reg, c, controller)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
docs[i] = doc
|
|
if len(doc.fields) > 0 || len(doc.tabs) > 0 || len(c.def.Hide) > 0 {
|
|
layout = true
|
|
}
|
|
}
|
|
form := newFormLayout(cc.Form.Fields, requires)
|
|
for i, c := range contributions {
|
|
ext := &compiledFormExtension{pluginID: c.plugin.ID(), def: c.def, own: map[string]FormField{}}
|
|
if err := compileExtensionKinds(reg, cc, c.plugin, ext, &docs[i], controller); err != nil {
|
|
return err
|
|
}
|
|
if err := form.hide(ext, controller); err != nil {
|
|
return err
|
|
}
|
|
if err := form.addTabs(ext, docs[i].tabs, controller); err != nil {
|
|
return err
|
|
}
|
|
if err := form.addFields(ext, docs[i].fields, controller); err != nil {
|
|
return err
|
|
}
|
|
cc.formExtensions = append(cc.formExtensions, ext)
|
|
if cc.extensionFields == nil {
|
|
cc.extensionFields = map[string]*compiledFormExtension{}
|
|
}
|
|
for _, name := range ext.fields {
|
|
cc.extensionFields[name] = ext
|
|
}
|
|
}
|
|
if !layout {
|
|
return nil
|
|
}
|
|
// A new slice: the owner's compiled list may be shared.
|
|
cc.Form.Fields = form.flatten()
|
|
for _, ext := range cc.formExtensions {
|
|
for _, name := range ext.fields {
|
|
if err := checkFieldReferences(cc.Form.Fields, ext.own[name]); err != nil {
|
|
return extErr(ext.pluginID, controller, "%w", err)
|
|
}
|
|
}
|
|
}
|
|
dropHiddenFields(cc, form.hidden)
|
|
return nil
|
|
}
|
|
|
|
// loadExtensionDoc reads and parses one extension's fields document.
|
|
func loadExtensionDoc(reg *Registry, c formContribution, controller string) (extensionDoc, error) {
|
|
pluginID := c.plugin.ID()
|
|
if strings.TrimSpace(c.def.Fields) != "" && len(c.def.FieldsYAML) > 0 {
|
|
return extensionDoc{}, extErr(pluginID, controller, "form extension sets both Fields and FieldsYAML")
|
|
}
|
|
raw := c.def.FieldsYAML
|
|
source := "FieldsYAML"
|
|
if file := strings.TrimSpace(c.def.Fields); file != "" {
|
|
assets, ok := c.plugin.(pact.AdminAssets)
|
|
if !ok || assets == nil || assets.AdminFS() == nil {
|
|
return extensionDoc{}, extErr(pluginID, controller, "form extension fields %s: the plugin has no AdminFS", file)
|
|
}
|
|
name, err := assetPath(pluginID, file)
|
|
if err != nil {
|
|
return extensionDoc{}, extErr(pluginID, controller, "form extension fields %s: %w", file, err)
|
|
}
|
|
if raw, err = readAsset(assets.AdminFS(), name); err != nil {
|
|
return extensionDoc{}, extErr(pluginID, controller, "form extension fields %s: %w", file, err)
|
|
}
|
|
source = name
|
|
}
|
|
if len(raw) == 0 {
|
|
return extensionDoc{}, nil
|
|
}
|
|
doc, err := parseExtensionDoc(raw, reg.fieldTypes)
|
|
if err != nil {
|
|
return extensionDoc{}, extErr(pluginID, controller, "form extension fields %s: %w", source, err)
|
|
}
|
|
return doc, nil
|
|
}
|
|
|
|
// parseExtensionDoc compiles a form extension fields document: a mapping
|
|
// with fields: (a fields.yaml mapping whose entries may add before:, after:
|
|
// and zone:) and tabs: (a list of name/before/after/zone mappings).
|
|
func parseExtensionDoc(raw []byte, types *fieldTypeRegistry) (extensionDoc, error) {
|
|
file, err := parser.ParseBytes(raw, 0)
|
|
if err != nil {
|
|
return extensionDoc{}, normalizeYAMLError(err)
|
|
}
|
|
var entries []*ast.MappingValueNode
|
|
for i, doc := range file.Docs {
|
|
if doc == nil {
|
|
continue
|
|
}
|
|
if i > 0 {
|
|
return extensionDoc{}, fmt.Errorf("want one YAML document")
|
|
}
|
|
switch body := unwrapNode(doc.Body).(type) {
|
|
case nil, *ast.NullNode:
|
|
case *ast.MappingNode:
|
|
entries = body.Values
|
|
case *ast.MappingValueNode:
|
|
entries = []*ast.MappingValueNode{body}
|
|
default:
|
|
return extensionDoc{}, fmt.Errorf("the document must be a mapping with fields: and tabs:")
|
|
}
|
|
}
|
|
var out extensionDoc
|
|
seen := map[string]bool{}
|
|
for _, entry := range entries {
|
|
key, err := nodeString(unwrapNode(entry.Key))
|
|
if err != nil {
|
|
return extensionDoc{}, err
|
|
}
|
|
if seen[key] {
|
|
return extensionDoc{}, fmt.Errorf("duplicate key %s", key)
|
|
}
|
|
seen[key] = true
|
|
switch key {
|
|
case "fields":
|
|
if out.fields, err = decodeExtensionFields(entry.Value, types); err != nil {
|
|
return extensionDoc{}, err
|
|
}
|
|
case "tabs":
|
|
if out.tabs, err = decodeExtensionTabs(entry.Value); err != nil {
|
|
return extensionDoc{}, err
|
|
}
|
|
default:
|
|
return extensionDoc{}, fmt.Errorf("unknown key %s (want fields or tabs)", key)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// decodeExtensionFields takes the placement keys out of every field entry
|
|
// and compiles the rest through decodeFieldMapping.
|
|
func decodeExtensionFields(node ast.Node, types *fieldTypeRegistry) ([]extensionField, error) {
|
|
node = unwrapNode(node)
|
|
placement := map[string]map[string]string{}
|
|
if mapping, ok := node.(*ast.MappingNode); ok {
|
|
for _, entry := range mapping.Values {
|
|
name, err := nodeString(unwrapNode(entry.Key))
|
|
if err != nil {
|
|
continue // decodeFieldMapping reports the name
|
|
}
|
|
body, ok := unwrapNode(entry.Value).(*ast.MappingNode)
|
|
if !ok {
|
|
continue
|
|
}
|
|
keys := map[string]string{}
|
|
kept := make([]*ast.MappingValueNode, 0, len(body.Values))
|
|
for _, item := range body.Values {
|
|
key, err := nodeString(unwrapNode(item.Key))
|
|
if err != nil || !placementKeys[key] {
|
|
kept = append(kept, item)
|
|
continue
|
|
}
|
|
value, err := nodeString(unwrapNode(item.Value))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("field %s: %s: %w", name, key, err)
|
|
}
|
|
keys[key] = strings.TrimSpace(value)
|
|
}
|
|
body.Values = kept
|
|
placement[name] = keys
|
|
}
|
|
}
|
|
compiled, err := decodeFieldMapping(node, nil, types)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]extensionField, 0, len(compiled))
|
|
for _, field := range compiled {
|
|
keys := placement[field.Name]
|
|
out = append(out, extensionField{field: field, before: keys["before"], after: keys["after"], zone: keys["zone"]})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// decodeExtensionTabs decodes the tabs: list.
|
|
func decodeExtensionTabs(node ast.Node) ([]extensionTab, error) {
|
|
node = unwrapNode(node)
|
|
if _, ok := node.(*ast.NullNode); ok || node == nil {
|
|
return nil, nil
|
|
}
|
|
list, ok := node.(*ast.SequenceNode)
|
|
if !ok {
|
|
return nil, fmt.Errorf("tabs must be a list of mappings with name, before, after and zone")
|
|
}
|
|
out := make([]extensionTab, 0, len(list.Values))
|
|
for i, item := range list.Values {
|
|
mapping, ok := unwrapNode(item).(*ast.MappingNode)
|
|
if !ok {
|
|
return nil, fmt.Errorf("tabs[%d] must be a mapping with name, before, after and zone", i)
|
|
}
|
|
var tab extensionTab
|
|
for _, entry := range mapping.Values {
|
|
key, err := nodeString(unwrapNode(entry.Key))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("tabs[%d]: %w", i, err)
|
|
}
|
|
value, err := nodeString(unwrapNode(entry.Value))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("tabs[%d]: %s: %w", i, key, err)
|
|
}
|
|
value = strings.TrimSpace(value)
|
|
switch key {
|
|
case "name":
|
|
tab.name = value
|
|
case "before":
|
|
tab.before = value
|
|
case "after":
|
|
tab.after = value
|
|
case "zone":
|
|
tab.zone = value
|
|
default:
|
|
return nil, fmt.Errorf("tabs[%d]: unknown key %s (want name, before, after or zone)", i, key)
|
|
}
|
|
}
|
|
if tab.name == "" {
|
|
return nil, fmt.Errorf("tabs[%d]: name is required", i)
|
|
}
|
|
out = append(out, tab)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// checkZone applies D-101: only the tab zone is rendered for now.
|
|
func checkZone(zone string) error {
|
|
switch zone {
|
|
case "", pact.AdminFormZoneTabs:
|
|
return nil
|
|
case pact.AdminFormZoneOutside, pact.AdminFormZoneSecondary:
|
|
return fmt.Errorf("zone %q is not supported yet (only %q)", zone, pact.AdminFormZoneTabs)
|
|
default:
|
|
return fmt.Errorf("unknown zone %q (want %q)", zone, pact.AdminFormZoneTabs)
|
|
}
|
|
}
|
|
|
|
// compileExtensionKinds runs the per-field checks of one extension's fields
|
|
// document before placement: the field types an extension may add, its
|
|
// widget actions and their element source, its partials, and the Save hook
|
|
// its value fields need.
|
|
func compileExtensionKinds(reg *Registry, cc *CompiledController, plugin party.Plugin, ext *compiledFormExtension, doc *extensionDoc, controller string) error {
|
|
pluginID := ext.pluginID
|
|
own := map[string]FormField{}
|
|
for _, item := range doc.fields {
|
|
own[item.field.Name] = item.field
|
|
}
|
|
if err := compileExtensionActions(cc, ext, controller); err != nil {
|
|
return err
|
|
}
|
|
values := false
|
|
var partials []string
|
|
for i := range doc.fields {
|
|
field := &doc.fields[i].field
|
|
if field.fieldType == nil && !extensionFieldTypes[field.Type] {
|
|
if field.Type == "fileupload" {
|
|
return extErr(pluginID, controller, "field %s: type fileupload is not supported in a form extension: an extension owns no attachments; store files through a plugin field type with Upload", field.Name)
|
|
}
|
|
return extErr(pluginID, controller, "field %s: type %s is not supported in a form extension", field.Name, field.Type)
|
|
}
|
|
if field.optionsMethod != "" {
|
|
return extErr(pluginID, controller, "field %s: a form extension's dropdown lists its options (no options method)", field.Name)
|
|
}
|
|
if field.DependsOn != "" {
|
|
return extErr(pluginID, controller, "field %s: dependsOn is not supported in a form extension", field.Name)
|
|
}
|
|
switch field.Type {
|
|
case "widget":
|
|
if err := checkWidgetTag(field.Widget, widgetTagPrefix(pluginID)); err != nil {
|
|
return extErr(pluginID, controller, "field %s: %w", field.Name, err)
|
|
}
|
|
action, ok := ext.actions[field.Action]
|
|
if !ok {
|
|
return extErr(pluginID, controller, "field %s: action %s is not one of the extension's Actions", field.Name, field.Action)
|
|
}
|
|
for _, key := range field.Fill {
|
|
target, ok := own[key]
|
|
if !ok || target.Type == "widget" || target.Type == "partial" {
|
|
return extErr(pluginID, controller, "field %s: fill %s is not a value field of this extension", field.Name, key)
|
|
}
|
|
}
|
|
if !extensionElementSource(reg, plugin) {
|
|
return extErr(pluginID, controller, "field %s: a widget needs its element script: register a field type with Scripts or implement pact.AdminClientAssets on the plugin", field.Name)
|
|
}
|
|
field.ActionLabel = action.Label
|
|
case "partial":
|
|
partials = append(partials, field.Path)
|
|
default:
|
|
values = true
|
|
}
|
|
}
|
|
if values && ext.def.Save == nil {
|
|
return extErr(pluginID, controller, "form extension has value fields but no Save")
|
|
}
|
|
if err := compileExtensionAssets(reg, cc, plugin, controller); err != nil {
|
|
return err
|
|
}
|
|
return compileExtensionPartials(cc, plugin, ext, partials, controller)
|
|
}
|
|
|
|
// compileExtensionActions checks the extension's widget actions: identifier
|
|
// names, no reserved name, a Run, and no name the owner or an earlier
|
|
// extension of the controller registers.
|
|
func compileExtensionActions(cc *CompiledController, ext *compiledFormExtension, controller string) error {
|
|
if len(ext.def.Actions) == 0 {
|
|
return nil
|
|
}
|
|
ext.actions = make(map[string]pact.AdminAction, len(ext.def.Actions))
|
|
for _, action := range ext.def.Actions {
|
|
switch {
|
|
case !identifier(action.Name):
|
|
return extErr(ext.pluginID, controller, "widget action %q: the name is not an identifier", action.Name)
|
|
case builtinToolbarActions[action.Name]:
|
|
return extErr(ext.pluginID, controller, "widget action %s: the name is reserved (create, delete)", action.Name)
|
|
case action.Run == nil:
|
|
return extErr(ext.pluginID, controller, "widget action %s: it has no Run function", action.Name)
|
|
}
|
|
if _, dup := ext.actions[action.Name]; dup {
|
|
return extErr(ext.pluginID, controller, "widget action %s: declared twice", action.Name)
|
|
}
|
|
if _, taken := cc.Actions[action.Name]; taken {
|
|
return extErr(ext.pluginID, controller, "widget action %s: the owner already registers an action with that name", action.Name)
|
|
}
|
|
for _, other := range cc.formExtensions {
|
|
if _, taken := other.actions[action.Name]; taken {
|
|
return extErr(ext.pluginID, controller, "widget action %s: plugin %s already adds an action with that name", action.Name, other.pluginID)
|
|
}
|
|
}
|
|
ext.actions[action.Name] = action
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// extensionElementSource reports whether the contributing plugin loads a
|
|
// script that can define its widget element on the target's form: a field
|
|
// type script (loaded on every admin page) or the plugin's own
|
|
// pact.AdminClientAssets JS (added to the target's assets).
|
|
func extensionElementSource(reg *Registry, plugin party.Plugin) bool {
|
|
if src, ok := plugin.(pact.AdminClientAssets); ok && src != nil && len(src.AdminJS()) > 0 {
|
|
return true
|
|
}
|
|
if reg == nil || reg.fieldTypes == nil {
|
|
return false
|
|
}
|
|
for _, ft := range reg.fieldTypes.byName {
|
|
if ft.owner == plugin.ID() && len(ft.scripts) > 0 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// compileExtensionAssets adds the JS and CSS a contributing plugin declares
|
|
// through pact.AdminClientAssets to the target controller's assets, once per
|
|
// plugin and controller, so its widget elements load with the form.
|
|
func compileExtensionAssets(reg *Registry, cc *CompiledController, plugin party.Plugin, controller string) error {
|
|
src, ok := plugin.(pact.AdminClientAssets)
|
|
if !ok || src == nil {
|
|
return nil
|
|
}
|
|
for _, ext := range cc.formExtensions {
|
|
if ext.pluginID == plugin.ID() {
|
|
return nil // added with the plugin's first extension
|
|
}
|
|
}
|
|
assets, ok := plugin.(pact.AdminAssets)
|
|
if !ok || assets == nil || assets.AdminFS() == nil {
|
|
return extErr(plugin.ID(), controller, "pact.AdminClientAssets needs the plugin's AdminFS")
|
|
}
|
|
owner := "form extension of " + controller
|
|
scripts, err := readClientAssets(plugin.ID(), owner, assets.AdminFS(), src.AdminJS(), ".js", ".mjs")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
styles, err := readClientAssets(plugin.ID(), owner, assets.AdminFS(), src.AdminCSS(), ".css")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// New slices: the compiled ones may be shared.
|
|
cc.scripts = append(append([]*pluginAsset(nil), cc.scripts...), scripts...)
|
|
cc.styles = append(append([]*pluginAsset(nil), cc.styles...), styles...)
|
|
if reg.assets == nil {
|
|
reg.assets = map[string]*pluginAsset{}
|
|
}
|
|
for _, file := range append(append([]*pluginAsset(nil), scripts...), styles...) {
|
|
if _, exists := reg.assets[file.key]; !exists {
|
|
reg.assets[file.key] = file
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// compileExtensionPartials reads and parses the extension's partial
|
|
// templates from Dir in the contributing plugin's AdminFS (D-91, RESEARCH
|
|
// Pitfall 4). A name the owner or another extension already uses fails boot.
|
|
func compileExtensionPartials(cc *CompiledController, plugin party.Plugin, ext *compiledFormExtension, names []string, controller string) error {
|
|
if len(names) == 0 {
|
|
return nil
|
|
}
|
|
pluginID := ext.pluginID
|
|
if ext.def.PartialData == nil {
|
|
return extErr(pluginID, controller, "partial %s: the form extension needs PartialData", names[0])
|
|
}
|
|
assets, ok := plugin.(pact.AdminAssets)
|
|
if !ok || assets == nil || assets.AdminFS() == nil {
|
|
return extErr(pluginID, controller, "partial %s: the plugin has no AdminFS", names[0])
|
|
}
|
|
dir := strings.Trim(path.Clean(strings.TrimSpace(ext.def.Dir)), "/")
|
|
if dir == "." || dir == "" || strings.HasPrefix(dir, "..") {
|
|
return extErr(pluginID, controller, "partial %s: Dir %q must name a directory in the plugin's AdminFS", names[0], ext.def.Dir)
|
|
}
|
|
ext.partials = map[string]*compiledPartial{}
|
|
for _, name := range names {
|
|
if _, done := ext.partials[name]; done {
|
|
continue
|
|
}
|
|
if _, taken := cc.partials[name]; taken {
|
|
return extErr(pluginID, controller, "partial %s: the owner already uses a partial with that name", name)
|
|
}
|
|
if other := cc.extensionPartials[name]; other != nil {
|
|
return extErr(pluginID, controller, "partial %s: plugin %s already adds a partial with that name", name, other.pluginID)
|
|
}
|
|
file := path.Join(dir, "_"+name+".htm")
|
|
src, err := readAsset(assets.AdminFS(), file)
|
|
if err != nil {
|
|
return extErr(pluginID, controller, "partial %s: template %s is not in the plugin's embedded files: %w", name, file, err)
|
|
}
|
|
compiled, err := parsePartial(name, src)
|
|
if err != nil {
|
|
return extErr(pluginID, controller, "partial %s: %w", name, err)
|
|
}
|
|
ext.partials[name] = compiled
|
|
if cc.extensionPartials == nil {
|
|
cc.extensionPartials = map[string]*compiledFormExtension{}
|
|
}
|
|
cc.extensionPartials[name] = ext
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkFieldReferences checks the trigger and preset of one extension field
|
|
// against the laid-out form, as checkTriggers and checkPresets do for the
|
|
// owner's.
|
|
func checkFieldReferences(fields []FormField, field FormField) error {
|
|
types := make(map[string]string, len(fields))
|
|
for _, other := range fields {
|
|
types[other.Name] = other.Type
|
|
}
|
|
if field.Trigger != nil {
|
|
typ, ok := types[field.Trigger.Field]
|
|
if !ok || field.Trigger.Field == field.Name {
|
|
return fmt.Errorf("field %s: trigger field %s is not another field of this form", field.Name, field.Trigger.Field)
|
|
}
|
|
if typ != "switch" && typ != "checkbox" {
|
|
return fmt.Errorf("field %s: trigger field %s must be a switch or checkbox, not type %s", field.Name, field.Trigger.Field, typ)
|
|
}
|
|
}
|
|
if field.Preset != nil {
|
|
typ, ok := types[field.Preset.Field]
|
|
if !ok || field.Preset.Field == field.Name {
|
|
return fmt.Errorf("field %s: preset field %s is not another field of this form", field.Name, field.Preset.Field)
|
|
}
|
|
if typ != "text" && typ != "mltext" {
|
|
return fmt.Errorf("field %s: preset field %s must be a text or mltext field", field.Name, field.Preset.Field)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// dropHiddenFields removes the fields extensions hide from the owner's
|
|
// bindings, so show and save leave them out too.
|
|
func dropHiddenFields(cc *CompiledController, hidden map[string]string) {
|
|
if len(hidden) == 0 {
|
|
return
|
|
}
|
|
writable := make([]WritableField, 0, len(cc.Writable))
|
|
for _, field := range cc.Writable {
|
|
if _, gone := hidden[field.Name]; !gone {
|
|
writable = append(writable, field)
|
|
}
|
|
}
|
|
cc.Writable = writable
|
|
for name := range hidden {
|
|
delete(cc.files, name)
|
|
delete(cc.dates, name)
|
|
delete(cc.permissions, name)
|
|
delete(cc.FieldRelations, name)
|
|
}
|
|
}
|
|
|
|
// tabGroup is one tab of a form being laid out: its raw tab key ("" for the
|
|
// default tab) and its fields in order.
|
|
type tabGroup struct {
|
|
key string
|
|
fields []FormField
|
|
}
|
|
|
|
// formLayout is a form being laid out by its extensions: the fields grouped
|
|
// by tab in first-appearance order, the default tab first (P-02), plus who
|
|
// added each field and tab.
|
|
type formLayout struct {
|
|
groups []*tabGroup
|
|
// fieldPlugin and tabPlugin map every field name and tab key to the
|
|
// plugin that added it; the owner's map to "". Hidden owner fields stay,
|
|
// so their names cannot be taken.
|
|
fieldPlugin map[string]string
|
|
tabPlugin map[string]string
|
|
// hidden maps a hidden owner field to the plugin that hid it.
|
|
hidden map[string]string
|
|
// cursor maps an After anchor (field "f:" or tab "t:" plus the name) to
|
|
// the last field or tab inserted after it, so several extensions
|
|
// anchored at one point keep plugin order (P-05).
|
|
cursor map[string]string
|
|
requires map[string]map[string]bool
|
|
}
|
|
|
|
func newFormLayout(fields []FormField, requires map[string]map[string]bool) *formLayout {
|
|
layout := &formLayout{
|
|
fieldPlugin: map[string]string{},
|
|
tabPlugin: map[string]string{},
|
|
hidden: map[string]string{},
|
|
cursor: map[string]string{},
|
|
requires: requires,
|
|
}
|
|
index := map[string]*tabGroup{}
|
|
for _, field := range fields {
|
|
group, ok := index[field.Tab]
|
|
if !ok {
|
|
group = &tabGroup{key: field.Tab}
|
|
index[field.Tab] = group
|
|
layout.groups = append(layout.groups, group)
|
|
layout.tabPlugin[field.Tab] = ""
|
|
}
|
|
group.fields = append(group.fields, field)
|
|
layout.fieldPlugin[field.Name] = ""
|
|
}
|
|
// The SPA always renders the default tab first.
|
|
for i, group := range layout.groups {
|
|
if group.key == "" && i > 0 {
|
|
copy(layout.groups[1:i+1], layout.groups[:i])
|
|
layout.groups[0] = group
|
|
break
|
|
}
|
|
}
|
|
return layout
|
|
}
|
|
|
|
// flatten is the laid-out field list.
|
|
func (l *formLayout) flatten() []FormField {
|
|
out := []FormField{}
|
|
for _, group := range l.groups {
|
|
out = append(out, group.fields...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// groupIndex returns the index of the tab group with key, -1 when the form
|
|
// has none.
|
|
func (l *formLayout) groupIndex(key string) int {
|
|
for i, group := range l.groups {
|
|
if group.key == key {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// locate returns the group holding field name and its index there.
|
|
func (l *formLayout) locate(name string) (*tabGroup, int) {
|
|
for _, group := range l.groups {
|
|
for i, field := range group.fields {
|
|
if field.Name == name {
|
|
return group, i
|
|
}
|
|
}
|
|
}
|
|
return nil, -1
|
|
}
|
|
|
|
// required reports whether plugin may anchor on what owner added: the owner
|
|
// controller's own fields and tabs, its own additions, or a plugin it lists
|
|
// in Requires().
|
|
func (l *formLayout) required(plugin, owner string) bool {
|
|
return owner == "" || owner == plugin || l.requires[plugin][owner]
|
|
}
|
|
|
|
// hide removes the owner fields the extension hides. A field another field
|
|
// still references cannot go.
|
|
func (l *formLayout) hide(ext *compiledFormExtension, controller string) error {
|
|
for _, raw := range ext.def.Hide {
|
|
name := strings.TrimSpace(raw)
|
|
if _, done := l.hidden[name]; done {
|
|
continue
|
|
}
|
|
owner, known := l.fieldPlugin[name]
|
|
if !known {
|
|
return extErr(ext.pluginID, controller, "Hide %q: the form has no such field", name)
|
|
}
|
|
if owner != "" {
|
|
return extErr(ext.pluginID, controller, "Hide %q: the field belongs to plugin %s; only the owner's fields can be hidden", name, owner)
|
|
}
|
|
for _, group := range l.groups {
|
|
for _, field := range group.fields {
|
|
if via := fieldReference(field, name); via != "" {
|
|
return extErr(ext.pluginID, controller, "Hide %q: field %s references it through %s", name, field.Name, via)
|
|
}
|
|
}
|
|
}
|
|
group, index := l.locate(name)
|
|
if group != nil {
|
|
group.fields = append(group.fields[:index:index], group.fields[index+1:]...)
|
|
}
|
|
l.hidden[name] = ext.pluginID
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// fieldReference names the key through which field refers to name, "" when
|
|
// it does not.
|
|
func fieldReference(field FormField, name string) string {
|
|
if field.Name == name {
|
|
return ""
|
|
}
|
|
for _, key := range field.Fill {
|
|
if key == name {
|
|
return "fill"
|
|
}
|
|
}
|
|
switch {
|
|
case field.DependsOn == name:
|
|
return "dependsOn"
|
|
case field.Trigger != nil && field.Trigger.Field == name:
|
|
return "trigger"
|
|
case field.Preset != nil && field.Preset.Field == name:
|
|
return "preset"
|
|
case field.CurrencyFrom == name:
|
|
return "currencyFrom"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// tabAnchorKey maps a tab anchor to its group key: default is "".
|
|
func tabAnchorKey(name string) string {
|
|
if name == defaultTabKey {
|
|
return ""
|
|
}
|
|
return name
|
|
}
|
|
|
|
// addTabs places the extension's new tabs.
|
|
func (l *formLayout) addTabs(ext *compiledFormExtension, tabs []extensionTab, controller string) error {
|
|
pluginID := ext.pluginID
|
|
for _, tab := range tabs {
|
|
if err := checkZone(tab.zone); err != nil {
|
|
return extErr(pluginID, controller, "tab %s: %w", tab.name, err)
|
|
}
|
|
if tab.name == defaultTabKey {
|
|
return extErr(pluginID, controller, "tab %s: the name default is reserved for the tab of fields without a tab", tab.name)
|
|
}
|
|
if owner, taken := l.tabPlugin[tab.name]; taken {
|
|
by := "the owner"
|
|
if owner != "" {
|
|
by = "plugin " + owner
|
|
}
|
|
return extErr(pluginID, controller, "tab %s: duplicate tab key (%s already has it)", tab.name, by)
|
|
}
|
|
if tab.before == defaultTabKey {
|
|
return extErr(pluginID, controller, "tab %s: before: default is refused; the default tab always comes first", tab.name)
|
|
}
|
|
for _, anchor := range []string{tab.after, tab.before} {
|
|
if anchor == "" || anchor == defaultTabKey {
|
|
continue
|
|
}
|
|
owner, known := l.tabPlugin[anchor]
|
|
if !known {
|
|
return extErr(pluginID, controller, "tab %s: unknown tab anchor %q", tab.name, anchor)
|
|
}
|
|
if !l.required(pluginID, owner) {
|
|
return extErr(pluginID, controller, "tab %s: anchor %q belongs to plugin %s, which plugin %s does not list in Requires()", tab.name, anchor, owner, pluginID)
|
|
}
|
|
}
|
|
group := &tabGroup{key: tab.name}
|
|
var at int
|
|
switch {
|
|
case tab.after != "":
|
|
key := tabAnchorKey(tab.after)
|
|
after := l.groupIndex(key)
|
|
if last, ok := l.cursor["t:"+key]; ok {
|
|
if i := l.groupIndex(last); i > after {
|
|
after = i
|
|
}
|
|
}
|
|
at = after + 1
|
|
l.cursor["t:"+key] = tab.name
|
|
case tab.before != "":
|
|
at = l.groupIndex(tab.before)
|
|
default:
|
|
at = len(l.groups)
|
|
}
|
|
l.groups = append(l.groups[:at], append([]*tabGroup{group}, l.groups[at:]...)...)
|
|
if tab.after != "" && tab.before != "" && l.groupIndex(tab.before) < at {
|
|
return extErr(pluginID, controller, "tab %s: cannot be after %q and before %q", tab.name, tab.after, tab.before)
|
|
}
|
|
l.tabPlugin[tab.name] = pluginID
|
|
ext.tabs = append(ext.tabs, tab.name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// addFields places the extension's fields: in an order where every field
|
|
// anchored on another field of the same extension comes after it (a cycle
|
|
// fails boot), each at its anchor or at the end of its tab.
|
|
func (l *formLayout) addFields(ext *compiledFormExtension, fields []extensionField, controller string) error {
|
|
pluginID := ext.pluginID
|
|
own := map[string]bool{}
|
|
for _, item := range fields {
|
|
own[item.field.Name] = true
|
|
}
|
|
placed := map[string]bool{}
|
|
remaining := append([]extensionField(nil), fields...)
|
|
for len(remaining) > 0 {
|
|
var next []extensionField
|
|
for _, item := range remaining {
|
|
waiting := false
|
|
for _, anchor := range []string{item.after, item.before} {
|
|
if anchor != "" && own[anchor] && !placed[anchor] {
|
|
waiting = true
|
|
}
|
|
}
|
|
if waiting {
|
|
next = append(next, item)
|
|
continue
|
|
}
|
|
if err := l.place(ext, item, controller); err != nil {
|
|
return err
|
|
}
|
|
placed[item.field.Name] = true
|
|
}
|
|
if len(next) == len(remaining) {
|
|
names := make([]string, len(next))
|
|
for i, item := range next {
|
|
names[i] = item.field.Name
|
|
}
|
|
return extErr(pluginID, controller, "field %s: anchor cycle among fields %s", names[0], strings.Join(names, ", "))
|
|
}
|
|
remaining = next
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// place inserts one extension field.
|
|
func (l *formLayout) place(ext *compiledFormExtension, item extensionField, controller string) error {
|
|
pluginID := ext.pluginID
|
|
field := item.field
|
|
if err := checkZone(item.zone); err != nil {
|
|
return extErr(pluginID, controller, "field %s: %w", field.Name, err)
|
|
}
|
|
if owner, taken := l.fieldPlugin[field.Name]; taken {
|
|
by := "the owner"
|
|
if owner != "" {
|
|
by = "plugin " + owner
|
|
}
|
|
return extErr(pluginID, controller, "field %s: duplicate field name (%s already has it)", field.Name, by)
|
|
}
|
|
for _, anchor := range []string{item.after, item.before} {
|
|
if anchor == "" {
|
|
continue
|
|
}
|
|
if by, gone := l.hidden[anchor]; gone {
|
|
return extErr(pluginID, controller, "field %s: anchor %q is hidden by plugin %s", field.Name, anchor, by)
|
|
}
|
|
owner, known := l.fieldPlugin[anchor]
|
|
if !known {
|
|
return extErr(pluginID, controller, "field %s: unknown anchor %q", field.Name, anchor)
|
|
}
|
|
if !l.required(pluginID, owner) {
|
|
return extErr(pluginID, controller, "field %s: anchor %q belongs to plugin %s, which plugin %s does not list in Requires()", field.Name, anchor, owner, pluginID)
|
|
}
|
|
}
|
|
if field.Tab != "" {
|
|
owner, known := l.tabPlugin[field.Tab]
|
|
if !known {
|
|
return extErr(pluginID, controller, "field %s: unknown tab %q (declare a new tab under tabs:)", field.Name, field.Tab)
|
|
}
|
|
if !l.required(pluginID, owner) {
|
|
return extErr(pluginID, controller, "field %s: tab %q belongs to plugin %s, which plugin %s does not list in Requires()", field.Name, field.Tab, owner, pluginID)
|
|
}
|
|
}
|
|
var (
|
|
group *tabGroup
|
|
at int
|
|
)
|
|
switch {
|
|
case item.after != "":
|
|
var index int
|
|
group, index = l.locate(item.after)
|
|
at = index + 1
|
|
if last, ok := l.cursor["f:"+item.after]; ok {
|
|
if lastGroup, i := l.locate(last); lastGroup == group && i >= at {
|
|
at = i + 1
|
|
}
|
|
}
|
|
l.cursor["f:"+item.after] = field.Name
|
|
case item.before != "":
|
|
group, at = l.locate(item.before)
|
|
default:
|
|
index := l.groupIndex(field.Tab)
|
|
if index < 0 {
|
|
// Only the default tab can be missing here: the owner tabs every
|
|
// field, and a declared tab always has a group.
|
|
group = &tabGroup{}
|
|
l.groups = append([]*tabGroup{group}, l.groups...)
|
|
} else {
|
|
group = l.groups[index]
|
|
}
|
|
at = len(group.fields)
|
|
}
|
|
if field.Tab == "" {
|
|
field.Tab = group.key
|
|
} else if field.Tab != group.key {
|
|
return extErr(pluginID, controller, "field %s: its anchor is in tab %q, not %q", field.Name, displayTab(group.key), field.Tab)
|
|
}
|
|
group.fields = append(group.fields[:at:at], append([]FormField{field}, group.fields[at:]...)...)
|
|
if item.after != "" && item.before != "" {
|
|
before, index := l.locate(item.before)
|
|
if before != group || index < at {
|
|
return extErr(pluginID, controller, "field %s: cannot be after %q and before %q", field.Name, item.after, item.before)
|
|
}
|
|
}
|
|
l.fieldPlugin[field.Name] = pluginID
|
|
ext.fields = append(ext.fields, field.Name)
|
|
ext.own[field.Name] = field
|
|
return nil
|
|
}
|
|
|
|
// displayTab names a tab key in an error; the default tab is "default".
|
|
func displayTab(key string) string {
|
|
if key == "" {
|
|
return defaultTabKey
|
|
}
|
|
return key
|
|
}
|
|
|
|
// checkFormExtensionTabLabels refuses two tab keys of an extended form whose
|
|
// labels translate to the same text in a locale the phrasebook loads: the
|
|
// admin keys tabs by their translated label, so they would merge (RESEARCH
|
|
// Pitfall 1). A key without "::" is its own label.
|
|
func checkFormExtensionTabLabels(reg *Registry, tr *phrasebook.Translator) error {
|
|
if reg == nil || tr == nil {
|
|
return nil
|
|
}
|
|
locales := tr.Locales("")
|
|
for _, id := range sortedControllerIDs(reg) {
|
|
cc := reg.byID[id]
|
|
if len(cc.formExtensions) == 0 || cc.Form == nil {
|
|
continue
|
|
}
|
|
declared := map[string]string{}
|
|
for _, ext := range cc.formExtensions {
|
|
for _, key := range ext.tabs {
|
|
declared[key] = ext.pluginID
|
|
}
|
|
}
|
|
if len(declared) == 0 {
|
|
continue
|
|
}
|
|
var keys []string
|
|
seen := map[string]bool{}
|
|
for _, field := range cc.Form.Fields {
|
|
if field.Tab != "" && !seen[field.Tab] {
|
|
seen[field.Tab] = true
|
|
keys = append(keys, field.Tab)
|
|
}
|
|
}
|
|
for _, locale := range locales {
|
|
labels := map[string]string{}
|
|
for _, key := range keys {
|
|
label := key
|
|
if strings.Contains(key, "::") {
|
|
label = tr.GetIn(locale, key, nil)
|
|
}
|
|
if other, clash := labels[label]; clash {
|
|
plugin, ok := declared[key]
|
|
if !ok {
|
|
plugin = declared[other]
|
|
}
|
|
return extErr(plugin, id, "tab %s: its label reads %q in locale %s, like tab %s; the admin would merge them", key, label, locale, other)
|
|
}
|
|
labels[label] = key
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateFormExtensionPermissions checks that every permission code a form
|
|
// extension or its widget actions name is registered, like
|
|
// compileContributions does for controllers.
|
|
func validateFormExtensionPermissions(reg *Registry) error {
|
|
if reg == nil {
|
|
return nil
|
|
}
|
|
for _, id := range sortedControllerIDs(reg) {
|
|
for _, ext := range reg.byID[id].formExtensions {
|
|
if err := reg.validatePermissions("form extension of "+id+" by plugin "+ext.pluginID, ext.def.Permissions); err != nil {
|
|
return err
|
|
}
|
|
for name, action := range ext.actions {
|
|
if err := reg.validatePermissions("form extension action "+id+"."+name, action.Permissions); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sortedControllerIDs are reg's controller IDs in sorted order.
|
|
func sortedControllerIDs(reg *Registry) []string {
|
|
ids := make([]string, 0, len(reg.byID))
|
|
for id := range reg.byID {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Strings(ids)
|
|
return ids
|
|
}
|
|
|
|
// extensionOf returns the form extension that added field name, nil for an
|
|
// owner field.
|
|
func (cc *CompiledController) extensionOf(name string) *compiledFormExtension {
|
|
if cc == nil {
|
|
return nil
|
|
}
|
|
return cc.extensionFields[name]
|
|
}
|
|
|
|
// extensionVisible reports whether principal sees ext's fields on this
|
|
// request: it holds the extension's Permissions and Visible, when set,
|
|
// answers true.
|
|
func extensionVisible(ctx context.Context, principal *bouncer.Principal, ext *compiledFormExtension) bool {
|
|
if ext == nil || !Allows(principal, ext.def.Permissions) {
|
|
return false
|
|
}
|
|
return ext.def.Visible == nil || ext.def.Visible(ctx)
|
|
}
|
|
|
|
// visibleExtensions are the form extensions of cc that principal sees on
|
|
// this request (D-91, RESEARCH Pitfall 7): the schema, show and save all use
|
|
// it, so an extension hidden from the schema is inert on save too.
|
|
func visibleExtensions(ctx context.Context, cc *CompiledController, principal *bouncer.Principal) map[*compiledFormExtension]bool {
|
|
out := map[*compiledFormExtension]bool{}
|
|
if cc == nil {
|
|
return out
|
|
}
|
|
for _, ext := range cc.formExtensions {
|
|
if extensionVisible(ctx, principal, ext) {
|
|
out[ext] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// extensionLocales are the default content locale and the enabled ones for
|
|
// translatable extension values: the translation writer's, or the admin's
|
|
// locale alone without one.
|
|
type extensionLocales struct {
|
|
def string
|
|
enabled map[string]bool
|
|
writer bool
|
|
}
|
|
|
|
func resolveExtensionLocales(ctx context.Context, db *gorm.DB, writer TranslationWriter, tr *phrasebook.Translator) (extensionLocales, error) {
|
|
if writer == nil {
|
|
def := schemaLocale(context.Background(), tr)
|
|
return extensionLocales{def: def, enabled: map[string]bool{def: true}}, nil
|
|
}
|
|
def, err := writer.DefaultLocale(ctx, db)
|
|
if err != nil || def == "" {
|
|
return extensionLocales{}, fmt.Errorf("cabana: default locale: %w", err)
|
|
}
|
|
codes, err := writer.EnabledLocales(ctx, db)
|
|
if err != nil {
|
|
return extensionLocales{}, fmt.Errorf("cabana: enabled locales: %w", err)
|
|
}
|
|
out := extensionLocales{def: def, enabled: map[string]bool{def: true}, writer: true}
|
|
for _, code := range codes {
|
|
out.enabled[code] = true
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// extensionValues are the submitted values of one save, per extension.
|
|
type extensionValues map[*compiledFormExtension]map[string]any
|
|
|
|
// extensionLift is what liftExtensionValues needs besides the body.
|
|
type extensionLift struct {
|
|
CC *CompiledController
|
|
Op string
|
|
Visible map[*compiledFormExtension]bool
|
|
Writer TranslationWriter
|
|
Tr *phrasebook.Translator
|
|
DB *gorm.DB
|
|
}
|
|
|
|
// liftExtensionValues takes the values of every extension field out of a
|
|
// save body before anything else reads it, so they never reach Fill, the
|
|
// owner's bindings or translation rows (D-91, T-32-01). The keys of an
|
|
// extension the administrator does not see are dropped (T-32-02). A visible
|
|
// extension keeps the values of its value fields whose context allows the
|
|
// operation: a translatable one as a map of locale to text (a string is the
|
|
// default locale's text), any other built-in type as a single JSON value, a
|
|
// plugin field type as sent.
|
|
func liftExtensionValues(ctx context.Context, in extensionLift, body map[string]any) (extensionValues, error) {
|
|
out := extensionValues{}
|
|
cc := in.CC
|
|
if cc == nil || len(cc.extensionFields) == 0 || body == nil {
|
|
return out, nil
|
|
}
|
|
var (
|
|
locales extensionLocales
|
|
resolved bool
|
|
)
|
|
details := map[string]any{}
|
|
for _, ext := range cc.formExtensions {
|
|
for _, name := range ext.fields {
|
|
value, present := body[name]
|
|
if !present {
|
|
continue
|
|
}
|
|
delete(body, name)
|
|
if !in.Visible[ext] || !ext.valueField(name) || !contextAllows(cc, name, in.Op) {
|
|
continue
|
|
}
|
|
field := ext.own[name]
|
|
if isMLField(field) {
|
|
if !resolved {
|
|
var err error
|
|
if locales, err = resolveExtensionLocales(ctx, in.DB, in.Writer, in.Tr); err != nil {
|
|
return nil, err
|
|
}
|
|
resolved = true
|
|
}
|
|
texts, message := extensionLocaleMap(name, value, locales)
|
|
if message != "" {
|
|
details[name] = []string{message}
|
|
continue
|
|
}
|
|
value = texts
|
|
} else if field.fieldType == nil && nestedValue(value) {
|
|
details[name] = []string{"The " + name + " field must be a single value."}
|
|
continue
|
|
}
|
|
if out[ext] == nil {
|
|
out[ext] = map[string]any{}
|
|
}
|
|
out[ext][name] = value
|
|
}
|
|
}
|
|
if len(details) > 0 {
|
|
return nil, &ValidationError{Details: details}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// extensionLocaleMap turns a submitted translatable value into a map of
|
|
// locale to text; message is the field error when it cannot.
|
|
func extensionLocaleMap(name string, value any, locales extensionLocales) (map[string]string, string) {
|
|
switch v := value.(type) {
|
|
case nil:
|
|
return map[string]string{locales.def: ""}, ""
|
|
case string:
|
|
return map[string]string{locales.def: v}, ""
|
|
case map[string]any:
|
|
if !locales.writer {
|
|
return nil, "The " + name + " field cannot be translated."
|
|
}
|
|
out := make(map[string]string, len(v))
|
|
for locale, item := range v {
|
|
text, ok := item.(string)
|
|
if !ok || !locales.enabled[locale] {
|
|
return nil, "The " + name + " field must be an object of locale codes to text."
|
|
}
|
|
out[locale] = text
|
|
}
|
|
return out, ""
|
|
default:
|
|
return nil, "The " + name + " field must be text or an object of locale codes to text."
|
|
}
|
|
}
|
|
|
|
// extensionSave is what saveFormExtensions needs.
|
|
type extensionSave struct {
|
|
Tx *gorm.DB
|
|
Tr *phrasebook.Translator
|
|
Writer TranslationWriter
|
|
CC *CompiledController
|
|
Target any
|
|
Values extensionValues
|
|
Visible map[*compiledFormExtension]bool
|
|
Created bool
|
|
Changes []pact.RecordChange
|
|
}
|
|
|
|
// saveFormExtensions runs every visible extension's Save inside the
|
|
// record's save transaction, after the owner row is written. A plugin field
|
|
// type's Decode runs first on its value, per locale for a translatable one;
|
|
// its notices join the save's.
|
|
func saveFormExtensions(ctx context.Context, in extensionSave) ([]pluginNotice, error) {
|
|
cc := in.CC
|
|
if cc == nil || len(cc.formExtensions) == 0 {
|
|
return nil, nil
|
|
}
|
|
var (
|
|
notices []pluginNotice
|
|
locales extensionLocales
|
|
resolved bool
|
|
)
|
|
for _, ext := range cc.formExtensions {
|
|
if ext.def.Save == nil || !in.Visible[ext] {
|
|
continue
|
|
}
|
|
values := map[string]any{}
|
|
for name, value := range in.Values[ext] {
|
|
values[name] = value
|
|
}
|
|
for _, name := range ext.fields {
|
|
field := ext.own[name]
|
|
ft := field.fieldType
|
|
value, ok := values[name]
|
|
if ft == nil || !ok {
|
|
continue
|
|
}
|
|
if !ft.def.Translatable {
|
|
decoded, err := decodeExtensionValue(ctx, in, field, "", true, value, ¬ices)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
values[name] = decoded
|
|
continue
|
|
}
|
|
if !resolved {
|
|
var err error
|
|
if locales, err = resolveExtensionLocales(ctx, in.Tx, in.Writer, in.Tr); err != nil {
|
|
return nil, err
|
|
}
|
|
resolved = true
|
|
}
|
|
texts, _ := value.(map[string]string)
|
|
out := make(map[string]string, len(texts))
|
|
for _, locale := range sortedLocales(texts) {
|
|
decoded, err := decodeExtensionValue(ctx, in, field, locale, locale == locales.def, texts[locale], ¬ices)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
text, isText := decoded.(string)
|
|
if !isText {
|
|
return nil, opaqueFieldTypeFailure(cc, field, "decode", fmt.Errorf("translatable type returned %T, want a string", decoded))
|
|
}
|
|
out[locale] = text
|
|
}
|
|
values[name] = out
|
|
}
|
|
result, err := ext.def.Save(ctx, pact.AdminFormSave{
|
|
DB: in.Tx,
|
|
Controller: controllerID(cc),
|
|
Record: in.Target,
|
|
ID: recordIDText(in.Target),
|
|
Created: in.Created,
|
|
Values: values,
|
|
Changes: append([]pact.RecordChange(nil), in.Changes...),
|
|
})
|
|
if err != nil {
|
|
return nil, extensionFailure(cc, ext, "save", err)
|
|
}
|
|
notices = append(notices, extensionNotices(result)...)
|
|
}
|
|
return notices, nil
|
|
}
|
|
|
|
// decodeExtensionValue runs a plugin field type's Decode on one extension
|
|
// value and records its notices.
|
|
func decodeExtensionValue(ctx context.Context, in extensionSave, field FormField, locale string, isDefault bool, value any, notices *[]pluginNotice) (any, error) {
|
|
ft := field.fieldType
|
|
decoded, err := ft.def.Decode(ctx, pact.FormFieldValue{
|
|
DB: in.Tx,
|
|
Record: in.Target,
|
|
Field: field.Name,
|
|
Options: copyJSONMap(field.TypeOptions),
|
|
Locale: locale,
|
|
Default: isDefault,
|
|
Value: value,
|
|
Translated: translatedReader(ctx, in.Tx, in.Writer, in.Target, ft, locale),
|
|
})
|
|
if err != nil {
|
|
return nil, fieldTypeFailure(ctx, in.Tr, in.CC, field, "decode", err)
|
|
}
|
|
for _, notice := range decoded.Notices {
|
|
*notices = append(*notices, pluginNotice{field: field.Name, locale: locale, notice: notice})
|
|
}
|
|
return decoded.Value, nil
|
|
}
|
|
|
|
// sortedLocales are the keys of texts in sorted order.
|
|
func sortedLocales(texts map[string]string) []string {
|
|
out := make([]string, 0, len(texts))
|
|
for locale := range texts {
|
|
out = append(out, locale)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// extensionNotices are a Save result's notices in field name order.
|
|
func extensionNotices(result pact.AdminFormSaveResult) []pluginNotice {
|
|
names := make([]string, 0, len(result.Notices))
|
|
for name := range result.Notices {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
var out []pluginNotice
|
|
for _, name := range names {
|
|
for _, notice := range result.Notices[name] {
|
|
out = append(out, pluginNotice{field: name, notice: notice})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// extensionFailure classifies an extension hook error: AdminFormErrors is
|
|
// validation_failed with its messages on the fields (localized by the save
|
|
// transaction), a cabana refusal passes through, anything else is logged and
|
|
// becomes the opaque lifecycle error.
|
|
func extensionFailure(cc *CompiledController, ext *compiledFormExtension, stage string, err error) error {
|
|
var invalid pact.AdminFormErrors
|
|
if errors.As(err, &invalid) && len(invalid) > 0 {
|
|
details := make(map[string]any, len(invalid))
|
|
for name, messages := range invalid {
|
|
details[name] = append([]string(nil), messages...)
|
|
}
|
|
return &ValidationError{Details: details}
|
|
}
|
|
var refused *ValidationError
|
|
var forbidden *ForbiddenError
|
|
if errors.As(err, &refused) || errors.As(err, &forbidden) {
|
|
return err
|
|
}
|
|
moduleLog.Error("cabana: form extension "+stage+" failed", "controller", controllerID(cc), "plugin", ext.pluginID, "error", err)
|
|
return &lifecycleError{ControllerID: controllerID(cc)}
|
|
}
|
|
|
|
// extensionHydrate is what hydrateFormExtensions needs.
|
|
type extensionHydrate struct {
|
|
Tx *gorm.DB
|
|
Tr *phrasebook.Translator
|
|
Writer TranslationWriter
|
|
CC *CompiledController
|
|
Target any
|
|
Op string
|
|
Visible map[*compiledFormExtension]bool
|
|
}
|
|
|
|
// hydrateFormExtensions adds every visible extension's Load values to a show
|
|
// or save response: only the extension's value fields whose context allows
|
|
// the operation, each then through its plugin field type's Load (per locale
|
|
// for a translatable one).
|
|
func hydrateFormExtensions(ctx context.Context, in extensionHydrate, data map[string]any) error {
|
|
cc := in.CC
|
|
if cc == nil || data == nil {
|
|
return nil
|
|
}
|
|
var (
|
|
locales extensionLocales
|
|
resolved bool
|
|
)
|
|
for _, ext := range cc.formExtensions {
|
|
if ext.def.Load == nil || !in.Visible[ext] {
|
|
continue
|
|
}
|
|
loaded, err := ext.def.Load(ctx, pact.AdminFormLoad{DB: in.Tx, Controller: controllerID(cc), Record: in.Target, ID: recordIDText(in.Target)})
|
|
if err != nil {
|
|
moduleLog.Error("cabana: form extension load failed", "controller", controllerID(cc), "plugin", ext.pluginID, "error", err)
|
|
return &lifecycleError{ControllerID: controllerID(cc)}
|
|
}
|
|
for _, name := range ext.fields {
|
|
value, ok := loaded[name]
|
|
if !ok || !ext.valueField(name) || !contextAllows(cc, name, in.Op) {
|
|
continue
|
|
}
|
|
field := ext.own[name]
|
|
ft := field.fieldType
|
|
if ft == nil || ft.def.Load == nil {
|
|
data[name] = value
|
|
continue
|
|
}
|
|
load := func(value any, locale string, isDefault bool) (any, error) {
|
|
out, err := ft.def.Load(ctx, pact.FormFieldValue{
|
|
DB: in.Tx, Record: in.Target, Field: name, Options: copyJSONMap(field.TypeOptions),
|
|
Locale: locale, Default: isDefault, Value: value,
|
|
Translated: translatedReader(ctx, in.Tx, in.Writer, in.Target, ft, locale),
|
|
})
|
|
if err != nil {
|
|
return nil, opaqueFieldTypeFailure(cc, field, "load", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
texts, isMap := value.(map[string]string)
|
|
if !ft.def.Translatable || !isMap {
|
|
served, err := load(derefValue(value), "", true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data[name] = served
|
|
continue
|
|
}
|
|
if !resolved {
|
|
if locales, err = resolveExtensionLocales(ctx, in.Tx, in.Writer, in.Tr); err != nil {
|
|
return err
|
|
}
|
|
resolved = true
|
|
}
|
|
served := make(map[string]any, len(texts))
|
|
for _, locale := range sortedLocales(texts) {
|
|
if served[locale], err = load(texts[locale], locale, locale == locales.def); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
data[name] = served
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// recordIDText is the record's primary key as text, "" when it has none.
|
|
func recordIDText(model any) string {
|
|
v := reflect.ValueOf(model)
|
|
for v.Kind() == reflect.Pointer {
|
|
if v.IsNil() {
|
|
return ""
|
|
}
|
|
v = v.Elem()
|
|
}
|
|
if !v.IsValid() {
|
|
return ""
|
|
}
|
|
field := fieldByColumn(v, primaryColumn(model))
|
|
if !field.IsValid() || !field.CanInterface() {
|
|
return ""
|
|
}
|
|
value := derefValue(field.Interface())
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprint(value)
|
|
}
|
|
|
|
// principalOf is the principal on ctx, nil without one.
|
|
func principalOf(ctx context.Context) *bouncer.Principal {
|
|
principal, _ := bouncer.User(ctx)
|
|
return principal
|
|
}
|