state_machine 0.9.2 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. data/CHANGELOG.rdoc +10 -0
  2. data/README.rdoc +8 -0
  3. data/Rakefile +1 -1
  4. data/examples/merb-rest/view_edit.html.erb +2 -2
  5. data/examples/merb-rest/view_index.html.erb +2 -2
  6. data/examples/merb-rest/view_show.html.erb +2 -2
  7. data/examples/rails-rest/view_edit.html.erb +2 -2
  8. data/examples/rails-rest/view_index.html.erb +2 -2
  9. data/examples/rails-rest/view_show.html.erb +2 -2
  10. data/lib/state_machine.rb +34 -0
  11. data/lib/state_machine/event.rb +17 -2
  12. data/lib/state_machine/event_collection.rb +1 -1
  13. data/lib/state_machine/integrations/active_model.rb +39 -15
  14. data/lib/state_machine/integrations/active_model/locale.rb +2 -2
  15. data/lib/state_machine/integrations/active_record.rb +15 -3
  16. data/lib/state_machine/integrations/active_record/locale.rb +16 -0
  17. data/lib/state_machine/integrations/mongo_mapper.rb +16 -2
  18. data/lib/state_machine/machine.rb +53 -10
  19. data/lib/state_machine/machine_collection.rb +1 -1
  20. data/lib/state_machine/state.rb +12 -1
  21. data/lib/state_machine/transition.rb +50 -34
  22. data/test/files/en.yml +9 -0
  23. data/test/{classes → files}/switch.rb +0 -0
  24. data/test/functional/state_machine_test.rb +9 -0
  25. data/test/unit/event_collection_test.rb +5 -7
  26. data/test/unit/event_test.rb +51 -0
  27. data/test/unit/integrations/active_model_test.rb +80 -33
  28. data/test/unit/integrations/active_record_test.rb +89 -30
  29. data/test/unit/integrations/data_mapper_test.rb +25 -1
  30. data/test/unit/integrations/mongo_mapper_test.rb +40 -7
  31. data/test/unit/integrations/sequel_test.rb +25 -1
  32. data/test/unit/machine_collection_test.rb +1 -1
  33. data/test/unit/machine_test.rb +123 -4
  34. data/test/unit/state_test.rb +53 -0
  35. data/test/unit/transition_test.rb +20 -0
  36. metadata +4 -3
data/CHANGELOG.rdoc CHANGED
@@ -1,5 +1,15 @@
1
1
  == master
2
2
 
3
+ == 0.9.3 / 2010-06-26
4
+
5
+ * Allow access to human state / event names in transitions and for the current state
6
+ * Use human state / event names in error messages
7
+ * Fix event names being used inconsistently in error messages
8
+ * Allow access to the humanized version of state / event names via human_state_name / human_state_event_name
9
+ * Allow MongoMapper 0.8.0+ scopes to be chainable
10
+ * Fix i18n deprecation warnings in ActiveModel / ActiveRecord 3.0.0.beta4
11
+ * Fix default error message translations overriding existing locales in ActiveModel / ActiveRecord
12
+
3
13
  == 0.9.2 / 2010-05-24
4
14
 
5
15
  * Fix MongoMapper integration failing in Ruby 1.9.2
data/README.rdoc CHANGED
@@ -181,6 +181,7 @@ like so:
181
181
  vehicle = Vehicle.new # => #<Vehicle:0xb7cf4eac @state="parked", @seatbelt_on=false>
182
182
  vehicle.state # => "parked"
183
183
  vehicle.state_name # => :parked
184
+ vehicle.human_state_name # => "parked"
184
185
  vehicle.parked? # => true
185
186
  vehicle.can_ignite? # => true
186
187
  vehicle.ignite_transition # => #<StateMachine::Transition attribute=:state event=:ignite from="parked" from_name=:parked to="idling" to_name=:idling>
@@ -228,6 +229,13 @@ like so:
228
229
  vehicle.alarm_state_name # => :active
