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

@@ -17,6 +17,7 @@ Schema-driven admin backend that compiles WinterCMS-style YAML list, form, filte
- Form widgets and controller actions: a `type: widget` field in `fields.yaml` names a plugin custom element (`widget:`, which must start with the owning plugin's `{vendor}-{plugin}-` prefix), the controller action it runs (`action:`, registered through `pact.HasAdminActions`) and the writable scalar fields of the same form the action may write back (`fill:`). The admin SPA posts the action to a cabana-owned route, so the CSRF check, permissions (the controller's plus the action's own) and record scoping (`pact.FormExtendQuery`) never depend on plugin code; the response carries only the declared fill keys whose values encode as JSON scalars (a value whose `MarshalJSON` writes an array or object, NaN or an infinity is dropped). Like the list schema's `toolbarActions`, the form schema carries a widget field only when the requesting administrator may run its action. The field's `context` applies to the action route as it does on save: a request without `record_id` is the create form's and one with it the update form's, and a widget its context hides on that form answers 404. Unknown keys, a foreign or invalid tag, an unregistered action or a fill key that is not a writable scalar field fail boot.
- Controller assets: a controller implementing `pact.AdminClientAssets` names JS (`.js`, `.mjs`) and CSS files under its plugin's `assets/` directory, Winter's `addJs`/`addCss`. They are read from the plugin's embedded tree at boot (a missing file fails boot; there is no disk override) and listed in the list and form schemas under `assets` as same-origin URLs with a `?v=` content hash. A form with a widget needs at least one JS file.
- Toolbar actions: `toolbar.buttons` in `config_list.yaml` lists the built-in `create` and `delete` next to names the controller registers through `pact.HasAdminActions`. Registered actions share one namespace with widget actions, `create` and `delete` are reserved, and each toolbar action needs a label. The list schema's `toolbarActions` carries only the actions the requesting administrator may run, with localized labels; an unknown name fails boot.
- Bulk actions: `bulkActions` in `config_list.yaml` lists names the controller registers through `pact.HasAdminBulkActions`; it needs `showCheckboxes: true`. Bulk actions have their own namespace (`create` and `delete` are reserved there too), and each needs a label. The posted ids are resolved and row-locked through `pact.ListExtendQuery` in one transaction and the action receives the loaded records, never ids: a selection that matches nothing answers `affected: 0` without running the action, and a partial match answers 409 and rolls back. The list schema's `bulkActions` carries the built-in `delete` and only the declared actions the requesting administrator may run, with localized `label` and `confirm`; an unknown or duplicate name fails boot. Each run is logged with the controller, action, administrator and affected count.
- Server-rendered partials: `headerPartial: <name>` in `config_list.yaml` (a strip above the list) and `type: partial` with `path: <name>` in `fields.yaml` render the template `{ConfigDir}/_<name>.htm` with `html/template` against a view model from the controller's `pact.AdminPartialData`. The result reaches the SPA as an allowlisted node tree, never as an HTML string. A missing or unparsable template, a free-form path or a controller without `pact.AdminPartialData` fails boot.
- Date pickers: a `type: datepicker` field in `fields.yaml` edits a date (`mode: date`, a `lagoon.Date` column), a date and time (`mode: datetime`, the default, a `time.Time` column stored in UTC) or a time of day (`mode: time`, a `lagoon.TimeOfDay` column); pointers to the three types make the value optional. It accepts WinterCMS's `mode`, `format` (a PHP `date()` format, served also as `displayFormat` in the SPA's tokens), `minDate`, `maxDate`, `yearRange`, `firstDay`, `twelveHour` and `ignoreTimezone`; any other key, a format letter with no equivalent, bounds on `mode: time`, `ignoreTimezone` outside `mode: datetime` or a column whose Go type does not match the mode fails boot. The save rechecks `minDate` and `maxDate` on the calendar date and answers 422 on the field. List columns take `type: date` and `type: time` for these columns; when `type` is omitted, a `time.Time` column is compiled as `datetime`, a `lagoon.Date` column as `date` and a `lagoon.TimeOfDay` column as `time`. A struct column that implements `sql.Scanner` or `driver.Valuer` is never taken for a relation.
- File uploads: a `type: fileupload` field in `fields.yaml` edits an attachOne or attachMany relation the record model declares through `attach.HasRelations` (its `AttachRelations` method) next to `attach.Owner`. The field accepts WinterCMS's `mode` (`image` or `file`), `fileTypes`, `mimeTypes`, `maxFilesize` (megabytes), `maxFiles` (attachMany only), `imageWidth`, `imageHeight`, `thumbOptions` (only `mode`: `auto`, `exact`, `crop` or `fit`), `useCaption` and `prompt`; any other key, an image-mode file type outside jpg, jpeg, png, gif and webp, a name that is not a declared relation or a `maxFilesize` whose file plus 64 KiB of multipart framing exceeds `http.body_limits.upload_bytes` fails boot. Uploads and removals are deferred, as in WinterCMS: the SPA sends a random form session key in the `X-Session-Key` header (`cabana.SessionKeyHeader`) with every file call and with the save, the server keeps the pending work in `deferred_bindings` against that key and the signed-in administrator, and the record's next create or update save applies it inside its transaction. A retry of the same upload may send `X-Upload-Id` so the server returns the already stored file. A save that fails with 422 keeps the pending uploads; another administrator's key matches nothing. The upload route caps the request body at the smaller of `http.body_limits.upload_bytes` and `maxFilesize` plus 64 KiB and answers 413 `payload_too_large` past it; the size, type and image checks run on the server (through `attach.Store`) and answer 422 on the field. A file list (`cabana.FileItem`) carries `url` and `thumb_url` only for a public relation.
@@ -42,6 +43,7 @@ All paths are relative to `<prefix>/api/v1`. A controller ID `vendor.plugin.cont
| GET and POST `/{vendor}/{plugin}/{controller}` | List records; create a record (needs a form and `create` in `toolbar.buttons`). |
| GET, PUT and DELETE `/{vendor}/{plugin}/{controller}/{id}` | Show, update and delete a record (update and delete need a form). |
| POST `/{vendor}/{plugin}/{controller}/bulk-delete` | Delete a set of records in one transaction; needs `delete` in `toolbar.buttons`. |
| POST `/{vendor}/{plugin}/{controller}/bulk/{action}` | Run a declared bulk action on `{ids}` in one transaction; answers `{message, affected}`, 409 for a partial selection. |
| POST `/{vendor}/{plugin}/{controller}/widgets/{field}` | Run the action of a `type: widget` field with an optional `record_id` and the fill snapshot; answers `{message, fill}`. |
| POST `/{vendor}/{plugin}/{controller}/toolbar/{action}` | Run a registered toolbar action with an empty `{}` body; answers `{message, fill: {}}`. |
| GET `/{vendor}/{plugin}/{controller}/partials/{name}` | Render a declared header or form partial as a node tree; `?id=` (form partials only) passes the scoped record to the view model. |
@@ -166,7 +168,10 @@ func (p *Plugin) AdminFS() fs.FS { return adminFS }
| `cabana.ListSchema` / `cabana.FormSchema` / `cabana.RelationSchema` | Locale-neutral compiled schemas; each request works on a localized copy. |
| `cabana.CompiledController` | One controller after compilation: list, form, relations and writable fields. |
| `cabana.Registry` | Immutable map of compiled controllers and settings, with permission-filtered metadata. |
| `cabana.CRUDService` | Schema-projected show, create, update, delete, bulk delete and relation options. |
| `cabana.CRUDService` | Schema-projected show, create, update, delete, bulk delete and relation options; `cabana.CRUDService.BulkAction` runs a declared bulk action on a scoped, locked id set. |
| `cabana.BulkAction` | One entry of a list schema's `bulkActions`: name, localized label and optional confirm text. |
| `cabana.BulkActionResult` | Answer of the bulk action route: the localized `message` and the `affected` count. |
| `cabana.AdminBulkAction` | Swag annotation of the bulk action route. |
| `cabana.ExecuteList` | Runs an allowlisted, paginated list query for a controller. |
| `cabana.RelationService` | Linked, candidate, link and unlink operations of relation managers, child create, show, update and delete (`CreateChild`, `ShowChild`, `UpdateChild`, `DeleteChildren`) and pivot values (`ShowPivot`, `UpdatePivot`); its `SessionKey` makes record id 0 the record being created in that session. |
| `cabana.SettingsService` | Reads and transactionally updates singleton settings rows. |

View File

@@ -32,7 +32,7 @@ func (s *service) widgetAction(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
if !s.allowAction(w, r, action) {
if !s.allowAction(w, r, action.Permissions) {
return
}
in, err := decodeActionRequest(r)
@@ -82,7 +82,7 @@ func (s *service) toolbarAction(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
if !s.allowAction(w, r, action) {
if !s.allowAction(w, r, action.Permissions) {
return
}
in, err := decodeActionRequest(r)
@@ -115,11 +115,68 @@ func toolbarActionOf(cc *CompiledController, name string) (pact.AdminAction, boo
return action, ok
}
// allowAction applies the action's own permissions on top of the controller's
// (already checked by protect). A denial is logged and answered 403.
func (s *service) allowAction(w http.ResponseWriter, r *http.Request, action pact.AdminAction) bool {
// bulkAction serves POST .../{controller}/bulk/{action} (D-09): a registered
// bulk action the list's bulkActions declares. The posted ids are resolved
// through the controller's list scope inside the action's transaction, so the
// plugin receives loaded records and never an id.
func (s *service) bulkAction(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
name := r.PathValue("action")
action, ok := bulkActionOf(cc, name)
if !ok {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
if !s.allowAction(w, r, action.Permissions) {
return
}
in, err := s.decodeCappedBulk(w, r)
if err != nil {
writeCRUDError(w, err)
return
}
svc, err := s.crud()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
result, err := svc.BulkAction(r.Context(), cc, name, in)
if err != nil {
writeCRUDError(w, err)
return
}
var adminID uint
if principal, _ := bouncer.User(r.Context()); principal != nil {
adminID = principal.ID
}
slog.Info("cabana: admin bulk action", "controller", controllerID(cc), "action", name, "admin_id", adminID, "affected", result.Affected)
WriteData(w, http.StatusOK, result, nil)
})
}
// bulkActionOf returns the registered bulk action a list declares under name
// in bulkActions; the built-in delete is not a declared action.
func bulkActionOf(cc *CompiledController, name string) (pact.AdminBulkAction, bool) {
if cc == nil || cc.List == nil || builtinToolbarActions[name] {
return pact.AdminBulkAction{}, false
}
declared := false
for _, entry := range cc.List.BulkActions {
declared = declared || entry.Name == name
}
if !declared {
return pact.AdminBulkAction{}, false
}
action, ok := cc.BulkActions[name]
return action, ok
}
// allowAction applies an action's own permissions on top of the controller's
// (already checked by protect). Every action kind shares it: a denial is
// logged and answered 403.
func (s *service) allowAction(w http.ResponseWriter, r *http.Request, permissions []string) bool {
principal, _ := bouncer.User(r.Context())
if Allows(principal, action.Permissions) {
if Allows(principal, permissions) {
return true
}
var adminID uint

View File

@@ -396,6 +396,28 @@ func AdminCreate() {}
// @Router /{vendor}/{plugin}/{controller}/bulk-delete [post]
func AdminBulkDelete() {}
// AdminBulkAction documents the declared bulk action route.
//
// @Summary Run a declared bulk action
// @Description Runs a bulk action the controller registers and the list's bulkActions declares. The ids are resolved and row-locked through the controller's list scope in one transaction before the action runs: a selection that matches no scoped row answers affected 0 without running the action, and a partial selection is a 409 that changes nothing.
// @Tags admin
// @Accept json
// @Produce json
// @Security BackendBearer
// @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin"
// @Param controller path string true "Controller"
// @Param action path string true "Bulk action name"
// @Param body body AdminIDsRequest true "Record ids"
// @Success 200 {object} Envelope[BulkActionResult]
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 409 {object} ErrorEnvelope
// @Failure 422 {object} ErrorEnvelope
// @Router /{vendor}/{plugin}/{controller}/bulk/{action} [post]
func AdminBulkAction() {}
// AdminActionRequest is the body of a widget or toolbar action. record_id is
// the record a widget on the update form belongs to (absent on create and
// always absent for a toolbar action); values is the widget's snapshot of its

View File

@@ -79,6 +79,10 @@ type CompiledController struct {
// the single namespace that toolbar.buttons names and widget action: keys
// resolve through. create and delete are reserved built-in names.
Actions map[string]pact.AdminAction
// BulkActions are the controller's pact.HasAdminBulkActions entries keyed
// by name. They have their own namespace next to Actions; create and
// delete are reserved there too.
BulkActions map[string]pact.AdminBulkAction
// scripts and styles are the controller's declared plugin JS and CSS,
// in declared order.

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)

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
},
}}
}

View File

@@ -67,6 +67,11 @@ func compileExtension(pluginID string, cc *CompiledController, fsys fs.FS) error
return fmt.Errorf("cabana: admin controller %s/%s: %w", pluginID, id, err)
}
cc.Actions = actions
bulkActions, err := compileBulkActions(cc.Controller)
if err != nil {
return fmt.Errorf("cabana: admin controller %s/%s: %w", pluginID, id, err)
}
cc.BulkActions = bulkActions
if err := compileClientAssets(pluginID, cc, fsys); err != nil {
return err
}
@@ -276,3 +281,30 @@ func compileActions(ctl pact.AdminController) (map[string]pact.AdminAction, erro
}
return out, nil
}
// compileBulkActions collects a controller's registered bulk actions. They
// have their own namespace next to the toolbar and widget actions: a name is
// unique among the bulk actions, and create and delete stay reserved.
func compileBulkActions(ctl pact.AdminController) (map[string]pact.AdminBulkAction, error) {
out := map[string]pact.AdminBulkAction{}
src, ok := ctl.(pact.HasAdminBulkActions)
if !ok || src == nil {
return out, nil
}
for _, action := range src.AdminBulkActions() {
if !identifier(action.Name) {
return nil, fmt.Errorf("bulk action name %q is not an identifier", action.Name)
}
if builtinToolbarActions[action.Name] {
return nil, fmt.Errorf("bulk action %s uses a reserved built-in name (create, delete)", action.Name)
}
if _, dup := out[action.Name]; dup {
return nil, fmt.Errorf("duplicate bulk action %s", action.Name)
}
if action.Run == nil {
return nil, fmt.Errorf("bulk action %s has no Run function", action.Name)
}
out[action.Name] = action
}
return out, nil
}

View File

@@ -255,6 +255,11 @@ func (s *service) mount(r pact.Router) {
constrainController(g)
g.Post("/{vendor}/{plugin}/{controller}/bulk-delete", requireAjax(s.bulkDelete))
constrainController(g)
// Declared bulk actions (D-09): ids are resolved through the list
// scope before plugin code runs.
g.Post("/{vendor}/{plugin}/{controller}/bulk/{action}", requireAjax(s.bulkAction))
constrainController(g)
g.Where("action", "[A-Za-z_][A-Za-z0-9_]*")
// Runtime extension actions (Phase 10.1): cabana owns these routes, so
// CSRF, auth and record scoping never depend on plugin code.
g.Post("/{vendor}/{plugin}/{controller}/widgets/{field}", requireAjax(s.widgetAction))
@@ -719,6 +724,20 @@ func (s *service) listSchema(w http.ResponseWriter, r *http.Request) {
}
}
view.ToolbarActions = allowed
// The built-in delete stays; a declared bulk action is offered only
// to an admin who may run it. A new slice: the cached schema is
// never mutated.
bulk := make([]BulkAction, 0, len(view.BulkActions))
for _, entry := range view.BulkActions {
if builtinToolbarActions[entry.Name] {
bulk = append(bulk, entry)
continue
}
if registered, ok := cc.BulkActions[entry.Name]; ok && Allows(principal, registered.Permissions) {
bulk = append(bulk, entry)
}
}
view.BulkActions = bulk
view.Assets = s.controllerAssets(cc)
meta := map[string]any{}
if view.Meta != nil {

View File

@@ -40,6 +40,7 @@ type listDocument struct {
Filter string `yaml:"filter"`
Messages *listMessageKeys `yaml:"messages"`
HeaderPartial string `yaml:"headerPartial"`
BulkActions bulkActionNames `yaml:"bulkActions"`
}
type listSortDocument struct {
@@ -153,6 +154,11 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc
if doc.ShowCheckboxes {
bulk = append(bulk, BulkAction{Name: "delete", Label: "backend::lang.list.delete_selected"})
}
declared, err := compileBulkActionNames(ctl, doc.BulkActions.items, doc.ShowCheckboxes)
if err != nil {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
}
bulk = append(bulk, declared...)
filters := []ListFilter{}
if strings.TrimSpace(doc.Filter) != "" {
filters, err = compileFilters(pluginID, ctl, fsys, dir, doc.Filter)
@@ -362,6 +368,81 @@ func compileToolbarButtons(ctl pact.AdminController, toolbar *listToolbar, showC
return out, custom, nil
}
// bulkActionNames is the declarative bulkActions list (D-09): the names of
// bulk actions the controller registers through pact.HasAdminBulkActions, in
// menu order. Decode has no controller, so membership is resolved in
// compileBulkActionNames.
type bulkActionNames struct {
items []string
}
func (b *bulkActionNames) UnmarshalYAML(node ast.Node) error {
items, err := actionNameList(node, "bulkActions", "bulkActions must be a list of bulk action names the controller registers")
if err != nil {
return err
}
b.items = items
return nil
}
// actionNameList decodes a YAML sequence of action names for key: duplicates
// are refused, and anything but a sequence is refused with scalarHint.
func actionNameList(node ast.Node, key, scalarHint string) ([]string, error) {
node = unwrapNode(node)
switch n := node.(type) {
case nil, *ast.NullNode:
return nil, nil
case *ast.SequenceNode:
values := sequenceValues(n)
items := make([]string, 0, len(values))
seen := map[string]struct{}{}
for _, item := range values {
name, err := nodeString(unwrapNode(item))
if err != nil {
return nil, fmt.Errorf("%s entries must be action names", key)
}
if _, dup := seen[name]; dup {
return nil, fmt.Errorf("%s: duplicate action %s", key, name)
}
seen[name] = struct{}{}
items = append(items, name)
}
return items, nil
default:
return nil, fmt.Errorf("%s", scalarHint)
}
}
// compileBulkActionNames resolves every bulkActions name against the bulk
// actions the controller registers and returns the schema entries in declared
// order, with their source label and confirm keys.
func compileBulkActionNames(ctl pact.AdminController, names []string, showCheckboxes bool) ([]BulkAction, error) {
if len(names) == 0 {
return nil, nil
}
if !showCheckboxes {
return nil, fmt.Errorf("bulkActions needs showCheckboxes: true")
}
registered := map[string]pact.AdminBulkAction{}
if src, ok := ctl.(pact.HasAdminBulkActions); ok && src != nil {
for _, action := range src.AdminBulkActions() {
registered[action.Name] = action
}
}
out := make([]BulkAction, 0, len(names))
for _, name := range names {
action, ok := registered[name]
if !ok || builtinToolbarActions[name] {
return nil, fmt.Errorf("bulkActions: unsupported action %s (want a bulk action the controller registers)", name)
}
if strings.TrimSpace(action.Label) == "" {
return nil, fmt.Errorf("bulkActions: action %s needs a label", name)
}
out = append(out, BulkAction{Name: name, Label: action.Label, Confirm: action.Confirm})
}
return out, nil
}
func compileDefaultSort(doc *listSortDocument, columns []ListColumn) (*ListSort, error) {
if doc == nil {
return nil, nil
@@ -555,6 +636,7 @@ func localizeBulkActions(ctx context.Context, tr *phrasebook.Translator, actions
out := make([]BulkAction, len(actions))
for i, action := range actions {
action.Label = translateKey(ctx, tr, action.Label)
action.Confirm = translateKey(ctx, tr, action.Confirm)
out[i] = action
}
if out == nil {

View File

@@ -279,6 +279,14 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
spare := e.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "spare-" + e.stamp}, true)
return e.send(t, http.MethodPost, "/acme/conform/gadgets/bulk-delete", map[string]any{"ids": []uint{dataID(t, spare.Body.Bytes())}}, true)
}, into[cabana.Envelope[cabana.BulkResult]](), nil},
{"POST /{vendor}/{plugin}/{controller}/bulk/{action}", 200, "cabana.Envelope-cabana_BulkActionResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.send(t, http.MethodPost, "/acme/conform/gadgets/bulk/touch", map[string]any{"ids": []uint{e.gadgetID}}, true)
var body cabana.Envelope[cabana.BulkActionResult]
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.Affected != 1 || body.Data.Message != "Touched" {
t.Fatalf("bulk action body = %s (%v)", rec.Body.String(), err)
}
return rec
}, into[cabana.Envelope[cabana.BulkActionResult]](), nil},
{"DELETE /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.Envelope-cabana_BulkResult", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true)
}, into[cabana.Envelope[cabana.BulkResult]](), nil},
@@ -790,6 +798,16 @@ func (c conformController) AdminActions() []pact.AdminAction {
},
}}
}
// AdminBulkActions registers the bulk action the list's bulkActions declares.
func (conformController) AdminBulkActions() []pact.AdminBulkAction {
return []pact.AdminBulkAction{{
Name: "touch", Label: "Touch", Permissions: []string{"acme.conform.access"},
Run: func(_ context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
return pact.AdminBulkActionResult{Message: "Touched", Affected: len(in.Records)}, nil
},
}}
}
func (conformController) AdminJS() []string { return []string{"assets/js/lookup.js"} }
// PartialData supplies curated view models, never the gadget model itself.
@@ -824,6 +842,7 @@ toolbar:
buttons: [create, delete, recount]
search:
prompt: backend::lang.list.search_prompt
bulkActions: [touch]
`),
// A plain custom element: a light-DOM button that asks the admin SPA
// to run the field's action. It makes no network call and never

