Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions adminforth/documentation/docs/tutorial/03-Customization/14-Actions.md
Original file line number Diff line number Diff line change
@@ -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](<Custom bulk actions.png>)


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.
19 changes: 17 additions & 2 deletions adminforth/modules/configValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,14 +361,29 @@ 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
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;
Expand Down
25 changes: 23 additions & 2 deletions adminforth/modules/restApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -1225,12 +1226,32 @@ 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 }) };
}

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 {
Expand Down
14 changes: 14 additions & 0 deletions adminforth/spa/src/components/ResourceListTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,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);

Expand Down
18 changes: 17 additions & 1 deletion adminforth/spa/src/components/ThreeDotsMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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({
Expand Down
15 changes: 15 additions & 0 deletions adminforth/spa/src/views/ShowView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
37 changes: 34 additions & 3 deletions adminforth/types/Back.ts
Original file line number Diff line number Diff line change
Expand Up @@ -727,7 +727,12 @@ export interface AdminForthActionInput {
showButton?: boolean,
showThreeDotsMenu?: boolean,
};
action: (params: {
allowed?: (params: {
adminUser: AdminUser;
standardAllowedActions: AllowedActions;
}) => boolean;
url?: string;
action?: (params: {
adminforth: IAdminForth;
resource: AdminForthResource;
recordId: string;
Expand Down Expand Up @@ -1146,8 +1151,6 @@ export interface ResourceOptionsInput extends Omit<AdminForthResourceCommon['opt
*/
bulkActions?: Array<AdminForthBulkAction>,

actions?: Array<AdminForthActionInput>,

/**
* Allowed actions for resource.
*
Expand All @@ -1165,10 +1168,38 @@ export interface ResourceOptionsInput extends Omit<AdminForthResourceCommon['opt
*
*/
allowedActions?: AllowedActionsInput,

/**
* Array of actions which will be displayed in the resource.
*
* Example:
*
* ```ts
* actions: [
* {
* name: 'Auto submit',
* allowed: ({ adminUser, standardAllowedActions }) => {
* 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<AdminForthActionInput>,
};

export interface ResourceOptions extends Omit<ResourceOptionsInput, 'allowedActions'> {
allowedActions: AllowedActions,
actions?: Array<AdminForthActionInput>,
}

/**
Expand Down
1 change: 1 addition & 0 deletions adminforth/types/Common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export enum ActionCheckSource {
CreateRequest = 'createRequest',
DeleteRequest = 'deleteRequest',
BulkActionRequest = 'bulkActionRequest',
CustomActionRequest = 'customActionRequest',
}

export enum AllowedActionsEnum {
Expand Down