The Simplified Go Food Web App - Iteration 4

What We Learned So Far

In the previous lesson, you have learned to:

  1. Write and use partial templates
  2. Write feature to empty your cart
  3. Write and use Ajax to update part of your app without reloading theĀ  whole page

Action Cable

New Requirements

As usual, your product manager comes with new requests. This time he asks you to write a feature that:

  1. Enable updates on foods' price to be broadcasted to every store index page currently opened in users' browser
  2. The broadcast does not update foods' price in users' cart

Generating Channel

To enable updates to be broadcasted, we use Rails' feature called "channel".

rails generate channel foods

Fill the newly generated "app/channels/foods_channel.rb".

class FoodsChannel < ApplicationCable::Channel
  def subscribed
    stream_from "foods"
  end

  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end
end

Changing Foods Controller

Unfortunately (or fortunately?) for us, RSpec currently does not support testing for ActionCable yet. So we will skip unit testing for this feature and jump right to the code.

class FoodsController < ApplicationController
  # -cut-
  # PATCH/PUT /foods/1
  # PATCH/PUT /foods/1.json
  def update
    respond_to do |format|
      if @food.update(food_params)
        format.html { redirect_to @food, notice: 'Food was successfully updated.' }
        format.json { render :show, status: :ok, location: @food }

        @foods = Food.all
        ActionCable.server.broadcast 'foods', html: render_to_string('store/index', layout: false)
      else
        format.html { render :edit }
        format.json { render json: @food.errors, status: :unprocessable_entity }
      end
    end
  end
  # -cut-
end

Modifying Foods' Javascript

Lastly, we modify our script for Foods in "app/assets/javascrips/channels/foods.coffee"

App.foods = App.cable.subscriptions.create "FoodsChannel",
  connected: ->
    # Called when the subscription is ready for use on the server

  disconnected: ->
    # Called when the subscription has been terminated by the server

  received: (data) ->
    $(".store #main").html(data.html)

Now whenever you update your foods' price in one window, any open window that shows your Store index page will automatically update the foods' price without having to reload.

Commit!

Now is a good time to commit your progress.

Capturing Order

The Requirements

Now we are going to finish our cart and convert it to an order. Here are the initial requirements:

  1. You are going to implement Order feature. An Order contains four information: the name, address, and email of the buyer, and the payment type of the order
  2. Order's payment type is stored as number, with number 0 means cash payment, 1 means Go Pay payment, and 2 means credit card payment

Write The Spec!

Based on the requirements above, write the spec and factory for our Order model.

Order Model Spec (1)

This is how your Order model spec should look like.

require 'rails_helper'

describe Order do
  it "has a valid factory" do
    expect(build(:order)).to be_valid
  end

  it "is valid with a name, address, email, and payment_type" do
    expect(build(:order)).to be_valid
  end

  it "is invalid without a name" do
    order = build(:order, name: nil)
    order.valid?
    expect(order.errors[:name]).to include("can't be blank")
  end

  it "is invalid without an address" do
    order = build(:order, address: nil)
    order.valid?
    expect(order.errors[:address]).to include("can't be blank")
  end
  # -cut-
end

Order Model Spec (2)

This is how your Order model spec should look like.

require 'rails_helper'

describe Order do
  # -cut-
  it "is invalid without an email" do
    order = build(:order, email: nil)
    order.valid?
    expect(order.errors[:email]).to include("can't be blank")
  end

  it "is invalid with email not in valid email format" do
    order = build(:order, email: "email")
    order.valid?
    expect(order.errors[:email]).to include("must be in valid email format")
  end

  it "is invalid without a payment_type" do
    order = build(:order, payment_type: nil)
    order.valid?
    expect(order.errors[:payment_type]).to include("can't be blank")
  end
end

Order Factory

This is how your Order factory should look like.

FactoryGirl.define do
  factory :order do
    name { Faker::Name.name }
    address { Faker::Address.street_address }
    email { Faker::Internet.email }
    payment_type { Faker::Number.between(0, 2) }
  end

  factory :invalid_order, parent: :order do
    name nil
    address nil
    email nil
    payment_type nil
  end
