├── 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 |
6 | <% @projects.each do |project| %>
7 |
8 | <%= link_to project.name, public_view_project_url(project) %>
9 |
10 | Started at: <%= project.start_date %>
11 |
12 | <% unless project.description.empty? %>
13 |
14 | <%= project.description.html_safe %>
15 |
16 | <% end %>
17 |
18 |
19 | The iteration starts on <%= Date::DAYNAMES[project.iteration_start_day].pluralize %> with length of <%= project.iteration_length %> week(s)
20 |
21 |
22 | <% end %>
23 |
--------------------------------------------------------------------------------
/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 |
6 | <% @projects.each do |project| %>
7 |
8 |
9 | <%= link_to 'Edit', edit_project_path(project) %>
10 | <%= link_to 'Members', project_users_path(project) %>
11 | <%= link_to 'Delete', project, :confirm => 'Are you sure you want to delete this project? This action cannot be undone.', :method => :delete %>
12 |
13 | <%= link_to project.name, project %>
14 | Started at:
15 | <%= project.start_date %>
16 |
17 |
18 | The iteration starts on <%= Date::DAYNAMES[project.iteration_start_day].pluralize %> with length of <%= project.iteration_length %> week(s)
19 |
20 |
21 | <% end %>
22 |
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 |
6 | <% @users.each do |user| %>
7 | <%= user %><%= link_to 'Remove', project_user_path(@project, user),
8 | :confirm => "Are you sure you want to remove #{user.email} from this project?",
9 | :method => :delete %>
10 | <% end %>
11 |
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 |
7 | <% @story.errors.full_messages.each do |msg| %>
8 | <%= msg %>
9 | <% end %>
10 |
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 |
7 | <% @project.errors.full_messages.each do |msg| %>
8 | <%= msg %>
9 | <% end %>
10 |
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 | Row
27 | Story
28 | Type
29 |
30 |
31 |
32 | <% @stories.each_with_index do |story, index| %>
33 | <% if story.valid? %>
34 |
35 | <%= index + 1 %>
36 | <%= story.title %>
37 | <%= story.story_type %>
38 |
39 | <% else %>
40 |
41 | <%= index + 1 %>
42 |
43 | <%= story.errors.full_messages.join(', ') %>
44 |
45 |
46 | <% end %>
47 | <% end %>
48 |
49 |
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 | Done
53 | In Progress
54 | Backlog
55 | Chilly Bin
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
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 | Done
74 | In Progress
75 | Backlog
76 | Chilly Bin
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
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 |
--------------------------------------------------------------------------------