[Rails] 掲示板にコメント機能を実装する

学習記録

はじめに

掲示板詳細画面の追加と、掲示板に対してコメント機能を持たせます。

  • 掲示板一覧から掲示板詳細画面へ遷移できるようにしましょう。
  • 掲示板詳細画面から、コメントを書き込めるようにしましょう。
  • コメントの文字数は65535字までにモデルで制限しましょう。
  • 書き込んだコメントは、掲示板詳細画面の下部に降順に表示されるようにしましょう。
  • コメントした本人だけ削除・編集ボタンを表示するよう制限しましょう。

作業の流れ

  • Commentモデルを作成
  • ルーティングの設定
  • commentsコントローラを作成
  • boards_controllerへ追記
  • 掲示板の編集と削除のボタンは部分パーシャル化する
  • 個々の掲示板表示パーシャル内で編集と削除ボタンのパーシャルを組み込む
  • 掲示板詳細画面の作成
  • コメントフォームのパーシャルを作成
  • コメント一覧部分のパーシャルを作成
  • 各コメント部分のパーシャルを作成

Commentモデルを作成

UserとBoardモデルに関連付けさせるには、referencesで指定します。

なぜなら、普通の$ bundle exec rails generate model Comment body:text user_id:integer board_id:integer という書き方では、

  • インデックスが貼られない。
  • 外部キー制約が貼られない。

などの問題が生じるからです。

外部キーを作るときは基本的にはreferences型を使うということを肝に銘じておきましょう。

$ bundle exec rails generate model Comment body:text user:references board:references

マイグレーションファイルにNOT NULL制約をつける

作成されたマイグレーションファイルを確認し、

  • 空で投稿できないようにNOT NULL制約を追記する。
  • 外部キー制約が付与されている事を確認

class CreateComments < ActiveRecord::Migration[5.2]
  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
end

外部キー制約とは

主キーと外部キーを使った制約で利用した場合、下記の制限が入る。

  1. 存在しない値を外部キーとして登録することはできない
  2. 子テーブルの外部キーに値が登録されている親テーブルのレコードは削除できない

外部キー制約のメリット・デメリット

メリット

  • 存在しない値が外部キーとして登録されることを防ぐことができる
    • データの整合性が保てる
  • うっかり親テーブルのレコードを消しちゃった。なんてことがなくなり、子テーブルのレコードの親子関係がバグることがない

デメリット

  • 親テーブルのレコード削除がめんどくさいかも
    • Railsでは dependent オプションがあるのでそんなに大変ではない印象
  • 設定を間違えた際に、意図せず重要なレコードが消える可能性がある
  • 大量のデータを抱えた親レコードがあっても、外部キー制約があると分割して消すことが難しいため、削除の負荷を分散できない
  • DBを跨いだ制約はかけることができない

マイグレ-ションを実行する

$ bundle exec rails db:migrate

各モデルの関連付け

class User < ApplicationRecord

  has_many :boards, dependent: :destroy
  has_many :comments, dependent: :destroy
end

class Board < ApplicationRecord
  belongs_to :user
  has_many :comments, dependent: :destroy
end

commentモデルには空が投稿できないようにと、文字制限するためのバリデーションを追加します。

class Comment < ApplicationRecord
	belongs_to :user
	belongs_to :board

	validates :body, presence: true, length: { maximum: 65_535 }
end

ルーティングの設定

ルーティングをネストして、boardscommentsとの親子関係を作ります。

今回必要なのはcreateアクションだけなので、%i[create] とします。

Rails.application.routes.draw do
  root 'static_pages#top'

  get 'login', to: 'user_sessions#new'
  post 'login', to: 'user_sessions#create'
  delete 'logout', to: 'user_sessions#destroy'

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

shallowオプションは生成されるルーティングをスッキリさせる事ができます。詳しくはこちらを確認しておきましょう。

https://railsguides.jp/routing.html#「浅い」ネスト

