[Rails]turboによる非同期コメントCRUD
はじめに
Rails7にアップグレードしましたので、turboを導入しajaxで作ったコメントのCRUD機能をturbo-frame
を使ってリロードなしでページ上に更新していきます。
環境
Rails 7.0.4
ruby 3.2.2
Rails 7ではデフォルトで入ってますが、turbo-rails
Gemが必要です。
ない場合インストールしましょう。
gem 'turbo-rails'
bundle install
turbo-frame
とは
Turboフレーム(Turbo Frames)は、Hotwire(旧称Turbo)の一部であり、リアルタイムなウェブアプリケーションを構築するためのツール群の中の1つです。Turboフレームは、Ajaxによってページの一部をリアルタイムに更新するためのフレームワークで、JavaScriptコードを書かずに部分的なページ更新を簡単に実現できるようにします。
Turboフレームは、ページの一部を<turbo-frame>
要素で囲むことで作成されます。各要素には一意なIDが必要で、これはサーバーに新しいページを要求する際に、置き換えられるコンテンツと一致させるために使われます。1つのページに複数のフレームを持つことができ、それぞれが独自のコンテキスト内にサーバーとやり取りを行います。
Commentsコントローラー
Comments
コントローラーのcreate
、destroy
とupdate
アクションをTurboリクエストに対応させます。
create.turbo_stream.erb
、update.turbo_stream.erb
、destroy.turbo_stream.erb
のビューファイルも作成していきます。
createアクション
コメントの作成で実現したいこと:
1. 作成されたコメントをコメント一覧の一番上に表示させる・prepend
する
2. コメントフォームを置き換える・replace
する
3. コメントの作成に成功・失敗する際にフラッシュメッセージを表示させる
prepend
はサーバーサイドで生成されたHTMLの一部をクライアントに送信して、クライアントのHTMLの先頭に追加する際に使用します。これにより、リアルタイムなコンテンツの追加や通知の表示などを簡単に実現できます。
replace
は指定した範囲で当てはまる要素を置換するメソッドです。
class CommentsController < ApplicationController
before_action :authenticate_user!, only: %i[create destroy edit update]
before_action :find_commentable, only: %i[create edit]
before_action :find_comment, only: %i[edit update destroy]
def create
@comment = @commentable.comments.build(comment_params)
respond_to do |format|
if @comment.save
format.turbo_stream { flash.now[:success] = t('defaults.message.created', item: Comment.model_name.human) }
format.html { redirect_to @comment.commentable,
flash: { success: t('defaults.message.created', item: Comment.model_name.human) } }
else
format.turbo_stream do
flash.now[:danger] = t('defaults.message.not_created', item: Comment.model_name.human)
render turbo_stream: [
turbo_stream.replace("flash_messages", partial: "shared/flash_messages"),
]
end
format.html { redirect_to @comment.commentable,
flash: { danger: t('defaults.message.not_created', item: Comment.model_name.human) } }
end
end
end
private
def comment_params
params.require(:comment).permit(:body).merge(user_id: current_user.id)
end
def find_commentable
params.each do |name, value|
@commentable = ::Regexp.last_match(1).classify.constantize.find(value) if name =~ /(.+)_id$/
end
nil
end
def find_comment
@comment = current_user.comments.find(params[:id])
end
end
format.turbo_stream
は、RailsコントローラーでTurbo Streamsを使用する際に、レスポンスをTurbo Streams形式で返すためのヘルパーメソッドです。
create.turbo_stream.erb
# フラッシュメッセージ
<%= turbo_stream.prepend "flash_messages", partial: "shared/flash_messages" %>
# フォーム
<%= turbo_stream.replace "#{dom_id(Comment.new)}_form" do %>
<%= render "form", commentable: @commentable, comment: Comment.new %>
<% end %>
# コメント件数
<%= turbo_stream.update "comments_count" do %>
<%= render "comments_count", commentable: @comment.commentable, comments: @commentable.comments %>
<% end %>
# コメント
<%= turbo_stream.prepend "comments" do %>
<%= render "comment", comment: @comment %>
<% end %>
- フラッシュメッセージの表示:
turbo_stream.prepend
メソッドは、指定した要素に指定した内容を追加する操作を行います。ここでは、flash_messages
という要素に、"shared/flash_messages"というパーシャルを追加しています。これにより、新しいフラッシュメッセージが表示されるたびに、ページに自動的に追加されます。ページを遷移しないためflash.now
を使います。
- フォームの置換:
turbo_stream.replace
メソッドは、指定した要素を別の要素で置き換える操作を行います。ここでは、新しいコメントフォームを表示するために、Comment
モデルの新しいインスタンスを使ってフォームのパーシャルを表示しています。
- コメント件数の更新:
turbo_stream.update
メソッドは、指定した要素の内容を更新する操作を行います。ここでは、コメント数を表示する要素("comments_count")の内容を、与えられたコメント可能な対象(@comment.commentable
)とコメントリスト(@commentable.comments
)を使って更新しています。
- コメントの追加:
turbo_stream.prepend
メソッドは、指定した要素の先頭に指定した内容を追加する操作を行います。ここでは、IDが"comments"
という要素に、新しいコメントの内容を追加しています。
destroyアクション
コメントの削除で実現したいこと:
1. コメント表示をページから削除する・remove
する
2. 削除の行為に対してフラッシュメッセージを表示させる
class CommentsController < ApplicationController
...
def destroy
@comment.destroy
respond_to do |format|
format.turbo_stream { flash.now[:success] = t('defaults.message.destroyed', item: Comment.model_name.human) }
format.html { redirect_to @comment.commentable,
flash: { success: t('defaults.message.destroyed', item: Comment.model_name.human) } }
end
end
end
destroy.turbo_stream.erb
<%= turbo_stream.prepend "flash_messages", partial: "shared/flash_messages" %>
<%= turbo_stream.remove @comment %>
コメントの削除:
remove
メソッドは、指定したオブジェクト(ここでは@comment
)に対応するHTML要素を、DOMから削除するために使用されます。これにより、コメントの削除後にクライアント側のHTMLからそのコメントが削除されます。
updateアクション
コメントの編集で実現したいこと:
1. コメントをフォームに置き換える・replace
する
2. コメントを更新する
3. コメントの更新に成功・失敗した際にフラッシュメッセージを表示させる
class CommentsController < ApplicationController
...
def edit; end
def update
respond_to do |format|
if @comment.update(comment_params)
format.turbo_stream { flash.now[:success] = t('defaults.message.updated', item: Comment.model_name.human) }
format.html { redirect_to @comment.commentable,
flash: { success: t('defaults.message.updated', item: Comment.model_name.human) } }
else
format.turbo_stream do
flash.now[:warning] = t('defaults.message.not_updated', item: Comment.model_name.human)
render turbo_stream: [
turbo_stream.replace("flash_messages", partial: "shared/flash_messages"),
]
end
format.html { redirect_to @comment.commentable,
flash: { danger: t('defaults.message.not_updated', item: Comment.model_name.human) } }
end
end
end
end
update.turbo_stream.erb
<%= turbo_stream.prepend "flash_messages", partial: "shared/flash_messages" %>
<%= turbo_stream.replace dom_id(@comment) do %>
<%= render "comment", comment: @comment %>
<% end %>
コメントの置換:
turbo_stream.replace
メソッドを使用して、要素(コメント)の内容を置き換える操作を実行します。dom_id(@comment)
は、コメントのDOM要素のIDを生成するヘルパーメソッドです。このIDは、特定のコメントを識別するために使用されます。
コメント一覧ビュー
turbo_frame_tag
ヘルパーメソッドを使用して、指定したフレーム内のコンテンツ("comments"
)を更新します。
コメントを作成された際にDOMがid="comments"
の要素を見つけて更新する流れとなります。
<%= turbo_frame_tag 'comments' do %>
<% comments.each do |comment| %>
<%= render 'comments/comment', comment: comment %>
<% end %>
<% end %>
コメントパーシャル
コメントを削除する場合、id
からコメントを特定して削除を行います。
dom_id
ヘルパーメソッドを使ってid
を追加します。
<%= turbo_frame_tag dom_id(comment) do %>
...
<li class="list-inline-item">
<%= link_to polymorphic_path([comment.commentable, comment]),
class: 'js-delete-comment-button',
id: "button-delete-#{comment.id}",
data: { turbo_method: :delete, turbo_confirm: t('defaults.message.delete_confirm') } do %>
<%= icon 'fas', 'trash' %>
<% end %>
</li>
...
<% end %>
生成されたhtmlはこちらです。<turbo-frame>
タグにコメントidが追加されました。
コメントフォーム
dom_id(comment)
:新規コメントを作成する場合のidはnew_comment
になります。
new_comment
の場合、railsがコメントを作成してくれます。
comment_xx
の場合、railsがコメントを更新してくれます。
<%= turbo_frame_tag 'comment_form' do %>
<%= form_with model: [commentable, comment], id: "#{dom_id(comment)}_form" do |form| %>
<% comment.errors.full_messages.each do |message| %>
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<%= message %>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<% end %>
<div class="mb-3">
<%= form.label :body %>
<%= form.text_area :body, class: 'form-control', id: 'js-new-comment-body' %>
</div>
<div class="text-end">
<%= form.submit class: 'btn btn-primary btn-sm' %>
</div>
<% end %>
<% end %>
コメント編集用のビューを作成する
dom_id(@comment)
にコメントidが代入されました。
<%= turbo_frame_tag dom_id(@comment) do %>
<%= render 'comments/form', commentable: @commentable, comment:
<%= link_to t('defaults.cancel'), @commentable, class: 'btn btn-secondary btn-sm float-end mt-2' %>
<% end %>
コメント件数
<%= turbo_frame_tag "comments_count" do %>
<%= Comment.model_name.human %>(<%= commentable.comments.count %>件)
<% end %>
ログの処理がturbo_stream
になっていることを確認します。
Started POST "/articles/20/comments" for ::1 at 2023-07-20 22:52:33 +0900
Processing by CommentsController#create as TURBO_STREAM
Parameters: {"authenticity_token"=>"[FILTERED]", "comment"=>{"body"=>"!!!"}, "commit"=>"登録する", "article_id"=>"20"}
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? ORDER BY "users"."id" ASC LIMIT ? [["id", 15], ["LIMIT", 1]]
Article Load (0.1ms) SELECT "articles".* FROM "articles" WHERE "articles"."id" = ? LIMIT ? [["id", 20], ["LIMIT", 1]]
↳ app/controllers/comments_controller.rb:45:in `block in find_commentable'
TRANSACTION (0.0ms) begin transaction
↳ app/controllers/comments_controller.rb:9:in `block in create'
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 15], ["LIMIT", 1]]
↳ app/controllers/comments_controller.rb:9:in `block in create'
Comment Create (1.3ms) INSERT INTO "comments" ("body", "user_id", "created_at", "updated_at", "commentable_type", "commentable_id") VALUES (?, ?, ?, ?, ?, ?) [["body", "!!!"], ["user_id", 15], ["created_at", "2023-07-20 22:52:34.003586"], ["updated_at", "2023-07-20 22:52:34.003586"], ["commentable_type", "Article"], ["commentable_id", 20]]
↳ app/controllers/comments_controller.rb:9:in `block in create'
TRANSACTION (0.7ms) commit transaction
↳ app/controllers/comments_controller.rb:9:in `block in create'
Rendered comments/_comment.html.erb (Duration: 2.3ms | Allocations: 999)
Rendered comments/_form.html.erb (Duration: 1.0ms | Allocations: 639)
Completed 200 OK in 22ms (Views: 0.1ms | ActiveRecord: 2.3ms | Allocations: 8461)
tarted DELETE "/articles/20/comments/7" for ::1 at 2023-07-20 22:55:18 +0900
Processing by CommentsController#destroy as TURBO_STREAM
Parameters: {"article_id"=>"20", "id"=>"7"}
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? ORDER BY "users"."id" ASC LIMIT ? [["id", 15], ["LIMIT", 1]]
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."user_id" = ? AND "comments"."id" = ? LIMIT ? [["user_id", 15], ["id", 7], ["LIMIT", 1]]
↳ app/controllers/comments_controller.rb:27:in `destroy'
TRANSACTION (0.0ms) begin transaction
↳ app/controllers/comments_controller.rb:28:in `destroy'
Comment Destroy (0.3ms) DELETE FROM "comments" WHERE "comments"."id" = ? [["id", 7]]
↳ app/controllers/comments_controller.rb:28:in `destroy'
TRANSACTION (0.8ms) commit transaction
↳ app/controllers/comments_controller.rb:28:in `destroy'
Completed 200 OK in 9ms (Views: 0.1ms | ActiveRecord: 1.4ms | Allocations: 5456)
Started PATCH "/articles/20/comments/1" for ::1 at 2023-07-21 01:32:23 +0900
Processing by CommentsController#update as TURBO_STREAM
Parameters: {"authenticity_token"=>"[FILTERED]", "comment"=>{"body"=>"hello"}, "commit"=>"更新する", "article_id"=>"20", "id"=>"1"}
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? ORDER BY "users"."id" ASC LIMIT ? [["id", 15], ["LIMIT", 1]]
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."user_id" = ? AND "comments"."id" = ? LIMIT ? [["user_id", 15], ["id", 1], ["LIMIT", 1]]
↳ app/controllers/comments_controller.rb:36:in `update'
TRANSACTION (0.0ms) begin transaction
↳ app/controllers/comments_controller.rb:37:in `update'
Article Load (0.1ms) SELECT "articles".* FROM "articles" WHERE "articles"."id" = ? LIMIT ? [["id", 20], ["LIMIT", 1]]
↳ app/controllers/comments_controller.rb:37:in `update'
Comment Update (0.3ms) UPDATE "comments" SET "body" = ?, "updated_at" = ? WHERE "comments"."id" = ? [["body", "hello"], ["updated_at", "2023-07-21 01:32:23.830941"], ["id", 1]]
↳ app/controllers/comments_controller.rb:37:in `update'
TRANSACTION (0.8ms) commit transaction
↳ app/controllers/comments_controller.rb:37:in `update'
Rendered comments/_comment.html.erb (Duration: 1.7ms | Allocations: 988)
Completed 200 OK in 14ms (Views: 0.1ms | ActiveRecord: 1.4ms | Allocations: 7676)
リファクタリング
コントローラーとビューに重複なコードがあるのでリファクタリングをしょましょう。
- フラッシュメッセージをヘルパーメソッドにまとめる
- エラー処理をプライベートメソッドにまとめる
未ログイン時にログインフォームに遷移する
ユーザーが未ログイン時にログインフォームに遷移する動きを追加していきます。
data: {turbo_frame: "_top"}
を追加する
フォームやボタンなどにページを遷移させたいアクションにdata: {turbo_frame: "_top"}
データ属性を追加します。
JSを使ってページをリロードさせる
終わり
javascriptを書かずにページを部分更新できるTurboが便利ですね!
Turboの他のフレームワークも試してみたいと思います。
Discussion