[社内ハンズオン資料] Angular+Firebaseで作るTODOアプリケーション

大阪オフィスのフロントエンド勉強会で使用したAngular+Firebaseハンズオンの資料を公開します。勉強用のサンプルアプリケーションとして活用していただければ幸いです。
2020.04.20

どうも!大阪オフィスの西村祐二です。

大阪オフィスではフロントエンド勉強会を隔週で行っており、そこでAngular+Firebaseで作るTODOアプリケーションのハンズオン行ったのでその資料を公開します。

勉強のためのサンプルアプリケーションとしてお役に立てれば幸いです。

ゴール

下記のようなTODOアプリケーションを実装します。

主な機能として下記になります。

  • TODOの追加削除
  • Drag And DropでTODOを移動させてデータを更新
  • Googleアカウントでログイン・ログアウト
  • TODO追加入力フォームのバリデーション

バックエンドはFirebaseを利用しています。

利用するツール

  • Angular
  • Angular Material
  • Firebase
    • Hosting
    • Authentication
    • Firestore

対象者

  • Angularをはじめたい人
  • Firebaseをはじめたい人
  • Angularの開発の雰囲気を掴みたい人

環境

Angular CLI: 9.1.1
Node: 12.13.0
OS: darwin x64

Angular: 9.1.1
... animations, cli, common, compiler, compiler-cli, core, forms
... language-service, platform-browser, platform-browser-dynamic
... router
Ivy Workspace: Yes

Package                           Version
-----------------------------------------------------------
@angular-devkit/architect         0.900.7
@angular-devkit/build-angular     0.901.1
@angular-devkit/build-optimizer   0.901.1
@angular-devkit/build-webpack     0.901.1
@angular-devkit/core              9.1.1
@angular-devkit/schematics        9.1.1
@angular/cdk                      9.2.0
@angular/fire                     6.0.0-rc.2
@angular/material                 9.2.0
@ngtools/webpack                  9.1.1
@schematics/angular               9.1.1
@schematics/update                0.901.1
rxjs                              6.5.5
typescript                        3.8.3
webpack                           4.42.0
  • firebase:7.14.0

流れ

  • Angularアプリケーション雛形ファイルの作成

  • Firebaseのプロジェクトセットアップ

  • AngularとFirebaseを連携設定

  • Firebase Hostingデプロイ

  • TODOアプリケーション実装

  • 動作確認

  • Firebase Hostingデプロイ

  • 終わり

アプリケーションの雛形作成

▼CLIを使ってアプリケーションの雛形ファイルなど作成しながら進めるため公式が提供するAngular CLIをインストールします。

インストールするとngというコマンドが利用できるようになります。

$ npm install -g @angular/cli

▼CLIを使ってアプリケーションの雛形ファイルを生成します。

ルーティングを制御するためのファイルをはじめから作成するために、--routingというオプションを設定しておきます。また、CSSファイルはSCSSを利用するために--style=scssのオプションを指定しておきます。

$ ng new demo-app --routing --style=scss
$ cd demo-app

▼ファイルの作成が完了したら、ローカルサーバを起動しアプリケーションを確認してみましょう。

起動したらブラウザからhttp://localhost:4200/にアクセスしてみましょう。すると、Angularのアプリケーションを確認することができると思います。

$ ng serve

--openをつけるど自動的にブラウザを開いてhttp://localhost:4200/にアクセスしてくれます。

設定ファイルの変更

はじめにやっておいたほうがよい設定を行っておきます。

ビルドに厳密な型チェックをするように設定を変更しておきます。

tsconfig.json

{
  "compileOnSave": false,
  "compilerOptions": {
    "baseUrl": "./",
    "strict": true,
    "outDir": "./dist/out-tsc",
    "sourceMap": true,
    "declaration": false,
    "downlevelIteration": true,
    "experimentalDecorators": true,
    "module": "esnext",
    "moduleResolution": "node",
    "importHelpers": true,
    "target": "es2015",
    "lib": [
      "es2018",
      "dom"
    ]
  },
  "angularCompilerOptions": {
    "strictTemplates": true,
    "strictInjectionParameters": true
  }
}

コードフォーマットのためにprettierを導入します。

$ yarn add prettier --dev --exact

--exactを指定すると正確なバージョンを指定してパッケージをインストールすることができます。 デフォルトでは、同じメジャーバージョンの最新のリリースが使用されます。

prettierの設定ファイルを作成します。ここではyamlでファイル作成していますが、各自好みのファイル形式で作成してください。Angular CLIでファイル作成した際、基本的にsingleQuoteが使用されるので、ここではsingleQuoteを許可する設定を記述しておきます。

.prettierrc.yaml

singleQuote: true

commit時に自動的にprettierでフォーマットするように設定を追加しておきます。

必要なライブラリを追加します。

$ npx mrm lint-staged

package.jsonに自動的に設定が追加されます。対象となるファイル形式を下記のように修正しておきます。

package.json

...
  "lint-staged": {
    "*.{ts,scss,html}": "prettier --write"
  }
...

Firebaseと連携

Firebaseの準備

プロジェクトの作成

プロジェクト作成しておきます。 ここではプロジェクト名を「todo-demo-app-angular」として作成しています。

プロジェクト作成の手順は下記画像を参考にしてください。

Firebaseのコンソール画面から作成していきます。

データベース(Firestore)の準備

TODOの情報などを格納するためのデータベースを作成します。

今回はハンズオンなので、テストモードでデータベースを作成します。

作成する際にリージョンは日本のリージョンを選択しておくと良いです。

Authenticationの準備

アプリケーションにログイン機能を実装するため、認証のAuthenticationを設定します。

今回はGoogleアカウントを使ってログインできるようにしてみます。

アプリの作成

Firestoreのプロジェクト内にアプリを作成します。

歯車のアイコンをクリックし、「プロジェクトを設定」を開きます。

▼下の方になる「マイアプリ」のところのwebのボタンをクリックします。

▼登録するアプリの名前を設定します。ここでは「todo-demo-app」とします。

Firebase Hostingの設定にもチェックを入れておきます。

あとは次へをクリックしていき、アプリを登録します。

▼アプリの登録が完了したら、表示が変化します。構成のラジオボタンをクリックします。

すると、アプリケーション側で利用するFirebaseの設定情報が表示されるので、コピーしておきます。

AngularアプリケーションとFirebaseを連携させる

▼Firebase CLIをインストール

$ npm install -g firebase-tools

▼Firebase CLIからloginしておきます。

❯ firebase login
i  Firebase optionally collects CLI usage and error reporting information to help improve our products. Data is collected in accordance with Google's privacy policy (https://policies.google.com/privacy) and is not used to identify you.

? Allow Firebase to collect CLI usage and error reporting information? No

Visit this URL on this device to log in:
https://accounts.google.com/o/oauth2/auth?client_id=xxxx.....

Waiting for authentication...

✔  Success! Logged in as XXXXXXXXXX@gmail.com

▼Angularで利用するFirebaseの公式Angularライブラリをインストールします。その際にプロジェクトを選択する際は、先程作成したtodo-demo-app-angularを選択します。

$ yarn add firebase
$ ng add @angular/fire@next

Installing packages for tooling via yarn.
Installed packages for tooling via yarn.
UPDATE package.json (1707 bytes)
✔ Packages installed successfully.
✔ Preparing the list of your Firebase projects
? Please select a project: todo-demo-app-angular (todo-demo-app-angular)
CREATE firebase.json (316 bytes)
CREATE .firebaserc (157 bytes)
UPDATE angular.json (3812 bytes)

▼先程コピーしたFirebaseの設定情報を参考にアプリケーションに追加します。

これで、アプリケーションからFirebaseに対して操作することができます。

設定情報はenvironment.tsenvironment.prod.tsの両方に追記しておいてください。

/src/environments/environment.ts

export const environment = {
  production: false,
  firebase: {
    apiKey: '<your-key>',
    authDomain: '<your-project-authdomain>',
    databaseURL: '<your-database-URL>',
    projectId: '<your-project-id>',
    storageBucket: '<your-storage-bucket>',
    messagingSenderId: '<your-messaging-sender-id>',
    appId: '<your setting>',
    measurementId: '<your setting>'
  }
};

▼AngularFireModuleを@NgModuleに設定していきます。

Angularでは大きくmodule単位で管理されており、利用したい機能のModuleをimportすることでアプリケーション側で利用できるようになります。