229
230
 
230
231
  vehicle.fire_events!(:ignite, :enable_alarm) # => StateMachine::InvalidTransition: Cannot run events in parallel: ignite, enable_alarm
232
+
233
+ # Human-friendly names can be accessed for states/events
234
+ Vehicle.human_state_name(:first_gear) # => "first gear"
235
+ Vehicle.human_alarm_state_name(:active) # => "active"
236
+
237
+ Vehicle.human_state_event_name(:shift_down) # => "shift down"
238
+ Vehicle.human_alarm_state_event_name(:enable_alarm) # => "enable alarm"
231
239
 
232
240
  == Integrations
233
241
 
data/Rakefile CHANGED
@@ -6,7 +6,7 @@ require 'rake/gempackagetask'
6
6
 
7
7
  spec = Gem::Specification.new do |s|
8
8
  s.name = 'state_machine'
9
- s.version = '0.9.2'
9
+ s.version = '0.9.3'
10
10
  s.platform = Gem::Platform::RUBY
11
11
  s.summary = 'Adds support for creating state machines for attributes on any Ruby class'
12
12
  s.description = s.summary
@@ -9,12 +9,12 @@
9
9
 
10
10
  <p>
11
11
  <%= label :state %><br />
12
- <%= select :state_event, :selected => @user.state_event.to_s, :collection => @user.state_transitions, :value_method => :event, :text_method => :to_name, :prompt => @user.state_name.to_s %>
12
+ <%= select :state_event, :selected => @user.state_event.to_s, :collection => @user.state_transitions, :value_method => :event, :text_method => :human_to_name, :prompt => @user.human_state_name %>
13
13
  </p>
14
14
 
15
15
  <p>
16
16
  <%= label :access_state %><br />
17
- <%= select :access_state_event, :selected => @user.access_state_event.to_s, :collection => @user.access_state_transitions, :value_method => :event, :text_method => :event, :prompt => "don't change" %>
17
+ <%= select :access_state_event, :selected => @user.access_state_event.to_s, :collection => @user.access_state_transitions, :value_method => :event, :text_method => :human_event, :prompt => "don't change" %>
18
18
  </p>
19
19
 
20
20
  <p><%= submit 'Update' %></p>
@@ -10,8 +10,8 @@
10
10
  <% @users.each do |user| %>
11
11
  <tr>
12
12
  <td><%=h user.name %></td>
13
- <td><%=h user.state %></td>
14
- <td><%=h user.access_state %></td>
13
+ <td><%=h user.human_state_name %></td>
14
+ <td><%=h user.human_access_state_name %></td>
15
15
  <td><%= link_to 'Show', resource(user) %></td>
16
16
  <td><%= link_to 'Edit', resource(user, :edit) %></td>
17
17
  </tr>
@@ -5,12 +5,12 @@
5
5
 
6
6
  <p>
7
7
  <b>State:</b>
8
- <%=h @user.state %>
8
+ <%=h @user.human_state_name %>
9
9
  </p>
10
10
 
11
11
  <p>
12
12
  <b>Access State:</b>
13
- <%=h @user.access_state %>
13
+ <%=h @user.human_access_state_name %>
14
14
  </p>
15
15
 
16
16
  <%= link_to 'Edit', resource(@user, :edit) %> |
@@ -10,12 +10,12 @@
10
10
 
11
11
  <p>
12
12
  <%= f.label :state %><br />
13
- <%= f.collection_select :state_event, @user.state_transitions, :event, :to_name, :include_blank => @user.state_name.to_s %>
13
+ <%= f.collection_select :state_event, @user.state_transitions, :event, :human_to_name, :include_blank => @user.human_state_name %>
14
14
  </p>
15
15
 
16
16
  <p>
17
17
  <%= f.label :access_state %><br />
18
- <%= f.collection_select :access_state_event, @user.access_state_transitions, :event, :event, :include_blank => "don't change" %>
18
+ <%= f.collection_select :access_state_event, @user.access_state_transitions, :event, :human_event, :include_blank => "don't change" %>
19
19
  </p>
20
20
 
21
21
  <p><%= f.submit 'Update' %></p>
@@ -10,8 +10,8 @@
10
10
  <% @users.each do |user| %>
11
11
  <tr>
12
12
  <td><%=h user.name %></td>
13
- <td><%=h user.state %></td>
14
- <td><%=h user.access_state %></td>
13
+ <td><%=h user.human_state_name %></td>
14
+ <td><%=h user.human_access_state_name %></td>
15
15
  <td><%= link_to 'Show', user %></td>
16
16
  <td><%= link_to 'Edit', edit_user_path(user) %></td>
17
17
  </tr>
@@ -5,12 +5,12 @@
5
5
 
6
6
  <p>
7
7
  <b>State:</b>
8
- <%=h @user.state %>
8
+ <%=h @user.human_state_name %>
9
9
  </p>
10
10
 
11
11
  <p>
12
12
  <b>Access State:</b>
13
- <%=h @user.access_state %>
13
+ <%=h @user.human_access_state_name %>
14
14
  </p>
15
15
 
16
16
  <%= link_to 'Edit', edit_user_path(@user) %> |
data/lib/state_machine.rb CHANGED
@@ -90,6 +90,37 @@ module StateMachine
90
90
  # end
91
91
  # end
92
92
  #
93
+ # == Class Methods
94
+ #
95
+ # The following class methods will be automatically generated by the
96
+ # state machine based on the *name* of the machine. Any existing methods
97
+ # will not be overwritten.
98
+ # * <tt>human_state_name(state)</tt> - Gets the humanized value for the
99
+ # given state. This may be generated by internationalization libraries if
100
+ # supported by the integration.
101
+ # * <tt>human_state_event_name(event)</tt> - Gets the humanized value for
102
+ # the given event. This may be generated by internationalization
103
+ # libraries if supported by the integration.
104
+ #
105
+ # For example,
106
+ #
107
+ # class Vehicle
108
+ # state_machine :state, :initial => :parked do
109
+ # event :ignite do
110
+ # transition :parked => :idling
111
+ # end
112
+ #
113
+ # event :shift_up do
114
+ # transition :idling => :first_gear
115
+ # end
116
+ # end
117
+ # end
118
+ #
119
+ # Vehicle.human_state_name(:parked) # => "parked"
120
+ # Vehicle.human_state_name(:first_gear) # => "first gear"
121
+ # Vehicle.human_state_event_name(:park) # => "park"
122
+ # Vehicle.human_state_event_name(:shift_up) # => "shift up"
123
+ #
93
124
  # == Instance Methods
94
125
  #
95
126
  # The following instance methods will be automatically generated by the
@@ -100,6 +131,8 @@ module StateMachine
100
131
  # * <tt>state?(name)</tt> - Checks the given state name against the current
101
132
  # state. If the name is not a known state, then an ArgumentError is raised.
102
133
  # * <tt>state_name</tt> - Gets the name of the state for the current value
134
+ # * <tt>human_state_name</tt> - Gets the human-readable name of the state
135
+ # for the current value
103
136
  # * <tt>state_events</tt> - Gets the list of events that can be fired on
104
137
  # the current object's state (uses the *unqualified* event names)
105
138
  # * <tt>state_transitions(requirements = {})</tt> - Gets the list of possible
@@ -125,6 +158,7 @@ module StateMachine
125
158
  # vehicle = Vehicle.new
126
159
  # vehicle.state # => "parked"
127
160
  # vehicle.state_name # => :parked
161
+ # vehicle.human_state_name # => "parked"
128
162
  # vehicle.state?(:parked) # => true
129
163
  #
130
164
  # # Changing state
@@ -24,6 +24,9 @@ module StateMachine
24
24
  # The fully-qualified name of the event, scoped by the machine's namespace
25
25
  attr_reader :qualified_name
26
26
 
