Interaction β
Encapsulate business logic into testable, reusable units. Registered as actions in definitions and executed by the controller. Built on ActiveModel attributes + validations.
π¨ Critical β
ActiveRecord::RecordInvalidis NOT rescued automatically. Always rescue when usingcreate!/update!/save!, returnfailed(e.record.errors).- Return
succeed(...)orfailed(...)fromexecuteβ the controller can't tell what happened otherwise. Returning anything else raises. - Redirect is automatic on success β only use
with_redirect_responsefor a different destination. - Bulk actions use
attribute :resources(plural). Policy authorization is checked per record β if any fails, the whole request fails. - The shape of the action (record / bulk / resource) is inferred from the interaction's attributes. See Resource βΊ Actions.
Structure β
# app/interactions/resource_interaction.rb β installed once
class ResourceInteraction < Plutonium::Resource::Interaction
end
# A real interaction
class PublishPostInteraction < ResourceInteraction
presents label: "Publish",
icon: Phlex::TablerIcons::Send,
description: "Make this post public"
attribute :resource
attribute :publish_date, :datetime, default: -> { Time.current }
input :publish_date
validates :publish_date, presence: true
private
def execute
resource.update!(published_at: publish_date)
succeed(resource).with_message("Post published!")
rescue ActiveRecord::RecordInvalid => e
failed(e.record.errors)
end
endAttributes β
ActiveModel-style:
attribute :resource # single record (record action)
attribute :resources # array of records (bulk action)
attribute :email, :string
attribute :count, :integer, default: 1
attribute :active, :boolean, default: -> { true } # callable default
attribute :tags, :array
attribute :metadata, :hash
attribute :date, :datetimeThe presence of :resource / :resources / neither determines the action type β see Resource βΊ Actions βΊ Inferred visibility.
Inputs β
Same DSL as definition input. Auto-detection from the attribute type applies β declare as: only when overriding.
input :email # auto: :email type from name match
input :role, as: :select, choices: %w[admin user]
input :content, as: :textSee Resource βΊ Definition for all as: types, options, and dynamic blocks.
Presentation β
presents label: "Archive Record",
icon: Phlex::TablerIcons::Archive,
description: "Move to archive"Access:
MyInteraction.label # => "Archive Record"
MyInteraction.icon # => Phlex::TablerIcons::Archive
MyInteraction.description # => "Move to archive"If action :foo, interaction: FooInteraction doesn't override label: / icon: etc., these presents values are used.
execute β outcomes β
execute MUST return a succeed(...) or failed(...) outcome. Validations run automatically before execute; if they fail, the interaction short-circuits to failed().
Success β
succeed(resource) # auto-redirect to resource
succeed(resource).with_message("Done!")
succeed(resource).with_message("Heads up!", :alert)
succeed(resource).with_redirect_response(custom_path) # different destination
succeed(resource).with_file_response(path, filename: "report.pdf")
succeed(resource).with_render_response(:custom_template)Failure β
failed("Something went wrong")
failed(resource.errors)
failed(email: "is invalid", name: "is required") # hash form
failed("Invalid value", :email) # string + attributeManual error addition β
def execute
errors.add(:base, "Post must have content")
return failure if errors.any?
# β¦continue
endChaining β
and_then chains interactions. On failure, the chain short-circuits and returns the failure immediately.
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!")
endValidations β
Standard ActiveModel. Run automatically before execute:
validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
validates :role, inclusion: {in: %w[admin user guest]}
validate :custom_check
private
def custom_check
errors.add(:resource, "cannot be modified when archived") if resource.archived?
endAccessing context β
current_user is provided by the base class (view_context.controller.helpers.current_user):
def execute
resource.update!(updated_by: current_user)
succeed(resource)
endInteraction types β
| Attribute pattern | Action type | Where it shows up |
|---|---|---|
attribute :resource | Record action | Show page + per-row in table |
attribute :resources (plural) | Bulk action | Bulk toolbar above table |
| neither | Resource action | Index page header |
Record action β
class ArchiveInteraction < Plutonium::Resource::Interaction
attribute :resource
def execute
resource.update!(archived: true)
succeed(resource).with_message("Archived")
rescue ActiveRecord::RecordInvalid => e
failed(e.record.errors)
end
endBulk action β
class BulkArchiveInteraction < Plutonium::Resource::Interaction
attribute :resources
def execute
resources.update_all(archived: true)
succeed(resources).with_message("Archived #{resources.size} records")
end
endPer-record authorization details in Resource βΊ Actions βΊ Bulk action.
Resource action (no record) β
class ImportInteraction < Plutonium::Resource::Interaction
attribute :file
input :file, as: :file
validates :file, presence: true
def execute
# β¦import logic
succeed(nil).with_message("Import completed.")
end
endCalling interactions directly β
The controller handles this for interactive actions. But you can call them manually too β useful in tests, jobs, and rake tasks.
Class method β
outcome = PublishPost.call(view_context: view_context, resource: post)
if outcome.success?
# β¦
else
# β¦
endInstance method β
interaction = PublishPost.new(view_context: view_context, resource: post)
outcome = interaction.callThe view_context: argument is required β interactions use it to access controller helpers and the current user.
Immediate vs form β
| Interaction shape | Behavior |
|---|---|
Only :resource / :resources (no extra attribute or input) | Immediate β browser confirmation ("#{label}?", e.g. "Archive?"), then runs. Override with confirmation: "Custom" or confirmation: false on the action. |
Additional attribute / input declared | Form β renders modal form first; no auto-confirmation. |
See Resource βΊ Actions βΊ Immediate vs form.
Generating interaction URLs β
resource_url_for with the interaction: kwarg. The action type (record / bulk / resource) is inferred from the element and the presence of ids::
# Record action β instance argument
resource_url_for(@post, interaction: :publish)
# => /posts/:id/record_actions/publish
# Resource action β class, no ids
resource_url_for(Post, interaction: :import)
# => /posts/resource_actions/import
# Bulk action β class + ids
resource_url_for(Post, interaction: :archive, ids: [1, 2, 3])
# => /posts/bulk_actions/archive?ids[]=1&ids[]=2&ids[]=3
# Composes with parent / entity scoping
resource_url_for(@post, parent: @user, interaction: :publish)The same URL serves GET (form/confirmation) and POST (commit) β the HTTP verb routes to the right controller action. Passing both interaction: and action: raises ArgumentError.
Complete example β
class Company::InviteUserInteraction < Plutonium::Resource::Interaction
presents label: "Invite User",
icon: Phlex::TablerIcons::UserPlus
attribute :resource # the company
attribute :email, :string
attribute :role, :string
input :email
input :role, as: :select, choices: -> { UserInvite.roles.keys }
validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
validates :role, presence: true, inclusion: {in: UserInvite.roles.keys}
validate :not_already_invited
private
def execute
invite = UserInvite.create!(
company: resource, email: email, role: role,
invited_by: current_user
)
UserInviteMailer.invitation(invite).deliver_later
succeed(resource).with_message("Invitation sent to #{email}")
rescue ActiveRecord::RecordInvalid => e
failed(e.record.errors)
end
def not_already_invited
return unless email.present?
if UserInvite.exists?(company: resource, email: email, state: :pending)
errors.add(:email, "already has a pending invitation")
end
end
endTesting β
RSpec.describe PublishPost do
let(:view_context) { double("view_context", controller: double(helpers: double(current_user: user))) }
let(:user) { create(:user) }
let(:post) { create(:post, user: user, published: false) }
it "publishes the post" do
outcome = described_class.call(view_context: view_context, resource: post)
expect(outcome).to be_success
expect(post.reload).to be_published
end
endSee Testing for Plutonium's built-in testing helpers β ResourceInteraction concern wraps these patterns.
Related β
- Resource βΊ Actions β registering interactions, inferred visibility, immediate vs form
- Policies β
def <action>?authorization methods - Controllers β
resource_url_for(..., interaction: β¦)URL generation - UI βΊ Forms β customizing the modal form rendered for actions with inputs