/src/app/app.module.ts

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { AngularFireModule } from '@angular/fire';
import { AngularFireAnalyticsModule } from '@angular/fire/analytics';
import { AngularFirestoreModule } from '@angular/fire/firestore';
import { environment } from 'src/environments/environment';
@NgModule({
  declarations: [AppComponent],
  imports: [
    BrowserModule,
    AppRoutingModule,
    AngularFireModule.initializeApp(environment.firebase),
    AngularFireAnalyticsModule,
    AngularFirestoreModule,
  ],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}

Firebase Hostingにデプロイしてみる

ng add @angular/fire@nextのインストールが完了すると自動的にFirebaseにHostingするための設定ファイルが追加・更新されます。

少し設定ファイルを覗いてみます。

▼Angularのアプリケーションの設定ファイルにdeployに関する設定が追加されています。

angular.json

...
        "deploy": {
          "builder": "@angular/fire:deploy",
          "options": {}
        }
        ...

▼firebaseの設定ファイルが作成されています。

中にはfirebaseでHostingするための設定がアプリケーションの環境をもとに設定されています、

firebase.json

{
  "hosting": [
    {
      "target": "test-firebase",
      "public": "dist/test-firebase",
      "ignore": [
        "firebase.json",
        "**/.*",
        "**/node_modules/**"
      ],
      "rewrites": [
        {
          "source": "**",
          "destination": "/index.html"
        }
      ]
    }
  ]
}

▼firebaseの設定ファイルが作成されています。

.firebaserc

{
  "targets": {
    "todo-demo-app-angular": {
      "hosting": {
        "test-firebase": [
          "todo-demo-app-angular"
        ]
      }
    }
  }
}

▼Firebase Hostingにデプロイしてみます。

動作確認も兼ねてFirebase Hostingにデプロイしてみましょう。

方法はとても簡単でng deployするだけです。設定などはインストール時に自動的に設定されています。

$ ng deploy

Compiling @angular/animations : es2015 as esm2015
Compiling @angular/compiler/testing : es2015 as esm2015
Compiling @angular/core : es2015 as esm2015
Compiling @angular/common : es2015 as esm2015
Compiling @angular/animations/browser : es2015 as esm2015
Compiling @angular/core/testing : es2015 as esm2015
Compiling @angular/animations/browser/testing : es2015 as esm2015
Compiling @angular/platform-browser : es2015 as esm2015
Compiling @angular/common/http : es2015 as esm2015
Compiling @angular/platform-browser/testing : es2015 as esm2015
Compiling @angular/platform-browser/animations : es2015 as esm2015
Compiling @angular/common/testing : es2015 as esm2015
Compiling @angular/platform-browser-dynamic : es2015 as esm2015
Compiling @angular/router : es2015 as esm2015
Compiling @angular/forms : es2015 as esm2015
Compiling @angular/common/http/testing : es2015 as esm2015
Compiling @angular/platform-browser-dynamic/testing : es2015 as esm2015
Compiling @angular/router/testing : es2015 as esm2015
Generating ES5 bundles for differential loading...
ES5 bundle generation complete.

chunk {0} runtime-es2015.f8b979f66300b1e53384.js (runtime) 1.45 kB [entry] [rendered]
chunk {0} runtime-es5.f8b979f66300b1e53384.js (runtime) 1.45 kB [entry] [rendered]
chunk {2} polyfills-es2015.a2c1af2b1be41024173b.js (polyfills) 36.1 kB [initial] [rendered]
chunk {3} polyfills-es5.85d83c3040e41a367f6b.js (polyfills-es5) 129 kB [initial] [rendered]
chunk {1} main-es2015.e2412a31842dacf9e991.js (main) 216 kB [initial] [rendered]
chunk {1} main-es5.e2412a31842dacf9e991.js (main) 258 kB [initial] [rendered]
chunk {4} styles.09e2c710755c8867a460.css (styles) 0 bytes [initial] [rendered]
Date: 2020-04-11T09:17:24.577Z - Hash: 6bf4e5c2889ba4665d88 - Time: 36148ms
? Your application is now available at https://todo-demo-app-angular.firebaseapp.com/

デプロイが完了したら表示されたURLにアクセスしてみましょう。

アプリケーション実装

Firebaseとの連携ができたので、次はAngularアプリケーションの実装を行っていきます。

Angular Materialを導入

UI部分の実装工数をへらすために、Angular公式が提供するUIフレームワークのAngular Materialをインストールします。

インストールの際に対話形式でいくつか質問されます。

  • custom theme:基本となるカラーテーマを選択します。今回はIndigo/Pinkとしておきます。
  • 他きかれることは基本的にYesで問題ないです。
$ ng add @angular/material

Installing packages for tooling via npm.
Installed packages for tooling via npm.

? Choose a prebuilt theme name, or "custom" for a custom theme: Indigo/Pink
? Set up global Angular Material typography styles? Yes
? Set up browser animations for Angular Material? Yes
UPDATE package.json (1799 bytes)
✔ Packages installed successfully.
UPDATE src/app/app.module.ts (860 bytes)
UPDATE angular.json (3976 bytes)
UPDATE src/index.html (547 bytes)
UPDATE src/styles.scss (181 bytes)

ng addコマンドでインストールすることで関連するライブラリのインストールや設定周りなどもまとめて面倒みてくれます。

Materialモジュール作成

Angular Materialはいろんなところで利用するためモジュール化しておきます。

Angular Materialの見た目を使いたいときは、MatIconModuleのようなモジュールをimportする必要があります。

importすることで、対象のモジュールにぶら下がるComponentの中でAngular Materialの見た目がつかえるようになります。

機能ごとにモジュールを分割していくと都度Angular Materialをimportする必要がでてきて面倒なので、まとめてimportするためのmaterial.moduleを作っておきます。

$ ng g module material

使いたいAngular Materialのmoduleをimportしておき、exportsに列挙していきます。

src/app/material/material.module.ts

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatToolbarModule } from '@angular/material/toolbar';
import { DragDropModule } from '@angular/cdk/drag-drop';

