docs/developer/dashboard/recipes/page-action-button.mdx
Built-in detail pages render <PageHeader> at the top with primary action buttons on the right and a more-actions (⋯) dropdown next to them. Both are slot-based — you can inject your own buttons or menu items without touching the host page.
Two slots cover the surface:
page.actions — the right-aligned action zone (buttons, badges)page.actions_dropdown — the dropdown menuThis recipe shows both — and how to gate them by permission + resource state. (Strings are hardcoded for brevity; real buttons should go through i18n — see Translations.)
// src/actions/send-invoice-button.tsx
import { adminClient, usePermissions, useResourceMutation } from '@spree/dashboard-core'
import { Button } from '@spree/dashboard-ui'
import { Send } from 'lucide-react'
interface Props {
resource: { id: string; state: string } // Order
}
export function SendInvoiceButton({ resource }: Props) {
const { permissions } = usePermissions()
const mutation = useResourceMutation({
mutationFn: () =>
adminClient.request('POST', `/orders/${resource.id}/send_invoice`),
successMessage: 'Invoice sent',
})
// Only show to users who can act, and only on completed orders.
if (!permissions.can('update', 'Spree::Order')) return null
if (resource.state !== 'complete') return null
return (
<Button
variant="outline"
size="sm"
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
>
<Send className="size-4" />
Send invoice
</Button>
)
}
Register it:
// src/plugins.ts (in your dashboard app)
import { defineDashboardPlugin } from '@spree/dashboard-core'
import { SendInvoiceButton } from './actions/send-invoice-button'
defineDashboardPlugin({
slots: {
'page.actions': [{
id: 'send-invoice',
component: SendInvoiceButton as never,
position: 50,
}],
},
})
Two layers of gating, both inside the component:
usePermissions(); render null when the user can't act.(The slot entry's if predicate receives only the slot's own context — { resource } here — so use it for resource-state conditions, and keep permission checks in the component. See Slots.)
For secondary actions, use page.actions_dropdown and render <DropdownMenuItem>:
// src/actions/sync-to-erp-item.tsx
import { adminClient, usePermissions, useResourceMutation } from '@spree/dashboard-core'
import { DropdownMenuItem } from '@spree/dashboard-ui'
import { RefreshCw } from 'lucide-react'
interface Props {
resource: { id: string }
}
export function SyncToErpItem({ resource }: Props) {
const { permissions } = usePermissions()
const mutation = useResourceMutation({
mutationFn: () => adminClient.request('POST', `/orders/${resource.id}/sync_to_erp`),
successMessage: 'Synced',
})
if (!permissions.can('manage', 'Spree::Order')) return null
return (
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault()
mutation.mutate()
}}
disabled={mutation.isPending}
>
<RefreshCw className="size-4" />
Sync to ERP
</DropdownMenuItem>
)
}
defineDashboardPlugin({
slots: {
'page.actions_dropdown': [{
id: 'sync-to-erp',
component: SyncToErpItem as never,
position: 50,
}],
},
})
The event.preventDefault() keeps the dropdown open if the mutation fails — without it, the menu closes on click and the toast appears with no context.
For destructive or expensive actions, wrap the click in a <Dialog>:
import { useState } from 'react'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
DropdownMenuItem, Button,
} from '@spree/dashboard-ui'
export function ResetCartItem({ resource }: { resource: { id: string } }) {
const [open, setOpen] = useState(false)
const mutation = useResourceMutation({
mutationFn: () => adminClient.request('POST', `/orders/${resource.id}/reset_cart`),
successMessage: 'Cart reset',
onSuccess: () => setOpen(false),
})
return (
<>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault()
setOpen(true)
}}
>
Reset cart
</DropdownMenuItem>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reset this cart?</DialogTitle>
</DialogHeader>
<p>All line items will be cleared. This cannot be undone.</p>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
<Button variant="destructive" onClick={() => mutation.mutate()}>
Reset
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
The resource prop is whatever the host page passed to <PageHeader resource={…} />. On the order detail page it's an Order; on a product page it's a Product. The slot doesn't statically know which — see Slots for the typing escape hatches.
For the current store, user, or permissions, call the hooks inside your component — useStore(), useAuth(), usePermissions() from @spree/dashboard-core.
page.actions / page.actions_dropdown in the slots cataloguseResourceMutation — 422-aware mutation hook