ネイティブアプリ風ウェブアプリを Ionic で作ってみる(完全入門編)その2

ネイティブアプリ風ウェブアプリを Ionic で作ってみる(完全入門編)その2

ここで作成しているアプリは、

https://dev.d24wcf2ponetu0.amplifyapp.com/

で公開しています。

また、ソースコードは、こちらで公開しています。

Contents

Amplify を使ってログイン、サインアップを追加する

まずは、Amazon Web Service (AWS) のアカウントをしましょう。AWS は、EC2 に代表される計算リソース(いわゆるサーバー)や S3 のようなストレージをインターネット経由で提供してくれるサービスです。世の中では、インフラとかクラウドとか呼ばれています。実際の計算リソースは、リージョン(地域)と呼ばれるところのどこかのスペースで厳重に管理されています。昔は、家に棚を置いてサーバーを並べたりして計算リソースを用意したのですが、クリックだけで計算リソースが手に入るようになっています。そのほかのサービスとしては、Google が提供する Google Cloud Platform (GCP)、Microsoft の Azure、IBM の IBM Cloud、GMO の GMO Cloud、さくらインターネットなどがあります。消費者向けにサービスを提供していないところもありますが、どこを選ぶかは事業や戦略に照らし合わせて検討する必要があります。

ただ、一個人が利用するのであれば、AWS / GCP / Azure の中から選ぶで良いと思います。非常に簡単に使えますし、無料枠があり色々機能を試すのに向いています。

Amazon アカウント作成

すでにアカウントがあれば、そのアカウントを使っても良いです。すでにアカウントがあってもプロジェクトを切り分けたい時などは新しいアカウントを作成してしまいましょう。リソースグループなどを使って管理している人はそれでも良いと思います。

こちらにアクセスします。Emailアドレス、パスワード、アカウント名を入力しましょう。Email アドレスがすでに登録されてしまっている場合は、Gmail系であれば、 <account>+project@gmail.com などと、+を使うとメールアドレスを分けられるのでこれで運用してみても良いかと思います。ただし、どのメールアドレスで登録したのか忘れないようにしましょう。

住所は、半角英数字で入力しないと行けなかったり、クレジットカードの登録が必要だったりするのであれって思いますが、無料枠を越えなければ請求されませんので安心してください。ただ、無料枠を超えると無慈悲に請求が来るのでコスト管理はしっかりしておきましょう。もし、練習だけであれば、練習後はアカウントを削除してしまってください。

これでアカウントの準備は完了です。

Amplify のセットアップをする

まずは、インストールしましょう。CLI(コマンドラインインターフェース)をグローバルにインストールするのでどこでやっても良いのですが、移動するのを忘れるので、 my-to-do のプロジェクトフォルダで行ってください。

npm install -g @aws-amplify/cli

これでコマンドラインがインストールできました。

amplify configure

このコマンドでセットアップします。amplify コマンドが見つからないと言うエラーが出ましたら、次のコマンドを実行してください。zshの場合、

exec $SHELL -l

bash の場合は、

source ./bash_profile

これでコマンドが認識されると思います。コマンドが認識されなければ、一度ターミナルを閉じて再び開けば大丈夫です。

さて、amplify configure に戻りましょう。まず、実行すると AWS のアカウントとの連携をするために、ブラウザでログインするように求められます。先程作ったアカウントで連携しましょう。

region を選択するように聞かれますが、us-east-1 (バージニア)でOKです。近い方がよければ ap-northeast-1 (ap は、Asia / Pcific の略)とかにしておきましょう。

次にユーザー名を聞かれます。このユーザー名と言うのは、Amplify が各種機能を実行する時に使われる IAM ユーザーで新規に作成されます。わかりやすい名前でもデフォルトのままでも大丈夫です。これも実行するとブラウザが開いて AWS の画面でIAMユーザーを作成します。デフォルトの設定のまま次へを進めましょう。最後にユーザー作成成功画面が出ますので、これは閉じないでください。開いたまま、コマンドラインに戻って enter を打ちます。ここで、accessKeyId と secretAccessKey を聞かれますので、ユーザー作成成功画面に表示されているものをコピーして設定します。

最後にプロフィール名を聞かれますが、プロフィール名と言うのは、一連の AWS アカウントとの連携の設定をローカルマシンに保存するときの名前です。わかりやすい名前にしておきましょう。

さて、これでできました。

Amplify Backend を作成する

amplify init

コマンドラインで amplify プロジェクトを開始します。まず Polyfills の設定が必要です。Polyfills と言うのは、どのブラウザでも JavaScript が正常に動作するように各種変換やつなぎこみを行ってくれるもので、Amplify のコードも JavaScript で書かれているので正常に認識されるように設定が必要になります。

(window as any).global = window;
(window as any).process = {   
  env: { DEBUG: undefined }, 
};

では、amplify init を実行しましょう。

amplify init
 Note: It is recommended to run this command from the root of your app directory
 ? Enter a name for the project mytodo      // プロジェクト名をセットします。mytodo にしました。
 ? Enter a name for the environment dev    //  amplify は環境を複数もてます。今は、dev (開発環境)にしておきます。
 ? Choose your default editor: Visual Studio Code // デフォルトで使うエディタ
 ? Choose the type of app that you're building javascript // JavaScript プロジェクトです
 Please tell us about your project
 ? What javascript framework are you using angular  // Angular を使っています
 ? Source Directory Path:  src // src フォルダにコードが入っています。
 ? Distribution Directory Path: www // ビルドしたコードは、www に入っています
 ? Build Command:  ionic build --prod // ビルド用のコマンド
 ? Start Command: ionic serve // スタート用のコマンド
 Using default provider  awscloudformation 
 For more information on AWS Profiles, see:
 https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-profiles.html
 ? Do you want to use an AWS profile? Yes // プロフィールを使います
 ? Please choose the profile you want to use my-to-do // my-to-do と言う名前のプロフィールで連携させます

これで、AWSの環境にアプリケーションのバックエンドが作成されます。2分くらいで終わるかと思いますので待ちます。

amplify status で空のプロジェクトのバックエンドの状況をみてみましょう。

Current Environment: dev

| Category | Resource name | Operation | Provider plugin |
| -------- | ------------- | --------- | --------------- |

はい。空です。うまく連携できていることはわかりました。

ログイン機能を追加

ログイン機能を追加してきます。まずは、amplify のライブラリをプロジェクトにインストールします。

npm install --save aws-amplify @aws-amplify/ui-angular

aws-amplify は、Amplify の JavaScript のライブラリで、@aws-amplify/ui-angular は、Angular や Ionic で使えるコンポーネントを追加してくれています。@aws-amplify/ui-angular を使うとログイン画面がめちゃくちゃ簡単に作れるのですが、今回はすでにある画面を使ってやっていきます。

AmplifyUIAngularModule を app.module.ts で読み込みます。

import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouteReuseStrategy } from '@angular/router';

import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
import { AmplifyUIAngularModule } from '@aws-amplify/ui-angular';

import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';
import { StoreModule } from '@ngrx/store';
import { TodoPageModule } from './todo/todo.module';
import { AuthStoreModule } from './auth/auth-store.module';
import { environment } from 'src/environments/environment';
import { ReactiveFormsModule } from '@angular/forms';
import { CalendarPageModule } from './calendar/calendar.module';
import { TodoModalPageModule } from './todo/todo-modal/todo-modal.module';

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [
    BrowserModule,
    IonicModule.forRoot(),
    AmplifyUIAngularModule,
    AppRoutingModule,
    StoreModule.forRoot({}),
    AuthStoreModule,
    StoreDevtoolsModule.instrument({
      maxAge: 25,
      logOnly: environment.production
    }),
    TodoPageModule,
    CalendarPageModule,
    TodoModalPageModule,
    ReactiveFormsModule,
  ],
  providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
  bootstrap: [AppComponent],
})
export class AppModule { }

この時点で、ionic serve してみましょう。何も前と変わらないです。

Amplify add auth

さて、Amplify の方に認証機能を追加します。

amplify add auth

Do you want to use the default authentication and security configuration? Default configuration
Warning: you will not be able to edit these selections. 
How do you want users to be able to sign in? Email

Do you want to configure advanced settings? No, I am done.

Default configuration を使って設定していきます。すると、次の選択は後から変更できないと注意が表示されます。そうです。認証の設定は後から変えることができないのです。どうしても後から変更したい場合は、新たな認証機能を作成して移行させる必要があります。

さて、amplify status をみてみましょう。

amplify status
Current Environment: dev

| Category | Resource name  | Operation | Provider plugin   |
| -------- | -------------- | --------- | ----------------- |
| Auth     | mytodoce62f1ca | Create    | awscloudformation |

Authが追加されています。AWS のコンソールで Cognito をみましょう。あれ、何も入っていません。そうです。バックエンドサービスを反映させるには、amplify push が必要です。

amplify push

数分で完了します。cognito をみてみましょう。

ユーザープールが確認できました。

cognito の画面からユーザーを作成してみましょう。

Eメールとユーザー名に両方Eメールを入れていますが、先程 add auth した時に、ログインを email にする設定にしたのでこのようになります。この新規ユーザーに招待を送信しますか?と言うところは、Eメールにチェックしておいてください。Eメール宛に仮パスワードが届きます。

ユーザーをみてみましょう。Status が、Force Change Password になっています。

さてログインしてみましょう。

amplify-auth.service を作成する

我々のログイン画面は、サインアップもなければEメール確認画面もありません。また、パスワード忘れ画面もありません。これらは、amplify/ui-angular を使うと一気に作成してくれるのですが、UIをそのまま使うことはほとんどないと思いますので、自前で作っていきます。

まずは、cognitoでログインさせる機能を作りましょう。main.ts で Amplify を読み込みます。

import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { AppModule } from './app/app.module';
import { environment } from './environments/environment';

import Amplify from "aws-amplify";
import aws_exports from "./aws-exports";
Amplify.configure(aws_exports);

if (environment.production) {
  enableProdMode();
}

platformBrowserDynamic().bootstrapModule(AppModule)
  .catch(err => console.log(err));

tsconfig.app.json で node を使えるようにします。

/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/app",
    "types": ["node"]
  },
  "files": [
    "src/main.ts",
    "src/polyfills.ts"
  ],
  "include": [
    "src/**/*.d.ts"
  ]
}

名前が紛らわしいファイルがいくつかあるので注意してください。サービスクラスを作成します。

ionic generate
 ? What would you like to generate? service
 ? Name/path of service: auth/cognito-auth
   ng generate service auth/cognito-auth --project=app 

サービスで Amplify を読み込み、AuthProvider を実装します。

import { Injectable } from '@angular/core';
import { Auth } from 'aws-amplify';
import { Observable } from 'rxjs';
import { AuthProvider } from './auth-provider';

@Injectable({
  providedIn: 'root'
})
export class CognitoAuthService implements AuthProvider {

  constructor() { }
  
  currentAuthenticatedSession$: Observable<{}>;
  isAuthenticated$: Observable<boolean>;

  login(authentication: {}): void {
    throw new Error('Method not implemented.');
  }
  logout(): void {
    throw new Error('Method not implemented.');
  }
}

これで形ができました。あとは、中身を実装していきます。

公式の API Doc で使えそうな API を探します。

この API が、currentAuthenticatedSession に似ていますね。返却は、Promise<CognitoUser>になっているので、これをうまく使ってみましょう。Promise になっていますが、rxjs の from(Promise) とやると Observable になってくれるのでこれを使いましょう。

signIn と signOut って言うのもそのまま使えそうですね。

import { Injectable } from '@angular/core';
import { Auth } from 'aws-amplify';
import { Observable, from } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { AuthProvider } from './auth-provider';
import { CognitoUser } from 'amazon-cognito-identity-js';
import { User } from './user.model';
import fromAuth, * as authSlice from './auth.slice';
import { Store } from '@ngrx/store';

@Injectable({
  providedIn: 'root'
})
export class CognitoAuthService implements AuthProvider {

  constructor(
    private store: Store<typeof fromAuth>
  ) { }

   currentAuthenticatedSession$: Observable<User | any> =
    from(Auth.currentAuthenticatedUser()).pipe(
      catchError((err) => {
        return of(err);
      }),
      mergeMap((user) => {
        this.completeSignIn(user);
        return this.getUserAttributesPromise(user);
      }),
      map((userData) => {
        return new User(userData.email, userData.id);
      }),
      catchError(err => {
        return from(err);
      })
    )

  isAuthenticated$: Observable<boolean> = this.currentAuthenticatedSession$.pipe(
    map(user => {
      if (user instanceof CognitoUser) {
        return true;
      }
    })
  );

  login(authentication: { email: string, password: string }): void {
    console.log('signin');
    Auth.signIn({
      username: authentication.email,
      password: authentication.password,
    }).then(user => {
      console.log(user);
      if (user instanceof CognitoUser) {
        user.getUserAttributes((err, result) => {
          if (err) {
            console.error(err);
            return;
          }

          const userData = {};
          for (let [key, value] of result.entries()) {
            userData[key] = value;
          }

          this.store.dispatch(authSlice.setLoginStatus({ email: userData['email'] }));
          this.store.dispatch(authSlice.setLoginSuccess(true));
  
        })
      } else {
        this.store.dispatch(authSlice.setLoginStatus(null));
        this.store.dispatch(authSlice.setLoginSuccess(false));
      }
    }).catch(err => {
      console.error(err);
    })
  }

  logout(): void {
    Auth.signOut().then(() => {
      this.store.dispatch(authSlice.setLoginStatus(null));
      this.store.dispatch(authSlice.setLoginSuccess(false));
    })
  }
}

エラーが発生することが、当然期待されるところは、catchError で捕捉しましょう。

同時に、User モデルにコンストラクタを追加します。

export class User {
  email: string;

  constructor(email: string) {
    this.email = email;
  }
}

上から見ていきましょう。まずは、Store を DI しています。これは問題ないですね。

次に currentAuthenticatedSession$ をどうやって作るかというと、Auth.currentAuthenticatedUser() が Promise を返却しますので、rxjs の from で Observable に変換しています。catchError オペレーターは初めて登場しますが、通信にはエラーがつきものなので一応入れておきました。エラーがあれば、コンソールにエラーを表示して null (認証状態がない)を返却しています。map で、user が CognitoUser の時に、userモデルを返却しています。

isAuthenticated$ は、currentAuthenticatedSession$ が User タイプであれば true としています。

login は、関数的に扱うので、rxjs は使わずに、promise の呼び出しで処理しています。signinが呼び出されていることを確認するためのデバッグ用にコンソールに表示して、Auth.signIn を徐に呼び出します。何が怒るかわからないので至る所に console.log を入れています。CognitoUser.getUserAttributes で、ユーザー属性(email)が取得できるようなのでこれを試しています。この関数の実装は、コールバックになっています。あとは、authSlice を使って状態を更新しています。

logout も同様にしています。

さて、auth.facade の DI を変更しましょう。

import { Injectable } from '@angular/core';
import { createSelector, Store } from '@ngrx/store';
import fromAuth, * as authSlice from './auth.slice';
import { CognitoAuthService } from './cognito-auth.service';        //  追加

@Injectable({
  providedIn: 'root'
})
export class AuthFacade {
  constructor(
    private readonly store: Store<typeof fromAuth>, 
    private authProvider: CognitoAuthService,                                 // 変更
  ) { }

  currentAuthenticatedSession$ = this.store.select(
    createSelector(authSlice.selectFeature, (state) => state.currentAuthenticatedSession)
  );

  loginSuccess$ = this.store.select(
    createSelector(authSlice.selectFeature, (state) => state.loginSuccess)
  )

  isAuthenticated$ = this.authProvider.isAuthenticated$;

  login(authentication): void {
    this.authProvider.login(authentication);
  }

  logout(): void {
    this.authProvider.logout();
  }

}

これだけです。UIの変更は不要です。では実行してみましょう。email は、先程コンソールから入力した値、パスワードは、メールで届いた仮パスワードを入れてみましょう。

User is not authenticated と言うエラーが、user.getUserAttributes から出ています。その上のところでは、user はしっかり取得できているのがわかります。なぜこうなったのでしょうか。CognitoUser を開いてみましょう。

ここで、NEW_PASSWORD_REQUIRED と言う challengeName があります。Cognitoでは、 認証フローを用意することができて、現在はデフォルトの認証フロー(USER_SRP_AUTH)になっています。challengeName のところに次のステップに必要な認証フローが入ってくると言う仕組みです。

そのまま理解してみると、仮パスワードではなく新しいパスワードを設定しないと行けないと言うことですね。それでは、新しいパスワードを設定する画面を追加しましょう。

パスワード変更用の画面を作成

とはいえ、新たなページを作るまでもないので状態変化をさせてログイン画面上で続きをやるようにしましょう。

  • authSlice にチャレンジの状態を保存できるようにする
  • challenge の状態によって表示する要素を制御する

こんな感じでどうでしょうか。

authSlice にチャレンジの状態を保存できるようにする

パスワードの変更が必要っていうステータスは、認証でよくありそうなのでインターフェースにも追加しておきましょう。また、状態にも含めるようにします。まずは、slice からいきましょう。challenge を含めるのと AuthState と言う方を宣言しています。これで、challenge に何が入ってくるかがわかりやすくなります。

import { createFeatureSelector } from '@ngrx/store';
import { createSlice } from '@reduxjs/toolkit';
import { User } from './user.model';

type AuthState = {
  currentAuthenticatedSession: User | null,
  loginSuccess: boolean,
  challenge: null | 'NEW_PASSWORD_REQUIRED',
}

const authSlice = createSlice({
  name: 'auth',
  initialState: {
    currentAuthenticatedSession: null,
    loginSuccess: false,
    challenge: null,
  } as AuthState,
  reducers: {
    setLoginStatus: (state, action) => {
      state.currentAuthenticatedSession = action.payload;
    },
    setLoginSuccess: (state, action) => {
      state.loginSuccess = action.payload;
    },
    setChallenge: (state, action) => {
      state.challenge = action.payload;
    }
  }
});

const {
  reducer,
  actions: {
    setLoginStatus,
    setLoginSuccess,
    setChallenge,
  },
  name
} = authSlice;

export default reducer;
export {
  name,
  setLoginStatus,
  setLoginSuccess,
  setChallenge,
};

export const selectFeature = createFeatureSelector<ReturnType<typeof reducer>>(
  name
);

AuthProvider インターフェースを更新します。

import { Observable } from "rxjs";

export interface AuthProvider { 
  currentAuthenticatedSession$: Observable<{}>;
  isAuthenticated$: Observable<boolean>;
  completeNewPassword(password: string): void;
  confirmSignIn(code: string): void;
  login(authentication: {}): void;
  logout(): void;
}

completeNewPassword と confirmSignIn と言うふたつを追加しました。completeNewPassword は、今やろうとしていることですが、confirmSignIn は、メールアドレス確認などで利用する想定です。FakeAuth サービスの方も実装を追加しておきましょう。void なので、空を実装しておくだけで良いです。

CognitoAuthService に実装していきます。completeNewPassword と言う API がまんま使えそうです。戻り値は、Promise<CognitoUser | any> ですね。

では、実装しましょう。

import { Injectable } from '@angular/core';
import { Auth } from 'aws-amplify';
import { Observable, from, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { AuthProvider } from './auth-provider';
import { CognitoUser } from 'amazon-cognito-identity-js';
import { User } from './user.model';
import fromAuth, * as authSlice from './auth.slice';
import { Store } from '@ngrx/store';

@Injectable({
  providedIn: 'root'
})
export class CognitoAuthService implements AuthProvider {

  constructor(
    private store: Store<typeof fromAuth>
  ) { }

  private cognitoUser: CognitoUser | null;

  completeNewPassword(password: string): void {
    if (!this.cognitoUser) return;

    Auth.completeNewPassword(this.cognitoUser, password).then((user: CognitoUser) => {
      this.completeSignIn(user);
    }).catch(err => {
      this.clearLoginState();
      console.error(err);
    })
  }

  confirmSignIn(code: string): void {
    throw new Error('Method not implemented.');
  }

  currentAuthenticatedSession$: Observable<User> =
    from(Auth.currentAuthenticatedUser()).pipe(
      catchError((err) => {
        return of(err);
      }),
      map(user => {
        if (user instanceof CognitoUser) {
          return new User(user.getUsername());
        }
      })
    )

  isAuthenticated$: Observable<boolean> = this.currentAuthenticatedSession$.pipe(
    map(user => {
      if (user instanceof User) {
        return true;
      }
    })
  );

  login(authentication: { email: string, password: string }): void {
    console.log('signin');
    Auth.signIn({
      username: authentication.email,
      password: authentication.password,
    }).then(user => {
      this.completeSignIn(user);
    }).catch(err => {
      console.error(err);
      this.clearLoginState();
    });
  }

  logout(): void {
    this.cognitoUser = null;
    Auth.signOut().then(() => {
      this.clearLoginState();
    })
  }

  private handleAuthChallenge(user: any) {
    const newPasswordRequired = "NEW_PASSWORD_REQUIRED"
    let challengeName = null;
    if (user.challengeName === newPasswordRequired) {
      challengeName = newPasswordRequired;
    }
    this.store.dispatch(authSlice.setChallenge(newPasswordRequired));
  }

 private completeSignIn(user: CognitoUser): void {
    console.log(user);
    if (user instanceof CognitoUser) {
      this.cognitoUser = user;
    } else {
      this.clearLoginState();
      throw new Error('Cannot complete signin');
    }

    this.cognitoUser.getUserAttributes((err, result) => {
      if (err) {
        this.handleAuthChallenge(this.cognitoUser);
        return;
      }

      const userData = {};
      console.log(result);
      for (let value of result) {
        userData[value.Name] = value.Value;
      }

      console.log(userData)

      this.store.dispatch(authSlice.setLoginStatus({ email: userData['email'], id:  userData['sub']}));
      this.store.dispatch(authSlice.setLoginSuccess(true));
    })

  private clearLoginState() {
    this.cognitoUser = null;
    this.store.dispatch(authSlice.setLoginStatus(null));
    this.store.dispatch(authSlice.setLoginSuccess(false));
  }
}

completeNewPassword を実装しています。ここで、cognitoUser と言う状態を一時的に Service に持たせています。この状態は、他のクラスから参照されたくないので private にしています。

completeNewPassword の中で、 this.completeSignIn と this.clearLoginState を使っています。ログイン状態をクリアしたり、サインインを完了させようとする時に複数回呼ばれるのでまとめておきました。completeSignIn のところでついでに userData を処理するところの不具合も解消しておきました。

ログインの方も同じです。completeSignIn では、一時的な cognitoUser を保持するのと、handleAuthChallenge と言うものを使って認証フローのステータスを変更しています。他は、ストアの値を更新しているだけです。

cognito-auth.service.ts を振り返ってみてみましょう。このサービスが Cognito に依存するのは設計に基づいていますが、Store (つまり NgRx)にも依存しているのはどうでしょうか。ちょっといまひとつな感じもします。サービスクラスは、できれば単体でも再利用可能なものにしたいので状態変化まで責務を持ってしまうと使いにくい場合も生じてきそうです。認証系であれば、ここでしか使わないのでおそらく問題になることはないでしょうが、責務はどっかに寄せたいですね。

依存度を下げるには、Store の方からサービスを呼び出すような設計にしておけば良いかもしれません。そうすれば、Facade は、Store だけに依存しサービスに依存しなくなります。また、Store とサービスは、DI により依存度が下げられる。つまり、Store の方で、同じインターフェースをもつ FakeAuth を使うか CognitoAuth を使うかを決めるだけで、インフラへの依存度を下げられるようになります。

このように、Store に、このシステム内(プロジェクトソースコード内)に関連する状態を管理するだけでなく、外部システムの状態も管理させてしまうと言うことも考えられますね。外部システムの状態を変えることを、副作用と呼びます。英語の Side effect を直訳したものですが、システム外に変化を生じさせるものをそのように呼んでいます。副作用と言うと望ましくない作用と思われるかもしれませんが、ソフトウェアの世界では完全に意図した作用のことです。

NgRx には、副作用を管理するための Effects と言う仕組みがあります。後に Effects の使い方も説明しますが、ログイン機能の Effects を使った書き換えは、とりあえず読者に任せることとします。

facade にも追加しましょう。

  challenge$ = this.store.select(
    createSelector(authSlice.selectFeature, (state) => state.challenge)
  )

  completeNewPassword(password: string) {
    this.authProvider.completeNewPassword(password);
  }

NgIf を使って表示する要素を出し分ける

さて、login.page.ts の方を実装しましょう。やることは、画面のだし分けようの属性を追加することと、フォームに newPassword と言うものを入れることです。

import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { Subject } from 'rxjs';
import { map } from 'rxjs/operators';
import { filter, takeUntil, tap } from 'rxjs/operators';
import { AuthFacade } from '../auth/auth.facade';

@Component({
  selector: 'app-login',
  templateUrl: './login.page.html',
  styleUrls: ['./login.page.scss'],
})
export class LoginPage implements OnDestroy, OnInit {

  isAuthenticated$ = this.authService.isAuthenticated$;
  loginForm: FormGroup = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
    password: ['', Validators.required],
    newPassword: [''],
  });

  anyChallenge$ = this.authService.challenge$;

  newPasswordRequired$ = this.authService.challenge$.pipe(
    filter(challenge => challenge === "NEW_PASSWORD_REQUIRED"),
    map(() => {
      this.loginForm.get('newPassword').setValidators([
        Validators.required,
        Validators.minLength(6)
      ]);
      return true
    })
  );

  // subscription を止めるための Subject
  destroy$ = new Subject<void>();

  readonly errorMessages = {
    email: [
      { type: 'required', message: 'Emailは、必須です。' },
      { type: 'email', message: 'Email形式で入力してください。' }
    ],
    password: [
      { type: 'required', message: 'パスワードを入力してください。' }
    ],
    newPassword: [
      { type: 'required', message: '新しいパスワードは必須です。'},
      { type: 'minlength', message: 'パスワードが短すぎます。'}
    ]
  };
  constructor(
    private authService: AuthFacade,
    private router: Router,
    private fb: FormBuilder,
  ) { }

  ngOnInit() {
    this.authService.loginSuccess$.pipe(
      takeUntil(this.destroy$),
      filter(success => success),
      tap(success => this.router.navigate(['/todo']))
    ).subscribe();
  }

  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }

  onLoginClicked() {
    if (!this.loginForm.valid) return;

    this.authService.login({
      email: this.loginForm.get('email').value,
      password: this.loginForm.get('password').value
    })
  }

}

フォームに newPassword を追加して、エラーメッセージも追加しています。だしわけ状態を取得するために、anyChallenge$ と newPasswordRequired$ のふたつを追加しています。

newPasswordRequired$ の時は、newPassword 用の要素を表示します。anyChallenge が空の時にログインボタンを表示します。

<ion-header [translucent]="true">
  <ion-toolbar>
    <ion-title>
      My ToDo
    </ion-title>
  </ion-toolbar>
</ion-header>

<ion-content [fullscreen]="true">
  <ion-header collapse="condense">
    <ion-toolbar>
      <ion-title size="large">My ToDo</ion-title>
    </ion-toolbar>
  </ion-header>

  <div id="container">
    <strong>Login</strong>
    <p>さあ始めましょう。</p>
    <p *ngIf="newPasswordRequired$ | async">新しいパスワードを設定してください。</p>
    <form [formGroup]="loginForm">
      <ion-list class="ion-margin-vertical">
        <ion-item>
          <ion-input placeholder="Email" formControlName="email"></ion-input>
        </ion-item>
        <ion-item>
          <ion-input placeholder="Password" type="password" formControlName="password"></ion-input>
        </ion-item>
        <ion-item *ngIf="newPasswordRequired$ | async">
          <ion-input placeholder="New Password" type="password" formControlName="newPassword"></ion-input>
        </ion-item>
      </ion-list>
    </form>
    <ion-button color="success" expand="full" class="ion-margin-vertical" 
      (click)="onCompleteNewPassword()" *ngIf="newPasswordRequired$ | async" [disabled]="!loginForm.valid">
      Confirm New Password
    </ion-button>
    <ion-button color="primary" expand="full" class="ion-margin-vertical" (click)="onLoginClicked()"
      [disabled]="!loginForm.valid" *ngIf="!(anyChallenge$ | async)">
      Login
    </ion-button>

    <p class="ion-margin" *ngFor="let errorMessage of errorMessages.email">
      <ion-text color="danger"
        *ngIf="loginForm.get('email').hasError(errorMessage.type) && (loginForm.get('email').dirty || loginForm.get('email').touched)">
        {{ errorMessage.message }}
      </ion-text>
    </p>

    <p class="ion-margin" *ngFor="let errorMessage of errorMessages.password">
      <ion-text color="danger"
        *ngIf="loginForm.get('password').hasError(errorMessage.type) && (loginForm.get('password').dirty || loginForm.get('password').touched)">
        {{ errorMessage.message }}
      </ion-text>
    </p>

    <p class="ion-margin" *ngFor="let errorMessage of errorMessages.newPassword">
      <ion-text color="danger"
        *ngIf="loginForm.get('newPassword').hasError(errorMessage.type) && (loginForm.get('newPassword').dirty || loginForm.get('password').touched)">
        {{ errorMessage.message }}
      </ion-text>
    </p>
  </div>
</ion-content>

ngIf を駆使して画面の状態を変更しています。早速、画面の動作をみてみましょう。

うまくいきましたね。新しいパスワードの確認がないので不安要素がありますがこのまま進めていきましょう。さて、Confirm New Password しても関数を実装していないので何も起こりません。実装していきましょう。

  onCompleteNewPassword() {
    if (!this.loginForm.valid) return;
    this.authService.completeNewPassword(this.loginForm.get('newPassword').value);
  }

といってもこれだけですね。やってみましょう。

Confirm New Password した後に、再度ログインしなければならないのがかっちょ悪いですが、その辺は後々修正しましょう。ただし、この ConfirmNewPassword は、Cognito の管理コンソールで作成した時だけ発生するステータスなので、もう二度と出てきません。。。これで認証を Cognito で行うことができるようになりました!

Amplify で ToDo データを永続化する

API を追加する

さてデータを永続かします。ダミーデータからの卒業です。まず、バックエンドの API を amplify で追加します。API は、GraphQL で作成しましょう。API 名は、ToDoAPI です。

amplify add api
 ? Please select from one of the below mentioned services: GraphQL
 ? Provide API name: ToDoAPI
 ? Choose the default authorization type for the API API key
 ? Enter a description for the API key: ToDoAPIKey
 ? After how many days from now the API key should expire (1-365): 7
 ? Do you want to configure advanced settings for the GraphQL API Yes, I want to make some additional changes.
 ? Configure additional auth types? No
 ? Configure conflict detection? Yes
 ? Select the default resolution strategy Auto Merge
 ? Do you have an annotated GraphQL schema? No
 ? Choose a schema template: Single object with fields (e.g., “Todo” with ID, name, description)

これを実行するとプロジェクトのトップに、amplify と言うフォルダができまして、その中に設定が入ります。amplify/backend/api/ToDoAPI/schema.graphql を開いてみましょう。

schema を定義する

type Todo @model {
  id: ID!
  name: String!
  description: String
}

サンプルを作ってくれていますね。これを利用しましょう。誰が作成した ToDo かをデータベースでわかるように userId フィールドでしょう。

type Todo @model @auth(rules: [{ allow: owner }]){
  id: ID!
  title: String!
  due: AWSDate
  memo: String
  done: Boolean
  userId: String!
}

一番上の、@auth(rules: [{ allow: owner }]) とするだけで、作成者にしかデータが見えなくなります。めちゃくちゃすごくないですか?こんな簡単に。使える型は、こちらで確認できます。

API を push する

この状態で、amplify pushしましょう。

amplify push

エラーになります。rules owner を使うには、cognitoUserPool で API を認証できるようにする必要があるからです。

$ amplify update api                 
 ? Please select from one of the below mentioned services: GraphQL
 ? Select from the options below Update auth settings
 ? Choose the default authorization type for the API Amazon Cognito User Pool
 Use a Cognito user pool configured as a part of this project.
 ? Configure additional auth types? No

このように amplify update api コマンドで、認証設定を更新しましょう。

再度、amplify push しましょう。そうしますと、必要なファイルを自動生成するか聞いてくるのでもちろん自動生成してもらって提案されるがままにファイルを追加してもらいましょう。

数分かかります。完了したら、 src/graphql に graphql の設定が多数出来上がっています。これらは、mutations など更新系の graphql が大量に含まれています。subscription と言うものもあり、subscription を活用することで、チャットみたいなリアルタイムなデータの同期ができるようになります。

ToDo データを取得・登録する

Userモデルを確認しましょう。cognitoUser の ID を保持できるようにしておきます。

export class User {
  email: string;
  id?: string; // 追加

  constructor(email: string, id = null) {
    this.email = email;
    this.id = id; // 追加
  }
}

ToDo を登録する

現状だと何もデータが登録されていない状況ですので、新規にデータを登録する機能を先に作りましょう。

副作用を起こす Effects について

NgRx では、副作用を発生させる(外部システムの状態を変える・使う)処理を Effects を使って処理させます。Effects は、同期処理でも非同期処理でも同じように利用できるものです。

外部システムの状態を含めて Store で全て管理させるようにしましょう。Store は、サービスクラスに依存させ、新規に TodoFacade を作成し、サービスクラスに依存させない作りにしてみましょう。

クラスに toJSON 関数を実装してデータの変換を定義する

まず、汎用的に JSON データをやりとりするときに便利な toJSON 関数を ToDo クラスに実装します。

type TodoJSON = {
  id?: string,
  title: string,
  due?: string,
  memo?: string,
  done?: boolean,
  userId?: string
}
export class ToDo {
  id?: string;
  title: string;
  due: Date = new Date();
  memo?: string;
  done: boolean = false;
  userId?: string;

  constructor(todoParams: Partial<ToDo>) {
    Object.assign(this, todoParams);
  }

  get stringDate() {
    return `${this.due.getFullYear()}-${('0' + (this.due.getMonth() + 1)).slice(-2)}-${('0' + this.due.getDate()).slice(-2)}`
  }

  set stringDate(val) {
    this.due = new Date((new Date(val)).setHours(0));
  }

  toJSON(): TodoJSON {
    return {
      id: this.id,
      title: this.title,
      due: this.stringDate,
      memo: this.memo,
      done: this.done,
      userId: this.userId,
    }
  }
}

一手間かかりますが、簡単にでも type を宣言しておくと良いと思います。

Facade を作成して依存度を下げる(元の TodoService と同じ)

そしたら、todo.service の方で addToDo を変更するのですが、前述のように todo.facade を用意しておきましょう。

import { Injectable } from '@angular/core';
import { createSelector, Store } from '@ngrx/store';
import { ToDo } from './todo.model';
import fromTodo, * as todoSlice from './todo.slice';

@Injectable({
  providedIn: 'root'
})
export class TodoFacade {
  constructor(
    private readonly store: Store<typeof fromTodo>, 
  ) { }

  todos$ = this.store.select(
    createSelector(
      todoSlice.selectFeature, (state) => state.todos
    )
  )

  addToDo(todo: ToDo): void {
    this.store.dispatch(todoSlice.addToDo({ todo }))
  }

  deleteToDo(todo: ToDo): void {
    this.store.dispatch(todoSlice.deleteToDo({ todo }))
  }

  updateToDo(todo: ToDo): void {
    this.store.dispatch(todoSlice.updateToDo({ todo }))
  }
}

元の Todoサービスと全く同じです。これが Store だけに依存しているのがわかるでしょうか。それでは、新規にサービスを作っていきます。

Amplify に依存した、AmplifyTodoService を作成する

import { Injectable } from '@angular/core';
import { ToDo } from './todo.model';

@Injectable({
  providedIn: 'root'
})
export class AmplifyTodoService {

  constructor() { }

  addToDo(todo: ToDo): void {
  }

  deleteToDo(todo: ToDo): void {
  }

  updateToDo(todo: ToDo): void {
  }
}

このサービスは、Store に依存させませんが、他のサービスには依存しています。こちらに API を実装していきます。

import { Injectable } from '@angular/core';
import { ToDo } from './todo.model';
import { APIService, CreateTodoInput, CreateTodoMutation } from '../API.service';
import { Observable } from 'rxjs';
import { AuthFacade } from '../auth/auth.facade';
import { mergeMap, take } from 'rxjs/operators';

@Injectable({
  providedIn: 'root'
})
export class AmplifyTodoService {

  constructor(
    private api: APIService,
    private authService: AuthFacade,
  ) { }

  addToDo(todo: ToDo): Observable<CreateTodoMutation> {
    console.log('addToDo');
    return this.authService.currentAuthenticatedSession$.pipe(
      take(1),
      mergeMap(session => {
        console.log('session');
        console.log(session);
        return this.api.CreateTodo(<CreateTodoInput>{ ...todo.toJSON(), userId: session.id})
      })
    )
  }

  deleteToDo(todo: ToDo): void {
  }

  updateToDo(todo: ToDo): void {
  }
}

まず、addToDo の中で、現在の認証状態を取得しています。session の中には、User モデルが入ります(これ紛らわしいネーミングにしてしまいましたw)ので、ここから id を取得してユーザー側でどのidを入れるかを制御できないようにします。take(1) としているのは、subscription を1度処理したら自動で破棄するようにするものですが、ここではいらないかもしれません。読者の皆さんの方で考えてみてください。

todo.toJSON() を使ってますが、<CreateTodoInput> にキャストしています。キャストを使うと、キャストがうまく行くのかどうかはプログラマーの責任でつかわなければなりません。ここではお手軽にキャストしてしまいます。CreateTodo は、 Promise<CreateTodoMutation> を返却します。それを Observable に変換しておきます。

cognito-auth.service.ts を修正

cognito-auth.service が、UserId を返却していませんでしたので、UserIdを返却するように修正します。ついでに、もろもろリファくたしておきましょう。

import { Injectable } from '@angular/core';
import { Auth } from 'aws-amplify';
import { Observable, from, of } from 'rxjs';
import { map, catchError, mergeMap } from 'rxjs/operators';
import { AuthProvider } from './auth-provider';
import { CognitoUser } from 'amazon-cognito-identity-js';
import { User } from './user.model';
import fromAuth, * as authSlice from './auth.slice';
import { Store } from '@ngrx/store';

type UserData = {
  email: string,
  id: string,
};
@Injectable({
  providedIn: 'root'
})
export class CognitoAuthService implements AuthProvider {

  constructor(
    private store: Store<typeof fromAuth>
  ) { }

  private cognitoUser: CognitoUser | null;

  completeNewPassword(password: string): void {
    if (!this.cognitoUser) return;

    Auth.completeNewPassword(this.cognitoUser, password).then((user: CognitoUser) => {
      this.completeSignIn(user);
    }).catch(err => {
      this.clearLoginState();
      console.error(err);
    })
  }

  confirmSignIn(code: string): void {
    throw new Error('Method not implemented.');
  }

  currentAuthenticatedSession$: Observable<User> =
    from(Auth.currentAuthenticatedUser()).pipe(
      catchError((err) => {
        return of(err);
      }),
      mergeMap((user) => {
        this.completeSignIn(user);
        return this.getUserAttributesPromise(user);
      }),
      map((userData) => {
        return new User(userData.email, userData.id);
      })
    )

  isAuthenticated$: Observable<boolean> = this.currentAuthenticatedSession$.pipe(
    map(user => {
      if (user instanceof User) {
        return true;
      }
    })
  );

  login(authentication: { email: string, password: string }): void {
    console.log('signin');
    Auth.signIn({
      username: authentication.email,
      password: authentication.password,
    }).then(user => {
      this.completeSignIn(user);
    }).catch(err => {
      console.error(err);
      this.clearLoginState();
    });
  }

  logout(): void {
    this.cognitoUser = null;
    Auth.signOut().then(() => {
      this.clearLoginState();
    })
  }

  private handleAuthChallenge(user: any) {
    const newPasswordRequired = "NEW_PASSWORD_REQUIRED"
    let challengeName = null;
    if (user.challengeName === newPasswordRequired) {
      challengeName = newPasswordRequired;
    }
    this.store.dispatch(authSlice.setChallenge(newPasswordRequired));
  }

  private async completeSignIn(user: CognitoUser): Promise<boolean> {
    console.log(user);
    if (user instanceof CognitoUser) {
      this.cognitoUser = user;
    } else {
      this.clearLoginState();
      throw new Error('Cannot complete signin');
    }

    const userData = await this.getUserAttributesPromise(this.cognitoUser).catch((err) => {
      this.handleAuthChallenge(this.cognitoUser);
    });

    this.store.dispatch(authSlice.setLoginStatus({ email: userData['email'], id: userData['sub'] }));
    this.store.dispatch(authSlice.setLoginSuccess(true));
    return true;
  }

  private clearLoginState() {
    this.cognitoUser = null;
    this.store.dispatch(authSlice.setLoginStatus(null));
    this.store.dispatch(authSlice.setLoginSuccess(false));
  }

  private getUserAttributesPromise(user: CognitoUser): Promise<any> {
    return new Promise((resolve, reject) => {
      user.getUserAttributes((err, result) => {
        if (err) {
          reject(err);
        }
        const userData = {};
        for (let value of result) {
          userData[value.Name] = value.Value;
        }
        resolve(userData);
      })
    })
  }
}

メインは、currentAuthenticatedSession$ のところで、userData を返却するように変更したところです。

Effect を登録していく

手順としては、

  • Action を作成する
  • Effect を作成する
  • Facade から dispatch する

まずは、effects をインストールしておきましょう。

npm install --save @ngrx/effects

Action を作成する

action は、todo.slice.ts の中に作成していきます。

import { createFeatureSelector } from '@ngrx/store';
import { createSlice } from '@reduxjs/toolkit';
import todos from './todo.data';
import { ToDo } from './todo.model';

const todoSlice = createSlice({
  name: 'todo',
  initialState: {
    todos: todos,
    addTodoloading: false,
  },
  reducers: {
    addToDo: (state, action) => {
      state.addTodoloading = true;
    },
    addToDoSuccess: (state, action) => {
      state.todos.push(action.payload.todo);
      state.addTodoloading = false;
    },
    addToDoError: (state, action) => {
      state.addTodoloading = false;
    },
    deleteToDo: (state, action) => {
      const todo = action.payload.todo;
      const todoIndex = state.todos.findIndex(el => el.id === todo.id)
      state.todos.splice(todoIndex, 1);
    },
    updateToDo: (state, action) => {
      const todo = action.payload.todo;
      const oldTodo = state.todos.find(el => el.id === todo.id);
      state.todos.splice(state.todos.indexOf(oldTodo), 1);
      state.todos.push(new ToDo({...oldTodo, ...todo}));
    }
  }
});

const {
  reducer,
  actions: {
    addToDo,
    addToDoSuccess,
    addToDoError,
    deleteToDo,
    updateToDo
  },
  name
} = todoSlice;

export default reducer;
export {
  name,
  addToDo,
  addToDoSuccess,
  addToDoError,
  deleteToDo,
  updateToDo,
};

export const selectFeature = createFeatureSelector<ReturnType<typeof reducer>>(
  name
);

AddToDo と言うアクションはそのまま残していますが、addToDoLoading と言う状態を変化させるだけの簡単なものにしています。さらに新規に addToDoSuccess と addToDoError を追加しています。addToDoSuccess は、今まで addToDo で提供していた状態変化を行っています。 addToDoError の方は、単純に addToDoLoading の状態を false にするだけにしています。

Effects を作る

todo.effects.ts を新規に作成します。

import { Injectable } from '@angular/core';
import { of } from 'rxjs';
import { mergeMap, map, catchError } from 'rxjs/operators';
import { Actions, ofType, createEffect } from '@ngrx/effects';
import * as fromTodo from './todo.slice';
import { AmplifyTodoService } from './amplify-todo.service';
import { ToDo } from './todo.model';

@Injectable()
export class TodoEffects {
  constructor(
    private actions$: Actions,
    private todoService: AmplifyTodoService
  ) {}

  postTodo$ = createEffect(
    () => this.actions$.pipe(
      ofType(fromTodo.addToDo),
      map(action => <ToDo>action.payload.todo),
      mergeMap( (todo: ToDo)  => {
        console.log('mergeMap');
        console.log(todo);
        return this.todoService.addToDo(todo).pipe(
          map((event) => {
            console.log('map', event);
            return fromTodo.addToDoSuccess({ todo })
          }),
          catchError((event) => {
            console.log('error', event);
            return of(fromTodo.addToDoError({}))
          })
        )
      })
    )
  )
}

createEffect 関数を使って Effect を登録していきます。 ofType(fromTodo.addToDo) としているのは、ngrx のカスタムオペレーターです、ofType(<Action>) とすると、アクションでフィルターしてくれます。

ここで注意して欲しいのが、fromTodo.addToDo に対応する reducer は、すでに createSlice の中で作成されていますので、reducer は同期的に実行されます。Effect の方は、reducer が呼ばれると同時に副作用を起こすように非同期あるいは同期的に実行できます。同期的、非同期的が関係ありませんので、todoService は、AmplifyTodoService を DI していますが、ローカルのハードコーディングしたデータでもインターフェースを揃えることにより簡単にバックエンドを変更できると言うことです。

さて、addToDo を呼び出した後に、さらに pipe して、map の中で、 fromTodo.addToDoSuccess({ todo }) としています。これは、Effect が完了した時に他のアクションを dispatch させるためです。何も dispatch しないときは、第二引数のオプションで、 { dispatch: false } としましょう。同様にcatchError で失敗した時の挙動を定義しています。catchError は、自動的に ObservableInput になってくれないので、of を使って Observable にしています。

さて、EffectModule を AppModule に読み込みましょう。

    EffectsModule.forRoot([TodoEffects]),  // これを追加

これでOKです。

ToDoPage の DI を TodoFacade にする

簡単ですね。

  constructor(
    private todoService: TodoFacade,
    private modalController: ModalController
  ) { }

これだけです。他は何も変更が入りません。

さて、実行してみましょう。

タイムラグがあって非同期処理されているのがわかると思います。本来ならローディングの表示が欲しいところですね。

コンソールで確認する

さて、Amplifyの方(DynamoDB)にも保存されているかみてみましょう。

AWS のコンソールから、Dynamo DB を検索して表示してください。この時、リージョンが、Amplifyを設定したリージョンとあっていることに注意してください。中をみてみましょう。

テーブルと言うところをクリックすると何かテーブルができています。

さらにレコードの ID をクリックして詳細を確認してみましょう。

userId / Title / Memo / Due がしっかり入っていますね。owner と言う項目は、Amplify が自動的に設定する項目です。

ToDo 一覧取得を追加する

新機能を追加しましょう。ToDo 一覧を取得します。

  • Service クラスに具体的な実装を追加する
  • Slice に Action を追加する
  • Effects を登録する
  • Facade に追加する