@NgModule({
  declarations: [],
  imports: [CommonModule],
  exports: [
    MatToolbarModule,
    MatIconModule,
    MatButtonModule,
    MatFormFieldModule,
    MatInputModule,
    DragDropModule,
  ],
})
export class MaterialModule {}

▼作成したMaterialモジュールをAppModuleにimportしてAppModuleに紐づくcomponetでAngular Materialを利用できるようにしておきます。

src/app/app.module.ts

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { MaterialModule } from 'src/app/material/material.module';
import { AngularFireModule } from '@angular/fire';
import { AngularFireAnalyticsModule } from '@angular/fire/analytics';
import { AngularFirestoreModule } from '@angular/fire/firestore';
import { environment } from 'src/environments/environment';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';

@NgModule({
  declarations: [AppComponent],
  imports: [
    BrowserModule,
    AppRoutingModule,
    AngularFireModule.initializeApp(environment.firebase),
    AngularFireAnalyticsModule,
    AngularFirestoreModule,
    BrowserAnimationsModule,
    MaterialModule,
  ],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}

▼AppModule配下のcomponentでAngular Materialがつかえるようになったのでapp.component.htmlでヘッダーをスタイリングしてみましょう。

app.component.htmlを下記ソースに置き換えます。

src/app/app.component.html

<mat-toolbar>
  <button mat-button>TODO</button>
  <span class="spacer"></span>
  <button mat-raised-button>ログイン</button>
</mat-toolbar>

▼ローカルサーバを起動させて確認してみましょう。

$ ng serve

http://localhost:4200にアクセスして下記のようなスタイルがあたった画面がでてきたらOKです。

Googleアカウントを使ったログイン・ログアウト機能を実装

Authサービス作成

FirebaseのAuthenticationを使ったログイン、ログアウトの機能を実装していきます。

認証関連の機能をまとめたサービスを作成します。

外部とのやり取りが発生するところはサービスに切り出しておきます。

$ ng g service services/auth

ggenerateを省略した形になります。

作成されたauth.service.tsに下記のようにログイン、ログアウトのロジックを実装していきます。

src/app/services/auth.service.ts

import { Injectable } from '@angular/core';
import { AngularFireAuth } from '@angular/fire/auth';
import { auth } from 'firebase';

@Injectable({
  providedIn: 'root',
})
export class AuthService {
  user$ = this.afAuth.user;
  constructor(private afAuth: AngularFireAuth) {}
  login() {
    this.afAuth
      .signInWithPopup(new auth.GoogleAuthProvider())
      .then((result) => {
        console.log(result);
      });
  }
  logout() {
    this.afAuth.signOut();
  }
}

ログインにはGoogleアカウントを利用するので、GoogleAuthProviderを設定しています。

AngularFireを使うと少ない記述でログイン周りの実装ができます。

また、公式によるライブラリの提供であるため安心感がありますね。

ヘッダー部分の実装

先程作ったAuthServiceをDIして、ログイン・ログアウトの機能を実装します。

user$$は慣習的にobservableを意味します。

もし、ログイン済であれば、user$にデータが流れてくるので、簡易的なログイン状態の判定に利用できます。

src/app/app.component.ts

import { Component } from '@angular/core';
import { AuthService } from './services/auth.service';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
})
export class AppComponent {
  user$ = this.auth.user$;

  constructor(private auth: AuthService) {}
  login() {
    this.auth.login();
  }
  logout() {
    this.auth.logout();
  }
}

