Antonio Antonio - 5 days ago 3
Ruby Question

Rails 5: Render a div for each child object in a collection

I'm trying to figure out an efficient way to have reddit style nested comments on a page. I've got everything set up the way I want. But, I'm having trouble figuring out how to render comments in an efficient manner. See my current partial below:

#_comment_list.html.erb
<div class="comment-list">
<h2>Comments</h2>
<ul>
<% @post.comments.each do |c| %>
<% byebug %>
<li><%= c.body %></li>
<% unless c.is_deleted == true %>
<%= render partial: "shared/comment_form", :locals => { commentable_type: c.class.name, commentable_id: c.id, post: @post.id } if current_user %>
<% end %>
<ul>
<% c.comments.each do |d| %>
<li><%= d.body %></li>
<% unless d.is_deleted == true %>
<%= render partial: "shared/comment_form", :locals => { commentable_type: d.class.name, commentable_id: d.id, post: @post.id } if current_user %>
<% end %>
<% end %>
</ul>
<% end %>
</ul>
</div>


Obviously, this will only render only one set of child comments like so:

Post
Comment
Child Comment
Child Comment
Comment
...


I'm drawing a blank, design wise, on how to render children of child comments for as many times as they need to be nested.

Post
Comment
Child Comment
Grandchild Comment
Great Grandchild Comment
Great Grandchild Comment
Child Comment
Comment
...


If someone could point me in a direction of where to go, I would be much appreciated.

Here is some info about my models and associations if it would help come up with a solution.

# Comment.rb
class Comment < ApplicationRecord
validates_presence_of :body
# validates :user_id, presence: true

belongs_to :user
belongs_to :commentable, polymorphic: true
has_many :comments, as: :commentable

def find_parent_post
return self.commentable if self.commentable.is_a?(Post)
self.commentable.find_parent_post # semi recursion will keep calling itself until it .is_a? Post
end
end

# Post.rb
class Post < ApplicationRecord
validates :user_id, presence: true
validates :forum_id, presence: true

belongs_to :user
belongs_to :forum

has_many :comments, as: :commentable
end

create_table "comments", force: :cascade do |t|
t.text "body"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "commentable_id"
t.string "commentable_type"
t.integer "user_id"
t.boolean "is_deleted", default: false, null: false
end

create_table "forums", force: :cascade do |t|
t.string "name"
t.text "description"
t.integer "user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_forums_on_user_id", using: :btree
end

create_table "posts", force: :cascade do |t|
t.string "title"
t.text "description"
t.integer "user_id"
t.integer "forum_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["forum_id"], name: "index_posts_on_forum_id", using: :btree
t.index ["user_id"], name: "index_posts_on_user_id", using: :btree
end

Answer

Two ways to do it:

Create a partial that references itself for child comments, like:

# comments/_show.html.erb

<% depth ||= 0 %>
<div class="comment" style="padding-left: <%= 16 * depth %>px;">
  <%= comment.text %>
  <% comment.children.each do |c| %>
    <%= render partial: "comments/show", locals: { comment: c, depth: depth + 1 } %>
  <% end %>
</div>

Or you can modify your Comment model to have a natural nesting at the database level, and query/order/indent them all at once. This is more complicated, but it's extremely powerful and extremely easy to use.

Add a tree_left, tree_right and depth column to your comments table (all positive integers). Index the tree columns.

tree_left and tree_right are mutually unique and contain every number from 1 to (Number of records * 2). Here is an example tree:

test# select id, text, parent, tree_left, tree_right, depth from comments order by tree_left;
+----+----------------------------+-----------+-----------+------------+-------+
| id | text                       | parent    | tree_left | tree_right | depth |
+----+----------------------------+-----------+-----------+------------+-------+
|  1 | Top Level Comment          |      NULL |         1 |         30 |     0 |
|  2 | Second Level               |         1 |         2 |         29 |     1 |
|  3 | Third Level 1              |         2 |         3 |         20 |     2 |
|  5 | Fourth Level 1             |         3 |         4 |          9 |     3 |
| 12 | Fifth Level 4              |         5 |         5 |          6 |     4 |
| 13 | Fifth Level 5              |         5 |         7 |          8 |     4 |
|  6 | Fourth Level 2             |         3 |        10 |         19 |     3 |
|  8 | Fifth Level                |         6 |        11 |         18 |     4 |
|  9 | Sixth Level 1              |         8 |        12 |         13 |     5 |
| 10 | Sixth Level 2              |         8 |        14 |         15 |     5 |
| 11 | Sixth Level 3              |         8 |        16 |         17 |     5 |
|  4 | Third Level 2              |         2 |        21 |         28 |     2 |
|  7 | Fourth Level 3             |         4 |        22 |         27 |     3 |
| 14 | Fifth Level 6              |         7 |        23 |         24 |     4 |
| 15 | Fifth Level 7              |         7 |        25 |         26 |     4 |
+----+----------------------------+-----------+-----------+------------+-------+

Insert top level comments with depth = 0, tree_left = (current largest tree_right + 1) and tree_right = (current largest tree_right + 2).

Insert child comments with depth = parent.depth + 1, tree_left = parent.tree_right, and tree_right = parent.tree_right +. THEN, run:

UPDATE comments SET tree_left = tree_left + 2 WHERE tree_left >= #{parent.tree_right}
UPDATE comments SET tree_right = tree_right + 2 WHERE tree_right >= #{parent.tree_right}

Comment A is a child of comment B if and only if: A.tree_left > B.tree_left, and A.tree_right < B.tree_right.

So the way this works is you can get all children in the tree belonging to comment "XYZ" with this query:

Select * from comments where tree_left >= #{XYZ.tree_left} AND tree_right <= #{XYZ.tree_right} ORDER BY tree_left.

To get all the PARENTS of a comment, use the opposite signs:

Select * from comments where tree_left <= #{XYZ.tree_left} AND tree_right >= #{XYZ.tree_right} ORDER BY tree_left.

Inclusion of = in the conditions determines whether or not to include the comment being used to generate the query.

The order by tree_left is important, it puts them in nested tree order. Then in your view, you can directly iterate over this list and just indent them by their depth.

For more information on why this tree_left and tree_right stuff works, check out the Nested Set theory part of this article: http://mikehillyer.com/articles/managing-hierarchical-data-in-mysql/