feat(32-01): cross-plugin admin form extensions (tracer)

- pact.AdminActionExtension.Form with AdminFormExtension: a fields
  document (fields.yaml format plus before:/after:/zone: and a tabs:
  list) given as an AdminFS path or YAML bytes, Load and Save hooks,
  AdminFormLoad/Save/SaveResult, AdminFormErrors and zone constants
- cabana compileFormExtensions places extension fields at owner
  anchors at boot, regrouping an extended form by tab
- save lifts extension values before any owner path and runs Save in
  the record's transaction after the owner row; show merges Load
- TestFormExtensionTracer: c after a on a, b gives a, c, b through the
  admin API, stored in the contributor's table
This commit is contained in:
2026-10-26 21:46:43 +01:00
parent 9868838fe3
commit 0819442634
8 changed files with 1150 additions and 6 deletions

View File

@@ -256,6 +256,12 @@ type CompiledController struct {
// singleton is true for a controller implementing pact.AdminSingleton
// (D-11): one record, opened directly; no list, create, bulk or delete.
singleton bool
// formExtensions are the form contributions other plugins (or the owner)
// add through pact.AdminActionExtension.Form (D-91), in plugin activation
// order; extensionFields maps the name of every field they add to its
// extension.
formExtensions []*compiledFormExtension
extensionFields map[string]*compiledFormExtension
}
// Registry is the immutable controller map keyed by controller ID.

View File