▼ヘッダー部分の実装を下記のように修正します。

ログイン後に取得できるアカウント情報からphotoURLを使って画像を表示させます。

未ログイン際は#showLoginのDOMが表示されるように制御しています。

src/app/app.component.html

<mat-toolbar>
  <button mat-button>TODO</button>
  <span class="spacer"></span>
  <ng-container *ngIf="user$ | async as user; else showLogin">
    <button mat-raised-button (click)="logout()">
      ログアウト
      <img src="{{ user.photoURL }}" alt="" width="30px" />
    </button>
  </ng-container>

  <ng-template #showLogin>
    <button mat-raised-button (click)="login()">ログイン</button>
  </ng-template>
</mat-toolbar>

▼見た目をcssで調整します。

src/app/app.component.scss

.spacer {
  flex: 1;
}

img {
  border-radius: 50%;
}

▼ローカルサーバーを起動して、動作確認してみましょう。

ログインボタンをクリックするとGoogleアカウントの選択画面が表示され、ログインを許可するとログインボタンが変わり下記のようなGoogleアカウントのアイコン付きボタンに変化するようになります。

TODO機能を実装

次にメインの機能であるTODO部分を作っていきます。

todoモジュール作成

TODO機能をもたせるためのmoduleを作っていきます。

AngularCLIでtodo用のmoduleの雛形を作成します。

$ ng g module todo --routing

--routingオプションでルーティング用のファイルも同時に生成してくれます。 ggenerateを省略した形になります。

TODOを追加するためのフォームなどを利用するので、フレームワークが提供するフォーム関連のモジュールをimportしておきます。また、Angular Materialを利用できるようにMaterialModuleもimportしておきます。

src/app/todo/todo.module.ts

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';

import { TodoRoutingModule } from './todo-routing.module';
import { MaterialModule } from 'src/app/material/material.module';

@NgModule({
  declarations: [],
  imports: [
    CommonModule,
    TodoRoutingModule,
    MaterialModule,
    FormsModule,
    ReactiveFormsModule,
  ],
})
export class TodoModule {}

todoコンポーネント作成

次に画面を構成するコンポーネントを作成します。

AngularCLIを使ってコンポーネントの雛形を作成します。

$ ng g component todo/todo -m todo/todo.module
$ ng g component todo/todo-item -m todo/todo.module.ts

-mをつけることで、どのmoduleにcomponentを紐付けるか指定することができます。

todoコンポーネントは主に振る舞いなどの役割をもたせる想定です。

todo-itemはtodoの表示の役割をもたせる想定です。

ルーティングの設定

作成したtodo.moduleapp.moduleに紐付けます。

今回はルーティングのパスによって読み込むmoduleを指定する方法で紐付けます。

今回作るアプリケーションではあまり関係ないですが、パスごとにmoduleをimportすることでLazyLoadされるので規模が大きくなっても初期ロード時に重くなることを避けることができます。

ここの設定ではrootのパスにアクセスするとTodoModuleが呼ばれる挙動になります。

/src/app/app-routing.module.ts

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

const routes: Routes = [
  {
    path: '',
    loadChildren: () =>
      import('src/app/todo/todo.module').then((m) => m.TodoModule),
  },
  {
    path: '**',
    redirectTo: '',
  },
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule],
})
export class AppRoutingModule {}

TODOモジュール側のルーティング設定を行うtodo-routing.module.tsでも、パスと対応するコンポーネントを設定します。

/src/app/todo/todo-routing.module.ts

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { TodoComponent } from './todo/todo.component';

const routes: Routes = [{ path: '', component: TodoComponent }];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule],
})
export class TodoRoutingModule {}

TodoModuleに紐付けられたコンポーネントを表示されるように修正します。

簡易的にngIfを使って未ログイン状態のときはTODOの画面が表示されないようにしています。

src/app/app.component.html

<mat-toolbar>
  <button mat-button>TODO</button>
  <span class="spacer"></span>
  <ng-container *ngIf="user$ | async as user; else showLogin">
    <button mat-raised-button (click)="logout()">
      ログアウト
      <img src="{{ user.photoURL }}" alt="" width="30px" />
    </button>
  </ng-container>

  <ng-template #showLogin>
    <button mat-raised-button (click)="login()">ログイン</button>
  </ng-template>
</mat-toolbar>

<ng-container *ngIf="user$ | async as user">
  <router-outlet></router-outlet>
</ng-container>

router-outletタグを使用することでルーティングに沿ったコンポーネントを紐付けてくれます。今回はtodo.component.htmlが紐付けられています。

