Skip to content

Commit 24d5d48

Browse files
authored
Merge pull request #323 from devforth/feat/bulkActionComponent
Feat/bulk action component
2 parents 417d33b + fa004e5 commit 24d5d48

File tree

20 files changed

+566
-91
lines changed

20 files changed

+566
-91
lines changed

adminforth/commands/createCustomComponent/main.js

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,9 @@ async function handleCrudPageInjectionCreation(config, resources) {
188188
const injectionPosition = await select({
189189
message: 'Where exactly do you want to inject the component?',
190190
choices: [
191+
...(crudType === 'create' || crudType === 'edit'
192+
? [{ name: '💾 Save button on create/edit page', value: 'saveButton' }, new Separator()]
193+
: []),
191194
{ name: '⬆️ Before Breadcrumbs', value: 'beforeBreadcrumbs' },
192195
{ name: '➡️ Before Action Buttons', value: 'beforeActionButtons' },
193196
{ name: '⬇️ After Breadcrumbs', value: 'afterBreadcrumbs' },
@@ -207,13 +210,15 @@ async function handleCrudPageInjectionCreation(config, resources) {
207210
},
208211
});
209212

210-
const isThin = await select({
211-
message: 'Will this component be thin enough to fit on the same page with list (so list will still shrink)?',
212-
choices: [
213-
{ name: 'Yes', value: true },
214-
{ name: 'No', value: false },
215-
],
216-
});
213+
const isThin = crudType === 'list'
214+
? await select({
215+
message: 'Will this component be thin enough to fit on the same page with list (so list will still shrink)?',
216+
choices: [
217+
{ name: 'Yes', value: true },
218+
{ name: 'No', value: false },
219+
],
220+
})
221+
: false;
217222
const formattedAdditionalName = additionalName
218223
? additionalName[0].toUpperCase() + additionalName.slice(1)
219224
: '';
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<template>
2+
<button
3+
class="px-4 py-2 border border-blue-500 text-blue-600 rounded hover:bg-blue-50 disabled:opacity-50"
4+
:disabled="props.disabled || props.saving || !props.isValid"
5+
@click="props.saveRecord()"
6+
>
7+
<span v-if="props.saving">Saving…</span>
8+
<span v-else>Save</span>
9+
</button>
10+
</template>
11+
12+
<script setup lang="ts">
13+
14+
const props = defineProps<{
15+
record: any
16+
resource: any
17+
adminUser: any
18+
meta: any
19+
saving: boolean
20+
validating: boolean
21+
isValid: boolean
22+
disabled: boolean
23+
saveRecord: () => Promise<void>
24+
}>();
25+
</script>
26+
27+
<style scoped>
28+
</style>

adminforth/documentation/docs/tutorial/03-Customization/08-pageInjections.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,78 @@ beforeActionButtons: [
440440
441441
## List table custom
442442
443+
## Create/Edit custom Save button
444+
445+
You can replace the default Save button on the create and edit pages with your own Vue component.
446+
447+
Supported locations:
448+
- `pageInjections.create.saveButton`
449+
- `pageInjections.edit.saveButton`
450+
451+
Example configuration:
452+
453+
```ts title="/resources/apartments.ts"
454+
{
455+
resourceId: 'aparts',
456+
...
457+
options: {
458+
pageInjections: {
459+
create: {
460+
// String shorthand
461+
saveButton: '@@/SaveBordered.vue',
462+
},
463+
edit: {
464+
// Object form (lets you pass meta later, if needed)
465+
saveButton: { file: '@@/SaveBordered.vue' },
466+
}
467+
}
468+
}
469+
}
470+
```
471+
472+
Minimal example of a custom save button component:
473+
474+
```vue title="/custom/SaveBordered.vue"
475+
<template>
476+
<button
477+
class="px-4 py-2 border border-blue-500 text-blue-600 rounded hover:bg-blue-50 disabled:opacity-50"
478+
:disabled="props.disabled || props.saving || !props.isValid"
479+
@click="props.saveRecord()"
480+
>
481+
<span v-if="props.saving">{{$t('Saving…')}}</span>
482+
<span v-else>{{$t('Save')}}</span>
483+
</button>
484+
485+
</template>
486+
487+
<script setup lang="ts">
488+
const props = defineProps<{
489+
record: any
490+
resource: any
491+
adminUser: any
492+
meta: any
493+
saving: boolean
494+
validating: boolean
495+
isValid: boolean
496+
disabled: boolean
497+
saveRecord: () => Promise<void>
498+
}>();
499+
</script>
500+
```
501+
502+
Notes:
503+
- Your component fully replaces the default Save button in the page header.
504+
- The `saveRecord()` prop triggers the standard AdminForth save flow. Call it on click.
505+
- `saving`, `validating`, `isValid`, and `disabled` reflect the current form state.
506+
- If no `saveButton` is provided, the default button is shown.
507+
508+
Scaffolding via CLI: you can generate a ready-to-wire component and auto-update the resource config using the interactive command:
509+
510+
```bash
511+
adminforth component
512+
# Choose: CRUD page injections → (create|edit) → Save button
513+
```
514+
443515
## Global Injections
444516
445517
You have opportunity to inject custom components to the global layout. For example, you can add a custom items into user menu

adminforth/documentation/docs/tutorial/03-Customization/09-Actions.md

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,4 +263,114 @@ bulkActions: [
263263
},
264264
}
265265
],
266-
```
266+
```
267+
268+
## Custom Component
269+
270+
If you want to style an action's button/icon without changing its behavior, attach a custom UI wrapper via `customComponent`.
271+
The file points to your SFC in the custom folder (alias `@@/`), and `meta` lets you pass lightweight styling options (e.g., border color, radius).
272+
273+
```ts title="./resources/apartments.ts"
274+
{
275+
resourceId: 'aparts',
276+
options: {
277+
actions: [
278+
{
279+
name: 'Auto submit',
280+
icon: 'flowbite:play-solid',
281+
// UI wrapper for the built-in action button
282+
//diff-add
283+
customComponent: {
284+
//diff-add
285+
file: '@@/ActionBorder.vue', // SFC path in your custom folder
286+
//diff-add
287+
meta: { color: '#94a3b8', radius: 10 } // free-form styling params
288+
//diff-add
289+
},
290+
showIn: { list: true, showButton: true, showThreeDotsMenu: true },
291+
action: async ({ recordId, adminUser }) => {
292+
return { ok: true, successMessage: 'Auto submitted' };
293+
}
294+
}
295+
]
296+
}
297+
}
298+
```
299+
300+
Use this minimal wrapper component to add a border/rounding around the default action UI while keeping the action logic intact.
301+
Keep the `<slot />` (that's where AdminForth renders the default button) and emit `callAction` (optionally with a payload) to trigger the handler when the wrapper is clicked.
302+
303+
```ts title="./custom/ActionBorder.vue"
304+
<template>
305+
<!-- Keep the slot: AdminForth renders the default action button/icon here -->
306+
<!-- Emit `callAction` (optionally with a payload) to trigger the action when the wrapper is clicked -->
307+
<div :style="styleObj" @click="emit('callAction', {})">
308+
<slot />
309+
</div>
310+
</template>
311+
312+
<script setup lang="ts">
313+
import { computed } from 'vue';
314+
315+
const props = defineProps<{ meta?: { color?: string; radius?: number; padding?: number } }>();
316+
const emit = defineEmits<{ (e: 'callAction', payload?: any): void }>();
317+
318+
const styleObj = computed(() => ({
319+
display: 'inline-block',
320+
border: `1px solid ${props.meta?.color ?? '#e5e7eb'}`,
321+
borderRadius: (props.meta?.radius ?? 8) + 'px',
322+
padding: (props.meta?.padding ?? 2) + 'px',
323+
}));
324+
</script>
325+
```
326+
327+
### Pass dynamic values to the action
328+
329+
You can pass arbitrary data from your custom UI wrapper to the backend action by emitting `callAction` with a payload. That payload will be available on the server under the `extra` argument of your action handler.
330+
331+
Frontend examples:
332+
333+
```vue title="./custom/ActionBorder.vue"
334+
<template>
335+
<!-- Two buttons that pass different flags to the action -->
336+
<button @click="emit('callAction', { asListed: true })" class="mr-2">Mark as listed</button>
337+
<button @click="emit('callAction', { asListed: false })">Mark as unlisted</button>
338+
339+
<!-- Or keep the default slot button and wrap it: -->
340+
<div :style="styleObj" @click="emit('callAction', { asListed: true })">
341+
<slot />
342+
</div>
343+
</template>
344+
345+
<script setup lang="ts">
346+
const emit = defineEmits<{ (e: 'callAction', payload?: any): void }>();
347+
</script>
348+
```
349+
350+
Backend handler: read the payload via `extra`.
351+
352+
```ts title="./resources/apartments.ts"
353+
{
354+
resourceId: 'aparts',
355+
options: {
356+
actions: [
357+
{
358+
name: 'Toggle listed',
359+
icon: 'flowbite:eye-solid',
360+
showIn: { list: true, showButton: true, showThreeDotsMenu: true },
361+
// The payload from emit('callAction', { asListed: true|false }) arrives here as `extra`
362+
action: async ({ resource, recordId, adminUser, extra }) => {
363+
const asListed = extra?.asListed === true;
364+
// Example update (use your own data layer):
365+
await admin.resource('aparts').update(recordId, { listed: asListed });
366+
return { ok: true, successMessage: `Set listed=${asListed}` };
367+
}
368+
}
369+
]
370+
}
371+
}
372+
```
373+
374+
Notes:
375+
- If you don’t emit a payload, the default behavior is used by the UI (e.g., in lists the current row context is used). When you do provide a payload, it will be forwarded to the backend as `extra` for your action handler.
376+
- You can combine default context with your own payload by merging before emitting, for example: `emit('callAction', { ...row, asListed: true })` if your component has access to the row object.

adminforth/documentation/docs/tutorial/06-CLICommands.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,19 @@ Generated example:
353353
custom/OrderShowBottomExportButton.vue
354354
```
355355

356+
Special position for create/edit:
357+
- `saveButton` — replaces the default Save button at the top bar.
358+
359+
Example usage via interactive flow:
360+
```bash
361+
adminforth component
362+
# → CRUD Page Injections
363+
# → (create | edit)
364+
# → Save button
365+
```
366+
367+
Your generated component will receive props documented in Page Injections → Create/Edit custom Save button. At minimum, call `props.saveRecord()` on click and respect `props.saving`, `props.isValid`, and `props.disabled`.
368+
356369
---
357370

358371
#### 🔐 Login Page Injections (`login`)

adminforth/documentation/docs/tutorial/07-Plugins/02-TwoFactorsAuth.md

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ To do it, first, create frontend custom component which wraps and intercepts cli
227227
async function onClick() {
228228
if (props.disabled) return;
229229

230-
const verificationResult = await window.adminforthTwoFaModal.get2FaConfirmationResult(); // this will ask user to enter code
230+
const verificationResult = await window.adminforthTwoFaModal.get2FaConfirmationResult(); // this will ask user to enter code
231231
emit('callAction', { verificationResult }); // then we pass this verification result to action (from fronted to backend)
232232
}
233233
</script>
@@ -303,6 +303,74 @@ options: {
303303
}
304304
```
305305
306+
## Request 2FA for create/edit (secure save gating)
307+
308+
To protect create and edit operations, collect the result of the 2FA modal on the frontend and send it along with the save payload. The server must verify it before writing changes.
309+
310+
Frontend (custom Save button example):
311+
312+
```vue
313+
<template>
314+
<button :disabled="disabled || saving || !isValid" @click="onClick">Save</button>
315+
<!-- The plugin injects TwoFAModal globally, exposing window.adminforthTwoFaModal -->
316+
</template>
317+
318+
<script setup lang="ts">
319+
const props = defineProps<{
320+
disabled: boolean;
321+
saving: boolean;
322+
isValid: boolean;
323+
// saveRecord accepts optional meta with confirmationResult
324+
saveRecord: (opts?: { confirmationResult?: any }) => Promise<void>;
325+
meta?: any;
326+
}>();
327+
328+
async function onClick() {
329+
if (props.disabled || props.saving || !props.isValid) return;
330+
const modal = (window as any)?.adminforthTwoFaModal;
331+
if (modal?.get2FaConfirmationResult) {
332+
const confirmationResult = await modal.get2FaConfirmationResult(undefined, props.meta?.twoFaTitle || 'Confirm to save changes');
333+
await props.saveRecord({ confirmationResult });
334+
} else {
335+
const code = window.prompt('Enter your 2FA code to proceed');
336+
if (!code) return;
337+
await props.saveRecord({ confirmationResult: { mode: 'totp', result: code } });
338+
}
339+
}
340+
</script>
341+
```
342+
343+
Backend (resource hook verification):
344+
345+
```ts
346+
// Inside resource config
347+
hooks: {
348+
edit: {
349+
beforeSave: async ({ adminUser, adminforth, extra }) => {
350+
const t2fa = adminforth.getPluginByClassName('TwoFactorsAuthPlugin');
351+
const confirmationResult = extra?.body?.meta?.confirmationResult;
352+
if (!confirmationResult) {
353+
return { ok: false, error: 'Two-factor authentication confirmation result is missing' };
354+
}
355+
const cookies = extra?.cookies;
356+
const verifyRes = await t2fa.verify(confirmationResult, {
357+
adminUser,
358+
userPk: adminUser.pk,
359+
cookies,
360+
});
361+
if (!('ok' in verifyRes) || verifyRes.ok !== true) {
362+
return { ok: false, error: verifyRes?.error || 'Two-factor authentication failed' };
363+
}
364+
return { ok: true };
365+
},
366+
},
367+
}
368+
```
369+
370+
This approach ensures 2FA cannot be bypassed by calling the API directly:
371+
- The client collects verification via the modal and forwards it under `meta.confirmationResult`.
372+
- The server validates it in `beforeSave` with access to `extra.cookies` and the `adminUser`.
373+
306374
## Request 2FA from custom components
307375
308376
Imagine you have some button which does some API call
@@ -360,7 +428,7 @@ import adminforth from '@/adminforth';
360428

361429
async function callAdminAPI() {
362430
// diff-add
363-
const verificationResult = await window.adminforthTwoFaModal.getCode();
431+
const verificationResult = await window.adminforthTwoFaModal.get2FaConfirmationResult();
364432

365433
const res = await callApi({
366434
path: '/myCriticalAction',

adminforth/modules/codeInjector.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,18 @@ class CodeInjector implements ICodeInjector {
473473
});
474474
}
475475
});
476+
resource.options.actions.forEach((action) => {
477+
const cc = action.customComponent;
478+
if (!cc) return;
479+
480+
const file = (typeof cc === 'string') ? cc : cc.file;
481+
if (!file) {
482+
throw new Error('customComponent.file is missing for action: ' + JSON.stringify({ id: action.id, name: action.name }));
483+
}
484+
if (!customResourceComponents.includes(file)) {
485+
customResourceComponents.push(file);
486+
}
487+
});
476488

477489
(Object.values(resource.options?.pageInjections || {})).forEach((injection) => {
478490
Object.values(injection).forEach((filePathes: {file: string}[]) => {

0 commit comments

Comments
 (0)