初めに
初めまして。Jintaと申します。
今回は、Turbo✖️Stimulusを使用して、モーダルでのレビュー機能を実装したので、学習の記録として残します。TurboまたはStimulusを使用して開発をされる方のお役に立てますと幸いです。
完成形
開発環境
- 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との統合が簡単なため、シンプルに対話的な動作の機能を実装できます。
実装手順
①テーブル作成
今回のテーブルの設計は以下のとおりです。
テーブルを作成したら、モデルでの関連付けを行います。
class User < ApplicationRecord
has_many :reviews, dependent: :destroy
end
class Shop < ApplicationRecord
has_many :reviews, dependent: :destroy
end
class Review < ApplicationRecord
belongs_to :user
belongs_to :shop
end
②ルーティングの設定
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
部分に追加します。
<!DOCTYPE html>
<html>
<head>
<%= turbo_frame_tag 'review_modal' %>
</head>
<body>
<%= yield %>
</body>
</html>
④stimulusのインストール
Rails7では、デフォルトでインストールされていると思いますが、インストールされているか確認します。
gem 'stimulus-rails'
⑤Stimulusのコントローラーの作成
ターミナルで以下のコマンドを打つとコントローラーが作成されます。コントローラー名はなんでも結構です。今回は、review_modal
にしたいと思います。
※何度も言いますが、Railsのコントローラーではありません。
$ bin/rails g stimulus review_modal
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();
}
}
}
⑥レビューの作成
レビュー作成ボタンの作成
ショップの詳細画面にレビューを作成するので、以下のビューファイルにレビュー作成フォーム(モーダル)を開くボタンを作成します。ボタンをクリックすると、モーダルが開きます。
<%= 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(コントローラーのどのアクションを呼び出すか)
<%= 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 %>
作成したレビューの表示
表示するレビューについては、パーシャルで切り分けて作成します。
<%= 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 %>
上記のパーシャルを、ショップ詳細画面で呼び出します。
<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>
レビュー作成までのコントローラは以下のとおり
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
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
ここまでで、レビューの作成を実装することができたと思います。
続いて、編集、削除も実装してみましょう。
⑦レビューの編集、削除
編集、削除用のボタンの作成
編集、削除ボタンはパーシャルに切り出して作成します。
<%= 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 %>
上記のパーシャルをレビューを表示しているパーシャルで呼び出す。
<%= 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属性は、作成の時とほとんど同じです。
<%= 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 %>
編集、削除までのコントローラは以下のとおり
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
レビューの編集、削除が以下の挙動で実装できているかと思います。
最後に
以上が、Turbo✖️Stimulusを使用したモーダルでのレビュー機能の実装になります。最初は、Stimulusを理解するのに時間がかかりましたが、理解してしまえばとても便利な技術だなと感じました。今回は以上で終了です。
最後まで見ていただきありがとうございました。