Skip to content

Fix save with state feat - #11

Merged
chaadow merged 8 commits into
chaadow:mainfrom
mathieu-valery:fix_save_with_state_feat
May 27, 2026
Merged

Fix save with state feat#11
chaadow merged 8 commits into
chaadow:mainfrom
mathieu-valery:fix_save_with_state_feat

Conversation

@mathieu-valery

Copy link
Copy Markdown
Contributor

Fix save_with_state and improve error handling

Corrections after rollback github.com//pull/7

Why

save_with_state currently returns true when a transition is invalid, silently swallowing the failure. In Sidecare, if an operator tries to transition a payment procedure from canceled to to_process, the form returns success but no @registered_callbacks ever fires, so no transition happens — misleading the operator and producing stale data.

This PR fixes the silent-failure bug, adds a proper error on the model, and tightens a few related rough edges in has_one_state_machine.

What changed

1. save_with_state now returns false and adds a model error on invalid transitions

If #{virtual_attribute_name} differs from the current state but can_transition_to? is false, the method now:

  • Adds an :invalid_transition error on the field_name attribute, and
  • Returns false.

This makes form failures visible via record.errors.full_messages as users would expect.

2. State-change detection no longer relies on _changed?

Replaced if #{virtual_attribute_name}_changed? with if #{virtual_attribute_name}.to_s != #{field_name}_current_state.to_s.

The previous check always returned true because Rails sees the virtual attribute as a change from nil → 'canceled', even when the effective value is unchanged. As a result, forms that re-submit the current state value (common in Sidecare) always failed to update. The string comparison reliably detects a real state change.

3. #{field_name} is now a public reader (with a guard)

Previously private, which broke form helpers like form.text_field :user_status with NoMethodError: private method called. The reader is now public so form helpers, serializers, and other public_send-style callers work.

To avoid silently clobbering an existing column or user-defined method with the same name as field_name, definition is now guarded:

unless method_defined?(field_name) ||
       private_method_defined?(field_name) ||
       (respond_to?(:column_names) && column_names.include?(field_name.to_s))
  # define def #{field_name}
end

4. Error message is now i18n-friendly

The previous implementation passed a hardcoded English string via message:, which:

  • Disabled localization (no other language could ever be used),
  • Showed raw state identifiers (canceled, to_process) instead of their humanized translations.

Now the macro uses Rails' standard i18n lookup with interpolation kwargs:

errors.add(
  :#{field_name},
  :invalid_transition,
  current_state: #{field_name}_current_state_human,
  target_state: I18n.t(#{virtual_attribute_name}, scope: "...", default: ...)
)

A default English translation ships with the gem in lib/statesman/multi_state/locales/en.yml, and the Railtie adds it to Rails' i18n load path automatically. Host apps can override per-locale, per-model, or per-attribute through their own locale files.

Example output (English, default):

User status cannot transition from User Pending to invalid_state

Example output (French, with host-app fr.yml):

User status transition impossible de En attente vers invalid_state

Tests

Added/updated tests:

  • save_with_state returns false and surfaces a proper error on invalid transition.
  • save_with_state does not error when the form submits the current state (the real-world Sidecare scenario).
  • has_one_state_machine defines a public field_name reader (regression guard for the form-helper bug).
  • has_one_state_machine does not clobber a pre-existing method named like field_name.
  • Invalid-transition error exposes :invalid_transition with humanized current_state / target_state options.
  • Target state is humanized when defined in the locale.

All tests pass:

18 runs, 68 assertions, 0 failures, 0 errors, 0 skips

Migration notes

None for app code — the change is fully backwards-compatible. Apps that already had locale entries under statesman.<field_name>_<model> will start seeing humanized state names in transition error messages.

Apps that asserted on the exact text of the previous error message ("cannot transition from user_pending to invalid_state") will need to update those assertions to match the new humanized format.

@chaadow
chaadow merged commit b162306 into chaadow:main May 27, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants