Search code examples
ruby-on-railsrubyrecursionviewcomments

Nested comment thread - recursive rendering in view


I am trying to create a question and answer thread in Rails 6 where a User can answer on a question, and then other users can comment on the answer - similar to a reddit or even stackoverflow.

I created a polymorphic association on my Answer model with a 'parent_id' and I am able to post answers on the initial question. However the nested answers do not render below the initial answer, but rather below the main question. I think I have isolated the problem to the corresponding partial view seen below:

Answer View

<li>
<%= answer.body %></br>
<%= link_to answer.user.first_name, answer.user %> 
<%= link_to answer.user.last_name, answer.user %> 
answered <%= time_ago_in_words(answer.created_at) %> ago.

<div class="comments-container">
<%= render partial: "answers/reply", locals: {commentable: answer.commentable, parent_id: answer.parent.id} %>  
</div>

<ul> <%= render partial: "answers/answer", collection: answer.answers %> </ul>
 </li>

From my understanding, the last line should render the answers to the answer, however the answers render underneath the initial question, and not the answer. Any ideas on what im doing wrong? Should I be using a gem like Ancestry to do this? If so how would that work?

For completeness, here are the other components

Question View

<h3><%= @question.title %></h3>
<p> Asked by <%= link_to @question.user.email, @question.user %> <%= time_ago_in_words(@question.created_at) %> ago. </p>
</br>
<span class="body"> <%= @question.body %> </span>
</br>

<h5><strong><%= @question.answers.count %> Answers</strong></h5>

<%= render @answers %></br>
<%= render partial: "answers/form", locals: {commentable: @question} %> </br>
<%= paginate @answers %>

Answer model

belongs_to :user
belongs_to :parent, optional: true, class_name: 'Answer'
belongs_to :commentable, polymorphic: true
has_many :answers, as: :commentable, dependent: :destroy
validates :body, presence: true 
validates :user, presence: true

Question model

belongs_to :user
has_many :answers, as: :commentable, dependent: :destroy
validates :body, presence: true
validates :title, presence: true
validates :user, presence: true

AnswerController

class AnswersController < ApplicationController
before_action :set_answer, only: [:edit, :update, :destroy, :upvote, :downvote]
before_action :find_commentable, only: [:create]

def new
  @answer = Answer.new
end

def create
  @answer = @commentable.answers.new(answer_params)
  respond_to do |format|
    if @answer.save
      format.html { redirect_to @commentable }
      format.json { render :show, status: :created, location: @commentable }
    else
      format.html { render :new }
      format.json { render json: @answer.errors, status: :unprocessable_entity }
    end
  end
end

def destroy
  @answer = @commentable.answers.find(params[:id])
  @answer.discard
  respond_to do |format|
    format.html { redirect_to @commentable, notice: 'Answer was successfully destroyed.' }
    format.json { head :no_content }
  end
end

private
def set_answer
  @answer = Answer.find(params[:id])
end

def answer_params
  params.require(:answer).permit(:body).merge(user_id: current_user.id, parent_id: params[:parent_id])
end

def find_commentable
  @commentable = Answer.find(params[:answer_id]) if params[:answer_id]
  @commentable = Question.find(params[:question_id]) if params[:question_id]
end

end

Question Controller

class QuestionsController < ApplicationController
before_action :set_question, only: [:show, :edit, :update, :destroy, :upvote, :downvote]

def index
  @questions = Question.order('created_at desc').page(params[:page])
end

def show
  @answer = @question.answers.new  
  @answers = if params[:answer]
             @question.answers.where(id: params[:answer])
             else
             @question.answers.where(parent_id: nil)
             end

  @answers = @answers.page(params[:page]).per(5)
end

def new
  @question = Question.new
end

def edit
end

def create
  @question = Question.new(question_params)
  respond_to do |format|
    if @question.save
      format.html { redirect_to @question, notice: 'You have successfully asked a question!' }
      format.json { render :show, status: :created, location: @question }
    else
      format.html { render :new }
      format.json { render json: @question.errors, status: :unprocessable_entity }
    end
  end
end

def update
  respond_to do |format|
    if @question.update(question_params)
      format.html { redirect_to @question, notice: 'Question successfully updated.' }
      format.json { render :show, status: :ok, location: @question }
    else
      format.html { render :edit }
      format.json { render json: @question.errors, status: :unprocessable_entity }
    end
  end
end

def destroy
  @question.discard
  respond_to do |format|
    format.html { redirect_to @questions_url, notice: 'Question successfully deleted.' }
    format.json { head :no_content }
  end
end

private

def set_question
  @question = Question.find(params[:id])
end

def question_params
  params.require(:question).permit(:title, :body, :tag_list).merge(user_id: current_user.id)
end

end


Solution

  • You kind of failed at modeling polymorphism. If you want a true polymorphic association you would model it as so:

    class Question
      has_many :answers, as: :answerable
    end
    
    class Answer
      belongs_to :answerable, polymorphic: true 
      has_many :answers, as: :answerable
    end
    

    This lets the "parent" of a question be either a Question or a Answer and you don't need to do ridiculous stuff like @question.answers.where(parent_id: nil). You can just do @answers = @question.answers and this will only include the first generation children.

    However polymorphism isn't all its cracked up to be and that will be especially apparent when building a tree hierarchy. Since we actually have to pull the rows out of the database to know where to join you can't eager load the tree effectively. Polymorphism is mainly useful if the number of parent classes in large or unknown or you're just prototyping.

    Instead you can use Single Table Inheritance to setup the associations:

    class CreateAnswers < ActiveRecord::Migration[6.0]
      def change
        create_table :answers do |t|
          t.string :type
          t.belongs_to :question, null: true, foreign_key: true
          t.belongs_to :answer, null: true, foreign_key: true
          # ... more columns
          t.timestamps
        end
      end
    end
    

    Note the nullable foreign key columns. Unlike with polymophism these are real foreign keys so the db will ensure referential integrity. Also note the type column which has a special significance in ActiveRecord.

    Then lets setup the models:

    class Question < ApplicationRecord
      has_many :answers, class_name: 'Questions::Answer'
    end
    
    class Answer < ApplicationRecord
      has_many :answers, class_name: 'Answers::Answer'
    end
    

    And the subclasses of Answer:

    # app/models/answers/answer.rb
    module Answer
      class Answer < ::Answer
        belongs_to :answer
        has_one :question, through: :answer
      end
    end
    
    # app/models/questions/answer.rb
    module Questions
      class Answer < ::Answer
        belongs_to :question
      end
    end
    

    Pretty cool. Now we can eager load to the first and second generation with:

    Question.eager_load(answers: :anser)
    

    And we can keep going:

    Question.eager_load(answers: { answers: :answer })
    Question.eager_load(answers: { answers: { answers: :answers }})
    

    But at some point you'll want to call it quits and start using ajax like reddit does.