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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,8 +6,8 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-8CEYdgqp.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BxJxH4xB.css">
<script type="module" crossorigin src="./assets/index-DEJgWNHv.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-57SuA8gQ.css">
</head>
<body>
<div id="app"></div>

View File

@@ -19,7 +19,8 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- 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.
- Row state: a controller implementing `pact.ListRowStates` is called once per list page with the page's records and the list's database handle. The list response carries `meta.row_states`, keyed by row id, with values from the fixed set `deleted`, `negative`, `disabled` in that order; a value outside the set is dropped and logged, rows without a state are left out, and a controller without the hook sends no `row_states` key. The badge texts are the list messages `rowStateDeleted`, `rowStateNegative` and `rowStateDisabled`, defaulting to `backend::lang.messages.list.row_state_*`. A soft-deleted record that the controller's `pact.ListExtendQuery` and `pact.FormExtendQuery` include can be shown, updated (it stays soft-deleted), targeted by bulk and record actions and removed for good by the controller's `pact.FormAfterDelete`.
- Record actions: `recordActions` in `config_form.yaml` lists names the controller registers through `pact.HasAdminRecordActions`, a third action namespace with the same reserved names. The show response's `meta.actions` (`cabana.RecordAction` entries with localized `label` and `confirm`) carries only the declared actions the requesting administrator may run and whose `Applies` reports true for the record; the key is absent when none is offered, and create and update responses never carry it. The action route loads the record through `pact.FormExtendQuery` with a row lock in one transaction (one 404 for a missing and an out-of-scope id), checks `Applies` again (409 when it reports false) and then runs the action. An unknown or duplicate name, or an action without a label, fails boot. Each run is logged with the controller, action, administrator and record id.
- Record actions: `recordActions` in `config_form.yaml` lists names the controller registers through `pact.HasAdminRecordActions`, a third action namespace with the same reserved names. It needs the form's `preview` block: record actions are offered on the preview screen, and a form that declares them without one fails boot. The show response's `meta.actions` (`cabana.RecordAction` entries with localized `label` and `confirm`) carries only the declared actions the requesting administrator may run and whose `Applies` reports true for the record; the key is absent when none is offered, and create and update responses never carry it. The action route loads the record through `pact.FormExtendQuery` with a row lock in one transaction (one 404 for a missing and an out-of-scope id), checks `Applies` again (409 when it reports false) and then runs the action. An unknown or duplicate name, or an action without a label, fails boot. Each run is logged with the controller, action, administrator and record id.
- Preview screen: a `preview` mapping in `config_form.yaml` (`preview: {}`, or with `headerPartial: <name>` for a status hint) gives the form a read-only record screen in the admin SPA. The form schema reports it as `preview` (`cabana.FormPreview`), fields with `context: preview` are shown only there and are never written by a save, `messages.preview` and `messages.edit` name the screen's subtitle and edit button, and `recordUrl` and the form redirects may point at it as `.../preview/:id`. An empty `preview:` key or an unknown key inside it fails boot.
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
- File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation.
@@ -78,7 +79,7 @@ Every path under the prefix that no API route matches is served by the admin SPA
### Partials
A partial is an `html/template` file next to the controller's YAML: `headerPartial: stats` and `path: stats` both resolve to `{ConfigDir}/_stats.htm`; Winter's `$/` and `~/` paths are not supported. The template's root is `.Data`, the value the controller's `PartialData(ctx, name, record)` returns, and `trans "<key>"` translates a phrase key in the request locale. `record` is nil for a header partial and for a form partial on the create form; with `?id=` it is the record cabana loaded through the controller's `pact.FormExtendQuery` scope, so a plugin never looks a record up by a request id itself.
A partial is an `html/template` file next to the controller's YAML: `headerPartial: stats` (in `config_list.yaml`, or under `preview:` in `config_form.yaml`) and `path: stats` all resolve to `{ConfigDir}/_stats.htm`; Winter's `$/` and `~/` paths are not supported. The template's root is `.Data`, the value the controller's `PartialData(ctx, name, record)` returns, and `trans "<key>"` translates a phrase key in the request locale. `record` is nil for a list header partial and for a form partial on the create form; a preview header partial is a form partial and always gets its record; with `?id=` it is the record cabana loaded through the controller's `pact.FormExtendQuery` scope, so a plugin never looks a record up by a request id itself.
The view model must be a curated struct built for the template. cabana walks its type through pointers, slices, arrays, maps, struct fields and the results of its exported methods (templates call methods), and the values held in interface-typed members such as `map[string]any`. It refuses the controller's own model type, any other GORM model (a struct with a `TableName` method, a `gorm` struct tag, `gorm.Model` or `gorm.DeletedAt`) and `html/template`'s pre-escaped content types anywhere in that structure, so escaping stays on for every record value. A method that returns an interface is not called, so its run-time result is not checked. The rendered output is parsed with `golang.org/x/net/html` and walked through an allowlist:
@@ -105,9 +106,22 @@ The admin SPA ships a small set of stable CSS classes that partial templates may
| `summer-stat` | One item of the strip: the value is shown above the label while `<dt>` stays first in the DOM. |
| `summer-stat__label` | The item label: 13px, muted, wraps. |
| `summer-stat__value` | The item value: 20px, weight 600, tabular numbers. |
| `summer-callout` | A status hint above a preview screen: a borderless block with a 12px radius and 14px 18px padding; long text wraps. |
| `summer-callout--warning`, `summer-callout--danger` | The callout's tone: the selection tint with body text, or the soft danger background with danger text. |
| `summer-callout__title` | The callout's first line, weight 600. |
| `summer-callout__text` | The callout's second line, weight 400. |
Use `<dl class="summer-stats">` with one `<div class="summer-stat">` per item holding a `<dt class="summer-stat__label">` and a `<dd class="summer-stat__value">`, as in the example above.
A status hint (`preview.headerPartial`) uses the callout classes. `role="status"` is on the attribute allowlist; a callout holds no icon, link or button:
```html
<div class="summer-callout summer-callout--warning" role="status">
<p class="summer-callout__title">{{ trans .Data.Title }}</p>
<p class="summer-callout__text">{{ trans .Data.Text }}</p>
</div>
```
Plugin CSS (declared through `pact.AdminClientAssets`) and any widget shadow DOM may read only these public variables. They inherit into shadow roots and switch automatically in dark mode: `--c-bg`, `--c-surface`, `--c-subtle`, `--c-border`, `--c-border-strong`, `--c-text`, `--c-muted`, `--c-placeholder`, `--c-primary`, `--c-on-primary`, `--c-danger`, `--c-danger-soft`, `--c-hover`, `--c-sel`, `--c-skel`, `--c-ring`. Plugins must not hardcode hex colours and must not rely on Tailwind utility classes: the SPA build purges every utility it does not use itself. A controller's stylesheets are disabled while another controller's list or form is open.
### Controller assets
@@ -176,6 +190,7 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.BulkAction` | One entry of a list schema's `bulkActions`: name, localized label and optional confirm text. |
| `cabana.BulkActionResult` | Answer of the bulk action route: the localized `message` and the `affected` count. |
| `cabana.AdminBulkAction` | Swag annotation of the bulk action route. |
| `cabana.FormPreview` | The `preview` object of a form schema: present when the form has a preview screen; `headerPartial` names its status hint partial. |
| `cabana.RecordAction` | One entry of a record response's `meta.actions`: name, localized label and optional confirm text. |
| `cabana.CRUDService.RecordAction` | Runs a declared record action on one scoped, locked record. |
| `cabana.AdminRecordAction` | Swag annotation of the record action route. |

View File

@@ -262,6 +262,7 @@ func AdminListSchema() {}
// AdminFormSchema documents the form schema route.
//
// @Summary Admin form schema
// @Description The form of a controller, localized. `preview` is present when config_form.yaml declares a preview block: the form then has a read-only preview screen, which shows the fields whose context allows preview, the record actions and, when preview.headerPartial is set, that partial as a status hint.
// @Tags admin
// @Produce json
// @Security BackendBearer

View File

@@ -92,6 +92,10 @@ func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error
if formFile == "" {
formFile = "config_form.yaml"
}
// Record actions are offered on the preview screen only (D-10, D-11).
if len(cc.Form.recordActions) > 0 && cc.Form.preview == nil {
return bootErr(pluginID, id, formFile, fmt.Errorf("recordActions needs a preview block (record actions are offered on the preview screen)"))
}
for _, name := range cc.Form.recordActions {
action, ok := recordActions[name]
if !ok {
@@ -144,8 +148,10 @@ func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error
}
// compilePartials reads and parses every partial the controller declares:
// config_list.yaml headerPartial and each `type: partial` field's path, both
// resolving to {ConfigDir}/_{name}.htm. A missing or unparsable template, or a
// config_list.yaml headerPartial, config_form.yaml preview.headerPartial and
// each `type: partial` field's path, all resolving to {ConfigDir}/_{name}.htm.
// The preview header partial is a form partial: the partial route renders it
// with a record id, loaded through the form scope. A missing or unparsable template, or a
// controller without pact.AdminPartialData, fails boot (D-11).
func compilePartials(pluginID string, cc *CompiledController, fsys fs.FS) error {
id := cc.Controller.ID()
@@ -155,6 +161,10 @@ func compilePartials(pluginID string, cc *CompiledController, fsys fs.FS) error
names = append(names, cc.List.HeaderPartial)
}
if cc.Form != nil {
if cc.Form.preview != nil && cc.Form.preview.HeaderPartial != "" {
names = append(names, cc.Form.preview.HeaderPartial)
formNames[cc.Form.preview.HeaderPartial] = true
}
for _, field := range cc.Form.Fields {
if field.Type == "partial" {
names = append(names, field.Path)

View File

@@ -17,6 +17,7 @@ import (
"git.golem15.com/golem15/summercms/modules/towel"
"github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/ast"
"github.com/goccy/go-yaml/parser"
)
var (
@@ -55,6 +56,68 @@ type formConfigDocument struct {
Update *formRedirects `yaml:"update"`
Messages *formMessageKeys `yaml:"messages"`
RecordActions recordActionList `yaml:"recordActions"`
Preview previewDocument `yaml:"preview"`
}
// previewDocument is the config_form.yaml preview block (D-11): a mapping
// that enables the read-only preview screen, with the optional headerPartial
// naming the status hint partial. The key is never half-set: an empty (null)
// value is refused, and `preview: {}` enables the screen without a hint.
type previewDocument struct {
set bool
headerPartial string
}
// previewShapeHint is the boot error of a preview key that is not a mapping.
const previewShapeHint = "preview must be a mapping; write preview: {} to enable the preview screen without a header partial"
// topLevelKey reports whether the YAML document's root mapping has key.
func topLevelKey(raw []byte, key string) bool {
file, err := parser.ParseBytes(raw, 0)
if err != nil {
return false
}
for _, doc := range file.Docs {
if doc == nil {
continue
}
var entries []*ast.MappingValueNode
switch body := unwrapNode(doc.Body).(type) {
case *ast.MappingNode:
entries = body.Values
case *ast.MappingValueNode:
entries = []*ast.MappingValueNode{body}
}
for _, entry := range entries {
if name, err := nodeString(unwrapNode(entry.Key)); err == nil && name == key {
return true
}
}
}
return false
}
func (p *previewDocument) UnmarshalYAML(node ast.Node) error {
mapping, ok := unwrapNode(node).(*ast.MappingNode)
if !ok {
return fmt.Errorf("%s", previewShapeHint)
}
p.set = true
for _, entry := range mapping.Values {
key, err := nodeString(unwrapNode(entry.Key))
if err != nil {
return fmt.Errorf("preview: %w", err)
}
if key != "headerPartial" {
return fmt.Errorf("preview: unknown field %s", key)
}
name, err := nodeString(unwrapNode(entry.Value))
if err != nil || !identifier(name) {
return fmt.Errorf("preview: headerPartial %q: %s", nodeText(entry.Value), partialPathHint)
}
p.headerPartial = name
}
return nil
}
// recordActionList is the declarative recordActions list (D-10): the names of
@@ -106,6 +169,11 @@ func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSc
if err := decodeStrict(raw, &doc); err != nil {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
}
// The decoder does not hand an empty (null) value to previewDocument, so
// a half-set `preview:` key is refused here.
if !doc.Preview.set && topLevelKey(raw, "preview") {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("%s", previewShapeHint))
}
if doc.ModelClass != ctl.ModelName() {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("modelClass %q does not match %q", doc.ModelClass, ctl.ModelName()))
}
@@ -140,6 +208,9 @@ func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSc
recordActions: doc.RecordActions.items,
}
if doc.Preview.set {
schema.preview = &FormPreview{HeaderPartial: doc.Preview.headerPartial}
}
if doc.Messages != nil {
schema.messageKeys = *doc.Messages
}
@@ -186,10 +257,16 @@ func (s *FormSchema) Localize(ctx context.Context, tr *phrasebook.Translator, pr
if fields == nil {
fields = []FormField{}
}
var preview *FormPreview
if s.preview != nil {
copied := *s.preview
preview = &copied
}
return &FormView{
Name: translateKey(ctx, tr, s.Name),
ModelClass: s.ModelClass,
Fields: fields,
Preview: preview,
Messages: localizeMessages[formMessageKeys, FormMessages](ctx, tr, s.formMessageKeySet()),
Redirects: s.redirects,
Meta: FormMeta{Locale: locale},

View File

@@ -68,6 +68,9 @@ type formMessageKeys struct {
Saved string `yaml:"saved"`
DeleteConfirm string `yaml:"deleteConfirm"`
Deleted string `yaml:"deleted"`
// The preview screen's subtitle and its edit button (D-11).
Preview string `yaml:"preview"`
Edit string `yaml:"edit"`
}
// FormMessages is a form's copy, every key resolved.
@@ -77,6 +80,9 @@ type FormMessages struct {
Saved MessageForms `json:"saved"`
DeleteConfirm MessageForms `json:"deleteConfirm"`
Deleted MessageForms `json:"deleted"`
// The preview screen's subtitle and its edit button (D-11).
Preview MessageForms `json:"preview"`
Edit MessageForms `json:"edit"`
}
// relationMessageKeys is one config_relation.yaml relation's messages block.
@@ -162,6 +168,8 @@ var (
Saved: "backend::lang.messages.form.saved",
DeleteConfirm: "backend::lang.messages.form.delete_confirm",
Deleted: "backend::lang.messages.form.deleted",
Preview: "backend::lang.messages.form.preview",
Edit: "backend::lang.messages.form.edit",
}
relationMessageDefaults = relationMessageKeys{
Link: "backend::lang.messages.relation.link",

View File

@@ -916,6 +916,7 @@ update:
redirect: acme/conform/gadgets
redirectClose: acme/conform/gadgets
recordActions: [ping]
preview: {}
`),
"controllers/gadgets/config_relation.yaml": file(`members:
label: Members

View File

@@ -15,10 +15,7 @@ import (
"gorm.io/gorm"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/party"
)
const rosterPeople = "/acme/roster/people"
@@ -437,20 +434,9 @@ func TestFormSchemaRecordActionsBoot(t *testing.T) {
// A name the controller does not register is refused when the controller
// is activated, where the form meets its registered actions.
t.Run("unregistered", func(t *testing.T) {
fsys := fstest.MapFS{}
for _, name := range []string{"controllers/people/config_list.yaml", "models/person/columns.yaml", "models/person/fields.yaml"} {
data, err := os.ReadFile(filepath.Join(rosterDir, name))
if err != nil {
t.Fatal(err)
}
fsys[name] = &fstest.MapFile{Data: data}
}
fsys["controllers/people/config_form.yaml"] = &fstest.MapFile{Data: []byte(head + "recordActions: [activate, promote]\n")}
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
if err != nil {
t.Fatal(err)
}
_, err = cabana.Activate(backpack.New(cfg), []party.Plugin{rosterPlugin{spy: &rosterSpy{}, fsys: fsys}})
err := rosterBoot(t, rosterTree(t, map[string]string{
"controllers/people/config_form.yaml": head + "preview: {}\nrecordActions: [activate, promote]\n",
}))
const want = "recordActions: unsupported action promote (want a record action the controller registers)"
if err == nil || !strings.Contains(err.Error(), want) || !strings.Contains(err.Error(), "acme.roster.people") || !strings.Contains(err.Error(), "controllers/people/config_form.yaml") {
t.Fatalf("error = %v, want %q", err, want)

View File

@@ -31,12 +31,14 @@ const rosterDir = "testdata/roster"
// rosterPerson is the fixture model: a person of one tenant who can be
// active, banned and soft-deleted.
type rosterPerson struct {
ID uint `gorm:"column:id;primaryKey"`
Tenant string `gorm:"column:tenant"`
Name string `gorm:"column:name"`
Email string `gorm:"column:email"`
Active bool `gorm:"column:active"`
Banned bool `gorm:"column:banned"`
ID uint `gorm:"column:id;primaryKey"`
Tenant string `gorm:"column:tenant"`
Name string `gorm:"column:name"`
Email string `gorm:"column:email"`
Active bool `gorm:"column:active"`
Banned bool `gorm:"column:banned"`
// JoinedIP is shown on the preview screen only (context: preview).
JoinedIP *string `gorm:"column:joined_ip"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
}
@@ -178,6 +180,35 @@ func (c rosterController) ListRowStates(ctx context.Context, db *gorm.DB, record
return out, nil
}
// rosterStatus is the curated view model of the preview status hint: the
// callout tone and the phrase keys of its title and text. It is empty when
// no state applies, and the template then renders nothing.
type rosterStatus struct {
Tone, Title, Text string
}
// PartialData serves the preview header partial `status`: one callout by
// precedence banned, archived, not active.
func (rosterController) PartialData(_ context.Context, name string, record any) (any, error) {
if name != "status" {
return nil, fmt.Errorf("unknown partial %s", name)
}
person, ok := record.(*rosterPerson)
if !ok || person == nil {
return rosterStatus{}, nil
}
const keys = "acme.roster::lang.people."
switch {
case person.Banned:
return rosterStatus{Tone: "danger", Title: keys + "banned_title", Text: keys + "banned_text"}, nil
case person.DeletedAt.Valid:
return rosterStatus{Tone: "danger", Title: keys + "deleted_title", Text: keys + "deleted_text"}, nil
case !person.Active:
return rosterStatus{Tone: "warning", Title: keys + "inactive_title", Text: keys + "inactive_text"}, nil
}
return rosterStatus{}, nil
}
// rosterLocked is the sentinel name of a person the roster's actions refuse.
const rosterLocked = "Locked"
@@ -381,6 +412,46 @@ func newRosterEnv(t *testing.T) (*rosterEnv, *gorm.DB) {
return env, gdb
}
// rosterTree is the roster fixture tree as an in-memory file system with the
// given files replaced or added (boot-error tests).
func rosterTree(t *testing.T, replace map[string]string) fstest.MapFS {
t.Helper()
out := fstest.MapFS{}
err := filepath.WalkDir(rosterDir, func(name string, entry fs.DirEntry, err error) error {
if err != nil || entry.IsDir() {
return err
}
data, err := os.ReadFile(name)
if err != nil {
return err
}
rel, err := filepath.Rel(rosterDir, name)
if err != nil {
return err
}
out[filepath.ToSlash(rel)] = &fstest.MapFile{Data: data}
return nil
})
if err != nil {
t.Fatal(err)
}
for name, body := range replace {
out[name] = &fstest.MapFile{Data: []byte(body)}
}
return out
}
// rosterBoot activates the roster plugin over fsys and returns the boot error.
func rosterBoot(t *testing.T, fsys fs.FS) error {
t.Helper()
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
if err != nil {
t.Fatal(err)
}
_, err = cabana.Activate(backpack.New(cfg), []party.Plugin{rosterPlugin{spy: &rosterSpy{}, fsys: fsys}})
return err
}
// rosterInsert stores one person and returns its id.
func rosterInsert(t *testing.T, gdb *gorm.DB, person rosterPerson) uint {
t.Helper()

View File

@@ -0,0 +1,138 @@
package cabana_test
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/cabana"
)
// rosterFormHead is the smallest config_form.yaml of the roster fixture.
const rosterFormHead = "form: ~/plugins/acme/roster/models/person/fields.yaml\nmodelClass: Person\n"
// rosterFormSchema fetches the people form schema as auth sees it.
func rosterFormSchema(t *testing.T, env *rosterEnv, auth string) (cabana.FormView, string) {
t.Helper()
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/schema/form", "", auth)
var body cabana.Envelope[cabana.FormView]
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("form schema: %v\n%s", err, rec.Body.String())
}
return body.Data, rec.Body.String()
}
// rosterRecord decodes a record response.
func rosterRecord(t *testing.T, raw []byte) cabana.RecordEnvelope {
t.Helper()
var body cabana.RecordEnvelope
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("record body %s: %v", raw, err)
}
return body
}
// rosterBootFails asserts that the roster plugin with the replaced files does
// not boot and that the error carries every wanted part.
func rosterBootFails(t *testing.T, replace map[string]string, want ...string) {
t.Helper()
err := rosterBoot(t, rosterTree(t, replace))
if err == nil {
t.Fatalf("the plugin booted, want an error naming %q", want)
}
for _, part := range want {
if !strings.Contains(err.Error(), part) {
t.Fatalf("error %q does not name %q", err, part)
}
}
}
// TestPreviewSmoke drives the preview context through the assembled router on
// PostgreSQL (D-11; T-12.1-14, T-12.1-15): the schema's preview block and
// messages, a preview-only field that is shown and never written, the status
// hint through the partial route with the form scope, and the boot rules.
func TestPreviewSmoke(t *testing.T) {
env, gdb := newRosterEnv(t)
ip := "203.0.113.7"
ada := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada", Email: "ada@example.test", Active: true, JoinedIP: &ip})
banned := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bea", Active: true, Banned: true})
foreign := rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Zed", Banned: true})
record := func(id uint) string { return fmt.Sprintf("%s/%d", rosterPeople, id) }
t.Run("schema reports the preview, its messages and the preview-only field", func(t *testing.T) {
view, raw := rosterFormSchema(t, env, "bearer")
if view.Preview == nil || view.Preview.HeaderPartial != "status" {
t.Fatalf("preview = %+v", view.Preview)
}
if !strings.Contains(raw, `"preview":{"headerPartial":"status"}`) {
t.Fatalf("preview block is not in the schema: %s", raw)
}
if view.Messages.Preview["other"] != "Person details" || view.Messages.Edit["other"] != "Edit person" {
t.Fatalf("messages = %+v", view.Messages)
}
if !strings.Contains(raw, `"name":"joined_ip","type":"text","label":"Joined from IP address","context":"preview"`) {
t.Fatalf("preview-only field is not in the schema: %s", raw)
}
if !strings.Contains(raw, `"redirectClose":"acme/roster/people/preview/:id"`) {
t.Fatalf("redirects do not point at the preview: %s", raw)
}
})
t.Run("a preview-only field is shown and never written", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodGet, record(ada), "", "bearer")
if got := rosterRecord(t, rec.Body.Bytes()).Data["joined_ip"]; got != ip {
t.Fatalf("show joined_ip = %v", got)
}
rec = env.expect(t, http.StatusOK, http.MethodPut, record(ada), `{"name":"Ada L","joined_ip":"198.51.100.1"}`, "bearer")
if got := rosterRecord(t, rec.Body.Bytes()).Data["joined_ip"]; got != ip {
t.Fatalf("update answered joined_ip = %v", got)
}
stored := rosterLoad(t, gdb, ada)
if stored.Name != "Ada L" || stored.JoinedIP == nil || *stored.JoinedIP != ip {
t.Fatalf("stored = %+v ip=%v", stored, stored.JoinedIP)
}
rec = env.expect(t, http.StatusCreated, http.MethodPost, rosterPeople, `{"name":"New","joined_ip":"198.51.100.2"}`, "bearer")
created := rosterRecord(t, rec.Body.Bytes())
id, _ := created.Data["id"].(float64)
if got := rosterLoad(t, gdb, uint(id)); got.JoinedIP != nil {
t.Fatalf("create wrote joined_ip = %q", *got.JoinedIP)
}
})
t.Run("the status hint renders through the partial route in the form scope", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/partials/status?id=%d", rosterPeople, banned), "", "bearer")
body := rec.Body.String()
for _, part := range []string{`"class":"summer-callout summer-callout--danger"`, `"role":"status"`, `"class":"summer-callout__title"`, "This person is banned", "A banned person cannot sign in until the ban is lifted."} {
if !strings.Contains(body, part) {
t.Fatalf("hint %s does not carry %s", body, part)
}
}
// No state applies: zero nodes, so the screen renders no hint.
rec = env.expect(t, http.StatusOK, http.MethodGet, fmt.Sprintf("%s/partials/status?id=%d", rosterPeople, ada), "", "bearer")
var view cabana.Envelope[cabana.PartialView]
if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil || len(view.Data.Nodes) != 0 {
t.Fatalf("hint for an active person = %s err=%v", rec.Body.String(), err)
}
// Another tenant's person is outside the form scope.
rec = env.expect(t, http.StatusNotFound, http.MethodGet, fmt.Sprintf("%s/partials/status?id=%d", rosterPeople, foreign), "", "bearer")
actErrorCode(t, rec.Body.Bytes(), "not_found")
})
t.Run("boot rules", func(t *testing.T) {
const form = "controllers/people/config_form.yaml"
rosterBootFails(t, map[string]string{form: rosterFormHead + "recordActions: [activate]\n"},
"recordActions needs a preview block (record actions are offered on the preview screen)", "acme.roster.people", form)
rosterBootFails(t, map[string]string{form: rosterFormHead + "preview:\n"},
"preview must be a mapping; write preview: {} to enable the preview screen without a header partial", form)
rosterBootFails(t, map[string]string{form: rosterFormHead + "preview: true\n"}, "preview must be a mapping")
rosterBootFails(t, map[string]string{form: rosterFormHead + "preview:\n toolbar: x\n"}, "preview: unknown field toolbar")
rosterBootFails(t, map[string]string{form: rosterFormHead + "preview:\n headerPartial: $/acme/status.htm\n"}, "headerPartial", "path must be a partial name")
rosterBootFails(t, map[string]string{form: rosterFormHead + "preview:\n headerPartial: missing\n"}, "partial missing", "controllers/people/_missing.htm")
// preview: {} enables the screen without a hint.
if err := rosterBoot(t, rosterTree(t, map[string]string{form: rosterFormHead + "preview: {}\nrecordActions: [activate]\n"})); err != nil {
t.Fatalf("preview: {} did not boot: %v", err)
}
})
}

