├── lib ├── tasks │ ├── .gitkeep │ └── fulcrum.rake └── validators │ ├── estimate_validator.rb │ └── belongs_to_project_validator.rb ├── vendor └── plugins │ └── .gitkeep ├── app ├── helpers │ ├── users_helper.rb │ ├── application_helper.rb │ ├── stories_helper.rb │ └── projects_helper.rb ├── views │ ├── notifications │ │ ├── accepted.text.erb │ │ ├── rejected.text.erb │ │ └── delivered.text.erb │ ├── projects │ │ ├── new.html.erb │ │ ├── edit.html.erb │ │ ├── _nav.html.erb │ │ ├── index.html.erb │ │ ├── _form.html.erb │ │ └── show.html.erb │ ├── devise │ │ ├── mailer │ │ │ ├── confirmation_instructions.html.erb │ │ │ ├── unlock_instructions.html.erb │ │ │ └── reset_password_instructions.html.erb │ │ ├── unlocks │ │ │ └── new.html.erb │ │ ├── passwords │ │ │ ├── new.html.erb │ │ │ └── edit.html.erb │ │ ├── confirmations │ │ │ └── new.html.erb │ │ ├── sessions │ │ │ └── new.html.erb │ │ ├── registrations │ │ │ ├── new.html.erb │ │ │ └── edit.html.erb │ │ └── shared │ │ │ └── _links.erb │ ├── public_view │ │ └── projects │ │ │ ├── index.html.erb │ │ │ └── show.html.erb │ ├── users │ │ └── index.html.erb │ ├── stories │ │ ├── _story.html.erb │ │ ├── _form.html.erb │ │ └── import.html.erb │ └── layouts │ │ ├── public_view.html.erb │ │ └── application.html.erb ├── models │ ├── ability.rb │ ├── changeset.rb │ ├── user.rb │ ├── project.rb │ ├── story_observer.rb │ └── story.rb ├── stylesheets │ ├── screen_changes.scss │ ├── _scaffold.scss │ ├── _jquery_gritter.scss │ └── locomotive.scss ├── controllers │ ├── changesets_controller.rb │ ├── public_view │ │ ├── changesets_controller.rb │ │ ├── base_controller.rb │ │ ├── projects_controller.rb │ │ └── stories_controller.rb │ ├── confirmations_controller.rb │ ├── application_controller.rb │ ├── users_controller.rb │ ├── projects_controller.rb │ └── stories_controller.rb └── mailers │ └── notifications.rb ├── test ├── fixtures │ └── csv │ │ ├── stories_illegal.csv │ │ └── stories_invalid.csv ├── unit │ ├── helpers │ │ ├── users_helper_test.rb │ │ ├── stories_helper_test.rb │ │ └── projects_helper_test.rb │ ├── changeset_test.rb │ ├── user_test.rb │ └── project_test.rb ├── performance │ └── browsing_test.rb ├── factories.rb ├── functional │ ├── confirmations_controller_test.rb │ ├── changesets_controller_test.rb │ ├── projects_controller_test.rb │ ├── notifications_test.rb │ └── users_controller_test.rb └── test_helper.rb ├── config ├── initializers │ ├── fulcrum.rb │ ├── require_csv.rb │ ├── mime_types.rb │ ├── compass.rb │ ├── sendgrid.rb │ ├── inflections.rb │ ├── backtrace_silencers.rb │ ├── session_store.rb │ ├── secret_token.rb │ └── devise.rb ├── environment.rb ├── boot.rb ├── locales │ ├── en.yml │ └── devise.en.yml ├── compass.rb ├── routes.rb ├── environments │ ├── development.rb │ ├── test.rb │ └── production.rb ├── database.yml.example └── application.rb ├── public ├── favicon.ico ├── images │ ├── bug.png │ ├── logo.png │ ├── redo.png │ ├── chore.png │ ├── expand.png │ ├── rails.png │ ├── collapse.png │ ├── details.png │ ├── feature.png │ ├── gritter.png │ ├── ie-spacer.gif │ ├── iteration.png │ ├── release.png │ ├── throbber.gif │ ├── dialog-warning.png │ ├── gritter-long.png │ ├── locomotive │ │ ├── title.jpg │ │ ├── favicon.ico │ │ ├── main_bg.png │ │ └── header_bg.png │ └── dialog-information.png ├── javascripts │ ├── models │ │ ├── user.js │ │ ├── iteration.js │ │ ├── story.js │ │ └── project.js │ ├── application.js │ ├── collections │ │ ├── user_collection.js │ │ └── story_collection.js │ ├── fulcrum.js │ ├── jquery.scrollTo-min.js │ ├── backbone.rails.js │ ├── views │ │ ├── form_view.js │ │ └── app_view.js │ ├── jquery.gritter.min.js │ ├── jquery.tmpl.min.js │ └── rails.js ├── Fulcrum_Import_Template.csv ├── robots.txt ├── 403.html ├── 422.html ├── 404.html └── 500.html ├── .gitignore ├── .travis.yml ├── config.ru ├── doc ├── README_FOR_APP └── logo.svg ├── db ├── migrate │ ├── 20110502100528_add_position_to_stories.rb │ ├── 20110908212947_add_description_to_projects.rb │ ├── 20110908171341_add_public_view_to_projects.rb │ ├── 20110316082355_create_projects_users.rb │ ├── 20110706094137_create_changesets.rb │ ├── 20110627083011_add_name_and_initials_to_users.rb │ ├── 20110210091929_create_projects.rb │ ├── 20110412083637_create_stories.rb │ └── 20110210082458_devise_create_users.rb ├── seeds.rb └── schema.rb ├── Rakefile ├── spec └── javascripts │ ├── helpers │ └── SpecHelper.js │ ├── support │ ├── jasmine_config.rb │ ├── jasmine_runner.rb │ ├── jasmine-sinon.js │ ├── jasmine.yml │ └── jasmine-jquery-1.2.0.js │ ├── collections │ ├── user_collection.spec.js │ └── story_collection.spec.js │ └── models │ └── iteration.spec.js ├── script └── rails ├── csvin.rb ├── Gemfile ├── Gemfile.lock └── README.md /lib/tasks/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/plugins/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/helpers/users_helper.rb: -------------------------------------------------------------------------------- 1 | module UsersHelper 2 | end 3 | -------------------------------------------------------------------------------- /test/fixtures/csv/stories_illegal.csv: -------------------------------------------------------------------------------- 1 | illegal quoting"' 2 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /config/initializers/fulcrum.rb: -------------------------------------------------------------------------------- 1 | ActiveSupport.use_standard_json_time_format = false -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/did/fulcrum/master/public/favicon.ico -------------------------------------------------------------------------------- /public/images/bug.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/did/fulcrum/master/public/images/bug.png -------------------------------------------------------------------------------- /public/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/did/fulcrum/master/public/images/logo.png -------------------------------------------------------------------------------- /public/images/redo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/did/fulcrum/master/public/images/redo.png -------------------------------------------------------------------------------- /public/images/chore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/did/fulcrum/master/public/images/chore.png -------------------------------------------------------------------------------- /public/images/expand.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/did/fulcrum/master/public/images/expand.png -------------------------------------------------------------------------------- /public/images/rails.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/did/fulcrum/master/public/images/rails.png -------------------------------------------------------------------------------- /public/images/collapse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/did/fulcrum/master/public/images/collapse.png -------------------------------------------------------------------------------- /public/images/details.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/did/fulcrum/master/public/images/details.png -------------------------------------------------------------------------------- /public/images/feature.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/did/fulcrum/master/public/images/feature.png -------------------------------------------------------------------------------- /public/images/gritter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/did/fulcrum/master/public/images/gritter.png -------------------------------------------------------------------------------- /public/images/ie-spacer.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/did/fulcrum/master/public/images/ie-spacer.gif -------------------------------------------------------------------------------- /public/images/iteration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/did/fulcrum/master/public/images/iteration.png -------------------------------------------------------------------------------- /public/images/release.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/did/fulcrum/master/public/images/release.png -------------------------------------------------------------------------------- /public/images/throbber.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/did/fulcrum/master/public/images/throbber.gif -------------------------------------------------------------------------------- /public/javascripts/models/user.js: -------------------------------------------------------------------------------- 1 | var User = Backbone.Model.extend({ 2 | name: 'user', 3 | }); 4 | -------------------------------------------------------------------------------- /public/images/dialog-warning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/did/fulcrum/master/public/images/dialog-warning.png -------------------------------------------------------------------------------- /public/images/gritter-long.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/did/fulcrum/master/public/images/gritter-long.png -------------------------------------------------------------------------------- /public/images/locomotive/title.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/did/fulcrum/master/public/images/locomotive/title.jpg -------------------------------------------------------------------------------- /public/images/dialog-information.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/did/fulcrum/master/public/images/dialog-information.png -------------------------------------------------------------------------------- /public/images/locomotive/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/did/fulcrum/master/public/images/locomotive/favicon.ico -------------------------------------------------------------------------------- /public/images/locomotive/main_bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/did/fulcrum/master/public/images/locomotive/main_bg.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config/database.yml 2 | .bundle 3 | db/*.sqlite3 4 | log/*.log 5 | tmp/**/* 6 | .rvmrc 7 | public/stylesheets/* 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | rvm: 2 | - 1.8.7 3 | - 1.9.2 4 | script: "bundle exec rake fulcrum:setup db:drop db:create db:migrate test" 5 | -------------------------------------------------------------------------------- /public/images/locomotive/header_bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/did/fulcrum/master/public/images/locomotive/header_bg.png -------------------------------------------------------------------------------- /test/unit/helpers/users_helper_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class UsersHelperTest < ActionView::TestCase 4 | end 5 | -------------------------------------------------------------------------------- /test/unit/helpers/stories_helper_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class StoriesHelperTest < ActionView::TestCase 4 | end 5 | -------------------------------------------------------------------------------- /app/views/notifications/accepted.text.erb: -------------------------------------------------------------------------------- 1 | <%= @accepted_by.name %> has accepted the story '<%= @story.title %>'. 2 | 3 | <%= project_url @story.project %> 4 | -------------------------------------------------------------------------------- /app/views/notifications/rejected.text.erb: -------------------------------------------------------------------------------- 1 | <%= @accepted_by.name %> has rejected the story '<%= @story.title %>'. 2 | 3 | <%= project_url @story.project %> 4 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require ::File.expand_path('../config/environment', __FILE__) 4 | run Fulcrum::Application 5 | -------------------------------------------------------------------------------- /app/views/projects/new.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :title_bar do %> 2 |

New project

3 | <% end %> 4 | 5 | <%= render 'form' %> 6 | 7 | <%= link_to 'Back', projects_path %> 8 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the rails application 2 | require File.expand_path('../application', __FILE__) 3 | 4 | # Initialize the rails application 5 | Fulcrum::Application.initialize! 6 | -------------------------------------------------------------------------------- /public/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // Place your application-specific JavaScript functions and classes here 2 | // This file is automatically included by javascript_include_tag :defaults 3 | -------------------------------------------------------------------------------- /public/Fulcrum_Import_Template.csv: -------------------------------------------------------------------------------- 1 | Story,Labels,Iteration,Iteration Start,Iteration End,Story Type,Estimate,Current State,Created at,Accepted at,Deadline,Requested By,Owned By,Description,URL,Note 2 | -------------------------------------------------------------------------------- /app/models/ability.rb: -------------------------------------------------------------------------------- 1 | class Ability 2 | include CanCan::Ability 3 | 4 | def initialize(user) 5 | user ||= User.new 6 | 7 | can :manage, Project, :id => user.project_ids 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/views/projects/edit.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :title_bar do %> 2 | <%= render :partial => 'projects/nav', :locals => {:project => @project} %> 3 | <% end %> 4 |

Edit project

5 | 6 | <%= render 'form' %> 7 | -------------------------------------------------------------------------------- /config/initializers/require_csv.rb: -------------------------------------------------------------------------------- 1 | require 'csv' 2 | if CSV.const_defined? :Reader 3 | require 'fastercsv' 4 | Object.send(:remove_const, :CSV) 5 | CSV = FasterCSV 6 | # CSV is now FasterCSV in ruby 1.9 7 | end 8 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | 3 | # Set up gems listed in the Gemfile. 4 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 5 | 6 | require 'bundler/setup' if File.exists?(ENV['BUNDLE_GEMFILE']) 7 | -------------------------------------------------------------------------------- /app/views/notifications/delivered.text.erb: -------------------------------------------------------------------------------- 1 | <%= @delivered_by.name %> has delivered your story '<%= @story.title %>'. 2 | 3 | You can now review the story, and either accept or reject it. 4 | 5 | <%= project_url @story.project %> 6 | -------------------------------------------------------------------------------- /doc/README_FOR_APP: -------------------------------------------------------------------------------- 1 | Use this README file to introduce your application and point to useful places in the API for learning more. 2 | Run "rake doc:app" to generate API documentation for your models, controllers, helpers, and libraries. 3 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/wc/norobots.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-Agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /app/helpers/stories_helper.rb: -------------------------------------------------------------------------------- 1 | module StoriesHelper 2 | def state_transition_button(story, state) 3 | path = send("#{state}_project_story_path", story.project, story) 4 | button_to(state, path, :method => :put, :class => state) 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | # Mime::Type.register_alias "text/html", :iphone 6 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Sample localization file for English. Add more files in this directory for other locales. 2 | # See http://github.com/svenfuchs/rails-i18n/tree/master/rails%2Flocale for starting points. 3 | 4 | en: 5 | hello: "Hello world" 6 | -------------------------------------------------------------------------------- /app/stylesheets/screen_changes.scss: -------------------------------------------------------------------------------- 1 | div.story { 2 | input[name=title] { 3 | width: 89%; 4 | } 5 | 6 | div:last-child { 7 | margin-right: 6px; 8 | } 9 | 10 | textarea { 11 | margin: 0px; 12 | width: 100%; 13 | height: 50px; 14 | } 15 | } -------------------------------------------------------------------------------- /db/migrate/20110502100528_add_position_to_stories.rb: -------------------------------------------------------------------------------- 1 | class AddPositionToStories < ActiveRecord::Migration 2 | def self.up 3 | add_column :stories, :position, :decimal 4 | end 5 | 6 | def self.down 7 | remove_column :stories, :position 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20110908212947_add_description_to_projects.rb: -------------------------------------------------------------------------------- 1 | class AddDescriptionToProjects < ActiveRecord::Migration 2 | def self.up 3 | add_column :projects, :description, :text 4 | end 5 | 6 | def self.down 7 | remove_column :projects, :description 8 | end 9 | end -------------------------------------------------------------------------------- /public/javascripts/collections/user_collection.js: -------------------------------------------------------------------------------- 1 | var UserCollection = Backbone.Collection.extend({ 2 | model: User, 3 | 4 | forSelect: function() { 5 | return this.map(function(user) { 6 | return [user.get('name'),user.id]; 7 | }); 8 | } 9 | }); 10 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require File.expand_path('../config/application', __FILE__) 5 | require 'rake' 6 | 7 | Fulcrum::Application.load_tasks 8 | -------------------------------------------------------------------------------- /db/migrate/20110908171341_add_public_view_to_projects.rb: -------------------------------------------------------------------------------- 1 | class AddPublicViewToProjects < ActiveRecord::Migration 2 | def self.up 3 | add_column :projects, :public_view, :boolean, :default => false 4 | end 5 | 6 | def self.down 7 | remove_column :projects, :public_view 8 | end 9 | end -------------------------------------------------------------------------------- /app/views/devise/mailer/confirmation_instructions.html.erb: -------------------------------------------------------------------------------- 1 |

Welcome <%= @resource.email %>!

2 | 3 |

You can confirm your account through the link below:

4 | 5 |

<%= link_to 'Confirm my account', confirmation_url(@resource, :confirmation_token => @resource.confirmation_token) %>

6 | -------------------------------------------------------------------------------- /spec/javascripts/helpers/SpecHelper.js: -------------------------------------------------------------------------------- 1 | beforeEach(function() { 2 | this.addMatchers({ 3 | toBePlaying: function(expectedSong) { 4 | var player = this.actual; 5 | return player.currentlyPlayingSong === expectedSong 6 | && player.isPlaying; 7 | } 8 | }) 9 | }); 10 | -------------------------------------------------------------------------------- /test/performance/browsing_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'rails/performance_test_help' 3 | 4 | # Profiling results for each test method are written to tmp/performance. 5 | class BrowsingTest < ActionDispatch::PerformanceTest 6 | def test_homepage 7 | get '/' 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /config/initializers/compass.rb: -------------------------------------------------------------------------------- 1 | # http://devcenter.heroku.com/articles/using-compass 2 | 3 | require 'fileutils' 4 | FileUtils.mkdir_p(Rails.root.join("tmp", "stylesheets")) 5 | 6 | Rails.configuration.middleware.insert_before('Rack::Sendfile', 'Rack::Static', 7 | :urls => ['/stylesheets'], 8 | :root => "#{Rails.root}/tmp") 9 | -------------------------------------------------------------------------------- /script/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby1.8 2 | # This command will automatically be run when you run "rails" with Rails 3 gems installed from the root of your application. 3 | 4 | APP_PATH = File.expand_path('../../config/application', __FILE__) 5 | require File.expand_path('../../config/boot', __FILE__) 6 | require 'rails/commands' 7 | -------------------------------------------------------------------------------- /db/migrate/20110316082355_create_projects_users.rb: -------------------------------------------------------------------------------- 1 | class CreateProjectsUsers < ActiveRecord::Migration 2 | def self.up 3 | create_table :projects_users, :id => false do |t| 4 | t.references :project 5 | t.references :user 6 | end 7 | end 8 | 9 | def self.down 10 | drop_table :projects_users 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /db/migrate/20110706094137_create_changesets.rb: -------------------------------------------------------------------------------- 1 | class CreateChangesets < ActiveRecord::Migration 2 | def self.up 3 | create_table :changesets do |t| 4 | t.references :story 5 | t.references :project 6 | 7 | t.timestamps 8 | end 9 | end 10 | 11 | def self.down 12 | drop_table :changesets 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/views/projects/_nav.html.erb: -------------------------------------------------------------------------------- 1 |

<%= project.name %>

2 | 3 | <%= link_to_unless_current 'Stories', project_path(project) %> | 4 | <%= link_to_unless_current 'Members', project_users_path(project) %> | 5 | <%= link_to_unless_current 'Edit', edit_project_path(project) %> | 6 | <%= link_to_unless_current 'Import', import_project_stories_path(project) %> 7 | -------------------------------------------------------------------------------- /db/migrate/20110627083011_add_name_and_initials_to_users.rb: -------------------------------------------------------------------------------- 1 | class AddNameAndInitialsToUsers < ActiveRecord::Migration 2 | def self.up 3 | add_column :users, :name, :string 4 | add_column :users, :initials, :string 5 | end 6 | 7 | def self.down 8 | remove_column :users, :initials 9 | remove_column :users, :name 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/views/devise/mailer/unlock_instructions.html.erb: -------------------------------------------------------------------------------- 1 |

Hello <%= @resource.email %>!

2 | 3 |

Your account has been locked due to an excessive amount of unsuccessful sign in attempts.

4 | 5 |

Click the link below to unlock your account:

6 | 7 |

<%= link_to 'Unlock my account', unlock_url(@resource, :unlock_token => @resource.unlock_token) %>

8 | -------------------------------------------------------------------------------- /lib/tasks/fulcrum.rake: -------------------------------------------------------------------------------- 1 | namespace :fulcrum do 2 | desc "Set up database yaml." 3 | task :setup do 4 | example_file = Rails.root.join('config',"database.yml.example") 5 | file = Rails.root.join('config',"database.yml") 6 | 7 | unless File.exists?(file) 8 | sh "cp #{example_file} #{file}" 9 | else 10 | puts "#{file} already exists!" 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /config/initializers/sendgrid.rb: -------------------------------------------------------------------------------- 1 | # Configure Heroku to use SendGrid. 2 | if ENV['SENDGRID_USERNAME'] 3 | ActionMailer::Base.smtp_settings = { 4 | :address => "smtp.sendgrid.net", 5 | :port => "25", 6 | :authentication => :plain, 7 | :user_name => ENV['SENDGRID_USERNAME'], 8 | :password => ENV['SENDGRID_PASSWORD'], 9 | :domain => ENV['SENDGRID_DOMAIN'] 10 | } 11 | end 12 | -------------------------------------------------------------------------------- /lib/validators/estimate_validator.rb: -------------------------------------------------------------------------------- 1 | class EstimateValidator < ActiveModel::EachValidator 2 | # Checks that the estimate being validated is valid for record.project 3 | def validate_each(record, attribute, value) 4 | if record.project 5 | unless record.project.point_values.include?(value) 6 | record.errors[attribute] << "is not an allowed value for this project" 7 | end 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/controllers/changesets_controller.rb: -------------------------------------------------------------------------------- 1 | class ChangesetsController < ApplicationController 2 | def index 3 | @project = current_user.projects.find(params[:project_id]) 4 | scope = @project.changesets.scoped 5 | scope = scope.where('id <= ?', params[:to]) if params[:to] 6 | scope = scope.where('id > ?', params[:from]) if params[:from] 7 | @changesets = scope 8 | render :json => @changesets 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format 4 | # (all these examples are active by default): 5 | # ActiveSupport::Inflector.inflections do |inflect| 6 | # inflect.plural /^(ox)$/i, '\1en' 7 | # inflect.singular /^(ox)en/i, '\1' 8 | # inflect.irregular 'person', 'people' 9 | # inflect.uncountable %w( fish sheep ) 10 | # end 11 | -------------------------------------------------------------------------------- /config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Fulcrum::Application.config.session_store :cookie_store, :key => '_fulcrum_session' 4 | 5 | # Use the database for sessions instead of the cookie-based default, 6 | # which shouldn't be used to store highly confidential information 7 | # (create the session table with "rails generate session_migration") 8 | # Fulcrum::Application.config.session_store :active_record_store 9 | -------------------------------------------------------------------------------- /app/controllers/public_view/changesets_controller.rb: -------------------------------------------------------------------------------- 1 | module PublicView 2 | class ChangesetsController < BaseController 3 | 4 | before_filter :fetch_project 5 | 6 | def index 7 | scope = @project.changesets.scoped 8 | scope = scope.where('id <= ?', params[:to]) if params[:to] 9 | scope = scope.where('id > ?', params[:from]) if params[:from] 10 | @changesets = scope 11 | render :json => @changesets 12 | end 13 | 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/controllers/public_view/base_controller.rb: -------------------------------------------------------------------------------- 1 | class PublicView::BaseController < ApplicationController 2 | 3 | skip_before_filter :authenticate_user! 4 | 5 | before_filter :fetch_public_projects 6 | 7 | layout 'public_view' 8 | 9 | protected 10 | 11 | def fetch_public_projects 12 | @projects ||= Project.with_public_view 13 | end 14 | 15 | def fetch_project 16 | @project ||= self.fetch_public_projects.find(params[:project_id] || params[:id]) 17 | end 18 | 19 | end 20 | -------------------------------------------------------------------------------- /db/migrate/20110210091929_create_projects.rb: -------------------------------------------------------------------------------- 1 | class CreateProjects < ActiveRecord::Migration 2 | def self.up 3 | create_table :projects do |t| 4 | t.string :name 5 | t.string :point_scale, :default => 'fibonacci' 6 | t.date :start_date 7 | t.integer :iteration_start_day, :default => 1 8 | t.integer :iteration_length, :default => 1 9 | 10 | t.timestamps 11 | end 12 | end 13 | 14 | def self.down 15 | drop_table :projects 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/validators/belongs_to_project_validator.rb: -------------------------------------------------------------------------------- 1 | class BelongsToProjectValidator < ActiveModel::EachValidator 2 | # Checks that the parameter being validated represents a User#id that 3 | # is a member of the object.project 4 | def validate_each(record, attribute, value) 5 | if record.project && !value.nil? 6 | unless record.project.user_ids.include?(value) 7 | record.errors[attribute] << "user is not a member of this project" 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/views/devise/mailer/reset_password_instructions.html.erb: -------------------------------------------------------------------------------- 1 |

Hello <%= @resource.email %>!

2 | 3 |

Someone has requested a link to change your password, and you can do this through the link below.

4 | 5 |

<%= link_to 'Change my password', edit_password_url(@resource, :reset_password_token => @resource.reset_password_token) %>

6 | 7 |

If you didn't request this, please ignore this email.

8 |

Your password won't change until you access the link above and create a new one.

9 | -------------------------------------------------------------------------------- /app/views/devise/unlocks/new.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :title_bar do %> 2 |

Resend unlock instructions

3 | <% end %> 4 | 5 | <%= form_for(resource, :as => resource_name, :url => unlock_path(resource_name), :html => { :method => :post }) do |f| %> 6 | <%= devise_error_messages! %> 7 | 8 |

<%= f.label :email %>
9 | <%= f.email_field :email %>

10 | 11 |

<%= f.submit "Resend unlock instructions" %>

12 | <% end %> 13 | 14 | <%= render :partial => "devise/shared/links" %> 15 | -------------------------------------------------------------------------------- /app/views/devise/passwords/new.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :title_bar do %> 2 |

Forgot your password?

3 | <% end %> 4 | 5 | <%= form_for(resource, :as => resource_name, :url => password_path(resource_name), :html => { :method => :post }) do |f| %> 6 | <%= devise_error_messages! %> 7 | 8 |

