Skip to content

Commit 49a1434

Browse files
authored
Merge pull request #134 from devforth/custom-actions
feat: implement custom actions for resources
2 parents 46be4e9 + 8151b51 commit 49a1434

File tree

5 files changed

+219
-8
lines changed

5 files changed

+219
-8
lines changed

adminforth/modules/configValidator.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,31 @@ export default class ConfigValidator implements IConfigValidator {
330330
return showInTransformedToObject as ShowIn;
331331
}
332332

333+
validateAndNormalizeCustomActions(resInput: AdminForthResourceInput, res: Partial<AdminForthResource>, errors: string[]): any[] {
334+
if (!resInput.options?.actions) {
335+
return [];
336+
}
337+
338+
const actions = [...resInput.options.actions];
339+
340+
actions.forEach((action) => {
341+
if (!action.name) {
342+
errors.push(`Resource "${res.resourceId}" has action without name`);
343+
}
344+
345+
if (!action.action) {
346+
errors.push(`Resource "${res.resourceId}" action "${action.name}" must have action function`);
347+
}
348+
349+
// Generate ID if not present
350+
if (!action.id) {
351+
action.id = md5hash(action.name);
352+
}
353+
});
354+
355+
return actions;
356+
}
357+
333358
validateAndNormalizeResources(errors: string[], warnings: string[]): AdminForthResource[] {
334359
if (!this.inputConfig.resources) {
335360
errors.push('No resources defined, at least one resource must be defined');
@@ -645,6 +670,7 @@ export default class ConfigValidator implements IConfigValidator {
645670
}
646671

647672
options.bulkActions = this.validateAndNormalizeBulkActions(resInput, res, errors);
673+
options.actions = this.validateAndNormalizeCustomActions(resInput, res, errors);
648674

649675
// if pageInjection is a string, make array with one element. Also check file exists
650676
const possibleInjections = ['beforeBreadcrumbs', 'afterBreadcrumbs', 'bottom', 'threeDotsDropdownItems', 'customActionIcons'];

adminforth/modules/restApi.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1185,5 +1185,31 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
11851185
this.adminforth.activatedPlugins.forEach((plugin) => {
11861186
plugin.setupEndpoints(server);
11871187
});
1188+
1189+
server.endpoint({
1190+
method: 'POST',
1191+
path: '/start_custom_action',
1192+
handler: async ({ body, adminUser, tr }) => {
1193+
const { resourceId, actionId, recordId } = body;
1194+
const resource = this.adminforth.config.resources.find((res) => res.resourceId == resourceId);
1195+
if (!resource) {
1196+
return { error: await tr(`Resource {resourceId} not found`, 'errors', { resourceId }) };
1197+
}
1198+
console.log("resource", actionId);
1199+
const action = resource.options.actions.find((act) => act.id == actionId);
1200+
if (!action) {
1201+
return { error: await tr(`Action {actionId} not found`, 'errors', { actionId }) };
1202+
}
1203+
1204+
const response = await action.action({ recordId, adminUser, resource, tr });
1205+
1206+
return {
1207+
actionId,
1208+
recordId,
1209+
resourceId,
1210+
...response
1211+
}
1212+
}
1213+
});
11881214
}
11891215
}