今回はcreateアクションしか無いのでshallowオプションの恩恵は無いと言えますね。

commentsコントローラを作成

以下のコマンドを入力することでcomments_controller が生成されます

$ bundle exec rails g controller comments

comments_controllerへ追記

createアクションにはview画面が無いので、インスタンス変数にしてません。

今回は失敗時にもredirectを採用しています。

render :new のようにするときは、new.html.erbにインスタンス変数を渡す必要がありました。

今回はコントローラ内で処理が完結している為、ローカル変数を採用しています。

class CommentsController < ApplicationController
	def create
		comment = current_user.comments.build(comment_params)
			if comment.save
				redirect_to board_path(comment.board), success: t('defaults.message.created', item: Comment.model_name.human)
			else
				redirect_to board_path(comment.board), danger: t('defaults.message.not_created', item: Comment.model_name.human) 
	end

	private

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

コードを紐解く

comment = current_user.comments.build(comment_params)

「ログインしているユーザ」に紐付いた「コメントデータ」を初期化し、comment に代入しています。

すなわち、current_userid(ログインしているユーザに対するuser_id) も初期化して代入していることになる。


comment = Comment.new(comment_params.merge(uesr_id: current_user.id))

こちらの書き方も同じ意味になりますが、前者のほうがRailsっぽい書き方になります。

前者を使うようにしましょう。

参考

# GOOD  アソシエーション活用
comment = current_uesr.comments.build(comment_params)

# BAD  初期化した後に値を代入
comment = Comment.new(comment_params)
comment.user_id = current_user.id

# BAD  初期化する際のパラメータをmerge
comment = Comment.new(comment_params.merge(user_id: current_user.id))

※ Build = new
buildはnewのエイリアス
慣習的にアソシエーション先のモデルを作成する時はbuildが使われる


def comment_params
	params_require(:commeent).permit(:body).merge(board_id: params[:board_id])
end

コメントを保存する際は、関連付けしているuser_idboard_idも一緒に保存する必要があります。

ここの部分は、ストロングパラメータとして生成される際に、入力した値(body)と一緒に、board_idも保存するという内容です。

board_id: params[board_id] はurlから取得しています。

commentsコントローラのcreateアクションに対するルーティング設定は、POSTメソッドのboards/:board_id/comments で、ヘルパーメソッドはboards_comments_path となります。

ここからboard_id をパラーメータとして取得しています。


boards_controllerへ追記

掲示板詳細画面(show.html.erb)にて、@boardと@comment、@commentsを渡します。

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

includesメソッドで関連付けを一括読み込みをする

includesは、関連付けられたテーブルのデータを参照するメソッドです。このメソッドを使うと、あるモデルからデータを取得する際に、関連付けされたモデルのデータも一緒に取得してくれます。

# GOOD 
@comments = @board.comments.includes(:user).order(created_at: :desc)
# BAD  N+1問題が起こる書き方
@comments = @board.comment.order(created_at: :desc)

掲示板の編集と削除のボタンは部分パーシャル化する

掲示板の編集と削除のボタンは、掲示板の一覧と詳細ページで同じものを表示するので、部分テンプレートとして作成しておきます。

<ul class='crud-menu-btn list-inline float-right'>
  <li class="list-inline-item">
    <%= link_to '#', id: "button-edit-#{board.id}" do %>
      <%= icon 'fa', 'pen' %>
    <% end %>
  </li>
  <li class="list-inline-item">
    <%= link_to '#', id: "button-delete-#{board.id}" do %>
      <%= icon 'fas', 'trash' %>
    <% end %>
  </li>
</ul>

個々の掲示板表示パーシャル内で編集と削除ボタンのパーシャルを組み込む

<%= image_tag board.board_image_url, class: 'card-img-top', size: '300x200' %>
      <div class="card-body">
        <h4 class="card-title">
          <%= link_to board.title, board_path(board) %>
        </h4>
        <%= render 'crud_menus', board: board %>
        <ul class="list-inline">
          <li class="list-inline-item">
            <%= icon 'far', 'user' %>

