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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user