🫠

コメントに返信機能[+render再利用]

2024/07/18に公開

渋ってたんですがついに実装しました〜(ほぼ草のため)
正直うまくまとめられんかった。

前提

・コメント機能実装済み(非同期化してません)アソシエーションも済
・コメントの中にraty.js使って星5段階評価つけてますが(空OK)返信機能にはつけない。
・投稿のshowページにコメントフォームと一覧実装。

実装結果

返信ボタン追加
返信ボタン追加押すとフォームが出現(javascript)
コメント一覧に返信も表示(背景色変更)

やったことリスト

1.commentテーブルにカラム追加

2.model関連付け

3.共通テンプレート作成(リプのリプを出力する為)

4.viewの編集/追加

1.commentテーブルにカラム追加

安定のdawnさせて直接書き換えてdb:migrateのやり方でしました💆‍♀️

migrateファイル
class CreateComments < ActiveRecord::Migration[6.1]
  def change
    create_table :comments do |t|
      t.integer :post_id, null: false
      t.integer :user_id, null: false
      t.integer :star
      t.string :comment, null: false
   ⭐️ t.integer :parent_id

      t.timestamps
    end

   ⭐️ add_index :comments, :parent_id
  end
end
『add_index』とは

『add_index』とはdbにインデックスを追加するためのRailsマイグレーションコマンド。
インデックスを追加することで、特定のカラムに対する検索やデータ操作が早くできる。
今回だとcommentsテーブルの中のparent_id(返信id)のindexを作っとく事で返信一覧などを早く見つけることができるから書いている。
結論→なくてもいいが高速な検索やパフォーマンス向上のためにあったほうがいい。

2.model関連付け

親子関係を規定するために記載してます🍔

comment model
class Comment < ApplicationRecord
 
 belongs_to :post
 belongs_to :user

 (親モデルに対する関連付け)
 ⭐️belongs_to :parent(任意の名前), class_name: 'Comment'(関連付けるモデル), optional: true
 (子モデルに対する関連付け)
 ⭐️ has_many :replies(任意の名前), class_name: 'Comment'(関連付けるモデル), foreign_key: 'parent_id', dependent: :destroy

 validates :comment, presence: true
end

belongs_toメソッドは、親モデルに対する関連付けを示します。
この場合、parentという名前でCommentモデル自身を指す関連付けを定義しています。
has_manyメソッドは、子モデルに対する関連付けを示します。
この場合、repliesという名前でCommentモデル自身を指す関連付けを定義しています。

optional: trueとは??

belongs_toの外部キー の nil を許可するというもの。
通常、belongs_to関連付けでは外部キー(例えばparent_idなど)は必須となるらしい😀知らんかった笑
返信しないコメントもあるもんね〜〜〜。

foreign_keyとは??

foreign_key: 'parent_id'は、子モデルが親を識別するための外部キー(この場合はparent_id)を指定しています。親子関係を表現するために必要です。

3.共通テンプレート作成(リプのリプを出力する為)

_replies.html.erb コード(レイアウト抜き)
<% comment.replies.each do |reply| %>
  <div class="card mb-2">
    <div class="card-body">
      <% if reply.user.present? && reply.user.profile_image.attached? %>
        <%= image_tag url_for(reply.user.profile_image), class: "avatar-img ", style: "width: 40px; height: 40px;" %>
      <% else %>
        <%= image_tag "no_image.jpg", class: "avatar-img", style: "width: 40px; height: 40px;" %>
      <% end %>
      <div class="flex-grow-1">
        <%= reply.comment.gsub(/\n/, '<br>').html_safe %>
      </div>

      <div class="ms-auto">
        <% if current_user == reply.user %>
          <%= button_to "削除", post_comment_path(@post, reply), method: :delete, class: "btn btn-dark" %>
        <% end %>
        <%= reply.created_at.strftime("%Y-%m-%d") %>
        <!-- ログインしててゲストユーザーじゃない場合は返信ボタン表示 -->
        <% unless current_user.guest_user? %>
          <button type="button" class="btn btn-secondary reply-button" data-comment-id="<%= reply.id %>">返信</button>
        <% end %>
      </div>
    </div>
    
    <!-- 返信フォーム -->
    <div class="reply-form" id="reply-form-<%= reply.id %>" style="display: none;">
      <!-- ログインしててゲストユーザーじゃない場合は返信ボタン表示 -->
      <% unless current_user.guest_user? %>
        <%= form_with model: [@post, @post.comments.new], local: true do |form| %>
          <%= form.hidden_field :parent_id, value: reply.id %>
          <%= form.label :comment, "返信:", class: "form-label" %>
          <%= form.text_area :comment, rows: 2, class: "form-control" %>
          <%= form.submit "返信を投稿", class: "btn btn-secondary btn-sm" %>
        <% end %>
      <% end %>
    </div>
    
    <%# 再帰的に呼び出し %>
    <%= render partial: 'replies', locals: { comment: reply } %>
  </div>
