狛ログ

2022年8月8日月曜日

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

8月 08, 2022
オフィス狛 技術部の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】コンポーネントの設計(画面ごとの設計)について。

8月 05, 2022
オフィス狛 技術部の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】エラーメッセージの管理について考える。

8月 04, 2022
オフィス狛 技術部の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】コンポーネントの設計(機能内の画面構成設計)について。

8月 03, 2022
オフィス狛 技術部の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の書き方を簡略化させる。

8月 02, 2022
オフィス狛 技術部の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の書き方を簡略化してみました。


2022年5月23日月曜日

【Red Hat Linux 8.2】ActiveDirectoryユーザでWebアプリのBASIC認証しようとしたらハマった話。

5月 23, 2022
技術部のyuckieee(ゆっきー)です!
最近、RedHatLinux上でWebアプリ(Web/APサーバ設定含む)を行ったのですが、その際にハマったエラーの解決策をメモしておきます。ググっても、RedHatLinux公式のカスタマーポータル等で探しても、中々それらしき記述がなく、めちゃくちゃ時間を潰しました。

■発生事象

今回は、外部ActiveDirecotry上に設定されたID/パスワードを使用して、WebアプリのBASIC認証を行う形をとっていました。
ですが、何度認証を行ってもBASIC認証ダイアログが出続けログインが出来ません。
SSSDのログには、BASIC認証を行ったタイミングで以下のようなエラーが表示されていました。
March 07 00:00:00 httpd [999999]: pam_sss(webapp:auth): authentication success; logname= uid=0 euid=0 tty= ruser= rhost= user=user_name
March 07 00:00:00 httpd [999999]: pam_sss(webapp:account): Access denied for user user_name: 6 (Permission denied)
内容を見ると、認証は成功するのに該当ユーザの権限が無いためにエラーとなっているように見えます。ただ、ActiveDirectoryにはユーザは間違いなく登録されてており、該当サーバへのアクセスが許可されたグループに所属、現にSSHでは接続できていました。謎です。

■発生環境

[構成]
 OS:RedHatLinux 8.2
 Web/Apサーバ:Apache 2.4.xx
  - BASIC認証のため、mod_authnz_pamモジュール使用
  - WebアプリはPython3.xを使用して構築(Apacheと連携させるため、mod_httpdを使用)
 認証:PAM
 ActiveDirecotry連携:SSSD 2.x

具体的なBASIC認証の設定は以下サイトを参考に行いました。
[参考]Apache module mod_authnz_pam
※pamの設定のみ以下のように変更して、ローカルユーザでの認証もできるようにしていました。
#%PAM-1.0
auth    include system-auth
account include system-auth
session include system-auth

■事象分析

エラー内容より「pam_sssによるwebapp(参考サイトでtlwikiとなっていたPAMサービス名)のaccountチェックにおいてアクセス権限エラーが出ている」と言うことは分かります。
そのため、pam_sssaccountを呼び出している箇所をコメントアウトしてみました。
/etc/pam.d/system-auth
#account     [default=bad success=ok user_unknown=ignore] pam_sss.so ←こちらコメントアウト

まさかですが、これでBASIC認証自体は可能となりました。

同じpam_sssを使用しているであろうSSH接続では発生していないことから、Webアプリ限定のエラーだと分かります。 ちなみにaccountは以下の役割を果たしているそうです。(10.2. PAM 設定ファイルについて)
account: このモジュールインターフェースは、アクセスが許可されることを確認します。
たとえば、ユーザーアカウントの有効期限が切れたか、または特定の時間にユーザーがログインできるかどうかを確認します。
アクセス許可の確認と書いてあるとおり、コメントアウトすることでGPOベースのアクセス制御が外れた状態になることが分かりました。
というか、このGPOベースのアクセス制御の設定に問題が有りました。

■発生原因

SSSDのバージョンアップに際し、セキュリティ向上の観点からデフォルトでActiveDirectoryのGPOベースのアクセス制御が有効となったようです。
さらに、本アクセス制御対象はデフォルト以外のPAMサービスがデフォルトで「拒否」状態となっており、デフォルトではない「Webアプリ用PAMサービス」は「拒否」となり、GPOベースのアクセス制御自体が許可されていない状態でした。
エラーメッセージは、ユーザではなく、PAMサービスに対する権限エラーだったのですね。

[参考]
Cannot log in to ClearCase WAN server after enabling SSSD-based authentication to Windows Active Directory on Red Hat Linux 8.x.
2.6.3. SSSD の GPO ベースのアクセス制御の設定

■解決方法

