🦓

[Rails]turboによる非同期コメントCRUD

2023/07/21に公開

はじめに

Rails7にアップグレードしましたので、turboを導入しajaxで作ったコメントのCRUD機能をturbo-frameを使ってリロードなしでページ上に更新していきます。

環境

Rails 7.0.4
ruby 3.2.2

Rails 7ではデフォルトで入ってますが、turbo-railsGemが必要です。
ない場合インストールしましょう。

Gemfile
gem 'turbo-rails'
bundle install

https://github.com/hotwired/turbo-rails

turbo-frameとは

Turboフレーム(Turbo Frames)は、Hotwire(旧称Turbo)の一部であり、リアルタイムなウェブアプリケーションを構築するためのツール群の中の1つです。Turboフレームは、Ajaxによってページの一部をリアルタイムに更新するためのフレームワークで、JavaScriptコードを書かずに部分的なページ更新を簡単に実現できるようにします。

Turboフレームは、ページの一部を<turbo-frame>要素で囲むことで作成されます。各要素には一意なIDが必要で、これはサーバーに新しいページを要求する際に、置き換えられるコンテンツと一致させるために使われます。1つのページに複数のフレームを持つことができ、それぞれが独自のコンテキスト内にサーバーとやり取りを行います。

https://turbo.hotwired.dev/handbook/frames

Commentsコントローラー

CommentsコントローラーのcreatedestroyupdateアクションをTurboリクエストに対応させます。

create.turbo_stream.erbupdate.turbo_stream.erbdestroy.turbo_stream.erbのビューファイルも作成していきます。

createアクション

コメントの作成で実現したいこと:
1. 作成されたコメントをコメント一覧の一番上に表示させる・prependする
2. コメントフォームを置き換える・replaceする
3. コメントの作成に成功・失敗する際にフラッシュメッセージを表示させる

prependはサーバーサイドで生成されたHTMLの一部をクライアントに送信して、クライアントのHTMLの先頭に追加する際に使用します。これにより、リアルタイムなコンテンツの追加や通知の表示などを簡単に実現できます。

replaceは指定した範囲で当てはまる要素を置換するメソッドです。

app/controllers/comments_controller.rb
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

app/views/comments/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 %>
  1. フラッシュメッセージの表示: turbo_stream.prependメソッドは、指定した要素に指定した内容を追加する操作を行います。ここでは、flash_messagesという要素に、"shared/flash_messages"というパーシャルを追加しています。これにより、新しいフラッシュメッセージが表示されるたびに、ページに自動的に追加されます。ページを遷移しないためflash.nowを使います。
  1. フォームの置換: turbo_stream.replaceメソッドは、指定した要素を別の要素で置き換える操作を行います。ここでは、新しいコメントフォームを表示するために、Commentモデルの新しいインスタンスを使ってフォームのパーシャルを表示しています。
  1. コメント件数の更新: turbo_stream.updateメソッドは、指定した要素の内容を更新する操作を行います。ここでは、コメント数を表示する要素("comments_count")の内容を、与えられたコメント可能な対象(@comment.commentable)とコメントリスト(@commentable.comments)を使って更新しています。
  1. コメントの追加: turbo_stream.prependメソッドは、指定した要素の先頭に指定した内容を追加する操作を行います。ここでは、IDが"comments"という要素に、新しいコメントの内容を追加しています。

destroyアクション

コメントの削除で実現したいこと:
1. コメント表示をページから削除する・removeする
2. 削除の行為に対してフラッシュメッセージを表示させる

app/controllers/comments_controller.rb
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

app/views/comments/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. コメントの更新に成功・失敗した際にフラッシュメッセージを表示させる

app/controllers/comments_controller.rb
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

app/views/comments/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"の要素を見つけて更新する流れとなります。

app/views/comments/_comments.html.erb
<%= turbo_frame_tag 'comments' do %>
  <% comments.each do |comment| %>
   <%= render 'comments/comment', comment: comment %>
  <% end %>
<% end %>

コメントパーシャル

コメントを削除する場合、idからコメントを特定して削除を行います。
dom_idヘルパーメソッドを使ってidを追加します。

app/views/comments/_comment.html.erb
<%= 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が追加されました。

https://railsdoc.com/page/dom_id

コメントフォーム

dom_id(comment):新規コメントを作成する場合のidはnew_commentになります。
new_commentの場合、railsがコメントを作成してくれます。
comment_xxの場合、railsがコメントを更新してくれます。

app/views/comments/_form.html.erb
<%= 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 %>

Image from Gyazo

コメント編集用のビューを作成する

dom_id(@comment)にコメントidが代入されました。

app/views/comments/edit.html.erb
<%= 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 %>

Image from Gyazo

コメント件数

app/views/comments/_comments_count.html.erb
<%= 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"}データ属性を追加します。
https://turbo.hotwired.dev/reference/frames#frame-targeting-the-whole-page-by-default

JSを使ってページをリロードさせる

https://github.com/hotwired/turbo/pull/863
http://ducktypelabs.com/turbo-break-out-and-redirect/

終わり

javascriptを書かずにページを部分更新できるTurboが便利ですね!
Turboの他のフレームワークも試してみたいと思います。

Discussion