View File

@@ -96,6 +96,7 @@ func TestPhase10Coverage(t *testing.T) {
"POST /auth/refresh",
"POST /{vendor}/{plugin}/{controller}",
"POST /{vendor}/{plugin}/{controller}/bulk-delete",
"POST /{vendor}/{plugin}/{controller}/bulk/{action}",
"POST /{vendor}/{plugin}/{controller}/toolbar/{action}",
"POST /{vendor}/{plugin}/{controller}/widgets/{field}",
"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}",
@@ -114,7 +115,7 @@ func TestPhase10Coverage(t *testing.T) {
"PUT /{vendor}/{plugin}/{controller}/{id}/relations/{name}/records/{child}/files/{field}/{file}",
}
if strings.Join(unsafe, "\n") != strings.Join(want, "\n") {
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 23 besides login):\n%s", strings.Join(unsafe, "\n"))
t.Fatalf("unsafe routes changed; extend TestPhase10CSRF (it expects 24 besides login):\n%s", strings.Join(unsafe, "\n"))
}
// The routes added in Phase 10 are safe reads: GET /lang and the shared
// nested pattern serving field options, filter options and relation lists.

View File

@@ -66,13 +66,13 @@ func TestPhase10CSRF(t *testing.T) {
}
})
}
// refresh, logout, settings put, create, bulk-delete, widget action,
// toolbar action, update, delete, link, unlink, file upload, file
// reorder, file caption, file remove, relation child create, update
// and delete, pivot update, child file upload, reorder, caption and
// remove
if unsafe != 23 {
t.Fatalf("walked %d state-changing routes, want 23: %v", unsafe, router.order)
// refresh, logout, settings put, create, bulk-delete, bulk action,
// widget action, toolbar action, update, delete, link, unlink, file
// upload, file reorder, file caption, file remove, relation child
// create, update and delete, pivot update, child file upload, reorder,
// caption and remove
if unsafe != 24 {
t.Fatalf("walked %d state-changing routes, want 24: %v", unsafe, router.order)
}
loginHandler := router.handlers[login]

View File

@@ -0,0 +1,251 @@
package cabana_test
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/cabana"
)
const rosterPeople = "/acme/roster/people"
// rosterBulkNames returns the bulk action names the list schema offers auth.
func rosterBulkNames(t *testing.T, env *rosterEnv, auth string) []string {
t.Helper()
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/schema/list", "", auth)
var list cabana.Envelope[cabana.ListSchema]
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
t.Fatalf("list schema: %v\n%s", err, rec.Body.String())
}
names := make([]string, 0, len(list.Data.BulkActions))
for _, action := range list.Data.BulkActions {
names = append(names, action.Name)
}
return names
}
func rosterBulkResult(t *testing.T, rec *httptest.ResponseRecorder) cabana.BulkActionResult {
t.Helper()
var body cabana.Envelope[cabana.BulkActionResult]
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("bulk action body %s: %v", rec.Body.String(), err)
}
return body.Data
}
// TestBulkActionTracer drives a declared bulk action through the assembled
// router on PostgreSQL (D-09; T-12.1-01, T-12.1-02, T-12.1-03, T-12.1-05):
// the per-principal list schema, the scoped and locked id resolution, the
// loaded records the plugin receives, the action permission and the CSRF
// header.
func TestBulkActionTracer(t *testing.T) {
env, gdb := newRosterEnv(t)
ada := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada", Email: "ada@example.test"})
bob := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bob", Email: "bob@example.test"})
cy := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Cy", Email: "cy@example.test", Active: true})
foreign := rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Zed", Email: "zed@example.test"})
const activate = rosterPeople + "/bulk/activate"
ids := func(list ...uint) string {
raw, _ := json.Marshal(map[string]any{"ids": list})
return string(raw)
}
t.Run("list schema is per principal", func(t *testing.T) {
if got := rosterBulkNames(t, env, "bearer"); !reflect.DeepEqual(got, []string{"delete", "activate", "archive"}) {
t.Fatalf("full admin bulkActions = %v", got)
}
if got := rosterBulkNames(t, env, "limited"); !reflect.DeepEqual(got, []string{"delete", "archive"}) {
t.Fatalf("limited admin bulkActions = %v", got)
}
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/schema/list", "", "bearer")
if !strings.Contains(rec.Body.String(), `"label":"Activate"`) || !strings.Contains(rec.Body.String(), `"confirm":"Activate the selected people?"`) {
t.Fatalf("label and confirm are not localized: %s", rec.Body.String())
}
})
t.Run("runs on loaded records in the list scope", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodPost, activate, ids(bob, ada, bob), "bearer")
if result := rosterBulkResult(t, rec); result.Affected != 2 || result.Message != "" {
t.Fatalf("result = %+v", result)
}
if !rosterLoad(t, gdb, ada).Active || !rosterLoad(t, gdb, bob).Active {
t.Fatal("the selected people were not activated")
}
calls := env.spy.takeBulk()
if len(calls) != 1 || len(calls[0].Records) != 2 {
t.Fatalf("calls = %+v", calls)
}
// The plugin gets loaded, locked records ordered by primary key and
// no id list: AdminBulkActionInput has no other field.
first, ok := calls[0].Records[0].(*rosterPerson)
second, ok2 := calls[0].Records[1].(*rosterPerson)
if !ok || !ok2 || first.ID != ada || second.ID != bob || first.Name != "Ada" || first.Tenant != "acme" {
t.Fatalf("records = %+v %+v", calls[0].Records[0], calls[0].Records[1])
}
if n := reflect.TypeOf(calls[0]).NumField(); n != 1 {
t.Fatalf("AdminBulkActionInput has %d fields, want only Records", n)
}
})
t.Run("affected may be lower than the selection", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodPost, activate, ids(ada, cy), "bearer")
if result := rosterBulkResult(t, rec); result.Affected != 0 {
t.Fatalf("result = %+v", result)
}
env.spy.takeBulk()
})
t.Run("server message is localized", func(t *testing.T) {
spare := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Spare"})
rec := env.expect(t, http.StatusOK, http.MethodPost, rosterPeople+"/bulk/archive", ids(spare), "limited")
if result := rosterBulkResult(t, rec); result.Affected != 1 || result.Message != "The selected people were archived." {
t.Fatalf("result = %+v", result)
}
if !rosterLoad(t, gdb, spare).DeletedAt.Valid {
t.Fatal("archive did not soft-delete the person")
}
env.spy.takeBulk()
})
t.Run("a partial selection is a 409 and changes nothing", func(t *testing.T) {
fresh := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Fresh"})
for _, other := range []uint{foreign, 999999} {
rec := env.expect(t, http.StatusConflict, http.MethodPost, activate, ids(fresh, other), "bearer")
actErrorCode(t, rec.Body.Bytes(), "conflict")
}
if rosterLoad(t, gdb, fresh).Active || rosterLoad(t, gdb, foreign).Active {
t.Fatal("a refused selection changed a row")
}
if calls := env.spy.takeBulk(); len(calls) != 0 {
t.Fatalf("action ran for a partial selection: %+v", calls)
}
})
t.Run("a selection outside the scope is a no-op", func(t *testing.T) {
rec := env.expect(t, http.StatusOK, http.MethodPost, activate, ids(foreign, 999999), "bearer")
if result := rosterBulkResult(t, rec); result.Affected != 0 {
t.Fatalf("result = %+v", result)
}
if rosterLoad(t, gdb, foreign).Active {
t.Fatal("an out-of-scope row was activated")
}
if calls := env.spy.takeBulk(); len(calls) != 0 {
t.Fatalf("action ran for out-of-scope ids: %+v", calls)
}
})
t.Run("body", func(t *testing.T) {
for _, body := range []string{`{"ids":[]}`, `{}`, `{"ids":["nope"]}`, `{`} {
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, activate, body, "bearer")
actErrorCode(t, rec.Body.Bytes(), "validation_failed")
}
})
t.Run("undeclared and reserved names are 404", func(t *testing.T) {
for _, name := range []string{"missing", "delete", "create", "hidden"} {
env.expect(t, http.StatusNotFound, http.MethodPost, rosterPeople+"/bulk/"+name, ids(ada), "bearer")
}
env.expect(t, http.StatusNotFound, http.MethodPost, "/acme/roster/nope/bulk/activate", ids(ada), "bearer")
})
t.Run("action permission on top of the controller's", func(t *testing.T) {
fresh := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Denied"})
rec := env.expect(t, http.StatusForbidden, http.MethodPost, activate, ids(fresh), "limited")
actErrorCode(t, rec.Body.Bytes(), "forbidden")
if rosterLoad(t, gdb, fresh).Active {
t.Fatal("a denied admin changed a row")
}
})
t.Run("cookie POSTs need X-Requested-With", func(t *testing.T) {
fresh := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Cookie"})
rec := env.expect(t, http.StatusForbidden, http.MethodPost, activate, ids(fresh), "cookie-only")
actErrorCode(t, rec.Body.Bytes(), "forbidden")
if rosterLoad(t, gdb, fresh).Active {
t.Fatal("a request without the CSRF header changed a row")
}
env.expect(t, http.StatusOK, http.MethodPost, activate, ids(fresh), "cookie")
if !rosterLoad(t, gdb, fresh).Active {
t.Fatal("the cookie request with the header did not run")
}
})
t.Run("bulk delete is unchanged", func(t *testing.T) {
spare := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Gone"})
rec := env.expect(t, http.StatusOK, http.MethodPost, rosterPeople+"/bulk-delete", ids(spare), "bearer")
if !strings.Contains(rec.Body.String(), `"deleted":1`) {
t.Fatalf("bulk delete = %s", rec.Body.String())
}
})
}
// rosterListFS is the roster fixture tree with config_list.yaml replaced.
func rosterListFS(t *testing.T, list string) fstest.MapFS {
t.Helper()
columns, err := os.ReadFile(filepath.Join(rosterDir, "models/person/columns.yaml"))
if err != nil {
t.Fatal(err)
}
return fstest.MapFS{
"controllers/people/config_list.yaml": &fstest.MapFile{Data: []byte(list)},
"models/person/columns.yaml": &fstest.MapFile{Data: columns},
}
}
// TestListSchemaBulkActionsBoot checks the fail-loud compile of the
// bulkActions key (D-09): every mistake names the plugin, controller and file.
func TestListSchemaBulkActionsBoot(t *testing.T) {
const head = "list: ~/plugins/acme/roster/models/person/columns.yaml\nmodelClass: Person\n"
for _, tc := range []struct {
name, yaml, want string
}{
{"unregistered", head + "showCheckboxes: true\nbulkActions: [activate, promote]\n", "bulkActions: unsupported action promote (want a bulk action the controller registers)"},
{"reserved", head + "showCheckboxes: true\nbulkActions: [delete]\n", "bulkActions: unsupported action delete (want a bulk action the controller registers)"},
{"duplicate", head + "showCheckboxes: true\nbulkActions: [activate, activate]\n", "bulkActions: duplicate action activate"},
{"no checkboxes", head + "bulkActions: [activate]\n", "bulkActions needs showCheckboxes: true"},
{"scalar", head + "showCheckboxes: true\nbulkActions: activate\n", "bulkActions must be a list of bulk action names the controller registers"},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := cabana.CompileList("acme.roster", rosterController{}, rosterListFS(t, tc.yaml))
if err == nil {
t.Fatal("the list compiled")
}
for _, part := range []string{tc.want, "acme.roster", "acme.roster.people", "controllers/people/config_list.yaml"} {
if !strings.Contains(err.Error(), part) {
t.Fatalf("error %q does not name %q", err, part)
}
}
})
}
t.Run("declared order after the built-in delete", func(t *testing.T) {
list, err := cabana.CompileList("acme.roster", rosterController{}, rosterListFS(t, head+"showCheckboxes: true\nbulkActions: [archive, activate]\n"))
if err != nil {
t.Fatal(err)
}
got := fmt.Sprint(list.BulkActions)
want := fmt.Sprint([]cabana.BulkAction{
{Name: "delete", Label: "backend::lang.list.delete_selected"},
{Name: "archive", Label: "acme.roster::lang.people.archive"},
{Name: "activate", Label: "acme.roster::lang.people.activate", Confirm: "acme.roster::lang.people.activate_confirm"},
})
if got != want {
t.Fatalf("bulkActions = %s, want %s", got, want)
}
})
t.Run("a list without bulkActions is unchanged", func(t *testing.T) {
list, err := cabana.CompileList("acme.roster", rosterController{}, rosterListFS(t, head+"showCheckboxes: true\n"))
if err != nil || len(list.BulkActions) != 1 || list.BulkActions[0].Name != "delete" {
t.Fatalf("bulkActions = %+v err=%v", list.BulkActions, err)
}
})
}

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
}

