📸

Rails の Flash をサーバー/クライアントで統一的に扱う Gem "flash_unified" の紹介

に公開

前回の記事で Flash メッセージを、サーバーサイド/クライアントサイドの両方から統一的に扱うアイデアを書きました。

https://zenn.dev/hwat/articles/3e047bdd51f9fe

簡単にいえば、次の二点を解決しようとするものです:

  • クライアントサイドから、サーバーサイドと同じ表現で Flash メッセージを表示したい
  • Turbo Frame でも Flash メッセージを表示したい

仕組みや背景は前回の記事を参照していただきたいのですが、それを実装しようとすると手間がかかるものでした。

しかし他の人でも簡単に試せる Gem という形にできましたので、今回はその Gem flash_unified を紹介します。同様の課題を持つ方は、ぜひ試してみてください。(そしてフィードバックをいただけるとありがたいです)

クイック・スタート

この記事では、動作確認のための最小セットアップを紹介します。 Flash メッセージをサーバー/クライアントの両方で扱うため、両側に設定が必要です。

1. インストール

Gem をインストールします:

$ gem install flash_unified

Bundler を利用する場合は Gemfile に gem 'flash_unified' と追記し、 bundle install してください。

2. セットアップ(クライアントサイド)

JavaScript は ES Modules で実装しています。 config/importmap を編集し、次のように all.bundle.js を pin してください:

pin 'flash_unified/all', to: 'flash_unified/all.bundle.js'

そしてエントリーポイント(たとえば app/javascript/application.js )で import します:

import 'flash_unified/all';

もし importmap-rails を使用しておらず、 Sprockets などのアセットパイプラインを使用している場合は、 HTML の <head> セクションに次のように記述することで、同様のセットアップができます:

アセットパイプラインの場合
<link rel="modulepreload" href="<%= asset_path('flash_unified/all.bundle.js') %>">
<script type="importmap">
  {
    "imports": {
      "flash_unified/all": "<%= asset_path('flash_unified/all.bundle.js') %>"
    }
  }
</script>
<script type="module">
  import 'flash_unified/all';
</script>

3. セットアップ(サーバーサイド)

ビューのレイアウトを編集し、 <body> タグの直後にヘルパー flash_unified_sources を配置してください:

<body>
  <%= flash_unified_sources %>
  ...

これは非表示要素を書き出すので、レイアウトは崩れません。このヘルパーはサーバーサイドからのメッセージやテンプレートなど Flash メッセージを表示するための元となるデータを書き出します。

そして Flash メッセージを表示したい箇所に、次のようにヘルパー flash_container を配置してください:

<div class="notify">
  <%= flash_container %>
  ...

基本は以上です。

Turbo Frame や Turbo Stream でページの一部だけを更新する場合、部分更新さる領域に Flash メッセージを届けるための設定が必要です。

  • Turbo Frame

    フレームの中に、ヘルパー flash_storage を置いてください:

    <turbo-frame id="foo">
      <%= flash_storage %>
      ...
    
  • Turbo Stream

    ストリームの一つに、次のヘルパーで書き出されるものを追加してください:

    <%= flash_turbo_stream %>
    

なおコントローラで Flash メッセージをセットすることについては従来どおりで、この Gem のために変更は不要です:

# Gem のための変更は不要
if @item.save
  redirect_to @item, notice: "Created successfully."
else
  flash.now[:alert] = "Could not create."
  render :new, status: :unprocessable_content
end

設定は以上です。サーバーを再起動し、期待した場面で Flash メッセージが表示されるか、確認してください。

テンプレートのカスタマイズ

表示される Flash メッセージの要素は既定では何も飾りがないため、カスタマイズして任意の CSS を適用してください。また、表示される Flash メッセージの要素は表示されたままになるので、必要に応じて閉じるための機能も実装してください。

重要な点は、次の二点です:

  • <template> 要素の id を flash-message-template-${type} とします。 $type 部分には infowarningalert といった Flash のタイプを代入します
  • その要素の中に CSS クラス名 flash-message-text を持つ要素を置いてください。ここにメッセージが挿入されます

テンプレートはここに配置します:

  • app/views/flash_unified/_templates.html.erb

雛形をジェネレータで書き出すことができます:

$ bin/rails generate flash_unified:install --template

または、ファイルを配置する代わりにヘルパーメソッド def flash_templates を上書きしてください。このヘルパーメソッドはテンプレート・ファイルを render するだけのものなので、テンプレート・ファイルを用意しなくとも、ヘルパー自身がタグを生成して返すことで、テンプレート・ファイルを省略できます。

たとえば Tailwind CSS をあてがった例です:

def flash_templates
# Override flash_unified view helper
def flash_templates
  templates = [
    {
      id: 'alert',
      text_color: 'text-red-700',
      bg_color: 'bg-red-100'
    },
    {
      id: 'notice',
      text_color: 'text-blue-700',
      bg_color: 'bg-blue-100'
    },
    {
      id: 'warning',
      text_color: 'text-yellow-700',
      bg_color: 'bg-yellow-100'
    }
  ]

  safe_join(
    templates.map do |tpl|
      content_tag(:template, id: "flash-message-template-#{tpl[:id]}") do
        content_tag(:div,
          content_tag(:span, '', class: 'flash-message-text'),
          class: "p-4 mb-4 text-sm rounded-lg #{tpl[:text_color]} #{tpl[:bg_color]}",
          role: 'alert',
          data: { controller: 'dismissable' }
        )
      end
    end
  )
end

そしてこの例で指定している Stimulus コントローラ "dismissable" の例です。このコントローラは要素の右上に "×" ボタンを表示し、クリックすると要素自身を削除します:

dismissable_controller.js
import { Controller } from '@hotwired/stimulus'

export default class extends Controller {
  connect() {
    const closeButton = document.createElement('button');
    closeButton.innerHTML = '&times;';
    closeButton.setAttribute('type', 'button');
    closeButton.setAttribute('aria-label', 'Close');
    closeButton.className = 'absolute top-2 right-2 text-gray-700 hover:text-gray-900';
    closeButton.addEventListener('click', () => this.dismiss());

    this.element.classList.add('relative');
    this.element.appendChild(closeButton);
  }

  dismiss() {
    this.element.remove();
  }
}

(オプション)ネットワークエラーを表示させる

all.bundle.js の中には、ネットワークエラー時にクライアントサイドから Flash メッセージを表示させるためのヘルパー機能があります。

たとえば Turbo を使ったフォームの送信に対してサーバーエラーが返ってきた時、イベントリスナーがそれを検知し、 HTTP ステータスコードに応じたエラーメッセージを、イベントハンドラーが自動的に Flash メッセージとして表示します。

デフォルトでは無効ですので、利用する場合は次のように data 属性を設定してください:

<html data-flash-unified-enable-network-errors="true">

このとき表示されるメッセージは、 I18n ロケールの翻訳ファイルで定義されています:

  • config/locales/http_status_messages.en.yml
  • config/locales/http_status_messages.ja.yml

カスタマイズする場合はファイルを配置し、文言を編集してください。ジェネレータで雛形を書き出すことができます:

$ bin/rails generate flash_unified:install --locale

高度な使い方

クイック・スタートで使用した flash_unified/all.bundle.js は、 import することで、いくつかのイベントリスナーを自動的に登録します。簡便なデモサイトや、規模の小さな管理画面などで便利かと思い、「全部お任せ」の内容になっています。

もちろん、お任せせずにカスタム制御が必要なケースにも対応できるようにしてあります。その場合は追加のセットアップや、イベントの登録(描画メソッドの呼び出し)などを実装する必要が生じます。

いずれにせよ、詳しいことは Gem のドキュメントに記しましたので、そちらを参照いただければと思います。トピックだけここに列挙して紹介します。

カスタムレンダラー

テンプレートの表示処理を、任意の関数に置き換えられます。たとえば Notyf などのサードパーティの通知ライブラリと連携させることができます。

import { setFlashMessageRenderer } from 'flash_unified';

setFlashMessageRenderer((messages) => {
  const notyf = new Notyf();
  messages.forEach(({ type, message }) => {
    const level = type === 'info' || type === 'notice' ? 'success' : 'error';
    notyf.open({ type: level, message });
  });
});

コンテナ選択

既定では、テンプレートで整形された Flash メッセージは、複数の表示先の要素(コンテナ要素)がある場合でもすべてのコンテナ要素に挿入されます。

特定のコンテナ要素に絞り込む場合には、カスタムレンダラーを作成するか、 HTML の data 属性を設定することで対応できます。

<html
  data-flash-unified-container-first-only="true"
  data-flash-unified-container-sort-by-priority="true"
  data-flash-unified-container-visible-only="true">

カスタムイベント

カスタムイベント flash-unified:messages を受信することで、メッセージを表示できます。

document.dispatchEvent(new CustomEvent('flash-unified:messages', {
  detail: { messages: [ { type: 'alert', message: '操作はキャンセルされました。' } ] }
}));

続きは GitHub で

以上、基本的な使い方を紹介しました。

この Gem の開発は GitHub 上で行っています。そちらではこの記事よりも詳細なドキュメント、開発者向けのドキュメントもあります。また、リポジトリをクローンすれば、すぐに試せる Rails アプリが test/dummy にあります。興味を持たれた方はぜひチェックしてみてください。

https://github.com/hiroaki/flash-unified

Discussion