Practical 7

0. Finish Practical Sheet 6.

1. Taking Orders. In this practical, we add to our online store the functionality that allows us to record the buyer’s details and thus turn a shopping cart into an order. As a first step in that direction, generate the scaffold for an Order model with the following components:

Order  
name string
address text
email string
pay_type integer

In order to relate the integer payment types to human readable names, add the following enum declaration to the generated file that contains the order model:

enum pay_type: {
  "Cheque" => 0,
  "Credit Card" => 1,
  "Purchase Order" => 2
}

2. Generate a migration to add a column order of type references to the line_items table. Write the name of the generated migration file into the comment box below, as evidence that you have attended this practical.

In this generated file, replace the add_reference command by the two lines

add_reference :line_items, :order, null: true, foreign_key: true
change_column :line_items, :cart_id, :integer, null: true

3. Connect the orders table with the line_items table by adding suitable has_many and belongs_to declarations (to which files?) Add a parameter optional: true to each belongs_to call, to allow this field to be empty. As in the cart model, add the parameter dependent: :destroy to the has_many call: when an order is destroyed, all its line items must go as well. Then run the migrations:

rake db:migrate

4. Add a 'Checkout' button next to the 'Empty Cart' button (which file?):

<%= button_to "Checkout", new_order_path, method: :get, class: "checkout" %>

Then put both buttons inside a <div> element with class attribute "actions".

5. There’s no point checking out an empty cart. In order to decide this, the orders controller needs access to the current cart. For this, add

include CurrentCart
before_action :set_cart, only: [:new, :create]
before_action :ensure_cart_isnt_empty, only: :new

to the top of this controller (which file?).

6. Then, after the private declaration of this controller, one can prevent empty orders from being placed by adding this code:

def ensure_cart_isnt_empty
  if @cart.line_items.empty?
    redirect_to store_url, notice: "Your cart is empty"
  end
end

7. This expected behavior should be formulated as a test. In the file orders_controller_test.rb add:

test "requires item in cart" do
  get new_order_url
  assert_redirected_to store_path
  assert_equal flash[:notice], 'Your cart is empty'
end

Also modify the "should get new" test so that it reads:

post line_items_url, params: { product_id: products(:ruby).id }
get new_order_url
assert_response :success

8. In the new setup, a line item now belongs to a cart, or to an order. In other words, at a given point in time, a line item might not have a cart (or an order) it belongs to. This optionality needs to be registered with the line items model. Change the lines that define the model relationships to

belongs_to :order, optional: true
belongs_to :cart, optional: true

8a. If all works fine, commit the changes to your local git repository, and push them to the github cloud.

git add .
git commit -m "added checkout button"
git push

9. Forms. Replace the contents of the order’s new view with:

<section class="depot_form">
  <h1>Please Enter Your Details</h1>
  <%= render 'form', order: @order %>
</section>

10. In the form itself, that is in the partial file _form.html.erb, restrict the name and email input fields to length 40 (size: 40), and the address to 3 rows and 40 columns (rows: 3, cols: 40). Turn the pay_type field into a selection and add an id attribute like so:

<%= form.select :pay_type, Order.pay_types.keys, id: :order_pay_type,
      prompt: "Select a payment method" %>

and pass the argument "Place Order" to the submit button.

Also, add the id attributes :order_name, :order_address and :order_email to the other three fields, e.g.,

<%= form.text_field :name, id: :order_name, size: 40 %>

11. Add these formatting rules to the end of the stylesheet app/assets/stylesheets/application.scss.

.depot_form {
  padding: 0 1em;
  h1 {
    font-size: 1.99em;
    line-height: 1.41em;
    margin-bottom: 0.5em;
    padding: 0;
  }
  .field, .actions {
    margin-bottom: 0.5em;
    padding: 0;
  }
  .actions {
    text-align: right;
    padding: 1em 0;
  }
  input, textarea, select, option {
    border: solid thin #888;
    box-sizing: border-box;
    font-size: 1em;
    padding: 0.5em;
    width: 100%;
  }
  label {
    padding: 0.5em 0;
  }
  input[type="submit"] {
    background-color: #bfb;
    border-radius: 0.354em;
    border: solid thin #888;
    color: black;
    font-size: 1.41em;
    font-weight: bold;
    padding: 0.354em 1em;
  }
  input[type="submit"]:hover {
    background-color: #9d9;
  }
  // Also, clean up the error styling
  #error_explanation {
    background-color: white;
    border-radius: 1em;
    border: solid thin red;
    margin-bottom: 0.5em;
    padding: 0.5em;
    width: 100%;
    h2 {
      background: none;
      color: red;
      font-size: 1.41em;
      line-height: 1.41em;
      padding: 1em;
    }
    ul {
      margin-top: 0;
      li {
        color: red;
        font-size: 1em;
      }
    }
  }
  .field_with_errors {
    background: none;
    color: red;
    width: 100%;
    label {
      font-weight: bold;
    }
    label::before {
      content: "! ";
    }
    input,textarea {
      background: pink;
    }
  }
}

