Turbo✖️Stimulusを使用したモーダルでのレビュー機能を実装してみた

# 初めに
初めまして。Jintaと申します。
今回は、Turbo✖️Stimulusを使用して、モーダルでのレビュー機能を実装したので、学習の記録として残します。TurboまたはStimulusを使用して開発をされる方のお役に立てますと幸いです。

# 完成形
![d0e3f0dd459ffd1d7c8841f8fa99a16a.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2600084/7b47f96c-aed2-3480-b6bd-2337a1af3b36.gif)

# 開発環境
- Ruby: 3.2.2
- Rails: 7.0.6
- stimulus
- css: tailwind css

# Turboとは
Turboは、JavaScriptのライブラリで以下の３つの技術で構成されています。

- Turbo Drive
- Turbo Frames
- Turbo Streams

今回は、非同期で部分的なページの更新を実現するTurbo Framesの一部である、`turbo_frame_tag `を使用して実装します。`turbo_frame_tag`で囲った箇所だけ非同期に更新することができます。


# Stimulusとは
Stimulusは、JavaScriptのフレームワークの一種で、HTMLのdata属性を使用して、Stimulusのコントローラ（Railsのコントローラとは別物です。）やアクションを指定することで、DOM操作ができるものです。
複雑なJavaScriptのライブラリやフレームワークを必要とせず、Railsとの統合が簡単なため、シンプルに対話的な動作の機能を実装できます。

# 実装手順
## ①テーブル作成
今回のテーブルの設計は以下のとおりです。
![1fe372a2b90c3fdf68e69e970da8959c.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2600084/592d40f8-94b4-d6f8-ea18-3e8a5bab664c.png)
テーブルを作成したら、モデルでの関連付けを行います。
```Ruby:user.rb
class User < ApplicationRecord
  has_many :reviews, dependent: :destroy
end
```
```Ruby:shop.rb
class Shop < ApplicationRecord
  has_many :reviews, dependent: :destroy
end
```
```Ruby:review.rb
class Review < ApplicationRecord
  belongs_to :user
  belongs_to :shop
end
```

## ②ルーティングの設定
```Ruby:config/routes.rb
Rails.application.routes.draw do
  resources :shops, only: %i[show] do 
    resources :reviews, only: %i[new create destroy edit update destroy], shallow: true
  end
end

```

## ③レイアウトに`turbo_frame_tag`を追加する
`application.erb`の`head`部分に追加します。
```erb:app/views/layouts/application.erb
<!DOCTYPE html>
<html>
  <head>
    <%= turbo_frame_tag 'review_modal' %>
  </head>
  <body>
    <%= yield %>
  </body>
</html>
```


## ④stimulusのインストール
Rails7では、デフォルトでインストールされていると思いますが、インストールされているか確認します。
```Ruby:Gemfile
gem 'stimulus-rails'
```

## ⑤Stimulusのコントローラーの作成
ターミナルで以下のコマンドを打つとコントローラーが作成されます。コントローラー名はなんでも結構です。今回は、`review_modal`にしたいと思います。
※何度も言いますが、Railsのコントローラーではありません。
```shell
$ bin/rails g stimulus review_modal
```
app/javascript/controllers配下に`review_modal_controller.js`ファイルができているはずです。
作成されたコントローラを下記のとおり編集します。
※各アクションの挙動はコメントアウトしています。
```js:app/javascript/controllers/review_modal_controller.js
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="review-modal"
export default class extends Controller {
  //ターゲットの定義
  static targets = ["reviewModal", "backGround"]
  //connectメソッドは、コントローラに繋がれた時に呼ばれるアクション（モーダルが開かれた時）
  connect() {
  }
  // フォームを送信した時に発火させるアクション
  close(event) {
    // event.detail.successは、レスポンスが成功ならtrueを返します。
    // バリデーションエラー時は、falseを返します。
    if (event.detail.success) {
      //"hidden"クラスを追加し、モーダルを閉じます。
      this.backGroundTarget.classList.add("hidden");
    }
  }
  // ただただ、モーダルを閉じるアクション
  closeModal() {
    //"hidden"クラスを追加し、モーダルを閉じます。
    this.backGroundTarget.classList.add("hidden");
  }
  // モーダルの外をクリックした際に、モーダルを閉じるアクション
  closeBackground(event) {
    //アクションを呼び出しているターゲットとbackGroundTargetが同じ場合はtrueを返す。（モーダルの外をクリックしているか）
    if(event.target === this.backGroundTarget) {
      //closeModalアクションを呼び出す。
      this.closeModal();
    }
  }
}

```

