Custom Actions

Add custom actions to resources beyond standard CRUD operations.

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

Need a Landing Page?

Modern landing pages with optional modules (blog, docs, forms, i18n). Let's discuss your project.

Build Your MVP

Full-stack SaaS development. Expert in database design, multi-tenancy, and scalable architecture.

Deployment Help

Dockerize your backend, set up CI/CD pipelines, deploy to Cloudflare or Hetzner. Early-stage setup.

Suggest a SaaS Tool

Missing a calculator or tool? Suggest what you'd like to see on our site.