狛ログ

2022年10月27日木曜日

【Angular】画面遷移前に確認ダイアログ風のポップアップを表示する。

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

今回は画面遷移前に表示する下図のような確認ダイアログ風のポップアップ(以降、確認ポップアップ)を作成していこうと思います。

1からこのような仕組みを作るのは大変なので、「Simple Modal Module」(ngx-simple-modal)というプラグインを使いたいと思います。

※「Simple Modal Module」のインストールや使い方の細かい説明は、弊社メンバーの記事(【Angular・【Simple Modal Module】モーダルを手軽に実装する。)をご覧ください。

当記事では、以前の記事で使用した「アカウント登録機能」に実装する形で、実践的な説明をしようと思います。
と言うことで、是非、以前の記事も参照ください

参照1:【Angular】独自エラーチェック(カスタムバリデーション)を作成する。
参照2:【Angular】コンポーネントの設計(画面ごとの設計)について。
参照3:【Angular】エラーメッセージの管理について考える。

(1)どのコンポーネントでポップアップを呼び出すべきか

まず、どのコンポーネントでポップアップを呼び出すべきか考えてみます。
親(Container)なのか、子(Presentation)なのか・・・・
以前の記事で、こんな定義をしたかと思います。

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

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


という事で、ちょっと悩みどころではあるのですが、呼び出しは「子(Presentation)」の方が良いと思います。
確認ポップアップの「OKボタン=画面遷移する」、「キャンセルボタン=画面遷移しない」と考えると、確認ポップアップとしての動きは、あくまで「画面遷移するかどうかの関連チェック」に過ぎない、というのが理由です。
他にも、「キャンセルを押された時に画面の表示を変えたい」という要件があった場合、確認ポップアップを親(Container)で呼び出すと、かなり複雑になってしまうから、というのも理由の1つです。(今回はそのような要件はないですが)

(2)確認ポップアップの作成

呼び出す場所は決まりましたので、呼び出す「確認ポップアップ」用のコンポーネントを作成しようと思います。
弊社のプロジェクトでは、「app」配下に「shared」と言うディレクトリを作り、その中にプロジェクトで共通的に使うものを配置しています。

今回は、以下のように作成します。
src/
└ app/
    └ shared/
        └ modal/
            └ containers/
                └ confirmation-dialog/
                    └ confirmation-dialog.component.html
                    └ confirmation-dialog.component.ts

では、それぞれ実装を見ていきましょう。まずは、テンプレート(View)から。
[confirmation-dialog.component.html]
<div class="message">
  <div class="modal-dialog modal-content">
    <div class="modal-header">
      <div class="modal-title"></div>
      <button type="button" class="close" aria-label="閉じる" (click)="onClickCancel()">
        <span aria-hidden="true">×</span></button>
    </div>
    <div class="modal-body">
      <p class="text-center">{{ message }}</p>
    </div>
    <div class="modal-footer p-0">
      <button class="btn btn2" (click)="onClickCancel()">キャンセル</button>
      <button class="btn btn1 btn-block" type="submit" (click)="onClickOk()">OK</button>
    </div>
  </div>
</div>
特筆すべきところは特に無いですが、このコンポーネントを汎用的に利用する為に、表示するメッセージは「{{ message }}」で変数を展開する形にしています。

続いて、コンポーネントの実装です。
[rconfirmation-dialog.component.ts]
import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { SimpleModalComponent } from 'ngx-simple-modal';

export interface ConfirmModel {
  message: string;
}

