Category Archives: Ruby on Rails

Rails – Xây dựng tính năng RSS

RSS là công nghệ hỗ trợ người dùng cập nhật nội dung từ website mà không cần phải vào trang web. Cách hoạt động thì đơn giản là website sẽ tạo ra một file có định dạng XML rồi trả về cho người dùng, người dùng sẽ dùng một phần mềm để đọc file đó.

RSS cũng có nhiều phiên bản khác nhau, phổ biến nhất là RSS 1.0, RSS 2.0 và Atom. Cái nào cũng có ưu/nhược điểm riêng và đều được sử dụng rộng rãi, có website dùng cả 3 phiên bản.

Riêng Ruby có thư viện hỗ trợ tạo file RSS ở 3 phiên bản trên và các phiên bản khác nhỏ lẻ khác. Rails cũng có hàm tạo RSS dựa trên thư viện của Ruby, và mặc định Rails chọn phiên bản Atom.

Chúng ta sẽ tạo một file Atom hiển thị những đơn hàng đã mua sản phẩm có id bất kỳ.

Định nghĩa Action

Đầu tiên chúng ta định nghĩa phương thức who_bought trong lớp ProductsController như sau:

class ProductsController < ApplicationController
    .
    .
    .
    def who_bought 
        @product = Product.find(params[:id]) 
        respond_to do |format|
            format.atom
            format.xml { render :xml => @product }
        end
    end
    .
    .
    .
end

Phương thức này sẽ được gọi từ URL là /products/<id>/who_bought.atom . Ở đây chúng ta lấy đối tượng Productid tương ứng trong tham số gửi lên. Sau đó gọi hàm format.atom, hàm này sẽ chạy đoạn code có trong file <tên_phương_thức>.atom.builder nằm trong thư mục app/views/<tên model> dùng để tạo dữ liệu Atom, tức là ở đây sẽ gọi tới file who_bought.atom.builder trong thư mục app/views/products. Chúng ta truyền vào file này biến @product đã tạo ra ở trên trong phương thức format.xml.

Tạo dữ liệu Atom

Tiếp theo chúng ta tạo file who_bought.atom.builder trong thư mục app/views/products có nội dung như sau:

