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

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