Extending the Depot application

From Smith family
Jump to: navigation, search

The Depot application from the Agile Web Development with Rails book is a good way of learning the basics of Rails. But there's nothing like cutting your own groove for really getting to grips with a new technology. In that spirit, here are some extensions to the Depot application that I've made.

Add a date products are available from

(Inspired by a change included in the first edition of the book)

The idea is that products will only become available after a certain date. After that date, products are present in the catalogue; before it, they're not listed there.

This change was made after the price was added, but before the test data migration was created. If you do this after the test data is created, make sure to include available dates for all the products.

Create a migration to add the 'date available' field

  • First, create a migration to add the new field:
user@desktop:depot$ ruby script/generate migration add_date_available_to_product date_available:date
  • The migration file itself shouldn't need any modification, so apply it:
user@desktop:depot$ rake db:migrate
  • Sorted!

If you have problems with the new field being unrecognised when you add test data, include a call to Product.reset_column_information in the migration:

def self.up
  add_column :products, :date_available, :date
  height=1emProduct.reset_column_information
height=1emend

Update the Product view forms

The date field needs to be alterable in the View, Create, and Update forms (we'll do the Index in a moment, below).

  • In app/views/products/show.html.erb, add these lines near the end:
 <p>
   <b>Price:</b>
   <%=h @product.price %>
 </p>

 height=1em<p>
   height=1em<b>Date available:</b>
   height=1em<%= f.label :date_available %><br />
 height=1em</p>

 <%= link_to 'Edit', edit_product_path(@product) %> |
 <%= link_to 'Back', products_path %>
  • In app/views/products/edit.html.erb, add these lines near the end:
 <p>
   <%= f.label :price %><br />
   <%= f.text_field :price %>
 </p>
 height=1em<p>
   height=1em<%= f.label :date_available %><br />
   height=1em<%= f.date_select :date_available, :order => [:day, :month, :year] %>
 height=1em</p>
 <p>
   <%= f.submit "Update" %>
 </p>
  • In app/views/products/new.html.erb, add these lines near the end:
 <p>
   <%= f.label :price %><br />
   <%= f.text_field :price %>
 </p>
 height=1em<p>
   height=1em<%= f.label :date_available %><br />
   height=1em<%= f.date_select :date_available, :order => [:day, :month, :year] %>
 height=1em</p>
 <p>
   <%= f.submit "Create" %>
 </p>

You should now be able to add availability dates to products.

Prettily format the Product index page

The index view needs a bit more work to make it pretty.

  • Adjust the display to include the date available field.
<dl>
  <dt><%=h product.title %></dt>
  <dd><%=h truncate(product.description.gsub(/<.*?>/, '|'), 
    :length => 80) %></dd>
  <dd><%=number_to_currency product.price, :unit => "£" %>
  height=1em<% if product.date_available.past? %>
    height=1em<dd>Available since <%= product.date_available %></dd>
  height=1em<% else %>
    height=1em<dd class="unavailable">Available from <%= product.date_available %></dd>
  height=1em<% end %>
</dl>
  • Include this section in the depot.css stylesheet to make the text stand out when a product us unavailable
height=1em#product-list .list-description .unavailable {
       height=1emcolor: #f00;
height=1em}

Update the Product model to include the date constraint

Add the :conditions option to the find_products_for_sale procedure in app/models/product.rb

 def self.find_products_for_sale
   find(:all,
     height=1em:conditions => "date_available <= now()",
     :order => :title)
 end

The catalogue should now only include products that are marked as being available.

Add a date products are available until

This is much the same as the change above, except that there is the added complication that not every product will have an end-of-availability date. If this date is unknown, it should be stored as nil in the database and the product should remain available forever.

Create a migration to add the 'date available' field

  • First, create a migration to add the new field:
user@desktop:depot$ ruby script/generate migration add_date_available_until_to_product date_available_until:date
  • The migration file itself shouldn't need any modification, so apply it:
user@desktop:depot$ rake db:migrate
  • Sorted!

If you have problems with the new field being unrecognised when you add test data, include a call to Product.reset_column_information in the migration:

def self.up
  add_column :products, :date_available_until, :date
  height=1emProduct.reset_column_information
end

Update the Product view forms