27
+ # The human-readable name for the event
28
+ attr_writer :human_name
29
+
27
30
  # The list of guards that determine what state this event transitions
28
31
  # objects to when fired
29
32
  attr_reader :guards
@@ -33,10 +36,16 @@ module StateMachine
33
36
  attr_reader :known_states
34
37
 
35
38
  # Creates a new event within the context of the given machine
36
- def initialize(machine, name) #:nodoc:
39
+ #
40
+ # Configuration options:
41
+ # * <tt>:human_name</tt> - The human-readable version of this event's name
42
+ def initialize(machine, name, options = {}) #:nodoc:
43
+ assert_valid_keys(options, :human_name)
44
+
37
45
  @machine = machine
38
46
  @name = name
39
47
  @qualified_name = machine.namespace ? :"#{name}_#{machine.namespace}" : name
48
+ @human_name = options[:human_name] || @name.to_s.tr('_', ' ')
40
49
  @guards = []
41
50
  @known_states = []
42
51
 
@@ -51,6 +60,12 @@ module StateMachine
51
60
  @known_states = @known_states.dup
52
61
  end
53
62
 
63
+ # Transforms the event name into a more human-readable format, such as
64
+ # "turn on" instead of "turn_on"
65
+ def human_name(klass = @machine.owner_class)
66
+ @human_name.is_a?(Proc) ? @human_name.call(self, klass) : @human_name
67
+ end
68
+
54
69
  # Creates a new transition that determines what to change the current state
55
70
  # to when this event fires.
56
71
  #
@@ -191,7 +206,7 @@ module StateMachine
191
206
  if transition = transition_for(object)
192
207
  transition.perform(*args)
193
208
  else
194
- machine.invalidate(object, :state, :invalid_transition, [[:event, name]])
209
+ machine.invalidate(object, :state, :invalid_transition, [[:event, human_name(object.class)]])
195
210
  false
196
211
  end
197
212
  end
@@ -106,7 +106,7 @@ module StateMachine
106
106
  if event = self[event_name.to_sym, :name]
107
107
  event.transition_for(object) || begin
108
108
  # No valid transition: invalidate
109
- machine.invalidate(object, :event, :invalid_event, [[:state, machine.states.match!(object).name || 'nil']]) if invalidate
109
+ machine.invalidate(object, :event, :invalid_event, [[:state, machine.states.match!(object).human_name(object.class)]]) if invalidate
110
110
  false
111
111
  end
112
112
  else
@@ -224,7 +224,7 @@ module StateMachine
224
224
 
225
225
  if Object.const_defined?(:I18n)
226
226
  locale = "#{File.dirname(__FILE__)}/active_model/locale.rb"
227
- I18n.load_path << locale unless I18n.load_path.include?(locale)
227
+ I18n.load_path.unshift(locale) unless I18n.load_path.include?(locale)
228
228
  end
229
229
  end
230
230
  end
@@ -262,15 +262,8 @@ module StateMachine
262
262
  def invalidate(object, attribute, message, values = [])
263
263
  if supports_validations?
264
264
  attribute = self.attribute(attribute)
265
- ancestors = ancestors_for(object.class)
266
-
267
265
  options = values.inject({}) do |options, (key, value)|
268
- # Generate all possible translation keys
269
- group = key.to_s.pluralize
270
- translations = ancestors.map {|ancestor| :"#{ancestor.model_name.underscore}.#{name}.#{group}.#{value}"}
271
- translations.concat([:"#{name}.#{group}.#{value}", :"#{group}.#{value}", value.to_s])
272
-
273
- options[key] = I18n.translate(translations.shift, :default => translations, :scope => [i18n_scope, :state_machines])
266
+ options[key] = value
274
267
  options
275
268
  end
276
269
 
@@ -309,22 +302,40 @@ module StateMachine
309
302
  defined?(::ActiveModel::Dirty) && owner_class <= ::ActiveModel::Dirty && object.respond_to?("#{self.attribute}_changed?")
310
303
  end
