モーダル

フォーム送信成功でモーダルを隠す

モーダルの中からformを送信し、送信中はpending UIを表示します。また成功したらトーストを表示し、表示中のデータを更新し、さらにモーダルが自動的に閉じるようにします。

考えるポイント

interactive-flow-hotwire.webp

  1. formの送信はTurboで行います。レスポンスを返す方法としてはTurbo FramesとTurbo Streamsが考えられますが、今回はredirectせずにトーストを出したりもしたいため、画面の一箇所しか書き換えられないTurbo Framesではなく、Turbo Streamsを選択します
  2. form送信が成功した場合に限り、モーダルを閉じます
    1. form送信結果を受け取り、JavaScriptを起動する必要があります。これはStimulusで行います
    2. form送信が失敗した場合の処理は別途解説します

コード

フォーム

app/views/todos/_form.html.erb
<%= form_with(
      model: todo, id: 'todo-form',
      data: { action: "turbo:submit-end->modal-dialog#hideOnSuccess" },
    ) do |form| %>
  <% if todo.errors.any? %>
    <div class="text-red-600">
      <ul>
        <% todo.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div>
    <%= form.label :title, class: "block text-sm/6 font-semibold text-gray-900" %>
    <div class="mt-2.5">
    <%= form.text_field :title, data: {action: "keydown.enter->modal-dialog#void:prevent"},
    class: "block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-orange-600 sm:text-sm/6"%>
    </div>
  </div>

  <div class="mt-8 flex justify-between">
    <button type="button" class="btn-outline-primary" data-action="click->modal-dialog#hide">
      キャンセル
    </button>
    <%= form.submit class: "btn-primary" %>
  </div>
<% end %>
  • モーダルの中に表示されている<form>の箇所です
  • data: { action: "turbo:submit-end->modal-dialog#hideOnSuccess" }のところは、<form>の送信後にStimulus ModalDialogControllerhideOnSuccess()メソッドを呼ぶという指示です
    • Turboは多数のカスタムイベントを発火します<form>送信後はturbo:submit-endを発火しますので、これを利用します
    • turbo:submit-endは失敗した時も発火しますので、hideOnSuccess()メソッドではレスポンスがsuccessであったことを確認した上でモーダルを隠す処理をします
    • Reactではfetchをawaitしてリクエスト送信後の処理を記述します。それに対してHotwireはこのようにイベント中心の記述をします
  • 一番下の<button>(キャンセル)のところは、クリックしたらModalDialogControllerhide()を呼ぶようにしています

Railsコントローラ

app/controllers/todos_controller.rb
class TodosController < ApplicationController
  # ...

  def update
    respond_to do |format|
      if @todo.update(todo_params)
        flash.now.notice = "Todo was successfully updated."
        format.turbo_stream
      else
        format.turbo_stream { render status: :unprocessable_content}
      end
    end
  end

  # ...

  private

  # ...

    # Only allow a list of trusted parameters through.
    def todo_params
      params.require(:todo).permit(:title)
    end
end
app/views/todos/update.turbo_stream.erb
<% if @todo.errors.any? %>
  <%= turbo_stream.replace "todo-form" do %>
    <%= render "form", todo: @todo %>
  <% end %>
<% else %>
  <%= turbo_stream.replace dom_id(@todo) do %>
    <%= render @todo, highlight: true %>
  <% end %>

  <%= turbo_stream.replace "global-notification" do %>
    <%= render "global_notification" %>
  <% end %>
<% end %>
  • updateのActionでリクエストを受け取り、レスポンスを返します
  • ここではflash.nowにトーストのメッセージをセットし、Turbo Streamのレスポンスを返しています
    • 通常はflashを使うところを、ここではflash.nowを使っています。flashはリダイレクト後にトーストを表示するときに使いますので、「次回のリクエスト」に内容を表示するためのものです
    • それに対してflash.nowは「現在のリクエスト」を対象にします
    • POST/Redirect/GETのパターンを使う時はflashを、今回のようにPOSTに対して直接レスポンスを返している場合はflash.nowを使い分ける形になります
  • 今はまず正常系だけ見ていますので、Turbo StreamのERBテンプレートでは、if ... elseelseの方だけみます
    • turbo_stream.replace dom_id(@todo)でデータが更新された行をreplaceで置換しています
    • turbo_stream.replace "global-notification"ではトーストを表示する箇所を指定しています。そこにglobal_notification partialを挿入しています。このpartialはflashの内容に基づいて、トーストを表示します

モーダルを隠す

app/javascript/controllers/modal_dialog_controller.js
import {Controller} from "@hotwired/stimulus"

// Connects to data-controller="modal-dialog"
export default class extends Controller {
  static values = {
    shown: {type: Boolean, default: false},
    page: String
  }

  // ...

  hide(event) {
    this.shownValue = false
  }

  // ...

  hideOnSuccess(event) {
    if (!event.detail.success) {
      return
    }

    this.hide(event)
  }
  // ...
}
  • 上述した通り、turbo:submit-endイベントに対してhideOnSuccess(event)メソッドを実行させるように<form>data-action属性を記述しました
  • hideOnSuccess(event)メソッドはステータスがsuccessだったかどうかを確認し、そうだった場合はhide(event)メソッドを呼び出してモーダルを表示します
    • 失敗だった場合はエラーメッセージを表示しますので、モーダルはそのまま開いておきます

まとめ

  • フォーム送信をRailsのcontrollerで受け取り、成功した場合にレスポンスを返すところをやりました
    • Turbo Streamsでモーダルの裏の画面の更新をしました
    • トーストの内容も一緒にTurbo Streamsに載せました
  • モーダルを閉じる操作はturbo:submit-endのイベントを検知して、Stimulus controllerの中で行いました。アニメーション付きでモーダルを閉じました

解説は長くなりましたが、