この手順でやっていきましょう。

Service クラスに fetchToDos を追加

とりあえず全部取得するように Service クラスを実装します。

  fetchToDos(): Observable<ListTodosQuery> {
    console.log('fetchTodo');
    return from(this.api.ListTodos());
  }

これで良いかな。

Slice に Action を登録

reducers のところにこれを3つ追加しましょう。

  reducers: {
    fetchToDo: (state, action) => {
      state.fetchLoading = true;
    },
    fetchToDoSuccess: (state, action) => {
      state.fetchLoading = false;
      state.todos = action.payload;
    },
    fetchToDoError: (state, action) => {
      state.fetchLoading = false;
    },

export しておきましょう。


const {
  reducer,
  actions: {
    addToDo,
    addToDoSuccess,
    addToDoError,
    fetchToDo,
    fetchToDoSuccess,
    fetchToDoError,
    deleteToDo,
    updateToDo
  },
  name
} = todoSlice;

export default reducer;
export {
  name,
  addToDo,
  addToDoSuccess,
  addToDoError,
  fetchToDo,
  fetchToDoSuccess,
  fetchToDoError,
  deleteToDo,
  updateToDo,
};

todos の初期状態も空にしておきましょう。

  initialState: {
    todos: [],
    fetchLoading: false,
    addToDoLoading: false,
  },

Effects に追加する

Effects を登録していきましょう。

  fetchTodo$ = createEffect(
    () => this.actions$.pipe(
      ofType(fromTodo.fetchToDo),
      switchMap(() => {
        return this.todoService.fetchToDos().pipe(
          map((todos) => {
            const todoList = todos.items.map(todoItem => {
              return new ToDo({ ...todoItem, ...{ due: null, stringDate: todoItem.due }})
            })
            return fromTodo.fetchToDoSuccess(todoList);
          })
        )
      })
    )
  );

map のところで受け取る todos は、ListTodoQuery 形式で返ってくるので、items の中にデータが入っています。new ToDo(…) を呼び出して ToDo モデルを生成しています。

switchMap を使っているのは、リクエストが連発したときに最後のだけ有効にするためです。

Facade に関数を追加する

  fetchToDos(): void {
    this.store.dispatch(todoSlice.fetchToDo({}));
  }

これだけですね。

Todo.page の方で、画面初期化時に fetchTodos を呼び出す

  ngOnInit() {
    this.todoService.fetchToDos();
  }

これだけです。恐るべし amplify

試してみます

初期の画面表示から、登録→再読み込みを試してみましょう。

うまくできています。

ToDo データを更新・削除する

手順は、同じです。

  • Service クラスに具体的な実装をする
  • Action を作成する
  • Effects を作成する
  • Facade から dispatch する

やっていきましょう。

AmplifyToDoService に実装を追加する

実装すべきは、deleteToDo と updateToDo ですね。

  deleteToDo(todo: ToDo): Observable<DeleteTodoMutation> {
    console.log('deleteToDo', todo);
    return from(this.api.DeleteTodo(<DeleteTodoInput>{ id: todo.id }));
  }

  updateToDo(todo: ToDo): Observable<UpdateTodoMutation> {
    console.log('updateToDo', todo);
    return from(this.api.UpdateTodo(<UpdateTodoInput>todo.toJSON()));
  }

これでいきましょう。

Action を作成する

もう簡単ですね。

  initialState: {
    todos: [],
    fetchLoading: false,
    addToDoLoading: false,
    updateToDoLoading: false,  // 追加
    deleteToDoLoading: false,   // 追加
  },

初期状態を追加します。(追加しても使ってないですけどねw)

続いて、Reducer を作成します。

    deleteToDo: (state, action) => {
      state.deleteToDoLoading = true;
    },
    deleteToDoSuccess: (state, action) => {
      const todo = action.payload.todo;
      const todoIndex = state.todos.findIndex(el => el.id === todo.id)
      state.todos.splice(todoIndex, 1);
      state.deleteToDoLoading = false;
    },
    deleteToDoError: (state, action) => {
      state.deleteToDoLoading = false;
    },
    updateToDo: (state, action) => {
      state.updateToDoLoading = true;
    },
    updateToDoSuccess: (state, action) => {
      const todo = action.payload.todo;
      const oldTodo = state.todos.find(el => el.id === todo.id);
      state.todos.splice(state.todos.indexOf(oldTodo), 1);
      state.todos.push(new ToDo({...oldTodo, ...todo}));
      state.updateToDoLoading = false;
    },
    updateToDoError: (state, action) => {
      state.updateToDoLoading = false;
    }

これで OK ですね。あとは、export しておいてください。

Effect を登録する

これも簡単ですね。

  updateTodo$ = createEffect(
    () => this.actions$.pipe(
      ofType(fromTodo.updateToDo),
      map(action => <ToDo>action.payload.todo),
      mergeMap((todo) => {
        console.log('update', todo)
        return this.todoService.updateToDo(todo).pipe(
          map(event => {
            console.log('updateToDoSuccess', event);
            return fromTodo.updateToDoSuccess({ todo });
          }),
          catchError(err => {
            console.log('updateToDOError', err);
            return of(fromTodo.updateToDoError({}));
          })
        )
      })
    )
  );

  deleteTodo$ = createEffect(
    () => this.actions$.pipe(
      ofType(fromTodo.deleteToDo),
      map(action => <ToDo>action.payload.todo),
      mergeMap((todo) => {
        console.log('deleteTodo', todo)
        return this.todoService.deleteToDo(todo).pipe(
          map(event => {
            console.log('deleteToDoSuccess', event);
            return fromTodo.deleteToDoSuccess({ todo });
          }),
          catchError(err => {
            console.log('deleteToDoError', err);
            return of(fromTodo.deleteToDoError({}));
          })
        )
      })
    )
  );

こんな感じですかね。

Facade から dispatch する

変更なしです。

確認しましょう

やってみます。

更新の確認

うまくいってますね。

ここで、コンポーネントの修正を一切していないことに注意してください。Facade や、依存関係に注意することで柔軟にバックエンドを変更できるようになっています。

この長いブログも別に初めから設計していたわけではありません。都度都度、Facade や Service クラスを作って行った結果柔軟なシステムにすることができました。初めから設計に時間をかける必要はいらないです。依存度を緩和する仕組みにしていけば自然に柔軟になるのでプロトタイプ開発などでは特に力を発揮します。

削除を確認する

削除も同様に確認してみましょう。

完璧ですね。

カレンダー画面は全く変更していませんが、カレンダー画面にしても同様に動作しますね。抽象化のメリットがわかるのではないでしょうか。

ところが、/calendar に直接行くと何も ToDo が表示されません。TodoPage では、OnInit で取得してきていたので一度 todo を表示すれば状態が変化しますが、calendar 画面に直接行くとでは、OnInit で何もやっていないのが理由です。修正しておいてください。

その3に続く