atom_feed do |feed|
    feed.title "Who bought #{@product.title}" 
 
    latest_order = @product.orders.sort_by(&:updated_at).last
    feed.updated(latest_order && latest_order.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|
                entry.name order.name
                entry.email order.email
            end
        end
    end
end

Hàm atom_feed là hàm helper tạo ra code XML theo định dạng Atom.

atom_feed do |feed|
...
end

Hàm này tạo ra một đối tượng ActionView::Helpers::AtomFeedHelper::AtomFeedBuilder, ở đây là biến feed.

feed.title "Who bought #{@product.title}" 
 
latest_order = @product.orders.sort_by(&:updated_at).last
feed.updated(latest_order && latest_order.updated_at)

Đầu tiên chúng ta gọi phương thức titleupdated để thiết lập tiêu đề và thời gian tạo gần đây nhất bằng cách lấy thời gian trong thuộc tính updated_at của bản ghi Order cuối cùng. Nếu không tìm thấy thì Rails sẽ lấy thời gian là giờ hiện tại trên máy. Hai phương thức này sẽ tạo thẻ <entry><title> trong file Atom.

@product.orders.each do |order|
    feed.entry(order) do |entry|
    ...
    end
end

Tiếp theo chúng ta lặp từng bản ghi Order, cứ mỗi lần lặp thì chúng ta gọi phương thức feed.entry. Phương thức này nhận vào một đối tượng bất kỳ và tạo ra biến ActionView::Helpers::AtomFeedHelper::AtomBuilder, ở đây là biến entry.

Chúng ta dùng biến entry này để tạo các thẻ html như bình thường. Bạn có thể dựa vào code là có thể tự đoán phương thức nào tạo thẻ nào rồi.

Thiết lập mối quan hệ

Tuy nhiên chúng ta chưa định nghĩa mối quan hệ giữa một đối tượng Product và một đối tượng Order. Nhưng một đối tượng Order lại chứa nhiều đối tượng LineItem, một đối tượng Product cũng có nhiều đối tượng LineItem, do đó chúng ta có thể liên kết đối tượng Product với đối tượng Order thông qua đối tượng LineItem như sau:

class Product < ActiveRecord::Base
    validates :title, :description, :image_url, :presence => true
    validates :price, :numericality => {:greater_than_or_equal_to => 1.0}
    validates :title, :uniqueness => true
    validates :image_url, :format => {
        :with => %r{\.(gif|jpg|png)\Z}i,
        :message => 'Chi nhan file GIF, JPG, PNG'
    }
 
    has_many :line_items
    has_many :orders, :through => :line_items
    before_destroy :check_if_has_line_item
 
private
 
    def check_if_has_line_item
        if line_items.empty?
            return true
        else
            errors.add(:base, 'This product has a LineItem')
            return false
        end
    end
end

Routing

Cuối cùng chúng ta điều hướng URL tới phương thức who_bought như sau:

Rails.application.routes.draw do
    resources :orders
    resources :line_items
    resources :carts
    get 'store/index'

    resources :products do 
        get :who_bought, :on => :member
    end
 
    root :to => 'store#index' 
end

Nếu bạn còn nhớ thì hàm resources sẽ tự động định nghĩa 7 URL vào 7 phương thức CRUD cơ bản.

Rails cho phép chúng ta điều hướng thêm vào sau các URL có sẵn, bằng cách khai báo thêm khối lệnh do...end sau phương thức resources và khai báo các dòng routing trong đó.

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

Dòng code trên sẽ gắn URL /who_bought vào URL của các phương thức đã định nghĩa trước đó, tức là Rails sẽ hiểu các URL như /products/1/who_bought. Và gửi tới phương thức who_bought trong lớp ProductsController.

Vậy là xong, bạn có thể chạy server và trỏ vào URL như /products/1/who_bought.atom là sẽ có đoạn code XML hiển thị danh sách các đơn hàng có sản phẩm có product_id1. Lưu ý là vì URL /who_bought trả về nội dung Atom chứ không phải HTML như bình thường nên khi trỏ thì chúng ta thêm đuôi .atom ở cuối URL.

capture

Nếu muốn bạn có thể dùng các phần mềm đọc tin RSS để đọc file Atom này cũng được. Dưới đây là hình khi dùng RSS Feed Reader, đây là một extension của trình duyệt Chrome.

bbbcapture

Rails – Xây dựng chức năng đặt hàng

Trong phần này chúng ta sẽ xây dựng chức năng đặt hàng đơn giản.

Chỉnh sửa model

Đầu tiên chúng ta định nghĩa một model có tên order (đơn hàng) bao gồm các trường:

  • name: tên người nhận
  • address: địa chỉ giao hàng
  • email: địa chỉ email
  • pay_type: phương thức thanh toán
C:\Projects\Rails\depot>rails generate scaffold order name:string address:text email:string pay_type:string
...

Khi một khách hàng thêm sản phẩm vào giỏ hàng thì một đối tượng LineItem sẽ được tạo ra, và chúng ta sẽ cần biết đối tượng LineItem này nằm trong giỏ hàng nào, do đó chúng ta sẽ thêm trường order_id vào model order như sau:

C:\Projects\Rails\depot>rails generate migration add_order_id_to_line_item order_id:integer
...

Cuối cùng chúng ta cập nhật lại cấu trúc CSDL:

C:\Project\Rails\depot>rake db:migrate
...

Chỉnh sửa View

Chúng ta thêm nút ‘Checkout’ vào giao diện như sau:

<h2>Your cart</h2>
<table>
    <%= render cart.line_items %>
    <tr class="total_line">
        <td colspan="2">Total</td>
        <td class="total_cell"><%= number_to_currency(cart.total_price) %></td>
    </tr>
</table>
 
<%= button_to 'Checkout', 
              new_order_path, 
              :method => :get %><br>
<%= button_to 'Empty cart', 
              cart, 
              :method => :delete,
              data: {:confirm => 'Are you sure?' } %>

Nút này sẽ gửi lên URL là '/orders/new' có trong biến new_order_path, phương thức gửi là GET, chúng ta phải chỉ rõ phương thức gửi ra vì mặc định hàm button_to gửi theo phương thức POST.

URL '/orders/new' được gọi tới phương thức action là new trong lớp OrdersController, phương thức này sẽ làm nhiệm vụ hiển thị form nhập thông tin đơn hàng, tuy nhiên chúng ta sửa lại để kiểm tra xem giỏ hàng hiện tại thật sự có hàng hay không thì mới hiển thị. Chúng ta sửa file orders_controller.rb trong thư mục app/controllers như sau:

class OrdersController < ApplicationController
    .
    .
    .
    # GET /orders/new
    def new 
        @cart = current_cart
        if @cart.line_items.empty?
            redirect_to '/', :notice => 'Your cart is empty'
            return 
        end
 
        @order = Order.new 
    end
    .
    .
    .
end

Nếu không có hàng trong giỏ hàng thì chúng ta cho quay lại trang '/', có tin nhắn thông báo là 'Your cart is empty'.

Ngược lại thì file View mặc định do Rails tạo ra là new.html.erb trong thư mục app/views/orders sẽ được hiển thị, chúng ta sửa lại như sau:

<div class="depot_form">
    <fieldset>
        <legend>Your information</legend>
        <%= render 'form' %> 
    </fieldset>
</div>

Ở đây chúng ta gọi hàm render với tham số là ‘form’, hàm này sẽ hiển thị form nhập thông tin từ file partial là _form.html.erb trong thư mục app/views/orders. File này Rails cũng tự động tạo cho chúng ta rồi, nhưng chúng ta sửa lại như sau:

<%= form_for(@order) do |f| %>
    <% if @order.errors.any? %>
        <div id="error_explanation">
            <h2>
                <%= pluralize(@order.errors.count, "error") %> prohibited this order from being saved:
            </h2>

            <ul>
                <% @order.errors.full_messages.each do |message| %>
                    <li><%= message %></li>
                <% end %>
            </ul>
        </div>
    <% end %>

    <div class="field">
        <%= f.label :name %><br>
        <%= f.text_field :name, :size => 40 %>
    </div>
    <div class="field">
        <%= f.label :address %><br>
        <%= f.text_area :address, :rows => 3, :cols => 40 %>
    </div>
    <div class="field">
        <%= f.label :email %><br>
        <%= f.text_field :email, :size => 40 %>
    </div>
    <div class="field">
        <%= f.label :pay_type %><br>
        <%= f.select :pay_type, Order::PAYMENT_TYPES, :prompt => 'Select a payment method' %>
    </div>
 
    <div class="actions">
        <%= f.submit 'Place order' %>
    </div>
<% end %>

Hàm form_for là một hàm helper giúp tạo form một cách nhanh chóng. Cú pháp có dạng:

<%= form_for <đối tượng> ... do |f| %>
    <%= f.label ... %>
    <%= f.text_area ... %>
<% end %>

Hàm form_for nhận vào một đối tượng, và tạo một đối tượng thuộc lớp ActionView::Helpers::FormaBuilder, ở đây là 'f', chúng ta có thể gọi các phương thức của đối tượng này để tạo các thẻ trong <form>, chẳng hạn như label, select, text_area, submit…v.v bạn có thể xem danh sách tại đây.

Ở phương thức f.select dùng để tạo combobox, chúng ta cho danh sách các phần tử là hằng số PAYMENT_TYPES trong lớp Order (app/models/order.rb), do đó chúng ta định nghĩa thêm hằng số này như sau:

class Order < ActiveRecord::Base
    PAYMENT_TYPES = [ "Cash on delivery", "Ngân lượng", "Bảo Kim", "Bank Card" ]
end

Cuối cùng chúng ta định nghĩa thêm một số lớp CSS như sau:

...
/* Order form style */
.depot_form fieldset {
    background: #efe;
}

.depot_form legend {
    color: #dfd;
    background: #141;
    font-family: sans-serif;
    padding: 0.2em 1em;
}

.depot_form label {
    width: 5em;
    float: left;
    text-align: right;
    padding-top: 0.2em;
    margin-right: 0.1em;
    display: block;
}

.depot_form select, .depot_form textarea, .depot_forma input {
    margin-left: 0.5em;
}

.depot_form .submit {
    margin-left: 4em;
}

.depot_form div {
    margin: 0.5em 0;
}

Đến đây giao diện sẽ như thế này:

capture

Định nghĩa validation

Chúng ta muốn đơn hàng được gửi lên phải thỏa mãn một số điều kiện, đó là không có trường nào được để trống. Chúng ta sửa lại file app/models/order.rb như sau:

class Order < ActiveRecord::Base
    PAYMENT_TYPES = [ "Cash on delivery", "Ngân lượng", "Bảo Kim", "Bank Card" ]
 
    validates :name, :address, :email, :pay_type, :presence => true
    validates :pay_type, :inclusion => PAYMENT_TYPES
end

Định nghĩa mỗi quan hệ

Một đối tượng LineItem sẽ thuộc về một đối tượng Order. Chúng ta sửa lại file model line_item.rb như sau:

class LineItem < ActiveRecord::Base
    belongs_to :order
    belongs_to :product
    belongs_to :cart
 
    def total_price
        product.price * quantity
    end
end

Ngược lại, một đối tượng Order sẽ có nhiều đối tượng LineItem trong đó, ngoài ra khi một đối tượng Order bị hủy thì các đối tượng LineItem “thuộc về” Order đó cũng sẽ bị hủy theo. Chúng ta sửa file model order.rb như sau:

class Order < ActiveRecord::Base
    has_many :line_items, :dependent => :destroy
    PAYMENT_TYPES = [ "Cash on delivery", "Ngân lượng", "Bảo Kim", "Bank Card" ]
 
    validates :name, :address, :email, :pay_type, :presence => true
    validates :pay_type, :inclusion => PAYMENT_TYPES
end

Tạo đơn hàng

Khi người dùng điền đầy đủ thông tin và click nút ‘Place order’, nút này sẽ gửi các thông tin đó đến phương thức create trong lớp OrdersController. Chúng ta sửa lại một tí như sau:

class OrdersController < ApplicationController
    .
    .
    .
    # POST /orders
    # POST /orders.json
    def create
        @order = Order.new(order_params)
        @order.add_line_items_from_cart(current_cart)
 
        respond_to do |format|
            if @order.save
                Cart.destroy(session[:cart_id])
                session[:cart_id] = nil
 
                format.html { redirect_to '/', notice: 'Thank you for your order' }
                format.json { render :show, status: :created, location: @order }
            else
                format.html { render :new }
                format.json { render json: @order.errors, status: :unprocessable_entity }
            end
        end
    end
    .
    .
    .
end

Phương thức mặc định của Rails là tạo một đối tượng Order với thông tin lấy từ các tham số do người dùng nhập vào. Tuy nhiên chưa có thông tin về các đối tượng LineItem, chúng ta thêm vào bằng phương thức add_line_items_from_cart, phương thức này sẽ được định nghĩa sau. Sau khi đã tạo đối tượng Order xong và lưu vào giỏ hàng thì chúng ta xóa giỏ hàng trong session đi.

Bây giờ chúng ta định nghĩa phương thức add_line_items_from_cart như sau:

class Order < ActiveRecord::Base
    has_many :line_items, :dependent => :destroy
    PAYMENT_TYPES = [ "Cash on delivery", "Ngân lượng", "Bảo Kim", "Bank Card" ]
 
    validates :name, :address, :email, :pay_type, :presence => true
    validates :pay_type, :inclusion => PAYMENT_TYPES
 
    def add_line_items_from_cart(cart) 
        cart.line_items.each do |item|
            item.cart_id = nil
            line_items << item
        end
    end
end

Phương thức này nhận vào tham số là một đối tượng Cart, chúng ta duyệt danh sách các đối tượng LineItem có trong Cart này, các đối tượng LineItem sẽ được gán giá trị cho thuộc tính cart_idnil để tránh báo lỗi khi xóa giỏ hàng, tiếp theo chúng ta đưa đối tượng LineItem đó vào mảng line_items.

Vậy là xong, bây giờ chúng ta có thể sử dụng chức năng đặt hàng được rồi.

capture

Và khi đặt hàng xong thì chúng ta được trỏ về trang ‘/’ với thông báo đặt hàng thành công (Thanh you for your order).

capture

Tuy nhiên nếu tiếp tục thêm hàng vào giỏ thì tin nhắn này vẫn tồn tại ở đó, chẳng qua là vì trình duyệt gửi lệnh AJAX để cập nhật giỏ hàng chứ không gửi lệnh HTTP để tải lại trang. Để ẩn dòng thông báo này thì chúng ta sửa lại file create.js.erb như sau:

var content = "<%= escape_javascript(render(current_cart)) %>";
document.getElementById("cart").innerHTML = content;

$("#current_item").css({'background-color':'#88ff88'});
$("#current_item").animate({backgroundColor:'#114411'}, 1000 );

var p_notice = document.getElementById("notice");
if(p_notice !== null)
    p_notice.remove();

Chỉ đơn giản là kiểm tra xem trang web có thẻ nào có idnotice hay không và xóa nó đi. Vậy là dòng thông báo sẽ biến mất.

Rails – Tạo phương thức Helper

Chúng ta đã biết các phương thức helper là các phương thức có thể được gọi trong file View (.html.erb), trong bài render chúng ta cũng biết là có một cách để “biến” một phương thức bình thường thành một phương thức controller bình thường thành một phương thức helper là khai báo trong phương thức helper_method.

Trong bài này chúng ta sẽ định nghĩa phương thức helper rõ ràng hơn.

Trong thư mục app của project, có một thư mục có tên là helpers, tất cả các lớp controller được tạo ra sẽ có một file có tên dạng <tên controller>_helper.rb nằm trong thư mục này. Ngoài ra khi tạo project thì còn có một file nữa là application_helper.rb. Đây là các file Rails tạo cho chúng ta để định nghĩa các hàm helper.

helpers

Chúng ta sẽ viết hàm ẩn thẻ <div id="cart"> nếu giỏ hàng không có sản phẩm nào.

Đầu tiên chúng ta sửa lại file application.html.erb như sau:

<!DOCTYPE html>
<html>
<head>
<title>Books Store</title> 
    <script src="https://code.jquery.com/jquery-3.1.1.min.js"></script>
    <script src="https://code.jquery.com/ui/1.12.1/jquery-ui.min.js"></script>
    <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %>
    <%= javascript_include_tag 'application', 'data-turbolinks-track' => true %>
    <%= csrf_meta_tags %>
</head>
<body id="store">
    <div id="banner">
        <%= image_tag("logo.png") %>
        <%= @page_title || "Books Store" %>
    </div>
    <div id="columns">
        <div id="side">
            <div id="cart"> 
                <%= hide_cart_if(current_cart.line_items.empty?, :id => "cart") do %>
                <%= render current_cart %> 
                <% end %>
            </div>
 
            <a href="#">Home</a><br />
            <a href="#">FAQ</a><br />
            <a href="#">News</a><br />
            <a href="#">Contact</a><br />
        </div>
        <div id="main">
            <%= yield %>
        </div>
    </div> 
</body>
</html>

Chúng ta bọc đoạn <%= render current_cart %> bằng <%= hide_cart_if() do %>...<% end %>. Chúng ta truyền vào hàm hide_cart_if 2 tham số, mục đích là để phương thức này biết giỏ hàng có trống không và nếu trống thì ẩn thẻ nào.

Tiếp theo chúng ta định nghĩa phương thức hide_cart_if trong file application_helper.rb như sau:

module ApplicationHelper
    def hide_cart_if(condition, attributes = {}, &block) 
        if condition
            attributes["style"] = "display:none"
        end
        content_tag("div", attributes, &block) 
    end
end

Tham số condition được truyền vào ở đây cho biết giỏ hàng có trống không. Tham số attributes là một danh sách các tham số khác, ở trên chúng ta truyền vào là id có giá trị "cart". Tham số &block là đoạn <%= render current_cart %>.

Nếu conditiontrue, tức là giỏ hàng đúng là trống, thì chúng ta định nghĩa thêm một phần tử trong thuộc tính attributes có khóa là style và giá trị là "display:none".

Cuối cùng chúng ta gọi phương thức content_tag(), hàm này nhận vào tên một thẻ, danh sách các thuộc tính và giá trị của tham số đó, và biến &block, hàm này sẽ bọc những gì trong &block với tên thẻ và thuộc tính. Tức là bây giờ đoạn code hiển thị giỏ hàng sẽ có dạng như thế này:

.
.
.
<div id="cart">
    <div style="display:none">
    <%= render current_cart %>
    </div>
</div>
.
.
.

Tức là không hiển thị ra nữa.

Chúng ta cũng sửa lại phương thức destroy trong lớp CartsController để khi xóa giỏ hàng thì được trỏ về trang '/' luôn, không trở lại trang /carts nữa:

class CartsController < ApplicationController
    .
    .
    .
    # DELETE /carts/1
    # DELETE /carts/1.json
    def destroy
        @cart = current_cart
        @cart.destroy
        session[:cart_id] = nil
 
        respond_to do |format|       
            format.html { redirect_to '/' }
            format.json { head :no_content } 
        end
    end
    .
    .
    .
end

Vậy là xong.

Rails – Sử dụng jQuery

jQuery là một thư viện kiểu mới của JavaScript, được tạo bởi John Resig vào năm 2006 với một phương châm tuyệt vời: Write less, do more – Viết ít hơn, làm nhiều hơn. jQuery làm đơn giản hóa việc truyền tải HTML, xử lý sự kiện, tạo hiệu ứng động và tương tác Ajax. (Theo Vietjack)

Trong phần này chúng ta sẽ sử dụng jQuery để đổi màu nền cho giỏ hàng khi thêm mới sản phẩm.

Đầu tiên chúng ta sửa phương thức create của lớp LineItemsController như sau:

class LineItemsController < ApplicationController
    .
    .
    .
    # POST /line_items
    # POST /line_items.json
    def create
        @cart = current_cart
        product = Product.find(params[:product_id])
        @line_item = @cart.add_product(product.id)
 
        respond_to do |format| 
            if @line_item.save
                format.html { redirect_to('/', :notice => 'Line item was successfully created') } 
                format.js   { @current_item = @line_item }
                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
    .
    .
    .
end

Chúng ta truyền thêm biến instance là @current_item có giá trị là biến @line_item đã khai báo ở trên.

Tiếp theo chúng ta sửa file partial _line_item.html.erb trong thư mục app/views/line_items như sau:

<% if @current_item != nil %>
    <% if @current_item.id == line_item.id %>
<tr id="current_item">
    <% else %>
<tr>
    <% end %>
<% end %>
    <td><%= line_item.quantity%>x</td>
    <td><%= line_item.product.title %></td>
    <td class="item_price"><%= number_to_currency(line_item.total_price) %></td>
</tr>

Mục đích của đoạn code trên là gán thuộc tính id có giá trị là "current_item" vào thẻ <tr> nào đang hiển thị sản phẩm vừa được thêm vào.

Mỗi lần file partial này được gọi thì đầu tiên chúng ta kiểm tra xem biến @current_item được truyền qua có phải nil hay không, bởi vì file partial này còn được gọi từ nhiều nơi khác nữa mà biến @current_item không tồn tại. Tiếp theo chúng ta kiểm tra xem biến @currrent_item này có id giống với biến line_item đang được dùng để hiển thị hay không, vì trong một giỏ hàng có nhiều sản phẩm, nhưng sản phẩm vừa được thêm vào thì chỉ có một loại, và nếu trùng id thì chúng ta khai báo thẻ <tr>idcurrent_item, ngược lại thì không có.

Tiếp theo chúng ta sửa file create.js.erb trong thư mục app/views/carts như sau:

var content = "<%= escape_javascript(render(current_cart)) %>";
document.getElementById("cart").innerHTML = content;

$("#current_item").css({'background-color':'#88ff88'});
$("#current_item").animate({backgroundColor:'#114411'}, 1000 );

Chúng ta sử dụng jQuery để tạo hiệu ứng phát sáng màu nền rồi giảm dần trong 1 giây cho thẻ <tr> có thuộc tính idcurrent_item. Nếu bạn chưa từng làm việc với jQuery thì tìm hiểu thêm trên mạng, ở đây mình không giải thích.

Để có thể sử dụng thì chúng ta cần khai báo jQuery trong file application.html.erb như sau:

<!DOCTYPE html>
<html>
<head>
<title>Books Store</title> 
    <script src="https://code.jquery.com/jquery-3.1.1.min.js"></script>
    <script src="https://code.jquery.com/ui/1.12.1/jquery-ui.min.js"></script>
    <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %>
    <%= javascript_include_tag 'application', 'data-turbolinks-track' => true %>
    <%= csrf_meta_tags %>
</head>
<body id="store">
    <div id="banner">
        <%= image_tag("logo.png") %>
        <%= @page_title || "Books Store" %>
    </div>
    <div id="columns">
        <div id="side">
        <div id="cart"> 
            <%= render current_cart %>
        </div>
 
        <a href="#">Home</a><br />
        <a href="#">FAQ</a><br />
        <a href="#">News</a><br />
        <a href="#">Contact</a><br />
    </div>
    <div id="main">
        <%= yield %>
    </div>
    </div> 
</body>
</html>

Kết quả sẽ được như hình sau:

untitled

Rails – AJAX trong Rails

Nếu bạn chưa biết AJAX là gì thì có thể hiểu đơn giản đây là một công nghệ của Javascript, hỗ trợ chúng ta trao đổi dữ liệu giữa server và client (trình duyệt) mà không cần phải gửi các lệnh HTTP thông thường, tức là không cần phải load lại trang web.

Chúng ta sẽ áp dụng AJAX vào nút ‘Add to cart’ để thêm sản phẩm vào giỏ hàng mà không cần phải load lại trang.

Ở phía client, chúng ta sửa lại file index.html.erb trong thư mục app/views/store như sau:

<% if notice %>
<p id="notice"><%= notice %></p>
<% end %>
 
<h1>Product List</h1>
 
<% @products.each do |product| %>
    <div class="entry">
        <%= image_tag(product.image_url) %>
        <h3><%= product.title %></h3> 
        <%= sanitize(product.description) %>
        <div class="price_line">
            <span class="price"><%= number_to_currency(product.price) %></span>
            <%= button_to 'Add to Cart', 
                          line_items_path(:product_id => product),
                          :remote => true %>
        </div>
    </div>
<% end %>

Ở hàm button_to, chúng ta thêm thuộc tính :remotetrue, và khi bấm vào nút thì trình duyệt sẽ gởi một lời gọi AJAX, thay vì một lời gọi HTTP như bình thường.

Nút này vẫn sẽ gửi tới hàm create trong lớp LineItemsController, do đó ở phía server chúng ta sửa lại như sau:

class LineItemsController < ApplicationController
    .
    .
    .
    # POST /line_items
    # POST /line_items.json
    def create
        @cart = current_cart
        product = Product.find(params[:product_id])
        @line_item = @cart.add_product(product.id)
 
        respond_to do |format| 
            if @line_item.save
                format.html { redirect_to('/', :notice => 'Line item was successfully created') }
                format.js 
                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
    .
    .
    .
end

Dòng format.js sẽ làm công việc trả lời cho các lời gọi AJAX, mặc định dữ liệu trả về sẽ được gửi trong một file Javascript có tên <tên_phương_thức_routing>.js.erb trong thư mục app/views/<tên_controller>, tức là ở đây sẽ trả về file create.js.erb trong thư mục app/views/line_items.

Do đó chúng ta tạo file create.js.erb có nội dung như sau:

var content = "<%= escape_javascript(render(current_cart)) %>";
document.getElementById("cart").innerHTML = content;

Nếu bạn đã từng làm việc với Javascript thì đoạn code trên cũng không có gì khó hiểu, chỉ khác là chúng ta có thể gọi thêm cả code ERB của Rails trong này. Ở đây chúng ta thay thế nội dung trong thẻ có idcart với biến đã được khai báo là content. Biến này chúng ta truyền vào giá trị lấy từ hàm render current_cart.

Vậy là xong, bây giờ nút ‘Add to cart’ sẽ gửi các lời gọi AJAX thay cho lời gọi HTTP bình thường. Bạn có thể kiểm tra bằng cách để ý icon trên tab của trình duyệt không hiển thị thành vòng tròn xoay, hoặc rõ hơn là lời nhắn thêm sản phẩm thành công của biến :notice'Line item was successfully created' không hiện ra khi chúng ta thêm sản phẩm mới.

Không có AJAX:

bbcapture

Có AJAX:

cc

Rails – Hàm render

Hiện tại thì trang /carts/<id> sẽ hiển thị giỏ hàng đang được lưu trong session của trình duyệt người dùng bằng phương thức show trong lớp CartController, phương thức này sẽ “vẽ” những gì được định nghĩa trong file show.html.erb trong thư mục views/carts. 

Trong file này chúng ta “vẽ cứng” cách hiển thị thông tin giỏ hàng như sau:

<%= notice %>
<h2>Your cart</h2>
<table>
    <% @cart.line_items.each do |item| %>
        <tr>
            <td><%= item.quantity %> x </td>
            <td><%= item.product.title %></td>
            <td class="item_price"><%= number_to_currency(item.total_price) %></td>
        </tr>
    <% end %>
    
    <tr class="total_line">
        <td colspan="2">Total</td>
        <td class="total_cell"><%= number_to_currency(@cart.total_price) %></td>
    </tr>
</table>
 
<%= button_to 'Empty cart', 
              @cart, 
              :method => :delete,
              data: {:confirm => 'Are you sure?'} %>

Tức là đoạn <table>...</table> được code trực tiếp trong file này, trong phần này chúng ta sẽ tách đoạn này ra lưu trong một file khác rồi gọi đến file đó khi cần, bạn có thể hình dung nó giống như là một layout thu nhỏ vậy.

Để có thể gọi đến đoạn code được tách ra thì chúng ta dùng hàm helper có tên là render, hàm này có thể hiển thị một file template (file .html.erb), chuỗi dữ liệu, XML, JSON…v.v

Đầu tiên chúng ta sửa file show.html.erb ở trên như sau:

<%= render @cart %>

Bây giờ file này chỉ có một dòng duy nhất là lời gọi hàm render đến biến @cart, đây là biến lưu trữ thông tin của giỏ hàng được truyền lên từ tham số trong URL /carts/<id>, Rails sẽ tự hiểu là hiển thị đoạn code có trong file _cart.html.erb trong thư mục views/carts, tức là cứ truyền vào một đối tượng thì Rails sẽ gọi đến file có tên dạng _<tên lớp đối tượng>.html.erb (chú ý có dấu gạch dưới), ngoài ra nếu tên đối tượng truyền vào có kí tự ‘s’ ở cuối tên (số nhiều trong tiếng Anh) thì tên file cũng bỏ kí tự này luôn, tức là nếu chúng ta truyền vào @carts thì file này vẫn là _cart.html.erb. Các file như này có tên gọi chung là file partialcác file này Rails không tự tạo nên chúng ta phải tạo bằng tay.

Bây giờ chúng ta tạo file partial _cart.html.erb có nội dung như sau:

<h2>Your cart</h2>
<table>
    <%= render cart.line_items %>
    <tr class="total_line">
        <td colspan="2">Total</td>
        <td class="total_cell"><%= number_to_currency(cart.total_price) %></td>
    </tr>
</table>

<%= button_to 'Empty cart', 
              cart, 
              :method => :delete,
              data: {:confirm => 'Are you sure?' } %>

Vẫn là đoạn code cũ, chỉ có khác một chỗ là đoạn hiển thị danh sách các sản phẩm cùng với số lượng và tổng tiền của chúng thì chúng ta lại gọi một hàm render đến một file partial khác, ở đây chúng ta truyền vào biến cart.line_items. Khi biến @cart được truyền vào file _cart.html.erb, chúng ta tham chiếu đến biến này với cái tên trùng với file partial này, tức là _cart.html.erb thì chúng ta tham chiếu tới biến cart, không có dấu '@' nữa.

Và ở đây chúng ta truyền vào hàm render đối tượng cart.line_items, đây là một đối tượng lưu trữ dạng danh sách/tập hợp nhiều phần tử, cũng tương tự như trên, chỉ khác là Rails sẽ lặp qua danh sách đó, với mỗi lần lặp thì Rails lại gọi file partial tương ứng là _line_item.html.erb. Do đây là các đối tượng thuộc lớp LineItem nên file partial cũng nằm trong thư mục line_items cho dù có hàm gọi nó ở bên thư mục khác.

Bây giờ chúng ta tạo file _line_item.html.erb trong thư mục app/views/line_items như sau:

<tr>
    <td><%= line_item.quantity%>x</td>
    <td><%= line_item.product.title %></td>
    <td class="item_price"><%= number_to_currency(line_item.total_price) %></td>
</tr>

Chúng ta hiển thị số tiền, tên sản phẩm và tổng tiền như bình thường.

Bạn có thể chạy lại và thấy giao diện vẫn hiển thị như cũ, chỉ khác là bây giờ các phần được hiển thị ở các file khác nhau chứ không được code “cứng” trong một file nữa.

aacapture

Và để tận dụng khả năng phân tách này thì chúng ta hiển thị giỏ hàng ở bên sidebar của layout luôn. Chúng ta sửa lại file application.html.erb như sau:

<!DOCTYPE html>
<html>
<head>
<title>Books Store</title>
    <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %>
    <%= javascript_include_tag 'application', 'data-turbolinks-track' => true %>
    <%= csrf_meta_tags %>
</head>
<body id="store">
    <div id="banner">
        <%= image_tag("logo.png") %>
        <%= @page_title || "Books Store" %>
    </div>
    <div id="columns">
        <div id="side">
            <div id="cart">
                <%= render @cart %>
            </div>

            <a href="#">Home</a>
            <a href="#">FAQ</a>
            <a href="#">News</a>
            <a href="#">Contact</a>
        </div>
        <div id="main">
            <%= yield %>
        </div>
    </div>
</body>
</html>

Ở đây chúng ta cũng gọi hàm render với tham số là biến @cart để hiển thị từ file _cart.html.erb. Tuy nhiên khác với lời gọi từ file show.html.erb là có kèm theo tham số sau URL là /carts/<id>, ở đây lời gọi tại file application.html.erb sẽ gọi phương thức index trong lớp StoreController, phương thức này đã được cấu hình để nhận URL '/' nếu bạn còn nhớ, và phương thức này không nhận tham số nào cả, do đó trong phương thức này chúng ta phải tạo một đối tượng @cart để sử dụng, chúng ta sửa lại file này như sau:

class StoreController < ApplicationController
    def index
        @products = Product.all
        @cart = current_cart
    end
end

Tiếp theo chúng ta định nghĩa thêm một vài lớp CSS:

.
.
.
/* Cart sidebar Style */
#cart, #cart table {
    font-size: smaller;
    color: white;
} 