Now try it out: put some items into your cart and proceed to the checkout! How does it look?

12. As this form is intended for the outside world, the application cannot rely on all input always being valid. Add validation (to which model?) to ensure that the fields name, address, email are all present and filled at submit time. Also make sure, the pay_type is one of the choices in the dropdown menu:

validates :pay_type, inclusion: pay_types.keys

13. Modifying the validation rules almost always requires us to adjust the fixtures so that they pass validation. In the fixtures file orders.yml modify fixture one to be:

one:
  name: Dave Thomas
  address: MyText
  email: dave@example.org
  pay_type: Cheque

Then, in line_items.yml, modify fixture two to be:

two:
  product: ruby
  order: one

13a. Test the application. If all works fine, commit the changes to your local git repository.

14. Create Order. Next comes the create action in the orders controller. It needs to

  1. Get the values from the order form to populate a new Order model object.

  2. Add the line items from the cart to that order.

  3. Validate and save the order object.

  4. In case of failure, display informative error messages that help the user to fix the problems.

  5. In case of success, delete the cart, redisplay the catalog page and a message confirming receipt of the order.

This is done as follows. In the Order model, define a method that moves items from a cart to this order:

def add_line_items_from_cart(cart)
  cart.line_items.each do |item|
    item.cart_id = nil
    line_items << item
  end
end

(Note that the append method << automatically sets item.order_id. Also note how item.cart_id is set to nil to prevent the item from disappearing when the cart will be destroyed.)

In the create method of the orders controller, add the line

@order.add_line_items_from_cart(@cart)

after the assignment @order = ..., and replace the format.html line under if @order.save by

Cart.destroy(session[:cart_id])
session[:cart_id] = nil

format.html {
  redirect_to store_url, notice: 'Thank you for your order.'
}

The new redirect requires us to modify the corresponding assertion in the "should create order" test: replace its last line by

assert_redirected_to store_path

14a. Now try it out and run the tests. If all works fine, commit the changes to your local git repository, and push them to the github cloud.

15. Atom feed. Feeds are news broadcasting services. You can subscribe to a feed and automatically receive regular updates. Here the good news to be spread is that someone ordered some product. In order to enable this service for our online store, we need to set up a new action. Recall that an action consists of three things: a controller method, a view template, and a route. First add the following method to the products controller:

def who_bought
  @product = Product.find(params[:id])
  @latest_order = @product.orders.order(:updated_at).last
  if stale?(@latest_order)
    respond_to do |format|
      format.atom
    end
  end
end

To make a product aware of all the orders it is contained in, add a line

has_many :orders, through: :line_items

to the product model (which file?)

16. The command format.atom expects to find a view template who_bought.atom.builder in the views/products finder (where builder is a template preprocessor, much like erb, but better suited for producing XML, and per default associated to .atom files). Create such a file with the following content.

atom_feed do |feed|
  feed.title "Who bought #{@product.title}"

  feed.updated @latest_order.try(:updated_at)

  @product.orders.each do |order|
    feed.entry(order) do |entry|
      entry.title "Order #{order.id}"
      entry.summary type: 'xhtml' do |xhtml|
        xhtml.p "Shipped to #{order.address}"

        xhtml.table do
          xhtml.tr do
            xhtml.th 'Product'
            xhtml.th 'Quantity'
            xhtml.th 'Total Price'
          end
          order.line_items.each do |item|
            xhtml.tr do
              xhtml.td item.product.title
              xhtml.td item.quantity
              xhtml.td number_to_currency item.total_price
            end
          end
          xhtml.tr do
            xhtml.th 'total', colspan: 2
            xhtml.th number_to_currency order.line_items.map(&:total_price).sum
          end
        end

        xhtml.p "Paid by #{order.pay_type}"
      end
      entry.author do |author|
        author.name order.name
        author.email order.email
      end
    end
  end
end

and briefly try and make sense of this code.

17. Finally, add a matching route by changing resources :products in the file config/routes.rb to:

resources :products do
  get :who_bought, on: :member
end

18. Subscribe to http://localhost:3000/products/3/who_bought.atom in your favorite reader, order the product from the store page, and get the good news.

18a. If all works fine, commit the changes to your local git repository, and push them to the github cloud.

Written on November 25, 2020 by CS424.