Cart Implementation

Goals

  • represent a cart using a PORO in Rails
    • Start thinking about opportunities for using POROs to extract logic from the controller.
  • use a flash to send messages to the view
  • load an object to be used throughout the app using a before_action filter in the ApplicationController

Structure

  • Warm Up
  • Code-along

Vocabulary

  • Session
  • PORO

Video

Warm Up

  • How do we make a class in Ruby?
  • How do we make an instance of a class?
  • Where does plain old Ruby data (like a class instance) “live”?

Intro

We’ll build out an app where a user should be able to add songs to their cart. The added songs are not saved to the database until the user has decided so.

Code-Along

We are going to use the SetList project for this example.

Writing a Test

# spec/features/cart/add_song_spec.rb

require 'rails_helper'

RSpec.describe "When a user adds songs to their cart" do
  it "displays a message" do
    artist = Artist.create(name: 'Rick Astley')
    song = artist.songs.create(title: 'Never Gonna Give You Up', length: 250, play_count: 1000000)

    visit "/songs"

    within("#song-#{song.id}") do
      click_button "Add Song"
    end

    expect(page).to have_content("You now have 1 copy of #{song.title} in your cart.")
  end
end

Creating a Cart and Adding a Flash Message

Run the test and it complains about not finding the css with the song id. Inside of views/songs/index.html.erb, wrap each song in a section with that id:

<h1>All Songs</h1>

<% @songs.each do |song| %>
  <ul>
    <section id="song-<%= song.id %>">
      <li>
        <%= link_to song.title, "/songs/#{song.id}" %>
        Play Count: <%= song.play_count %>
      </li>
    </section>
  </ul>
<% end %>

Now the test isn’t finding the button. Let’s add a button inside our section:

<%= button_to "Add Song" %>

Our error now is:

No route matches [POST] "/songs"

button_to, by default, will send a POST request, and since we haven’t specified the path, it uses the current path which is /songs.

So we need a route to handle adding a song, but what route should it be? We aren’t going to store Carts in our database, so there’s no ReSTful routing convention to follow in this case. Let’s try to make our route look as ReSTful as possible. If you think about every user having an empty cart by default, what we want to do is update that cart, so it would make sense to use a PATCH verb. Since we are updating the cart, it also makes sense that our path includes /cart. Finally, we need to know what song we are putting in the cart, so we’ll include a :song_id parameter in our route. Putting this all together, we’ll use the route:

patch '/cart/:song_id'

Our route will also need a controller and action to route to. Since we’re updating the cart, it makes sense to create a new cart controller with an update action. Add this to your routes file:

patch '/cart/:song_id', to: 'cart#update'

As always, run rake routes and make sure your new route is there.

Now that we have a route, let’s make our button go to this route:

<%= button_to "Add Song", "/cart/#{song.id}", method: :patch %>

Now we get a new error:

ActionController::RoutingError:
     uninitialized constant CartController

Make a controller: touch app/controllers/cart_controller.rb and add the CartController class. If you run the test again, it will complain about missing the action update. So, inside of the controller file:

class CartController < ApplicationController
  def update
  end
end

And now our error is funky, harder to decipher:

Unable to find visible xpath "/html"

This is because we are sending this action nowhere; Capybara cannot see any HTML to parse. There is no direct view that corresponds with update so we need to redirect it somewhere. Let’s bring the user back to the songs index.

class CartController < ApplicationController
  def update
    redirect_to '/songs'
  end
end

Does it work? Start up your server, visit the songs index page, and click the “Add Song” button. Does it redirect you back to the same page? Good.

When you run the test, it fails looking for the content “You now have 1 of #{@song.title} in your cart.” This should be a flash message. Let’s update our action to find the requested song and display the message

class CartController < ApplicationController
  def update
    song = Song.find(params[:song_id])
    flash[:notice] = "You now have 1 copy of #{song.title} in your cart."
    redirect_to '/songs'
  end
end

If you don’t already have something to render flash messages, you will need to add one. In the /app/views/layouts/application.html.erb file, add this code right before the yield tag.

<% flash.each do |type, message| %>
    <section class=<%= type %>>
      <p><%= message %></p>
    </section>
<% end %>

Because we put this code in application.html.erb, we can put a flash message in any controller action and it will appear in any view!

Great! That test is passing. But what happens if we add two songs?

Adding Multiple Songs and Updating Our Flash

Let’s update our test to check and see.

#spec/features/cart/add_song_spec.rb