@@ -252,6 +252,11 @@ func BindWritableFields(cc *CompiledController) error {
if !valueFormField(field) || protectedFillKey(field.Name) || cc.virtual[field.Name] {
continue
}
// A form extension's field is never a column of the owner's model
// (D-91): its extension loads and saves it.
if cc.extensionOf(field.Name) != nil {
continue
}
if _, known := cols[field.Name]; !known {
return errors.New("cabana: controller " + id + ": field " + field.Name + " is not a model column")
}
@@ -533,6 +538,9 @@ func (s CRUDService) ShowRecord(ctx context.Context, cc *CompiledController, id
if err := hydratePluginFields(ctx, tx, cc, s.writer, target, result.Data, "update"); err != nil {
return err
}
if err := hydrateFormExtensions(withTx(ctx, tx), tx, cc, target, result.Data, "update"); err != nil {
return err
}
result.Meta.Actions, err = s.offeredRecordActions(withTx(ctx, tx), cc, target)
return err
})
@@ -747,6 +755,9 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
if update {
op = "update"
}
// D-91: form extension values leave the body first, so no owner path
// (Fill, bindings, translations, attachments) ever reads them.
extensions := liftExtensionValues(cc, in.Body, op)
// Writable relation keys are lifted before scalar projection (which drops
// every nested value); only keys present in the body are applied.
relations, err := liftRelationValues(cc, in.Body, op)
@@ -925,6 +936,13 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
if err != nil {
return err
}
// D-91: form extensions save their own values in this transaction,
// after the owner row is written; an error rolls everything back.
extNotices, err := saveFormExtensions(ctx, tx, cc, target, extensions, !update)
if err != nil {
return err
}
notices = append(notices, extNotices...)
result, err = projectFullRecord(ctx, tx, cc, target)
if err != nil {
return err
@@ -932,7 +950,10 @@ func (s CRUDService) save(ctx context.Context, cc *CompiledController, id any, i
if err := hydrateMLRecord(ctx, tx, cc, s.writer, target, result.Data, op); err != nil {
return err
}
return hydratePluginFields(ctx, tx, cc, s.writer, target, result.Data, op)
if err := hydratePluginFields(ctx, tx, cc, s.writer, target, result.Data, op); err != nil {
return err
}
return hydrateFormExtensions(ctx, tx, cc, target, result.Data, op)
})
if err != nil {
return RecordResult{}, err

View File

@@ -0,0 +1,608 @@
package cabana
import (
"context"
"errors"
"fmt"
"reflect"
"sort"
"strings"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"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"
// 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
}
// 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 each field
// is placed at its anchor. 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{}
var order []string
for _, plugin := range plugins {
if plugin == nil {
continue
}
src, ok := plugin.(pact.HasAdminActionExtensions)
if !ok || src == nil {
continue
}
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]); 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) 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)
for i, c := range contributions {
ext := &compiledFormExtension{pluginID: c.plugin.ID(), def: c.def, own: map[string]FormField{}}
if err := form.apply(ext, docs[i], 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 {
// A new slice: the owner's compiled list may be shared.
cc.Form.Fields = form.flatten()
}
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
}
// 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.
type formLayout struct {
groups []*tabGroup
// fieldPlugin maps every field name to the plugin that added it; owner
// fields map to "".
fieldPlugin map[string]string
}
func newFormLayout(fields []FormField) *formLayout {
layout := &formLayout{fieldPlugin: map[string]string{}}
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)
}
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
}
// group returns the tab group with key, nil when the form has none.
func (l *formLayout) group(key string) *tabGroup {
for _, group := range l.groups {
if group.key == key {
return group
}
}
return nil
}
// 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
}
// apply places one extension's fields.
func (l *formLayout) apply(ext *compiledFormExtension, doc extensionDoc, controller string) error {
pluginID := ext.pluginID
if len(doc.tabs) > 0 {
return extErr(pluginID, controller, "tab %s: new tabs are not supported yet", doc.tabs[0].name)
}
for _, item := range doc.fields {
field := item.field
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 adds it)", field.Name, by)
}
anchor, before := item.after, false
if anchor == "" && item.before != "" {
anchor, before = item.before, true
}
var (
group *tabGroup
index int
)
if anchor != "" {
group, index = l.locate(anchor)
if group == nil {
return extErr(pluginID, controller, "field %s: unknown anchor %q", field.Name, anchor)
}
if field.Tab == "" {
field.Tab = group.key
} else if field.Tab != group.key {
return extErr(pluginID, controller, "field %s: anchor %q is in another tab than %q", field.Name, anchor, field.Tab)
}
if !before {
index++
}
} else {
group = l.group(field.Tab)
if group == nil {
if field.Tab != "" {
return extErr(pluginID, controller, "field %s: unknown tab %q", field.Name, field.Tab)
}
group = &tabGroup{}
l.groups = append([]*tabGroup{group}, l.groups...)
}
index = len(group.fields)
}
group.fields = append(group.fields[:index], append([]FormField{field}, group.fields[index:]...)...)
l.fieldPlugin[field.Name] = pluginID
ext.fields = append(ext.fields, field.Name)
ext.own[field.Name] = field
}
return nil
}
// 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]
}
// extensionValues are the submitted values of one save, per extension.
type extensionValues map[*compiledFormExtension]map[string]any
// 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). Only fields whose
// context allows op are kept for their extension's Save.
func liftExtensionValues(cc *CompiledController, body map[string]any, op string) extensionValues {
out := extensionValues{}
if cc == nil || len(cc.extensionFields) == 0 || body == nil {
return out
}
for name, ext := range cc.extensionFields {
value, present := body[name]
if !present {
continue
}
delete(body, name)
if !contextAllows(cc, name, op) {
continue
}
if out[ext] == nil {
out[ext] = map[string]any{}
}
out[ext][name] = value
}
return out
}
// saveFormExtensions runs every extension's Save inside the record's save
// transaction, after the owner row is written. created is true on create.
func saveFormExtensions(ctx context.Context, tx *gorm.DB, cc *CompiledController, target any, values extensionValues, created bool) ([]pluginNotice, error) {
if cc == nil {
return nil, nil
}
var notices []pluginNotice
for _, ext := range cc.formExtensions {
if ext.def.Save == nil {
continue
}
own := values[ext]
if own == nil {
own = map[string]any{}
}
result, err := ext.def.Save(ctx, pact.AdminFormSave{
DB: tx,
Controller: controllerID(cc),
Record: target,
ID: recordIDText(target),
Created: created,
Values: own,
})
if err != nil {
return nil, extensionFailure(cc, ext, "save", err)
}
notices = append(notices, extensionNotices(result)...)
}
return notices, nil
}
// extensionNotices are a Save result's notices in field name order.
func extensionNotices(result pact.AdminFormSaveResult) []pluginNotice {
var out []pluginNotice
names := make([]string, 0, len(result.Notices))
for name := range result.Notices {
names = append(names, name)
}
sort.Strings(names)
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, 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)}
}
// hydrateFormExtensions adds every extension's Load values to a show or save
// response. Only keys that are the extension's own fields whose context
// allows op are kept.
func hydrateFormExtensions(ctx context.Context, tx *gorm.DB, cc *CompiledController, target any, data map[string]any, op string) error {
if cc == nil || data == nil {
return nil
}
for _, ext := range cc.formExtensions {
if ext.def.Load == nil {
continue
}
loaded, err := ext.def.Load(ctx, pact.AdminFormLoad{DB: tx, Controller: controllerID(cc), Record: target, ID: recordIDText(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 || !contextAllows(cc, name, op) {
continue
}
data[name] = value
}
}
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)
}

View File

@@ -0,0 +1,340 @@
package cabana
import (
"bytes"
"context"
"encoding/json"
"io/fs"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"gorm.io/gorm"
)
// The D-91 form extension save, show and schema paths on a real Postgres
// database (testcontainers, like newListService): an owner plugin acme.blog
// with the posts controller, and a contributor acme.extra that adds fields
// and stores their values in its own table.
// fePost is the owner's model: columns a and b only.
type fePost struct {
ID uint `gorm:"column:id;primaryKey"`
A string `gorm:"column:a"`
B string `gorm:"column:b"`
}
func (fePost) TableName() string { return "acme_blog_posts" }
func (*fePost) Fillable() []string { return []string{"a", "b"} }
func (*fePost) Rules() map[string]string { return map[string]string{} }
// feExtraValue is the contributor's own table: one value per post.
type feExtraValue struct {
RecordID uint `gorm:"column:record_id;primaryKey;autoIncrement:false"`
Value string `gorm:"column:value"`
}
func (feExtraValue) TableName() string { return "acme_extra_values" }
// fePosts is the owner's admin controller.
type fePosts struct{}
func (fePosts) ID() string { return "acme.blog.posts" }
func (fePosts) ModelName() string { return "Post" }
func (fePosts) ConfigDir() string { return "controllers/posts" }
func (fePosts) NewRecord() any { return &fePost{} }
func (fePosts) RequiredPermissions() []string { return []string{"acme.blog.access"} }
// feOwner is the owner plugin; fields is the body of its fields.yaml.
type feOwner struct {
fields string
}
func (feOwner) ID() string { return "acme.blog" }
func (feOwner) Requires() []string { return nil }
func (feOwner) Register(*backpack.App) error { return nil }
func (feOwner) Boot(*backpack.App) error { return nil }
func (p feOwner) AdminFS() fs.FS { return feOwnerFS(p.fields) }
func (feOwner) AdminControllers() []pact.AdminController {
return []pact.AdminController{fePosts{}}
}
// feOwnerFields is the owner form of the tracer: a and b, no tabs.
const feOwnerFields = "fields:\n a:\n type: text\n label: A\n b:\n type: text\n label: B\n"
func feOwnerFS(fields string) fstest.MapFS {
return fstest.MapFS{
"controllers/posts/config_form.yaml": &fstest.MapFile{Data: []byte("name: Post\nform: ~/plugins/acme/blog/models/post/fields.yaml\nmodelClass: Post\n")},
"controllers/posts/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/blog/models/post/columns.yaml\nmodelClass: Post\nrecordsPerPage: 20\ntoolbar:\n buttons: [create]\n")},
"models/post/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n a:\n label: A\n")},
"models/post/fields.yaml": &fstest.MapFile{Data: []byte(fields)},
}
}
// feContributor is a plugin that extends other plugins' forms.
type feContributor struct {
id string
requires []string
fsys fstest.MapFS
types []pact.FormFieldType
extensions []pact.AdminActionExtension
}
func (p feContributor) ID() string { return p.id }
func (p feContributor) Requires() []string { return p.requires }
func (feContributor) Register(*backpack.App) error { return nil }
func (feContributor) Boot(*backpack.App) error { return nil }
func (p feContributor) AdminFS() fs.FS { return p.fsys }
func (p feContributor) FormFieldTypes() []pact.FormFieldType { return p.types }
func (p feContributor) AdminActionExtensions() []pact.AdminActionExtension {
return p.extensions
}
var (
_ party.Plugin = feContributor{}
_ pact.HasAdminActionExtensions = feContributor{}
)
// feCompile runs the boot path: field types, the registry, then the form
// extensions, as Activate does.
func feCompile(plugins ...party.Plugin) (*Registry, error) {
reg, err := ftCompile(plugins...)
if err != nil {
return nil, err
}
if err := compileFormExtensions(reg, plugins); err != nil {
return nil, err
}
return reg, nil
}
// feStore is the contributor's storage spy: every Save call's input, the
// owner row as Save read it through the save transaction, and failures to
// return.
type feStore struct {
mu sync.Mutex
saves []pact.AdminFormSave
ownerA []string
fail error
notices map[string][]pact.FormFieldNotice
}
func (s *feStore) save(_ context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.saves = append(s.saves, in)
var a string
if err := in.DB.Raw("SELECT a FROM acme_blog_posts WHERE id = ?", in.ID).Scan(&a).Error; err != nil {
return pact.AdminFormSaveResult{}, err
}
s.ownerA = append(s.ownerA, a)
if value, ok := in.Values["c"]; ok {
text, _ := value.(string)
id, _ := strconv.ParseUint(in.ID, 10, 64)
row := feExtraValue{RecordID: uint(id), Value: text}
if err := in.DB.Save(&row).Error; err != nil {
return pact.AdminFormSaveResult{}, err
}
}
if s.fail != nil {
return pact.AdminFormSaveResult{}, s.fail
}
return pact.AdminFormSaveResult{Notices: s.notices}, nil
}
func (s *feStore) load(_ context.Context, in pact.AdminFormLoad) (map[string]any, error) {
var row feExtraValue
err := in.DB.Where("record_id = ?", in.ID).Limit(1).Find(&row).Error
if err != nil {
return nil, err
}
return map[string]any{"c": row.Value, "a": "never served"}, nil
}
// feExtraFS is the contributor's tree with one fields document.
func feExtraFS(doc string) fstest.MapFS {
return fstest.MapFS{"extensions/posts.yaml": &fstest.MapFile{Data: []byte(doc)}}
}
// feCall runs one admin request against a controller of svc.
func feCall(t *testing.T, svc *service, principal *bouncer.Principal, controller, method, route, id string, body any) *httptest.ResponseRecorder {
t.Helper()
var raw []byte
if body != nil {
var err error
if raw, err = json.Marshal(body); err != nil {
t.Fatal(err)
}
}
req := httptest.NewRequest(method, "/", bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
parts := strings.SplitN(controller, ".", 3)
req.SetPathValue("vendor", parts[0])
req.SetPathValue("plugin", parts[1])
req.SetPathValue("controller", parts[2])
if id != "" {
req.SetPathValue("id", id)
}
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
rec := httptest.NewRecorder()
switch route {
case "schema":
svc.formSchema(rec, req)
case "record":
switch method {
case http.MethodPost:
svc.create(rec, req)
case http.MethodGet:
svc.show(rec, req)
case http.MethodPut:
svc.update(rec, req)
default:
t.Fatalf("method %s", method)
}
default:
t.Fatalf("route %s", route)
}
return rec
}
// feSchemaNames is the field name list of a form schema response.
func feSchemaNames(t *testing.T, rec *httptest.ResponseRecorder) []string {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("schema status=%d body=%s", rec.Code, rec.Body.String())
}
var out struct {
Data struct {
Fields []struct {
Name string `json:"name"`
Tab string `json:"tab"`
} `json:"fields"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
names := make([]string, 0, len(out.Data.Fields))
for _, field := range out.Data.Fields {
names = append(names, field.Name)
}
return names
}
// feEnv is a Postgres-backed admin service with the owner and contributor.
type feEnv struct {
db *gorm.DB
svc *service
}
func newFEEnv(t *testing.T, plugins ...party.Plugin) feEnv {
t.Helper()
_, db := newListService(t)
if err := db.Migrator().DropTable(&fePost{}, &feExtraValue{}); err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&fePost{}, &feExtraValue{}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Migrator().DropTable(&fePost{}, &feExtraValue{}) })
reg, err := feCompile(plugins...)
if err != nil {
t.Fatalf("compile: %v", err)
}
app := backpack.New(nil)
if err := app.Publish(db); err != nil {
t.Fatal(err)
}
return feEnv{db: db, svc: &service{app: app, reg: reg}}
}
func (e feEnv) post(t *testing.T, id uint) fePost {
t.Helper()
var out fePost
if err := e.db.First(&out, id).Error; err != nil {
t.Fatal(err)
}
return out
}
func (e feEnv) extra(t *testing.T, id uint) (string, bool) {
t.Helper()
var rows []feExtraValue
if err := e.db.Where("record_id = ?", id).Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) == 0 {
return "", false
}
return rows[0].Value, true
}
// TestFormExtensionTracer (SC-1, D-91): plugin acme.extra adds field c after
// a on acme.blog's form of a and b. The schema lists a, c, b; an update
// stores a and b in the owner row and c in the contributor's table in the
// same transaction; show serves c; the owner model never gains a c column.
func TestFormExtensionTracer(t *testing.T) {
store := &feStore{}
extra := feContributor{
id: "acme.extra",
fsys: feExtraFS("fields:\n c:\n type: text\n label: C\n after: a\n"),
extensions: []pact.AdminActionExtension{{
Controller: "acme.blog.posts",
Form: &pact.AdminFormExtension{Fields: "extensions/posts.yaml", Load: store.load, Save: store.save},
}},
}
env := newFEEnv(t, feOwner{fields: feOwnerFields}, extra)
names := feSchemaNames(t, feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodGet, "schema", "", nil))
if strings.Join(names, " ") != "a c b" {
t.Fatalf("schema fields = %v, want [a c b]", names)
}
seed := fePost{A: "A0", B: "B0"}
if err := env.db.Create(&seed).Error; err != nil {
t.Fatal(err)
}
idText := strconv.FormatUint(uint64(seed.ID), 10)
rec := feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodPut, "record", idText, map[string]any{"a": "A1", "b": "B1", "c": "C1"})
if rec.Code != http.StatusOK {
t.Fatalf("update status=%d body=%s", rec.Code, rec.Body.String())
}
saved := pgFTDecode(t, rec)
if saved.Data["c"] != "C1" || saved.Data["a"] != "A1" {
t.Fatalf("update data=%v, want a=A1 and c=C1 served by Load", saved.Data)
}
if got := env.post(t, seed.ID); got.A != "A1" || got.B != "B1" {
t.Fatalf("owner row=%+v", got)
}
if value, ok := env.extra(t, seed.ID); !ok || value != "C1" {
t.Fatalf("contributor row=%q,%v", value, ok)
}
if len(store.saves) != 1 || store.saves[0].Created || store.saves[0].ID != idText || store.saves[0].Controller != "acme.blog.posts" {
t.Fatalf("saves=%+v", store.saves)
}
if len(store.saves[0].Values) != 1 || store.saves[0].Values["c"] != "C1" {
t.Fatalf("Save values=%v, want only c", store.saves[0].Values)
}
if store.ownerA[0] != "A1" {
t.Fatalf("Save read owner a=%q through its DB, want the uncommitted A1 of the same transaction", store.ownerA[0])
}
rec = feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodGet, "record", idText, nil)
if rec.Code != http.StatusOK {
t.Fatalf("show status=%d body=%s", rec.Code, rec.Body.String())
}
if shown := pgFTDecode(t, rec); shown.Data["c"] != "C1" || shown.Data["a"] != "A1" {
t.Fatalf("show data=%v", shown.Data)
}
if env.db.Migrator().HasColumn(&fePost{}, "c") {
t.Fatal("the owner table gained a c column")
}
}

View File

@@ -114,6 +114,12 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
if err != nil {
return nil, err
}
// D-91: fields and tabs other plugins add to a controller's form are
// placed once every form is compiled, before any later pass reads the
// forms.
if err := compileFormExtensions(reg, plugins); err != nil {
return nil, err
}
uploadBytes, err := configBytes(app, "http.body_limits.upload_bytes")
if err != nil {
return nil, err

View File

@@ -31,7 +31,7 @@ Use cases:
- MCP declarations, free of any MCP SDK: `pact.HasMCP` with `pact.MCP`, `pact.MCPTool` and `pact.MCPPrompt` bind tools and prompts to the plugin's own token-guarded routes; `pact.MCPGuard` and `pact.MCPGuards` let the owner of a route guard publish how its routes are guarded and dispatched; `pact.MCPTokenVerifier`, `pact.MCPPersonalTokenResolver` and `pact.MCPLinkedBackendResolver` are the services the MCP endpoint looks up; `pact.MCPSubjectChanged` is the event that refreshes a session's tool list.
- My account contributions: `pact.HasAccountContributions` with `pact.AccountContribution` and `pact.AccountAction`, so a plugin adds a section with a status line and buttons to the administrator's own account page, run for the signed-in administrator only.
- Admin controller contracts: `pact.AdminController`, `pact.HasAdminControllers`, `pact.AdminAssets` (embedded Winter-shaped admin YAML), `pact.AdminPermissioned` and `pact.AdminRecordSource`.
- Admin extension contracts, so a plugin extends the compiled admin SPA without a Node build: `pact.AdminClientAssets` (per-controller JS and CSS from the plugin's embedded `assets/` tree, Winter's `addJs`/`addCss`), `pact.HasAdminActions` with `pact.AdminAction`, `pact.AdminActionInput` and `pact.AdminActionResult` (named toolbar and widget actions whose routes, CSRF check, permissions and record scoping the framework owns), `pact.HasAdminBulkActions` with `pact.AdminBulkAction`, `pact.AdminBulkActionInput` and `pact.AdminBulkActionResult` (named actions on the rows selected in a list, which receive records the framework loaded through the list scope, never ids; a `Detached` action runs outside the framework transaction), `pact.HasAdminRecordActions` with `pact.AdminRecordAction`, `pact.AdminRecordActionInput` and `pact.AdminRecordActionResult` (named actions on one record, each with an `Applies` rule for the record's state), `pact.HasAdminActionExtensions` with `pact.AdminActionExtension` (a plugin adds record actions to any admin controller by controller ID, one it does not own included; they are offered on that controller's preview screen after the owner's), and `pact.AdminPartialData` (the curated view model a partial template renders).
- Admin extension contracts, so a plugin extends the compiled admin SPA without a Node build: `pact.AdminClientAssets` (per-controller JS and CSS from the plugin's embedded `assets/` tree, Winter's `addJs`/`addCss`), `pact.HasAdminActions` with `pact.AdminAction`, `pact.AdminActionInput` and `pact.AdminActionResult` (named toolbar and widget actions whose routes, CSRF check, permissions and record scoping the framework owns), `pact.HasAdminBulkActions` with `pact.AdminBulkAction`, `pact.AdminBulkActionInput` and `pact.AdminBulkActionResult` (named actions on the rows selected in a list, which receive records the framework loaded through the list scope, never ids; a `Detached` action runs outside the framework transaction), `pact.HasAdminRecordActions` with `pact.AdminRecordAction`, `pact.AdminRecordActionInput` and `pact.AdminRecordActionResult` (named actions on one record, each with an `Applies` rule for the record's state), `pact.HasAdminActionExtensions` with `pact.AdminActionExtension` (a plugin adds record actions to any admin controller by controller ID, one it does not own included; they are offered on that controller's preview screen after the owner's) and `pact.AdminFormExtension` (a plugin places fields and tabs in another controller's form, before or after the owner's, and loads and saves their values itself inside the record's save transaction), and `pact.AdminPartialData` (the curated view model a partial template renders).
- Plugin field types: `pact.HasFormFieldTypes` returns `pact.FormFieldType` entries, admin form field types any controller's `fields.yaml` uses by a short alias (`type: notes`). A type names its custom element, its scripts and styles, its own option keys and its Go funcs: `Decode` turns a submitted `pact.FormFieldValue` into a `pact.FormFieldDecoded` (the value to store plus optional `pact.FormFieldNotice` messages for the administrator, such as what a sanitizer removed) or refuses it with a `pact.FormFieldError`; `Load` serves the stored value to the element, `ListValue` formats a list column, and `Upload` stores a file from a `pact.FormFieldUpload` and answers a `pact.FormFieldUploadResult`. The admin framework owns the routes, permissions, record scope, locale storage of a translatable type and the localization of notices.
- Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), list row states (`pact.ListRowStates` with the fixed `pact.RowState` set `pact.RowStateDeleted`, `pact.RowStateNegative` and `pact.RowStateDisabled`), values of unstored list columns filled once per page (`pact.ListExtendRecords`), the record title of the form and preview screens (`pact.FormRecordTitle`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), a hook after a reorder save (`pact.ReorderAfterSave`), form-only fields that reach those hooks without being model columns (`pact.FormVirtualFields`), validation rules per operation for admin saves (`pact.FormRules`), relation hooks (`pact.RelationExtendManageQuery`, `pact.RelationExtendOptionsQuery`, `pact.RelationBeforeLink`), relation child hooks around creating, updating and deleting a related record (`pact.RelationBeforeCreate`, `pact.RelationAfterCreate`, `pact.RelationBeforeUpdate`, `pact.RelationAfterUpdate`, `pact.RelationBeforeDelete`, `pact.RelationAfterDelete`), filter scopes (`pact.FilterScope`, `pact.FilterOptions`) and dropdown options (`pact.DropdownOptionsProvider`).
- Dependent dropdown options: `pact.DependentOptionsProvider` answers a `pact.DependentOptionSet` (whether the sibling's value takes a choice at all, and the choices) for a dropdown with `dependsOn`, from the submitted values of its siblings and with a context, so the choices can come from the database; the admin framework calls it for the options route and again on every save. `pact.Option.Depth` indents a choice taken from a tree.
@@ -145,8 +145,14 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
| `pact.AdminRecordActionInput` | What a record action receives: `RecordID` and `Record`, the record loaded and row-locked through the form scope, and `Value`, the trimmed prompt answer (empty without a `Prompt`). |
| `pact.AdminRecordActionResult` | What a record action returns: an optional message for the toast. |
| `pact.HasAdminRecordActions` | Registers a controller's record actions for the `recordActions` list of `config_form.yaml`. |
| `pact.AdminActionExtension` | Record actions a plugin adds to one admin controller by its `Controller` ID; offered on the target's preview screen after the owner's actions, each checked against the target's `RequiredPermissions` and its own `Permissions`. A contribution to `summercms.cabana.users` is refused with `user.outranked` when the target administrator outranks the caller. |
| `pact.HasAdminActionExtensions` | Implemented by a plugin (not a controller) that adds actions to admin controllers, its own or another plugin's. An unknown controller ID, a target without a preview block or a duplicate name stops the boot. |
| `pact.AdminActionExtension` | What a plugin adds to one admin controller by its `Controller` ID: `RecordActions`, offered on the target's preview screen after the owner's actions, each checked against the target's `RequiredPermissions` and its own `Permissions` (a contribution to `summercms.cabana.users` is refused with `user.outranked` when the target administrator outranks the caller), and `Form`, an optional `pact.AdminFormExtension`. |
| `pact.HasAdminActionExtensions` | Implemented by a plugin (not a controller) that adds actions or form fields to admin controllers, its own or another plugin's. An unknown controller ID, a target without a preview block (record actions) or a form (form fields), or a duplicate name stops the boot. |
| `pact.AdminFormExtension` | Fields and tabs a plugin places in another controller's form, loaded and saved by the plugin itself: a fields document in the `fields.yaml` format (`Fields`, a path in the plugin's `AdminFS`, or `FieldsYAML`, the bytes built at boot) whose fields take `before:`/`after:` anchors and `zone:` and whose `tabs:` list places new tabs; `Hide` (owner fields to leave out), `Permissions` and `Visible` (who sees the fields), `Dir`, `PartialData` and `Actions` (its partial and widget fields), `Load` (values on show) and `Save` (values in the record's save transaction). The owner's model is never written. |
| `pact.AdminFormLoad` | What `Load` receives: the read or save transaction `DB`, the target `Controller` ID, the loaded `Record` and its `ID` as text. |
| `pact.AdminFormSave` | What `Save` receives: the save transaction `DB`, `Controller`, the written `Record`, its `ID`, `Created` (true on create) and `Values`, the submitted values of the extension's fields (a translatable field as a `map[string]string` of locale to text). |
| `pact.AdminFormSaveResult` | What `Save` returns: `Notices`, save notices per field name (`pact.FormFieldNotice`), shown like a field type's notices. |
| `pact.AdminFormErrors` | A `Save` refusal: field name to messages (phrase keys or text). The save answers 422 with them on the fields and rolls back. |
| `pact.AdminFormZoneTabs` | The `zone:` value of the form's tab zone, the default and the only zone rendered so far; `pact.AdminFormZoneOutside` and `pact.AdminFormZoneSecondary` are reserved and stop the boot ("is not supported yet"). |
| `pact.HasAccountContributions` | Implemented by a plugin that adds sections to the admin's My account page. |
| `pact.AccountContribution` | One My account section: `Name` (an identifier unique across plugins), `Label`, `Description`, an optional pure-read `Status` func for the signed-in administrator and its `Actions`. A non-identifier or taken name, an empty label, a duplicate or non-identifier action name, a nil `Run` or an empty action label stops the boot. |
| `pact.AccountAction` | One button of a section: name, label, optional `Confirm` text, `Danger`, extra `Permissions`, an optional pure-read `Applies` rule and the Go `Run` function, which acts for the signed-in administrator only. |

View File

@@ -7,6 +7,7 @@ import (
"io/fs"
"net/http"
"regexp"
"sort"
"strings"
"time"
@@ -424,11 +425,150 @@ type HasAdminRecordActions interface {
// plugin registers admin controllers). An unknown controller ID, a target
// without a preview block, a name the owner or an earlier extension already
// registers on that controller, a reserved name (create, delete), a name that
// is not an identifier, a missing Run or an empty Label stops the boot. Only
// record actions are carried for now; the struct leaves room for more kinds.
// is not an identifier, a missing Run or an empty Label stops the boot.
// Record actions and form contributions (Form) are carried; the struct
// leaves room for more kinds.
type AdminActionExtension struct {
Controller string
RecordActions []AdminRecordAction
// Form adds fields and tabs to the target controller's form and loads
// and saves their values; nil adds none. See AdminFormExtension.
Form *AdminFormExtension
}
// Form extension zones: the values a field or tab of an AdminFormExtension
// fields document may give as zone:. AdminFormZoneTabs is the form's tab
// zone, the default for an empty zone and the only zone the admin renders
// for now; any other zone stops the boot ("zone ... is not supported yet").
const (
AdminFormZoneTabs = "tabs"
AdminFormZoneOutside = "outside"
AdminFormZoneSecondary = "secondary"
)
// AdminFormExtension adds fields and tabs to an admin controller's form that
// the contributing plugin owns, places them between the owner's fields and
// tabs, and loads and saves their values itself. The owner's model, columns,
// translation rows and attachments are never written: extension values reach
// neither Fill nor the owner's bindings, only Save.
//
// The fields and tabs are a fields document in the fields.yaml format: a
// fields: mapping whose entries take every fields.yaml key plus before:,
// after: (the stable name of an anchor field) and zone:, and an optional
// top-level tabs: list whose entries take name: (the tab key), before: and
// after: (an anchor tab key) and zone:. A tab key is the raw tab: value, as
// owner fields carry it; default names the tab of fields without a tab:. Fields
// is the document's path in the contributing plugin's AdminFS (resolved like
// SettingsItem.Form, so $/vendor/plugin/... and ~/plugins/vendor/plugin/...
// work); FieldsYAML is the document itself, for fields the plugin builds at
// boot. Set at most one of them; an extension with neither adds no field and
// may still hide owner fields and run Load and Save.
//
// Placement: a field goes Before or After its anchor field, or to the end of
// its tab without one; a field without tab: takes its anchor's tab. A new tab
// goes Before or After its anchor tab, or after the last tab without one;
// before: default is refused. A form an extension lays out is regrouped by
// tab in first-appearance order with the default tab first, as the admin
// renders it; a form no extension lays out is unchanged. Extensions apply in
// plugin activation order, and several anchored at the same point keep that
// order. An anchor may name a field or tab another plugin's extension added
// only when that plugin is in the contributor's Requires().
//
// Hide lists owner fields the form leaves out for everyone: they leave the
// schema, show and save, and a tab without fields disappears. Permissions
// are backend permission codes an administrator needs to see the extension's
// fields; Visible, when set, is asked per request as well. An administrator
// who fails either gets none of the fields in the schema or the record, and a
// save drops their posted keys and does not call Save.
//
// Dir is the directory in the contributing plugin's AdminFS that holds the
// _<name>.htm templates of the document's partial fields; PartialData renders
// their view models (the same rules as pact.AdminPartialData). Actions are
// the widget actions of the document's widget fields.
//
// Load returns the extension's values for a record on show and after a
// save, keyed by field name; keys that are not its fields are dropped. Save
// runs inside the record's save transaction after the owner row is written,
// on create and update; Values holds the submitted values of its fields. A
// translatable field (mltext, mltextarea, mlmarkdown or a translatable
// plugin field type) carries a map[string]string of locale to text in Load
// and Save; a plugin field type's Decode runs before Save and its Load after
// Load. A Save error rolls the whole save back: AdminFormErrors answers 422
// with its messages on the fields, any other error the opaque 500.
//
// The admin compiles extensions at boot. An unknown controller, a target
// without a form, an unknown field or tab anchor, a duplicate field name
// (against the owner or an earlier extension), a duplicate tab key, two tab
// keys whose labels translate to the same text, an anchor cycle, an anchor on
// another plugin's extension outside Requires(), before: default, a zone other
// than the tab zone, a Hide of an unknown field or of a field another field
// references (fill, dependsOn, trigger, preset), a fileupload field (an
// extension owns no attachments; a plugin field type with Upload stores files
// instead), a widget action or partial name the owner already uses, and a
// field document error each stop the boot with the plugin, the controller and
// the offending name.
type AdminFormExtension struct {
Fields string
FieldsYAML []byte
Hide []string
Permissions []string
Visible func(ctx context.Context) bool `json:"-"`
Dir string
Actions []AdminAction
PartialData func(ctx context.Context, name string, record any) (any, error) `json:"-"`
Load func(ctx context.Context, in AdminFormLoad) (map[string]any, error) `json:"-"`
Save func(ctx context.Context, in AdminFormSave) (AdminFormSaveResult, error) `json:"-"`
}
// AdminFormLoad is what the framework hands AdminFormExtension.Load. DB is
// the read transaction (show) or the save transaction (after Save). Controller
// is the target controller ID, Record the loaded record and ID its primary key
// as text.
type AdminFormLoad struct {
DB *gorm.DB
Controller string
Record any
ID string
}
// AdminFormSave is what the framework hands AdminFormExtension.Save. DB is the
// record's save transaction (cabana.TxFromContext reads it too); the owner row
// is already written, so Record carries its primary key, also given as ID.
// Created is true on create. Values are the submitted values of the
// extension's fields whose context allows the operation, keyed by field name;
// a field the request did not send is absent.
type AdminFormSave struct {
DB *gorm.DB
Controller string
Record any
ID string
Created bool
Values map[string]any
}
// AdminFormSaveResult is Save's answer. Notices are non-blocking messages per
// field name, shown as save notices; a notice may name an owner field.
type AdminFormSaveResult struct {
Notices map[string][]FormFieldNotice
}
// AdminFormErrors is a save refusal from AdminFormExtension.Save: field name
// to messages (phrase keys or text). The framework answers 422 with the
// messages on the fields and rolls the save back.
type AdminFormErrors map[string][]string
// Error lists the fields in name order with their messages joined by "; ".
func (e AdminFormErrors) Error() string {
names := make([]string, 0, len(e))
for name := range e {
names = append(names, name)
}
sort.Strings(names)
parts := make([]string, 0, len(names))
for _, name := range names {
parts = append(parts, name+": "+strings.Join(e[name], "; "))
}
return strings.Join(parts, "; ")
}
// HasAdminActionExtensions is implemented by plugins (not admin controllers)