Nested Comment Treads in ROR

 建立一个嵌套的评论 

  1. 建立数据库结构和嵌套视图(使用Stimulus取元素和绑event)
  2. 可以删除评论,可以对嵌套视图的层数进行控制.
  3. 用Ajax代替完全的刷新页面。
  4. 删除一个评论,但不丢失它的子评论。。。。
  5. 给嵌套评论添加pagination。

 

 


 

视频1

https://gorails.com/episodes/nested-comment-threads-in-rails-part-1?autoplay=1

目标:建立数据库结构,和嵌套视图。

方法概览:

1. 建立一个可以自我关联的comment的表结构。

2. 建立partial模版_comment和_form

3. 设置routes, 出现post/1/comments/2这样的url

4. 新建controller:

 class Posts::CommentsController < CommentsController
//内部添加before_action :set_commentable, 通过params[:post_id]找到对应的@post实例。

5. 新建CommentsController类,增加create方法

6. 在show页面添加form渲染和comment渲染

7. 基本结构已经做出来了,下面进行嵌套评论的设计,代码在视图层完成。

   在comment模版上增加form模版:

  • 在_form上添加parent_id的输入框<input>,parent_id自动得到值,因此设置为不可见type='hidden'
  • 子评论form默认不可见,需要点击‘回复reply’才出现。comment模版增加这个功能。这里使用Stimulus来取元素(reply 链接)并绑定click事件(添加/移除 form模版)

8. 需要在父评论的下面显示新增子评论。而不是直接显示在所有评论最下方。因此需要修改show视图。

  • show视图只显示非子评论。即渲染comment时,增加一个筛选parent_id: nil。
  • 在comment模版中,渲染出当前评论的子评论<%= render comment.comments %>
  • 还是comment模版,它渲染的form模版,传入的变量改为comment.commentable。

 

rails new -m template.rb nested_coments

cd nested_comments

rails g scaffold Post title body:text

rails g model Comments user:references commentable:references{polymorphic}:index parent_id:integer body:text

rails db:migrate
解释:
commentable:references{polymorphic}:index 用于一个model belongs_to从属多个models.即Polymorphic.
生成了commentable_type:string, commentable_id:integer,以及这两个列组成的index.

class Post < ApplicationRecord
  has_many :comments, as: :commentable
end

class Comment < ApplicationRecord
belongs_to :user
belongs_to :commentable, polymorphic: true
belongs_to :parent, optional: true, class_name: "Comment"
//optional和class_name用于内部Comment关联,并且这是可选的无需存在验证。✅

def comments
//搜索某个post下的一个父评论的所有它的子评论。
//搜索条件1: comment记录的commentable_type和commentable_id指向post,
//因为添加了commentable的index索引,所以使用commentabel: commentable
//搜索条件2: comment记录的子评论,用parent_id即可得到。
 Comment.where(commentable: commentable, parent_id: id)
 end
end

 

解释Polymorphic 关联:

一个model可以属于belongs_to多个其他model。

 

commentable_id, commentable_type已经由model产生。

@comment.commentable_id是@post的🆔, 或另一个model实例的🆔.

@comment.commentable_type是是字符串,应该是"Post",或者其他model的名字。

 

在views/posts/show.html.erb中,添加一个partial:

<%= render partial: 'comments/form', locals: { commentable: @post}%>

 

然后新建comments/_form.html.erb

<%= form_with model: [commentable, Comment.new] local: true do |form| %>
    <div class='form-group'>
    // 生产出name='comment[body]' <%= form.text_area :body, placeholder: "Add a comment", class:'form-control'%> </div> <div claas='form-group'> <%= form.submit class: "btn btn-primary"%> </div> <% end %>

 

添加路径:

resources :posts do
  resources :comments, module: :posts
// routes call Posts::CommentsController end

resources :discussions do
  resources :comments, module: :discussions
// routes call Discussions::CommentsController end
root to: "posts#index"

 

新建controllers/posts/comments_controller.rb

class Post::CommentsController < CommentsController
  before_action :set_commentabe

  private

  def set_commentabel
    @commentable = Post.find(params[:post_id])
  end
end

 

新建controllers/comments_controller.rb

class CommentsController < ApplicationController
  before_action :authenticate_user!
  
  def create
    @comment = @commentable.comments.new(comment_params)
    @comment.user = current_user
    if @comment.save
      redirect_to @commentable
    else
      redirect_to @commentable, alert: "Something went wrong"
    end
  end

  private

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

 

回到show.html.erb

在浏览器新建一个comment实例,从log中可以看到insert into语法。

但是浏览器看不到评论,因为还没写view代码。

<%= render @post.comments %>

 然后新建views/comments/_comment.html.erb

<div class='border-left border-bottom border-top p-4 mb-4'>
  <%= comment.user.name%> posted
  <%= simple_format comment.body%>
  //simple_format,可以换行。
</div>


 

增加功能:每个评论后可以添加子评论。

添加代码:

<%= render partial: 'comments/form', locals: { commentable: @post, parent_id: comment.id }%>

   parent_id就是父评论的🆔。


 

然后comments/_form.html.erb, 添加隐藏的输入框,用于存储parent_id的值。

<%= form_with model: [commentable, Comment.new] local: true do |form| %>
    <div class='form-group'>
      <%= form.text_area :body, placeholder: "Add a comment", class:'form-control'%>
    </div>

    <div claas='form-group'>
+  <%= form.hidden_field :parent_id, value: local_assigns[:parent_id]%> <%= form.submit class: "btn btn-primary"%> </div> <% end %>

解释:

添加一个<input type='hidden'>,不想要用户看到这个input,所以隐藏。

local_assigns[:parent_id]  #=>得到变量parent_id的值。可以在sub templates中使用它存取本地变量。

 

2. 子评论功能的补充:

  • 默认子评论输入form不可见,需要点击父评论上的buttong或者link.

所以,

  1. 默认把partical: 'comments/form'隐藏。传入一个类class: "d-none"即 display: none。
  2. 添加button / link

comments/_comment.html.erb

<div class='border-left border-bottom border-top p-4 mb-4'>
  <%= comment.user.name%> posted
  <%= simple_format comment.body%>
   
<%= link_to 'Reply', "#"%> <%= render partial: 'comments/form',
  locals: { commentable: @post, parent_id: comment.id, class: "d-none"}%> </div> 解释: d-none是bootstrap4的便捷类,

 

在comments/_form.html.erb中:添加html: {...}用于增加HTML attributes给form tag。

<%= form_with model: [commentable, Comment.new] local: true, 
  html: {class: local_assigns[:class]} do |form| %>

 

rails webpacker:install:stimulus  (点击查看git说明。用webpacker安装stimulus,还可以安装js框架vue)


 

https://github.com/stimulusjs/stimulus

stimulus(6300✨)

一个现代JS框架,不会完全占据你的前端,事实上它不涉及渲染HTML。

相反,它被设计用于增加你的HTML和刚刚够好的behavior。

 

这是 Ruby 社区给多页面应用设计的框架。

Stimulus对HTML5页面局部更新支持非常到位,可以说是和RoR的Turbolinks配合最好的JS库。

解决了JQuery和Turbolinks配合时,对事件绑定要小心处理的问题。

用 MutationObserver 监控元素的变化, 补绑事件或者修改元素的引用.

 

原文:  https://chloerei.com/2018/02/24/stimulus/

 


继续:使用stimulus来绑定click事件:

rails webpacker:install:stimulus 

mv app/javascript/controllers/{hello, reply}_controller.js    //把hello改为reply

 

在app/views/shared/_head.html.erb中增加:

<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload'%>

 

controllers/relpy_controller.js:

import { Controller } from "stimulus"

export default class extends Controller {
  static targets = ["form"]  

  show() {
    this.formTarget.classList.remove("d-none")
  }
}

 

comments/_comment.html.erb:

data-controller和data-target取元素, data-action绑事件。

<div data-controller="reply">
+   <%= link_to "Reply", "#", data: {action: "click->reply#show"}%>
    <%= render partial: "comments/form", locals: { commentable: @post, 
+   parent_id: comment.id, class:
"d-none", target: "reply.form"}%> </div>

 

然后修改_form.html.erb:

<%= form_with model: [commentable, Comment.new] local: true, 
  html: {class: local_assigns[:class], data: {target: local_assigns[:target]}} do |form| %>

 

因为<a>tag的默认行为是Get,所以修改:reply_controller.js:

  show(event) {
    event.preventDefault()
    this.formTarget.classList.remove("d-none")
  }

//还可以添加隐藏的form输入的功能。

  

此时:在浏览器,输入子评论。发现子评论只显示在最下面,这是因为还没有修改view视图。

     

在show.html.erb中:修改查询条件:

//只渲染全部父评论,因为parent_id:nil证明这是一个父评论
<%= render @posts.comments.where(parent_id: nil)%>

 

 然后在_comment.html.erb中增加父评论的子评论的集合:

<%= render comment.comments%>

 

_comment模版中,把commentable: @post改为:

<div data-controller="reply">
    <%= link_to "Reply", "#", data: {action: "click->reply#show"}%>
    <%= render partial: "comments/form", locals: { commentable: comment.commentable, 
   parent_id: comment.id, class: "d-none", target: "reply.form"}%>
</div>

 

因为无论是嵌套的子评论,还是子子评论,他们都有同一个commentable即post对象。

       

 


 

 

视频2

  • 删除自己的评论的功能,需要添加controller: destroy方法。
  • 对嵌套的层数限制,这是直接在视图上,用整数变量进行计算,用if判断进行控制。

 

rails g scaffold Discussion title:string

rails db:migrate

在route.rb:

resources :discussions do
  resources :comments, module: :discussions
end

 

新增controllers/discussions/comments_controller.rb

class Disscussions::Commentscontroller < CommentsController
  before_action :set_commentable

  private

  def set_commentable
    @commentable = Disscussion.find(params[:discussion_id])
  end
end

 

添加上关联:

class Discussion < ApplicationRecord
  has_many :comments, as: :commentable
end

 

在app/views/discussions/show.html.erb中添加上form和comment的渲染:

<h3>Comments</h3>

<%= render partial: "comments/form", locals: {commentable: @discussion}%>

<%= render @discussion.comments.where(parent_id: nil)%>

  

在views/comments/_comment.html.erb:

添加一个删除连接。用于删除自己的评论

<div data-controller="reply">
  <small>
    <%= link_to "Reply", "#", data: { action: "click->reply#toggle"}%>
    <%= link_to "Delete",[comment.commentable, comment], method: :delete, 
    data: { confirm: "Are you sure?"} if comment.user == current_user %>
</small>

 

⚠️: [comment.commentable, comment] 用于作为删除的路径,指向当前的discussion对象下的这个comment.

 

添加删除comment的action, CommentsController#destroy:

def destroy
  @comment = @commentable.comments.find(params[:id])
  @comment.destroy
  redirect_to @commentable
end

 

对嵌套评论的层数限制功能:

在app/views/discussions/show.html.erb中添加一个hash变量:

<%= render @discussion.comments.where(parent_id: nil), max_nesting: 3%>

 

在_comments.html.erb中对这个max_nesting进行判断:

<% nesting = local_assigns.fetch(:nesting, 1)%>
<% max_nesting = local_assigns[:max_nesting]%>
...   <small>
   //<%= nesting %> //显示当前的嵌套层数。
//<%= max_nesting %> //显示最大的嵌套层数。
<%= render comment.comments, {nesting: nesting + 1, max_nesting: local_assigns["max_nesting"]}%>

 

local_assings获得一个渲染中的声明的hash对象集合。

fetch(key, [default])方法:

  • hash对象中不存在key,但给了默认值,则返回这个默认值
  • hash对象中存在key,则返回这个key.

如果当前嵌套层数 大于 max_nesting则移除回复的连接:

修改_comment.html.erb

<%= link_to "Reply", "#", data: {action: "click->reply#toggle"} if nesting < max_nesting %> 

但是这么改不太好,用户不知道如何添加新评论了,因此:

可以改成如果嵌套层数 大于 max_nesting,生成的新嵌套评论则和上一个子评论在视觉效果上是平级的:

<%= render partial: "comments/form", locals {
    commentable: comment.commentable,
    parent_id: (nesting < max_nesting ? comment.id : comment.parent_id),
    class: "mt-4 d-none",
    target: "reply.form"
}%>

 

 

做到这里,会出现一个❌,comparison of Integer with nil failed: max_nesting是nil,

这是因为post-comment无需添加限制嵌套评论层数的功能,所以posts/show.html.erb模版中,没有声明max_nesting。

因此,共用的_comment.html.erb模版需要加判断:

parent_id: ( max_nesting.blank? || nesting < max_nesting ? comment.id : comment.parent_id),

注意⚠️:

  • | |优先级小于?;
  • nil.blank? #=>true

 

 重构它,变成helper_method:

parent_id: reply_to_comment_id(comment, nesting, max_nesting)

 

app/helpers/comments_helper.rb:

module CommentsHelper
  def reply_to_comment_id(comment, nesting, max_nesting)
   //可以添加一些说明的注释。。。
if max_nesting.blank? || nesting < max_nesting comment.id else comment.parent_id end end end

 


 

 

视频3 : 使用Ajax(async and JavaScript and xml)

1 去掉_form.html.erb模版中:local: true选项, 表示传输数据使用JS,开启远程。

2.使用Ajax请求局部数据,并处理响应得到数据,而不是使用turbolinks缓存返回的整个的网页。

3. 在comments_comtroller.rb修改create方法

def create
  @comment = @commentable.comments.new(comment_params)
  @comment.user = current_user
  if @comment.save
    respond_to do |format|
      format.html { redirect_to @commentable }
      format.js
    end
  else
  redirect_to @commentable, alert: "Someting went wrong"
  end
end

  

4. 创建create.js.erb

<%= j render partial: "comments/comment", locals: { comment: @comment}, format: :html%>

 

因为渲染的模版格式是html。所以添加上format: :html选项。

j是escape_javascript(javascript)的简写,一般用于JavaScript的responsess。把一些特殊字符处理一下。可以通过html_safe的检验。根本目的是否防止恶意的黑客行为。

 

5. 把discussion和post的show.html.erb的渲染用<div id="comments">包裹起来。

6. _comment.html.erb中的渲染也用<div>📦。

<%= tag.div id: "#{dom_id(comment)}_comments" do %>
  <%= render comment.comments, nesting:nesting +1, max_nesting: local_assigns[:max_nesting]%>
<% #js插入的地方 %> <% end %>

 

使用tag.div,主要是为了自定义id. 如:comment_44_comments

dom_id(record, prefix=nil)属于ActionView#RecordIdentifier模块中的方法,可以和DOM elements关联。

dom_id(Post.find(45))            # => "post_45"
dom_id(Post.new)                 # => "new_post"
dom_id(Post.find(45), :edit)  # => "edit_post_45"
dom_id(Post.new, :custom)    # => "custom_post"

解释:

5和6步骤的目的:使用不同的🆔来区分父评论和子评论。

 

7.create.js.erb:

<% if @comment.parent_id? %>
//如果@comment是一条子评论。则找到这条评论的父评论。 var comments = document.querySelector("#<%= dom_id(@comment.parent)%>_comments") <% else %> //如果@comment是父评论。则找到本web page的第一条父评论。
 var comments
= document.querySelector("#comments") <% end %> //把HTML节点插入到所选元素的内部的最后子节点之后。需要测试浏览器兼容性。 comments.insertAdjacentHTML("beforeend", '<%= j render partial: "comments/comment", locals: { comment: @comment}, format: :html%>')
//找到comments评论的父元素中的form元素。 var form
= comments.parentElement.querySelector("form")
form.reset() //清空输入框中的输入内容。
form.classList.add("d-none") //把这个form隐藏

 

但是本网页第一个form要求一直显示在网页上面的。所以添加一个判断,只有当操作子评论才能隐藏它的父元素的form:

<% if  @comment.parent_id?%>
  form.classList.add("d-none")
<% end %>

 

视频4  

删除一个评论,但不丢失它的子评论 。默认删除一条父评论,则子评论也就无法显示出来了。

改进方法:

方法1:把要删除的评论的所有子评论的parent向上移动一级,改成删除的评论的父评论。

class Comment < ApplicationRecord
...
// 查找所有父评论是调用这个方法的对象的评论
def child_comments Comment.where(parent: self) end before_destroy :handle_children def handle_children child_comments.update_all(parent_id: self.parent_id) end ... end

 

 

方法2 推荐👍

并不真实删除这条评论,只是把它和user的关联去掉,并把它储存信息的body属性的值改为:nil

即告诉浏览器页面,这里有一条评论,已经被删除。你看不到评论内容也不知道谁发表的评论!

class Comment < ApplicationRecord
  //。。。略。。。

belongs_to :user, optional: true //不理解,是不是覆盖了CommentsController#destroy这个方法
def destroy update(user:nil, body: nil) end // 用于在视图上判断 def deleted? user.nil? end end

 

 _comment.html.erb修改:加上if判断

<div class="border-left pl-4 mt-4">
  <% if comment.deleted? %>
    <strong>🈲️[deleted]</strong> posted
  <% else %>
    <strong><%= comment.user.name %></strong> posted
    <p><%= simple_format comment.body %></p>
  <% end %>
...

 


 

视频5

方法1: 使用gem paginate分页器。

把posts/show.html.erb中的 

<%= render @post.comments.where(parent_id: nil) %>
//改为:
<%= render @comments%>

//在页面底部加上:
<%= paginate @comments%>

 

同时在controller的show方法,添加:

def show
  @comments = @post.comments.where(parent_id: nil).page(params[:page]).per(5)
end

 

每页只显示5条无父评论的评论。

 

方法2: 类似reddit 的 导航

目的:通过点击每个评论旁边的连接"posted"可以只显示这个评论和它的子评论:

 

1。去掉方法1对导航gem的使用的代码。

2. _comment.html.erb:

<%= tag.div id: dom_id(comment), class: "border-left pl-4 mt-4" do %>
。。。`
<strong><%= comment.user.name%></strong> //修改 <%= link_to 'posted', url_for(comment: comment.id, anchor: dom_id(comment))%>
/URL: localhost:3000/posts/1?comment=10#comment_10
...
<% end %>

 

url_for(options = nil)

返回由options设置的URL. 默认是相对URL.

options:

  • :anchor - 指定一个⚓️name, 附加到path后面。

 

link_to can also produce links with anchors or query strings:

link_to "Comment wall", profile_path(@profile, anchor: "wall")
# => <a href="/profiles/1#wall">Comment wall</a>

 

3. 修改posts_controller.rb

def show
//根据用户行为显示评论:
//如果用户点击页面上"posted"🔗时,url包含comment参数,因此显示指定的评论(也会包括它的子评论)
if params[:comment] @comments = @post.comments.where(id: params[:comment]) else //否则,显示post记录的所有评论(无父评论的评论)
  @comments
= @post.comments.where(parent: nil) end end

  

4 post没有嵌套层数限制。

这里做一个判断,如果nesting >=10,并且后面还有嵌套子评论,则后面的嵌套子评论就不显示了,用一个连接“Continue this thread”代替。点击这个链接后显示剩余的子评论。

<%= tag.div id: "#{dom_id(comment)}_comments" do %>
  <% if nesting >=10 && comment.comments.any? %>
    <%= link_to "Continue this thread", url_for(comment: comment_id, anchor: dom_id(comment))%>
  <% else %> 
    <%= render comment.comments, nesting: nesting + 1, max_nesting: local_assigns[:max_nesting]%>
  <% end %>
<% end %>

5 discussion和post共用一个_comment。所有需要加上if判断:

修改:

//添加,用于在partial模版传递变量
<% continue_thread = local_assigns[:continue_thread] %>
//...略...

<%= tag.div id: "#{dom_id(comment)}_comments" do %>
  <% if continue_thread.present? && nesting >= continue_thread && comment.comments.any? %>
    <%= link_to "Continue this thread", url_for(comment: comment_id, anchor: dom_id(comment))%>
  <% else %> 
    <%= render comment.comments, { continue_thread: continue_thread  ,nesting: nesting + 1, max_nesting: local_assigns[:max_nesting] } %>
  <% end %>
<% end %>

posts/show.html.erb: 

//修改<%= render @comments%>
<%= render @comments, continue_thread: 5 %>

 


 

 

6 在show.html.erb上加一个回到默认的🔗。当点击Continue this thread后,可以返回最开始的视图。

<div id="comments">
  <%= render partial: "comments/form", locals: { commentable: @post}%>
  //添加:如果有参数comment,代表点击过"Continue  this thread"链接,
//则添加一个返回初始的链接url_for()
<% if params[:comment]%> <p>Viewing single comment thread. <%= link_to "View all comments", url_for()%></p> <% end %>


 

7 方法1和2可以一起使用。

 

posted @ 2018-10-17 09:36  Mr-chen  阅读(440)  评论(0编辑  收藏  举报