docs(cabana): widget element contract and payload fixture (quick-261006-eyj)

- acme-demo-lookup fixture is a reorder widget: a click reverses its items,
  sends the new id order as detail.payload and renders the returned items from
  the data attribute and the summer-result event with textContent, no HTTP
- partials-and-widgets gains a Widget element contract subsection listing the
  attributes the SPA sets and the summer-action / summer-result events
- cabana README names both events in the form widgets bullet
This commit is contained in:
Jakub Zych
2026-10-06 11:18:03 +02:00
parent c6f68e4639
commit e723c391fb
3 changed files with 66 additions and 6 deletions

View File

@@ -72,6 +72,22 @@ lookup:
The SPA posts `record_id`, the fill snapshot `values` and, when the element supplied one, `payload` to `.../widgets/{field}`. cabana checks the CSRF header, the controller's and the action's permissions and the record scope, then calls the action's `Run` with a `pact.AdminActionInput`. The action reads the payload from the `Payload` field as raw JSON (any value up to 64 KiB, exactly the bytes the client sent, nil when absent) and decodes it itself; it must treat any ids inside the payload as untrusted input and re-check them against its own scope, because the framework never uses the payload to select the record. The answer's `pact.AdminActionResult` carries a message and the fill values; keys outside `fill` and values that are not scalars are dropped before the response is written. It may also carry `Data`, any JSON value up to 256 KiB encoded, which is passed through as `data` and is not filtered like `fill` (a larger or unencodable value is a 500). Toolbar and record actions refuse a payload. An action may return a `cabana.ValidationError` to answer 422 on a field.
### Widget element contract
The SPA talks to the plugin element through attributes and two events, nothing else. It sets these attributes:
- `record-id` (empty on the create form), `field-name`, `locale`, `fill-values` (the current values of the `fill` fields as JSON, updated as the form changes), `label` and `busy-label`;
- `busy` while a request runs, removed when it ends;
- `state` set to `error` after a failed action, removed when the next one starts;
- `data` holding the serialized response data after a successful action, removed when the answer carried none.
The events:
- `summer-action` is dispatched by the element (with `bubbles` and `composed`) to ask for its action. Its optional `detail.payload` is any JSON value; the SPA posts it as `payload`. A detail without a `payload` property sends none.
- `summer-result` is dispatched by the SPA on the element after a successful action, with `detail` `{data, fill, message}`: the response data as returned (`undefined` when none), the fill object and the message. A failed action dispatches nothing.
The element never receives a token, a cookie, a Vue instance or a function, and it must not make requests of its own: the SPA owns HTTP and the admin session. Render server data with `textContent`, not `innerHTML`: `data` is the plugin's own answer, but it still came over the wire. The `acme-demo-lookup` fixture under `modules/cabana/testdata/extension/assets/js/lookup.js` is a complete reorder widget: a click sends the new id order as the payload and the returned item list is rendered from the `data` attribute and the `summer-result` event.
## Toolbar actions
Names in `toolbar.buttons` of `config_list.yaml`, other than the built-in `create` and `delete`, are actions the controller registers through `pact.HasAdminActions`. Each needs a label. A toolbar action runs with an empty body and no record IDs, so it can never become an unscoped lookup of IDs the client chose. The list schema lists only the actions the administrator may run.

View File