掲示板詳細画面の作成

<% content_for(:title, @board.title) %>
<div class="container pt-5">
  <div class="row mb-3">
    <div class="col-lg-8 offset-lg-2">
      <h1><%= t('.title') %></h1>
      <!-- 掲示板内容 -->
      <article class="card">
        <div class="card-body">
          <div class='row'>
            <div class='col-md-3'>
              <%= image_tag @board.board_image.url, class: 'card-img-top img-fluid', size: '300x200' %>
            </div>
            <div class='col-md-9'>
              <h3 class="d-inline"><%= @board.title %></h3>
              <%= render 'crud_menus', board: @board %>
              <ul class="list-inline">
                <li class="list-inline-item">by <%= @board.user.decorate.full_name %></li>
                <li class="list-inline-item"><%= l @board.created_at, format: :long  %></li>
              </ul>
            </div>
          </div>
          <p><%= simple_format(@board.body) %></p>
        </div>
      </article>
    </div>
  </div>

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

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

掲示板詳細画面は大まかに3つに分かれています。

  • 掲示板内容表示
  • コメントフォーム
  • コメントエリア

なお、コメントフォームとコメントエリアはパーシャルを呼び出します。


コードを紐解く

<% content_for(:title, @board.title) %>

content_for でタイトルを個別に指定することができます。

application.html.erbで以下の設定をする必要があります。

<title><%= yield(:title) %></title>

詳しくはこちらの記事を参照してください。

【Rails 】content_forとyieldの使い方 - Qiita
#はじめに今回はRailsにおけるcontent_forとyieldの使い方について解説します!コンテンツを1箇所に保存し、他のビューで使えるようにするためのものです!ぜひご覧ください#使い方content_forを説明する前に…

<%= simple_format(@board.body) %>

simple_format はRailsのヘルパーメソッドです。

改行文字を含むテキストをブラウザ上で表示させるときに使います。

404 Not Found - Qiita - Qiita

コメントフォームのパーシャルを作成

原則としてパーシャルは再利用性を高めるためにローカル変数を使用する

インスタンス変数を使うとなると、コントローラと関連付いてしまうため、再利用性が低くなります。

エラーメッセージを呼び込むパーシャルも実装しましょう。

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

コードを紐解く

<%= form_with model: comment, url: [board, comment], local: true do |f| %>
  • model: comment Commentモデルと紐付けます。
  • url: [board, comment] データの送信先を指定しています。
    1. 今回の場合、ルーティングを親子関係でネスト構造になっているので、パスがboard/board_id/commentsとなっています。
    2. よって、データの送信先としてboard も指定してやる必要があります。
    3. また、親であるboardを先に書かないといけません。
    4. なぜなら、先にboard,後にcomment と指定することで初めてデータ送信先としてboard_comments_path となるからです。
  • locale: true:locale オプションは、送信時にAjaxを使わなくします。
    1. 普通にHTMLとしてフォームを送信する場合にlocal: trueが必要になります。

コメント一覧部分のパーシャルを作成

コメント一覧部分は、show.html.erbから呼び出される形になっています。

ファイル名はcommentsと複数形になっていることに注意が必要です。

ここではさらに_comment.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>

各コメント部分のパーシャルを作成

<tr id="comment-<%= comment.id %>">
  <td style="width: 60px">
    <%= image_tag 'sample.jpg', class: 'rounded-circle', size: '50x50' %>
  </td>
  <td>
    <h3 class="small"><%= comment.user.decorate.full_name %></h3>
    <div id="js-comment-id-<%= comment.id %>">
      <%= simple_format(comment.body) %>
    </div>
    <div id="js-textarea-comment-box-<%= comment.id %>" style="display: none;">
      <textarea id="js-textarea-comment-<%= comment.id %>" class="form-control mb-1"><%= comment.body %></textarea>
      <button class="btn btn-light js-button-edit-comment-cancel" data-comment-id="<%= comment.id %>">キャンセル</button>
      <button class="btn btn-success js-button-comment-update" data-comment-id="<%= comment.id %>">更新</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="#" class='js-edit-comment-button' data-comment-id="<%= comment.id %>">
          <%= icon 'fa', 'pen' %>
          </a>
        </li>
        <li class="list-inline-item">
          <a href="#" class='js-edit-comment-button' data-comment-id="<%= comment.id %>">
            <%= icon 'fas', 'trash' %>
          </a>
        </li>
      </ul>
    </td>
  <% end %>