311
304
 
305
+ # Gets the terminator to use for callbacks
306
+ def callback_terminator
307
+ @terminator ||= lambda {|result| result == false}
308
+ end
309
+
312
310
  # Determines the base scope to use when looking up translations
313
311
  def i18n_scope
314
312
  owner_class.i18n_scope
315
313
  end
316
314
 
315
+ # Translates the given key / value combo. Translation keys are looked
316
+ # up in the following order:
317
+ # * <tt>#{i18n_scope}.state_machines.#{model_name}.#{machine_name}.#{plural_key}.#{value}</tt>
318
+ # * <tt>#{i18n_scope}.state_machines.#{machine_name}.#{plural_key}.#{value}
319
+ # * <tt>#{i18n_scope}.state_machines.#{plural_key}.#{value}</tt>
320
+ #
321
+ # If no keys are found, then the humanized value will be the fallback.
322
+ def translate(klass, key, value)
323
+ ancestors = ancestors_for(klass)
324
+ group = key.to_s.pluralize
325
+ value = value ? value.to_s : 'nil'
326
+
327
+ # Generate all possible translation keys
328
+ translations = ancestors.map {|ancestor| :"#{ancestor.model_name.underscore}.#{name}.#{group}.#{value}"}
329
+ translations.concat([:"#{name}.#{group}.#{value}", :"#{group}.#{value}", value.humanize.downcase])
330
+ I18n.translate(translations.shift, :default => translations, :scope => [i18n_scope, :state_machines])
331
+ end
332
+
317
333
  # Build a list of ancestors for the given class to use when
318
334
  # determining which localization key to use for a particular string.
319
335
  def ancestors_for(klass)
320
336
  klass.lookup_ancestors
321
337
  end
322
338
 
323
- # Gets the terminator to use for callbacks
324
- def callback_terminator
325
- @terminator ||= lambda {|result| result == false}
326
- end
327
-
328
339
  # Adds the default callbacks for notifying ActiveModel observers
329
340
  # before/after a transition has been performed.
330
341
  def after_initialize
@@ -371,7 +382,20 @@ module StateMachine
371
382
  end
372
383
  end
373
384
 
374
- private
385
+ # Configures new states with the built-in humanize scheme
386
+ def add_states(new_states)
387
+ super.each do |state|
388
+ state.human_name = lambda {|state, klass| translate(klass, :state, state.name)}
389
+ end
390
+ end
391
+
392
+ # Configures new event with the built-in humanize scheme
393
+ def add_events(new_events)
394
+ super.each do |event|
395
+ event.human_name = lambda {|event, klass| translate(klass, :event, event.name)}
396
+ end
397
+ end
398
+
375
399
  # Notifies observers on the given object that a callback occurred
376
400
  # involving the given transition. This will attempt to call the
377
401
  # following methods on observers:
@@ -3,8 +3,8 @@
3
3
  :errors => {
4
4
  :messages => {
5
5
  :invalid => StateMachine::Machine.default_messages[:invalid],
6
- :invalid_event => StateMachine::Machine.default_messages[:invalid_event] % ['{{state}}'],
7
- :invalid_transition => StateMachine::Machine.default_messages[:invalid_transition] % ['{{event}}']
6
+ :invalid_event => StateMachine::Machine.default_messages[:invalid_event] % ['%{state}'],
7
+ :invalid_transition => StateMachine::Machine.default_messages[:invalid_transition] % ['%{event}']
8
8
  }
9
9
  }
10
10
  }
@@ -276,8 +276,11 @@ module StateMachine
276
276
  # errors:
277
277
  # messages:
278
278
  # invalid: "is invalid"
279
- # invalid_event: "cannot transition when {{state}}"
280
- # invalid_transition: "cannot transition via {{event}}"
279
+ # invalid_event: "cannot transition when %{state}"
280
+ # invalid_transition: "cannot transition via %{event}"
281
+ #
282
+ # Notice that the interpolation syntax is %{key} in Rails 3+. In Rails 2.x,
283
+ # the appropriate syntax is {{key}}.
281
284
  #
