Table: RailsNotes
User: dreamable
Created at: 2021-02-27 11:19:16 UTC
Updated at: 2021-03-05 06:08:24 UTC
Reference:(Table ID 3, Record ID 26)

标题 :
Rails Comment System
笔记 :

Comment

  • DB
    1. comments: user:references, table:references, record_id:integer parent:references rely:references content:text
    2. limit to table or table record only. Use polymorphic for commenting anything.
    3. Add parent_id/reply_id for replying comments, could be nil. Parent points to the top-level comment, reply points to the comment it replies.
  • Model
    1. Add has_many :children and has_many: replies through parent/reply_id.
    2. Once a comment deleted, we deleted all its children and replied comments recursively
    3. Add cascade_reply_ids which find all comment IDs that need to be deleted.
  • Views
    1. The comment container has two parts: new comment and current comments
    2. For each comment, we show its content, followed by # of replies, which will show all replies once clicked. Also show when it's created, and icon for deleting
    3. For reply part, its similar, on top, we show new reply, followed by all replies.
    4. To make view nice, we show only two level of comments. All deeper comments are flatten by setting parent_id to top-level comment.
    5. We use reply_id to link to the comment it replies to. We also show short notes of the comment.
    6. Once a new reply added, we insert the reply on the top, update # of replies as well.
    7. Once a top-level comment deleted, we delete all its children; once a reply deleted, we delete all replies recursively; update # of replies as well.

Code
1. use data-turbolinks=false to make anchor works without reloading pages
2. use js.erb file to make operating comments AJAX without reloading pages

# db/migrate/20210227120803_create_comments.rb
class CreateComments < ActiveRecord::Migration[6.0]
  def change
    create_table :comments do |t|
      t.references :user, null: false, foreign_key: true
      t.references :table, null: false, foreign_key: true
      t.integer :record_id, null: true
      # For nested comment, parent links to the top-level comment, reply links to its reply
      # parent comment
      t.references :parent, null: true, foreign_key: {to_table: "comments"}
      # reply comment
      t.references :reply,  null: true, foreign_key: {to_table: "comments"}
      t.text :content, null: false, default: ""
      t.timestamps
    end
    add_index :comments, [:table_id, :record_id]
    add_index :comments, [:table_id, :record_id, :parent_id]
  end
end

# app/models/comment.rb
class Comment < ApplicationRecord
  default_scope { order('id DESC') }
  belongs_to :user
  belongs_to :table
  belongs_to :parent, class_name: "Comment", optional: true
  belongs_to :reply, class_name: "Comment", optional: true
  has_many   :children, class_name: 'Comment', foreign_key: :parent_id, dependent: :destroy
  has_many   :replies, class_name: 'Comment', foreign_key: :reply_id, dependent: :destroy
  # delete_all has better performance if many records. InvalidForeignKey error
  #has_many   :children, class_name: 'Comment', foreign_key: :parent_id, dependent: :delete_all
  #has_many   :replies, class_name: 'Comment', foreign_key: :reply_id, dependent: :delete_all
  validates_presence_of :content

  def cascade_reply_ids
    # top-level comment
    return self.children.pluck(:id) unless self.parent_id?
    # non-top-level comment
    ids = self.replies.pluck(:id); nids=ids.clone
    while nids.any?
      id = nids.pop
      nn = Comment.where(table: self.table, record_id: self.record_id, parent_id:self.parent_id, reply_id: id).pluck(:id)
      ids += nn;
      nids.unshift(*nn)
    end
    return ids;
  end
end

# app/views/comments/_comment_section.html.erb
<div class="comment-container mt-5 small">
  <% if logged_in? %>
    <div class="new-comment" id="new-reply">
      <%= render "comments/new_comment", comment: comment %>
    </div>
  <% end %>
  <div class="comments" id="children">
    <%= render comments %>
  </div>
</div>

# app/views/comments/_comment.html.erb
<% comment_id = "comment-#{comment.id}" %>
<% reply_container_id = "reply-container-#{comment.id}" %>
<% new_reply_id = "new-reply-#{comment.id}" %>
<% children_id = "children-#{comment.id}" %>
<div class="card mx-4 my-1" id="<%=comment_id%>">
  <div class="card-body">
    <div class="card-title">
      <h6><%= link_to comment.user.name, f_user_path(comment.user) %></h6>
    </div>
    <% if (comment.parent_id && comment.reply_id && (comment.parent_id!=comment.reply_id)) %>
      <div class='markdown-body'>
        <% rc = "> @#{comment.reply.user.name} \n >> #{comment.reply.content.truncate(100)}" %>
        <a href="#comment-<%=comment.reply.id%>" data-turbolinks="false">
          <%= markdown(rc) %>
        </a>
      </div>
    <% end %>
    <div class='markdown-body'>
      <%= markdown(comment.content)%>
    </div>
    <% tstyle = fmt_sh_style(comment.children.any?) %>
    <% cid = "children-num-#{comment.id}" %>
    <% cnum = comment.children.count %>
    <% tstr = t_cat([("<div id='#{cid}' style='#{tstyle}'>"+cnum.to_s+"</div>"),t('reply'),">"]).html_safe %>
    <div class="d-flex flex-row justify-content-between mt-2">
      <div>
        <%= link_to tstr, "javascript:void(0)", class: "click-toggle-id", "toggle-id": reply_container_id%>
        <%= fmt_time(comment.created_at) %>
      </div>
      <% if logged_in? %>
        <% if current_user==comment.user  || admin? %>
          <%= link_to fa_destroy_icon, comment_path(comment), class: "#{lh_btn_cls} btn-sm", method: :delete, remote: true, data: { confirm: t('destroy_confirm') } %>
        <% end %>
      <% end %>
    </div>

    <div class="comment-reply-container" id="<%=reply_container_id%>" style="<%=fmt_hide_style%>">
      <div class="new-comment" id="<%=new_reply_id%>">
        <% if logged_in? %>
          <%= render "comments/new_comment", comment: Comment.new(table: comment.table, record_id: comment.record_id, parent_id: comment.parent_id || comment.id, reply_id: comment.id) %>
        <% end %>
      </div>
      <div class="comment-children" id="<%=children_id%>" %>
        <%= render comment.children if comment.children.any? %>
      </div>
    </div>
  </div>
</div>

# app/views/comments/_new_comment.html.erb
<%= form_with(model: comment, remote: true) do |form| %>
  <% 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 |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field form-group row justify-content-between">
    <%= form.hidden_field :table_id %>
    <%= form.hidden_field :record_id %>
    <%= form.hidden_field :parent_id %>
    <%= form.hidden_field :reply_id %>
    <div class="col-md-10 col-xs-12">
      <%= form.text_area :content, rows: 1, class:"form-control", placeholder: t("comment"), required: true %>
    </div>
    <div class="col-md-2 col-xs-12 text-center">
      <%= form.submit t("submit"), class: "btn btn-secondary form-control"%>
    </div>
  </div>
<% end %>

# app/views/comments/create.js.erb
<% if @comment.parent_id.present? %>
  // Clear form
  $('.comment-container #new-reply-<%=@comment.reply_id%> form')[0].reset()
  <% if @comment.reply_id!=@comment.parent_id %>
    $('.comment-container #reply-container-<%=@comment.reply_id%>').hide()
  <% end %>
  // Add comment
  $('.comment-container #children-<%=@comment.parent_id%>').prepend('<%= escape_javascript(render(@comment)) %>')
  // Update children number
  $('.comment-container #children-num-<%=@comment.parent_id%>').attr('style','display: inline-block;')
  <% cnum = @comment.parent.children.count %>
  $('.comment-container #children-num-<%=@comment.parent_id%>').text(<%=cnum%>)
<% else %>
  $('.comment-container #new-reply form')[0].reset()
  $('.comment-container #children').prepend('<%= escape_javascript(render(@comment)) %>')
<% end %>

# app/views/comments/destroy.js.erb
// Remove comments
$('.comment-container #comment-<%=@comment.id%>').remove()
<% if @comment.parent_id.present? %>
  // Remove comments's replies
  <% @reply_ids.each do |id| %>
    $('.comment-container #comment-<%=id%>').remove()
  <% end %>

  // Update children number
  <% cnum = @comment.parent.children.count %>
  // Set reply num
  $('.comment-container #children-num-<%=@comment.parent_id%>').text(<%=cnum%>)
  // Hide if no reply
  <% if cnum <=0 %>
    $('.comment-container #children-num-<%=@comment.parent_id%>').attr('style','display: none;')
  <% end %>
<% end %>
Tag: