バリデーションはUI/UXの良し悪しに大きな影響を与えます。ここではStimulusとブラウザネイティブのバリデーションを組み合わせた例を紹介します。ブラウザネイティブのバリデーションの拡張性が高いこと、またかなり優れたUI/UXが実現できることが確認できると思います。
ここで作るのは次のようなUIです。サーバサイドバリデーションだけのものと、それにクライアントサイドバリデーションを重ねた2つのデモを用意しています。
バリデーションに大きく2つの役割があります。
Ruby on Railsは双方の役割を果たす機能を従来から用意してきました。しかしブラウザネイティブのバリデーションが発展んしたため、サーバ側が親切なエラーメッセージ表示を表示する必然性はなくなりました。サーバはデータの整合性に注力し、親切なエラーメッセージの表示はクライアントサイドに任せるという分業もできます。ブラウザのバリデーションを迂回し、悪質なリクエストを投げてきたユーザにわざわざ親切なメッセージを返す必要はないという考え方です。
そうなると優れたUI/UXを提供するためのクライアントサイドバリデーションの役割がますます重要になってきます。
Validatorクラスを書き、カスタマイズの冗長性をある程度減らしています。<!-- Stimulus controller (ValidatiorController)に接続しています --> <%= form_with(model: membership, data: { controller: 'validator' }) do |form| %> <!-- ここはサーババリデーションのエラーを表示する箇所です --> <% if membership.errors.any? %> <div style="color: red"> <h2><%= pluralize(membership.errors.count, "error") %> prohibited this membership from being saved:</h2> <ul> <% membership.errors.each do |error| %> <li><%= error.full_message %></li> <% end %> </ul> </div> <% end %> <div class="mt-4"> <%= form.label :name, class: "form-label form-label--required" %> <%= form.text_field :name, class: "text-field", required: true, # "input"イベントでvalidationを実行 # Target "name"としてのvalidationを行う。 data: { action: "input->validator#validate", validator_target: "name" } %> <!-- validationの結果を格納する --> <div class="error-message" data-validator-target="nameError"></div> </div> ... <div class="mt-8 flex justify-end"> <%= form.submit class: "btn-primary" %> </div> <% end %>
data-actionはバリデーション実行のタイミングを指示し、data-validator-targetはエラーの表示箇所を指定しています。.text-field { @apply block w-full rounded-md border-gray-300 shadow-sm focus:border-orange-600 focus:ring-orange-600; &:user-invalid { @apply !bg-red-500/10 !border-red-500 !border-2; } &:user-invalid + .error-message { @apply block } } .select-field { @apply block w-full rounded-md border-gray-300 shadow-sm focus:border-orange-600 focus:ring-orange-600; &:user-invalid { @apply !bg-red-500/10 !border-red-500 !border-2; } &:user-invalid + .error-message { @apply block } }
:user-invalidおよび:user-invalid + .error-messageでエラーメッセージを表示しています。:user-invalidはブラウザネイティブの機能ですがかなり高度な制御をしています。ユーザがバリデーションエラーを実際に起こしてしまうまではエラーを表示せず、エラーを起こした後に初めてエラーを表示します。エラーメッセージの鬱陶しさを最小限にコントロールしています。import {Controller} from "@hotwired/stimulus" import {Validator} from "../utilities/validator" // Connects to data-controller="validator" export default class extends Controller { static targets = [ 'name', 'nameError', 'email', 'emailError', 'membershipType', 'membershipTypeError', 'validFrom', 'validFromError', 'validTo', 'validToError', 'companyName', 'companyNameError' ] connect() { this.validator = new Validator([ { target: this.nameTarget, errorBox: this.nameErrorTarget, customMessages: {valueMissing: "名前を入力してください"}, customValidation: (target) => { if (target.value.length < 3) { target.setCustomValidity("名前は3文字以上で入力してください。") } } }, //... { target: this.validFromTarget, errorBox: this.validFromErrorTarget, customValidation: (target) => { if (!target.value || !this.validToTarget.value) return if (new Date(target.value) > new Date(this.validToTarget.value)) { target.setCustomValidity("開始時期は終了時期より前でなければなりません。") } } }, //... ]) } validate(event) { this.validator.validate() } }
new Validatorの箇所でバリデーションルールやカスタムメッセージを指定しています。Validatorはバリデーションの冗長なコードをまとめたカスタムのJavaScriptクラスです(下記参照)export class Validator { constructor(validatables) { this.validatables = validatables } validate() { this.#clearErrors() // Run custom validations or validations this.validatables.forEach(validatable => { this.#validateField(validatable) }) this.#updateErrorMessages() } #updateErrorMessages() { Object.values(this.validatables).forEach(validatable => { if (!validatable.errorBox) { throw new Error("errorBox is not defined ", validatable) } validatable.errorBox.textContent = validatable.target.validationMessage }) } #clearErrors() { Object.values(this.validatables).forEach(validatable => { if (!validatable.target) { throw new Error("Target is not defined") } validatable.target.setCustomValidity("") }) } #validateField(validationConfig) { const target = validationConfig.target if (!target) { throw new Error("Target is not defined") } const customMessages = validationConfig.customMessages const customValidation = validationConfig.customValidation // First run native validations if (!target.validity.valid) { // Customize validation messages here. if (customMessages) { Object.keys(customMessages).forEach(key => { if (target.validity[key]) { target.setCustomValidity(customMessages[key]) } }) } } else { // If native validation passes, run custom validations if (customValidation) { customValidation(target) } } } }
#validateField()のところがネイティブのclient-side form validationのメインの部分です。
target.validity.validでネイティブのバリデーションを実行します。customMessages)を適応します。customValidation)を実行します。:user-invalidを使用します。