The Simplified Go Food Web App - Iteration 3
Continue Where We Left Off
Yesterday we have:
- Learn how to create unit testing for Model and Controller
- Learn how to create Model, Controller, and View from scratch without the help of scaffolding
- Developing "Cart" feature to enable our users to add Food(s) to cart
Emptying Cart
We have written features to add foods to cart. But what about removing them from it? Let's start with the requirements:
- There should be a button named "empty cart" in your cart
- When a user clicks it
- it should remove only user's own cart
- and it should remove the cart from user's session
- After the cart is removed, user is redirected to Store index
Write Your Specs
Based on requirements above, modify Carts controller spec for "destroy" method!
Carts Controller Spec (1)
Your modified spec should look like this.
describe CartsController do
# -cut-
describe "DELETE destroy" do
before :each do
@cart = create(:cart)
session[:cart_id] = @cart.id
end
# -cut-
end
end
Carts Controller Spec (2)
Your modified spec should look like this.
describe CartsController do
# -cut-
describe "DELETE destroy" do
# -cut-
context "with valid cart id" do
it "destroys the requested cart" do
expect {
delete :destroy, params: { id: @cart.id }, session: valid_session
}.to change(Cart, :count).by(-1)
end
it "removes the cart from user's session" do
delete :destroy, params: { id: @cart.id }, session: valid_session
expect(session[:id]).to eq(nil)
end
it "redirects to the store home page" do
delete :destroy, params: { id: @cart.id }, session: valid_session
expect(response).to redirect_to(store_index_url)
end
end
# -cut-
end
end
Carts Controller Spec (3)
Your modified spec should look like this.
describe CartsController do
# -cut-
describe "DELETE destroy" do
# -cut-
context "with invalid cart id" do
it "does not destroy the requested cart" do
other_cart = create(:cart)
expect{
delete :destroy, params: { id: other_cart.id }, session: valid_session
}.not_to change(Cart, :count)
end
end
end
end
Make It Pass (1)
Now try to modify your cart's "destroy" method to make it pass.
Make It Pass (2)
Your cart's destroy method should look like this.
class CartsController < ApplicationController
# -cut-
def destroy
@cart.destroy if @cart.id == session[:cart_id]
session[:cart_id] == nil
respond_to do |format|
format.html { redirect_to store_index_url, notice: 'Cart was successfully destroyed.' }
format.json { head :no_content }
end
end
end
Write The View
Add the "empty cart" button to your view.
<p id="notice"><%= notice %></p>
<h2>Your Cart</h2>
<table>
<% @cart.line_items.each do |item| %>
<tr>
<td><%= item.quantity %> ×</td>
<td><%= item.food.name %></td>
<td class="item_price"><%= number_to_currency(item.total_price, unit: "Rp ", delimiter: ".", separator: ",") %></td>
</tr>
<% end %>
<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 'Empty cart', @cart, method: :delete, data: { confirm: 'Are you sure?' } %>
Commit!
Play around with your app and commit your progress.
Ajax-Based Cart
What is Ajax?
Asynchronous Javascript and XML
Ajax is a combination of:
- A browser built-in XMLHttpRequest object (to request data from a web server)
- JavaScript and HTML DOM (to display or use the data)
But What does It Do, Exactly?
Ajax allows web pages to be updated asynchronously by exchanging data with a web server behind the scenes. This means that it is possible to update parts of a web page, without reloading the whole page.
Ajax in Your Daily Life
- Notifications
- Autocomplete Search
- Live Chat
- In Place Submissions
- State Altering Drag and Drop
- etc...
Ajax in Your App
It's your product manager again! Looking at your progress, he is excited to add more features to your app. Yeay... I guess?
Let's go to the requirements:
- The cart should appear in the sidebar in every page
- The cart should be able to be updated without reloading the whole page
Partial Templates (1)
We are going to move our cart to the sidebar. But before that, we will make use of partial templates. Let's modify our show cart page first.
<p id="notice"><%= notice %></p>
<h2>Your Cart</h2>
<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 'Empty cart', @cart, method: :delete, data: { confirm: 'Are you sure?' } %>
Partial Templates (2)
The "render" method in our previous code will iterate over any collection that is passed to it. A partial template is simply another template file. However, Rails automatically prepends an underscore to the partial name when looking for the file. Following Rails convention, we should name our partial "_line_item.html.erb" and put it in "app/views/line_items" folder.
<tr>
<td><%= line_item.quantity %> ×</td>
<td><%= line_item.food.name %></td>
<td class="item_price"><%= number_to_currency(line_item.total_price, unit: "Rp ", delimiter: ".", separator: ",") %></td>
</tr>
Also note that in the partial file, we refer to the current object by using the variable name that matches the name of the template, "line_item".
Partial Templates (3)
Next, because we want to use the same cart template both in show cart page and in the sidebar, we want to be able to render the whole cart as a partial. So, we will change our show cart page again.
<p id="notice"><%= notice %></p>
<h2>Your Cart</h2>
<%= render @cart %>
Can you guess where to put the lines of code that we just removed from our show cart page?
Partial Templates (4)
You should have guessed it easily, we put our partial in a file named "_cart.html.erb" in "app/views/carts/" folder.
<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 'Empty cart', @cart, method: :delete, data: { confirm: 'Are you sure?' } %>
Partial Templates (5)
Now we can render our cart partial from the sidebar.
<!DOCTYPE html>
<html>
<!-- cut -->
<body class="<%= controller.controller_name %>">
<!-- cut -->
<div id="columns">
<div id="side">
<div id=cart>
<%= render @cart %>
</div>
<ul>
<li><a href="#">Home</a></li>
<li><a href="#">My Account</a></li>
<li><a href="#">My Orders</a></li>
</ul>
</div>
<div id="main">
<%= yield %>
</div>
</div>
</body>
</html>
Modifying The Stylesheet (1)
We now have to modify our stylesheet for the cart.
.carts, #side #cart {
.item_price, .total_line {
text-align: right;
}
.total_line, .total_cell {
font-weight: bold;
border-top: 1px solid #595;
}
}
Modifying The Stylesheet (2)
Also our application stylesheet.
/* cut */
#columns {
/* cut */
#side {
padding: 1em 2em;
background: #141;
form, div {
display: inline;
}
input {
font-size: small;
}
#cart {
font-size: smaller;
color: white;
table {
border-top: 1px dotted #595;
border-bottom: 1px dotted #595;
margin-bottom: 10px;
}
}
/* cut */
}
/* cut */
}
Modifying Store Controller Spec
We need to change our store controller spec to include CurrentCart module. We have done this before with LineItems controller, so you should be able to write the specs and the modification to the controller already. Write them!
Store Controller and Spec
This how your store controller spec should look like.
require 'rails_helper'
describe StoreController do
describe "GET index" do
# -cut-
it "includes CurrentCart" do
expect(StoreController.ancestors.include? CurrentCart).to eq(true)
end
end
end
And this how your store controller should look like.
class StoreController < ApplicationController
include CurrentCart
before_action :set_cart
def index
@foods = Food.order(:name)
end
end
Modifying LinteItems Controller Spec
This also should be easy for you. Change our create method in LineItems controller spec to redirect to Store index page after finish executing its job. Write the spec and the modified controller!
LineItems Controller and Spec
This how your LineItems controller spec should look like.
require 'rails_helper'
describe LineItemsController do
describe 'POST #create' do
# -cut-
it "redirects to store#index" do
post :create, params: { food_id: @food.id }
expect(response).to redirect_to(store_index_url)
end
end
end
LineItems Controller
And this how your LineItems controller should look like.
class LineItemsController < ApplicationController
# -cut-
def create
# -cut-
respond_to do |format|
if @line_item.save
format.html { redirect_to store_index_url, 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
Commit!
Play around with your app and commit your progress.
Remote: True
So, after all the preparations, how do we actually add Ajax to our Rails app? First, we start by changing our "Add to Cart" button to call asynchronous request.
<p id="notice"><%= notice %></p>
<h1>Your Food Catalog</h1>
<% @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), remote: true %>
</span>
</div>
</div>
<% end %>
Format JS
Next, we change our LineItems controller create method to also respond to JS format.
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 store_index_url, notice: 'Line item was successfully created.' }
format.js
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
Adding JS File
Lastly, we add a javascript file in our views. Put this in "app/views/line_items/create.js.erb"
$('#cart').html("<%=j render(@cart) %>");
Try to add foods to cart in your app now.
What Happened?
- We tell our "button_to" to execute its request asynchronously by adding parameter "remote: true"
- Then, we add our view to respond_to Javascript by adding the line "format.js"
- With these two changes, when the button "Add to Cart" is clicked, Rails will look for "create" template that is written in javascript format
- We then provide this javascript file in the form of our "create.js.erb" file
- Our javascript file uses jQuery library (aliased with $) that replace html element with id "cart" with whatever rendered by our @cart object
Commit!
You have made a great progress so far. Let's commit your progress this far to your git repository.
Highlighting and Hiding
You have successfully moved your cart to the sidebar and updated it with Ajax. Now your product manager wants a fancier interaction in your cart. He wants your cart to:
- Highlighting changes in the cart whenever it happens
- Hiding the cart when it is empty
Are you up to this task?
Highlighting Changes
Let's start with highlighting changes in our cart.
jQuery UI (1)
We have utilized a little bit of jQuery in our Ajax cart. There are more things we can make use of from jQuery. For this, we need to install jQuery UI gem.
gem 'jquery-ui-rails'
Don't forget to run "bundle install" after that.
jQuery UI (2)
We need to include some jQuery effect in our application's javascript ("app/assets/javascripts/application.js") file.
//= require jquery
//= require jquery-ui/effects/effect-blind
//= require jquery_ujs
Don't forget to run restart your rails server after this.
Modifying LineItems Controller
Pass @current_item variable to our respond_to block that looks for javascript format.
class LineItemsController < ApplicationController
# -cut-
def create
# -cut-
respond_to do |format|
if @line_item.save
format.html { redirect_to store_index_url, notice: 'Line item was successfully created.' }
format.js { @current_item = @line_item }
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 LineItem Partial
Give <tr> tag an id "current_item" if it is the same item as the current_item variable that we pass from our LineItems controller.
<% if line_item == @current_item %>
<tr id="current_item">
<% else %>
<tr>
<% end %>
<td><%= line_item.quantity %> ×</td>
<td><%= line_item.food.name %></td>
<td class="item_price"><%= number_to_currency(line_item.total_price, unit: "Rp ", delimiter: ".", separator: ",") %></td>
</tr>
Modifying Javascript
Change our "create.js.erb" file to change some css attributes of html element with id "current_item".
$('#cart').html("<%=j render(@cart) %>");
$('#current_item').css({'background-color':'#88ff88'}).
animate({'background-color':'#114411'}, 1000);
Try adding more foods to your cart and watch how nicely it is animated when you do that.
Hiding Empty Cart
There are many ways to hide our empty cart. First, let's try by modifying our view.
<% 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 'Empty cart', @cart, method: :delete, data: { confirm: 'Are you sure?' } %>
<% end %>
Modifying Javascript
To make a smoother, we use jQuery library to add interface effect when the cart is created for the first time.
if ($('#cart tr').length == 1) { $('#cart').show('blind', 1000); }
$('#cart').html("<%=j render(@cart) %>");
$('#current_item').css({'background-color':'#88ff88'}).
animate({'background-color':'#114411'}, 1000);
Other Options
The next option is by modifying css for cart in the application layout. You don't have to write this one.
<div id="cart"
<% if @cart.line_items.empty? %>
style="display: none"
<% end %>
>
<%= render(@cart) %> </div>
The code above works just fine but also looks terrible, not just aesthetically. We can abstract some processing in our view using helper methods.
Helper
Instead of using if-else clause inline, we create a new helper named "hidden_div_if" like this:
module ApplicationHelper
def hidden_div_if(condition, attributes = {}, &block)
if condition
attributes["style"] = "display: none"
end
content_tag("div", attributes, &block)
end
end
Modifying Application Layout
And call it in our application layout like this:
<div id=cart>
<% if @cart %>
<%= hidden_div_if(@cart.line_items.empty?, id: 'cart') do %>
<%= render @cart %>
<% end %>
<% end %>
</div>
Commit!
You have made a great progress so far. Let's commit your progress this far to your git repository.
The Simplified Go Food Web App - Iteration 3
By qblfrb
The Simplified Go Food Web App - Iteration 3
- 329