Invitation Modal

In this tutorial I will show how to create a invitation modal, where the user puts some emails, than the application sends an invitation email for each one.

Running sample

http://fpastore-01.herokuapp.com/

Final version repository

https://github.com/fpastore/01-invitations

Pre-requisites

Please, read first: Bootstrap Project for the Tutorials

Invitation model

Our model will have an array of Emails and a Message.
Since we don’t need to store this invitations, we will not use Active Record model.
Rails 3 brings Active Model, that allow us to use validation, etc, from Active Record, but with no database migrations.
If you want to know more about Active Model, visit: http://railscasts.com/episodes/219-active-model

	#app/models/invitation.rb
	class Invitation
	  include ActiveModel::Validations
	  include ActiveModel::Conversion
	  extend ActiveModel::Naming

	  attr_accessor :emails, :message

	  def initialize(attributes = {:emails => []})
	    attributes.each do |name, value|
	      send("#{name}=", value)
	    end
	  end

	  def persisted?
	    false
	  end
	end

Note that initialize method has :emails => [] as default, because our emails attribute is an array of emails.

Next step, validations.. Time to develop some tests. 😀

Invitation Model test

Before we start, make sure you have Spork server initialized:

	#console
	spork

Everytime that we need run tests, we could use this command:

	#console
	bundle exec rspec spec --drb

When I mention “Test Status: [Passing] [Failing] it’s because it’s time to run the tests.

First, create a “invitation_spec.rb” inside “spec/model” folder and add the success case.

	#spec/model/invitation_spec.rb
	require 'spec_helper'
	describe Invitation do
	  before(:each) do
	    @invitation = Invitation.new
	  end

	  context "when all attributes are correct" do
	    it "should be valid" do
	      @invitation.message = "Custom message..."
	      @invitation.emails << "right@test.com"
	      @invitation.valid?.should be_true
	    end
	  end
	end

Test Status: Passing

The before(:each) ensures that we have an fresh Invitation object for each test. Lets start with the easy one. Our “message” attribute should have a maximum lenght validator, so:


	#spec/model/invitation_spec.rb
	#...
	  describe "Validations" do
	    context "when message has more than 500 characters" do
	      it "have error on message" do
		    @invitation.message = "x"*501
		    @invitation.valid?.should be_false
		    @invitation.errors[:message].any?.should be_true
	      end
	    end
	  end
	#...

Test Status: Failing

Backing to the model:


	#app/model/invitation.rb
	#...
	validates :message, :length => {:maximum => 500}
	#...

Test Status: Passing

The “emails” attribute is an array, so we will not be able to use conventional validators with it, we will need to create our custom ones.
To see more about custom validators, check: http://guides.rubyonrails.org/active_record_validations_callbacks.html#custom-methods.

But before bothering, let’s create our tests:

	#spec/model/invitation_spec.rb
	#...
	describe "Validations" do
	#...
	    context "when some email is in a wrong format" do
	      it "have error on emails" do
		    @invitation.emails << "wrong@"
		    @invitation.valid?.should be_false
		    @invitation.errors[:emails].any?.should be_true
	      end
	    end

	    context "when emails are empty" do
	      it "have error on emails" do
		    @invitation.emails = []
		    @invitation.valid?.should be_false
		    @invitation.errors[:emails].any?.should be_true
	      end
	    end
	end
	#...

Test Status: Failing

Back to the model, we then create our custom validators:

	#app/model/invitation.rb
	#...
	validate :has_at_least_one_filled_email,
		 :validate_emails

	def has_at_least_one_filled_email
	  unless emails.reject{|email| email.blank?}.any?
	    errors.add(:emails, "Please, inform at least one filled email.")
	  end
	end

	def validate_emails
	    if emails.reject{|email| email.blank? || email.match(/^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i)}.any?
		errors.add(:emails, "Please, correct invalid email(s).")
	    end
	end
	#...

The “validate_emails” performs the default email validation, and “has_at_least_one_filled_email” ensures that our Invitation model has at least one filled emails before try to send it (comming soon).
If your not familiar with “reject” function, this check if the logic inside the block is false, than adds to the returned array.

Test Status: Passing

An invitation system without sending emails is worthless

So, lets create our mailer.

	#console
	rails g mailer invitation_mailer

Following our test driven approach, lets create some tests:

	#spec/mailers/invitation_mailer_spec.rb
	require "spec_helper"

	describe InvitationMailer do
	  describe "invite" do
	    let(:mail) { InvitationMailer.invite("felipepastoree@gmail.com") }
	    context "when email sent" do
	      it "have the informed receiver email" do
		mail.to.should eq(["felipepastoree@gmail.com"])
	      end
	    end
	  end
	end

