【Rails】少しだけ楽！Bootstrapのモーダルを使って通知機能を作成する(いいね・コメント・フォロー)

こんばんは！
PF作成でモーダルを使った通知に憧れ、記事を参考にしてみたもの非同期通信や難しいJavascriptの記述が理解できず、エラー沼から抜け出せなくなったので、もう簡単にできるモーダル通知を作るしかない！と思いBootstrapを使って実装してみました。なので少しだけ楽！な実装です（笑）

ちなみに確かに元の記事と比べると簡単にはなりますが、記述箇所が多くて大変なのでなるべく丁寧に解説できるように紹介をしていきたいと思います。
この記事ではまずは通知を表示させるというところまで解説します。

::: note warn
通知の既読などはもし実装に余裕があれば載せようかとおもいますので、ご了承ください。また、元の記事を一度参考にして作った状態から色々作り直しているので、いらない記述などがもしあれば教えていただきたいです。
→**2024/3/12に通知数の表示と既読処理の追記をしました！**
:::

参考記事はこちら。

https://zenn.dev/ganmo3/articles/7afaff32809681

## 完成形・実装時の諸注意
完成形はこちら！ベルマークを押すと通知が表示されます。また、通知を開いて詳細にいくと通知が減ります(通知を見た瞬間に0になる仕様ではないのでご注意ください。)。
![スクリーンショット 2024-03-12 232639.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3677302/cd9a930d-5a67-adea-e803-8f81219fc789.png)


![スクリーンショット 2024-03-11 195352.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3677302/573d6202-dfc9-47ee-417d-8edfca703bd2.png)

### 実装時の注意

::: note warn
投稿(post)→目標(goal)、いいね(favorite)→応援(cheer)、名前(name)→ニックネーム(nickname)などモデルやカラム名が一般的なものと異なる場合があるので、適宜変更をお願いします！
:::

::: note warn
今回はヘッダーの部分テンプレートに記述しています。いいね、コメント、フォロー機能のうちどれかできていれば実装可能です。
:::

::: note warn
Bootstrap最新版(ver.5以降)を導入しています。他のバージョンでは不具合が出る可能性があります！その場合は公式を見て導入するか、過去記事に書いているのでそちらの通りに導入してください。
:::

## モデル作成
まずは通知用のNotificationモデルを作成。
```terminal: ターミナル
rails g model Notification 
```
マイグレーションファイルに記入。
``` db/migrate/xxxxx_notifications.rb
class CreateNotifications < ActiveRecord::Migration[6.1]
  def change
    create_table :notifications do |t|
      t.integer :visitor_id, null: false
      t.integer :visited_id, null: false
      t.integer :goal_id　#適宜変更
      t.integer :comment_id
      t.integer :cheer_id　#適宜変更
      t.integer :relationship_id
      t.string :action, null: false, default: ''
      t.boolean :is_checked, null: false, default: false
      t.timestamps
    end
  end
end
```

:::note info
解説
visitor_id: 通知を作った人(いいね、コメント、フォローをした人本人)
visited_id: 通知を受け取る人
goal_id: 投稿の外部キー(適宜post_idなどに変更してください)
cheer_id: いいねの外部キー(適宜favorite_idなどに変更してください)
relationship_id: フォローやフォロワーを管理するテーブルの外部キー
is_checked: 通知を確認したかしていないか
:::

ここですかさず
```terminal:ターミナル
rails db:migrate
```

## モデル関連づけ
### user.rb
```ruby:app/models/user.rb
has_many :active_notifications, class_name: "Notification", foreign_key: "visitor_id", dependent: :destroy
has_many :passive_notifications, class_name: "Notification", foreign_key: "visited_id", dependent: :destroy
```
### 各モデル.rb(いいね、コメント、relationshipなど)
```ruby:app/models/xxxxx.rb
has_many :notifications, dependent: :destroy
```

### notification.rb

