モーダル
Hotwireで作成したモーダル中のフォームからリクエストを送信し、成功するところまでを解説します。下記のビデオのものになります。
form
の送信はTurboで行います。レスポンスを返す方法としてはTurbo FramesとTurbo Streamsが考えられますが、今回は裏の画面を更新するだけでなく、トーストも出します。画面の一箇所しか書き換えられないTurbo Framesではなく、複数箇所が更新できるTurbo Streamsを選択します
form
の送信をTurbo Streamsで行う」というのは正確ではありません。なぜならTurbo Streamsで応答するかどうかはform
送信時には決まらないからです。Turbo Streamsでレスポンスするかどうかはあくまでもサーバ側が決めます。詳しくはサーバから見たTurbo FramesとTurbo Streamsの違いで紹介しています。したがって、例えば更新が成功したときはTurbo Driveでredirectして、失敗したときはTurbo Streamsでエラーメッセージを出すなどということも可能です(今回は双方ともTurbo Streamsで処理していますが)form
送信のpending UI(待ちUI)は、Ruby on Railsが昔からデフォルトで提供している機能を使います(UJS: Unobtrusive JavaScript)。Hotwireでも同じ機能が引き継がれています
form
のsubmitterを自動的にdisabledにします 1form
送信が成功した場合に限り、モーダルを閉じます
form
送信結果を受け取り、JavaScriptでモーダルを閉じる必要がありますので、Stimulusを使いますform
送信が失敗した場合の処理は別途解説しますform
送信が成功した場合はTurbo Streamsを使って、裏画面の書き換えを行います
<%= form_with( model: todo, id: 'todo-form', data: { action: "turbo:submit-end->modal-dialog#hideOnSuccess", turbo_frame: "_top", }, ) do |form| %> <!-- ... --> <div> <%= form.label :title, class: "block text-sm/6 font-semibold text-gray-900" %> <div class="mt-2.5"> <%= form.text_field :title, 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", data: {turbo_submits_with: "更新中..."} %> </div> <% end %>
<form>
の箇所ですdata-action="turbo:submit-end->modal-dialog#hideOnSuccess"
のところは、<form>
のレスポンス受信後にStimulus ModalDialogController
のhideOnSuccess()
メソッドを呼ぶという指示です
<form>
のレスポンスを受信後に発火されるturbo:submit-end
を利用します2hideOnSuccess()
メソッドはレスポンスステータスを確認し、success
の場合にモーダルを非表示しますform
のdata-turbo-frame="_top"
の箇所はpending UI(待ちUI)の関係で使っています
form
を送信すると、form
自身、およびそれが関連するturbo-frame
にaria-busy="true"
属性が自動的に付与されますform
が<turbo-frame id="modal-dialog__frame">
と敢えて紐づかいないようにしています。そのために"_top"
を指定しています<button>
(キャンセル)のところは、クリックしたらModalDialogController
のhide()
を呼ぶようにしています。モーダルを非表示にするものですform.submit
のところは「更新する」ボタンです。これにdata-turbo-submits-with
属性がついていて、"更新中…“となっています。これはTurboが用意してくれているoptimistic UI (楽観的UI)です。ボタンをクリックしてform
を送信すると、ボタンの名前が自動的に"更新中…"に切り替わってくれます
disabled
にもしてくれますので、form
の二重送信防止になりますform
を送信中は、Turboがform
要素にaria-busy
属性を自動でつけてくれます。これをCSS擬似セレクタで検知して、待ちUI (pending UI)を追加することもできます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
<% if @todo.errors.any? %> <!-- ... --> <% 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のレスポンスを返しています
update
をするときは、flash
を使うことがほとんどです。一方、ここではflash.now
を使っています。flash
はリダイレクト後にトーストを表示するときに使いますので、「次回のリクエスト」で使いたい時に使用しますflash.now
は「現在のリクエスト」が対象です。すぐに使いたい時に使いますflash
を、今回のようにPOSTに対して直接レスポンスを返している場合はflash.now
と使い分ける形になりますif ... else
のelse
の方だけみます
turbo_stream.replace dom_id(@todo)
でデータが更新された行をreplace
で置換しています。背景画面であるapp/views/todos/index.html.erb
では、各行をapp/views/todos/_todo.html.erb
partialでレンダリングしていますが、ここでは@todo
に該当する行だけを置換していますturbo_stream.replace "global-notification"
ではトーストを表示します。トーストの内容はglobal_notification
partialからとっています。なおトーストをよく使用するのであれば、丸ごとhelperにしてしまった方が便利かもしれません(下のglobal_notification_stream
helperのコードを参照)module GlobalNotificationHelper def global_notification_stream turbo_stream.replace "global-notification" do render "global_notification" end end end
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)
メソッドを呼び出してモーダルを隠します
turbo:submit-end
のイベントを検知して、モーダルを非表示にしました。特にレスポンスが成功した時だけ非表示にするようにしています。バリデーションエラーの時はモーダルを表示したままにしましたform
をdisable
して二重送信防止をしたり、軽いpending UIも実装しました高いレベルのUI/UXを実現するためには、細かいことをたくさん実施する必要があります。その分、どうしても処理が複雑になることは避けられません。ただしHotwireを使うと、その一つ一つが少ないコードで実現できますので、UI/UXが複雑になってもうまく対応できます。
私はRails出身ということもあり、form
送信時にdisabledにするのは二重送信防止策として一般的だと思いましたが、Next.jsなどではやっておらず、MDNなどでも言及していないようです。ただし実際に試してみるとNext.jsは二重送信をしてしまいますし、二重送信対策としてのdisabledはウェブ上でも広く推奨されています。なるべくならばやった方が間違いないでしょう ↩
リクエストが成功したかどうかはサーバが決定することです。そしてこれをブラウザに表示しなければなりませんが、どのようにメッセージを伝播するべきかは難しい問題です。Hotwireの場合は大きく2つの選択肢があります。1つ目はレスポンスのbodyであるTurbo Streamを使うことです。この場合はサーバが「モーダルのHTML要素を変えろ!」と指示します。2つ目はレスポンスのstatusで200系や400系を返し、ブラウザ側でstatusを読み取り、ブラウザ側が自ら「モーダルを消すぞ!」と判断する形です。開発者によって、どのやり方を選択するかが異なります。なお、ReactだとJSON APIしかないので、必然的に後者の形になります。私はモーダルの表示・非表示はあくまでもブラウザが管理することであり、なるべくならサーバは関わるべきではないと思っていますので、後者を採用しています。 ↩