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.
|
||||
|
||||
@@ -27,6 +27,8 @@ The SPA signs in through the admin API and keeps the token in the HttpOnly cooki
|
||||
|
||||
Each record form makes a session key when it opens: 32 random bytes, base64url encoded. The form sends it in the `X-Session-Key` header with every file upload, file list and file removal, and with the final create or update save. Uploads and removals are deferred: the server holds them against the key and the admin, and the save that carries the same key commits them in its transaction. Until then the form counts as unsaved, so leaving it asks first, and a new record's files go to record id `0`. The form will not save while an upload is still in flight. Uploads use `XMLHttpRequest` for progress events and carry the same `X-Requested-With` header and cookie as every other call, plus an `X-Upload-Id` so a retry returns the already stored file. Files of a protected relation are fetched through the admin API with the key and shown from object URLs. The key travels only in headers, never in a URL. See [File uploads](forms.md#file-uploads) for the `fileupload` field.
|
||||
|
||||
A list whose schema carries declared bulk actions shows a "Bulk actions" menu after the selection count. The menu lists only the actions the server offered to this administrator, and its button stays disabled until a row is selected. Choosing an action always asks for confirmation; the dialog stays open while the request runs, and the list reloads afterwards. See [Bulk actions](admin-controllers.md#bulk-actions).
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -40,6 +40,9 @@ toolbar:
|
||||
| `toolbar` | `buttons` (the built-in `create` and `delete` and registered actions) and `search.prompt`. |
|
||||
| `filter` | The filter file, relative to the controller's directory. |
|
||||
| `headerPartial` | A server-rendered strip above the list; see [Partials and widgets](partials-and-widgets.md). |
|
||||
| `bulkActions` | The bulk actions the list offers for the selected rows: a list of names the controller registers through `pact.HasAdminBulkActions`. Needs `showCheckboxes: true`. |
|
||||
|
||||
A declared bulk action runs on the selected rows in one transaction, after cabana resolved the posted ids through the list's own scope. A selection of which only a part is still in that scope (a row was deleted meanwhile, or lies outside `pact.ListExtendQuery`) answers 409 and changes nothing; the admin then reloads the list and asks for a new selection. See [Bulk actions](admin-controllers.md#bulk-actions).
|
||||
|
||||
## columns.yaml
|
||||
|
||||
|
||||
Reference in New Issue
Block a user