feat(10.1-01): run registered widget actions through a cabana-owned route

- pact: AdminClientAssets, AdminAction, AdminActionInput, AdminActionResult,
  HasAdminActions and AdminPartialData contracts
- fields.yaml type: widget with widget, action and fill keys; boot checks the
  plugin tag prefix, the registered action and writable scalar fill fields
- POST .../widgets/{field} behind requireAjax, controller and action
  permissions, scoped non-locking record read and a server-side fill filter
- typed OpenAPI operation, inventories and an acme conformance case
This commit is contained in:
Jakub Zych
2026-09-28 23:35:00 +02:00
parent 9b98d8409f
commit f9281949a6
19 changed files with 876 additions and 10 deletions

View File

@@ -129,6 +129,70 @@ type AdminRecordSource interface {
NewRecord() any
}
// AdminClientAssets is Winter's addJs/addCss for one admin controller. The
// paths are relative to the owning plugin's AdminFS and must live under
// assets/ (for example assets/js/lookup.js). The admin SPA loads them when the
// controller's list or form opens; files are always served from the embedded
// tree, never from disk. It is separate from AdminAssets, which is the YAML
// tree itself.
type AdminClientAssets interface {
AdminJS() []string
AdminCSS() []string
}
// AdminAction is one controller action that a list toolbar button or a form
// widget runs. The admin framework owns the HTTP route, the CSRF check,
// authentication and record scoping; Run only carries the business logic.
// Name is an identifier unique within the controller; create and delete are
// reserved for the built-in toolbar actions. Label is a phrase key or literal
// text used as the button caption. Permissions are checked in addition to the
// controller's RequiredPermissions.
type AdminAction struct {
Name string
Label string
Permissions []string
Run func(ctx context.Context, in AdminActionInput) (AdminActionResult, error) `json:"-"`
}
// AdminActionInput is what the framework hands an AdminAction. Field is the
// widget field name and is empty for a toolbar action. RecordID is nil on the
// create form and always nil for a toolbar action: toolbar actions carry no
// record ids, so an id list can never become an unscoped lookup. Record is the
// record the framework loaded through the controller's FormExtendQuery scope,
// nil when RecordID is nil. Values is the widget's snapshot of its fill
// fields, already reduced to the field's declared fill keys and to scalars.
type AdminActionInput struct {
Field string
RecordID *uint64
Record any
Values map[string]any
}
// AdminActionResult is an action's answer. Message is a phrase key or text,
// localized by the framework and shown as a toast. Fill is the widget
// write-back; keys outside the field's declared fill keys and non-scalar
// values are dropped before the response is written.
type AdminActionResult struct {
Message string
Fill map[string]any
}
// HasAdminActions is implemented by an admin controller that registers named
// actions for its toolbar.buttons list and its `type: widget` form fields.
type HasAdminActions interface {
AdminActions() []AdminAction
}
// AdminPartialData supplies the view model a controller partial template
// renders (config_list.yaml headerPartial, fields.yaml `type: partial`). name
// is the partial name; record is the scoped record for a form partial on an
// existing record, else nil. The result must be a curated view model built
// for the template, never the GORM model itself: the framework refuses a
// value of the controller's model type.
type AdminPartialData interface {
PartialData(ctx context.Context, name string, record any) (any, error)
}
// Permission is one registerPermissions() entry.
type Permission struct {
Code string