狛ログ

2022年8月8日月曜日

【Angular】独自エラーチェック(カスタムバリデーション)を作成する。

オフィス狛 技術部のKoma(Twitterアカウントの中の人&CEO)です。

今回は、独自エラーチェック(カスタムバリデーション)を作成します。
プログラムは、以前の記事で使用した「アカウント登録機能」を使用したいと思いますので、下記の記事も参照ください。

参照1:【Angular】コンポーネントの設計(画面ごとの設計)について。
参照2:【Angular】エラーメッセージの管理について考える。

(1)登録画面の実装を確認

まずは、現在の登録画面のコンポーネントを見てみます。
コード量が多いので、全体はこちら(register.component.ts)で確認ください
今回カスタムバリデーションを追加するのは、こちらの携帯番号(mobilePhoneNumber)部分です。
  mobilePhoneNumberMaxLength = 11;
  nameMaxLength = 20;
  // (中略)
  formRegister: FormGroup = this.formBuilder.group({
    mobilePhoneNumber: ['',[Validators.required,Validators.maxLength(this.mobilePhoneNumberMaxLength)]],
    name: ['', [Validators.maxLength(this.nameMaxLength)]],
  });

しかし、これだけだと、「18011112222」のような「0」始まりではない番号はエラーになりません。
このチェックを独自で作成しようと思いますが、
その前に、一応テンプレート側の記載も見ておこうと思います。

こちらもコード量が多いので、全体はこちら(register.component.html)で確認ください
下記に、今回関係する部分だけ記載しておきます。
    <div>
      <label>携帯電話番号<span>必須</span></label>
      <input formControlName="mobilePhoneNumber" type="tel" inputmode="numeric" class="form-control" placeholder="携帯電話番号を入力"
        [ngClass]="{'alert-danger' : v.mobilePhoneNumberInvalid}" autofocus>
      <ng-container *ngIf="v.mobilePhoneNumberInvalid">
        <p *ngIf="v.mobilePhoneNumberHasErrorRequired" class="error-message">{{ message('msg_error_field_required', '携帯電話番号') }}</p>
        <p *ngIf="v.mobilePhoneNumberHasErrorMaxLength" class="error-message">{{ message('msg_error_field_max', '携帯電話番号', mobilePhoneNumberMaxLength) }}</p>
      </ng-container>
    </div>

(2)カスタムバリデーションを作成する

弊社のプロジェクトでは、「app」配下に「shared」と言うディレクトリを作り、その中にプロジェクトで共通的に使うものを配置しています。
今回のカスタムバリデーションも、共通的に使われるものなので、、以下のように作成します。
src/
└ app/
    └ shared/
        └ validator/
            └ custom-validators.ts

では、携帯電話番号のチェックを実装しようと思います。
[custom-validators.ts]
import { FormControl } from '@angular/forms';

export class CustomValidators {
  /**
   * 携帯電話番号かどうか判定
   * @param control Formのコントロール
   */
  static mobilePhoneNumberValidator(control: FormControl) {
    const dateObj = control.value;

    // 当メソッドでは、電話番号は空文字で登録することも許可する
    if (dateObj === '') {
      return null;
    }

    const regexp = new RegExp('^(0{1}\\d{10})');
    if (
      typeof dateObj === 'undefined' ||
      dateObj === null ||
      !regexp.test(dateObj)
    ) {
      return { mobilePhoneNumberFormat: true };
    }

    return null;
  }
}

当メソッドでは、電話番号は空文字で登録することも許可する」と言うコメントの部分が特殊なのですが、例えば、「任意の項目」でこのバリデーションを使いたくなった場合、そのまま使うと「未入力」でもエラーになってしまうので、未入力はエラーとしないようにしています。
どっちにしても、必須かどうかは、通常のバリデーションで行なっているので、そちらに任せる、と言う感じです。
それと、「携帯電話番号かどうか」はかなり適当に記載していますので、ご了承ください(今回は、その説明が本質では無いので)

ちょっと分かり難い(勘違いしやすい)のですが、「エラーになるパターンは『true』を返却し、エラーとしない場合『null』を返却しています」

(3)作成したカスタムバリデーションを使う

では、作成したカスタムバリデーションを実際に使ってみます。
「register.component.ts」に実装していきますが、まずは先ほどのカスタムバリデーションをimportします。
import { CustomValidators } from '@app/shared/validator/custom-validators';

importしたカスタムバリデーションは、通常のバリデーションと同じ流れで定義します。
  formRegister: FormGroup = this.formBuilder.group({
    mobilePhoneNumber: [
      '',
      [
        Validators.required, // ←通常のバリデーション
        Validators.maxLength(this.mobilePhoneNumberMaxLength), // ←通常のバリデーション
        CustomValidators.mobilePhoneNumberValidator, // ←カスタムバリデーション
      ],
    ],
    name: ['', [Validators.maxLength(this.nameMaxLength)]],
  });

とても簡単ですね、次は、別ファイルにしている「register.validator.ts」にも追記します。
こちらは、全文そのまま記載しようと思います。
[register.component.html]
import { Injectable } from '@angular/core';
import { FormGroup } from '@angular/forms';

