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

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"log/slog"
"math"
"net/http"
"reflect"
@@ -14,6 +15,7 @@ import (
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
"gocloud.dev/blob"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -47,6 +49,14 @@ type BulkResult struct {
Deleted int `json:"deleted"`
}
// BulkActionResult is the answer of a declared bulk action: the localized
// message for the toast (empty when the action sets none) and the number of
// records the action reports as changed.
type BulkActionResult struct {
Message string `json:"message"`
Affected int `json:"affected"`
}
// ValidationError is a D-10 validation_failed failure.
type ValidationError struct {
Details map[string]any
@@ -246,6 +256,79 @@ func (s CRUDService) BulkDelete(ctx context.Context, cc *CompiledController, in
return result, nil
}
// BulkAction runs the declared bulk action name on a normalized id set in one
// transaction. The ids are resolved and row-locked through the controller's
// ListExtendQuery scope first, and the action receives the loaded records,
// never the ids. An empty selection is validation_failed. A selection that
// matches no scoped row is a successful no-op with Affected 0 and the action
// does not run. A mixed present/absent selection conflicts and rolls back. A
// name the list does not declare, or the controller does not register, is not
// found.
func (s CRUDService) BulkAction(ctx context.Context, cc *CompiledController, name string, in BulkDeleteInput) (BulkActionResult, error) {
if s.DB == nil {
return BulkActionResult{}, errors.New("cabana: database is not configured")
}
if ctx == nil {
ctx = context.Background()
}
action, ok := bulkActionOf(cc, name)
if !ok {
return BulkActionResult{}, recordNotFound{}
}
ids, err := normalizeIDs(in.IDs)
if err != nil {
return BulkActionResult{}, err
}
if _, err := newWritableModel(cc); err != nil {
return BulkActionResult{}, err
}
ctx = towel.WithLocale(ctx, schemaLocale(ctx, s.tr))
var result BulkActionResult
err = lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
ctx = withTx(ctx, tx)
if err := ctx.Err(); err != nil {
return lifecycleFailure(cc, err)
}
proto, err := newWritableModel(cc)
if err != nil {
return err
}
rows, err := lockScoped(ctx, tx, cc, proto, ids)
if err != nil {
return err
}
if len(rows) == 0 {
return nil
}
if len(rows) != len(ids) {
return partialSelection{}
}
out, err := action.Run(ctx, pact.AdminBulkActionInput{Records: rows})
if err != nil {
return actionFailure(cc, "bulk action", name, err)
}
result = BulkActionResult{Message: translateKey(ctx, s.tr, out.Message), Affected: out.Affected}
return nil
})
if err != nil {
return BulkActionResult{}, err
}
return result, nil
}
// actionFailure classifies the error of a bulk or record action's Run like a
// lifecycle hook's: the errors a plugin may answer with pass through, anything
// else is logged with the controller and action and becomes the opaque
// lifecycle error (500, no error text).
func actionFailure(cc *CompiledController, kind, name string, err error) error {
out := lifecycleFailure(cc, err)
var life *lifecycleError
if errors.As(out, &life) && !errors.As(err, &life) {
slog.Error("cabana: admin "+kind+" failed", "controller", controllerID(cc), "action", name, "error", err)
}
return out
}
// Show loads one scoped record. Missing and out-of-scope ids are identical.
func (s CRUDService) Show(ctx context.Context, cc *CompiledController, id any) (map[string]any, error) {
res, err := s.ShowRecord(ctx, cc, id)