## ⑥レビューの作成
### レビュー作成ボタンの作成
ショップの詳細画面にレビューを作成するので、以下のビューファイルにレビュー作成フォーム（モーダル）を開くボタンを作成します。ボタンをクリックすると、モーダルが開きます。
```erb:app/views/shops/show.html.erb
<%= link_to 'レビューを書く', new_shop_review_path(@shop), class: 'btn btn-sm md:btn-md btn-neutral', data: { turbo_frame: 'review_modal' } %>
```
### 作成フォームの作成
ビューファイルで以下のdata属性を指定して、挙動を操作します。
- data-controller属性（どのコントローラーに繋げるか）
- data-[コントローラー名]-target属性（ターゲットの指定）
- data-action（コントローラーのどのアクションを呼び出すか）

```erb:app/views/reviews/new.html.erb
<%= turbo_frame_tag "review_modal" do %>
  <%# data-review-modal-target="backGround"で、モーダル外だと判別する %>
  <%# data-action="click->review-modal#closeBackground"で、モーダル外をクリックすると,
closeBackgroundアクションを呼び出すよう指定 %>
  <div class="fixed inset-0 flex items-center justify-center z-50 bg-gray-500 bg-opacity-50" data-controller="review-modal" data-review-modal-target="backGround" data-action="click->review-modal#closeBackground">
    <%# data-review-modal-target="reviewModal"で、モーダル内だと判別する %>
    <div class="modal-content bg-white rounded-xl shadow-lg p-10 w-18 md:w-[450px]" data-review-modal-target="reviewModal">
      <div class="flex justify-end items-center">
        <%# data-action="click->review-modal#closeModal"で、iタグをクリックすると、closeModalアクションを呼び出すよう指定 %>
        <i class="fa-solid fa-xmark h-6 w-6 hover:text-yellow-500" data-action="click->review-modal#closeModal"></i>
      </div>
      <%# form_withにdata属性を指定し、フォームが送信されたらcloseアクションを呼び出すよう指定 %>
      <%= form_with model: @review, url: shop_reviews_path(@shop), data: { action: "turbo:submit-end->review-modal#close" }, method: :post do |f| %>
        <%= render 'shared/error_messages', object: f.object %>
        <div class="max-w-lg mx-auto p-2 md:p-6 bg-white rounded-xl space-y-2 md:space-y-4">
          <%= f.label :rating, class: 'block font-medium text-sm text-gray-700' %>
          <%= f.select :rating, Review.ratings.invert, class: 'rounded-xl p-2 border border-gray-300' %>

          <div class="form-control">
            <%= f.label :content, class: 'block font-medium text-sm text-gray-700' %>
            <%= f.text_area :content, class: 'w-full rounded-xl p-2 border border-gray-300' %>
          </div>

          <%= f.submit '投稿する', class: 'w-full py-2 px-4 bg-yellow-500 text-white rounded-xl hover:bg-yellow-600 focus:outline-none focus:ring focus:ring-yellow-200' %>
        </div>
      <% end %>      
    </div>
  </div>
<% end %>
```

