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

@@ -0,0 +1,88 @@
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
},
}}
}