Introducing ActiveAdmin-StateMachine

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!

Ready to have a chat?

Contact us to chat with our founder
so we can learn about you and your project.