### 作成したレビューの表示
表示するレビューについては、パーシャルで切り分けて作成します。
```erb:app/views/reviews/_review.html.erb
<%= turbo_frame_tag review do %>
  <div class="border-b border-gray-400 py-5 mx-5">
    <div class="flex mx-2 items-center">      
      <%= image_tag 'logo.png', class: 'w-9 h-9 rounded-full' %>
      <div class="ml-2 text-sm flex flex-col">
        <p class="text-[10px] md:text-sm"><%= review.user.name %></p>
        <p class="text-[10px] md:text-sm text-gray-500"><%= l review.created_at, format: :simple %></p>
      </div>
    </div>
    <div class="flex flex-col mt-1.5 mb-1.5 p-2 mx-2 border rounded-lg border-gray-300">
      <div class="flex items-center mb-2">
        <i class="fa-solid fa-star text-yellow-500 mr-2"></i>
        <p class="text-[10px] md:text-sm"><%= review.rating_as_number %></p>
      </div>
      <p class="text-[10px] md:text-sm"><%= review.content %></p>
    </div>
  </div>
<% end %>

```
上記のパーシャルを、ショップ詳細画面で呼び出します。
```erb:app/views/shops/show.html.erb
<div class="card-body border-t border-gray-400 p-5 md:p-10">
  <div class="flex justify-center items-center h-full">
    <div class="card w-full bg-base-200 shadow-xl">
      <p class="pl-10 py-4 md:py-7 text-sm md:text-base border-b border-gray-500">レビュー</p>
        <div id="reviews">
          <%= turbo_frame_tag 'review_modal' do %>
            <%= render @reviews %>
          <% end %>
        </div>
        <% if user_signed_in? %>
          <div class="flex justify-end mx-10 mb-5">
            <%= link_to 'レビューを書く', new_shop_review_path(@shop), class: 'btn btn-sm md:btn-md btn-neutral', data: { turbo_frame: 'review_modal' } %>
          </div>
        <% end %>
    </div>
  </div>
</div>
```

### レビュー作成までのコントローラは以下のとおり
```Ruby:app/controllers/reviews_controller.rb
class ReviewsController < ApplicationController
  before_action :set_shop, only: %i[new create more]

  def new
    @review = Review.new
  end

  def create
    @review = current_user.reviews.build(review_params)
    if @review.save
      flash.now.notice = "レビューを投稿しました"
      render turbo_stream: [
        turbo_stream.prepend("reviews", @review),
        turbo_stream.update("flash", partial: "shared/flash")
      ]
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def review_params
    params.require(:review).permit(:rating, :content).merge(shop_id: params[:shop_id])
  end

  def set_shop
    @shop = Shop.find(params[:shop_id])
  end
end

```
```Ruby:app/controllers/shops_controller.rb
class ShopsController < ApplicationController
  skip_before_action :authenticate_user!

  def show
    @shop = Shop.find(params[:id])
    @reviews = @shop.reviews.includes(:user).order(created_at: :desc)
  end
end

```
ここまでで、レビューの作成を実装することができたと思います。
![d0e3f0dd459ffd1d7c8841f8fa99a16a.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2600084/7b47f96c-aed2-3480-b6bd-2337a1af3b36.gif)
続いて、編集、削除も実装してみましょう。

