Skip to content

Commit 439b3e6

Browse files
committed
feat: implement custom bulk action handling and validation in resource actions
1 parent 520827a commit 439b3e6

File tree

8 files changed

+122
-13
lines changed

8 files changed

+122
-13
lines changed

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: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,10 @@ 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+
bulkHandler: !!action.bulkHandler,
686+
})),
683687
allowedActions,
684688
}
685689
}
@@ -1589,6 +1593,47 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
15891593
}
15901594
}
15911595
});
1596+
server.endpoint({
1597+
method: 'POST',
1598+
path: '/start_custom_bulk_action',
1599+
handler: async ({ body, adminUser, tr, response, cookies, headers }) => {
1600+
const { resourceId, actionId, recordIds, extra } = body;
1601+
const resource = this.adminforth.config.resources.find((res) => res.resourceId == resourceId);
1602+
if (!resource) {
1603+
return { error: await tr(`Resource {resourceId} not found`, 'errors', { resourceId }) };
1604+
}
1605+
const { allowedActions } = await interpretResource(
1606+
adminUser,
1607+
resource,
1608+
{ requestBody: body },
1609+
ActionCheckSource.CustomActionRequest,
1610+
this.adminforth
1611+
);
1612+
const action = resource.options.actions.find((act) => act.id == actionId);
1613+
if (!action) {
1614+
return { error: await tr(`Action {actionId} not found`, 'errors', { actionId }) };
1615+
}
1616+
if (!action.bulkHandler) {
1617+
return { error: `Action "${actionId}" has no bulkHandler` };
1618+
}
1619+
if (action.allowed) {
1620+
const execAllowed = await action.allowed({ adminUser, standardAllowedActions: allowedActions });
1621+
if (!execAllowed) {
1622+
return { error: await tr(`Action "{actionId}" not allowed`, 'errors', { actionId: action.name }) };
1623+
}
1624+
}
1625+
const result = await action.bulkHandler({
1626+
recordIds,
1627+
adminUser,
1628+
resource,
1629+
tr,
1630+
adminforth: this.adminforth,
1631+
response,
1632+
extra: { ...extra, cookies, headers },
1633+
});
1634+
return { actionId, recordIds, resourceId, ...result };
1635+
}
1636+
});
15921637
server.endpoint({
15931638
method: 'POST',
15941639
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?.bulkHandler && 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?.bulkHandler ? 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: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1305,6 +1305,19 @@ export interface AdminForthActionInput {
13051305
standardAllowedActions: AllowedActions;
13061306
}) => boolean;
13071307
url?: string;
1308+
bulkHandler?: (params: {
1309+
adminforth: IAdminForth;
1310+
resource: AdminForthResource;
1311+
recordIds: string[];
1312+
adminUser: AdminUser;
1313+
response: IAdminForthHttpResponse;
1314+
extra?: HttpExtra;
1315+
tr: Function;
1316+
}) => Promise<{
1317+
ok: boolean;
1318+
error?: string;
1319+
message?: string;
1320+
}>;
13081321
action?: (params: {
13091322
adminforth: IAdminForth;
13101323
resource: AdminForthResource;

0 commit comments

Comments
 (0)