@@ -14,7 +14,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- Generic CRUD with `cabana.CRUDService`: list, show, create, update, delete and bulk delete. Writes run in transactions, and reads and writes are scoped by the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` hooks. `cabana.ExecuteList` applies search, sort, filters and pagination only on columns declared in the schema, so request parameters never reach SQL directly.
- Mass-assignment protection: writable form fields are bound to model columns at activation (`cabana.BindWritableFields`), and `cabana.ProjectWritableFields` drops unknown keys, case variants, nested objects and protected columns from request bodies. Values are filled and validated through [lagoon](../lagoon/README.md); a value that does not fit its column (a `lagoon.FillTypeError`, such as a fraction for an integer field) is a 422 `validation_failed` on that field, and the form lifecycle hooks declared in `pact` (before and after create, update and delete) run around each write.
- Relations: `type: relation` form fields for belongsTo and belongsToMany (`cabana.FieldRelationProvider`, `cabana.FieldRelationContract`) with a paginated options endpoint and display labels in every record response; relation managers (`cabana.AdminRelationContractProvider`, `cabana.RelationContract`) served by `cabana.RelationService` for listing linked records and candidates, linking and unlinking, and creating related records. A contract is a belongsToMany (`cabana.RelationBelongsToMany`, the kind of a contract that leaves `Kind` empty) with a pivot model, or a hasMany (`cabana.RelationHasMany`) whose `ForeignKey` column on the related model points at the parent. A relation's child form comes from `manage.form` in `config_relation.yaml` (or a top-level `form`, WinterCMS's fallback), its read-only preview from `view.form` (same fallback), and a belongsToMany's pivot columns are edited through `pivot.form` (WinterCMS `pivot[x]` field names compile to `x`; pivot keys, timestamps and hook columns are refused); WinterCMS `$/<vendor>/<plugin>/...` paths resolve inside the same plugin only. A relation form accepts the scalar field types plus `datepicker` and `fileupload`; `relation`, `relation-manager`, `widget` and `partial` fail boot. The view panel's `toolbarButtons` (`create`, `update`, `delete`, `link`, `unlink`) are the capability of their routes. A relation's `messages` block takes the link keys (`link`, `linkHint`, `candidateSearch`, `linked`, `unlinkSelected`, `unlinkConfirm`, `unlinked`, `empty`) and the child and pivot modal keys (`create`, `createTitle`, `updateTitle`, `previewTitle`, `created`, `updated`, `deleteSelected`, `deleteConfirm`, `deleteOneConfirm`, `deleted`, `pivotTitle`, `pivotSaved`, `editPivot`, `createSubmit`, `updateSubmit`, `pivotSubmit`, `linkSubmit`), each defaulting to `backend::lang.messages.relation.*`. Framework code never guesses table, pivot or foreign-key names: the controller supplies them. A belongsTo field over a protected foreign key is read-only unless its contract sets `WritableForeignKey`; the protected key list itself does not change, and the submitted id is still rechecked through the scoped options query. A controller implementing `cabana.RelationLockProvider` returns a `cabana.RelationLock` per field and request: options and labels carry `locked: true` for those ids, and a create or update that adds or removes a locked id is answered 403 `forbidden` before any row is written.
- Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys whose values encode as JSON scalars (a value whose `MarshalJSON` writes an array or object, NaN or an infinity is dropped). Like the list schema's `toolbarActions`, the form schema carries a widget field only when the requesting administrator may run its action. The field's `context` applies to the action route as it does on save: a request without `record_id` is the create form's and one with it the update form's, and a widget its context hides on that form answers 404. Besides `record_id` and `values`, the request may carry `payload`, the widget's own JSON value (at most 64 KiB, a 422 above that; refused on toolbar and record routes), which reaches the action unfiltered as the `Payload` field of `pact.AdminActionInput`; the action may answer with `Data` on `pact.AdminActionResult`, written to the response as `data` exactly as it encodes (any JSON up to 256 KiB encoded, a 500 above that or when it cannot be encoded), not subject to the fill allowlist and omitted when nil. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot.
- Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys whose values encode as JSON scalars (a value whose `MarshalJSON` writes an array or object, NaN or an infinity is dropped). Like the list schema's `toolbarActions`, the form schema carries a widget field only when the requesting administrator may run its action. The field's `context` applies to the action route as it does on save: a request without `record_id` is the create form's and one with it the update form's, and a widget its context hides on that form answers 404. Besides `record_id` and `values`, the request may carry `payload`, the widget's own JSON value (at most 64 KiB, a 422 above that; refused on toolbar and record routes), which reaches the action unfiltered as the `Payload` field of `pact.AdminActionInput`; the action may answer with `Data` on `pact.AdminActionResult`, written to the response as `data` exactly as it encodes (any JSON up to 256 KiB encoded, a 500 above that or when it cannot be encoded), not subject to the fill allowlist and omitted when nil. The element asks for its action with a `summer-action` event whose optional `detail.payload` becomes the request `payload`, and receives the answer as a `data` attribute plus a `summer-result` event with `{data, fill, message}`. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot.
- Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file.
- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot.
- Bulk actions: `bulkActions` in `config_list.yaml` lists names the controller registers through `pact.HasAdminBulkActions`; it needs `showCheckboxes: true`. Bulk actions have their own namespace (`create` and `delete` are reserved there too), and each needs a label. The posted ids are resolved and row-locked through `pact.ListExtendQuery` in one transaction and the action receives the loaded records, never ids: a selection that matches nothing answers `affected: 0` without running the action, and a partial match answers 409 and rolls back. The list schema's `bulkActions` carries the built-in `delete` and only the declared actions the requesting administrator may run, with localized `label` and `confirm`; an unknown or duplicate name fails boot. Each run is logged with the controller, action, administrator and affected count.

View File

@@ -1,16 +1,60 @@
// A plain custom element: one light-DOM button that asks the admin SPA to
// run the field's action. It makes no request and reads no cookie or
// storage; the SPA owns HTTP.
// A plain custom element that demonstrates a reorder widget: one light-DOM
// button and an ordered list. The SPA owns HTTP; this element only speaks the
// summer-action / summer-result events. It makes no request and reads no
// cookie or storage. A click reverses the current items (the simplest stand-in
// for a drag reorder) and sends the new id order as the action's payload; the
// returned data (its items array) arrives both as the `data` attribute and as
// the summer-result event's detail.data, and is rendered with textContent.
class AcmeDemoLookup extends HTMLElement {
static get observedAttributes() {
return ['data']
}
constructor() {
super()
this.items = []
this.list = null
this.addEventListener('summer-result', (event) => this.receive(event.detail && event.detail.data))
}
connectedCallback() {
if (this.firstChild) return
const button = document.createElement('button')
button.type = 'button'
button.textContent = this.getAttribute('label') || ''
button.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('summer-action', { bubbles: true, composed: true }))
this.items = this.items.slice().reverse()
this.render()
const payload = { order: this.items.map((item) => item.id) }
this.dispatchEvent(new CustomEvent('summer-action', { bubbles: true, composed: true, detail: { payload } }))
})
this.append(button)
this.list = document.createElement('ol')
this.append(button, this.list)
this.render()
}
attributeChangedCallback(name, _old, value) {
if (name !== 'data') return
try {
this.receive(value === null ? null : JSON.parse(value))
} catch {
this.receive(null)
}
}
receive(data) {
this.items = data && Array.isArray(data.items) ? data.items.filter((item) => item && typeof item === 'object') : []
this.render()
}
render() {
if (!this.list) return
this.list.textContent = ''
for (const item of this.items) {
const li = document.createElement('li')
li.textContent = String(item.title == null ? item.id : item.title)
this.list.append(li)
}
}
}
if (!customElements.get('acme-demo-lookup')) {