adminforth/spa/src/components/ResourceListTable.vue

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,19 @@
172172
:record="row"
173173
/>
174174
</template>
175+
176+
<template v-if="resource.options?.actions">
177+
<Tooltip v-for="action in resource.options.actions.filter(a => a.showIn?.list)" :key="action.id">
178+
<button
179+
@click="startCustomAction(action.id, row)"
180+
>
181+
<component v-if="action.icon" :is="getIcon(action.icon)" class="w-5 h-5 text-lightPrimary dark:text-darkPrimary"></component>
182+
</button>
183+
<template v-slot:tooltip>
184+
{{ action.name }}
185+
</template>
186+
</Tooltip>
187+
</template>
175188
</div>
176189
</td>
177190
</tr>
@@ -280,7 +293,7 @@ import { getCustomComponent } from '@/utils';
280293
import { useCoreStore } from '@/stores/core';
281294
import { showSuccesTost, showErrorTost } from '@/composables/useFrontendApi';
282295
import SkeleteLoader from '@/components/SkeleteLoader.vue';
283-
296+
import { getIcon } from '@/utils';
284297
import {
285298
IconInboxOutline,
286299
} from '@iconify-prerendered/vue-flowbite';
@@ -505,4 +518,39 @@ async function deleteRecord(row) {
505518
};
506519
}
507520
}
521+
522+
const actionLoadingStates = ref({});
523+
524+
async function startCustomAction(actionId, row) {
525+
actionLoadingStates.value[actionId] = true;
526+
527+
const data = await callAdminForthApi({
528+
path: '/start_custom_action',
529+
method: 'POST',
530+
body: {
531+
resourceId: props.resource.resourceId,
532+
actionId: actionId,
533+
recordId: row._primaryKeyValue
534+
}
535+
});
536+
537+
actionLoadingStates.value[actionId] = false;
538+
539+
if (data?.ok) {
540+
emits('update:records', true);
541+
542+
if (data.successMessage) {
543+
adminforth.alert({
544+
message: data.successMessage,
545+
variant: 'success'
546+
});
547+
}
548+
}
549+
550+
if (data?.error) {
551+
showErrorTost(data.error);
552+
}
553+
}
554+
555+
508556
</script>

adminforth/spa/src/components/ThreeDotsMenu.vue

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<template >
2-
<template v-if="threeDotsDropdownItems?.length">
2+
<template v-if="threeDotsDropdownItems?.length || customActions?.length">
33
<button
44
data-dropdown-toggle="listThreeDotsDropdown"
55
class="flex items-center py-2 px-2 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded border border-gray-300 hover:bg-gray-100 hover:text-lightPrimary focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 rounded-default"
@@ -23,21 +23,73 @@
2323
/>
2424
</a>
2525
</li>
26+
<li v-for="action in customActions" :key="action.id">
27+
<a href="#" @click.prevent="handleActionClick(action)" class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">
28+
<div class="flex items-center gap-2">
29+
<component
30+
v-if="action.icon"
31+
:is="getIcon(action.icon)"
32+
class="w-4 h-4 text-lightPrimary dark:text-darkPrimary"
33+
/>
34+
{{ action.name }}
35+
</div>
36+
</a>
37+
</li>
2638
</ul>
2739
</div>
2840
</template>
2941
</template>
3042

3143

3244
<script setup lang="ts">
45+
import { getCustomComponent, getIcon } from '@/utils';
46+
import { useCoreStore } from '@/stores/core';
47+
import adminforth from '@/adminforth';
48+
import { callAdminForthApi } from '@/utils';
49+
import { useRoute } from 'vue-router';
3350
34-
import { getCustomComponent } from '@/utils';
35-
import { useCoreStore } from '@/stores/core'
51+
const route = useRoute();
52+
const coreStore = useCoreStore();
3653
37-
const coreStore = useCoreStore()
54+
const props = defineProps({
55+
threeDotsDropdownItems: Array,
56+
customActions: Array
57+
});
3858
39-
const props = defineProps<{
40-
threeDotsDropdownItems: any[] | undefined
41-
}>()
59+
async function handleActionClick(action) {
60+
adminforth.list.closeThreeDotsDropdown();
61+
62+
const actionId = action.id;
63+
const data = await callAdminForthApi({
64+
path: '/start_custom_action',
65+
method: 'POST',
66+
body: {
67+
resourceId: route.params.resourceId,
68+
actionId: actionId,
69+
recordId: route.params.primaryKey
70+
}
71+
});
72+
73+
if (data?.ok) {
74+
await coreStore.fetchRecord({
75+
resourceId: route.params.resourceId,
76+
primaryKey: route.params.primaryKey,
77+
source: 'show',
78+
});
4279
80+
if (data.successMessage) {
81+
adminforth.alert({
82+
message: data.successMessage,
83+
variant: 'success'
84+
});
85+
}
86+
}
87+
88+
if (data?.error) {
89+
adminforth.alert({
90+
message: data.error,
91+
variant: 'danger'
92+
});
93+
}
94+
}
4395
</script>

