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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user