<%= f.label :email %>
9 | <%= f.email_field :email %>

10 | 11 |

<%= f.submit "Send me reset password instructions" %>

12 | <% end %> 13 | 14 | <%= render :partial => "devise/shared/links" %> 15 | -------------------------------------------------------------------------------- /app/views/devise/confirmations/new.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :title_bar do %> 2 |

Resend confirmation instructions

3 | <% end %> 4 | 5 | <%= form_for(resource, :as => resource_name, :url => confirmation_path(resource_name), :html => { :method => :post }) do |f| %> 6 | <%= devise_error_messages! %> 7 | 8 |

<%= f.label :email %>
9 | <%= f.email_field :email %>

10 | 11 |

<%= f.submit "Resend confirmation instructions" %>

12 | <% end %> 13 | 14 | <%= render :partial => "devise/shared/links" %> 15 | -------------------------------------------------------------------------------- /app/controllers/confirmations_controller.rb: -------------------------------------------------------------------------------- 1 | class ConfirmationsController < Devise::ConfirmationsController 2 | # GET /resource/confirmation?confirmation_token=abcdef 3 | def show 4 | self.resource = resource_class.confirm_by_token(params[:confirmation_token]) 5 | 6 | if resource.errors.empty? 7 | set_flash_message :notice, :confirmed 8 | redirect_to edit_user_password_path(:reset_password_token => resource.reset_password_token) 9 | else 10 | render_with_scope :new 11 | end 12 | end 13 | 14 | end -------------------------------------------------------------------------------- /config/initializers/secret_token.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | # Make sure the secret is at least 30 characters and all random, 6 | # no regular words or you'll be exposed to dictionary attacks. 7 | Fulcrum::Application.config.secret_token = '2f0d0a139bff42c286f5f4af2910d03aebd461f7d72bab018d6d9095f7f578c04b2f8041923eeb62e2246efbaf7468c3e93f65f3fa007f06b95ff867a52330bd' 8 | -------------------------------------------------------------------------------- /test/fixtures/csv/stories_invalid.csv: -------------------------------------------------------------------------------- 1 | Id,Story,Labels,Iteration,Iteration Start,Iteration End,Story Type,Estimate,Current State,Created at,Accepted at,Deadline,Requested By,Owned By,Description,URL,Note 2 | 1817033,Valid story,"",1,"Nov 23, 2009","Nov 29, 2009",bug,,accepted,"Nov 27, 2009","Nov 28, 2009",,Malcolm Locke,Malcolm Locke,,http://www.pivotaltracker.com/story/show/1817033 3 | 1816991,This story has an invalid estimate and type,"",2,"Nov 30, 2009","Dec 6, 2009",flunk,123,accepted,"Nov 27, 2009","Dec 1, 2009",,Malcolm Locke,Malcolm Locke,,http://www.pivotaltracker.com/story/show/1816991 4 | -------------------------------------------------------------------------------- /db/migrate/20110412083637_create_stories.rb: -------------------------------------------------------------------------------- 1 | class CreateStories < ActiveRecord::Migration 2 | def self.up 3 | create_table :stories do |t| 4 | t.string :title 5 | t.text :description 6 | t.integer :estimate 7 | t.string :story_type, :default => 'feature' 8 | t.string :state, :default => 'unstarted' 9 | t.date :accepted_at 10 | t.integer :requested_by_id 11 | t.integer :owned_by_id 12 | t.references :project 13 | 14 | t.timestamps 15 | end 16 | end 17 | 18 | def self.down 19 | drop_table :stories 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/views/devise/sessions/new.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :title_bar do %> 2 |

Sign in

3 | <% end %> 4 | 5 | <%= form_for(resource, :as => resource_name, :url => session_path(resource_name)) do |f| %> 6 |

<%= f.label :email %>
7 | <%= f.email_field :email %>

8 | 9 |

<%= f.label :password %>
10 | <%= f.password_field :password %>

11 | 12 | <% if devise_mapping.rememberable? -%> 13 |

<%= f.check_box :remember_me %> <%= f.label :remember_me %>

14 | <% end -%> 15 | 16 |

<%= f.submit "Sign in" %>

17 | <% end %> 18 | 19 | <%= render :partial => "devise/shared/links" %> 20 | -------------------------------------------------------------------------------- /spec/javascripts/support/jasmine_config.rb: -------------------------------------------------------------------------------- 1 | module Jasmine 2 | class Config 3 | 4 | # Add your overrides or custom config code here 5 | 6 | end 7 | end 8 | 9 | 10 | # Note - this is necessary for rspec2, which has removed the backtrace 11 | module Jasmine 12 | class SpecBuilder 13 | def declare_spec(parent, spec) 14 | me = self 15 | example_name = spec["name"] 16 | @spec_ids << spec["id"] 17 | backtrace = @example_locations[parent.description + " " + example_name] 18 | parent.it example_name, {} do 19 | me.report_spec(spec["id"]) 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/javascripts/collections/user_collection.spec.js: -------------------------------------------------------------------------------- 1 | describe('UserCollection collection', function() { 2 | 3 | beforeEach(function() { 4 | var User = Backbone.Model.extend({ 5 | name: 'user', 6 | }); 7 | 8 | this.users = new UserCollection(); 9 | this.users.add(new User({id: 1, name: 'User 1'})); 10 | this.users.add(new User({id: 2, name: 'User 2'})); 11 | }); 12 | 13 | describe("utility methods", function() { 14 | 15 | it("should return an array for a select control", function() { 16 | expect(this.users.forSelect()).toEqual([['User 1',1],['User 2',2]]); 17 | }); 18 | 19 | }); 20 | 21 | }); 22 | -------------------------------------------------------------------------------- /test/factories.rb: -------------------------------------------------------------------------------- 1 | Factory.define :user do |u| 2 | u.sequence(:name) {|n| "User #{n}"} 3 | u.sequence(:initials) {|n| "U#{n}"} 4 | u.sequence(:email) {|n| "user#{n}@example.com"} 5 | u.password 'password' 6 | u.password_confirmation 'password' 7 | u.after_build {|user| user.confirm!} 8 | end 9 | 10 | Factory.define :project do |p| 11 | p.name 'Test Project' 12 | end 13 | 14 | Factory.define :story do |s| 15 | s.title 'Test story' 16 | s.association :requested_by, :factory => :user 17 | s.association :project 18 | end 19 | 20 | Factory.define :changeset do |c| 21 | c.association :story 22 | c.association :project 23 | end 24 | -------------------------------------------------------------------------------- /app/models/changeset.rb: -------------------------------------------------------------------------------- 1 | class Changeset < ActiveRecord::Base 2 | belongs_to :project 3 | belongs_to :story 4 | 5 | validates :project_id, :presence => true 6 | validates :story_id, :presence => true 7 | 8 | before_validation :assign_project_from_story 9 | 10 | default_scope order(:id) 11 | 12 | scope :since, lambda {|since_id| where("id > ?", since_id)} 13 | 14 | protected 15 | 16 | # If project_id is not already set, it can be inferred from the stories 17 | # project_id 18 | def assign_project_from_story 19 | if project_id.nil? && !story_id.nil? 20 | self.project_id = story.project_id 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /csvin.rb: -------------------------------------------------------------------------------- 1 | require 'csv' 2 | if CSV.const_defined? :Reader 3 | require 'fastercsv' 4 | Object.send(:remove_const, :CSV) 5 | CSV = FasterCSV 6 | # CSV is now FasterCSV in ruby 1.9 7 | end 8 | 9 | project_id = 1 10 | 11 | project = Project.find(project_id) 12 | user = User.first 13 | 14 | project.stories.destroy_all 15 | 16 | csv = CSV.parse(STDIN.read, :headers => true) 17 | csv.each do |row| 18 | row = row.to_hash 19 | project.stories.create!(:state => row["Current State"], :title => row["Story"], 20 | :story_type => row["Story Type"], :requested_by => user, 21 | :estimate => row["Estimate"]) 22 | end 23 | -------------------------------------------------------------------------------- /app/views/devise/passwords/edit.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :title_bar do %> 2 |

Change your password

3 | <% end %> 4 | 5 | <%= form_for(resource, :as => resource_name, :url => password_path(resource_name), :html => { :method => :put }) do |f| %> 6 | <%= devise_error_messages! %> 7 | <%= f.hidden_field :reset_password_token %> 8 | 9 |

<%= f.label :password, "New password" %>
10 | <%= f.password_field :password %>

11 | 12 |

<%= f.label :password_confirmation, "Confirm new password" %>
13 | <%= f.password_field :password_confirmation %>

14 | 15 |

<%= f.submit "Change my password" %>

16 | <% end %> 17 | 18 | <%= render :partial => "devise/shared/links" %> 19 | -------------------------------------------------------------------------------- /public/403.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Forbidden (403) 5 | 17 | 18 | 19 | 20 |
21 |

Forbidden

22 |

You are not authorized to access this resource.

23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /public/javascripts/fulcrum.js: -------------------------------------------------------------------------------- 1 | $(function() { 2 | $('#add_story').click(function() { 3 | window.Project.stories.add([{ 4 | title: "New story", events: [], editing: true 5 | }]); 6 | 7 | var newStoryElement = $('#chilly_bin div.story:last'); 8 | $('#chilly_bin').scrollTo(newStoryElement, 100); 9 | }); 10 | 11 | $('div.sortable').sortable({ 12 | handle: '.story-title', opacity: 0.6, 13 | 14 | items: ".story", 15 | 16 | update: function(ev, ui) { 17 | ui.item.trigger("sortupdate", ev, ui); 18 | } 19 | 20 | }); 21 | 22 | $('#backlog').sortable('option', 'connectWith', '#chilly_bin'); 23 | $('#chilly_bin').sortable('option', 'connectWith', '#backlog'); 24 | }); 25 | -------------------------------------------------------------------------------- /app/controllers/public_view/projects_controller.rb: -------------------------------------------------------------------------------- 1 | module PublicView 2 | class ProjectsController < BaseController 3 | 4 | # GET /public_projects 5 | # GET /public_projects.xml 6 | def index 7 | respond_to do |format| 8 | format.html # index.html.erb 9 | format.xml { render :xml => @projects } 10 | end 11 | end 12 | 13 | # GET /public_projects/1 14 | # GET /public_projects/1.xml 15 | def show 16 | @project = self.fetch_public_projects.find(params[:id]) 17 | 18 | respond_to do |format| 19 | format.html 20 | format.js { render :json => @project } 21 | format.xml { render :xml => @project } 22 | end 23 | end 24 | 25 | end 26 | end -------------------------------------------------------------------------------- /app/views/public_view/projects/index.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :title_bar do %> 2 |

Listing projects

3 | <% end %> 4 | 5 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | protect_from_forgery 3 | 4 | before_filter :authenticate_user! 5 | 6 | # Handle unauthorized access with a good old fashioned 'forbidden' 7 | rescue_from CanCan::AccessDenied do |exception| 8 | render :file => "#{Rails.root}/public/403.html", :status => :forbidden 9 | end 10 | 11 | rescue_from ActiveRecord::RecordNotFound, :with => :render_404 12 | 13 | protected 14 | def render_404 15 | respond_to do |format| 16 | format.html do 17 | render :file => Rails.root.join('public', '404.html'), 18 | :status => '404' 19 | end 20 | format.xml do 21 | render :nothing => true, :status => '404' 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/functional/confirmations_controller_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ConfirmationsControllerTest < ActionController::TestCase 4 | include Devise::TestHelpers 5 | 6 | def setup 7 | @request.env['devise.mapping'] = Devise.mappings[:user] 8 | end 9 | test "should be able to change password after confirmation" do 10 | user = Factory.create(:user, :reset_password_token => 'fdsedf4343334ik3hhudfug') 11 | user.confirmed_at = nil 12 | user.confirmation_token = Devise.friendly_token 13 | user.confirmation_sent_at = Time.now.utc 14 | user.save(:validate => false) 15 | 16 | get :show, :confirmation_token => user.confirmation_token 17 | assert_redirected_to(edit_user_password_path(:reset_password_token => user.reset_password_token)) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/helpers/projects_helper.rb: -------------------------------------------------------------------------------- 1 | module ProjectsHelper 2 | # Returns an array of valid project point scales suitable for 3 | # use in a select helper. 4 | def point_scale_options 5 | Project::POINT_SCALES.collect do |name,values| 6 | ["#{name.humanize} (#{values.join(',')})", name] 7 | end 8 | end 9 | 10 | # Returns an array of valid iteration length options suitable for use in 11 | # a select helper. 12 | def iteration_length_options 13 | (1..4).collect do |weeks| 14 | [pluralize(weeks, "week"), weeks] 15 | end 16 | end 17 | 18 | # Returns an array of day name options suitable for use in 19 | # a select helper. The values are 0 to 6, with 0 being Sunday. 20 | def day_name_options 21 | DateTime::DAYNAMES.each_with_index.collect{|name,i| [name,i]} 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/views/devise/registrations/new.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :title_bar do %> 2 |

Sign up

3 | <% end %> 4 | 5 | <%= form_for(resource, :as => resource_name, :url => registration_path(resource_name)) do |f| %> 6 | <%= devise_error_messages! %> 7 | 8 |

<%= f.label :name %>
9 | <%= f.text_field :name %>

10 | 11 |

<%= f.label :initials %>
12 | <%= f.text_field :initials %>

13 | 14 |

<%= f.label :email %>
15 | <%= f.email_field :email %>

16 | 17 |

<%= f.label :password %>
18 | <%= f.password_field :password %>

19 | 20 |

<%= f.label :password_confirmation %>
21 | <%= f.password_field :password_confirmation %>

22 | 23 |

<%= f.submit "Sign up" %>

24 | <% end %> 25 | 26 | <%= render :partial => "devise/shared/links" %> 27 | -------------------------------------------------------------------------------- /app/controllers/public_view/stories_controller.rb: -------------------------------------------------------------------------------- 1 | module PublicView 2 | class StoriesController < BaseController 3 | 4 | include ActionView::Helpers::TextHelper 5 | 6 | before_filter :fetch_project 7 | 8 | def index 9 | @stories = @project.stories 10 | render :json => @stories 11 | end 12 | 13 | def show 14 | @story = @project.stories.find(params[:id]) 15 | render :json => @story 16 | end 17 | 18 | def done 19 | @stories = @project.stories.done 20 | render :json => @stories 21 | end 22 | 23 | def backlog 24 | @stories = @project.stories.backlog 25 | render :json => @stories 26 | end 27 | 28 | def in_progress 29 | @stories = @project.stories.in_progress 30 | render :json => @stories 31 | end 32 | 33 | end 34 | 35 | end -------------------------------------------------------------------------------- /public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 17 | 18 | 19 | 20 | 21 |
22 |

The change you wanted was rejected.

23 |

Maybe you tried to change something you didn't have access to.

24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /db/migrate/20110210082458_devise_create_users.rb: -------------------------------------------------------------------------------- 1 | class DeviseCreateUsers < ActiveRecord::Migration 2 | def self.up 3 | create_table(:users) do |t| 4 | t.database_authenticatable :null => false 5 | t.recoverable 6 | t.rememberable 7 | t.trackable 8 | t.confirmable 9 | t.encryptable 10 | # t.lockable :lock_strategy => :failed_attempts, :unlock_strategy => :both 11 | # t.token_authenticatable 12 | 13 | 14 | t.timestamps 15 | end 16 | 17 | add_index :users, :email, :unique => true 18 | add_index :users, :reset_password_token, :unique => true 19 | add_index :users, :confirmation_token, :unique => true 20 | # add_index :users, :unlock_token, :unique => true 21 | end 22 | 23 | def self.down 24 | drop_table :users 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 17 | 18 | 19 | 20 | 21 |
22 |

The page you were looking for doesn't exist.

23 |

You may have mistyped the address or the page may have moved.

24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 17 | 18 | 19 | 20 | 21 |
22 |

We're sorry, but something went wrong.

23 |

We've been notified about this issue and we'll take a look at it shortly.

24 |
25 | 26 | 27 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | ENV["RAILS_ENV"] = "test" 2 | require File.expand_path('../../config/environment', __FILE__) 3 | require 'rails/test_help' 4 | 5 | class ActiveSupport::TestCase 6 | # Setup all fixtures in test/fixtures/*.(yml|csv) for all tests in alphabetical order. 7 | # 8 | # Note: You'll currently still have to declare fixtures explicitly in integration tests 9 | # -- they do not yet inherit this setting 10 | #fixtures :all 11 | 12 | # Add more helper methods to be used by all tests here... 13 | 14 | # Check the passed object returns the passed hash from it's as_json 15 | # method. 16 | def assert_returns_json(attrs, object) 17 | wrapper = object.class.name.underscore 18 | assert_equal(attrs.sort, object.as_json[wrapper].keys.sort) 19 | end 20 | end 21 | 22 | class ActionController::TestCase 23 | include Devise::TestHelpers 24 | end 25 | -------------------------------------------------------------------------------- /test/unit/helpers/projects_helper_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ProjectsHelperTest < ActionView::TestCase 4 | def setup 5 | @project = Factory.create(:project) 6 | end 7 | 8 | test "should return point scale options for select" do 9 | # Should be an array of arrays 10 | assert_instance_of Array, point_scale_options 11 | point_scale_options.each do |option| 12 | assert_instance_of Array, option 13 | end 14 | end 15 | 16 | test "should return iteration length options for select" do 17 | assert_instance_of Array, iteration_length_options 18 | iteration_length_options.each do |option| 19 | assert_instance_of Array, option 20 | end 21 | end 22 | 23 | test "should return day name options for select" do 24 | assert_instance_of Array, day_name_options 25 | day_name_options.each do |option| 26 | assert_instance_of Array, option 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/javascripts/support/jasmine_runner.rb: -------------------------------------------------------------------------------- 1 | $:.unshift(ENV['JASMINE_GEM_PATH']) if ENV['JASMINE_GEM_PATH'] # for gem testing purposes 2 | 3 | require 'rubygems' 4 | require 'jasmine' 5 | jasmine_config_overrides = File.expand_path(File.join(File.dirname(__FILE__), 'jasmine_config.rb')) 6 | require jasmine_config_overrides if File.exist?(jasmine_config_overrides) 7 | if Jasmine::rspec2? 8 | require 'rspec' 9 | else 10 | require 'spec' 11 | end 12 | 13 | jasmine_config = Jasmine::Config.new 14 | spec_builder = Jasmine::SpecBuilder.new(jasmine_config) 15 | 16 | should_stop = false 17 | 18 | if Jasmine::rspec2? 19 | RSpec.configuration.after(:suite) do 20 | spec_builder.stop if should_stop 21 | end 22 | else 23 | Spec::Runner.configure do |config| 24 | config.after(:suite) do 25 | spec_builder.stop if should_stop 26 | end 27 | end 28 | end 29 | 30 | spec_builder.start 31 | should_stop = true 32 | spec_builder.declare_suites -------------------------------------------------------------------------------- /db/seeds.rb: -------------------------------------------------------------------------------- 1 | # Create a user 2 | 3 | user = User.create! :name => 'Test User', :initials => 'TU', 4 | :email => 'test@example.com', :password => 'testpass' 5 | user.confirm! 6 | 7 | project = Project.create! :name => 'Test Project', :users => [user] 8 | 9 | project.stories.create! :title => "A user should be able to create features", 10 | :story_type => 'feature', :requested_by => user 11 | project.stories.create! :title => "A user should be able to create bugs", 12 | :story_type => 'bug', :requested_by => user 13 | project.stories.create! :title => "A user should be able to create chores", 14 | :story_type => 'chore', :requested_by => user 15 | project.stories.create! :title => "A user should be able to create releases", 16 | :story_type => 'release', :requested_by => user 17 | project.stories.create! :title => "A user should be able to estimate features", 18 | :story_type => 'feature', :requested_by => user, :estimate => 1 19 | -------------------------------------------------------------------------------- /app/views/projects/index.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :title_bar do %> 2 |

Listing projects

3 | <% end %> 4 | 5 | 23 | 24 | <%= link_to 'New Project', new_project_path, :class => :button %> 25 | -------------------------------------------------------------------------------- /app/mailers/notifications.rb: -------------------------------------------------------------------------------- 1 | class Notifications < ActionMailer::Base 2 | 3 | def delivered(story, delivered_by) 4 | @story = story 5 | @delivered_by = delivered_by 6 | 7 | mail :to => story.requested_by.email, :from => delivered_by.email, 8 | :subject => "[#{story.project.name}] Your story '#{story.title}' has been delivered for acceptance." 9 | end 10 | 11 | def accepted(story, accepted_by) 12 | @story = story 13 | @accepted_by = accepted_by 14 | 15 | mail :to => story.owned_by.email, :from => accepted_by.email, 16 | :subject => "[#{story.project.name}] #{accepted_by.name} ACCEPTED your story '#{story.title}'." 17 | end 18 | 19 | def rejected(story, rejected_by) 20 | @story = story 21 | @accepted_by = rejected_by 22 | 23 | mail :to => story.owned_by.email, :from => rejected_by.email, 24 | :subject => "[#{story.project.name}] #{rejected_by.name} REJECTED your story '#{story.title}'." 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/views/users/index.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :title_bar do %> 2 | <%= render :partial => 'projects/nav', :locals => {:project => @project} %> 3 | <% end %> 4 |

Project members

5 | 12 |

Add a new member

13 | <%= form_for project_users_path(@project, @user) do |f| %> 14 | <% fields_for :user do |u| %> 15 |

16 | <%= u.label :email %>
17 | <%= u.text_field :email %> 18 |

19 |

20 | <%= u.label :name %>
21 | <%= u.text_field :name %> 22 |

23 |

24 | <%= u.label :initials %>
25 | <%= u.text_field :initials %> 26 |

27 |

28 | <%= u.submit 'Add user' %> 29 |