解決方法としては以下2パターンが考えられます。
①GPOベースのアクセス制御時、デフォルト以外のPAMサービスの扱いを「拒否」から「許可」に設定変更(=記載追加)する。
以下のとおりsssd.confのADドメインのセクションに記載を追加し、SSSDサービスを再起動することで対応。
/etc/sssd/sssd.conf
ad_gpo_default_right=permit

②GPOベースのアクセス制御時、作成したPAMサービスを「許可」するよう設定追加する。
以下のとおりsssd.confのADドメインのセクションに記載を追加し、SSSDサービスを再起動することで対応。
/etc/sssd/sssd.conf
ad_gpo_map_interactive = + webapp

解決策として①だと影響範囲が大きそうでしたので②を採用しました。

■まとめ

今回、ActiveDirectoryやSSSD設定が自分の担当ではなかったことで原因調査がかなり難航しました。
振り返りとしては、早期にSSSDのデバッグログ出力設定変更などの相談をしておくと良かったのかなとも思いましたが、原因箇所が特定出来ない中では中々難しいですね。

個人的には良い経験となったと思いますので、この内容を今後に活かしていければ良いなと考えています。

2022年5月16日月曜日

Angular ユニットテスト エラー(NgRxのReducerで発生する「TypeError: Cannot read properties of undefined (reading 'xxxxx')」)の対応。

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