#cart table {
    border-top: 1px dotted #595;
    border-bottom: 1px dotted #595;
    margin-bottom: 10px;
}

Bây giờ mỗi khi bấm nút ‘Add to Cart’ trên sản phẩm thì chúng ta sẽ được trỏ về trang /line_items?id=<id>, chúng ta sửa lại phương thức create trong lớp LineItemsController để hàm này trỏ lại trang '/' như sau:

class LineItemsController < ApplicationController
    .
    .
    .
    # POST /line_items
    # POST /line_items.json
    def create
        @cart = current_cart
        product = Product.find(params[:product_id]) 
        @line_item = @cart.add_product(product.id)
 
        respond_to do |format|
            if @line_item.save
                format.html { redirect_to('/', :notice => 'Line item was successfully created') } 
                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
    .
    .
    .
end

Vậy là xong, bây giờ website của chúng ta sẽ có giao diện như thế này:

bbbbcapture

Tuy nhiên còn một vấn dề nhỏ nữa, nếu bạn trỏ vào các trang như /carts, /products, /line_items thì bạn sẽ bị lỗi biến @cart dùng để gọi render bên sidebar không tồn tại (có giá trị nil).

capture

Lý do là vì chúng ta chỉ định nghĩa biến này ở hàm index trong lớp StoreController thôi, để sửa thì bạn có thể vào từng phương thức index của từng lớp CartsController, ProductsControllerLineItemsController và chèn thêm dòng khai báo @cart = current_cart là sẽ hết lỗi, tuy nhiên cách này rất “cùi bắp” vì cứ mỗi lần tạo thêm controller thì chúng ta lại phải thêm một dòng như thế.

