掲示板にコメント機能を付与する方法


掲示板にコメント機能を付与する方法
モデルの関係は以下のようになっている f:id:kosukerino:20210512034918p:plain

[コメントモデルの作成]
$ rails generate model comment body:text


作成されたマイグレーションファイルにNOT NULL制約を追加

[db/migrate ファイル]

def change
    create_table :comments do |t|
      t.text :body, null:false

      t.timestamps
    end
end



$ rails db:migrate

$ rails generate migration AddForeignToComments

上記で作成したマイグレーションファイルに外部キーを付ける

[db/migrate ファイル]

def up
    add_reference :comments, :user, foreign_key: true
    add_reference :comments, :board, foreign_key: true
end

def down
    remove_reference :comments, :user
    remove_reference :comments, :board
end



$ rails db:migrate

上記のCommentsテーブルの作成と外部キーの付与の工程は、Commentsテーブルを作成する時に以下のコマンドと手順でも可能。 $ rails generate model comment body:text user:references board:references

$ raild db:migrate

下記のようにコメントのbody属性にNOT NULL制約を付与する。

[db/migrate ファイル]

def change
    create_table :comments do |t|
      t.text :body, null: false
      t.references :user, foreign_key: true
      t.references :board, foreign_key: true

      t.timestamps
    end
end


[アソシエーションの設定]

[app/models/user.rb]

has_many :boards, dependent: :destroy
has_many :comments, dependent: :destroy
[app/models/board.rb]

belongs_to :user
has_many :comments, dependent: :destroy
[app/models/comment.rb]

belongs_to :user
belongs_to :board


[ルーティングの設定]

ルーティングをネスト化する事によって、モデルの親子関係をルーティングで表せる。
ルーティングのネスト化の記載方法と生成されるURLは、以下のようになる。

[config/routes.rb]

resources :boards do
    resources :comments
end


[生成されるパスとそれぞれのアクション]

HTTPメソッド パス コントローラ#アクショ
GET /boards/:board_id/comments comments#index
GET /boards/:board_id/comments/new comments#new
POST /boards/:board_id/comments comments#create
GET /boards/:board_id/comments/:id comments#show
GET /boards/:board_id/comments/:id/edit comments#edit
PATCH/PUT /boards/:board_id/comments/:id comments#update
DELETE /boards/:board_id/comments/:id comments#destroy

※上記のパスの[/boards/:board_id/comments]の[:board_id]は、boardに格納されているidの事。

上記の表からも分かる通り、全部のアクションのパスに[:board_id]が入っている。commentsコントローラのshowアクション、editアクション、updateアクション、destroyアクションにもパスに[:board_id]が入ってしまっているが、これらは[:board_id]情報が必要ない。 ルーティングをネスト化する際には、ネスト化をやりすぎると「深い」ネストになってしまい分かりにくくなってしまう為、ネスト化をするものは必要最低限に留め「浅い」ネストにするのが好ましい。 浅いネスト化のルーティングは、以下のように記載できる。

[config/routes.rb]

resources :articles do
  resources :comments, only: [:index, :new, :create]
end
resources :comments, only: [:show, :edit, :update, :destroy]

また、上記と同じ内容を以下のように記載することもできる。

[config/routes.rb]

resources :articles do
  resources :comments, shallow: true
end

また必要なアクションに限定して以下のように記載することもできる。

[config/routes.rb]

resources :boards, only: %i[index new create show] do
    resources :comments, only: %i[create], shallow: true
end


[コントローラの設定]
Commentsコントローラを作成 $ rails generate controller comments

commentを保存する際に、外部キー[user_id,board_id]を格納するのを忘れないようにする。

[app/controllers/comments_controller.rb]

def create
    @comment = current_user.comments.new(comment_params)
    if @comment.save
      redirect_to board_url(@comment.board_id), success: (t '.success')
    else
      redirect_to board_url(@comment.board_id), danger: (t '.fail')
    end
  end

  private

  def comment_params
    params.require(:comment).permit(:body).merge(board_id: params[:board_id])
  end



掲示板詳細ページのコントローラの設定 掲示板の詳細ページからコメントフォーム欄とコメントエリアのビューをrenderしているので、render先のビューで使うインスタンス変数も忘れないように記載する。

[app/controllers/boards_controller.rb]

 def show
    @board = Board.find_by(id: params[:id])
    @comment = Comment.new
    @comments = @board.comments.includes(:user).order(created_at: :desc)
end



掲示板詳細のビューとrenderしているビュー

[app/views/boards/show.html.erb]

<div class="container pt-5">
  <div class="row mb-3">
    <div class="col-lg-8 offset-lg-2">
      <h1>掲示板詳細</h1>
      <!-- 掲示板内容 -->
      <article class="card">
        <div class="card-body">
          <div class='row'>
            <div class='col-md-3'>
              <%= image_tag @board.image.thumb.url, class: "card-img-top img-fluid", width: "300", height: "200" %>
            </div>
            <div class='col-md-9'>
              <h3 style='display: inline;'><%= @board.title %></h3>
              <%= render 'crud_menus', { board: @board } %>
              <ul class="list-inline">
                <li class="list-inline-item"><%= @board.user.decorate.full_name %></li>
                <li class="list-inline-item"><%= l @board.created_at %></li>
              </ul>
            </div>
          </div>
          <p><%= simple_format(@board.body) %></p>
        </div>
      </article>
    </div>
  </div>

  <!-- コメントフォーム -->
  <%= render partial: 'comments/form', locals: { board: @board, comment: @comment } %>

  <!-- コメントエリア -->
    <%= render 'comments/comments', { comments: @comments } %>
</div>

以下の編集や削除する際のアイコンは、様々な場所で使うので切り離してrenderで利用できるようにする。

[app/views/boards/_crud_menus.html.erb]

<div class='mr10 float-right'>
  <%= link_to '#', id: 'button-edit-#{board.id}' do %>
    <%= icon 'fa', 'pen' %>
  <% end %>
  <%= link_to '#', id: 'button-delete-#{board.id}', method: :delete, data: {confirm: ''} do %>
    <%= icon 'fas', 'trash' %>
  <% end %>
</div>

コメントのフォーム欄のビュー

[app/views/comments/_form.html.erb]

<div class="row mb-3">
<div class="col-lg-8 offset-lg-2">
  <%= form_with model: [@board, @comment], local: true do |f| %>
    <%= render 'shared/error_messages', object: f.object %>
    <div class="form-group">
      <%= f.label :body %>
      <%= f.text_area :body, class: 'form-control' %>
    </div>
    <%= f.submit (t 'defaults.post'), class: 'btn btn-primary' %>
  <% end %>
</div>
</div>

コメントエリアのビュー(render commentsでrenderに変数(コメント一覧)を渡していることでrenderしている)

[app/views/comments/_comments.html.erb]

<div class="row">
  <div class="col-lg-8 offset-lg-2">
    <table id="js-table-comment" class="table">
      <%= render comments %>
    </table>
  </div>
</div>
[app/views/comments/_comment.html.erb]

<tr id="comment-<%= comment.id %>">
  <td style="width: 60px">
    <%= image_tag "sample.jpg", class: "rounded-circle", width: "50", height: "50" %>
  </td>
  <td>
    <h3 class="small"><%= comment.user.decorate.full_name %></h3>
    <div id="js-comment-25">
    <%= simple_format(comment.body) %>
    </div>
    <div style="display: none;">
      <textarea class="form-control mb-1">コメントです</textarea>
      <button class="btn btn-light">キャンセル</button>
      <button class="btn btn-success">更新</button>
    </div>
  </td>

  <% if current_user.own?(comment) %>
    <td class="action">
      <ul class="list-inline justify-content-center" style="float: right;">
        <li class="list-inline-item">
          <a href="#"><i class='fa fa-pencil-alt'></i></a>
        </li>
        <li class="list-inline-item">
          <a href="#">
            <i class="fa fa-trash"></i>
          </a>
        </li>
      </ul>
    </td>
  <% end %>
</tr>


※注意
コメントエリアのビューを以下のように記載してしまうと、インスタンス変数@commentsでrenderしているため、render対象の[app/views/comments/_comment.html.erb]が何個も生成されるため、1つ1つのコメントエリアに対して、

となってしまい、以下のようなテストコードがあった場合に
が複数となってしまい、どの id="js-table-comment" か分からなくなってしまいテスト失敗となる。 Image from Gyazo

[app/views/boards/show.html.erb]

<div class="container pt-5">
  <div class="row mb-3">
    <div class="col-lg-8 offset-lg-2">
      <h1>掲示板詳細</h1>
      <!-- 掲示板内容 -->
      <article class="card">
        <div class="card-body">
          <div class='row'>
            <div class='col-md-3'>
              <%= image_tag @board.image.url, class: "card-img-top img-fluid", width: "300", height: "200" %>
            </div>
            <div class='col-md-8'>
              <h3 style='display: inline;'><%= @board.title %></h3>
              <ul class="list-inline">
                <li class="list-inline-item"><%= @board.user.decorate.full_name %></li>
                <li class="list-inline-item"><%= l @board.created_at %></li>
              </ul>
            </div>
            <% if current_user == @board.user %>
              <div class='col-md-1'>
                <td class="action">
                  <ul class="list-group list-group-horizontal" style="float: right;">
                    <li class="list-group-item">
                      <a href="#"><i class='fa fa-pencil-alt'></i></a>
                    </li>
                    <li class="list-group-item">
                      <a href="#">
                        <i class="fa fa-trash"></i>
                      </a>
                    </li>
                  </ul>
                </td>
              </div>
            <% end %>
          </div>
          <p><%= @board.body %></p>
        </div>
      </article>
    </div>
  </div>

  <!-- コメントフォーム -->
  <%= render partial: 'comments/form', locals: { board: @board, comment: @comment } %>

  <!-- コメントエリア -->
  <%= render @comments %>
</div>
[app/views/_comment.html.erb]

<div class="row">
    <div class="col-lg-8 offset-lg-2">
      <table id="js-table-comment" class="table">
        <tr id="comment-<%= comment.id %>">
          <td style="width: 60px">
            <%= image_tag "sample.jpg", class: "rounded-circle", width: "50", height: "50" %>
          </td>
          <td>
            <h3 class="small"><%= comment.user.decorate.full_name %></h3>
            <div id="js-comment-25">
              <p><%= comment.body %></p>
            </div>
            <div style="display: none;">
              <textarea class="form-control mb-1">コメントです</textarea>
              <button class="btn btn-light">キャンセル</button>
              <button class="btn btn-success">更新</button>
            </div>
          </td>

          <% if current_user == comment.user %>
            <td class="action">
              <ul class="list-inline justify-content-center" style="float: right;">
                <li class="list-inline-item">
                  <a href="#"><i class='fa fa-pencil-alt'></i></a>
                </li>
                <li class="list-inline-item">
                  <a href="#">
                    <i class="fa fa-trash"></i>
                  </a>
                </li>
              </ul>
            </td>
          <% end %>
        </tr>
      </table>
    </div>
  </div>
[spec ファイル]

require 'rails_helper'

