Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -263,4 +263,63 @@ bulkActions: [
},
}
],
```

## Custom Component

If you want to style an action's button/icon without changing its behavior, attach a custom UI wrapper via `customComponent`.
The file points to your SFC in the custom folder (alias `@@/`), and `meta` lets you pass lightweight styling options (e.g., border color, radius).

```ts title="./resources/apartments.ts"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@NoOne7135 lets use mark as listed example as base (with link to original mark as listed action)

{
resourceId: 'aparts',
options: {
actions: [
{
name: 'Auto submit',
icon: 'flowbite:play-solid',
// UI wrapper for the built-in action button
//diff-add
customComponent: {
//diff-add
file: '@@/ActionBorder.vue', // SFC path in your custom folder
//diff-add
meta: { color: '#94a3b8', radius: 10 } // free-form styling params
//diff-add
},
showIn: { list: true, showButton: true, showThreeDotsMenu: true },
action: async ({ recordId, adminUser }) => {
return { ok: true, successMessage: 'Auto submitted' };
}
}
]
}
}
```

Use this minimal wrapper component to add a border/rounding around the default action UI while keeping the action logic intact.
Keep the `<slot />` (that's where AdminForth renders the default button) and emit `callAction` to trigger the handler when the wrapper is clicked.

```ts title="./custom/ActionBorder.vue"
<template>
<!-- Keep the slot: AdminForth renders the default action button/icon here -->
<!-- Emit `callAction` to trigger the action when the wrapper is clicked -->
<div :style="styleObj" @click="emit('callAction')">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@NoOne7135 for verbosity can we use emit('callAction', {}) ? so user will understand maybe better that he will pass payload

<slot />
</div>
</template>

<script setup lang="ts">
import { computed } from 'vue';

const props = defineProps<{ meta?: { color?: string; radius?: number; padding?: number } }>();
const emit = defineEmits<{ (e: 'callAction', payload?: unknown): void }>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@NoOne7135 payload any here?


const styleObj = computed(() => ({
display: 'inline-block',
border: `1px solid ${props.meta?.color ?? '#e5e7eb'}`,
borderRadius: (props.meta?.radius ?? 8) + 'px',
padding: (props.meta?.padding ?? 2) + 'px',
}));
</script>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@NoOne7135 lets add one more example which demonstrates how to pass dynamic value from frontend to backend.

@click="emit('callAction', {asListed: true})"

@click="emit('callAction', {asListed: false})"

and how to access it on backend in handler - using extra

```
13 changes: 13 additions & 0 deletions adminforth/modules/codeInjector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,19 @@ class CodeInjector implements ICodeInjector {
});
}
});
resource.options.actions.forEach((action) => {
const cc = action.customComponent;
if (!cc) return;

const file = (typeof cc === 'string') ? cc : cc.file;
if (!file) {
throw new Error('customComponent.file is missing for action: ' + JSON.stringify({ id: action.id, name: action.name }));
}
if (!customResourceComponents.includes(file)) {
console.log('Found injection', file);
customResourceComponents.push(file);
}
});

(Object.values(resource.options?.pageInjections || {})).forEach((injection) => {
Object.values(injection).forEach((filePathes: {file: string}[]) => {
Expand Down
6 changes: 5 additions & 1 deletion adminforth/modules/configValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,14 +389,18 @@ export default class ConfigValidator implements IConfigValidator {
if (!action.name) {
errors.push(`Resource "${res.resourceId}" has action without name`);
}

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`);
}

if (action.customComponent) {
action.customComponent = this.validateComponent(action.customComponent as any, errors);
}

