Angular FormArray内のFormGroupでCross-field validationを行い任意のinput要素にエラーを返す

はじめに

長いタイトルのままではありますが AngularにはReactiveFormという便利なModuleがあります。
かなり直感的にデータモデルの取り回しやValidation、ErrorHandlingが可能で私がAngularを愛してやまない理由の1つです。

今回は学習のためFormArrayでフォームの増減が可能かつ、要素をまたぐValidationを行うようなアプリケーションを作成してみます。

作ったもの

まずは結論から。stackblitzを貼っておきます。
余談ではありますが先日の発表でGCP CloudRunへのdeployができるようになったようです。(有料っぽい)
AngularにおいてstackoverflowやGithub issueなどのディスカッションになくてはならない存在になりつつあるstackblitzですが、学習&開発サイクルに大きな影響を与えてくれそうでとてもワクワクしています。

stackblitz.com

やりたいこと

今回作成したのは簡単な料金計算アプリです。
ユーザーは購入する商品とその個数を選択することができます。
また+で別の商品を追加でき-で商品を削除できます。

実装したいバリデーションは個数としての入力が整数であること、最大個数上限以下であること。を考えます。

f:id:nananao_dev:20190503102654p:plain
料金計算アプリ

Angular RactiveFormの構築

今回はフロントエンド側にベタ書きですがサーバーから以下のような商品情報が与えられると仮定します。
商品名や価格、最大発注上限数などがあります。

sampleProducts: Product[] = [
  {productName: 'Apple', productCode: 'p001', price: 100, maxQuantity: 10},
  {productName: 'Orange', productCode: 'p002', price: 80, maxQuantity: 15},
  {productName: 'Banana', productCode: 'p003', price: 60, maxQuantity: 5},
  {productName: 'Pineapple', productCode: 'p004', price: 500, maxQuantity: 3},
];

上記商品をいくつ、何種類購入するかを選択するUIになりそうです。
Formの全体像は以下のようにしました。(FormArrayをFormGroupでくくっているのは送料など別の要素を追加するためでした。今回のコードには関係ありません)

this.productFormGroup = this.formBuilder.group({
  products: this.formBuilder.array([
    this.formBuilder.group({
      product: this.formBuilder.control('', []),
      productNumber: this.formBuilder.control(1, [Validators.min(1), CustomValidator.integer]),
    }, { validators: CustomValidator.maxQuantity})
  ], [])
});

上記FormArrayを+ボタンで増加-ボタンで減少させ機能を実装します。
追加に関してはFormArrayの配列にpushするだけでFormが追加できてしまいます。恐ろしく簡単かつ直感的です。
削除に関しては(click)="delInput(index)" のようにテンプレート側から何番目のFormを削除したいのかを指定します。
また、テンプレートを*ngFor="let index=index; let last=last;"とすると最終要素も簡単に取得できるので追加ボタンは最後だけにでるようにしています。

get products(): FormArray {
  return this.productFormGroup.get('products') as FormArray;
}

addInput() {
 this.products.push(this.formBuilder.group({
    product: this.formBuilder.control('', []),
    productNumber: this.formBuilder.control(1, [Validators.min(1), CustomValidator.integer]),
  }, { validators: this.maxcheck}));
}

delInput(index) {
  this.products.removeAt(index);
}

Angular CustomValidation

ReactiveFormではCustomValidationをとても簡単に指定できます。
再掲となりますが以下のように各FormControlの要素の第二引数に対して配列でバリデータを渡すことで各要素単体のバリデーションが可能です。
また、FormGroupに対しても同様で各要素を跨いだバリデーションが可能です。

this.productFormGroup = this.formBuilder.group({
  products: this.formBuilder.array([
    this.formBuilder.group({
      product: this.formBuilder.control('', []),
      productNumber: this.formBuilder.control(1, [Validators.min(1), CustomValidator.integer]),
    }, { validators: CustomValidator.maxQuantity})
  ], [])
});

FormGroupに対しるCross-field validationですが、以下のように特定のinput要素にsetErrorsすることが可能です。
これは仮にproductNameを変更されたことが契機でINVALID(maxQuantityを超過してしまった)になった場合でも、productNumberがエラーであると表示したいためこのように実装しています。

public static maxQuantity(formGroup: FormGroup) {
  if (formGroup.controls.productNumber.value > formGroup.controls.product.value.maxQuantity) {
    formGroup.controls.productNumber.setErrors({ maxQuantityInvalid: true });
  } else {
    formGroup.controls.productNumber.setErrors(null);
  }
}

まとめ

Angular FormArray内のFormGroupでCross-field validationを行い任意のinput要素にエラーを返すことができました。
FormArray全体でのmaxQuantityのチェックができていないことに気づいたので後々修正しておきます。