## ⑦レビューの編集、削除
### 編集、削除用のボタンの作成
編集、削除ボタンはパーシャルに切り出して作成します。
```erb:app/views/reviews/_crud_menus.html.erb
<%= link_to edit_review_path(review), data: { turbo_frame: 'review_modal' } do %>
  <i class="fa-solid fa-pencil text-blue-500 hover:text-blue-700 mr-2"></i>
<% end %>
<%= link_to review, data: { turbo_method: :delete, turbo_confirm: "本当に削除しますか？" } do %>
  <i class="fa-solid fa-trash text-blue-500 hover:text-blue-700"></i>
<% end %>
```
上記のパーシャルをレビューを表示しているパーシャルで呼び出す。
```erb:app/views/reviews/_review.html.erb
<%= turbo_frame_tag review do %>
  <div class="border-b border-gray-400 py-5 mx-5">
    <div class="flex mx-2 items-center">      
      <%= image_tag 'logo.png', class: 'w-9 h-9 rounded-full' %>
      <div class="ml-2 text-sm flex flex-col">
        <p class="text-[10px] md:text-sm"><%= review.user.name %></p>
        <p class="text-[10px] md:text-sm text-gray-500"><%= l review.created_at, format: :simple %></p>
      </div>
      <% if user_signed_in? %>
        <%# current_user.own?はカスタムメソッドです（自身のレビューかを判別しています） %>
        <% if current_user.own?(review) %>
          <div class="ml-auto">
            <%= render 'reviews/crud_menus', review: review %>
          </div>
        <% end %>
      <% end %>
    </div>
    <div class="flex flex-col mt-1.5 mb-1.5 p-2 mx-2 border rounded-lg border-gray-300">
      <div class="flex items-center mb-2">
        <i class="fa-solid fa-star text-yellow-500 mr-2"></i>
        <p class="text-[10px] md:text-sm"><%= review.rating_as_number %></p>
      </div>
      <p class="text-[10px] md:text-sm"><%= review.content %></p>
    </div>
  </div>
<% end %>
```
### 編集フォームの作成
指定しているdata属性は、作成の時とほとんど同じです。
```erb:app/views/reviews/edit.html.erb
<%= turbo_frame_tag 'review_modal' do %>
  <div class="fixed inset-0 flex items-center justify-center z-50 bg-gray-500 bg-opacity-50" data-controller="review-modal" data-review-modal-target="backGround" data-action="click->review-modal#closeBackground">
    <div class="modal-content bg-white rounded-xl shadow-lg p-10 w-18 md:w-[450px]" data-review-modal-target="reviewModal">
      <%= form_with model: @review, url: review_path(@review), data: { action: "turbo:submit-end->review-modal#close" }, method: :patch do |f| %>
        <%= render 'shared/error_messages', object: f.object %>
        <div class="max-w-lg mx-auto p-2 md:p-6 bg-white rounded-xl space-y-2 md:space-y-4">
          <%= f.label :rating, class: 'block font-medium text-sm text-gray-700' %>
          <%= f.select :rating, Review.ratings.invert, class: 'rounded-xl p-2 border border-gray-300' %>

          <div class="form-control">
            <%= f.label :content, class: 'block font-medium text-sm text-gray-700' %>
            <%= f.text_area :content, class: 'w-full rounded-xl p-2 border border-gray-300' %>
          </div>

          <%= f.submit '更新する', class: 'w-full py-2 px-4 bg-yellow-500 text-white rounded-xl hover:bg-yellow-600 focus:outline-none focus:ring focus:ring-yellow-200' %>
          <p class="w-full py-2 px-4 bg-gray-500 text-white text-center rounded-xl hover:bg-gray-600 focus:outline-none focus:ring forcus:ring-gray-200" data-action="click->review-modal#closeModal">キャンセル</p>
        </div>
      <% end %>      
    </div>
  </div>
<% end %>
```
### 編集、削除までのコントローラは以下のとおり
```Ruby:app/controllers/reviews_controller.rb
class ReviewsController < ApplicationController
  before_action :set_shop, only: %i[new create more]
  before_action :set_review, only: %i[edit update destroy]

  def new
    @review = Review.new
  end

  def create
    @review = current_user.reviews.build(review_params)
    if @review.save
      flash.now.notice = "レビューを投稿しました"
      render turbo_stream: [
        turbo_stream.prepend("reviews", @review),
        turbo_stream.update("flash", partial: "shared/flash")
      ]
    else
      render :new, status: :unprocessable_entity
    end
  end

  def edit; end

  def update
    if @review.update(review_params.except(:shop_id))
      flash.now.notice = "レビューを更新しました"
      render turbo_stream: [
        turbo_stream.replace(@review),
        turbo_stream.update("flash", partial: "shared/flash")
      ]
    else
      render :edit, status: :unprocessable_entity
    end
  end

  def destroy
    @review.destroy!
    flash.now.notice = "レビューを削除しました"
    render turbo_stream: [
      turbo_stream.remove(@review),
      turbo_stream.update("flash", partial: "shared/flash")
    ]
  end

  private

  def review_params
    params.require(:review).permit(:rating, :content).merge(shop_id: params[:shop_id])
  end

  def set_shop
    @shop = Shop.find(params[:shop_id])
  end

  def set_review
    @review = Review.find(params[:id])
  end
end

```
レビューの編集、削除が以下の挙動で実装できているかと思います。
![5eb7744724adcf85cd1f74bdfc8f5cc2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2600084/1819e816-a67f-a188-4d37-c358cbf7d919.gif)

![14401161e2c20f5a0bb53335c8aeefa1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2600084/082a0622-a0b7-9b1a-d2ea-72e1c83ac3c6.gif)

# 最後に
以上が、Turbo✖️Stimulusを使用したモーダルでのレビュー機能の実装になります。最初は、Stimulusを理解するのに時間がかかりましたが、理解してしまえばとても便利な技術だなと感じました。今回は以上で終了です。
最後まで見ていただきありがとうございました。

# 参考文献
- https://zenn.dev/shita1112/books/cat-hotwire-turbo
- https://zenn.dev/redheadchloe/articles/34e9c211981dc6