Skip to content

Commit be7badd

Browse files
authored
Merge pull request #139 from devforth/custom-actions
feat: enhance custom actions with URL redirection and validation
2 parents 238aea6 + 666fe13 commit be7badd

File tree

10 files changed

+273
-8
lines changed

10 files changed

+273
-8
lines changed
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# Actions
2+
3+
You might need to give admin users a feature to perform some action on a single record. Actions can be displayed as buttons in the list view and/or in the three-dots menu.
4+
5+
Here's how to add a custom action:
6+
7+
```ts title="./resources/apartments.ts"
8+
{
9+
resourceId: 'aparts',
10+
options: {
11+
actions: [
12+
{
13+
name: 'Auto submit', // Display name of the action
14+
icon: 'flowbite:play-solid', // Icon to display (using Flowbite icons)
15+
16+
// Control who can see/use this action
17+
allowed: ({ adminUser, standardAllowedActions }) => {
18+
return true; // Allow everyone
19+
},
20+
21+
// Handler function when action is triggered
22+
action: ({ recordId, adminUser }) => {
23+
console.log("auto submit", recordId, adminUser);
24+
return {
25+
ok: true,
26+
successMessage: "Auto submitted"
27+
};
28+
},
29+
30+
// Configure where the action appears
31+
showIn: {
32+
list: true, // Show in list view
33+
showButton: true, // Show as a button
34+
showThreeDotsMenu: true, // Show in three-dots menu
35+
}
36+
}
37+
]
38+
}
39+
}
40+
```
41+
42+
## Action Configuration Options
43+
44+
- `name`: Display name of the action
45+
- `icon`: Icon to show (using Flowbite icon set)
46+
- `allowed`: Function to control access to the action
47+
- `action`: Handler function that executes when action is triggered
48+
- `showIn`: Controls where the action appears
49+
- `list`: Show in list view
50+
- `showButton`: Show as a button
51+
- `showThreeDotsMenu`: Show in three-dots menu
52+
53+
## Access Control
54+
55+
You can control who can use an action through the `allowed` function. This function receives:
56+
57+
```ts title="./resources/apartments.ts"
58+
{
59+
options: {
60+
actions: [
61+
{
62+
name: 'Auto submit',
63+
allowed: ({ adminUser, standardAllowedActions }) => {
64+
if (adminUser.dbUser.role !== 'superadmin') {
65+
return false;
66+
}
67+
return true;
68+
},
69+
// ... other configuration
70+
}
71+
]
72+
}
73+
}
74+
```
75+
76+
The `allowed` function receives:
77+
- `adminUser`: The current admin user object
78+
- `standardAllowedActions`: Standard permissions for the current user
79+
80+
Return:
81+
- `true` to allow access
82+
- `false` to deny access
83+
- A string with an error message to explain why access was denied
84+
85+
Here is how it looks:
86+
![alt text](<Custom bulk actions.png>)
87+
88+
89+
You might want to allow only certain users to perform your custom bulk action.
90+
91+
To implement this limitation use `allowed`:
92+
93+
If you want to prohibit the use of bulk action for user, you can do it this way:
94+
95+
```ts title="./resources/apartments.ts"
96+
bulkActions: [
97+
{
98+
label: 'Mark as listed',
99+
icon: 'flowbite:eye-solid',
100+
state:'active',
101+
allowed: async ({ resource, adminUser, selectedIds }) => {
102+
if (adminUser.dbUser.role !== 'superadmin') {
103+
return false;
104+
}
105+
return true;
106+
},
107+
confirm: 'Are you sure you want to mark all selected apartments as listed?',
108+
action: function ({selectedIds, adminUser }: {selectedIds: any[], adminUser: AdminUser }, allow) {
109+
const stmt = admin.resource('aparts').dataConnector.db.prepare(`UPDATE apartments SET listed = 1 WHERE id IN (${selectedIds.map(() => '?').join(',')}`);
110+
stmt.run(...selectedIds);
111+
return { ok: true, error: false, successMessage: `Marked ${selectedIds.length} apartments as listed` };
112+
},
113+
}
114+
],
115+
```
116+
117+
## Action URL
118+
119+
Instead of defining an `action` handler, you can specify a `url` that the user will be redirected to when clicking the action button:
120+
121+
```ts title="./resources/apartments.ts"
122+
{
123+
name: 'View details',
124+
icon: 'flowbite:eye-solid',
125+
url: '/resource/aparts', // URL to redirect to
126+
showIn: {
127+
list: true,
128+
showButton: true,
129+
showThreeDotsMenu: true,
130+
}
131+
}
132+
```
133+
134+
The URL can be:
135+
- A relative path within your admin panel (starting with '/')
136+
- An absolute URL (starting with 'http://' or 'https://')
137+
138+
To open the URL in a new tab, add `?target=_blank` to the URL:
139+
140+
```ts
141+
{
142+
name: 'View on Google',
143+
icon: 'flowbite:external-link-solid',
144+
url: 'https://google.com/search?q=apartment&target=_blank',
145+
showIn: {
146+
list: true,
147+
showButton: true
148+
}
149+
}
150+
```
151+
152+
> ☝️ Note: You cannot specify both `action` and `url` for the same action - only one should be used.
File renamed without changes.
File renamed without changes.

adminforth/modules/configValidator.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -361,14 +361,29 @@ export default class ConfigValidator implements IConfigValidator {
361361
errors.push(`Resource "${res.resourceId}" has action without name`);
362362
}
363363

364-
if (!action.action) {
365-
errors.push(`Resource "${res.resourceId}" action "${action.name}" must have action function`);
364+
if (!action.action && !action.url) {
365+
errors.push(`Resource "${res.resourceId}" action "${action.name}" must have action or url`);
366+
}
367+
368+
if (action.action && action.url) {
369+
errors.push(`Resource "${res.resourceId}" action "${action.name}" cannot have both action and url`);
366370
}
367371

368372
// Generate ID if not present
369373
if (!action.id) {
370374
action.id = md5hash(action.name);
371375
}
376+
if (!action.showIn) {
377+
action.showIn = {
378+
list: true,
379+
showButton: false,
380+
showThreeDotsMenu: false,
381+
}
382+
} else {
383+
action.showIn.list = action.showIn.list ?? true;
384+
action.showIn.showButton = action.showIn.showButton ?? false;
385+
action.showIn.showThreeDotsMenu = action.showIn.showThreeDotsMenu ?? false;
386+
}
372387
});
373388