282
285
  # You can override these for a specific model like so:
283
286
  #
@@ -332,7 +335,7 @@ module StateMachine
332
335
 
333
336
  if Object.const_defined?(:I18n)
334
337
  locale = "#{File.dirname(__FILE__)}/active_record/locale.rb"
335
- I18n.load_path << locale unless I18n.load_path.include?(locale)
338
+ I18n.load_path.unshift(locale) unless I18n.load_path.include?(locale)
336
339
  end
337
340
  end
338
341
 
@@ -371,6 +374,15 @@ module StateMachine
371
374
  :activerecord
372
375
  end
373
376
 
377
+ # Only allows translation of I18n is available
378
+ def translate(klass, key, value)
379
+ if Object.const_defined?(:I18n)
380
+ super
381
+ else
382
+ value ? value.to_s.humanize.downcase : 'nil'
383
+ end
384
+ end
385
+
374
386
  # Attempts to look up a class's ancestors via:
375
387
  # * #lookup_ancestors
376
388
  # * #self_and_descendants_from_active_record
@@ -1,4 +1,20 @@
1
1
  filename = "#{File.dirname(__FILE__)}/../active_model/locale.rb"
2
2
  translations = eval(IO.read(filename), binding, filename)
3
3
  translations[:en][:activerecord] = translations[:en].delete(:activemodel)
4
+
5
+ # Only ActiveRecord 2.3.5+ can pull i18n >= 0.1.3 from system-wide gems (and
6
+ # therefore possibly have I18n::VERSION available)
7
+ begin
8
+ require 'i18n/version'
9
+ rescue Exception => ex
10
+ end unless ::ActiveRecord::VERSION::MAJOR == 2 && (::ActiveRecord::VERSION::MINOR < 3 || ::ActiveRecord::VERSION::TINY < 5)
11
+
12
+ # Only i18n 0.4.0+ has the new %{key} syntax
13
+ if !defined?(I18n::VERSION) || I18n::VERSION < '0.4.0'
14
+ translations[:en][:activerecord][:errors][:messages].each do |key, message|
15
+ message.gsub!('%{', '{{')
16
+ message.gsub!('}', '}}')
17
+ end
18
+ end
19
+
4
20
  translations
@@ -226,6 +226,11 @@ module StateMachine
226
226
  def callback_terminator
227
227
  end
228
228
 
229
+ # Don't allow translations
230
+ def translate(klass, key, value)
231
+ value.to_s.humanize.downcase
232
+ end
233
+
229
234
  # Defines an initialization hook into the owner class for setting the
230
235
  # initial state of the machine *before* any attributes are set on the
231
236
  # object
@@ -282,13 +287,22 @@ module StateMachine
282
287
  # Creates a scope for finding records *with* a particular state or
283
288
  # states for the attribute
284
289
  def create_with_scope(name)
285
- lambda {|model, values| model.all(:conditions => {attribute => {'$in' => values}})}
290
+ define_scope(name, lambda {|values| {:conditions => {attribute => {'$in' => values}}}})
286
291
  end
287
292
 
288
293
  # Creates a scope for finding records *without* a particular state or
289
294
  # states for the attribute
290
295
  def create_without_scope(name)
291
- lambda {|model, values| model.all(:conditions => {attribute => {'$nin' => values}})}
296
+ define_scope(name, lambda {|values| {:conditions => {attribute => {'$nin' => values}}}})
297
+ end
298
+
299
+ # Defines a new scope with the given name
300
+ def define_scope(name, scope)
301
+ if defined?(::MongoMapper::Version) && ::MongoMapper::Version >= '0.8.0'
302
+ lambda {|model, values| model.query.merge(model.query(scope.call(values)))}
303
+ else
304
+ lambda {|model, values| model.all(scope.call(values))}
305
+ end
292
306
  end
293
307
  end
294
308
  end