::: note warn
goal→post, cheer→favoriteなど名称を適宜変えてください
:::
```ruby:app/models/xxxxx.rb
class Notification < ApplicationRecord
  default_scope -> { order(created_at: "DESC") }

  belongs_to :goal, optional: true <!-- -->
  belongs_to :comment, optional: true
  belongs_to :cheer, optional: true　
  belongs_to :relationship, optional: true

  belongs_to :visitor, class_name: "User", foreign_key: "visitor_id", optional: true
  belongs_to :visited, class_name: "User", foreign_key: "visited_id", optional: true

end
```

## コントローラ―作成
コントローラー作成。今回は部分テンプレートのみ使用し、コントローラ―のアクションの記述は~~一切書きません~~少し記述します。部分テンプレートに必要なコントローラの記述は後で入れ込みます。あくまでviewファイルを入れるだけの存在です。ルーティングすらいりません。
```terminal:ターミナル
rails g controller notifications
```

## モデルに通知のメソッド作成
フォローに関する通知
```rb: app/model/user.rb
 # フォロー通知を作成するメソッド
def create_notification_follow!(current_user)
    # すでにフォロー通知が存在するか検索

    existing_notification = Notification.find_by(visitor_id: current_user.id, visited_id: self.id, action: 'follow')

    # フォロー通知が存在しない場合のみ、通知レコードを作成
    if existing_notification.blank?
      notification = current_user.active_notifications.build(
        visited_id: self.id,
        action: 'follow'
      )
      notification.save if notification.valid?
    end
  end
  ```
いいねの通知
```rb: app/model/post.rbなど
  # postへのいいね通知機能
 def create_notification_favorite_post!(current_user)
   # 同じユーザーが同じ投稿に既にいいねしていないかを確認
   existing_notification = Notification.find_by(post_id: self.id, visitor_id: current_user.id, action: "favorite_post")
   
   # すでにいいねされていない場合のみ通知レコードを作成
   if existing_notification.nil? && current_user != self.user
     notification = Notification.new(
       post_id: self.id,
       visitor_id: current_user.id,
       visited_id: self.user.id,
       action: "favorite_post"
     )

     if notification.valid?
       notification.save
     end
   end
 end
```
コメントの通知
```app/model/comment.rb
  # コメントが投稿された際に通知を作成するメソッド
 def create_notification_comment!(current_user, comment_id)
   # 自分以外にコメントしている人をすべて取得し、全員に通知を送る
   other_commenters_ids = Comment.select(:user_id).where(post_id: id).where.not(user_id: current_user.id).distinct.pluck(:user_id)

   # 各コメントユーザーに対して通知を作成
   other_commenters_ids.each do |commenter_id|
     save_notification_comment!(current_user, comment_id, commenter_id)
   end

   # まだ誰もコメントしていない場合は、投稿者に通知を送る
   save_notification_comment!(current_user, comment_id, user_id) if other_commenters_ids.blank?
 end

 # 通知を保存するメソッド
 def save_notification_comment!(current_user, comment_id, visited_id)
   notification = current_user.active_notifications.build(
     post_id: id,
     comment_id: comment_id,
     visited_id: visited_id,
     action: 'comment'
   )

   # 自分の投稿に対するコメントの場合は、通知済みとする
   notification.is_checked = true if notification.visitor_id == notification.visited_id

   # 通知を保存（バリデーションが成功する場合のみ）
   notification.save if notification.valid?
 end
 ```
 ## コントローラ―での通知作成
 フォロー通知
```rb: app/controller/relationships_controller.rb
class Public::RelationshipsController < ApplicationController

def create
:
  # フォロー通知を作成・保存
  @user.create_notification_follow!(current_user)
 end
: 
end
```
いいね通知
```rb:app/controller/post_favoretes_controller.rb
class Public::PostFavoritesController < ApplicationController

　def create
  :
   if current_user != @post.user
     @post.create_notification_favorite_post!(current_user)
   end
  :
end
```
コメント通知
```rb: app/controller/comments_controleer
class Public::CommentsController < ApplicationController
  def create
　:　
    # コメントの投稿に対する通知を作成・保存
    @post.create_notification_comment!(current_user, @comment.id)
  :
  end
end
```

