circulator 2.1.1 → 2.1.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 31b604ea699b2cbe133905f3078e81aa7860bc4e70e52cfd928611b92140b3f8
4
- data.tar.gz: e264bd9c50f93fb1ab9f606a0d6b670ce4708304ccdf4a04a67a3a2e588b5bf5
3
+ metadata.gz: 1a3d83bc194cf2440d13e088b1b23e72cf3ec89071ae9d046d575a7e5d1014c7
4
+ data.tar.gz: 4c1afec2234a2edf3675048519aac13e4b64f2c8bbb08b98387603fc5f354ea8
5
5
  SHA512:
6
- metadata.gz: 80262bad01b9861f1f20459a67c80fbbd77c1f4cf4bc661d5b5918bd727582e8e099871cfd63b29a973fc5998a0c942a0f63aa2ced5894d41de91b5722517b19
7
- data.tar.gz: fcbd512e14e020ee4ede8cb34b0432b46c38fbfee246c43a14906f0711302825e633376e8bb1b98c00eee55a24396c5e5814e1fd394dbc8f5a106737a615cb39
6
+ metadata.gz: a5bf023b56fe2f8352fbb2fadce2c8a29cb9a47cfd5b011c80c098646696b9dcd5735c2b027c8f4a126c285005f11c8b46e9c009ac8ad243df3e1bc8a5979523
7
+ data.tar.gz: e4f99d8cd3e1cdbf87a15eb0b588ffc778f0fdf412c897fb5bf6796ff1295351fcf542c6ea80d1f883b1c3fa6afd817925ecddbda39ac1e16ffc9a59665754cb
data/CHANGELOG.md CHANGED
@@ -5,9 +5,8 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [2.1.1] - 2025-10-06
8
+ ## [2.1.2] - 2025-10-17
9
9
 
10
10
  ### Added
11
11
 
12
- - Visual organization for separate flows in generated diagrams
13
- - Ability to generate separate diagrams for each flow in a class.
12
+ - Hash-based allow_if for nested state dependencies (411a6b5)
data/README.md CHANGED
@@ -8,6 +8,7 @@ A lightweight and flexible state machine implementation for Ruby that allows you
8
8
  - **Flexible DSL**: Intuitive syntax for defining states and transitions
9
9
  - **Dynamic Method Generation**: Automatically creates helper methods for state transitions
10
10
  - **Conditional Transitions**: Support for guards and conditional logic
11
+ - **Nested State Dependencies**: State machines can depend on the state of other attributes
11
12
  - **Transition Callbacks**: Execute code before, during, or after transitions
12
13
  - **Multiple State Machines**: Define multiple independent state machines per class
13
14
  - **Framework Agnostic**: Works with plain Ruby objects, no Rails or ActiveRecord required
@@ -93,6 +94,43 @@ class Document
93
94
  end
94
95
  ```
95
96
 
97
+ #### Nested State Dependencies
98
+
99
+ You can make one state machine depend on another using hash-based `allow_if`:
100
+
101
+ ```ruby
102
+ class Document
103
+ extend Circulator
104
+
105
+ attr_accessor :status, :review_status
106
+
107
+ # Review must be completed first
108
+ flow :review_status do
109
+ state :pending do
110
+ action :approve, to: :approved
111
+ end
112
+ state :approved
113
+ end
114
+
115
+ # Document status depends on review status
116
+ flow :status do
117
+ state :draft do
118
+ # Can only publish if review is approved
119
+ action :publish, to: :published, allow_if: {review_status: [:approved]}
120
+ end
121
+ end
122
+ end
123
+
124
+ doc = Document.new
125
+ doc.status = :draft
126
+ doc.review_status = :pending
127
+
128
+ doc.status_publish # => blocked, status remains :draft
129
+
130
+ doc.review_status_approve # => :approved
131
+ doc.status_publish # => :published ✓
132
+ ```
133
+
96
134
  #### Dynamic Destination States
97
135
 
98
136
  ```ruby
@@ -23,6 +23,11 @@ module Circulator
23
23
  def action(name, to:, from: :__not_specified__, allow_if: nil, &block)
24
24
  raise "You must be in a state block or have a `from` option to declare an action" unless defined?(@current_state) || from != :__not_specified__
25
25
 
26
+ # Validate allow_if parameter
27
+ if allow_if
28
+ validate_allow_if(allow_if)
29
+ end
30
+
26
31
  @transition_map[name] ||= {}
27
32
  selected_state = (from == :__not_specified__) ? @current_state : from
28
33
 
@@ -36,6 +41,13 @@ module Circulator
36
41
  states_to_process.each do |from_state|
37
42
  from_state = from_state.to_sym if from_state.respond_to?(:to_sym)
38
43
  @states.add(from_state)
