Skip to content

Commit d7a41ba

Browse files
committed
fix: preserve custom human_name for both states and events
- Cherry-picked fix from PR #38 to use ||= instead of = in add_states - Applied same fix to add_events method to preserve event human names - Added comprehensive tests for both state and event human_name preservation - Added regression test specifically for issue #37 This ensures that hard-coded human_name values are not overwritten by the default translation lambdas when using ActiveModel integration. Fixes #37 Closes #38
1 parent 24231d8 commit d7a41ba

File tree

5 files changed

+435
-78
lines changed

5 files changed

+435
-78
lines changed

lib/state_machines/integrations/active_model.rb

Lines changed: 23 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
require 'state_machines/integrations/active_model/version'
99

1010
module StateMachines
11-
module Integrations #:nodoc:
11+
module Integrations # :nodoc:
1212
# Adds support for integrating state machines with ActiveModel classes.
1313
#
1414
# == Examples
@@ -189,64 +189,6 @@ module Integrations #:nodoc:
189189
# Note, also, that the transition can be accessed by simply defining
190190
# additional arguments in the callback block.
191191
#
192-
# == Observers
193-
#
194-
# In order to hook in observer support for your application, the
195-
# ActiveModel::Observing feature must be included. This can be added by including the
196-
# https://github.com/state-machines/state_machines-activemodel-observers gem in your
197-
# Gemfile. Because of the way
198-
# ActiveModel observers are designed, there is less flexibility around the
199-
# specific transitions that can be hooked in. However, a large number of
200-
# hooks *are* supported. For example, if a transition for a object's
201-
# +state+ attribute changes the state from +parked+ to +idling+ via the
202-
# +ignite+ event, the following observer methods are supported:
203-
# * before/after/after_failure_to-_ignite_from_parked_to_idling
204-
# * before/after/after_failure_to-_ignite_from_parked
205-
# * before/after/after_failure_to-_ignite_to_idling
206-
# * before/after/after_failure_to-_ignite
207-
# * before/after/after_failure_to-_transition_state_from_parked_to_idling
208-
# * before/after/after_failure_to-_transition_state_from_parked
209-
# * before/after/after_failure_to-_transition_state_to_idling
210-
# * before/after/after_failure_to-_transition_state
211-
# * before/after/after_failure_to-_transition
212-
#
213-
# The following class shows an example of some of these hooks:
214-
#
215-
# class VehicleObserver < ActiveModel::Observer
216-
# # Callback for :ignite event *before* the transition is performed
217-
# def before_ignite(vehicle, transition)
218-
# # log message
219-
# end
220-
#
221-
# # Callback for :ignite event *after* the transition has been performed
222-
# def after_ignite(vehicle, transition)
223-
# # put on seatbelt
224-
# end
225-
#
226-
# # Generic transition callback *before* the transition is performed
227-
# def after_transition(vehicle, transition)
228-
# Audit.log(vehicle, transition)
229-
# end
230-
#
231-
# def after_failure_to_transition(vehicle, transition)
232-
# Audit.error(vehicle, transition)
233-
# end
234-
# end
235-
#
236-
# More flexible transition callbacks can be defined directly within the
237-
# model as described in StateMachine::Machine#before_transition
238-
# and StateMachine::Machine#after_transition.
239-
#
240-
# To define a single observer for multiple state machines:
241-
#
242-
# class StateMachineObserver < ActiveModel::Observer
243-
# observe Vehicle, Switch, Project
244-
#
245-
# def after_transition(object, transition)
246-
# Audit.log(object, transition)
247-
# end
248-
# end
249-
#
250192
# == Internationalization
251193
#
252194
# Any error message that is generated from performing invalid transitions
@@ -403,14 +345,14 @@ def reset(object)
403345
end
404346

405347
# Runs state events around the object's validation process
406-
def around_validation(object)
407-
object.class.state_machines.transitions(object, action, after: false).perform { yield }
348+
def around_validation(object, &)
349+
object.class.state_machines.transitions(object, action, after: false).perform(&)
408350
end
409351

410352
protected
411353

412354
def define_state_initializer
413-
define_helper :instance, <<-end_eval, __FILE__, __LINE__ + 1
355+
define_helper :instance, <<-END_EVAL, __FILE__, __LINE__ + 1
414356
def initialize(params = nil, **kwargs)
415357
# Support both positional hash and keyword arguments
416358
attrs = params.nil? ? kwargs : params
@@ -428,7 +370,7 @@ def initialize(params = nil, **kwargs)
428370
end
429371
end
430372
end
431-
end_eval
373+
END_EVAL
432374
end
433375

434376
# Whether validations are supported in the integration. Only true if
@@ -446,7 +388,7 @@ def runs_validations_on_action?
446388

447389
# Gets the terminator to use for callbacks
448390
def callback_terminator
449-
@terminator ||= ->(result) { result == false }
391+
@callback_terminator ||= ->(result) { result == false }
450392
end
451393

452394
# Determines the base scope to use when looking up translations
@@ -476,7 +418,7 @@ def translate(klass, key, value)
476418
# Generate all possible translation keys
477419
translations = ancestors.map { |ancestor| :"#{ancestor.model_name.to_s.underscore}.#{name}.#{group}.#{value}" }
478420
translations.concat(ancestors.map { |ancestor| :"#{ancestor.model_name.to_s.underscore}.#{group}.#{value}" })
479-
translations.concat([:"#{name}.#{group}.#{value}", :"#{group}.#{value}", value.humanize.downcase])
421+
translations.push(:"#{name}.#{group}.#{value}", :"#{group}.#{value}", value.humanize.downcase)
480422
I18n.translate(translations.shift, default: translations, scope: [i18n_scope(klass), :state_machines])
481423
end
482424

@@ -490,10 +432,12 @@ def ancestors_for(klass)
490432
def define_state_accessor
491433
name = self.name
492434

435+
return unless supports_validations?
436+
493437
owner_class.validates_each(attribute) do |object|
494438
machine = object.class.state_machine(name)
495439
machine.invalidate(object, :state, :invalid) unless machine.states.match(object)
496-
end if supports_validations?
440+
end
497441
end
498442

499443
# Adds hooks into validation for automatically firing events
@@ -511,22 +455,32 @@ def define_validation_hook
511455
# Creates a new callback in the callback chain, always inserting it
512456
# before the default Observer callbacks that were created after
513457
# initialization.
514-
def add_callback(type, options, &block)
458+
def add_callback(type, options, &)
515459
options[:terminator] = callback_terminator
516460
super
517461
end
518462

519463
# Configures new states with the built-in humanize scheme
520464
def add_states(*)
521465
super.each do |new_state|
522-
new_state.human_name ||= ->(state, klass) { translate(klass, :state, state.name) }
466+
# Only set the translation lambda if human_name is the default auto-generated value
467+
# This preserves user-specified human names while still applying translations for defaults
468+
default_human_name = new_state.name ? new_state.name.to_s.tr('_', ' ') : 'nil'
469+
if new_state.human_name == default_human_name
470+
new_state.human_name = ->(state, klass) { translate(klass, :state, state.name) }
471+
end
523472
end
524473
end
525474

526475
# Configures new event with the built-in humanize scheme
527476
def add_events(*)
528477
super.each do |new_event|
529-
new_event.human_name = ->(event, klass) { translate(klass, :event, event.name) }
478+
# Only set the translation lambda if human_name is the default auto-generated value
479+
# This preserves user-specified human names while still applying translations for defaults
480+
default_human_name = new_event.name ? new_event.name.to_s.tr('_', ' ') : 'nil'
481+
if new_event.human_name == default_human_name
482+
new_event.human_name = ->(event, klass) { translate(klass, :event, event.name) }
483+
end
530484
end
531485
end
532486
end

test/event_human_name_test.rb

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
# frozen_string_literal: true
2+
3+
require_relative 'test_helper'
4+
5+
class EventHumanNameTest < BaseTestCase
6+
def setup
7+
@model = new_model do
8+
include ActiveModel::Validations
9+
attr_accessor :status
10+
end
11+
end
12+
13+
def test_should_allow_custom_human_name_on_event
14+
machine = StateMachines::Machine.new(@model, :status, initial: :parked) do
15+
event :start, human_name: 'Start Engine' do
16+
transition parked: :running
17+
end
18+
19+
event :stop do
20+
transition running: :parked
21+
end
22+
23+
event :pause, human_name: 'Temporarily Pause' do
24+
transition running: :paused
25+
end
26+
end
27+
28+
assert_equal 'Start Engine', machine.events[:start].human_name(@model)
29+
assert_equal 'Temporarily Pause', machine.events[:pause].human_name(@model)
30+
end
31+
32+
def test_should_not_override_custom_event_human_name_with_translation
33+
# Set up I18n translations
34+
I18n.backend.store_translations(:en, {
35+
activemodel: {
36+
state_machines: {
37+
events: {
38+
ignite: 'Translation for Ignite',
39+
park: 'Translation for Park',
40+
repair: 'Translation for Repair'
41+
}
42+
}
43+
}
44+
})
45+
46+
machine = StateMachines::Machine.new(@model, :status, initial: :parked) do
47+
event :ignite, human_name: 'Custom Ignition' do
48+
transition parked: :idling
49+
end
50+
51+
event :park do
52+
transition idling: :parked
53+
end
54+
55+
event :repair, human_name: 'Custom Repair Process' do
56+
transition any => :parked
57+
end
58+
end
59+
60+
# Custom human names should be preserved
61+
assert_equal 'Custom Ignition', machine.events[:ignite].human_name(@model)
62+
assert_equal 'Custom Repair Process', machine.events[:repair].human_name(@model)
63+
64+
# Event without custom human_name should use translation
65+
assert_equal 'Translation for Park', machine.events[:park].human_name(@model)
66+
end
67+
68+
def test_should_allow_custom_event_human_name_as_string
69+
machine = StateMachines::Machine.new(@model, :status) do
70+
event :activate, human_name: 'Turn On'
71+
end
72+
73+
assert_equal 'Turn On', machine.events[:activate].human_name(@model)
74+
end
75+
76+
def test_should_allow_custom_event_human_name_as_lambda
77+
machine = StateMachines::Machine.new(@model, :status) do
78+
event :process, human_name: ->(event, klass) { "#{klass.name}: #{event.name.to_s.capitalize} Action" }
79+
end
80+
81+
assert_equal 'Foo: Process Action', machine.events[:process].human_name(@model)
82+
end
83+
84+
def test_should_use_default_translation_when_no_custom_event_human_name
85+
machine = StateMachines::Machine.new(@model, :status) do
86+
event :idle
87+
end
88+
89+
# Should fall back to humanized version when no translation exists
90+
assert_equal 'idle', machine.events[:idle].human_name(@model)
91+
end
92+
93+
def test_should_handle_nil_event_human_name
94+
machine = StateMachines::Machine.new(@model, :status) do
95+
event :wait
96+
end
97+
98+
# Explicitly set to nil
99+
machine.events[:wait].human_name = nil
100+
101+
# When human_name is nil, Event#human_name returns nil
102+
assert_nil machine.events[:wait].human_name(@model)
103+
end
104+
105+
def test_should_preserve_event_human_name_through_multiple_definitions
106+
machine = StateMachines::Machine.new(@model, :status, initial: :draft)
107+
108+
# First define event with custom human name
109+
machine.event :publish, human_name: 'Make Public' do
110+
transition draft: :published
111+
end
112+
113+
# Redefine the same event (this should not override the human_name)
114+
machine.event :publish do
115+
transition pending: :published
116+
end
117+
118+
assert_equal 'Make Public', machine.events[:publish].human_name(@model)
119+
end
120+
121+
def test_should_work_with_state_machine_helper_method
122+
@model.class_eval do
123+
state_machine :status, initial: :pending do
124+
event :approve, human_name: 'Grant Approval' do
125+
transition pending: :approved
126+
end
127+
128+
event :reject do
129+
transition pending: :rejected
130+
end
131+
end
132+
end
133+
134+
machine = @model.state_machine(:status)
135+
assert_equal 'Grant Approval', machine.events[:approve].human_name(@model)
136+
end
137+
138+
def test_should_handle_complex_i18n_lookup_with_custom_event_human_name
139+
# Set up complex I18n structure
140+
I18n.backend.store_translations(:en, {
141+
activemodel: {
142+
state_machines: {
143+
foo: {
144+
status: {
145+
events: {
146+
submit: 'Model Specific Submit'
147+
}
148+
}
149+
},
150+
status: {
151+
events: {
152+
submit: 'Machine Specific Submit'
153+
}
154+
},
155+
events: {
156+
submit: 'Generic Submit'
157+
}
158+
}
159+
}
160+
})
161+
162+
machine = StateMachines::Machine.new(@model, :status) do
163+
event :submit, human_name: 'Send for Review' do
164+
transition draft: :pending
165+
end
166+
end
167+
168+
# Should use the custom human_name, not any of the I18n translations
169+
assert_equal 'Send for Review', machine.events[:submit].human_name(@model)
170+
end
171+
172+
def teardown
173+
# Clear I18n translations after each test
174+
I18n.backend.reload!
175+
end
176+
end

0 commit comments

Comments
 (0)