## notificationコントローラ―
```rb: app/controller/notifications_controller.rb
class Public::NotificationsController < ApplicationController
  before_action :authenticate_user!

  def update_checked
    current_user.passive_notifications.update_all(is_checked: true)
    head :no_content
  end
end
```

## view作成
### ヘッダーにベルマークと通知数を表示させる
```erb: ベルマークを表示させたい場所に記述
<% if user_signed_in? %>
    <button type="button" class="btn btn-light" data-bs-toggle="modal" data-bs-target="#headerModal">
        <i class="fa-regular fa-bell rounded-pill"></i
        <span class="badge badge-danger"><%= current_user.passive_notifications.where(is_checked: false).count %></span>
    </button>
<% end %>
```
### モーダルの表示
```erb: ベルマークを表示させたhtml.erbファイルの一番下にモーダルを追記
<div class="modal fade" id="headerModal" tabindex="-1" aria-labelledby="headerModalLabel" aria-hidden="true">
  <div class="modal-dialog modal-dialog-scrollable">
    <div class="modal-content">
      <div class="modal-header">
        <h1 class="modal-title fs-5" id="headerModalLabel">通知一覧</h1>
        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
      </div>
      <div class="modal-body">
        <% if user_signed_in? %>
          <%= render 'notifications/index' %>
        <% end %>
      </div>
      <div class="modal-footer">
        <button type="button" class="btn btn-secondary rounded-pill" data-bs-dismiss="modal">閉じる</button>
      </div>
    </div>
  </div>
</div>     
```

:::note warn
注意！ベルの記述したファイルの最下部で、タグが閉じ終わったところの真下にモーダルの記述を追記しないと、親子関係がうまくいかずモーダルとして機能しません！ベルの真下には間違っても書かないように！
:::

### モーダルに表示したい内容を部分テンプレートで作る
notification配下に_index.html.erbを作成します。ヘッダーでまずここにrenderしています。部分テンプレートに今回は記述しているので、コントローラ―の定義は一番上の<% %>内に記述しています。
```erb: app/view/notifications/_index.html.erb
<%
    @notices = current_user.passive_notifications.order(created_at: :desc)
    @unchecked_notifications = @notices.where(is_checked: false)

    # 確認済みの通知を取得
    @checked_notifications = @notices.where(is_checked: true).limit(20)

    # 通知を確認済みに更新
    current_user.passive_notifications.update_all(is_checked: true)
%>
<% notifications = @unchecked_notifications.where.not(visitor_id: current_user.id) %>
<% if (@unchecked_notifications.present? && @unchecked_notifications.any?) || (@checked_notifications.present? && @checked_notifications.any?) %>
  <% if @unchecked_notifications.present? %>
    <%= render "notifications/notification", notifications: @unchecked_notifications %>
  <% end %>

  <% if @checked_notifications.present? %>
    <%= render "notifications/notification", notifications: @checked_notifications %>
  <% end %>
<% else %>
  <p>通知はありません</p>
<% end %>
```
_index.html.erbからのrender先の_notification.html.erbを作成後記述します。

::: note warn
通知のlink_toのパスを自分のパスと一致しているか確認してください。
goal→post, cheer→favorite, nickname→nameなどに書き換えてください。
imageなどは今回get_profile_imageというように定義を使って呼び出しているので、ご自身の定義もしくはimageなどに置き換えてください。
通知で表示する文章は適宜変更してください。
:::