The “let(:mail)” creates our email to be tested, and “invite someone by email” just checks the receiver of the email.

Test Status: Failing

At our InvitationMailer, we create the invite(email) action.

	#app/mailers/invitation_mailer.rb
	class InvitationMailer < ActionMailer::Base
	  default from: "from@example.com"

	  def invite(email)
	    mail :to => email
	  end
	end

Test Status: Passing

The goal is to make receivers visits the website, so we should send a link to your website.

	#spec/mailers/invitation_mailer_test.rb
	#...
	describe "invite" do
	    context "when email sent" do
	#...
	      it {mail.subject.should match(root_url)}
	      it {mail.body.should match(root_url)}
	#...

Test Status: Failing

Since mailers works similar to controllers, mailers have “views” too.

	#app/views/invitation_mailer/invite.text.erb
	Invitation
	Hello, you've been invited to <%= root_url %>.
	Please join us.

And a small change to our invite method…

	#app/mailers/invitation_mailer.rb
	def invite(email)
	    mail :to => email, :subject => "Invitation to #{root_url}"
	end

Test Status: Passing

Oh, we almost forgot, why we have a message attribute at our Invitation model if we’re not using it?

First, some modifications to our tests.

	#spec/mailers/invitation_mailer_test.rb
	#...
	    let(:mail) { InvitationMailer.invite("felipepastoree@gmail.com", "Customized message...") }

	    context "when email sent" do
	      it "have a custom message" do
		mail.body.should match("Customized message...")
	      end
	#...

Test Status: Failing

Than we adapt our mailer and mailer’s view.

	#app/mailers/invitation_mailer.rb
	#...
  	def invite(email, message)
		@message = message
		mail :to => email, :subject => "Invitation to #{root_url}"
	end
	#...
	#app/views/invitation_mailer/invite.text.erb
	Invitation
	Hello, you've been invited to <%= root_url %>.
	<%= @message %>
	Please join us.

Test Status: Passing

Make Invitation model call Invitation Mailer

Ok, next step is to adjust the model to send the emails.


	#spec/models/invitation_spec.rb
	#...
	  context "Send Emails" do
	    before(:each) do
	      #reset emails
	      ActionMailer::Base.deliveries = []
	      @invitation = Invitation.new(:emails => ["felipepastoree@gmail.com", "test@test.com"], :message => "Custom message...")
	    end

	    context "when all values are correct" do
	      it "sends emails" do
		    @invitation.send_invitations.should_not be_nil
		    ActionMailer::Base.deliveries.map(&:to).should eq([["felipepastoree@gmail.com"], ["test@test.com"]])
	      end
	    end

	    context "when no email is informed" do
	      it "dont send emails" do
		    @invitation.emails = []
		    @invitation.send_invitations.should be_nil
		    ActionMailer::Base.deliveries.should eq []
	      end
	    end

	    context "when some email is invalid" do
	      it "dont send emails" do
		    @invitation.emails << "invalidemail"
		    @invitation.send_invitations.should be_nil
		    ActionMailer::Base.deliveries.should eq []
	      end
	    end

	    context "when have filled and blank emails" do
	      it "sends only the filled ones" do
		    @invitation.emails << ""
		    @invitation.send_invitations.should_not be_nil
		    ActionMailer::Base.deliveries.map(&:to).should eq([["felipepastoree@gmail.com"], ["test@test.com"]])
	      end
	    end

	    context "when some emails are duplicated" do
	      it "sends only one of them" do
		    @invitation.emails << "felipepastoree@gmail.com"
		    @invitation.send_invitations.should_not be_nil
		    ActionMailer::Base.deliveries.map(&:to).should eq([["felipepastoree@gmail.com"], ["test@test.com"]])
	      end
	    end
	  end
	#...

Test Status: Failing

Than we adapt the model.


    #app/models/invitation.rb
    #...
      def send_invitations
        # check if invitation is valid
        return nil unless valid?
        emails.reject{|email| email.blank?}.uniq.each do |email|
          InvitationMailer.invite(email, self.message).deliver
        end
      end
    #...

Our method rejects all blank emails and gets only the unique ones before send it.

Test Status: Passing

The controller and view layer

Now that we have a solid tested model layer, we can go to controller and views, relieved that things are working. First, lets create a modal that opens when user clicks at “New Invitation” button.

Integration Tests

Integration tests are the tests that check the function of the entire system, including views. So, let’s create our integration test.

#console
rails g integration_test send_invitations

Then we write the first integration test.

	#spec/requests/send_invitations_spec.rb
	require 'spec_helper'
	describe "SendInvitations", :js => true do
	  context "when New Invitation button is clicked" do
	    it "opens invitation modal" do
	      visit "/"
	      click_link "New Invitation"
	      page.should have_content("Send an invitation link to your contacts.")
	    end
	  end
	end

