Back to list
radioactive-labs

plutonium-interaction

by radioactive-labs

Build production-ready Rails apps in minutes, not days. Convention-driven, fully customizable, AI-ready.

52🍴 7📅 Jan 23, 2026

SKILL.md


name: plutonium-interaction description: Plutonium interactions - encapsulated business logic for custom actions

Plutonium Interactions

Interactions encapsulate business logic into reusable, testable units. They handle input validation, execution, and outcomes.

Basic Structure

# app/interactions/resource_interaction.rb (generated during install)
class ResourceInteraction < Plutonium::Resource::Interaction
end

# app/interactions/publish_post_interaction.rb
class PublishPostInteraction < ResourceInteraction
  # Presentation
  presents label: "Publish",
           icon: Phlex::TablerIcons::Send,
           description: "Make this post public"

  # Attributes (inputs)
  attribute :resource  # The record being acted upon
  attribute :publish_date, :datetime, default: -> { Time.current }

  # Form inputs (what user sees)
  input :publish_date, as: :datetime

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

Attributes

Define inputs using ActiveModel attributes:

attribute :resource                              # Record (for record actions)
attribute :resources                             # Collection (for bulk actions)
attribute :email, :string                        # String input
attribute :count, :integer, default: 1           # With default
attribute :active, :boolean, default: -> { true } # Callable default
attribute :tags, :array                          # Array
attribute :metadata, :hash                       # Hash
attribute :date, :datetime                       # DateTime

Form Inputs

Define form fields with the input method (same as definitions):

input :email
input :role, as: :select, choices: %w[admin user]
input :content, as: :text
input :date, as: :date

See plutonium-definition-fields skill for all input types and options.

Presentation

Configure how the action appears in the UI:

presents label: "Archive Record",
         icon: Phlex::TablerIcons::Archive,
         description: "Move to archive for later reference"

Access presentation:

MyInteraction.label       # => "Archive Record"
MyInteraction.icon        # => Phlex::TablerIcons::Archive
MyInteraction.description # => "Move to archive..."

Execution and Outcomes

The execute Method

private

def execute
  # Your business logic here
  # Must return succeed() or failed()
end

Success Outcomes

# Basic success (redirects automatically to resource)
succeed(resource)

# With message
succeed(resource).with_message("Done!")
succeed(resource).with_message("Warning!", :alert)

# With custom redirect (only if different from default)
succeed(resource).with_redirect_response(custom_path)

# With file download
succeed(resource).with_file_response(file_path, filename: "report.pdf")

Note: Redirect is automatic on success - the controller redirects to the resource by default. Only use with_redirect_response if you need a different destination.

Failure Outcomes

# Basic failure
failed("Something went wrong")

# With ActiveModel errors
failed(resource.errors)

# With hash of errors
failed(email: "is invalid", name: "is required")

Chaining Interactions

def execute
  CreateUserInteraction.call(view_context:, **user_params)
    .and_then { |result| SendWelcomeEmail.call(view_context:, user: result.value) }
    .and_then { |result| LogActivity.call(view_context:, user: result.value) }
    .with_message("User created and welcomed!")
end

On failure, the chain short-circuits and returns the failure immediately.

Validations

Use standard ActiveModel validations:

validates :email, presence: true
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :role, inclusion: { in: %w[admin user guest] }

validate :custom_validation

private

def custom_validation
  if resource.archived?
    errors.add(:resource, "cannot be modified when archived")
  end
end

Validations run automatically before execute. If invalid, returns failed() with errors.

Interaction Types

Record Actions

Act on a single record:

class ArchiveInteraction < Plutonium::Resource::Interaction
  attribute :resource  # Single record

  def execute
    resource.update!(archived: true)
    succeed(resource)
  rescue ActiveRecord::RecordInvalid => e
    failed(e.record.errors)
  end
end

Note: ActiveRecord::RecordInvalid is NOT rescued automatically. Always rescue it when using bang methods (create!, update!, save!).

