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,246 @@
package cabana_test
import (
"context"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"sync"
"testing"
"testing/fstest"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/surf"
"gorm.io/gorm"
)
// rosterDir is the neutral acme.roster fixture plugin tree of the Phase 12.1
// framework features: declared bulk actions, record actions, row state and
// the forbidden error.
const rosterDir = "testdata/roster"
// rosterPerson is the fixture model: a person of one tenant who can be
// active, banned and soft-deleted.
type rosterPerson struct {
ID uint `gorm:"column:id;primaryKey"`
Tenant string `gorm:"column:tenant"`
Name string `gorm:"column:name"`
Email string `gorm:"column:email"`
Active bool `gorm:"column:active"`
Banned bool `gorm:"column:banned"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
}
func (rosterPerson) TableName() string { return "roster_people" }
func (rosterPerson) Fillable() []string { return []string{"name", "email"} }
func (rosterPerson) Rules() map[string]string { return map[string]string{"name": "required"} }
// rosterSpy records what each registered action's Run receives.
type rosterSpy struct {
mu sync.Mutex
bulk []pact.AdminBulkActionInput
}
func (s *rosterSpy) recordBulk(in pact.AdminBulkActionInput) {
s.mu.Lock()
defer s.mu.Unlock()
s.bulk = append(s.bulk, in)
}
func (s *rosterSpy) takeBulk() []pact.AdminBulkActionInput {
s.mu.Lock()
defer s.mu.Unlock()
out := s.bulk
s.bulk = nil
return out
}
type rosterPlugin struct{ spy *rosterSpy }
func (rosterPlugin) ID() string { return "acme.roster" }
func (rosterPlugin) Requires() []string { return nil }
func (rosterPlugin) Register(*backpack.App) error { return nil }
func (rosterPlugin) Boot(*backpack.App) error { return nil }
func (p rosterPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{rosterController{spy: p.spy}}
}
func (rosterPlugin) Permissions() []pact.Permission {
return []pact.Permission{{Code: "acme.roster.access", Roles: []string{"developer"}}, {Code: "acme.roster.manage", Roles: []string{"developer"}}}
}
func (rosterPlugin) AdminFS() fs.FS { return os.DirFS(rosterDir) }
// LangFS serves only the fixture's lang/ tree.
func (rosterPlugin) LangFS() fs.FS {
out := fstest.MapFS{}
for _, name := range []string{"lang/en/lang.yaml", "lang/pl/lang.yaml"} {
data, err := os.ReadFile(filepath.Join(rosterDir, name))
if err != nil {
panic(err)
}
out[name] = &fstest.MapFile{Data: data}
}
return out
}
type rosterController struct{ spy *rosterSpy }
func (rosterController) ID() string { return "acme.roster.people" }
func (rosterController) ModelName() string { return "Person" }
func (rosterController) ConfigDir() string { return "controllers/people" }
func (rosterController) RequiredPermissions() []string { return []string{"acme.roster.access"} }
func (rosterController) NewRecord() any { return &rosterPerson{} }
// ListExtendQuery and FormExtendQuery scope every lookup to the acme tenant,
// so a person of another tenant is out of scope.
func (rosterController) ListExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
return db.Where("tenant = ?", "acme")
}
func (rosterController) FormExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
return db.Where("tenant = ?", "acme")
}
// AdminBulkActions: activate needs acme.roster.manage and sets active on the
// rows that are not active yet, reporting how many it changed; archive needs
// only the controller permission and soft-deletes the rows.
func (c rosterController) 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) {
c.spy.recordBulk(in)
tx, ok := cabana.TxFromContext(ctx)
if !ok {
return pact.AdminBulkActionResult{}, fmt.Errorf("no transaction on the context")
}
changed := 0
for _, record := range in.Records {
person := record.(*rosterPerson)
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",
Permissions: []string{"acme.roster.access"},
Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
c.spy.recordBulk(in)
tx, ok := cabana.TxFromContext(ctx)
if !ok {
return pact.AdminBulkActionResult{}, fmt.Errorf("no transaction on the context")
}
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
},
}}
}
// rosterEnv is the assembled admin API over the roster fixture. The embedded
// actEnv supplies call and expect with the four auth modes: bearer (developer
// token), limited (acme.roster.access only), cookie and cookie-only.
type rosterEnv struct {
*actEnv
spy *rosterSpy
}
func newRosterEnv(t *testing.T) (*rosterEnv, *gorm.DB) {
t.Helper()
gdb := adminGorm(t)
models := []any{&rosterPerson{}}
if err := gdb.Migrator().DropTable(models...); err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(models...); err != nil {
t.Fatal(err)
}
stamp := fmt.Sprintf("r%d", time.Now().UnixNano())
login := "roster-" + stamp
insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
var roleID uint
if err := gdb.Raw(`INSERT INTO backend_user_roles (name, code, permissions, is_system, created_at, updated_at)
VALUES (?, ?, ?, FALSE, NOW(), NOW()) RETURNING id`, "Roster limited "+stamp, "roster-limited-"+stamp, `{"acme.roster.access":1}`).Scan(&roleID).Error; err != nil || roleID == 0 {
t.Fatalf("limited role: id=%d err=%v", roleID, err)
}
limitedLogin := "roster-limited-" + stamp
limited := insertAdmin(t, gdb, limitedLogin, limitedLogin+"@example.test", adminTestPassword, true, false)
if err := gdb.Exec(`UPDATE backend_users SET role_id = ? WHERE id = ?`, roleID, limited.ID).Error; err != nil {
t.Fatal(err)
}
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-roster\nlocale: en\nfallback_locale: en\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
if err != nil {
t.Fatal(err)
}
for key, value := range map[string]any{"http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 1048576} {
if err := cfg.Set(key, value); err != nil {
t.Fatal(err)
}
}
app := backpack.New(cfg)
if err := lagoon.Publish(app, adminSQL, gdb); err != nil {
t.Fatal(err)
}
spy := &rosterSpy{}
plugins := []party.Plugin{rosterPlugin{spy: spy}}
if err := phrasebook.Activate(app, plugins); err != nil {
t.Fatal(err)
}
h, err := surf.Assemble(app, plugins)
if err != nil {
t.Fatal(err)
}
env := &rosterEnv{actEnv: &actEnv{h: h}, spy: spy}
rec := postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": login, "password": adminTestPassword})
if rec.Code != http.StatusOK {
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())
}
env.token = accessToken(t, rec.Body.Bytes())
env.cookie = &http.Cookie{Name: cabana.AdminCookieName, Value: env.token}
rec = postJSON(t, h, adminAPI("/auth/login"), map[string]string{"login": limitedLogin, "password": adminTestPassword})
if rec.Code != http.StatusOK {
t.Fatalf("limited login status=%d body=%s", rec.Code, rec.Body.String())
}
env.limited = accessToken(t, rec.Body.Bytes())
return env, gdb
}
// rosterInsert stores one person and returns its id.
func rosterInsert(t *testing.T, gdb *gorm.DB, person rosterPerson) uint {
t.Helper()
if err := gdb.Create(&person).Error; err != nil {
t.Fatal(err)
}
return person.ID
}
// rosterLoad reads one person, soft-deleted or not.
func rosterLoad(t *testing.T, gdb *gorm.DB, id uint) rosterPerson {
t.Helper()
var person rosterPerson
if err := gdb.Unscoped().First(&person, id).Error; err != nil {
t.Fatal(err)
}
return person
}