@Injectable()
export class RegisterValidator {
  private form: FormGroup;

  constructor() {}

  set formGroup(form: FormGroup) {
    this.form = form;
  }

  get mobilePhoneNumberInvalid() {
    return (
      this.form.controls['mobilePhoneNumber'].invalid &&
      (this.form.controls['mobilePhoneNumber'].dirty ||
        this.form.controls['mobilePhoneNumber'].touched)
    );
  }
  get mobilePhoneNumberHasErrorRequired() {
    return this.form.controls['mobilePhoneNumber'].hasError('required');
  }
  get mobilePhoneNumberHasErrorMaxLength() {
    return (
      !this.form.controls['mobilePhoneNumber'].hasError('required') &&
      this.form.controls['mobilePhoneNumber'].hasError('maxlength')
    );
  }
  get mobilePhoneNumberHasErrorFormat() {
    return (
      !this.form.controls['mobilePhoneNumber'].hasError('required') &&
      this.form.controls['mobilePhoneNumber'].hasError(
        'mobilePhoneNumberFormat',
      )
    );
  }

  get nameInvalid() {
    return (
      this.form.controls['name'].invalid &&
      (this.form.controls['name'].dirty || this.form.controls['name'].touched)
    );
  }
  get nameHasErrorMaxLength() {
    return this.form.controls['name'].hasError('maxlength');
  }
}

追記した部分は以下の通りです。
  get mobilePhoneNumberHasErrorFormat() {
    return (
      !this.form.controls['mobilePhoneNumber'].hasError('required') &&
      this.form.controls['mobilePhoneNumber'].hasError(
        'mobilePhoneNumberFormat',
      )
    );
  }
この「mobilePhoneNumberHasErrorFormat」は、テンプレート側で使うことになります。
では、そのテンプレート側も変更しようと思います。

    <div>
      <label>携帯電話番号<span>必須</span></label>
      <input formControlName="mobilePhoneNumber" type="tel" inputmode="numeric" class="form-control" placeholder="携帯電話番号を入力"
        [ngClass]="{'alert-danger' : v.mobilePhoneNumberInvalid}" autofocus>
      <ng-container *ngIf="v.mobilePhoneNumberInvalid">
        <p *ngIf="v.mobilePhoneNumberHasErrorRequired" class="error-message">{{ message('msg_error_field_required', '携帯電話番号') }}</p>
        <p *ngIf="v.mobilePhoneNumberHasErrorMaxLength" class="error-message">{{ message('msg_error_field_max', '携帯電話番号', mobilePhoneNumberMaxLength) }}</p>
        <p *ngIf="v.mobilePhoneNumberHasErrorFormat" class="error-message">{{ message('msg_error_cellphone_number') }}</p>
      </ng-container>
    </div>

下記が追加した部分です。今回、新たにエラーメッセージも追加しています。
(エラーメッセージの管理については、「【Angular】エラーメッセージの管理について考える。」を参照ください)
<p *ngIf="v.mobilePhoneNumberHasErrorFormat" class="error-message">{{ message('msg_error_cellphone_number') }}</p>

これで、独自エラーチェック(カスタムバリデーション)を実装できました。
ある程度規模の大きいプロジェクトになると、結構な数のカスタムバリデーションを作る事になるかと思いますので、参考にして頂ければ幸いです。


2022年8月5日金曜日

【Angular】コンポーネントの設計(画面ごとの設計)について。

オフィス狛 技術部のKoma(Twitterアカウントの中の人&CEO)です。

前回、「機能内の画面構成(コンポーネント構成)設計」について記載したので、今回は、「画面ごとのコンポーネント設計」を記載しようと思います。

それでは、前回の続きとして、『アカウント登録処理』から「登録情報入力画面」について、コンポーネントの設計を説明しようと思います。

(1)前回のおさらい

「アカウント登録処理」として、以下のような画面が必要と想定されます。

①登録情報入力画面
②入力情報確認画面
③登録完了画面

画面遷移としては以下になります。

そして、ディレクトリ構成は以下となります。(一部省略)
src/
└ app/
    └ account/
        └ containers/
            ├ account/
            ├ account-register
            │  └ account-register.component.html
            │  └ account-register.component.spec.ts
            │  └ account-register.component.ts
            ├ account-register-confirm
            │  └ ・・・
            └ account-register-complete
                 └ ・・・

図で表すと、以下のようになります。

(2)画面単位でのディレクトリ構成を考える

画面単位で考えた時、「登録情報入力画面」にはどんな機能が含まれるでしょうか?

  • 画面の表示
  • 入力項目のチェック(バリデーション、業務的な関連チェックなど)
  • 入力項目を次の画面へ持ち越す為の準備
  • 確認画面から戻って来た場合は、入力した値を再現

などなど、色々ありますね。
もっと複雑な機能であれば、「遷移先を入力値によって分岐させる」や、「APIを呼び出し、入力用の補足情報を取得する」・・・・なんて事もあるかもしれません。

このような多種多様な処理を以下の「containers」配下のコンポーネントだけでやるとしたら、コンポーネントが肥大化してしまうと思います。
src/
└ app/
    └ account/
        └ containers/
            └ account-register
                  └ account-register.component.ts ← 肥大化する

そこで、「画面の表示」「入力項目のチェック」など、View(ビュー)側に属する処理については、「presentations」ディレクトリに分けることにします。

と言うことで「presentations」ディレクトリとファイルを追加します。
src/
└ app/
    └ account/
        └ containers/
        └ presentations/
              └ register/
                   └ register.component.html
                   └ register.component.spec.ts
                   └ register.component.ts
                   └ register.validator.ts

※「 register.validator.ts」について
弊社のプロジェクトでは、Validationは出来る限りViewとコンポーネントからは切り離して別ファイルで管理するようにしています。(Validationについては、以前の記事「【Angular】FormのValidationの書き方を簡略化させる。」を参照ください。)

(3)「Container」と「Presentation」の切り分け方

「Container」と「Presentation」の切り分けですが、弊社では以下のように定義付けしています。

「Container」として分類するもの
  • 画画面の状態保持に関すること(NgRxのStore操作など)
  • 画面に入力した値の業務チェック(API呼び出しが必要なもの、など)
  • API(バックエンド処理)の呼び出し
  • 画面遷移

「Presentation」として分類するもの
  • 画面表示、及び表示内容の制御(エラー時など)
  • 画面に入力した値のバリデーションチェック
  • 画面に入力した値の関連チェック、及び業務チェック

「関連チェック」というのは、例えば「AとBが選択されているときは、Cは必須となる」とか、です。
両方に含まれている「業務チェック」が、判断難しいところかな、と思います。
クライアント内で完結出来るチェックであれば「Presentation」。完結出来ないものは「Container」、という考えで良いと思います。

アカウント登録で言うと、「入力されたメールアドレスが既に使用されているかどうか」のチェックは、クライアント内では完結出来ず、バックエンドの処理(API等)を呼び出す必要があると思いますので、「Container」側に記載する必要があります。

さて、今の状態を図で表すと、以下のようになります。(登録情報入力画面のみ)

(4)「Container」と「Presentation」間のデータ・処理受け渡し方法を考える

ここで、分かりやすいように、「Container」を、「Presentation」をとして、話を進めようと思います。
親と子の間では、データ・処理のやり取りが必要になります。

コンポネート間のデータ・処理のやり取りについては、いくつか方法がありますが、
今回は、「@Input()、@Output()」を使います。

参考サイト(本家):ディレクティブとコンポーネントの親子間でのデータ共有

(5)親子間でやり取りが必要なデータ・処理を考える

方式が決まったところで、親子間で、どんなデータ・処理のやり取りが必要か考えます。
登録情報入力画面の内部処理は、以下の流れになると思います。

1. 画面初期表示
 ※確認画面から戻ってきた場合は、画面の入力値を復元
  →親から子へ、復元用のデータを送る(@Input())
2. 画面の各フィールドへの値入力(バリデーション)
3. Submitによって、入力値を引き継ぎつつ、確認画面へ遷移
 →入力値を引数に、子から親へ処理を引き継ぐ(@Output())

上記を実現したいのですが、ここで、
  • 親子間でのデータ
  • 次画面(確認画面)に送るデータ
両方を兼ね備えたデータモデルを作成しようと思います。
src/
└ app/
    └ account/
        └ containers/
        └ presentations/
        └ models/
              └ account.ts

中身は下記のようにします。
// アカウント登録用画面間保持データモデル
export class AccountViewSaveModel {
  mobilePhoneNumber: string;
  name: string;
}
とりあえず、電話番号と名前を項目として用意しました。この辺はあくまで例なので適当です。

続いて、親と子のコンポーネントの中身を実装します。

(6)親子間のデータ・処理のやり取りを実装する

まずは親コンポーネントの実装です。
[account-register.component.ts]
import { Component, OnInit } from '@angular/core';
import { Store, select } from '@ngrx/store';
import { Router } from '@angular/router';
import { AccountViewSaveModel } from '../../models/account';
import * as fromAccount from '../../store/reducers';
import * as AccountActions from '../../store/actions/account.actions';

@Component({
  selector: 'koma-account-register',
  templateUrl: './account-register.component.html',
})
export class AccountRegisterComponent implements OnInit {

  // Storeから入力情報を取得する(確認画面から戻ってきた場合には値が入っている)
  // 取得値の型はObservableになるので、変数の末尾に「$」を付ける(お約束みたいなもの)
  accountViewSave$ = this.store.pipe(select(fromAccount.getDataRegisterPost));

  constructor(public store: Store<fromAccount.State>, private router: Router) {}

  ngOnInit(): void {}

  onSubmit(formModel: AccountViewSaveModel): void {
    // 入力情報をStoreに格納して(=引き継ぎ情報として)、遷移後の画面でも使えるようにする。
    this.store.dispatch(
      AccountActions.setRegisterPostData({ data: formModel }),
    );
    // 確認画面へ遷移する
    this.router.navigateByUrl('/account/register-confirm');
  }
}

唐突に「NgRxのStore」が出てきましたが、ここは一旦スルーしてください。(いずれ、NgRxの実装方法も記事にしようと思います。)
ここでは、画面で入力された値はStore(と言う場所)に格納されている、ぐらいの理解でOKです。
(今回重要なのは、「データ保存方式」ではなく、あくまで「データ受け渡し部分」なので)

細かい説明も実装内のコメントとして記載しましたので、参考にお願いします。

続いて、子コンポーネントの実装です。
[register.component.ts]
import { Component, OnInit, Input, Output, OnDestroy, EventEmitter } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Observable, Subscription } from 'rxjs';
import { RegisterValidator } from './register.validator';
import { AccountViewSaveModel } from '../../models/account';
import { getMessage } from '@app/shared/message/error-messages';

@Component({
  selector: 'koma-register',
  templateUrl: './register.component.html',
})
export class RegisterComponent implements OnInit, OnDestroy {
  // Input・Outputの定義。
  @Input() accountViewSave$: Observable<AccountViewSaveModel>;
  @Output() formSubmit = new EventEmitter<AccountViewSaveModel>();

  mobilePhoneNumberMaxLength = 11;
  nameMaxLength = 20;

  registerSubscription: Subscription = new Subscription();

  formRegister: FormGroup = this.formBuilder.group({
    mobilePhoneNumber: ['',[Validators.required,Validators.maxLength(this.mobilePhoneNumberMaxLength)]],
    name: ['', [Validators.maxLength(this.nameMaxLength)]],
  });

  constructor(private formBuilder: FormBuilder, public v: RegisterValidator) {}

  ngOnInit(): void {
    // バリデーション設定
    this.v.formGroup = this.formRegister;

    this.registerSubscription.add(
      // 画面初期値設定
      this.accountViewSave$.subscribe(value => {
        if (value) {
          this.formRegister.controls.mobilePhoneNumber.setValue(
            value.mobilePhoneNumber,
          );
          this.formRegister.controls.name.setValue(value.name);
        }
      }),
    );
  }

  ngOnDestroy(): void {
    // サブスクリプション解除
    this.registerSubscription.unsubscribe();
  }

  // 画面でSubmitが発生した時の処理
  onSubmit(): void {
    if (this.formRegister.valid) {
      // バリデーションエラーが発生していない場合
      const formModel = {
        mobilePhoneNumber: this.formRegister.controls.mobilePhoneNumber.value,
        name: this.formRegister.controls.name.value,
      } as AccountViewSaveModel;

      this.formSubmit.emit(formModel);
    }
  }

  message(messageId: string, ...args: any[]): string {
    return getMessage(messageId, ...args);
  }
}
ちょっと記載量が多いのですが、あくまで「親子間のデータやり取り」部分についてのみ説明します。
その他、FormBuilder, FormGroup, Validatorsあたりは、以前の記事などを参照ください。

参照:FormのValidationの書き方を簡略化させる

まずはInput・Outputの定義です。
  @Input() accountViewSave$: Observable<AccountViewSaveModel>;
  @Output() formSubmit = new EventEmitter<AccountViewSaveModel>();
Inputは、親からデータが来る為の受け口になるので、分かりやすく同じ変数名にします。
また、型も同じ「Observable」にします。
Outputについては、「処理を引き継ぐ」為のお決まりの「EventEmitter」を使います。

続いて、ngOnInitで、親からのデータをFormに反映する処理をSubscribeします。
      // 画面初期値設定
      this.accountViewSave$.subscribe(value => {
        if (value) {
          this.formRegister.controls.mobilePhoneNumber.setValue(
            value.mobilePhoneNumber,
          );
          this.formRegister.controls.name.setValue(value.name);
        }
      }),

Subscribeする事で、親からのデータが来れば、常にアクティブに画面表示が更新されることになります。

最後が、画面でSubmitが発生した場合です。
  // 画面でSubmitが発生した時の処理
  onSubmit(): void {
    if (this.formRegister.valid) {
      // バリデーションエラーが発生していない場合
      const formModel = {
        mobilePhoneNumber: this.formRegister.controls.mobilePhoneNumber.value,
        name: this.formRegister.controls.name.value,
      } as AccountViewSaveModel;

      this.formSubmit.emit(formModel);
    }
  }

バリデーションエラーが発生していない場合、画面で入力された値を Model にセットし、その Model を引数に Emit します。 Emitする事で、親側に処理を引数ごと引き渡すことになります。

続いて、親のテンプレート(View)を実装します。
[account-register.component.html]
<koma-register
  [accountViewSave$]="accountViewSave$"
  (formSubmit)="onSubmit($event)"
></koma-register>
左辺が子の変数(処理)、右辺が親の変数(処理)となります。
ここで、親子を結び付けています。

最後に、子のテンプレート(View)を実装します。
[register.component.html](form部分のみ抜粋)
<form [formGroup]="formRegister" (ngSubmit)="onSubmit()" (keydown.enter)="$event.preventDefault()">
  <div class="form-input">
    <div>
      <label>携帯電話番号<span>必須</span></label>
      <input formControlName="mobilePhoneNumber" type="tel" inputmode="numeric" class="form-control" placeholder="携帯電話番号を入力"
        [ngClass]="{'alert-danger' : v.mobilePhoneNumberInvalid}" autofocus>
      <ng-container *ngIf="v.mobilePhoneNumberInvalid">
        <p *ngIf="v.mobilePhoneNumberHasErrorRequired" class="error-message">{{ message('msg_error_field_required', '携帯電話番号') }}</p>
        <p *ngIf="v.mobilePhoneNumberHasErrorMaxLength" class="error-message">{{ message('msg_error_field_max', '携帯電話番号', mobilePhoneNumberMaxLength) }}</p>
      </ng-container>
    </div>
    <div>
      <label>お名前</label>
      <input formControlName="name" type="text" class="form-control" placeholder="お名前を入力(任意)"
        [ngClass]="{'alert-danger' : v.nameInvalid}">
      <ng-container *ngIf="v.nameInvalid">
        <p class="error-message">{{ message('msg_error_field_max', 'お名前', nameMaxLength)}}</p>
      </ng-container>
    </div>
  </div>
  <footer>
    <button [disabled]="formRegister.invalid" type="submit">確認</button>
  </footer>
</form>
こちらに関しては、特に「親子間のデータやり取り」について意識している部分は無いですね。

これで、

1. 画面初期表示
 ※確認画面から戻ってきた場合は、画面の入力値を復元
  →親から子へ、復元用のデータを送る(@Input())
2. 画面の各フィールドへの値入力(バリデーション)
3. Submitによって、入力値を引き継ぎつつ、確認画面へ遷移
 →入力値を引数に、子から親へ処理を引き継ぐ(@Output())

が実現出来たことになります。(長かった・・・・・)
最終的に図で表すと下記のようになります。

今回は、コンポーネント設計(と実装)を紹介しました。
もっと複雑な画面になると、設計方法や実装も違ってきますので、それはまた別途記事にしようと思います。


2022年8月4日木曜日

【Angular】エラーメッセージの管理について考える。

オフィス狛 技術部のKoma(Twitterアカウントの中の人&CEO)です。

以前「FormのValidationの書き方を簡略化させる」と言う記事を書きましたが、その時、特にValidationエラーのメッセージについては触れませんでした。
今回、Validationエラーも含めた「メッセージ管理」について考えて行こうと思います。
以前の記事をまだ見ていない方は、是非、目を通して頂けると幸いです。

ちなみに、今回の考え方と実装は、Angularに限らず(TypeScriptやJavaScriptを使っているフレームワークであれば)、同じように使えるかな、と思います。

(1)そもそも、何が問題なのか

前回、最終的にテンプレート(View)側は下記のようになりました。
[register.component.html]
<form [formGroup]="formRegister" (ngSubmit)="onSubmit()" (keydown.enter)="$event.preventDefault()">
      <div>
        <label>お名前</label>
        <input formControlName="name" type="text" class="form-control" placeholder="お名前を入力(必須)"
          [ngClass]="{'alert-danger' : v.nameInvalid}">
        <ng-container *ngIf="v.nameInvalid">
          <ng-container *ngIf="v.nameHasErrorRequired">
            <p class="error-message">お名前は必ず入力してください。</p>
          </ng-container>
          <ng-container *ngIf="v.nameHasErrorMaxLength">
            <p class="error-message">お名前は10文字以内で入力してください。</p>
          </ng-container>
        </ng-container>
      </div>
      <button class="btn btn1 ml-auto" [disabled]="formRegister.invalid" type="submit">登録</button>
</form>

これの何が問題になるのでしょうか・・・・?

例えば、必須入力のエラーである「xxxは必ず入力してください。」ですが、内容を「xxxの入力は必須です。」に変えたい、となった場合を考えます。
テンプレート(View)側を変えれば良いのでしょうが、例えば、必須項目が数十個あった場合はどうでしょうか?
そして、この画面だけでなく、他の画面でも必須項目があった場合はどうでしょうか?
なるべく修正箇所は少なく済ませたいですが、どうしても対応する量が多くなってしまいます。

(2)メッセージを1箇所で管理する

まあ、1箇所って言ってしまうと極端ですが、なるべくまとめて管理する、が良いと思います。

※例えば、メッセージにも「正常」「エラー」「ワーニング」など種別があると思いますので、それを全て1箇所で管理すると、今度は管理するファイルが肥大化することになるので、種別毎に管理した方が良い・・・とか、ですね。

早速、まとめて管理するファイルを作ろうと思います。
弊社のプロジェクトでは、「app」配下に「shared」と言うディレクトリを作り、その中にプロジェクトで共通的に使うものを配置しています。
今回は、以下のように作成します。
src/
└ app/
    └ shared/
        └ message/
            └ error-messages.ts

見ての通り、今回は、あくまで「エラー用メッセージ」として管理します。

(3)メッセージ管理処理の仕様について

さて、管理用のファイルを作ったところで、実装はどのようにしましょう。
パッと思い付く要求仕様としては、

  • 複数のメッセージを定義可能。
  • メッセージは、差し込みが可能。
  • メッセージ毎にユニークなKeyを持ち、Keyからメッセージを取得可能。

と言うところですかね。

特に今回は「メッセージは、差し込みが可能。」が重要です。
必須項目であれば、「{0}は必ず入力してください。」と定義して、「{0}」の部分を名前だったり、電話番号だったり、郵便番号だったり・・・・と動的に差し込み出来れば、メッセージの定義は1つだけで済みます。

では、要求仕様を満たす実装をします。
[error-messages.ts]
export const errorMessages: { [key: string]: string } = {
  msg_error_field_required: '{0}は必ず入力してください。',
  msg_error_field_max: '{0}は{1}文字以内で入力してください。',
};

function formatMessage(msg: string, ...args: any[]): string {
  return msg.replace(/\{(\d+)\}/g, (m, k) => {
    return args[k];
  });
}

export function getMessage(messageId: string, ...args: any[]): string {
  return formatMessage(errorMessages[messageId], ...args);
}

ざっくりと説明します。
まず、他の処理からこの「メッセージ管理処理」を使いたい時は、メッセージの Key と、差し込みたい文字列を引数に getMessage を呼ぶことになります。
export function getMessage(messageId: string, ...args: any[]): string {
  return formatMessage(errorMessages[messageId], ...args);
}

getMessage の中で、formatMessage が呼ばれ、ここで、Keyに該当するメッセージを取得しつつ、差し込み文字列を置換している、と言う感じです。
function formatMessage(msg: string, ...args: any[]): string {
  return msg.replace(/\{(\d+)\}/g, (m, k) => {
    return args[k];
  });
}

メッセージの定義自体は、連想配列として、Key・Valueで定義しています。
export const errorMessages: { [key: string]: string } = {
  msg_error_field_required: '{0}は必ず入力してください。',
  msg_error_field_max: '{0}は{1}文字以内で入力してください。',
};

(4)メッセージ管理処理を使ってみる

では、実際にメッセージ管理処理を使ってみます。
まずは、コンポーネント側の実装です。
[register.component.ts]
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { RegisterValidator } from './register.validator';
import { getMessage } from '@app/shared/message/error-messages';
  // (中略)
export class RegisterComponent implements OnInit {
  // (中略)
  formRegister: FormGroup = this.formBuilder.group({
    name: ['', [Validators.required, Validators.maxLength(10)]],
  });
 
  // (中略)
  
  message(messageId: string, ...args: any[]): string {
    return getMessage(messageId, ...args);
  }

コンポーネント側で「error-messages.ts」のimportを行い、
メッセージ管理処理の「getMessage」を呼び出す「message()」を追加しました。

この「message()」を呼び出すのは、テンプレート側です。
[register.component.html]
<form [formGroup]="formRegister" (ngSubmit)="onSubmit()" (keydown.enter)="$event.preventDefault()">
      <div>
        <label>お名前</label>
        <input formControlName="name" type="text" class="form-control" placeholder="お名前を入力(必須)"
          [ngClass]="{'alert-danger' : v.nameInvalid}">
        <ng-container *ngIf="v.nameInvalid">
          <ng-container *ngIf="v.nameHasErrorRequired">
            <p class="error-message">{{ message('msg_error_field_required', 'お名前') }}</p>
          </ng-container>
          <ng-container *ngIf="v.nameHasErrorMaxLength">
            <p class="error-message">{{ message('msg_error_field_max', 'お名前', 10)}}</p>
          </ng-container>
        </ng-container>
      </div>
      <button class="btn btn1 ml-auto" [disabled]="formRegister.invalid" type="submit">登録</button>
</form>

元々、メッセージを固定で記載していた部分を以下のように変更しています。
{{ message('msg_error_field_required', 'お名前') }}
{{ message('msg_error_field_max', 'お名前', 10)}}

テンプレート側とコンポーネント側で「10」を2回定義しているのが気になりますね。ここは定数宣言して1回の定義で済むようにしましょう。
[register.component.ts]
  nameMaxLength = 10;

  formRegister: FormGroup = this.formBuilder.group({
    name: ['', [Validators.required, Validators.maxLength(this.nameMaxLength)]],
  });

テンプレート側も変えておきます。 [register.component.html]
{{ message('msg_error_field_max', 'お名前', nameMaxLength)}}

これで全ての対応が完了しました。

今後、仮に「xxxは必ず入力してください。」を「xxxの入力は必須です。」に変えたいとなっても、
全項目・全画面確認する必要はなく、「error-messages.ts」を変更すれば、一律変更出来ることになりました。

(5)デメリットも把握しておく

当然ながら、汎用的に使われているメッセージについて、「特定の1箇所だけメッセージだけ変えたい」と言う要望に対しては、対応が難しくなります。
まあ、そんな事はあまり無い思いますが、それよりも、実際に弊社が抱えている悩みとしては・・・・・
デザイナーが作ったhtmlをAngular側に反映する時に苦労する。
があります。

この方式は結局のところ、テンプレート側にロジックを記載している事になるのですが、デザイナーとしては、そんなこと知ったこっちゃないので、元のままメッセージ直書きしたhtmlを渡してきます。
画面のレイアウトに変更があった場合など、プログラマーが気を付ける必要がある、と言う事が、デメリットと言えばデメリットなのかもしれません。


2022年8月3日水曜日

【Angular】コンポーネントの設計(機能内の画面構成設計)について。

オフィス狛 技術部のKoma(Twitterアカウントの中の人&CEO)です。

今回は、弊社内のAngularプロジェクトでのコンポーネントの設計のルールを記載しようと思います。

2記事に分けて「コンポーネントの設計」についてお届けしますが、今回は「機能内の画面構成設計」となります。
(次回は「画面ごとの設計」になります)

それでは、今回は、『アカウント登録処理』を例に、コンポーネントの設計を説明しようと思います。

(1)まずは必要な画面と、画面遷移を考える

「アカウント登録処理」として、以下のような画面が必要と想定されます。

①登録情報入力画面
②入力情報確認画面
③登録完了画面

当然、画面遷移としては以下になりますね。

(2)機能としてのディレクトリ構成を考える

では、次は「アカウント登録処理」として、ディレクトリ構成を考えます。
各画面の設計の前に、「機能」としての設計をする、って事ですね。

まず、何も機能が無いAngularプロジェクトだと、
src/
├ app/
├ app-routing.module.ts
├ app.component.html
├ app.component.ts
└ app.module.ts
となっています。

ここに「アカウント登録処理」を追加するとしたら、以下のようになります。
src/
└ app/
    └ account/
        ├ containers/
        │  └ account/
        │      └ account.component.html
        │      └ account.component.spec.ts
        │      └ account.component.ts
        ├ account-routing.module.ts
        └ account.module.ts
いきなり「containers」ディレクトリが出てきましたが、後ほど説明しますので、一旦スルーしてください。
ここでは、「account」と言う「アカウント登録処理」を示すコンポーネントを作った、と言うことになります。
図で表すと、
となります。これだけだと、「??」って感じですが、とりあえず先へ進みます。

(3)各画面のディレクトリ構成を考える

続いて、各画面のディレクトリ構成を考えてみます。
画面が3つだから、ディレクトリも3つで、コンポーネントとしても3つ・・・はい、最初はその考えで大丈夫です。
Angularに限らず、コンポーネント設計で重要なのは、まずは、ざっと大きめな区分けで考えておいて、後から細分化していく、です。

と言うことで、以下のような構成になります。
src/
└ app/
    └ account/
        └ containers/
            ├ account/
            ├ account-register/
            │  └ account-register.component.html
            │  └ account-register.component.spec.ts
            │  └ account-register.component.ts
            ├ account-register-confirm/
            │  └ account-register-confirm.component.html
            │  └ account-register-confirm.component.spec.ts
            │  └ account-register-confirm.component.ts
            └ account-register-complete/
                 └ account-register-complete.component.html
                 └ account-register-complete.component.spec.ts
                 └ account-register-complete.component.ts

図で表すと、
こんな感じですね。
なんとなくイメージが出来るようになって来ました。

ついでなので、ルーティングの設定も記載しておきます。
[app-routing.module.ts]
  {
    path: 'account',
    loadChildren: () =>
      import('./account/account.module').then(m => m.AccountModule),
  },

[account-routing.module.ts]
const routes: Routes = [
  {
    path: '',
    component: AccountComponent,
    children: [
      { path: '', redirectTo: 'register' },
      {
        path: 'register',
        component: AccountRegisterComponent,
      },
      {
        path: 'register-confirm',
        component: AccountRegisterConfirmComponent,
      },
      {
        path: 'register-complete',
        component: AccountRegisterCompleteComponent,
      },
    ],
  },
];

上記を元に、先ほどの図にURLを記載すると、以下のようになります。

今回はここまで。

次回は、さらに細分化して「画面ごとのコンポーネント設計」を見て行こうと思います。


2022年8月2日火曜日

【Angular】FormのValidationの書き方を簡略化させる。

オフィス狛 技術部のKoma(Twitterアカウントの中の人&CEO)です。

Angularに限ったことでは無いですが、Form の Validation って、記載が煩雑になりますよね。
弊社内のAngularプロジェクトでは、出来る限り記載を楽にしようと日々試行錯誤しています。

今回は、本家サイトのValidationの説明を参考に、
入力必須で最大10文字の「名前」項目
を作ってみます。

(1)とりあえずバリデーションを作ってみる

まずはテンプレート側を作ってみます。
[register.component.html]
<form [formGroup]="formRegister" (ngSubmit)="onSubmit()" (keydown.enter)="$event.preventDefault()">
    <div>
      <label>お名前</label>
      <input formControlName="name" type="text" class="form-control" placeholder="お名前を入力(必須)">

      <ng-container *ngIf="name.invalid && (name.dirty || name.touched)">
        <ng-container *ngIf="name.errors?.['required']">
          <p class="error-message">お名前は必ず入力してください。</p>
        </ng-container>
        <ng-container *ngIf="name.errors?.['maxLength']">
          <p class="error-message">お名前は10文字以内で入力してください。</p>
        </ng-container>
      </ng-container>

    </div>
    <button [disabled]="formRegister.invalid" type="submit">登録</button>
</form>

続いて、コントロール(コンポーネント)側を作ります。(抜粋)
[register.component.ts]
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
  // (中略)
export class RegisterComponent implements OnInit {
  // (中略)
  formRegister: FormGroup = this.formBuilder.group({
    name: ['', [Validators.required, Validators.maxLength(10)]],
  });

  constructor(
    private formBuilder: FormBuilder,
  ) {}

  get name() { return this.formRegister.get('name'); }

  ngOnInit(): void {
  // (後略)

ここだけ見ると、何て簡単なんだ!って思うのですが・・・・・

(2)エラー時に背景色を変える

では、ここで、バリデーションエラーの時に、input項目の背景色を変えたい、となったとします。
テンプレート側の修正をしましょう。
[register.component.html]
    <label>お名前</label>
    <input formControlName="name" type="text" class="form-control" placeholder="お名前を入力(必須)"
    [ngClass]="{'alert-danger' : name.invalid && (name.dirty || name.touched)}">
「ngClass」を追加しました。
※ngClassの利用方法については、以前の記事を参照ください。

name.invalid && (name.dirty || name.touched)」の部分、
同じ条件なのに、また記載する事に・・・・

例えば、「名前」の他に、「住所」、「電話番号」などが増えていくと、
その都度、記載していく事に・・・・テンプレートがゴチャゴチャしていきますね。
特に、「 (xxxx.dirty || xxxx.touched)」の記載が大量になります。

「デザイナーとの分業?知ったこっちゃねぇ」なら良いですが、普通は分業していると思うのでhtml(デザイン)に修正が入ると、どんどんAngularに取り込むのが辛くなります。

「関心の分離」と言うことで、バリデーションはバリデーション専用のクラスを作り、それをコントロール(コンポーネント)側にDIする形にしてみようと思います。

(3)バリデーションの処理を分ける

まずは、バリデーション専用のクラスを新規で作成します。
[register.validator.ts]
import { Injectable } from '@angular/core';
import { FormGroup } from '@angular/forms';

@Injectable()
export class RegisterValidator {
  private form: FormGroup;

  constructor() {}

  set formGroup(form: FormGroup) {
    this.form = form;
  }

  get nameInvalid() {
    return (
      this.form.controls['name'].invalid &&
      (this.form.controls['name'].dirty || this.form.controls['name'].touched)
    );
  }
  get nameHasErrorRequired() {
    return this.form.controls['name'].hasError('required');
  }
  get nameHasErrorMaxLength() {
    return this.form.controls['name'].hasError('maxlength');
  }
}
上記クラスは、モジュールで定義をしてDI出来るようにしておいてください
@NgModule({
    providers: [
      RegisterValidator,
  ],

続いて、コントロール(コンポーネント)側でDIします。
[register.component.ts]
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { RegisterValidator } from './register.validator';
  // (中略)
export class RegisterComponent implements OnInit {
  // (中略)
  formRegister: FormGroup = this.formBuilder.group({
    name: ['', [Validators.required, Validators.maxLength(10)]],
  });

  constructor(
    private formBuilder: FormBuilder,
    public v: RegisterValidator,
  ) {}

  ngOnInit(): void {
    // バリデーション設定
    this.v.formGroup = this.formRegister;
  // (後略)

get name() { return this.formRegister.get('name'); }
は不要になるので、削除しています。

コンストラクタで、
  constructor(
    private formBuilder: FormBuilder,
    public v: RegisterValidator,
  ) {}
のように定義して、DIを行います。 public にしているのは、このオブジェクトをテンプレート側で使いたい為です。
また、変数名はなるべく短い方が、テンプレート側がゴチャゴチャしません

「ngOnInit」で、Formの設定内容をバリデーションクラスに送るのも忘れずに。
  ngOnInit(): void {
    // バリデーション設定
    this.v.formGroup = this.formRegister;

(4)テンプレート側の記載を変える

最後はテンプレート側を変更します。
[register.component.html]
<form [formGroup]="formRegister" (ngSubmit)="onSubmit()" (keydown.enter)="$event.preventDefault()">
      <div>
        <label>お名前</label>
        <input formControlName="name" type="text" class="form-control" placeholder="お名前を入力(必須)"
          [ngClass]="{'alert-danger' : v.nameInvalid}">
        <ng-container *ngIf="v.nameInvalid">
          <ng-container *ngIf="v.nameHasErrorRequired">
            <p class="error-message">お名前は必ず入力してください。</p>
          </ng-container>
          <ng-container *ngIf="v.nameHasErrorMaxLength">
            <p class="error-message">お名前は10文字以内で入力してください。</p>
          </ng-container>
        </ng-container>
      </div>
      <button class="btn btn1 ml-auto" [disabled]="formRegister.invalid" type="submit">登録</button>
</form>

name.invalid && (name.dirty || name.touched)」の条件を「v.nameInvalid」で判断出来るので、かなり記載がスッキリします。

「v.nameHasErrorRequired」などについても、実際の処理は、バリデーション専用のクラス側にあるので、処理の分離も出来ています。

という事で、今回はAngularのValidationの書き方を簡略化してみました。