View File

@@ -140,6 +140,18 @@ type FormSchema struct {
configPath string
// recordActions are the declared recordActions names, in declared order.
recordActions []string
// preview is the config_form.yaml preview block; nil when the form has
// no preview screen.
preview *FormPreview
}
// FormPreview is a form's preview screen (config_form.yaml `preview:`, D-11):
// a read-only record view that shows the fields whose context allows
// `preview` and offers the form's record actions. HeaderPartial names the
// controller partial rendered above the fields as a status hint; it is empty
// when the screen has none.
type FormPreview struct {
HeaderPartial string `json:"headerPartial,omitempty"`
}
// FormView is one request's localized form, including the locale actually used.
@@ -147,6 +159,9 @@ type FormView struct {
Name string `json:"name,omitempty"`
ModelClass string `json:"modelClass,omitempty"`
Fields []FormField `json:"fields"`
// Preview is set when the form has a preview screen (D-11); a form
// without one omits the key.
Preview *FormPreview `json:"preview,omitempty"`
// Messages is the form's copy resolved in the request locale (D-13).
Messages FormMessages `json:"messages"`
// Redirects are the raw Winter config_form.yaml targets; the SPA maps

View File

@@ -0,0 +1,6 @@
{{- 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 -}}

View File

@@ -3,9 +3,14 @@ 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

View File

@@ -1,7 +1,7 @@
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:

View File

@@ -15,3 +15,12 @@ people:
refused: You may not rename this person.
refused_name: This name is reserved.
locked: This person is locked and cannot be changed.
preview: Person details
edit: Edit person
joined_ip: Joined from IP address
banned_title: This person is banned
banned_text: A banned person cannot sign in until the ban is lifted.
deleted_title: This person is archived
deleted_text: An archived person is hidden from the directory.
inactive_title: This person is not active
inactive_text: Activate the person to let them sign in.

View File

@@ -15,3 +15,12 @@ people:
refused: Nie możesz zmienić nazwy tej osoby.
refused_name: Ta nazwa jest zastrzeżona.
locked: Ta osoba jest zablokowana i nie można jej zmienić.
preview: Szczegóły osoby
edit: Edytuj osobę
joined_ip: Adres IP przy dołączeniu
banned_title: Ta osoba jest zablokowana
banned_text: Zablokowana osoba nie może się zalogować do czasu zdjęcia blokady.
deleted_title: Ta osoba jest zarchiwizowana
deleted_text: Zarchiwizowana osoba jest ukryta w katalogu.
inactive_title: Ta osoba jest nieaktywna
inactive_text: Aktywuj osobę, aby mogła się zalogować.

View File

@@ -7,3 +7,7 @@ fields:
label: acme.roster::lang.people.email
type: text
span: right
joined_ip:
label: acme.roster::lang.people.joined_ip
type: text
context: preview

View File

@@ -67,6 +67,7 @@ form:
update: Edit
create: Create
return_to_list: Back to list
return_to_preview: Back to preview
close: Close
confirm: Confirm
saving: Saving…
@@ -183,6 +184,8 @@ messages:
saved: Saved
delete_confirm: Delete this record?
deleted: Record deleted
preview: Record preview
edit: Edit record
relation:
link: Add
link_hint: Choose the records to link.

View File

@@ -73,6 +73,7 @@ form:
update: Edytuj
create: Utwórz
return_to_list: Wróć do listy
return_to_preview: Wróć do podglądu
close: Zamknij
confirm: Potwierdź
saving: Zapisywanie…
@@ -199,6 +200,8 @@ messages:
saved: Zapisano
delete_confirm: Usunąć ten rekord?
deleted: Usunięto rekord
preview: Podgląd rekordu
edit: Edytuj rekord
relation:
link: Dodaj
link_hint: Wybierz rekordy, które chcesz dołączyć.