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:
@@ -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. |
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user