adminforth/spa/src/views/ShowView.vue

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,22 @@
1010
:adminUser="coreStore.adminUser"
1111
/>
1212
<BreadcrumbsWithButtons>
13+
<template v-if="coreStore.resource?.options?.actions">
14+
<button
15+
v-for="action in coreStore.resource.options.actions.filter(a => a.showIn?.showButton)"
16+
:key="action.id"
17+
@click="startCustomAction(action.id)"
18+
:disabled="actionLoadingStates[action.id]"
19+
class="flex items-center py-1 px-3 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-default border border-gray-300 hover:bg-gray-100 hover:text-lightPrimary focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
20+
>
21+
<component
22+
v-if="action.icon"
23+
:is="getIcon(action.icon)"
24+
class="w-4 h-4 me-2 text-lightPrimary dark:text-darkPrimary"
25+
/>
26+
{{ action.name }}
27+
</button>
28+
</template>
1329
<RouterLink v-if="coreStore.resource?.options?.allowedActions?.create"
1430
:to="{ name: 'resource-create', params: { resourceId: $route.params.resourceId } }"
1531
class="flex items-center py-1 px-3 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded border border-gray-300 hover:bg-gray-100 hover:text-lightPrimary focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700 rounded-default"
@@ -34,6 +50,7 @@
3450

3551
<ThreeDotsMenu
3652
:threeDotsDropdownItems="coreStore.resourceOptions?.pageInjections?.show?.threeDotsDropdownItems"
53+
:customActions="customActions"
3754
></ThreeDotsMenu>
3855
</BreadcrumbsWithButtons>
3956

@@ -121,13 +138,20 @@ import ThreeDotsMenu from '@/components/ThreeDotsMenu.vue';
121138
import ShowTable from '@/components/ShowTable.vue';
122139
import adminforth from "@/adminforth";
123140
import { useI18n } from 'vue-i18n';
141+
import { getIcon } from '@/utils';
124142
125143
const route = useRoute();
126144
const router = useRouter();
127145
const loading = ref(true);
128146
const { t } = useI18n();
129147
const coreStore = useCoreStore();
130148
149+
const actionLoadingStates = ref({});
150+
151+
const customActions = computed(() => {
152+
return coreStore.resource?.options?.actions?.filter(a => a.showIn?.showThreeDotsMenu) || [];
153+
});
154+
131155
onMounted(async () => {
132156
loading.value = true;
133157
await coreStore.fetchResourceFull({
@@ -206,4 +230,39 @@ async function deleteRecord(row) {
206230
207231
}
208232
233+
async function startCustomAction(actionId) {
234+
actionLoadingStates.value[actionId] = true;
235+
236+
const data = await callAdminForthApi({
237+
path: '/start_custom_action',
238+
method: 'POST',
239+
body: {
240+
resourceId: route.params.resourceId,
241+
actionId: actionId,
242+
recordId: route.params.primaryKey
243+
}
244+
});
245+
246+
actionLoadingStates.value[actionId] = false;
247+
248+
if (data?.ok) {
249+
await coreStore.fetchRecord({
250+
resourceId: route.params.resourceId,
251+
primaryKey: route.params.primaryKey,
252+
source: 'show',
253+
});
254+
255+
if (data.successMessage) {
256+
adminforth.alert({
257+
message: data.successMessage,
258+
variant: 'success'
259+
});
260+
}
261+
}
262+
263+
if (data?.error) {
264+
showErrorTost(data.error);
265+
}
266+
}
267+
209268
</script>

0 commit comments

Comments
 (0)