end

Generate Order Model

When you run your rspec for Order model now, you will be told that Order is an uninitialized constant. Let's create our Order model then.

rails generate model Order name address:text email payment_type:integer

When prompted with overwriting specs and factories, answer with "n". Also notice that we did not specify the type of name and email when generating our model. Whenever we do this, Rails will automatically assume the type is string.

Validating Order Model (1)

After you run db:migrate and rspec command, you will see that your validation tests do not work as we intend to yet. Write validations in your Order model to make it pass.

Validating Order Model (2)

Your Order model validation should look like this.

class Order < ApplicationRecord
  validates :name, :address, :email, :payment_type, presence: true
  validates :email, format: {
    with: /.+@.+\..+/i,
    message: 'must be in valid email format'
  }
end

Enumerating Payment Type

Storing payment type in integer is an efficient way. However, we may need to keep track of which values are used for which payment type. Rails provides us with "enum" to do this.

class Order < ApplicationRecord
  enum payment_type: {
    "Cash" => 0,
    "Go Pay" => 1,
    "Credit Card" => 2
  }

  # -cut-
  validates :payment_type, inclusion: payment_types.keys
end

You can learn more about Enum here and here.

Modifying Order Model Spec

Using enum will change our Order model spec and its factory.

describe Order do
  # -cut-
  it "is invalid with wrong payment_type" do
    expect{ build(:order, payment_type: "Grab Pay") }.to raise_error(ArgumentError)
  end
end

And this would be your modified factory.

FactoryGirl.define do
  factory :order do
    name { Faker::Name.name }
    address { Faker::Address.street_address }
    email { Faker::Internet.email }
    payment_type "Cash"
  end
  # -cut-
end

More Requirements

These are the requirements for your Orders controller:

  1. Add "checkout" button to your cart
  2. When clicked, it will create a new Order
  3. When a new Order is created, it will ensure that the Cart is not empty,
    • if Cart is empty, app will redirect user to Store index
    • if Cart is not empty, app will proceed the order
  4. User will then fill his/her name, address, email, and payment type to a new Order form
  5. When the form is submitted, app will add LineItems from the Cart to the Order and then destroy current session's Cart

Write The Spec!

As usual, we deal with our view later. First, based on previous requirements, we will write the spec for Order model and Orders controller.

Order Model Spec

Your Order model spec should look like this.

describe Order do
  # -cut-
  describe "adding line_items from cart" do
    before :each do
      @cart = create(:cart)
      @line_item = create(:line_item, cart: @cart)
      @order = build(:order)
    end
    
    it "add line_items to order" do
      expect{
        @order.add_line_items(@cart)
        @order.save
      }.to change(@order.line_items, :count).by(1)
    end

    it "removes line_items from cart" do
      expect{
        @order.add_line_items(@cart)
        @order.save
      }.to change(@cart.line_items, :count).by(-1)
    end
  end
end

Make It Pass!

When we run our Order model spec, it will ask us to define a method called "add_line_items". Write this method!

Add Lines from Cart

This is how your Order model should look like.

class Order < ApplicationRecord
  has_many :line_items, dependent: :destroy
  
  # -cut-

  def add_line_items(cart)
    cart.line_items.each do |item|
      item.cart_id = nil
      line_items << item
    end
  end
end

Modifying LineItem Model

When modifying your Order model, you should realize that your LineItem now should belongs to Order too. Also note that when a LineItem is associated with a Cart, it does not associate with Order, and vice versa.

class LineItem < ApplicationRecord
  belongs_to :food
  belongs_to :cart, optional: true
  belongs_to :order, optional: true

  def total_price
    quantity * food.price
  end
end

Adding Order ID to LineItem

And of course don't forget to add Order ID to LineItem.

rails generate migration AddOrderIdToLineItem order:references

Now run rails db:migrate and your rspec again, your model specs should all pass.

Commit!

Now is a good time to commit your progress.

