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 @@
+
+
+
+
+
+
+
+
+
+
+ {{ action.label }}
+
+
+
+
+
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 @@
@@ -45,7 +60,7 @@ const labels = computed(() => new Map(props.actions.map((action) => [action.name
/>
-
+
new Map(props.actions.map((action) => [action.name
>
{{ selectedLabel }}
+
emit('bulk', name)"
+ />