Do đó có một cách khác là chúng ta khai báo phương thức current_cart là một phương thức helper, tức là phương thức này sẽ có thể gọi được từ các file View, để làm điều này thì chúng ta thêm dòng khai báo sau phương thức current_cart như sau:

class ApplicationController < ActionController::Base
    # Prevent CSRF attacks by raising an exception.
    # For APIs, you may want to use :null_session instead.
    protect_from_forgery with: :exception
 
    private
 
    def current_cart
        Cart.find(session[:cart_id])
    rescue
        cart = Cart.create
        session[:cart_id] = cart.id
        cart
    end
 
    helper_method :current_cart
end

Phương thức helper_method nhận vào tên các phương thức (cách nhau bởi dấu phẩy), phương thức nào được truyền vào thì sẽ được định nghĩa là một phương thức helper.

Và chúng ta có thể gọi đến phương thức này trong file View, chúng ta sửa lại file application.html.erb như sau:

<!DOCTYPE html>
<html>
<head>
<title>Books Store</title>
    <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %>
    <%= javascript_include_tag 'application', 'data-turbolinks-track' => true %>
    <%= csrf_meta_tags %>
</head>
<body id="store">
    <div id="banner">
        <%= image_tag("logo.png") %>
        <%= @page_title || "Books Store" %>
    </div>
    <div id="columns">
        <div id="side">
            <div id="cart">
                <%= render current_cart %>
            </div>

            <a href="#">Home</a>
            <a href="#">FAQ</a>
            <a href="#">News</a>
            <a href="#">Contact</a>
        </div>
        <div id="main">
            <%= yield %>
        </div>
    </div>
</body>
</html>

Vậy là hết lỗi! Dòng @cart = current_cart trong phương thức index của lớp StoreController bây giờ trở nên vô nghĩa, bạn có thể bỏ đi nếu muốn.

Rails – Tùy chỉnh giỏ hàng

Chúng ta sẽ thêm nút xóa giỏ hàng, hiển thị tổng tiền cho giỏ hàng.

Thêm nút xóa giỏ hàng

Đầu tiên chúng ta sửa lại file View show.html.erb trong thư mục app/views/carts như sau:

<%= notice %>
<h2>Your cart</h2>
<ul>
    <% @cart.line_items.each do |a| do %>
        <li><%= a.quantity %> x <%= a.product.title %></li>
    <% end %>
</ul>

<%= button_to 'Empty cart', 
              @cart, 
              :method => :delete,
              data: {:confirm => 'Are you sure?'} %>

Chúng ta gọi phương thức button_to để tạo một nút bấm với nhãn là ‘Empty cart’, URL chúng ta lấy từ biến @cart, nếu muốn bạn vẫn có thể dùng các phương thức như cart_path(), phương thức gửi lên là DELETE, tham số :data với thuộc tính :confirm sẽ tạo ra một hộp thoại như hàm alert() trong Javascript nếu bạn còn nhớ.

Ngoài ra chúng ta cũng đã xóa 2 nút EditBack do Rails tự tạo nếu bạn còn nhớ:

<%= link_to 'Edit', edit_cart_path(@cart) %> |
<%= link_to 'Back', carts_path %>

Nút ‘Empty cart’ sẽ gọi đến phương thức destroy trong lớp controller CartsController, trong trang /cart Rails cũng đã tạo cho chúng ta một nút như vậy với nhãn là ‘Destroy’ (nếu muốn bạn có thể vào xem thử), và mặc định phương thức destroy trong lớp Controller sẽ xóa giỏ hàng với id được truyền từ tham số, tuy nhiên ở đây chúng ta muốn phương thức này chỉ xóa giỏ hàng nào đang được lưu trong session của trình duyệt thôi, do đó chúng ta sửa lại phương thức destroy này như sau:

class CartsController < ApplicationController 
    .
    .
    .
    # DELETE /carts/1
    # DELETE /carts/1.json
    def destroy 
        @cart = current_cart
        @cart.destroy
        session[:cart_id] = nil
 
        respond_to do |format|
            format.html { redirect_to carts_url, notice: 'Cart was successfully destroyed.' }
            format.json { head :no_content }
        end
    end
    .
    .
    .
end

Chúng ta lấy model Cart hiện tại bằng phương thức current_cart đã được định nghĩa trong lớp ApplicationController, sau đó gọi hàm destroy để xóa, cuối cùng gán giá trị trong sessionnil.

Hiển thị tổng số tiền trong giỏ hàng

Chúng ta sửa lại file View show.html.erb một lần nữa như sau:

<%= notice %>
<h2>Your cart</h2>
<table>
    <% @cart.line_items.each do |item| %>
        <tr>
            <td><%= item.quantity %> x </td>
            <td><%= item.product.title %></td>
            <td class="item_price"><%= number_to_currency(item.total_price) %></td>
        </tr>
    <% end %>
    
    <tr class="total_line">
        <td colspan="2">Total</td>
        <td class="total_cell"><%= number_to_currency(@cart.total_price) %></td>
    </tr>
</table>

<%= button_to 'Empty cart', 
              @cart, 
              :method => :delete,
              data: {:confirm => 'Are you sure?'} %>

Ở đây thay vì dùng các thẻ <ul>, <li> để hiển thị danh sách thì chúng ta sẽ dùng bảng. Giá trị của tổng số tiền trên từng LineItem và tổng số tiền của Cart sẽ lấy từ phương thức total_price của lớp model tương ứng.

Các thẻ trên cũng dùng một số lớp CSS riêng, chúng ta định nghĩa các lớp CSS mới như sau:

.
.
.
#store .cart_title {
    font: 120% bold;
}

#store .item_price, #store .total_line {
    text-align: right;
}

#store .total_line .total_cell {
    font-weight: bold;
    border-top: 1px solid #595;
}

Và bây giờ chúng ta sẽ định nghĩa phương thức total_price cho từng model, đầu tiên chúng ta định nghĩa cho lớp LineItem như sau:

class LineItem < ActiveRecord::Base
    belongs_to :product
    belongs_to :cart
 
    def total_price
        product.price * quantity
    end
end

Rất đơn giản, số tiền bằng tổng số sản phẩm nhân với đơn giá sản phẩm.

Tiếp theo là lớp Cart:

class Cart < ActiveRecord::Base 
    has_many :line_items, :dependent => :destroy
 
    def add_product(product_id)
        current_item = line_items.find_by_product_id(product_id)
        if current_item 
            current_item.quantity += 1
        else
            current_item = line_items.build(:product_id => product_id)
        end
        current_item
    end
 
    def total_price 
        line_items.to_a.sum { |item| item.total_price }
    end
end

Ở đây số tiền sẽ bằng tổng số tiền của các đối tượng LineItem cộng lại. Phương thức to_a sẽ chuyển một đối tượng danh sách sang kiểu mảng, sau đó chúng ta gọi phương thức sum của mảng đó.

Bạn có thể xem tổng tiền và có thể xóa giỏ hàng được rồi:

capture

Ngoài ra nếu trước khi làm chức năng session mà bạn đã từng tạo nhiều giỏ hàng thì trong trang /carts (liệt kê danh sách giỏ hàng), trang này gọi đến phương thức index trong lớp CartsController, chúng ta sẽ thấy có nhiều dòng giỏ hàng, đây là trang hiển thị mặc định của Rails mà đến bây giờ chúng ta vẫn chưa chỉnh sửa gì trong này.

capture

Trang này có hiển thị nút ‘Destroy’ để bạn xóa giỏ hàng, tuy nhiên sau khi làm chức năng chỉ xóa giỏ hàng hiện tại ở trên thì bạn chỉ có thể xóa một giỏ hàng trong danh sách đó thôi, mặc dù nếu muốn bạn vẫn có thể bấm nút xóa trên các giỏ hàng khác và Rails vẫn thông báo xóa thành công, nhưng khi load lại trang thì bạn vẫn thấy các giỏ hàng đó tồn tại. Chúng ta sửa lại để hàm này chỉ hiển thị giỏ hàng trong session của trình duyệt như sau:

class CartsController < ApplicationController
    .
    .
    .
    # GET /carts
    # GET /carts.json
    def index  
        @carts = []
        if session[:cart_id] != nil 
            @carts << Cart.find_by_id(session[:cart_id])
        end
    end
    .
    .
    .
end

Vậy là xong, bây giờ nếu bạn mở 2 trình duyệt hoặc mở 2 máy khác nhau, và mỗi trình duyệt đều có giỏ hàng riêng, và cùng trỏ đến trang /carts thì bạn chỉ thấy giỏ hàng của từng trình duyệt đó chứ không thấy giỏ hàng của trình duyệt khác.

Rails – Migration

Trong bài trước chúng ta đã xây dựng tính năng giỏ hàng, tuy nhiên chưa xử lý được thông minh lắm, giả sử bạn bấm nút ‘Add to Cart’ của một sản phẩm nhiều lần, thì khi hiển thị trên trang /carts/<id>, danh sách các sản phẩm được hiển thị ra hết trên từng dòng, có 100 sản phẩm thì có 100 dòng, cho dù chúng cùng là một thứ, bây giờ chúng ta sẽ thiết kế để in số lượng sản phẩm ngay bên cạnh tên sản phẩm luôn, chứ không cần phải in trên nhiều dòng như thế.

Để làm việc này thì chúng ta cần thêm một cột/trường mới để lưu số lượng sản phẩm trong model/bảng LineItem. Tuy nhiên việc thay thế cấu trúc các bảng trong CSDL như thêm, xóa, sửa cột sẽ gây ra nhiều rắc rối (đặc biệt là các cột khóa ngoại, khóa chính…v.v), chúng ta có thể “bảo” Rails thay thế giùm chúng ta bằng tính năng Migration của Rails.

Mỗi khi chạy migration, Rails sẽ tạo ra một phiên bản CSDL mới dựa vào các migration mà chúng ta đã tạo ra và cập nhật lại.

Migration trong Rails là các file .rb có tên tương tự như <YYYYMMDDHHMMSS>_create_products.rb, bên trong là phần định nghĩa một lớp có tên như CreateProducts được kế thừa từ lớp ActiveRecord::Migration, và một phương thức có tên là change dùng để thêm/xóa/sửa cột trong bảng. Phần YYYYMMDDHHSS là thời gian file này được tạo ra, Rails sẽ dựa vào chuỗi này để biết phần nào được cập nhật trước.

Ví dụ chúng ta thực hiện việc thêm trường quantity dùng để lưu số lượng sản phẩm trong bảng line_items bằng cách chạy lệnh rails generate migration như sau:

C:\Projects\Rails\depot>rails generate migration add_quantity_to_line_items quantity:integer
    invoke active_record
    create   db/migrate/20161123043154_add_quantity_to_line_items.rb

Chúng ta tạo một migration có tên add_quantity_to_line_items, Rails sẽ tạo một file có tên dạng như 20161123043154_add_quantity_to_line_items.rb trong thư mục db/migrate, bên trong là định nghĩa lớp AddQuantityToLineItems. Nếu lớp migration của bạn có tên dạng như Add<X>To<Y> hoặc Remove<X>From<Y> thì Rails sẽ tự động hiểu là tạo thêm trường X vào bảng Y hoặc xóa trường X trong bảng Y, và tự động thêm các đoạn code thích hợp trong phương thức change.

Ở đây Rails sẽ hiểu là thêm một trường có tên là quantity vào bảng line_items, và tự biết là trường này có kiểu dữ liệu là INTEGER do chúng ta truyền vào trong lệnh generate migration ở trên. Nội dung của file 20161123043154_add_quantity_to_line_items.rb hiện tại như sau:

class AddQuantityToLineItems < ActiveRecord::Migration
    def change
        add_column :line_items, :quantity, :integer
    end 
end

Bên trong phương thức change chỉ có một dòng duy nhất là lời gọi phương thức add_column, bạn có thể tự biết đây là phương thức thêm một cột mới trong bảng, thêm số đầu tiên là tên bảng, thâm số thứ 2 là tên cột, tham số thứ 3 là kiểu dữ liệu, các kiểu dữ liệu khác có thể đưa vào là :string, :text, :float, :decimal, :datetime, :timestamp, :time, :date, :binary, :boolean. Ngoài ra còn có một tham số thứ 4 nữa mà chúng ta có thể đưa vào nếu muốn, đây là tham số tùy chọn, chúng ta sửa lại để cột này nhận giá trị mặc định là 1 như sau:

class AddQuantityToLineItems < ActiveRecord::Migration 
    def change 
        add_column :line_items, :quantity, :integer, :default => 1
    end
end

Các tham số tùy chọn khác có thể đưa vào là :limit (giới hạn) và :null (cột có được phép để NULL hay không), khi truyền nhiều tham số tùy chọn thì chúng ta truyền vào kiểu bảng băm ví dụ như: { :limit => 50, :null => false, :default => 1 }.

Ngoài ra còn có một số phương thức khác như:

  • create_table(name, options): tạo bảng với tên là name
  • drop_table(name): xóa bảng với tên là name
  • change_table(name, options): thay đổi cấu trúc bảng name
  • rename_table(oldname, newname): đổi tên bảng oldname thành newname
  • rename_column(table, oldname, newname): đổi tên cột oldname thành newname trong bảng table
  • change_column(table, column, options): thay đổi cột column trong bảng table
  • remove_column(table, column, type, options): xóa cột column khỏi bảng table

Để thực hiện thay đổi CSDL thì chúng ta chạy lệnh rake db:migrate là được

C:\Projects\Rails\depot>rake db:migrate
== 20161123043154 AddQuantityToLineItems: migrating ===================================
-- add_column(:line_items, :quantity, :integer, {:default=>1})
   -> 0.0205s
== 20161123043154 AddQuantityToLineItems: migrated (0.0216s) ==========================

Bây giờ chúng ta cần chỉnh sửa lại cách model tạo giỏ hàng cho đúng với CSDL mới.

Đầu tiên chúng ta sửa lại file cart.rb trong thư mục app/models như sau.

class Cart < ActiveRecord::Base 
    has_many :line_items, :dependent => :destroy
 
    def add_product(product_id)
        current_item = line_items.find_by_product_id(product_id)
        if current_item 
            current_item.quantity += 1
        else
            current_item = line_items.build(:product_id => product_id)
        end
        current_item
    end
end

Chúng ta định nghĩa phương thức add_product nhận vào id của sản phẩm để tạo hoặc chỉnh sửa đối tượng LineItems. 

Đầu tiên chúng ta tìm xem trong dách sách các đối tượng LineItems (trong biến line_items) có đối tượng nào có product_id trùng với product_id trong tham số hay không bằng cách dùng phương thức find_by_product_id, phương thức này mặc định không có trong lớp ActiveRecord::Base, nhưng chúng ta vẫn có thể sử dụng được, đó là nhờ vào tính năng Dynamic Finders (đọc thêm ở đây) trong lớp này, theo đó thì trong model/bảng của bạn có thuộc tính nào thì bạn có thể gọi một phương thức có tên find_by_<tên cột>và truyền vào tham số là một giá trị có cùng kiểu dữ liệu với cột đó, ở đây chúng ta truyền vào một số nguyên là id của Product.

Sau đó nếu tìm thấy đối tượng LineItems nào thì chúng ta tăng giá trị của thuộc tính quantity lên 1, nếu không thì chúng ta tạo một đối tượng LineItems mới. Cuối cùng chúng ta trả về đối tượng LineItems đó.

Tiếp theo chúng ta sửa lại phương thức create của lớp controller trong file line_items_controller.rb như sau:

class LineItemsController < ApplicationControler
    .
    .
    .
    # POST /line_items
    # POST /line_items.json
    def create
        @cart = current_cart
        product = Product.find(params[:product_id])
        @line_item = @cart.add_product(product.id)

        respond_to do |format|    
            if @line_item.save
                format.html { redirect_to(@line_item.cart, :notice => 'Line item was successfully created') }
                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
    .
    .
    .
end

Đối tượng LineItems sẽ được tạo bằng phương thức add_product đã được định nghĩa ở trên.

Cuối cùng chúng ta sửa lại View để hiển thị số lượng như sau:

 
<%= notice %>
<h2>Your cart</h2>
<ul>
    <% @cart.line_items.each do |a| %>
    <li><%= a.quantity %> x <%= a.product.title %></li>
    <% end %>
</ul>
<%= link_to 'Edit', edit_cart_path(@cart) %> |
<%= link_to 'Back', carts_path %>

Vậy là xong, chúng ta có thể chạy được rồi.

capture

Rails – Tạo nút thêm giỏ hàng

Trong các bài trước chúng ta đã xây dựng cơ chế session và tạo mối quan hệ giữa Product và Cart, bây giờ chúng ta sẽ xây dựng chức năng tạo giỏ hàng.

Đầu tiên chúng ta sửa lại file index.html.erb trong thư mục app/views/store như sau:

<% if notice %>
    <p id="notice"><%= notice %></p>
<% end %>
 
<h1>Product List</h1>
 
<% @products.each do |product| %>
    <div class="entry">
        <%= image_tag(product.image_url) %>
        <h3><%= product.title %></h3> 
        <%= sanitize(product.description) %>
        <div class="price_line">
            <span class="price"><%= number_to_currency(product.price) %></span> 
            <%= button_to 'Add to Cart', line_items_path(:product_id => product) %>
        </div>
    </div>
<% end %>

Phương thức button_to sẽ tạo một thẻ <form></form> với class là button_to, bên trong có một thẻ <input> với thuộc tính typeSubmit, nhãn là tham số do chúng ta truyền vào, ở đây là chuỗi ‘Add to Cart’, phương thức mặc định là POST, URL chuyển đến là tham số thứ 2, ở đây là chuỗi được tạo ra từ phương thức helper line_items_path(), phương thức này được Rails định nghĩa tự động cho từng controller và sẽ trả về URL của lớp controller tương ứng, ví dụ như line_items_path() trả về /line_item, cart_path() trả về /cart…v.v Phương thức này còn có thể nhận vào tham số và tự gắn vào đuôi như URL bình thường, ở đây chúng ta định nghĩa tham số :product_id và truyền vào giá trị của biến product, tức là id của sản phẩm, và URL cuối cùng sẽ có dạng như /line_item?product_id=1, /line_item?product_id=2…v.v

Và khi chúng ta click vào nút đó thì một trong số các phương thức routing của lớp LineItemsController sẽ được gọi:

class LineItemsController < ApplicationController
    before_action :set_line_item, only: [:show, :edit, :update, :destroy]

    # GET /line_items
    # GET /line_items.json
    def index
        @line_items = LineItem.all
    end

    # GET /line_items/1
    # GET /line_items/1.json
    def show 
    end

    # GET /line_items/new
    def new    
    end

    # GET /line_items/1/edit
    def edit
    end

    # POST /line_items
    # POST /line_items.json
    def create   
        @line_item = LineItem.new(line_item_params)

        respond_to do |format|
            if @line_item.save
                format.html { redirect_to @line_item, notice: 'Line item was successfully created.' }
                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

    # PATCH/PUT /line_items/1
    # PATCH/PUT /line_items/1.json
    def update
        respond_to do |format|
            if @line_item.update(line_item_params)
                format.html { redirect_to @line_item, notice: 'Line item was successfully updated.' }
                format.json { render :show, status: :ok, location: @line_item }
            else
                format.html { render :edit }
                format.json { render json: @line_item.errors, status: :unprocessable_entity }
            end
        end
    end

    # DELETE /line_items/1
    # DELETE /line_items/1.json
    def destroy
        @line_item.destroy
        respond_to do |format|
            format.html { redirect_to line_items_url, notice: 'Line item was successfully destroyed.' }
           format.json { head :no_content }
        end
    end

private
    # Use callbacks to share common setup or constraints between actions.
    def set_line_item
        @line_item = LineItem.find(params[:id])
    end

