feat(12.1-01): declared bulk actions on admin lists

- pact.HasAdminBulkActions with AdminBulkAction, its input and result
- config_list.yaml bulkActions, compiled fail-loud, needs showCheckboxes
- POST .../{controller}/bulk/{action}: ids resolved and locked through the
  list scope in one transaction; partial selection is 409
- list schema offers declared actions per principal, with confirm text
- admin SPA bulk actions menu with confirm, busy state and failure toasts
- acme.roster fixture, tracer test, OpenAPI, TS types, dist, READMEs, docs
This commit is contained in:
Jakub Zych
2026-10-04 23:28:30 +02:00
parent ca9e9c0557
commit a879d6388c
45 changed files with 2007 additions and 38 deletions

View File

@@ -224,3 +224,124 @@ Scope reads and writes with `pact.ListExtendQuery` and `pact.FormExtendQuery` ra
## Toolbar actions
`toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` and any action the controller registers through `pact.HasAdminActions`. The declarations are enforced by the server, not only shown by the SPA. `POST /{controller}` needs a `config_form.yaml` and `create` in `toolbar.buttons`; `PUT` and `DELETE /{controller}/{id}` need a form (the form screen carries the delete button, as in WinterCMS); `POST /{controller}/bulk-delete` needs `delete` in `toolbar.buttons`, which in turn needs `showCheckboxes: true`. A write the controller does not declare answers 403 `forbidden`. See [Partials and widgets](partials-and-widgets.md) for actions and the rest of the extension points.
## Bulk actions
A bulk action runs on the rows an administrator selected in the list, as the `onBulkAction` handlers of a WinterCMS list toolbar do. The controller registers its bulk actions through `pact.HasAdminBulkActions`, and `bulkActions` in `config_list.yaml` lists the ones the list offers, in menu order:
```go src=modules/cabana/example_actions_test.go
package cabana_test
import (
"context"
"errors"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
)
// Person is the model behind the acme.roster people controller.
type Person struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Email string `gorm:"column:email"`
Active bool `gorm:"column:active"`
}
func (Person) TableName() string { return "acme_roster_people" }
// Fillable lists the columns the admin form may write.
func (Person) Fillable() []string { return []string{"name", "email"} }
// PeopleController is an admin controller whose list offers bulk actions.
type PeopleController struct{}
var (
_ pact.AdminController = PeopleController{}
_ pact.AdminRecordSource = PeopleController{}
_ pact.HasAdminBulkActions = PeopleController{}
)
func (PeopleController) ID() string { return "acme.roster.people" }
func (PeopleController) ModelName() string { return "Person" }
func (PeopleController) ConfigDir() string { return "controllers/people" }
func (PeopleController) NewRecord() any { return &Person{} }
func (PeopleController) RequiredPermissions() []string {
return []string{"acme.roster.access"}
}
// AdminBulkActions registers the actions config_list.yaml offers under
// bulkActions. Run receives the selected records, already loaded and locked
// through the list scope, and writes through the request's transaction.
func (PeopleController) AdminBulkActions() []pact.AdminBulkAction {
return []pact.AdminBulkAction{{
Name: "activate",
Label: "acme.roster::lang.people.activate",
Confirm: "acme.roster::lang.people.activate_confirm",
Permissions: []string{"acme.roster.manage"},
Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
tx, ok := cabana.TxFromContext(ctx)
if !ok {
return pact.AdminBulkActionResult{}, errors.New("no transaction")
}
changed := 0
for _, record := range in.Records {
person := record.(*Person)
if person.Active {
continue
}
if err := tx.Model(person).Update("active", true).Error; err != nil {
return pact.AdminBulkActionResult{}, err
}
changed++
}
return pact.AdminBulkActionResult{Affected: changed}, nil
},
}, {
Name: "archive",
Label: "acme.roster::lang.people.archive",
Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
tx, ok := cabana.TxFromContext(ctx)
if !ok {
return pact.AdminBulkActionResult{}, errors.New("no transaction")
}
for _, record := range in.Records {
if err := tx.Delete(record).Error; err != nil {
return pact.AdminBulkActionResult{}, err
}
}
return pact.AdminBulkActionResult{
Message: "acme.roster::lang.people.archived",
Affected: len(in.Records),
}, nil
},
}}
}
```
```yaml src=modules/cabana/testdata/roster/controllers/people/config_list.yaml
list: ~/plugins/acme/roster/models/person/columns.yaml
modelClass: Person
title: acme.roster::lang.people.title
recordUrl: acme/roster/people/update/:id
recordsPerPage: 20
showCheckboxes: true
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt
bulkActions: [activate, archive]
messages:
create: acme.roster::lang.people.create
```
The admin SPA shows the declared actions in a "Bulk actions" menu next to the selection, asks for confirmation (the action's `Confirm` text, or a default one), and posts the selected ids to `POST /{controller}/bulk/{action}`. cabana owns that route:
- The ids are resolved and row-locked through the controller's `pact.ListExtendQuery` scope inside one transaction. `Run` receives the loaded records in `pact.AdminBulkActionInput`, never the ids, so an id outside the scope cannot reach plugin code.
- A selection that matches no row in the scope answers `affected: 0` without calling `Run`. A selection of which only a part is in the scope answers 409 `conflict` and changes nothing.
- `Run` writes through `cabana.TxFromContext(ctx)`. Any error rolls every row back; a `cabana.ValidationError` is a 422 and any other error the opaque 500.
- The action's `Permissions` are checked on top of the controller's. An administrator who may not run an action does not get it in the list schema, and posting it answers 403.
- `pact.AdminBulkActionResult` carries an optional `Message` (a phrase key or text) and `Affected`, the number of records the action changed. The answer is a `cabana.BulkActionResult`.
Bulk actions have their own namespace next to the toolbar and widget actions: a name is unique among the controller's bulk actions, and `create` and `delete` stay reserved. `bulkActions` needs `showCheckboxes: true`. A name the controller does not register, a duplicate, or an action without a label stops the start-up. The built-in bulk delete is not a declared action and keeps its own route and toolbar button.