Orders Controller Spec (1)

require 'rails_helper'

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

Orders Controller Spec (2)

describe OrdersController do
  describe 'GET #index' do
    it "populates an array of all orders" do 
      order1 = create(:order, name: "Buyer 1")
      order2 = create(:order, name: "Buyer 2")
      get :index
      expect(assigns(:orders)).to match_array([order1, order2])
    end

    it "renders the :index template" do
      get :index
      expect(response).to render_template :index
    end
  end

  describe 'GET #show' do
    it "assigns the requested order to @order" do
      order = create(:order)
      get :show, params: { id: order }
      expect(assigns(:order)).to eq order
    end

    it "renders the :show template" do
      order = create(:order)
      get :show, params: { id: order }
      expect(response).to render_template :show
    end
  end
  # -cut-
end

Orders Controller Spec (3)

require 'rails_helper'

describe OrdersController do
  # -cut-
  describe 'GET #new' do
    context "with non-empty cart" do
      before :each do
        @cart = create(:cart)
        session[:cart_id] = @cart.id
        @line_item = create(:line_item, cart: @cart)
      end

      it "assigns a new Order to @order" do
        get :new
        expect(assigns(:order)).to be_a_new(Order)
      end

      it "renders the :new template" do
        get :new
        expect(:response).to render_template :new
      end
    end
    # -cut-
  end
  # -cut-
end

Orders Controller Spec (4)

require 'rails_helper'

describe OrdersController do
  # -cut-
  describe 'GET #new' do
    # -cut-
    context "with empty cart" do
      before :each do
        @cart = create(:cart)
        session[:cart_id] = @cart.id
      end

      it "redirects to the store index page" do
        get :new
        expect(:response).to redirect_to store_index_url
      end
    end
  end
  # -cut-
end

Orders Controller Spec (5)

require 'rails_helper'

describe OrdersController do
  # -cut-
  describe 'GET #edit' do
    it "assigns the requested order to @order" do
      order = create(:order)
      get :edit, params: { id: order }
      expect(assigns(:order)).to eq order
    end

    it "renders the :edit template" do
      order = create(:order)
      get :edit, params: { id: order }
      expect(response).to render_template :edit
    end
  end
  # -cut-
end

Orders Controller Spec (6)

describe OrdersController do
  # -cut-
  describe 'POST #create' do
    context "with valid attributes" do
      it "saves the new order in the database" do
        expect{
          post :create, params: { order: attributes_for(:order) }
        }.to change(Order, :count).by(1)
      end

      it "destroys session's cart" do
        expect{
          post :create, params: { order: attributes_for(:order) }
        }.to change(Cart, :count).by(-1)
      end

      it "removes the cart from session's params" do
        post :create, params: { order: attributes_for(:order) }
        expect(session[:cart_id]).to eq(nil)
      end

      it "redirects to store index page" do
        post :create, params: { order: attributes_for(:order) }
        expect(response).to redirect_to store_index_url
      end
    end
    # -cut-
  end
  # -cut-
end

Orders Controller Spec (7)

describe OrdersController do
  # -cut-
  describe 'POST #create' do
    # -cut-
    context "with invalid attributes" do
      it "does not save the new order in the database" do
        expect{
          post :create, params: { order: attributes_for(:invalid_order) }
        }.not_to change(Order, :count)
      end

      it "re-renders the :new template" do
        post :create, params: { order: attributes_for(:invalid_order) }
        expect(response).to render_template :new
      end
    end
  end
  # -cut-
end

Orders Controller Spec (8)

describe OrdersController do
  # -cut-
  describe 'PATCH #update' do
    before :each do
      @order = create(:order)
    end

    context "with valid attributes" do
      it "locates the requested @order" do
        patch :update, params: { id: @order, order: attributes_for(:order) }
        expect(assigns(:order)).to eq @order
      end

      it "changes @order's attributes" do
        patch :update, params: { id: @order, order: attributes_for(:order, name: 'Nasi Uduk') }
        @order.reload
        expect(@order.name).to eq('Nasi Uduk')
      end

      it "redirects to the order" do
        patch :update, params: { id: @order, order: attributes_for(:order) }
        expect(response).to redirect_to @order
      end
    end
    # -cut-
  end
  # -cut-
