とんかつ時々あんどーなつ

〜たとえ低空でも飛行していられるように〜

改めて Angular のフォームと向き合ってみた

ng-japan OnAir vol.37 でフォームについて考える機会があったので改めて Angular のフォームと向き合ってみた。 ng-japan OnAir で取り上げられた記事はこちら。

timdeschryver.dev

題材

個人的にフォームって素振りしにくいなとずっと思っていたのでなんかいいサンプルがないか探してみた。 今回は Google アカウントの作成に似たフォームを作ることにした。

f:id:kasaharu:20210330203714p:plain

仕様

  • フォームは「姓」「名」「ユーザー名」「パスワード」「確認」の 5 つのフィールドがある
  • 氏名は必須
    • 「姓」「名」両方が空の場合は「氏名を入力指定ください」のメッセージが出る
    • 「姓」のみ空の場合は「名前(名)を入力してください」のメッセージが出る
    • 「名」両方が空の場合は「名前(姓)を入力してください」のメッセージが出る
  • ユーザー名は必須
    • 6 文字以上 30 文字以下の文字数で設定しないと「ユーザー名は 6 文字から 30 文字の間で設定する必要があります」のメッセージが出る
  • パスワードは必須
    • 8 文字に満たない場合「パスワードは 8 文字以上で設定してください」のメッセージが出る
    • パスワードと確認に入力した文字が一致していないと「パスワードが一致しませんでした」のメッセージが出る
  • パスワードを表示する機能などは今回フォーカスしない
  • ここから先の説明は「姓: familyName」「名: firstName」「ユーザー名: userName」「パスワード: password」「確認: passwordConfirmation」とする

Angular の組み込みバリデーターを使う

まずはシンプルに Angular の組み込みバリデーターを使ってみる。

https://angular.io/guide/form-validation#built-in-validator-functions

対象となるフォームは FormGroup で定義する。FormBuilder を使って FormGroup を作ると以下のようになる。

form: FormGroup = this.fb.group({
  firstName: [''],
  familyName: [''],
  userName: [''],
  password: [''],
  passwordConfirmation: [''],
});

ここにバリデーターを指定するにはそれぞれの FormControl の第 2 引数に一つ、もしくは配列で複数渡せばよい。 必須のものには Validators.required を最小の長さが決まっている場合は Validators.minLength(x) を渡すだけで簡単に設定できる。

form: FormGroup = this.fb.group({
  firstName: ['', Validators.required],
  familyName: ['', Validators.required],
  userName: ['', [Validators.required, Validators.minLength(6), Validators.maxLength(30)]],
  password: ['', [Validators.required, Validators.minLength(8)]],
  passwordConfirmation: [''],
});

ただしバリデーターを FormControl に対して設定しているため、フォームのフィールド単位でしか指定できない。 例えば「パスワードと確認に入力した文字が一致しているか」というバリデーションはできない。

このままでも期待するメッセージを表示することはできる。

<form [formGroup]="form">
  <div class="fullname">
    <input type="text" formControlName="familyName" placeholder="姓" />
    <input type="text" formControlName="firstName" placeholder="名" />
  </div>
  <ng-container *ngIf="familyName?.errors?.required && firstName?.errors?.required">
    <div class="error-message">氏名を入力指定ください</div>
  </ng-container>
  <ng-container *ngIf="familyName?.errors?.required && !firstName?.errors?.required">
    <div class="error-message">名前(姓)を入力してください</div>
  </ng-container>
  <ng-container *ngIf="firstName?.errors?.required && !familyName?.errors?.required">
    <div class="error-message">名前(名)を入力してください</div>
  </ng-container>

  <div class="username">
    <input type="text" formControlName="userName" placeholder="ユーザー名" />
  </div>
  <ng-container *ngIf="userName?.errors?.required">
    <div class="error-message">ユーザー名は必須です</div>
  </ng-container>
  <ng-container *ngIf="userName?.errors?.minlength || userName?.errors?.maxlength">
    <div class="error-message">ユーザー名は 6 文字から 30 文字の間で設定する必要があります</div>
  </ng-container>

  <div class="password">
    <input type="password" formControlName="password" placeholder="パスワード" />
    <input type="password" formControlName="passwordConfirmation" placeholder="確認" />
  </div>
  <ng-container *ngIf="password?.errors?.required">
    <div class="error-message">パスワードを入力</div>
  </ng-container>
  <ng-container *ngIf="password?.errors?.minlength">
    <div class="error-message">パスワードは 8 文字以上で設定してください</div>
  </ng-container>
  <ng-container *ngIf="password?.errors === null && password?.value !== passwordConfirmation?.value">
    <div class="error-message">パスワードが一致しませんでした</div>
  </ng-container>
</form>

上に書いたように複数のフィールドをまたぐバリデーションは組み込みバリデーターでは書けないので、条件文がテンプレート側によってしまう問題がある。 また一つのフィールドに複数のバリデーターが指定されていてそれぞれで異なるエラーメッセージを表示したい場合に、getter が必要になったりもする。

クロスフィールドバリデーションを使う

https://angular.io/guide/form-validation#cross-field-validation

クロスフィールドバリデーションはフォーム内のフィールドをまたいだカスタムバリデータを指す。 例えば「パスワードと確認に入力した文字が一致しているか」というのは以下のように実装できる。

const passwordConfirmationValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
  const password = control.get('password');
  const passwordConfirmation = control.get('passwordConfirmation');

  if (password === null || passwordConfirmation === null) {
    return null;
  }

  return password.errors !== null || password.value === passwordConfirmation.value ? null : { unmatchPassword: true };
};

使うときは FormGroup の第 2 引数にオブジェクトで渡す必要がある。

form: FormGroup = this.fb.group(
  {
    firstName: ['', Validators.required],
    familyName: ['', Validators.required],
    userName: ['', [Validators.required, Validators.minLength(6), Validators.maxLength(30)]],
    password: ['', [Validators.required, Validators.minLength(8)]],
    passwordConfirmation: [''],
  },
  { validators: [passwordConfirmationValidator] },
);

これにより HTML 側にあったロジックが TS 側で管理できるようになる。 また、エラーが発生した場合も FormControl ではなく FormGroup の errors にセットされるため、テンプレート側で FormControl を取得するための getter も不要になる。

ここまでやって気づいたこと

ただし、これらを使っても以下のような課題感を持った。

  • 組み込みバリデーター関数を使う場合とクロスフィールドバリデーションを使う場合で定義の場所が散らばってしまう
  • 一つのフォームでクロスフィールドバリデーションが増えてくるとどのフィールドに対してのバリデーションかわかりにくくなる

まとめ

今回 Angular でフォームを実装してみることでメリット・デメリットがわかった。 また、改めてピックアップされていた 記事 を見ると課題感に共感でき、かなり魅力的な提案だと感じた。

上記の記事を参考にもう少し改善を試みたい。

今回試したコードはここ: https://github.com/kasaharu/ng-clone-gmail-signup-form