虎の穴開発室ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

Hotwire に トースト通知 機能を作る

皆さん、こんにちは。おっくんです。

今回は、先日公開したHotwire の記事で作ったアプリケーションに トースト通知 を追加してみます。

toranoana-lab.hatenablog.com

最終的には、こんなものを作成します。

f:id:toranoana-lab:20210315111210g:plain

実行環境

  • 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以外のイベントもリファレンスには記載されています。

Turbo Reference - Events

turbo.hotwire.dev

今回はトースト通知の実装に当たり、native-toastを使用します。

github.com

こちらが、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>

動作確認

作成したアプリケーションを二つのタブで開き、操作した様子が冒頭でも示した以下の動画です。

f:id:toranoana-lab:20210315111210g:plain

両方のタブで内容が書き換わり、書き込みをした側のタブでのみトースト通知が実行できるようになりました。

実装のもう 1 プラン

今回は保存できなかった場合のトースト通知も要件に入れています。 しかし、成功した時にだけ通知するとコントローラーの修正はなくなり、投稿内容を更新するテンプレートにユーザー情報を示す ID 相当のものをカスタムデータに入れることで、追加された投稿が自分のものか判断できるので、シンプルにすることは可能です。

まとめ

Hotwire で作ったアプリケーションに(特に Turbo stream で作った機能)に、トースト通知を組み込んでみました。 結論として、カスタムデータ属性に情報を入れ込まれたレスポンスを、turbo:before-stream-renderイベントを拾って操作することで「ちょっとだけ」複雑なことができる可能性が見えました。

カスタムデータ属性をうまく使えば、特定の動作への橋渡しをすることはできますが、JSON でのやり取りを代替しただけになりかねません。 複雑な JavaScript での操作は、Hotwire の目的意識からすると避けるべきものであると思います。 今回くらいの軽い演出でとどめておくのがよさそうです

P.S.

■採用情報

yumenosora.co.jp

■ToraLab.fmスタートしました!

メンバーによるPodcastを配信中!
是非スキマ時間に聞いて頂けると嬉しいです。

anchor.fm

■Twitterもフォローしてくださいね!

ツイッターでも随時情報発信をしています
twitter.com