The Simplified Go Food
Web App - Iteration 2, Part 1
The Store
Previously you have finished features to manage food catalogue for your app. Happy with your work, your product manager decides that it's time to let the customer to actually order food from your app.
For this purpose, your product manager asks you to build a new customer-facing page that acts as a store for your foods. In that page, customers can add foods to cart and subsequently process with the order.
The Store Controller
Based on this new requirement, we need a new controller that will only serves purchasing process of customers, not the CRUD of the food. We shall name this controller as StoreController. Now, let's scaffold it.
rails generate controller Store index
Rails will then generate needed skeletons for us, including...
The Store Controller Spec (1)
As mentioned earlier, from this point on we will create our app with TDD in mind. That is, we will write the tests first and write the code later.
Given this requirement, can you write the spec?
- Store's index page will show all the foods, ordered alphabetically by their name
The Store Controller Spec (2)
require 'rails_helper'
describe StoreController do
describe "GET index" do
it "returns http success" do
get :index
expect(response).to have_http_status(:success)
end
it "returns a list of foods, ordered by name" do
nasi_uduk = create(:food, name: "Nasi Uduk")
kerak_telor = create(:food, name: "Kelar Telor")
get :index
expect(assigns(:foods)).to eq([kerak_telor, nasi_uduk])
end
end
end
Rails has provided us with a default spec for our Store controller. We will just modify it a little bit.
Afterward, if you run rspec, it will shows one failed example and that is indeed what we expect.
Modifying The Store Controller
Now we need to modify our StoreController to make it pass the test. This should be easy.
class StoreController < ApplicationController
def index
@foods = Food.order(:name)
end
end
Here, we use ActiveRecord's method "order" to sort our Food model by name. You can see what other options to use for sorting ActiveRecord in this page.
Once you finished with this slight modification, your rspec should be all green again.
Layout and Style
We have passed our unit testing for the StoreController. Now it's time to play a little bit with the look and feel.
Actually, Rails has a built in unit test for view as well. However, in this class we are skipping unit test for view because it's not really the focus of our curriculum. So for this one, let's jump to the code!
Modifying Index
Modify your store's view page for index method as follow:
<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"><%= food.price %></span>
</div>
</div>
<% end %>
A little highlight: we use "sanitize" method to safely protect us from cross-site scripting when adding HTML tags in our food's description.
Modifying Routes
Slight modification to our routes:
Rails.application.routes.draw do
root 'store#index', as: 'store_index'
resources :foods
get 'home/hello'
end
Now when you access your http://localhost:3000 it will directly load our store's index page
Modifying Stylesheet
// Place all the styles related to the Store controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: http://sass-lang.com/
.store {
h1 {
margin: 0;
padding-bottom: 0.5em;
font: 150% sans-serif;
color: #226;
border-bottom: 3px dotted #77d;
}
/* An entry in our store catalogue */
.entry {
overflow: auto;
margin-top: 1em;
border-bottom: 1px dotted #77d;
min-height: 100px;
img {
width: 80px;
margin-right: 5px;
margin-bottom: 5px;
position: absolute;
}
h3 {
font-size: 120%;
font-family: sans-serif;
margin-left: 100px;
margin-top: 0;
margin-bottom: 2px;
color: #227;
}
p, div.price_line {
margin-left: 100px;
margin-top: 0.5em;
margin-bottom: 0.8em;
}
.price {
color: #44a;
font-weight: bold;
margin-right: 3em;
}
}
}
Application Layout
You may have noticed that our app does not have a proper navigation menu. Therefore we need to create a simple menu that can be seen from every page. To do this we need to change our application layout.
Modifying Application Layout
Change your "app/views/layouts/application.html.erb"
<!DOCTYPE html>
<html>
<head>
<title>The Simplified Go Food Web App</title>
<%= csrf_meta_tags %>
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
<%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>
</head>
<body class="<%= controller.controller_name %>">
<div id="banner">
<%= image_tag 'go-food.jpg', alt: "Simplified Go Food" %>
<span class="title"><%= @page_title %></span>
</div>
<div id="columns">
<div id="side">
<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 Application Stylesheet
We also need to change our application-wide stylesheet.
/*
*
*= require_tree .
*= require_self
*/
body, body > p, body > ol, body > ul, body > td {margin: 8px !important}
#banner {
position: relative;
min-height: 40px;
background: #9c9;
padding: 10px;
border-bottom: 2px solid;
font: small-caps 40px/40px "Times New Roman", serif;
color: #282;
text-align: center;
img {
position: absolute;
top: 0;
left: 0;
height: 60px;
width: 192px;
}
}
#notice:empty {
display: none;
}
#columns {
background: #141;
display: flex;
#main {
padding: 1em;
background: white;
flex: 1;
}
#side {
padding: 1em 2em;
background: #141;
ul {
padding: 0;
li {
list-style: none;
a {
color: #bfb;
font-size: small;
}
}
}
}
}
@media all and (max-width: 800px) {
#columns {
flex-direction: column-reverse;
}
}
@media all and (max-width: 500px) {
#banner {
height: 1em;
}
#banner .title {
display: none;
}
}
Using Helper to Format Price
Next, we want to change how prices are displayed to provide better experience for our users.
<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: ",") %>
</span>
</div>
</div>
<% end %>
You can take a look at this page to see what other options available for "number_to_currency" method.
Commit!
Now is a good time to commit and take a look at your progress in your app.
Creating The Cart
Now that we have a proper catalogue, we can start selling the foods on our app. In order to sell things, we need to provide users with cart.
In this exercise, a cart is just a placeholder to temporarily store all items that a user wants to buy. A cart, however, has a unique attribute: it should be easy to recover whenever a user open it with the same session.
Now, let's try to build it!
Scaffolding The Cart
Since it's just a placeholder with an id and no other attributes, we don't have much to do with our Cart MVC. Therefore, we just scaffold it and then migrate the database.
rails generate scaffold Cart
rails db:migrate
Concerning The Cart (1)
We mentioned about making our cart easy to recover from the same browser session. To do this, we need to ensure for every browser session, there is exactly only one cart assigned. We are going to implement this using Rails' "concerns".
module CurrentCart
private
def set_cart
@cart = Cart.find(session[:cart_id])
rescue ActiveRecord::RecordNotFound
@cart = Cart.create
session[:cart_id] = @cart.id
end
end
Store it in "app/controllers/concerns/current_cart.rb"
Concerning The Cart (2)
Concern is a tool provided by Rails that enables us to include modules in our model or controller classes.
In our "current_cart" concern, we define just one method: "set_cart". Its job is to find a cart in current browser session. In the case that no cart is found, it creates a new cart and store its id in "session[:cart_id]" parameter.
Adding Foods to Cart
Once we have our cart set, we are ready to add foods to our cart. To do this, we will create a functioning MVC for a model named "LineItem". Let's try to build it from scratch in TDD way.
First, we define LineItem's requirements:
- Every LineItem stores data about a Food and a Cart
- If a Cart is deleted, all LineItem in it will be deleted too
- While LineItem is in a Cart, the Food it's referencing to can't be deleted
Model Specs for LineItem
We start from a basic spec:
require 'rails_helper'
describe LineItem do
it "has a valid factory" do
expect(build(:line_item)).to be_valid
end
end
Run "rspec spec/models/line_item_spec.rb" in your console and watch it fails.
Follow The Message
Our failed test tells us one thing though: we haven't created a class named LineItem. Let's create an empty LineItem model. By now you should already know where to put this file.
class LineItem < ApplicationRecord
end
When we run "rspec spec/models/line_item_spec.rb" again, now it tells us that we don't have a factory for line_item yet.
LineItem Factory
Write the factory. This time, though, you should notice something different:
FactoryGirl.define do
factory :line_item do
association :food
association :cart
end
end
We use "association" to tell FactoryGirl to create a new Food and Cart on the fly for this LineItem to belongs to.
Migration
When we run rspec again, it tells us that there is no table named "line_items". We need to create the migration file.
rails generate migration CreateLineItem food:references cart:belongs_to
# Please note that "references" and "belongs_to" are actually the same thing
# We use both in this example to introduce you to both to enrich your vocabulary
Take a look at the generated migration file and then execute "rails db:migrate" command. After you finish, take a look at your "db/schema.rb" file to see the result.
Belongs to
When we run rspec again, it tells us that there is no method named "food=" in our LineItem model. We need to add this in our model:
class LineItem < ApplicationRecord
belongs_to :food
belongs_to :cart
end
After this little modification, our first spec for LineItem model should get the green light.
Commit!
Now is a good time to commit.
Modifying Model Specs for Cart
To comply with our requirements, we need to add new spec to our Cart model: when it is deleted, all LineItem(s) associated with it should also be deleted.
require 'rails_helper'
describe Cart do
it "has a valid factory" do
expect(build(:cart)).to be_valid
end
it "deletes line_items when deleted" do
cart = create(:cart)
food1 = create(:food)
food2 = create(:food)
line_item1 = create(:line_item, cart: cart, food: food1)
line_item2 = create(:line_item, cart: cart, food: food2)
cart.line_items << line_item1
cart.line_items << line_item2
expect { cart.destroy }.to change { LineItem.count }.by(-2)
end
end
Modifying Cart
Now when we run "rspec spec/models/cart_spec.rb", we will get an error message "undefined method 'line_items' for Cart". We need to modify our Cart model.
class Cart < ApplicationRecord
has_many :line_items, dependent: :destroy
end
Adding "has_many :line_items" directive will tell enable us to call "line_items" from our Cart, while parameter "dependent: :destroy" is a way to tell Cart model to destroy all line_items that are associated with it if it is destroyed.
Your command "rspec spec/models/cart_spec.rb" should gives you green light now.
Modifying Model Specs for Food (1)
To comply with our requirements, we need to add new spec to our Food model: when a LineItem is in cart, associated Food can't be deleted.
Based on our spec for Cart earlier, can you try to write this new spec?
Modifying Model Specs for Food (2)
This is how your additional spec for Food look like:
require 'rails_helper'
describe Food do
# -cut-
it "can't be destroyed while it has line_item(s)" do
cart = create(:cart)
food = create(:food)
line_item = create(:line_item, cart: cart, food: food)
food.line_items << line_item
expect { food.destroy }.not_to change(Food, :count)
end
end
Run "rspec spec/models/food_spec.rb" and watch it fails again, but this time for something else.
Modifying Food
As with our test with Cart before, RSpec tells us that there is no method called "line_items" in our Food model. Therefore we will modify our Food model accordingly.
class Food < ApplicationRecord
has_many :line_items
end
When we run "rspec spec/models/food_spec.rb" it will still fail, but for another reason.
Callback
You are now to be introduced with a "callback". In Rails, a callback allows you to trigger logic before or after an alteration of an object's state.
Here, we are going to write a callback that check if a Food still has any LineItem(s) whenever it is about to be destroyed. If it does, the callback prevent the action from being executed.
class Food < ApplicationRecord
# -cut-
before_destroy :ensure_not_referenced_by_any_line_item
# -cut-
private
def ensure_not_referenced_by_any_line_item
unless line_items.empty?
errors.add(:base, 'Line Items present')
throw :abort
end
end
end
Commit!
Now you have made all your model specs turn green. It is a good time to commit.
What We Just Learned
- Generating a controller without scaffolding
- Modifying a controller's views
- Modifying application layout
- Creating a concern
- Generating a model without scaffolding
- Creating factory with association
- Following TDD to construct a controller and a model from scratch to finally pass all the required tests
What We Will Learn Next
Unfortunately though, we still can not see our cart in action because we have not finished all the required pieces. But you do not need to worry because tomorrow we will finish our cart and make it works!
The Simplified Go Food Web App - Iteration 2, Part 1
By qblfrb
The Simplified Go Food Web App - Iteration 2, Part 1
- 357