<% end %>

_replies.html.erb コード(レイアウトあり)
<% comment.replies.each do |reply| %>
  <div class="card mb-2 ml-3" style="background-color: #F8F8FF;">
    <div class="card-body">
      <div class="d-flex">
        <% if reply.user.present? && reply.user.profile_image.attached? %>
          <div class="me-3">
            <%= image_tag url_for(reply.user.profile_image), class: "avatar-img rounded-circle mr-4", style: "width: 40px; height: 40px; object-fit: cover;" %>
            <p class="text-center mr-4"><small class="text-muted"><%= reply.user.name if reply.user %></small></p>
          </div>
        <% else %>
          <div class="me-3">
            <%= image_tag "no_image.jpg", class: "avatar-img rounded-circle mr-4", style: "width: 40px; height: 40px; object-fit: cover;" %>
            <p class="text-center mr-4"><small class="text-muted"><%= reply.user.name if reply.user %></small></p>
          </div>
        <% end %>
        <div class="flex-grow-1">
          <p class="card-text"><small class="text-muted"><%= reply.comment.gsub(/\n/, '<br>').html_safe %></small></p>
        </div>
        <div class="ms-auto d-flex flex-column align-items-end">
          <% if current_user == reply.user %>
            <%= button_to "削除", post_comment_path(@post, reply), method: :delete, data: { confirm: "本当に削除しますか?" }, class: "btn btn-outline-dark btn-sm mb-2" %>
          <% end %>
          <p class="card-text mb-0"><small class="text-muted"><%= reply.created_at.strftime("%Y-%m-%d") %></small></p>
          <% unless current_user.guest_user? %>
            <button type="button" class="btn btn-sm btn-outline-secondary mt-2 reply-button" data-comment-id="<%= reply.id %>">返信</button>
          <% end %>
        </div>
      </div>
      
      <!-- 返信フォーム -->
      <div class="reply-form" id="reply-form-<%= reply.id %>" style="display: none;">
        <% unless current_user.guest_user? %>
          <%= form_with model: [@post, @post.comments.new], local: true do |form| %>
            <%= form.hidden_field :parent_id, value: reply.id %>
            <div class="mb-2">
              <%= form.label :comment, "返信:", class: "form-label" %>
              <%= form.text_area :comment, rows: 2, class: "form-control" %>
            </div>
            <div class="text-end">
              <%= form.submit "返信を投稿", class: "btn btn-outline-secondary btn-sm" %>
            </div>
          <% end %>
        <% end %>
      </div>
      <%# 再帰的に呼び出し %>
      <%= render partial: 'replies', locals: { comment: reply } %> 
    </div>
  </div>
<% end %>

point

返信フォームのstyle="display: none;"

style="display: none;" により初期状態では非表示としています。

model: [@post, @post.comments.new]

@post に紐付いた新しいコメントオブジェクトを作成します。

<%= form.hidden_field :parent_id, value: reply.id %>

隠しフィールドとして、返信対象の親コメントのID (parent_id) をフォームに含めます。
これにより、どのコメントに対して返信が行われているかを識別します。

4.viewの編集/追加

コード(レイアウト抜き)
変更前
<% @comments.each do |comment| %>
変更後
<% @comments.where(parent_id: nil).each do |comment| %>

親コメント(返信ではないコメント)のみを取得するため変更!!!
リプは下記で出力。

追加部分のみ メモ付き
 <% if current_user == comment.user %>
  <%= button_to "削除", post_comment_path(@post, comment), method: :delete, data: { confirm: "本当に削除しますか?" }, class: "btn btn-dark" %>
 <% end %>
  <p class="card-text"><%= comment.created_at.strftime("%Y-%m-%d") %></p>

<!-- ログインしててゲストユーザーじゃない場合は返信ボタン表示 -->
<% unless current_user.guest_user? %>
  <button type="button" class="btn btn-secondary reply-button" data-comment-id="<%= comment.id %>">返信</button>
 <% end %>

<!-- 返信フォーム -->
 <div class="reply-form" id="reply-form-<%= comment.id %>">

 <!-- ログインしててゲストユーザーじゃない場合は返信ボタン表示 -->
 <% unless current_user.guest_user? %>
   <%= form_with model: [@post, @post.comments.new], local: true do |form| %>
   <%= form.hidden_field :parent_id, value: comment.id %>
  <div class="mb-2">
   <%= form.label :comment, "返信:", class: "form-label" %>
   <%= form.text_area :comment, rows: 2, class: "form-control" %>
  </div>

    <%= form.submit "返信を投稿", class: "btn btn-secondary" %>
   <% end %>
 <% end %>
</div>

<!-- 返信一覧 -->
 <%= render partial: 'replies', locals: { comment: comment } %>

