action_flow 0.2.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.
@@ -0,0 +1,5 @@
1
+ *~
2
+ pkg
3
+ *.sw?
4
+ .DS_Store
5
+ coverage
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2008 Justin Balthrop
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,78 @@
1
+ = Flow
2
+
3
+ Flow is a simple workflow engine mixin for controllers that makes generating simple
4
+ or complex user flows as easy as it should be, rather than the painful process it usually is with MVC.
5
+ Also, it makes your controllers incredibly skinny, by moving the flow logic out of the
6
+ controller into a Flow::Context model.
7
+
8
+ == Usage:
9
+
10
+ Say you want to create a multi-page flow for new user signup. Assuming you keep the logic
11
+ for creating users in your User model, where it belongs, this is all the code you would need:
12
+
13
+ class NewUserFlowContext < Flow::Context
14
+ state :start do
15
+ if params[:already_a_member]
16
+ transition(:login)
17
+ else
18
+ transition(:signup)
19
+ end
20
+ end
21
+
22
+ state :login
23
+
24
+ state :signup do
25
+ if User.name_taken?(params[:username])
26
+ flash[:error] = 'Username already taken. Please choose another.'
27
+ transition(:signup)
28
+ else
29
+ u = User.create(params)
30
+ data[:user_id] = u.id
31
+ transition(:confirm)
32
+ end
33
+ end
34
+ end
35
+
36
+ class NewUserController
37
+ include Flow
38
+ flow :new_user
39
+ end
40
+
41
+ Then you just create a template for each state in app/views/new_user. Flow also provides
42
+ two helper functions to make template creation really easy:
43
+
44
+ <%= flow_link_to "I already have an account", :already_a_member => true %>
45
+
46
+ <% flow_form_tag do -%>
47
+ <%= text_field_tag :name %>
48
+ <%= text_field_tag :email_address %>
49
+ <%= password_field_tag :password %>
50
+ <%= submit_tag %>
51
+ <% end %>
52
+
53
+ These are just like link_to and form_tag, but they submit to the :next action, which is a
54
+ special action for transitioning between states. They also add the parameters necessary
55
+ to maintain context.
56
+
57
+ == Internals:
58
+
59
+ When you call the flow class macro in a controller, it creates an action for each state
60
+ defined in the specified flow context. It also creates an action called :next
61
+ for transitioning between states. The template helper functions (flow_link_to, and
62
+ flow_form_tag) submit a POST to this action. All transition logic is performed within next
63
+ and then the user is redirected using a GET to the correct state action. This means users
64
+ can safely use their browser back button to return to previous steps and use the forward
65
+ button if they change their mind.
66
+
67
+ Flow::Context is an ActiveRecord model. This allows flows to be easily persisted between
68
+ steps.
69
+
70
+ == Install:
71
+
72
+ sudo gem install flow
73
+
74
+ You also need to create a migration to make the flow_contexts table. See examples/sample_migration.rb
75
+
76
+ == License:
77
+
78
+ Copyright (c) 2009 Justin Balthrop, Geni.com; Published under The MIT License, see License.txt
@@ -0,0 +1,45 @@
1
+ require 'rake'
2
+ require 'rake/testtask'
3
+ require 'rake/rdoctask'
4
+
5
+ begin
6
+ require 'jeweler'
7
+ Jeweler::Tasks.new do |s|
8
+ s.name = "action_flow"
9
+ s.summary = %Q{ A state-machine inspired mixin for controllers that makes creating flows and wizards dead simple. }
10
+ s.email = "code@justinbalthrop.com"
11
+ s.homepage = "http://github.com/ninjudd/action_flow"
12
+ s.description = "A state-machine inspired mixin for controllers that makes creating flows and wizards dead simple."
13
+ s.add_dependency('meta', '>= 0.1.1')
14
+ s.authors = ["Justin Balthrop"]
15
+ end
16
+ Jeweler::GemcutterTasks.new
17
+ rescue LoadError
18
+ puts "Jeweler not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
19
+ end
20
+
21
+ Rake::TestTask.new do |t|
22
+ t.libs << 'lib'
23
+ t.pattern = 'test/**/*_test.rb'
24
+ t.verbose = false
25
+ end
26
+
27
+ Rake::RDocTask.new do |rdoc|
28
+ rdoc.rdoc_dir = 'rdoc'
29
+ rdoc.title = 'action_flow'
30
+ rdoc.options << '--line-numbers' << '--inline-source'
31
+ rdoc.rdoc_files.include('README*')
32
+ rdoc.rdoc_files.include('lib/**/*.rb')
33
+ end
34
+
35
+ begin
36
+ require 'rcov/rcovtask'
37
+ Rcov::RcovTask.new do |t|
38
+ t.libs << 'test'
39
+ t.test_files = FileList['test/**/*_test.rb']
40
+ t.verbose = true
41
+ end
42
+ rescue LoadError
43
+ end
44
+
45
+ task :default => :test
@@ -0,0 +1,5 @@
1
+ ---
2
+ :build:
3
+ :patch: 0
4
+ :major: 0
5
+ :minor: 2
@@ -0,0 +1,9 @@
1
+ class CreateFlowContexts < ActiveRecord::Migration
2
+ def self.up
3
+ FlowContextMigration.up
4
+ end
5
+
6
+ def self.down
7
+ FlowContextMigration.down
8
+ end
9
+ end
@@ -0,0 +1,157 @@
1
+ require 'meta'
2
+ require 'active_record'
3
+ require 'action_controller'
4
+
5
+ module ActionFlow
6
+ def flow(name)
7
+ helper ActionFlow::Helper
8
+
9
+ flow = "#{name}_flow_context".camelize.constantize
10
+
11
+ define_method(:context) do
12
+ @context ||= flow.find_or_create(params.delete(:k))
13
+ end
14
+ private :context
15
+
16
+ flow.states.each do |state|
17
+ define_method(state) do
18
+ context.at_state(state)
19
+ context.data.each do |key, value|
20
+ instance_variable_set("@#{key}", value)
21
+ end
22
+ end
23
+ end
24
+
25
+ define_method(:next) do
26
+ context.at_state(params.delete(:state))
27
+ context.fire_transition(self)
28
+ redirect_to(:action => context.state, :k => context.key)
29
+ end
30
+ end
31
+
32
+ module Helper
33
+ def flow_link_to(name, options = {}, html_options = {})
34
+ options.merge!(flow_options)
35
+ html_options.merge!(:post => true)
36
+ link_to(name, options, html_options)
37
+ end
38
+
39
+ def flow_form_tag(options = {}, html_options = {}, *args, &block)
40
+ options.merge!(flow_options)
41
+ html_options.merge!(:method => :post)
42
+ form_tag(options, html_options, *args, &block)
43
+ end
44
+
45
+ private
46
+
47
+ def flow_options
48
+ {:controller => controller.controller_name, :action => :next, :state => controller.context.state, :k => controller.context.key}
49
+ end
50
+ end
51
+
52
+ class Context < ActiveRecord::Base
53
+ set_table_name 'flow_contexts'
54
+
55
+ def before_save
56
+ self.states = Marshal.dump(states)
57
+ self.state_data = Marshal.dump(state_data)
58
+ end
59
+
60
+ def after_save
61
+ self.states = Marshal.load(states)
62
+ self.state_data = Marshal.load(state_data)
63
+ end
64
+
65
+ def after_find
66
+ after_save
67
+ end
68
+
69
+ inheritable_class_attr :initial
70
+ initial :start
71
+
72
+ def self.find_or_create(key = nil)
73
+ context = find_by_key(key) if key
74
+ context || create(
75
+ :states => [initial],
76
+ :state_data => {},
77
+ :key => generate_key
78
+ )
79
+ end
80
+
81
+ def self.generate_key
82
+ sha = Digest::SHA1::new
83
+ now = Time.now
84
+ sha.update(now.to_s)
85
+ sha.update(String(now.usec))
86
+ sha.update(String(rand))
87
+ sha.update(String($$))
88
+ sha.update('go with the flow')
89
+ sha.hexdigest
90
+ end
91
+
92
+ def self.transitions
93
+ @transitions ||= {}
94
+ end
95
+
96
+ def self.transition(state)
97
+ ancestors.each do |klass|
98
+ return if klass == ActionFlow::Context
99
+ next unless klass.respond_to?(:transitions)
100
+ transition = klass.transitions[state]
101
+ return transition if transition
102
+ end
103
+ end
104
+
105
+ def self.states
106
+ transitions.keys
107
+ end
108
+
109
+ def self.state(state, &block)
110
+ transitions[state] = block
111
+ end
112
+
113
+ attr_reader :controller
114
+ delegate :params, :flash, :to => :controller
115
+
116
+ def at_state(state)
117
+ state = state.to_sym
118
+ if states.include?(state)
119
+ @state = state
120
+ states.slice!(states.index(state) + 1..-1)
121
+ else
122
+ raise InvalidState, "state #{state} not valid in this context"
123
+ end
124
+ end
125
+
126
+ def state
127
+ @state ||= states.last
128
+ end
129
+
130
+ def data
131
+ state_data[:state] ||= {}
132
+ end
133
+
134
+ def fire_transition(controller)
135
+ @controller = controller
136
+ begin
137
+ transition = self.class.transition(state)
138
+ transition.bind(self).call
139
+ rescue TransitionFired
140
+ end
141
+ end
142
+
143
+ def transition(state)
144
+ @state = state
145
+ self.states << state
146
+ self.save
147
+ raise TransitionFired
148
+ end
149
+
150
+ class InvalidState < StandardError; end
151
+ class TransitionFired < Exception; end
152
+ end
153
+ end
154
+
155
+ class ActionController::Base
156
+ extend ActionFlow
157
+ end
@@ -0,0 +1,17 @@
1
+ class FlowContextMigration < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :flow_contexts do |t|
4
+ t.timestamps
5
+ t.string :key
6
+ t.string :type
7
+ t.binary :states
8
+ t.binary :state_data
9
+ t.text :final_destination
10
+ end
11
+ add_index :flow_contexts, :key, :unique => true
12
+ end
13
+
14
+ def self.down
15
+ drop_table :flow_contexts
16
+ end
17
+ end
@@ -0,0 +1,81 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/../test_helper')
2
+
3
+ class ActionFlowContextTest < Test::Unit::TestCase
4
+ class ParentContext < ActionFlow::Context
5
+ state :start do
6
+ transition(:parent_one)
7
+ end
8
+
9
+ state :parent_one do
10
+ transition(:parent_two)
11
+ end
12
+
13
+ state :parent_two
14
+ end
15
+
16
+ class ChildContext < ParentContext
17
+ state :parent_two do
18
+ transition(:child_one)
19
+ end
20
+
21
+ state :child_one do
22
+ transition(:child_two)
23
+ end
24
+
25
+ state :child_two
26
+ end
27
+
28
+ def setup
29
+ FlowContextMigration.down rescue nil
30
+ FlowContextMigration.up
31
+ end
32
+
33
+ def test_find_or_create
34
+ count = ActionFlow::Context.count
35
+ ChildContext.find_or_create
36
+ assert count != ActionFlow::Context.count
37
+ end
38
+
39
+ def test_state_serializaton
40
+ ChildContext.find_or_create
41
+ assert_equal [:start], ChildContext.first.states
42
+ end
43
+
44
+ def test_state_data_serializaton
45
+ state_data = {:one=>1, :two=>2, (1..10)=>'1 to 10'}
46
+ ChildContext.find_or_create.update_attributes(:state_data=>state_data)
47
+ assert_equal state_data, ChildContext.first.state_data
48
+ end
49
+
50
+ def test_singleton_transition_for_valid_state
51
+ assert_equal Proc, ChildContext.transition(:parent_one).class
52
+ end
53
+
54
+ def test_singleton_transition_for_invalid_state
55
+ assert_equal nil, ChildContext.transition(:foo)
56
+ end
57
+
58
+ def test_singleton_states
59
+ assert_equal [:child_one, :child_two, :parent_two], ChildContext.states.sort_by {|ii| ii.to_s}
60
+ end
61
+
62
+ def test_at_state_pops_subsequent_states_off_list
63
+ ctx = ChildContext.find_or_create
64
+ ctx.states << :parent_one
65
+ ctx.at_state(:start)
66
+ assert_equal [:start], ctx.states
67
+ end
68
+
69
+ def test_state
70
+ ctx = ChildContext.find_or_create
71
+ ctx.states << :parent_one
72
+ assert_equal :parent_one, ctx.state
73
+ end
74
+
75
+ def test_fire_transition
76
+ ctx = ChildContext.find_or_create
77
+ ctx.fire_transition(nil)
78
+ ctx.reload
79
+ assert_equal [:start, :parent_one], ctx.states
80
+ end
81
+ end
@@ -0,0 +1,74 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/../test_helper')
2
+ require 'action_view/test_case'
3
+ require 'ostruct'
4
+
5
+ module ActionFlow
6
+
7
+ class HelperTest < ActionView::TestCase
8
+ tests ActionFlow::Helper
9
+
10
+ class TestController < ActionController::Base
11
+ attr_accessor :url
12
+
13
+ def initialize(url)
14
+ self.request = ActionController::TestRequest.new
15
+ self.url = ActionController::UrlRewriter.new(request, url)
16
+ end
17
+
18
+ def context
19
+ @context ||= OpenStruct.new(:state=>'state', :key=>'key')
20
+ end
21
+
22
+ end
23
+
24
+ def setup
25
+ @controller = TestController.new({:controller=>'text', :action=>'show'})
26
+ end
27
+
28
+ def test_flow_link_to
29
+ text = 'text'
30
+ options = {:option=>'value'}
31
+ html_options = {:html_option=>'value'}
32
+ with_route do
33
+ result = flow_link_to(text, options, html_options)
34
+ assert_match 'href="/test/next', result, 'href missing'
35
+ assert_match 'k=key', result, 'flow key missng'
36
+ assert_match 'state=state', result, 'flow state missing'
37
+ assert_match 'option=value', result
38
+ assert_match 'html_option="value"', result
39
+ assert_match '>text</a>', result, 'text missing'
40
+ end
41
+ end
42
+
43
+ def test_flow_form_tag
44
+ options = {:option=>'value'}
45
+ html_options = {:html_option=>'value'}
46
+ with_route do
47
+ result = flow_form_tag(options, html_options)
48
+ assert_match 'action="/test/next', result, 'action missing'
49
+ assert_match 'k=key', result, 'flow key missng'
50
+ assert_match 'state=state', result, 'flow state missing'
51
+ assert_match 'option=value', result
52
+ assert_match 'html_option="value"', result
53
+ assert_match 'method="post"', result
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def with_route
60
+ with_routing do |set|
61
+ set.draw do |map|
62
+ map.connect ':controller/:action/:id'
63
+ end
64
+ yield
65
+ end
66
+ end
67
+
68
+ def protect_against_forgery?
69
+ false
70
+ end
71
+
72
+ end # class HelperTest
73
+
74
+ end # module ActionFlow
@@ -0,0 +1,38 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/test_helper')
2
+
3
+ class DummyFlowContext < ActionFlow::Context
4
+ state :start do
5
+ transition :one
6
+ end
7
+
8
+ state :one do
9
+ transition :two
10
+ end
11
+
12
+ state :two
13
+ end
14
+
15
+ class DummyController < ActionController::Base
16
+ flow :dummy
17
+ end
18
+
19
+ class ActionFlowTest < Test::Unit::TestCase
20
+ def test_flow_class_macro_includes_flow_helper
21
+ assert_equal true, DummyController.master_helper_module.include?(ActionFlow::Helper)
22
+ end
23
+
24
+ def test_flow_class_macro_adds_context_method
25
+ assert_equal true, DummyController.private_instance_methods.include?('context')
26
+ end
27
+
28
+ def test_flow_class_macro_adds_state_methods
29
+ DummyFlowContext.states.each do |state|
30
+ assert_equal true, DummyController.instance_methods.include?(state.to_s)
31
+ end
32
+ end
33
+
34
+ def test_flow_class_macro_adds_next_method
35
+ assert_equal true, DummyController.instance_methods.include?('next')
36
+ end
37
+ end
38
+
@@ -0,0 +1,22 @@
1
+ require 'set'
2
+ require 'test/unit'
3
+ require 'rubygems'
4
+ require 'mocha'
5
+ require 'pp'
6
+
7
+ $:.unshift File.dirname(__FILE__), File.dirname(__FILE__) + '/../lib'
8
+
9
+ require 'action_flow'
10
+ require 'flow_context_migration'
11
+ require 'action_controller/test_process'
12
+
13
+ ActiveRecord::Base.establish_connection(
14
+ :adapter => "postgresql",
15
+ :host => "localhost",
16
+ :username => "postgres",
17
+ :password => "",
18
+ :database => "test"
19
+ )
20
+
21
+ ActiveRecord::Migration.verbose = false
22
+ ActiveRecord::Base.connection.client_min_messages = 'panic'
metadata ADDED
@@ -0,0 +1,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: action_flow
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Justin Balthrop
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2010-04-23 00:00:00 -07:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: meta
17
+ type: :runtime
18
+ version_requirement:
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: 0.1.1
24
+ version:
25
+ description: A state-machine inspired mixin for controllers that makes creating flows and wizards dead simple.
26
+ email: code@justinbalthrop.com
27
+ executables: []
28
+
29
+ extensions: []
30
+
31
+ extra_rdoc_files:
32
+ - LICENSE
33
+ - README.rdoc
34
+ files:
35
+ - .gitignore
36
+ - LICENSE
37
+ - README.rdoc
38
+ - Rakefile
39
+ - VERSION.yml
40
+ - examples/sample_migration.rb
41
+ - lib/action_flow.rb
42
+ - lib/flow_context_migration.rb
43
+ - test/action_flow/context_test.rb
44
+ - test/action_flow/helper_test.rb
45
+ - test/action_flow_test.rb
46
+ - test/test_helper.rb
47
+ has_rdoc: true
48
+ homepage: http://github.com/ninjudd/action_flow
49
+ licenses: []
50
+
51
+ post_install_message:
52
+ rdoc_options:
53
+ - --charset=UTF-8
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: "0"
61
+ version:
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: "0"
67
+ version:
68
+ requirements: []
69
+
70
+ rubyforge_project:
71
+ rubygems_version: 1.3.5
72
+ signing_key:
73
+ specification_version: 3
74
+ summary: A state-machine inspired mixin for controllers that makes creating flows and wizards dead simple.
75
+ test_files:
76
+ - test/action_flow/context_test.rb
77
+ - test/action_flow/helper_test.rb
78
+ - test/action_flow_test.rb
79
+ - test/test_helper.rb
80
+ - examples/sample_migration.rb