RSpec.describe "When a user adds songs to their cart" do
  it "displays a message" do
    ...
  end

  it "the message correctly increments for multiple songs" do
    artist = Artist.create(name: 'Rick Astley')
    song_1 = artist.songs.create(title: 'Never Gonna Give You Up', length: 250, play_count: 1000000)
    song_2 = artist.songs.create(title: "Don't Stop Believin'", length: 300, play_count: 1)

    visit '/songs'

    within("#song-#{song_1.id}") do
      click_button "Add Song"
    end

    within("#song-#{song_2.id}") do
      click_button "Add Song"
    end

    within("#song-#{song_1.id}") do
      click_button "Add Song"
    end

    expect(page).to have_content("You now have 2 copies of #{song_1.title} in your cart.")
  end
end

If we run this now it fails because even though we’ve added two songs, our flash message will always say that we have one song. We need a way to store information about how many songs have been added.

Thinking through this a little bit, we could store something in the database every time someone adds a songs to their cart, but there are a few drawbacks to that approach:

  • It would require multiple updates (and potentially deletions) while someone makes up their mind about what to actually keep in their cart. That’s a lot of extra database work.
    • It would also require the user to log in before they could add anything to their cart so that we could create that association in our database anyway.
  • It could potentially result in some abandoned database records if a user decides not to finalize their cart contents.

Instead, we need to find a way to store the state of a cart (which songs have been added to our cart and how many of each type). Can we store data in a session?

Let’s go back into the CartController:

class CartController < ApplicationController
  def update
    song = Song.find(params[:song_id])
    song_id_str = song.id.to_s
    session[:cart] ||= Hash.new(0)
    session[:cart][song_id_str] ||= 0
    session[:cart][song_id_str] = session[:cart][song_id_str] + 1
    flash[:notice] = "You now have #{session[:cart][song_id_str]} copy of #{song.title} in your cart."
    redirect_to "/songs"
  end
end

Slight Detour

You might be wondering why we’re converting the song’s integer ID to a string, and initializing it to 0 even though we’re telling Hash to initialize all new keys with a value of 0. While our code could certainly process the song ID as an integer in memory, when the session is packed up as one big string to store in the client cookie, and read back again after the next request, the song ID is no longer an integer. Also, the Hash.new(0) functionality is also lost when we read a cart from the cookie, since [:cart] now exists, so on subsequent requests, Hash.new(0) is never run.

Back to our code …

This is close. Our test will still fail, but it looks like our number is incrementing correctly. Our error likely looks something like:

  1) User adds a song to their cart the message correctly increments for multiple songs
     Failure/Error: expect(page).to have_content("You now have 2 copies of Song 1 in your cart.")
       expected to find text "You now have 2 copies of #{song.title} in your cart." in ""You now have 2 copy of #{song.title} in your cart.""
     # ./spec/features/user_adds_song_to_cart_spec.rb:25:in `block (2 levels) in <top (required)>'

It’s the plural of “copy/copies” that’s giving us a hard time. Luckily we have a tool for that with #pluralize. This is a view helper method, so in order to use it here in our controller to set a flash message, we’ll need to include ActionView::Helpers::TextHelper explicitly in our controller.

class CartController < ApplicationController
  include ActionView::Helpers::TextHelper

  def update
    song = Song.find(params[:song_id])
    song_id_str = song.id.to_s
    session[:cart] ||= Hash.new(0)
    session[:cart][song_id_str] ||= 0
    session[:cart][song_id_str] = session[:cart][song_id_str] + 1
    quantity = session[:cart][song_id_str]
    flash[:notice] = "You now have #{pluralize(quantity, "copy")} of #{song.title} in your cart."
    redirect_to "/songs"
  end
end

And there we have another passing test.

That’s nice, but wouldn’t it be better to see how many songs total we have in our cart? Yes. Yes it would.

Adding a Cart Tracker

First, let’s update our feature test.

require 'rails_helper'

RSpec.feature "When a user adds songs to their cart" do
  it "displays a message" do
    ...
  end

  it "the message correctly increments for multiple songs" do
    ...
  end

  it "displays the total number of songs in the cart" do
    artist = Artist.create(name: 'Rick Astley')
    song_1 = artist.songs.create(title: 'Never Gonna Give You Up', length: 250, play_count: 1000000)
    song_2 = artist.songs.create(title: "Don't Stop Believin'", length: 300, play_count: 1)

    visit "/songs"

    expect(page).to have_content("Cart: 0")

    within("#song-#{song_1.id}") do
      click_button "Add Song"
    end

    expect(page).to have_content("Cart: 1")

    within("#song-#{song_2.id}") do
      click_button "Add Song"
    end

    expect(page).to have_content("Cart: 2")

    within("#song-#{song_1.id}") do
      click_button "Add Song"
    end

    expect(page).to have_content("Cart: 3")
  end
end

If we run our test now, we’ll see that we have not included “Cart: 0” in our view or main app layout. It’s easy enough to get past that error. Let’s open the app/views/layouts/application.html.erb file and add a paragraph tag with the cart information just below our flash message. For now let’s hardcode it so that we can see what happens, then think through how we want to implement this permanently.

