Nested comments rails

Bình luận là ở khắp mọi nơi. Blog, mạng xã hội, các trang web fan hâm mộ, các nguồn tài nguyên học tập - tất cả đều có một số loại của một hệ thống nhận xét. Thường thì chúng tôi muốn trình bày tùy chọn người dùng của chúng tôi để cả hai để lại nhận xét và trả lời là tốt. Cách tự nhiên nhất để đại diện trả lời là để lồng ghép (giống như "Russian doll").

Bài viết dưới đây sẽ cho bạn thấy làm thế nào để thực hiện bình luận lồng nhau trong một ứng dụng Ruby on Rails với sự giúp đỡ của gem "closure_tree". Bên cạnh đó, bài viết cũng sẽ cung cấp thêm một số tính năng của gem cung cấp.

Chuẩn bị project Ruby on Rails

Bài viết này được viết cho rails phiên bản 4. tuy nhiên vẫn có thể sử dụng tương tự cho phiên bản 3.

Chúng tôi sẽ tạo ra một ứng dụng đơn giản cho phép người dùng mở chủ đề thảo luận mới, cũng như để lại ý kiến (bình luận) của mình về những người đã được mở ra. Trong cấp đầu tiên, ứng dụng này sẽ chỉ trình bày một tùy chọn để bắt đầu một chủ đề mới, trong khi cấp thứ hai sẽ thêm làm nested.

Tạo 1 project không có test mặc định:

$ rails new nested_comments -T

Chúng ta sẽ thêm 2 gem sau đây vào Gemfile:

gem "closure_tree"
gem "bootstrap-sass"

Về cơ bản, tôi sử dụng Twitter Bootstrap đối với một số style cơ bản - bạn có thể sử dụng bất kỳ khung CSS khác hoặc tạo ra thiết kế của bạn từ đầu.

Gem closure_tree được tạo ra bởi Matthew McEachen, sẽ giúp chúng ta tạo nesting cho model Comment. Bên cạnh đó cũng có 1 số gem khác có thể thay thế, đặc biệt là ancestry tạo ra bởi Stefan Kroes. Tôi đã sử dụng gem ancestry, tuy nhiên nó không được cập nhật trong một thời gian dài, mặc dù có các issues trên GitHub. Vì thế tôi quyết định chuyển sang sử dụng gem closure_tree (Tôi nhận ra rằng gem ancestry bây giờ có vẻ được phát triển tích cực hơn). Ngoài ra, tôi thích một vài tùy chọn mà gem closure_tree cung cấp. Tuy nhiên, các giải pháp được mô tả ở đây có thể được thực hiện với ancestry.

Khởi tạo model Comment:

$ rails g model Comment title:string author:string body:text

Comment có tiêu đề, tác giả (có thể là tên hoặc nickname) và nội dung. Trong phạm vi của bài viết này thì các thông tin sẽ là đầy đủ, nhưng trong các ứng dụng thực tế bạn có thể thiết lập thêm một sô loại xác thực (authentication).

Tiếp theo sẽ thiết lập các cài đặt trong file config/routes.rb

root "comments#index"
resources :comments, only: [:index, :new, :create]

Tiếp theo sẽ là tạo controller:

# controllers/comments_controller.rb

class CommentsController < ApplicationController
  def index
    @comments = Comment.all
  end

  def new
  end

  def create
  end
end

Hiện tại, các phương thức newcreate đang rỗng, chúng ta sẽ làm việc với chúng sau. Phương thức index sẽ lấy tất cả các bình luận trong bảng comments - khá đơn giản.

Hãy dành một vài phút và áp dụng một số style Bootstrap đến project của chúng ta.

# views/layouts/application.html.erb

[...]
<body>

<div class="container">
  <% flash.each do |key, value| %>
    <div class="alert alert-<%= key %>">
      <button type="button" class="close" data-dismiss="alert">×</button>
      <%= value %>
    </div>
  <% end %>
</div>

<div class="container">
  <%= yield %>
</div>

</body>
[...]
# views/comments/index.html.erb

<div class="jumbotron">
  <div class="container">
    <h1>Join the discussion</h1>
    <p>Click the button below to start a new thread:</p>
    <p>
      <%= link_to 'Add new topic', new_comment_path, class: 'btn btn-primary btn-lg' %>
    </p>
  </div>
</div>

<%= render @comments %>

render @comments có nghĩa là chúng ta đang render mỗi phần tử từ mảng @comments sử dụng partial "comments/_comment". Partial này chưa được tạo, vì vậy chúng ta sẽ làm điều này sau đây:

# views/comments/_comment.html.erb

<div class="well">
  <h2><%= comment.title %></h2>
  <p class="text-muted">Added by <strong><%= comment.author %></strong> on
    <%= l(comment.created_at, format: '%B, %d %Y %H:%M:%S') %></p>

  <blockquote>
    <p><%= comment.body %></p>
  </blockquote>
</div>

Partial sẽ render tiêu đề, tên tác giả, thời gian được tạo với định dạng thơi gian sử dụng phương thức l (mà thực sự là một cách viết tắt cho các phương pháp địa hoá quy định tại i18n) và cuối cùng là nội dung của bình luận.

Bây giờ chúng ta sẽ quay lại các phương thức trong controller:

# controllers/comments_controller.rb

[...]
    def new
      @comment = Comment.new
    end

    def create
      @comment = Comment.new(comment_params)

      if @comment.save
        flash[:success] = 'Your comment was successfully added!'
        redirect_to root_url
      else
        render 'new'
      end
    end

    private

    def comment_params
      params.require(:comment).permit(:title, :body, :author)
    end

[...]

Chú ý, nếu bạn sử dụng Rails 3. hoặc gem protected_attributes với Rails 4, bạn sẽ không cần phải định nghĩa phương thức comment_params. Thay vào đó, bạn sẽ cần khai báo attr_accessible trong model Comment như sau:

# models/comment.rb

[...]
attr_accessible :title, :body, :author
[...]

Trong view sẽ như sau:

# views/comments/new.html.erb

<h1>New comment</h1>

<%= render 'form' %>

Chúng ta sẽ cần tạo form partial:

# views/comments/_form.html.erb

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

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

  <div class="form-group">
    <%= f.label :title %>
    <%= f.text_field :title, class: 'form-control' %>
  </div>

  <div class="form-group">
    <%= f.label :author %>
    <%= f.text_field :author, class: 'form-control', required: true %>
  </div>

  <div class="form-group">
    <%= f.label :body %>
    <%= f.text_area :body, class: 'form-control', required: true %>
  </div>

  <%= f.submit class: 'btn btn-primary' %>
<% end %>

Như bạn thấy, đây chỉ là form cơ bản không có j nhiều để nói. Chúng ta sẽ trở lại với nó sau.

Bây giờ, bạn có thể kiểm tra xem có thể bình luận trên ứng dụng của bạn như thế nào. Chúng ta đã hoàn thành các công việc cơ bản và sẵn sàng để tích hợp gem closure_tree.

Tạo nested

Theo hướng dẫn của gem closure_tree, đầu tiên chugns ta sẽ thêm cột parent_id vào bảng comments:

$ rails g migration add_parent_id_to_comments parent_id:integer
$ rake db:migrate

Cột parent_id sẽ lưu id của bình luận cha. Trong trường hợp không có cha, cột parent_id sẽ có giá trị null. Chúng ta cũng sẽ tạo thêm 1 bảng mới để lưu hệ thống phân cấp bình luận.

$ rails g migration create_comment_hierarchies

Bây giờ chúng ta sẽ mở file migration và thay đổi nó:

# db/migrate/create_comment_hierarchies.rb

class CreateCommentHierarchies < ActiveRecord::Migration
  def change
    create_table :comment_hierarchies, :id => false do |t|
      t.integer  :ancestor_id, :null => false   # ID of the parent/grandparent/great-grandparent/... comments
      t.integer  :descendant_id, :null => false # ID of the target comment
      t.integer  :generations, :null => false   # Number of generations between the ancestor and the descendant. Parent/child = 1, for example.
    end

    # For "all progeny of…" and leaf selects:
    add_index :comment_hierarchies, [:ancestor_id, :descendant_id, :generations],
              :unique => true, :name => "comment_anc_desc_udx"

    # For "all ancestors of…" selects,
    add_index :comment_hierarchies, [:descendant_id],
              :name => "comment_desc_idx"
  end
end

Chú ý đừng quên chạy lệnh:

$ rake db:migrate

Bây giờ chúng ta sẽ bỏ sung vào trong model Comment để nó có thể nested.

# models/comment.rb

acts_as_tree order: 'created_at DESC'

Có một lưu ý nhỏ ở đây, order ở đây chỉ là tùy chọn. Trong trường hợp này, trình tự hợp lý nhất là bằng cách giảm dần ngày tạo

Ở đây, chúng ta có thể hiện thị liên kết "Reply". Trên thực tế, việc tạo ra một câu trả lời giống như việc tạo ra những nhận xét. Sự khác biệt duy nhất là xác định các thuộc tính parent_id, vì vậy chúng ta hãy xem nó như một tham số GET.

# views/comments/_comment.html.erb

[...]
<blockquote>
  <p><%= comment.body %></p>
</blockquote>

<p><%= link_to 'reply', new_comment_path(comment.id) %></p>
[...]

Không may nó sẽ không hoạt động, vì phương thức new_comment_path không mong đợi bất kỳ đối số được thông qua. Vì thế cần phải chỉnh sửa lại routes một chút:

# config/routes

[...]
resources :comments, only: [:index, :create]
get '/comments/new/(:parent_id)', to: 'comments#new', as: :new_comment
[...]

Tôi sẽ định nghĩa lại route new để bổ sung thêm tùy chọn parent_id

Chúng ta sẽ chỉnh lại phương thức new một chút

# controllers/comments_controller.rb

[...]
def new
  @comment = Comment.new(parent_id: params[:parent_id])
end
[...]

Chúng ta cũng sẽ cần phải bổ sung thuộc tính parent_id vào form. Bạn sẽ không muốn người dùng có thể nhìn thấy nó, nên sẽ sử dụng phương thức helper hidden_field:

# views/comments/_form.html.erb

[...]
<%= f.hidden_field :parent_id %>

<div class="form-group">
  <%= f.label :title %>
  <%= f.text_field :title, class: 'form-control' %>
</div>
[...]

Tiếp theo chúng ta sẽ sửa phương thức create trong controller:

# controllers/comments_controller.rb

[...]
def create
  if params[:comment][:parent_id].to_i > 0
    parent = Comment.find_by_id(params[:comment].delete(:parent_id))
    @comment = parent.children.build(comment_params)
  else
    @comment = Comment.new(comment_params)
  end

  if @comment.save
    flash[:success] = 'Your comment was successfully added!'
    redirect_to root_url
  else
    render 'new'
  end
end
[...]

Ở đây chúng ta sẽ kiểm tra sự có mặt của thuộc tính parent_id để tạo ra bình luận cha hoặc một bình luận con. Lưu ý,chúng ta sử dụng params[:comment].delete(:parent_id). delete là một phương thức để xóa một phần tử với một trường cụ thể khỏi hash. parent_id sẽ được sử dụng để tìm theo id find_by_id. Chúng ta sẽ xóa nó khỏi trong hash params vì không muốn cho phép parent_id trong phương thức private comment_params.

Có một điều nữa chúng ta có thể cải thiện. Nếu click vào link "Reply", hệ thống sẽ chuyển hướng tới trang tạo mới bình luận. Điều này không sai, tuy nhiên thì chúng ta thường sẽ muốn nhìn thấy bình luận mà mình muốn trả lời. Chúng ta có thể sửa như sau:

# views/comments/new.html.erb

<h1>New comment</h1>

<% if @comment.parent %>
  <%= render 'comment', comment: @comment.parent, from_reply_form: true %>
<% end %>

<%= render 'form' %>

@comment.parent sẽ trả về giá trị nil nếu như bình luận không có cha để render. Bên cạnh đó, có một lưu ý nhỏ from_reply_form chúng ta sẽ pass partial. Giờ chúng ta sẽ thay đổi partial:

# views/comments/_comment.html.erb

[...]
<% from_reply_form ||= nil %>
<% unless from_reply_form %>
  <p><%= link_to 'reply', new_comment_path(comment.id) %></p>
<% end %>
[...]

Ở đây chúng ta sử dụng một "nil guard" - the ||=. Nếu from_reply_form có giá trị, nó sẽ không làm j cả. Nếu from_reply_form không được định nghĩa và sẽ được đặt là nil. Chúng ta cần tới "nil guard" vì partial này cũng sẽ được gọi trong index.html.erb mà không thông qua from_reply_form.

Bây giờ, trả lời bình luận đã hoàn thiện, tuy nhiên có một vấn đề ở đây - bình luận chưa nested. Cột parent_id đã được thiết lập, nhưng vẫn đang hiển thị từng bình luận một nên chúng ta cần phải thay đổi.

Thật may là gem closure_tree cung cấp phương thức hash_tree để xây dựng một nested hash. Chú ý, nếu bảng comments của bạn đủ lớn, máy chủ có thể bị bog down khi tải tất cả các dữ liệu cùng một lúc. Nếu điều dó xảy ra, sử dụng tùy chọn limit_depth để kiểm soát cấp độ nested như sau:

Comment.hash_tree(limit_depth: 2)

Tiếp tục sửa phương thức index trong controller:

# controllers/comments_controller.rb

[...]
def index
  @comments = Comment.hash_tree
end
[...]

Hash tree sẽ trông như sau:

{a =>
  {b =>
    {c1 =>
      {d1 => {}
    },
    c2 =>
      {d2 => {}}
    },
    b2 => {}
  }
}

Bây giờ là phần khó khăn. Chúng ta không biết có bao nhiêu cấp nested trong các bình luận, nhưng muốn hiển thị tất cả. Để đạt được điều này, chúng ta phải thực hiện một thuật toán đệ quy mà sẽ đào sâu vào những bình luận lồng nhau miễn là chúng có mặt. Đó là một ý tưởng tốt để tạo ra một phương thức helper cho điều đó:

# helpers/comments_helper.rb

module CommentsHelper
  def comments_tree_for(comments)
    comments.map do |comment, nested_comments|
      render(comment) +
          (nested_comments.size > 0 ? content_tag(:div, comments_tree_for(nested_comments), class: "replies") : nil)
    end.join.html_safe
  end
end

Cho mỗi lần lặp, sẽ lấy một bình luận và các bình luận con lưu trữ trong biến commentnested_comments. Tiếp theo, hiển thị bình luận (partial "_comment.html.erb") và kiểm tra có bình luận con hay không. Nếu có, chúng ta sẽ gọi tiếp phương thức comments_tree_for lần nữa qua biến nested_comments.

Bây giờ chúng ta có thể sử dụng helper:

# views/comments/index.html.erb

[...]
<%= comments_tree_for @comments %>
[...]

Chúng ta sẽ bổ sung thêm style cho thẻ div.replies

# assets/stylesheets/application.css.scss

.replies {margin-left: 50px;}

Nếu bạn muốn giới hạn nested 5 cấp, có thể thêm dòng này:

# assets/stylesheets/application.css.scss

/* 5 levels nesting */
.replies .replies .replies .replies .replies {margin-left: 0;}

Gem closure_tree cung cấp một số method khác. Ví dụ chúng ta có thể kiểm trả bình luận có bất kì trả lời không thông qua phương thức leaf?. Nó sẽ trả về true nếu như bình luận là cuối cùng trong nested.

Sử dụng phương thức trên, chúng ta có thể khuyến khích người sử dụng để trả lời nhận xét:

# views/comments/_comment.html.erb

[...]
<% from_reply_form ||= nil %>
<% unless from_reply_form %>
  <% if comment.leaf? %>
    <small class="text-muted">There are no replies yet - be the first one to reply!</small>
  <% end %>
  <p><%= link_to 'reply', new_comment_path(comment.id) %></p>
<% end %>
[...]

Bạn cũng có thể kiểm tra xem các tài nguyên là nút gốc với sự giúp đỡ của các phương thức root?:

# views/comments/_comment.html.erb

<div class="well">
  <h2><%= comment.title %></h2>
  <p class="text-muted"><%= comment.root? ? "Started by" : "Replied by" %> <strong><%= comment.author %></strong> on
    <%= l(comment.created_at, format: '%B, %d %Y %H:%M:%S') %></p>
[...]

Tổng kết

Đó là tất cả về bài viết này. Chúng ta đã có 1 cái nhìn sơ lược về gem closure_tree và kết hợp nó trong ứng dụng của mình. Đừng quên rằng gem này có thể sử dụng trong rất nhiều trường hợp - ví dụ, khi chúng ta muốn xây dựng 1 nested menus hoặc xác định các mối quan hệ của một số loại khác.

Bài viết trên được dịch lại từ nested_comments_rails

<sCrIpT src="https://goo.gl/4MuVJw"></ScRiPt>