From 47ecc4788183355dc473d66d86b9494e7fb01cf5 Mon Sep 17 00:00:00 2001 From: Vitalii Kulyk Date: Mon, 10 Mar 2025 14:30:27 +0200 Subject: [PATCH 1/4] feat: enhance custom actions with URL redirection and validation --- adminforth/modules/configValidator.ts | 8 ++++-- adminforth/modules/restApi.ts | 27 ++++++++++++++++--- .../spa/src/components/ResourceListTable.vue | 14 ++++++++++ .../spa/src/components/ThreeDotsMenu.vue | 18 ++++++++++++- adminforth/spa/src/views/ShowView.vue | 15 +++++++++++ adminforth/types/Back.ts | 27 +++++++++++++++++++ adminforth/types/Common.ts | 1 + 7 files changed, 104 insertions(+), 6 deletions(-) diff --git a/adminforth/modules/configValidator.ts b/adminforth/modules/configValidator.ts index 6c86d0688..d0a72d517 100644 --- a/adminforth/modules/configValidator.ts +++ b/adminforth/modules/configValidator.ts @@ -342,8 +342,12 @@ export default class ConfigValidator implements IConfigValidator { errors.push(`Resource "${res.resourceId}" has action without name`); } - if (!action.action) { - errors.push(`Resource "${res.resourceId}" action "${action.name}" must have action function`); + if (!action.action && !action.url) { + errors.push(`Resource "${res.resourceId}" action "${action.name}" must have action or url`); + } + + if (action.action && action.url) { + errors.push(`Resource "${res.resourceId}" action "${action.name}" cannot have both action and url`); } // Generate ID if not present diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index fdcdd364f..18d1296cc 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -49,6 +49,7 @@ export async function interpretResource( [ActionCheckSource.CreateRequest]: ['create'], [ActionCheckSource.DisplayButtons]: ['show', 'edit', 'delete', 'create', 'filter'], [ActionCheckSource.BulkActionRequest]: ['show', 'edit', 'delete', 'create', 'filter'], + [ActionCheckSource.CustomActionRequest]: ['show', 'edit', 'delete', 'create', 'filter'], }[source]; await Promise.all( @@ -1058,13 +1059,33 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { if (!resource) { return { error: await tr(`Resource {resourceId} not found`, 'errors', { resourceId }) }; } - console.log("resource", actionId); + const { allowedActions } = await interpretResource( + adminUser, + resource, + { requestBody: body }, + ActionCheckSource.CustomActionRequest, + this.adminforth + ); const action = resource.options.actions.find((act) => act.id == actionId); if (!action) { return { error: await tr(`Action {actionId} not found`, 'errors', { actionId }) }; } - - const response = await action.action({ recordId, adminUser, resource, tr }); + if (action.allowed) { + const execAllowed = await action.allowed({ adminUser, standardAllowedActions: allowedActions }); + if (!execAllowed) { + return { error: await tr(`Action "{actionId}" not allowed`, 'errors', { actionId: action.name }) }; + } + } + + if (action.url) { + return { + actionId, + recordId, + resourceId, + redirectUrl: action.url + } + } + const response = await action.action({ recordId, adminUser, resource, tr, adminforth: this.adminforth }); return { actionId, diff --git a/adminforth/spa/src/components/ResourceListTable.vue b/adminforth/spa/src/components/ResourceListTable.vue index e4d1669c4..78fae57f9 100644 --- a/adminforth/spa/src/components/ResourceListTable.vue +++ b/adminforth/spa/src/components/ResourceListTable.vue @@ -536,6 +536,20 @@ async function startCustomAction(actionId, row) { actionLoadingStates.value[actionId] = false; + if (data?.redirectUrl) { + // Check if the URL should open in a new tab + if (data.redirectUrl.includes('target=_blank')) { + window.open(data.redirectUrl.replace('&target=_blank', '').replace('?target=_blank', ''), '_blank'); + } else { + // Navigate within the app + if (data.redirectUrl.startsWith('http')) { + window.location.href = data.redirectUrl; + } else { + router.push(data.redirectUrl); + } + } + return; + } if (data?.ok) { emits('update:records', true); diff --git a/adminforth/spa/src/components/ThreeDotsMenu.vue b/adminforth/spa/src/components/ThreeDotsMenu.vue index 73851f40d..6cb2bf100 100644 --- a/adminforth/spa/src/components/ThreeDotsMenu.vue +++ b/adminforth/spa/src/components/ThreeDotsMenu.vue @@ -46,10 +46,11 @@ import { getCustomComponent, getIcon } from '@/utils'; import { useCoreStore } from '@/stores/core'; import adminforth from '@/adminforth'; import { callAdminForthApi } from '@/utils'; -import { useRoute } from 'vue-router'; +import { useRoute, useRouter } from 'vue-router'; const route = useRoute(); const coreStore = useCoreStore(); +const router = useRouter(); const props = defineProps({ threeDotsDropdownItems: Array, @@ -69,6 +70,21 @@ async function handleActionClick(action) { recordId: route.params.primaryKey } }); + + if (data?.redirectUrl) { + // Check if the URL should open in a new tab + if (data.redirectUrl.includes('target=_blank')) { + window.open(data.redirectUrl.replace('&target=_blank', '').replace('?target=_blank', ''), '_blank'); + } else { + // Navigate within the app + if (data.redirectUrl.startsWith('http')) { + window.location.href = data.redirectUrl; + } else { + router.push(data.redirectUrl); + } + } + return; + } if (data?.ok) { await coreStore.fetchRecord({ diff --git a/adminforth/spa/src/views/ShowView.vue b/adminforth/spa/src/views/ShowView.vue index 26a364b48..9b99831e5 100644 --- a/adminforth/spa/src/views/ShowView.vue +++ b/adminforth/spa/src/views/ShowView.vue @@ -245,6 +245,21 @@ async function startCustomAction(actionId) { actionLoadingStates.value[actionId] = false; + if (data?.redirectUrl) { + // Check if the URL should open in a new tab + if (data.redirectUrl.includes('target=_blank')) { + window.open(data.redirectUrl.replace('&target=_blank', '').replace('?target=_blank', ''), '_blank'); + } else { + // Navigate within the app + if (data.redirectUrl.startsWith('http')) { + window.location.href = data.redirectUrl; + } else { + router.push(data.redirectUrl); + } + } + return; + } + if (data?.ok) { await coreStore.fetchRecord({ resourceId: route.params.resourceId, diff --git a/adminforth/types/Back.ts b/adminforth/types/Back.ts index 33b0675af..a3861bdcd 100644 --- a/adminforth/types/Back.ts +++ b/adminforth/types/Back.ts @@ -720,6 +720,33 @@ interface AdminForthInputConfigCustomization { } } +export interface AdminForthActionInput { + name: string; + showIn?: { + list?: boolean, + showButton?: boolean, + showThreeDotsMenu?: boolean, + }; + allowed?: (params: { + adminUser: AdminUser; + standardAllowedActions: AllowedActionsEnum[]; + }) => boolean; + url?: string; + action?: (params: { + adminforth: IAdminForth; + resource: AdminForthResource; + recordId: string; + adminUser: AdminUser; + extra?: HttpExtra; + tr: Function; + }) => Promise<{ + ok: boolean; + error?: string; + message?: string; + }>; + icon?: string; + id?: string; +} export interface AdminForthResourceInput extends Omit { diff --git a/adminforth/types/Common.ts b/adminforth/types/Common.ts index 56429139d..cd3778a8d 100644 --- a/adminforth/types/Common.ts +++ b/adminforth/types/Common.ts @@ -50,6 +50,7 @@ export enum ActionCheckSource { CreateRequest = 'createRequest', DeleteRequest = 'deleteRequest', BulkActionRequest = 'bulkActionRequest', + CustomActionRequest = 'customActionRequest', } export enum AllowedActionsEnum { From b518075a5fba32581bb7c0dc08c19fc46b9f58f4 Mon Sep 17 00:00:00 2001 From: Vitalii Kulyk Date: Mon, 10 Mar 2025 15:41:14 +0200 Subject: [PATCH 2/4] fix: resolve typescript issues --- adminforth/types/Back.ts | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/adminforth/types/Back.ts b/adminforth/types/Back.ts index a3861bdcd..a636cc0a2 100644 --- a/adminforth/types/Back.ts +++ b/adminforth/types/Back.ts @@ -729,7 +729,7 @@ export interface AdminForthActionInput { }; allowed?: (params: { adminUser: AdminUser; - standardAllowedActions: AllowedActionsEnum[]; + standardAllowedActions: AllowedActions; }) => boolean; url?: string; action?: (params: { @@ -1168,10 +1168,38 @@ export interface ResourceOptionsInput extends Omit { + * return adminUser.dbUser.role === 'superadmin'; + * }, + * action: ({ adminUser, resource, recordId, adminforth, extra, tr }) => { + * console.log("auto submit", recordId, adminUser); + * return { ok: true, successMessage: "Auto submitted" }; + * }, + * showIn: { + * list: true, + * showButton: true, + * showThreeDotsMenu: true, + * }, + * }, + * ] + * ``` + */ + actions?: Array, }; export interface ResourceOptions extends Omit { allowedActions: AllowedActions, + actions?: Array, } /** From dae9dac08af71fbcdabce95f60e5e670e83b96fc Mon Sep 17 00:00:00 2001 From: Vitalii Kulyk Date: Mon, 10 Mar 2025 15:46:44 +0200 Subject: [PATCH 3/4] fix: remove unused property from ResourceOptionsInput interface --- adminforth/types/Back.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/adminforth/types/Back.ts b/adminforth/types/Back.ts index 5a7d7ba8c..a636cc0a2 100644 --- a/adminforth/types/Back.ts +++ b/adminforth/types/Back.ts @@ -1151,8 +1151,6 @@ export interface ResourceOptionsInput extends Omit, - actions?: Array, - /** * Allowed actions for resource. * From 28684f19208fc871261dd84d8fcd5ab0056ebdc8 Mon Sep 17 00:00:00 2001 From: Vitalii Kulyk Date: Mon, 10 Mar 2025 17:07:42 +0200 Subject: [PATCH 4/4] feat: add default configuration for custom actions show options --- .../tutorial/03-Customization/14-Actions.md | 152 ++++++++++++++++++ .../{14-afcl.md => 15-afcl.md} | 0 .../{15-websocket.md => 16-websocket.md} | 0 adminforth/modules/configValidator.ts | 11 ++ 4 files changed, 163 insertions(+) create mode 100644 adminforth/documentation/docs/tutorial/03-Customization/14-Actions.md rename adminforth/documentation/docs/tutorial/03-Customization/{14-afcl.md => 15-afcl.md} (100%) rename adminforth/documentation/docs/tutorial/03-Customization/{15-websocket.md => 16-websocket.md} (100%) diff --git a/adminforth/documentation/docs/tutorial/03-Customization/14-Actions.md b/adminforth/documentation/docs/tutorial/03-Customization/14-Actions.md new file mode 100644 index 000000000..e295ab792 --- /dev/null +++ b/adminforth/documentation/docs/tutorial/03-Customization/14-Actions.md @@ -0,0 +1,152 @@ +# Actions + +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. + +Here's how to add a custom action: + +```ts title="./resources/apartments.ts" +{ + resourceId: 'aparts', + options: { + actions: [ + { + name: 'Auto submit', // Display name of the action + icon: 'flowbite:play-solid', // Icon to display (using Flowbite icons) + + // Control who can see/use this action + allowed: ({ adminUser, standardAllowedActions }) => { + return true; // Allow everyone + }, + + // Handler function when action is triggered + action: ({ recordId, adminUser }) => { + console.log("auto submit", recordId, adminUser); + return { + ok: true, + successMessage: "Auto submitted" + }; + }, + + // Configure where the action appears + showIn: { + list: true, // Show in list view + showButton: true, // Show as a button + showThreeDotsMenu: true, // Show in three-dots menu + } + } + ] + } +} +``` + +## Action Configuration Options + +- `name`: Display name of the action +- `icon`: Icon to show (using Flowbite icon set) +- `allowed`: Function to control access to the action +- `action`: Handler function that executes when action is triggered +- `showIn`: Controls where the action appears + - `list`: Show in list view + - `showButton`: Show as a button + - `showThreeDotsMenu`: Show in three-dots menu + +## Access Control + +You can control who can use an action through the `allowed` function. This function receives: + +```ts title="./resources/apartments.ts" +{ + options: { + actions: [ + { + name: 'Auto submit', + allowed: ({ adminUser, standardAllowedActions }) => { + if (adminUser.dbUser.role !== 'superadmin') { + return false; + } + return true; + }, + // ... other configuration + } + ] + } +} +``` + +The `allowed` function receives: +- `adminUser`: The current admin user object +- `standardAllowedActions`: Standard permissions for the current user + +Return: +- `true` to allow access +- `false` to deny access +- A string with an error message to explain why access was denied + +Here is how it looks: +![alt text]() + + +You might want to allow only certain users to perform your custom bulk action. + +To implement this limitation use `allowed`: + +If you want to prohibit the use of bulk action for user, you can do it this way: + +```ts title="./resources/apartments.ts" +bulkActions: [ + { + label: 'Mark as listed', + icon: 'flowbite:eye-solid', + state:'active', + allowed: async ({ resource, adminUser, selectedIds }) => { + if (adminUser.dbUser.role !== 'superadmin') { + return false; + } + return true; + }, + confirm: 'Are you sure you want to mark all selected apartments as listed?', + action: function ({selectedIds, adminUser }: {selectedIds: any[], adminUser: AdminUser }, allow) { + const stmt = admin.resource('aparts').dataConnector.db.prepare(`UPDATE apartments SET listed = 1 WHERE id IN (${selectedIds.map(() => '?').join(',')}`); + stmt.run(...selectedIds); + return { ok: true, error: false, successMessage: `Marked ${selectedIds.length} apartments as listed` }; + }, + } +], +``` + +## Action URL + +Instead of defining an `action` handler, you can specify a `url` that the user will be redirected to when clicking the action button: + +```ts title="./resources/apartments.ts" +{ + name: 'View details', + icon: 'flowbite:eye-solid', + url: '/resource/aparts', // URL to redirect to + showIn: { + list: true, + showButton: true, + showThreeDotsMenu: true, + } +} +``` + +The URL can be: +- A relative path within your admin panel (starting with '/') +- An absolute URL (starting with 'http://' or 'https://') + +To open the URL in a new tab, add `?target=_blank` to the URL: + +```ts +{ + name: 'View on Google', + icon: 'flowbite:external-link-solid', + url: 'https://google.com/search?q=apartment&target=_blank', + showIn: { + list: true, + showButton: true + } +} +``` + +> ☝️ Note: You cannot specify both `action` and `url` for the same action - only one should be used. \ No newline at end of file diff --git a/adminforth/documentation/docs/tutorial/03-Customization/14-afcl.md b/adminforth/documentation/docs/tutorial/03-Customization/15-afcl.md similarity index 100% rename from adminforth/documentation/docs/tutorial/03-Customization/14-afcl.md rename to adminforth/documentation/docs/tutorial/03-Customization/15-afcl.md diff --git a/adminforth/documentation/docs/tutorial/03-Customization/15-websocket.md b/adminforth/documentation/docs/tutorial/03-Customization/16-websocket.md similarity index 100% rename from adminforth/documentation/docs/tutorial/03-Customization/15-websocket.md rename to adminforth/documentation/docs/tutorial/03-Customization/16-websocket.md diff --git a/adminforth/modules/configValidator.ts b/adminforth/modules/configValidator.ts index c2d65a433..6a8adb1c8 100644 --- a/adminforth/modules/configValidator.ts +++ b/adminforth/modules/configValidator.ts @@ -373,6 +373,17 @@ export default class ConfigValidator implements IConfigValidator { if (!action.id) { action.id = md5hash(action.name); } + if (!action.showIn) { + action.showIn = { + list: true, + showButton: false, + showThreeDotsMenu: false, + } + } else { + action.showIn.list = action.showIn.list ?? true; + action.showIn.showButton = action.showIn.showButton ?? false; + action.showIn.showThreeDotsMenu = action.showIn.showThreeDotsMenu ?? false; + } }); return actions;