<p>Cart: 0</p>

Sure enough, that gets us to a new error where now our test is looking for “Cart: 1” and not finding it on our page (because we’re still displaying “Cart: 0” since it’s hard-coded in our view).

We could potentially pass in a count specifically to use in that slot, but it’s starting to feel like we’re doing a LOT of logic with this cart. More than should probably just be handled in the controller.

What we really want to do here is to have some sort of an object that I can call #total on to get the number of objects in the cart. I don’t have a model to use since we’re not saving the cart in the database, but that doesn’t stop me from creating a Cart class that doesn’t interact with the database. We call these types of classes “POROS” (Plain Old Ruby Objects). The question becomes where to start, and what to refactor.

Since we’re thinking of implementing this here in the view, let’s start there with how we’d like this object to behave. Change the new line that we added to the view to the following:

<p>Cart: <%= @cart.total_count %></p>

Run our test, and … we’ve broken everything!!!

  3) User adds a song to their cart the total number of items in the cart increments
     Failure/Error: <p>Cart: <%= @cart.total_count %></p>

     ActionView::Template::Error:
       undefined method `total_count' for nil:NilClass`

That’s okay! We can fix this!!

In our SongsController, let’s go ahead and add the instance variable @cart. Let’s also assume that we’ll need to pass it the contents currently sitting in our session to make it work.

  def index
    @songs = Song.all
    @cart = Cart.new(session[:cart])
  end

When we run our tests again, we see another failing test telling us that we don’t have a Cart class:

      Failure/Error: @cart = Cart.new(session[:cart])

      NameError:
        uninitialized constant SongsController::Cart

At this point, we’re going to have to create our PORO, so let’s start with a model test.

Creating Our Cart PORO

In our spec/models folder, add a new test for our Cart class: spec/models/cart_spec.rb. Within our new test file, add the following:

require 'rails_helper'

RSpec.describe Cart do

  describe "#total_count" do
    it "can calculate the total number of items it holds" do
      cart = Cart.new({
        '1' => 2,  # two copies of song 1
        '2' => 3   # three copies of song 2
      })
      expect(cart.total_count).to eq(5)
    end
  end
end

Run that test using rspec spec/models so we can avoid the distraction of all of our feature tests failing.

And now we see the error that we need to actually go create our Cart class:

NameError:
  uninitialized constant Cart

In our app/models folder, add cart.rb and insert the following code:

class Cart
  attr_reader :contents

  def initialize(initial_contents)
    @contents = initial_contents
  end

  def total_count
    @contents.values.sum
  end
end

Side note

Our PORO does not inherit from ApplicationRecord (or ActiveRecord::Base) because we don’t store PORO’s in our database. They’re used “in transit” for one request/response life cycle and then discarded.

Back to code…

And that makes our model test pass!

Unfortunately, our feature tests still don’t pass for us.

  3) User adds a song to their cart the total number of songs in the cart increments
     Failure/Error: contents.values.sum

     ActionView::Template::Error:
       undefined method `values' for nil:NilClass`

We’re trying to call #values on our initial contents, but the first time that we render our index our session hasn’t been set, so initial_contents evaluates to nil. Let’s fix that by adding some code to ensure that @contents is always defined as a hash. In your Cart class:

class Cart
  attr_reader :contents

  def initialize(initial_contents)
    @contents = initial_contents || Hash.new(0)
  end

  def total_count
    @contents.values.sum
  end
end

And now, our newest test passes, but we have more refactoring and cleanup to do so every other test can pass.

Refactoring CartController

Let’s take another look at the current status of our CartController

class CartController < ApplicationController
  include ActionView::Helpers::TextHelper

  def update
    song = Song.find(params[:song_id])
    song_id_str = song.id.to_s
    session[:cart] ||= Hash.new(0)
    session[:cart][song_id_str] ||= 0
    session[:cart][song_id_str] = session[:cart][song_id_str] + 1
    quantity = session[:cart][song_id_str]
    flash[:notice] = "You now have #{pluralize(quantity, "copy")} of #{song.title} in your cart."
    redirect_to "/songs"
  end
end

Currently we’re doing a lot of work in this controller with our cart. Now that we have a Cart PORO, it seems like we could refactor this to have the PORO take on some of that load.

Let’s refactor the CartController so that it looks like this:

class CartController < ApplicationController
  include ActionView::Helpers::TextHelper

  def update
    song = Song.find(params[:song_id])
    @cart = Cart.new(session[:cart])
    @cart.add_song(song.id)
    session[:cart] = @cart.contents
    quantity = @cart.count_of(song.id)
    flash[:notice] = "You now have #{pluralize(quantity, "copy")} of #{song.title} in your cart."
    redirect_to songs_path
  end
end

