アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

Rails の入力フォームのエラー表示のカスタマイズ

morishitaです。

Web アプリケーションではユーザの入力を求めるために入力フォームを実装することがあります。 HTML の <form><input><select> タグなどを使ってテキストボックスやラジオボタン、セレクトボックスなどで構成する UI ですね。

f:id:HeRo:20210912003502p:plain
入力フォームの例

Rails ではフォームを構成するためにフォームヘルパーと呼ばれる一連のヘルパーメソッドが提供されており、ビューテンプレートの中で利用できます。 それらを使って比較的簡単にモデルオブジェクトと連動した HTML フォームを実装できます。
rails generate を使えば動作するテンプレートの生成もできます。

人間というのはとかく間違うものなので、入力フォームがあれば必ず間違った値を入力したり、入力を忘れたりする人がいます。
間違った値をそのまま受け入れシステムを壊さないようにモデルの保存時に検出する仕組みとしてバリデーションがあります。

通常、バリデーションエラーがあれば入力フォームに戻し、間違いの修正を促します。
いわゆるエラー表示ですね。
その時、何がどう間違っていたのか? どう修正すればいいのかを示さないとストレスフルな UX となります。
逆にわかりやすければユーザは再入力という手間を求められても、納得し使いやすいとさえ思ってもらえるかもしれません。

Rails のフォームヘルパーはこのエラー表示もサポートしています。

前置きが長くなりましたが、本エントリはこの Rails のフォームヘルパーエラー表示について書こうと思います。
なお、全て rails 6.1.4.1 での結果です。

Rails の標準のフォームエラー表示

次の様なモデルを登録する入力フォームの例で進めます。

class Book < ApplicationRecord
  enum genre: { novel: 0, bussiness: 1, technology: 2, hobby: 3, commic: 4, magazine: 5, other: 99 }
  enum media: { paper: 0, kindle: 1, epub: 2, pdf: 3}

  validates :title, presence: true
  validates :genre, presence: true
  validates :media, presence: true
end

このモデルを次の様な実装のフォームで登録します。
ちなみにこのテンプレートは sccarfold で生成したものベースにしています。

<%= form_with(model: book) do |form| %>
  <% if book.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(book.errors.count, "error") %> prohibited this book from being saved:</h2>

      <ul>
        <% book.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= form.label :title %>
    <%= form.text_field :title %>
  </div>

  <div class="field">
    <%= form.label :memo %>
    <%= form.text_area :memo %>
  </div>

  <div class="field">
    <%= form.label :genre %>
    <%= form.select :genre, Book.genres.keys.prepend('') %>
  </div>

  <div class="field">
    <%= form.label :media %>
    <%= form.collection_radio_buttons :media, Book.media, :first, :first %>
  </div>

  <div class="field">
    <%= form.label :already_read %>
    <%= form.check_box :already_read %>
  </div>

  <div class="actions">
    <%= form.submit %>
  </div>
<% end %>

このフォームのデフォルトのエラー表示は次のようになっています。
左が初期状態で、右がエラー状態です。

f:id:HeRo:20210912003552p:plain
デフォルトのエラー表示の例

フォームのレイアウトが崩れてしまっています。 原因はエラー時に Rails のフォームタグヘルパーがタグを追加するからです。

次の HTML の断片は初期状態の書名入力欄です。

<div class="field">
  <label for="book_title">書名</label>
  <input type="text" name="book[title]" id="book_title">
</div>

次がエラー発生時の書名入力欄の HTML ソースです。 <label> タグと <input> タグが <div class="field_with_errors"></div> で囲われています。

<div class="field">
  <div class="field_with_errors">
    <label for="book_title">書名</label>
  </div>
  <div class="field_with_errors">
    <input type="text" value="" name="book[title]" id="book_title">
  </div>
</div>

<div> はブロック要素なので改行が入ってしまいフォームのレイアウトが崩れます。

また、scaffold で作成すると CSS も出力されますが、 .field_with_errors には次の様に実装されています。

.field_with_errors {
  padding: 2px;
  background-color: red;
  display: table;
}

手っ取り早くレイアウト崩れを防ぐには display: table;display: inline-block; に変更すればいいです。
変更してみた結果は次のとおりです。

f:id:HeRo:20210912003639p:plain
CSSで少しレイアウトを整えたエラー表示の例

カスタマイズする方法

Rails が挿入してくれる <div class="field_with_errors"></div> ですが、レイアウト崩れの原因になりがちです。崩れを防いだり、独自のデザインを入れたいと思うとカスタマイズしたいということになります。

カスタマイズの方法としては次の3つの方法があります1

  1. config/application.rbconfig.action_view.field_error_proc を設定する
  2. イニシャライザで ActionView::Base.field_error_proc を実装する
  3. カスタムフォームビルダーを実装する

これらを順に見ていきます。

config.action_view.field_error_proc を設定する

最もシンプルなカスタマイズ方法は application.rb に次の様な設定を追加することです。

config.action_view.field_error_proc = Proc.new { |html_tag, instance| %(<span class="field_with_errors">#{html_tag}</span>).html_safe }

この例は、単純にデフォルトの実装を <div><span> に変更しているだけです2
この設定の結果表示したものは次の通りです。

f:id:HeRo:20210912003730p:plain
config.action_view.field_error_proc を設定したエラー表示の例

<div><span> に変わっているだけですが、出力される HTML も示しておきます。

<div class="field">
  <span class="field_with_errors">
    <label for="book_title">書名</label>
  </span>
  <span class="field_with_errors">
    <input type="text" value="" name="book[title]" id="book_title">
  </span>
</div>

ActionView::Base.field_error_proc を実装する

本質的には1つ目の方法と同じです。
もう少し手の混んだ実装をしたいけれど、 application.rb をごちゃごちゃさせたくない場合には、config/initializers にファイルを追加して、 ActionView::Base.field_error_proc を実装すればいいでしょう。 ちなみに application.rbconfig.action_view.field_error_proc も設定した場合には application.rb が優先され initializers の実装は無視されます。

以下に実装例を示します。
バリデーションエラーメッセージを各入力項目の直後に表示するようにしています。

ActionView::Base.field_error_proc = Proc.new do |html_tag, instance|
  if instance.kind_of?(ActionView::Helpers::Tags::Label)
    html_tag.html_safe
  else
    method_name = instance.instance_variable_get(:@method_name)
    errors = instance.object.errors.full_messages_for(method_name)
    errors_tag = errors.map do |error|
      %(<span class="error-msg">#{error}</span>)
    end

    html = <<~EOM
    <div class="field_with_errors">
      #{html_tag}<br>
      #{errors_tag.join}
    </div>
    EOM
    html.html_safe
  end
end

Proc のブロック引数には次がセットされています。

  • html_tag
    • ActiveSupport::SafeBuffer3 のインスタンス
    • <label for="book_title">書名</label> といった文字列としての HTML タグ
  • instance
    • フォームヘルパーが生成するフォームを構成するクラスのインスタンス
    • ActionView::Helpers::Tags::LabelActionView::Helpers::Tags::TextField など

上記の実装の表示結果は次の様になります。

f:id:HeRo:20210912012216p:plain
Initializer でカスタマイズしたエラー表示の例

書名やジャンルはもう少し CSS で見栄えを整えればいい感じにできそうです。
でもラジオボタンでは4つあるボタンごとにメッセージが表示されてしまっています。
できるなら、ひとかたまりのラジオボタンの下に1回だけメッセージを表示したいところです。 しかし、それはちょっとむずかしいです。

ラジオボタンでは instanceActionView::Helpers::Tags::RadioButton インスタンスが渡されます。 この例のフォームの場合、媒体選択欄は四者択一のラジオボタンになっており、それぞれのラジオボタンごとに処理が実行されます。
出力される媒体選択欄の HTML は次のとおりです。

<div class="field">
  <label for="book_media">媒体</label>
  <input type="hidden" name="book[media]" value="">
  <div class="field_with_errors">
    <input type="radio" value="paper" name="book[media]" id="book_media_paper"><br>
    <span class="error-msg">媒体を入力してください</span>
  </div>
  <label for="book_media_paper">paper</label>
  <div class="field_with_errors">
    <input type="radio" value="kindle" name="book[media]" id="book_media_kindle"><br>
    <span class="error-msg">媒体を入力してください</span>
  </div>
  <label for="book_media_kindle">kindle</label>
  <div class="field_with_errors">
    <input type="radio" value="epub" name="book[media]" id="book_media_epub"><br>
    <span class="error-msg">媒体を入力してください</span>
  </div>
  <label for="book_media_epub">epub</label>
  <div class="field_with_errors">
    <input type="radio" value="pdf" name="book[media]" id="book_media_pdf"><br>
    <span class="error-msg">媒体を入力してください</span>
  </div>
  <label for="book_media_pdf">pdf</label>
</div>

先に示したようにフォームの実装では collection_radio_buttons ヘルパーを使っていますが、分解されて4つのラジオボタンそれぞれに対する処理となるようです。
結局、一連のラジオボタンの最後がどれなのかわからないので、ひとかたまりのラジオボタン群の後に1回だけエラーメッセージを表示するというのができないのです。

カスタムフォームビルダーを実装する

さて、最後のカスタマイズ方法はカスタムフォームビルダーを実装し、フォームに適用する方法です。

カスタムフォームビルダーは ActionView::Helpers::FormBuilder を継承して実装します。
ActionView::Helpers::FormBuilder には text_fieldcheck_box などといったいわゆるフォームタグヘルパーが実装されています。それらをオーバーライドすることによりカスタマイズできるということです。

以下は実装例を示します。
この例ではフォームに必要なタグヘルパーのみオーバーライドしていますが、必要に応じて他のヘルパーも実装すれば良いと思います。

class CustomFormBuilder < ActionView::Helpers::FormBuilder
  def text_field(method, options = {})
    super + ererror_messageror(method)
  end

  def select(method, choices = nil,  options = {})
    super + error_message(method)
  end

  def collection_radio_buttons(method, collection, value_method, text_method, options = {})
    super + error_message(method)
  end

  private

  def error_message(method)
    return if @object.errors[method].blank?

    @template.content_tag(:div, class: 'error-message') do
      @object.errors.full_messages_for(method).each do |msg|
        @template.concat(@template.content_tag(:div, msg))
      end
    end
  end
end

ActionView::Helpers::FormBuilder に実装されているデフォルトの実装の出力を super で得て、それに error_message メソッドで生成したエラーメッセージを追加しています。

このカスタムフォームビルダーを利用するには、フォームのテンプレートで form_with の引数として次のように指定します。

<%= form_with(model: book, builder: CustomFormBuilder) do |form| %>

で、表示結果は次の様になります。

f:id:HeRo:20210912003917p:plain
カスタムフォームビルダーによるエラー表示の例

出力される書名の入力欄部分の HTML は次の様になります。

ラジオボタン部分の HTML 出力は次の様になり、エラーメッセージも1回だけの出力を実現できています。

<div class="field">
  <div class="field_with_errors">
      <label for="book_media">媒体</label>
  </div>
  <input type="hidden" name="book[media]" value="">
  <div class="field_with_errors">
    <input type="radio" value="paper" name="book[media]" id="book_media_paper">
  </div>
  <label for="book_media_paper">paper</label>
  <div class="field_with_errors">
    <input type="radio" value="kindle" name="book[media]" id="book_media_kindle">
  </div>
  <label for="book_media_kindle">kindle</label>
  <div class="field_with_errors">
    <input type="radio" value="epub" name="book[media]" id="book_media_epub">
  </div>
  <label for="book_media_epub">epub</label>
  <div class="field_with_errors">
    <input type="radio" value="pdf" name="book[media]" id="book_media_pdf">
  </div>
  <label for="book_media_pdf">pdf</label>
  <div class="error-message">
    <div>媒体を入力してください</div>
  </div>
</div>

また、この方法は適用したいフォームのみに適用できる、すなわち影響範囲が小さいというメリットもあります。

まとめ

本エントリで紹介した次の3つのカスタマイズ方法の比較をしてみます。

  • 方法1: config/application.rbconfig.action_view.field_error_proc を設定する
  • 方法2: イニシャライザで ActionView::Base.field_error_proc を実装する
  • 方法3: カスタムフォームビルダーを実装する
方法 実装
しやすさ
カスタマイズ
柔軟性
適用範囲
方法1 アプリケーション全体
方法2 アプリケーション全体
方法3 適用フォームのみ

実装しやすさは実装する場所が指定される方法1が劣り、他の方法は独立ファイルに実装できるので同じくらい実装しやすいと思います。

カスタマイズ柔軟性は方法1、2ではラジオボタンのように複数のフォームタグで構成されるものはどれがひとかたまりなのか判別できないので劣ります。一方フォームタグヘルパー単位でカスタムできる方法3が有利と考えます。

適用範囲ですが、方法1,2は実施するとアプリケーション内のすべてのフォームに影響するのでフォームの多いとすべての影響確認が大変で使いづらいと思います。方法3なら各フォームに設定を追加する手間は増えるものの影響範囲が限定的で使いやすいのではないでしょうか。

最後にこれらのカスタマイズを同時に実装した場合について述べます。
方法1と方法2をともに実装した場合には方法1の実装が優先され、方法2は無視されます。
方法1(または方法2)と方法3をともに実装した場合には方法1(または方法2)が適用された上に方法3の実装も有効となります。
例えば、全部の方法を実装すると方法1+方法3の結果となります。

参考

最後に

アクトインディではエンジニアを募集しています。

actindi.net


  1. そもそもフォームヘルパーを使わず実装するという方法ももちろんあります。

  2. デフォルトの実装はこちら

  3. XSS 攻撃などを避けるために出力の安全性を担保するための文字列バッファ。ソースはこちら