end

Orders Controller Spec (9)

require 'rails_helper'

describe OrdersController do
  # -cut-
  describe 'PATCH #update' do
    # -cut-
    context "with invalid attributes" do
      it "does not update the order in the database" do
        patch :update, params: { id: @order, order: attributes_for(:order, name: 'Nasi Uduk', description: nil) }
        @order.reload
        expect(@order.name).not_to eq('Nasi Uduk')
      end

      it "re-renders the :edit template" do
        patch :update, params: { id: @order, order: attributes_for(:invalid_order) }
        expect(response).to render_template :edit
      end
    end
  end
  # -cut-
end

Orders Controller Spec (10)

require 'rails_helper'

describe OrdersController do
  # -cut-
  describe 'DELETE #destroy' do
    before :each do
      @order = create(:order)
    end

    it "deletes the order from the database" do
      expect{
        delete :destroy, params: { id: @order }
      }.to change(Order, :count).by(-1)
    end

    it "redirects to orders#index" do
      delete :destroy, params: { id: @order }
      expect(response).to redirect_to orders_url
    end
  end
end

Make It Pass!

Try to write your Orders controller so that your controller specs all pass.

Orders Controller (1)

class OrdersController < ApplicationController
  include CurrentCart
  before_action :set_cart, only: [:new, :create]
  before_action :ensure_cart_isnt_empty, only: :new
  
  # -cut-

  private
    def ensure_cart_isnt_empty
      if @cart.line_items.empty?
        redirect_to store_index_url, notice: 'Your cart is empty'
      end
    end
    # -cut-
end

First, we include CurrentCart and its "set_cart" method. Then we create our own private method named to ensure that current cart is not empty before executing "new" method.

Orders Controller (2)

class OrdersController < ApplicationController
  # -cut-
  before_action :set_order, only: [:show, :edit, :update, :destroy]
  
  # -cut-

  private
    # -cut-
    # Use callbacks to share common setup or constraints between actions.
    def set_order
      @order = Order.find(params[:id])
    end

    # Never trust parameters from the scary internet, only allow the white list through.
    def order_params
      params.require(:order).permit(:name, :address, :email, :payment_type)
    end
end

Then, we write our standard private methods like "set_order" and "order_params".

Orders Controller (3)

class OrdersController < ApplicationController
  # -cut-
  # GET /orders
  # GET /orders.json
  def index
    @orders = Order.all
  end

  # GET /orders/1
  # GET /orders/1.json
  def show
  end

  # GET /orders/new
  def new
    @order = Order.new
  end

  # GET /orders/1/edit
  def edit
  end

  # -cut-
end

Index, show, new, and edit are quite standard.

Orders Controller (4)

class OrdersController < ApplicationController
  # -cut-
  # POST /orders
  # POST /orders.json
  def create
    @order = Order.new(order_params)
    @order.add_line_items(@cart)

    respond_to do |format|
      if @order.save
        Cart.destroy(session[:cart_id])
        session[:cart_id] = nil

        format.html { redirect_to store_index_url, notice: 'Thank you for your order.' }
        format.json { render :show, status: :created, location: @order }
      else
        format.html { render :new }
        format.json { render json: @order.errors, status: :unprocessable_entity }
      end
    end
  end
  # -cut-
end

Create method is a little bit different. Here, we invoke the method "add_line_items" and destroy current cart and remove it from session parameters.

Orders Controller (5)