We’re still letting the controller handle getting and setting the session, but we’re putting our PORO in charge of managing the hash that we’re storing there: both 1) adding songs to it, and 2) reporting on how many of a particular song we have.

Since we’ve added these methods to our controller, we now have a missing method error when we run our test. Let’s add both methods to our model test to see that they do what we expect them to. The subject syntax is a nice RSpec feature to use if you’re using a similar setup in many tests. In this case, we abstract the logic for creating a Cart instance, and can call that return value with subject. I like how this reads, but feel free to forego this pattern if you don’t like it as much.

require 'rails_helper'

RSpec.describe Cart do
  subject { Cart.new({'1' => 2, '2' => 3}) }

  describe "#total_count" do
    it "calculates the total number of songs it holds" do
      expect(subject.total_count).to eq(5)
    end
  end

  describe "#add_song" do
    it "adds a song to its contents" do
      cart = Cart.new({
        '1' => 2,  # two copies of song 1
        '2' => 3   # three copies of song 2
      })
      subject.add_song(1)
      subject.add_song(2)

      expect(subject.contents).to eq({'1' => 3, '2' => 4})
    end
  end
end

In order to get the Cart PORO tests to pass, add the following method to our Cart PORO.

def add_song(id)
  @contents[id.to_s] = @contents[id.to_s] + 1
end

What if we ask for the count of a non-existent song? Let’s add a test:

describe "#count_of" do
  it "returns the count of all songs in the cart" do
    cart = Cart.new({})

    expect(cart.count_of(5)).to eq(0)
  end
end

Let’s add a count_of method that coerces nil values to 0 with #to_i.

class Cart

...

  def count_of(id)
    @contents[id.to_s].to_i
  end

What if we want to add a song that hasn’t been added yet?

describe "#add_song" do
  it "adds a song to its contents" do
    ...
  end

  it "adds a song that hasn't been added yet" do
    subject.add_song('3')

    expect(subject.contents).to eq({'1' => 2, '2' => 3, '3' => 1})
  end
end

We need this test for the case when a user has added some songs and tries to add a new song. Remember, when we initialize a new Cart, we pass it the contents of session[:cart], which acts like a Hash, but it doesn’t have a default value. The default value gets lost in translation between our app and the client’s cookies.

This test is giving us an undefined method '+' for nil:NilClass error because @contents[id.to_s] is coming back as nil when a song hasn’t been set yet. Let’s use our handy new count_of method that coerces nils to 0 to fix this:

def add_song(id)
  @contents[id.to_s] = count_of(id) + 1
end

And our PORO tests are all passing!

Controller Cleanup

Our feature tests are still in bad shape. Our newest test should pass, but if you run all the tests you’ll see a lot of failures. It’s from the line in our application.html.erb where we call @cart.total_count. @cart is nil because, although we set it in our SongsController#index and CartController#update, we didn’t set it in every action. We could go through every action and add the line @cart = Cart.new(session[:cart]) to each one, but that wouldn’t be very DRY. Instead, let’s do some refactoring.

Instead of creating an @cart all over the place, we’ll make a method on our ApplicationController that will handle creating the Cart object:

  helper_method :cart

  def cart
    @cart ||= Cart.new(session[:cart])
  end

If for some reason we access the cart more than once in a given request/response cycle, the cart object is memoized with ||=.

We make this new method a helper_method so that we can access it in the views.

Now we can delete the following line from both the SongsController and CartController:

  @cart = Cart.new(session[:cart])

And now change all the references from @cart to cart.

Double check to see that our tests are still passing, and we should be in good shape!

Checkin and Review

  • Why fuss with all of this PORO business?
  • “Where” does PORO data live?
  • How do I add a flash message to a view?

Extensions

Showing the cart

Let’s say that you wanted users to be able to click on “View Cart” (similar to “View Cart” on an e-commerce site).

  • Which controller?
  • What does the view look like?
  • How can we make the cart have access to Cart objects instead of just iterating through a hash of keys and values?

Ending and saving the cart contents

What about allowing users to save their cart songs as a package? You might approach it something like this:

  Cart: <%= cart.total_count %>
  <%= button_to "Save Package", packages_path %>

In your routes:

  resources :packages, only: [:create]

Make a Packages Controller:

class PackagesController < ApplicationController
  include ActionView::Helpers::TextHelper
  def create
    # the four lines below probably would be best delegated to a PackageCreator PORO
    package = Package.new(user_name: "Rachel")
    cart.contents.each do |song_id, quantity|
      package.songs.new(song_id: song_id, quantity: quantity)
    end

    if package.save
      session[:cart] = nil
      flash[:notice] = "Your bag is packed! You packed #{package.songs.count} songs."
      redirect_to songs_path
    else
      # implement if you have validations
    end
  end
end

Lesson Search Results

Showing top 10 results