Navigation Menu

Skip to content

Instantly share code, notes, and snippets.

@radar
Created July 5, 2012 08:28
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save radar/3052267 to your computer and use it in GitHub Desktop.
Save radar/3052267 to your computer and use it in GitHub Desktop.
Order.class_eval do
checkout_flow do
go_to_state :address
go_to_state :delivery
go_to_state :payment, :if => lambda { payment_required? }
go_to_state :confirm, :if => lambda { confirmation_required? }
go_to_state :complete
remove_transition :from => :delivery, :to => :confirm
end
end

This is an example implementation of an order class that can have custom states defined for its instances. This could allow a flexible workflow for Spree.

The code works by calling the checkout_flow method which clears all current transitions. The go_to_state method then defines transitions for each step of the process. The first call will transition from cart, and subsequent calls will transition from the state before it.

If a state has a condition on it, then a record of all previous states are kept until there is a state that has no condition. Once this happens, the code will define state transitions for all state paths for the states in between. For instance, the follow transitions will be defined between delivery and complete:

  • Delivery to payment
  • Delivery to confirm
  • Delivery to complete
  • Payment to confirm
  • Payment to complete
  • Confirm to complete

The remove method will remove a transition that has previously been either automatically or manually defined. In the case of this state machine, the delivery to confirm transition has been automatically defined, but we don't want it. So we get rid of it. As in the above example, the "delivery to confirm" transition will be removed.

Please review the code and attempt to run the specs and point out any issues you see.

require 'active_support/core_ext/class/attribute_accessors'
require 'state_machine'
module Checkout
def self.included(klass)
klass.class_eval do
attr_accessor :state
attr_accessor :transitions
attr_accessor :previous_states
cattr_accessor :checkout_flow
def self.checkout_flow(&block)
if block_given?
@checkout_flow = block
else
@checkout_flow
end
end
def initialize
super
machine
end
def transitions
@transitions ||= []
end
def add_transition(options)
self.transitions << { options.delete(:from) => options.delete(:to) }.merge(options)
end
# TODO: Delegate
def next!
machine.next!
end
def machine
@machine ||= begin
order = self
checkout_flow = order.class.checkout_flow
self.state = :cart
self.previous_states = [:cart]
order.instance_eval(&checkout_flow)
StateMachine.new(order, :initial => :cart) do
order.transitions.each { |attrs| transition(attrs) }
# Persist the state on the order
after_transition do
order.state = order.machine.state
order.save
end
end
end
end
def go_to_state(name, options={})
if options[:if]
previous_states.each do |state|
add_transition({:from => state, :to => name, :on => :next}.merge(options))
end
self.previous_states << name
else
previous_states.each do |state|
add_transition({:from => state, :to => name, :on => :next}.merge(options))
end
self.previous_states = [name]
end
end
def remove(options={})
if transition = find_transition(options)
self.transitions.delete(transition)
end
end
def find_transition(options={})
self.transitions.detect do |transition|
transition[options[:from].to_sym] == options[:to].to_sym
end
end
end
end
class StateMachine
def self.new(object, *args, &block)
machine = Class.new do
def definition
self.class.state_machine
end
end
machine.state_machine(*args, &block)
machine.new
end
end
end
class Order
include Checkout
def payment_required?
false
end
def confirmation_required?
false
end
def save
#noop
end
end
describe Order do
let(:order) { Order.new }
context "with default state machine" do
before do
Order.class_eval do
checkout_flow do
go_to_state :address
go_to_state :delivery
go_to_state :payment, :if => lambda { payment_required? }
go_to_state :confirm, :if => lambda { confirmation_required? }
go_to_state :complete
remove :from => :delivery, :to => :confirm
end
end
end
it "has the following transitions" do
transitions = [
{ :address => :delivery },
{ :delivery => :payment },
{ :payment => :confirm },
{ :confirm => :complete },
{ :payment => :complete },
{ :delivery => :complete }
]
transitions.each do |transition|
transition = order.find_transition(:from => transition.keys.first, :to => transition.values.first)
transition.should_not be_nil
end
end
it "does not have a transition from delivery to confirm" do
transition = order.find_transition(:from => :delivery, :to => :confirm)
transition.should be_nil
end
it "starts out at cart" do
order.state.should == :cart
end
it "transitions to address" do
order.next!
order.state.should == "address"
end
context "from address" do
before do
order.machine.state = 'address'
end
it "transitions to delivery" do
order.next!
order.state.should == "delivery"
end
end
context "from delivery" do
before do
order.machine.state = 'delivery'
end
context "with payment required" do
before do
order.stub :payment_required? => true
end
it "transitions to payment" do
order.next!
order.state.should == 'payment'
end
end
context "without payment required" do
before do
order.stub :payment_required? => false
end
it "transitions to complete" do
order.next!
order.state.should == "complete"
end
end
end
context "from payment" do
before do
order.machine.state = 'payment'
end
context "with confirmation required" do
before do
order.stub :confirmation_required? => true
end
it "transitions to confirm" do
order.next!
order.state.should == "confirm"
end
end
context "without confirmation required" do
before do
order.stub :confirmation_required? => false
end
it "transitions to complete" do
order.next!
order.state.should == "complete"
end
end
end
end
end
@BDQ
Copy link

BDQ commented Jul 6, 2012

This looks very promising, but I've a couple of questions:

  1. Would the current default state_machine definition need to change to support this?

  2. The redefine_states! methods doesn't actually clear the states just the transitions, so
    2.A) I think the name is misleading.
    2.B) Should it not just clear the transitions associated with the :next event, as clearing all could affect transitions on other events?
    2.C) Is there any downside to leaving states defined which are never transitioned to?

  3. How would I add a new state? Could go_to_state support adding them if the state is undefined?

  4. This doesn't address the idea of loading the state machine per order instance, do you think that aspect is overkill?

@radar
Copy link
Author

radar commented Jul 7, 2012

🏆

  1. The definition in the Order model will be moved to a separate module which will be included into the Order model. This is just to make the Order model a lot neater, and if we want, we would be able to test this without using the Order model as well (as we do in this Gist). Other than that... no. The current state machine will not change.

  2. The redefine_states! method is gone. I've replaced it now with a checkout_flow class method which will allow you to define the state machine much cleaner.
    2.A) All that method does was clear the transitions and re-define some state-machine specific variables, which I've now moved into the machine method. That method is called when a new Order object is initialized.
    2.B) The only transitions that were being effected are the ones for the next event.
    2.C) Not that I am aware of. If a state does not have a transition going to it, it won't adversely effect the state machine.

  3. You don't. By defining a transition to a state using go_to_state the state is automatically defined. I think state_machine does that.

  4. Yes, I think that loading the state machine per order instance is overkill. If people want to customize the order process dramatically per-order, I think that can wait until v2.

@BDQ
Copy link

BDQ commented Jul 9, 2012

Ryan - I'm happy with this now, great stuff.

@radar
Copy link
Author

radar commented Jul 9, 2012 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment