state_machine 0.9.2 → 0.9.3

Sign up to get free protection for your applications and to get access to all the features.
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