Custom Actions
Custom Actions
Add custom actions to resources beyond standard CRUD operations.
Overview
Custom actions allow you to add specialized operations like:
- Bulk operations - Archive multiple posts
- Single item actions - Publish a draft, send email
- Page-level actions - Export all data, bulk import
Note: This is a planned feature. The configuration API is defined but implementation is in progress.
Action Types
Single Item Actions
Actions that operate on one record:
resources: {
posts: {
actions: {
publish: {
label: 'Publish Now',
icon: 'i-heroicons-rocket-launch',
type: 'single',
location: 'row',
handler: async (post, context) => {
await $fetch(`/api/posts/${post.id}`, {
method: 'PATCH',
body: {
status: 'published',
publishedAt: new Date(),
},
})
await context.refresh()
context.toast.success('Post published successfully')
},
},
},
},
}
Bulk Actions
Actions that operate on multiple records:
actions: {
archiveSelected: {
label: 'Archive',
icon: 'i-heroicons-archive-box',
type: 'bulk',
location: 'toolbar',
handler: async (posts, context) => {
await Promise.all(
posts.map(post =>
$fetch(`/api/posts/${post.id}`, {
method: 'PATCH',
body: { status: 'archived' },
})
)
)
await context.refresh()
context.toast.success(`${posts.length} posts archived`)
},
},
}
Page-Level Actions
Actions that operate on the entire resource:
actions: {
exportAll: {
label: 'Export to CSV',
icon: 'i-heroicons-arrow-down-tray',
type: 'page-level',
location: 'toolbar',
handler: async (_, context) => {
const response = await $fetch('/api/export/posts')
// Download file logic
context.toast.success('Export started')
},
},
}
Action Configuration
label
Display text for the action button:
{
label: 'Publish Now',
}
icon
Heroicon name for the button:
{
icon: 'i-heroicons-rocket-launch',
}
type
Action scope:
'single'- Operates on one item'bulk'- Operates on multiple items'page-level'- Operates on entire resource
{
type: 'single',
}
location
Where the action appears:
'row'- In table row dropdown menu'toolbar'- In page toolbar'detail'- On detail page
{
location: 'row', // Shows in each table row
}
handler
Function that executes the action:
{
handler: async (item, context) => {
// item: Single record or array of records
// context: { user, resource, refresh, toast }
// Your custom logic
await performAction(item)
// Refresh the data
await context.refresh()
// Show notification
context.toast.success('Action completed')
},
}
permission
Check if user can perform the action:
{
permission: (context) => {
return context.user?.role === 'admin'
},
}
confirm
Show confirmation dialog before executing:
{
confirm: 'Are you sure you want to publish this post?',
}
Or use a function for dynamic messages:
{
confirm: (post) => `Publish "${post.title}"?`,
}
For bulk actions:
{
confirm: (posts) => `Archive ${posts.length} posts?`,
}
variant & color
Button styling:
{
variant: 'ghost', // 'primary' | 'secondary' | 'ghost' | 'link'
color: 'red', // Any Tailwind color
}
Action Context
The context object provides:
interface ActionContext {
user: any // Current user
resource: string // Resource name
refresh: () => Promise<void> // Refresh table data
toast: { // Toast notifications
success: (message: string) => void
error: (message: string, error?: any) => void
}
}
Examples
Publish Draft Post
resources: {
posts: {
actions: {
publish: {
label: 'Publish',
icon: 'i-heroicons-rocket-launch',
type: 'single',
location: 'row',
permission: (context) => {
// Only admins and editors can publish
return ['admin', 'editor'].includes(context.user?.role)
},
confirm: (post) => `Publish "${post.title}"?`,
handler: async (post, context) => {
try {
await $fetch(`/api/posts/${post.id}`, {
method: 'PATCH',
body: {
status: 'published',
publishedAt: new Date(),
},
})
await context.refresh()
context.toast.success('Post published successfully')
} catch (error) {
context.toast.error('Failed to publish post', error)
}
},
},
},
},
}
Send Email to User
resources: {
users: {
actions: {
sendWelcomeEmail: {
label: 'Send Welcome Email',
icon: 'i-heroicons-envelope',
type: 'single',
location: 'detail',
confirm: (user) => `Send welcome email to ${user.email}?`,
handler: async (user, context) => {
await $fetch('/api/emails/welcome', {
method: 'POST',
body: { userId: user.id },
})
context.toast.success('Email sent')
},
},
},
},
}
Bulk Archive
resources: {
posts: {
actions: {
bulkArchive: {
label: 'Archive Selected',
icon: 'i-heroicons-archive-box',
type: 'bulk',
location: 'toolbar',
confirm: (posts) => `Archive ${posts.length} post(s)?`,
handler: async (posts, context) => {
const results = await Promise.allSettled(
posts.map(post =>
$fetch(`/api/posts/${post.id}`, {
method: 'PATCH',
body: { status: 'archived' },
})
)
)
const succeeded = results.filter(r => r.status === 'fulfilled').length
const failed = results.filter(r => r.status === 'rejected').length
if (failed > 0) {
context.toast.error(`${failed} posts failed to archive`)
} else {
context.toast.success(`${succeeded} posts archived`)
}
await context.refresh()
},
},
},
},
}
Export to CSV
resources: {
users: {
actions: {
exportCsv: {
label: 'Export to CSV',
icon: 'i-heroicons-arrow-down-tray',
type: 'page-level',
location: 'toolbar',
handler: async (_, context) => {
const response = await $fetch('/api/export/users', {
query: { format: 'csv' },
responseType: 'blob',
})
// Download file
const url = URL.createObjectURL(response)
const a = document.createElement('a')
a.href = url
a.download = 'users.csv'
a.click()
URL.revokeObjectURL(url)
context.toast.success('Export completed')
},
},
},
},
}
Clone Record
resources: {
posts: {
actions: {
clone: {
label: 'Clone',
icon: 'i-heroicons-document-duplicate',
type: 'single',
location: 'row',
confirm: (post) => `Create a copy of "${post.title}"?`,
handler: async (post, context) => {
const { id, createdAt, updatedAt, ...data } = post
const newPost = await $fetch('/api/posts', {
method: 'POST',
body: {
...data,
title: `${data.title} (Copy)`,
status: 'draft',
},
})
await context.refresh()
context.toast.success('Post cloned successfully')
},
},
},
},
}
Button Placement
Row Actions (location: 'row')
Appears in the dropdown menu for each table row, alongside Edit and Delete.
Toolbar Actions (location: 'toolbar')
Appears in the toolbar at the top of the list page, next to the "Create New" button.
Detail Actions (location: 'detail')
Appears on the detail page, next to the Edit and Delete buttons.
Permission Handling
Actions respect permissions:
{
permission: (context) => {
// Check user role
if (context.user?.role !== 'admin') return false
// Check specific permission
if (!context.user?.permissions?.includes('posts.publish')) return false
return true
},
}
If permission check returns false:
- Button is hidden (if
unauthorizedButtons: 'hide') - Button is disabled (if
unauthorizedButtons: 'disable')
Error Handling
Always handle errors in your action handlers:
handler: async (item, context) => {
try {
await performAction(item)
await context.refresh()
context.toast.success('Success!')
} catch (error) {
console.error('Action failed:', error)
context.toast.error('Action failed', error)
}
}
Future Enhancements
Planned features for custom actions:
- Form inputs - Collect data before executing action
- Progress tracking - Show progress for long-running actions
- Action history - Audit log of executed actions
- Scheduled actions - Queue actions for later execution
- Action templates - Reusable action configurations
Next Steps
- Composables - Available composables for actions
- Permissions - Detailed permission handling
M2M Relationships
This guide covers how M2M relationships work in the auto-admin interface.
Composables
Auto-imported composables available in your admin components and custom pages.