In the previous post we examined meta-programming in RubyMotion and using the text-to-speech functionality available in iOS7.
In this article we are taking a look at a very large Rails engine, ActiveAdmin, and a way to integrate state_machine. There is no reason you cannot use both without a gem tying these together, but of course more DSL makes things cleaner for repetitive tasks!
The gem providing a simple DSL that looks right at home in ActiveAdmin is ActiveAdmin-StateMachine.
Our reasoning
Why would we want to create a gem adding even more DSL to ActiveAdmin? Let's consider the scenario of managing some content, say a blog Post
. The Post
can be in several states, going from draft, to published, and to archived.
This state machine is exactly why we would want to use the state_machine gem itself, which defines the logic and workflow that the Post
goes through in our domain. However, we end up writing a lot of controller code to implement the functionality to move the Post
through the workflow.
- We need to ensure the user is authorized to move the
Post
to the next step in the workflow. - We write a lot of conditional UI to determine what actions are indeed available to move the
Post
to. - The controller actions turn out to be pretty cookie-cutter after a few, and there is our hint to refactor!
Post state machine
What follows is our example Post
class which has an imaginable workflow for management.
# app/models/post.rb
class Post < ActiveRecord::Base
attr_accessible :body, :status, :title
validates :title, presence: true, uniqueness: true
validates :body, presence: true
DRAFT = 'draft'
REVIEWED = 'reviewed'
PUBLISHED = 'published'
state_machine :status, initial: DRAFT do
event :peer_review do
transition DRAFT => REVIEWED
end
event :publish do
transition REVIEWED => PUBLISHED
end
end
end
Controller actions
In the simplest example of creating a controller action, we have exposed a new method state_action
that appears to be just another ActiveAdmin DSL method at our disposal.
We see that we can pass blocks for further define the controller action as we see fit, but most of our configuration is handled with a few options and sane defaults of I18n translations.
The reason we can get by with this is because state_machine exposes the following methods on our Post
class:
p = Post.new
p.can_publish?
p.publish!
With those dynamic methods defined, our controller action that state_action
creates for us starts to come into focus:
class PostsController
def publish
if resource.can_publish?
resource.publish!
redirect_to smart_resource_url, alert: "post was published!"
end
end
end
The full finished implementation that creates the controller action follows below. There are a few fun things to note:
- We make use of ActiveAdmin's
member_action
to specify a code block for the controller action body, but also provide a HTTP verb which translates into the routing system. - We allow the user to pass lambdas for several options so that they can determine a I18n string or message in a view context, but the defaults are very sensible and helpful.
- ActiveAdmin is built itself on top of InheritedResources, which provides a lot of the subsystem that you might not be familiar with, such as
smart_resource_url
. - We're additionally defining an action item for the state, such as "Publish". In ActiveAdmin, an action item is a button that is shown when viewing the resource.
This appears as:
module ActiveAdmin
module StateMachine
module DSL
#
# Easily tie into a state_machine action
#
# @param [Symbol] state machine event, ie: :publish
# @param [Hash] options
# - permission [Symbol] permission to check authorization against
# - http_verb [Symbol] :put, :post, :get, etc
#
# Will call "resource.publish!", if "resource.can_publish?" returns true
#
def state_action(action, options={}, &controller_action)
singular = config.resource_name.singular
plural = config.resource_name.plural
options[:permission] ||= controller.new.send(:action_to_permission, action)
confirmation = options.fetch(:confirm, false)
if confirmation == true
default = "Are you sure you want to #{action.to_s.humanize.downcase}?"
confirmation = ->{ I18n.t(:confirm, scope: "#{plural}.#{action}", default: default) }
end
http_verb = options.fetch :http_verb, :put
action_item only: :show do
if resource.send("can_#{action}?") && authorized?(options[:permission], resource)
path = resource_path << "/#{action}"
label = I18n.t("#{plural}.#{action}.label", default: action.to_s.titleize)
link_options = {}
if confirmation.is_a?(Proc)
link_options[:data] ||= {}
link_options[:data][:confirm] = instance_exec(&confirmation)
end
link_options[:class] = "btn btn-large"
link_options[:method] = http_verb
link_to label, path, link_options
end
end
unless block_given?
controller_action = -> do
resource.send("#{action}!")
flash[:notice] = t("#{plural}.#{action}.flash.success")
redirect_to smart_resource_url
end
end
member_action action, method: http_verb, &controller_action
end
end
end
end
Wrapping up
I wanted to introduce this gem as a super convenient way to integrate state_machine into your ActiveAdmin project. The UI for moving the resource through the state machine is completed generated from the state machine itself, and everything respects your CanCan ability (or custom ActiveAdmin authorization adapter).
Be sure to take a look at the project README more in depth for full example usage, including the fact that this gem is helpful even without state_machine!
Up next
What do you want to see in the next article, connect via twitter @madebylotus and let me know!