Skip to main content

Buttons for AASM events

This might be of interest if you use the AASM state machine gem and Ruby on Rails. If not, well, I warned you.

Let’s take the example, from the AASM documentation, of a Job. You can run it, sleep it or clean it. We want to have an UI that allows an operator to control the job with a series of buttons.

class Job
  include AASM

  aasm do
    state :sleeping, initial: true
    state :running, :cleaning

    event :run do
      transitions from: :sleeping, to: :running
    end

    event :clean do
      transitions from: :running, to: :cleaning
    end

    event :sleep do
      transitions from: [:running, :cleaning], to: :sleeping
    end
  end

end

The buttons that need to be shown depend on the state. You write some code that copes with that:

<% if @job.sleeping? %>
 <%= link_to "Run", run_job_path, method: :patch, class: "btn btn-success" %>
<% elsif @job.running? %>
 <%= link_to "Clean", clean_job_path, method: :patch, class: "btn btn-default" %>
 <%= link_to "Shut Down", sleep_job_path, method: :patch, class: "btn btn-danger" %>
<% elsif @job.cleaning? %>
 <%= link_to "Shut Down", sleep_job_path, method: :patch, class: "btn btn-danger" %>
<% end %>

And if the job is running:

Step 1 — Less to think about

But that’s hard to get right (you need to understand all the events and their transitions) and will need changing if the model changes. However, AASM allows you to inspect the state and see what is possible. Instead of working out which events are possible from which state we just ask what events are possible from the current state and check if our event is in that list. It makes things a little better:

<% @job.aasm.events(possible: true).map(&:name).tap do |possible| %>
  <% if possible.include? :run %>
    <%= link_to "Run", run_job_path, method: :patch, class: "btn btn-success" %>
  <% end %>

  <% if possible.include? :clean %>
    <%= link_to "Clean", clean_job_path, method: :patch, class: "btn btn-default" %>
  <% end %>
  <% if possible.include? :sleep %>
    <%= link_to "Shut Down", sleep_job_path, method: :patch, class: "btn btn-danger" %>
  <% end %>
<% end %>

That’s still lots of typing but at least we don’t need to know about the relationship between state and events.

Step 2 — Less code too

We can take this a step further though. Instead of checking whether a specific event is in the list of possible events why not just iterate over the list of events? We’ll need to adapt our code a little, especially the path:

<% @job.aasm.events(possible: true).map(&:name).each do |event| %>
  <%= link_to event, [event, @job], method: :patch, class: "btn btn-default" %>
<% end %>

This will display a button for each event that is possible from that state. We’ve only used three lines of code and it will automatically adapt to any code changes in the model. Nice.

Step 3 — Making it look nicer

We want the button captions to be different from the event names and we might want to use different styling on different buttons. We can make use of Rails’ I18n to translate those values:

<% job.aasm.events(possible: true).map(&:name).each do |event| %>
  <%= link_to t(event, scope: [:job, :event]), [event, job], method: :patch, class: "btn btn-#{t event, scope: [:job, :btn_class]}" %>
<% end %>

And then in the en.yml file:

en:
  job:
    event:
      run: "Run"
      sleep: "Shut Down"
      clean: "Clean"
    btn_class:
      run: "success"
      sleep: "danger"
      clean: "default"

Step 4 — Not All Events

Say we introduce a new event called alarm which gets called if something bad happens. We don’t want operators to press the alarm button so we introduce the concept of operator_events and clean up our view a little in the process:

class Job
  include AASM
  aasm do
    state :sleeping, initial: true
    state :running, :cleaning, :broken
    event :run do
      transitions from: :sleeping, to: :running
    end
    event :clean do
      transitions from: :running, to: :cleaning
    end
    event :sleep do
     transitions from: [:running, :cleaning], to: :sleeping
    end
    event :alarm do
      transitions from: [:running], to: :broken
    end
    event :fix do
      transitions from: [:broken], to: :sleeping
    end
  end
  def operator_events
    aasm.events(possible: true).map(&:name) & %i[run clean sleep fix]
  end
end

Then the view is slightly simpler and will never show the alarm button:

<% @job.operator_events.each do |event| %>
  <%= link_to t(event, scope: [:job, :event]), [event, @job], method: :patch, class: "btn btn-#{t event, scope: [:job, :btn_class]}" %>
<% end %>

Step 5 — Sharing

This code is a candidate for being a shared partial.

render 'shared/events', model: job

And the partial that we can reuse for other models:

<% model.operator_events.each do |event| %>
  <%= link_to t(event, scope: [model.class.name.underscore, :event]), [event, model], method: :patch, class: "btn btn-#{t event, scope: [model.class.name.underscore, :btn_class]}" %>
<% end %>

If this feels like a bit too much abstraction for you, at least go for step 1 and don’t make your view think about what event is possible from what state. Here it is again:

<% @job.aasm.events(possible: true).map(&:name).tap do |possible| %>
  <% if possible.include? :run %>
    <%= link_to "Run", run_job_path, method: :patch, class: "btn btn-success" %>
  <% end %>
  <% if possible.include? :clean %>
    <%= link_to "Clean", clean_job_path, method: :patch, class: "btn btn-default" %>
  <% end %>
  <% if possible.include? :sleep %>
    <%= link_to "Shut Down", sleep_job_path, method: :patch, class: "btn btn-danger" %>
  <% end %>
<% end %>

See the code

You can see the code for this article in this GitHub repository with each step as a separate commit.