Nested Comment Treads in ROR
建立一个嵌套的评论
- 建立数据库结构和嵌套视图(使用Stimulus取元素和绑event)
- 可以删除评论,可以对嵌套视图的层数进行控制.
- 用Ajax代替完全的刷新页面。
- 删除一个评论,但不丢失它的子评论。。。。
- 给嵌套评论添加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.
所以,
- 默认把partical: 'comments/form'隐藏。传入一个类class: "d-none"即 display: none。
- 添加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可以一起使用。