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 %>
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]
endend
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 %>sh
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 %>
You can see the code for this article here with each step as a separate commit.