[rails7]turbo streamsでコメント投稿機能を実装する

## はじめに
個人開発でアプリケーション作成をするにあたり、turbo streamを用いた実装をしたことがなかったため、コメント投稿機能に組み込んでみようと思いました。

備忘録も兼ねて、実装内容を残していこうと思います。

### 開発環境
- ruby 3.2.0
- Rails 7.0.7.2
- MySQL

## 完成形イメージ
### コメント投稿・編集
![コメント投稿・編集](https://gyazo.com/bafe10b8117bd0163430813d900122d9/raw)

### コメント削除
![コメント削除](https://gyazo.com/9d91529e939db54aed99980800387136/raw)


私の場合、planモデルに対してcommentを複数投稿できるように実装を進めています。
ER図は以下のようなイメージです。
![ER図](https://gyazo.com/4e00c0e72ba8c1969687d4c0ad3c3067/raw)

また、コメント投稿する動線としては、planの詳細ページ→コメント一覧ページ→コメント投稿という流れを取っています。


### 1\. 同期通信でコメント投稿機能を実装する。
ルーティング・コントローラー・ビューをそれぞれ以下のように実装します。

```ruby: routes.rb
Rails.application.routes.draw do
  # plansコントローラーにcommentsコントローラーをネストする。
  resources :plans do
    resources :comments, only: [:index, :create, :edit, :destroy]
  end
end
```

```ruby:app/controllers/comments_controller.rb
class CommentsController < ApplicationController
  before_action :set_plan, only: [:index, :create]
  
  def index
    @comment = Comment.new
  end

  def create
    @comment = Comment.new(comment_params)
    if @comment.save
        redirect_to plan_comments_path
    else
       render :index
    end

  end

  private
  def comment_params
    params.require(:comment).permit(:comment).merge(user_id: current_user.id, plan_id: params[:plan_id])
  end

  def set_plan
    @plan = Plan.find(params[:plan_id])
  end

end

```

```erb: app/views/comments/index.html.erb
<div class="text-center mx-auto py-6 sm:py-8 lg:py-12 w-1/2">
  <h2>コメント一覧</h2>

  <%= render partial:"form", locals: {plan: @plan, comment: @comment} %>
</div>
```

```erb:app/views/comments/_form.html.erb
<div id="new_comment">
  <%= form_with model:[plan, comment],local:true, remote: true do |f|%>
    <%= render "shared/error_messages", resource: f.object %>
    <%= f.text_field :comment %>
    <%= f.submit "投稿" %>
  <% end %>
</div>
```

### 2\. turbo_streamを用いた実装に変更する。

1\.コントローラーの記述を変更。
[`respond_to`](https://api.rubyonrails.org/classes/ActionController/MimeResponds.html#method-i-respond_to)を用いて、turbo streamsのリクエクトに対するレスポンスを作成します。

turbo streamsに対するレスポンスは`format.turbo_stream`と記述することで設定ができます。

```ruby: app/controllers/comments_controller.rb
class CommentsController < ApplicationController
  before_action :authenticate_user!
  before_action :set_plan, only: [:index, :create]
  before_action :set_comments, only: [:index, :create]

  def index
    @comment = Comment.new
  end

  def create
    @comment = Comment.new(comment_params)

      respond_to do |format|
        if @comment.save
          # 入力フォームのリセットのために変数を再定義
          @comment = Comment.new
          format.html { redirect_to plan_comments_path }
          # turbo　streamsに関するレスポンスを指定。
          format.turbo_stream
        else
          format.html { render :index, status: :unprocessable_entity }
        end
      end
  end

  private
  def comment_params
    params.require(:comment).permit(:comment).merge(user_id: current_user.id, plan_id: params[:plan_id])
  end

  def set_plan
    @plan = Plan.find(params[:plan_id])
  end

  def set_comments
    @comments = @plan.comments.includes(:user)
  end
end
```

2\.ビューの変更。
コントローラーで変更した`format.turbo_stream`の記述によって、レスポンスでは`xxxx.turbo_stream.erb`形式のビューが返されるようになります。

アクション名に対応したビューが返るため、今回はcreate.turbo_stream.erbファイルを作成します。

```erb: app/views/comments/create.turbo_stream.erb
<h2>コメント一覧</h2>

<!-- id="new_comment"の要素を、ブロックで囲んだ内容と置き換えする --!>
<%= turbo_stream.replace "new_comment" do %>
  <%= render partial:"form", locals: {plan: @plan, comment: @comment} %>
<% end %>

<!-- id="comments"の要素に対して、ブロックで囲んだ内容を差し込む --!>
<%= turbo_stream.prepend "comments" do %>
  <%= render @comments %>
<% end %>
```
※`<%= render @comments %>`と`<%= render partial: "comment", collection: @comments %>`は同義。

こちらのファイル内では、turbo_streamのメソッドを用いて、ビューの更新をしていきます。

:::note info
**turbo_stream.replace**
更新をかけたいターゲット要素を含めて更新する。
turbo_streamタグで囲んでいる要素を、`id="new_comment"`を付与された要素と置き換えて更新をかける。(今回は_form.html.erb内に`id="new_comment"`を持ったdivタグを用意)
こうすることで、コメント投稿が完了した後にフォームがリセットされて、再度新規投稿ができるようになる。
:::

:::note info
**turbo_stream.prepend**
→更新したいターゲット要素の先頭に要素を追加する。
ターゲット要素(index.html.erbファイル内の`id="comments"`)の先頭にブロック内の`<%= render @comments %>`差し込むことで、非同期通信でコメント投稿ができる。
:::

なお、部分テンプレートとして1投稿分のコメントを描写しているビューは以下になります。
`turbo_frame_tag `を使用することによって、”comment”というIDを持ったTurbo Frameを作成できます。このturbo Frame内あるコンテンツが非同期的に更新されるように実装が可能です。
```er: app/views/comments/_comment.html.erb
<%= turbo_frame_tag dom_id(comment) do %>
  <div class="comment">
    <div class="chat chat-start">
      <div class="chat-header">
        <%= comment.user.nickname %>
        <time class="text-xs opacity-50"><%= l comment.created_at %></time>
      </div>
      <div class="chat-bubble">
        <%= comment.comment %>
        <div class="flex justify-end text-xs mt-1">
          <%= link_to '削除', plan_comment_path(comment.plan, comment), data: { turbo_method: :delete} %>
          <%= link_to '編集',edit_plan_comment_path(comment.plan, comment), class:"ml-2" %>
        </div>
      </div>
    </div>
  </div>
<% end %>

```

ここまでの実装で、以下のような非同期通信でのコメント投稿が完了します。
![挙動確認](https://gyazo.com/93d6bbe495b75ede1733b52b717c0d41/raw)


## 3. コメント編集機能の実装。

### 1\.編集用のコントローラーの作成
コントローラーにeditアクションを追加します。
createアクションの時と同様に、respond_toを用いて、turbo_streamのテンプレートをレスポンスとして返します。
```ruby: app/controllers/comments_controller.rb
  def edit
    @comment = Comment.find(params[:id])
  end

  def update
    @comment = Comment.find(params[:id])
    respond_to do |format|
      if @comment.update(comment_params)
        @comment = Comment.new
        format.html { redirect_to plan_comments_path }
        format.turbo_stream
      else
        format.html { render :index, status: :unprocessable_entity }
      end
    end
  end
```

### 2\.ビューファイルの修正
**①編集ページを描写させるための記述はこちら。**
ポイントは置き換えたい部分を```turbo_frame_tag```で囲むこと。
また、入力フォームを準備する必要があったので、以下のように記述しました。



```erb: edit.html.erb
<%= turbo_frame_tag dom_id(@comment) do %>
  <div class="comment">
    <%= form_with model:[@plan, @comment],local:true, remote: true do |f|%>
      <div class="chat chat-start">
        <div class="chat-header">
          <%= @comment.user.nickname %>
          <time class="text-xs opacity-50"><%= l @comment.updated_at %></time>
        </div>
        <div class="chat-bubble">
          # こちらに入力欄を追加。入力欄に記入された値をもとに、更新をupdateアクションでしていきます。
          <%= f.text_field :comment, class:"input input-bordered w-full bg-gray-600" %>
          <div class="flex justify-end text-xs mt-1">
            <%= link_to 'キャンセル', plan_comments_path(@plan) %>
            <%= f.submit "更新", class:"ml-2" %>
          </div>
        </div>
      </div>
    <% end %>
  </div>
<% end %>
```

**②更新後のページを描写させるための記述はこちら。**
createアクションと同じく、`update.turbo_stream.erb`ファイルを作成してcreateと同じ記述をしています。

```erb: update.turbo_stream.erb
<%= turbo_stream.prepend "comments" do %>
  <%= render @comments %>
<% end %>

<%= turbo_stream.replace "new_comment" do %>
  <%= render partial:"form", locals: {plan: @plan, comment: @comment} %>
<% end %>
```
ここまでの実装で、コメント編集機能は完了です！
![挙動確認](https://gyazo.com/f96d565b57c6c693ba2a3b1b8a91aa93/raw)


## 4.コメント削除機能の実装

### 1\. 削除用のコントローラーの作成
編集と同じく、respond_toを使用してdestroy.turbo_stream.erbを表示させる記述をします。
コメントを削除した後、`reset_form`を呼び出しているところがポイントです。
`reset_form`がない場合、コメント入力欄に`@comment`の値が残ってしまうので、フォームのリセットをするために記述をします。
(editやcreateでも同じ記述をしていたので、メソッド化をしました）

```ruby: app/controllers/comments_controller.rb
  def destroy
    respond_to do |format|
      if @comment.destroy
        reset_form
        @comments = @plan.comments.includes(:user)
        format.html { redirect_to plan_comments_path }
        format.turbo_stream
      else
        format.html { render :index, status: :unprocessable_entity }
      end
    end
  end

  ~省略~
  private
  def reset_form
    @comment = Comment.new
  end
```

### 2\. ビューファイルの作成
削除後に表示させる`detroy.turbo_stream.erb`を作成。
ポイントは、`turbo_stream.update`メソッドを使用すること。

```erb: app/views/comments/destroy.turbo_stream.erb
<%= turbo_stream.replace "new_comment" do %>
  <%= render partial:"form", locals: {plan: @plan, comment: @comment} %>
<% end %>

<!-- turbo_stream.updateを使用して、更新をかける。 --> 
<%= turbo_stream.update "comments" do %>
  <%= render @comments %>
<% end %>
```
edit/updateアクションの場合は、編集をするターゲットも含めて更新をしたかったため、`replace`を使用しました。
一方で、destroyのアクションの場合は削除したターゲットのみを更新(削除)するため、`update`を使用します。

ここまでの実装で、以下のような非同期通信でのコメント投稿が完了します。
![挙動確認](https://gyazo.com/b53b9d44da276e0b1e6a8519ede758a4/raw)

## まとめ
今回はじめてturbo_streamを用いて投稿や編集の実装にチャレンジしてみました。
体感としては理屈さえ押さえてしまえば、javascriptを書くよりも簡単に非同期で投稿・編集・削除の実装が出来た気がします！