前回前々回に引き続き、Angularでユニットテスト作成する時のエラー対応です。
今回はNgRx関連のエラーです。
error properties: Object({ longStack: 'TypeError: Cannot read properties of undefined (reading 'xxxxx')
      at http://localhost:9876/_karma_webpack_/webpack:/src/app/koma/store/reducers/index.ts:xx:xx
みたいなエラーが出てしまった場合の対応です。

・・・・ええ、言いたいことは分かります。
undefinedって出ているんだから、該当箇所見ればすぐ分かるじゃん。
って事ですよね?

まあ、仰る通りなのですが、エラーとなっている箇所は、コンポーネント側ではなく、NgRxのReducer(index.ts)で、エラーも微妙な位置なので、どう修正すれば良いのか、分からないんですよね・・・・。

ちなみに、コンポーネント側は、
[koma-confirm.component.ts]
export class KomaConfirmComponent implements OnInit, OnDestroy {
  komaConfirmSubscription: Subscription = new Subscription();
  saveData: KomaViewSaveModel;

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

  ngOnInit(): void {
    // 画面間保持項目の退避
    this.komaConfirmSubscription.add(
      this.store
        .pipe(select(fromKoma.getDataRegisterPost))
        .subscribe(model => {
          if (model) {
            this.saveData = model;
          } else {
            this.router.navigateByUrl('/koma/home');
          }
        }),
    );
  }

  ngOnDestroy(): void {
    this.komaConfirmSubscription.unsubscribe();
  }
}
となっています。

良くある登録系機能(入力→確認→完了)の「確認画面」を想定して頂くと分かりやすいです。
入力画面で入力された値をStoreに格納し、それを確認画面(のngOnInit)で取得しようとしています。
もし、Storeからデータを取得出来ない場合、不正な遷移を行ったと見做し、ホーム画面に遷移させています。

なので、
      this.store
        .pipe(select(fromKoma.getDataRegisterPost))
が何となく怪しいのは分かるのですが、Reducer(index.ts)側は、

[/src/app/koma/store/reducers/index.ts]
export const getKomaState = createSelector(
  getKomaFeatureState,
  (state: KomaFeatureState) => state.koma, // ←ここがエラーになってる。
);

export const getDataRegisterPost = createSelector(
  getKomaState,
  fromKoma.getDataRegisterPost, // ←せめてここでエラーになっていれば・・・・
);
上記の「state.koma」の部分がエラーとなっていて、内容としては、
TypeError: Cannot read properties of undefined (reading 'koma')
と出ています。

せめて、
  fromKoma.getDataRegisterPost,
の部分でエラーになっていれば、エラー原因の当たりがすぐにつくのですが・・・・

では、このエラーの対応をしていきたいと思います。

テストファイルの方を見ていきましょう。
[koma-confirm.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { provideMockStore } from '@ngrx/store/testing';
import { KomaConfirmComponent } from './koma-confirm.component';

describe('KomaConfirmComponent', () => {
  let component: KomaConfirmComponent;
  let fixture: ComponentFixture<KomaConfirmComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [KomaConfirmComponent],
      providers: [
        provideMockStore(),
      ],
      imports: [
        RouterTestingModule,
      ],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(KomaConfirmComponent);

    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});
DIの設定だけはしている感じですね。

前回は、「StoreのdispatchでActionの実行をテストする」だったので、特に意識しなかったのですが、
Storeからデータを取得する、という場合は、「selector」と、取得するデータを定義する必要があります。

[koma-confirm.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { provideMockStore } from '@ngrx/store/testing';
import * as fromKoma from '../../store/reducers';
import { KomaConfirmComponent } from './koma-confirm.component';

describe('KomaConfirmComponent', () => {
  let component: KomaConfirmComponent;
  let fixture: ComponentFixture<KomaConfirmComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [KomaConfirmComponent],
      providers: [
        provideMockStore({
          selectors: [
            { selector: fromKoma.getDataRegisterPost,
              value: {
                komaId: '1',
                komaData: '1111',
              }
            },
          ]
        }),
      ],
      imports: [
        RouterTestingModule,
      ],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(KomaConfirmComponent);

    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});

provideMockStoreの定義に引数として、selectorとvalueを設定しています。

これで、エラー(TypeError: Cannot read properties of undefined (reading 'xxxxx'))は出なくなると思います。

では、ちゃんとテストケースを追加していこうと思います。

まず、書き換えたいのが、
provideMockStoreの定義に引数として、selectorとvalueを設定している部分です。
今、そこを追加したのに・・・変えるんかい!って感じですね。

後々、テストケースが追加になった時に、selector内の値の入れ替えを楽にしたいので、
provideMockStoreの引数で設定するのではなく、テストクラス内で使えるように変えていこうと思います。
テストケースも追加していきます。
[koma-confirm.component.spec.ts]
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { Router } from '@angular/router';
import { provideMockStore, MockStore } from '@ngrx/store/testing';
import * as fromKoma from '../../store/reducers';
import { KomaConfirmComponent } from './koma-confirm.component';
import { KomaViewSaveModel } from '../../models/koma';

describe('KomaConfirmComponent', () => {
  let component: KomaConfirmComponent;
  let fixture: ComponentFixture<KomaConfirmComponent>;
  // 全テストケースで利用出来るように、ここで定義
  let store: MockStore<fromKoma.State>;
  let router: Router;
  let mockDataRegisterPostSelector;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [KomaConfirmComponent],
      providers: [
        provideMockStore(),
      ],
      imports: [
        RouterTestingModule,
      ],
    }).compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(KomaConfirmComponent);

    // DI後のインスタンスを取得
    router = TestBed.inject(Router);
    store = TestBed.inject(MockStore);

    // データの初期値を設定
    mockDataRegisterPostSelector = store.overrideSelector(
      fromAccount.getDataRegisterPost, {
        komaId: '1',
        komaData: '1111'
      }
    );

    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  describe('ngOnInitのテスト', () => {
    it('画面引き継ぎ情報が無い場合、ホーム画面へ遷移すること', () => {
      // 処理のモックを作成
      const navigateByUrlSpy = spyOn(router, 'navigateByUrl');

      // データにnullを設定
      mockDataRegisterPostSelector.setResult(null);

      // テスト対象のメソッドを呼び出す
      component.ngOnInit();

      // navigateByUrlが、'/koma/home'を引数として呼ばれているか確認
      expect(navigateByUrlSpy).toHaveBeenCalledWith('/koma/home');
    });

    it('画面引き継ぎ情報がある場合、データを退避すること', () => {
      // 処理のモックを作成
      const navigateByUrlSpy = spyOn(router, 'navigateByUrl');

      // データを再設定
      const formModel: KomaViewSaveModel = {
        komaId: '2',
        komaData: '2222'
      };
      mockDataRegisterPostSelector.setResult(formModel);

      // テスト対象のメソッドを呼び出す
      component.ngOnInit();

      // 退避されたデータの中身をチェック
      expect(component.saveData).not.toBeUndefined();
      expect(component.saveData).not.toBeNull();
      expect(component.saveData.komaId).toBe('2');
      expect(component.saveData.komaData).toBe('2222');

      // navigateByUrlが、呼ばれていないか確認
      expect(navigateByUrlSpy).not.toHaveBeenCalled();
    });
  });
});

ポイントとしては、「overrideSelector」によって、selectorを再定義している部分
    mockDataRegisterPostSelector = store.overrideSelector(
        // (中略)
    );
と、値を再設定する
    mockDataRegisterPostSelector.setResult(formModel);
の部分ですね。

これで、Store内のデータを設定し、テストをすることが可能になりました!

今後も、Angularでユニットテストについて、ブログに書いて行こうと思います。