Skip to content

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

FlavorUse for
Simple action — navigate to a URLLinking to external docs, jumping to a custom page that does its own thing
Interactive action — run an interaction classAnything 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

ruby
# 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
end

Rescue 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

ruby
class PostDefinition < ResourceDefinition
  action :publish, interaction: PublishPostInteraction
end

Action 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

ruby
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 declaresInferred flag → button shows up
attribute :resourcerecord_action: true + collection_record_action: true → show page + per-row
attribute :resources (plural)bulk_action: true → bulk toolbar
neitherresource_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:

ruby
# 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: false

For simple navigation actions (no interaction:), declare flags manually.

With form inputs

If the interaction declares extra attribute/input, a modal form is rendered first:

ruby
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
end

Bulk actions

Plural attribute :resources automatically becomes a bulk action. The table gets checkboxes and a bulk-actions toolbar.

ruby
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
end

Policy — checked per record (fails the whole request if any record is unauthorized):

ruby
def bulk_archive?
  create? && !record.locked?
end

The UI only shows bulk actions ALL selected records support.

Resource action (no specific record)

Neither :resource nor :resources → resource action on the index page:

ruby
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
end

Immediate vs form

  • Immediate — interaction has only :resource / :resources (no extra inputs). Browser confirmation ("#{label}?", e.g. "Archive?"), then runs. Override with confirmation: "Custom message" or confirmation: false on the action.
  • Form — interaction has additional attribute / input. Renders modal form first; no auto-confirmation (the form is the confirmation).

Action options

ruby
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 :slideover

Full options: Reference › Resource › Actions › Action options.

Simple actions (navigation only)

When you just want to link somewhere:

ruby
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: true

Custom routes MUST be named:

ruby
register_resource ::Post do
  collection { get :reports, as: :reports }   # ← `as:` is required
end

Without as:, resource_url_for can't build the URL.

Inherited actions

Actions defined on the base ResourceDefinition propagate to every resource:

ruby
# app/definitions/resource_definition.rb
class ResourceDefinition < Plutonium::Resource::Definition
  action :archive, interaction: ArchiveInteraction, color: :danger, position: 1000
end

Every resource gets :archive automatically.

Chaining interactions

ruby
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!")
end

The chain short-circuits on the first failure.

Common issues

  • Action button missing — check the policy method (def my_action?). Undefined returns false.
  • ActiveRecord::RecordInvalid crashes the action — not rescued automatically. Wrap with rescue, return failed(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: false on the action.

Released under the MIT License.