Skip to content

Commit fe7823b

Browse files
authored
Merge pull request #529 from devforth/feature/AdminForth/1374/instead-of-bulk-actions-we-nee
feat: implement custom bulk action handling and validation in resourc…
2 parents 87b3fb0 + f19375c commit fe7823b

File tree

10 files changed

+172
-16
lines changed

10 files changed

+172
-16
lines changed

adminforth/documentation/docs/tutorial/03-Customization/09-Actions.md

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,57 @@ Here's how to add a custom action:
4848
- `name`: Display name of the action
4949
- `icon`: Icon to show (using Flowbite icon set)
5050
- `allowed`: Function to control access to the action
51-
- `action`: Handler function that executes when action is triggered
51+
- `action`: Handler function that executes when action is triggered for a **single** record
52+
- `bulkHandler`: Handler function that executes when the action is triggered for **multiple** records at once (see [Bulk button with bulkHandler](#bulk-button-with-bulkhandler))
5253
- `showIn`: Controls where the action appears
5354
- `list`: whether to show in list view
5455
- `listThreeDotsMenu`: whether to show in three dots menu in the list view
5556
- `showButton`: whether to show as a button on show view
5657
- `showThreeDotsMenu`: when to show in the three-dots menu of show view
58+
- `bulkButton`: whether to show as a bulk-selection button in the list view
59+
60+
### Bulk button with `action`
61+
62+
When `showIn.bulkButton` is `true` and only `action` (not `bulkHandler`) is defined, AdminForth automatically calls your `action` function **once per selected record** using `Promise.all`. This is convenient for simple cases but means N separate handler invocations run in parallel:
63+
64+
```ts title="./resources/apartments.ts"
65+
{
66+
name: 'Auto submit',
67+
action: async ({ recordId }) => {
68+
// Called once per selected record when used as a bulk button
69+
await doSomething(recordId);
70+
return { ok: true, successMessage: 'Done' };
71+
},
72+
showIn: {
73+
bulkButton: true, // triggers Promise.all over selected records
74+
showButton: true,
75+
}
76+
}
77+
```
78+
79+
If your operation can be expressed more efficiently as a single batched query (e.g., a single `UPDATE … WHERE id IN (…)`), define `bulkHandler` instead. AdminForth will call it **once** with all selected record IDs:
80+
81+
```ts title="./resources/apartments.ts"
82+
{
83+
name: 'Auto submit',
84+
// bulkHandler receives all recordIds in one call – use it for batched operations
85+
bulkHandler: async ({ recordIds, adminforth, resource }) => {
86+
await doSomethingBatch(recordIds);
87+
return { ok: true, successMessage: `Processed ${recordIds.length} records` };
88+
},
89+
// You can still keep `action` for the single-record show/edit buttons
90+
action: async ({ recordId }) => {
91+
await doSomething(recordId);
92+
return { ok: true, successMessage: 'Done' };
93+
},
94+
showIn: {
95+
bulkButton: true,
96+
showButton: true,
97+
}
98+
}
99+
```
100+
101+
> ☝️ When both `action` and `bulkHandler` are defined, AdminForth uses `bulkHandler` for bulk operations and `action` for single-record operations. When only `action` is defined and `bulkButton` is enabled, AdminForth falls back to `Promise.all` over individual `action` calls.
57102
58103
### Access Control
59104

adminforth/modules/configValidator.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -400,12 +400,12 @@ export default class ConfigValidator implements IConfigValidator {
400400
errors.push(`Resource "${res.resourceId}" has action without name`);
401401
}
402402

403-
if (!action.action && !action.url) {
404-
errors.push(`Resource "${res.resourceId}" action "${action.name}" must have action or url`);
403+
if (!action.action && !action.bulkHandler && !action.url) {
404+
errors.push(`Resource "${res.resourceId}" action "${action.name}" must have action, bulkHandler or url`);
405405
}
406406

407-
if (action.action && action.url) {
408-
errors.push(`Resource "${res.resourceId}" action "${action.name}" cannot have both action and url`);
407+
if ((action.action && action.url) || (action.bulkHandler && action.url)) {
408+
errors.push(`Resource "${res.resourceId}" action "${action.name}" cannot combine url with action or bulkHandler`);
409409
}
410410

411411
if (action.customComponent) {
@@ -429,6 +429,11 @@ export default class ConfigValidator implements IConfigValidator {
429429
action.showIn.showButton = action.showIn.showButton ?? false;
430430
action.showIn.showThreeDotsMenu = action.showIn.showThreeDotsMenu ?? false;
431431
}
432+
433+
const shownInNonBulk = action.showIn.list || action.showIn.listThreeDotsMenu || action.showIn.showButton || action.showIn.showThreeDotsMenu;
434+
if (shownInNonBulk && !action.action && !action.url) {
435+
errors.push(`Resource "${res.resourceId}" action "${action.name}" has showIn enabled for non-bulk locations (list, listThreeDotsMenu, showButton, showThreeDotsMenu) but has no "action" or "url" handler. Either add an "action" handler or set those showIn flags to false.`);
436+
}
432437
});
433438

434439
return actions;

adminforth/modules/restApi.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,11 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
680680
confirm: action.confirm ? translated[`bulkActionConfirm${i}`] : action.confirm,
681681
})
682682
),
683+
actions: resource.options.actions?.map((action) => ({
684+
...action,
685+
hasBulkHandler: !!action.bulkHandler,
686+
bulkHandler: undefined,
687+
})),
683688
allowedActions,
684689
}
685690
}
@@ -1589,6 +1594,47 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
15891594
}
15901595
}
15911596
});
1597+
server.endpoint({
1598+
method: 'POST',
1599+
path: '/start_custom_bulk_action',
1600+
handler: async ({ body, adminUser, tr, response, cookies, headers }) => {
1601+
const { resourceId, actionId, recordIds, extra } = body;
1602+
const resource = this.adminforth.config.resources.find((res) => res.resourceId == resourceId);
1603+
if (!resource) {
1604+
return { error: await tr(`Resource {resourceId} not found`, 'errors', { resourceId }) };
1605+
}
1606+
const { allowedActions } = await interpretResource(
1607+
adminUser,
1608+
resource,
1609+
{ requestBody: body },
1610+
ActionCheckSource.CustomActionRequest,
1611+
this.adminforth
1612+
);
1613+
const action = resource.options.actions.find((act) => act.id == actionId);
1614+
if (!action) {
1615+
return { error: await tr(`Action {actionId} not found`, 'errors', { actionId }) };
1616+
}
1617+
if (!action.bulkHandler) {
1618+
return { error: await tr(`Action "{actionId}" has no bulkHandler`, 'errors', { actionId }) };
1619+
}
1620+
if (action.allowed) {
1621+
const execAllowed = await action.allowed({ adminUser, standardAllowedActions: allowedActions });
1622+
if (!execAllowed) {
1623+
return { error: await tr(`Action "{actionId}" not allowed`, 'errors', { actionId: action.name }) };
1624+
}
1625+
}
1626+
const result = await action.bulkHandler({
1627+
recordIds,
1628+
adminUser,
1629+
resource,
1630+
tr,
1631+
adminforth: this.adminforth,
1632+
response,
1633+
extra: { ...extra, cookies, headers },
1634+
});
1635+
return { actionId, recordIds, resourceId, ...result };
1636+
}
1637+
});
15921638
server.endpoint({
15931639
method: 'POST',
15941640
path: '/validate_columns',

adminforth/spa/src/components/ResourceListTable.vue

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,12 +211,25 @@
211211
<button
212212
type="button"
213213
class="border border-gray-300 dark:border-gray-700 dark:border-opacity-0 border-opacity-0 hover:border-opacity-100 dark:hover:border-opacity-100 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
214+
:disabled="!!actionLoadingStates[`${action.id}_${row._primaryKeyValue}`]"
214215
>
215216
<component
216-
v-if="action.icon"
217+
v-if="action.icon && !actionLoadingStates[`${action.id}_${row._primaryKeyValue}`]"
217218
:is="getIcon(action.icon)"
218219
class="w-6 h-6 text-lightPrimary dark:text-darkPrimary"
219220
/>
221+
<svg
222+
v-if="actionLoadingStates[`${action.id}_${row._primaryKeyValue}`]"
223+
aria-hidden="true"
224+
class="w-6 h-6 p-1 animate-spin text-gray-200 dark:text-gray-500 fill-gray-500 dark:fill-gray-300"
225+
viewBox="0 0 100 101"
226+
fill="none"
227+
xmlns="http://www.w3.org/2000/svg"
228+
>
229+
<path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/>
230+
<path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/>
231+
</svg>
232+
<span v-if="actionLoadingStates[`${action.id}_${row._primaryKeyValue}`]" class="sr-only">Loading...</span>
220233
</button>
221234
</component>
222235

@@ -613,7 +626,7 @@ async function startCustomAction(actionId: string | number, row: any, extraData:
613626
recordId: row._primaryKeyValue,
614627
extra: extraData,
615628
setLoadingState: (loading: boolean) => {
616-
actionLoadingStates.value[actionId] = loading;
629+
actionLoadingStates.value[`${actionId}_${row._primaryKeyValue}`] = loading;
617630
},
618631
onSuccess: async (data: any) => {
619632
emits('update:records', true);

adminforth/spa/src/components/ThreeDotsMenu.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
@callAction="(payload? : Object) => handleActionClick(action, payload)"
5252
>
5353
<a @click.prevent class="block">
54-
<div class="flex items-center gap-2">
54+
<div class="flex items-center gap-2 px-4 py-2 hover:text-lightThreeDotsMenuBodyTextHover hover:bg-lightThreeDotsMenuBodyBackgroundHover dark:hover:bg-darkThreeDotsMenuBodyBackgroundHover dark:hover:text-darkThreeDotsMenuBodyTextHover">
5555
<component
5656
v-if="action.icon && !actionLoadingStates[action.id!]"
5757
:is="getIcon(action.icon)"

adminforth/spa/src/utils/utils.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Dropdown } from 'flowbite';
88
import adminforth, { useAdminforth } from '../adminforth';
99
import sanitizeHtml from 'sanitize-html'
1010
import debounce from 'debounce';
11-
import type { AdminForthResourceColumnInputCommon, Predicate } from '@/types/Common';
11+
import type { AdminForthActionFront, AdminForthResourceColumnInputCommon, AdminForthResourceCommon, Predicate } from '@/types/Common';
1212
import { i18nInstance } from '../i18n'
1313
import { useI18n } from 'vue-i18n';
1414
import { onBeforeRouteLeave } from 'vue-router';
@@ -769,6 +769,7 @@ export async function executeCustomBulkAction({
769769
onError,
770770
setLoadingState,
771771
confirmMessage,
772+
resource,
772773
}: {
773774
actionId: string | number | undefined,
774775
resourceId: string,
@@ -778,6 +779,7 @@ export async function executeCustomBulkAction({
778779
onError?: (error: string) => void,
779780
setLoadingState?: (loading: boolean) => void,
780781
confirmMessage?: string,
782+
resource?: AdminForthResourceCommon,
781783
}): Promise<any> {
782784
if (!recordIds || recordIds.length === 0) {
783785
if (onError) {
@@ -799,7 +801,38 @@ export async function executeCustomBulkAction({
799801
setLoadingState?.(true);
800802

801803
try {
802-
// Execute action for all records in parallel using Promise.all
804+
const action = resource?.options?.actions?.find((a: any) => a.id === actionId) as AdminForthActionFront | undefined;
805+
806+
if (action?.hasBulkHandler && action?.showIn?.bulkButton) {
807+
const result = await callAdminForthApi({
808+
path: '/start_custom_bulk_action',
809+
method: 'POST',
810+
body: {
811+
resourceId,
812+
actionId,
813+
recordIds,
814+
extra: extra || {},
815+
}
816+
});
817+
818+
if (result?.ok) {
819+
if (onSuccess) {
820+
await onSuccess([result]);
821+
}
822+
return { ok: true, results: [result] };
823+
}
824+
825+
if (result?.error) {
826+
if (onError) {
827+
onError(result.error);
828+
}
829+
return { error: result.error };
830+
}
831+
832+
return result;
833+
}
834+
835+
// Per-record parallel calls (legacy path)
803836
const results = await Promise.all(
804837
recordIds.map(recordId =>
805838
callAdminForthApi({
@@ -814,7 +847,6 @@ export async function executeCustomBulkAction({
814847
})
815848
)
816849
);
817-
818850
const lastResult = results[results.length - 1];
819851
if (lastResult?.redirectUrl) {
820852
if (lastResult.redirectUrl.includes('target=_blank')) {

adminforth/spa/src/views/ListView.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,7 @@ async function startCustomBulkActionInner(actionId: string | number) {
350350
resourceId: route.params.resourceId as string,
351351
recordIds: checkboxes.value,
352352
confirmMessage: action?.bulkConfirmationMessage,
353+
resource: coreStore.resource!,
353354
setLoadingState: (loading: boolean) => {
354355
customActionLoadingStates.value[actionId] = loading;
355356
},
@@ -360,7 +361,7 @@ async function startCustomBulkActionInner(actionId: string | number) {
360361
const successResults = results.filter(r => r?.successMessage);
361362
if (successResults.length > 0) {
362363
alert({
363-
message: action?.bulkSuccessMessage || `${successResults.length} out of ${results.length} items processed successfully`,
364+
message: action?.bulkSuccessMessage ? action.bulkSuccessMessage : action?.hasBulkHandler ? successResults[0].successMessage : `${successResults.length} out of ${results.length} items processed successfully`,
364365
variant: 'success'
365366
});
366367
}

adminforth/spa/src/views/ShowView.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
<BreadcrumbsWithButtons>
1313
<template v-if="coreStore.resource?.options?.actions">
1414

15-
<template v-for="action in coreStore.resource.options.actions.filter(a => a.showIn?.showButton)" :key="action.id">
15+
<div class="flex gap-1" v-for="action in coreStore.resource.options.actions.filter(a => a.showIn?.showButton)" :key="action.id">
1616
<component
1717
:is="action?.customComponent ? getCustomComponent(formatComponent(action.customComponent)) : CallActionWrapper"
1818
:meta="action.customComponent ? formatComponent(action.customComponent).meta : undefined"
@@ -45,7 +45,7 @@
4545
{{ action.name }}
4646
</button>
4747
</component>
48-
</template>
48+
</div>
4949
</template>
5050
<RouterLink v-if="coreStore.resource?.options?.allowedActions?.create"
5151
:to="{ name: 'resource-create', params: { resourceId: $route.params.resourceId } }"

adminforth/types/Back.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1305,14 +1305,27 @@ export interface AdminForthActionInput {
13051305
standardAllowedActions: AllowedActions;
13061306
}) => boolean;
13071307
url?: string;
1308+
bulkHandler?: (params: {
1309+
adminforth: IAdminForth;
1310+
resource: AdminForthResource;
1311+
recordIds: (string | number)[];
1312+
adminUser: AdminUser;
1313+
response: IAdminForthHttpResponse;
1314+
extra?: HttpExtra;
1315+
tr: ITranslateFunction;
1316+
}) => Promise<{
1317+
ok: boolean;
1318+
error?: string;
1319+
successMessage?: string;
1320+
}>;
13081321
action?: (params: {
13091322
adminforth: IAdminForth;
13101323
resource: AdminForthResource;
13111324
recordId: string;
13121325
adminUser: AdminUser;
13131326
response: IAdminForthHttpResponse;
13141327
extra?: HttpExtra;
1315-
tr: Function;
1328+
tr: ITranslateFunction;
13161329
}) => Promise<{
13171330
ok: boolean;
13181331
error?: string;

adminforth/types/Common.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,8 +314,9 @@ export type FieldGroup = {
314314
noTitle?: boolean;
315315
};
316316

317-
export interface AdminForthActionFront extends Omit<AdminForthActionInput, 'id'> {
317+
export interface AdminForthActionFront extends Omit<AdminForthActionInput, 'id' | 'bulkHandler' | 'action' | 'allowed'> {
318318
id: string;
319+
hasBulkHandler?: boolean;
319320
}
320321

321322
export interface AdminForthBulkActionFront extends Omit<AdminForthBulkActionCommon, 'id'> {

0 commit comments

Comments
 (0)