action_flow 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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