ローカルサーバを起動して確認してみましょう。

ログインするとtodo.component.htmlの内容「todo works!」が表示されていることがわかります。

todoの型定義ファイル作成

todoはどんなデータを持っているか定義しておきます。

▼AngularCLIを使って雛形ファイルを作成します。

$ ng g interface interfaces/todo

今回は下記のようにしています。

src/app/insterfaces/todo.ts

export interface Todo {
  uid: string;
  name: string;
  list: 'todo' | 'doing' | 'done';
  id: string;
}

todoサービス作成

TODOデータはFirebaseのFirestoreに格納するので、Firestoreとやり取りするtodoサービスを作成します。

$ ng g service services/todo

TODO作成、更新、取得、削除するロジックを下記のように実装します。

src/app/services/todo.service.ts

import { Injectable } from '@angular/core';
import { AngularFirestore } from '@angular/fire/firestore';
import { Todo } from 'src/app/interfaces/todo';

@Injectable({
  providedIn: 'root',
})
export class TodoService {
  constructor(private afs: AngularFirestore) {}

  createTodo(name: string, uid: string) {
    const id = this.afs.createId();
    const params: Todo = {
      name,
      uid,
      id,
      list: 'todo',
    };
    this.afs.collection<Todo>('todos').doc(id).set(params);
  }

  putTodo(params: Todo) {
    this.afs.collection<Todo>('todos').doc(params.id).set(params);
  }

  getTodos() {
    return this.afs.collection<Todo>('todos').valueChanges();
  }

  deleteTodo(id: string) {
    this.afs.collection<Todo>('todos').doc(id).delete();
  }
}

実装のハマりどころはデータを登録するときに、ドキュメントIDをデータとして持たせて登録するところです。

理由としてはTODOデータを取得する時にvalueChanges()メソッドを使用していると、ドキュメントIDが取得できないので、更新・削除するときに困ってしまうからです。

今回は用意されているcreateId()を使ってID生成し、そのIDをドキュメントIDと格納するデータの中にもたせています。

TODOを追加するフォームを実装

Angularはフォーム周りも公式で機能が提供されているので、これを利用して、TODOを追加するフォームを実装していきます。

フォームのバリデーションもある程度用意されているので実装はとても簡単です。

src/app/todo/todo.component.ts

import { Component, OnInit } from '@angular/core';
import { tap } from 'rxjs/operators';
import { TodoService } from 'src/app/services/todo.service';
import { AuthService } from 'src/app/services/auth.service';
import { FormBuilder, Validators } from '@angular/forms';

@Component({
  selector: 'app-todo',
  templateUrl: './todo.component.html',
  styleUrls: ['./todo.component.scss'],
})
export class TodoComponent implements OnInit {
  todos$ = this.todo.getTodos().pipe(tap((data) => console.log(data)));
  user$ = this.auth.user$;
  lists = ['todo', 'doing', 'done'];
  form = this.fb.group({
    name: ['', [Validators.required, Validators.maxLength(10)]],
  });
  constructor(
    private todo: TodoService,
    private auth: AuthService,
    private fb: FormBuilder
  ) {}

  ngOnInit(): void {}
  createTodo(uid: string) {
    const name = this.form.controls.name.value;
    this.todo.createTodo(name, uid);
    this.form.reset();
    this.form.controls.name.setErrors({ required: null });
  }
}

13行目: TODOの登録が完了するとtodo$からデータがながれてきます。

27行目: this.form.controls.name.valueはフォームのnameプロパティに入っているデータを取ってくる処理です。

29行目: TODOの登録ボタンがクリックされたら、フォームの中身リフレッシュされます。

30行目: フォームの中身がリフレッシュされるとrequiredのバリデーションのエラーメッセージが表示されてしまうので、最初は出ないようしています。

▼表示部分のテンプレート実装を行います。

[formGroup]="form"とすることで、コンポーネント側とテンプレート側をバインディングすることができます。

src/app/todo/todo.component.html

<ng-container *ngIf="user$ | async as user">
  <form class="todoForm" [formGroup]="form" (ngSubmit)="createTodo(user!.uid)">
    <mat-form-field hintLabel="最大文字数">
      <mat-label>TODO追加</mat-label>
      <input
        matInput
        type="text"
        formControlName="name"
        autocomplete="off"
        #input
      />
      <!-- バリデーションエラー -->
      <mat-error *ngIf="form.controls.name.hasError('maxlength')">
        文字数オーバー
      </mat-error>
      <mat-error *ngIf="form.controls.name.hasError('required')">
        入力してください
      </mat-error>
      <mat-hint align="end"> {{ input.value?.length || 0 }}/10 </mat-hint>
    </mat-form-field>
    <button mat-icon-button [disabled]="!form.valid">
      <mat-icon>send</mat-icon>
    </button>
  </form>
