Custom Actions
Add buttons beyond CRUD — Publish, Archive, Import, Send invitation, Bulk-update, etc.
Goal
A button appears in the right place (show page / table row / index header / bulk-actions toolbar), the user clicks it, optional form collects input, business logic runs, a success/failure message appears.
Two flavors
| Flavor | Use for |
|---|---|
| Simple action — navigate to a URL | Linking to external docs, jumping to a custom page that does its own thing |
| Interactive action — run an interaction class | Anything with business logic (the common case) |
Prefer interactive actions. They handle authorization, form rendering, modal chrome, success/failure messaging, and automatic redirects — all for free.
Quick recipe — interactive action
1. Write the interaction
# app/interactions/publish_post_interaction.rb
class PublishPostInteraction < ResourceInteraction
presents label: "Publish",
icon: Phlex::TablerIcons::Send,
description: "Make this post public"
attribute :resource
def execute
resource.update!(published: true, published_at: Time.current)
succeed(resource).with_message("Post published!")
rescue ActiveRecord::RecordInvalid => e
failed(e.record.errors)
end
endRescue ActiveRecord::RecordInvalid
Plutonium doesn't rescue it automatically. Always rescue when using create! / update! / save!, return failed(e.record.errors).
2. Register it in the definition
class PostDefinition < ResourceDefinition
action :publish, interaction: PublishPostInteraction
endAction visibility (record / bulk / resource) is inferred from the interaction's attributes — no need to declare record_action: true. See Inferred visibility below.
3. Add a policy method
class PostPolicy < ResourcePolicy
def publish? = update? && record.draft?
end🚨 Without this, the button silently disappears (undefined methods return false).
4. Visit the show page
The "Publish" button appears in the toolbar. Clicking it shows a "Publish?" confirmation, then runs.
Inferred visibility
For interaction:-based actions, visibility flags are inferred from the interaction:
| Interaction declares | Inferred flag → button shows up |
|---|---|
attribute :resource | record_action: true + collection_record_action: true → show page + per-row |
attribute :resources (plural) | bulk_action: true → bulk toolbar |
| neither | resource_action: true → index page header |
User-supplied flags can only opt OUT of inferred ones. Don't try to "broaden" — the interaction's attribute shape is semantic:
# Hide from per-row menu, keep on show page
action :archive, interaction: ArchiveInteraction, collection_record_action: false
# Hide from show page, keep per-row only
action :preview, interaction: PreviewInteraction, record_action: falseFor simple navigation actions (no interaction:), declare flags manually.
With form inputs
If the interaction declares extra attribute/input, a modal form is rendered first:
class Company::InviteUserInteraction < ResourceInteraction
presents label: "Invite User", icon: Phlex::TablerIcons::Mail
attribute :resource # the company
attribute :email
attribute :role
input :email, as: :email
input :role, as: :select, choices: %w[admin member]
validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
validates :role, presence: true
def execute
UserInvite.create!(company: resource, email: email, role: role)
succeed(resource).with_message("Invitation sent to #{email}.")
rescue ActiveRecord::RecordInvalid => e
failed(e.record.errors)
end
endBulk actions
Plural attribute :resources automatically becomes a bulk action. The table gets checkboxes and a bulk-actions toolbar.
class BulkArchiveInteraction < ResourceInteraction
presents label: "Archive Selected", icon: Phlex::TablerIcons::Archive
attribute :resources
def execute
resources.update_all(archived: true)
succeed(resources).with_message("Archived #{resources.size} records.")
end
endPolicy — checked per record (fails the whole request if any record is unauthorized):
def bulk_archive?
create? && !record.locked?
endThe UI only shows bulk actions ALL selected records support.
Resource action (no specific record)
Neither :resource nor :resources → resource action on the index page:
class ImportInteraction < ResourceInteraction
presents label: "Import CSV", icon: Phlex::TablerIcons::Upload
attribute :file
input :file, as: :file
validates :file, presence: true
def execute
# …import logic
succeed(nil).with_message("Import completed.")
end
endImmediate vs form
- Immediate — interaction has only
:resource/:resources(no extra inputs). Browser confirmation ("#{label}?", e.g."Archive?"), then runs. Override withconfirmation: "Custom message"orconfirmation: falseon the action. - Form — interaction has additional
attribute/input. Renders modal form first; no auto-confirmation (the form is the confirmation).
Action options
action :name,
# Display
label: "Custom Label",
description: "What it does",
icon: Phlex::TablerIcons::Star,
color: :danger, # :primary, :secondary, :danger
# Grouping
category: :primary, # :primary, :secondary, :danger
position: 50,
# Behavior
confirmation: "Are you sure?",
modal: :slideover # :centered (default) or :slideoverFull options: Reference › Resource › Actions › Action options.
Simple actions (navigation only)
When you just want to link somewhere:
action :documentation,
label: "Docs",
route_options: {url: "https://docs.example.com"},
icon: Phlex::TablerIcons::Book,
resource_action: true
action :reports,
route_options: {action: :reports}, # links to PostsController#reports
resource_action: trueCustom routes MUST be named:
register_resource ::Post do
collection { get :reports, as: :reports } # ← `as:` is required
endWithout as:, resource_url_for can't build the URL.
Inherited actions
Actions defined on the base ResourceDefinition propagate to every resource:
# app/definitions/resource_definition.rb
class ResourceDefinition < Plutonium::Resource::Definition
action :archive, interaction: ArchiveInteraction, color: :danger, position: 1000
endEvery resource gets :archive automatically.
Chaining interactions
def execute
CreateUserInteraction.call(view_context:, **user_params)
.and_then { |r| SendWelcomeEmail.call(view_context:, user: r.value) }
.and_then { |r| LogActivity.call(view_context:, user: r.value) }
.with_message("User created and welcomed!")
endThe chain short-circuits on the first failure.
Common issues
- Action button missing — check the policy method (
def my_action?). Undefined returnsfalse. ActiveRecord::RecordInvalidcrashes the action — not rescued automatically. Wrap withrescue, returnfailed(e.record.errors).- Bulk action fails on some records — that's by design. Bulk policy is checked per-record; if any fails, the whole request is rejected. Either fix authorization or pre-filter the selection.
- Confirmation prompt shows when you don't want one — pass
confirmation: falseon the action.
Related
- Reference › Resource › Actions — full action options and bulk patterns
- Reference › Behavior › Interactions — interaction class anatomy
- Reference › Behavior › Policies —
def <action>?methods - Authorization — policy patterns
