We know by now that to implement the empty-cart function, we have to add a link to the cart and modify the destroy method in the carts controller to clean up the session.
It can seem hard in the beginning to know when to use product_path and when to use product_url when you want to link or redirect to a given route. In reality, it’s simple.
When you use product_url, you’ll get the full enchilada with protocol and domain name, like http://example.com/products/1. That’s the thing to use when you’re doing redirect_to, because the HTTP spec requires a fully qualified URL when doing 302 Redirect and friends. You also need the full URL if you’re redirecting from one domain to another, like product_url(domain: "example2.com", product: product).
The rest of the time, you can happily use product_path. This will generate only the /products/1 part, and that’s all you need when doing links or pointing forms, like link_to "My lovely product", product_path(product).
The confusing part is that oftentimes the two are interchangeable because of lenient browsers. You can do a redirect_to with a product_path and it’ll probably work, but it won’t be valid according to spec. And you can link_to a product_url, but then you’re littering up your HTML with needless characters, which is a bad idea too.
Start with the template and use the button_to method to add a button:
| <% if notice %> |
| <aside id="notice"><%= notice %></aside> |
| <% end %> |
| |
| <h2>Your Pragmatic Cart</h2> |
| <ul> |
| <% @cart.line_items.each do |item| %> |
| <li><%= item.quantity %> × <%= item.product.title %></li> |
| <% end %> |
| </ul> |
| |
» | <%= button_to 'Empty cart', @cart, method: :delete, |
» | data: { confirm: 'Are you sure?' } %> |
In the controller, let’s modify the destroy method to ensure that the user is deleting his or her own cart (think about it!) and to remove the cart from the session before redirecting to the index page with a notification message:
| 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: 'Your cart is currently empty' } |
| format.json { head :no_content } |
| end |
| end |
And we update the corresponding test in test/controllers/carts_controller_test.rb:
| test "should destroy cart" do |
» | post line_items_url, params: { product_id: products(:ruby).id } |
» | @cart = Cart.find(session[:cart_id]) |
» | |
| assert_difference('Cart.count', -1) do |
| delete cart_url(@cart) |
| end |
| |
» | assert_redirected_to store_index_url |
| end |
Now when we view our cart and click the “Empty cart” button, we are taken back to the catalog page and see the message shown in the screenshot.
We can remove the flash message that’s autogenerated when a line item is added:
| def create |
| product = Product.find(params[:product_id]) |
| @line_item = @cart.add_product(product) |
| |
| respond_to do |format| |
| if @line_item.save |
» | format.html { redirect_to @line_item.cart } |
| 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 |
Finally, we get around to tidying up the cart display. The <li>-based approach makes it hard to style. A table-based layout would be easier. Replace app/views/carts/show.html.erb with the following:
| <article> |
| <% if notice %> |
| <aside id="notice"><%= notice %></aside> |
| <% end %> |
| |
| <h2>Your Cart</h2> |
| <table> |
| <% @cart.line_items.each do |line_item| %> |
| <tr> |
| <td class="quantity"><%= line_item.quantity %></td> |
| <td><%= line_item.product.title %></td> |
| <td class="price"><%= number_to_currency(line_item.total_price) %></td> |
| </tr> |
| <% end %> |
| <tfoot> |
| <tr> |
| <th colspan="2">Total:</th> |
| <td class="price"><%= number_to_currency(@cart.total_price) %></td> |
| </tr> |
| </tfoot> |
| </table> |
| <%= button_to 'Empty cart', @cart, |
| method: :delete, |
| data: { confirm: 'Are you sure?' } %> |
| |
| </article> |
To make this work, we need to add a method to both the LineItem and Cart models that returns the total price for the individual line item and entire cart, respectively. Here is the line item, which involves only simple multiplication:
| def total_price |
| product.price * quantity |
| end |
We implement the Cart method using the nifty Array::sum method to sum the prices of each item in the collection:
| def total_price |
| line_items.to_a.sum { |item| item.total_price } |
| end |
With this in place, we’ll style the cart to look a bit nicer. This all gets inserted into app/assets/stylesheet/carts.css.
| // Place all the styles related to the Carts controller here. |
| // They will automatically be included in application.css. |
| // You can use Sass (SCSS) here: http://sass-lang.com/ |
| |
| .carts { |
| table { |
| border-collapse: collapse; |
| } |
| td { |
| padding: 0.5em; |
| } |
| td.quantity { |
| white-space: nowrap; |
| } |
| td.quantity::after { |
| content: " ×"; |
| } |
| td.price { |
| font-weight: bold; |
| text-align: right; |
| } |
| tfoot { |
| th, td.price { |
| font-weight: bold; |
| padding-top: 1em; |
| } |
| th { |
| text-align: right; |
| } |
| td.price { |
| border-top: solid thin; |
| } |
| } |
| input[type="submit"] { |
| background-color: #881; |
| border-radius: 0.354em; |
| border: solid thin #441; |
| color: white; |
| font-size: 1em; |
| padding: 0.354em 1em; |
| } |
| input[type="submit"]:hover { |
| background-color: #992; |
| } |
| } |
The following screenshot shows a nicer-looking cart.
Finally, we update our test cases to match the current output:
| test "should create line_item" do |
| assert_difference('LineItem.count') do |
| post line_items_url, params: { product_id: products(:ruby).id } |
| end |
| |
| follow_redirect! |
| |
» | assert_select 'h2', 'Your Cart' |
» | assert_select 'td', "Programming Ruby 1.9" |
| end |
Our shopping cart is now something the client is happy with. Along the way, we covered the following:
But, just as we think we’ve wrapped up this functionality, our customer wanders over with a copy of Information Technology and Golf Weekly. Apparently, it has an article about the Ajax style of browser interface, where stuff gets updated on the fly. Hmmm…let’s look at that tomorrow.
Here’s some stuff to try on your own:
Create a migration that copies the product price into the line item, and change the add_product method in the Cart model to capture the price whenever a new line item is created.
Write unit tests that add both unique products and duplicate products to a cart. Assert how many products should be in the cart in each instance. Note that you’ll need to modify the fixture to refer to products and carts by name—for example, product: ruby.
Check products and line items for other places where a user-friendly error message would be in order.
Add the ability to delete individual line items from the cart. This will require buttons on each line, and such buttons will need to be linked to the destroy action in the LineItemsController.
We prevented accessing other user’s carts in the LineItemsController, but you can still see other carts by navigating directly to a URL like http://localhost/carts/3. See if you can prevent accessing any cart other than than one currently stored in the session.