# Never trust parameters from the scary internet, only allow the white list through.
    def line_item_params
        params.require(:line_item).permit(:product_id, :cart_id)
    end
end

Có 2 phương thức có URL giống nhau (đều là /line_items) là indexcreate, tuy nhiên phương thức được gọi sẽ là create, bởi vì chỉ có create mới dùng phương thức POST. 

Tuy nhiên phương thức này đang chạy theo code mặc định của Rails, tức là tạo đối tượng LineItems được gửi lên từ form ở /line_items/new, bạn có thể trỏ vào dùng thử.

Chúng ta sửa lại phương thức create để lấy từ nút button do chúng ta định nghĩa ở trên như sau:

class LineItemsController < ApplicationControler
    .
    .
    .
    # POST /line_items
    # POST /line_items.json
    def create
        @cart = current_cart
        product = Product.find(params[:product_id])
        @line_item = @cart.line_items.build(:product => product)

        respond_to do |format|    
            if @line_item.save
                format.html { redirect_to(@line_item.cart, :notice => 'Line item was successfully created') }
                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
    .
    .
    .
end

Đầu tiên chúng ta tạo hoặc lấy một đối tượng Cart trong session của người dùng từ phương thức current_cart được định nghĩa trong lớp ApplicationController (application_controller.rb).

Sau đó chúng ta dùng id được truyền vào tham số :product_id để lấy đối tượng Product cụ thể, tham số :product_id được gửi vào biến params, đây là biến được tạo ra khi chúng ta bấm vào nút ‘Add to Cart’ đã định nghĩa ở trên.

Trong đối tượng @cart ở trên có đối tượng line_items, đây là một đối tượng lưu trữ các phần tử theo dạng danh sách thuộc lớp ActiveRecord::Associations::CollectionProxy, lớp này có một phương thức là build() dùng để tạo một phần tử cho danh sách của nó. Chúng ta gọi phương thức này để tạo một đối tượng LineItem mới, có tham số truyền vào là id của Product (:product_id) rồi lưu vào biến @line_item.

Tiếp theo là đoạn respond_to dùng để trả nội dung về cho người dùng, từ trước đến giờ chúng ta vẫn chưa tìm hiểu sâu về các đoạn respond_to cũng như đối tượng format, trong bài này chúng ta cũng sẽ chưa đi vào tìm hiểu làm gì, mình sẽ giải thích ở các bài sau, chỉ có một lưu ý là chúng ta gọi hàm redirect_to đến URL /cart/<id> dựa vào biến @line_item.cart, thay vì trả về /line_items.

Chúng ta có thể chạy thử được rồi, bấm vào một nút ‘Add to Cart’ để thêm một sản phầm vào giỏ hàng.

capture

Chúng ta có thể liệt kê danh sách các sản phẩm trong giỏ hàng ngay tại trang /cart/<id> đó luôn bằng cách sửa file show.html.erb trong thư mục app/views/carts như sau:

<p id="notice"><%= notice %></p>
<h2>Your cart</h2>
<ul>
    <% @cart.line_items.each do |a| %>
    <li><%= a.product.title %></li>
    <% end %>
</ul>
<%= link_to 'Edit', edit_cart_path(@cart) %> |
<%= link_to 'Back', carts_path %>

capture

Rails – Quan hệ giữa model

Nếu bạn đã từng học lý thuyết về cơ sở dữ liệu quan hệ thì bạn sẽ biết là các bảng trong một CSDL sẽ có thể có các mối quan hệ với nhau, như quan hệ 1-1, 1-n, n-n…v.v, nếu bạn chưa biết về lý thuyết quan hệ này thì tự tìm hiểu thêm, trong bài này mình không giải thích mà chỉ hướng dẫn cách mô tả các mối quan hệ này trong Rails.

Trong các bài trước chúng ta đã tạo một model là product dùng để lưu thông tin của từng sản phẩm, model cart có chức năng lưu thông tin của một giỏ hàng (mặc dù không có trường nào), bây giờ chúng ta sẽ tạo một model dùng để kết nối thông tin giữa sản phẩm và giỏ hàng.

Model đó sẽ có tên line_item, chúng ta tạo model như sau:

C:\Projects\Rails\depot>rails generate scaffold line_item product_id:integer cart_id:integer
...

Model này sẽ có 2 cột dùng để lưu id của cart và product là product_idcart_id.

Tiếp theo chúng ta chạy lệnh rake db:migrate để tạo bảng.

C:\Projects\Rails\depot>rake db:migrate
== 20161121012142 CreateLineItems: migrating ==========================================
-- create_table(:line_items)
   -> 0.0150s
== 20161121012142 CreateLineItems: migrated (0.0158s) =================================

Vậy là chúng ta đã tạo xong bảng trong CSDL và lớp model, tuy nhiên hiện tại model lại không biết gì về các quan hệ này, chúng ta phải gọi thêm một số phương thức nữa, các phương thức mà chúng ta sẽ dùng được gọi là Associations.

Chúng ta sửa file cart.rb trong thư mục app/models như sau:

class Cart < ActiveRecord::Base
    has_many :line_items, :dependent => :destroy
end

Phương thức has_many :line_items chỉ định một đối tượng Cart sẽ có nhiều liên kết tới đối tượng LineItem, tham số :dependent => :destroy cho biết khi một đối tượng Cart bị hủy thì đối tượng LineItem đó cũng sẽ bị hủy theo.

TIếp theo chúng ta sửa file model line_item.rb như sau:

class LineItem < ActiveRecord::Base
    belongs_to :product
    belongs_to :cart
end

Phương thức belongs_to cho biết một đối tượng LineItem sẽ “thuộc về” một đối tượng nào đó, ở đây là ProductCart. Phương thức này bắt buộc Rails phải “hiểu” rằng sẽ không bao giờ tồn tại một đối tượng LineItem nào mà không liên kết/thuộc về một đối tượng CartProduct nào đó.

Khi một đối tượng LineItem “thuộc về” một đối tượng ProductCart thì chúng ta có thể truy xuất các đối tượng ProductCart đó trong đối tượng LineItem.

Cuối cùng chúng ta sửa file model product.rb như sau:

class Product < ActiveRecord::Base
    validates :title, :description, :image_url, :presence => true
    validates :price, :numericality => {:greater_than_or_equal_to => 1.0}
    validates :title, :uniqueness => true
    validates :image_url, :format => {
        :with => %r{\.(gif|jpg|png)\Z}i,
        :message => 'Chi nhan file GIF, JPG, PNG'
    }
 
    has_many :line_items
    before_destroy :check_if_has_line_item
 
private
 
    def check_if_has_line_item
        if line_items.empty?
            return true
        else
            errors.add(:base, 'This product has a LineItem')
            return false
        end
    end
end

Tương tự với model Cart, một đối tượng Product cũng sẽ có nhiều liên kết tới một đối tượng LineItem.

before_destroy :check_if_has_line_item

Ngoài ra ở đây chúng ta còn sử dụng tới một hàm Callback của lớp ActiveRecord::Basebefore_destroy, hàm Callback ở đây là các hàm sẽ được gọi trước hoặc sau khi Rails thực hiện một thao tác có cập nhật lên CSDL, chẳng hạn như before_destroy tức là gọi một phương thức nào đó trước khi hủy một dòng trong bảng, ở đây là phương thức check_if_has_line_item do chúng ta tự định nghĩa, phương thức này chúng ta để phạm vi truy cập là private.

private
 
    def check_if_has_line_item
        if line_items.empty?
            return true
        else
            errors.add(:base, 'This product has a LineItem')
            return false
        end
    end

Phương thức này sẽ kiểm tra xem một đối tượng Product có liên kết nào tới một đối tượng LineItem nào không, nếu không thì trả về true, ngược lại thì chúng ta tạo một phần tử trong thuộc tính errors có khóa là :base và giá trị là 'This product has a LineItem', rồi trả về false.

Nếu phương thức ở before_destroy trả về false thì Rails sẽ không hủy đối tượng đó.

Bạn có thể tìm hiểu thêm về các hàm callback ở đây:

http://guides.rubyonrails.org/active_record_callbacks.html

Bạn có thể tìm hiểu thêm về các phương thức Associations ở đây:

http://guides.rubyonrails.org/association_basics.html