// Generate ID if not present
if (!action.id) {
Expand Down
4 changes: 2 additions & 2 deletions adminforth/modules/restApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1432,7 +1432,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
method: 'POST',
path: '/start_custom_action',
handler: async ({ body, adminUser, tr }) => {
const { resourceId, actionId, recordId } = body;
const { resourceId, actionId, recordId, extra } = body;
const resource = this.adminforth.config.resources.find((res) => res.resourceId == resourceId);
if (!resource) {
return { error: await tr(`Resource {resourceId} not found`, 'errors', { resourceId }) };
Expand Down Expand Up @@ -1463,7 +1463,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
redirectUrl: action.url
}
}
const response = await action.action({ recordId, adminUser, resource, tr, adminforth: this.adminforth });
const response = await action.action({ recordId, adminUser, resource, tr, adminforth: this.adminforth, extra });

return {
actionId,
Expand Down
15 changes: 15 additions & 0 deletions adminforth/spa/src/components/CallActionWrapper.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<template>
<div @click="onClick">
<slot />
</div>
</template>

<script setup lang="ts">
const props = defineProps<{ disabled?: boolean, extra?: any }>();
const emit = defineEmits<{ (e: 'callAction', extra?: any ): void }>();

function onClick() {
if (props.disabled) return;
emit('callAction', props.extra);
}
</script>
34 changes: 27 additions & 7 deletions adminforth/spa/src/components/ResourceListTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -177,18 +177,38 @@
</template>

<template v-if="resource.options?.actions">
<Tooltip v-for="action in resource.options.actions.filter(a => a.showIn?.list)" :key="action.id">
<button
@click="startCustomAction(action.id, row)"
>
<component v-if="action.icon" :is="getIcon(action.icon)" class="w-5 h-5 mr-2 text-lightPrimary dark:text-darkPrimary"></component>
</button>
<template v-slot:tooltip>
<Tooltip
v-for="action in resource.options.actions.filter(a => a.showIn?.list)"
:key="action.id"
>
<component
:is="action.customComponent ? getCustomComponent(action.customComponent) : CallActionWrapper"
:meta="action.customComponent?.meta"
:row="row"
:resource="resource"
:adminUser="adminUser"
@callAction="(payload? : Object) => startCustomAction(action.id, payload ?? row)"
>
<button
type="button"
:disabled="rowActionLoadingStates?.[action.id]"
@click.stop.prevent
>
<component
v-if="action.icon"
:is="getIcon(action.icon)"
class="w-5 h-5 mr-2 text-lightPrimary dark:text-darkPrimary"
/>
</button>
</component>

<template #tooltip>
{{ action.name }}
</template>
</Tooltip>
</template>
</div>

</td>
</tr>
</tbody>
Expand Down
38 changes: 31 additions & 7 deletions adminforth/spa/src/components/ResourceListTableVirtual.vue
Original file line number Diff line number Diff line change
Expand Up @@ -186,14 +186,38 @@
/>
</template>

<template v-if="resource.options?.actions">
<Tooltip v-for="action in resource.options.actions.filter(a => a.showIn?.list)" :key="action.id">
<button
@click="startCustomAction(action.id, row)"
<template v-if="resource.options?.actions">
<Tooltip
v-for="action in resource.options.actions.filter(a => a.showIn?.list)"
:key="action.id"
>
<CallActionWrapper
:disabled="rowActionLoadingStates?.[action.id]"
@callAction="startCustomAction(action.id, row)"
>
<component v-if="action.icon" :is="getIcon(action.icon)" class="w-5 h-5 mr-2 text-lightPrimary dark:text-darkPrimary"></component>
</button>
<template v-slot:tooltip>
<component
:is="action.customComponent ? getCustomComponent(action.customComponent) : 'span'"
:meta="action.customComponent?.meta"
:row="row"
:resource="resource"
:adminUser="adminUser"
@callAction="(payload? : Object) => startCustomAction(action.id, payload ?? row)"
>
<button
type="button"
:disabled="rowActionLoadingStates?.[action.id]"
@click.stop.prevent
>
<component
v-if="action.icon"
:is="getIcon(action.icon)"
class="w-5 h-5 mr-2 text-lightPrimary dark:text-darkPrimary"
/>
</button>
</component>
</CallActionWrapper>

<template #tooltip>
{{ action.name }}
</template>
</Tooltip>
Expand Down
45 changes: 23 additions & 22 deletions adminforth/spa/src/components/ThreeDotsMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,25 @@
/>
</a>
</li>
<li v-if="customActions" v-for="action in customActions" :key="action.id">
<a href="#" @click.prevent="handleActionClick(action)" class="block px-4 py-2 hover:text-lightThreeDotsMenuBodyTextHover hover:bg-lightThreeDotsMenuBodyBackgroundHover dark:hover:bg-darkThreeDotsMenuBodyBackgroundHover dark:hover:text-darkThreeDotsMenuBodyTextHover">
<div class="flex items-center gap-2">
<component
v-if="action.icon"
:is="getIcon(action.icon)"
class="w-4 h-4 text-lightPrimary dark:text-darkPrimary"
/>
{{ action.name }}
</div>
</a>
<li v-for="action in customActions" :key="action.id">
<component
:is="(action.customComponent && getCustomComponent(action.customComponent)) || CallActionWrapper"
:meta="action.customComponent?.meta"
@callAction="(payload? : Object) => handleActionClick(action, payload)"
>
<a href="#" @click.prevent class="block px-4 py-2 hover:text-lightThreeDotsMenuBodyTextHover hover:bg-lightThreeDotsMenuBodyBackgroundHover dark:hover:bg-darkThreeDotsMenuBodyBackgroundHover dark:hover:text-darkThreeDotsMenuBodyTextHover">
<div class="flex items-center gap-2">
<component
v-if="action.icon"
:is="getIcon(action.icon)"
class="w-4 h-4 text-lightPrimary dark:text-darkPrimary"
/>
{{ action.name }}
</div>
</a>
</component>
</li>
<li v-for="action in bulkActions?.filter((a:AdminForthBulkActionCommon ) => a.showInThreeDotsDropdown)" :key="action.id">
<li v-for="action in (bulkActions ?? []).filter(a => a.showInThreeDotsDropdown)" :key="action.id">
<a href="#" @click.prevent="startBulkAction(action.id)"
class="block px-4 py-2 hover:text-lightThreeDotsMenuBodyTextHover hover:bg-lightThreeDotsMenuBodyBackgroundHover dark:hover:bg-darkThreeDotsMenuBodyBackgroundHover dark:hover:text-darkThreeDotsMenuBodyTextHover"
:class="{
Expand Down Expand Up @@ -75,8 +81,8 @@ import { useCoreStore } from '@/stores/core';
import adminforth from '@/adminforth';
import { callAdminForthApi } from '@/utils';
import { useRoute, useRouter } from 'vue-router';
import type { AdminForthComponentDeclarationFull, AdminForthBulkActionCommon, AdminForthActionInput } from '@/types/Common.js';
import { ref, type ComponentPublicInstance } from 'vue';
import CallActionWrapper from '@/components/CallActionWrapper.vue'


const route = useRoute();
const coreStore = useCoreStore();
Expand All @@ -98,13 +104,7 @@ const props = defineProps({

const emit = defineEmits(['startBulkAction']);

function setComponentRef(el: ComponentPublicInstance | null, index: number) {
if (el) {
threeDotsDropdownItemsRefs.value[index] = el;
}
}

async function handleActionClick(action: AdminForthActionInput) {
async function handleActionClick(action: any, payload: any) {
adminforth.list.closeThreeDotsDropdown();

const actionId = action.id;
Expand All @@ -114,7 +114,8 @@ async function handleActionClick(action: AdminForthActionInput) {
body: {
resourceId: route.params.resourceId,
actionId: actionId,
recordId: route.params.primaryKey
recordId: route.params.primaryKey,
extra: payload || {},
}
});

Expand Down
44 changes: 27 additions & 17 deletions adminforth/spa/src/views/ShowView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,28 @@
/>
<BreadcrumbsWithButtons>
<template v-if="coreStore.resource?.options?.actions">
<button
v-for="action in coreStore.resource.options.actions.filter(a => a.showIn?.showButton)"
:key="action.id"
@click="startCustomAction(action.id)"
:disabled="actionLoadingStates[action.id!]"
class="flex items-center py-1 px-3 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-default border border-gray-300 hover:bg-gray-100 hover:text-lightPrimary focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
>
<component
v-if="action.icon"
:is="getIcon(action.icon)"
class="w-4 h-4 me-2 text-lightPrimary dark:text-darkPrimary"
/>
{{ action.name }}
</button>

<template v-for="action in coreStore.resource.options.actions.filter(a => a.showIn?.showButton)" :key="action.id">
<component
:is="getCustomComponent(action.customComponent) || CallActionWrapper"
:meta="action.customComponent?.meta"
@callAction="(payload?) => startCustomAction(action.id, payload)"
:disabled="actionLoadingStates[action.id]"
>
<button
:key="action.id"
:disabled="actionLoadingStates[action.id!]"
class="flex items-center py-1 px-3 text-sm font-medium text-gray-900 focus:outline-none bg-white rounded-default border border-gray-300 hover:bg-gray-100 hover:text-lightPrimary focus:z-10 focus:ring-4 focus:ring-gray-100 dark:focus:ring-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:border-gray-600 dark:hover:text-white dark:hover:bg-gray-700"
>
<component
v-if="action.icon"
:is="getIcon(action.icon)"
class="w-4 h-4 me-2 text-lightPrimary dark:text-darkPrimary"
/>
{{ action.name }}
</button>
</component>
</template>
</template>
<RouterLink v-if="coreStore.resource?.options?.allowedActions?.create"
:to="{ name: 'resource-create', params: { resourceId: $route.params.resourceId } }"
Expand Down Expand Up @@ -139,7 +147,8 @@ import ShowTable from '@/components/ShowTable.vue';
import adminforth from "@/adminforth";
import { useI18n } from 'vue-i18n';
import { getIcon } from '@/utils';
import { type AdminForthComponentDeclarationFull, type AdminForthResourceColumnCommon, type FieldGroup } from '@/types/Common.js';
import { type AdminForthComponentDeclarationFull } from '@/types/Common.js';
import CallActionWrapper from '@/components/CallActionWrapper.vue'

const route = useRoute();
const router = useRouter();
Expand Down Expand Up @@ -234,7 +243,7 @@ async function deleteRecord() {

}

async function startCustomAction(actionId: string) {
async function startCustomAction(actionId, extra) {
actionLoadingStates.value[actionId] = true;

const data = await callAdminForthApi({
Expand All @@ -243,7 +252,8 @@ async function startCustomAction(actionId: string) {
body: {
resourceId: route.params.resourceId,
actionId: actionId,
recordId: route.params.primaryKey
recordId: route.params.primaryKey,
extra: extra,
}
});

Expand Down
1 change: 1 addition & 0 deletions adminforth/types/Back.ts
Original file line number Diff line number Diff line change
Expand Up @@ -861,6 +861,7 @@ export interface AdminForthActionInput {
}>;
icon?: string;
id?: string;
customComponent?: AdminForthComponentDeclaration;
}

export interface AdminForthResourceInput extends Omit<NonNullable<AdminForthResourceInputCommon>, 'columns' | 'hooks' | 'options'> {
Expand Down
1 change: 1 addition & 0 deletions dev-demo/custom/ShadowLoginButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<span> TEST CUSTOM COMPONENT </span>
Loading