Custom Actions
Custom actions allow you to add buttons and functionality to entity pages beyond standard CRUD operations.
Overview
Actions can be added to:
- Index page: Bulk operations or entity-wide actions
- Show page: Item-specific operations
- Edit page: Additional save options or workflows
- Menu: Global actions (see Menu configuration)
Action Configuration
type ActionConfig = {
[key: string]: {
label: string | ((params: ActionParams) => string);
confirm?: string;
newWindow?: boolean;
action: string | ((params: ActionParams) => Promise<ActionResult>);
}
}
type ActionParams = {
entityUI: EntityUI;
item?: any;
id?: string;
principal: Principal;
runtime: ActiveQLServer;
activeAdmin: ActiveAdmin;
}
type ActionResult = {
message?: string;
redirect?: string;
}Index Actions
Actions on the index (list) page:
{
entities: {
Product: {
index: {
actions: {
export: {
label: 'Export to CSV',
action: async ({ entityUI, runtime }) => {
const products = await runtime.entity('Product').findAll();
const csv = await generateCSV(products);
const url = await saveFile(csv, 'products.csv');
return { redirect: url };
}
},
import: {
label: 'Import Products',
action: '/admin/products/import'
},
publishAll: {
label: 'Publish All Draft',
confirm: 'Publish all draft products?',
action: async ({ runtime }) => {
const count = await runtime.entity('Product').updateMany(
{ status: 'draft' },
{ status: 'published' }
);
return { message: `${count} products published` };
}
}
}
}
}
}
}Show Actions
Actions on the detail page:
{
entities: {
Order: {
show: {
actions: {
printInvoice: {
label: 'Print Invoice',
action: async ({ item }) => {
const pdf = await generateInvoice(item);
return { redirect: pdf.url };
},
newWindow: true
},
sendConfirmation: {
label: 'Resend Confirmation',
confirm: 'Send order confirmation email?',
action: async ({ item, runtime }) => {
await sendOrderConfirmation(item);
return { message: 'Confirmation email sent' };
}
},
cancel: {
label: 'Cancel Order',
confirm: 'This will cancel the order and refund the customer. Continue?',
action: async ({ item, runtime }) => {
await cancelOrder(item.id, runtime);
return { message: 'Order cancelled and refunded' };
}
},
duplicate: {
label: 'Duplicate',
action: async ({ item, entityUI }) => {
const newItem = { ...item, id: undefined, status: 'draft' };
const saved = await entityUI.entity.save(newItem);
return { redirect: entityUI.editPath(saved.id) };
}
}
}
}
}
}
}Edit Actions
Actions on the edit page:
{
entities: {
Article: {
edit: {
actions: {
saveAndPublish: {
label: 'Save & Publish',
action: async ({ item, entityUI }) => {
item.status = 'published';
item.publishedAt = new Date();
const saved = await entityUI.entity.save(item);
return { redirect: entityUI.showPath(saved.id) };
}
},
saveDraft: {
label: 'Save as Draft',
action: async ({ item, entityUI }) => {
item.status = 'draft';
const saved = await entityUI.entity.save(item);
return { redirect: entityUI.editPath(saved.id) };
}
},
preview: {
label: 'Preview',
action: ({ item }) => `/preview/article/${item.id}`,
newWindow: true
}
}
}
}
}
}Action Types
String Action (Redirect)
Simple navigation:
{
viewOnSite: {
label: 'View on Website',
action: '/products/${item.slug}',
newWindow: true
}
}Function Action
Custom logic:
{
archive: {
label: 'Archive',
confirm: 'Archive this item?',
action: async ({ item, entityUI }) => {
item.status = 'archived';
item.archivedAt = new Date();
await entityUI.entity.save(item);
return { message: 'Item archived' };
}
}
}Action Results
Show Message
Display a notification:
{
action: async ({ item }) => {
await processItem(item);
return { message: 'Processing complete' };
}
}Redirect
Navigate to another page:
{
action: async ({ item, entityUI }) => {
const newItem = await duplicateItem(item);
return { redirect: entityUI.showPath(newItem.id) };
}
}File Download
Return a download URL:
{
action: async ({ item }) => {
const pdf = await generatePDF(item);
return { redirect: pdf.downloadUrl };
}
}Confirmation Dialogs
Add confirm to show a confirmation dialog:
{
delete: {
label: 'Delete Permanently',
confirm: 'This action cannot be undone. Delete this item?',
action: async ({ item, entityUI }) => {
await entityUI.entity.delete(item.id);
return { redirect: entityUI.indexPath() };
}
}
}Dynamic Labels
Labels can be functions:
{
toggle: {
label: ({ item }) => item.isActive ? 'Deactivate' : 'Activate',
action: async ({ item, entityUI }) => {
item.isActive = !item.isActive;
await entityUI.entity.save(item);
return { message: `Item ${item.isActive ? 'activated' : 'deactivated'}` };
}
}
}Permission Checks
Check permissions within actions:
{
approve: {
label: 'Approve',
action: async ({ item, principal, entityUI }) => {
if (!principal.roles.includes('approver')) {
return { message: 'You do not have permission to approve' };
}
item.status = 'approved';
item.approvedBy = principal.id;
item.approvedAt = new Date();
await entityUI.entity.save(item);
return { message: 'Item approved' };
}
}
}State Transitions
Use actions for state machine transitions:
{
entities: {
Order: {
show: {
actions: {
confirm: {
label: 'Confirm Order',
action: async ({ item, entityUI }) => {
if (item.status !== 'pending') {
return { message: 'Order is not pending' };
}
item.status = 'confirmed';
await entityUI.entity.save(item);
return { message: 'Order confirmed' };
}
},
ship: {
label: 'Mark as Shipped',
action: async ({ item, entityUI }) => {
if (item.status !== 'confirmed') {
return { message: 'Order must be confirmed first' };
}
item.status = 'shipped';
item.shippedAt = new Date();
await entityUI.entity.save(item);
return { message: 'Order marked as shipped' };
}
},
deliver: {
label: 'Mark as Delivered',
action: async ({ item, entityUI }) => {
if (item.status !== 'shipped') {
return { message: 'Order must be shipped first' };
}
item.status = 'delivered';
item.deliveredAt = new Date();
await entityUI.entity.save(item);
return { message: 'Order delivered' };
}
}
}
}
}
}
}Complete Example
{
entities: {
Product: {
index: {
actions: {
export: {
label: 'Export CSV',
action: async ({ runtime }) => {
const products = await runtime.entity('Product').findAll();
const csv = generateCSV(products);
return { redirect: `/download/products.csv?data=${csv}` };
}
},
bulkPublish: {
label: 'Publish All Draft',
confirm: 'Publish all products in draft status?',
action: async ({ runtime }) => {
const result = await runtime.entity('Product').updateMany(
{ status: 'draft' },
{ status: 'published', publishedAt: new Date() }
);
return { message: `${result.count} products published` };
}
}
}
},
show: {
actions: {
duplicate: {
label: 'Duplicate',
action: async ({ item, entityUI }) => {
const copy = {
...item,
id: undefined,
name: `${item.name} (Copy)`,
sku: `${item.sku}-COPY`,
status: 'draft'
};
const saved = await entityUI.entity.save(copy);
return { redirect: entityUI.editPath(saved.id) };
}
},
publish: {
label: ({ item }) => item.status === 'published' ? 'Unpublish' : 'Publish',
confirm: ({ item }) => item.status === 'published'
? 'Remove this product from the website?'
: 'Publish this product to the website?',
action: async ({ item, entityUI }) => {
if (item.status === 'published') {
item.status = 'draft';
item.publishedAt = null;
} else {
item.status = 'published';
item.publishedAt = new Date();
}
await entityUI.entity.save(item);
return {
message: `Product ${item.status === 'published' ? 'published' : 'unpublished'}`
};
}
},
viewOnSite: {
label: 'View on Website',
action: ({ item }) => `/products/${item.slug}`,
newWindow: true
}
}
},
edit: {
actions: {
saveAndNew: {
label: 'Save & Create Another',
action: async ({ item, entityUI }) => {
await entityUI.entity.save(item);
return { redirect: entityUI.newPath() };
}
},
preview: {
label: 'Preview',
action: ({ item }) => `/preview/products/${item.id}`,
newWindow: true
}
}
}
}
}
}