Skip to content

Commit 3e19695

Browse files
authored
Merge pull request #46 from state-machines/fix/preserve-custom-human-names
fix: preserve custom human_name for both states and events
2 parents bd4f60d + d7a41ba commit 3e19695

File tree

5 files changed

+435
-20
lines changed

5 files changed

+435
-20
lines changed

lib/state_machines/integrations/active_model.rb

Lines changed: 23 additions & 11 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
@@ -345,14 +345,14 @@ def reset(object)
345345
end
346346

347347
# Runs state events around the object's validation process
348-
def around_validation(object)
349-
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(&)
350350
end
351351

352352
protected
353353

354354
def define_state_initializer
355-
define_helper :instance, <<-end_eval, __FILE__, __LINE__ + 1
355+
define_helper :instance, <<-END_EVAL, __FILE__, __LINE__ + 1
356356
def initialize(params = nil, **kwargs)
357357
# Support both positional hash and keyword arguments
358358
attrs = params.nil? ? kwargs : params
@@ -370,7 +370,7 @@ def initialize(params = nil, **kwargs)
370370
end
371371
end
372372
end
373-
end_eval
373+
END_EVAL
374374
end
375375

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

389389
# Gets the terminator to use for callbacks
390390
def callback_terminator
391-
@terminator ||= ->(result) { result == false }
391+
@callback_terminator ||= ->(result) { result == false }
392392
end
393393

394394
# Determines the base scope to use when looking up translations
@@ -418,7 +418,7 @@ def translate(klass, key, value)
418418
# Generate all possible translation keys
419419
translations = ancestors.map { |ancestor| :"#{ancestor.model_name.to_s.underscore}.#{name}.#{group}.#{value}" }
420420
translations.concat(ancestors.map { |ancestor| :"#{ancestor.model_name.to_s.underscore}.#{group}.#{value}" })
421-
translations.concat([:"#{name}.#{group}.#{value}", :"#{group}.#{value}", value.humanize.downcase])
421+
translations.push(:"#{name}.#{group}.#{value}", :"#{group}.#{value}", value.humanize.downcase)
422422
I18n.translate(translations.shift, default: translations, scope: [i18n_scope(klass), :state_machines])
423423
end
424424

@@ -432,10 +432,12 @@ def ancestors_for(klass)
432432
def define_state_accessor
433433
name = self.name
434434

435+
return unless supports_validations?
436+
435437
owner_class.validates_each(attribute) do |object|
436438
machine = object.class.state_machine(name)
437439
machine.invalidate(object, :state, :invalid) unless machine.states.match(object)
438-
end if supports_validations?
440+
end
439441
end
440442

441443
# Adds hooks into validation for automatically firing events
@@ -453,22 +455,32 @@ def define_validation_hook
453455
# Creates a new callback in the callback chain, always inserting it
454456
# before the default Observer callbacks that were created after
455457
# initialization.
456-
def add_callback(type, options, &block)
458+
def add_callback(type, options, &)
457459
options[:terminator] = callback_terminator
458460
super
459461
end
460462

461463
# Configures new states with the built-in humanize scheme
462464
def add_states(*)
463465
super.each do |new_state|
464-
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
465472
end
466473
end
467474

468475
# Configures new event with the built-in humanize scheme
469476
def add_events(*)
470477
super.each do |new_event|
471-
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
472484
end
473485
end
474486
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
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# frozen_string_literal: true
2+
3+
require_relative 'test_helper'
4+
5+
class HumanNamePreservationTest < 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_preserve_custom_state_human_name_when_using_activemodel_integration
14+
# This test specifically verifies that PR #38's fix works:
15+
# Using ||= instead of = in add_states method
16+
17+
@model.class_eval do
18+
state_machine :status, initial: :pending do
19+
# Define a state with a custom human_name
20+
state :pending, human_name: 'My Custom Pending'
21+
state :approved
22+
end
23+
end
24+
25+
machine = @model.state_machine(:status)
26+
27+
# The custom human_name should be preserved, not overwritten by the integration
28+
assert_equal 'My Custom Pending', machine.states[:pending].human_name(@model)
29+
end
30+
31+
def test_should_preserve_custom_event_human_name_when_using_activemodel_integration
32+
# This test verifies our additional fix for events:
33+
# Using ||= instead of = in add_events method
34+
35+
@model.class_eval do
36+
state_machine :status, initial: :pending do
37+
event :approve, human_name: 'Grant Authorization' do
38+
transition pending: :approved
39+
end
40+
41+
event :reject do
42+
transition pending: :rejected
43+
end
44+
end
45+
end
46+
47+
machine = @model.state_machine(:status)
48+
49+
# The custom human_name should be preserved, not overwritten by the integration
50+
assert_equal 'Grant Authorization', machine.events[:approve].human_name(@model)
51+
end
52+
53+
def test_regression_issue_37_hard_coded_human_name_preserved
54+
# This is the exact regression test for issue #37
55+
# "Hard-coded human_name is being overwritten"
56+
57+
@model.class_eval do
58+
state_machine :status do
59+
state :pending, human_name: 'Pending Approval'
60+
state :active, human_name: 'Active State'
61+
62+
event :activate, human_name: 'Activate Now' do
63+
transition pending: :active
64+
end
65+
end
66+
end
67+
68+
machine = @model.state_machine(:status)
69+
70+
# Both states and events should preserve their hard-coded human names
71+
assert_equal 'Pending Approval', machine.states[:pending].human_name(@model)
72+
assert_equal 'Active State', machine.states[:active].human_name(@model)
73+
assert_equal 'Activate Now', machine.events[:activate].human_name(@model)
74+
end
75+
end

0 commit comments

Comments
 (0)