44
+
45
+ # Add the target state to @states if it's not a callable
46
+ unless to.respond_to?(:call)
47
+ to_state = to.respond_to?(:to_sym) ? to.to_sym : to
48
+ @states.add(to_state)
49
+ end
50
+
39
51
  @transition_map[name][from_state] = {to:, block:}
40
52
  @transition_map[name][from_state][:allow_if] = allow_if if allow_if
41
53
  end
@@ -67,5 +79,51 @@ module Circulator
67
79
  @no_action
68
80
  end
69
81
  end
82
+
83
+ private
84
+
85
+ def validate_allow_if(allow_if)
86
+ # Must be either a Proc or a Hash
87
+ unless allow_if.is_a?(Proc) || allow_if.is_a?(Hash)
88
+ raise ArgumentError, "allow_if must be a Proc or Hash, got: #{allow_if.class}"
89
+ end
90
+
91
+ # If it's a Hash, validate the structure
92
+ if allow_if.is_a?(Hash)
93
+ validate_hash_allow_if(allow_if)
94
+ end
95
+ end
96
+
97
+ def validate_hash_allow_if(allow_if_hash)
98
+ # Must have exactly one key
99
+ if allow_if_hash.size != 1
100
+ raise ArgumentError, "allow_if hash must contain exactly one attribute, got: #{allow_if_hash.keys.inspect}"
101
+ end
102
+
103
+ attribute_name, valid_states = allow_if_hash.first
104
+
105
+ # Convert attribute name to symbol
106
+ attribute_name = attribute_name.to_sym if attribute_name.respond_to?(:to_sym)
107
+
108
+ # Get model_key from the class name string, not the Class object
109
+ model_key = Circulator.model_key(@klass.to_s)
110
+ unless @klass.flows&.dig(model_key, attribute_name)
111
+ available_flows = @klass.flows&.dig(model_key)&.keys || []
112
+ raise ArgumentError, "allow_if references undefined flow attribute :#{attribute_name}. Available flows: #{available_flows.inspect}"
113
+ end
114
+
115
+ # Get the states from the referenced flow
116
+ referenced_flow = @klass.flows.dig(model_key, attribute_name)
117
+ referenced_states = referenced_flow.instance_variable_get(:@states)
118
+
119
+ # Convert valid_states to array of symbols
120
+ valid_states_array = Array(valid_states).map { |s| s.respond_to?(:to_sym) ? s.to_sym : s }
121
+
122
+ # Check if all specified states exist in the referenced flow
123
+ invalid_states = valid_states_array - referenced_states.to_a
124
+ if invalid_states.any?
125
+ raise ArgumentError, "allow_if references invalid states #{invalid_states.inspect} for :#{attribute_name}. Valid states: #{referenced_states.to_a.inspect}"
126
+ end
127
+ end
70
128
  end
71
129
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Circulator
4
- VERSION = "2.1.1"
4
+ VERSION = "2.1.2"
5
5
  end
data/lib/circulator.rb CHANGED
@@ -129,7 +129,7 @@ module Circulator
129
129
  @flows ||= {}
130
130
  model_key = Circulator.model_key(model)
131
131
  @flows[model_key] ||= {}
132
- @flows[model_key][attribute_name] = Flow.new(model, attribute_name, &block)
132
+ @flows[model_key][attribute_name] = Flow.new(self, attribute_name, &block)
133
133
 
134
134
  flow_module = ancestors.find { |ancestor|
135
135
  ancestor.name.to_s =~ /FlowMethods/
@@ -170,7 +170,23 @@ module Circulator
170
170
  end
171
171
 
172
172
  if transition[:allow_if]
173
- return unless flow_target.instance_exec(*args, **kwargs, &transition[:allow_if])
173
+ # Handle hash-based allow_if (checking other attribute states)
174
+ if transition[:allow_if].is_a?(Hash)
175
+ attribute_name_to_check, valid_states = transition[:allow_if].first
176
+ current_state = flow_target.send(attribute_name_to_check)
177
+
178
+ # Convert current state to symbol if possible
179
+ current_state = current_state.to_sym if current_state.respond_to?(:to_sym)
180
+
181
+ # Convert valid_states to array of symbols
182
+ valid_states_array = Array(valid_states).map { |s| s.respond_to?(:to_sym) ? s.to_sym : s }
183
+
184
+ # Return early if current state is not in the valid states
185
+ return unless valid_states_array.include?(current_state)
186
+ else
187
+ # Handle proc-based allow_if (original behavior)
188
+ return unless flow_target.instance_exec(*args, **kwargs, &transition[:allow_if])
189
+ end
174
190
  end
175
191
 
176
192
  if transition[:block]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: circulator
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.1
4
+ version: 2.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jim Gay