#bottonのjava部分!!!
<script>
document.addEventListener("DOMContentLoaded", function() {
  document.querySelectorAll('.reply-button').forEach(button => {
    button.addEventListener('click', function() {
      const commentId = this.getAttribute('data-comment-id');
      const replyForm = document.getElementById(`reply-form-${commentId}`);
      replyForm.style.display = replyForm.style.display === 'none' ? 'block' : 'none';
    });
  });
});
</script>
完成コード(post/show)
<% if @comments.any? %>
  <h4>コメント一覧</h4>
  コメント数: <%= @comments.count %><% @comments.where(parent_id: nil).each do |comment| %>
    <div class="card mb-1">
      <div class="card-body">
        <div class="d-flex">
          <% if comment.user.present? && comment.user.profile_image.attached? %>
            <div class="me-3">
              <%= image_tag url_for(comment.user.profile_image), class: "avatar-img rounded-circle mr-4", style: "width: 50px; height: 50px; object-fit: cover;" %>
              <p class="text-center mr-4"><small class="text-muted"><%= comment.user.name if comment.user %></small></p>
            </div>
          <% else %>
            <div class="me-3">
              <%= image_tag "no_image.jpg", class: "avatar-img rounded-circle mr-4", style: "width: 50px; height: 50px; object-fit: cover;" %>
              <p class="text-center mr-4"><small class="text-muted"><%= comment.user.name if comment.user %></small></p>
            </div>
          <% end %>
          <div class="flex-grow-1">
            <p class="card-text"><small class="text-muted"><%= comment.comment.gsub(/\n/, '<br>').html_safe %></small></p>
          </div>
          
          <div id="star_<%= @post.id %>_<%= comment.id %>"></div>
          <script>
            $(document).on('turbolinks:load', function() {
              let elem = document.querySelector('#star_<%= @post.id %>_<%= comment.id %>');
              if (elem == null) return;
              
              elem.innerHTML = "";
              let opt = {  
                starOn: "<%= asset_path('star-on.png') %>",
                starOff: "<%= asset_path('star-off.png') %>",
                score: '<%= comment.star %>',
                readOnly: true,
              };
              raty(elem, opt);
            });
          </script>
          
          <div class="ms-auto d-flex flex-column align-items-end">
            <% if current_user == comment.user %>
              <%= button_to "削除", post_comment_path(@post, comment), method: :delete, data: { confirm: "本当に削除しますか?" }, class: "btn btn-outline-dark btn-sm mb-2" %>
            <% end %>
            <p class="card-text mb-0"><small class="text-muted"><%= comment.created_at.strftime("%Y-%m-%d") %></small></p>
            <% unless current_user.guest_user? %>
              <button type="button" class="btn btn-sm btn-outline-secondary mt-2 reply-button" data-comment-id="<%= comment.id %>">返信</button>
            <% end %>
          </div>
        </div>
        
        <!-- 返信フォーム -->
        <div class="reply-form" id="reply-form-<%= comment.id %>" style="display: none;">
          <% unless current_user.guest_user? %>
            <%= form_with model: [@post, @post.comments.new], local: true do |form| %>
              <%= form.hidden_field :parent_id, value: comment.id %>
              <div class="mb-2">
                <%= form.label :comment, "返信:", class: "form-label" %>
                <%= form.text_area :comment, rows: 2, class: "form-control" %>
              </div>
              <div class="text-end">
                <%= form.submit "返信を投稿", class: "btn btn-outline-secondary btn-sm" %>
              </div>
            <% end %>
          <% end %>
        </div>

        <!-- 返信一覧 -->
        <%= render partial: 'replies', locals: { comment: comment } %>
        
      </div>
    </div>
  <% end %>
<% else %>
  <p>まだコメントはありません。</p>
<% end %>
</div>

<script>
document.addEventListener("DOMContentLoaded", function() {
  document.querySelectorAll('.reply-button').forEach(button => {
    button.addEventListener('click', function() {
      const commentId = this.getAttribute('data-comment-id');
      const replyForm = document.getElementById(`reply-form-${commentId}`);
      replyForm.style.display = replyForm.style.display === 'none' ? 'block' : 'none';
    });
  });
});
</script>
render解説
post/show
 <%= render partial: 'replies', locals: { comment: comment } %>   
_replies.html.erb
<%= render partial: 'replies', locals: { comment: reply } %>

このように同じ共通テンプレートを引数変えて再利用したいときにこの書き方をする
locals: { comment: reply }ここで.each do |comment|のcommentに何の引数渡すか決める
_replies.html.erbの方はリプへのリプだから引数がreplyになる。

全部の感想/ポイント

あまり共通テンプレート使いたくない波なんですけど(1ページで完結させたい)今回みたいに
『コメントに対する返信に対する返信を表示する』となると階層が変わりすぎてうまく動作しないらしく素直に共通テンプレート使用しました🐥
2箇所(共通とshowページ)返信フォームがあるからコード量はめちゃくや多くてうまくまとめられなかった。

Discussion