374389
return actions;

adminforth/modules/restApi.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export async function interpretResource(
4949
[ActionCheckSource.CreateRequest]: ['create'],
5050
[ActionCheckSource.DisplayButtons]: ['show', 'edit', 'delete', 'create', 'filter'],
5151
[ActionCheckSource.BulkActionRequest]: ['show', 'edit', 'delete', 'create', 'filter'],
52+
[ActionCheckSource.CustomActionRequest]: ['show', 'edit', 'delete', 'create', 'filter'],
5253
}[source];
5354

5455
await Promise.all(
@@ -1225,12 +1226,32 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
12251226
if (!resource) {
12261227
return { error: await tr(`Resource {resourceId} not found`, 'errors', { resourceId }) };
12271228
}
1228-
console.log("resource", actionId);
1229+
const { allowedActions } = await interpretResource(
1230+
adminUser,
1231+
resource,
1232+
{ requestBody: body },
1233+
ActionCheckSource.CustomActionRequest,
1234+
this.adminforth
1235+
);
12291236
const action = resource.options.actions.find((act) => act.id == actionId);
12301237
if (!action) {
12311238
return { error: await tr(`Action {actionId} not found`, 'errors', { actionId }) };
12321239
}
1233-
1240+
if (action.allowed) {
1241+
const execAllowed = await action.allowed({ adminUser, standardAllowedActions: allowedActions });
1242+
if (!execAllowed) {
1243+
return { error: await tr(`Action "{actionId}" not allowed`, 'errors', { actionId: action.name }) };
1244+
}
1245+
}
1246+
1247+
if (action.url) {
1248+
return {
1249+
actionId,
1250+
recordId,
1251+
resourceId,
1252+
redirectUrl: action.url
1253+
}
1254+
}
12341255
const response = await action.action({ recordId, adminUser, resource, tr, adminforth: this.adminforth });
12351256

12361257
return {

adminforth/spa/src/components/ResourceListTable.vue

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,20 @@ async function startCustomAction(actionId, row) {
541541
542542
actionLoadingStates.value[actionId] = false;
543543
544+
if (data?.redirectUrl) {
545+
// Check if the URL should open in a new tab
546+
if (data.redirectUrl.includes('target=_blank')) {
547+
window.open(data.redirectUrl.replace('&target=_blank', '').replace('?target=_blank', ''), '_blank');
548+
} else {
549+
// Navigate within the app
550+
if (data.redirectUrl.startsWith('http')) {
551+
window.location.href = data.redirectUrl;
552+
} else {
553+
router.push(data.redirectUrl);
554+
}
555+
}
556+
return;
557+
}
544558
if (data?.ok) {
545559
emits('update:records', true);
546560

adminforth/spa/src/components/ThreeDotsMenu.vue

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,11 @@ import { getCustomComponent, getIcon } from '@/utils';
4646
import { useCoreStore } from '@/stores/core';
4747
import adminforth from '@/adminforth';
4848
import { callAdminForthApi } from '@/utils';
49-
import { useRoute } from 'vue-router';
49+
import { useRoute, useRouter } from 'vue-router';
5050
5151
const route = useRoute();
5252
const coreStore = useCoreStore();
53+
const router = useRouter();
5354
5455
const props = defineProps({
5556
threeDotsDropdownItems: Array,
@@ -69,6 +70,21 @@ async function handleActionClick(action) {
6970
recordId: route.params.primaryKey
7071
}
7172
});
73+
74+
if (data?.redirectUrl) {
75+
// Check if the URL should open in a new tab
76+
if (data.redirectUrl.includes('target=_blank')) {
77+
window.open(data.redirectUrl.replace('&target=_blank', '').replace('?target=_blank', ''), '_blank');
78+
} else {
79+
// Navigate within the app
80+
if (data.redirectUrl.startsWith('http')) {
81+
window.location.href = data.redirectUrl;
82+
} else {
83+
router.push(data.redirectUrl);
84+
}
85+
}
86+
return;
87+
}
7288
7389
if (data?.ok) {
7490
await coreStore.fetchRecord({

adminforth/spa/src/views/ShowView.vue

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,21 @@ async function startCustomAction(actionId) {
245245
246246
actionLoadingStates.value[actionId] = false;
247247
248+
if (data?.redirectUrl) {
249+
// Check if the URL should open in a new tab
250+
if (data.redirectUrl.includes('target=_blank')) {
251+
window.open(data.redirectUrl.replace('&target=_blank', '').replace('?target=_blank', ''), '_blank');
252+
} else {
253+
// Navigate within the app
254+
if (data.redirectUrl.startsWith('http')) {
255+
window.location.href = data.redirectUrl;
256+
} else {
257+
router.push(data.redirectUrl);
258+
}
259+
}
260+
return;
261+
}
262+
248263
if (data?.ok) {
249264
await coreStore.fetchRecord({
250265
resourceId: route.params.resourceId,

adminforth/types/Back.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -727,7 +727,12 @@ export interface AdminForthActionInput {
727727
showButton?: boolean,
728728
showThreeDotsMenu?: boolean,
729729
};
730-
action: (params: {
730+
allowed?: (params: {
731+
adminUser: AdminUser;
732+
standardAllowedActions: AllowedActions;
733+
}) => boolean;
734+
url?: string;
735+
action?: (params: {
731736
adminforth: IAdminForth;
732737
resource: AdminForthResource;
733738
recordId: string;
@@ -1146,8 +1151,6 @@ export interface ResourceOptionsInput extends Omit<AdminForthResourceCommon['opt
11461151
*/
11471152
bulkActions?: Array<AdminForthBulkAction>,
11481153

1149-
actions?: Array<AdminForthActionInput>,
1150-
11511154
/**
11521155
* Allowed actions for resource.
11531156
*
@@ -1165,10 +1168,38 @@ export interface ResourceOptionsInput extends Omit<AdminForthResourceCommon['opt
11651168
*
11661169
*/
11671170
allowedActions?: AllowedActionsInput,
1171+
1172+
/**
1173+
* Array of actions which will be displayed in the resource.
1174+
*
1175+
* Example:
1176+
*
1177+
* ```ts
1178+
* actions: [
1179+
* {
1180+
* name: 'Auto submit',
1181+
* allowed: ({ adminUser, standardAllowedActions }) => {
1182+
* return adminUser.dbUser.role === 'superadmin';
1183+
* },
1184+
* action: ({ adminUser, resource, recordId, adminforth, extra, tr }) => {
1185+
* console.log("auto submit", recordId, adminUser);
1186+
* return { ok: true, successMessage: "Auto submitted" };
1187+
* },
1188+
* showIn: {
1189+
* list: true,
1190+
* showButton: true,
1191+
* showThreeDotsMenu: true,
1192+
* },
1193+
* },
1194+
* ]
1195+
* ```
1196+
*/
1197+
actions?: Array<AdminForthActionInput>,
11681198
};
11691199

11701200
export interface ResourceOptions extends Omit<ResourceOptionsInput, 'allowedActions'> {
11711201
allowedActions: AllowedActions,
1202+
actions?: Array<AdminForthActionInput>,
11721203
}
11731204

11741205
/**

adminforth/types/Common.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export enum ActionCheckSource {
5050
CreateRequest = 'createRequest',
5151
DeleteRequest = 'deleteRequest',
5252
BulkActionRequest = 'bulkActionRequest',
53+
CustomActionRequest = 'customActionRequest',
5354
}
5455

5556
export enum AllowedActionsEnum {

0 commit comments

Comments
 (0)