- placement: tabs before/after tab anchors, anchor cursor for plugin order, own-field anchors sorted with cycle detection, Requires() rule, anchor tab inheritance, Hide with reference checks, zone refusal (D-101) - boot errors name plugin, controller and offending name; fileupload is refused (D-100); localized tab label collisions checked in Activate - extension widgets dispatch to AdminFormExtension.Actions, partials render from the contributor's Dir through PartialData, plugin field types decode for Save and load after Load, translatable values reach Save as locale maps and never the owner's translations - Permissions and Visible apply to schema, show, save, widgets and partials; AdminFormErrors answer 422, Save notices join meta.notices
1511 lines
49 KiB
Go
1511 lines
49 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
|
|
}
|
|
|
|
// 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,
|
|
})
|
|
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
|
|
}
|