|
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 |
🏆
The definition in the
Order
model will be moved to a separate module which will be included into theOrder
model. This is just to make theOrder
model a lot neater, and if we want, we would be able to test this without using theOrder
model as well (as we do in this Gist). Other than that... no. The current state machine will not change.The
redefine_states!
method is gone. I've replaced it now with acheckout_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 newOrder
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.
You don't. By defining a transition to a state using
go_to_state
the state is automatically defined. I thinkstate_machine
does that.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.