モーダル
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送信が成功した場合に限り、モーダルを閉じます
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しかないので、必然的に後者の形になります。私はモーダルの表示・非表示はあくまでもブラウザが管理することであり、なるべくならサーバは関わるべきではないと思っていますので、後者を採用しています。 ↩