class OrdersController < ApplicationController
  # -cut-
  # PATCH/PUT /orders/1
  # PATCH/PUT /orders/1.json
  def update
    respond_to do |format|
      if @order.update(order_params)
        format.html { redirect_to @order, notice: 'Order was successfully updated.' }
        format.json { render :show, status: :ok, location: @order }
      else
        format.html { render :edit }
        format.json { render json: @order.errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /orders/1
  # DELETE /orders/1.json
  def destroy
    @order.destroy
    respond_to do |format|
      format.html { redirect_to orders_url, notice: 'Order was successfully destroyed.' }
      format.json { head :no_content }
    end
  end
  # -cut-
end

Routes

Rails.application.routes.draw do
  get 'home/hello'
  root 'store#index', as: 'store_index'
  
  resources :carts
  resources :foods
  resources :line_items
  resources :orders
end

Of course, when we add a controller, don't forget to add its routes to our app.

Commit!

Now you should pass your controller specs. Don't forget to commit your progress afterward.

The Views

Now that our models and controllers have passed their respective unit tests, it's time for us to write the views.

Adding "Checkout" Button

Let's start by adding "Checkout" button to our cart.

<% unless cart.line_items.empty? %>
  <table>
    <%= render (@cart.line_items) %>
    
    <tr class="total_line">
      <td colspan="2">Total</td>
      <td class="total_cell"><%= number_to_currency(@cart.total_price, unit: "Rp ", delimiter: ".", separator: ",") %></td>
    </tr>
  </table>

  <%= button_to "Checkout", new_order_path, method: :get %>
  <%= button_to 'Empty cart', @cart, method: :delete, data: { confirm: 'Are you sure?' } %>
<% end %>

Writing New Order Page

Next, we are going to write our new Order page.

<div class="go_food_form">
  <fieldset>
    <legend>Please Enter Your Details</legend>
    <%= render 'form', order: @order %> 
  </fieldset>
</div>

Adding Order Form (1)

Then, we are going to write our Order form.

<%= form_for(order) do |f| %>
  <% if order.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(order.errors.count, "error") %> prohibited this order from being saved:</h2>

      <ul>
      <% order.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= f.label :name %>
    <%= f.text_field :name, size: 40 %>
  </div>

  <div class="field">
    <%= f.label :address %>
    <%= f.text_area :address, rows: 3, cols: 60 %>
  </div>
  <!-- cut -->
<% end %>

Adding Order Form (2)

Then, we are going to write our Order form.

<%= form_for(order) do |f| %>
  <!-- cut -->

  <div class="field">
    <%= f.label :email %>
    <%= f.text_field :email, size: 40 %>
  </div>

  <div class="field">
    <%= f.label :payment_type %>
    <%= f.select :payment_type, Order.payment_types.keys, prompt: "Select a payment type" %>
  </div>

  <div class="actions">
    <%= f.submit "Place Order" %>
  </div>
<% end %>

Note that since we use Rails' enum, we can pass "Cash", "Go Pay", and "Credit Card" in our payment type field instead of 0, 1, or 2 using "Order.payment_types.keys" method.

The Stylesheet

Add these lines to the bottom of your application.scss file.

.go_good_form {
  fieldset {
    background: #efe;
    legend {
      color: #dfd;
      background: #141;
      font-family: sans-serif;
      padding: 0.2em 1em;
    }
    
    div {
      margin-bottom: 0.3em;
    }
  }

  form { 
    label {
      width: 5em;
      float: left;
      text-align: right;
      padding-top: 0.2em;
      margin-right: 0.1em;
      display: block;
    }
    
    select, textarea, input {
      margin-left: 0.5em;
    }
    
    .submit {
      margin-left: 4em;
    }
    
    br {
      display: none;
    }
  }
}

Play Around

Once you have finished writing the views, you can play around with your app. Try the checkout button and see how it works.

Also, commit your progress so far.

Exercise

You have done very well, now your product manager expects you to do even better. Implement these features:

  1. Create a page to display the list of orders
  2. In that page, we should be able to see the id, name, address, email, payment type, and total price of an order
  3. If an order's id is clicked, the app will display the detailed view of an order
  4. In the detailed view, we should be able to see the same information as in number 2, plus the information of all line items of the order

As always, do this task by writing the unit tests first.

The Simplified Go Food Web App - Iteration 4

By qblfrb

The Simplified Go Food Web App - Iteration 4

  • 236