diff --git a/README.md b/README.md index b3f05f9..eeea00f 100644 --- a/README.md +++ b/README.md @@ -417,6 +417,71 @@ SimpleMultipleExample.aasm(:work).states *Final note*: Support for multiple state machines per class is a pretty new feature (since version `4.3`), so please bear with us in case it doesn't work as expected. +### Extending AASM + +AASM allows you to easily extend `AASM::Base` for your own application purposes. + +Let's suppose we have common logic across many AASM models. We can embody this logic in a sub-class of `AASM::Base`. + +``` +class CustomAASMBase < AASM::Base + # A custom transiton that we want available across many AASM models. + def count_transitions! + klass.class_eval do + aasm :with_klass => CustomAASMBase do + after_all_transitions :increment_transition_count + end + end + end + + # A custom annotation that we want available across many AASM models. + def requires_guards! + klass.class_eval do + attr_reader :authorizable_called, + :transition_count, + :fillable_called + + def authorizable? + @authorizable_called = true + end + + def fillable? + @fillable_called = true + end + + def increment_transition_count + @transition_count ||= 0 + @transition_count += 1 + end + end + end +end +``` + +When we declare our model that has an AASM state machine, we simply declare the AASM block with a `:with` key to our own class. + +``` +class SimpleCustomExample + include AASM + + # Let's build an AASM state machine with our custom class. + aasm :with_klass => CustomAASMBase do + requires_guards! + count_transitions! + + state :initialised, :initial => true + state :filled_out + state :authorised + + event :fill_out do + transitions :from => :initialised, :to => :filled_out, :guard => :fillable? + end + event :authorise do + transitions :from => :filled_out, :to => :authorised, :guard => :authorizable? + end + end +end +``` ### ActiveRecord diff --git a/lib/aasm/aasm.rb b/lib/aasm/aasm.rb index a1b2521..4523bd3 100644 --- a/lib/aasm/aasm.rb +++ b/lib/aasm/aasm.rb @@ -38,6 +38,12 @@ module AASM AASM::StateMachine[self][state_machine_name] ||= AASM::StateMachine.new(state_machine_name) + # use a default despite the DSL configuration default. + # this is because configuration hasn't been setup for the AASM class but we are accessing a DSL option already for the class. + aasm_klass = options[:with_klass] || AASM::Base + + raise ArgumentError, "The class #{aasm_klass} must inherit from AASM::Base!" unless aasm_klass.ancestors.include?(AASM::Base) + @aasm ||= {} if @aasm[state_machine_name] # make sure to use provided options @@ -46,7 +52,7 @@ module AASM end else # create a new base - @aasm[state_machine_name] = AASM::Base.new( + @aasm[state_machine_name] = aasm_klass.new( self, state_machine_name, AASM::StateMachine[self][state_machine_name], diff --git a/lib/aasm/base.rb b/lib/aasm/base.rb index 6a912e9..a5f1998 100644 --- a/lib/aasm/base.rb +++ b/lib/aasm/base.rb @@ -1,12 +1,13 @@ module AASM class Base - attr_reader :state_machine + attr_reader :klass, + :state_machine def initialize(klass, name, state_machine, options={}, &block) @klass = klass @name = name - # @state_machine = @klass.aasm(@name).state_machine + # @state_machine = klass.aasm(@name).state_machine @state_machine = state_machine @state_machine.config.column ||= (options[:column] || default_column).to_sym # @state_machine.config.column = options[:column].to_sym if options[:column] # master @@ -27,12 +28,15 @@ module AASM # set to true to forbid direct assignment of aasm_state column (in ActiveRecord) configure :no_direct_assignment, false + # allow a AASM::Base sub-class to be used for state machine + configure :with_klass, AASM::Base + configure :enum, nil # make sure to raise an error if no_direct_assignment is enabled # and attribute is directly assigned though aasm_name = @name - @klass.send :define_method, "#{@state_machine.config.column}=", ->(state_name) do + klass.send :define_method, "#{@state_machine.config.column}=", ->(state_name) do if self.class.aasm(:"#{aasm_name}").state_machine.config.no_direct_assignment raise AASM::NoDirectAssignmentError.new( 'direct assignment of AASM column has been disabled (see AASM configuration for this class)' @@ -63,19 +67,19 @@ module AASM # define a state def state(name, options={}) - @state_machine.add_state(name, @klass, options) + @state_machine.add_state(name, klass, options) - if @klass.instance_methods.include?("#{name}?") - warn "#{@klass.name}: The aasm state name #{name} is already used!" + if klass.instance_methods.include?("#{name}?") + warn "#{klass.name}: The aasm state name #{name} is already used!" end aasm_name = @name - @klass.send :define_method, "#{name}?", ->() do + klass.send :define_method, "#{name}?", ->() do aasm(:"#{aasm_name}").current_state == :"#{name}" end - unless @klass.const_defined?("STATE_#{name.upcase}") - @klass.const_set("STATE_#{name.upcase}", name) + unless klass.const_defined?("STATE_#{name.upcase}") + klass.const_set("STATE_#{name.upcase}", name) end end @@ -83,8 +87,8 @@ module AASM def event(name, options={}, &block) @state_machine.add_event(name, options, &block) - if @klass.instance_methods.include?("may_#{name}?".to_sym) - warn "#{@klass.name}: The aasm event name #{name} is already used!" + if klass.instance_methods.include?("may_#{name}?".to_sym) + warn "#{klass.name}: The aasm event name #{name} is already used!" end # an addition over standard aasm so that, before firing an event, you can ask @@ -92,16 +96,16 @@ module AASM # on the transition will let this happen. aasm_name = @name - @klass.send :define_method, "may_#{name}?", ->(*args) do + klass.send :define_method, "may_#{name}?", ->(*args) do aasm(:"#{aasm_name}").may_fire_event?(:"#{name}", *args) end - @klass.send :define_method, "#{name}!", ->(*args, &block) do + klass.send :define_method, "#{name}!", ->(*args, &block) do aasm(:"#{aasm_name}").current_event = :"#{name}!" aasm_fire_event(:"#{aasm_name}", :"#{name}", {:persist => true}, *args, &block) end - @klass.send :define_method, "#{name}", ->(*args, &block) do + klass.send :define_method, "#{name}", ->(*args, &block) do aasm(:"#{aasm_name}").current_event = :"#{name}" aasm_fire_event(:"#{aasm_name}", :"#{name}", {:persist => false}, *args, &block) end @@ -145,7 +149,7 @@ module AASM # aasm.event(:event_name).human? def human_event_name(event) # event_name? - AASM::Localizer.new.human_event_name(@klass, event) + AASM::Localizer.new.human_event_name(klass, event) end def states_for_select diff --git a/lib/aasm/configuration.rb b/lib/aasm/configuration.rb index eb80865..12d21e4 100644 --- a/lib/aasm/configuration.rb +++ b/lib/aasm/configuration.rb @@ -18,6 +18,9 @@ module AASM # forbid direct assignment in aasm_state column (in ActiveRecord) attr_accessor :no_direct_assignment + # allow a AASM::Base sub-class to be used for state machine + attr_accessor :with_klass + attr_accessor :enum end -end \ No newline at end of file +end diff --git a/spec/models/simple_custom_example.rb b/spec/models/simple_custom_example.rb new file mode 100644 index 0000000..fe51c3f --- /dev/null +++ b/spec/models/simple_custom_example.rb @@ -0,0 +1,53 @@ +class CustomAASMBase < AASM::Base + # A custom transiton that we want available across many AASM models. + def count_transitions! + klass.class_eval do + aasm :with_klass => CustomAASMBase do + after_all_transitions :increment_transition_count + end + end + end + + # A custom annotation that we want available across many AASM models. + def requires_guards! + klass.class_eval do + attr_reader :authorizable_called, + :transition_count, + :fillable_called + + def authorizable? + @authorizable_called = true + end + + def fillable? + @fillable_called = true + end + + def increment_transition_count + @transition_count ||= 0 + @transition_count += 1 + end + end + end +end + +class SimpleCustomExample + include AASM + + # Let's build an AASM state machine with our custom class. + aasm :with_klass => CustomAASMBase do + requires_guards! + count_transitions! + + state :initialised, :initial => true + state :filled_out + state :authorised + + event :fill_out do + transitions :from => :initialised, :to => :filled_out, :guard => :fillable? + end + event :authorise do + transitions :from => :filled_out, :to => :authorised, :guard => :authorizable? + end + end +end diff --git a/spec/unit/simple_custom_example_spec.rb b/spec/unit/simple_custom_example_spec.rb new file mode 100644 index 0000000..fe48925 --- /dev/null +++ b/spec/unit/simple_custom_example_spec.rb @@ -0,0 +1,39 @@ +require 'spec_helper' + +describe 'Custom AASM::Base' do + context 'when aasm_with invoked with SimpleCustomExample' do + let(:simple_custom) { SimpleCustomExample.new } + + subject do + simple_custom.fill_out! + simple_custom.authorise + end + + it 'has invoked authorizable?' do + expect { subject }.to change { simple_custom.authorizable_called }.from(nil).to(true) + end + + it 'has invoked fillable?' do + expect { subject }.to change { simple_custom.fillable_called }.from(nil).to(true) + end + + it 'has two transition counts' do + expect { subject }.to change { simple_custom.transition_count }.from(nil).to(2) + end + end + + context 'when aasm_with invoked with non AASM::Base' do + subject do + Class.new do + include AASM + + aasm :with_klass => String do + end + end + end + + it 'should raise an ArgumentError' do + expect { subject }.to raise_error(ArgumentError, 'The class String must inherit from AASM::Base!') + end + end +end