View File

@@ -227,6 +227,11 @@ func compileContributions(reg *Registry, plugins []party.Plugin) error {
return err
}
}
for name, action := range controller.BulkActions {
if err := reg.validatePermissions("bulk action "+id+"."+name, action.Permissions); err != nil {
return err
}
}
}
for _, item := range reg.navigation {
if err := reg.validateNavigation(item); err != nil {

View File

@@ -45,10 +45,15 @@ type RowAction struct {
URL string `json:"url,omitempty"`
}
// BulkAction is a checkbox action. It carries no SQL.
// BulkAction is a checkbox action. It carries no SQL. The built-in delete is
// always listed when the list shows checkboxes; a declared action
// (config_list.yaml bulkActions) is per-principal: a response lists it only
// when the requesting admin may run it. Confirm is the action's own
// confirmation question, empty when the admin should show its default text.
type BulkAction struct {
Name string `json:"name"`
Label string `json:"label,omitempty"`
Name string `json:"name"`
Label string `json:"label,omitempty"`
Confirm string `json:"confirm,omitempty"`
}
// ListSchema is the boot-compiled list contract for one controller.

View File

@@ -53,6 +53,7 @@ var phase09Routes = []adminRoute{
{key: "GET /{vendor}/{plugin}/{controller}"},
{key: "POST /{vendor}/{plugin}/{controller}"},
{key: "POST /{vendor}/{plugin}/{controller}/bulk-delete"},
{key: "POST /{vendor}/{plugin}/{controller}/bulk/{action}"},
{key: "POST /{vendor}/{plugin}/{controller}/widgets/{field}"},
{key: "POST /{vendor}/{plugin}/{controller}/toolbar/{action}"},
{key: "GET /{vendor}/{plugin}/{controller}/{id}"},
@@ -290,6 +291,7 @@ func phase09ProtectedCalls() []phase09Call {
{"list", (*service).list},
{"create", (*service).create},
{"bulk-delete", (*service).bulkDelete},
{"bulk-action", (*service).bulkAction},
{"widget-action", (*service).widgetAction},
{"toolbar-action", (*service).toolbarAction},
{"partial", (*service).partial},

View File

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

View File

@@ -0,0 +1,13 @@
list: ~/plugins/acme/roster/models/person/columns.yaml
modelClass: Person
title: acme.roster::lang.people.title
recordUrl: acme/roster/people/update/:id
recordsPerPage: 20
showCheckboxes: true
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt
bulkActions: [activate, archive]
messages:
create: acme.roster::lang.people.create

View File

@@ -0,0 +1,10 @@
people:
title: People
form: Person
create: New person
name: Name
email: Email
activate: Activate
activate_confirm: Activate the selected people?
archive: Archive
archived: The selected people were archived.

View File

@@ -0,0 +1,10 @@
people:
title: Osoby
form: Osoba
create: Nowa osoba
name: Imię i nazwisko
email: E-mail
activate: Aktywuj
activate_confirm: Aktywować zaznaczone osoby?
archive: Archiwizuj
archived: Zaznaczone osoby zostały zarchiwizowane.

View File

@@ -0,0 +1,7 @@
columns:
name:
label: acme.roster::lang.people.name
searchable: true
email:
label: acme.roster::lang.people.email
searchable: true

View File

@@ -0,0 +1,9 @@
fields:
name:
label: acme.roster::lang.people.name
type: text
span: left
email:
label: acme.roster::lang.people.email
type: text
span: right