- pact.AdminActionInput.Payload (json.RawMessage) carries the widget's own JSON value untouched; pact.AdminActionResult.Data is passed through as data - cabana decodes payload with a 64 KiB cap (422 on body), refuses it on the toolbar and record routes, and embeds Data once encoded with a 256 KiB cap (opaque 500 when larger or unencodable); fill stays filtered - root .swaggo overrides json.RawMessage so swag keeps record_id and values; admin.json and schema.d.ts regenerated (payload?: unknown, data?: unknown) - TestWidgetPayloadAndData covers pass-through, cap, refusal and data 500 - cabana and pact READMEs, partials-and-widgets and admin-spa docs updated
6.6 KiB
title, description, section, order
| title | description | section | order |
|---|---|---|---|
| Partials and widgets | Extend admin screens with server-rendered partials, plugin JavaScript and CSS, form widgets backed by server actions, and custom toolbar buttons. | backend | 70 |
Partials and widgets
WinterCMS controllers extend their screens with partials, addJs and addCss, custom form widgets and toolbar buttons that call AJAX handlers. The SummerCMS admin is a single-page app, so cabana keeps these extension points in a form the SPA can render safely: partials arrive as an allowlisted node tree, plugin scripts are declared files, and every button or widget runs a server action through a cabana-owned route.
Server-rendered partials
Two places accept a partial:
headerPartial: <name>inconfig_list.yaml, a strip above the list;- a
type: partialfield withpath: <name>infields.yaml; preview.headerPartial: <name>inconfig_form.yaml, a status hint above the fields of the preview screen.
All three render {ConfigDir}/_<name>.htm with Go's html/template. WinterCMS $/ and ~/ partial paths are not supported. The template's data is .Data, the value the controller's pact.AdminPartialData returns for that partial name; for a form partial on an existing record, cabana passes the record it loaded through the controller's pact.FormExtendQuery scope. trans "<key>" translates a phrase key in the request locale.
A statistics strip above a list, using the SPA's partial style classes:
<dl class="summer-stats">
{{- range .Data.Items -}}
<div class="summer-stat"><dt class="summer-stat__label">{{ trans .Label }}</dt><dd class="summer-stat__value">{{ .Count }}</dd></div>
{{- end -}}
</dl>
The view model must be a struct built for the template. cabana refuses a view model that holds the controller's model or any other GORM model, anywhere inside it, and refuses pre-escaped html/template content types, so every record value stays escaped.
The rendered HTML is parsed and walked through an allowlist before it reaches the SPA: script, style, iframe, form and similar elements are removed with their content, unknown elements are unwrapped, id, style and event handler attributes are dropped, and links and images must be same-origin paths. Output is capped at 64 KiB, 2000 nodes and a depth of 32. The cabana README lists the allowed elements, attributes and style classes.
Status hints
The preview screen's header partial always belongs to one record, so cabana renders it like a form partial: with the record it loaded through the controller's pact.FormExtendQuery scope. A typical hint tells the administrator why a record needs attention, and the record action that resolves it is a button in the screen's footer. The SPA's callout classes give it the native look:
{{- if .Data.Title -}}
<div class="summer-callout summer-callout--{{ .Data.Tone }}" role="status">
<p class="summer-callout__title">{{ trans .Data.Title }}</p>
<p class="summer-callout__text">{{ trans .Data.Text }}</p>
</div>
{{- end -}}
summer-callout is the block, summer-callout--warning and summer-callout--danger set its tone, and summer-callout__title and summer-callout__text are its two lines. role="status" is on the attribute allowlist and makes a hint that appears after an action polite to screen readers. A callout holds no icon, link or button. A template that renders nothing (here: when the view model has no title) leaves no gap on the screen, and the hint is fetched again after every record action.
Plugin JavaScript and CSS
A controller that implements pact.AdminClientAssets names .js, .mjs and .css files under its plugin's assets/ directory, the Go form of addJs and addCss. They are read from the embedded tree at start-up (a missing file stops it) and served from <prefix>/assets/{vendor}/{plugin}/... with a content hash in the URL, the admin Content-Security-Policy (script-src 'self') and nosniff. Only declared files are reachable; the YAML and templates never are.
Plugin CSS may use only the SPA's public CSS variables (--c-bg, --c-surface, --c-text, --c-primary and the others the cabana README lists), which switch with dark mode. Do not hardcode colours and do not rely on the SPA's utility classes.
Form widgets
A type: widget field puts a plugin custom element in the form and connects it to a server action:
lookup:
label: acme.blog::lang.posts.lookup
type: widget
widget: acme-blog-lookup
action: lookup
fill: [title, slug]
widgetis the custom element's tag, which must start with the plugin's{vendor}-{plugin}-prefix. A plugin script declared throughpact.AdminClientAssetsdefines the element.actionnames an action the controller registers throughpact.HasAdminActions.filllists the writable scalar fields of the same form the action may write back.
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.
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.
An unknown action name, a widget tag outside the plugin's prefix or a fill key that is not a writable scalar field stops the start-up.