The date field needs to be alterable in the View, Create, and Update forms (we'll do the Index in a moment, below).

  • In app/views/products/show.html.erb, add these lines near the end:
<p>
  <b>Price:</b>
  <%=h @product.price %>
</p>

<p>
  <b>Date available:</b>
  <%=h @product.date_available %>
</p>

height=1em<p>
  height=1em<b>Date available until:</b>
  height=1em<%=h @product.date_available_until %>
height=1em</p>

<%= link_to 'Edit', edit_product_path(@product) %> |
<%= link_to 'Back', products_path %>
  • In app/views/products/edit.html.erb, add these lines near the end:
 <p>
   <%= f.label :price %><br />
   <%= f.text_field :price %>
 </p>
 <p>
   <%= f.label :date_available %><br />
   <%= f.date_select :date_available, :order => [:day, :month, :year] %>
 </p>
 height=1em<p>
   height=1em<%= f.label :date_available_until %><br />
   height=1em<%= f.date_select :date_available_until, :order => [:day, :month, :year], :include_blank => true %>
 height=1em</p>
 <p>
   <%= f.submit "Update" %>
 </p>
  • In app/views/products/new.html.erb, add these lines near the end:
 <p>
   <%= f.label :price %><br />
   <%= f.text_field :price %>
 </p>
 <p>
   <%= f.label :date_available %><br />
   <%= f.date_select :date_available, :order => [:day, :month, :year] %>
 </p>
 height=1em<p>
   height=1em<%= f.label :date_available_until %><br />
   height=1em<%= f.date_select :date_available_until, :order => [:day, :month, :year], :include_blank => true %>
 height=1em</p>
 <p>
   <%= f.submit "Create" %>
 </p>

You should now be able to add availability dates to products.

Prettily format the Product index page

The index view needs a bit more work to make it pretty.

  • Adjust the display to include the date available field.
<dl>
  <dt><%=h product.title %></dt>
  <dd><%=h truncate(product.description.gsub(/<.*?>/, '|'), 
    :length => 80) %></dd>
  <dd><%=number_to_currency product.price, :unit => "£" %>
  <% if product.date_available.past? %><
    <dd>Available since <%= product.date_available %></dd>
  <% else %>
    <dd class="unavailable">Available from <%= product.date_available %></dd>
  <% end %>
  height=1em<% if not product.date_available_until.nil? %> 
    height=1em<% if product.date_available_until.future? %>
       height=1em<dd>Available until <%= product.date_available_until %></dd>
    height=1em<% else %>
       height=1em<dd class="unavailable">Unavailable since <%= product.date_available_until %></dd>
    height=1em<% end %>
  height=1em<% end %>
</dl>

Update the Product model to include the date constraint

Add the :conditions option to the find_products_for_sale procedure in app/models/product.rb

 def self.find_products_for_sale
   find(:all,
     :conditions => "date_available <= now() height=1emand (date_available_until is null or date_available_until >= now())",
     :order => :title)
 end

The catalogue should now only include products that are marked as being available.

Sort cart items alphabetically

In app/views/store/_cart.html.erb, make the cart items render in order.

<%= render(:partial => 'cart_item', :collection => cart.itemsheight=1em.sort_by {|item| item.product.title}) %>

Implement the cart as a genuine database model

Not done yet

(Inspired by a comment in the text about the cart)

AJAXify the checkout form

If you've been following along the book, doing all the additional extras, you'll have a fully AJAXified cart. Unfortunately, that means that it won't work properly with the checkout code as presented in the book. To make it work, you need to make the following modifications.

  • Add the 'Checkout' button to _cart.html.erb
<div class="cart-title">Your Cart</div>
<table>
  <%= render(:partial => 'cart_item', :collection => cart.items.sort_by {|item| item.product.title}) %>

  <tr class="total-line">
    <td colspan="2">Total</td>
    <td class="total-cell"><%= number_to_currency(cart.total_price, :unit => "£") %></td>
  </tr>
</table>

height=1em<% form_remote_tag :url => {:action => 'checkout'} do %>
    height=1em<%= submit_tag "Checkout" %>
height=1em<% end %>

<% form_remote_tag :url => {:action => 'empty_cart'} do %>
    <%= submit_tag "Empty cart" %>
<% end %>
  • Modify the checkout action in the store_controller to respond properly to an xhr request.
def checkout
  @cart = find_cart
  if @cart.items.empty?
    redirect_to_index("Your cart is empty" )
  else
    @order = Order.new
    height=1emrespond_to do |format|
      height=1emformat.js if request.xhr?
      height=1emformat.html
    height=1emend
  end
end

The desired action is for the 'Checkout' button to replace the catalogue display with the checkout form. That means we need a way of identifying the catalogue display so it can be replaced and something to replace it with. We also need to ensure that the checkout form display works both with and without JavaScript.

  • First, identify the main panel where the catalogue display is. Modify app/views/layouts/store.html.erb to wrap the main catalogue display in a <div>:
...
<div id="main">
  <% if flash[:notice] -%>
    <div id="notice"><%= flash[:notice] %></div>
  <% end -%>
  height=1em<div id="main_panel">
    <%= yield :layout %>
  height=1em</div>
</div>
...
  • For the checkout action to render properly when called as an AJAX operation, it needs a partial to render. This partial should contain the whole checkout form, so rename app/views/checkout.html.erb as app/views/_checkout.html.erb
  • The app/views/checkout.html.erb view needs to render this partial. This means that non-AJAX calls will render it correctly. The whole view should be this one line:
<%= render(:partial => "checkout", :object => @order) %>
  • Create app/views/checkout.js.rsj to handle displaying the checkout form when called by AJAX
page.replace_html("main_panel", :partial => "checkout", :object => @order)
This replaces the contents of the main panel in the standard layout with the partial.

Improve the decoupling between cart items and order items

(Based on the discussion at the book's wiki site.)

As written in the book, the store controller's save order action creates a new order and passes it the cart. The order model then creates a new line item for each item in the cart (requiring that the order model know about the internal structure of the cart) and the creation of the line item is the responsibility of the line item model, which has to know about the internal structure of the cart item.

This is rather a lot of coupling.

It's better for the order, line item, cart, and cart item models each to know very little about each other. The only place in the application that knows about both carts and orders is the store controller, so this is were the transfer of information from one to the other should take place.

  • Remove the add_line_items_from_cart(cart) procedure from app/models/line_item.rb
  • Remove the self.from_cart_item(cart_item) procedure from app/models/line_item.rb
  • Modify the save_order procedure in app/controllers/store.rb. Remove the call to @order.add_line_items_from_cart(@cart). Add the order creation code shown below.
def save_order
  @cart = find_cart
  @order = Order.new(params[:order])
  height=1em@cart.items.each do |item|
    height=1emli = LineItem.new
    height=1emli.product      = item.product
    height=1emli.quantity     = item.quantity
    height=1emli.total_price  = item.price
    height=1em@order.line_items << li
  height=1emend
  if @order.save
    session[:cart] = nil
    redirect_to_index("Thank you for your order")
  else
    render :action => 'checkout'
  end
end

Eliminate old sessions

Not done yet

Make orders take real money from PayPal

Not done yet