</ng-container>

<ng-container *ngFor="let todo of todos$ | async">
  <div>
    {{ todo.name }}
  </div>
</ng-container>

21行目:フォームがエラーのときボタンをクリックできないようにする判定処理は!form.validがTrueのときフォームにdisabledの属性が有効となる処理を記述して制御しています。

フォーム周りはいろいろな機能があり詳しく知りたい方は下記を参考ください。

https://angular.io/guide/reactive-forms#grouping-form-controls

https://material.angular.io/components/form-field/overview

https://material.angular.io/components/input/overview

▼実装きたらローカルサーバーを起動して、動作確認してみましょう。

下記のようにTODOデータの追加、表示ができるようになります。

また、Firestoreにもデータが格納されているか確認しておきましょう。

TODOデータをDrag And Dropできるように実装

Angular Materialは見た目の部分だけではなく、UI以外の、ふるまいの機能だけ利用することもできます。

今回はDrag And Dropの機能を使いTODOの移動などできるようにし、UIはCSSでTrello風な画面にしてみます。

▼はじめに、TODOデータを表示する役割をTodoItemComponentに移譲させます。

親コンポーネントがTodoComponent,子コンポーネントがTodoItemComponentという関係になります。

親コンポーネントから子コンポーネントにデータを渡せるように実装していきます。

@Inputが子コンポーネントが親コンポーネントからデータを受け取る設定です。

@Outputが子コンポーネントから親コンポーネントにデータを渡して関数を実行するための設定です。

今回は子コンポーネントの削除ボタンがクリックされると親コンポーネントにあるdeleteTodoが実行されるようにします。

src/app/todo/todo-item.component.ts

import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
import { Todo } from 'src/app/interfaces/todo';

@Component({
  selector: 'app-todo-item',
  templateUrl: './todo-item.component.html',
  styleUrls: ['./todo-item.component.scss'],
})
export class TodoItemComponent implements OnInit {
  @Input() todo!: Todo;
  @Output() deleteTodo = new EventEmitter<string>();
  constructor() {}

  ngOnInit(): void {}
  remove(id: string) {
    this.deleteTodo.emit(id);
  }
}

src/app/todo/todo-item.component.html

<ng-container *ngIf="todo">
  <div>
    {{ todo.name }}
    <button (click)="remove(todo.id)" mat-icon-button>
      <mat-icon>clear</mat-icon>
    </button>
  </div>
</ng-container>

▼次はtodo.component.tsにDropしたときにデータを更新する処理、TODOデータを削除する処理、listによって表示を分ける処理など追加します。

src/app/todo/todo.component.ts

import { Component, OnInit } from '@angular/core';
import { tap } from 'rxjs/operators';
import { TodoService } from 'src/app/services/todo.service';
import { AuthService } from 'src/app/services/auth.service';
import { FormBuilder, Validators } from '@angular/forms';
import { Todo } from 'src/app/interfaces/todo';
import { CdkDragDrop } from '@angular/cdk/drag-drop';

@Component({
  selector: 'app-todo',
  templateUrl: './todo.component.html',
  styleUrls: ['./todo.component.scss'],
})
export class TodoComponent implements OnInit {
  todos$ = this.todo.getTodos().pipe(tap((data) => console.log(data)));
  user$ = this.auth.user$;
  lists = ['todo', 'doing', 'done'];
  form = this.fb.group({
    name: ['', [Validators.required, Validators.maxLength(10)]],
  });
  constructor(
    private todo: TodoService,
    private auth: AuthService,
    private fb: FormBuilder
  ) {}

  ngOnInit(): void {}
  createTodo(uid: string) {
    const name = this.form.controls.name.value;
    this.todo.createTodo(name, uid);
    this.form.reset();
    this.form.controls.name.setErrors({ required: null });
  }

  deleteTodo(id: string) {
    this.todo.deleteTodo(id);
  }

  filterTodo(filterList: string, todos: Todo[]): Todo[] {
    if (!todos) {
      return [];
    }
    return todos.filter((todo) => todo.list === filterList);
  }

