-
Notifications
You must be signed in to change notification settings - Fork 0
tutorial rails prelaunch signup
Last updated 9 May 2012
Ruby on Rails tutorial showing how to create a “beta launching soon” application for a startup prelaunch site with a signup page. You can clone the rails-prelaunch-signup repository for the complete example application.
Screenshot:
Read an interview with Michael Gajda of XPlaygrounds.com about how he used the rails-prelaunch-signup example to launch his startup site. If you launch a site using this example, add your link to the list of Rails Applications Built from the Examples.
Follow the project on Twitter: @rails_apps. Tweet some praise if you like what you’ve found.
The initial app for a typical web startup announces the founders’ plans and encourages visitors to enter an email address for future notification of the site’s launch. It’s not difficult to build such an app in Rails.
But why build it yourself if others have already done so? This project aims to:
- eliminate effort spent building an application that meets a common need;
- offer code that is already implemented and tested by a large community;
- provide a well-thought-out app containing most of the features you’ll need.
By using code from this project, you’ll be able to:
- direct your attention to the design and product offer for your prelaunch site;
- get started faster building the ultimate application for your business.
The tutorial can help founders who are new to Ruby on Rails to learn how to build a real-world Rails web application.
We’ve chosen to use the Devise gem for our authentication and user management. We use Devise because it offers a full set of features used in more complex applications, such as recovering a user’s forgotten password or allowing users to invite friends. By using Devise for the prelaunch signup application, you can use the same user database for your post-launch product. You’ll also have the benefit of receiving help from a large community of developers using Devise, should you need help in troubleshooting or customizing the implementation.
Devise offers many features and configuration choices; in fact, exploring its options can take more time than actually implementing authentication from scratch. This app will save you time; we’ve selected the configuration that best accommodates a typical web startup signup site.
Devise handles signing up users through a registration feature. We use the Devise registration process to collect email addresses and create user accounts. Instead of confirming an email address immediately, we email a “thank you for your request” acknowledgment and postpone the email confirmation step. When you are ready to invite users to the site, the app provides an administrative interface so you can select users to receive an invitation that instructs them to confirm their email address and set a password.
The Devise wiki and answers on Stack Overflow can help you alter this implementation if you need different functionality.
This is one in a series of Rails example apps and tutorials from the RailsApps Project.
This application is based on two of the RailsApps example apps:
The rails3-devise-rspec-cucumber app shows how to set up Devise for user authentication, explaining how to integrate RSpec and Cucumber for testing. The rails3-bootstrap-devise-cancan app shows how to set up Devise and add CanCan to manage access to administrative pages. You can use this tutorial without studying these example applications; if you find you are lost, it may be helpful to look at the two simpler examples.
Look at rails3-mongoid-devise example if you want to use the MongoDB datastore instead of ActiveRecord and a SQL database.
This tutorial documents each step that you must follow to create this application. Every step is documented concisely, so a complete beginner can create this application without any additional knowledge. However, little explanation is offered for any of the steps, so if you are a beginner, you’re advised to look for an introduction to Rails elsewhere. See resources for getting started with Rails.
Most of the tutorials from the RailsApps project take about an hour to complete. This tutorial is more complex; it will take you about three hours to build the complete app. (Is our estimate accurate? Please leave a comment when you are done.)
If you follow this tutorial closely, you’ll have a working application that closely matches the example app in this GitHub repository. The example app is your reference implementation. If you find problems with the app you build from this tutorial, download the example app (in Git speak, clone it) and use a file compare tool to identify differences that may be causing errors. On a Mac, good file compare tools are FileMerge, DiffMerge, Kaleidoscope, or Ian Baird’s Changes.
If you clone and install the example app and find problems or wish to suggest improvements, please create a GitHub issue.
To improve this tutorial, please edit this wiki page or leave comments below.
Follow this tutorial.
To create the application, you can cut and paste the code from the tutorial into your own files. It’s a bit tedious and error-prone but you’ll have a good opportunity to examine the code closely.
Use the ready-made application template to generate the code.
We’ll soon offer an application template to generate a new Rails app with code that closely matches the tutorial. It’s not available yet.
Before beginning this tutorial, you need to install
- The Ruby language (version 1.9.3)
- Rails 3.2
Check that appropriate versions of Ruby and Rails are installed in your development environment:
$ ruby -v
$ rails -v
Be sure to read Installing Rails for detailed instructions and advice.
Arguably, for a simple application like this, you don’t need a lot of ceremony. There’s no need for a written specification. And no need for tests, right? Just code it up. However, for the purposes of this tutorial, I want to show the practices that establish a good software development process. You may find it beneficial to use the same process when you develop more complex applications.
Here’s the software development process we’ll use:
- write user stories
- write a short specification for our feature set using Cucumber scenarios
- create acceptance tests using Cucumber step definitions
- code each feature
- run acceptance tests as each feature is completed
By practicing the process that leads from concept to code, you’ll be prepared to build more complex applications.
User stories are a way to discuss and describe the requirements for a software application. The process of writing user stories will help you identify all the features that are needed for your application. Breaking down the application’s functionality into discrete user stories will help you organize your work and track progress toward completion.
User stories are generally expressed in the following format:
As a <role>, I want <goal> so that <benefit>
As an example, here are four user stories we will implement for this application:
*Request Invitation* As a visitor to the website I want to request an invitation so I can be notified when the site is launched *See Invitation Requests* As the owner of the site I want to view a list of visitors who have requested invitations so I can know if my offer is popular *Send Invitations* As the owner of the site I want to send invitations to visitors who have requested invitations so users can try the site *Collect Email Addresses* As the owner of the site I want to collect email addresses for a mailing list so I can send announcements before I launch the site
If you have ideas for additional features for this application, simply create a GitHub issue describing the feature you’d like to see.
Before you write any code, you’ll start by generating an example app using an application template script.
The $
character indicates a shell prompt; don’t include it when you run the command.
For a starter app using ActiveRecord and a SQL database, use the command:
$ rails new rails-prelaunch-signup -m https://raw.github.com/RailsApps/rails3-application-templates/master/rails3-bootstrap-devise-cancan-template.rb -T
Use the -T
flags to skip Test::Unit files.
If you want to use the MongoDB datastore instead of ActiveRecord and a SQL database use the rails3-mongoid-devise starter app:
$ rails new rails-prelaunch-signup -m https://github.com/RailsApps/rails3-application-templates/raw/master/rails3-mongoid-devise-template.rb -T -O
Use the -T -O
flags to skip Test::Unit files and Active Record files.
This creates a new Rails app named rails-prelaunch-signup
on your computer. You can use a different name if you wish.
The application generator template will ask you for your preferences. For this tutorial, choose the following preferences:
- Would you like to use Haml instead of ERB? yes
- Would you like to use RSpec instead of TestUnit? yes
- Would you like to use factory_girl for test fixtures with RSpec? yes
- Would you like to use machinist for test fixtures with RSpec? no
- Would you like to use Cucumber for your BDD? yes
- Would you like to use Guard to automate your workflow? no
- Would you like the app to use a Gmail account to send email? yes
- Would you like to use Devise for authentication? #4
- No
- Devise with default modules
- Devise with Confirmable module
- Devise with Confirmable and Invitable modules
- Would you like to manage authorization with CanCan & Rolify? yes
- Which front-end framework would you like for HTML5 and CSS3? #4
- None
- Zurb Foundation
- Twitter Bootstrap (less)
- Twitter Bootstrap (sass)
- Skeleton
- Normalize CSS for consistent styling
- Which form gem would you like? #3
- None
- simple form
- simple form (bootstrap)
- Would you like to use rails-footnotes during development? no
- Would you like to set a robots.txt file to ban spiders? yes
- Would you like to add ‘will_paginate’ for pagination? no
Be sure to choose the CanCan & Rolify option as well as the Twitter Bootstrap (sass) option to create the example application.
Be sure to choose the Devise with Confirmable and Invitable modules option as it is required as a basis for the rails-prelaunch-signup app. You’ll also need the Bootstrap version of the SimpleForm gem.
You can choose other selections if you don’t care about matching the example application exactly.
After you create the application, switch to its folder to continue work directly in the application:
$ cd rails-prelaunch-signup
If your version of the app will be visible on GitHub, please edit the README file to add a description of the app and your contact info. Changing the README is important if you’re using a clone of the example app. I’ve been mistaken (and contacted) as the author of apps that are copied from my example.
When you generate the starter app, the template sets up a source control repository and makes an initial commit of the code.
At this point, you should create a GitHub repository for your project.
See detailed instructions for Using Git with Rails.
It’s a good idea to create a new gemset using rvm, the Ruby Version Manager, as described in the article Installing Rails.
The starter app script sets up your gemfile.
Open your Gemfile and you should see the following. Gem version numbers may differ:
Gemfile
source 'https://rubygems.org' gem 'rails', '3.2.3' gem 'sqlite3' group :assets do gem 'sass-rails', '~> 3.2.3' gem 'coffee-rails', '~> 3.2.1' gem 'uglifier', '>= 1.0.3' end gem 'jquery-rails' gem "haml", ">= 3.1.4" gem "haml-rails", ">= 0.3.4", :group => :development gem "rspec-rails", ">= 2.9.0.rc2", :group => [:development, :test] gem "factory_girl_rails", ">= 3.2.0", :group => [:development, :test] gem "email_spec", ">= 1.2.1", :group => :test gem "cucumber-rails", ">= 1.3.0", :group => :test gem "capybara", ">= 1.1.2", :group => :test gem "database_cleaner", ">= 0.7.2", :group => :test gem "launchy", ">= 2.1.0", :group => :test gem "devise", ">= 2.1.0.rc" gem "devise_invitable", ">= 1.0.1" gem "cancan", ">= 1.6.7" gem "rolify", ">= 3.1.0" gem "bootstrap-sass", ">= 2.0.1" gem "simple_form"
Check for the current version of Rails and replace gem 'rails', '3.2.3'
accordingly.
Note: The RailsApps examples are generated with application templates created by the Rails Apps Composer Gem. For that reason, groups such as :development
or :test
are specified inline. You can reformat the Gemfiles to organize groups in an eye-pleasing block style. The functionality is the same.
When you add a new gem to the Gemfile, you should run the bundle install
command to install the required gems on your computer. In this case, the starter app script has already run the bundle install
command.
You can check which gems are installed on your computer with:
$ gem list --local
Keep in mind that you have installed these gems locally. When you deploy the app to another server, the same gems (and versions) must be available.
In this example, we’ll use Haml instead of the default “ERB” Rails template engine. The starter app script sets up Haml. You can see details about adding Haml to Rails with a discussion of the benefits and drawbacks in using Haml.
The starter app script sets up RSpec for unit testing. Run rake -T
to check that rake tasks for RSpec are available. You should be able to run rake spec
to run all specs provided with the example app. To learn more about using RSpec, refer to The RSpec Book.
The starter app script sets up Cucumber for specifications and acceptance testing. To learn more about using Cucumber, refer to The Cucumber Book or the free introduction to Cucumber, The Secret Ninja Cucumber Scrolls.
You should be able to run rake cucumber
, or more simply, cucumber
, to run the Cucumber scenarios and steps provided with the example app. You can run a single Cucumber feature with a command such as:
$ bundle exec cucumber features/visitors/request_invitation.feature
The starter app script sets up the config/cucumber.yml file so it is not necessary to add --require features
to the command to run a single Cucumber feature.
You must configure the app for your email account so your application can send email messages, for example, to acknowledge invitation requests or send welcome messages.
The starter app script sets up a default email configuration. You must add details about your email account.
ActionMailer is configured for development in the config/environments/development.rb file:
# ActionMailer Config config.action_mailer.default_url_options = { :host => 'localhost:3000' } config.action_mailer.delivery_method = :smtp # change to false to prevent email from being sent during development config.action_mailer.perform_deliveries = false config.action_mailer.raise_delivery_errors = true config.action_mailer.default :charset => "utf-8"
ActionMailer is configured for production in the config/environments/production.rb file:
config.action_mailer.default_url_options = { :host => 'example.com' } # ActionMailer Config # Setup for production - deliveries, no errors raised config.action_mailer.delivery_method = :smtp config.action_mailer.perform_deliveries = true config.action_mailer.raise_delivery_errors = false config.action_mailer.default :charset => "utf-8"
ActionMailer is configured for testing in the config/environments/test.rb file:
# ActionMailer Config config.action_mailer.default_url_options = { :host => 'example.com' }
This will set the example application to deliver email in production. Email messages are visible in the log file so there is no need to send email in development. The configuration above will raise delivery errors in development but not in production.
In development, config.action_mailer.default_url_options
is set for a host at localhost:3000
which will enable links in Devise confirmation email messages to work properly during development.
For testing, config.action_mailer.default_url_options
is set for a host at example.com
. Any value allows tests to run.
For production, you’ll need to change the config.action_mailer.default_url_options
host option from example.com
to your own domain.
If you want to use a Gmail account to send email, you’ll need to modify the files config/environments/development.rb and config/environments/production.rb:
config.action_mailer.smtp_settings = { address: "smtp.gmail.com", port: 587, domain: "example.com", authentication: "plain", enable_starttls_auto: true, user_name: ENV["GMAIL_USERNAME"], password: ENV["GMAIL_PASSWORD"] }
You can replace ENV["GMAIL_USERNAME"]
and ENV["GMAIL_PASSWORD"]
with your Gmail username and password. However, committing the file to a public GitHub repository will expose your secret password.
If you’re familiar with setting Unix environment variables, it’s advisable to leave config.action_mailer.smtp_settings
unchanged and set your environment variables in the file that is read when starting an interactive shell (the ~/.bashrc file for the bash shell). This will keep the password out of your repository.
Are you using a bash shell? Use echo $SHELL
to find out. For a bash shell, edit the ~/.bashrc file and add:
export GMAIL_USERNAME="[email protected]" export GMAIL_PASSWORD="secret*"
Open a new shell or restart your terminal application to continue.
Complete your email configuration by modifying
config/initializers/devise.rb
and setting the config.mailer_sender
option for the return email address for messages that Devise sends from the application.
You’ll want to set up a default user so you can test the app. The file db/seeds.rb already contains:
puts 'SETTING UP DEFAULT USER LOGIN' user = User.create! :name => 'First User', :email => '[email protected]', :password => 'please', :password_confirmation => 'please', :confirmed_at => Time.now.utc puts 'New user created: ' << user.name user2 = User.create! :name => 'Second User', :email => '[email protected]', :password => 'please', :password_confirmation => 'please', :confirmed_at => Time.now.utc puts 'New user created: ' << user2.name user.add_role :admin
You can change the values for name, email, and password as you wish.
The starter app script has already set up the database and added the default user.
If you change the default user’s name, email, or password you’ll need to reset the database:
$ bundle exec rake db:reset
If you need to, you can run $ bundle exec rake db:reset
whenever you need to recreate the database.
You can check that the example app runs properly by entering the command
$ rails server
To see your application in action, open a browser window and navigate to http://localhost:3000/. You should see the default user listed on the home page. When you click on the user’s name, you should be required to log in before seeing the user’s detail page.
Stop the server with Control-C.
If you’ve tested the example app, you’ve seen that any user who logs in will see a list of all the users on the home page. That’s fine for an example app but it’s not what we want for production.
Replace the contents of the file app/views/home/index.html.haml:
%h3 Welcome
You can embellish the page as you wish.
Modify the file app/controllers/home_controller.rb to remove the index
method:
class HomeController < ApplicationController end
We will set a configuration constant Rails.configuration.launched
in an initializer file. This constant will be set to false (before we launch our site) or true (after we launch our site).
Create a file config/initializers/prelaunch-signup.rb:
# change to "true" (and restart) when you want visitors to sign up without an invitation Rails.configuration.launched = false
We’ll use the configuration constant anywhere in the application where we want behavior to be dependent on whether we’ve launched the site.
Now we’ll pick a user story and turn it into a specification that will guide implementation of a feature.
First we’ll get our git workflow set up for adding a new feature.
When you are using git for version control, you can commit every time you save a file, even for the tiniest typo fixes. If only you will ever see your git commits, no one will care. But if you are working on a team, either commercially or as part of an open source project, you will drive your fellow programmers crazy if they try to follow your work and see such “granular” commits. Instead, get in the habit of creating a git branch each time you begin work to implement a feature. When your new feature is complete, merge the branch and “squash” the commits so your comrades see just one commit for the entire feature.
Create a new git branch for this feature:
$ git checkout -b request-invitation
The command creates a new branch named “request-invitation” and switches to it, analogous to copying all your files to a new directory and moving to work in the new directory (though that is not really what happens with git).
Here’s the user story we’ll specify and implement:
*Request Invitation* As a visitor to the website I want to request an invitation so I'll be notified when the site is launched
In many situations, your user story is all you need to guide you in coding the implementation. If you are both the product owner and a hands-on developer, you don’t need a written specification to implement a feature. Many developers code directly from a user story. However, I recommend wriitng a specification before coding.
Consider the benefits of writing a specification:
- It helps us discover the functionality we need to implement.
- It is a “to-do list” to identify what needs to be accomplished and helps us track progress.
- It helps us describe and discuss features with our business partners.
- It is the basis for acceptance testing or integration testing.
For the purposes of this tutorial, acceptance tests and integration tests are synonymous. Acceptance tests demonstrate that developers have succeeded in implementing a specification. Integration tests assure you that your application runs as intended. We will use the specification to create an automated acceptance test so we know when a feature has been successfully implemented. Our acceptance tests also serve as integration tests so we can continue to test the code as we build out or maintain the application. Automated acceptance tests are the key to managing risk for a software development project.
We’ll use Cucumber to create our specification and acceptance tests. Not all developers use Cucumber. Some developers create integration tests using Capybara in combination with RSpec as described in Ryan Bates’s How I Test Railscast. Cucumber is appropriate when a team includes nonprogrammers who are involved in defining product requirements or there is a need for a specification and acceptance tests to be maintained independently of implementation (for example, when implementation is outsourced). For this tutorial, we may all be programmers, but using Cucumber to create a specification helps to describe the features, organize the tutorial, and break the work into discrete tasks.
Cucumber is a tool for managing software development. It’s up to you to decide if you need to establish management processes at this stage of your business.
Now we begin writing our specification.
The features directory contains our Cucumber feature files. We can organize Cucumber feature files any way we wish by placing them in subdirectories. For this application, we’ll organize features by roles in subdirectories for “visitors” and “owner”. Create a subdirectory features/visitors and then create the following file:
features/visitors/request_invitation.feature
Feature: Request Invitation As a visitor to the website I want to request an invitation so I can be notified when the site is launched Background: Given I am not logged in Scenario: User views home page When I visit the home page Then I should see a button "Request Invitation" Scenario: User views invitation request form When I visit the home page And I click a button "Request Invitation" Then I should see a form with a field "Email" Scenario: User signs up with valid data When I request an invitation with valid user data Then I should see a message "Thank You" And my email address should be stored in the database And my account should be unconfirmed And I should receive an email with subject "Request Received" Scenario: User signs up with invalid email When I request an invitation with an invalid email Then I should see an invalid email message
This Cucumber feature file contains the specification needed to implement the user story “Request Invitation.”
It’s important to note that user stories don’t necessarily translate directly into Cucumber features. In this case, our first user story is easily transformed into a set of Cucumber scenarios that describe the user story completely. This is not always the case; don’t be concerned if Features != User Stories as explained in Matt Wynne’s blog post.
Here we turn our specification into an automated acceptance test.
Cucumber scenarios can be read as plain English text. Alone, they serve as specifications. To create an acceptance test or integration test, we must write test code for each step in a scenario, called “step definitions.” Our test code interacts directly with our application, as if it was a visitor to the website using the application.
We’ll create step definitions for all the scenario steps in our “Feature: Request Invitation” file.
Create the following file:
features/step_definitions/visitor_steps.rb
def new_user @user ||= { :email => "[email protected]", :password => "please", :password_confirmation => "please" } end def invitation_request user visit '/users/sign_up' fill_in "Email", :with => user[:email] click_button "Request Invitation" end When /^I visit the home page$/ do visit root_path end Then /^I should see a button "([^\"]*)"$/ do |arg1| page.should have_button (arg1) end When /^I click a button "([^"]*)"$/ do |arg1| click_button (arg1) end Then /^I should see a form with a field "([^"]*)"$/ do |arg1| page.should have_content (arg1) end Then /^I should see a message "([^\"]*)"$/ do |arg1| page.should have_content (arg1) end Then /^my email address should be stored in the database$/ do test_user = User.find_by_email("[email protected]") test_user.should respond_to(:email) end Then /^my account should be unconfirmed$/ do test_user = User.find_by_email("[email protected]") test_user.confirmed_at.should be_nil end When /^I request an invitation with valid user data$/ do invitation_request new_user end When /^I request an invitation with an invalid email$/ do user = new_user.merge(:email => "notanemail") invitation_request user end
These step definitions accommodate all scenarios in our “Request Invitation” feature.
Cucumber uses all the step definitions in separate files in the features/step_definitions/ directory so we can group the step definitions in as many files as we want.
Be sure you’ve set up the database for testing before running Cucumber:
$ bundle exec rake db:test:prepare
Then we can run our integration test with the following command:
$ bundle exec cucumber features/visitors/request_invitation.feature
The test should fail indicating that the home page has no button “Request Invitation.”
We begin implementing the actual application here.
The application’s home page doesn’t contain a “Request Invitation” form. Should we add a sign-up form to the home page? We could but we already have a sign-up form provided by Devise, our authentication gem. It’ll be easier to adapt the existing Devise mechanism.
We’ll modify the Devise form to use the SimpleForm gem. The SimpleForm gem lets us create forms that include tags that apply attractive Twitter Bootstrap form styles.
Take a look at the file app/views/devise/registrations/new.html.haml. We’ll modify it to make it a “Request Invitation” form.
- We’ll use SimpleForm.
- We’ll change the heading from “Sign up” to “Request Invitation.”
- We’ll remove the password fields.
- We’ll remove the user name field (you could keep it if you wish).
- We’ll change the submit button text from “Sign up” to “Request Invitation.”
%h2 Request Invitation = simple_form_for(resource, :as => resource_name, :url => registration_path(resource_name)) do |f| = devise_error_messages! = f.input :email, :placeholder => '[email protected]' = f.submit "Request Invitation"
Devise won’t let us create a new user without a password when we use the default :validatable
module. We want Devise to validate the email address but ignore the missing password, so we’ll override Devise’s password_required?
method.
Modify the file app/models/user.rb to override methods supplied by Devise:
class User < ActiveRecord::Base rolify # Include default devise modules. Others available are: # :token_authenticatable, :encryptable, :confirmable, :lockable, :timeoutable and :omniauthable devise :invitable, :database_authenticatable, :registerable, :confirmable, :recoverable, :rememberable, :trackable, :validatable # Setup accessible (or protected) attributes for your model attr_accessible :name, :email, :password, :password_confirmation, :remember_me, :confirmed_at # override Devise method # no password is required when the account is created; validate password when the user sets one def password_required? if !persisted? false else !password.nil? || !password_confirmation.nil? end end end
To make sure our acceptance tests continue to pass, we’ll need to make changes to the Cucumber step definition file features/step_definitions/user_steps.rb.
Replace the sign_up
method with the following:
def sign_up delete_user visit '/users/sign_up' fill_in "Email", :with => @visitor[:email] click_button "Request Invitation" find_user end
We no longer ask the visitor to set a password when the account is created.
In the features/users/sign_up.feature file, remove the following:
Scenario: User signs up without password When I sign up without a password Then I should see a missing password message Scenario: User signs up without password confirmation When I sign up without a password confirmation Then I should see a missing password confirmation message Scenario: User signs up with mismatched password and confirmation When I sign up with a mismatched password confirmation Then I should see a mismatched password message
We no longer ask the visitor to supply a name when the account is created.
In the features/users/user_show.feature file, remove the following:
Scenario: Viewing users Given I exist as a user When I look at the list of users Then I should see my name
Examine the config/routes.rb file to learn how to use the Devise registrations page as our home page.
The starter app script sets up the config/routes.rb file with a route to the home page for authenticated users (those who have an account and are logged in) and the same route for all other users (those who have no account or are not logged in).
authenticated :user do root :to => 'home#index' end root :to => "home#index"
To make the Devise registrations page serve as the home page for users who have no account (or are not logged in), replace the second root :to => "home#index"
so you see this:
authenticated :user do root :to => 'home#index' end devise_scope :user do root :to => "devise/registrations#new" end
With this change, casual visitors will see a “Request Invitation” form on the home page. And users who log in (presumably only an administrator or invited guests) will see the application “home#index” page.
For a simple “thank you” page, there’s no need to create a dynamic page with a controller and view. Instead, create a file for a static web page:
public/thankyou.html
<h1>Thank You</h1>
Obviously, you can embellish this page as needed.
Later, this tutorial will show you how to eliminate the “thank you” page and use AJAX to update the sign up page with a thank you message. For now, it’s helpful to see a simple implementation without AJAX.
We want the visitor to see the “thank you” page after they request an invitation.
Override the Devise::RegistrationsController with a new controller. Create a file:
app/controllers/registrations_controller.rb
class RegistrationsController < Devise::RegistrationsController protected def after_inactive_sign_up_path_for(resource) '/thankyou.html' end def after_sign_up_path_for(resource) '/thankyou.html' end end
When a visitor creates an account by requesting an invitation, Devise will call the after_inactive_sign_up_path_for
method to redirect the visitor to the thank you page. In the future, after you launch the application and allow visitors to become active as soon as they create an account, you may use the after_sign_up_path_for
to redirect the new user to the thank you page.
Modify config/routes.rb to use the new controller. Replace devise_for :users
with:
devise_for :users, :controllers => { :registrations => "registrations" }
To make sure our acceptance tests continue to pass, we’ll need to make changes to the Cucumber step definition file features/step_definitions/user_steps.rb.
Replace the “successful sign up message” step definition content with “Thank You” (or anything else you’ve added to the thank you page):
Then /^I should see a successful sign up message$/ do page.should have_content "Thank You" end
For more information, the Devise wiki explains How to Redirect to a Specific Page on Successful Sign Up.
We want a visitor to create a new account when they request an invitation but we don’t want to confirm the email address and activate the account until we send an invitation.
There are several ways to implement this functionality. One approach is to add an “active” attribute to the User model and designate the account as “inactive” when it is created. Another approach is to simply revise the confirmation email to make it a simple “welcome” without the confirmation request but this would require re-implementing the confirmation request process later. The simplest approach is to postpone sending the user a request to confirm their email address, leaving the account unconfirmed until after we send the user an invitation.
We’ll modify the file app/models/user.rb to override methods supplied by Devise:
class User < ActiveRecord::Base rolify # Include default devise modules. Others available are: # :token_authenticatable, :encryptable, :confirmable, :lockable, :timeoutable and :omniauthable devise :invitable, :database_authenticatable, :registerable, :confirmable, :recoverable, :rememberable, :trackable, :validatable # Setup accessible (or protected) attributes for your model attr_accessible :name, :email, :password, :password_confirmation, :remember_me, :confirmed_at # override Devise method # no password is required when the account is created; validate password when the user sets one def password_required? if !persisted? false else !password.nil? || !password_confirmation.nil? end end # override Devise method def confirmation_required? false end # override Devise method def active_for_authentication? confirmed? || confirmation_period_valid? end end
Devise uses a conditional “after_create” callback to generate a confirmation token and send the confimation request email. It is only called if confirmation_required?
returns true. By indicating that “confirmation is not required,” no confimation email will be sent when the account is created.
When we tell Devise that “confirmation is not required,” Devise will assume that any new account is “active_for_authentication.” We don’t want that, so we override the active_for_authentication?
method so that unconfirmed accounts are not active.
Ordinarily, when the Devise Confirmable module is configured and a new user requests an invitation, Devise will send an email with instructions to confirm the account. We’ve changed this behavior so the user doesn’t get a confirmation request. However, we still want to send a welcome email.
We didn’t revise the confirmation email to make it a welcome message. That might seem simpler but it would require us to re-implement the confirmation request process later. Instead we’ll add send an email welcome message using a new ActionMailer method when the account is created.
Generate a mailer with views and a spec:
$ rails generate mailer UserMailer
Modify the file spec/mailers/user_mailer_spec.rb to create a test:
require "spec_helper" describe UserMailer do before(:all) do @user = FactoryGirl.create(:user, email: "[email protected]") @email = UserMailer.welcome_email(@user).deliver end it "should be delivered to the email address provided" do @email.should deliver_to("[email protected]") end it "should contain the correct message in the mail body" do @email.should have_body_text(/Welcome/) end it "should have the correct subject" do @email.should have_subject(/Request Received/) end end
Add a welcome_email
method to the mailer by editing the file app/mailers/user_mailer.rb:
class UserMailer < ActionMailer::Base default :from => "[email protected]" def welcome_email(user) mail(:to => user.email, :subject => "Invitation Request Received") end end
Create a mailer view by creating a file app/views/user_mailer/welcome_email.html.erb. This will be the template used for the email, formatted in HTML:
<!DOCTYPE html> <html> <head> <meta content="text/html; charset=UTF-8" http-equiv="Content-Type" /> </head> <body> <h1>Welcome</h1> <p> We have received your request for an invitation to example.com. </p> <p> We'll contact you when we launch. </p> </body> </html>
It is a good idea to make a text-only version for this message. Create a file app/views/user_mailer/welcome_email.text.erb:
Welcome! We have received your request for an invitation to example.com. We'll contact you when we launch.
When you call the mailer method, ActionMailer will detect the two templates (text and HTML) and automatically generate a multipart/alternative email.
Now we’ll wire up the User model to send the welcome message when an account is created.
Modify the file app/models/user.rb to add the send_welcome_email
method:
class User < ActiveRecord::Base rolify # Include default devise modules. Others available are: # :token_authenticatable, :encryptable, :confirmable, :lockable, :timeoutable and :omniauthable devise :invitable, :database_authenticatable, :registerable, :confirmable, :recoverable, :rememberable, :trackable, :validatable # Setup accessible (or protected) attributes for your model attr_accessible :name, :email, :password, :password_confirmation, :remember_me, :confirmed_at after_create :send_welcome_email # override Devise method def confirmation_required? false end # override Devise method def active_for_authentication? confirmed? || confirmation_period_valid? end private def send_welcome_email UserMailer.welcome_email(self).deliver end end
Now the visitor will get a welcome email when they request an invitation.
The required functionality is largely complete. But there are a few small changes to make.
If the user visits the sign-in page immediately after requesting an invitation, they may see this flash message:
“A message with a confirmation link has been sent to your email address. Please open the link to activate your account.”
In the file config/locales/devise.en.yml, find this message:
signed_up_but_unconfirmed: 'A message with a confirmation link has been sent to your email address. Please open the link to activate your account.'
Replace it with this:
signed_up_but_unconfirmed: 'Your invitation request has been received. You will receive an invitation when we launch.'
Be careful not to use an apostrophe or single quote in the message unless you surround the text with double quotes.
If the visitor attempts to sign in, they will see this message:
“You have to confirm your account before continuing.”
In the file config/locales/devise.en.yml, find this message:
unconfirmed: 'You have to confirm your account before continuing.'
Replace it with this:
unconfirmed: 'Your account is not active.'
To make sure our acceptance tests continue to pass, we’ll need to make changes to the Cucumber step definition file features/step_definitions/user_steps.rb.
Replace the “unconfirmed account message” step definition:
Then /^I see an unconfirmed account message$/ do page.should have_content "Your account is not active." end
The user will also see links on the sign-in page: “Didn’t receive confirmation instructions?” and “Forgot your password?”. We want visitors to see these links after we launch. Before we launch, we don’t want visitors to see these links. The sign-in page is provided from a view template in the Devise gem. We don’t have to modify the sign-in page. Instead we’ll create a new “links” partial.
In the “links” partial we’ll use a configuration constant: Rails.configuration.launched
. This constant is set to false (before we launch our site) or true (after we launch our site). When the constant is false, the links for “Didn’t receive confirmation instructions?” and “Forgot your password?” will not appear.
Create a file app/views/devise/shared/_links.html.haml:
- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' = link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %br/ - if devise_mapping.omniauthable? - resource_class.omniauth_providers.each do |provider| = link_to "Sign in with #{provider.to_s.titleize}", omniauth_authorize_path(resource_name, provider) %br/ - if devise_mapping.confirmable? && controller_name != 'confirmations' && Rails.configuration.launched == true = link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) %br/ - if devise_mapping.recoverable? && controller_name != 'passwords' && Rails.configuration.launched == true = link_to "Forgot your password?", new_password_path(resource_name) %br/
Now our feature is complete.
Run the integration test with the following command:
$ bundle exec cucumber features/visitors/request_invitation.feature
The test should succeed.
Evaluating only the functionality, the feature is complete. In the next section, we’ll improve the look and feel of the feature.
Since the new feature is complete, merge the working branch to “master” and squash the commits so you have just one commit for the entire feature:
$ git checkout master $ git merge --squash request-invitation $ git commit -am "implement 'Request Invitation' feature"
You can delete the working branch when you’re done:
$ git branch -D request-invitation
The functionality of the “Request Invitation” feature is complete. Let’s improve the look and feel of the feature.
Create a new git branch for the changes you’ll make to the page design:
$ git checkout -b work-in-progress
The command creates a new branch named “work-in-progress” and switches to it.
It’d be nice for the “request invitation” page to include some “romance copy” to convince the visitor of the value of the site. The “call to action” could be a big button that says, “Request Invitation,” which opens a modal window and invites the visitor to enter an email address and click a submit button.
Twitter Bootstrap gives us everything we need to implement this in a few lines of code.
Open the file app/views/devise/registrations/new.html.haml and replace the contents with this new code:
#request-invite.modal{:style => "display: none;"} = simple_form_for resource, :as => resource_name, :url => registration_path(resource_name) , :html => {:class => 'form-horizontal' } do |f| .modal-header %a.close{"data-dismiss" => "modal"} × %h3 Request Invitation .modal-body = f.error_notification = f.hidden_field :password, :value => 'please' = f.input :email, :placeholder => '[email protected]' .modal-footer = f.submit "Request Invitation", :class => "btn.btn-success", :id => 'invitation_button' %a.btn{"data-dismiss" => "modal", :href => "#"} Close #romance-copy{:style => "text-align: center; margin-top: 80px"} %h2 Want in? #call-to-action{:style => "text-align: center; margin-top: 80px"} %a.btn.btn-primary.btn-large{"data-toggle" => "modal", :href => "#request-invite"} Request invite
The revised view code includes CSS classes from Twitter Bootstrap that implement the modal window and apply style to the buttons. You’ll note that our code includes some simple style rules to position the romance copy and the call-to-action button. These styles could be moved to an external stylesheet; for now, it’s easier to include them in the view file.
Displaying the “request invitation” form inside a modal window is a nice touch but it creates a problem: Form validation error messages are hidden. See for yourself. Open the modal window, leave the email field blank and click the submit button. You’ll see the home page without any indication that there was a problem. Click the home page “Request Invite” button again and you’ll see there is an error message in the modal window.
Our first option is to force the form to be displayed when errors are present. We can do this by modifying the first line like this:
#request-invite.modal{:style => "display: #{@user.errors.any? ? 'block' : 'none'};"}
When you test this by submitting a form without an email address, you’ll see the form appears with error messages. However, we don’t see the full effect of the modal window because the page is not opaque as it should be with the modal window. We can keep the modified code, but we really need some jQuery code to trigger the modal window when an error message is present.
Add the following to the file app/assets/javascripts/application.js:
// display validation errors for the "request invitation" form $('document').ready(function() { if ($('#error_explanation').length > 0) { $("#request-invite").modal('toggle'); } })
When the #error_explanation
element contains text, the page will refresh and the modal window will be displayed properly.
Thank you to Andrea Pavoni for the AJAX implementation.
We’ve improved the appearance of the “invitation request” page by adding a modal window using Twitter Bootstrap. We can make further improvements. As implemented, the page refreshes with a roundtrip to the server when we submit the form successfully (or get an an error). Our app will look more sophisticated if we use AJAX techniques to update the page without a page refresh.
We don’t need changes to the “invitation request” page. We’ll add a partial to provide a “thank you” message. Add a file app/views/devise/registrations/_thankyou.html.haml:
%h1 Thank you #request-invite.modal{:style => "display: 'block';"} .modal-header %a.close{"data-dismiss" => "modal"} × %h3 Thank you! .modal-body %p We have received your request for an invitation to example.com. %p We'll contact you when we launch.
Next, add some JavaScript to the file app/assets/javascripts/application.js. This will trigger an AJAX submission action when the “request invitation” button is clicked and update the #request-invite
div on completion.
$('document').ready(function() { // display validation errors for the "request invitation" form if ($('#error_explanation').length > 0) { $("#request-invite").modal('toggle'); } // use AJAX to submit the "request invitation" form $('#invitation_button').live('click', function() { var email = $('form #user_email').val(); var password = $('form #user_password').val(); var dataString = 'user[email]='+ email + '&user[password]=' + password; $.ajax({ type: "POST", url: "/users", data: dataString, success: function(data) { $('#request-invite').html(data); } }); return false; }); })
Finally, we need to override the create
method in the Devise registration controller to render the “thank you” partial. Modify the file app/controllers/registrations_controller.rb:
class RegistrationsController < Devise::RegistrationsController # ovverride #create to respond to AJAX with a partial def create build_resource if resource.save if resource.active_for_authentication? sign_in(resource_name, resource) (render(:partial => 'thankyou', :layout => false) && return) if request.xhr? respond_with resource, :location => after_sign_up_path_for(resource) else expire_session_data_after_sign_in! (render(:partial => 'thankyou', :layout => false) && return) if request.xhr? respond_with resource, :location => after_inactive_sign_up_path_for(resource) end else clean_up_passwords resource render :action => :new, :layout => !request.xhr? end end protected def after_inactive_sign_up_path_for(resource) '/thankyou.html' end def after_sign_up_path_for(resource) '/thankyou.html' end end
To make sure our acceptance tests continue to pass, we’ll need to make a tweak to the Cucumber step definition file features/step_definitions/user_steps.rb.
Replace the “unconfirmed account message” step definition to accommodate the SimpleForm output:
Then /^I should see an invalid email message$/ do page.should have_content "Emailis invalid" end
Now we can say we have built a “Web 2.0” application. When the visitor submits the form, the modal window changes to display a “thank you” message (or an error message) without a page refresh.
Merge the working branch to “master”:
$ git checkout master $ git merge --squash work-in-progress $ git commit -am "improve design for 'Request Invitation' feature" $ git branch -D work-in-progress
Now we’ll implement the next user story. As the owner of the site, you’ll want to visit and see how many visitors have requested invitations. You likely don’t want anyone else to view that information, so we’ll need some form of authorization to restrict access to yourself or approved administrators.
First we’ll set up our git workflow so we can add a new feature.
Create a new git branch for this feature:
$ git checkout -b view-progress
The command creates a new branch named “view-progress.”
Here’s the user story we’ll specify and implement:
*View Progress* As the owner of the site I want to know how many visitors have requested invitations so I can know if my offer is popular
Let’s write our specification.
Create a subdirectory features/admin and then create the following file:
features/admin/view_progress.feature
Feature: View Progress As the owner of the site I want to know how many visitors have requested invitations so I can know if my offer is popular Scenario: Administrator views list of users Given I am logged in as an administrator When I visit the users page Then I should see a list of users Scenario: User cannot view list of users Given I am logged in When I visit the users page Then I should see an access denied message
This Cucumber feature file contains the specification needed to implement the user story “View Progress.”
Turn the specification into an automated acceptance test. Create step definitions for all the scenario steps in our “Feature: View Progress” file.
Create the following file:
features/step_definitions/admin_steps.rb
Given /^I am logged in as an administrator$/ do create_user @user.add_role :admin sign_in end When /^I visit the users page$/ do visit users_path end Then /^I should see a list of users$/ do page.should have_content @user[:email] end Then /^I should see an access denied message$/ do page.should have_content "Not authorized as an administrator" end
Be sure you’ve set up the database for testing before running Cucumber:
$ bundle exec rake db:test:prepare
Then we can run our integration test with the following command:
$ bundle exec cucumber features/admin/view_progress.feature
The test will succeed. That’s because the application template we used to generate the application already implements the functionality we want. Let’s see how.
We already have a page that shows a list of users.
Take a look at the file app/views/users/index.html.haml:
%h2 Users - @users.each do |user| %br/ #{link_to user.email, user} signed up #{user.created_at.to_date}
We can use this page as our “administrative dashboard.” It’s fine for our example application; if you later build a more complex application, you may wish to create an AdminController with corresponding Admin views.
Take a look at the controller file app/controllers/users_controller.rb:
class UsersController < ApplicationController before_filter :authenticate_user! def index authorize! :index, @user, :message => 'Not authorized as an administrator.' @users = User.all end def show @user = User.find(params[:id]) end end
Notice the index method contains a statement which limits access to administrators only:
authorize! :index, @user, :message => 'Not authorized as an administrator.'
When we generated our starter app, we answered “yes” to the question, “Would you like to manage authorization with CanCan & Rolify?” The application template set up everything we need for limiting access to administrative pages. You can take a look at the tutorial for the rails3-bootstrap-devise-cancan example app to see how it is done. In a nutshell, Rolify allows us to designate a user as an “admin.” CanCan provides an Ability
class and the authorize!
method. We add the authorize!
method to any controller action that should be restricted to administrators only. The authorize!
method makes a call to the Ability
class to determine which users are authorized for access.
If you look at the file app/models/ability.rb, you’ll see this:
class Ability include CanCan::Ability def initialize(user) user ||= User.new # guest user (not logged in) if user.has_role? :admin can :manage, :all end end end
Very simply, if we add the authorize!
method to any controller action, it will check the Ability
class and allow access for users in the “admin” role. By default, other users will not be allowed. Of course, if the authorize!
method is not included in a controller action, any user will have access.
Run the integration test with the following command:
$ bundle exec cucumber features/admin/view_progress.feature
The test should succeed.
Since the new feature is complete, merge the working branch to “master” and squash the commits so you have just one commit for the entire feature:
$ git checkout master $ git merge --squash view-progress $ git commit -am "implement 'View Progress' feature"
You can delete the working branch when you’re done:
$ git branch -D view-progress
Our “administrative dashboard” shows a list of visitors who have requested invitations. It meets our basic requirements but I’m sure we’d prefer to see the trend of sign-ups over time. Let’s add a chart.
If you look at The Ruby Toolbox site in the Graphing category, you’ll see ten gems of varying popularity, age and activity. Google offers an online service, Google Chart Tools, that uses Javascript in the browser to generate charts and graphs. Two of the gems listed use Google Chart Tools to generate charts: Matt Aimonetti’s GoogleCharts and Winston Teo’s GoogleVisualr. We’ll use the GoogleVisualr gem; it includes a useful view helper.
First we’ll set up our git workflow so we can add a new feature.
Create a new git branch for this feature:
$ git checkout -b add-chart
We need to add the GoogleVisualr gem to the Gemfile:
gem "google_visualr", ">= 2.1.2"
Run the bundle install
command to install the required gem on your computer:
$ bundle install
We’ll add the Javascript library that loads the Google Chart Tools API. We won’t add it to the Javascript files in our asset pipeline. Instead, we’ll add it to the application layout for the administrative page, loading it directly from Google’s content delivery network (CDN).
We want to add it to a single page of our application. Our default application layout will accept a page-specific addition to the head
section. The = yield(:head)
statement in the file app/views/layouts/application.html.haml will incorporate anything we add to a page with the content_for :head
tag.
Modify the file app/views/users/index.html.haml:
- content_for :head do = javascript_include_tag 'http://www.google.com/jsapi' %h2 Users - @users.each do |user| %br/ #{link_to user.email, user} signed up #{user.created_at.to_date}
The default application layout will pick up the content_for :head
code and add it to the head
section of the application layout. The page will load the Javascript code for the Google Chart Tools API from Google’s content delivery network.
We’ll use the GoogleVisualr gem in the user controller to retrieve data and generate a chart.
Modify the controller file app/controllers/users_controller.rb:
class UsersController < ApplicationController before_filter :authenticate_user! def index authorize! :index, @user, :message => 'Not authorized as an administrator.' @users = User.all @chart = create_chart end def show @user = User.find(params[:id]) end private def create_chart users_by_day = User.group("DATE(created_at)").count data_table = GoogleVisualr::DataTable.new data_table.new_column('date') data_table.new_column('number') users_by_day.each do |day| data_table.add_row([ Date.parse(day[0]), day[1]]) end @chart = GoogleVisualr::Interactive::AnnotatedTimeLine.new(data_table) end end
The UsersController will use the GoogleVisualr gem to interact with the Google Chart Tools API to generate a chart. Google Chart Tools offers a wide variety of charts (see examples) and the GoogleVisualr documentation shows how to implement many of the available charts. We use the Annotated Time Line chart.
Let’s add the chart to the page that shows a list of users.
Modify the file app/views/users/index.html.haml:
- content_for :head do = javascript_include_tag 'http://www.google.com/jsapi' #chart{:style => "width: 700px; height: 240px;"} = render_chart @chart, 'chart' %h2 Users - @users.each do |user| %br/ #{link_to user.email, user} signed up #{user.created_at.to_date}
The Annotated Time Line chart must be placed inside a div
tag with a fixed width and height because it uses Adobe Flash Player to provide interactive features.
We now have a simple chart on the administrative page.
Since the new feature is complete, merge the working branch to “master” and squash the commits so you have just one commit for the entire feature:
$ git checkout master $ git merge --squash add-chart $ git commit -am "add chart to admin dashboard"
You can delete the working branch when you’re done:
$ git branch -D add-chart
We can make our administrative dashboard more useful by improving the display of the table of users. As implemented so far, it is just a simple list. We can add a jQuery plugin to give us a table that is sortable, searchable, and with pagination. The DataTables jQuery plugin is popular and integrates with Twitter Bootstrap. Ryan Bates offers a DataTables RailsCast that shows how to set it up using Robin Wenglewski’s jquery-datatables-rails gem.
First we’ll set up our git workflow so we can add a new feature.
Create a new git branch for this feature:
$ git checkout -b sort-table
We need to add the jquery-datatables-rails gem to the Gemfile:
gem "jquery-datatables-rails"
Run the bundle install
command to install the required gem on your computer:
$ bundle install
Update the file app/assets/javascripts/application.js to include the jQuery plugin and its Bootstrap support library:
//= require jquery //= require jquery_ujs //= require bootstrap //= require dataTables/jquery.dataTables //= require dataTables/jquery.dataTables.bootstrap //= require_tree .
Update the file app/assets/stylesheets/application.css.scss to include CSS rules for DataTables that integrate with Twitter Bootstrap:
*= require_self *= require dataTables/jquery.dataTables.bootstrap *= require_tree .
We’ll need JavaScript for the administrative page where we will use DataTables. Add CoffeeScript to the file app/assets/javascripts/users.js.coffee:
#// For fixed width containers jQuery -> $('.datatable').dataTable({ "sDom": "<'row'<'span6'l><'span6'f>r>t<'row'<'span6'i><'span6'p>>", "sPaginationType": "bootstrap" });
Let’s add a table of users to the administrative page.
Modify the file app/views/users/index.html.haml:
- content_for :head do = javascript_include_tag 'http://www.google.com/jsapi' %h2 Users .span9 #chart{:style => "width: 700px; height: 240px"} = render_chart @chart, 'chart' %br .span9 %table.datatable.table.table-bordered.table-condensed %thead %tr %th Email %th Signed up %tbody - @users.each do |user| %tr %td #{link_to user.email, user} %td #{user.created_at.to_date}
We now have a sortable, searchable table of users on the administrative page.
Note: The top line of the table (containing “Show entries” and “Search”) isn’t aligned nicely. Suggestions to improve it?
As implemented, our administrative page has a scalability constraint. In the Users controller, we query the database for all the users in the database and render a page containing a table of all the users. If we have many thousands of users requesting invitations, the administrative page will be slow to load. Ryan Bates shows how to implement pagination using server-side processing in his DataTables RailsCast. For a startup launch with a few thousand users, our implementation should be adequate.
Since the new feature is complete, merge the working branch to “master” and squash the commits so you have just one commit for the entire feature:
$ git checkout master $ git merge --squash sort-table $ git commit -am "add sortable table on the admin dashboard"
You can delete the working branch when you’re done:
$ git branch -D sort-table
As implemented, our application collects invitation requests from visitors. We’ve built a simple administrative dashboard displaying a graph of requests over time and a list of visitors’ email addresses. This is all you need while you are promoting and building your application. But when you ready for users to begin trying your site, you’ll want to select users for your beta test, send each an invitation, and ask each to confirm their email address and set an account password. We’ll call this functionality the “Send Invitations” feature and we’ll start implementing it with a user story and acceptance tests.
Create a new git branch for this feature:
$ git checkout -b invitations
Here’s the user story we’ll specify and implement:
*Send Invitations* As the owner of the site I want to send invitations to visitors who have requested invitations so users can try the site
Let’s write our specification.
Add the following file:
features/admin/send_invitations.feature
Feature: Send Invitations As the owner of the site I want to send invitations to visitors who have requested invitations so users can try the site Scenario: Administrator sends invitation Given I am logged in as an administrator When I visit the users page And I click a link "send invitation" And I open the email with subject "Confirmation instructions" Then I should see "confirm your account email" in the email body
This Cucumber feature file contains the specification needed to implement the user story “Send Invitations.”
Turn the specification into an automated acceptance test.
Add these steps to the existing file:
features/step_definitions/admin_steps.rb
When /^I click a link "([^"]*)"$/ do |arg1| click_on (arg1) end
Be sure you’ve set up the database for testing before running Cucumber:
$ bundle exec rake db:test:prepare
Then we can run our integration test with the following command:
$ bundle exec cucumber features/admin/send_invitations.feature
The test will fail because we haven’t yet implemented the functionality.
Devise already knows how to send an account confirmation email to a user, with its user.send_confirmation_instructions
method, which will generate a confirmation token and send an email message to the user. We’ll add a “send invitation” link to each user listed on the administrative dashboard. We’ll need a corresponding invite
action in the user controller and a matching route. We also want to make sure only an administrator is authorized to initiate the invite
action.
So far, our application architecture has been very simple. We’ve used a few RESTful actions supplied by Devise and improved on a simple index
method to prepare our administrative dashboard page. Now we’ll need a custom invite
action in the user controller.
Modify the controller file app/controllers/users_controller.rb:
class UsersController < ApplicationController before_filter :authenticate_user! def index authorize! :index, @user, :message => 'Not authorized as an administrator.' @users = User.all @chart = create_chart end def show @user = User.find(params[:id]) end def invite authorize! :invite, @user, :message => 'Not authorized as an administrator.' @user = User.find(params[:id]) @user.send_confirmation_instructions redirect_to :back, :notice => "Sent invitation to #{@user.email}." end private def create_chart users_by_day = User.group("DATE(created_at)").count data_table = GoogleVisualr::DataTable.new data_table.new_column('date') data_table.new_column('number') users_by_day.each do |day| data_table.add_row([ Date.parse(day[0]), day[1]]) end @chart = GoogleVisualr::Interactive::AnnotatedTimeLine.new(data_table) end end
CanCan’s authorize!
method ensures that only an administrator is authorized to initiate the invite
action.
The invite
action will use Devise’s supplied send_confirmation_instructions
method to generate a confirmation token and send an email to the selected user. Then it will redirect the administrator back to a previous page.
Modify config/routes.rb to add the new action. Replace resources :users, :only => [:show, :index]
with:
resources :users, :only => [:show, :index] do get 'invite', :on => :member end
The Rails Guide, Routing from the Outside In, shows how we add additional routes to a RESTful resource.
We will modify the file app/views/users/index.html.haml to add a link to the invite
action for each user. While we’re at it, we’ll add columns to show when the visitor joined (that is, confirmed their account), the number of times they’ve logged it, and the date of the most recent login.
- content_for :head do = javascript_include_tag 'http://www.google.com/jsapi' %h2 Users .span9 #chart{:style => "width: 700px; height: 240px"} = render_chart @chart, 'chart' %br .span9 %table.datatable.table.table-bordered.table-condensed %thead %tr %th Email %th Requested %th Invitation %th Joined %th Visits %th Most Recent %tbody - @users.each do |user| %tr %td #{link_to user.email, user} %td #{user.created_at.to_date} %td #{(user.confirmation_token.nil? ? (link_to "send invitation", invite_user_path(user)) : (link_to "resend", invite_user_path(user))) unless user.confirmed_at} %td #{user.confirmed_at.to_date if user.confirmed_at} %td #{user.sign_in_count if user.sign_in_count} %td #{user.last_sign_in_at.to_date if user.last_sign_in_at}
Now, as the site administrator, you can invite individual users to complete the account confirmation process and obtain access to the site.
There’s a bit of complex logic in the display of the “send invitation” link. First, with unless user.confirmed_at
, we check to see if the user has already confirmed the account. Next, we check the user’s confirmation_token
attribute to see if we’ve already sent an invitation (in which case, a confirmation token was set by Devise). If no confirmation token was set, we display the link “send invitation”; otherwise, we display “resend”.
If you are selecting only a few dozen (or a few hundred) initial users, this process of manual selection will be adequate. If you are ready to launch and want to invite many thousand users, you’ll need a way to invite multiple users with a single action. We need to implement a “bulk invitations” feature.
We’ll add a bulk_invite
action to the controller file app/controllers/users_controller.rb:
class UsersController < ApplicationController before_filter :authenticate_user! def index authorize! :index, @user, :message => 'Not authorized as an administrator.' @users = User.all @chart = create_chart end def show @user = User.find(params[:id]) end def invite authorize! :invite, @user, :message => 'Not authorized as an administrator.' @user = User.find(params[:id]) @user.send_confirmation_instructions redirect_to :back, :notice => "Sent invitation to #{@user.email}." end def bulk_invite authorize! :bulk_invite, @user, :message => 'Not authorized as an administrator.' users = User.where(:confirmation_token => nil).order(:created_at).limit(params[:quantity]) users.each do |user| user.send_confirmation_instructions end redirect_to :back, :notice => "Sent invitation to #{users.count} users." end private def create_chart users_by_day = User.group("DATE(created_at)").count data_table = GoogleVisualr::DataTable.new data_table.new_column('date') data_table.new_column('number') users_by_day.each do |day| data_table.add_row([ Date.parse(day[0]), day[1]]) end @chart = GoogleVisualr::Interactive::AnnotatedTimeLine.new(data_table) end end
The bulk_invite
action has a few peculiarities. First, we only select users where the :confirmation_token
attribute is null. Devise sets a confirmation token when we send an invitation; with this constraint, we only invite users who have not been previously invited. Second, we make sure we select the oldest records first with the order(:created_at)
method. Finally, we limit the number of records that are retrieved to the size of the batch we want; then we loop over each to send an invitation.
Modify config/routes.rb to add the new action:
RailsPrelaunchSignup::Application.routes.draw do authenticated :user do root :to => 'home#index' end devise_scope :user do root :to => "devise/registrations#new" match '/user/confirmation' => 'confirmations#update', :via => :put, :as => :update_user_confirmation end devise_for :users, :controllers => { :registrations => "registrations", :confirmations => "confirmations" } match 'users/bulk_invite/:quantity' => 'users#bulk_invite', :via => :get, :as => :bulk_invite resources :users, :only => [:show, :index] do get 'invite', :on => :member end end
Finally, we will modify the file app/views/users/index.html.haml to add links for the bulk_invite
action:
- content_for :head do = javascript_include_tag 'http://www.google.com/jsapi' %h2 Users .span9 #chart{:style => "width: 700px; height: 240px"} = render_chart @chart, 'chart' %br .span9 %p Send Bulk Invitations: = link_to "10 ·".html_safe, bulk_invite_path(:quantity => '10') = link_to "50 ·".html_safe, bulk_invite_path(:quantity => '50') = link_to "100 ·".html_safe, bulk_invite_path(:quantity => '100') = link_to "500 ·".html_safe, bulk_invite_path(:quantity => '500') = link_to "1000", bulk_invite_path(:quantity => '1000') %table.datatable.table.table-bordered.table-condensed %thead %tr %th Email %th Requested %th Invitation %th Joined %th Visits %th Most Recent %tbody - @users.each do |user| %tr %td #{link_to user.email, user} %td #{user.created_at.to_date} %td #{(user.confirmation_token.nil? ? (link_to "send invitation", invite_user_path(user)) : (link_to "resend", invite_user_path(user))) unless user.confirmed_at} %td #{user.confirmed_at.to_date if user.confirmed_at} %td #{user.sign_in_count if user.sign_in_count} %td #{user.last_sign_in_at.to_date if user.last_sign_in_at}
We now have the option to send 10, 50, 100, 500, or 1000 invitations at once.
Since the new feature is complete, merge the working branch to “master” and squash the commits so you have just one commit for the entire feature:
$ git checkout master $ git merge --squash invitations $ git commit -am "enable admin to send invitations"
You can delete the working branch when you’re done:
$ git branch -D invitations
Once the user has been invited and has confirmed the account, we need to provide a way for the user to set a password.
The Devise wiki explains How to Override Confirmations So Users Can Pick Their Own Passwords As Part of Confirmation Activation. So far, we’ve made small modifications to customize the behavior of Devise; now we’ll make extensive modifications, overriding the Confirmations controller and view as well as adding methods to the User model.
Create a new git branch for this improvement:
$ git checkout -b fix-password
When an invited user clicks a link in the invitation email to confirm an account, we want to ask them to choose a password for their account. The Devise gem supplies a Confirmations view; we need to replace it with a page that includes fields for password and password confirmation.
Create a new folder app/views/devise/confirmations/.
Create a file app/views/devise/confirmations/show.html.haml:
%h2 Account Activation = form_for resource, :as => resource_name, :url => update_user_confirmation_path, :html => {:method => 'put'}, :id => 'activation-form' do |f| = devise_error_messages! %fieldset %legend Account Activation - if resource.email for #{resource.email} - if @requires_password %p = f.label :password,'Choose a Password:' = f.password_field :password %p = f.label :password_confirmation, 'Password Confirmation:' = f.password_field :password_confirmation = hidden_field_tag :confirmation_token, @confirmation_token %p= f.submit "Activate"
The Devise gem supplies a Confirmations controller; we need to replace it with one that will accommodate our customized view that asks the user to set a password. We’ll override the Devise Confirmations controller by creating our own Confirmations controller. Often, in overriding a controller, we inherit from the original controller. In this case, we’ll inherit from the Devise Passwords controller that provides methods to set the user’s password.
Create a file app/controllers/confirmations_controller.rb:
class ConfirmationsController < Devise::PasswordsController # Remove the first skip_before_filter (:require_no_authentication) if you # don't want to enable logged users to access the confirmation page. skip_before_filter :require_no_authentication skip_before_filter :authenticate_user! # PUT /resource/confirmation def update with_unconfirmed_confirmable do if @confirmable.has_no_password? @confirmable.attempt_set_password(params[:user]) if @confirmable.valid? do_confirm else do_show @confirmable.errors.clear #so that we won't render :new end else self.class.add_error_on(self, :email, :password_allready_set) end end if [email protected]? render 'devise/confirmations/new' end end # GET /resource/confirmation?confirmation_token=abcdef def show with_unconfirmed_confirmable do if @confirmable.has_no_password? do_show else do_confirm end end if [email protected]? render 'devise/confirmations/new' end end protected def with_unconfirmed_confirmable @confirmable = User.find_or_initialize_with_error_by(:confirmation_token, params[:confirmation_token]) if [email protected]_record? @confirmable.only_if_unconfirmed {yield} end end def do_show @confirmation_token = params[:confirmation_token] @requires_password = true self.resource = @confirmable render 'devise/confirmations/show' end def do_confirm @confirmable.confirm! set_flash_message :notice, :confirmed sign_in_and_redirect(resource_name, @confirmable) end end
Modify config/routes.rb to use the new controller:
RailsPrelaunchSignup::Application.routes.draw do authenticated :user do root :to => 'home#index' end devise_scope :user do root :to => "devise/registrations#new" match '/user/confirmation' => 'confirmations#update', :via => :put, :as => :update_user_confirmation end devise_for :users, :controllers => { :registrations => "registrations", :confirmations => "confirmations" } resources :users, :only => [:show, :index] do get 'invite', :on => :member end end
We need to modify the User model to allow the new user to set a password when they confirm their account.
Add the following methods to the file app/models/user.rb
# new function to set the password def attempt_set_password(params) p = {} p[:password] = params[:password] p[:password_confirmation] = params[:password_confirmation] update_attributes(p) end # new function to determine whether a password has been set def has_no_password? self.encrypted_password.blank? end # new function to provide access to protected method pending_any_confirmation def only_if_unconfirmed pending_any_confirmation {yield} end
Merge the new code into the master branch and commit it:
$ git checkout master $ git merge --squash fix-password $ git commit -am "enable a user to set a password"
You can delete the working branch when you’re done:
$ git branch -D fix-password
You can check that your app runs properly by entering the command
$ rails server
To see your application in action, open a browser window and navigate to http://localhost:3000/.
Sign in as the first user (the administrator) using:
- email: [email protected]
- password: please
You’ll see a navigation link for Admin. Clicking the link will display a page with a list of users at
http://localhost:3000/users.
To sign in as the second user, use
- email: [email protected]
- password: please
The second user will not see the Admin navigation link and will not be able to access the page at
http://localhost:3000/users.
If you want to see what the admininstrative dashboard looks like with many users, you can add a line to the db/seeds.rb file to create a hundred bogus users:
100.times {|i| User.create! :name => "User #{i+3}", :email => "user#{i+3}@example.com", :password => 'please', :password_confirmation => 'please', :confirmed_at => (Time.now + i.day).utc, :created_at => (Time.now + i.day).utc }
You’ll have to modify the file app/models/user.rb to allow mass assignment of the created_at
field:
attr_accessible :name, :email, :password, :password_confirmation, :remember_me, :confirmed_at, :created_at
Then run $ bundle exec rake db:reset
to recreate the database and visit the site again.
The tutorial is not yet finished. There will be more to come. Specifically a feature to allow visitors to post to Facebook, Twitter, or Google+ after they request an invitation.
Heroku provides low cost, easily configured Rails application hosting. For your convenience, see Tutorial: Rails on Heroku.
This concludes the tutorial for the RailsApp rails-prelaunch-signup example app.
Was this useful to you? Follow rails_apps on Twitter and tweet some praise. I’d love to know you were helped out by the tutorial.
Get some link juice! Add your website to the list of Rails Applications Built from the Examples. I love to see what people have built with these examples.
Blog it! Share your discovery with the startup community. It’s up to you to get the word out and help fellow entrepreneurs. Links are the best way for people to find resources like this.
Please leave a comment to tell me how long it took for you to read the tutorial and complete the app.
Any issues? Please create an issue on GitHub. Reporting (and patching!) issues helps everyone.
Daniel Kehoe implemented the application and wrote the tutorial.