Resource Actions

Act at the collection/class level (no specific record):

class ImportInteraction < Plutonium::Resource::Interaction
  # No :resource attribute
  attribute :file

  input :file, as: :file

  def execute
    records = CSV.parse(file)
    Post.import(records)
    succeed(records)
  end
end

Bulk Actions (Multiple Records)

Act on multiple selected records. When registered, the table shows checkboxes and a toolbar appears when records are selected.

class BulkArchiveInteraction < Plutonium::Resource::Interaction
  attribute :resources  # Collection of records (note: plural)

  def execute
    resources.update_all(archived: true)
    succeed(resources).with_message("Archived #{resources.count} records")
  end
end

Authorization: Bulk actions use per-record authorization. The policy method is checked for each selected record - if any fails, the entire request is rejected. The UI only shows actions that all selected records support.

Connecting to Definitions

Register interactions as actions:

class PostDefinition < ResourceDefinition
  # Record action (shows on individual records)
  action :publish, interaction: PublishPostInteraction

  # Resource action (shows at collection level)
  action :import, interaction: ImportInteraction

  # With options
  action :archive,
    interaction: ArchiveInteraction,
    confirmation: "Are you sure?",
    category: :danger,
    position: 100
end

Action Options

OptionDescription
interaction:The interaction class
confirmation:Confirmation message before execution
category::primary, :secondary, :danger
position:Display order (lower = first)
turbo_frame:Turbo frame target (default: remote_modal)
icon:Override interaction icon
label:Override interaction label

Policy Integration

Control access with policy methods:

class PostPolicy < ResourcePolicy
  def publish?
    update? && record.draft?
  end

  def archive?
    destroy? && !record.archived?
  end

  def import?
    create?  # Resource-level action
  end
end

The policy method name matches the action name with ?.

Accessing Context

Inside interactions:

def execute
  # Access current user via view_context
  current_user = view_context.controller.helpers.current_user

  # Access the resource
  resource.update!(updated_by: current_user)

  succeed(resource)
end

Immediate vs Form Actions

Plutonium automatically determines if an action needs a form:

  • Has inputs defined → Shows form first (GET), then executes (POST)
  • No inputs → Executes immediately (POST with confirmation)
# Shows form (has inputs)
class InviteUserInteraction < Plutonium::Resource::Interaction
  attribute :resource
  attribute :email
  input :email  # This triggers form display
end

# Immediate execution (no inputs)
class ArchiveInteraction < Plutonium::Resource::Interaction
  attribute :resource
  # No inputs = immediate with confirmation
end

Complete Example

class Company::InviteUserInteraction < Plutonium::Resource::Interaction
  presents label: "Invite User",
           icon: Phlex::TablerIcons::UserPlus,
           description: "Send an invitation email"

  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

  def current_user
    view_context.controller.helpers.current_user
  end
end

Best Practices

  1. Keep interactions focused - One action per interaction
  2. Use validations - Validate all inputs before execution
  3. Handle errors gracefully - Rescue exceptions and return failed()
  4. Return meaningful messages - Help users understand what happened
  5. Use and_then for chains - Compose complex workflows from simple interactions
  6. Test independently - Interactions are easy to unit test
  • plutonium-definition-actions - Declaring actions in definitions
  • plutonium-forms - Custom interaction form templates
  • plutonium-policy - Controlling access to actions
  • plutonium-resource - How interactions fit in the architecture

Score

Total Score

75/100

Based on repository quality metrics

SKILL.md

SKILL.mdファイルが含まれている

+20
LICENSE

ライセンスが設定されている

+10
説明文

100文字以上の説明がある

+10
人気

GitHub Stars 100以上

0/15
最近の活動

1ヶ月以内に更新

+10
フォーク

10回以上フォークされている

0/5
Issue管理

オープンIssueが50未満

+5
言語

プログラミング言語が設定されている

+5
タグ

1つ以上のタグが設定されている

+5

Reviews

💬

Reviews coming soon