Skip to content

Commit 5bcc605

Browse files
committed
Merge branch 'next' of github.com:devforth/adminforth into next
2 parents 28fe7d3 + 5a268aa commit 5bcc605

File tree

12 files changed

+310
-18
lines changed

12 files changed

+310
-18
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.

adminforth/documentation/docs/tutorial/03-Customization/14-afcl.md renamed to adminforth/documentation/docs/tutorial/03-Customization/15-afcl.md

File renamed without changes.

adminforth/documentation/docs/tutorial/03-Customization/15-websocket.md renamed to adminforth/documentation/docs/tutorial/03-Customization/16-websocket.md

File renamed without changes.

adminforth/index.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -343,12 +343,6 @@ class AdminForth implements IAdminForth {
343343
// first find discovered values, but allow override
344344
res.columns[i] = { ...fieldTypes[col.name], ...col };
345345
});
346-
347-
this.validateFieldGroups(res.options.fieldGroups, fieldTypes);
348-
this.validateFieldGroups(res.options.showFieldGroups, fieldTypes);
349-
this.validateFieldGroups(res.options.createFieldGroups, fieldTypes);
350-
this.validateFieldGroups(res.options.editFieldGroups, fieldTypes);
351-
352346

353347
// check if primaryKey column is present
354348
if (!res.columns.some((col) => col.primaryKey)) {

adminforth/modules/configValidator.ts

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

333+
validateFieldGroups(fieldGroups: {
334+
groupName: string;
335+
columns: string[];
336+
}[], resourceColumns: string[]): void {
337+
if (!fieldGroups) return;
338+
339+
fieldGroups.forEach((group) => {
340+
group.columns.forEach((col) => {
341+
if (!resourceColumns.includes(col)) {
342+
const similar = suggestIfTypo(resourceColumns, col);
343+
throw new Error(
344+
`Group '${group.groupName}' has an unknown column '${col}'. ${similar ? `Did you mean '${similar}'?` : ''
345+
}`
346+
);
347+
}
348+
});
349+
});
350+
}
351+
333352
validateAndNormalizeCustomActions(resInput: AdminForthResourceInput, res: Partial<AdminForthResource>, errors: string[]): any[] {
334353
if (!resInput.options?.actions) {
335354
return [];
@@ -342,14 +361,29 @@ export default class ConfigValidator implements IConfigValidator {
342361
errors.push(`Resource "${res.resourceId}" has action without name`);
343362
}
344363

345-
if (!action.action) {
346-
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`);
347370
}
348371

349372
// Generate ID if not present
350373
if (!action.id) {
351374
action.id = md5hash(action.name);
352375
}
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+
}
353387
});
354388

355389
return actions;
@@ -671,6 +705,12 @@ export default class ConfigValidator implements IConfigValidator {
671705
options.bulkActions = this.validateAndNormalizeBulkActions(resInput, res, errors);
672706
options.actions = this.validateAndNormalizeCustomActions(resInput, res, errors);
673707

708+
const allColumnsList = res.columns.map((col) => col.name);
709+
this.validateFieldGroups(options.fieldGroups, allColumnsList);
710+
this.validateFieldGroups(options.showFieldGroups, allColumnsList);
711+
this.validateFieldGroups(options.createFieldGroups, allColumnsList);
712+
this.validateFieldGroups(options.editFieldGroups, allColumnsList);
713+
674714
// if pageInjection is a string, make array with one element. Also check file exists
675715
const possibleInjections = ['beforeBreadcrumbs', 'afterBreadcrumbs', 'bottom', 'threeDotsDropdownItems', 'customActionIcons'];
676716
const possiblePages = ['list', 'show', 'create', 'edit'];

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/ResourceForm.vue

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,12 @@ const setCurrentValue = (key, value, index=null) => {
190190
} else if (index === currentValues.value[key].length) {
191191
currentValues.value[key].push(null);
192192
} else {
193-
if (['integer', 'float'].includes(col.isArray.itemType) && (value || value === 0)) {
194-
currentValues.value[key][index] = +value;
193+
if (['integer', 'float', 'decimal'].includes(col.isArray.itemType)) {
194+
if (value || value === 0) {
195+
currentValues.value[key][index] = +value;
196+
} else {
197+
currentValues.value[key][index] = null;
198+
}
195199
} else {
196200
currentValues.value[key][index] = value;
197201
}
@@ -200,8 +204,12 @@ const setCurrentValue = (key, value, index=null) => {
200204
}
201205
}
202206
} else {
203-
if (['integer', 'float'].includes(col.type) && (value || value === 0)) {
204-
currentValues.value[key] = +value;
207+
if (['integer', 'float', 'decimal'].includes(col.type)) {
208+
if (value || value === 0) {
209+
currentValues.value[key] = +value;
210+
} else {
211+
currentValues.value[key] = null;
212+
}
205213
} else {
206214
currentValues.value[key] = value;
207215
}

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,

0 commit comments

Comments
 (0)