ふるてつのぶろぐ

福岡在住のエンジニアです。

写真提供:福岡市

Angular 9 で Web アプリを作ろう - カスタムバリデーション

今日すること

こんにちは、ふるてつです。

今日はカスタムバリデーションについて書きます。
Angular標準のバリデーションは1つの単項目しかチェックできません。 複数の項目にまたがったチェックをしたい場合はカスタムバリデーションを作ります。

例えば画面上の「在庫数」と「購入数」を比較し「在庫数より購入数が上回っている場合」に在庫不足エラーとする。
そんな感じのバリデーションが簡単に書けるので便利です。

今回その「在庫数」と「購入数」を比較するバリデーションを作ってみました。
少しつきなみかもしれませんが、今日はそのお話をします😎

日本語リファレンスの「カスタムバリデータ」~「クロスフィールドバリデーション」を参考にして書きました。
https://angular.jp/guide/form-validation

f:id:tetsufuru:20200304004000p:plain

カスタムバリデーションを書く場所

まずカスタムバリデーションを追加する場所ですが、基本的にはcomponentts中に書きます。
わたしの場合は、リアクティブフォームを使っていますので、下記のようにformBuilder.group()の中になります。
バリデーションは「PurchaseQuantityStockQuantityValidator」という名前です。

registeringForm = this.formBuilder.group(
  {
    productCode: this.productCode,
    productName: this.productName,
 ~ 中略 ~
    validatorLocale: this.validatorLocale
  },
  {
    // ↓ ここです。
    // ↓ formBuilder.group()の validators に追加します。
    validators: [PurchaseQuantityStockQuantityValidator]
    // ↑ ここです。
  }
);

カスタムバリデーションの内容

バリデーションの内容は下記になりました。
長くないので全文を掲載しました。
「在庫数」と「購入数」以外に「ロケール」も必要になったので、追加してます。
(カンマ区切りになっている在庫数や購入数を数値に戻すときに必要になりました)
そしてこのバリデーションを使うフォームにも「ロケール」が必要になりますねー。

import { FormattedNumberPipe } from 'src/app/core/pipes/formatted-number.pipe';

import { FormGroup, ValidationErrors, ValidatorFn } from '@angular/forms';

const PRODUCT_STOCK_QUANTITY = 'productStockQuantity';
const PRODUCT_PURCHASE_QUANTITY = 'productPurchaseQuantity';
const VALIDATOR_LOCALE = 'validatorLocale';

export const PurchaseQuantityStockQuantityValidator: ValidatorFn = (control: FormGroup): ValidationErrors | null => {
  // フォームから必要なコントロールの値を取得する。
  // ↓「在庫数」
  const productStockQuantity: string = control.get(PRODUCT_STOCK_QUANTITY).value;
  // ↓「購入数」
  const productPurchaseQuantity: string = control.get(PRODUCT_PURCHASE_QUANTITY).value;
  // ↓「ロケール」
  const validatorLocale: string = control.get(VALIDATOR_LOCALE).value;

  // どちらかが空の場合は null を返します(エラーなしとします)。  
  if (!productStockQuantity) {
    return;
  }
  if (!productPurchaseQuantity) {
    return;
  }
  
  // 自作のパイプでカンマ区切りの値を数値に戻しています。  
  const formattedNumberPipe: FormattedNumberPipe = new FormattedNumberPipe();
  const numProductStockQuantity = Number(formattedNumberPipe.parse(productStockQuantity, validatorLocale));
  const numProductPurchaseQuantity = Number(formattedNumberPipe.parse(productPurchaseQuantity, validatorLocale));

  //  在庫数が多い場合は正常なので null を返します(エラーなし)。
  if (numProductPurchaseQuantity <= numProductStockQuantity) {
    return;
  }

  // ↓ ここから下はエラーになります。
  const validateError = { exceedStockError: true };
  // ↓ 「購入数」のエラーにしたいので購入数の```control```にエラーをセットします。
  control.get(PRODUCT_PURCHASE_QUANTITY).setErrors(validateError);
  // ↓ 戻り値もエラーを返します。
  return validateError;
};

Unitテスト

Unitテストも念のため掲載します。
こちらも全文載せました。
無駄に長ければすみません。

import { TestBed } from '@angular/core/testing';
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';

import {
    PurchaseQuantityStockQuantityValidator
} from './purchase-quantity-stock-quantity-validator';

const PRODUCT_PURCHASE_QUANTITY = 'productPurchaseQuantity';
describe('PurchaseQuantityStockQuantityValidator', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [PurchaseQuantityStockQuantityValidator]
    });
  });

  describe('#validate', () => {
    // ↓ 「在庫数」がブランクの時にエラーがないことを確認
    it('should not have error | productStockQuantity is blank', () => {
      const formBuilder: FormBuilder = new FormBuilder();
      const testingForm: FormGroup = formBuilder.group({
        productStockQuantity: new FormControl(''),
        productPurchaseQuantity: new FormControl(1),
        validatorLocale: new FormControl('ja-JP')
      });
      PurchaseQuantityStockQuantityValidator(testingForm);
      expect(testingForm.get(PRODUCT_PURCHASE_QUANTITY).getError('exceedStockError')).toBeNull();
    });
    // ↓ 「在庫数」が null の時にエラーがないことを確認(上と同じ内容ですが念のため null の時をテストしておく)
    it('should not have error | productStockQuantity is null', () => {
      const formBuilder: FormBuilder = new FormBuilder();
      const testingForm: FormGroup = formBuilder.group({
        productStockQuantity: new FormControl(null),
        productPurchaseQuantity: new FormControl(1),
        validatorLocale: new FormControl('ja-JP')
      });
      PurchaseQuantityStockQuantityValidator(testingForm);
      expect(testingForm.get(PRODUCT_PURCHASE_QUANTITY).getError('exceedStockError')).toBeNull();
    });

    // ↓ 「購入数」がブランクの時にエラーがないことを確認
    it('should not have error | productPurchaseQuantity is blank', () => {
      const formBuilder: FormBuilder = new FormBuilder();
      const testingForm: FormGroup = formBuilder.group({
        productStockQuantity: new FormControl(1),
        productPurchaseQuantity: new FormControl(''),
        validatorLocale: new FormControl('ja-JP')
      });
      PurchaseQuantityStockQuantityValidator(testingForm);
      expect(testingForm.get(PRODUCT_PURCHASE_QUANTITY).getError('exceedStockError')).toBeNull();
    });

    // ↓ 「購入数」が null の時にエラーがないことを確認
    it('should not have error | productPurchaseQuantity is null', () => {
      const formBuilder: FormBuilder = new FormBuilder();
      const testingForm: FormGroup = formBuilder.group({
        productStockQuantity: new FormControl(1),
        productPurchaseQuantity: new FormControl(null),
        validatorLocale: new FormControl('ja-JP')
      });
      PurchaseQuantityStockQuantityValidator(testingForm);
      expect(testingForm.get(PRODUCT_PURCHASE_QUANTITY).getError('exceedStockError')).toBeNull();
    });

    // ↓ 「購入数」と「在庫数」が等しい時にエラーがないことを確認
    it('should not have error | productPurchaseQuantity equals productStockQuantity', () => {
      const formBuilder: FormBuilder = new FormBuilder();
      const testingForm: FormGroup = formBuilder.group({
        productStockQuantity: new FormControl(1),
        productPurchaseQuantity: new FormControl(1),
        validatorLocale: new FormControl('ja-JP')
      });
      PurchaseQuantityStockQuantityValidator(testingForm);
      expect(testingForm.get(PRODUCT_PURCHASE_QUANTITY).getError('exceedStockError')).toBeNull();
    });

    // ↓ 「購入数」が「在庫数」を超えた時はエラーになることを確認
    it('should have error | productPurchaseQuantity exceeds productStockQuantity', () => {
      const formBuilder: FormBuilder = new FormBuilder();
      const testingForm: FormGroup = formBuilder.group({
        productStockQuantity: new FormControl(1),
        productPurchaseQuantity: new FormControl(2),
        validatorLocale: new FormControl('ja-JP')
      });
      PurchaseQuantityStockQuantityValidator(testingForm);
      expect(testingForm.get(PRODUCT_PURCHASE_QUANTITY).getError('exceedStockError')).toBeTruthy();
    });
  });
});

動かしてみる

今日やりたかったのは下記のような感じです。
f:id:tetsufuru:20200304013527p:plain

今日の感想


今日はカスタムバリデーションについて書いてみました。
コードもさほど多くならないし、テストもあまり難しくならなかったので、練習でさらに一つ二つ書いてみるのもいいかもと思いました🌸

では、今日もお疲れ様でした。