30 | <% end %> 31 | <% end %> 32 | -------------------------------------------------------------------------------- /test/unit/changeset_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ChangesetTest < ActiveSupport::TestCase 4 | setup do 5 | @user = Factory.create(:user) 6 | @project = Factory.create(:project, :users => [@user]) 7 | @story = Factory.create(:story, :project => @project, 8 | :requested_by => @user) 9 | @changeset = Factory.create(:changeset, :story => @story, 10 | :project => @project) 11 | end 12 | 13 | test "should not save without a story" do 14 | @changeset.story = nil 15 | assert !@changeset.save 16 | end 17 | 18 | test "should determine project from story" do 19 | # If project is not set, it can be inferred from story.project 20 | @changeset.project = nil 21 | assert @changeset.save! 22 | assert_equal @project, @changeset.project 23 | end 24 | 25 | test "should get changesets since a given id" do 26 | assert_equal 'id > 234', Changeset.since(234).where_values.first 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/views/stories/_story.html.erb: -------------------------------------------------------------------------------- 1 | <%= div_for story, :class => story.story_type do %> 2 | 3 | <% if story.estimable? %> 4 |
5 | <% story.project.point_values.each do |points| %> 6 | <%= points %> 7 | <% end %> 8 |
9 | <% else %> 10 | <% unless story.events.empty? %> 11 |
12 | <% story.events.each do |state| %> 13 | 14 | <%= state_transition_button(story, state) %> 15 | <% end %> 16 |
17 | <% end %> 18 | <% end %> 19 |
20 | <%= image_tag 'expand.png', :class => 'expand' %> 21 | <%= image_tag "#{story.story_type}.png", :class => 'story_type' %> 22 | <% if story.estimated? %> 23 | <%= story.estimate %> 24 | <% end %> 25 |
26 |
<%= story %>
27 | <% end %> 28 | -------------------------------------------------------------------------------- /test/functional/changesets_controller_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ChangesetsControllerTest < ActionController::TestCase 4 | setup do 5 | @user = Factory.create(:user) 6 | @project = Factory.create(:project, :users => [@user]) 7 | @story = Factory.create(:story, :project => @project, 8 | :requested_by => @user) 9 | @changeset = Factory.create(:changeset, :story => @story, 10 | :project => @project) 11 | end 12 | 13 | test "should get all in json format" do 14 | sign_in @user 15 | get :index, :project_id => @project.to_param, :format => 'json' 16 | assert_response :success 17 | assert_equal @project.changesets, assigns(:changesets) 18 | end 19 | 20 | test "should get in json format with from and to params" do 21 | sign_in @user 22 | get :index, :project_id => @project.to_param, :format => 'json', 23 | :from => @changeset.id - 1, :to => @changeset.id 24 | assert_response :success 25 | assert_equal [@changeset], assigns(:changesets) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /config/compass.rb: -------------------------------------------------------------------------------- 1 | # This configuration file works with both the Compass command line tool and within Rails. 2 | # Require any additional compass plugins here. 3 | project_type = :rails 4 | 5 | # Set this to the root of your project when deployed: 6 | http_path = "/" 7 | 8 | # You can select your preferred output style here (can be overridden via the command line): 9 | # output_style = :expanded or :nested or :compact or :compressed 10 | 11 | # To enable relative paths to assets via compass helper functions. Uncomment: 12 | relative_assets = true 13 | 14 | # To disable debugging comments that display the original location of your selectors. Uncomment: 15 | # line_comments = false 16 | 17 | # http://devcenter.heroku.com/articles/using-compass 18 | css_dir = 'tmp/stylesheets' 19 | sass_dir = 'app/stylesheets' 20 | 21 | # If you prefer the indented syntax, you might want to regenerate this 22 | # project again passing --syntax sass, or you can uncomment this: 23 | # preferred_syntax = :sass 24 | # and then run: 25 | # sass-convert -R --from scss --to sass app/stylesheets scss && rm -rf sass && mv scss sass 26 | -------------------------------------------------------------------------------- /test/unit/user_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class UserTest < ActiveSupport::TestCase 4 | def setup 5 | @user = Factory.create(:user) 6 | end 7 | 8 | test "should return a string" do 9 | assert_equal "#{@user.name} (#{@user.initials}) <#{@user.email}>", @user.to_s 10 | end 11 | 12 | test "should set a flag if user created by dynamic finder" do 13 | user = User.find_or_create_by_email(@user.email) do |u| 14 | u.was_created = true 15 | end 16 | assert !user.was_created 17 | 18 | user = User.find_or_create_by_email('non_existent@example.com') do |u| 19 | u.was_created = true 20 | end 21 | assert user.was_created 22 | end 23 | 24 | test "should not save a user without a name" do 25 | @user.name = '' 26 | assert !@user.save 27 | end 28 | 29 | test "should not save a user without initials" do 30 | @user.initials = '' 31 | assert !@user.save 32 | end 33 | 34 | test "should return json" do 35 | attrs = ["id", "name", "initials", "email"] 36 | 37 | assert_equal(attrs.count, @user.as_json['user'].keys.count) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /app/stylesheets/_scaffold.scss: -------------------------------------------------------------------------------- 1 | body { background-color: #fff; color: #333; } 2 | 3 | body, p, ol, ul, td { 4 | font-family: verdana, arial, helvetica, sans-serif; 5 | font-size: 13px; 6 | line-height: 18px; 7 | } 8 | 9 | pre { 10 | background-color: #eee; 11 | padding: 10px; 12 | font-size: 11px; 13 | } 14 | 15 | a { color: #000; } 16 | a:visited { color: #666; } 17 | 18 | div.field, div.actions { 19 | margin-bottom: 10px; 20 | } 21 | 22 | #notice { 23 | color: green; 24 | } 25 | 26 | .field_with_errors { 27 | padding: 2px; 28 | background-color: red; 29 | display: table; 30 | } 31 | 32 | #error_explanation { 33 | width: 450px; 34 | border: 2px solid red; 35 | padding: 7px; 36 | padding-bottom: 0; 37 | margin-bottom: 20px; 38 | background-color: #f0f0f0; 39 | } 40 | 41 | #error_explanation h2 { 42 | text-align: left; 43 | font-weight: bold; 44 | padding: 5px 5px 5px 15px; 45 | font-size: 12px; 46 | margin: -7px; 47 | margin-bottom: 0px; 48 | background-color: #c00; 49 | color: #fff; 50 | } 51 | 52 | #error_explanation ul li { 53 | font-size: 12px; 54 | list-style: square; 55 | } 56 | -------------------------------------------------------------------------------- /public/javascripts/collections/story_collection.js: -------------------------------------------------------------------------------- 1 | var StoryCollection = Backbone.Collection.extend({ 2 | model: Story, 3 | 4 | initialize: function() { 5 | this.bind('change:position', this.sort); 6 | this.bind('change:state', this.sort); 7 | this.bind('change:estimate', this.sort); 8 | }, 9 | 10 | comparator: function(story) { 11 | return story.position(); 12 | }, 13 | 14 | next: function(story) { 15 | return this.at(this.indexOf(story) + 1); 16 | }, 17 | 18 | previous: function(story) { 19 | return this.at(this.indexOf(story) - 1); 20 | }, 21 | 22 | // Returns all the stories in the named column, either #done, #in_progress, 23 | // #backlog or #chilly_bin 24 | column: function(column) { 25 | return this.select(function(story) { 26 | return story.column() == column; 27 | }); 28 | }, 29 | 30 | // Returns an array of the stories in a set of columns. Pass an array 31 | // of the column names accepted by column(). 32 | columns: function(columns) { 33 | var that = this; 34 | return _.flatten(_.map(columns, function(column) { 35 | return that.column(column); 36 | })); 37 | } 38 | }); 39 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Fulcrum::Application.routes.draw do 2 | 3 | get 'story/new' 4 | 5 | resources :projects do 6 | resources :users, :only => [:index, :create, :destroy] 7 | resources :changesets, :only => [:index] 8 | resources :stories, :only => [:index, :create, :update, :destroy, :show] do 9 | collection do 10 | get :done 11 | get :in_progress 12 | get :backlog 13 | get :import 14 | post :import_upload 15 | end 16 | member do 17 | put :start 18 | put :finish 19 | put :deliver 20 | put :accept 21 | put :reject 22 | end 23 | end 24 | end 25 | 26 | namespace :public_view do 27 | root :to => 'projects#index' 28 | 29 | resources :projects do 30 | resources :changesets, :only => [:index] 31 | resources :stories, :only => [:index, :show] do 32 | collection do 33 | get :done 34 | get :in_progress 35 | get :backlog 36 | end 37 | end 38 | end 39 | end 40 | 41 | devise_for :users, :controllers => { :confirmations => "confirmations" } 42 | 43 | 44 | root :to => "public_view/projects#index" 45 | end 46 | -------------------------------------------------------------------------------- /app/views/devise/shared/_links.erb: -------------------------------------------------------------------------------- 1 | <%- if controller_name != 'sessions' %> 2 | <%= link_to "Sign in", new_session_path(resource_name) %>
3 | <% end -%> 4 | 5 | <%- if devise_mapping.registerable? && controller_name != 'registrations' %> 6 | <%= link_to "Sign up", new_registration_path(resource_name) %>
7 | <% end -%> 8 | 9 | <%- if devise_mapping.recoverable? && controller_name != 'passwords' %> 10 | <%= link_to "Forgot your password?", new_password_path(resource_name) %>
11 | <% end -%> 12 | 13 | <%- if devise_mapping.confirmable? && controller_name != 'confirmations' %> 14 | <%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) %>
15 | <% end -%> 16 | 17 | <%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %> 18 | <%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %>
19 | <% end -%> 20 | 21 | <%- if devise_mapping.omniauthable? %> 22 | <%- resource_class.omniauth_providers.each do |provider| %> 23 | <%= link_to "Sign in with #{provider.to_s.titleize}", omniauth_authorize_path(resource_name, provider) %>
24 | <% end -%> 25 | <% end -%> -------------------------------------------------------------------------------- /config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Fulcrum::Application.configure do 2 | # Settings specified here will take precedence over those in config/environment.rb 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the webserver when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Log error messages when you accidentally call methods on nil. 10 | config.whiny_nils = true 11 | 12 | # Show full error reports and disable caching 13 | config.consider_all_requests_local = true 14 | config.action_view.debug_rjs = true 15 | config.action_controller.perform_caching = false 16 | 17 | # Don't care if the mailer can't send 18 | config.action_mailer.perform_deliveries = false 19 | config.action_mailer.raise_delivery_errors = false 20 | config.action_mailer.default_url_options = { :host => '127.0.0.1:3000' } 21 | 22 | # Print deprecation notices to the Rails logger 23 | config.active_support.deprecation = :log 24 | 25 | # Only use best-standards-support built into browsers 26 | config.action_dispatch.best_standards_support = :builtin 27 | end 28 | 29 | -------------------------------------------------------------------------------- /app/views/devise/registrations/edit.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :title_bar do %> 2 |

Edit <%= resource_name.to_s.humanize %>

3 | <% end %> 4 | 5 | <%= form_for(resource, :as => resource_name, :url => registration_path(resource_name), :html => { :method => :put }) do |f| %> 6 | <%= devise_error_messages! %> 7 | 8 |

<%= f.label :name %>
9 | <%= f.text_field :name %>

10 | 11 |

<%= f.label :initials %>
12 | <%= f.text_field :initials %>

13 | 14 |

<%= f.label :email %>
15 | <%= f.email_field :email %>

16 | 17 |