</tr>

なぜ二重にパーシャルを呼び込む必要があるのか?

結論を言えば、each文を使わずにテンプレートを繰り返し表示するためです。

<%= render comments %>

上の書き方は省略形になっています。

書き換えると、

<%= render partial: 'comment', collection: comments %>

となります。

ファイル名と複数形sを除いたオブジェクト名が一致しており、かつeach文のように繰り返し表示した場合は、上記のように省略することができます。

今回だと_comment.html.erbcomments なので、一致していると言えます。

繰り返しになりますが、collectionオプションは、渡されたオブジェクトを、そのテンプレートを繰り返し表示することができるオプションです。

さらに表現を変えると、

<%= render comments %>

という省略形は、

<% comments.each do |comment| %>
	<%= render partial: 'comment', locals: { comment: comment } %>
<% end %>

上記の形に書き換えることもできます。

少しややこしいですが、renderの書き方はいろいろあるので整理してみてください。


コメントした本人だけ削除・編集ボタンを表示する

  • コメントの編集・削除ボタン表示の判定ロジックは、Userモデルにクラスメソッドではなく、インスタンスメソッドとして記載します。
  • ロジックをControllerやViewではなく、Modelに記載して呼び出すことで、メンテナンス時にModelだけの変更で住むようになるだけでなく、Fat Controllerを防ぐことにもなります。
    1. また、Userモデルにインスタンスメソッドとして書くことで、レシーバ(メソッドを実行している側のオブジェクトを指す用語)がUserモデルのインスタンスを対象にしていることが分かりますね。
    2. 「判定ロジックはモデルに記載する」などとルールを決めていくと、後から参照しやすくまとまっているので良いですね。
  • 自分のコメントかどうかの比較は、アソシエーション先のモデルのオブジェクト比較ではなく、idの値で比較するようにしましょう。
    1. comment.user == current_user のように、Userオブジェクトの比較を行うと、commentモデルにあるuser_idを使って、Usersテーブルからアソシエーション先のUserオブジェクトを探してきてしまい、無駄にSQLを発行させてしまいます。
    2. comment.user_id == [self.id](<http://self.id>) だと、commentインスタンスが持っているuser_idを使って比較しているだけなのでSQLが発行されません。
    3. self(呼び出し元のオブジェクトのこと)レシーバは省略するのが一般的な記載方法になります。

user.rb

def my_comment?(comment)
  comment.user_id == id
# comment.user_id == self.id
end

# より汎用的な形がこちら。
def own?(object)
  object.user_id == id
end

なお、セッター以外はself. が省略されていると考えるとよいでしょう。

今回、idはセッターではない為、self.id はUserインスタンスのid を指します。

セッターの使用例は以下のようなケースになります。

class User < ApplicationRecord

  def change_email(email_after)
    self.email = email_after
  end
end

emailに代入する形になっています。


終わりに

非常にボリューミーな実装でしたが、理解を深める一助になれたら幸いです。


参考文献

外部キーの概要と制約を使うことのメリット・デメリット - Qiita
外部キーとはテーブル同士の紐づけに用いるカラムのこと。users テーブル と user_login_histories テーブル が合った時に、 user_login_histories テー…
Rails のルーティング - Railsガイド
Railsのルーティング機能について解説します。Railsアプリケーションで行われているルーティングのしくみについて理解したい場合は、ここからお読みください。

コメント

タイトルとURLをコピーしました