mirror of
https://github.com/TracksApp/tracks.git
synced 2026-02-04 06:51:48 +01:00
Converted "done" property of a project into a status with states of 'active', 'hidden' & 'completed'. Fixes #389.
Added acts_as_state_machine plugin and made Project use it to implement this change. git-svn-id: http://www.rousette.org.uk/svn/tracks-repos/trunk@324 a4c988fc-2ded-0310-b66e-134b36920a42
This commit is contained in:
parent
8b65f1e8ee
commit
57005432e2
28 changed files with 765 additions and 47 deletions
|
|
@ -1,11 +1,7 @@
|
|||
class Project < ActiveRecord::Base
|
||||
|
||||
has_many :todos, :dependent => true
|
||||
has_many :notes, :dependent => true, :order => "created_at DESC"
|
||||
belongs_to :user
|
||||
acts_as_list :scope => :user
|
||||
|
||||
attr_protected :user
|
||||
|
||||
# Project name must not be empty
|
||||
# and must be less than 255 bytes
|
||||
|
|
@ -13,7 +9,28 @@ class Project < ActiveRecord::Base
|
|||
validates_length_of :name, :maximum => 255, :message => "project name must be less than 256 characters"
|
||||
validates_uniqueness_of :name, :message => "already exists", :scope =>"user_id"
|
||||
validates_format_of :name, :with => /^[^\/]*$/i, :message => "cannot contain the slash ('/') character"
|
||||
|
||||
|
||||
acts_as_list :scope => :user
|
||||
acts_as_state_machine :initial => :active, :column => 'state'
|
||||
|
||||
state :active
|
||||
state :hidden
|
||||
state :completed
|
||||
|
||||
event :activate do
|
||||
transitions :to => :active, :from => [:hidden, :complete]
|
||||
end
|
||||
|
||||
event :hide do
|
||||
transitions :to => :hidden, :from => [:active, :complete]
|
||||
end
|
||||
|
||||
event :complete do
|
||||
transitions :to => :completed, :from => [:active, :hidden]
|
||||
end
|
||||
|
||||
attr_protected :user
|
||||
|
||||
def description_present?
|
||||
attribute_present?("description")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@
|
|||
<div class="project_description"><%= sanitize(@project.description) %></div>
|
||||
<% end -%>
|
||||
|
||||
<% if @project.done? -%>
|
||||
<% if @project.completed? -%>
|
||||
<p class="project_completed">Project has been marked as completed</p>
|
||||
<% elsif @project.completed? -%>
|
||||
<p class="project_completed">Project has been marked as hidden</p>
|
||||
<% end -%>
|
||||
<div class="items toggle_target">
|
||||
<div id="p<%= project.id %>empty-nd" style="display:<%= @not_done.empty? ? 'block' : 'none'%>;">
|
||||
|
|
|
|||
|
|
@ -3,14 +3,18 @@
|
|||
%>
|
||||
<tr>
|
||||
<td width="150"><label for="project_name">Name:</label></td>
|
||||
<td width="300"><%= text_field 'project', 'name', :class => 'project-name' %></td>
|
||||
<td width="300"><%= text_field :project, 'name', :class => 'project-name' %></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="150"><label for="project_description">Description (optional):</label></td>
|
||||
<td width="300"><%= text_area 'project', 'description', "cols" => 30, "rows" => 4, :class => 'project-description' %></td>
|
||||
<td width="300"><%= text_area :project, 'description', "cols" => 30, "rows" => 4, :class => 'project-description' %></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="150"><label for="project_done">Project done?</label></td>
|
||||
<td width="300"><%= check_box 'project', 'done', :class => 'project-done' %></td>
|
||||
<td width="150"><label for="project_done">Project status:</label></td>
|
||||
<td width="300">
|
||||
<% ['active', 'hidden', 'completed'].each do | state | %>
|
||||
<%= radio_button(:project, 'state', state, {:class => 'project-done'}) %> <%= state.titlecase %>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% @project = nil %>
|
||||
|
|
@ -9,8 +9,10 @@
|
|||
<%= link_to( sanitize("#{project.name}"), :action => "show", :name => urlize(project.name) ) %><%= " (" + count_undone_todos(project,"actions") + ")" %>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<% if project.done? -%>
|
||||
<% if project.completed? -%>
|
||||
<span class="grey">COMPLETED</span>
|
||||
<% elsif project.hidden? -%>
|
||||
<span class="grey">HIDDEN</span>
|
||||
<% else -%>
|
||||
<span class="grey">ACTIVE</span>
|
||||
<% end -%>
|
||||
|
|
|
|||
|
|
@ -13,36 +13,42 @@
|
|||
|
||||
<div class="container">
|
||||
<div id="notes">
|
||||
<h2>Notes</h2>
|
||||
<div id="empty-n" style="display:<%= @notes.empty? ? 'block' : 'none'%>;">
|
||||
<%= render :partial => "shared/empty",
|
||||
<div class="add_note_link"><%= link_to_function( "Add a note", "Element.toggle('new-note'); Form.focusFirstElement('form-new-note');") %></div>
|
||||
<h2>Notes</h2>
|
||||
<div id="empty-n" style="display:<%= @notes.empty? ? 'block' : 'none'%>;">
|
||||
<%= render :partial => "shared/empty",
|
||||
:locals => { :message => "Currently there are no notes attached to this project"} %>
|
||||
</div>
|
||||
<%= render :partial => "note/notes_summary", :collection => @notes %>
|
||||
</div>
|
||||
<%= render :partial => "note/notes_summary", :collection => @notes %>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
<% if @project.done? -%>
|
||||
<%= button_to "Mark project as uncompleted", {:action => "toggle_project_done", :id => @project.id} %><br />
|
||||
<% else -%>
|
||||
<%= button_to "Mark project as completed", {:action => "toggle_project_done", :id => @project.id} %><br />
|
||||
<% end -%>
|
||||
<div id="new-note" style="display:none;">
|
||||
<%= form_remote_tag :url => { :controller => "note", :action => "add" },
|
||||
:update => "notes",
|
||||
:position => "bottom",
|
||||
:complete => "new Effect.Highlight('notes');$('empty-n').hide();",
|
||||
:html => {:id=>'form-new-note', :class => 'inline-form'} %>
|
||||
<%= hidden_field( "new_note", "project_id", "value" => "#{@project.id}" ) %>
|
||||
<%= text_area( "new_note", "body", "cols" => 50, "rows" => 3, "tabindex" => 1 ) %>
|
||||
<br /><br />
|
||||
<input type="submit" value="Add note" tabindex="2" />
|
||||
<%= end_form_tag %>
|
||||
</div>
|
||||
|
||||
<%= link_to_function( "Add a note", "Element.toggle('new-note'); Form.focusFirstElement('form-new-note');") %>
|
||||
|
||||
<div id="new-note" style="display:none;">
|
||||
<%= form_remote_tag :url => { :controller => "note", :action => "add" },
|
||||
:update => "notes",
|
||||
:position => "bottom",
|
||||
:complete => "new Effect.Highlight('notes');$('empty-n').hide();",
|
||||
:html => {:id=>'form-new-note', :class => 'inline-form'} %>
|
||||
<%= hidden_field( "new_note", "project_id", "value" => "#{@project.id}" ) %>
|
||||
<%= text_area( "new_note", "body", "cols" => 50, "rows" => 3, "tabindex" => 1 ) %>
|
||||
<br /><br />
|
||||
<input type="submit" value="Add note" tabindex="2" />
|
||||
<%= end_form_tag %>
|
||||
<div class="container">
|
||||
<div id="status">
|
||||
<h2>Status</h2>
|
||||
<div>
|
||||
<% ['active', 'hidden', 'completed'].each do | state | %>
|
||||
<% js = "new Ajax.Request('#{ url_for :controller => 'project', :action => 'update', :id => @project.id, 'project[state]' => state }', {asynchronous:true, evalScripts:true});" %>
|
||||
<%= radio_button(:project, 'state', state, {:class => 'project-done', :onclick => js}) %> <%= state.titlecase %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- [end:display_box] -->
|
||||
|
||||
<div id="input_box">
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@
|
|||
<br />
|
||||
|
||||
<label for="todo_project_id">Project</label><br />
|
||||
<%= select( "todo", "project_id", @projects.reject{|x| x.done? }.collect {|p| [p.name, p.id] },
|
||||
<%= select( "todo", "project_id", @projects.select{|x| x.active? }.collect {|p| [p.name, p.id] },
|
||||
{ :selected => @selected_project, :include_blank => true }, {"tabindex" => 4}) %>
|
||||
<br />
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@
|
|||
</tr>
|
||||
<tr>
|
||||
<td><label for="todo_project_id">Project</label></td>
|
||||
<td><%= select( "todo", "project_id", @projects.reject{|x| x.done? }.collect {|p| [p.name, p.id] }, { :selected => @selected_project, :include_blank => true }, {"tabindex" => 4}) %></td>
|
||||
<td><%= select( "todo", "project_id", @projects.select{|x| x.active? }.collect {|p| [p.name, p.id] }, { :selected => @selected_project, :include_blank => true }, {"tabindex" => 4}) %></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="todo_due">Due</label></td>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<h3>Active Projects:</h3>
|
||||
<ul>
|
||||
<% for project in @projects.reject{|p| p.done? } -%>
|
||||
<% for project in @projects.select{|p| p.active? } -%>
|
||||
<li id="sidebar-project-<%= project.id %>" class="sidebar-project"><%= link_to( sanitize(project.name), { :controller => "project", :action => "show",
|
||||
:name => urlize(project.name) } ) + " (" + count_undone_todos(project,"actions") + ")" %></li>
|
||||
<% end -%>
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
<% if @user.preference.show_completed_projects_in_sidebar %>
|
||||
<h3>Completed Projects:</h3>
|
||||
<ul>
|
||||
<% for project in @projects.reject{|p| !p.done? } -%>
|
||||
<% for project in @projects.select{|p| p.completed? } -%>
|
||||
<li id="sidebar-project-<%= project.id %>" class="sidebar-project"><%= link_to( sanitize(project.name), { :controller => "project", :action => "show",
|
||||
:name => urlize(project.name) } ) + " (" + count_undone_todos(project,"actions") + ")" %></li>
|
||||
<% end -%>
|
||||
|
|
|
|||
28
tracks/db/migrate/014_convert_project_to_state_machine.rb
Normal file
28
tracks/db/migrate/014_convert_project_to_state_machine.rb
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
class ConvertProjectToStateMachine < ActiveRecord::Migration
|
||||
|
||||
class Project < ActiveRecord::Base; end
|
||||
|
||||
def self.up
|
||||
ActiveRecord::Base.transaction do
|
||||
add_column :projects, :state, :string, :limit => 20, :default => "active", :null => false
|
||||
@projects = Project.find(:all)
|
||||
@projects.each do |project|
|
||||
project.state = project.done ? 'completed' : 'active'
|
||||
project.save
|
||||
end
|
||||
remove_column :projects, :done
|
||||
end
|
||||
end
|
||||
|
||||
def self.down
|
||||
ActiveRecord::Base.transaction do
|
||||
add_column :projects, :done, :integer, :limit => 4, :default => 0, :null => false
|
||||
@projects = Project.find(:all)
|
||||
@projects.each do |project|
|
||||
project.done = project.state == 'completed'
|
||||
project.save
|
||||
end
|
||||
remove_column :projects, :state
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
# migrations feature of ActiveRecord to incrementally modify your database, and
|
||||
# then regenerate this schema definition.
|
||||
|
||||
ActiveRecord::Schema.define(:version => 13) do
|
||||
ActiveRecord::Schema.define(:version => 14) do
|
||||
|
||||
create_table "contexts", :force => true do |t|
|
||||
t.column "name", :string, :default => "", :null => false
|
||||
|
|
@ -24,20 +24,20 @@ ActiveRecord::Schema.define(:version => 13) do
|
|||
t.column "date_format", :string, :limit => 40, :default => "%d/%m/%Y", :null => false
|
||||
t.column "week_starts", :integer, :default => 0, :null => false
|
||||
t.column "show_number_completed", :integer, :default => 5, :null => false
|
||||
t.column "staleness_starts", :integer, :default => 14, :null => false
|
||||
t.column "staleness_starts", :integer, :default => 7, :null => false
|
||||
t.column "show_completed_projects_in_sidebar", :boolean, :default => true, :null => false
|
||||
t.column "show_hidden_contexts_in_sidebar", :boolean, :default => true, :null => false
|
||||
t.column "due_style", :integer, :default => 0, :null => false
|
||||
t.column "admin_email", :string, :default => "", :null => false
|
||||
t.column "admin_email", :string, :default => "butshesagirl@rousette.org.uk", :null => false
|
||||
t.column "refresh", :integer, :default => 0, :null => false
|
||||
end
|
||||
|
||||
create_table "projects", :force => true do |t|
|
||||
t.column "name", :string, :default => "", :null => false
|
||||
t.column "position", :integer, :default => 0, :null => false
|
||||
t.column "done", :integer, :limit => 4, :default => 0, :null => false
|
||||
t.column "user_id", :integer, :default => 0, :null => false
|
||||
t.column "description", :text
|
||||
t.column "state", :string, :limit => 20, :default => "active", :null => false
|
||||
end
|
||||
|
||||
create_table "sessions", :force => true do |t|
|
||||
|
|
|
|||
|
|
@ -298,6 +298,12 @@ div.note_footer a, div.note_footer a:hover {
|
|||
background-color: transparent;
|
||||
}
|
||||
|
||||
div.add_note_link {
|
||||
float: right;
|
||||
}
|
||||
div#status > div {
|
||||
padding: 10px;
|
||||
}
|
||||
a.footer_link {color: #cc3334; font-style: normal;}
|
||||
a.footer_link:hover {color: #fff; background-color: #cc3334 !important;}
|
||||
|
||||
|
|
|
|||
6
tracks/test/fixtures/projects.yml
vendored
6
tracks/test/fixtures/projects.yml
vendored
|
|
@ -5,7 +5,7 @@ timemachine:
|
|||
name: Build a working time machine
|
||||
description: ''
|
||||
position: 1
|
||||
done: false
|
||||
state: 'active'
|
||||
user_id: 1
|
||||
|
||||
moremoney:
|
||||
|
|
@ -13,7 +13,7 @@ moremoney:
|
|||
name: Make more money than Billy Gates
|
||||
description: ''
|
||||
position: 2
|
||||
done: false
|
||||
state: 'active'
|
||||
user_id: 1
|
||||
|
||||
gardenclean:
|
||||
|
|
@ -21,5 +21,5 @@ gardenclean:
|
|||
name: Evict dinosaurs from the garden
|
||||
description: ''
|
||||
position: 3
|
||||
done: false
|
||||
state: 'active'
|
||||
user_id: 1
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ class ProjectControllerXmlApiTest < ActionController::IntegrationTest
|
|||
def test_creates_new_project
|
||||
initial_count = Project.count
|
||||
authenticated_post_xml_to_project_create
|
||||
assert_response_and_body_matches 200, %r|^<\?xml version="1\.0" encoding="UTF-8"\?>\n<project>\n <name>#{@@project_name}</name>\n <done type=\"integer\">0</done>\n <id type=\"integer\">[0-9]+</id>\n <description></description>\n <position type=\"integer\">1</position>\n</project>\n$|
|
||||
assert_response_and_body_matches 200, %r|^<\?xml version="1\.0" encoding="UTF-8"\?>\n<project>\n <name>#{@@project_name}</name>\n <id type=\"integer\">[0-9]+</id>\n <description></description>\n <position type=\"integer\">1</position>\n <state>active</state>\n</project>$|
|
||||
assert_equal initial_count + 1, Project.count
|
||||
project1 = Project.find_by_name(@@project_name)
|
||||
assert_not_nil project1, "expected project '#{@@project_name}' to be created"
|
||||
|
|
|
|||
|
|
@ -38,5 +38,28 @@ class ProjectTest < Test::Unit::TestCase
|
|||
assert_equal 1, newproj.errors.count
|
||||
assert_equal "cannot contain the slash ('/') character", newproj.errors.on(:name)
|
||||
end
|
||||
|
||||
def test_project_initial_state_is_active
|
||||
assert_equal :active, @timemachine.current_state
|
||||
assert @timemachine.active?
|
||||
end
|
||||
|
||||
def test_hide_project
|
||||
@timemachine.hide!
|
||||
assert_equal :hidden, @timemachine.current_state
|
||||
assert @timemachine.hidden?
|
||||
end
|
||||
|
||||
def test_activate_project
|
||||
@timemachine.activate!
|
||||
assert_equal :active, @timemachine.current_state
|
||||
assert @timemachine.active?
|
||||
end
|
||||
|
||||
def test_complete_project
|
||||
@timemachine.complete!
|
||||
assert_equal :completed, @timemachine.current_state
|
||||
assert @timemachine.completed?
|
||||
end
|
||||
|
||||
end
|
||||
|
|
|
|||
7
tracks/vendor/plugins/acts_as_state_machine/CHANGELOG
vendored
Normal file
7
tracks/vendor/plugins/acts_as_state_machine/CHANGELOG
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
* 2.0 * (2006-01-20 15:26:28 -0500)
|
||||
Enter / Exit actions
|
||||
Transition guards
|
||||
Guards and actions can be a symbol pointing to a method or a Proc
|
||||
|
||||
* 1.0 * (2006-01-15 12:16:55 -0500)
|
||||
Initial Release
|
||||
20
tracks/vendor/plugins/acts_as_state_machine/MIT-LICENSE
vendored
Normal file
20
tracks/vendor/plugins/acts_as_state_machine/MIT-LICENSE
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
Copyright (c) 2006 Scott Barron
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
33
tracks/vendor/plugins/acts_as_state_machine/README
vendored
Normal file
33
tracks/vendor/plugins/acts_as_state_machine/README
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
= Acts As State Machine
|
||||
|
||||
This act gives an Active Record model the ability to act as a finite state
|
||||
machine (FSM).
|
||||
|
||||
Acquire via subversion at:
|
||||
|
||||
http://elitists.textdriven.com/svn/plugins/acts_as_state_machine/trunk
|
||||
|
||||
If prompted, use the user/pass anonymous/anonymous.
|
||||
|
||||
== Example
|
||||
|
||||
class Order < ActiveRecord::Base
|
||||
acts_as_state_machine :initial => :opened
|
||||
|
||||
state :opened
|
||||
state :closed, :enter => Proc.new {|o| Mailer.send_notice(o)}
|
||||
state :returned
|
||||
|
||||
event :close do
|
||||
transitions :to => :closed, :from => :opened
|
||||
end
|
||||
|
||||
event :return do
|
||||
transitions :to => :returned, :from => :closed
|
||||
end
|
||||
end
|
||||
|
||||
o = Order.create
|
||||
o.close! # notice is sent by mailer
|
||||
o.return!
|
||||
|
||||
28
tracks/vendor/plugins/acts_as_state_machine/Rakefile
vendored
Normal file
28
tracks/vendor/plugins/acts_as_state_machine/Rakefile
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
require 'rake'
|
||||
require 'rake/testtask'
|
||||
require 'rake/rdoctask'
|
||||
|
||||
desc 'Default: run unit tests.'
|
||||
task :default => [:clean_db, :test]
|
||||
|
||||
desc 'Remove the stale db file'
|
||||
task :clean_db do
|
||||
`rm -f #{File.dirname(__FILE__)}/test/state_machine.sqlite.db`
|
||||
end
|
||||
|
||||
desc 'Test the acts as state machine plugin.'
|
||||
Rake::TestTask.new(:test) do |t|
|
||||
t.libs << 'lib'
|
||||
t.pattern = 'test/**/*_test.rb'
|
||||
t.verbose = true
|
||||
end
|
||||
|
||||
desc 'Generate documentation for the acts as state machine plugin.'
|
||||
Rake::RDocTask.new(:rdoc) do |rdoc|
|
||||
rdoc.rdoc_dir = 'rdoc'
|
||||
rdoc.title = 'Acts As State Machine'
|
||||
rdoc.options << '--line-numbers --inline-source'
|
||||
rdoc.rdoc_files.include('README')
|
||||
rdoc.rdoc_files.include('TODO')
|
||||
rdoc.rdoc_files.include('lib/**/*.rb')
|
||||
end
|
||||
11
tracks/vendor/plugins/acts_as_state_machine/TODO
vendored
Normal file
11
tracks/vendor/plugins/acts_as_state_machine/TODO
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
* Currently invalid events are ignored, create an option so that they can be
|
||||
ignored or raise an exception.
|
||||
|
||||
* Query for a list of possible next states.
|
||||
|
||||
* Make listing states optional since they can be inferred from the events.
|
||||
Only required to list a state if you want to define a transition block for it.
|
||||
|
||||
* Real transition actions
|
||||
|
||||
* Default states
|
||||
5
tracks/vendor/plugins/acts_as_state_machine/init.rb
vendored
Normal file
5
tracks/vendor/plugins/acts_as_state_machine/init.rb
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
require 'acts_as_state_machine'
|
||||
|
||||
ActiveRecord::Base.class_eval do
|
||||
include RailsStudio::Acts::StateMachine
|
||||
end
|
||||
222
tracks/vendor/plugins/acts_as_state_machine/lib/acts_as_state_machine.rb
vendored
Normal file
222
tracks/vendor/plugins/acts_as_state_machine/lib/acts_as_state_machine.rb
vendored
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
module RailsStudio #:nodoc:
|
||||
module Acts #:nodoc:
|
||||
module StateMachine #:nodoc:
|
||||
class InvalidState < Exception #:nodoc:
|
||||
end
|
||||
class NoInitialState < Exception #:nodoc:
|
||||
end
|
||||
|
||||
def self.included(base) #:nodoc:
|
||||
base.extend ActMacro
|
||||
end
|
||||
|
||||
module SupportingClasses
|
||||
class StateTransition
|
||||
attr_reader :from, :to
|
||||
def initialize(from, to, guard=nil)
|
||||
@from, @to, @guard = from, to, guard
|
||||
end
|
||||
|
||||
def guard(obj)
|
||||
@guard ? obj.send(:run_transition_action, @guard) : true
|
||||
end
|
||||
|
||||
def ==(obj)
|
||||
@from == obj.from && @to == obj.to
|
||||
end
|
||||
end
|
||||
|
||||
class TransitionCollector
|
||||
attr_reader :opts
|
||||
def transitions(opts)
|
||||
(@opts ||= []) << opts
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module ActMacro
|
||||
# Configuration options are
|
||||
#
|
||||
# * +column+ - specifies the column name to use for keeping the state (default: state)
|
||||
# * +initial+ - specifies an initial state for newly created objects (required)
|
||||
def acts_as_state_machine(opts)
|
||||
self.extend(ClassMethods)
|
||||
raise NoInitialState unless opts[:initial]
|
||||
|
||||
write_inheritable_attribute :states, {}
|
||||
write_inheritable_attribute :initial_state, opts[:initial]
|
||||
write_inheritable_attribute :transition_table, {}
|
||||
write_inheritable_attribute :state_column, opts[:column] || 'state'
|
||||
|
||||
class_inheritable_reader :initial_state
|
||||
class_inheritable_reader :state_column
|
||||
class_inheritable_reader :transition_table
|
||||
|
||||
class_eval "include RailsStudio::Acts::StateMachine::InstanceMethods"
|
||||
|
||||
before_create :set_initial_state
|
||||
end
|
||||
end
|
||||
|
||||
module InstanceMethods
|
||||
def set_initial_state #:nodoc:
|
||||
write_attribute self.class.state_column, self.class.initial_state.to_s
|
||||
end
|
||||
|
||||
# Returns the current state the object is in, as a Ruby symbol.
|
||||
def current_state
|
||||
self.send(self.class.state_column).to_sym
|
||||
end
|
||||
|
||||
# Returns what the next state for a given event would be, as a Ruby symbol.
|
||||
def next_state_for_event(event)
|
||||
ns = next_states_for_event(event)
|
||||
ns.empty? ? nil : ns.first.to
|
||||
end
|
||||
|
||||
def next_states_for_event(event)
|
||||
self.class.read_inheritable_attribute(:transition_table)[event.to_sym].select do |s|
|
||||
s.from == current_state
|
||||
end
|
||||
end
|
||||
|
||||
def run_transition_action(action)
|
||||
if action.kind_of?(Symbol)
|
||||
self.method(action).call
|
||||
else
|
||||
action.call(self)
|
||||
end
|
||||
end
|
||||
private :run_transition_action
|
||||
end
|
||||
|
||||
module ClassMethods
|
||||
# Returns an array of all known states.
|
||||
def states
|
||||
read_inheritable_attribute(:states).keys
|
||||
end
|
||||
|
||||
# Define an event. This takes a block which describes all valid transitions
|
||||
# for this event.
|
||||
#
|
||||
# Example:
|
||||
#
|
||||
# class Order < ActiveRecord::Base
|
||||
# acts_as_state_machine :initial => :open
|
||||
#
|
||||
# state :open
|
||||
# state :closed
|
||||
#
|
||||
# event :close_order do
|
||||
# transitions :to => :closed, :from => :open
|
||||
# end
|
||||
# end
|
||||
#
|
||||
# +transitions+ takes a hash where <tt>:to</tt> is the state to transition
|
||||
# to and <tt>:from</tt> is a state (or Array of states) from which this
|
||||
# event can be fired.
|
||||
#
|
||||
# This creates an instance method used for firing the event. The method
|
||||
# created is the name of the event followed by an exclamation point (!).
|
||||
# Example: <tt>order.close_order!</tt>.
|
||||
def event(event, &block)
|
||||
class_eval <<-EOV
|
||||
def #{event.to_s}!
|
||||
next_states = next_states_for_event(:#{event.to_s})
|
||||
next_states.each do |ns|
|
||||
if ns.guard(self)
|
||||
loopback = current_state == ns.to
|
||||
exitact = self.class.read_inheritable_attribute(:states)[current_state][:exit]
|
||||
enteract = self.class.read_inheritable_attribute(:states)[ns.to][:enter]
|
||||
|
||||
run_transition_action(enteract) if enteract && !loopback
|
||||
|
||||
self.update_attribute(self.class.state_column, ns.to.to_s)
|
||||
|
||||
run_transition_action(exitact) if exitact && !loopback
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
EOV
|
||||
|
||||
tt = read_inheritable_attribute(:transition_table)
|
||||
tt[event.to_sym] ||= []
|
||||
|
||||
if block_given?
|
||||
t = SupportingClasses::TransitionCollector.new
|
||||
t.instance_eval(&block)
|
||||
trannys = t.opts
|
||||
trannys.each do |tranny|
|
||||
Array(tranny[:from]).each do |s|
|
||||
tt[event.to_sym] << SupportingClasses::StateTransition.new(s.to_sym, tranny[:to], tranny[:guard])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def transitions(opts) #:nodoc:
|
||||
opts
|
||||
end
|
||||
|
||||
# Define a state of the system. +state+ can take an optional Proc object
|
||||
# which will be executed every time the system transitions into that
|
||||
# state. The proc will be passed the current object.
|
||||
#
|
||||
# Example:
|
||||
#
|
||||
# class Order < ActiveRecord::Base
|
||||
# acts_as_state_machine :initial => :open
|
||||
#
|
||||
# state :open
|
||||
# state :closed, Proc.new { |o| Mailer.send_notice(o) }
|
||||
# end
|
||||
def state(state, opts={})
|
||||
read_inheritable_attribute(:states)[state.to_sym] = opts
|
||||
|
||||
class_eval <<-EOS
|
||||
def #{state.to_s}?
|
||||
current_state == :#{state.to_s}
|
||||
end
|
||||
EOS
|
||||
end
|
||||
|
||||
# Wraps ActiveRecord::Base.find to conveniently find all records in
|
||||
# a given state. Options:
|
||||
#
|
||||
# * +number+ - This is just :first or :all from ActiveRecord
|
||||
# * +state+ - The state to find
|
||||
# * +args+ - The rest of the args are passed down to ActiveRecord +find+
|
||||
def find_in_state(number, state, *args)
|
||||
raise InvalidState unless states.include?(state)
|
||||
|
||||
options = args.last.is_a?(Hash) ? args.pop : {}
|
||||
if options[:conditions]
|
||||
options[:conditions].first << " AND #{self.state_column} = ?"
|
||||
options[:conditions] << state.to_s
|
||||
else
|
||||
options[:conditions] = ["#{self.state_column} = ?", state.to_s]
|
||||
end
|
||||
self.find(number, options)
|
||||
end
|
||||
|
||||
# Wraps ActiveRecord::Base.count to conveniently count all records in
|
||||
# a given state. Options:
|
||||
#
|
||||
# * +state+ - The state to find
|
||||
# * +args+ - The rest of the args are passed down to ActiveRecord +find+
|
||||
def count_in_state(state, conditions=nil)
|
||||
raise InvalidState unless states.include?(state)
|
||||
|
||||
if conditions
|
||||
conditions.first << " AND #{self.state_column} = ?"
|
||||
conditions << state.to_s
|
||||
else
|
||||
conditions = ["#{self.state_column} = ?", state.to_s]
|
||||
end
|
||||
self.count(conditions)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
174
tracks/vendor/plugins/acts_as_state_machine/test/acts_as_state_machine_test.rb
vendored
Normal file
174
tracks/vendor/plugins/acts_as_state_machine/test/acts_as_state_machine_test.rb
vendored
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
require File.dirname(__FILE__) + '/test_helper'
|
||||
|
||||
include RailsStudio::Acts::StateMachine
|
||||
|
||||
class ActsAsStateMachineTest < Test::Unit::TestCase
|
||||
fixtures :conversations
|
||||
|
||||
def test_no_initial_value_raises_exception
|
||||
assert_raise(NoInitialState) {
|
||||
Person.acts_as_state_machine({})
|
||||
}
|
||||
end
|
||||
|
||||
def test_initial_state_value
|
||||
assert_equal :needs_attention, Conversation.initial_state
|
||||
end
|
||||
|
||||
def test_column_was_set
|
||||
assert_equal 'state_machine', Conversation.state_column
|
||||
end
|
||||
|
||||
def test_initial_state
|
||||
c = Conversation.create
|
||||
assert_equal :needs_attention, c.current_state
|
||||
assert c.needs_attention?
|
||||
end
|
||||
|
||||
def test_states_were_set
|
||||
[:needs_attention, :read, :closed, :awaiting_response, :junk].each do |s|
|
||||
assert Conversation.states.include?(s)
|
||||
end
|
||||
end
|
||||
|
||||
def test_event_methods_created
|
||||
c = Conversation.create
|
||||
%w(new_message! view! reply! close! junk! unjunk!).each do |event|
|
||||
assert c.respond_to?(event)
|
||||
end
|
||||
end
|
||||
|
||||
def test_query_methods_created
|
||||
c = Conversation.create
|
||||
%w(needs_attention? read? closed? awaiting_response? junk?).each do |event|
|
||||
assert c.respond_to?(event)
|
||||
end
|
||||
end
|
||||
|
||||
def test_transition_table
|
||||
tt = Conversation.transition_table
|
||||
|
||||
assert tt[:new_message].include?(SupportingClasses::StateTransition.new(:read, :needs_attention))
|
||||
assert tt[:new_message].include?(SupportingClasses::StateTransition.new(:closed, :needs_attention))
|
||||
assert tt[:new_message].include?(SupportingClasses::StateTransition.new(:awaiting_response, :needs_attention))
|
||||
end
|
||||
|
||||
def test_next_state_for_event
|
||||
c = Conversation.create
|
||||
assert_equal :read, c.next_state_for_event(:view)
|
||||
end
|
||||
|
||||
def test_change_state
|
||||
c = Conversation.create
|
||||
c.view!
|
||||
assert c.read?
|
||||
end
|
||||
|
||||
def test_can_go_from_read_to_closed_because_guard_passes
|
||||
c = Conversation.create
|
||||
c.can_close = true
|
||||
c.view!
|
||||
c.reply!
|
||||
c.close!
|
||||
assert_equal :closed, c.current_state
|
||||
end
|
||||
|
||||
def test_cannot_go_from_read_to_closed_because_of_guard
|
||||
c = Conversation.create
|
||||
c.can_close = false
|
||||
c.view!
|
||||
c.reply!
|
||||
c.close!
|
||||
assert_equal :read, c.current_state
|
||||
end
|
||||
|
||||
def test_ignore_invalid_events
|
||||
c = Conversation.create
|
||||
c.view!
|
||||
c.junk!
|
||||
|
||||
# This is the invalid event
|
||||
c.new_message!
|
||||
assert_equal :junk, c.current_state
|
||||
end
|
||||
|
||||
def test_entry_action_executed
|
||||
c = Conversation.create
|
||||
c.read_enter = false
|
||||
c.view!
|
||||
assert c.read_enter
|
||||
end
|
||||
|
||||
def test_exit_action_executed
|
||||
c = Conversation.create
|
||||
c.read_exit = false
|
||||
c.view!
|
||||
c.junk!
|
||||
assert c.read_exit
|
||||
end
|
||||
|
||||
def test_entry_and_exit_not_run_on_loopback_transition
|
||||
c = Conversation.create
|
||||
c.view!
|
||||
c.read_enter = false
|
||||
c.read_exit = false
|
||||
c.view!
|
||||
assert !c.read_enter
|
||||
assert !c.read_exit
|
||||
end
|
||||
|
||||
def test_run_transition_action_is_private
|
||||
c = Conversation.create
|
||||
assert_raise(NoMethodError) { c.run_transition_action :foo }
|
||||
end
|
||||
|
||||
def test_find_all_in_state
|
||||
cs = Conversation.find_in_state(:all, :read)
|
||||
|
||||
assert_equal 2, cs.size
|
||||
end
|
||||
|
||||
def test_find_first_in_state
|
||||
c = Conversation.find_in_state(:first, :read)
|
||||
|
||||
assert_equal conversations(:first).id, c.id
|
||||
end
|
||||
|
||||
def test_find_all_in_state_with_conditions
|
||||
cs = Conversation.find_in_state(:all, :read, :conditions => ['subject = ?', conversations(:second).subject])
|
||||
|
||||
assert_equal 1, cs.size
|
||||
assert_equal conversations(:second).id, cs.first.id
|
||||
end
|
||||
|
||||
def test_find_first_in_state_with_conditions
|
||||
c = Conversation.find_in_state(:first, :read, :conditions => ['subject = ?', conversations(:second).subject])
|
||||
assert_equal conversations(:second).id, c.id
|
||||
end
|
||||
|
||||
def test_count_in_state
|
||||
cnt0 = Conversation.count(['state_machine = ?', 'read'])
|
||||
cnt = Conversation.count_in_state(:read)
|
||||
|
||||
assert_equal cnt0, cnt
|
||||
end
|
||||
|
||||
def test_count_in_state_with_conditions
|
||||
cnt0 = Conversation.count(['state_machine = ? AND subject = ?', 'read', 'Foo'])
|
||||
cnt = Conversation.count_in_state(:read, ['subject = ?', 'Foo'])
|
||||
|
||||
assert_equal cnt0, cnt
|
||||
end
|
||||
|
||||
def test_find_in_invalid_state_raises_exception
|
||||
assert_raise(InvalidState) {
|
||||
Conversation.find_in_state(:all, :dead)
|
||||
}
|
||||
end
|
||||
|
||||
def test_count_in_invalid_state_raises_exception
|
||||
assert_raise(InvalidState) {
|
||||
Conversation.count_in_state(:dead)
|
||||
}
|
||||
end
|
||||
end
|
||||
18
tracks/vendor/plugins/acts_as_state_machine/test/database.yml
vendored
Normal file
18
tracks/vendor/plugins/acts_as_state_machine/test/database.yml
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
sqlite:
|
||||
:adapter: sqlite
|
||||
:dbfile: state_machine.sqlite.db
|
||||
sqlite3:
|
||||
:adapter: sqlite3
|
||||
:dbfile: state_machine.sqlite3.db
|
||||
postgresql:
|
||||
:adapter: postgresql
|
||||
:username: postgres
|
||||
:password: postgres
|
||||
:database: state_machine_test
|
||||
:min_messages: ERROR
|
||||
mysql:
|
||||
:adapter: mysql
|
||||
:host: localhost
|
||||
:username: rails
|
||||
:password:
|
||||
:database: state_machine_test
|
||||
50
tracks/vendor/plugins/acts_as_state_machine/test/fixtures/conversation.rb
vendored
Normal file
50
tracks/vendor/plugins/acts_as_state_machine/test/fixtures/conversation.rb
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
class Conversation < ActiveRecord::Base
|
||||
attr_writer :can_close
|
||||
attr_accessor :read_enter, :read_exit
|
||||
|
||||
acts_as_state_machine :initial => :needs_attention, :column => 'state_machine'
|
||||
|
||||
state :needs_attention
|
||||
state :read, :enter => :read_enter_action,
|
||||
:exit => Proc.new { |o| o.read_exit = true }
|
||||
state :closed
|
||||
state :awaiting_response
|
||||
state :junk
|
||||
|
||||
event :new_message do
|
||||
transitions :to => :needs_attention, :from => [:read, :closed, :awaiting_response]
|
||||
end
|
||||
|
||||
event :view do
|
||||
transitions :to => :read, :from => [:needs_attention, :read]
|
||||
end
|
||||
|
||||
event :reply do
|
||||
transitions :to => :awaiting_response, :from => [:read, :closed]
|
||||
end
|
||||
|
||||
event :close do
|
||||
transitions :to => :closed, :from => [:read, :awaiting_response], :guard => Proc.new {|o| o.can_close?}
|
||||
transitions :to => :read, :from => [:read, :awaiting_response], :guard => :always_true
|
||||
end
|
||||
|
||||
event :junk do
|
||||
transitions :to => :junk, :from => [:read, :closed, :awaiting_response]
|
||||
end
|
||||
|
||||
event :unjunk do
|
||||
transitions :to => :closed, :from => :junk
|
||||
end
|
||||
|
||||
def can_close?
|
||||
@can_close
|
||||
end
|
||||
|
||||
def read_enter_action
|
||||
self.read_enter = true
|
||||
end
|
||||
|
||||
def always_true
|
||||
true
|
||||
end
|
||||
end
|
||||
11
tracks/vendor/plugins/acts_as_state_machine/test/fixtures/conversations.yml
vendored
Normal file
11
tracks/vendor/plugins/acts_as_state_machine/test/fixtures/conversations.yml
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
first:
|
||||
id: 1
|
||||
state_machine: read
|
||||
subject: This is a test
|
||||
closed: false
|
||||
|
||||
second:
|
||||
id: 2
|
||||
state_machine: read
|
||||
subject: Foo
|
||||
closed: false
|
||||
2
tracks/vendor/plugins/acts_as_state_machine/test/fixtures/person.rb
vendored
Normal file
2
tracks/vendor/plugins/acts_as_state_machine/test/fixtures/person.rb
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
class Person < ActiveRecord::Base
|
||||
end
|
||||
11
tracks/vendor/plugins/acts_as_state_machine/test/schema.rb
vendored
Normal file
11
tracks/vendor/plugins/acts_as_state_machine/test/schema.rb
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
ActiveRecord::Schema.define(:version => 1) do
|
||||
create_table :conversations do |t|
|
||||
t.column :state_machine, :string
|
||||
t.column :subject, :string
|
||||
t.column :closed, :boolean
|
||||
end
|
||||
|
||||
create_table :people do |t|
|
||||
t.column :name, :string
|
||||
end
|
||||
end
|
||||
38
tracks/vendor/plugins/acts_as_state_machine/test/test_helper.rb
vendored
Normal file
38
tracks/vendor/plugins/acts_as_state_machine/test/test_helper.rb
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
$:.unshift(File.dirname(__FILE__) + '/../lib')
|
||||
RAILS_ROOT = File.dirname(__FILE__)
|
||||
|
||||
require 'rubygems'
|
||||
require 'test/unit'
|
||||
require 'active_record'
|
||||
require 'active_record/fixtures'
|
||||
require 'active_support/binding_of_caller'
|
||||
require 'active_support/breakpoint'
|
||||
require "#{File.dirname(__FILE__)}/../init"
|
||||
|
||||
|
||||
config = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml'))
|
||||
ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__) + "/debug.log")
|
||||
ActiveRecord::Base.establish_connection(config[ENV['DB'] || 'sqlite'])
|
||||
|
||||
load(File.dirname(__FILE__) + "/schema.rb") if File.exist?(File.dirname(__FILE__) + "/schema.rb")
|
||||
|
||||
Test::Unit::TestCase.fixture_path = File.dirname(__FILE__) + "/fixtures/"
|
||||
$LOAD_PATH.unshift(Test::Unit::TestCase.fixture_path)
|
||||
|
||||
class Test::Unit::TestCase #:nodoc:
|
||||
def create_fixtures(*table_names)
|
||||
if block_given?
|
||||
Fixtures.create_fixtures(Test::Unit::TestCase.fixture_path, table_names) { yield }
|
||||
else
|
||||
Fixtures.create_fixtures(Test::Unit::TestCase.fixture_path, table_names)
|
||||
end
|
||||
end
|
||||
|
||||
# Turn off transactional fixtures if you're working with MyISAM tables in MySQL
|
||||
self.use_transactional_fixtures = true
|
||||
|
||||
# Instantiated fixtures are slow, but give you @david where you otherwise would need people(:david)
|
||||
self.use_instantiated_fixtures = false
|
||||
|
||||
# Add more helper methods to be used by all tests here...
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue