【Ruby on Rails5】フラッシュメッセージをいい感じに表示させる。

## 環境
Ruby 2.5.7
Rails 5.2.4

### 前提
形を問わずフラッシュメッセージの表示が確認できる状態。
turbolinksは切っています。オンの状態で動作確認はしていません。

### gem
gem 'devise'

## 経緯
タイトルに「いい感じ」と表現しましたが、他に言葉が見つかりませんでした・・・笑
ユーザーが何かアクションを起こした時に、それをお知らせする方法としてページ内へのフラッシュメッセージの埋め込みがありますが、それをただ表示させるのは物足りないと感じて今回の形を実装してみました。（AbemaTVのアプリから発想を得ました笑）

## 目標
ログイン・ログアウトをメインに、アクションを起こすごとに下の方からニュッっとお知らせが一定時間出てくる。
![ezgif.com-gif-maker (2).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/643639/50d70b6c-0843-4567-e69f-7b9587d29a36.gif)


## 手順
1.お知らせ窓`layouts/_flash_window.html.erb`を作成する
2.cssでお知らせ窓をいい感じにする
3.コントローラーで`flash[:notice]`を作成する
4.お知らせ窓専用のjsファイル`assets/javascripts/flash_window.js`を作成する

### 1.お知らせ窓`layouts/_flash_window.html.erb`を作成する
お知らせ窓`views/layouts/_flash_window.html.erb`を部分テンプレートとして作成します。
今回は簡単なので`application.html.erb`に直接書いてもいいのですが、窓の中身が今後複雑になったり、基本的に見えていない部分なので最初から分けておいてもいいかと思います。

```erb:layouts/_flash_window.html.erb

<div class="flash-window hidden">
  <p><%= notice %><span class="flash-window--delete">X</span></p>
</div>

```

CSSクラス名に`hiddden`を付けていますが、これがついているときは非表示、これがjs(toggle)で外れると表示される仕組みです。
2行目は`noticeメッセージ（フラッシュメッセージ）`の後ろにX(バツボタン)を表示させることでお知らせ窓が自動で閉じる前に任意で閉じることができるようにしていきます。

ここで作成した部分テンプレートを`application.html.erb`に反映させていきます。

```erb:application.html.erb

...

<main>
  <div class="main">
    <%= yield %>
    <%= render partial: 'layouts/flash_window' %>
  </div>
</main>

...

```

`<main>`タグの下に`<div>`を挟んでいる理由としては、お知らせ窓の位置を`<main>`直下の`<div>`に基点にするためです。
`<main>`タグを起点にしても良かったのですが、後述するcssにmainを指定してしまうと思わぬところで依存関係ができてしまいそうでしたので、今回はお知らせ窓用に`<div>`を作った形です。
また、`<main>`タグの中に書くことで、どこのページでもお知らせ窓が表示されるようになります。

### 2.cssでお知らせ窓をいい感じにする
作成したお知らせ窓をcssでいい感じにデザインします。
これは一例なので、お好きにカスタマイズしてください。
(scssで記述していきます。)

```scss:assets/stylesheets/application.scss

main {
  .main {
    .flash-window  {
      // 窓の形、色、文字の形、色
      height: 50px;
      padding: 10px;
      color: white;
      background: black;
      border-radius: 10px;

      // 中のp要素(メッセージ)を上下左右中央揃え
      display: flex;
      justify-content: center;
      align-items: center;

      // 表示・非表示にかける時間
      transition: 0.5s 0s ease;

      // 窓の位置調整
      position: fixed;
      bottom: 40px;
      left: 0;
      right: 0;
      margin: auto;

      p {
        color: white;

        span {
          margin-left: 1vw;
          color: white;

          &:hover {
            cursor: pointer;
          }
        }
      }

      // 窓が非表示の時の状態
      &.hidden {
        opacity: 0;
        bottom: -50px;
      }
    }
  }
}

@media (min-width: PC, タブレット共通) {
  main {
    .main {
      .flash-window  {
        // 大画面の時は窓の横幅は固定
        width: 500px;
      }
    }
  }
}
@media (min-width: スマホ) {
  main {
    .main {
      .flash-window  {
        // 小画面の時は画面サイズの90%で可変
        width: 90%;
        p {
          font-size: 4vw;
        }
      }
    }
  }
}

```

CSSセレクタの`hidden`が付くと"非表示"と言いましたが、実際は`bottom: -50px;`で要素が画面外に隠れているだけです。
さらに`opacity: 0;`も指定しているので、`hidden`の時は完全に透明になっている状態です。
なので画面内に現れる時は透明の状態から具現化し、画面外にいく（非表示になる）時は段々と透明になっていきます。

### 3.コントローラーで`flash[:notice]`を作成する
今回はredirect後のフラッシュメッセージ（ログイン後TOPページにリダイレクトと同時にお知らせが出現など）を想定しています。
render後のフラッシュメッセージ（バリデーションエラーによるrender&エラーメッセージ）については最後に触れます。

```ruby:users_controller.rb

...

def update
  if current_user.update(user_params)
    flash[:notice] = '会員情報を更新しました。'
    redirect_to user_path(current_user)
  else
    render :edit
  end
end

...

```

ここでは会員情報の更新時に会員詳細にリダイレクト&フラッシュメッセージの表示をする記述をしましたが、必要なところがあれば適宜行ってください。
基本的には`flash[:notice] = '内容'`でnoticeキーにフラッシュメッセージを格納したあとに、`redirect_to`で任意のページに遷移することで、遷移先で先ほど格納したフラッシュメッセージが表示されるようになります。
ちなみに、deviseを使った新規登録・ログイン・ログアウトの処理はdevise内部で自動的に`flash[:notice] = 'ログインしました。'`等が格納されるので、自分で記述する必要はありません。

### 4.お知らせ窓専用のjsファイル`assets/javascripts/flash_window.js`を作成する
最後に専用のjsファイルを作成していきます。（既存の`application.js`から切り分ける理由は後述します。）

```js:flash_window.js

addEventListener('load', ()=> {
  const flashWindow = document.getElementsByClassName("flash-window")[0];
  flashWindowToggle();
  setTimeout(flashWindowToggle, 3000)
  document.getElementsByClassName("flash-window--delete")[0].addEventListener('click', flashWindowToggle);
  function flashWindowToggle() {
    flashWindow.classList.toggle("hidden");
  }
});

```

`addEventListener`のイベントハンドラに`load`を指定しています。
これはページの読み込みが全て完了した時点でイベントを発生させないと、ページが読み込み中（白紙）の時に先にお知らせウィンドウだけが表示されてしまうからです。
`toggle`メソッドを使ったCSSクラスの`hidden`の追加・削除は関数化しています。
この関数が呼び出されると、`hidden`が削除されお知らせ窓が表示されます。
その後、この関数が3000ms(3秒)後に自動的に呼び出されるか、X(バツボタン)がクリックされると、`hidden`が追加されます。

そして、このjsファイルは`flash[:notice]`に値が入っている時だけ発火させたいので、設定を少し変えていきます。

```js:application.js

...

//= stub flash_window #ここを追加
//= require_tree .

...

```

`application.js`の冒頭の読み込み設定で`//= stub flash_window`を書くことで、`flash_window.js`がプリコンパイルで結合されるjsファイルから除外されます。
しかし、この状態では`flash_window.js`自体がプリコンパイルされないことになってしまうので、`config/initializers/assets.rb`に以下を追記します。

```ruby:config/initializers/assets.rb

...

# 以下を追加
Rails.application.config.assets.precompile += %w( flash_window.js )

...

```

これにより、`flash_window.js`のプリコンパイルを明示できます。

しかし、このままではプリコンパイルはされても`application.js`には結合されていないので読み込まれません。
そこで`application.html.erb`の`<head>`タグ内に`flash_window.js`の読み込みタグを直接追加する必要があります。

```erb:layouts/application.html.erb

<head>

...

<%= javascript_include_tag 'application', defer: true %>

# 以下を追加
<% if flash[:notice] %>
  <%= javascript_include_tag 'flash_window' %>
<% end %>

...

</head>

```

このように`application.js`から`flash_window.js`を切り離すことでif文で`flash[:notice]`がある時だけ`flash_window.js`が読み込まれるという動作を作ることができました。
`defer: true`オプションを付けていない理由は、今回は`flash_window.js`内のイベントがページ読み込み完了後であることを`addEventListener('load', ()=>{...});`という形で直接書いているためです。
このファイルに他のイベントを書く場合は`defer: true`が必要になるかも知れませんので、そこは適宜お願いします。

以上で、目的の動作が達成されます。

ここからは番外編です！

## 番外編
### 番外編その１.不要なフラッシュメッセージが表示される場合
deviseのcreate, update, destroyアクションにはほとんどの場合フラッシュメッセージ`flash[:notice]`が格納されているため、不要な場面でも窓が出てきてしまうことがあります。
その場合の対処法を書いていきます。

```ruby:application_controller.rb

...

protected

...

# deviseで作成されたflashのリセット
def delete_devise_flash_messages
  flash[:notice] = nil
end

```

`application_controller.rb`でこのメソッドを定義し、不要なフラッシュメッセージが表示されるコントローラー内で`before_action :delete_devise_flash_messages`または`after_action :delete_devise_flash_messages`を書くとフラッシュメッセージ`flash[:notice]`が表示されなくなります。(:notice以外のキーは残ります。)

私の場合は新規登録の場面で利用しています。

入力内容の確認画面から認証メールの送信アクション、そして"メールを確認してください。"というページに遷移するのですが、そのページ内でメールについて詳しい説明を書いているのに、改めてフラッシュメッセージで"メールを確認してください"は重複するので不要です。
そこで下記のような記述をしています。

```ruby:registrations_controller.rb

class Users::RegistrationsController < Devise::RegistrationsController

  before_action :delete_devise_flash_messages, only: %i[email_notice]

  # 入力された内容を確認するページ
  def confirm
  end

  # 認証メールが送信されたことをお知らせするページ
  def email_notice
  end

  # 登録が完了したことをお知らせするページ
  def complete
  end

end

```

これで認証メールの送信をお知らせするページ`email_notice`アクションのみ`flash[:notice]`のリセットが働いてフラッシュメッセージが表示されなくなります。


### 番外編その２.エラーのフラッシュメッセージに対応する方法
ここまでは`flash[:notice]`に格納されたメッセージのみを取り扱ってきましたが、バリデーションで発生したエラーなども表示したい場合は`flash[:alert]`を追加していきます。
さらにフラッシュメッセージごとに挙動を変更したい場合は`flash[:error]`や`flash[:success]`なんかも自作されてみるといいでしょう。
変更箇所は主に2カ所です。

```erb:layouts/application.html.erb

...

<%= javascript_include_tag 'application', defer: true %>

<%# if文に || flash[:alert]を追加 %>
<% if flash[:notice] || flash[:alert] %>
  <%= javascript_include_tag 'flash_window' %>
<% end %>

...

```

if文に`|| flash[:alert]`キーを追加します。
これにより:noticeキーもしくは:alertキーに値があると`flash_window.js`が発火するようになります。

次に部分テンプレート内にもalertを追加をしていきます。

```erb:layouts/_flash_window.html.erb

<div class="flash-window hidden">
  <% if flash[:notice] %>
    <p><%= notice %><span class="flash-window--delete">X</span></p>
  <% end %>
  <% if flash[:alert] %>
    <p><%= alert %><span class="flash-window--delete">X</span></p>
  <% end %>
</div>

```

部分テンプレートにもif文を書くことで表示や動作などの細かい部分を分けることができるようになります。

## まとめ
今回ご紹介した物はベースとなると考えていますので、動作などはお好きなようにカスタムしていただければと思います^^
みなさまの参考になれば幸いです。

また、質問や解釈の違い、記述方法に違和感ありましたら、コメント等でご指摘いただけると幸いです。

私のTwitterでも毎日このようなテクニックや感想・考察を発信していますので、もしご興味があれば一度覗いてみてください(´ｰ｀)
[Twitter - @Masao_Sasaki_ae](https://twitter.com/Masao_Sasaki_ae)

最後まで読んでいただきありがとうございました！


## 参考サイト
より実用的な使い方については、私のGitHubに実際に使っているファイルを公開しているのでこちらも参考にしていただければと思います！
[GitHub - MasaoSasaki/matchi](https://github.com/MasaoSasaki/matchi)