Test Status: Failing

Note that we informed “:js => true”, this says to the test framework to use Selenium http://seleniumhq.org/,
because the standard framework dont allow us to use Ajax (Javascript).

First step, create the Invitation Controller where the application will manage Create and New request. We should add the apropriate routes, too.

	#console
	rails g controller Invitations new create
	#config/routes.rb
	Invitations::Application.routes.draw do
	  root :to => 'home#index'
	  resources :invitations, :only => [:create, :new]
	end

We should delete “app/views/invitations/create.html.erb” and “app/views/invitations/create.html.erb”, because we will not use them, we will use JS instead (comming soon).
If generated, we need to delete “spec/controllers” directory (because we will not test controllers).

We will use the “_invitation_modal.html.erb” partial to specify our modal.
Note that our modal is twitter bootstrap based, you can find more specification here: http://twitter.github.com/bootstrap/javascript.html#modals


	#app/views/invitations/_invitation_modal.html.erb
	<div class="modal" id="invitation_modal" style="display: none;">
	      <div class="modal-header">
		<button class="close " data-dismiss="modal">×</button>
		<h3>Invitations</h3>
	      </div>

	      <div class="modal-body">

		<h5>Send an invitation link to your contacts.</h5>
		<hr/>
	      </div>

	      <div class="modal-footer">
		<a href="#" class="btn" data-dismiss="modal">Close</a>
	      </div>
	</div>

Then we add some JS helpers that opens and closes controller based modals:

	#app/assets/javascripts/application.js
	#... comments and imports
	function openOrReloadModal(id, partial){
	    if ($(id).length>0){
		$(id).modal('hide');
		$(id).replaceWith(partial);
	    }else{
		$('body').append(partial);
	    }
	    $(id).modal();
	}

	function closeModal(id){
	    $(id).modal('hide');
	    $(id).remove();
	}

The “openOrReloadModal” function accepts the ID of the modal specified in the partial and the partial of the modal.
The “closeModal” function closes and removes the modal given the ID.

Controllers can render JS too!

Since we will need populate the Invitation modal, we need controllers to do that.
Our controller can respond to JavaScript and open the invitation modal.

	#app/controllers/invitations_controller.rb
	#...
	def new
	  respond_to :js
	end
	#...

Then we render the JavaScript that opens our modal.

	#app/views/invitations/new.js.erb
	openOrReloadModal("#invitation_modal","<%= j render :partial => 'invitation_modal' %>");

The New Invitation button:

	#app/views/home/index.html.erb
	<%= link_to "New Invitation", new_invitation_path, :id => "new_invitation", :remote => true %>

Test Status: Passing

The “:remote => true” tells that is an Ajax request.

Invitation Form

Our invitation form will have 3 email inputs and 1 message text area:

	#spec/requests/send_invitations_spec.rb

	  describe "Elements Present" do
	    context "when opened invitation modal" do
	      it "has three email inputs" do
		visit "/"
		click_link "New Invitation"
		3.times do |index|
		  find_field("invitation_emails_#{index}").should_not be_nil
		end
	      end

	      it "has one message input" do
		visit "/"
		click_link "New Invitation"
		find_field("invitation_message").should_not be_nil
	      end
	    end
	  end

Test Status: Failing

To add them we should adapt the view and controller:


	#app/views/invitations/_invitation_modal.html.erb
	<div class="modal" id="invitation_modal" style="display: none;">
	  <%= simple_form_for(@invitation,:remote=>true, :html => {:class => 'form-horizontal' }) do |f|  %>
	      <div class="modal-header">
		<button class="close " data-dismiss="modal">×</button>
		<h3>Invitations</h3>
	      </div>

	      <div class="modal-body">

		<h5>Send an invitation link to your contacts.</h5>
		<hr/>

		 <% @invitation.emails.each_with_index do |email,index| %>
		   <%= f.input :emails,
		               :label => "Email: ",
		 	       # little trick to send arrays to model
		               :input_html => { :id => "invitation_emails_#{index}" ,:name => "#{f.object_name}[emails][]", :value => email },
		               :error => false %>
		 <% end %>

		<%= f.input :message,
		            :label => "Message: ",
		            :input_html => { :style=>"resize: none", :cols => 50, :rows => 3 },
		            :as => :text %>
	      </div>

	      <div class="modal-footer">
		<%= f.button :submit, "Send", :class => "btn btn-success"%>
		<a href="#" class="btn" data-dismiss="modal">Close</a>
	      </div>
	  <% end %>
	</div>


	#...
	def new
	  @invitation = Invitation.new
    	  3.times {@invitation.emails<<""}
    	  respond_to :js
	end
	#...

Test Status: Passing

Our modal should validate and display messages when informed values are wrong:


	#spec/requests/send_invitations_spec.rb
	  describe "Validatons" do
	    context "when message is invalid" do
	      it "shows a error message" do
		visit "/"
		click_link "New Invitation"
		fill_in "invitation_emails_0", :with => "felipepastoree@gmail.com"
		fill_in "invitation_message", :with => "A"*501
		click_button "Send"
		page.should have_content("maximum is 500 characters")
	      end
	    end

	    context "when some email is invalid" do
	      it "show an alert error message" do
		visit "/"
		click_link "New Invitation"
		fill_in "invitation_emails_0", :with => "wrong"
		fill_in "invitation_emails_1", :with => "felipepastoree@gmail.com"
		click_button "Send"
		# little hack to get alert object
		alert = page.driver.browser.switch_to.alert
		alert.text.should == "Please, correct invalid email(s)."
		alert.accept
	      end
	    end

	    context "when there is no email informed" do
	      it "show an alert error message" do
		visit "/"
		click_link "New Invitation"
		click_button "Send"
		# little hack to get alert object
		alert = page.driver.browser.switch_to.alert
		alert.text.should == "Please, inform at least one filled email."
		alert.accept
	      end
	    end
	  end

Test Status: Failing

Then, to implement this functionality, we need to alter the controller’s “Create” method:

	#app/controllers/invitations_controller.rb
	#...
	  def create
	    @invitation = Invitation.new(params[:invitation])
	    if @invitation.valid?
	      @status = true
	    else
	      @status = false
	    end
	    respond_to :js
	  end
	#...

The “@status” informs the JavaScript rendered that the validation was a success or failure:

	#app/views/invitations/create.js.erb
	if(<%= @status %>){
	    closeModal("#invitation_modal");
	}else{
	    // if validations when wrong, reload the modal to show errors
	    openOrReloadModal("#invitation_modal","<%= j render :partial => 'invitation_modal', :locals => { :invitation => @invitation } %>");
	    // if there is errors on emails, show an alert
	    <% if @invitation.errors[:emails].any? %>
		alert("<%= @invitation.errors[:emails].join("\\n") %>");
	    <% end %>
	}

Test Status: Passing

Success Case

The success case is to actually deliver the invitations, so:

	#spec/requests/send_invitations_spec.rb
	  describe "Send Invitations" do
	    context "when is valid" do
	      it "shows a success message" do
		visit "/"
		click_link "New Invitation"
		fill_in "invitation_emails_0", :with => "felipepastoree@gmail.com"
		fill_in "invitation_message", :with => "Custom message..."
		click_button "Send"
		sleep 3
		# little hack to get alert object
		alert = page.driver.browser.switch_to.alert
		alert.text.should == "Invitations sent with success!"
		alert.accept
	      end
	    end

	    context "when informed several emails" do
	      it "send all of them" do
		ActionMailer::Base.deliveries = []
		visit "/"
		click_link "New Invitation"
		fill_in "invitation_emails_0", :with => "felipepastoree@gmail.com"
		fill_in "invitation_emails_1", :with => "test@test.com"
		fill_in "invitation_message", :with => "Custom message..."
		click_button "Send"
		sleep 3
		ActionMailer::Base.deliveries.map(&:to).should eq([["felipepastoree@gmail.com"], ["test@test.com"]])
		# little hack to get alert object
		# if you dont close the present alert, tests will raise error "Modal dialog present"
		alert = page.driver.browser.switch_to.alert
		alert.accept
	      end
	    end
	  end

Test Status: Failing

To implement this functionality, we need to alter again the Create method:

	#app/controllers/invitations_controller.rb
	  def create
	    @invitation = Invitation.new(params[:invitation])
	    if @invitation.valid?
	      @status = @invitation.send_invitations.present?
	    else
	      @status = false
	    end
	    respond_to :js
	  end

Note the “@invitation.send_invitations” call.

We need to alert the user that the email was sent with success, so:

	#app/views/invitations/create.js.erb
	#...
	if(<%= @status %>){
	    closeModal("#invitation_modal");
	    alert("Invitations sent with success!")
	#...

Test Status: Passing

Invitation Modal up and running!

You can now start the server and see our Invitation Modal running.

	#console
	rails -s

Hope you enjoyed.

Tagged , ,

2 thoughts on “Invitation Modal

  1. Bob Roberts says:

    Reblogged this on My Corner of the Web and commented:
    Very good tutorial covers a lot here. Great Job!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: