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