From a879d6388c39bdceaf990a7cc409709c4d3601a9 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sun, 4 Oct 2026 23:28:30 +0200 Subject: [PATCH] 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 --- admin/openapi/admin.json | 158 +++++++++ admin/src/api/schema.d.ts | 107 ++++++ admin/src/api/types.ts | 4 + admin/src/components/list/BulkActionsMenu.vue | 60 ++++ admin/src/components/list/ListToolbar.vue | 32 +- admin/src/views/ListView.vue | 80 ++++- admin/tests/fixtures/roster.list-schema.json | 41 +++ admin/tests/fixtures/roster.list.json | 8 + admin/tests/fixtures/typed.ts | 6 + admin/tests/smoke/actions.smoke.test.ts | 317 ++++++++++++++++++ docs/backend/admin-controllers.md | 121 +++++++ docs/backend/admin-spa.md | 2 + docs/backend/lists-and-filters.md | 3 + .../boardwalk/dist/assets/index-BOx4jB46.css | 1 + .../boardwalk/dist/assets/index-BrvHT7-x.js | 9 + .../boardwalk/dist/assets/index-C1hSuckg.js | 9 - .../boardwalk/dist/assets/index-Cr97p7-8.css | 1 - modules/boardwalk/dist/index.html | 4 +- modules/cabana/README.md | 7 +- modules/cabana/actions.go | 69 +++- modules/cabana/admin_openapi.go | 22 ++ modules/cabana/contracts.go | 4 + modules/cabana/crud.go | 83 +++++ modules/cabana/example_actions_test.go | 88 +++++ modules/cabana/extension.go | 32 ++ modules/cabana/http.go | 19 ++ modules/cabana/list_schema.go | 82 +++++ modules/cabana/openapi_conformance_test.go | 19 ++ modules/cabana/phase10_coverage_test.go | 3 +- modules/cabana/phase10_csrf_test.go | 14 +- modules/cabana/phase121_actions_test.go | 251 ++++++++++++++ modules/cabana/phase121_fixture_test.go | 246 ++++++++++++++ modules/cabana/registry.go | 5 + modules/cabana/schema_types.go | 11 +- modules/cabana/security_coverage_test.go | 2 + .../controllers/people/config_form.yaml | 10 + .../controllers/people/config_list.yaml | 13 + .../cabana/testdata/roster/lang/en/lang.yaml | 10 + .../cabana/testdata/roster/lang/pl/lang.yaml | 10 + .../roster/models/person/columns.yaml | 7 + .../testdata/roster/models/person/fields.yaml | 9 + modules/pact/README.md | 6 +- modules/pact/capabilities.go | 44 +++ modules/phrasebook/backend/lang/en/lang.yaml | 7 + modules/phrasebook/backend/lang/pl/lang.yaml | 9 + 45 files changed, 2007 insertions(+), 38 deletions(-) create mode 100644 admin/src/components/list/BulkActionsMenu.vue create mode 100644 admin/tests/fixtures/roster.list-schema.json create mode 100644 admin/tests/fixtures/roster.list.json create mode 100644 admin/tests/smoke/actions.smoke.test.ts create mode 100644 modules/boardwalk/dist/assets/index-BOx4jB46.css create mode 100644 modules/boardwalk/dist/assets/index-BrvHT7-x.js delete mode 100644 modules/boardwalk/dist/assets/index-C1hSuckg.js delete mode 100644 modules/boardwalk/dist/assets/index-Cr97p7-8.css create mode 100644 modules/cabana/example_actions_test.go create mode 100644 modules/cabana/phase121_actions_test.go create mode 100644 modules/cabana/phase121_fixture_test.go create mode 100644 modules/cabana/testdata/roster/controllers/people/config_form.yaml create mode 100644 modules/cabana/testdata/roster/controllers/people/config_list.yaml create mode 100644 modules/cabana/testdata/roster/lang/en/lang.yaml create mode 100644 modules/cabana/testdata/roster/lang/pl/lang.yaml create mode 100644 modules/cabana/testdata/roster/models/person/columns.yaml create mode 100644 modules/cabana/testdata/roster/models/person/fields.yaml diff --git a/admin/openapi/admin.json b/admin/openapi/admin.json index c78bc16..7941d6c 100644 --- a/admin/openapi/admin.json +++ b/admin/openapi/admin.json @@ -176,6 +176,9 @@ }, "cabana.BulkAction": { "properties": { + "confirm": { + "type": "string" + }, "label": { "type": "string" }, @@ -188,6 +191,21 @@ ], "type": "object" }, + "cabana.BulkActionResult": { + "properties": { + "affected": { + "type": "integer" + }, + "message": { + "type": "string" + } + }, + "required": [ + "affected", + "message" + ], + "type": "object" + }, "cabana.BulkResult": { "properties": { "deleted": { @@ -367,6 +385,21 @@ ], "type": "object" }, + "cabana.Envelope-cabana_BulkActionResult": { + "properties": { + "data": { + "$ref": "#/components/schemas/cabana.BulkActionResult" + }, + "meta": { + "$ref": "#/components/schemas/cabana.SuccessMeta" + } + }, + "required": [ + "data", + "meta" + ], + "type": "object" + }, "cabana.Envelope-cabana_BulkResult": { "properties": { "data": { @@ -2727,6 +2760,131 @@ ] } }, + "/{vendor}/{plugin}/{controller}/bulk/{action}": { + "post": { + "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.", + "parameters": [ + { + "description": "Vendor", + "in": "path", + "name": "vendor", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Plugin", + "in": "path", + "name": "plugin", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Controller", + "in": "path", + "name": "controller", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Bulk action name", + "in": "path", + "name": "action", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.AdminIDsRequest" + } + } + }, + "description": "Record ids", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.Envelope-cabana_BulkActionResult" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/cabana.ErrorEnvelope" + } + } + }, + "description": "Unprocessable Entity" + } + }, + "security": [ + { + "BackendBearer": [] + } + ], + "summary": "Run a declared bulk action", + "tags": [ + "admin" + ] + } + }, "/{vendor}/{plugin}/{controller}/fields/{field}/options": { "get": { "description": "Choices for a writable `type: relation` field: value is the related id, label its nameFrom column. Read-only and non-relation fields answer 404.", diff --git a/admin/src/api/schema.d.ts b/admin/src/api/schema.d.ts index c27bbae..e886528 100644 --- a/admin/src/api/schema.d.ts +++ b/admin/src/api/schema.d.ts @@ -862,6 +862,104 @@ export interface paths { patch?: never; trace?: never; }; + "/{vendor}/{plugin}/{controller}/bulk/{action}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 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. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Vendor */ + vendor: string; + /** @description Plugin */ + plugin: string; + /** @description Controller */ + controller: string; + /** @description Bulk action name */ + action: string; + }; + cookie?: never; + }; + /** @description Record ids */ + requestBody: { + content: { + "application/json": components["schemas"]["cabana.AdminIDsRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.Envelope-cabana_BulkActionResult"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + /** @description Unprocessable Entity */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["cabana.ErrorEnvelope"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/{vendor}/{plugin}/{controller}/fields/{field}/options": { parameters: { query?: never; @@ -4046,9 +4144,14 @@ export interface components { name: string; }; "cabana.BulkAction": { + confirm?: string; label?: string; name: string; }; + "cabana.BulkActionResult": { + affected: number; + message: string; + }; "cabana.BulkResult": { deleted: number; }; @@ -4092,6 +4195,10 @@ export interface components { data: components["schemas"]["cabana.AdminRecord"]; meta: components["schemas"]["cabana.SuccessMeta"]; }; + "cabana.Envelope-cabana_BulkActionResult": { + data: components["schemas"]["cabana.BulkActionResult"]; + meta: components["schemas"]["cabana.SuccessMeta"]; + }; "cabana.Envelope-cabana_BulkResult": { data: components["schemas"]["cabana.BulkResult"]; meta: components["schemas"]["cabana.SuccessMeta"]; diff --git a/admin/src/api/types.ts b/admin/src/api/types.ts index fe1f7d7..718436c 100644 --- a/admin/src/api/types.ts +++ b/admin/src/api/types.ts @@ -34,6 +34,10 @@ export type FilterOption = Schemas['cabana.FilterOption'] export type RecordMeta = Schemas['cabana.RecordMeta'] export type RecordEnvelope = Schemas['cabana.RecordEnvelope'] export type BulkResult = Schemas['cabana.BulkResult'] +/** A checkbox action of a list: the built-in delete or a declared bulk action. */ +export type BulkAction = Schemas['cabana.BulkAction'] +/** A declared bulk action's answer: the toast message and the affected count. */ +export type BulkActionResult = Schemas['cabana.BulkActionResult'] /** Body of a widget action: the record id (absent on create) and fill values. */ export type AdminActionRequest = Schemas['cabana.AdminActionRequest'] /** A widget or toolbar action result: the toast message and fill values. */ diff --git a/admin/src/components/list/BulkActionsMenu.vue b/admin/src/components/list/BulkActionsMenu.vue new file mode 100644 index 0000000..07c9707 --- /dev/null +++ b/admin/src/components/list/BulkActionsMenu.vue @@ -0,0 +1,60 @@ + + + diff --git a/admin/src/components/list/ListToolbar.vue b/admin/src/components/list/ListToolbar.vue index 761124c..4f44275 100644 --- a/admin/src/components/list/ListToolbar.vue +++ b/admin/src/components/list/ListToolbar.vue @@ -1,9 +1,10 @@