RSpec.describe 'コメント', type: :system do
  let(:me) { create(:user) }
  let(:board) { create(:board) }
  let(:comment_by_me) { create(:comment, user: me, board: board) }
  let(:comment_by_others) { create(:comment, board: board) }

  describe 'コメントのCRUD' do
    before do
      comment_by_me
      comment_by_others
    end
    describe 'コメントの一覧' do
      it 'コメントの一覧が表示されること' do
        login_as_user me
        visit board_path board
        within('#js-table-comment') do
          expect(page).to have_content(comment_by_me.body), 'コメントの本文が表示されていません'
          expect(page).to have_content(comment_by_me.user.decorate.full_name), 'コメントの投稿者のフルネームが表示されていません'
        end
      end
    end

    describe 'コメントの作成' do
      it 'コメントを作成できること', js: true do
        login_as_user me
        visit board_path board
        fill_in 'コメント', with: '新規コメント'
        click_on '投稿'
        comment = Comment.last
        within("#comment-#{comment.id}") do
          expect(page).to have_content(me.decorate.full_name), '新規作成したコメントの投稿者のフルネームが表示されていません'
          expect(page).to have_content('新規コメント'), '新規作成したコメントの本文が表示されていません'
        end
      end
      it 'コメントの作成に失敗すること', js: true do
        login_as_user me
        visit board_path board
        fill_in 'コメント', with: ''
        click_on '投稿'
        expect(page).to have_content('コメントを作成できませんでした'), 'コメント作成失敗時のエラーメッセージ「コメントを作成できませんでした」が表示されていません'
      end
    end

    describe 'コメントの編集' do
      context '他人のコメントの場合' do
        it '編集ボタン・削除ボタンが表示されないこと' do
          login_as_user me
          visit board_path board
          within("#comment-#{comment_by_others.id}") do
            expect(page).not_to have_selector('.js-edit-comment-button'), '他人のコメントに対して編集ボタンが表示されてしまっています'
            expect(page).not_to have_selector('.js-delete-comment-button'), '他人のコメントに対して削除ボタンが表示されてしまっています'
          end
        end
      end
    end
  end
end

[ユーザーのコメントであるかを判定するメソッドの記載] ロジックをControllerやViewでなく、Modelに記載して呼び出すことで、メンテナンス時にModelだけの変更で済むようにする為。

[app/models/user.rb]

def own?(object)
    self.id == object.user_id
end

上記は、以下を略したもの。(selfレシーバは省略して記載するのが一般的) インスタンスメソッド内でselfレシーバを使うと実行対象のインスタンス変数の情報を参照できる。(current_user.own?(comment)の場合は、レシーバがcurrent_userになっているので、selfにcurrent_userが入る。)

[app/models/user.rb]

def own?(object)
    id == object.user_id
end

以下のように使うことができる。

 <% if current_user.own?(comment) %>


※注意
以下のように記載してしまうと、commentモデルにある user_id を使って Usersテーブルから対象のレコードを取ってきてしまい無駄なSQLが発行されてしまう為、[id == object.user_id]のようにして、commentインスタンスが持っている user_id を使って比較しているに過ぎないのでSQLが発行されないようにする。

[app/models/user.rb]

def own?(object)
    object.user == current_user
end

saveメソッドで失敗した時にrenderする場合は、render先のインスタンス変数を定義しないといけない。(redirect_toなら再度、掲示板のshowアクションを通りそこにインスタンス変数が定義されているが、renderはアクションを通らずに直接showのビューに行く為。)

[app/controllers/comments_controller.rb]

def create
    @board = Board.find_by(id: params[:board_id])
    @comment = current_user.comments.new(comment_params)
    @comments = @board.comments.includes(:user).order(created_at: :desc)
    if @comment.save
      redirect_to board_url(@comment.board_id), success: (t '.success')
    else
      flash.now[:danger] = (t '.fail')
      render template: 'boards/show'
    end
end

private

def comment_params
    params.require(:comment).permit(:body).merge(board_id: params[:board_id])
end

simple_formatとは? 改行を含む文章をsimple_formatに渡してやれば、自動的に改行(
)に置き換えてくれる。 ex) simple_format(sample_text)

参考記事: capybara で複数マッチする要素の最初のフォーム要素にアクションする - Qiita

Railsヘルパーメソッド「simple_format」の使い方 - Qiita

Railsのヘルパーメソッド「simple_format」について調べて見た - とんてき

【Rails】form_with/form_forについて【入門】 - Qiita