feat(12.1-02): read-only preview screen with a status hint and record actions

- config_form.yaml preview block (optional headerPartial), reported in the form schema as preview
- fields with context: preview show only on the preview screen and are never written
- form messages preview and edit; recordActions without a preview block stops boot
- SPA route {id}/preview, PreviewView and PreviewField, record actions in the footer
- mapWinterUrl maps preview/:id; the update form returns to the preview
- summer-callout partial style classes for status hints
- README, docs, OpenAPI document, TS types and the embedded build updated
This commit is contained in:
Jakub Zych
2026-10-05 00:10:48 +02:00
parent 1c99de5013
commit a65c670574
50 changed files with 1441 additions and 66 deletions

View File

@@ -381,7 +381,7 @@ func (PeopleController) AdminRecordActions() []pact.AdminRecordAction {
list: ~/plugins/acme/roster/models/person/columns.yaml
modelClass: Person
title: acme.roster::lang.people.title
recordUrl: acme/roster/people/update/:id
recordUrl: acme/roster/people/preview/:id
recordsPerPage: 20
showCheckboxes: true
toolbar:
@@ -414,12 +414,17 @@ form: ~/plugins/acme/roster/models/person/fields.yaml
modelClass: Person
defaultRedirect: acme/roster/people
create:
redirect: acme/roster/people/update/:id
redirect: acme/roster/people/preview/:id
redirectClose: acme/roster/people
update:
redirect: acme/roster/people
redirectClose: acme/roster/people
redirectClose: acme/roster/people/preview/:id
preview:
headerPartial: status
recordActions: [activate, reinstate]
messages:
preview: acme.roster::lang.people.preview
edit: acme.roster::lang.people.edit
```
Each `pact.AdminRecordAction` has a `Name`, a `Label`, an optional `Confirm` text, its own `Permissions`, an optional `Applies` function and `Run`:

View File

@@ -31,6 +31,8 @@ A list whose schema carries declared bulk actions shows a "Bulk actions" menu af
A list row that carries a state (deleted, negative or disabled) shows a text badge for each state after its first cell, together with a text style; the row background is never changed. See [Row state](lists-and-filters.md#row-state).
A form whose schema carries `preview` has a read-only record screen at `<controller>/<id>/preview`, next to the list (`<controller>`), the create form (`<controller>/create`) and the update form (`<controller>/<id>`). It renders the fields whose context allows `preview` as text, the status hint partial above them and, in its footer, the record actions the record response offers before the one edit button. Opening that route for a form without a preview goes to the update form. See [Preview screen](forms.md#preview-screen).
## Types from OpenAPI
The admin API is described by swag annotations in cabana. `scripts/check-admin-openapi.sh` generates the OpenAPI document (`admin/openapi/admin.json`) from them and the SPA's TypeScript types (`admin/src/api/schema.d.ts`) from the document, so the SPA's API client is checked against the server's shapes at compile time. `--check` fails when either committed file is out of date:

View File

@@ -27,7 +27,7 @@ update:
`modelClass` must equal the controller's `pact.AdminController.ModelName`. The `~/plugins/<vendor>/<plugin>/` prefix points into the plugin's own embedded tree.
An optional `recordActions` key lists the record actions the form offers, by the names the controller registers through `pact.HasAdminRecordActions`. The show response of a record then carries `meta.actions`: the declared actions the administrator may run and that apply to the record in its current state. See [Record actions](admin-controllers.md#record-actions).
An optional `recordActions` key lists the record actions the form offers, by the names the controller registers through `pact.HasAdminRecordActions`. It needs a `preview` block, because record actions are offered on the [preview screen](#preview-screen). The show response of a record then carries `meta.actions`: the declared actions the administrator may run and that apply to the record in its current state. See [Record actions](admin-controllers.md#record-actions).
## fields.yaml
@@ -102,7 +102,7 @@ The WinterCMS widgets that are not in this list (the rich editor, the markdown e
A field takes `label`, `comment`, `type`, `required`, `default`, `tab`, `span` (`left`, `right`, `full`, `auto`, `row`), `size` (`tiny`, `small`, `large`, `huge`, `giant`), `context`, `attributes` (scalar HTML attributes for the input), `options` and `emptyOption`, plus `nameFrom` and `relation` on relation fields. WinterCMS keys outside this set, such as `readOnly`, `disabled`, `trigger` or `dependsOn`, are refused.
`context: update` shows a field only on the update form, and `context: create` only on the create form; a list of contexts is also accepted. The context is enforced on the server too: a field that is hidden on a form is never written by that form's save, whatever the request body holds.
`context: update` shows a field only on the update form, and `context: create` only on the create form; a list of contexts is also accepted, and `context: preview` shows a field only on the [preview screen](#preview-screen). The context is enforced on the server too: a field that is hidden on a form is never written by that form's save, whatever the request body holds.
## Date pickers
@@ -185,6 +185,37 @@ Removing a file is deferred the same way: the file disappears from the form at o
The limits are enforced on the server: the upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB (413 `payload_too_large` past it), and a file that is too large, of a type the field does not allow, or not a valid image in image mode is a 422 on the field.
## Preview screen
A form may have a read-only screen in front of its update form, as WinterCMS's preview context: the list opens the record there, the administrator reads it, runs a record action or presses the edit button. A `preview` block in `config_form.yaml` turns it on:
```yaml src=modules/cabana/testdata/roster/controllers/people/config_form.yaml
name: acme.roster::lang.people.form
form: ~/plugins/acme/roster/models/person/fields.yaml
modelClass: Person
defaultRedirect: acme/roster/people
create:
redirect: acme/roster/people/preview/:id
redirectClose: acme/roster/people
update:
redirect: acme/roster/people
redirectClose: acme/roster/people/preview/:id
preview:
headerPartial: status
recordActions: [activate, reinstate]
messages:
preview: acme.roster::lang.people.preview
edit: acme.roster::lang.people.edit
```
- `preview` is a mapping. `headerPartial: <name>` names a controller partial rendered above the fields as a status hint (see [Partials and widgets](partials-and-widgets.md#status-hints)); write `preview: {}` for a screen without a hint. An empty `preview:` key, any other key inside it, or a `recordActions` list without a `preview` block stops the start-up.
- The form schema reports the block as `preview` (a `cabana.FormPreview`), and the SPA then serves the record at `<controller>/<id>/preview`. A form without the block has no such screen: the route goes to the update form.
- The screen shows every field whose `context` allows `preview`. A field without a `context` shows on every screen; `context: preview` shows a field only here. Values are rendered as text; `widget` and `relation-manager` fields are not shown.
- A field with `context: preview` is never written. A save asks only for the `create` or the `update` context, so such a field in a request body is dropped like any field hidden on that form.
- `messages.preview` is the screen's subtitle and `messages.edit` the label of its edit button; both default to framework texts (`cabana.FormMessages`).
- `recordUrl` in `config_list.yaml` and the form's `create.redirect`, `update.redirectClose` and the other redirects may point at the screen as `<vendor>/<plugin>/<controller>/preview/:id`. On the update form of a record with a preview, the back arrow and Cancel return to the preview; after a delete the form goes to the list.
- The footer holds the record actions the show response offers in `meta.actions`, then the edit button. After an action the record and the status hint are loaded again in place.
## What a save may write
The form's writable fields are bound to model columns at boot. A save passes only those fields that are also in the model's `Fillable` list, drops unknown keys, case variants and nested objects, and fills the model with `lagoon.Fill` (see [Models](../database/models.md)). The model's validation rules (a `Rules` method returning `lagoon.Validate` rule strings) and the form's `required` flags are checked in the save's transaction, and a failure is a 422 with messages per field. A value that does not fit its column is also a 422 on that field.

View File

@@ -13,9 +13,10 @@ WinterCMS controllers extend their screens with partials, `addJs` and `addCss`,
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`.
- 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).
Both 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.
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:
@@ -31,6 +32,21 @@ The view model must be a struct built for the template. cabana refuses a view mo
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.