  drop(event: CdkDragDrop<Todo[]>) {
    console.log(event);
    if (event.previousContainer !== event.container) {
      const list = event.container.id;
      const todo = event.item.data;
      todo.list = list;
      this.todo.putTodo(todo);
    }
  }
}

▼テンプレート側todo.component.htmlの実装をしていきます。

Drag And Dropは要素にcdkDragを追加するだけて、要素を自由に動かせるようになります。

src/app/todo/todo.component.html

<ng-container *ngIf="user$ | async as user">
  <form class="todoForm" [formGroup]="form" (ngSubmit)="createTodo(user!.uid)">
    <mat-form-field hintLabel="最大文字数">
      <mat-label>TODO追加</mat-label>
      <input
        matInput
        type="text"
        formControlName="name"
        autocomplete="off"
        #input
      />
      <!-- バリデーションエラー -->
      <mat-error *ngIf="form.controls.name.hasError('maxlength')">
        文字数オーバー
      </mat-error>
      <mat-error *ngIf="form.controls.name.hasError('required')">
        入力してください
      </mat-error>
      <mat-hint align="end"> {{ input.value?.length || 0 }}/10 </mat-hint>
    </mat-form-field>
    <button mat-icon-button [disabled]="!form.valid">
      <mat-icon>send</mat-icon>
    </button>
  </form>
</ng-container>

<div class="todolist" cdkDropListGroup>
  <ng-container *ngFor="let list of lists">
    <div class="todolist__container mat-elevation-z2">
      <div class="todolist__label">{{ list }}</div>
      <div
        id="{{ list }}"
        class="todolist__box"
        cdkDropList
        [cdkDropListData]="filterTodo(list, (todos$ | async)!)"
        (cdkDropListDropped)="drop($event)"
      >
        <app-todo-item
          class="todolist__item mat-elevation-z1"
          *ngFor="let todo of filterTodo(list, (todos$ | async)!)"
          cdkDrag
          [cdkDragData]="todo"
          [todo]="todo"
          (deleteTodo)="deleteTodo($event)"
        ></app-todo-item>
      </div>
    </div>
  </ng-container>
</div>

▼Trello風な見た目にするためにCSSを記述します

src/app/todo/todo.component.scss

.todolist {
  display: flex;
  margin-left: 10px;
  &__container {
    background-color: #ebecf0;
    border-radius: 3px;
    width: 300px;
    max-width: 100%;
    margin: 10px;
    display: inline-block;
    vertical-align: top;
  }
  &__label {
    width: 100%;
    font-size: 20px;
    font-weight: bold;
    margin: 10px 0px 0px 10px;
  }
  &__box {
    min-height: 60px;
    overflow: hidden;
    display: block;
  }
  &__item {
    padding: 10px;
    margin: 10px;
    display: flex;
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
    box-sizing: border-box;
    cursor: move;
    background: white;
    border-radius: 3px;
  }
}
.todoForm {
  margin: 20px;
}

.cdk-drag-preview {
  box-sizing: border-box;
  border-radius: 4px;
  box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
    0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12);
}

.cdk-drag-placeholder {
  opacity: 0;
}

.cdk-drag-animating {
  transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}

.todolist__item:last-child {
  border: none;
}

.todolist__box.cdk-drop-list-dragging
  .todolist__item:not(.cdk-drag-placeholder) {
  transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}

これで実装は終わりです。

▼ローカルサーバを起動して動作確認してみましょう。

下記のような挙動をするアプリケーションができていれば完成です。

最後にTODOアプリケーションをデプロイ

アプリケーションが完成したらFirebase Hostingにデプロイしてみましょう。

方法はng deployするだけです。

$ ng deploy

デプロイが完了したら、表示されるURLにアクセスしTODOアプリケーションが動くことを確認しましょう。

これでハンズオンは終了です。お疲れ様でした。もし、余裕があれば下記課題にチャレンジしてみてください。

課題

登録したTODOの名前を変更する機能の実装

今の状態だと登録したTODOの名前の修正ができないので、名前修正機能を実装してみましょう。

登録したTODOの並び替え機能の実装

今の状態だと、Drag And Dropしたあと、サーバー側でソートされた状態で表示されてしまいます。

DropしたところにTODOが来るように修正してみましょう。

おわりに

Angular + Firebaseは開発元が近いこともあって、連携も簡単で開発体験はとてもよかったです。準備の段階ではFirebaseはほとんど触れたことがなかったのですがスムーズに実装・準備することができました。

ハンズオンの内容としては雑なところがありますが、Angular + Firebaseを使った開発の雰囲気が伝われば幸いです。