😸

Railsでも手軽にリアルタイムバリデーションしたい

に公開

簡単なまとめ

  • HTMLの標準バリデーションでも見た目や文言は変更できるので、工夫で使える余地はありそう。
  • もっとRailsのView層とHTMLを密結合にするなら、バリデーションは定義した方が、Formに対してのバリデーションが楽になりそう。
  • 結局のところRailsのバリデーションは強いこだわりがなければサボらない方が良い。

発端

前回の記事では、主にRailsでのバックエンドバリデーションについて触れた。

https://zenn.dev/osiro/articles/4327f6978a67bb

DHHはHTMLのバリデーションが優秀なことを理由の一つとして、Railsでのバリデーションは積極的にはしないということだったので、今回はHTMLのバリデーションを少し深ぼることにした。
DHHはそのままでもいいと思っているけど、実務だとブラウザデフォルトのバリデーションはデザインがプロダクトに合わなかったり、文言を変えたい!ということはよくあるはずなので、HTMLのバリデーションを使いながらも自前のデザインや文言が反映できないかを検証してみた。

前提

前提として、バックエンドと通信としない、すぐにフィードバックが表示されるバリデーションをリアルタイムバリデーションと呼称しています。

作ったもの

HTML標準のバリデーションもカスタムのUIを利用できるし、バリデーションメッセージを変えることがわかったので、それを生かした仕組みを作ってみることにしてみた。
細かい振る舞いの調整が必要だが、これで動くものはざっくりできている。

使い方

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    /* 無効な場合の枠線 */
    input:invalid {
      border: 2px solid red;
    }

    /* 有効な場合の枠線 */
    input:valid {
      border: 2px solid green;
    }

    .feedback {
      font-size: 0.9em;
      margin-top: 4px;
    }

    .feedback.error {
      color: red;
    }

    .feedback.success {
      color: green;
    }
  </style>
</head>

<body>
  <form data-controller="validatable-form" data-action="submit->validatable-form#onSubmit">
    <div>
      <label for="username">ユーザー名:</label>
      <input id="username" type="text" name="username" required minlength="4" maxlength="10"
        pattern="[a-zA-Z0-9]{4,10}" placeholder="英数字4〜10文字" 
        data-message-required="ユーザー名を入力してください"
        data-message-too-short="ユーザー名は4文字以上で入力してください" 
        data-message-too-long="ユーザー名は10文字以下で入力してください"
        data-message-pattern-missmatch="ユーザー名は英数字で4〜10文字にしてください">
      <!-- フィードバック表示領域 -->
      <div class="feedback" id="username-feedback"></div>
    </div>
    <div>
      <label for="rule">ルールに同意する</label>
      <input type="checkbox" id="rule" name="rule" 
        required
        data-message-required="ルールに同意してください"
      >
      <div class="feedback" id="rule-feedback"></div>
    </div>
    <button type="submit">送信</button>
  </form>
  <script src="https://unpkg.com/@hotwired/stimulus/dist/stimulus.umd.js"></script>
  <script>
    const application = Stimulus.Application.start();
    application.register("validatable-form", class extends Stimulus.Controller {
      connect() {
        this.element.setAttribute("novalidate", "");
        this.updateValidationBound = this.updateValidation.bind(this);

        this.inputs = this.element.querySelectorAll('input, textarea, select');
        this.inputs.forEach(input => {
          input.addEventListener('change', this.updateValidationBound);
        });
      }

      disconnect() {
        this.inputs.forEach(input => {
          input.removeEventListener('change', this.updateValidationBound);
        });
      }

      // フォーム送信時の処理
      onSubmit(event) {
        let isValid = true;
        this.inputs.forEach((input) => {
          this.updateFeedback(input);
          if (!input.checkValidity()) {
            isValid = false;
          }
        });
        if (!isValid) {
          event.preventDefault();
        }
      }

      // 各 input の change イベントで呼び出される
      updateValidation(event) {
        const input = event.target;
        this.updateFeedback(input);
      }

      // 入力フィールドのフィードバックを更新
      updateFeedback(input) {
        const inputId = input.id || input.name;
        const feedbackDiv = this.element.querySelector(`#${inputId}-feedback`);
        if (!input.checkValidity()) {
          const message = this.errorMessage(input);
          input.setCustomValidity(message);
          if (feedbackDiv) {
            feedbackDiv.textContent = message;
            feedbackDiv.className = "feedback error";
          }
        } else {
          input.setCustomValidity("");
          if (feedbackDiv) {
            feedbackDiv.textContent = "";
            feedbackDiv.className = "feedback success";
          }
        }
      }

      // エラーメッセージを決定
      errorMessage(input) {
        if (input.validity.valueMissing) return input.dataset.messageRequired;
        if (input.validity.typeMismatch) return input.dataset.messageTypeMismatch;
        if (input.validity.rangeOverflow) return input.dataset.messageRangeOverflow;
        if (input.validity.rangeUnderflow) return input.dataset.messageRangeUnderflow;
        if (input.validity.tooShort) return input.dataset.messageTooShort;
        if (input.validity.tooLong) return input.dataset.messageTooLong;
        if (input.validity.patternMismatch) return input.dataset.messagePatternMismatch;
        return "";
      }
    });
   </script>
</body>

</html>

メリット

メリットとしてはHTML属性に必要なバリデーションを書いていくだけメッセージも任意の内容を設定できるし、少しリッチなバリデーションUIが提供できる。
さらに、HTML標準のバリデーションを利用しているので、依存するライブラリはほぼないことからメンテナンスコストが下がる。

デメリット

複雑なバリデーションはあまりやらない方がいい。
pattern属性を使ったりして、複雑な条件にすればするだけ品質担保が厳しくなる。
デバッグが難しく、それこそe2e動かすくらいしかテストできなくなって、ユニットテストができなくなってしまい辛い状況になってしまう。
DHHはSystem Testsに対しても噛み付いているし、ここでe2eテストに頼るのはRails Wayではない。

あとはこのままだと複数のエラーをまとめて出すことはできない。最初にブラウザがヒットしたエラーだけが表示される。

デメリットへの対策

ここで言及しないが、複雑なバリデーションについてはカスタムバリデーションできる機能を追加すれば良いと思う。しかしそうしてしまうと、HTMLのバリデーションを使う意味が薄れてくるので、Hotwireなプロジェクトの場合、このようなUXは少し割り切ってしまった方が良い。

もっとRailsらしく発展させる

この仕組みをそのまま使ってもいいが、真骨頂はRailsのHelperと組み合わせて使うことにあると思っている。いっそ密結合にして、Railsのモデルとバリデーションからフォームの入力フィールドに対してのHTML属性を自動生成するようにするのだ。
バックエンドとフロントエンドのバリデーション管理も楽になるのではないか?というのが私の仮説。前回バリデーションはいらないかもなんて言っておきながら、バリデーションをつけた方がラクというオチになりそう。。

Model側のコード

モデル側のコードは下記のようにバリデーションを定義しているものとする。

class User < ApplicationRecord
  validates :name, presence: true, length: { minimum: 2, maximum: 50 }
  validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :age, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 120 }
end

RailsのFormBuilderの実装例については、ちょっと長いのでgistsに掲載。
このFormBuilderのおかげで、validation_***_field というメソッドが生え、TextFieldの他に入力内容のフィードバックを生成するHTML要素も作成する。

https://gist.github.com/webuilder240/cd497cb2bfceb5ff19b1019e906e074b

ということで最終系はこんな感じに

<%= form_with model: user, builder: ValidationFormBuilder, data: {controller: "validatable-form", action: "submit->validatable-form#onSubmit"} do |form| %>

  <div>
    <%= form.label :name, style: "display: block" %>
    <%= form.validation_text_field :name %>
  </div>

  <div>
    <%= form.label :email, style: "display: block" %>
    <%= form.validation_email_field :email %>
  </div>

  <div>
    <%= form.label :age, style: "display: block" %>
    <%= form.validation_number_field :age %>
  </div>

  <div>
    <%= form.submit %>
  </div>
<% end %>

結局バリデーションを書いた方が、HTMLのバリデーションの属性もSkipできるし、便利なのでHotwireであっても、バリデーションは書いた方がいいということが証明されてしまったように思った。

まとめ

HTMLバリデーションとRailsの連携をかなりシームレスに行えるようにした。これで実務でも利用できるレベルのバリデーションになったと思う。RailsでもちょっとJSで動くリッチなバリデーションが欲しい!という場合でもこれで十分ではないだろうか。

OSIRO テックブログ

Discussion