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.
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...
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
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.
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.
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!
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.
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
// 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;
}
}
}
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.
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>
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;
}
}
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.
Now is a good time to commit and take a look at your progress in your app.
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!
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
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"
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.
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:
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.
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.
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.
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.
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.
Now is a good time to commit.
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
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.
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?
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.
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.
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
Now you have made all your model specs turn green. It is a good time to commit.
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!