- 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
80 lines
6.6 KiB
Markdown
80 lines
6.6 KiB
Markdown
---
|
|
title: Partials and widgets
|
|
description: Extend admin screens with server-rendered partials, plugin JavaScript and CSS, form widgets backed by server actions, and custom toolbar buttons.
|
|
section: backend
|
|
order: 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](../../modules/cabana/README.md) 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>` in `config_list.yaml`, a strip above the list;
|
|
- a `type: partial` field with `path: <name>` in `fields.yaml`;
|
|
- `preview.headerPartial: <name>` in `config_form.yaml`, a status hint above the fields of the [preview screen](forms.md#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:
|
|
|
|
```html src=modules/cabana/testdata/extension/controllers/gadgets/_stats.htm
|
|
<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:
|
|
|
|
```html src=modules/cabana/testdata/roster/controllers/people/_status.htm
|
|
{{- 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:
|
|
|
|
```yaml
|
|
lookup:
|
|
label: acme.blog::lang.posts.lookup
|
|
type: widget
|
|
widget: acme-blog-lookup
|
|
action: lookup
|
|
fill: [title, slug]
|
|
```
|
|
|
|
- `widget` is the custom element's tag, which must start with the plugin's `{vendor}-{plugin}-` prefix. A plugin script declared through `pact.AdminClientAssets` defines the element.
|
|
- `action` names an action the controller registers through `pact.HasAdminActions`.
|
|
- `fill` lists 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.
|