[007.4] Implementing the Checklist screen from our acceptance tests, Part 2

Sharing checklists

Subscribe now

Implementing the Checklist screen from our acceptance tests, Part 2 [12.07.2017]

We will start this episode by using the tag after episode 007.3. Let's see the behavior of our app so far. We will start the rails server and have a look. We can see the CheckLists. In our case, we have just one. If we click on a checklist, we can see the CheckList Items. We can mark them as completed or not. This was what we did in the last episode, and it works so far.

In this episode, we will manage the CheckList Items, share them, and work with authorization for each CheckList Item.

Let's look at our tests, and see what we need to do. Our scenario says we need to share a checklist with another user. The user should accept via a link.

    scenario 'sharing a checklist with another user' do
      visit checklist_path(ruby)
      share_checklist_with('bob@example.com')
      expect(page).to have_pending_share_for('bob@example.com')
      accept_share_for('bob@example.com', ruby)
      visit checklist_path(ruby)
      expect(page).to have_accepted_share_for('bob@example.com')
    end

Sharing a Checklist

We created a model called ChecklistShare. It has a relationship between the User and the Checklist. Everytime we want to share the Checklist, we just need to create a ChecklistShare record.

We will start by creating an input field in our Checklist show. An input and a button to share the Checklist.

The form will be generated by a form_tag. In the form parameters, the checklist_id will be passed, and the button will have an id according to the Checklist id. This id will make our life easier when we are doing the acceptance tests.

    = form_tag checklist_shares_path
      = text_field_tag :email
      = hidden_field_tag :checklist_id, @checklist.id
      = submit_tag "Share", id: "share-checklist-#{@checklist.id}"

We don't have the controller for ChecklistShares yet. Only authenticated users can see this screen, and we will need to have the current user information. So, we will create a method in our Application Controller to check if the User is logged in.

If we don't have have the current user, we just redirect to the new user sessions path and let the user log in.

  def authenticate_user!
    redirect_to new_user_session_path unless current_user
  end

Now, this will be a before action in our Checklist controller.

class ChecklistsController < ApplicationController
  before_action :authenticate_user!

Our ChecklistShares controller will only have the create method for the moment. We will find the Checklist, and call a method called share_with from a checklist, which we will pass the current user and the email we want to share with. If everything works fine, we show a success message. Otherwise, if the Checklist doesn’t exist or if the user could not be created correctly, we will show the error message.

To share a checklist, the user should be logged in. So, we will add the authenticate_user! before the create action.

class ChecklistSharesController < ApplicationController
  before_action :authenticate_user!, only: [:create]

  def create
    begin
      checklist = Checklist.find(params[:checklist_id])
      checklist.share_with(current_user, params[:email])
      flash[:success] = "Checklist shared with #{params[:email]}!"
    rescue ActiveRecord::RecordInvalid => e
      flash[:error] = e.message
    end
    redirect_to root_path
  end
end

The method share_with will be an instance method for the Checklist model.

  def share_with(current_user, email)
    user = User.find_by(email: email)
    user ||= User.create_with_password(email: email)
    ChecklistShare.find_or_create_by(user: user, checklist: self)
    ShareChecklistMailer.email(current_user, user, self).deliver_now!
  end

We will find the user by email, and if the user does not exist, we will create the user with a password. This is ok, because the log in will be made by GitHub. So, we can easily generate a password for this user. After that, we can find or create a ChecklistShare for the user and the Checklist. Finally, we will send the email to the user. We don't have the mailer yet or the method create_with_password for the User.

We will create the class method on the user class. The password will be generated by Devise, and we will return the saved user.

  def self.create_with_password(email: new_email)
    user = User.new(email: email)
    user.password = Devise.friendly_token[0, 20]
    user.save!
    user
  end

Accept Sharing

The user should accept the shared Checklist. So, we will create an accepted boolean field in the ChecklistShare model. The Checklist Share email will contain a link to accept the Checklist.

rails g migration AddAcceptedToChecklistShares accepted:boolean

Now, we need to implement an accept method in the controller. In the accept method, we receive the email and the checklist id. We check if the user exists. If not, we send a flash message, and redirect to the root path. After this, we check if the Checklistshare exists. If it does not exist, we send a flash message, and redirect to the root path.

If the user and the ChecklistShare both exist, we send a flash message saying everything was good, and we redirect to the root_path. To accept the Checklist, the user doesn't need to be logged in. So, we don't use the authenticate_user! before action here.

  def accept
    email = params[:email]
    checklist_id = params[:checklist_id]

    user = User.find_by(email: email)
    flash_and_redirect('The user does not exist') unless user

    checklist_share = ChecklistShare.find_by(user: user, checklist_id: checklist_id)
    flash_and_redirect('We could not find this invitation') unless checklist_share

    checklist_share.update(accepted: true)
    flash[:success] = 'You have accepted the Checklist'
    redirect_to root_path
  end

And our method to send the flash message and redirect will be a private method. I'm creating this method just to re-use in these two cases, where the resource does not exist.

  private

  def flash_and_redirect(msg)
   flash[:error] = msg
   redirect_to(root_path)
 end

