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:
Jakub Zych
2026-10-04 23:37:30 +02:00
parent a879d6388c
commit e0ccced76a
34 changed files with 1350 additions and 24 deletions

View File

@@ -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.

View File

@@ -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