🦓

[Rails]コメント 10/20

2023/06/28に公開

はじめに

投稿の詳細ページにコメント機能を入れていきます。

環境:

Rails 6.1.7.3
ruby 3.0.0

コメントモデルを作成する

bin/rails generate model Comment body:text article:references user:references
     invoke  active_record
      create    db/migrate/20230627133238_create_comments.rb
      create    app/models/comment.rb
db/migrate/xxx_create_comments.rb
class CreateComments < ActiveRecord::Migration[6.1]
  def change
    create_table :comments do |t|
      t.text :body, null: false
      t.references :article, :user, null: false, foreign_key: true
      t.timestamps
    end
  end
end
bin/rails db:migrate
Running via Spring preloader in process 31692
== 20230627133238 CreateComments: migrating ===================================
-- create_table(:comments)
   -> 0.0042s
== 20230627133238 CreateComments: migrated (0.0043s) ==========================

コメントモデルを編集する

アソシエーションとバリデーションを追加します。

app/models/comment.rb
class Comment < ApplicationRecord
    belongs_to :article
    belongs_to :user
    validates :body. presence: true, length: { maximum: 65_535 }
end
app/models/user.rb
class User < ApplicationRecord
...
    has_many :articles, dependent: :destroy
    has_many :comments, dependent: :destroy
end
app/models/article.rb
class Article < ApplicationRecord
...
    has_many :comments, dependent: :destroy
    belongs_to :user
end

ネストしたリソースを作成する

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

config/routes
resources :articles do
  resources :comments, only: %i[create]
end

コントローラーを作成する

bin/rails generate controller Comments
      create  app/controllers/comments_controller.rb
      invoke  erb
      create    app/views/comments
      invoke  decorator
      create    app/decorators/comment_decorator.rb

コメントコントローラーにCRUDアクションを追加します。

app/controllers/comments_controller.rb
class CommentsController < ApplicationController
    def create
        comment = Current.user.comments.build(comment_params)

        if comment.save
          flash[:success] = t('defaults.message.created', item: Comment.model_name.human)
          redirect_to article_path(comment.article)
        else
          flash[:danger] = t('defaults.message.not_created', item: Comment.model_name.human)
          redirect_back fallback_location: article_path(comment.article)
        end
    end
    
    private

    def comment_params
        params.require(:comment).permit(:body).merge(article_id: params[:article_id])
    end
end
# 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))
article_comments POST   /articles/:article_id/comments(.:format)       comments#create

コメントを作成してみます。

irb(main):001:0> user = User.first
   (0.8ms)  SELECT sqlite_version(*)
  User Load (0.1ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> #<User id: 1, user_name: "test_user", email: "user@sample.com", password_digest: [FILTERED], created_at: "2023-06-15 17:14...
irb(main):002:0> article = Article.first
  Article Load (0.1ms)  SELECT "articles".* FROM "articles" ORDER BY "articles"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> #<Article id: 1, title: "hello", body: "rails", created_at: "2023-06-22 16:46:49.176551000 +0900", updated_at: "2023-06-22...
irb(main):003:0> comment = article.comments.build(user_id:1, body:"test comment")
=> #<Comment id: nil, body: "test comment", article_id: 1, user_id: 1, created_at: nil, updated_at: nil>
irb(main):004:0> comment.save
  TRANSACTION (0.1ms)  begin transaction
  User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  Comment Create (0.9ms)  INSERT INTO "comments" ("body", "article_id", "user_id", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?)  [["body", "test comment"], ["article_id", 1], ["user_id", 1], ["created_at", "2023-06-27 16:02:19.999866"], ["updated_at", "2023-06-27 16:02:19.999866"]]
  TRANSACTION (0.7ms)  commit transaction
=> true
irb(main):005:0> comment = Comment.first
  Comment Load (0.2ms)  SELECT "comments".* FROM "comments" ORDER BY "comments"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> #<Comment id: 1, body: "test comment", article_id: 1, user_id: 1, created_at: "2023-06-28 01:02:19.999866000 +0900", updat...

irb(main):004:0> comment = article.comments.build(body:"test comment")
=> #<Comment id: nil, body: "test comment", article_id: 1, user_id: nil, created_at: nil, updated_at: nil>
irb(main):005:0> comment.save
=> false
irb(main):006:0> comment.errors.full_messages
=> ["Userを入力してください"]

Articleコントローラーを編集する

showアクションにコメントのインスタンス変数を渡します。

app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def show
    @article = Article.find_by(id: [params[:id]])
    @comment = Comment.new
    @comments = @article.comments.includes(:user).order(created_at: :desc)

    if @article.nil?
      flash[:danger] = "記事が見つかりませんでした。"
      redirect_to root_path
    end
  end
 end

N+1問題を回避する

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

# GOOD 
@comments = @board.comments.includes(:user).order(created_at: :desc)

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

コメントフォームを作成する

コメントフォームをテンプレート化して投稿の詳細ページに読み込みます。

app/views/comments/_form.html.erb
<%= form_with model: [article, comment] do |form| %>
  <div class="mb-3">
    <%= form.label :body %>
    <%= form.text_area :body, class: 'form-control' %>
  </div>
  <div class="text-end">
    <%= form.submit class: 'btn btn-primary btn-sm' %>
  </div>
<% end %>

ローカル変数にインスタンス変数を渡します。

app/views/articles/show.html.erb
<%= render 'comments/form', { article: @article, comment: @comment } %>

コメント一覧ビューを作成する

コメント一覧もテンプレート化して投稿の詳細ページにコメントフォームの下に読み込みます。
コメントした人だけコメントの編集と削除を表示させます。
simple_formatはRailsのヘルパーメソッドです。
改行文字を含むテキストをブラウザ上で表示させるときに使います。

app/views/comments/_comments.html.erb
<% @article.comments.each do |comment| %>
  <div class="card mb-4" id="comment-<%= comment.id %>">
    <div class="card-body">
      <div class="d-flex justify-content-between mb-3">
        <p><%= simple_format(comment.body) %></p>
        <% if comment.user_id == Current.user.id %>
          <ul class='crud-menu-btn list-inline float-right'>
            <li class="list-inline-item">
              <%= link_to '#', id: "button-edit-#{comment.id}" do %>
                <%= icon 'fa', 'pen' %>
              <% end %>
            </li>
            <li class="list-inline-item">
              <%= link_to '#', id: "button-delete-#{comment.id}" do %>
                <%= icon 'fas', 'trash' %>
              <% end %>
            </li>
          </ul>
        <% end %>
      </div>
      <div class="d-flex justify-content-between">
        <div class="d-flex flex-row align-items-center">
          <%= image_tag 'profile.jpg',size:'25x25' %>
          <p class="small mb-0 ms-2"><%= comment.user.user_name %></p>
        </div>
        <div class="d-flex flex-row align-items-center">
          <p class="small text-muted mb-0"><%= l(comment.created_at, format: :long) %></p>
        </div>
      </div>
    </div>
  </div>
<% end  %>

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

each文を使わずにテンプレートを繰り返し表示したいのでコメント一覧部分のパーシャルを作成します。

app/views/comments/_comments.html.erb
<%= render comments %>

ここではさらに_comment.html.erbを呼び出しています。

app/views/comments/_comment.html.erb
<div class="card mb-4" id="comment-<%= comment.id %>">
  <div class="card-body">
    <div class="d-flex justify-content-between mb-3">
      <p><%= comment.body %></p>
      <% if Current.user.own?(comment) %>
        <ul class='crud-menu-btn list-inline float-right'>
          <li class="list-inline-item">
            <%= link_to '#', id: "button-edit-#{comment.id}" do %>
              <%= icon 'fa', 'pen' %>
            <% end %>
          </li>
          <li class="list-inline-item">
            <%= link_to '#', id: "button-delete-#{comment.id}" do %>
              <%= icon 'fas', 'trash' %>
            <% end %>
          </li>
        </ul>
      <% end %>
    </div>
    <div class="d-flex justify-content-between">
      <div class="d-flex flex-row align-items-center">
        <%= image_tag 'profile.jpg',size:'25x25' %>
        <p class="small mb-0 ms-2"><%= comment.user.user_name %></p>
      </div>
      <div class="d-flex flex-row align-items-center">
        <p class="small text-muted mb-0"><%= l(comment.created_at, format: :long) %></p>
      </div>
    </div>
  </div>
</div>

二重にパーシャル

ファイル名と複数形sを除いたオブジェクト名が一致しており、かつeach文のように繰り返し表示した場合は、このように省略することができます。
今回だと_comment.html.erbcommentsなので一致してます。
collectionオプションは、渡されたオブジェクトを、そのテンプレートを繰り返し表示することができるオプションです。

app/views/comments/_comments.html.erb
<%= render comments %>

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

<% comments.each do |comment| %>
   <%= render partial: 'comment', locals: { comment: comment } %>
<% end %>
app/views/articles/show.html.erb
# コメントフォーム
<%= render 'comments/form', { article: @article, comment: @comment } %>
# コメント一覧
<%= render 'comments/comments', { comments: @comments } %>

比較用の汎用メソッドを作成する

投稿モデルとコメントモデルに共通の判定ロジックなので、Userモデルにまとめていきます。

ロジックをControllerやViewではなく、Modelに記載して呼び出すことで、メンテナンス時にModelだけの変更で住むようになるだけでなく、Fat Controllerを防ぐことにもなります。

app/models/user.rb
class User < ApplicationRecord
def own?(object)
  object.user_id == id
end

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

ビューにインスタンスメソッドを書き換えます。
すっきりになりましたね。

# before
<% if Current.user && Current.user.id == article.user_id %>
<% if comment.user_id == Current.user.id %>

# after
<% if Current.user.own?(@article) %>
<% if Current.user.own?(comment) %>

localeファイルを編集する

コメント関連の翻訳を追加します。

config/locales/activerecord/ja.yml
ja:
  activerecord:
    models:
      comment: 'コメント'
    attributes:
      comment:
        body: 'コメント'

終わりに

モデルの関連付けが複雑になりましたのでMVCの仕組みを理解した上でどのような実装をすべきだったか改めて学び直しました。

Discussion