workflow-orchestrator 1.3.0
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 +7 -0
- data/.gitignore +20 -0
- data/.travis.yml +36 -0
- data/CHANGELOG.md +133 -0
- data/Gemfile +3 -0
- data/MIT-LICENSE +22 -0
- data/README.md +707 -0
- data/Rakefile +30 -0
- data/gemfiles/Gemfile.rails-3.x +12 -0
- data/gemfiles/Gemfile.rails-4.0 +14 -0
- data/gemfiles/Gemfile.rails-4.1 +14 -0
- data/gemfiles/Gemfile.rails-4.2 +14 -0
- data/gemfiles/Gemfile.rails-edge +14 -0
- data/lib/workflow/adapters/active_record.rb +75 -0
- data/lib/workflow/adapters/remodel.rb +15 -0
- data/lib/workflow/draw.rb +79 -0
- data/lib/workflow/errors.rb +20 -0
- data/lib/workflow/event.rb +38 -0
- data/lib/workflow/event_collection.rb +36 -0
- data/lib/workflow/specification.rb +83 -0
- data/lib/workflow/state.rb +44 -0
- data/lib/workflow/version.rb +3 -0
- data/lib/workflow.rb +307 -0
- data/orders_workflow.png +0 -0
- data/test/active_record_scopes_test.rb +56 -0
- data/test/active_record_scopes_with_values_test.rb +79 -0
- data/test/adapter_hook_test.rb +52 -0
- data/test/advanced_examples_test.rb +84 -0
- data/test/advanced_hooks_and_validation_test.rb +119 -0
- data/test/attr_protected_test.rb +107 -0
- data/test/before_transition_test.rb +36 -0
- data/test/couchtiny_example.rb +46 -0
- data/test/enum_values_in_memory_test.rb +23 -0
- data/test/enum_values_test.rb +30 -0
- data/test/incline_column_test.rb +54 -0
- data/test/inheritance_test.rb +56 -0
- data/test/main_test.rb +588 -0
- data/test/multiple_workflows_test.rb +84 -0
- data/test/new_versions/compare_states_test.rb +32 -0
- data/test/new_versions/persistence_test.rb +62 -0
- data/test/on_error_test.rb +52 -0
- data/test/on_unavailable_transition_test.rb +85 -0
- data/test/readme_example.rb +37 -0
- data/test/test_helper.rb +39 -0
- data/test/without_active_record_test.rb +54 -0
- data/workflow-orchestrator.gemspec +42 -0
- metadata +267 -0
data/lib/workflow.rb
ADDED
@@ -0,0 +1,307 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
|
3
|
+
require 'workflow/specification'
|
4
|
+
require 'workflow/adapters/active_record'
|
5
|
+
require 'workflow/adapters/remodel'
|
6
|
+
|
7
|
+
# See also README.markdown for documentation
|
8
|
+
module Workflow
|
9
|
+
module ClassMethods
|
10
|
+
attr_reader :workflow_spec
|
11
|
+
|
12
|
+
def workflow_column(column_name=nil)
|
13
|
+
#I guess we want to preserve the api???
|
14
|
+
@workflow_state_column_name ||= column_name
|
15
|
+
if @workflow_state_column_name.nil? && superclass.respond_to?(:workflow_column)
|
16
|
+
@workflow_state_column_name = superclass.workflow_column
|
17
|
+
end
|
18
|
+
@workflow_state_column_name ||= :workflow_state
|
19
|
+
|
20
|
+
end
|
21
|
+
|
22
|
+
def workflow(column=nil,&specification)
|
23
|
+
column = workflow_column(column)
|
24
|
+
assign_workflow Specification.new(Hash.new, &specification)
|
25
|
+
|
26
|
+
inject_setter_for_state
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
#allow setting of workflow_state by name, and interpolate the value
|
32
|
+
def inject_setter_for_state
|
33
|
+
define_method("#{@workflow_state_column_name}=") do |val|
|
34
|
+
|
35
|
+
matching_state = spec.states.select{|k,v| v.name.to_s == val.to_s}.values.first
|
36
|
+
val = matching_state.value if matching_state
|
37
|
+
super(val)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# Creates the convinience methods like `my_transition!`
|
42
|
+
def assign_workflow(specification_object)
|
43
|
+
|
44
|
+
# Merging two workflow specifications can **not** be done automically, so
|
45
|
+
# just make the latest specification win. Same for inheritance -
|
46
|
+
# definition in the subclass wins.
|
47
|
+
if respond_to? :inherited_workflow_spec # undefine methods defined by the old workflow_spec
|
48
|
+
inherited_workflow_spec.states.values.each do |state|
|
49
|
+
state_name = state.name
|
50
|
+
module_eval do
|
51
|
+
undef_method "#{state_name}?"
|
52
|
+
end
|
53
|
+
|
54
|
+
state.events.flat.each do |event|
|
55
|
+
event_name = event.name
|
56
|
+
module_eval do
|
57
|
+
undef_method "#{event_name}!".to_sym
|
58
|
+
undef_method "can_#{event_name}?"
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
@workflow_spec = specification_object
|
65
|
+
@workflow_spec.states.values.each do |state|
|
66
|
+
state_name = state.name
|
67
|
+
module_eval do
|
68
|
+
define_method "#{state_name}?" do
|
69
|
+
state_name == current_state.name
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
state.events.flat.each do |event|
|
74
|
+
event_name = event.name
|
75
|
+
module_eval do
|
76
|
+
define_method "#{event_name}!".to_sym do |*args|
|
77
|
+
process_event!(event_name, *args)
|
78
|
+
end
|
79
|
+
|
80
|
+
define_method "can_#{event_name}?" do
|
81
|
+
return !!current_state.events.first_applicable(event_name, self)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
module InstanceMethods
|
90
|
+
|
91
|
+
def current_state
|
92
|
+
loaded_state_value = load_workflow_state
|
93
|
+
if loaded_state_value
|
94
|
+
loaded_state_name = spec.states.select{|k,v| v.value.to_s == loaded_state_value.to_s}.keys.first
|
95
|
+
end
|
96
|
+
|
97
|
+
res = spec.states[loaded_state_name.to_sym] if loaded_state_name
|
98
|
+
res || spec.initial_state
|
99
|
+
end
|
100
|
+
|
101
|
+
# See the 'Guards' section in the README
|
102
|
+
# @return true if the last transition was halted by one of the transition callbacks.
|
103
|
+
def halted?
|
104
|
+
@halted
|
105
|
+
end
|
106
|
+
|
107
|
+
# @return the reason of the last transition abort as set by the previous
|
108
|
+
# call of `halt` or `halt!` method.
|
109
|
+
def halted_because
|
110
|
+
@halted_because
|
111
|
+
end
|
112
|
+
|
113
|
+
def process_event!(name, *args)
|
114
|
+
event = current_state.events.first_applicable(name, self)
|
115
|
+
if event.nil?
|
116
|
+
return run_on_unavailable_transition(current_state, name, *args)
|
117
|
+
end
|
118
|
+
@halted_because = nil
|
119
|
+
@halted = false
|
120
|
+
|
121
|
+
check_transition(event)
|
122
|
+
|
123
|
+
from = current_state
|
124
|
+
to_state = spec.states[event.transitions_to]
|
125
|
+
to_value = to_state.value
|
126
|
+
|
127
|
+
run_before_transition(from, to_state, name, *args)
|
128
|
+
return false if @halted
|
129
|
+
|
130
|
+
begin
|
131
|
+
return_value = run_action(event.action, *args) || run_action_callback(event.name, *args)
|
132
|
+
rescue StandardError => e
|
133
|
+
run_on_error(e, from, to_state, name, *args)
|
134
|
+
end
|
135
|
+
|
136
|
+
return false if @halted
|
137
|
+
|
138
|
+
run_on_transition(from, to_state, name, *args)
|
139
|
+
|
140
|
+
run_on_exit(from, to_state, name, *args)
|
141
|
+
|
142
|
+
transition_value = persist_workflow_state to_value
|
143
|
+
|
144
|
+
run_on_entry(to_state, from, name, *args)
|
145
|
+
|
146
|
+
run_after_transition(from, to_state, name, *args)
|
147
|
+
|
148
|
+
return_value.nil? ? transition_value : return_value
|
149
|
+
end
|
150
|
+
|
151
|
+
def halt(reason = nil)
|
152
|
+
@halted_because = reason
|
153
|
+
@halted = true
|
154
|
+
end
|
155
|
+
|
156
|
+
def halt!(reason = nil)
|
157
|
+
@halted_because = reason
|
158
|
+
@halted = true
|
159
|
+
raise TransitionHalted.new(reason)
|
160
|
+
end
|
161
|
+
|
162
|
+
def spec
|
163
|
+
# check the singleton class first
|
164
|
+
class << self
|
165
|
+
return workflow_spec if workflow_spec
|
166
|
+
end
|
167
|
+
|
168
|
+
c = self.class
|
169
|
+
# using a simple loop instead of class_inheritable_accessor to avoid
|
170
|
+
# dependency on Rails' ActiveSupport
|
171
|
+
until c.workflow_spec || !(c.include? Workflow)
|
172
|
+
c = c.superclass
|
173
|
+
end
|
174
|
+
c.workflow_spec
|
175
|
+
end
|
176
|
+
|
177
|
+
private
|
178
|
+
|
179
|
+
def check_transition(event)
|
180
|
+
# Create a meaningful error message instead of
|
181
|
+
# "undefined method `on_entry' for nil:NilClass"
|
182
|
+
# Reported by Kyle Burton
|
183
|
+
if !spec.states[event.transitions_to]
|
184
|
+
raise WorkflowError.new("Event[#{event.name}]'s " +
|
185
|
+
"transitions_to[#{event.transitions_to}] is not a declared state.")
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
def run_before_transition(from, to, event, *args)
|
190
|
+
instance_exec(from.name, to.name, event, *args, &spec.before_transition_proc) if
|
191
|
+
spec.before_transition_proc
|
192
|
+
end
|
193
|
+
|
194
|
+
def run_on_error(error, from, to, event, *args)
|
195
|
+
if spec.on_error_proc
|
196
|
+
instance_exec(error, from.name, ( to.name rescue nil ), event, *args, &spec.on_error_proc)
|
197
|
+
halt(error.message)
|
198
|
+
else
|
199
|
+
raise error
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
def run_on_unavailable_transition(from, to_name, *args)
|
204
|
+
if !spec.on_unavailable_transition_proc || !instance_exec(from.name, to_name.to_sym, *args, &spec.on_unavailable_transition_proc)
|
205
|
+
run_on_error(NoTransitionAllowed.new(
|
206
|
+
"There is no event #{to_name.to_sym} defined for the #{current_state} state"),
|
207
|
+
current_state,
|
208
|
+
nil, to_name, *args)
|
209
|
+
return false
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
def run_on_transition(from, to, event, *args)
|
214
|
+
instance_exec(from.name, to.name, event, *args, &spec.on_transition_proc) if spec.on_transition_proc
|
215
|
+
end
|
216
|
+
|
217
|
+
def run_after_transition(from, to, event, *args)
|
218
|
+
instance_exec(from.name, to.name, event, *args, &spec.after_transition_proc) if
|
219
|
+
spec.after_transition_proc
|
220
|
+
end
|
221
|
+
|
222
|
+
def run_action(action, *args)
|
223
|
+
instance_exec(*args, &action) if action
|
224
|
+
end
|
225
|
+
|
226
|
+
def has_callback?(action)
|
227
|
+
# 1. public callback method or
|
228
|
+
# 2. protected method somewhere in the class hierarchy or
|
229
|
+
# 3. private in the immediate class (parent classes ignored)
|
230
|
+
action = action.to_sym
|
231
|
+
self.respond_to?(action) or
|
232
|
+
self.class.protected_method_defined?(action) or
|
233
|
+
self.private_methods(false).map(&:to_sym).include?(action)
|
234
|
+
end
|
235
|
+
|
236
|
+
def run_action_callback(action_name, *args)
|
237
|
+
action = action_name.to_sym
|
238
|
+
self.send(action, *args) if has_callback?(action)
|
239
|
+
end
|
240
|
+
|
241
|
+
def run_on_entry(state, prior_state, triggering_event, *args)
|
242
|
+
if state.on_entry
|
243
|
+
instance_exec(prior_state.name, triggering_event, *args, &state.on_entry)
|
244
|
+
else
|
245
|
+
hook_name = "on_#{state}_entry"
|
246
|
+
self.send hook_name, prior_state, triggering_event, *args if has_callback?(hook_name)
|
247
|
+
end
|
248
|
+
end
|
249
|
+
|
250
|
+
def run_on_exit(state, new_state, triggering_event, *args)
|
251
|
+
if state
|
252
|
+
if state.on_exit
|
253
|
+
instance_exec(new_state.name, triggering_event, *args, &state.on_exit)
|
254
|
+
else
|
255
|
+
hook_name = "on_#{state}_exit"
|
256
|
+
self.send hook_name, new_state, triggering_event, *args if has_callback?(hook_name)
|
257
|
+
end
|
258
|
+
end
|
259
|
+
end
|
260
|
+
|
261
|
+
# load_workflow_state and persist_workflow_state
|
262
|
+
# can be overriden to handle the persistence of the workflow state.
|
263
|
+
#
|
264
|
+
# Default (non ActiveRecord) implementation stores the current state
|
265
|
+
# in a variable.
|
266
|
+
#
|
267
|
+
# Default ActiveRecord implementation uses a 'workflow_state' database column.
|
268
|
+
def load_workflow_state
|
269
|
+
@workflow_state if instance_variable_defined? :@workflow_state
|
270
|
+
end
|
271
|
+
|
272
|
+
def persist_workflow_state(new_value)
|
273
|
+
|
274
|
+
@workflow_state = new_value
|
275
|
+
end
|
276
|
+
end
|
277
|
+
|
278
|
+
def self.included(klass)
|
279
|
+
klass.send :include, InstanceMethods
|
280
|
+
|
281
|
+
# backup the parent workflow spec, making accessible through #inherited_workflow_spec
|
282
|
+
if klass.superclass.respond_to?(:workflow_spec, true)
|
283
|
+
klass.module_eval do
|
284
|
+
# see http://stackoverflow.com/a/2495650/111995 for implementation explanation
|
285
|
+
pro = Proc.new { klass.superclass.workflow_spec }
|
286
|
+
singleton_class = class << self; self; end
|
287
|
+
singleton_class.send(:define_method, :inherited_workflow_spec) do
|
288
|
+
pro.call
|
289
|
+
end
|
290
|
+
end
|
291
|
+
end
|
292
|
+
|
293
|
+
klass.extend ClassMethods
|
294
|
+
|
295
|
+
# Look for a hook; otherwise detect based on ancestor class.
|
296
|
+
if klass.respond_to?(:workflow_adapter)
|
297
|
+
klass.send :include, klass.workflow_adapter
|
298
|
+
else
|
299
|
+
if Object.const_defined?(:ActiveRecord) && klass < ActiveRecord::Base
|
300
|
+
klass.send :include, Adapter::ActiveRecord
|
301
|
+
end
|
302
|
+
if Object.const_defined?(:Remodel) && klass < Adapter::Remodel::Entity
|
303
|
+
klass.send :include, Adapter::Remodel::InstanceMethods
|
304
|
+
end
|
305
|
+
end
|
306
|
+
end
|
307
|
+
end
|
data/orders_workflow.png
ADDED
Binary file
|
@@ -0,0 +1,56 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'test_helper')
|
2
|
+
|
3
|
+
$VERBOSE = false
|
4
|
+
require 'active_record'
|
5
|
+
require 'sqlite3'
|
6
|
+
require 'workflow'
|
7
|
+
|
8
|
+
ActiveRecord::Migration.verbose = false
|
9
|
+
|
10
|
+
class Article < ActiveRecord::Base
|
11
|
+
include Workflow
|
12
|
+
|
13
|
+
workflow do
|
14
|
+
state :new
|
15
|
+
state :accepted
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
class ActiveRecordScopesTest < ActiveRecordTestCase
|
20
|
+
|
21
|
+
def setup
|
22
|
+
super
|
23
|
+
|
24
|
+
ActiveRecord::Schema.define do
|
25
|
+
create_table :articles do |t|
|
26
|
+
t.string :title
|
27
|
+
t.string :body
|
28
|
+
t.string :blame_reason
|
29
|
+
t.string :reject_reason
|
30
|
+
t.string :workflow_state
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def assert_state(title, expected_state, klass = Order)
|
36
|
+
o = klass.find_by_title(title)
|
37
|
+
assert_equal expected_state, o.read_attribute(klass.workflow_column)
|
38
|
+
o
|
39
|
+
end
|
40
|
+
|
41
|
+
test 'have "with_new_state" scope' do
|
42
|
+
assert_respond_to Article, :with_new_state
|
43
|
+
end
|
44
|
+
|
45
|
+
test 'have "with_accepted_state" scope' do
|
46
|
+
assert_respond_to Article, :with_accepted_state
|
47
|
+
end
|
48
|
+
|
49
|
+
test 'have "without_new_state" scope' do
|
50
|
+
assert_respond_to Article, :without_new_state
|
51
|
+
end
|
52
|
+
|
53
|
+
test 'have "without_accepted_state" scope' do
|
54
|
+
assert_respond_to Article, :without_accepted_state
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'test_helper')
|
2
|
+
|
3
|
+
$VERBOSE = false
|
4
|
+
require 'active_record'
|
5
|
+
require 'sqlite3'
|
6
|
+
require 'workflow'
|
7
|
+
|
8
|
+
ActiveRecord::Migration.verbose = false
|
9
|
+
|
10
|
+
class EnumArticle < ActiveRecord::Base
|
11
|
+
include Workflow
|
12
|
+
|
13
|
+
workflow do
|
14
|
+
state :new, 1 do
|
15
|
+
event :accept, transitions_to: :accepted
|
16
|
+
end
|
17
|
+
state :accepted, 3
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
class ActiveRecordScopesWithValuesTest < ActiveRecordTestCase
|
22
|
+
|
23
|
+
def setup
|
24
|
+
super
|
25
|
+
|
26
|
+
ActiveRecord::Schema.define do
|
27
|
+
create_table :enum_articles do |t|
|
28
|
+
t.string :title
|
29
|
+
t.string :body
|
30
|
+
t.string :blame_reason
|
31
|
+
t.string :reject_reason
|
32
|
+
t.integer :workflow_state
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
test 'have "with_new_state" scope' do
|
38
|
+
assert_respond_to EnumArticle, :with_new_state
|
39
|
+
end
|
40
|
+
|
41
|
+
test '"with_new_state" selects matching value' do
|
42
|
+
article = EnumArticle.create
|
43
|
+
assert_equal(article.workflow_state, 1)
|
44
|
+
assert_equal(EnumArticle.with_new_state.all, [article])
|
45
|
+
end
|
46
|
+
|
47
|
+
test 'have "with_accepted_state" scope' do
|
48
|
+
assert_respond_to EnumArticle, :with_accepted_state
|
49
|
+
end
|
50
|
+
|
51
|
+
test '"with_accepted_state" selects matching values' do
|
52
|
+
article = EnumArticle.create
|
53
|
+
article.accept!
|
54
|
+
assert_equal(EnumArticle.with_accepted_state.all, [article])
|
55
|
+
end
|
56
|
+
|
57
|
+
test 'have "without_new_state" scope' do
|
58
|
+
assert_respond_to EnumArticle, :without_new_state
|
59
|
+
end
|
60
|
+
|
61
|
+
test '"without_new_state" filters matching value' do
|
62
|
+
article = EnumArticle.create
|
63
|
+
article.accept!
|
64
|
+
assert_equal(article.workflow_state, 3)
|
65
|
+
assert_equal(EnumArticle.without_new_state, [article])
|
66
|
+
end
|
67
|
+
|
68
|
+
test 'have "without_accepted_state" scope' do
|
69
|
+
assert_respond_to EnumArticle, :without_accepted_state
|
70
|
+
end
|
71
|
+
|
72
|
+
test '"without_accepted_state" filters matching value' do
|
73
|
+
article = EnumArticle.create
|
74
|
+
assert_equal(article.workflow_state, 1)
|
75
|
+
assert_equal(EnumArticle.without_accepted_state, [article])
|
76
|
+
end
|
77
|
+
|
78
|
+
end
|
79
|
+
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'test_helper')
|
2
|
+
require 'workflow'
|
3
|
+
class AdapterHookTest < ActiveRecordTestCase
|
4
|
+
test 'hook to choose adapter' do
|
5
|
+
|
6
|
+
ActiveRecord::Schema.define do
|
7
|
+
create_table(:examples) { |t| t.string :workflow_state }
|
8
|
+
end
|
9
|
+
|
10
|
+
class DefaultAdapter < ActiveRecord::Base
|
11
|
+
self.table_name = :examples
|
12
|
+
include Workflow
|
13
|
+
workflow do
|
14
|
+
state(:initial) { event :progress, :transitions_to => :last }
|
15
|
+
state(:last)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
class ChosenByHookAdapter < ActiveRecord::Base
|
20
|
+
self.table_name = :examples
|
21
|
+
attr_reader :foo
|
22
|
+
def self.workflow_adapter
|
23
|
+
Module.new do
|
24
|
+
def load_workflow_state
|
25
|
+
@foo if defined?(@foo)
|
26
|
+
end
|
27
|
+
def persist_workflow_state(new_value)
|
28
|
+
@foo = new_value
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
include Workflow
|
34
|
+
workflow do
|
35
|
+
state(:initial) { event :progress, :transitions_to => :last }
|
36
|
+
state(:last)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
default = DefaultAdapter.create
|
41
|
+
assert default.initial?
|
42
|
+
default.progress!
|
43
|
+
assert default.last?
|
44
|
+
assert DefaultAdapter.find(default.id).last?, 'should have persisted via ActiveRecord'
|
45
|
+
|
46
|
+
hook = ChosenByHookAdapter.create
|
47
|
+
assert hook.initial?
|
48
|
+
hook.progress!
|
49
|
+
assert_equal hook.foo, :last, 'should have "persisted" with custom adapter'
|
50
|
+
assert ChosenByHookAdapter.find(hook.id).initial?, 'should not have persisted via ActiveRecord'
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'test_helper')
|
2
|
+
require 'workflow'
|
3
|
+
class AdvanceExamplesTest < ActiveRecordTestCase
|
4
|
+
|
5
|
+
class Article
|
6
|
+
include Workflow
|
7
|
+
workflow do
|
8
|
+
state :new do
|
9
|
+
event :submit, :transitions_to => :awaiting_review
|
10
|
+
end
|
11
|
+
state :awaiting_review do
|
12
|
+
event :review, :transitions_to => :being_reviewed
|
13
|
+
end
|
14
|
+
state :being_reviewed do
|
15
|
+
event :accept, :transitions_to => :accepted
|
16
|
+
event :reject, :transitions_to => :rejected
|
17
|
+
end
|
18
|
+
state :accepted do
|
19
|
+
end
|
20
|
+
state :rejected do
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
test '#63 undoing event - automatically add revert events for every defined event' do
|
26
|
+
# also see https://github.com/geekq/workflow/issues/63
|
27
|
+
spec = Article.workflow_spec
|
28
|
+
spec.state_names.each do |state_name|
|
29
|
+
state = spec.states[state_name]
|
30
|
+
|
31
|
+
(state.events.flat.reject {|e| e.name.to_s =~ /^revert_/ }).each do |event|
|
32
|
+
event_name = event.name
|
33
|
+
revert_event_name = "revert_" + event_name.to_s
|
34
|
+
|
35
|
+
# Add revert events
|
36
|
+
spec.states[event.transitions_to.to_sym].events.push(
|
37
|
+
revert_event_name,
|
38
|
+
Workflow::Event.new(revert_event_name, state)
|
39
|
+
)
|
40
|
+
|
41
|
+
# Add methods for revert events
|
42
|
+
Article.module_eval do
|
43
|
+
define_method "#{revert_event_name}!".to_sym do |*args|
|
44
|
+
process_event!(revert_event_name, *args)
|
45
|
+
end
|
46
|
+
define_method "can_#{revert_event_name}?" do
|
47
|
+
return self.current_state.events.include?(revert_event_name)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
a = Article.new
|
55
|
+
assert(a.new?, "should start with the 'new' state")
|
56
|
+
a.submit!
|
57
|
+
assert(a.awaiting_review?, "should now be in 'awaiting_review' state")
|
58
|
+
assert_equal(['revert_submit', 'review'], a.current_state.events.keys.map(&:to_s).sort)
|
59
|
+
a.revert_submit! # this method is added by our meta programming magic above
|
60
|
+
assert(a.new?, "should now be back in the 'new' state")
|
61
|
+
end
|
62
|
+
|
63
|
+
test '#92 Load workflow specification' do
|
64
|
+
c = Class.new
|
65
|
+
c.class_eval do
|
66
|
+
include Workflow
|
67
|
+
end
|
68
|
+
|
69
|
+
# build a Specification (you can load it from yaml file too)
|
70
|
+
myspec = Workflow::Specification.new do
|
71
|
+
state :one do
|
72
|
+
event :dynamic_transition, :transitions_to => :one_a
|
73
|
+
end
|
74
|
+
state :one_a
|
75
|
+
end
|
76
|
+
|
77
|
+
c.send :assign_workflow, myspec
|
78
|
+
|
79
|
+
a = c.new
|
80
|
+
a.dynamic_transition!(1)
|
81
|
+
assert a.one_a?, 'Expected successful transition to a new state'
|
82
|
+
end
|
83
|
+
|
84
|
+
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'test_helper')
|
2
|
+
|
3
|
+
$VERBOSE = false
|
4
|
+
require 'active_record'
|
5
|
+
require 'sqlite3'
|
6
|
+
require 'workflow'
|
7
|
+
|
8
|
+
ActiveRecord::Migration.verbose = false
|
9
|
+
|
10
|
+
# Transition based validation
|
11
|
+
# ---------------------------
|
12
|
+
# If you are using ActiveRecord you might want to define different validations
|
13
|
+
# for different transitions. There is a `validates_presence_of` hook that let's
|
14
|
+
# you specify the attributes that need to be present for an successful transition.
|
15
|
+
# If the object is not valid at the end of the transition event the transition
|
16
|
+
# is halted and a TransitionHalted exception is thrown.
|
17
|
+
#
|
18
|
+
# Here is a sample that illustrates how to use the presence validation:
|
19
|
+
# (use case suggested by http://github.com/southdesign)
|
20
|
+
class Article < ActiveRecord::Base
|
21
|
+
include Workflow
|
22
|
+
workflow do
|
23
|
+
state :new do
|
24
|
+
event :accept, :transitions_to => :accepted, :meta => {:validates_presence_of => [:title, :body]}
|
25
|
+
event :reject, :transitions_to => :rejected
|
26
|
+
end
|
27
|
+
state :accepted do
|
28
|
+
event :blame, :transitions_to => :blamed, :meta => {:validates_presence_of => [:title, :body, :blame_reason]}
|
29
|
+
event :delete, :transitions_to => :deleted
|
30
|
+
end
|
31
|
+
state :rejected do
|
32
|
+
event :delete, :transitions_to => :deleted
|
33
|
+
end
|
34
|
+
state :blamed do
|
35
|
+
event :delete, :transitions_to => :deleted
|
36
|
+
end
|
37
|
+
state :deleted do
|
38
|
+
event :accept, :transitions_to => :accepted
|
39
|
+
end
|
40
|
+
|
41
|
+
on_transition do |from, to, triggering_event, *event_args|
|
42
|
+
if self.class.superclass.to_s.split("::").first == "ActiveRecord"
|
43
|
+
singleton = class << self; self end
|
44
|
+
validations = Proc.new {}
|
45
|
+
|
46
|
+
meta = Article.workflow_spec.states[from].events[triggering_event].first.meta
|
47
|
+
fields_to_validate = meta[:validates_presence_of]
|
48
|
+
if fields_to_validate
|
49
|
+
validations = Proc.new {
|
50
|
+
errors.add_on_blank(fields_to_validate) if fields_to_validate
|
51
|
+
}
|
52
|
+
end
|
53
|
+
|
54
|
+
singleton.send :define_method, :validate_for_transition, &validations
|
55
|
+
validate_for_transition
|
56
|
+
halt! "Event[#{triggering_event}]'s transitions_to[#{to}] is not valid." unless self.errors.empty?
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
class AdvancedHooksAndValidationTest < ActiveRecordTestCase
|
63
|
+
|
64
|
+
def setup
|
65
|
+
super
|
66
|
+
|
67
|
+
ActiveRecord::Schema.define do
|
68
|
+
create_table :articles do |t|
|
69
|
+
t.string :title
|
70
|
+
t.string :body
|
71
|
+
t.string :blame_reason
|
72
|
+
t.string :reject_reason
|
73
|
+
t.string :workflow_state
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
exec "INSERT INTO articles(title, body, blame_reason, reject_reason, workflow_state) VALUES('new1', NULL, NULL, NULL, 'new')"
|
78
|
+
exec "INSERT INTO articles(title, body, blame_reason, reject_reason, workflow_state) VALUES('new2', 'some content', NULL, NULL, 'new')"
|
79
|
+
exec "INSERT INTO articles(title, body, blame_reason, reject_reason, workflow_state) VALUES('accepted1', 'some content', NULL, NULL, 'accepted')"
|
80
|
+
|
81
|
+
end
|
82
|
+
|
83
|
+
def assert_state(title, expected_state, klass = Order)
|
84
|
+
o = klass.find_by_title(title)
|
85
|
+
assert_equal expected_state, o.read_attribute(klass.workflow_column)
|
86
|
+
o
|
87
|
+
end
|
88
|
+
|
89
|
+
test 'deny transition from new to accepted because of the missing presence of the body' do
|
90
|
+
a = Article.find_by_title('new1');
|
91
|
+
assert_raise Workflow::TransitionHalted do
|
92
|
+
a.accept!
|
93
|
+
end
|
94
|
+
assert_state 'new1', 'new', Article
|
95
|
+
end
|
96
|
+
|
97
|
+
test 'allow transition from new to accepted because body is present this time' do
|
98
|
+
a = Article.find_by_title('new2');
|
99
|
+
assert a.accept!
|
100
|
+
assert_state 'new2', 'accepted', Article
|
101
|
+
end
|
102
|
+
|
103
|
+
test 'allow transition from accepted to blamed because of a blame_reason' do
|
104
|
+
a = Article.find_by_title('accepted1');
|
105
|
+
a.blame_reason = "Provocant thesis"
|
106
|
+
assert a.blame!
|
107
|
+
assert_state 'accepted1', 'blamed', Article
|
108
|
+
end
|
109
|
+
|
110
|
+
test 'deny transition from accepted to blamed because of no blame_reason' do
|
111
|
+
a = Article.find_by_title('accepted1');
|
112
|
+
assert_raise Workflow::TransitionHalted do
|
113
|
+
assert a.blame!
|
114
|
+
end
|
115
|
+
assert_state 'accepted1', 'accepted', Article
|
116
|
+
end
|
117
|
+
|
118
|
+
end
|
119
|
+
|