<%= f.label :password %> (leave blank if you don't want to change it)
18 | <%= f.password_field :password %>

19 | 20 |

<%= f.label :password_confirmation %>
21 | <%= f.password_field :password_confirmation %>

22 | 23 |

<%= f.label :current_password %> (we need your current password to confirm your changes)
24 | <%= f.password_field :current_password %>

25 | 26 |

<%= f.submit "Update" %>

27 | <% end %> 28 | 29 |

Cancel my account

30 | 31 |

Unhappy? <%= link_to "Cancel my account", registration_path(resource_name), :confirm => "Are you sure?", :method => :delete %>.

32 | 33 | <%= link_to "Back", :back %> 34 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | 3 | gem 'rails', '3.0.9' 4 | 5 | # Bundle edge Rails instead: 6 | # gem 'rails', :git => 'git://github.com/rails/rails.git' 7 | 8 | # SQLite 9 | gem 'sqlite3-ruby', :require => 'sqlite3' 10 | 11 | # MySQL 12 | # gem 'mysql2', '~> 0.2.6' 13 | 14 | # PostgreSQL 15 | # gem 'pg' 16 | 17 | gem 'devise', '1.2.1' 18 | gem 'cancan', '1.6.1' 19 | gem 'transitions', '0.0.9', :require => ["transitions", "active_record/transitions"] 20 | 21 | gem 'fastercsv', '1.5.3', :platforms => :ruby_18 22 | gem 'compass', '>= 0.11.5' 23 | # (using standard csv lib if ruby version is 1.9) 24 | 25 | # Use unicorn as the web server 26 | # gem 'unicorn' 27 | 28 | # Deploy with Capistrano 29 | # gem 'capistrano' 30 | 31 | # To use debugger 32 | # gem 'ruby-debug' 33 | 34 | # Bundle the extra gems: 35 | # gem 'bj' 36 | # gem 'nokogiri' 37 | # gem 'sqlite3-ruby', :require => 'sqlite3' 38 | # gem 'aws-s3', :require => 'aws/s3' 39 | 40 | # Bundle gems for the local environment. Make sure to 41 | # put test-only gems in this group so their generators 42 | # and rake tasks are available in development mode: 43 | # group :development, :test do 44 | # gem 'webrat' 45 | # end 46 | 47 | group :development, :test do 48 | gem 'factory_girl_rails' 49 | gem 'jasmine' 50 | end 51 | -------------------------------------------------------------------------------- /spec/javascripts/support/jasmine-sinon.js: -------------------------------------------------------------------------------- 1 | (function(global) { 2 | 3 | var spyMatchers = "called calledOnce calledTwice calledThrice calledBefore calledAfter calledOn alwaysCalledOn calledWith alwaysCalledWith calledWithExactly alwaysCalledWithExactly".split(" "), 4 | i = spyMatchers.length, 5 | spyMatcherHash = {}, 6 | unusualMatchers = { 7 | "returned": "toHaveReturned", 8 | "alwaysReturned": "toHaveAlwaysReturned" 9 | }, 10 | 11 | getMatcherFunction = function(sinonName) { 12 | return function() { 13 | var sinonProperty = this.actual[sinonName]; 14 | return (typeof sinonProperty === 'function') ? sinonProperty.apply(this.actual, arguments) : sinonProperty; 15 | }; 16 | }; 17 | 18 | while(i--) { 19 | var sinonName = spyMatchers[i], 20 | matcherName = "toHaveBeen" + sinonName.charAt(0).toUpperCase() + sinonName.slice(1); 21 | 22 | spyMatcherHash[matcherName] = getMatcherFunction(sinonName); 23 | }; 24 | 25 | for (var j in unusualMatchers) { 26 | spyMatcherHash[unusualMatchers[j]] = getMatcherFunction(j); 27 | } 28 | 29 | global.sinonJasmine = { 30 | getMatchers: function() { 31 | return spyMatcherHash; 32 | } 33 | }; 34 | 35 | })(window); 36 | 37 | beforeEach(function() { 38 | 39 | this.addMatchers(sinonJasmine.getMatchers()); 40 | 41 | }); -------------------------------------------------------------------------------- /app/views/stories/_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_for([@project, @story]) do |f| %> 2 | <% if @story.errors.any? %> 3 |
4 |

<%= pluralize(@story.errors.count, "error") %> prohibited this project from being saved:

5 | 6 | 11 |
12 | <% end %> 13 | 14 |
15 | <%= f.label :title %>
16 | <%= f.text_field :title %> 17 |
18 |
19 | <%= f.submit %> 20 |
21 |
22 | <%= f.label :story_type %>
23 | <%= f.select :story_type, Story::STORY_TYPES %> 24 |
25 |
26 | <%= f.label :estimate %>
27 | <%= f.select :estimate, @story.project.point_values, :include_blank => 'No estimate' %> 28 |
29 |
30 | <%= f.label :state %>
31 | <%= f.select :state, Story.state_machines[:default].states_for_select %> 32 |
33 |
34 | <%= f.label :requested_by_id %>
35 | <%= f.collection_select :requested_by_id, @project.users, :id, :email %> 36 |
37 |
38 | <%= f.label :description %>
39 | <%= f.text_area :description %> 40 |
41 | <% end %> 42 | -------------------------------------------------------------------------------- /app/controllers/users_controller.rb: -------------------------------------------------------------------------------- 1 | class UsersController < ApplicationController 2 | 3 | respond_to :html, :json 4 | 5 | def index 6 | @project = current_user.projects.find(params[:project_id]) 7 | @users = @project.users 8 | @user = User.new 9 | respond_with(@users) 10 | end 11 | 12 | def create 13 | @project = current_user.projects.find(params[:project_id]) 14 | @users = @project.users 15 | @user = User.find_or_create_by_email(params[:user][:email]) do |u| 16 | # Set to true if the user was not found 17 | u.was_created = true 18 | u.name = params[:user][:name] 19 | u.initials = params[:user][:initials] 20 | end 21 | 22 | if @user.new_record? && !@user.save 23 | render 'index' 24 | return 25 | end 26 | 27 | if @project.users.include?(@user) 28 | flash[:alert] = "#{@user.email} is already a member of this project" 29 | else 30 | @project.users << @user 31 | if @user.was_created 32 | flash[:notice] = "#{@user.email} was sent an invite to join this project" 33 | else 34 | flash[:notice] = "#{@user.email} was added to this project" 35 | end 36 | end 37 | 38 | redirect_to project_users_url(@project) 39 | end 40 | 41 | def destroy 42 | @project = current_user.projects.find(params[:project_id]) 43 | @user = @project.users.find(params[:id]) 44 | @project.users.delete(@user) 45 | redirect_to project_users_url(@project) 46 | end 47 | 48 | end 49 | -------------------------------------------------------------------------------- /app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ActiveRecord::Base 2 | 3 | # FIXME - DRY up, repeated in Story model 4 | JSON_ATTRIBUTES = ["id", "name", "initials", "email"] 5 | 6 | # Include default devise modules. Others available are: 7 | # :token_authenticatable, :confirmable, :lockable and :timeoutable 8 | devise :database_authenticatable, :registerable, :confirmable, 9 | :recoverable, :rememberable, :trackable, :validatable 10 | 11 | # Setup accessible (or protected) attributes for your model 12 | attr_accessible :email, :password, :password_confirmation, :remember_me, 13 | :name, :initials 14 | 15 | # Flag used to identify if the user was found or created from find_or_create 16 | attr_accessor :was_created 17 | 18 | has_and_belongs_to_many :projects, :uniq => true 19 | 20 | before_validation :set_random_password_if_blank, :set_reset_password_token 21 | 22 | validates :name, :presence => true 23 | validates :initials, :presence => true 24 | 25 | def to_s 26 | "#{name} (#{initials}) <#{email}>" 27 | end 28 | 29 | def set_random_password_if_blank 30 | if new_record? && self.password.blank? && self.password_confirmation.blank? 31 | self.password = self.password_confirmation = Digest::SHA1.hexdigest("--#{Time.now.to_s}--#{email}--")[0,6] 32 | end 33 | end 34 | 35 | def set_reset_password_token 36 | if new_record? 37 | self.reset_password_token = Devise.friendly_token 38 | end 39 | end 40 | 41 | def as_json(options = {}) 42 | super(:only => JSON_ATTRIBUTES) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /config/database.yml.example: -------------------------------------------------------------------------------- 1 | # SQLite 2 | development: 3 | adapter: sqlite3 4 | database: db/development.sqlite3 5 | pool: 5 6 | timeout: 5000 7 | 8 | test: 9 | adapter: sqlite3 10 | database: db/test.sqlite3 11 | pool: 5 12 | timeout: 5000 13 | 14 | production: 15 | adapter: sqlite3 16 | database: db/production.sqlite3 17 | pool: 5 18 | timeout: 5000 19 | 20 | # MySQL 21 | # development: 22 | # adapter: mysql2 23 | # encoding: utf8 24 | # reconnect: false 25 | # database: fulcrum_development 26 | # pool: 5 27 | # username: root 28 | # password: 29 | # host: localhost 30 | # 31 | # test: 32 | # adapter: mysql2 33 | # encoding: utf8 34 | # reconnect: false 35 | # database: fulcrum_test 36 | # pool: 5 37 | # username: root 38 | # password: 39 | # host: localhost 40 | # 41 | # production: 42 | # adapter: mysql2 43 | # encoding: utf8 44 | # reconnect: false 45 | # database: fulcrum_production 46 | # pool: 5 47 | # username: root 48 | # password: 49 | # host: localhost 50 | 51 | # PostgreSQL 52 | # development: 53 | # adapter: postgresql 54 | # encoding: unicode 55 | # database: fulcrum_development 56 | # pool: 5 57 | # username: fulcrum 58 | # password: 59 | # 60 | # test: 61 | # adapter: postgresql 62 | # encoding: unicode 63 | # database: fulcrum_test 64 | # pool: 5 65 | # username: fulcrum 66 | # password: 67 | # 68 | # production: 69 | # adapter: postgresql 70 | # encoding: unicode 71 | # database: fulcrum_production 72 | # pool: 5 73 | # username: fulcrum 74 | # password: -------------------------------------------------------------------------------- /app/views/projects/_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_for(@project) do |f| %> 2 | <% if @project.errors.any? %> 3 |
4 |

<%= pluralize(@project.errors.count, "error") %> prohibited this project from being saved:

5 | 6 | 11 |
12 | <% end %> 13 | 14 |
15 | Project properties 16 |
17 | <%= f.label :name %>
18 | <%= f.text_field :name %> 19 |
20 |
21 | <%= f.label :description %>
22 | <%= f.text_area :description, :rows => 5 %> 23 |
24 |
25 | <%= f.label :point_scale %>
26 | <%= f.select :point_scale, point_scale_options %> 27 |
28 |
29 | <%= f.label :start_date %>
30 | <%= f.date_select :start_date %> 31 |
32 |
33 | <%= f.label :iteration_start_day %>
34 | <%= f.select :iteration_start_day, day_name_options %> 35 |
36 |
37 | <%= f.label :iteration_length %>
38 | <%= f.select :iteration_length, iteration_length_options %> 39 |
40 |
41 | <%= f.label :public_view %>
42 | <%= f.check_box :public_view %> 43 |
44 |
45 | <%= f.submit %> 46 |
47 |
48 | <% end %> 49 | -------------------------------------------------------------------------------- /app/views/stories/import.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :title_bar do %> 2 | <%= render :partial => 'projects/nav', :locals => {:project => @project} %> 3 | <% end %> 4 | 5 |

Import stories

6 | 7 |

8 | You can bulk import stories in comma separated variable (CSV) format. The 9 | first line of the CSV must contain the correct headers for the import to 10 | succeed. You can download a 11 | CSV template with the correct 12 | headers to get you started. 13 |

14 |

Note: You can also use a CSV export from Pivotal Tracker.

15 | <%= form_tag import_upload_project_stories_path(@project), :multipart => true %> 16 | <%= file_field_tag :csv %> 17 | <%= submit_tag :import %> 18 | 19 | 20 | <% if @stories %> 21 |

Import results

22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | <% @stories.each_with_index do |story, index| %> 33 | <% if story.valid? %> 34 | 35 | 36 | 37 | 38 | 39 | <% else %> 40 | 41 | 42 | 45 | 46 | <% end %> 47 | <% end %> 48 | 49 |
RowStoryType
<%= index + 1 %><%= story.title %><%= story.story_type %>
<%= index + 1 %> 43 | <%= story.errors.full_messages.join(', ') %> 44 |
50 | <% end %> 51 | -------------------------------------------------------------------------------- /public/javascripts/models/iteration.js: -------------------------------------------------------------------------------- 1 | var Iteration = Backbone.Model.extend({ 2 | 3 | name: 'iteration', 4 | 5 | initialize: function(opts) { 6 | this.set({'stories': opts.stories || []}); 7 | }, 8 | 9 | points: function() { 10 | return _.reduce(this.get('stories'), function(memo, story) { 11 | var estimate = 0; 12 | if (story.get('story_type') === 'feature') { 13 | estimate = story.get('estimate') || 0; 14 | } 15 | return memo + estimate; 16 | }, 0); 17 | }, 18 | 19 | // Returns the number of points available before this iteration is full. 20 | // Only valid for backlog iterations. 21 | availablePoints: function() { 22 | return this.get('maximum_points') - this.points(); 23 | }, 24 | 25 | // 26 | // Returns true if this iteration has enough points free for a given 27 | // story. Only valid for backlog iterations. 28 | canTakeStory: function(story) { 29 | if (this.points() === 0) { 30 | return true; 31 | } 32 | 33 | if (story.get('story_type') === 'feature') { 34 | return story.get('estimate') <= this.availablePoints(); 35 | } else { 36 | return true; 37 | } 38 | }, 39 | 40 | // Report how many points this iteration overflows by. For example, if 41 | // the iteration maximum points is 2, and it has a single 5 point story, 42 | // its overflow will be 3 points. Will return 0 if the iteration has 43 | // less than or equal to its maximum points. 44 | overflowsBy: function() { 45 | var difference = this.points() - this.get('maximum_points'); 46 | return (difference < 0) ? 0 : difference; 47 | } 48 | 49 | }); 50 | -------------------------------------------------------------------------------- /app/views/layouts/public_view.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Agile LocomotiveCMS 5 | 6 | 7 | 8 | <%= csrf_meta_tag %> 9 | <%= stylesheet_link_tag 'screen.css', 'locomotive.css', :Media => 'screen, projection' %> 10 | <%= javascript_include_tag 'underscore.js', 'jquery-1.4.2.min', 11 | 'backbone.js', 'jquery-ui-1.8.11.custom.min', 'jquery.tmpl.min.js', 12 | 'jquery.gritter.min.js', 'jquery.scrollTo-min.js', 'backbone.rails', 13 | 'rails.js', 14 | 'models/iteration', 'models/story', 'models/project', 'models/user', 15 | 'collections/story_collection', 'collections/user_collection', 16 | 'views/form_view', 'views/story_view', 'views/app_view', 17 | 'fulcrum' %> 18 | 19 | 20 | 21 | 37 | 38 |
39 | <%= yield :title_bar %> 40 |
41 |
42 |

<%= notice %>

43 |

<%= alert %>

44 | <%= yield %> 45 |
46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Fulcrum::Application.configure do 2 | # Settings specified here will take precedence over those in config/environment.rb 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Log error messages when you accidentally call methods on nil. 11 | config.whiny_nils = true 12 | 13 | # Show full error reports and disable caching 14 | config.consider_all_requests_local = true 15 | config.action_controller.perform_caching = false 16 | 17 | # Raise exceptions instead of rendering exception templates 18 | config.action_dispatch.show_exceptions = false 19 | 20 | # Disable request forgery protection in test environment 21 | config.action_controller.allow_forgery_protection = false 22 | 23 | # Tell Action Mailer not to deliver emails to the real world. 24 | # The :test delivery method accumulates sent emails in the 25 | # ActionMailer::Base.deliveries array. 26 | config.action_mailer.delivery_method = :test 27 | config.action_mailer.default_url_options = {:host => 'test.local'} 28 | 29 | # Use SQL instead of Active Record's schema dumper when creating the test database. 30 | # This is necessary if your schema can't be completely dumped by the schema dumper, 31 | # like if you have constraints or database-specific column types 32 | # config.active_record.schema_format = :sql 33 | 34 | # Print deprecation notices to the stderr 35 | config.active_support.deprecation = :stderr 36 | end 37 | -------------------------------------------------------------------------------- /app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Fulcrum 5 | <%= csrf_meta_tag %> 6 | <%= stylesheet_link_tag 'screen.css', 'screen_changes.css', :Media => 'screen, projection' %> 7 | <%= javascript_include_tag 'underscore.js', 'jquery-1.4.2.min', 8 | 'backbone.js', 'jquery-ui-1.8.11.custom.min', 'jquery.tmpl.min.js', 9 | 'jquery.gritter.min.js', 'jquery.scrollTo-min.js', 'backbone.rails', 10 | 'rails.js', 11 | 'models/iteration', 'models/story', 'models/project', 'models/user', 12 | 'collections/story_collection', 'collections/user_collection', 13 | 'views/form_view', 'views/story_view', 'views/app_view', 14 | 'fulcrum' %> 15 | 16 | 17 | 18 | 39 | 40 |
41 | <%= yield :title_bar %> 42 |
43 | 44 |
45 |

<%= notice %>

46 |

<%= alert %>

47 | 48 | <%= yield %> 49 |
50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /app/models/project.rb: -------------------------------------------------------------------------------- 1 | class Project < ActiveRecord::Base 2 | 3 | JSON_ATTRIBUTES = [ 4 | "id", "iteration_length", "iteration_start_day", "start_date" 5 | ] 6 | JSON_METHODS = ["last_changeset_id", "point_values"] 7 | 8 | # These are the valid point scalse for a project. These represent 9 | # the set of valid points estimate values for a story in this project. 10 | POINT_SCALES = { 11 | 'fibonacci' => [0,1,2,3,5,8], 12 | 'powers_of_two' => [0,1,2,4,8], 13 | 'linear' => [0,1,2,3,4,5], 14 | } 15 | validates_inclusion_of :point_scale, :in => POINT_SCALES.keys, 16 | :message => "%{value} is not a valid estimation scheme" 17 | 18 | ITERATION_LENGTH_RANGE = (1..4) 19 | validates_numericality_of :iteration_length, 20 | :greater_than_or_equal_to => ITERATION_LENGTH_RANGE.min, 21 | :less_than_or_equal_to => ITERATION_LENGTH_RANGE.max, :only_integer => true, 22 | :message => "must be between 1 and 4 weeks" 23 | 24 | validates_numericality_of :iteration_start_day, 25 | :greater_than_or_equal_to => 0, :less_than_or_equal_to => 6, 26 | :only_integer => true, :message => "must be an integer between 0 and 6" 27 | 28 | validates :name, :presence => true 29 | 30 | has_and_belongs_to_many :users, :uniq => true 31 | accepts_nested_attributes_for :users, :reject_if => :all_blank 32 | 33 | has_many :stories, :dependent => :destroy 34 | has_many :changesets, :dependent => :destroy 35 | 36 | scope :with_public_view, where(:public_view => true) 37 | 38 | def to_s 39 | name 40 | end 41 | 42 | # Returns an array of the valid points values for this project 43 | def point_values 44 | POINT_SCALES[point_scale] 45 | end 46 | 47 | def last_changeset_id 48 | changesets.last && changesets.last.id 49 | end 50 | 51 | def as_json(options = {}) 52 | super(:only => JSON_ATTRIBUTES, :methods => JSON_METHODS) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /app/models/story_observer.rb: -------------------------------------------------------------------------------- 1 | class StoryObserver < ActiveRecord::Observer 2 | 3 | # Create a new changeset whenever the story is changed 4 | def after_save(story) 5 | story.changesets.create! 6 | 7 | if story.state_changed? 8 | 9 | # Send a 'the story has been delivered' notification if the state has 10 | # changed to 'delivered' 11 | if story.state == 'delivered' && story.acting_user && story.requested_by && story.acting_user != story.requested_by 12 | notifier = Notifications.delivered(story, story.acting_user) 13 | notifier.deliver if notifier 14 | end 15 | 16 | # Send 'story accepted' email if state changed to 'accepted' 17 | if story.state == 'accepted' && story.acting_user && story.owned_by && story.owned_by != story.acting_user 18 | notifier = Notifications.accepted(story, story.acting_user) 19 | notifier.deliver if notifier 20 | end 21 | 22 | # Send 'story accepted' email if state changed to 'accepted' 23 | if story.state == 'rejected' && story.acting_user && story.owned_by && story.owned_by != story.acting_user 24 | notifier = Notifications.rejected(story, story.acting_user) 25 | notifier.deliver if notifier 26 | end 27 | 28 | # Set the project start date to today if the project start date is nil 29 | # and the state is changing to any state other than 'unstarted' or 30 | # 'unscheduled' 31 | if story.project && !story.project.start_date && !['unstarted', 'unscheduled'].include?(story.state) 32 | story.project.update_attribute :start_date, Date.today 33 | end 34 | end 35 | 36 | # If a story's 'accepted at' date is prior to the project start date, 37 | # the project start date should be moved back accordingly 38 | if story.accepted_at_changed? && story.accepted_at && story.accepted_at < story.project.start_date 39 | story.project.update_attribute :start_date, story.accepted_at 40 | end 41 | 42 | end 43 | 44 | end 45 | -------------------------------------------------------------------------------- /config/locales/devise.en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | errors: 3 | messages: 4 | not_found: "not found" 5 | already_confirmed: "was already confirmed" 6 | not_locked: "was not locked" 7 | 8 | devise: 9 | failure: 10 | unauthenticated: 'You need to sign in or sign up before continuing.' 11 | unconfirmed: 'You have to confirm your account before continuing.' 12 | locked: 'Your account is locked.' 13 | invalid: 'Invalid email or password.' 14 | invalid_token: 'Invalid authentication token.' 15 | timeout: 'Your session expired, please sign in again to continue.' 16 | inactive: 'Your account was not activated yet.' 17 | sessions: 18 | signed_in: 'Signed in successfully.' 19 | signed_out: 'Signed out successfully.' 20 | passwords: 21 | send_instructions: 'You will receive an email with instructions about how to reset your password in a few minutes.' 22 | updated: 'Your password was changed successfully. You are now signed in.' 23 | confirmations: 24 | send_instructions: 'You will receive an email with instructions about how to confirm your account in a few minutes.' 25 | confirmed: 'Your account was successfully confirmed. You are now signed in.' 26 | registrations: 27 | signed_up: 'You have signed up successfully. If enabled, a confirmation was sent to your e-mail.' 28 | updated: 'You updated your account successfully.' 29 | destroyed: 'Bye! Your account was successfully cancelled. We hope to see you again soon.' 30 | unlocks: 31 | send_instructions: 'You will receive an email with instructions about how to unlock your account in a few minutes.' 32 | unlocked: 'Your account was successfully unlocked. You are now signed in.' 33 | mailer: 34 | confirmation_instructions: 35 | subject: 'Confirmation instructions' 36 | reset_password_instructions: 37 | subject: 'Reset password instructions' 38 | unlock_instructions: 39 | subject: 'Unlock Instructions' 40 | -------------------------------------------------------------------------------- /app/stylesheets/_jquery_gritter.scss: -------------------------------------------------------------------------------- 1 | /* the norm */ 2 | #gritter-notice-wrapper { 3 | position:fixed; 4 | top:12px; 5 | right:40%; 6 | width:301px; 7 | z-index:9999; 8 | } 9 | #gritter-notice-wrapper.top-left { 10 | left: 20px; 11 | right: auto; 12 | } 13 | #gritter-notice-wrapper.bottom-right { 14 | top: auto; 15 | left: auto; 16 | bottom: 20px; 17 | right: 20px; 18 | } 19 | #gritter-notice-wrapper.bottom-left { 20 | top: auto; 21 | right: auto; 22 | bottom: 20px; 23 | left: 20px; 24 | } 25 | .gritter-item-wrapper { 26 | position:relative; 27 | margin:0 0 10px 0; 28 | background:url('../images/ie-spacer.gif'); /* ie7/8 fix */ 29 | } 30 | .gritter-top { 31 | background:url(../images/gritter.png) no-repeat left -30px; 32 | height:10px; 33 | } 34 | .hover .gritter-top { 35 | background-position:right -30px; 36 | } 37 | .gritter-bottom { 38 | background:url(../images/gritter.png) no-repeat left bottom; 39 | height:8px; 40 | margin:0; 41 | } 42 | .hover .gritter-bottom { 43 | background-position: bottom right; 44 | } 45 | .gritter-item { 46 | display:block; 47 | background:url(../images/gritter.png) no-repeat left -40px; 48 | color:#eee; 49 | padding:2px 11px 8px 11px; 50 | font-size: 11px; 51 | font-family:verdana; 52 | } 53 | .hover .gritter-item { 54 | background-position:right -40px; 55 | } 56 | .gritter-item p { 57 | padding:0; 58 | margin:0; 59 | } 60 | .gritter-close { 61 | position:absolute; 62 | top:5px; 63 | left:3px; 64 | background:url(../images/gritter.png) no-repeat left top; 65 | cursor:pointer; 66 | width:30px; 67 | height:30px; 68 | } 69 | .gritter-title { 70 | font-size:14px; 71 | font-weight:bold; 72 | padding:0 0 7px 0; 73 | display:block; 74 | text-shadow:1px 1px #000; /* Not supported by IE :( */ 75 | } 76 | .gritter-image { 77 | width:48px; 78 | height:48px; 79 | float:left; 80 | } 81 | .gritter-with-image, 82 | .gritter-without-image { 83 | padding:0 0 5px 0; 84 | } 85 | .gritter-with-image { 86 | width:220px; 87 | float:right; 88 | } 89 | -------------------------------------------------------------------------------- /config/environments/production.rb: -------------------------------------------------------------------------------- 1 | Fulcrum::Application.configure do 2 | # Settings specified here will take precedence over those in config/environment.rb 3 | 4 | # The production environment is meant for finished, "live" apps. 5 | # Code is not reloaded between requests 6 | config.cache_classes = true 7 | 8 | # Full error reports are disabled and caching is turned on 9 | config.consider_all_requests_local = false 10 | config.action_controller.perform_caching = true 11 | 12 | # Specifies the header that your server uses for sending files 13 | config.action_dispatch.x_sendfile_header = "X-Sendfile" 14 | 15 | # For nginx: 16 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' 17 | 18 | # If you have no front-end server that supports something like X-Sendfile, 19 | # just comment this out and Rails will serve the files 20 | 21 | # See everything in the log (default is :info) 22 | # config.log_level = :debug 23 | 24 | # Use a different logger for distributed setups 25 | # config.logger = SyslogLogger.new 26 | 27 | # Use a different cache store in production 28 | # config.cache_store = :mem_cache_store 29 | 30 | # Disable Rails's static asset server 31 | # In production, Apache or nginx will already do this 32 | config.serve_static_assets = false 33 | 34 | # Enable serving of images, stylesheets, and javascripts from an asset server 35 | # config.action_controller.asset_host = "http://assets.example.com" 36 | 37 | # Disable delivery errors, bad email addresses will be ignored 38 | # config.action_mailer.raise_delivery_errors = false 39 | config.action_mailer.default_url_options = { :host => "#{ENV['APP_NAME']}.heroku.com" } 40 | 41 | # Enable threaded mode 42 | # config.threadsafe! 43 | 44 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 45 | # the I18n.default_locale when a translation can not be found) 46 | config.i18n.fallbacks = true 47 | 48 | # Send deprecation notices to registered listeners 49 | config.active_support.deprecation = :notify 50 | end 51 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../boot', __FILE__) 2 | 3 | require 'rails/all' 4 | 5 | # If you have a Gemfile, require the gems listed there, including any gems 6 | # you've limited to :test, :development, or :production. 7 | Bundler.require(:default, Rails.env) if defined?(Bundler) 8 | 9 | module Fulcrum 10 | class Application < Rails::Application 11 | # Settings in config/environments/* take precedence over those specified here. 12 | # Application configuration should go into files in config/initializers 13 | # -- all .rb files in that directory are automatically loaded. 14 | 15 | # Custom directories with classes and modules you want to be autoloadable. 16 | config.autoload_paths += %W(#{config.root}/lib/validators) 17 | 18 | # Only load the plugins named here, in the order given (default is alphabetical). 19 | # :all can be used as a placeholder for all plugins not explicitly named. 20 | # config.plugins = [ :exception_notification, :ssl_requirement, :all ] 21 | 22 | # Activate observers that should always be running. 23 | # config.active_record.observers = :cacher, :garbage_collector, :forum_observer 24 | config.active_record.observers = :story_observer 25 | 26 | # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone. 27 | # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC. 28 | # config.time_zone = 'Central Time (US & Canada)' 29 | 30 | # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. 31 | # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] 32 | # config.i18n.default_locale = :de 33 | 34 | # JavaScript files you want as :defaults (application.js is always included). 35 | # config.action_view.javascript_expansions[:defaults] = %w(jquery rails) 36 | 37 | # Configure the default encoding used in templates for Ruby 1.9. 38 | config.encoding = "utf-8" 39 | 40 | # Configure sensitive parameters which will be filtered from the log file. 41 | config.filter_parameters += [:password] 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /public/javascripts/jquery.scrollTo-min.js: -------------------------------------------------------------------------------- 1 | /** 2 | * jQuery.ScrollTo - Easy element scrolling using jQuery. 3 | * Copyright (c) 2007-2009 Ariel Flesler - aflesler(at)gmail(dot)com | http://flesler.blogspot.com 4 | * Dual licensed under MIT and GPL. 5 | * Date: 5/25/2009 6 | * @author Ariel Flesler 7 | * @version 1.4.2 8 | * 9 | * http://flesler.blogspot.com/2007/10/jqueryscrollto.html 10 | */ 11 | ;(function(d){var k=d.scrollTo=function(a,i,e){d(window).scrollTo(a,i,e)};k.defaults={axis:'xy',duration:parseFloat(d.fn.jquery)>=1.3?0:1};k.window=function(a){return d(window)._scrollable()};d.fn._scrollable=function(){return this.map(function(){var a=this,i=!a.nodeName||d.inArray(a.nodeName.toLowerCase(),['iframe','#document','html','body'])!=-1;if(!i)return a;var e=(a.contentWindow||a).document||a.ownerDocument||a;return d.browser.safari||e.compatMode=='BackCompat'?e.body:e.documentElement})};d.fn.scrollTo=function(n,j,b){if(typeof j=='object'){b=j;j=0}if(typeof b=='function')b={onAfter:b};if(n=='max')n=9e9;b=d.extend({},k.defaults,b);j=j||b.speed||b.duration;b.queue=b.queue&&b.axis.length>1;if(b.queue)j/=2;b.offset=p(b.offset);b.over=p(b.over);return this._scrollable().each(function(){var q=this,r=d(q),f=n,s,g={},u=r.is('html,body');switch(typeof f){case'number':case'string':if(/^([+-]=)?\d+(\.\d+)?(px|%)?$/.test(f)){f=p(f);break}f=d(f,this);case'object':if(f.is||f.style)s=(f=d(f)).offset()}d.each(b.axis.split(''),function(a,i){var e=i=='x'?'Left':'Top',h=e.toLowerCase(),c='scroll'+e,l=q[c],m=k.max(q,i);if(s){g[c]=s[h]+(u?0:l-r.offset()[h]);if(b.margin){g[c]-=parseInt(f.css('margin'+e))||0;g[c]-=parseInt(f.css('border'+e+'Width'))||0}g[c]+=b.offset[h]||0;if(b.over[h])g[c]+=f[i=='x'?'width':'height']()*b.over[h]}else{var o=f[h];g[c]=o.slice&&o.slice(-1)=='%'?parseFloat(o)/100*m:o}if(/^\d+$/.test(g[c]))g[c]=g[c]<=0?0:Math.min(g[c],m);if(!a&&b.queue){if(l!=g[c])t(b.onAfterFirst);delete g[c]}});t(b.onAfter);function t(a){r.animate(g,j,b.easing,a&&function(){a.call(this,n,b)})}}).end()};k.max=function(a,i){var e=i=='x'?'Width':'Height',h='scroll'+e;if(!d(a).is('html,body'))return a[h]-d(a)[e.toLowerCase()]();var c='client'+e,l=a.ownerDocument.documentElement,m=a.ownerDocument.body;return Math.max(l[h],m[h])-Math.min(l[c],m[c])};function p(a){return typeof a=='object'?a:{top:a,left:a}}})(jQuery); -------------------------------------------------------------------------------- /spec/javascripts/support/jasmine.yml: -------------------------------------------------------------------------------- 1 | # src_files 2 | # 3 | # Return an array of filepaths relative to src_dir to include before jasmine specs. 4 | # Default: [] 5 | # 6 | # EXAMPLE: 7 | # 8 | # src_files: 9 | # - lib/source1.js 10 | # - lib/source2.js 11 | # - dist/**/*.js 12 | # 13 | src_files: 14 | - spec/javascripts/support/sinon-1.1.1.js 15 | - spec/javascripts/support/jasmine-sinon.js 16 | - spec/javascripts/support/jasmine-jquery-1.2.0.js 17 | - public/javascripts/underscore.js 18 | - public/javascripts/jquery-1.4.2.min.js 19 | - public/javascripts/backbone.js 20 | - public/javascripts/jquery-ui-1.8.11.custom.min.js 21 | - public/javascripts/jquery.tmpl.min.js 22 | - public/javascripts/backbone.rails.js 23 | - public/javascripts/models/iteration.js 24 | - public/javascripts/models/project.js 25 | - public/javascripts/models/story.js 26 | - public/javascripts/models/user.js 27 | - public/javascripts/collections/story_collection.js 28 | - public/javascripts/collections/user_collection.js 29 | - public/javascripts/views/form_view.js 30 | - public/javascripts/views/story_view.js 31 | 32 | # stylesheets 33 | # 34 | # Return an array of stylesheet filepaths relative to src_dir to include before jasmine specs. 35 | # Default: [] 36 | # 37 | # EXAMPLE: 38 | # 39 | # stylesheets: 40 | # - css/style.css 41 | # - stylesheets/*.css 42 | # 43 | stylesheets: 44 | - stylesheets/**/*.css 45 | 46 | # helpers 47 | # 48 | # Return an array of filepaths relative to spec_dir to include before jasmine specs. 49 | # Default: ["helpers/**/*.js"] 50 | # 51 | # EXAMPLE: 52 | # 53 | # helpers: 54 | # - helpers/**/*.js 55 | # 56 | helpers: 57 | - helpers/**/*.js 58 | 59 | # spec_files 60 | # 61 | # Return an array of filepaths relative to spec_dir to include. 62 | # Default: ["**/*[sS]pec.js"] 63 | # 64 | # EXAMPLE: 65 | # 66 | # spec_files: 67 | # - **/*[sS]pec.js 68 | # 69 | spec_files: 70 | - **/*[sS]pec.js 71 | 72 | # src_dir 73 | # 74 | # Source directory path. Your src_files must be returned relative to this path. Will use root if left blank. 75 | # Default: project root 76 | # 77 | # EXAMPLE: 78 | # 79 | # src_dir: public 80 | # 81 | src_dir: 82 | 83 | # spec_dir 84 | # 85 | # Spec directory path. Your spec_files must be returned relative to this path. 86 | # Default: spec/javascripts 87 | # 88 | # EXAMPLE: 89 | # 90 | # spec_dir: spec/javascripts 91 | # 92 | spec_dir: spec/javascripts 93 | -------------------------------------------------------------------------------- /public/javascripts/backbone.rails.js: -------------------------------------------------------------------------------- 1 | // 2 | // Backbone.Rails.js 3 | // 4 | // Makes Backbone.js play nicely with the default Rails setup, i.e., 5 | // no need to set 6 | // ActiveRecord::Base.include_root_in_json = false 7 | // and build all of your models directly from `params` rather than 8 | // `params[:model]`. 9 | // 10 | // Load this file after backbone.js and before your application JS. 11 | // 12 | 13 | Backbone.RailsJSON = { 14 | // In order to properly wrap/unwrap Rails JSON data, we need to specify 15 | // what key the object will be wrapped with. 16 | _name : function() { 17 | if (!this.name) throw new Error("A 'name' property must be specified"); 18 | return this.name; 19 | }, 20 | 21 | // A test to indicate whether the given object is wrapped. 22 | isWrapped : function(object) { 23 | return (object.hasOwnProperty(this._name()) && 24 | (typeof(object[this._name()]) == "object")); 25 | }, 26 | 27 | // Extracts the object's wrapped attributes. 28 | unwrappedAttributes : function(object) { 29 | return object[this._name()]; 30 | }, 31 | 32 | // Wraps the model's attributes under the supplied key. 33 | wrappedAttributes : function() { 34 | var object = new Object; 35 | object[this._name()] = _.clone(this.attributes); 36 | return object; 37 | }, 38 | 39 | // Sets up the new model's internal state so that it matches the 40 | // expected format. Should be called early in the model's constructor. 41 | maybeUnwrap : function(args) { 42 | if (this.isWrapped(args)) { 43 | this.set(this.unwrappedAttributes(args), { silent: true }); 44 | this.unset(this._name(), { silent: true }); 45 | this._previousAttributes = _.clone(this.attributes); 46 | } 47 | } 48 | }; 49 | 50 | _.extend(Backbone.Model.prototype, Backbone.RailsJSON, { 51 | // This is called on all models coming in from a remote server. 52 | // Unwraps the given response from the default Rails format. 53 | parse : function(resp) { 54 | return this.unwrappedAttributes(resp); 55 | }, 56 | 57 | // This is called just before a model is persisted to a remote server. 58 | // Wraps the model's attributes into a Rails-friendly format. 59 | toJSON : function() { 60 | return this.wrappedAttributes(); 61 | }, 62 | 63 | // A new default initializer which handles data directly from Rails as 64 | // well as unnested data. 65 | initialize : function(args) { 66 | this.maybeUnwrap(args); 67 | } 68 | }); 69 | -------------------------------------------------------------------------------- /test/functional/projects_controller_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ProjectsControllerTest < ActionController::TestCase 4 | 5 | setup do 6 | @project = Factory.create(:project) 7 | @user = Factory.create(:user, :projects => [@project]) 8 | end 9 | 10 | test "should not get project list if not logged in" do 11 | get :index 12 | assert_redirected_to new_user_session_url 13 | end 14 | 15 | test "should get index" do 16 | other_project = Factory.create(:project) 17 | sign_in @user 18 | get :index 19 | assert_response :success 20 | assert_not_nil assigns(:projects) 21 | assert assigns(:projects).include?(@project) 22 | 23 | # Ensure the user cannot see other users projects 24 | assert !assigns(:projects).include?(other_project) 25 | end 26 | 27 | test "should get new" do 28 | sign_in @user 29 | get :new 30 | assert_response :success 31 | end 32 | 33 | test "should create project" do 34 | sign_in @user 35 | assert_difference('Project.count') do 36 | post :create, :project => @project.attributes 37 | assert_equal [@user], assigns(:project).users 38 | assert_redirected_to project_path(assigns(:project)) 39 | end 40 | end 41 | 42 | test "should show project" do 43 | sign_in @user 44 | get :show, :id => @project.to_param 45 | assert_equal @project, assigns(:project) 46 | assert assigns(:story) 47 | assert_response :success 48 | end 49 | 50 | test "should show project in js format" do 51 | sign_in @user 52 | get :show, :id => @project.to_param, :format => 'js' 53 | assert_equal @project, assigns(:project) 54 | assert_response :success 55 | end 56 | 57 | test "should not show other users project" do 58 | other_user = Factory.create(:user) 59 | sign_in other_user 60 | get :show, :id => @project.to_param 61 | assert_response :missing 62 | end 63 | 64 | test "should get edit" do 65 | sign_in @user 66 | get :edit, :id => @project.to_param 67 | assert_response :success 68 | end 69 | 70 | test "should update project" do 71 | sign_in @user 72 | put :update, :id => @project.to_param, :project => @project.attributes 73 | assert_redirected_to project_path(assigns(:project)) 74 | end 75 | 76 | test "should destroy project" do 77 | sign_in @user 78 | assert_difference('Project.count', -1) do 79 | delete :destroy, :id => @project.to_param 80 | end 81 | 82 | assert_redirected_to projects_path 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /app/views/public_view/projects/show.html.erb: -------------------------------------------------------------------------------- 1 | <%= javascript_tag "var AUTH_TOKEN = '#{form_authenticity_token}';" if protect_against_forgery? %> 2 | 10 | 11 | 12 | 44 | 45 | <% content_for :title_bar do %> 46 |

<%= @project.name %>

47 | <% end %> 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 |
DoneIn ProgressBacklogChilly Bin
67 | -------------------------------------------------------------------------------- /public/javascripts/views/form_view.js: -------------------------------------------------------------------------------- 1 | var FormView = Backbone.View.extend({ 2 | tagName: 'form', 3 | 4 | label: function(elem_id, value) { 5 | value = value || elem_id; 6 | return this.make('label', {'for': elem_id}, value); 7 | }, 8 | 9 | textField: function(name) { 10 | var el = this.make('input', {type: "text", name: name, value: this.model.get(name)}); 11 | this.bindElementToAttribute(el, name); 12 | return el; 13 | }, 14 | 15 | hiddenField: function(name) { 16 | var el = this.make('input', {type: "hidden", name: name, value: this.model.get(name)}); 17 | this.bindElementToAttribute(el, name); 18 | return el; 19 | }, 20 | 21 | textArea: function(name) { 22 | var el = this.make('textarea', {name: name, value: this.model.get(name)}); 23 | this.bindElementToAttribute(el, name); 24 | return el; 25 | }, 26 | 27 | select: function(name, select_options, options) { 28 | var select = this.make('select', {name: name}); 29 | var view = this; 30 | var model = this.model; 31 | 32 | if (typeof options == 'undefined') { 33 | options = {}; 34 | } 35 | 36 | if (options.blank) { 37 | $(select).append(this.make('option', {value: ''}, options.blank)); 38 | } 39 | 40 | _.each(select_options, function(option) { 41 | if (option instanceof Array) { 42 | option_name = option[0]; 43 | option_value = option[1]; 44 | } else { 45 | option_name = option_value = option + ''; 46 | } 47 | var attr = {value: option_value}; 48 | if (model.get(name) == option_value) { 49 | attr.selected = true; 50 | } 51 | $(select).append(view.make('option', attr, option_name)); 52 | }); 53 | this.bindElementToAttribute(select, name); 54 | return select; 55 | }, 56 | 57 | checkBox: function(name) { 58 | var attr = {type: "checkbox", name: name, value: 1}; 59 | if (this.model.get(name)) { 60 | attr.checked = "checked"; 61 | } 62 | var el = this.make('input', attr); 63 | this.bindElementToAttribute(el, name); 64 | return el; 65 | }, 66 | 67 | submit: function() { 68 | var el = this.make('input', {id: "submit", type: "button", value: "Save"}); 69 | return el; 70 | }, 71 | 72 | destroy: function() { 73 | var el = this.make('input', {id: "destroy", type: "button", value: "Delete"}); 74 | return el; 75 | }, 76 | 77 | cancel: function() { 78 | var el = this.make('input', {id: "cancel", type: "button", value: "Cancel"}); 79 | return el; 80 | }, 81 | 82 | bindElementToAttribute: function(el, name) { 83 | var that = this; 84 | $(el).bind("change", function() { 85 | that.changed_attributes || (that.changed_attributes = {}); 86 | that.changed_attributes[name] = $(el).val(); 87 | }); 88 | }, 89 | }); 90 | -------------------------------------------------------------------------------- /test/functional/notifications_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class NotificationsTest < ActionMailer::TestCase 4 | 5 | # Needed for url helpers 6 | include Rails.application.routes.url_helpers 7 | 8 | def default_url_options 9 | Rails.application.config.action_mailer.default_url_options 10 | end 11 | 12 | test "delivered" do 13 | requester = Factory.create(:user, :name => "Requester") 14 | deliverer = Factory.create(:user, :name => "Deliverer") 15 | project = Factory.create(:project, :users => [requester, deliverer]) 16 | story = Factory.create(:story, :project => project, 17 | :requested_by => requester) 18 | 19 | mail = Notifications.delivered(story, deliverer) 20 | assert_equal "[Test Project] Your story 'Test story' has been delivered for acceptance.", mail.subject 21 | assert_equal [requester.email], mail.to 22 | assert_equal [deliverer.email], mail.from 23 | assert_match "Deliverer has delivered your story 'Test story'.", mail.body.encoded 24 | assert_match "You can now review the story, and either accept or reject it.", mail.body.encoded 25 | assert_match project_url(project), mail.body.encoded 26 | end 27 | 28 | test "accepted" do 29 | owner = Factory.create(:user, :name => "Owner") 30 | accepter = Factory.create(:user, :name => "Accepter") 31 | requester = Factory.create(:user, :name => "Requester") 32 | project = Factory.create(:project, :users => [owner, accepter, requester]) 33 | story = Factory.create(:story, :project => project, 34 | :requested_by => requester, :owned_by => owner) 35 | 36 | mail = Notifications.accepted(story, accepter) 37 | assert_equal "[Test Project] Accepter ACCEPTED your story 'Test story'.", mail.subject 38 | assert_equal [owner.email], mail.to 39 | assert_equal [accepter.email], mail.from 40 | assert_match "Accepter has accepted the story 'Test story'.", mail.body.encoded 41 | assert_match project_url(project), mail.body.encoded 42 | end 43 | 44 | test "rejected" do 45 | owner = Factory.create(:user, :name => "Owner") 46 | rejecter = Factory.create(:user, :name => "Rejecter") 47 | requester = Factory.create(:user, :name => "Requester") 48 | project = Factory.create(:project, :users => [owner, rejecter, requester]) 49 | story = Factory.create(:story, :project => project, 50 | :requested_by => requester, :owned_by => owner) 51 | 52 | mail = Notifications.rejected(story, rejecter) 53 | assert_equal "[Test Project] Rejecter REJECTED your story 'Test story'.", mail.subject 54 | assert_equal [owner.email], mail.to 55 | assert_equal [rejecter.email], mail.from 56 | assert_match "Rejecter has rejected the story 'Test story'.", mail.body.encoded 57 | assert_match project_url(project), mail.body.encoded 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /app/controllers/projects_controller.rb: -------------------------------------------------------------------------------- 1 | class ProjectsController < ApplicationController 2 | 3 | # GET /projects 4 | # GET /projects.xml 5 | def index 6 | @projects = current_user.projects 7 | respond_to do |format| 8 | format.html # index.html.erb 9 | format.xml { render :xml => @projects } 10 | end 11 | end 12 | 13 | # GET /projects/1 14 | # GET /projects/1.xml 15 | def show 16 | @project = current_user.projects.find(params[:id]) 17 | @story = @project.stories.build 18 | 19 | respond_to do |format| 20 | format.html # show.html.erb 21 | format.js { render :json => @project } 22 | format.xml { render :xml => @project } 23 | end 24 | end 25 | 26 | # GET /projects/new 27 | # GET /projects/new.xml 28 | def new 29 | @project = Project.new 30 | 31 | respond_to do |format| 32 | format.html # new.html.erb 33 | format.xml { render :xml => @project } 34 | end 35 | end 36 | 37 | # GET /projects/1/edit 38 | def edit 39 | @project = current_user.projects.find(params[:id]) 40 | @project.users.build 41 | end 42 | 43 | # POST /projects 44 | # POST /projects.xml 45 | def create 46 | @project = current_user.projects.build(params[:project]) 47 | @project.users << current_user 48 | 49 | respond_to do |format| 50 | if @project.save 51 | format.html { redirect_to(@project, :notice => 'Project was successfully created.') } 52 | format.xml { render :xml => @project, :status => :created, :location => @project } 53 | else 54 | format.html { render :action => "new" } 55 | format.xml { render :xml => @project.errors, :status => :unprocessable_entity } 56 | end 57 | end 58 | end 59 | 60 | # PUT /projects/1 61 | # PUT /projects/1.xml 62 | def update 63 | @project = current_user.projects.find(params[:id]) 64 | 65 | respond_to do |format| 66 | if @project.update_attributes(params[:project]) 67 | format.html { redirect_to(@project, :notice => 'Project was successfully updated.') } 68 | format.xml { head :ok } 69 | else 70 | format.html { render :action => "edit" } 71 | format.xml { render :xml => @project.errors, :status => :unprocessable_entity } 72 | end 73 | end 74 | end 75 | 76 | # DELETE /projects/1 77 | # DELETE /projects/1.xml 78 | def destroy 79 | @project = current_user.projects.find(params[:id]) 80 | @project.destroy 81 | 82 | respond_to do |format| 83 | format.html { redirect_to(projects_url) } 84 | format.xml { head :ok } 85 | end 86 | end 87 | 88 | # GET /projects/1/users 89 | # GET /projects/1/users.xml 90 | def users 91 | @project = current_user.projects.find(params[:id]) 92 | @users = @project.users 93 | 94 | respond_to do |format| 95 | format.html # users.html.erb 96 | format.xml { render :xml => @project } 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /test/unit/project_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ProjectTest < ActiveSupport::TestCase 4 | def setup 5 | @project = Factory.create(:project) 6 | end 7 | 8 | test "should not save a project without a name" do 9 | @project.name = "" 10 | assert !@project.save 11 | assert_equal ["can't be blank"], @project.errors[:name] 12 | end 13 | 14 | test "should return a string" do 15 | assert_equal @project.name, @project.to_s 16 | end 17 | 18 | test "default point scale should be fibonacci" do 19 | assert_equal 'fibonacci', Project.new.point_scale 20 | end 21 | 22 | test "should reject invalid point scale" do 23 | @project.point_scale = 'invalid_point_scale' 24 | assert !@project.save 25 | end 26 | 27 | test "should return the valid values for point scale" do 28 | assert_equal [0,1,2,3,5,8], @project.point_values 29 | end 30 | 31 | test "default iteration length should be 1 week" do 32 | assert_equal 1, Project.new.iteration_length 33 | end 34 | 35 | test "should reject invalid iteration lengths" do 36 | @project.iteration_length = 0 37 | assert !@project.save 38 | @project.iteration_length = 5 39 | assert !@project.save 40 | # Must be an integer 41 | @project.iteration_length = 2.5 42 | assert !@project.save 43 | end 44 | 45 | test "default iteration start day should be Monday" do 46 | assert_equal 1, Project.new.iteration_start_day 47 | end 48 | 49 | test "should reject invalid iteration start days" do 50 | @project.iteration_start_day = -1 51 | assert !@project.save 52 | @project.iteration_start_day = 7 53 | assert !@project.save 54 | # Must be an integer 55 | @project.iteration_start_day = 2.5 56 | assert !@project.save 57 | end 58 | 59 | test "should return the id of the most recent changeset" do 60 | assert_equal nil, @project.last_changeset_id 61 | user = Factory.create(:user) 62 | @project.users << user 63 | story = Factory.create(:story, :project => @project, :requested_by => @user) 64 | assert_equal Changeset.last.id, @project.last_changeset_id 65 | end 66 | 67 | test "should return json" do 68 | attrs = [ 69 | "id", "point_values", "last_changeset_id", "iteration_length", 70 | "iteration_start_day", "start_date" 71 | ] 72 | assert_returns_json attrs, @project 73 | end 74 | 75 | test "should set the start date when starting the first story" do 76 | assert_nil @project.start_date 77 | story = Factory.create(:story, :project => @project, :requested_by => @user) 78 | story.update_attribute :state, 'started' 79 | assert_equal Date.today, @project.start_date 80 | end 81 | 82 | test "should cascade delete stories" do 83 | story = Factory.create(:story, :project => @project, :requested_by => @user) 84 | assert_equal @project.stories.count, 1 85 | assert_difference 'Story.count', -1 do 86 | assert @project.destroy 87 | end 88 | end 89 | 90 | test "should cascade delete changesets" do 91 | story = Factory.create(:story, :project => @project, :requested_by => @user) 92 | assert_equal @project.changesets.count, 1 93 | assert_difference 'Changeset.count', -1 do 94 | assert @project.destroy 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # Note that this schema.rb definition is the authoritative source for your 6 | # database schema. If you need to create the application database on another 7 | # system, you should be using db:schema:load, not running all the migrations 8 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations 9 | # you'll amass, the slower it'll run and the greater likelihood for issues). 10 | # 11 | # It's strongly recommended to check this file into your version control system. 12 | 13 | ActiveRecord::Schema.define(:version => 20110908212947) do 14 | 15 | create_table "changesets", :force => true do |t| 16 | t.integer "story_id" 17 | t.integer "project_id" 18 | t.datetime "created_at" 19 | t.datetime "updated_at" 20 | end 21 | 22 | create_table "projects", :force => true do |t| 23 | t.string "name" 24 | t.string "point_scale", :default => "fibonacci" 25 | t.date "start_date" 26 | t.integer "iteration_start_day", :default => 1 27 | t.integer "iteration_length", :default => 1 28 | t.datetime "created_at" 29 | t.datetime "updated_at" 30 | t.boolean "public_view", :default => false 31 | t.text "description" 32 | end 33 | 34 | create_table "projects_users", :id => false, :force => true do |t| 35 | t.integer "project_id" 36 | t.integer "user_id" 37 | end 38 | 39 | create_table "stories", :force => true do |t| 40 | t.string "title" 41 | t.text "description" 42 | t.integer "estimate" 43 | t.string "story_type", :default => "feature" 44 | t.string "state", :default => "unstarted" 45 | t.date "accepted_at" 46 | t.integer "requested_by_id" 47 | t.integer "owned_by_id" 48 | t.integer "project_id" 49 | t.datetime "created_at" 50 | t.datetime "updated_at" 51 | t.decimal "position" 52 | end 53 | 54 | create_table "users", :force => true do |t| 55 | t.string "email", :default => "", :null => false 56 | t.string "encrypted_password", :limit => 128, :default => "", :null => false 57 | t.string "reset_password_token" 58 | t.string "remember_token" 59 | t.datetime "remember_created_at" 60 | t.integer "sign_in_count", :default => 0 61 | t.datetime "current_sign_in_at" 62 | t.datetime "last_sign_in_at" 63 | t.string "current_sign_in_ip" 64 | t.string "last_sign_in_ip" 65 | t.string "confirmation_token" 66 | t.datetime "confirmed_at" 67 | t.datetime "confirmation_sent_at" 68 | t.string "password_salt" 69 | t.datetime "created_at" 70 | t.datetime "updated_at" 71 | t.string "name" 72 | t.string "initials" 73 | end 74 | 75 | add_index "users", ["confirmation_token"], :name => "index_users_on_confirmation_token", :unique => true 76 | add_index "users", ["email"], :name => "index_users_on_email", :unique => true 77 | add_index "users", ["reset_password_token"], :name => "index_users_on_reset_password_token", :unique => true 78 | 79 | end 80 | -------------------------------------------------------------------------------- /app/views/projects/show.html.erb: -------------------------------------------------------------------------------- 1 | <%= javascript_tag "var AUTH_TOKEN = '#{form_authenticity_token}';" if protect_against_forgery? %> 2 | 29 | 35 | 36 | 64 | 65 | <% content_for :title_bar do %> 66 | <%= render :partial => 'projects/nav', :locals => {:project => @project} %> 67 | | Add story 68 | <% end %> 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 |
DoneIn ProgressBacklogChilly Bin
88 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: http://rubygems.org/ 3 | specs: 4 | abstract (1.0.0) 5 | actionmailer (3.0.9) 6 | actionpack (= 3.0.9) 7 | mail (~> 2.2.19) 8 | actionpack (3.0.9) 9 | activemodel (= 3.0.9) 10 | activesupport (= 3.0.9) 11 | builder (~> 2.1.2) 12 | erubis (~> 2.6.6) 13 | i18n (~> 0.5.0) 14 | rack (~> 1.2.1) 15 | rack-mount (~> 0.6.14) 16 | rack-test (~> 0.5.7) 17 | tzinfo (~> 0.3.23) 18 | activemodel (3.0.9) 19 | activesupport (= 3.0.9) 20 | builder (~> 2.1.2) 21 | i18n (~> 0.5.0) 22 | activerecord (3.0.9) 23 | activemodel (= 3.0.9) 24 | activesupport (= 3.0.9) 25 | arel (~> 2.0.10) 26 | tzinfo (~> 0.3.23) 27 | activeresource (3.0.9) 28 | activemodel (= 3.0.9) 29 | activesupport (= 3.0.9) 30 | activesupport (3.0.9) 31 | arel (2.0.10) 32 | bcrypt-ruby (2.1.4) 33 | builder (2.1.2) 34 | cancan (1.6.1) 35 | childprocess (0.1.9) 36 | ffi (~> 1.0.6) 37 | chunky_png (1.2.0) 38 | compass (0.11.5) 39 | chunky_png (~> 1.2) 40 | fssm (>= 0.2.7) 41 | sass (~> 3.1) 42 | devise (1.2.1) 43 | bcrypt-ruby (~> 2.1.2) 44 | orm_adapter (~> 0.0.3) 45 | warden (~> 1.0.3) 46 | diff-lcs (1.1.2) 47 | erubis (2.6.6) 48 | abstract (>= 1.0.0) 49 | factory_girl (1.3.3) 50 | factory_girl_rails (1.0.1) 51 | factory_girl (~> 1.3) 52 | railties (>= 3.0.0) 53 | fastercsv (1.5.3) 54 | ffi (1.0.9) 55 | fssm (0.2.7) 56 | i18n (0.5.0) 57 | jasmine (1.0.2.1) 58 | json_pure (>= 1.4.3) 59 | rack (>= 1.1) 60 | rspec (>= 1.3.1) 61 | selenium-webdriver (>= 0.1.3) 62 | json_pure (1.5.2) 63 | mail (2.2.19) 64 | activesupport (>= 2.3.6) 65 | i18n (>= 0.4.0) 66 | mime-types (~> 1.16) 67 | treetop (~> 1.4.8) 68 | mime-types (1.16) 69 | orm_adapter (0.0.5) 70 | polyglot (0.3.1) 71 | rack (1.2.3) 72 | rack-mount (0.6.14) 73 | rack (>= 1.0.0) 74 | rack-test (0.5.7) 75 | rack (>= 1.0) 76 | rails (3.0.9) 77 | actionmailer (= 3.0.9) 78 | actionpack (= 3.0.9) 79 | activerecord (= 3.0.9) 80 | activeresource (= 3.0.9) 81 | activesupport (= 3.0.9) 82 | bundler (~> 1.0) 83 | railties (= 3.0.9) 84 | railties (3.0.9) 85 | actionpack (= 3.0.9) 86 | activesupport (= 3.0.9) 87 | rake (>= 0.8.7) 88 | rdoc (~> 3.4) 89 | thor (~> 0.14.4) 90 | rake (0.9.2) 91 | rdoc (3.6.1) 92 | rspec (2.6.0) 93 | rspec-core (~> 2.6.0) 94 | rspec-expectations (~> 2.6.0) 95 | rspec-mocks (~> 2.6.0) 96 | rspec-core (2.6.4) 97 | rspec-expectations (2.6.0) 98 | diff-lcs (~> 1.1.2) 99 | rspec-mocks (2.6.0) 100 | rubyzip (0.9.4) 101 | sass (3.1.5) 102 | selenium-webdriver (0.2.1) 103 | childprocess (>= 0.1.7) 104 | ffi (>= 1.0.7) 105 | json_pure 106 | rubyzip 107 | sqlite3 (1.3.3) 108 | sqlite3-ruby (1.3.3) 109 | sqlite3 (>= 1.3.3) 110 | thor (0.14.6) 111 | transitions (0.0.9) 112 | treetop (1.4.9) 113 | polyglot (>= 0.3.1) 114 | tzinfo (0.3.28) 115 | warden (1.0.4) 116 | rack (>= 1.0) 117 | 118 | PLATFORMS 119 | ruby 120 | 121 | DEPENDENCIES 122 | cancan (= 1.6.1) 123 | compass (>= 0.11.5) 124 | devise (= 1.2.1) 125 | factory_girl_rails 126 | fastercsv (= 1.5.3) 127 | jasmine 128 | rails (= 3.0.9) 129 | sqlite3-ruby 130 | transitions (= 0.0.9) 131 | -------------------------------------------------------------------------------- /spec/javascripts/collections/story_collection.spec.js: -------------------------------------------------------------------------------- 1 | describe('StoryCollection collection', function() { 2 | 3 | beforeEach(function() { 4 | this.story1 = new Story({id: 1, title: "Story 1", position: '10.0'}); 5 | this.story2 = new Story({id: 2, title: "Story 2", position: '20.0'}); 6 | this.story3 = new Story({id: 3, title: "Story 3", position: '30.0'}); 7 | 8 | this.stories = new StoryCollection(); 9 | this.stories.add([this.story3, this.story2, this.story1]); 10 | }); 11 | 12 | describe('position', function() { 13 | 14 | it('should return stories in position order', function() { 15 | expect(this.stories.at(0)).toBe(this.story1); 16 | expect(this.stories.at(1)).toBe(this.story2); 17 | expect(this.stories.at(2)).toBe(this.story3); 18 | }); 19 | 20 | it('should move between 2 other stories', function() { 21 | 22 | expect(this.stories.at(2)).toBe(this.story3); 23 | 24 | this.story3.moveBetween(1,2); 25 | expect(this.story3.position()).toEqual(15.0); 26 | expect(this.stories.at(1).id).toEqual(this.story3.id); 27 | }); 28 | 29 | it('should move after another story', function() { 30 | 31 | expect(this.stories.at(2)).toBe(this.story3); 32 | 33 | this.story3.moveAfter(1); 34 | expect(this.story3.position()).toEqual(15.0); 35 | expect(this.stories.at(1).id).toEqual(this.story3.id); 36 | }); 37 | 38 | it('should move after the last story', function() { 39 | expect(this.stories.at(2)).toBe(this.story3); 40 | this.story1.moveAfter(3); 41 | expect(this.story1.position()).toEqual(31.0); 42 | expect(this.stories.at(2).id).toEqual(this.story1.id); 43 | }); 44 | 45 | it('should move before the first story', function() { 46 | expect(this.stories.at(0)).toBe(this.story1); 47 | this.story3.moveBefore(1); 48 | expect(this.story3.position()).toEqual(5.0); 49 | expect(this.stories.at(0).id).toEqual(this.story3.id); 50 | }); 51 | 52 | it('should move before another story', function() { 53 | 54 | expect(this.stories.at(2)).toBe(this.story3); 55 | 56 | this.story3.moveBefore(2); 57 | expect(this.story3.position()).toEqual(15.0); 58 | expect(this.stories.at(1).id).toEqual(this.story3.id); 59 | }); 60 | 61 | it('should return the story after a given story', function() { 62 | expect(this.stories.next(this.story1)).toBe(this.story2); 63 | 64 | // Should return undefined if there is no next story 65 | expect(this.stories.next(this.story3)).toBeUndefined(); 66 | }); 67 | 68 | it('should return the story before a given story', function() { 69 | expect(this.stories.previous(this.story3)).toBe(this.story2); 70 | 71 | // Should return undefined if there is no previous story 72 | expect(this.stories.previous(this.story1)).toBeUndefined(); 73 | }); 74 | 75 | it("should reset whenever a models position attr changes", function() { 76 | var spy = sinon.spy(); 77 | this.stories.bind("reset", spy); 78 | this.story1.set({position: 0.5}); 79 | expect(spy).toHaveBeenCalled(); 80 | }); 81 | 82 | it("should reset whenever a models state changes", function() { 83 | var spy = sinon.spy(); 84 | this.stories.bind("reset", spy); 85 | this.story1.set({state: 'unstarted'}); 86 | expect(spy).toHaveBeenCalled(); 87 | }); 88 | 89 | }); 90 | 91 | describe("columns", function() { 92 | 93 | it("should return all stories in the done column", function() { 94 | expect(this.stories.column('#done')).toEqual([]); 95 | this.story1.column = function() {return '#done';}; 96 | expect(this.stories.column('#done')).toEqual([this.story1]); 97 | }); 98 | 99 | it("returns a set of columns", function() { 100 | this.story1.column = function() {return '#done';}; 101 | this.story2.column = function() {return '#current';}; 102 | this.story3.column = function() {return '#backlog';}; 103 | expect(this.stories.columns(['#backlog', '#current', '#done'])) 104 | .toEqual([this.story3,this.story2,this.story1]); 105 | }); 106 | 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /app/models/story.rb: -------------------------------------------------------------------------------- 1 | class Story < ActiveRecord::Base 2 | 3 | JSON_ATTRIBUTES = [ 4 | "title", "accepted_at", "created_at", "updated_at", "description", 5 | "project_id", "story_type", "owned_by_id", "requested_by_id", "estimate", 6 | "state", "position", "id", "events", "estimable", "estimated" 7 | ] 8 | JSON_METHODS = [ 9 | "events", "estimable", "estimated", "errors" 10 | ] 11 | 12 | belongs_to :project 13 | validates_presence_of :project_id 14 | 15 | validates :title, :presence => true 16 | 17 | belongs_to :requested_by, :class_name => 'User' 18 | validates :requested_by_id, :belongs_to_project => true 19 | 20 | belongs_to :owned_by, :class_name => 'User' 21 | validates :owned_by_id, :belongs_to_project => true 22 | 23 | has_many :changesets 24 | 25 | # This attribute is used to store the user who is acting on a story, for 26 | # example delivering or modifying it. Usually set by the controller. 27 | attr_accessor :acting_user 28 | 29 | STORY_TYPES = [ 30 | 'feature', 'chore', 'bug', 'release' 31 | ] 32 | validates :story_type, :inclusion => STORY_TYPES 33 | 34 | validates :estimate, :estimate => true, :allow_nil => true 35 | 36 | before_validation :set_position_to_last 37 | before_save :set_accepted_at 38 | 39 | # Scopes for the different columns in the UI 40 | scope :done, where(:state => :accepted) 41 | scope :in_progress, where(:state => [:started, :finished, :delivered]) 42 | scope :backlog, where(:state => :unstarted) 43 | scope :chilly_bin, where(:state => :unscheduled) 44 | 45 | include ActiveRecord::Transitions 46 | state_machine do 47 | state :unscheduled 48 | state :unstarted 49 | state :started 50 | state :finished 51 | state :delivered 52 | state :accepted 53 | state :rejected 54 | 55 | event :start do 56 | transitions :to => :started, :from => [:unstarted, :unscheduled] 57 | end 58 | 59 | event :finish do 60 | transitions :to => :finished, :from => :started 61 | end 62 | 63 | event :deliver do 64 | transitions :to => :delivered, :from => :finished 65 | end 66 | 67 | event :accept do 68 | transitions :to => :accepted, :from => :delivered 69 | end 70 | 71 | event :reject do 72 | transitions :to => :rejected, :from => :delivered 73 | end 74 | 75 | event :restart do 76 | transitions :to => :started, :from => :rejected 77 | end 78 | end 79 | 80 | def to_s 81 | title 82 | end 83 | 84 | # Returns the list of state change events that can operate on this story, 85 | # based on its current state 86 | def events 87 | self.class.state_machine.events_for(current_state) 88 | end 89 | 90 | # Returns true or false based on whether the story has been estimated. 91 | def estimated? 92 | !estimate.nil? 93 | end 94 | alias :estimated :estimated? 95 | 96 | # Returns true if this story can have an estimate made against it 97 | def estimable? 98 | story_type == 'feature' && !estimated? 99 | end 100 | alias :estimable :estimable? 101 | 102 | # Returns the CSS id of the column this story belongs in 103 | def column 104 | case state 105 | when 'unscheduled' 106 | '#chilly_bin' 107 | when 'unstarted' 108 | '#backlog' 109 | when 'accepted' 110 | '#done' 111 | else 112 | '#in_progress' 113 | end 114 | end 115 | 116 | def as_json(options = {}) 117 | super(:only => JSON_ATTRIBUTES, :methods => JSON_METHODS) 118 | end 119 | 120 | def set_position_to_last 121 | return true if position 122 | last = project.stories.first(:order => 'position DESC') 123 | if last 124 | self.position = last.position + 1 125 | else 126 | self.position = 1 127 | end 128 | end 129 | 130 | private 131 | 132 | def set_accepted_at 133 | if state_changed? 134 | if state == 'accepted' && accepted_at == nil 135 | # Set accepted at to today when accepted 136 | self.accepted_at = Date.today 137 | elsif state_was == 'accepted' 138 | # Unset accepted at when changing from accepted to something else 139 | self.accepted_at = nil 140 | end 141 | end 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /app/stylesheets/locomotive.scss: -------------------------------------------------------------------------------- 1 | @import 'compass/css3'; 2 | @import 'scaffold'; 3 | @import 'jquery_gritter'; 4 | 5 | body { 6 | background: transparent url(/images/locomotive/main_bg.png) repeat 0 0; 7 | 8 | font-family: helvetica, arial !important; 9 | 10 | &.public-view { 11 | .storycolumn .story .story-title { 12 | cursor: default !important; 13 | } 14 | } 15 | } 16 | 17 | #header { 18 | background: transparent url(/images/locomotive/header_bg.png) repeat-x 0 0; 19 | 20 | border-bottom: none; 21 | @include box-shadow(0 0px 0px transparent); 22 | } 23 | 24 | ul#primary-nav { 25 | background: none; 26 | 27 | padding-left: 0px; 28 | 29 | li { 30 | &.root { 31 | width: 363px; 32 | height: 44px; 33 | 34 | a { 35 | position: relative; 36 | top: -3px; 37 | 38 | width: 100%; 39 | height: 100%; 40 | 41 | text-align: left; 42 | 43 | background: transparent url(/images/locomotive/title.jpg) no-repeat 0 0; 44 | display: block; 45 | text-indent: -9999px; 46 | } 47 | } 48 | 49 | &.secondary { 50 | margin-right: 10px; 51 | padding: 1px 18px 6px 6px; 52 | 53 | background-color: rgba(0, 0, 0, 0.6); 54 | 55 | ul { 56 | padding: 0px; 57 | width: 100%; 58 | 59 | background: rgba(0, 0, 0, 0.6); 60 | 61 | li { 62 | padding: 6px; 63 | } 64 | } 65 | 66 | } 67 | 68 | a { 69 | font-weight: normal; 70 | color: #1F82BC; 71 | @include text-shadow(rgba(0, 0, 0, 1), 0px, 1px, 1px); 72 | 73 | &:hover { 74 | color: #fff; 75 | } 76 | } 77 | 78 | } 79 | } 80 | 81 | #title_bar { 82 | @include background(linear-gradient(top, #d4214d, #8f0821)); 83 | 84 | padding: 10px 17px; 85 | 86 | h1 { 87 | color: #fff; 88 | font-size: 18px; 89 | @include text-shadow(rgba(0, 0, 0, 1), 0px, 1px, 1px); 90 | } 91 | 92 | @include box-shadow(0 0 0 transparent); 93 | 94 | border-bottom: 1px solid #fff; 95 | } 96 | 97 | #main { 98 | padding: 10px 10px 0 10px; 99 | margin: 0px; 100 | 101 | p.notice, p.alert { 102 | display: none; 103 | } 104 | 105 | ul#projects { 106 | 107 | li { 108 | position: relative; 109 | 110 | @include border-radius(4px); 111 | background: #d1d4dd; 112 | border: 1px solid #999ba7; 113 | 114 | color: #707380; 115 | @include text-shadow(rgba(255, 255, 255, 0.6), 0px, 1px, 1px); 116 | 117 | h2 { 118 | display: block; 119 | margin: 0px; 120 | padding: 0 0 10px 0; 121 | 122 | a { 123 | text-decoration: none; 124 | font-weight: normal; 125 | color: #444; 126 | 127 | &:hover { 128 | text-decoration: underline; 129 | } 130 | } 131 | 132 | border-bottom: 1px solid #a7a9b3; 133 | } 134 | 135 | .date { 136 | position: absolute; 137 | top: 10px; 138 | right: 10px; 139 | } 140 | 141 | .description { 142 | color: #444; 143 | font-weight: 100; 144 | 145 | margin-top: 0px; 146 | padding-top: 10px; 147 | 148 | border-top: 1px solid #e9eaee; 149 | 150 | a { 151 | color: #1F82BC; 152 | text-decoration: none; 153 | 154 | &:hover { 155 | text-decoration: underline; 156 | } 157 | } 158 | } 159 | 160 | p { 161 | margin-bottom: 0px; 162 | 163 | font-size: 12px; 164 | } 165 | } 166 | } 167 | } 168 | 169 | table.stories { 170 | 171 | margin-top: 10px; 172 | 173 | th { 174 | @include background(linear-gradient(top, #33343c, #1e1f26)); 175 | 176 | line-height: 30px; 177 | padding: 0px; 178 | 179 | font-size: 14px; 180 | 181 | 182 | 183 | @include text-shadow(rgba(0, 0, 0, 1), 0px, 1px, 1px); 184 | } 185 | 186 | td { 187 | background: #d1d4dd url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAEUlEQVQYlWNgIA4YjyqisyIAlxIB/2n+IBUAAAAASUVORK5CYII=) repeat 0 0; 188 | 189 | padding: 0px !important; 190 | 191 | .iteration { 192 | background-color: #085f92; 193 | } 194 | } 195 | 196 | } 197 | -------------------------------------------------------------------------------- /spec/javascripts/models/iteration.spec.js: -------------------------------------------------------------------------------- 1 | describe("iteration", function() { 2 | 3 | beforeEach(function() { 4 | this.iteration = new Iteration(); 5 | }); 6 | 7 | describe("initialize", function() { 8 | 9 | it("should assign stories if passed", function() { 10 | var stories = [1,2,3]; 11 | var iteration = new Iteration({'stories': stories}); 12 | expect(iteration.get('stories')).toEqual(stories); 13 | }); 14 | 15 | }); 16 | 17 | describe("defaults", function() { 18 | 19 | it("should have an empty array of stories", function() { 20 | expect(this.iteration.get('stories')).toEqual([]); 21 | }); 22 | 23 | }); 24 | 25 | describe("points", function() { 26 | 27 | beforeEach(function() { 28 | var Story = Backbone.Model.extend({name: 'story'}); 29 | this.stories = [ 30 | new Story({estimate: 2, story_type: 'feature'}), 31 | new Story({estimate: 3, story_type: 'feature'}), 32 | new Story({estimate: 3, story_type: 'bug'}) // Only features count 33 | // towards velocity 34 | ]; 35 | this.iteration.set({stories: this.stories}); 36 | }); 37 | 38 | it("should calculate its points", function() { 39 | expect(this.iteration.points()).toEqual(5); 40 | }); 41 | 42 | it("should return 0 for points if it has no stories", function() { 43 | this.iteration.unset('stories'); 44 | expect(this.iteration.points()).toEqual(0); 45 | }); 46 | 47 | it("should report how many points it overflows by", function() { 48 | // Should return 0 49 | this.iteration.set({'maximum_points':2}) 50 | var pointsStub = sinon.stub(this.iteration, 'points'); 51 | 52 | // Should return 0 if the iteration points are less than maximum_points 53 | pointsStub.returns(1); 54 | expect(this.iteration.overflowsBy()).toEqual(0); 55 | 56 | // Should return 0 if the iteration points are equal to maximum_points 57 | pointsStub.returns(2); 58 | expect(this.iteration.overflowsBy()).toEqual(0); 59 | 60 | // Should return the difference if iteration points are greater than 61 | // maximum_points 62 | pointsStub.returns(5); 63 | expect(this.iteration.overflowsBy()).toEqual(3); 64 | }); 65 | 66 | }); 67 | 68 | describe("filling backlog iterations", function() { 69 | 70 | it("should return how many points are available", function() { 71 | var pointsStub = sinon.stub(this.iteration, "points"); 72 | pointsStub.returns(3); 73 | 74 | this.iteration.set({'maximum_points': 5}); 75 | expect(this.iteration.availablePoints()).toEqual(2); 76 | }); 77 | 78 | it("should always accept chores bugs and releases", function() { 79 | var stub = sinon.stub(); 80 | var story = {get: stub}; 81 | 82 | stub.withArgs('story_type').returns('chore'); 83 | expect(this.iteration.canTakeStory(story)).toBeTruthy(); 84 | stub.withArgs('story_type').returns('bug'); 85 | expect(this.iteration.canTakeStory(story)).toBeTruthy(); 86 | stub.withArgs('story_type').returns('release'); 87 | expect(this.iteration.canTakeStory(story)).toBeTruthy(); 88 | }); 89 | 90 | it("should accept a feature if there are enough free points", function() { 91 | var availablePointsStub = sinon.stub(this.iteration, "availablePoints"); 92 | availablePointsStub.returns(3); 93 | var pointsStub = sinon.stub(this.iteration, 'points'); 94 | pointsStub.returns(1); 95 | 96 | var stub = sinon.stub(); 97 | var story = {get: stub}; 98 | 99 | stub.withArgs('story_type').returns('feature'); 100 | stub.withArgs('estimate').returns(3); 101 | 102 | expect(this.iteration.canTakeStory(story)).toBeTruthy(); 103 | 104 | // Story is too big to fit in iteration 105 | stub.withArgs('estimate').returns(4); 106 | expect(this.iteration.canTakeStory(story)).toBeFalsy(); 107 | }); 108 | 109 | // Each iteration should take at least one feature 110 | it("should always take at least one feature no matter how big", function() { 111 | var availablePointsStub = sinon.stub(this.iteration, "availablePoints"); 112 | availablePointsStub.returns(1); 113 | 114 | var stub = sinon.stub(); 115 | var story = {get: stub}; 116 | stub.withArgs('story_type').returns('feature'); 117 | stub.withArgs('estimate').returns(2); 118 | 119 | expect(this.iteration.points()).toEqual(0); 120 | expect(this.iteration.canTakeStory(story)).toBeTruthy(); 121 | }); 122 | 123 | 124 | }); 125 | 126 | }); 127 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Fulcrum 2 | ======= 3 | 4 | Fulcrum is an application to provide a user story based backlog management 5 | system for agile development teams. See 6 | [the project page](http://wholemeal.co.nz/projects/fulcrum.html) for more 7 | details. 8 | 9 | Get involved 10 | ------------ 11 | 12 | Fulcrum is still in early development, so now is the time to make your mark on 13 | the project. 14 | 15 | There are 2 discussion groups for Fulcrum: 16 | 17 | * [Fulcrum Users](http://groups.google.com/group/fulcrum-users) - A general 18 | discussion group for users of Fulcrum. 19 | * [Fulcrum Developers](http://groups.google.com/group/fulcrum-devel) - Discussion 20 | on the development of Fulcrum. 21 | 22 | Goals 23 | ----- 24 | 25 | Fulcrum is a clone of [Pivotal Tracker](http://pivotaltracker.com/). It will 26 | almost certainly never surpass the functionality, usability and sheer 27 | awesomeness of Pivotal Tracker, but aims to provide a usable alternative for 28 | users who require a Free and Open Source solution. 29 | 30 | Installation 31 | ------------ 32 | 33 | Fulcrum is still a work in progress, but if you're really keen to try it out 34 | these instructions will hopefully help you get up and running. 35 | 36 | First up, your system will need the 37 | [prerequisites for running Ruby on Rails 3.0.x installed](http://rubyonrails.org/download) 38 | 39 | Once you have these: 40 | 41 | # Checkout the project 42 | $ git clone git://github.com/malclocke/fulcrum.git 43 | $ cd fulcrum 44 | 45 | # Install the project dependencies 46 | $ gem install bundler 47 | $ bundle install 48 | 49 | # Set up the development database 50 | $ rake fulcrum:setup db:setup 51 | 52 | # Start the local web server 53 | $ rails server 54 | 55 | You should then be able to navigate to `http://localhost:3000/` in a web browser. 56 | You can log in with the test username `test@example.com`, password `testpass`. 57 | 58 | 59 | Heroku setup 60 | ------------ 61 | 62 | If you wish to host a publicly available copy of Fulcrum, the easiest option is 63 | to host it on [Heroku](http://heroku.com/). 64 | 65 | To deploy it to Heroku, make sure you have a local copy of the project; refer 66 | to the previous section for instuctions. Then: 67 | 68 | # Make sure you have the Heroku gem 69 | $ gem install heroku 70 | 71 | # Create your app. Replace APPNAME with whatever you want to name it. 72 | $ heroku create APPNAME --stack bamboo-mri-1.9.2 73 | 74 | # Define where the user emails will be coming from 75 | # (This email address does not need to exist) 76 | $ heroku config:add MAILER_SENDER=noreply@example.org 77 | 78 | # Allow emails to be sent 79 | $ heroku addons:add sendgrid:free 80 | 81 | # Deploy the first version 82 | $ git push heroku master 83 | 84 | # Set up the database 85 | $ heroku rake db:setup 86 | 87 | Once that's done, you will be able to view your site at 88 | `http://APPNAME.heroku.com`. 89 | 90 | Development 91 | ----------- 92 | 93 | Fulcrum is currently welcoming contributions, but if you're planning on 94 | implementing a major feature please contact us first, your feature may 95 | already be in progress. 96 | 97 | The following features are being developed actively at the moment: 98 | 99 | * Iterations 100 | * Comments 101 | 102 | Particularly welcome at the time of writing would be UI improvement and 103 | clean ups. 104 | 105 | For any development, please bear the following in mind: 106 | 107 | * Please send patches as either github pull requests or as git patches. 108 | Try to break patches up into the smallest logical blocks possible. We'd 109 | prefer to receive many small commits to one large one. 110 | * All patches should be covered by tests, and should not break the existing 111 | tests, unless a current test is invalidated by a code change. This includes 112 | Javascript, which is covered with a Jasmine test 113 | suite in `spec/javascripts/`. 114 | * For any UI changes, please try to follow the 115 | [Tango theme guidelines](http://tango.freedesktop.org/Tango_Icon_Theme_Guidelines). 116 | 117 | 118 | Colophon 119 | -------- 120 | 121 | Fulcrum is built with the following Open Source technologies: 122 | 123 | * [Ruby on Rails](http://rubyonrails.org/) 124 | * [Backbone.js](http://documentcloud.github.com/backbone/) 125 | * [jQuery](http://jquery.com/) 126 | * [Tango Icon Library](http://tango.freedesktop.org/Tango_Icon_Library) 127 | * [Jasmine](http://pivotal.github.com/jasmine/) 128 | * [Sinon](http://sinonjs.org/) 129 | 130 | License 131 | ------- 132 | Copyright 2011, Malcolm Locke. 133 | 134 | Fulcrum is made available under the Affero GPL license version 3, see 135 | LICENSE.txt. 136 | -------------------------------------------------------------------------------- /test/functional/users_controller_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class UsersControllerTest < ActionController::TestCase 4 | def setup 5 | @user = Factory.create(:user) 6 | @project = Factory.create(:project, :users => [@user]) 7 | end 8 | 9 | test "should not get project users if not logged in" do 10 | get :index, :project_id => @project.to_param 11 | assert_redirected_to new_user_session_url 12 | end 13 | 14 | test "should get project users" do 15 | sign_in @user 16 | get :index, :project_id => @project.to_param 17 | assert_response :success 18 | assert_equal @project, assigns(:project) 19 | assert assigns(:user).new_record? 20 | assert_equal [@user], assigns(:users) 21 | end 22 | 23 | test "should get project users in json format" do 24 | sign_in @user 25 | get :index, :project_id => @project.to_param, :format => 'json' 26 | assert_response :success 27 | assert_equal @project, assigns(:project) 28 | assert_equal @project.users, assigns(:users) 29 | assert_equal Mime::JSON, response.content_type 30 | end 31 | 32 | 33 | test "should not get other users project users" do 34 | user = Factory.create(:user) 35 | sign_in user 36 | get :index, :project_id => @project.to_param 37 | assert_response :missing 38 | end 39 | 40 | test "should add existing user as project member" do 41 | user = Factory.create(:user) 42 | sign_in @user 43 | 44 | # Because this user already exists, no new users should be created, but 45 | # the user should be added to the project users collection. 46 | assert_no_difference 'User.count' do 47 | post :create, :project_id => @project.to_param, 48 | :user => {:email => user.email} 49 | end 50 | assert_equal @project, assigns(:project) 51 | assert_equal user, assigns(:user) 52 | assert assigns(:project).users.include?(user) 53 | assert_equal "#{user.email} was added to this project", flash[:notice] 54 | assert_redirected_to project_users_path(@project) 55 | end 56 | 57 | test "should create a new user as project member" do 58 | sign_in @user 59 | 60 | assert_difference ['User.count', 'ActionMailer::Base.deliveries.size'] do 61 | post :create, :project_id => @project.to_param, 62 | :user => { 63 | :name => 'New User', :initials => 'NU', 64 | :email => 'new_user@example.com' 65 | } 66 | assert_not_nil assigns(:users) 67 | assert_equal @project, assigns(:project) 68 | assert_equal 'new_user@example.com', assigns(:user).email 69 | assert !assigns(:user).confirmed? 70 | assert assigns(:project).users.include?(assigns(:user)) 71 | assert_equal "new_user@example.com was sent an invite to join this project", 72 | flash[:notice] 73 | assert_redirected_to project_users_path(@project) 74 | end 75 | end 76 | 77 | test "should not create a new invalid user as project member" do 78 | sign_in @user 79 | 80 | assert_no_difference ['User.count', 'ActionMailer::Base.deliveries.size'] do 81 | post :create, :project_id => @project.to_param, 82 | :user => { 83 | :email => 'new_user@example.com' 84 | } 85 | assert_not_nil assigns(:users) 86 | assert_equal @project, assigns(:project) 87 | assert_equal 'new_user@example.com', assigns(:user).email 88 | assert_response :success 89 | end 90 | end 91 | 92 | test "should not add a user who is already a member" do 93 | user = Factory.create(:user) 94 | @project.users << user 95 | 96 | sign_in @user 97 | 98 | assert_no_difference '@project.users.count' do 99 | post :create, :project_id => @project.to_param, 100 | :user => {:email => user.email} 101 | end 102 | assert_equal "#{user.email} is already a member of this project", 103 | flash[:alert] 104 | assert_redirected_to project_users_path(@project) 105 | end 106 | 107 | test "should not create a user for someone elses project" do 108 | user = Factory.create(:user) 109 | sign_in user 110 | 111 | assert_no_difference ['User.count', 'ActionMailer::Base.deliveries.size'] do 112 | post :create, :project_id => @project.to_param, 113 | :user => {:email => 'new_user@example.com'} 114 | end 115 | 116 | assert_response :missing 117 | end 118 | 119 | test "should remove a project member" do 120 | user = Factory.create(:user) 121 | @project.users << user 122 | 123 | sign_in @user 124 | 125 | assert_difference '@project.users.count', -1 do 126 | delete :destroy, :project_id => @project.to_param, 127 | :id => user.id 128 | end 129 | assert_equal @project, assigns(:project) 130 | assert_equal user, assigns(:user) 131 | assert_redirected_to project_users_url(@project) 132 | end 133 | 134 | end 135 | -------------------------------------------------------------------------------- /public/javascripts/jquery.gritter.min.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Gritter for jQuery 3 | * http://www.boedesign.com/ 4 | * 5 | * Copyright (c) 2011 Jordan Boesch 6 | * Dual licensed under the MIT and GPL licenses. 7 | * 8 | * Date: March 29, 2011 9 | * Version: 1.7.1 10 | */ 11 | (function($){$.gritter={};$.gritter.options={position:'',fade_in_speed:'medium',fade_out_speed:1000,time:6000} 12 | $.gritter.add=function(params){try{return Gritter.add(params||{});}catch(e){var err='Gritter Error: '+e;(typeof(console)!='undefined'&&console.error)?console.error(err,params):alert(err);}} 13 | $.gritter.remove=function(id,params){Gritter.removeSpecific(id,params||{});} 14 | $.gritter.removeAll=function(params){Gritter.stop(params||{});} 15 | var Gritter={position:'',fade_in_speed:'',fade_out_speed:'',time:'',_custom_timer:0,_item_count:0,_is_setup:0,_tpl_close:'
',_tpl_item:'',_tpl_wrap:'
',add:function(params){if(!params.title||!params.text){throw'You need to fill out the first 2 params: "title" and "text"';} 16 | if(!this._is_setup){this._runSetup();} 17 | var user=params.title,text=params.text,image=params.image||'',sticky=params.sticky||false,item_class=params.class_name||'',position=$.gritter.options.position,time_alive=params.time||'';this._verifyWrapper();this._item_count++;var number=this._item_count,tmp=this._tpl_item;$(['before_open','after_open','before_close','after_close']).each(function(i,val){Gritter['_'+val+'_'+number]=($.isFunction(params[val]))?params[val]:function(){}});this._custom_timer=0;if(time_alive){this._custom_timer=time_alive;} 18 | var image_str=(image!='')?'':'',class_name=(image!='')?'gritter-with-image':'gritter-without-image';tmp=this._str_replace(['[[username]]','[[text]]','[[image]]','[[number]]','[[class_name]]','[[item_class]]'],[user,text,image_str,this._item_count,class_name,item_class],tmp);this['_before_open_'+number]();$('#gritter-notice-wrapper').addClass(position).append(tmp);var item=$('#gritter-item-'+this._item_count);item.fadeIn(this.fade_in_speed,function(){Gritter['_after_open_'+number]($(this));});if(!sticky){this._setFadeTimer(item,number);} 19 | $(item).bind('mouseenter mouseleave',function(event){if(event.type=='mouseenter'){if(!sticky){Gritter._restoreItemIfFading($(this),number);}} 20 | else{if(!sticky){Gritter._setFadeTimer($(this),number);}} 21 | Gritter._hoverState($(this),event.type);});return number;},_countRemoveWrapper:function(unique_id,e,manual_close){e.remove();this['_after_close_'+unique_id](e,manual_close);if($('.gritter-item-wrapper').length==0){$('#gritter-notice-wrapper').remove();}},_fade:function(e,unique_id,params,unbind_events){var params=params||{},fade=(typeof(params.fade)!='undefined')?params.fade:true;fade_out_speed=params.speed||this.fade_out_speed,manual_close=unbind_events;this['_before_close_'+unique_id](e,manual_close);if(unbind_events){e.unbind('mouseenter mouseleave');} 22 | if(fade){e.animate({opacity:0},fade_out_speed,function(){e.animate({height:0},300,function(){Gritter._countRemoveWrapper(unique_id,e,manual_close);})})} 23 | else{this._countRemoveWrapper(unique_id,e);}},_hoverState:function(e,type){if(type=='mouseenter'){e.addClass('hover');var find_img=e.find('img');(find_img.length)?find_img.before(this._tpl_close):e.find('span').before(this._tpl_close);e.find('.gritter-close').click(function(){var unique_id=e.attr('id').split('-')[2];Gritter.removeSpecific(unique_id,{},e,true);});} 24 | else{e.removeClass('hover');e.find('.gritter-close').remove();}},removeSpecific:function(unique_id,params,e,unbind_events){if(!e){var e=$('#gritter-item-'+unique_id);} 25 | this._fade(e,unique_id,params||{},unbind_events);},_restoreItemIfFading:function(e,unique_id){clearTimeout(this['_int_id_'+unique_id]);e.stop().css({opacity:''});},_runSetup:function(){for(opt in $.gritter.options){this[opt]=$.gritter.options[opt];} 26 | this._is_setup=1;},_setFadeTimer:function(e,unique_id){var timer_str=(this._custom_timer)?this._custom_timer:this.time;this['_int_id_'+unique_id]=setTimeout(function(){Gritter._fade(e,unique_id);},timer_str);},stop:function(params){var before_close=($.isFunction(params.before_close))?params.before_close:function(){};var after_close=($.isFunction(params.after_close))?params.after_close:function(){};var wrap=$('#gritter-notice-wrapper');before_close(wrap);wrap.fadeOut(function(){$(this).remove();after_close();});},_str_replace:function(search,replace,subject,count){var i=0,j=0,temp='',repl='',sl=0,fl=0,f=[].concat(search),r=[].concat(replace),s=subject,ra=r instanceof Array,sa=s instanceof Array;s=[].concat(s);if(count){this.window[count]=0;} 27 | for(i=0,sl=s.length;i @stories 9 | end 10 | 11 | def show 12 | @project = current_user.projects.find(params[:project_id]) 13 | @story = @project.stories.find(params[:id]) 14 | render :json => @story 15 | end 16 | 17 | def update 18 | @project = current_user.projects.find(params[:project_id]) 19 | @story = @project.stories.find(params[:id]) 20 | @story.acting_user = current_user 21 | respond_to do |format| 22 | if @story.update_attributes(filter_story_params) 23 | format.html { redirect_to project_url(@project) } 24 | format.js { render :json => @story } 25 | else 26 | format.html { render :action => 'edit' } 27 | format.js { render :json => @story, :status => :unprocessable_entity } 28 | end 29 | end 30 | end 31 | 32 | def destroy 33 | @project = current_user.projects.find(params[:project_id]) 34 | @story = @project.stories.find(params[:id]) 35 | @story.destroy 36 | head :ok 37 | end 38 | 39 | def done 40 | @project = current_user.projects.find(params[:project_id]) 41 | @stories = @project.stories.done 42 | render :json => @stories 43 | end 44 | def backlog 45 | @project = current_user.projects.find(params[:project_id]) 46 | @stories = @project.stories.backlog 47 | render :json => @stories 48 | end 49 | def in_progress 50 | @project = current_user.projects.find(params[:project_id]) 51 | @stories = @project.stories.in_progress 52 | render :json => @stories 53 | end 54 | 55 | def create 56 | @project = current_user.projects.find(params[:project_id]) 57 | @story = @project.stories.build(filter_story_params) 58 | @story.requested_by_id = current_user.id unless @story.requested_by_id 59 | respond_to do |format| 60 | if @story.save 61 | format.html { redirect_to project_url(@project) } 62 | format.js { render :json => @story } 63 | else 64 | format.html { render :action => 'new' } 65 | format.js { render :json => @story, :status => :unprocessable_entity } 66 | end 67 | end 68 | end 69 | 70 | def start 71 | state_change(:start!) 72 | end 73 | 74 | def finish 75 | state_change(:finish!) 76 | end 77 | 78 | def deliver 79 | state_change(:deliver!) 80 | end 81 | 82 | def accept 83 | state_change(:accept!) 84 | end 85 | 86 | def reject 87 | state_change(:reject!) 88 | end 89 | 90 | # CSV import form 91 | def import 92 | @project = current_user.projects.find(params[:project_id]) 93 | end 94 | 95 | # CSV import 96 | def import_upload 97 | 98 | @project = current_user.projects.find(params[:project_id]) 99 | 100 | stories = [] 101 | 102 | if params[:csv].blank? 103 | 104 | flash[:alert] = "You must select a file for import" 105 | 106 | else 107 | 108 | # FIXME Move to model 109 | begin 110 | 111 | # Eager load this so that we don't have to make multiple db calls when 112 | # searching for users by full name from the CSV. 113 | users = @project.users 114 | 115 | csv = CSV.parse(File.read(params[:csv].path), :headers => true) 116 | csv.each do |row| 117 | row = row.to_hash 118 | stories << { 119 | :state => row["Current State"], 120 | :title => row["Story"], 121 | :story_type => row["Story Type"], 122 | :requested_by => users.detect {|u| u.name == row["Requested By"]}, 123 | :owned_by => users.detect {|u| u.name == row["Owned By"]}, 124 | :accepted_at => row["Accepted at"], 125 | :estimate => row["Estimate"] 126 | } 127 | end 128 | @stories = @project.stories.create(stories) 129 | @valid_stories = @stories.select(&:valid?) 130 | @invalid_stories = @stories.reject(&:valid?) 131 | flash[:notice] = "Imported #{pluralize(@valid_stories.count, "story")}" 132 | 133 | unless @invalid_stories.empty? 134 | flash[:alert] = "#{pluralize(@invalid_stories.count, "story")} failed to import" 135 | end 136 | rescue CSV::MalformedCSVError => e 137 | flash[:alert] = "Unable to import CSV: #{e.message}" 138 | end 139 | 140 | end 141 | 142 | render 'import' 143 | 144 | end 145 | 146 | private 147 | def state_change(transition) 148 | @project = current_user.projects.find(params[:project_id]) 149 | 150 | @story = @project.stories.find(params[:id]) 151 | @story.send(transition) 152 | 153 | redirect_to project_url(@project) 154 | end 155 | 156 | # Removes all unwanted keys from the params hash passed for Backbone 157 | def filter_story_params 158 | allowed = [ 159 | :title, :description, :estimate, :story_type, :state, :requested_by_id, 160 | :owned_by_id, :position 161 | ] 162 | filtered = {} 163 | params[:story].each do |key, value| 164 | filtered[key.to_sym] = value if allowed.include?(key.to_sym) 165 | end 166 | filtered 167 | end 168 | end 169 | -------------------------------------------------------------------------------- /public/javascripts/models/story.js: -------------------------------------------------------------------------------- 1 | var Story = Backbone.Model.extend({ 2 | name: 'story', 3 | 4 | initialize: function(args) { 5 | this.bind('change:state', this.changeState); 6 | // FIXME Call super()? 7 | this.maybeUnwrap(args); 8 | }, 9 | 10 | changeState: function(model, new_value) { 11 | if (new_value == "started") { 12 | model.set({owned_by_id: model.collection.project.current_user.id}, true); 13 | } 14 | 15 | if (new_value == "accepted" && !model.get('accepted_at')) { 16 | var today = new Date(); 17 | today.setHours(0); 18 | today.setMinutes(0); 19 | today.setSeconds(0); 20 | today.setMilliseconds(0); 21 | model.set({accepted_at: today}); 22 | } 23 | }, 24 | 25 | moveBetween: function(before, after) { 26 | var beforeStory = this.collection.get(before); 27 | var afterStory = this.collection.get(after); 28 | var difference = (afterStory.position() - beforeStory.position()) / 2; 29 | var newPosition = difference + beforeStory.position(); 30 | this.set({position: newPosition}); 31 | this.collection.sort(); 32 | return this; 33 | }, 34 | 35 | moveAfter: function(beforeId) { 36 | var before = this.collection.get(beforeId); 37 | var after = this.collection.next(before); 38 | if (typeof after == 'undefined') { 39 | afterPosition = before.position() + 2; 40 | } else { 41 | afterPosition = after.position(); 42 | } 43 | var difference = (afterPosition - before.position()) / 2; 44 | var newPosition = difference + before.position(); 45 | 46 | this.set({position: newPosition}); 47 | return this; 48 | }, 49 | 50 | moveBefore: function(afterId) { 51 | var after = this.collection.get(afterId); 52 | var before = this.collection.previous(after); 53 | if (typeof before == 'undefined') { 54 | beforePosition = 0.0; 55 | } else { 56 | beforePosition = before.position(); 57 | } 58 | var difference = (after.position() - beforePosition) / 2; 59 | var newPosition = difference + beforePosition; 60 | 61 | this.set({position: newPosition}); 62 | this.collection.sort({silent: true}); 63 | return this; 64 | }, 65 | 66 | defaults: { 67 | events: [], 68 | state: "unscheduled", 69 | story_type: "feature" 70 | }, 71 | 72 | column: function() { 73 | switch(this.get('state')) { 74 | case 'unscheduled': 75 | return '#chilly_bin'; 76 | break; 77 | case 'unstarted': 78 | return '#backlog'; 79 | break; 80 | case 'accepted': 81 | // Accepted stories remain in the in progress column if they were 82 | // completed within the current iteration. 83 | if (this.collection.project.currentIterationNumber() === this.iterationNumber()) { 84 | return '#in_progress'; 85 | } else { 86 | return '#done'; 87 | } 88 | break; 89 | default: 90 | return '#in_progress'; 91 | break; 92 | } 93 | }, 94 | 95 | clear: function() { 96 | if (!this.isNew()) { 97 | this.destroy(); 98 | } 99 | this.collection.remove(this); 100 | this.view.remove(); 101 | }, 102 | 103 | estimable: function() { 104 | return this.get('story_type') === 'feature'; 105 | }, 106 | 107 | estimated: function() { 108 | return typeof this.get('estimate') !== 'undefined'; 109 | }, 110 | 111 | point_values: function() { 112 | return this.collection.project.get('point_values'); 113 | }, 114 | 115 | // State machine transitions 116 | start: function() { 117 | this.set({state: "started"}); 118 | }, 119 | 120 | finish: function() { 121 | this.set({state: "finished"}); 122 | }, 123 | 124 | deliver: function() { 125 | this.set({state: "delivered"}); 126 | }, 127 | 128 | accept: function() { 129 | this.set({state: "accepted"}); 130 | }, 131 | 132 | reject: function() { 133 | this.set({state: "rejected"}); 134 | }, 135 | 136 | restart: function() { 137 | this.set({state: "started"}); 138 | }, 139 | 140 | position: function() { 141 | return parseFloat(this.get('position')); 142 | }, 143 | 144 | className: function() { 145 | var className = 'story ' + this.get('story_type'); 146 | if (this.estimable() && !this.estimated()) { 147 | className += ' unestimated'; 148 | } 149 | return className; 150 | }, 151 | 152 | hasErrors: function() { 153 | return (typeof this.get('errors') != "undefined"); 154 | }, 155 | 156 | errorsOn: function(field) { 157 | if (!this.hasErrors()) return false; 158 | return (typeof this.get('errors')[field] != "undefined"); 159 | }, 160 | 161 | errorMessages: function() { 162 | return _.map(this.get('errors'), function(errors, field) { 163 | return _.map(errors, function(error) { 164 | return field + " " + error; 165 | }).join(', '); 166 | }).join(', '); 167 | }, 168 | 169 | // Returns the user that owns this Story, or undefined if no user owns 170 | // the Story 171 | owned_by: function() { 172 | return this.collection.project.users.get(this.get('owned_by_id')); 173 | }, 174 | 175 | hasDetails: function() { 176 | return typeof this.get('description') == "string"; 177 | }, 178 | 179 | iterationNumber: function() { 180 | if (this.get('state') === "accepted") { 181 | return this.collection.project.getIterationNumberForDate(new Date(this.get("accepted_at"))); 182 | } 183 | } 184 | }); 185 | -------------------------------------------------------------------------------- /public/javascripts/jquery.tmpl.min.js: -------------------------------------------------------------------------------- 1 | (function(a){var r=a.fn.domManip,d="_tmplitem",q=/^[^<]*(<[\w\W]+>)[^>]*$|\{\{\! /,b={},f={},e,p={key:0,data:{}},h=0,c=0,l=[];function g(e,d,g,i){var c={data:i||(d?d.data:{}),_wrap:d?d._wrap:null,tmpl:null,parent:d||null,nodes:[],calls:u,nest:w,wrap:x,html:v,update:t};e&&a.extend(c,e,{nodes:[],parent:d});if(g){c.tmpl=g;c._ctnt=c._ctnt||c.tmpl(a,c);c.key=++h;(l.length?f:b)[h]=c}return c}a.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(f,d){a.fn[f]=function(n){var g=[],i=a(n),k,h,m,l,j=this.length===1&&this[0].parentNode;e=b||{};if(j&&j.nodeType===11&&j.childNodes.length===1&&i.length===1){i[d](this[0]);g=this}else{for(h=0,m=i.length;h0?this.clone(true):this).get();a.fn[d].apply(a(i[h]),k);g=g.concat(k)}c=0;g=this.pushStack(g,f,i.selector)}l=e;e=null;a.tmpl.complete(l);return g}});a.fn.extend({tmpl:function(d,c,b){return a.tmpl(this[0],d,c,b)},tmplItem:function(){return a.tmplItem(this[0])},template:function(b){return a.template(b,this[0])},domManip:function(d,l,j){if(d[0]&&d[0].nodeType){var f=a.makeArray(arguments),g=d.length,i=0,h;while(i1)f[0]=[a.makeArray(d)];if(h&&c)f[2]=function(b){a.tmpl.afterManip(this,b,j)};r.apply(this,f)}else r.apply(this,arguments);c=0;!e&&a.tmpl.complete(b);return this}});a.extend({tmpl:function(d,h,e,c){var j,k=!c;if(k){c=p;d=a.template[d]||a.template(null,d);f={}}else if(!d){d=c.tmpl;b[c.key]=c;c.nodes=[];c.wrapped&&n(c,c.wrapped);return a(i(c,null,c.tmpl(a,c)))}if(!d)return[];if(typeof h==="function")h=h.call(c||{});e&&e.wrapped&&n(e,e.wrapped);j=a.isArray(h)?a.map(h,function(a){return a?g(e,c,d,a):null}):[g(e,c,d,h)];return k?a(i(c,null,j)):j},tmplItem:function(b){var c;if(b instanceof a)b=b[0];while(b&&b.nodeType===1&&!(c=a.data(b,"tmplItem"))&&(b=b.parentNode));return c||p},template:function(c,b){if(b){if(typeof b==="string")b=o(b);else if(b instanceof a)b=b[0]||{};if(b.nodeType)b=a.data(b,"tmpl")||a.data(b,"tmpl",o(b.innerHTML));return typeof c==="string"?(a.template[c]=b):b}return c?typeof c!=="string"?a.template(null,c):a.template[c]||a.template(null,q.test(c)?c:a(c)):null},encode:function(a){return(""+a).split("<").join("<").split(">").join(">").split('"').join(""").split("'").join("'")}});a.extend(a.tmpl,{tag:{tmpl:{_default:{$2:"null"},open:"if($notnull_1){_=_.concat($item.nest($1,$2));}"},wrap:{_default:{$2:"null"},open:"$item.calls(_,$1,$2);_=[];",close:"call=$item.calls();_=call._.concat($item.wrap(call,_));"},each:{_default:{$2:"$index, $value"},open:"if($notnull_1){$.each($1a,function($2){with(this){",close:"}});}"},"if":{open:"if(($notnull_1) && $1a){",close:"}"},"else":{_default:{$1:"true"},open:"}else if(($notnull_1) && $1a){"},html:{open:"if($notnull_1){_.push($1a);}"},"=":{_default:{$1:"$data"},open:"if($notnull_1){_.push($.encode($1a));}"},"!":{open:""}},complete:function(){b={}},afterManip:function(f,b,d){var e=b.nodeType===11?a.makeArray(b.childNodes):b.nodeType===1?[b]:[];d.call(f,b);m(e);c++}});function i(e,g,f){var b,c=f?a.map(f,function(a){return typeof a==="string"?e.key?a.replace(/(<\w+)(?=[\s>])(?![^>]*_tmplitem)([^>]*)/g,"$1 "+d+'="'+e.key+'" $2'):a:i(a,e,a._ctnt)}):e;if(g)return c;c=c.join("");c.replace(/^\s*([^<\s][^<]*)?(<[\w\W]+>)([^>]*[^>\s])?\s*$/,function(f,c,e,d){b=a(e).get();m(b);if(c)b=j(c).concat(b);if(d)b=b.concat(j(d))});return b?b:j(c)}function j(c){var b=document.createElement("div");b.innerHTML=c;return a.makeArray(b.childNodes)}function o(b){return new Function("jQuery","$item","var $=jQuery,call,_=[],$data=$item.data;with($data){_.push('"+a.trim(b).replace(/([\\'])/g,"\\$1").replace(/[\r\t\n]/g," ").replace(/\$\{([^\}]*)\}/g,"{{= $1}}").replace(/\{\{(\/?)(\w+|.)(?:\(((?:[^\}]|\}(?!\}))*?)?\))?(?:\s+(.*?)?)?(\(((?:[^\}]|\}(?!\}))*?)\))?\s*\}\}/g,function(m,l,j,d,b,c,e){var i=a.tmpl.tag[j],h,f,g;if(!i)throw"Template command not found: "+j;h=i._default||[];if(c&&!/\w$/.test(b)){b+=c;c=""}if(b){b=k(b);e=e?","+k(e)+")":c?")":"";f=c?b.indexOf(".")>-1?b+c:"("+b+").call($item"+e:b;g=c?f:"(typeof("+b+")==='function'?("+b+").call($item):("+b+"))"}else g=f=h.$1||"null";d=k(d);return"');"+i[l?"close":"open"].split("$notnull_1").join(b?"typeof("+b+")!=='undefined' && ("+b+")!=null":"true").split("$1a").join(g).split("$1").join(f).split("$2").join(d?d.replace(/\s*([^\(]+)\s*(\((.*?)\))?/g,function(d,c,b,a){a=a?","+a+")":b?")":"";return a?"("+c+").call($item"+a:d}):h.$2||"")+"_.push('"})+"');}return _;")}function n(c,b){c._wrap=i(c,true,a.isArray(b)?b:[q.test(b)?b:a(b).html()]).join("")}function k(a){return a?a.replace(/\\'/g,"'").replace(/\\\\/g,"\\"):null}function s(b){var a=document.createElement("div");a.appendChild(b.cloneNode(true));return a.innerHTML}function m(o){var n="_"+c,k,j,l={},e,p,i;for(e=0,p=o.length;e=0;i--)m(j[i]);m(k)}function m(j){var p,i=j,k,e,m;if(m=j.getAttribute(d)){while(i.parentNode&&(i=i.parentNode).nodeType===1&&!(p=i.getAttribute(d)));if(p!==m){i=i.parentNode?i.nodeType===11?0:i.getAttribute(d)||0:0;if(!(e=b[m])){e=f[m];e=g(e,b[i]||f[i],null,true);e.key=++h;b[h]=e}c&&o(m)}j.removeAttribute(d)}else if(c&&(e=a.data(j,"tmplItem"))){o(e.key);b[e.key]=e;i=a.data(j.parentNode,"tmplItem");i=i?i.key:0}if(e){k=e;while(k&&k.key!=i){k.nodes.push(j);k=k.parent}delete e._ctnt;delete e._wrap;a.data(j,"tmplItem",e)}function o(a){a=a+n;e=l[a]=l[a]||g(e,b[e.parent.key+n]||e.parent,null,true)}}}function u(a,d,c,b){if(!a)return l.pop();l.push({_:a,tmpl:d,item:this,data:c,options:b})}function w(d,c,b){return a.tmpl(a.template(d),c,b,this)}function x(b,d){var c=b.options||{};c.wrapped=d;return a.tmpl(a.template(b.tmpl),b.data,c,b.item)}function v(d,c){var b=this._wrap;return a.map(a(a.isArray(b)?b.join(""):b).filter(d||"*"),function(a){return c?a.innerText||a.textContent:a.outerHTML||s(a)})}function t(){var b=this.nodes;a.tmpl(null,null,null,this).insertBefore(b[0]);a(b).remove()}})(jQuery) -------------------------------------------------------------------------------- /public/javascripts/models/project.js: -------------------------------------------------------------------------------- 1 | var Project = Backbone.Model.extend({ 2 | name: 'project', 3 | 4 | editMode: true, 5 | 6 | initialize: function(args) { 7 | 8 | this.maybeUnwrap(args); 9 | 10 | this.bind('change:last_changeset_id', this.updateChangesets); 11 | 12 | this.stories = new StoryCollection; 13 | this.stories.url = this.url() + '/stories'; 14 | this.stories.project = this; 15 | 16 | this.users = new UserCollection; 17 | this.users.url = this.url() + '/users'; 18 | this.users.project = this; 19 | 20 | this.iterations = []; 21 | }, 22 | 23 | defaults: { 24 | default_velocity: 10 25 | }, 26 | 27 | url: function() { 28 | var prefix_url = this.editMode ? '/projects/' : '/public_view/projects/'; 29 | return prefix_url + this.id; 30 | }, 31 | 32 | // This method is triggered when the last_changeset_id attribute is changed, 33 | // which indicates there are changed or new stories on the server which need 34 | // to be loaded. 35 | updateChangesets: function() { 36 | var from = this.previous('last_changeset_id'); 37 | if (from === null) { 38 | from = 0; 39 | } 40 | var to = this.get('last_changeset_id'); 41 | 42 | var model = this; 43 | var options = { 44 | type: 'GET', 45 | dataType: 'json', 46 | success: function(resp, status, xhr) { 47 | model.handleChangesets(resp); 48 | }, 49 | data: {from: from, to: to}, 50 | url: this.url() + '/changesets' 51 | }; 52 | 53 | $.ajax(options); 54 | }, 55 | 56 | // (Re)load each of the stories described in the provided changesets. 57 | handleChangesets: function(changesets) { 58 | var that = this; 59 | 60 | var story_ids = _.map(changesets, function(changeset) { 61 | return changeset.changeset.story_id; 62 | }); 63 | story_ids = _.uniq(story_ids); 64 | 65 | _.each(story_ids, function(story_id) { 66 | // FIXME - Feature envy on stories collection 67 | var story = that.stories.get(story_id); 68 | if (story) { 69 | // This is an existing story on the collection, just reload it 70 | story.fetch(); 71 | } else { 72 | // This is a new story, which is present on the server but we don't 73 | // have it locally yet. 74 | that.stories.add({id: story_id}); 75 | story = that.stories.get(story_id); 76 | story.fetch(); 77 | } 78 | }); 79 | }, 80 | 81 | milliseconds_in_a_day: 1000 * 60 * 60 * 24, 82 | 83 | // Return the correct iteration number for a given date. 84 | getIterationNumberForDate: function(compare_date) { 85 | //var start_date = new Date(this.get('start_date')); 86 | var start_date = this.startDate(); 87 | var difference = Math.abs(compare_date.getTime() - start_date.getTime()); 88 | var days_apart = Math.round(difference / this.milliseconds_in_a_day); 89 | return Math.floor((days_apart / (this.get('iteration_length') * 7)) + 1); 90 | }, 91 | 92 | getDateForIterationNumber: function(iteration_number) { 93 | // The difference betweeen the start date in days. Iteration length is 94 | // in weeks. 95 | var difference = (7 * this.get('iteration_length')) * (iteration_number - 1); 96 | var start_date = this.startDate(); 97 | var iteration_date = new Date(start_date); 98 | 99 | iteration_date.setDate(start_date.getDate() + difference); 100 | return iteration_date; 101 | }, 102 | 103 | currentIterationNumber: function() { 104 | return this.getIterationNumberForDate(new Date()); 105 | }, 106 | 107 | startDate: function() { 108 | 109 | var start_date; 110 | if (this.get('start_date')) { 111 | start_date = new Date(this.get('start_date')); 112 | } else { 113 | start_date = new Date(); 114 | } 115 | 116 | // Is the specified project start date the same week day as the iteration 117 | // start day? 118 | if (start_date.getDay() === this.get('iteration_start_day')) { 119 | return start_date; 120 | } else { 121 | // Calculate the date of the nearest prior iteration start day to the 122 | // specified project start date. So if the iteration start day is 123 | // set to Monday, but the project start date is set to a specific 124 | // Thursday, return the Monday before the Thursday. A greater 125 | // mathemtician than I could probably do this with the modulo. 126 | var day_difference = start_date.getDay() - this.get('iteration_start_day'); 127 | 128 | // The iteration start day is after the project start date, in terms of 129 | // day number 130 | if (day_difference < 0) { 131 | day_difference = day_difference + 7; 132 | } 133 | return new Date(start_date - day_difference * this.milliseconds_in_a_day); 134 | } 135 | }, 136 | 137 | velocity: function() { 138 | if (this.doneIterations().length === 0) { 139 | return this.get('default_velocity'); 140 | } else { 141 | // TODO Make number of iterations configurable 142 | var numIterations = 3; 143 | var iterations = this.doneIterations(); 144 | 145 | // Take a maximum of numIterations from the end of the array 146 | if (iterations.length > numIterations) { 147 | iterations = iterations.slice(iterations.length - numIterations); 148 | } 149 | 150 | var pointsArray = _.invoke(iterations, 'points'); 151 | var sum = _.reduce(pointsArray, function(memo, points) { 152 | return memo + points; 153 | }, 0); 154 | var velocity = Math.floor(sum / pointsArray.length); 155 | return velocity < 1 ? 1 : velocity; 156 | } 157 | }, 158 | 159 | doneIterations: function() { 160 | return _.select(this.iterations, function(iteration) { 161 | return iteration.get('column') === "#done"; 162 | }); 163 | } 164 | }); 165 | -------------------------------------------------------------------------------- /doc/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 20 | 24 | 28 | 32 | 36 | 40 | 41 | 42 | 64 | 66 | 67 | 69 | image/svg+xml 70 | 72 | 73 | 74 | 75 | 76 | 81 | 90 | 99 | 110 | 116 | 134 | 146 | 147 | 148 | -------------------------------------------------------------------------------- /public/javascripts/views/app_view.js: -------------------------------------------------------------------------------- 1 | var AppView = Backbone.View.extend({ 2 | 3 | initialize: function() { 4 | _.bindAll(this, 'addOne', 'addAll', 'render'); 5 | 6 | window.Project.stories.bind('add', this.addOne); 7 | window.Project.stories.bind('reset', this.addAll); 8 | window.Project.stories.bind('all', this.render); 9 | 10 | window.Project.stories.fetch(); 11 | }, 12 | 13 | addOne: function(story) { 14 | var view = new StoryView({model: story}); 15 | $(story.column()).append(view.render().el); 16 | }, 17 | 18 | addAll: function() { 19 | $('#done').html(""); 20 | $('#in_progress').html(""); 21 | $('#backlog').html(""); 22 | $('#chilly_bin').html(""); 23 | 24 | // 25 | // Done column 26 | // 27 | var that = this; 28 | var done_iterations = _.groupBy(window.Project.stories.column('#done'), 29 | function(story) { 30 | return story.iterationNumber(); 31 | }); 32 | 33 | // There will sometimes be gaps in the done iterations, i.e. no work 34 | // may have been accepted in a given iteration, and it will therefore 35 | // not appear in the set. Store this to iterate over those gaps and 36 | // insert empty iterations. 37 | var last_iteration = new Iteration({'number': 0}); 38 | 39 | _.each(done_iterations, function(stories, iterationNumber) { 40 | 41 | var iteration = new Iteration({ 42 | 'number': iterationNumber, 'stories': stories, column: '#done' 43 | }); 44 | 45 | window.Project.iterations.push(iteration); 46 | 47 | that.fillInEmptyIterations('#done', last_iteration, iteration); 48 | last_iteration = iteration; 49 | 50 | $('#done').append(that.iterationDiv(iteration)); 51 | _.each(stories, function(story) {that.addOne(story)}); 52 | }); 53 | 54 | // Fill in any remaining empty iterations in the done column 55 | var currentIteration = new Iteration({ 56 | 'number': window.Project.currentIterationNumber(), 57 | 'stories': window.Project.stories.column('#in_progress') 58 | }); 59 | this.fillInEmptyIterations('#done', last_iteration, currentIteration); 60 | 61 | // 62 | // In progress column 63 | // 64 | // FIXME - Show completed/total points 65 | $('#in_progress').append(that.iterationDiv(currentIteration)); 66 | _.each(window.Project.stories.column('#in_progress'), this.addOne); 67 | 68 | 69 | 70 | // 71 | // Backlog column 72 | // 73 | var backlogIteration = new Iteration({ 74 | 'number': currentIteration.get('number') + 1, 75 | 'rendered': false, 76 | 'maximum_points': window.Project.velocity() 77 | }); 78 | _.each(window.Project.stories.column('#backlog'), function(story) { 79 | 80 | if (!backlogIteration.canTakeStory(story)) { 81 | // The iteration is full, render it 82 | $('#backlog').append(that.iterationDiv(backlogIteration)); 83 | _.each(backlogIteration.get('stories'), function(iterationStory) { 84 | that.addOne(iterationStory); 85 | }); 86 | backlogIteration.set({'rendered': true}); 87 | 88 | var nextNumber = backlogIteration.get('number') + 1 + Math.ceil(backlogIteration.overflowsBy() / window.Project.velocity()); 89 | 90 | var nextIteration = new Iteration({ 91 | 'number': nextNumber, 92 | 'rendered': false, 93 | 'maximum_points': window.Project.velocity() 94 | }); 95 | 96 | // If the iteration overflowed, create enough empty iterations to 97 | // accommodate the surplus. For example, if the project velocity 98 | // is 1, and the last iteration contained 1 5 point story, we'll 99 | // need 4 empty iterations. 100 | // 101 | that.fillInEmptyIterations('#backlog', backlogIteration, nextIteration); 102 | backlogIteration = nextIteration; 103 | } 104 | 105 | backlogIteration.get('stories').push(story); 106 | //that.addOne(story); 107 | }); 108 | 109 | // Render the backlog final backlog iteration if it isn't already 110 | $('#backlog').append(that.iterationDiv(backlogIteration)); 111 | _.each(backlogIteration.get('stories'), function(story) { 112 | that.addOne(story); 113 | }); 114 | backlogIteration.set({'rendered': true}); 115 | 116 | _.each(window.Project.stories.column('#chilly_bin'), this.addOne); 117 | }, 118 | 119 | // Creates a set of empty iterations in column, with iteration numbers 120 | // starting at start and ending at end 121 | fillInEmptyIterations: function(column, start, end) { 122 | var el = $(column); 123 | var missing_range = _.range( 124 | parseInt(start.get('number')) + 1, 125 | parseInt(end.get('number')) 126 | ); 127 | var that = this; 128 | _.each(missing_range, function(missing_iteration_number) { 129 | var iteration = new Iteration({ 130 | 'number': missing_iteration_number, 'column': column 131 | }); 132 | window.Project.iterations.push(iteration); 133 | el.append(that.iterationDiv(iteration)); 134 | }); 135 | }, 136 | 137 | scaleToViewport: function() { 138 | var storyTableTop = $('table.stories tbody').offset().top; 139 | // Extra for the bottom padding and the 140 | var extra = 100; 141 | var height = $(window).height() - (storyTableTop + extra); 142 | $('.storycolumn').css('height', height + 'px'); 143 | }, 144 | 145 | // FIXME - Make a view 146 | iterationDiv: function(iteration) { 147 | // FIXME Make a model method 148 | var iteration_date = window.Project.getDateForIterationNumber(iteration.get('number')); 149 | var points_markup = '' + iteration.points() + ' points'; 150 | return '
' + iteration.get('number') + ' - ' + iteration_date.toDateString() + points_markup + '
' 151 | }, 152 | 153 | notice: function(message) { 154 | $.gritter.add(message); 155 | } 156 | }); 157 | -------------------------------------------------------------------------------- /public/javascripts/rails.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Unobtrusive scripting adapter for jQuery 3 | * 4 | * Requires jQuery 1.4.3 or later. 5 | * https://github.com/rails/jquery-ujs 6 | */ 7 | 8 | (function($) { 9 | // Make sure that every Ajax request sends the CSRF token 10 | function CSRFProtection(fn) { 11 | var token = $('meta[name="csrf-token"]').attr('content'); 12 | if (token) fn(function(xhr) { xhr.setRequestHeader('X-CSRF-Token', token) }); 13 | } 14 | if ($().jquery == '1.5') { // gruesome hack 15 | var factory = $.ajaxSettings.xhr; 16 | $.ajaxSettings.xhr = function() { 17 | var xhr = factory(); 18 | CSRFProtection(function(setHeader) { 19 | var open = xhr.open; 20 | xhr.open = function() { open.apply(this, arguments); setHeader(this) }; 21 | }); 22 | return xhr; 23 | }; 24 | } 25 | else $(document).ajaxSend(function(e, xhr) { 26 | CSRFProtection(function(setHeader) { setHeader(xhr) }); 27 | }); 28 | 29 | // Triggers an event on an element and returns the event result 30 | function fire(obj, name, data) { 31 | var event = new $.Event(name); 32 | obj.trigger(event, data); 33 | return event.result !== false; 34 | } 35 | 36 | // Submits "remote" forms and links with ajax 37 | function handleRemote(element) { 38 | var method, url, data, 39 | dataType = element.attr('data-type') || ($.ajaxSettings && $.ajaxSettings.dataType); 40 | 41 | if (element.is('form')) { 42 | method = element.attr('method'); 43 | url = element.attr('action'); 44 | data = element.serializeArray(); 45 | // memoized value from clicked submit button 46 | var button = element.data('ujs:submit-button'); 47 | if (button) { 48 | data.push(button); 49 | element.data('ujs:submit-button', null); 50 | } 51 | } else { 52 | method = element.attr('data-method'); 53 | url = element.attr('href'); 54 | data = null; 55 | } 56 | 57 | $.ajax({ 58 | url: url, type: method || 'GET', data: data, dataType: dataType, 59 | // stopping the "ajax:beforeSend" event will cancel the ajax request 60 | beforeSend: function(xhr, settings) { 61 | if (settings.dataType === undefined) { 62 | xhr.setRequestHeader('accept', '*/*;q=0.5, ' + settings.accepts.script); 63 | } 64 | return fire(element, 'ajax:beforeSend', [xhr, settings]); 65 | }, 66 | success: function(data, status, xhr) { 67 | element.trigger('ajax:success', [data, status, xhr]); 68 | }, 69 | complete: function(xhr, status) { 70 | element.trigger('ajax:complete', [xhr, status]); 71 | }, 72 | error: function(xhr, status, error) { 73 | element.trigger('ajax:error', [xhr, status, error]); 74 | } 75 | }); 76 | } 77 | 78 | // Handles "data-method" on links such as: 79 | // Delete 80 | function handleMethod(link) { 81 | var href = link.attr('href'), 82 | method = link.attr('data-method'), 83 | csrf_token = $('meta[name=csrf-token]').attr('content'), 84 | csrf_param = $('meta[name=csrf-param]').attr('content'), 85 | form = $('
'), 86 | metadata_input = '', 87 | form_params = link.data('form-params'); 88 | 89 | if (csrf_param !== undefined && csrf_token !== undefined) { 90 | metadata_input += ''; 91 | } 92 | 93 | // support non-nested JSON encoded params for links 94 | if (form_params != undefined) { 95 | var params = $.parseJSON(form_params); 96 | for (key in params) { 97 | form.append($("").attr({"type": "hidden", "name": key, "value": params[key]})); 98 | } 99 | } 100 | 101 | form.hide().append(metadata_input).appendTo('body'); 102 | form.submit(); 103 | } 104 | 105 | function disableFormElements(form) { 106 | form.find('input[data-disable-with]').each(function() { 107 | var input = $(this); 108 | input.data('ujs:enable-with', input.val()) 109 | .val(input.attr('data-disable-with')) 110 | .attr('disabled', 'disabled'); 111 | }); 112 | } 113 | 114 | function enableFormElements(form) { 115 | form.find('input[data-disable-with]').each(function() { 116 | var input = $(this); 117 | input.val(input.data('ujs:enable-with')).removeAttr('disabled'); 118 | }); 119 | } 120 | 121 | function allowAction(element) { 122 | var message = element.attr('data-confirm'); 123 | return !message || (fire(element, 'confirm') && confirm(message)); 124 | } 125 | 126 | function requiredValuesMissing(form) { 127 | var missing = false; 128 | form.find('input[name][required]').each(function() { 129 | if (!$(this).val()) missing = true; 130 | }); 131 | return missing; 132 | } 133 | 134 | $('a[data-confirm], a[data-method], a[data-remote]').live('click.rails', function(e) { 135 | var link = $(this); 136 | if (!allowAction(link)) return false; 137 | 138 | if (link.attr('data-remote') != undefined) { 139 | handleRemote(link); 140 | return false; 141 | } else if (link.attr('data-method')) { 142 | handleMethod(link); 143 | return false; 144 | } 145 | }); 146 | 147 | $('form').live('submit.rails', function(e) { 148 | var form = $(this), remote = form.attr('data-remote') != undefined; 149 | if (!allowAction(form)) return false; 150 | 151 | // skip other logic when required values are missing 152 | if (requiredValuesMissing(form)) return !remote; 153 | 154 | if (remote) { 155 | handleRemote(form); 156 | return false; 157 | } else { 158 | // slight timeout so that the submit button gets properly serialized 159 | setTimeout(function(){ disableFormElements(form) }, 13); 160 | } 161 | }); 162 | 163 | $('form input[type=submit], form button[type=submit], form button:not([type])').live('click.rails', function() { 164 | var button = $(this); 165 | if (!allowAction(button)) return false; 166 | // register the pressed submit button 167 | var name = button.attr('name'), data = name ? {name:name, value:button.val()} : null; 168 | button.closest('form').data('ujs:submit-button', data); 169 | }); 170 | 171 | $('form').live('ajax:beforeSend.rails', function(event) { 172 | if (this == event.target) disableFormElements($(this)); 173 | }); 174 | 175 | $('form').live('ajax:complete.rails', function(event) { 176 | if (this == event.target) enableFormElements($(this)); 177 | }); 178 | })( jQuery ); 179 | -------------------------------------------------------------------------------- /config/initializers/devise.rb: -------------------------------------------------------------------------------- 1 | # Use this hook to configure devise mailer, warden hooks and so forth. The first 2 | # four configuration values can also be set straight in your models. 3 | Devise.setup do |config| 4 | # ==> Mailer Configuration 5 | # Configure the e-mail address which will be shown in DeviseMailer. 6 | config.mailer_sender = ENV['MAILER_SENDER'] || "please-change-me@config-initializers-devise.com" 7 | 8 | # Configure the class responsible to send e-mails. 9 | # config.mailer = "Devise::Mailer" 10 | 11 | # ==> ORM configuration 12 | # Load and configure the ORM. Supports :active_record (default) and 13 | # :mongoid (bson_ext recommended) by default. Other ORMs may be 14 | # available as additional gems. 15 | require 'devise/orm/active_record' 16 | 17 | # ==> Configuration for any authentication mechanism 18 | # Configure which keys are used when authenticating an user. By default is 19 | # just :email. You can configure it to use [:username, :subdomain], so for 20 | # authenticating an user, both parameters are required. Remember that those 21 | # parameters are used only when authenticating and not when retrieving from 22 | # session. If you need permissions, you should implement that in a before filter. 23 | # config.authentication_keys = [ :email ] 24 | 25 | # Tell if authentication through request.params is enabled. True by default. 26 | # config.params_authenticatable = true 27 | 28 | # Tell if authentication through HTTP Basic Auth is enabled. False by default. 29 | # config.http_authenticatable = false 30 | 31 | # Set this to true to use Basic Auth for AJAX requests. True by default. 32 | # config.http_authenticatable_on_xhr = true 33 | 34 | # The realm used in Http Basic Authentication 35 | # config.http_authentication_realm = "Application" 36 | 37 | # ==> Configuration for :database_authenticatable 38 | # For bcrypt, this is the cost for hashing the password and defaults to 10. If 39 | # using other encryptors, it sets how many times you want the password re-encrypted. 40 | config.stretches = 10 41 | 42 | # Define which will be the encryption algorithm. Devise also supports encryptors 43 | # from others authentication tools as :clearance_sha1, :authlogic_sha512 (then 44 | # you should set stretches above to 20 for default behavior) and :restful_authentication_sha1 45 | # (then you should set stretches to 10, and copy REST_AUTH_SITE_KEY to pepper) 46 | #config.encryptor = :bcrypt 47 | 48 | # Setup a pepper to generate the encrypted password. 49 | config.pepper = "9f492a1986b28d42a42da845757a3a84ddf6f22c1900093fa0323dd9eccc26fe64bf891635eab04c4e698611f91a09e8ac9ac4c5b3fd9c97f9f47ee88e860f34" 50 | 51 | # ==> Configuration for :confirmable 52 | # The time you want to give your user to confirm his account. During this time 53 | # he will be able to access your application without confirming. Default is nil. 54 | # When confirm_within is zero, the user won't be able to sign in without confirming. 55 | # You can use this to let your user access some features of your application 56 | # without confirming the account, but blocking it after a certain period 57 | # (ie 2 days). 58 | # config.confirm_within = 2.days 59 | 60 | # ==> Configuration for :rememberable 61 | # The time the user will be remembered without asking for credentials again. 62 | # config.remember_for = 2.weeks 63 | 64 | # If true, a valid remember token can be re-used between multiple browsers. 65 | # config.remember_across_browsers = true 66 | 67 | # If true, extends the user's remember period when remembered via cookie. 68 | # config.extend_remember_period = false 69 | 70 | # ==> Configuration for :validatable 71 | # Range for password length 72 | # config.password_length = 6..20 73 | 74 | # Regex to use to validate the email address 75 | # config.email_regexp = /^([\w\.%\+\-]+)@([\w\-]+\.)+([\w]{2,})$/i 76 | 77 | # ==> Configuration for :timeoutable 78 | # The time you want to timeout the user session without activity. After this 79 | # time the user will be asked for credentials again. 80 | # config.timeout_in = 10.minutes 81 | 82 | # ==> Configuration for :lockable 83 | # Defines which strategy will be used to lock an account. 84 | # :failed_attempts = Locks an account after a number of failed attempts to sign in. 85 | # :none = No lock strategy. You should handle locking by yourself. 86 | # config.lock_strategy = :failed_attempts 87 | 88 | # Defines which strategy will be used to unlock an account. 89 | # :email = Sends an unlock link to the user email 90 | # :time = Re-enables login after a certain amount of time (see :unlock_in below) 91 | # :both = Enables both strategies 92 | # :none = No unlock strategy. You should handle unlocking by yourself. 93 | # config.unlock_strategy = :both 94 | 95 | # Number of authentication tries before locking an account if lock_strategy 96 | # is failed attempts. 97 | # config.maximum_attempts = 20 98 | 99 | # Time interval to unlock the account if :time is enabled as unlock_strategy. 100 | # config.unlock_in = 1.hour 101 | 102 | # ==> Configuration for :token_authenticatable 103 | # Defines name of the authentication token params key 104 | # config.token_authentication_key = :auth_token 105 | 106 | # ==> Scopes configuration 107 | # Turn scoped views on. Before rendering "sessions/new", it will first check for 108 | # "users/sessions/new". It's turned off by default because it's slower if you 109 | # are using only default views. 110 | # config.scoped_views = true 111 | 112 | # Configure the default scope given to Warden. By default it's the first 113 | # devise role declared in your routes. 114 | # config.default_scope = :user 115 | 116 | # Configure sign_out behavior. 117 | # By default sign_out is scoped (i.e. /users/sign_out affects only :user scope). 118 | # In case of sign_out_all_scopes set to true any logout action will sign out all active scopes. 119 | # config.sign_out_all_scopes = false 120 | 121 | # ==> Navigation configuration 122 | # Lists the formats that should be treated as navigational. Formats like 123 | # :html, should redirect to the sign in page when the user does not have 124 | # access, but formats like :xml or :json, should return 401. 125 | # If you have any extra navigational formats, like :iphone or :mobile, you 126 | # should add them to the navigational formats lists. Default is [:html] 127 | # config.navigational_formats = [:html, :iphone] 128 | 129 | # ==> Warden configuration 130 | # If you want to use other strategies, that are not (yet) supported by Devise, 131 | # you can configure them inside the config.warden block. The example below 132 | # allows you to setup OAuth, using http://github.com/roman/warden_oauth 133 | # 134 | # config.warden do |manager| 135 | # manager.oauth(:twitter) do |twitter| 136 | # twitter.consumer_secret = 137 | # twitter.consumer_key = 138 | # twitter.options :site => 'http://twitter.com' 139 | # end 140 | # manager.default_strategies(:scope => :user).unshift :twitter_oauth 141 | # end 142 | end 143 | -------------------------------------------------------------------------------- /spec/javascripts/support/jasmine-jquery-1.2.0.js: -------------------------------------------------------------------------------- 1 | var readFixtures = function() { 2 | return jasmine.getFixtures().proxyCallTo_('read', arguments); 3 | }; 4 | 5 | var loadFixtures = function() { 6 | jasmine.getFixtures().proxyCallTo_('load', arguments); 7 | }; 8 | 9 | var setFixtures = function(html) { 10 | jasmine.getFixtures().set(html); 11 | }; 12 | 13 | var sandbox = function(attributes) { 14 | return jasmine.getFixtures().sandbox(attributes); 15 | }; 16 | 17 | var spyOnEvent = function(selector, eventName) { 18 | jasmine.JQuery.events.spyOn(selector, eventName); 19 | } 20 | 21 | jasmine.getFixtures = function() { 22 | return jasmine.currentFixtures_ = jasmine.currentFixtures_ || new jasmine.Fixtures(); 23 | }; 24 | 25 | jasmine.Fixtures = function() { 26 | this.containerId = 'jasmine-fixtures'; 27 | this.fixturesCache_ = {}; 28 | this.fixturesPath = 'spec/javascripts/fixtures'; 29 | }; 30 | 31 | jasmine.Fixtures.prototype.set = function(html) { 32 | this.cleanUp(); 33 | this.createContainer_(html); 34 | }; 35 | 36 | jasmine.Fixtures.prototype.load = function() { 37 | this.cleanUp(); 38 | this.createContainer_(this.read.apply(this, arguments)); 39 | }; 40 | 41 | jasmine.Fixtures.prototype.read = function() { 42 | var htmlChunks = []; 43 | 44 | var fixtureUrls = arguments; 45 | for(var urlCount = fixtureUrls.length, urlIndex = 0; urlIndex < urlCount; urlIndex++) { 46 | htmlChunks.push(this.getFixtureHtml_(fixtureUrls[urlIndex])); 47 | } 48 | 49 | return htmlChunks.join(''); 50 | }; 51 | 52 | jasmine.Fixtures.prototype.clearCache = function() { 53 | this.fixturesCache_ = {}; 54 | }; 55 | 56 | jasmine.Fixtures.prototype.cleanUp = function() { 57 | $('#' + this.containerId).remove(); 58 | }; 59 | 60 | jasmine.Fixtures.prototype.sandbox = function(attributes) { 61 | var attributesToSet = attributes || {}; 62 | return $('
').attr(attributesToSet); 63 | }; 64 | 65 | jasmine.Fixtures.prototype.createContainer_ = function(html) { 66 | var container = $('
'); 67 | container.html(html); 68 | $('body').append(container); 69 | }; 70 | 71 | jasmine.Fixtures.prototype.getFixtureHtml_ = function(url) { 72 | if (typeof this.fixturesCache_[url] == 'undefined') { 73 | this.loadFixtureIntoCache_(url); 74 | } 75 | return this.fixturesCache_[url]; 76 | }; 77 | 78 | jasmine.Fixtures.prototype.loadFixtureIntoCache_ = function(relativeUrl) { 79 | var self = this; 80 | var url = this.fixturesPath.match('/$') ? this.fixturesPath + relativeUrl : this.fixturesPath + '/' + relativeUrl; 81 | $.ajax({ 82 | async: false, // must be synchronous to guarantee that no tests are run before fixture is loaded 83 | cache: false, 84 | dataType: 'html', 85 | url: url, 86 | success: function(data) { 87 | self.fixturesCache_[relativeUrl] = data; 88 | } 89 | }); 90 | }; 91 | 92 | jasmine.Fixtures.prototype.proxyCallTo_ = function(methodName, passedArguments) { 93 | return this[methodName].apply(this, passedArguments); 94 | }; 95 | 96 | 97 | jasmine.JQuery = function() {}; 98 | 99 | jasmine.JQuery.browserTagCaseIndependentHtml = function(html) { 100 | return $('
').append(html).html(); 101 | }; 102 | 103 | jasmine.JQuery.elementToString = function(element) { 104 | return $('
').append(element.clone()).html(); 105 | }; 106 | 107 | jasmine.JQuery.matchersClass = {}; 108 | 109 | (function(namespace) { 110 | var data = { 111 | spiedEvents: {}, 112 | handlers: [] 113 | }; 114 | 115 | namespace.events = { 116 | spyOn: function(selector, eventName) { 117 | var handler = function(e) { 118 | data.spiedEvents[[selector, eventName]] = e; 119 | }; 120 | $(selector).bind(eventName, handler); 121 | data.handlers.push(handler); 122 | }, 123 | 124 | wasTriggered: function(selector, eventName) { 125 | return !!(data.spiedEvents[[selector, eventName]]); 126 | }, 127 | 128 | cleanUp: function() { 129 | data.spiedEvents = {}; 130 | data.handlers = []; 131 | } 132 | } 133 | })(jasmine.JQuery); 134 | 135 | (function(){ 136 | var jQueryMatchers = { 137 | toHaveClass: function(className) { 138 | return this.actual.hasClass(className); 139 | }, 140 | 141 | toBeVisible: function() { 142 | return this.actual.is(':visible'); 143 | }, 144 | 145 | toBeHidden: function() { 146 | return this.actual.is(':hidden'); 147 | }, 148 | 149 | toBeSelected: function() { 150 | return this.actual.is(':selected'); 151 | }, 152 | 153 | toBeChecked: function() { 154 | return this.actual.is(':checked'); 155 | }, 156 | 157 | toBeEmpty: function() { 158 | return this.actual.is(':empty'); 159 | }, 160 | 161 | toExist: function() { 162 | return this.actual.size() > 0; 163 | }, 164 | 165 | toHaveAttr: function(attributeName, expectedAttributeValue) { 166 | return hasProperty(this.actual.attr(attributeName), expectedAttributeValue); 167 | }, 168 | 169 | toHaveId: function(id) { 170 | return this.actual.attr('id') == id; 171 | }, 172 | 173 | toHaveHtml: function(html) { 174 | return this.actual.html() == jasmine.JQuery.browserTagCaseIndependentHtml(html); 175 | }, 176 | 177 | toHaveText: function(text) { 178 | if (text && jQuery.isFunction(text.test)) { 179 | return text.test(this.actual.text()); 180 | } else { 181 | return this.actual.text() == text; 182 | } 183 | }, 184 | 185 | toHaveValue: function(value) { 186 | return this.actual.val() == value; 187 | }, 188 | 189 | toHaveData: function(key, expectedValue) { 190 | return hasProperty(this.actual.data(key), expectedValue); 191 | }, 192 | 193 | toBe: function(selector) { 194 | return this.actual.is(selector); 195 | }, 196 | 197 | toContain: function(selector) { 198 | return this.actual.find(selector).size() > 0; 199 | }, 200 | 201 | toBeDisabled: function(selector){ 202 | return this.actual.attr("disabled") == true; 203 | } 204 | }; 205 | 206 | var hasProperty = function(actualValue, expectedValue) { 207 | if (expectedValue === undefined) { 208 | return actualValue !== undefined; 209 | } 210 | return actualValue == expectedValue; 211 | }; 212 | 213 | var bindMatcher = function(methodName) { 214 | var builtInMatcher = jasmine.Matchers.prototype[methodName]; 215 | 216 | jasmine.JQuery.matchersClass[methodName] = function() { 217 | if (this.actual instanceof jQuery) { 218 | var result = jQueryMatchers[methodName].apply(this, arguments); 219 | this.actual = jasmine.JQuery.elementToString(this.actual); 220 | return result; 221 | } 222 | 223 | if (builtInMatcher) { 224 | return builtInMatcher.apply(this, arguments); 225 | } 226 | 227 | return false; 228 | }; 229 | }; 230 | 231 | for(var methodName in jQueryMatchers) { 232 | bindMatcher(methodName); 233 | } 234 | })(); 235 | 236 | beforeEach(function() { 237 | this.addMatchers(jasmine.JQuery.matchersClass); 238 | this.addMatchers({ 239 | toHaveBeenTriggeredOn: function(selector) { 240 | this.message = function() { 241 | return [ 242 | "Expected event " + this.actual + " to have been triggered on" + selector, 243 | "Expected event " + this.actual + " not to have been triggered on" + selector 244 | ]; 245 | }; 246 | return jasmine.JQuery.events.wasTriggered(selector, this.actual); 247 | } 248 | }) 249 | }); 250 | 251 | afterEach(function() { 252 | jasmine.getFixtures().cleanUp(); 253 | jasmine.JQuery.events.cleanUp(); 254 | }); 255 | --------------------------------------------------------------------------------