【Rails6 非同期でコメント機能実装（投稿・削除）slim】

#実装内容
Ajaxを用いて非同期処理を行い、コメント機能を実装していきます。

- コメント作成
- コメントの削除
- コメントの表示（開閉式ボタン）

#完成形はこちら
####コメントの投稿と削除
![d30b2f501ab9d58b154d22b517b73501.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1131643/d67633b9-48a9-b50b-c508-7e04e8f9ca02.gif)

####コメントの表示（開閉ボタン）
![3bf2c378f1ba6d8df9b384da9dcfead0.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1131643/b9e7dc37-79b6-cfa4-eef0-cd228416e5e8.gif)

#環境
`macOS Big Sur 11.2.3`
`ruby: 2.7.2`
`rails: 6.1.3`
`テンプレートエンジン: slim`
`レイアウト: Bootstrap4`

#DB設計（ER図）
users : travel_records = １対多
users : comments = １対多
travel_records : comments = １対多
comments = usersとravel_recordsの中間テーブル
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1131643/228394c7-b1e8-9436-a040-0463725cf554.png)

#モデルの作成
UserモデルとTravelRecordモデルは作成した前提で勧めていきます。

```
% rails g model Comment comment:text user_id:integer travel_record_id:integer
% rails db:migrate
```
上記実装後の結果↓

```rails:db/schema.rb
  create_table "comments", force: :cascade do |t|
    t.text "comment"
    t.integer "user_id"
    t.integer "travel_record_id"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end
```
#関連付け（アソシエーション）
####Userモデル

```app/models/user.rb
class User < ApplicationRecord
  has_many :travel_records, dependent: :destroy
  has_many :comments, dependent: :destroy
end
```

####TravelRecordモデル

```app/models/travel_record.rb
class TravelRecord < ApplicationRecord
  belongs_to :user
  has_many :comments, dependent: :destroy
end
```

####Commentモデル

```app/models/comment.rb
class Comment < ApplicationRecord
  belongs_to :user
  belongs_to :travel_record
  validates :comment, presence: true, length: { maximum: 300 }
end
```

`dependent: :destroy`を付与することで、「親モデルを削除する際に、その親モデルに紐づく「子モデル」も一緒に削除できる」ようになります。
今回の場合、userがtarvel_record(投稿記事)を削除すると、その投稿記事に投稿されたコメントも一緒に削除されます。

`Commentモデル`には、空白では投稿できないようにする`presence: true`とコメントの長さ(最大300文字)を制限する`length: { maximum: 300 }`をバリデーションとして掛けています。

#コントローラの作成

```
% rails g controller comments 
```
#ルーティングの作成

```config/routes.rb
Rails.application.routes.draw do
  resources :users
  resources :travel_records do
    resources :comments, only: [:create, :destroy]
  end
end
```

travel_recordsコントローラへのルーティングの記述の中に、commentsコントローラをネストすることで、どの投稿に紐付いたコメントなのかをURLが判別できるようにしています。

上記の記述により生成されるURLは、**「/travel_records/id/comments」**になります。

もっと具体的に言うと、とある投稿記事にコメントすると「/travel_records/13/comments」のようなURLが生成されます。この「13」という数字は投稿記事のid番号です。
**つまり、このコメントは13番目のツイートに対するコメントですよということが、URLから判断することができます。**

また、`only: [:create, :destroy]`としているのはコメントの投稿と削除のみを利用するためです。

####ルーティング設定についての要注意ポイント
ルーティングを自動生成してくれるメソッドとして「resourcesメソッド」と「resourceメソッド」が存在します。
複数形（resources）と単数形（resource）の違いなのですが、**resourceメソッド**は、コントローラの７つのアクションに対して、**indexとid付きのパスが生成されません。**
そのため設定する際は、**resourcesメソッド(複数形)でルーティングを設定しないと正常に動作しないので注意が必要です。**

#コントローラの編集
###travel_recordsコントローラ

```app/controllers/tavel_records_controller.rb
class TravelRecordsController < ApplicationController

  def show
    @travel_record = TravelRecord.find(params[:id])
    # コメント一覧表示で使用する全コメントデータを代入（新着順で表示）
    @comments = @travel_record.comments.order(created_at: :desc)
    # コメントの作成
    @comment = Comment.new
```

`order(created_at: :desc)`を付与することで、コメントを新着順（降順）で表示することができます。

###commentsコントローラ

```app/controllers/comments_controller.rb
class CommentsController < ApplicationController
  def create
    # コメントをする対象の投稿(travel_record)のインスタンスを作成
    @travel_record = TravelRecord.find(params[:travel_record_id])
    #投稿に紐づいたコメントを作成
    @comment = @travel_record.comments.new(comment_params)
    # コメント投稿者(user)のidを代入
    @comment.user_id = current_user.id
    if @comment.save
      flash.now[:notice] = "コメントの投稿に成功しました。"
      render :index
    else
      flash.now[:alert] ="コメントの投稿に失敗しました。"
      render :index
    end
  end

  def destroy
    @comment = Comment.find(params[:id])
    @comment.destroy
    flash.now[:notice] = "コメントを削除しました。"
    render :index
  end

  private
    def comment_params
      params.require(:comment).permit(:comment, :user_id, :travel_record_id)
    end
end
```

コメントが保存がされると、`render :index`によって`app/views/comments/index.js.erb`を探しにいきます。
#ビューの編集
###travel_records/show（投稿詳細ページ）

```app/views/travel_records/show.html.slim
#comments_area
  = render 'comments/index', comments: @comments
- if user_signed_in?
  = render 'comments/form', travel_record: @travel_record, comment: @comment
```
`#comments_area`をターゲットにid内をAjaxで書き換えます。
コメント一覧とコメント入力フォームはそれぞれパーシャルにしています。

 `= render`の後の `'comments/index'`がパーシャル名で`comments: @comments`がローカル変数名: インスタンス変数名になります。
最初は理解するのに苦労しました（すいません、現在もの間違いでした、、）

複数箇所で使用するコードはパーシャル化しておくと、可読性と汎用性の向上につながります（下記参考）

https://qiita.com/takannporo/items/a8ff93109afc3bc3bab4

###comments/_index.html.slim（コメント一覧）

```app/views/comments/_index.html.slim
/ ----- 'travel_records/show'で使用中 -----
h5 コメント一覧（#{comments.count}件)
hr
= render 'layouts/flash_messages'
- comments.first(2).each do |comment|
  = render 'comments/hide_comments', comment: comment
/ ----- コメントが3件以上の場合、残りのコメントをプルダウンで表示するボタンの作成 -----
- comments.offset(2).any?
  .text-right
    button.btn.btn-primary[data-toggle="collapse" data-target="#collapseExample" aria-expanded="false" aria-controls="collapseExample"]
      | もっと見る....
.collapse#collapseExample
  - comments.where(parent_id: nil).offset(2).each do |comment|
    = render 'comments/hide_comments', comment: comment
```
`（#{comments.count}件)`でコメント数を表示しています。

each文の`comments.first(2)`で最初の2件、`comments.offset(2)`で最初の2件以外を取ってきています。

コメントが3件以上の場合、通常は隠しておき、開閉式の「もっと見る....」ボタンをクリックすることで残りのコメントを表示させるようにしています。
また、`comments.offset(2)`の後に`.any?`というメソッドを付与することでコメントが3件以上の場合のみ、ボタンを表示させています。

開閉ボタンは**Bootstrap4のCollapse**というコンポーネントを使用しています（下記参考）

https://cccabinet.jpn.org/bootstrap4/components/collapse

###comments/_hide_comments.html.slim

