The Simplified Go Food

Web App - Iteration 2, Part 2

Where Were We...

Yesterday you have developed:

  1. StoreController without using scaffold
  2. CartController
  3. CurrentCart concern
  4. LineItem model without using scaffold
  5. LineItemsController by using TDD from scratch

Adding The Button

Let's start by putting "add to cart" button to our view.

<!-- cut -->
<% @foods.each do |food| %>
  <div class="entry">
    <%= image_tag(food.image_url) %> 
    <h3><%= food.name %></h3>
    <%= sanitize(food.description) %>
    <div class="price_line">
      <span class="price">
        <%= number_to_currency(food.price, unit: "Rp ", delimiter: ".", separator: ",") %>
        <%= button_to 'Add to Cart', line_items_path(food_id: food) %>
      </span>
    </div>
  </div>
<% end %>

Modifying The Stylesheet

Above we use a helper method called "button_to". 

.store {
  /* cut */
  .entry {
    /* cut */
    p, div.price_line {
      margin-left: 100px;
      margin-top: 0.5em;
      margin-bottom: 0.8em;

      form, div {
        display: inline
      }
    }
    /* cut */
  }
}

Requirements

We are going to create our LineItems controller and modify our Carts controller. Let's start with the requirements:

  1. There should be a "add to cart" button in every food
  2. When clicked
    • a cart is created if there is no cart already
    • selected food is then added to the cart
  3. Afterward, user is redirected to the cart page
  4. In the cart page, user can see list of items already inserted to the cart

Based on these requirements, write (or modify) only necessary controller specs for LineItems and Carts controller.

LineItems Controller Specs

This is how your LineItems controller specs should look like. We only write specs for "create" method because that's all we need for the time being.

require 'rails_helper'

describe LineItemsController do
  describe 'POST #create' do
    it "includes CurrentCart"
    
    context "with existing cart" do
      it "does not create a new cart before saving a new line_item"
    end

    context "without existing cart" do
      it "creates a new cart before saving new line_item"
    end

    it "saves the new line_item in the database"
    it "redirects to carts#show"
  end
end

Creating LineItems Controller

When we run rspec for our LineItems controller spec, it will tell us that LineItemsController does not exist. Let's create it.

class LineItemsController < ApplicationController
end
Rails.application.routes.draw do
  # -cut-
  resources :line_items
  # -cut-
end

When creating a controller, don't forget to add its route too.

Detailed Specs (1)

If we run our spec for LineItems controller, now it says that all examples are pending. Let's implement it one by one.

# -cut-
describe LineItemsController do
  describe 'POST #create' do
    before :each do
      @food = create(:food)
    end
    # -cut-
  end
end

First, we create a class variable @food that we will use for our entire specs for 'POST #create'

Detailed Specs (2)

Next, we write detailed example for including CurrentCart module.

# -cut-
describe LineItemsController do
  describe 'POST #create' do
    # -cut-
    it "includes CurrentCart" do
      expect(LineItemsController.ancestors.include? CurrentCart).to eq(true)
    end
    # -cut-
  end
end

Here, we just make sure that our LineItemsController has CurrentCart as one of its ancestors.

Detailed Specs (3)

Next, we write detailed example for a scenario when there is already a cart in the browser session.

# -cut-
describe LineItemsController do
  describe 'POST #create' do
    # -cut-
    context "with existing cart" do
      it "does not create a new cart before saving new line_item" do
        cart = create(:cart)
        session[:cart_id] = cart.id

        expect{
          post :create, params: { food_id: @food.id }
        }.not_to change(Cart, :count)
      end
    end
    # -cut-
  end
end

Here, we create our cart with FactoryGirl and assign its id to session[:cart_id]. We expect that when a new line_item is created, no new cart is created hence the total number of Cart does not change.

Detailed Specs (4)

Next, we write detailed example for a scenario when there is no cart already in the browser session.

# -cut-
describe LineItemsController do
  describe 'POST #create' do
    # -cut-
    context "without existing cart" do
      it "creates a new cart before saving new line_item" do
        expect{
          post :create, params: { food_id: @food.id }
        }.to change(Cart, :count).by(1)
      end
    end
    # -cut-
  end
end

Here, we just make sure that a new cart is created, indicated by the change in the number of Cart.

Detailed Specs (5)

Next, we write detailed example for saving the new line_item to the database.

# -cut-
describe LineItemsController do
  describe 'POST #create' do
    # -cut-
    it "saves the new line_item in the database" do
      expect{
        post :create, params: { food_id: @food.id }
      }.to change(LineItem, :count).by(1)
    end
    # -cut-
  end
end

Here, we just make sure that a new line_item is created, indicated by the change in the number of LineItem.

Also note that we do not use attributes_for because our "add to cart" button only pass one parameter that is "product_id".

Detailed Specs (6)

Next, we write detailed example for the redirect process after the saving process finished.

# -cut-
describe LineItemsController do
  describe 'POST #create' do
    # -cut-
    it "redirects to carts#show" do
      post :create, params: { food_id: @food.id }
      expect(response).to redirect_to(cart_path(assigns(:line_item).cart))
    end
  end
end

Different from our specs for Foods controller, here we do not redirect our response to line_item show page. We redirect it to the cart page instead.

We use "assigns(:line_item)" to get the newly created line_item object.

Make Them Pass

RSpec now shows a lot of errors when we run it. Let's try to make the red turn to green one by one!

Including Concern

For our first example, RSpec tells us that CurrentCart is not included yet in our LineItems controller. Include it.

class LineItemsController < ApplicationController
  include CurrentCart
end

Our first example still do not pass after this addition. It tells us that there is no "create" method. Let's make one.

"Create" Method (1)

For our first example, RSpec tells us that CurrentCart is not included yet in our LineItems controller. Include it.

class LineItemsController < ApplicationController
  include CurrentCart

  def create
    food = Food.find(params[:food_id])
    @line_item = @cart.line_items.build(food: food)

    respond_to do |format|
      if @line_item.save
        format.html { redirect_to @line_item.cart, notice: 'Line item was successfully created.' }
        format.json { render :show, status: :created, location: @line_item }
      else
        format.html { render :new }
        format.json { render json: @line_item.errors, status: :unprocessable_entity }
      end
    end
  end
end

"Create" Method (2)

Explained:

  1. We find food object with the same id as the one sent via "params[:food_id]"
  2. Since the "add to cart" button only sends "params[:food_id]", we need to build our line_items with the line "@cart.line_items.build(food: food)"
  3. If saving "@line_item" is successful, we redirect it to cart's show page

Before Action (1)

After the latest addition to your LineItems controller, it still does not go green. Instance variable "@cart" still returns nil. That's because we have not really called CurrentCart's "set_cart". We need to call this method whenever we are about to create a line_item.

class LineItemsController < ApplicationController
  include CurrentCart
  before_action :set_cart, only: [:create]
  # -cut-
end

Before Action (2)

Above, we use one of Action Controller's filter called "before_action" to call "set_cart" before we execute "create" method in LineItems controller.

To find out what other filters you can use in your controllers, take a look at this page.

Modifying Show Cart View

As per requirements, our cart's show page should display all line_item(s) inserted to it. Since we don't cover unit testing for view, let's jump to the code

<p id="notice"><%= notice %></p>

<h2>Your Cart</h2>

<ul>
  <% @cart.line_items.each do |item| %>
    <li><%= item.food.name %></li>
  <% end %>
</ul>

Commit!

Now if you run your rspec again, you will get all green. You can try your current progress in the browser. Afterward, don't forget to commit your code.

Smarter Cart

Requirements

Your product manager is happy with your progress so far. However, he wants to an improvement to your app. Here is his requirements:

  1. Currently, if we add a food that is already in our cart, it will be displayed more than once in the cart page. We want to change this by displaying the quantity of items for every food in the cart instead.
  2. The cart page should also display the price of each item in it and the total price of all items.
  3. Implement an "empty cart" button that will erase every items currently in the cart.

Modifying Cart Model Spec

First, we change our Cart model spec to accommodate the new requirements.

describe Cart do
  # -cut-
  it "does not change the number of line_item if the same food is added" do
    cart = create(:cart)
    food = create(:food, name: "Nasi Uduk")
    line_item = create(:line_item, food: food, cart: cart)

    expect { cart.add_food(food) }.not_to change(LineItem, :count)
  end

  it "increments the quantity of line_item if the same food is added" do
    cart = create(:cart)
    food = create(:food, name: "Nasi Uduk")
    line_item = create(:line_item, food: food, cart: cart)

    expect { cart.add_food(food) }.to change { line_item.quantity }.by(1)
  end
end

Modifying Cart Model

RSpec tells us that we don't have a method named "add_food" in our Cart model. Let's write this method.

class Cart < ApplicationRecord
  has_many :line_items, dependent: :destroy

  def add_food(food)
    current_item = line_items.find_by(food_id: food.id)
    
    if current_item
      current_item.quantity += 1
    else
      current_item = line_items.build(food_id: food.id)
    end

    current_item
  end
end

Migrating LineItem (1)

RSpec then tells us that we don't have a method "quantity" in our LineItem. Since quantity is something that we want to store in our database, we need to change our line_items table. To do this, we use migration.

Run the following command in your console.

rails generate migration add_quantity_to_line_items quantity:integer

Migrating LineItem (2)

Take a look at the generated file, and you should see a migration file like this:

class AddQuantityToLineItems < ActiveRecord::Migration[5.0]
  def change
    add_column :line_items, :quantity, :integer
  end
end

You can read what else we can do with ActiveRecord migration in this page. To apply this migration, run "rails db:migrate".

Rolling Back

When you run the rspec command now, you will see a message saying "undefined method '+' for nil:NilClass". That's because when we create a new "line_item", its quantity is nil.

We need to change this. We need to make sure that everytime a new "line_item" is newly created, its quantity is set to 1.

To do that, we need to rollback our migration first.

rails db:rollback

Changing The Migration

Then we change our migration file to make sure the default value of line_item's quantity is 1.

class AddQuantityToLineItems < ActiveRecord::Migration[5.0]
  def change
    add_column :line_items, :quantity, :integer, null: false, default: 1
  end
end

Execute the command "rails db:migrate" again after that.

Commit!

Now if you run your rspec again, you will get all green. Don't forget to commit your code.

Modifying LineItems Controller Spec

We also need to change our LineItems controller spec to follow the new requirements. Can you write the spec modification?

describe LineItemsController do
  describe 'POST #create' do
    # -cut-
    context "with existing line_item with the same food" do
      before :each do
        cart = create(:cart)
        session[:cart_id] = cart.id
        line_item = create(:line_item, food: @food, cart: cart)
      end

      it "does not save the new line_item in the database" do
        expect{
          post :create, params: { food_id: @food.id }
        }.not_to change(LineItem, :count)
      end

      it "increments the quantity of line_item with the same food" do
        post :create, params: { food_id: @food.id }
        expect(assigns(:line_item).quantity).to eq(2)
      end
    end

    context "without existing line_item with the same food" do
      it "saves the new line_item in the database" do
        expect{
          post :create, params: { food_id: @food.id }
        }.to change(LineItem, :count).by(1)
      end
    end
    # -cut-
  end
  # -cut-
end

Modifying LineItems Controler

We need just a slight modification in our LineItems controller:

class LineItemsController < ApplicationController
  include CurrentCart
  before_action :set_cart, only: [:create]

  def create
    food = Food.find(params[:food_id])
    @line_item = @cart.add_food(food)

    respond_to do |format|
      if @line_item.save
        format.html { redirect_to @line_item.cart, notice: 'Line item was successfully created.' }
        format.json { render :show, status: :created, location: @line_item }
      else
        format.html { render :new }
        format.json { render json: @line_item.errors, status: :unprocessable_entity }
      end
    end
  end
end

Modifying The Views

Slight modification to our view:

<p id="notice"><%= notice %></p>

<h2>Your Cart</h2>

<table>
  <% total_price = 0.0 %>
  <% @cart.line_items.each do |item| %>
    <tr>
      <td><%= item.quantity %> ×</td>
      <td><%= item.food.name %></td>
      <td class="item_price"><%= number_to_currency(item.quantity * item.food.price, unit: "Rp ", delimiter: ".", separator: ",") %></td>
    </tr>
    <% total_price += (item.quantity * item.food.price) %>
  <% end %>
  <tr class="total_line">
    <td colspan="2">Total</td>
    <td class="total_cell"><%= number_to_currency(total_price, unit: "Rp ", delimiter: ".", separator: ",") %></td>
  </tr>
</table>

Modifying The Stylesheet

Fill in a stylesheet for your carts:

.carts {
  .item_price, .total_line {
    text-align: right;
  }

  .total_line, .total_cell {
    font-weight: bold;
    border-top: 1px solid #595;
  }
}

Commit!

Congratulations! You have developed a working cart. Play with your cart a little bit, afterward don't forget to commit your code.

Exercise

In this exercise, you always have to write the spec first before you write your code.

  1. Create a new method called "total_price" in LineItem model, it will return the calculation of LineItem's quantity times its Product's price
  2. Create a new method called "total_price" in Cart model, it will return the sum of the total price of its LineItem's
  3. Create a functioning MVC called "Category" with only one attribute "name", do it with TDD approach and without a scaffold
  4. Create "empty cart" button, it will use Carts controller's "destroy" method with slight modification: it redirects to Store controller's index after it finishes

The Simplified Go Food Web App - Iteration 2, Part 2

By qblfrb

The Simplified Go Food Web App - Iteration 2, Part 2

  • 348