```erb: app/notifications/_notification.html.erb
<% notifications.each do |notification| %>
  <% visitor = notification.visitor %>
  <% visited = notification.visited %>
  <div class="col-md-12 mx-auto notification-container">
    <div class="notification-box d-flex align-items-center">
      <div class="notification-icon">
        <%= link_to user_path(visitor), data: { turbolinks: false } do %>
          <%= image_tag visitor.get_profile_image, size: '40x40', class: 'rounded-circle mr-3' %>
        <% end %>
      </div>

      <!-- 通知の種類に応じて表示内容を切り替え -->

      <!-- フォロー通知の場合 -->
      <% if notification.action == 'follow'  %>
        <strong><%= visitor.nickname %></strong>さんがあなたをフォローしました

      <!-- 投稿がいいねされた通知の場合 -->

      <% elsif notification.goal && notification.action == 'cheer_goal' %>
        <%= link_to goal_path(notification.goal) do %>
          <strong><%= visitor.nickname %></strong>さんが<strong><%= truncate(notification.goal.title, length: 10) %></strong>の目標を応援しました
        <% end %>

      <!-- コメントが投稿された通知の場合 -->
      <% elsif notification.action ==  'comment' %>
        <!-- 自分自身の投稿へのコメントの場合 -->
        <% if notification.goal && notification.goal.user_id == visited.id %>
          <%= link_to goal_path(notification.goal) do %>
            <strong><%= visitor.nickname %></strong>さんが<strong><%= truncate(notification.goal.title, length: 10) %></strong>の目標にコメントしました
            <% comment = Comment.find_by(id: notification.comment_id) %>
            <div class="notification-comment">
              <%#= truncate(comment, length: 30) %>
            </div>
          <% end %>

        <!-- 投稿者が異なり自身がコメントした投稿に他のユーザーがコメントした場合 -->
        <% else %>
          <span>
            <% if notification.goal %>
              <%= link_to goal_path(notification.goal) do %>
                <strong><%= visitor.nickname %></strong>さんが<strong><%= notification.goal.user.nickname + 'さんの投稿' %></strong>にコメントしました
                <% comment = Comment.find_by(id: notification.comment_id) %>
                <div class="notification-comment">
                  <%#= truncate(comment, length: 30) %>
                </div>
              <% end %>
            <% end %>
          </span>
        <% end %>
      <% end %>

    </div>
    <div class="small text-muted text-right">
      <%= time_ago_in_words(notification.created_at).upcase %>前
    </div>
  </div>
<% end %>
```

実装はこれで以上です！お疲れ様です！
長かったと思いますし、おそらく色々置き換える場所が多くて最初はエラーが出ると思いますが、少しは楽に実装できることを願っています。
初心者ですので間違っているところなどあればお気軽にコメントなどでご指摘ください。

## 追記
もし通知の何時間前などの数字がうまく表示されない場合、config/locales/ja.ymlファイルに記述してください。
```ja.yml
ja:
  time:
    formats:
      default: "%Y/%m/%d %H:%M"
  datetime:
    distance_in_words:
      half_a_minute: 半分
      less_than_x_seconds:
        one:   1秒未満
        other: "%{count}秒未満"
      x_seconds:
        one:   1秒
        other: "%{count}秒"
      less_than_x_minutes:
        one:   1分未満
        other: "%{count}分未満"
      x_minutes:
        one:   1分
        other: "%{count}分"
      about_x_hours:
        one:   約1時間
        other: 約%{count}時間
      x_days:
        one:   1日
        other: "%{count}日"
      about_x_months:
        one:   約1ヶ月
        other: 約%{count}ヶ月
      x_months:
        one:   1ヶ月
        other: "%{count}ヶ月"
      about_x_years:
        one:   約1年
        other: 約%{count}年
      over_x_years:
        one:   1年以上
        other: "%{count}年以上"
```

そもそも日本語設定してない、i18nのgemを入れていないという方はこちらの記事を参考にしてみてください。deviseの日本語化の記事ではありますが、最初の流れは同じです。何ならこの機会にdeviseの日本語化もしてしまいましょう(どちらにせよ日本語化するとdeviseで日本語訳できませんというエラーが出るので)。時間の記述と違ってja.ymlファイルの記述ではないのでそこは注意してください。

https://qiita.com/mirimu/items/aba6d60c27fa9f9d33d5