皆さん、こんにちは。おっくんです。
今回は、先日公開したHotwire の記事で作ったアプリケーションに トースト通知 を追加してみます。
最終的には、こんなものを作成します。
実行環境
- macOS 10.15.17
- Redis 5.0.7
- Ruby 3.0.0
作るもの
Hotwire を使ってみよう ~ 簡単なチャット 検索画面 更新通知 を 作る ~
で定義したアプリケーションの要件は以下の通りでした。
- 簡単な 1 部屋 1 画面だけのチャットアプリ
- 書き込みを検索できる機能
- 書き込みの検索結果を表示している間に新しい投稿があった時は、新着通知を表示する機能
この要件に以下を追加します。
- 投稿者の投稿が保存結果をトーストで通知する
実装
実装の下敷きとして前回作成したアプリケーションを改修する形で進めます。 改修にかかわりのない実装については触れないので、以前の記事を参照してください。
モデル
投稿が失敗するパターンとして、投稿の「コメント」が無い場合は、保存できないようにします。
[app/models/post.rb]
class Post < ApplicationRecord # バリデーションを追加 validates :comment, presence: true after_create_commit do broadcast_prepend_to "post-list", target: "post-list-el", partial: "posts/post" broadcast_replace_to "post-info", target: "post-info-el", partial: "posts/info" end end
コントローラー
HTML の書き換え内容は、app/models/post.rb
に以下のように記述することで、ページの利用者に配信されていました。
[app/models/post.rb]
after_create_commit do broadcast_prepend_to "post-list", target: "post-list-el", partial: "posts/post" broadcast_replace_to "post-info", target: "post-info-el", partial: "posts/info" end
投稿した本人だけに通知させたいので、create アクションのレスポンスはダミーではなく保存結果を返すようにします。
[app/controllers/posts_controller.rb]
class PostsController < ApplicationController def index # 省略 end def create @post = Post.new(post_params) @success = "success" if @post.save return respond_to do |format| format.turbo_stream do # 修正前 # render turbo_stream: turbo_stream.prepend("post", partial: "posts/dummy") # 修正後 render turbo_stream: turbo_stream.replace("post-toast-el", partial: "posts/save_result", locals: { success: @success }) end end end # 省略 end
view テンプレート
view テンプレートは、新規に作るもの 1 つ修正するものが1つです。
新規に作成する_save_result.html.erb
は、コントローラーで新たに使用することにしたパーシャルテンプレートです。
[新規 app/views/posts/_save_result.html.erb]
<div id="post-toast-el" data-success="<%= success.nil? ? '' : success %>"></div>
データの保存結果の受け渡しだけを行いたいので、置換するパーシャルにカスタムデータ属性として保存結果を乗せてレスポンスとして返します。
続けて、このパーシャルの書き換え先をindex.html.erb
に用意します。
以下のようになります。
[改修 app/views/posts/index.html.erb]
<!--今回追加したポイント ここから--> <div data-controller="toast" class="invisible-for-stream"> <div id="post-toast-el" data-success=""></div> </div> <!--今回追加したポイント ここまで--> <section class="section"> <h1 class="title">サンプルアプリ</h1> <p>現在時刻<%= DateTime.now %></p> <%= form_with url: posts_path, method: :get do |f| %> <div class="field has-addons"> <div class="control"> <%= f.text_field :keyword, value: @search_keyword, class: "input", placeholder: "検索キーワード"%> </div> <div class="control"><%= f.submit "検索", class: "button is-info" %></div> <div class="control"> <%= link_to "リセット", posts_path, class: "button is-link" %> </div> </div> <% end %> </section> <!-- Turbo Frame の機能を使い部分的な書き換えをする場合には、<turbo-frame>タグで囲ってあげる --> <turbo-frame id="posts"> <section class="section"> <!-- Turbo Stream の機能を使うには、turbo_stream_from でリッスンするチャンネルを指定する --> <% if @search_keyword.present? %> <%= turbo_stream_from "post-info" %> <% else %> <%= turbo_stream_from "post-list" %> <% end %> <% if !@search_keyword.present? %> <%= form_with model: Post, url: posts_path, method: :post, data: { controller: "posts", action: "turbo:submit-end->posts#reset" } do |f| %> <div class="field is-horizontal"> <div class="field-label is-normal"> <label class="label">ハンドルネーム</label> </div> <div class="field-body"> <div class="field"> <div class="control"><%= f.text_field :name, class: "input" %></div> </div> </div> </div> <div class="field is-horizontal"> <div class="field-label is-normal"> <label class="label">コメント</label> </div> <div class="field-body"> <div class="field"> <div class="control"> <%= f.text_field :comment, class: "input", data: { "posts-target": "comment"} %> </div> </div> </div> </div> <div class="field is-horizontal"> <div class="field-label"> <!-- Left empty for spacing --> </div> <div class="field-body"> <div class="field"> <div class="control"> <%= f.submit "書き込み", class: "button is-primary" %> </div> </div> </div> </div> <% end %> <% end %> </section> <section class="section"> <div id="post-info-el"></div> <% if @posts.present? %> <% if @search_keyword.present? %> 「<%= @search_keyword %>」の検索結果 <% end %> <div id="post-list-el"> <% @posts.each do |post|%> <%= render partial: "post", :locals => { post: post } %> <% end %> </div> <% else %> <% if @search_keyword.present? %> 「<%= @search_keyword %>」を含む投稿はありません。 <% else %> 投稿はありません。 <% end %> <% end %> </section> </turbo-frame>
追加されたのは、以下の部分になります。
こちらの内容が先に示したapp/views/posts/_save_result.html.erb
で書き換えられるようになります。
<div data-controller="toast" class="invisible-for-streams"> <div id="post-toast-el" data-success=""></div> </div>
この div 要素は、画面の表示には関わりを持たないので、以下の CSS で非表示としています。
.invisible-for-streams { display: none; }
Stimulus を利用してトースト通知本体を実装
ここまでで、<div id="post-toast-el" data-success=""></div>
のdata-success
が書き換わることで結果を受け取ることができました。
Stimulus コントローラを追加して以下の様にトースト通知本体を実装します。
[app/assets/javascripts/controllers/toast_controller.js]
import { Controller } from "stimulus"; export default class extends Controller { connect() { document.addEventListener("turbo:before-stream-render", this.toast); } disconnect() { document.removeEventListener("turbo:before-stream-render", this.toast); } toast(e) { const responseStr = e.target.outerHTML; const dom_parser = new DOMParser(); const response = dom_parser.parseFromString(responseStr, "text/xml"); const tag = response.querySelector("#post-toast-el"); if (!tag) return; if (!tag.attributes["data-success"]) return; if (tag.attributes["data-success"].value === "success") { nativeToast({ message: "投稿が完了しました。", position: "north-east", timeout: 3000, type: "success", }); } else { nativeToast({ message: "投稿に失敗しました。", position: "north-east", timeout: 5000, type: "error", }); } } }
Tarbo streams による HTML の書き変えが起こる前にturbo:before-stream-render
というイベントが発生します。
このイベントは、書き換え対象のタグに関連したイベントではなく document のイベントであるのがポイントです。
このイベントのtarget.outerHTML
を DOM としてパースし、#post-toast-el
の要素を抽出。
data-success
の内容を取得しトースト通知内容を振り分けます。
turbo:before-stream-render
以外のイベントもリファレンスには記載されています。
今回はトースト通知の実装に当たり、native-toastを使用します。
こちらが、CDN から使用できるので、application.html.erb
で以下のように読み込みます。
[app/views/layouts/application.html.erb]
<!DOCTYPE html> <html> <head> <!--省略--> <!--native-toast を読み込み--> <script type="text/javascript" src="https://unpkg.com/native-toast@2.0.0/dist/native-toast.js" ></script> <link href="https://unpkg.com/native-toast@2.0.0/dist/native-toast.css" rel="stylesheet" /> </head> <body> <div class="container"><%= yield %></div> </body> </html>
動作確認
作成したアプリケーションを二つのタブで開き、操作した様子が冒頭でも示した以下の動画です。
両方のタブで内容が書き換わり、書き込みをした側のタブでのみトースト通知が実行できるようになりました。
実装のもう 1 プラン
今回は保存できなかった場合のトースト通知も要件に入れています。 しかし、成功した時にだけ通知するとコントローラーの修正はなくなり、投稿内容を更新するテンプレートにユーザー情報を示す ID 相当のものをカスタムデータに入れることで、追加された投稿が自分のものか判断できるので、シンプルにすることは可能です。
まとめ
Hotwire で作ったアプリケーションに(特に Turbo stream で作った機能)に、トースト通知を組み込んでみました。
結論として、カスタムデータ属性に情報を入れ込まれたレスポンスを、turbo:before-stream-render
イベントを拾って操作することで「ちょっとだけ」複雑なことができる可能性が見えました。
カスタムデータ属性をうまく使えば、特定の動作への橋渡しをすることはできますが、JSON でのやり取りを代替しただけになりかねません。 複雑な JavaScript での操作は、Hotwire の目的意識からすると避けるべきものであると思います。 今回くらいの軽い演出でとどめておくのがよさそうです
P.S.
■採用情報
■ToraLab.fmスタートしました!
メンバーによるPodcastを配信中!
是非スキマ時間に聞いて頂けると嬉しいです。
■Twitterもフォローしてくださいね!
ツイッターでも随時情報発信をしています
twitter.com