```app/views/comments/_hide_comments.html.slim
/ ----- 'comments/_index'で使用中 -----
.container
  / ユーザー詳細の表示
  .row.d-flex.align-items-center.pb-3
    .col
      - if comment.user.profile_image?
        = link_to image_tag(comment.user.profile_image.thumb.url), user_path(comment.user.id)
      - else
        = link_to image_tag('no_profile_img.png'), user_path(comment.user.id)
      = link_to "#{comment.user.name}さん", user_path(comment.user.id), class: 'link pl-1'
    .col.d-flex.justify-content-end
      = comment.created_at.strftime('%Y/%m/%d')
  / コメントの表示
  .card-body.border.rounded.mb-3
    = comment.comment
  hr.d-none
    - if comment.user == current_user
      = link_to travel_record_comment_path(comment.travel_record_id, comment.id), method: :delete, remote: true do
        .text-right
          button.btn.btn-outline-secondary
            | 削除する
  hr
```
**BootstrapのCard**というコンポーネントを使用し、その中でコメントを表示させています（下記参考）

https://getbootstrap.jp/docs/4.2/components/card/

コメントの削除機能ですが、まずif文でコメント投稿者のみに表示されるようにしております。

`= link_to`内に`remote: true`を付与することで、commentsコントローラのdestroyアクションから**htmlファイルではなく、jsファイルを探しに行ってくれるようになります。**

また、第2引数のパス「`travel_record_comment_path`」には引数として「`comment.travel_record_id, comment.id`」を付与しており、これは**投稿のid**と**コメントのid**を渡すためのものでコメントを削除するために必要になります。

###comments/_form.html.slim（コメント投稿フォーム）

```app/views/comments/_form.html.slim
/ ----- 'travel_records/show'で使用中 -----
= form_with model: [travel_record, comment], local: false do |f|
  .form-group
    = f.label "コメントを投稿する"
    = f.text_area :comment, :size => "3x3", autocomplete: 'off', placeholder: "コメントを入力してください", class: 'form-control'
  .text-right
    = f.submit "コメントする", class: 'btn btn-warning text-white'
```

`= form_with`内の`model: [travel_record, comment]`は、**`travel_records/show（投稿詳細ページ）`**の`= render 'comments/form', travel_record: @travel_record, comment: @comment`で渡しているインスタンスのローカル変数です。投稿に紐付いたコメントを生成するため、必要になります。

**⚠ここで一番のつまずきポイントに遭遇！**

それが、`= form_with`内に付与されている`local: false`というオプションです。
実はRails 6.1 から仕様が変わり， `local: true` がデフォルトになってしまったため、jsファイルを呼び出すには `local: false` が必要になってしまいました。
そうとは知らず、削除できないと嘆きながら時間のみが溶けていったのは、良い思い出なので一旦置いときます（下記参考）

https://techracho.bpsinc.jp/hachi8833/2021_01_22/103256

###comments/index.js.erb（create,destroy兼用）

```app/views/comments/index.js.erb
$("#comments_area").html("<%= j(render 'index', { comments: @comment.travel_record.comments.order(created_at: :desc)}) %>");
$("textarea").val('');
```

このjsファイルで**`travel_records/show（投稿詳細ページ）`**の`#comments_area`とした箇所を書き換える処理を記載しています。

`$("#comments_area")`で`#comments_area`をターゲットとし、`j(render 'index'`で指定しているパーシャル「`comments/_index.html.slim`」の内容を書き換えています。

`{ comments: @comment.travel_record.comments.order(created_at: :desc)　}`は、`@comment.travel_record.comments.order(created_at: :desc)`をローカル変数の`comments`に渡しています。

`@comment.travel_record.comments`は、コメント一覧表示に必要な全てのコメントが格納されています。また、`.order(created_at: :desc)`を付与することで非同期でも新着順（降順）で表示されるようになります。

そして、`$("textarea").val('')`でコメント投稿後のコメント入力フォームを空にしています。

#最後に
以上で非同期でのコメント投稿機能（投稿・削除）の実装になります！

自分も陥った開発環境の違いから、正常に動作せず、苦労することも多々あると思うのですが、諦めずに実装できたときの喜びもひとしおです！

また、近日にはコメント返信機能も投稿予定ですので、興味がある方は是非ご覧下さい。

間違っている箇所や分かりづらい箇所が多々あるかと思います。
その際は、気軽にコメントいただけれると幸いです:pray:

最後までご覧いただき、ありがとうございました!

#参考記事

https://qiita.com/yuto_1014/items/c7d6213139a48833e21a

https://ysk-pro.hatenablog.com/entry/2018/02/10/101739