We need to create the route for it.

  root to: 'checklists#index'
  get '/checklists/accept' => 'checklist_shares#accept', as: :accept_checklist
  resources :checklists
  resources :checklist_shares

So, now once the user receives the email, he can click in the link to accept it.

Creating email

Before going to the email implementation, we will add letter opener, a gem that helps us to send and check emails locally. When the app sends an email it opens the email in the browser, and we can check it easily.

vim Gemfile

We will add the gem into our Gemfile. They will be only for development and test gem group.

  gem 'letter_opener'

For the development environment, we need to set up the email delivery method to use letter_opener. Also, we need to set the default url options, and pass the host.

  config.action_mailer.delivery_method = :letter_opener
  config.action_mailer.default_url_options = { host: 'localhost:3000' }

We will also set the host for the test environment.

We can create the email by using a Rails generator. Our Mailer will be called ShareChecklistMailer. It will have just one email to be sent, and I will call it email.

rails g mailer ShareChecklist email

This command creates an emailer for us. We will be using the generated email. In the Mailer, we will receive the user who is sending the Checklist and the user we are sending the Checklist to. In our secrets, we will have the produciton email, the email the app will use as its from address.

class ShareChecklistMailer < ApplicationMailer
  def email(from_user, to_user, checklist)
    @to_user = to_user
    @from_user = from_user
    @checklist = checklist
    mail(
      from: Rails.application.secrets.produciton_email,
      to: @to_user.email,
      subject: "#{@from_user.email} has shared a Checklist with you!"
    )
  end
end

Let's add the produciton e-mail in our secrets file.

vim config/secrets.yml

We will be using this for the three environments we have.

  produciton_email: <%= ENV["PRODUCITON_EMAIL"] %>

For the email itself. We will be using only the HTML part. So, we can delete the TXT file.

In our email HTML, we will let the user know someone has shared a Checklist with them, and we will use the accept checklist url. We will pass the user email, and the checklist id.

<p>Howdy,</p>

<p>
  <%= @to_user.email %> has shared a Checklist with you.
</p>

<p>
  You can log in in our system using GitHub and see the Checklist.
  <%=  link_to "Click here to accept", accept_checklist_url(email: @to_user.email, checklist_id: @checklist.id) %>
</p>

So, when a user logs in our website through GitHub, they will see the shared Checklists.

Making the tests pass

We did a lot implementing the sharing part. Let's now go back to our tests and see what it looks like, and see if we make the tests pass.

The first thing we need to set up is the login for user. Before, we were not worrying about it. But now our controller has this validation. Tomorrow we will check more about authorizations, but today to see the Checklists and Checklist Items, we need to log in. In our support file, we can create a simple method that logs in a given user.

We will create a user using FactoryBot, and pass this user to the method we are going to create.

  let(:user) { create(:user) }
...
def log_in(user)
  fill_in('Email', with: user.email)
  fill_in('Password', with: user.password)
  click_on('Log in')
end

When the user logs in, the user goes to the root path, which is the Checklist index. So, the user should click in the checklist. After clicking on it, we want to share the Checklist with someone. So, we will fill the input with the email we want to share, and click share. A flash message should appear saying we are sharing this checklist with the following email.

The user receives the email, and clicks on it. To simulate the email click, we will just visit the link. It redirects us to the root path, and we should see a message saying the user has just accepted. Let's implement all of this in our tests.

Starting with our share_checklist_with method.

def share_checklist_with(email)
  fill_in('email', with: email)
  click_on('Share')
end

Then, we need to create the have_pending_share_for matcher. On this matcher, we can just check if the page has the correct message from the flash message.

RSpec::Matchers.define :have_pending_share_for do |expected|
  match do |actual|
    text = "Checklist shared with #{expected}!"
    expect(actual).to have_text(text)
  end

  description do
    "have a pending share for #{expected}"
  end
end

Now, let's implement the accept share for, using the email and checklist. On this one, we will just visit the link.

def accept_share_for(email, checklist)
  visit accept_checklist_url(email: email, checklist_id: checklist.id)
end

Visiting the link redirects us to the root path with the correct flash message. We need to check this message. This is our have_accepted_share matcher.

    expect(page).to have_accepted_share

And in the matcher, we just need to check the text.

RSpec::Matchers.define :have_accepted_share do |_expected|
  match do |actual|
    text = 'You have accepted the Checklist'
    expect(actual).to have_text(text)
  end

  description do
    'have accepted share'
  end
end

Now our test for sharing the Checklist should work. We can execute it.

  scenario 'sharing a checklist with another user' do
    visit checklist_path(ruby)
    log_in(user)
    click_on(ruby.title)
    share_checklist_with('bob@example.com')
    expect(page).to have_pending_share_for('bob@example.com')
    accept_share_for('bob@example.com', ruby)
    expect(page).to have_accepted_share
  end

Summary

Today we have implemented the Sharing Checklist feature. It was nice that we had our tests to guide us, although there were a lot of details in the implementation. I hoped you liked it.

In tomorrow's episode, we will make this app prettier with CSS, we will test authorization and manage the checklists. See you tomorrow!

Resources