feat(12.1-01): declared record actions with an applicability rule
- pact.HasAdminRecordActions with AdminRecordAction (Applies, Run)
- config_form.yaml recordActions, compiled fail-loud
- show response meta.actions lists the permitted actions that apply
- POST .../{controller}/{id}/actions/{action}: record loaded and locked
through the form scope; 404 out of scope, 409 when it does not apply
- RecordActions.vue with confirm and request flow (mounted by plan 02)
- roster fixture, smoke tests, OpenAPI, TS types, READMEs, docs
This commit is contained in:
@@ -246,6 +246,7 @@ type Person struct {
|
||||
Name string `gorm:"column:name"`
|
||||
Email string `gorm:"column:email"`
|
||||
Active bool `gorm:"column:active"`
|
||||
Banned bool `gorm:"column:banned"`
|
||||
}
|
||||
|
||||
func (Person) TableName() string { return "acme_roster_people" }
|
||||
@@ -253,13 +254,14 @@ 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.
|
||||
// PeopleController is an admin controller with bulk and record actions.
|
||||
type PeopleController struct{}
|
||||
|
||||
var (
|
||||
_ pact.AdminController = PeopleController{}
|
||||
_ pact.AdminRecordSource = PeopleController{}
|
||||
_ pact.HasAdminBulkActions = PeopleController{}
|
||||
_ pact.AdminController = PeopleController{}
|
||||
_ pact.AdminRecordSource = PeopleController{}
|
||||
_ pact.HasAdminBulkActions = PeopleController{}
|
||||
_ pact.HasAdminRecordActions = PeopleController{}
|
||||
)
|
||||
|
||||
func (PeopleController) ID() string { return "acme.roster.people" }
|
||||
@@ -318,6 +320,47 @@ func (PeopleController) AdminBulkActions() []pact.AdminBulkAction {
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// AdminRecordActions registers the actions config_form.yaml offers under
|
||||
// recordActions. Applies decides whether an action fits the record's current
|
||||
// state; Run receives the record loaded and locked through the form scope.
|
||||
func (PeopleController) AdminRecordActions() []pact.AdminRecordAction {
|
||||
return []pact.AdminRecordAction{{
|
||||
Name: "activate",
|
||||
Label: "acme.roster::lang.people.activate",
|
||||
Permissions: []string{"acme.roster.manage"},
|
||||
Applies: func(_ context.Context, record any) (bool, error) {
|
||||
return !record.(*Person).Active, nil
|
||||
},
|
||||
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
|
||||
tx, ok := cabana.TxFromContext(ctx)
|
||||
if !ok {
|
||||
return pact.AdminRecordActionResult{}, errors.New("no transaction")
|
||||
}
|
||||
if err := tx.Model(in.Record).Update("active", true).Error; err != nil {
|
||||
return pact.AdminRecordActionResult{}, err
|
||||
}
|
||||
return pact.AdminRecordActionResult{Message: "acme.roster::lang.people.activated"}, nil
|
||||
},
|
||||
}, {
|
||||
Name: "reinstate",
|
||||
Label: "acme.roster::lang.people.reinstate",
|
||||
Confirm: "acme.roster::lang.people.reinstate_confirm",
|
||||
Applies: func(_ context.Context, record any) (bool, error) {
|
||||
return record.(*Person).Banned, nil
|
||||
},
|
||||
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
|
||||
tx, ok := cabana.TxFromContext(ctx)
|
||||
if !ok {
|
||||
return pact.AdminRecordActionResult{}, errors.New("no transaction")
|
||||
}
|
||||
if err := tx.Model(in.Record).Update("banned", false).Error; err != nil {
|
||||
return pact.AdminRecordActionResult{}, err
|
||||
}
|
||||
return pact.AdminRecordActionResult{}, nil
|
||||
},
|
||||
}}
|
||||
}
|
||||
```
|
||||
|
||||
```yaml src=modules/cabana/testdata/roster/controllers/people/config_list.yaml
|
||||
@@ -345,3 +388,30 @@ The admin SPA shows the declared actions in a "Bulk actions" menu next to the se
|
||||
- `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.
|
||||
|
||||
## Record actions
|
||||
|
||||
A record action runs on one record, as a button on its screen: activating an account, lifting a ban. The controller registers its record actions through `pact.HasAdminRecordActions` (`PeopleController` above registers two), and `recordActions` in `config_form.yaml` lists the ones the form offers, in display order:
|
||||
|
||||
```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/update/:id
|
||||
redirectClose: acme/roster/people
|
||||
update:
|
||||
redirect: acme/roster/people
|
||||
redirectClose: acme/roster/people
|
||||
recordActions: [activate, reinstate]
|
||||
```
|
||||
|
||||
Each `pact.AdminRecordAction` has a `Name`, a `Label`, an optional `Confirm` text, its own `Permissions`, an optional `Applies` function and `Run`:
|
||||
|
||||
- `Applies` reports whether the action fits the record's current state; without one the action always applies. It must only read, because it runs in two places: when a record is shown, to decide which actions to offer, and again inside the action's transaction, right before `Run`.
|
||||
- The show response (`GET /{controller}/{id}`) lists the offered actions in `meta.actions` as `cabana.RecordAction` entries: only those the administrator may run and that apply to the record. The key is absent when none is offered, and create and update responses never carry it.
|
||||
- `POST /{controller}/{id}/actions/{action}` takes an empty `{}` body. cabana loads the record through `pact.FormExtendQuery` with a row lock in one transaction and hands it to `Run` in `pact.AdminRecordActionInput`. A missing record and a record outside the scope are the same 404; an action whose `Applies` reports false answers 409 `conflict`; an administrator without the action's permissions gets 403.
|
||||
- `Run` writes through `cabana.TxFromContext(ctx)` and returns a `pact.AdminRecordActionResult` with an optional `Message`. Any error rolls the transaction back.
|
||||
|
||||
Record actions have their own namespace: a record action and a bulk action may share a name, such as `activate` here. `create` and `delete` are reserved. A name in `recordActions` that the controller does not register, a duplicate, or an action without a label stops the start-up.
|
||||
|
||||
@@ -27,6 +27,8 @@ 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).
|
||||
|
||||
## fields.yaml
|
||||
|
||||
```yaml src=modules/cabana/testdata/docs/models/post/fields.yaml
|
||||
|
||||
Reference in New Issue
Block a user