@Component({
  selector: 'koma-confirmation-dialog',
  templateUrl: './confirmation-dialog.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ConfirmationDialogComponent
  extends SimpleModalComponent<ConfirmModel, boolean>
  implements ConfirmModel, OnInit {
 
  message: string;

  constructor() {
    super();
  }

  ngOnInit() {}

  onClickCancel() {
    this.result = false;
    this.close();
  }

  onClickOk() {
    this.result = true;
    this.close();
  }
}

少し詳細を説明しています。
まず、下記の記載でポップアップに表示するメッセージを呼び出し元で設定出来るようにしています。
export interface ConfirmModel {
  message: string;
}

あと重要なのは、「ngx-simple-modal」のクラス(SimpleModalComponent)を継承し、先程作った「ConfirmModel」を実装する、と言う事です。
export class ConfirmationDialogComponent
  extends SimpleModalComponent<ConfirmModel, boolean>
  implements ConfirmModel, OnInit, OnDestroy {

「OKが押されたか、キャンセルが押されたか」は、「result」にTrue、Falseを設定することで、呼び出し側へ知らせます。
  onClickCancel() {
    this.result = false;
    this.close();
  }

  onClickOk() {
    this.result = true;
    this.close();
  }

これで確認ポップアップが作成できました。次はこの確認ポップアップを呼び出してみましょう。

※モジュール(NgModule)ファイルの記載は省略していますので、適宜、追加したコンポーネントをモジュールでdeclarationsしておいてください。
※モジュール(NgModule)ファイルでの「SimpleModalModule(ngx-simple-modal)」のインポートについてですが、共通的に使用されることを考えると、「app.module.ts」などでインポートした方が良いと思います。

(3)確認ポップアップの呼び出し

では早速、先程作成した確認ポップアップの呼び出しを実装して行きます。
まずは、呼び出し側の子のテンプレート(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>
            <p *ngIf="v.mobilePhoneNumberHasErrorFormat" class="error-message">{{ message('msg_error_cellphone_number') }}</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 class="footer fixed-bottom d-flex">
        <button type="button" (click)="onClickPrev()">戻る</button>
        <button [disabled]="formRegister.invalid" type="submit">確認</button>
      </footer>
    </form>

特筆すべき部分は特になく、単純に下記の部分を追加しているだけです。
<button type="button" (click)="onClickPrev()">戻る</button>
これは単純ですね、「戻る」ボタンを追加しただけ、になります。

では、次は子コンポーネントに戻るボタンを押した時のアクションを実装して行きます。
[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 { CustomValidators } from '@app/shared/validator/custom-validators';
import { RegisterValidator } from './register.validator';
import { AccountViewSaveModel } from '../../models/account';
import { getMessage } from '@app/shared/message/error-messages';
import { SimpleModalService } from 'ngx-simple-modal';
import { ConfirmationDialogComponent } from '@app/shared/modal/containers/confirmation-dialog/confirmation-dialog.component';

@Component({
  selector: 'koma-register',
  templateUrl: './register.component.html',
})
export class RegisterComponent implements OnInit, OnDestroy {
  // Input・Outputの定義。
  @Input() accountViewSave$: Observable<AccountViewSaveModel>;
  @Output() formBack = new EventEmitter();
  @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),
        CustomValidators.mobilePhoneNumberValidator,
      ],
    ],
    name: ['', [Validators.maxLength(this.nameMaxLength)]],
  });

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

  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.simpleModalService.removeAll();
    // サブスクリプション解除
    this.registerSubscription.unsubscribe();
  }

  onClickPrev(): void {
    // 確認ダイアログの表示
    this.simpleModalService
      .addModal(ConfirmationDialogComponent, {
        message: getMessage('msg_confirm_screen_transition'),
      })
      .subscribe(result => {
        if (result) {
          this.formBack.emit();
        }
      });
  }

  // 画面で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);
  }
}

中身を細かく見ていきましょう。
まずは、「ngx-simple-modalのSimpleModalService」と、先程作成した確認ポップアップをインポートしています。
import { SimpleModalService } from 'ngx-simple-modal';
import { ConfirmationDialogComponent } from '@app/shared/modal/containers/confirmation-dialog/confirmation-dialog.component';

戻るボタンを押された場合の画面遷移の制御は、親コンポーネント側で行うので、親コンポーネントへのイベント登録(定義)も行います。
@Output() formBack = new EventEmitter();

そして、インポートした「SimpleModalService」はDIする必要があります。
  constructor(
    private formBuilder: FormBuilder,
    public v: RegisterValidator,
    private simpleModalService: SimpleModalService,
  ) {}

次に「戻るボタン」を押した時の処理です。ここで確認ポップアップを表示します。
  onClickPrev(): void {
    // 確認ダイアログの表示
    this.simpleModalService
      .addModal(ConfirmationDialogComponent, {
        message: getMessage('msg_confirm_screen_transition'),
      })
      .subscribe(result => {
        if (result) {
          this.formBack.emit();
        }
      });
  }

ざっくり説明すると、
  • SimpleModalServiceのaddModalに自作コンポーネントを指定して、さらに自作コンポーネントのmessageに、表示用の文言を設定している
  • ポップアップの戻りは、subscribeで受け取り設定する(resultにTrue、Falseが設定されて戻ってくる)
と言う感じですね。
Trueの場合(確認ポップアップで「OK」が押された場合)、親画面へイベントを委譲しています。

(4)親コンポーネントの実装

確認ポップアップの表示とは直接関係ないですが、親コンポーネントも実装しておきましょう。
まずはテンプレート(View)に、子のOutput(formBack)と親のイベント(onClickBack)の紐付けを追加します。

[account-register.component.html]
<koma-register
  (formBack)="onClickBack()"
  (formSubmit)="onSubmit($event)"
  [accountViewSave$]="accountViewSave$"
></koma-register>

続いて、コンポーネント側の実装です。
[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 {}

  onClickBack(): void {
    // ホーム画面に遷移する(戻る)
    this.router.navigateByUrl('/home/top');
  }

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

「NgRxのStore」などが使われていますが、ここは一旦スルーしてください。(いずれ、NgRxの実装方法も記事にしようと思います。)
ここでは、画面で入力された値はStore(と言う場所)に格納されている、ぐらいの理解でOKです。
(重要なのは、「データ保存方式」ではなく、あくまで「戻るボタンの制御」なので)

と言う事で、中身を見ていくと・・・・先程、子側と紐付けたイベント(onClickBack)が実装されていますね。
画面遷移するだけの処理ですが、URLがそのまま固定文字列で記載されているのが気になります。
こちらも今回の記事の本質では無いですが、URLの固定文字列も定数化しちゃいましょう。

(2)でも説明したように、「app」配下に「shared」と言うディレクトリを作り、その中にプロジェクトで共通的に使うものを配置します。
今回は、以下のように作成します。
src/
└ app/
    └ shared/
        └ constant/
            └ page-constants.ts

実装は下記のようになります。
[page-constants.ts]
export namespace url {
  export const HOME_TOP = '/home/top';
  export const ACCOUNT_REGISTER_CONFIRM = '/account/register-confirm';
}

では、先程の固定文字列を定数に変更してみます。
下記が、最終的な親のコンポーネントとなります。
[account-register.component.ts]
import { Component, OnInit } from '@angular/core';
import { Store, select } from '@ngrx/store';
import { Router } from '@angular/router';
import * as PageConstants from '@app/shared/constant/page-constants';
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 {}

  onClickBack(): void {
    // ホーム画面に遷移する(戻る)
    this.router.navigateByUrl(PageConstants.url.HOME_TOP);
  }

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

(5)メッセージを追加する

最後に確認ポップアップに送る為のメッセージ定義もしておきましょう。

[error-messages.ts]
export const errorMessages: { [key: string]: string } = {
  msg_error_field_required: '{0}は必ず入力してください。',
  msg_error_field_max: '{0}は{1}文字以内で入力してください。',
  msg_confirm_screen_transition:
    '現在の入力中の情報は破棄されます。前の画面に戻りますか?',
};

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);
}

これで、全ての実装が完了しました。
この応用(むしろ、今回の方が応用ですが)で、「アラート表示ポップアップ」や、「完了メッセージ表示ポップアップ」など、色々作れると思います。

ぜひ、色々カスタマイズしてみてください!


0 件のコメント:

コメントを投稿