LoginSignup
13
11

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

Last updated at Posted at 2023-10-25

初めに

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

完成形

d0e3f0dd459ffd1d7c8841f8fa99a16a.gif

開発環境

  • Ruby: 3.2.2
  • Rails: 7.0.6
  • stimulus
  • css: tailwind css

Turboとは

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

  • 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
テーブルを作成したら、モデルでの関連付けを行います。

user.rb
class User < ApplicationRecord
  has_many :reviews, dependent: :destroy
end
shop.rb
class Shop < ApplicationRecord
  has_many :reviews, dependent: :destroy
end
review.rb
class Review < ApplicationRecord
  belongs_to :user
  belongs_to :shop
end

②ルーティングの設定

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.erbhead部分に追加します。

app/views/layouts/application.erb
<!DOCTYPE html>
<html>
  <head>
    <%= turbo_frame_tag 'review_modal' %>
  </head>
  <body>
    <%= yield %>
  </body>
</html>

④stimulusのインストール

Rails7では、デフォルトでインストールされていると思いますが、インストールされているか確認します。

Gemfile
gem 'stimulus-rails'

⑤Stimulusのコントローラーの作成

ターミナルで以下のコマンドを打つとコントローラーが作成されます。コントローラー名はなんでも結構です。今回は、review_modalにしたいと思います。
※何度も言いますが、Railsのコントローラーではありません。

$ bin/rails g stimulus review_modal

app/javascript/controllers配下にreview_modal_controller.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();
    }
  }
}

⑥レビューの作成

レビュー作成ボタンの作成

ショップの詳細画面にレビューを作成するので、以下のビューファイルにレビュー作成フォーム(モーダル)を開くボタンを作成します。ボタンをクリックすると、モーダルが開きます。

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(コントローラーのどのアクションを呼び出すか)
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 %>

作成したレビューの表示

表示するレビューについては、パーシャルで切り分けて作成します。

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 %>

上記のパーシャルを、ショップ詳細画面で呼び出します。

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>

レビュー作成までのコントローラは以下のとおり

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

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
続いて、編集、削除も実装してみましょう。

⑦レビューの編集、削除

編集、削除用のボタンの作成

編集、削除ボタンはパーシャルに切り出して作成します。

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 %>

上記のパーシャルをレビューを表示しているパーシャルで呼び出す。

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属性は、作成の時とほとんど同じです。

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 %>

編集、削除までのコントローラは以下のとおり

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

14401161e2c20f5a0bb53335c8aeefa1.gif

最後に

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

参考文献

13
11
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
11