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

@@ -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)