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:
1
modules/boardwalk/dist/assets/index-BOx4jB46.css
vendored
Normal file
1
modules/boardwalk/dist/assets/index-BOx4jB46.css
vendored
Normal file
File diff suppressed because one or more lines are too long
9
modules/boardwalk/dist/assets/index-BrvHT7-x.js
vendored
Normal file
9
modules/boardwalk/dist/assets/index-BrvHT7-x.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
modules/boardwalk/dist/index.html
vendored
4
modules/boardwalk/dist/index.html
vendored
@@ -6,8 +6,8 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||
<title>SummerCMS</title>
|
||||
<script type="module" crossorigin src="./assets/index-C1hSuckg.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-Cr97p7-8.css">
|
||||
<script type="module" crossorigin src="./assets/index-BrvHT7-x.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BOx4jB46.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
88
modules/cabana/example_actions_test.go
Normal file
88
modules/cabana/example_actions_test.go
Normal 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
|
||||
},
|
||||
}}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
251
modules/cabana/phase121_actions_test.go
Normal file
251
modules/cabana/phase121_actions_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
246
modules/cabana/phase121_fixture_test.go
Normal file
246
modules/cabana/phase121_fixture_test.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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},
|
||||
|
||||
10
modules/cabana/testdata/roster/controllers/people/config_form.yaml
vendored
Normal file
10
modules/cabana/testdata/roster/controllers/people/config_form.yaml
vendored
Normal 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
|
||||
13
modules/cabana/testdata/roster/controllers/people/config_list.yaml
vendored
Normal file
13
modules/cabana/testdata/roster/controllers/people/config_list.yaml
vendored
Normal 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
|
||||
10
modules/cabana/testdata/roster/lang/en/lang.yaml
vendored
Normal file
10
modules/cabana/testdata/roster/lang/en/lang.yaml
vendored
Normal 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.
|
||||
10
modules/cabana/testdata/roster/lang/pl/lang.yaml
vendored
Normal file
10
modules/cabana/testdata/roster/lang/pl/lang.yaml
vendored
Normal 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.
|
||||
7
modules/cabana/testdata/roster/models/person/columns.yaml
vendored
Normal file
7
modules/cabana/testdata/roster/models/person/columns.yaml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
columns:
|
||||
name:
|
||||
label: acme.roster::lang.people.name
|
||||
searchable: true
|
||||
email:
|
||||
label: acme.roster::lang.people.email
|
||||
searchable: true
|
||||
9
modules/cabana/testdata/roster/models/person/fields.yaml
vendored
Normal file
9
modules/cabana/testdata/roster/models/person/fields.yaml
vendored
Normal 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
|
||||
@@ -14,7 +14,7 @@ Capability interfaces that compiled plugins implement to contribute routes, conf
|
||||
- HTTP contracts: the `pact.Router` group builder (implemented by surf), the `pact.Middleware` type, and named, parameterized (`name:param`) and house-envelope middleware through `pact.HasMiddleware`, `pact.HasMiddlewareFactories` and `pact.HasHouseMiddleware`.
|
||||
- Backend registration data: `pact.Permission`, `pact.NavigationItem` and `pact.SettingsItem`, exposed through `pact.HasPermissions`, `pact.HasNavigation` and `pact.HasSettings`.
|
||||
- Admin controller contracts: `pact.AdminController`, `pact.HasAdminControllers`, `pact.AdminAssets` (embedded Winter-shaped admin YAML), `pact.AdminPermissioned` and `pact.AdminRecordSource`.
|
||||
- Admin extension contracts, so a plugin extends the compiled admin SPA without a Node build: `pact.AdminClientAssets` (per-controller JS and CSS from the plugin's embedded `assets/` tree, Winter's `addJs`/`addCss`), `pact.HasAdminActions` with `pact.AdminAction`, `pact.AdminActionInput` and `pact.AdminActionResult` (named toolbar and widget actions whose routes, CSRF check, permissions and record scoping the framework owns), and `pact.AdminPartialData` (the curated view model a partial template renders).
|
||||
- Admin extension contracts, so a plugin extends the compiled admin SPA without a Node build: `pact.AdminClientAssets` (per-controller JS and CSS from the plugin's embedded `assets/` tree, Winter's `addJs`/`addCss`), `pact.HasAdminActions` with `pact.AdminAction`, `pact.AdminActionInput` and `pact.AdminActionResult` (named toolbar and widget actions whose routes, CSRF check, permissions and record scoping the framework owns), `pact.HasAdminBulkActions` with `pact.AdminBulkAction`, `pact.AdminBulkActionInput` and `pact.AdminBulkActionResult` (named actions on the rows selected in a list, which receive records the framework loaded through the list scope, never ids), and `pact.AdminPartialData` (the curated view model a partial template renders).
|
||||
- Optional admin hooks a controller or model can implement: list and form query scoping (`pact.ListExtendQuery`, `pact.FormExtendQuery`), create, update and delete hooks (`pact.FormBeforeCreate`, `pact.FormAfterUpdate`, `pact.FormBeforeDelete` and their siblings), relation hooks (`pact.RelationExtendManageQuery`, `pact.RelationExtendOptionsQuery`, `pact.RelationBeforeLink`), relation child hooks around creating, updating and deleting a related record (`pact.RelationBeforeCreate`, `pact.RelationAfterCreate`, `pact.RelationBeforeUpdate`, `pact.RelationAfterUpdate`, `pact.RelationBeforeDelete`, `pact.RelationAfterDelete`), filter scopes (`pact.FilterScope`, `pact.FilterOptions`) and dropdown options (`pact.DropdownOptionsProvider`).
|
||||
- A background job contract (`pact.Job`, `pact.JobArgs`) that does not depend on any queue library.
|
||||
- A schedule contract: `pact.HasSchedule` returns `pact.ScheduledCommand` entries (a registered command name, its arguments and a `pact.Cadence` built with `pact.Daily`, `pact.DailyAt` or `pact.Every`), the Go form of WinterCMS `registerSchedule`. It does not depend on any queue library either.
|
||||
@@ -109,6 +109,10 @@ func (p *Plugin) Schedule() []pact.ScheduledCommand {
|
||||
| `pact.AdminActionInput` | What an action receives: widget field, optional record id and scoped record, and the fill snapshot. |
|
||||
| `pact.AdminActionResult` | What an action returns: a message for the toast and the fill write-back values. |
|
||||
| `pact.HasAdminActions` | Registers a controller's actions for `toolbar.buttons` and `type: widget` fields. |
|
||||
| `pact.AdminBulkAction` | One named bulk action: name, label, optional confirm text, extra permissions and the Go `Run` function. |
|
||||
| `pact.AdminBulkActionInput` | What a bulk action receives: `Records`, the selected records loaded and row-locked through the list scope. |
|
||||
| `pact.AdminBulkActionResult` | What a bulk action returns: an optional message for the toast and `Affected`, the number of records it changed. |
|
||||
| `pact.HasAdminBulkActions` | Registers a controller's bulk actions for the `bulkActions` list of `config_list.yaml`. |
|
||||
| `pact.AdminPartialData` | Supplies the view model a controller partial template renders; never the GORM model. |
|
||||
| `pact.FilterScope` | Model scopes a list filter may call, limited to an exact allow list. |
|
||||
| `pact.RelationBeforeLink` | Optional controller hook that checks or fills pivot columns before a relation link is written. |
|
||||
|
||||
@@ -255,6 +255,50 @@ type HasAdminActions interface {
|
||||
AdminActions() []AdminAction
|
||||
}
|
||||
|
||||
// AdminBulkAction is one controller action an administrator runs on the rows
|
||||
// selected in a list (config_list.yaml bulkActions). The admin framework owns
|
||||
// the HTTP route, the CSRF check, authentication, the resolution of the posted
|
||||
// ids and the transaction; Run only carries the business logic and reads the
|
||||
// write transaction with cabana.TxFromContext. Name is an identifier unique
|
||||
// among the controller's bulk actions; create and delete are reserved, and a
|
||||
// record action may reuse a bulk action's name. Label and Confirm are phrase
|
||||
// keys or literal text: Label is the menu item, Confirm the confirmation
|
||||
// question, and an empty Confirm means the framework's default confirm text.
|
||||
// Permissions are checked in addition to the controller's
|
||||
// RequiredPermissions.
|
||||
type AdminBulkAction struct {
|
||||
Name string
|
||||
Label string
|
||||
Confirm string
|
||||
Permissions []string
|
||||
Run func(ctx context.Context, in AdminBulkActionInput) (AdminBulkActionResult, error) `json:"-"`
|
||||
}
|
||||
|
||||
// AdminBulkActionInput is what the framework hands an AdminBulkAction.
|
||||
// Records are pointers to the controller's model, loaded and row-locked
|
||||
// through the controller's ListExtendQuery scope and ordered by primary key.
|
||||
// A bulk action never receives ids, so an id list can never become an
|
||||
// unscoped lookup.
|
||||
type AdminBulkActionInput struct {
|
||||
Records []any
|
||||
}
|
||||
|
||||
// AdminBulkActionResult is a bulk action's answer. Message is a phrase key or
|
||||
// text, localized by the framework and shown as a toast; when it is empty the
|
||||
// admin shows its default text with Affected. Affected is the number of
|
||||
// records the action changed, which may be lower than the selection when the
|
||||
// action skips rows.
|
||||
type AdminBulkActionResult struct {
|
||||
Message string
|
||||
Affected int
|
||||
}
|
||||
|
||||
// HasAdminBulkActions is implemented by an admin controller that registers
|
||||
// named bulk actions for its config_list.yaml bulkActions list.
|
||||
type HasAdminBulkActions interface {
|
||||
AdminBulkActions() []AdminBulkAction
|
||||
}
|
||||
|
||||
// AdminPartialData supplies the view model a controller partial template
|
||||
// renders (config_list.yaml headerPartial, fields.yaml `type: partial`). name
|
||||
// is the partial name; record is the scoped record for a form partial on an
|
||||
|
||||
@@ -51,6 +51,13 @@ list:
|
||||
filter_from: From
|
||||
filter_to: To
|
||||
pagination: Pagination
|
||||
bulk_actions: Bulk actions
|
||||
bulk_confirm: "Run “:action” on the selected (:count)?"
|
||||
bulk_done:
|
||||
one: "Action completed for :count record."
|
||||
other: "Action completed for :count records."
|
||||
bulk_stale: Some of the selected records are no longer available. The list has been refreshed; select the records again.
|
||||
action_forbidden: You do not have permission to run this action.
|
||||
form:
|
||||
save: Save
|
||||
save_and_close: Save and close
|
||||
|
||||
@@ -55,6 +55,15 @@ list:
|
||||
filter_from: Od
|
||||
filter_to: Do
|
||||
pagination: Stronicowanie
|
||||
bulk_actions: Działania masowe
|
||||
bulk_confirm: "Wykonać „:action” na zaznaczonych (:count)?"
|
||||
bulk_done:
|
||||
one: "Wykonano akcję dla :count rekordu."
|
||||
few: "Wykonano akcję dla :count rekordów."
|
||||
many: "Wykonano akcję dla :count rekordów."
|
||||
other: "Wykonano akcję dla :count rekordów."
|
||||
bulk_stale: Część zaznaczonych rekordów nie jest już dostępna. Lista została odświeżona; zaznacz rekordy ponownie.
|
||||
action_forbidden: Nie masz uprawnień do wykonania tej akcji.
|
||||
form:
|
||||
save: Zapisz
|
||||
save_and_close: Zapisz i zamknij
|
||||
|
||||
Reference in New Issue
Block a user