Angular と Firebase を使用してウェブ アプリケーションを作成する

1. はじめに

最終更新日: 2020 年 9 月 11 日

作成するアプリの概要

この Codelab では、Angular と Firebase を使用してウェブカンバンを作成します。完成したアプリには、バックログ、進行中、完了済みという 3 つのカテゴリがあります。タスクの作成、削除、ドラッグ&ドロップによるカテゴリ間での移動を行うことができます。

Angular はユーザー インターフェースの開発に使用し、Firestore は永続ストアとして使用します。Codelab の最後では、Angular CLI を使用してアプリを Firebase Hosting にデプロイします。

b23bd3732d0206b.png

学習する内容

  • Angular マテリアルと CDK を使用する方法
  • Angular アプリに Firebase 統合を追加する方法
  • Firestore で永続データを保持する方法
  • 単一のコマンドで Angular CLI を使用し、アプリを Firebase Hosting にデプロイする方法

必要なもの

この Codelab は、Google アカウントを持ち、Angular と Angular CLI の基本的な知識があることを前提としています。

では始めましょう。

2. 新しいプロジェクトを作成する

まず、新しい Angular ワークスペースを作成します。

ng new kanban-fire
? Would you like to add Angular routing? No
? Which stylesheet format would you like to use? CSS

このステップは数分かかることがあります。Angular CLI は、プロジェクト構造を作成し、すべての依存関係をインストールします。インストール プロセスが完了したら、kanban-fire ディレクトリに移動し、Angular CLI の開発サーバーを起動します。

ng serve

http://localhost:4200 を開くと、次のような出力が表示されます。

5ede7bc5b1109bf3.png

エディタで src/app/app.component.html を開き、その内容をすべて削除します。http://localhost:4200 に戻ると、空白ページが表示されます。

3. マテリアルと CDK を追加する

Angular には、@angular/material パッケージの一部として、マテリアル デザインに対応したユーザー インターフェース コンポーネントの実装が付属しています。@angular/material の依存関係の一つに、コンポーネント開発キット(CDK)があります。CDK は、ユーザー補助ユーティリティ、ドラッグ&ドロップ、オーバーレイなどのプリミティブを提供します。Google は @angular/cdk パッケージで CDK を配布しています。

アプリにマテリアルを追加するには、次のコマンドを実行します。

ng add @angular/material

このコマンドは、グローバルなマテリアル タイポグラフィ スタイルを使用したい場合と Angular マテリアルにブラウザ アニメーションを設定したい場合に、テーマを選択するよう求めます。この Codelab と同じ結果を得るために「Indigo/Pink」を選択し、最後の 2 つの質問に「Yes」と回答してください。

ng add コマンドにより、@angular/material とその依存関係がインストールされ、AppModuleBrowserAnimationsModule がインポートされます。次のステップとして、このモジュールが提供するコンポーネントの使用を開始できます。

まず、ツールバーとアイコンを AppComponent に追加しましょう。app.component.html を開き、次のマークアップを追加します。

src/app/app.component.html

<mat-toolbar color="primary">
  <mat-icon>local_fire_department</mat-icon>
  <span>Kanban Fire</span>
</mat-toolbar>

ここでは、マテリアル デザイン テーマのメインカラーを使用したツールバーを追加し、その中の「Kanban Fire」というラベルの横で local_fire_depeartment アイコンを使用しています。コンソールを見ると、Angular がいくつかのエラーをスローしていることがわかります。これらを修正するには、AppModule に次のインポートを追加します。

src/app/app.module.ts

...
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatIconModule } from '@angular/material/icon';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    MatToolbarModule,
    MatIconModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Angular マテリアルのツールバーとアイコンを使用するので、AppModule で対応するモジュールをインポートする必要があります。

次のような画面が表示されます。

a39cf8f8428a03bc.png

たった 4 行の HTML と 2 つのインポートにしては、悪くない結果です。

4. タスクを視覚化する

次のステップとして、カンバンボードでタスクを視覚化するために使用できるコンポーネントを作成しましょう。

src/app ディレクトリに移動して、次の CLI コマンドを実行します。

ng generate component task

このコマンドによって、TaskComponent が生成され、その宣言が AppModule に追加されます。task ディレクトリ内に task.ts という名前のファイルを作成します。このファイルを使用して、カンバンボードにタスクのインターフェースを定義します。各タスクには、オプションの idtitle、および description フィールド(すべて文字列型)があります。

src/app/task/task.ts

export interface Task {
  id?: string;
  title: string;
  description: string;
}

ここで、task.component.ts を更新しましょう。TaskComponentTask 型のオブジェクトを入力として受け取り、それを「edit」の出力として生成するようにします。

src/app/task/task.component.ts

import { Component, Input, Output, EventEmitter } from '@angular/core';
import { Task } from './task';

@Component({
  selector: 'app-task',
  templateUrl: './task.component.html',
  styleUrls: ['./task.component.css']
})
export class TaskComponent {
  @Input() task: Task | null = null;
  @Output() edit = new EventEmitter<Task>();
}

TaskComponent のテンプレートを編集します。task.component.html を開き、その内容を次の HTML で置き換えます。

src/app/task/task.component.html

<mat-card class="item" *ngIf="task" (dblclick)="edit.emit(task)">
  <h2>{{ task.title }}</h2>
  <p>
    {{ task.description }}
  </p>
</mat-card>

コンソールにエラーが表示されることに注意してください。

'mat-card' is not a known element:
1. If 'mat-card' is an Angular component, then verify that it is part of this module.
2. If 'mat-card' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@NgModule.schemas' of this component to suppress this message.ng

上記のテンプレートでは、@angular/material からの mat-card コンポーネントを使用していますが、対応するモジュールをアプリにインポートしていません。上記のエラーを修正するには、AppModuleMatCardModule をインポートする必要があります。

src/app/app.module.ts

...
import { MatCardModule } from '@angular/material/card';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    MatCardModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

次に、AppComponent にいくつかのタスクを作成し、TaskComponent を使用してそれらを視覚化します。

AppComponent で、todo という名前の配列を定義し、その中に次の 2 つのタスクを追加します。

src/app/app.component.ts

...
import { Task } from './task/task';

@Component(...)
export class AppComponent {
  todo: Task[] = [
    {
      title: 'Buy milk',
      description: 'Go to the store and buy milk'
    },
    {
      title: 'Create a Kanban app',
      description: 'Using Firebase and Angular create a Kanban app!'
    }
  ];
}

ここで、app.component.html の末尾に次の *ngFor ディレクティブを追加します。

src/app/app.component.html

<app-task *ngFor="let task of todo" [task]="task"></app-task>

ブラウザを開くと、次のような画面が表示されます。

d96fccd13c63ceb1.png

5. タスクのドラッグ&ドロップを実装する

いよいよ興味深いパートに入ります。タスクが取りうる 3 種類の状態を表す 3 つのスイムレーンを作成し、Angular CDK を使用してドラッグ&ドロップ機能を実装します。

app.component.html で、*ngFor ディレクティブを含む一番上の app-task コンポーネントを削除し、次のコードで置き換えます。

src/app/app.component.html

<div class="container-wrapper">
  <div class="container">
    <h2>Backlog</h2>

    <mat-card
      cdkDropList
      id="todo"
      #todoList="cdkDropList"
      [cdkDropListData]="todo"
      [cdkDropListConnectedTo]="[doneList, inProgressList]"
      (cdkDropListDropped)="drop($event)"
      class="list">
      <p class="empty-label" *ngIf="todo.length === 0">Empty list</p>
      <app-task (edit)="editTask('todo', $event)" *ngFor="let task of todo" cdkDrag [task]="task"></app-task>
    </mat-card>
  </div>

  <div class="container">
    <h2>In progress</h2>

    <mat-card
      cdkDropList
      id="inProgress"
      #inProgressList="cdkDropList"
      [cdkDropListData]="inProgress"
      [cdkDropListConnectedTo]="[todoList, doneList]"
      (cdkDropListDropped)="drop($event)"
      class="list">
      <p class="empty-label" *ngIf="inProgress.length === 0">Empty list</p>
      <app-task (edit)="editTask('inProgress', $event)" *ngFor="let task of inProgress" cdkDrag [task]="task"></app-task>
    </mat-card>
  </div>

  <div class="container">
    <h2>Done</h2>

    <mat-card
      cdkDropList
      id="done"
      #doneList="cdkDropList"
      [cdkDropListData]="done"
      [cdkDropListConnectedTo]="[todoList, inProgressList]"
      (cdkDropListDropped)="drop($event)"
      class="list">
      <p class="empty-label" *ngIf="done.length === 0">Empty list</p>
      <app-task (edit)="editTask('done', $event)" *ngFor="let task of done" cdkDrag [task]="task"></app-task>
    </mat-card>
  </div>
</div>

ここでは多くのことが行われています。このスニペットの個々の部分を順に見ていきましょう。テンプレートの最上位の構造は次のとおりです。

src/app/app.component.html

...
<div class="container-wrapper">
  <div class="container">
    <h2>Backlog</h2>
    ...
  </div>

  <div class="container">
    <h2>In progress</h2>
    ...
  </div>

  <div class="container">
    <h2>Done</h2>
    ...
  </div>
</div>

ここでは、「container-wrapper」という名前のクラスを使用して、3 つのスイムレーンをすべてラップする div を作成しています。各スイムレーンのクラス名は「container」で、h2 タグ内にタイトルが含まれています。

それでは、1 つ目のスイムレーンの構造を見てみましょう。

src/app/app.component.html

...
    <div class="container">
      <h2>Backlog</h2>

      <mat-card
        cdkDropList
        id="todo"
        #todoList="cdkDropList"
        [cdkDropListData]="todo"
        [cdkDropListConnectedTo]="[doneList, inProgressList]"
        (cdkDropListDropped)="drop($event)"
        class="list"
      >
        <p class="empty-label" *ngIf="todo.length === 0">Empty list</p>
        <app-task (edit)="editTask('todo', $event)" *ngFor="let task of todo" cdkDrag [task]="task"></app-task>
      </mat-card>
    </div>
...

まず、スイムレーンを mat-card として定義します。これは cdkDropList ディレクティブを使用します。mat-card を使用する理由は、このコンポーネントが提供するスタイルにあります。cdkDropList は、後で要素内にタスクをドロップするために使用できます。次の 2 つの入力も設定します。

  • cdkDropListData - データ配列を指定できるドロップリストの入力
  • cdkDropListConnectedTo - 現在の cdkDropList の接続先である他の cdkDropList への参照。この入力を設定することにより、アイテムをドロップできる他のリストを指定します

さらに、cdkDropListDropped 出力を使用してドロップ イベントを処理します。cdkDropList がこの出力を生成したら、AppComponent 内で宣言されている drop メソッドを呼び出し、現在のイベントを引数として渡します。

このコンテナの識別子として使用する id と、スタイルを設定するための class 名も指定している点に注意してください。次に、mat-card の子のコンテンツを見てみましょう。次の 2 つの要素があります。

  • todo リストにアイテムがないときに「Empty list」というテキストを表示するために使用する段落。
  • app-task コンポーネント。リスト名と $event オブジェクトを使用して editTask メソッドを呼び出すことにより、最初に宣言した edit 出力を処理している点に注意してください。これは、正しいリストからの編集済みタスクを置き換えるのに役立ちます。次に、上記の処理と同様に todo リストを反復処理し、task 入力を渡します。ただし、今回は cdkDrag ディレクティブも追加します。これにより、個々のタスクがドラッグ可能になります。

これらをすべて機能させるには、app.module.ts を更新し、DragDropModule のインポートを含める必要があります。

src/app/app.module.ts

...
import { DragDropModule } from '@angular/cdk/drag-drop';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    DragDropModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

また、inProgress 配列と done 配列を、editTask および drop メソッドとともに宣言する必要があります。

src/app/app.component.ts

...
import { CdkDragDrop, transferArrayItem } from '@angular/cdk/drag-drop';

@Component(...)
export class AppComponent {
  todo: Task[] = [...];
  inProgress: Task[] = [];
  done: Task[] = [];

  editTask(list: string, task: Task): void {}

  drop(event: CdkDragDrop<Task[]|null>): void {
    if (event.previousContainer === event.container) {
      return;
    }
    if (!event.container.data || !event.previousContainer.data) {
      return;
    }
    transferArrayItem(
      event.previousContainer.data,
      event.container.data,
      event.previousIndex,
      event.currentIndex
    );
  }
}

drop メソッドで、タスクの移動元と同じリストにドロップしているかどうかを最初にチェックしている点に注意してください。ドロップしている場合は、何もせずに返します。ドロップしていない場合は、現在のタスクを目的のスイムレーンに移動します。

結果は次のようになります。

460f86bcd10454cf.png

以上により、2 つのリスト間でアイテムを移動できるようになりました。

6 新しいタスクを作成する

次に、新しいタスクを作成する機能を実装しましょう。そのためには、AppComponent のテンプレートを更新します。

src/app/app.component.html

<mat-toolbar color="primary">
...
</mat-toolbar>

<div class="content-wrapper">
  <button (click)="newTask()" mat-button>
    <mat-icon>add</mat-icon> Add Task
  </button>

  <div class="container-wrapper">
    <div class="container">
      ...
    </div>
</div>

container-wrapper を囲む最上位の div 要素を作成し、「Add Task」というラベルの横に「add」マテリアル アイコンを備えたボタンを追加します。スイムレーンのリストの上に、ボタンを配置する追加のラッパーが必要です。スイムレーンは、後で Flexbox を使用して隣り合わせに配置します。このボタンはマテリアル ボタン コンポーネントを使用するので、AppModule で対応するモジュールをインポートする必要があります。

src/app/app.module.ts

...
import { MatButtonModule } from '@angular/material/button';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    MatButtonModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

次に、AppComponent にタスクを追加する機能を実装しましょう。マテリアル ダイアログを使用します。このダイアログには、タイトルと説明の 2 つのフィールドを含むフォームがあります。ユーザーが [Add Task] ボタンをクリックしたときは、ダイアログを開きます。ユーザーがフォームを送信したときは、新しく作成されたタスクを todo リストに追加します。

この機能を AppComponent に実装する方法の概要を見てみましょう。

src/app/app.component.ts

...
import { MatDialog } from '@angular/material/dialog';

@Component(...)
export class AppComponent {
  ...

  constructor(private dialog: MatDialog) {}

  newTask(): void {
    const dialogRef = this.dialog.open(TaskDialogComponent, {
      width: '270px',
      data: {
        task: {},
      },
    });
    dialogRef
      .afterClosed()
      .subscribe((result: TaskDialogResult|undefined) => {
        if (!result) {
          return;
        }
        this.todo.push(result.task);
      });
  }
}

MatDialog クラスを注入するコンストラクタを宣言します。newTask 内で、次の処理を行います。

  • 少し後で定義する TaskDialogComponent を使用して新しいダイアログを開きます。
  • ダイアログの幅を 270px. に指定します。
  • 空のタスクをデータとしてダイアログに渡します。TaskDialogComponent 内で、このデータ オブジェクトへの参照を取得できます。
  • 「閉じる」イベントをサブスクライブ登録し、result オブジェクトから取得したタスクを todo 配列に追加します。

これを機能させるには、まず AppModuleMatDialogModule をインポートする必要があります。

src/app/app.module.ts

...
import { MatDialogModule } from '@angular/material/dialog';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    MatDialogModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

それでは、TaskDialogComponent を作成しましょう。src/app ディレクトリに移動して、次のコマンドを実行します。

ng generate component task-dialog

この機能を実装するには、まず src/app/task-dialog/task-dialog.component.html を開き、次の内容で置き換えます。

src/app/task-dialog/task-dialog.component.html

<mat-form-field>
  <mat-label>Title</mat-label>
  <input matInput cdkFocusInitial [(ngModel)]="data.task.title" />
</mat-form-field>

<mat-form-field>
  <mat-label>Description</mat-label>
  <textarea matInput [(ngModel)]="data.task.description"></textarea>
</mat-form-field>

<div mat-dialog-actions>
  <button mat-button [mat-dialog-close]="{ task: data.task }">OK</button>
  <button mat-button (click)="cancel()">Cancel</button>
</div>

上記のテンプレートで、titledescription の 2 つのフィールドを含むフォームを作成します。cdkFocusInput ディレクティブを使用して、ユーザーがダイアログを開いたときは title 入力を自動的にフォーカスします。

テンプレート内でどのようにコンポーネントの data プロパティを参照しているかに注意してください。これは、AppComponentdialogopen メソッドに渡す data と同じです。対応するフィールドの内容をユーザーが変更したときにタスクのタイトルと説明を更新するには、ngModel との双方向データ バインディングを使用します。

ユーザーが [OK] ボタンをクリックしたときは、{ task: data.task } という結果を自動的に返します。これは、上記のテンプレートのフォーム フィールドを使用して変更したタスクです。

次に、コンポーネントのコントローラを実装しましょう。

src/app/task-dialog/task-dialog.component.ts

import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Task } from '../task/task';

@Component({
  selector: 'app-task-dialog',
  templateUrl: './task-dialog.component.html',
  styleUrls: ['./task-dialog.component.css'],
})
export class TaskDialogComponent {
  private backupTask: Partial<Task> = { ...this.data.task };

  constructor(
    public dialogRef: MatDialogRef<TaskDialogComponent>,
    @Inject(MAT_DIALOG_DATA) public data: TaskDialogData
  ) {}

  cancel(): void {
    this.data.task.title = this.backupTask.title;
    this.data.task.description = this.backupTask.description;
    this.dialogRef.close(this.data);
  }
}

TaskDialogComponent でダイアログへの参照を注入して、ダイアログを閉じることができるようにします。また、MAT_DIALOG_DATA トークンに関連付けられたプロバイダの値も挿入します。これは、上記の AppComponent の open メソッドに渡したデータ オブジェクトです。また、プライベート プロパティ backupTask も宣言します。これは、データ オブジェクトと一緒に渡したタスクのコピーです。

ユーザーが [Cancel] ボタンを押したときは、this.data.task の変更された可能性のあるプロパティを復元してダイアログを閉じ、this.data を結果として渡します。

参照したものの、まだ宣言していない 2 つの型(TaskDialogDataTaskDialogResult)があります。src/app/task-dialog/task-dialog.component.ts 内で、ファイルの末尾に次の宣言を追加します。

src/app/task-dialog/task-dialog.component.ts

...
export interface TaskDialogData {
  task: Partial<Task>;
  enableDelete: boolean;
}

export interface TaskDialogResult {
  task: Task;
  delete?: boolean;
}

機能を準備する前の最後の作業として、AppModule でいくつかのモジュールをインポートする必要があります。

src/app/app.module.ts

...
import { MatInputModule } from '@angular/material/input';
import { FormsModule } from '@angular/forms';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    MatInputModule,
    FormsModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

[Add Task] ボタンをクリックすると、次のユーザー インターフェースが表示されます。

33bcb987fade2a87.png

7. アプリのスタイルを改善する

アプリケーションの外観をより魅力的にするために、スタイルを微調整してレイアウトを改善しましょう。スイムレーンを隣り合わせに配置します。また、[Add Task] ボタンと空のリストのラベルを微調整します。

src/app/app.component.css を開き、一番下に以下のスタイルを追加します。

src/app/app.component.css

mat-toolbar {
  margin-bottom: 20px;
}

mat-toolbar > span {
  margin-left: 10px;
}

.content-wrapper {
  max-width: 1400px;
  margin: auto;
}

.container-wrapper {
  display: flex;
  justify-content: space-around;
}

.container {
  width: 400px;
  margin: 0 25px 25px 0;
}

.list {
  border: solid 1px #ccc;
  min-height: 60px;
  border-radius: 4px;
}

app-new-task {
  margin-bottom: 30px;
}

.empty-label {
  font-size: 2em;
  padding-top: 10px;
  text-align: center;
  opacity: 0.2;
}

上記のスニペットでは、ツールバーとそのラベルのレイアウトを調整しています。また、コンテンツの幅を 1400px、マージンを auto に設定することで、コンテンツが水平方向に配置されるようにしています。次に、Flexbox を使用してスイムレーンを隣り合わせに配置します。最後に、タスクと空のリストを視覚化する方法を調整します。

アプリを再読み込みすると、次のユーザー インターフェースが表示されます。

69225f0b1aa5cb50.png

アプリのスタイルは大幅に改善されましたが、まだ厄介な問題があります。つまり、タスクを移動する際の表示が次のようになります。

f9aae712027624af.png

「Buy milk」タスクのドラッグを開始すると、同じタスクのカードが 2 つ表示されます(ドラッグ中のカードとスイムレーン内のカード)。Angular CDK には、この問題を解決するために使用できる CSS クラスが用意されています。

src/app/app.component.css の末尾に、次のようなスタイルのオーバーライドを追加します。

src/app/app.component.css

.cdk-drag-animating {
  transition: transform 250ms;
}

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

要素のドラッグ中に、Angular CDK をドラッグ&ドロップしてクローンを作成し、元のアイテムをドロップしようとしている位置にそれを挿入します。この要素が表示されないようにするため、CDK がプレースホルダに追加しようとしている cdk-drag-placeholder クラスに不透明度プロパティを設定します。

さらに、ユーザーが要素をドロップする際に、CDK は cdk-drag-animating クラスを追加します。要素を直接スナップする代わりに滑らかなアニメーションを表示するため、時間が 250ms の遷移を定義します。

また、タスクのスタイルにいくつかの微調整を加えます。task.component.css で、ホスト要素の表示を block に設定し、いくらかのマージンを設定します。

src/app/task/task.component.css

:host {
  display: block;
}

.item {
  margin-bottom: 10px;
  cursor: pointer;
}

8. 既存のタスクを編集および削除する

既存のタスクを編集および削除するには、これまでに実装した機能のほとんどを再利用します。ユーザーがタスクをダブルクリックしたときは、TaskDialogComponent を開いて、フォームの 2 つのフィールドにタスクの titledescription を入力します。

また、TaskDialogComponent に削除ボタンを追加します。ユーザーがこのボタンをクリックしたときは、削除手順を渡します。これは最終的に AppComponent に含まれます。

TaskDialogComponent では、テンプレートにのみ変更を加える必要があります。

src/app/task-dialog/task-dialog.component.html

<mat-form-field>
 ...
</mat-form-field>

<div mat-dialog-actions>
  ...
  <button
    *ngIf="data.enableDelete"
    mat-fab
    color="primary"
    aria-label="Delete"
    [mat-dialog-close]="{ task: data.task, delete: true }">
    <mat-icon>delete</mat-icon>
  </button>
</div>

このボタンは、マテリアルの削除アイコンを表示します。ユーザーがこのボタンをクリックしたときは、ダイアログを閉じ、オブジェクト リテラル { task: data.task, delete: true } を結果として渡します。また、mat-fab を使用してボタンを円形にし、色をメインカラーに設定して、ダイアログ データの削除が有効になっている場合にのみ、これを表示します。

編集および削除機能の残りの実装は、AppComponent にあります。editTask メソッドを次のように置き換えてください。

src/app/app.component.ts

@Component({ ... })
export class AppComponent {
  ...
  editTask(list: 'done' | 'todo' | 'inProgress', task: Task): void {
    const dialogRef = this.dialog.open(TaskDialogComponent, {
      width: '270px',
      data: {
        task,
        enableDelete: true,
      },
    });
    dialogRef.afterClosed().subscribe((result: TaskDialogResult|undefined) => {
      if (!result) {
        return;
      }
      const dataList = this[list];
      const taskIndex = dataList.indexOf(task);
      if (result.delete) {
        dataList.splice(taskIndex, 1);
      } else {
        dataList[taskIndex] = task;
      }
    });
  }
  ...
}

editTask メソッドの引数を見てみましょう。

  • 'done' | 'todo' | 'inProgress', のリスト。これは、個々のスイムレーンに関連付けられたプロパティに対応する値を持つ文字列リテラル共用体型です。
  • 編集しようとしている現在のタスク。

メソッドの本文では、最初に TaskDialogComponent のインスタンスを開きます。その data として、編集しようとしているタスクを指定するオブジェクト リテラルを渡します。また、enableDelete プロパティを true に設定して、フォームの編集ボタンを有効にします。

ダイアログから結果を取得したら、次の 2 つのシナリオを処理します。

  • delete フラグが true に設定されたとき(つまり、ユーザーが削除ボタンを押したとき)は、対応するリストからタスクを削除します。
  • または、ただ単に、指定されたインデックス上のタスクを、ダイアログの結果から取得したタスクで置き換えます。

9. 新しい Firebase プロジェクトを作成する

次に、新しい Firebase プロジェクトを作成しましょう。

  • Firebase コンソールに移動します。
  • 「KanbanFire」という名前の新しいプロジェクトを作成します。

10. プロジェクトに Firebase を追加する

このセクションでは、プロジェクトを Firebase と統合します。Firebase チームは、2 つのテクノロジーの統合を可能にする @angular/fire パッケージを提供しています。アプリに Firebase サポートを追加するには、ワークスペースのルート ディレクトリを開いて、次のコマンドを実行します。

ng add @angular/fire

このコマンドを実行すると、@angular/fire パッケージがインストールされ、いくつかの質問が表示されます。ターミナルには次のように表示されます。

9ba88c0d52d18d0.png

その後、インストールによってブラウザ ウィンドウが開き、Firebase アカウントによる認証が可能になります。最後に、Firebase プロジェクトを選択するよう求められ、ディスクにいくつかのファイルが作成されます。

次に、Firestore データベースを作成する必要があります。[Cloud Firestore] で [データベースを作成] をクリックします。

1e4a08b5a2462956.png

その後、テストモードでデータベースを作成します。

ac1181b2c32049f9.png

最後にリージョンを選択します。

34bb94cc542a0597.png

後は、Firebase 構成を環境に追加するだけです。プロジェクト構成は、Firebase コンソールで確認できます。

  • [プロジェクトの概要] の横にある歯車アイコンをクリックします。
  • [プロジェクトの設定] を選択します。

c8253a20031de8a9.png

[アプリ] で [ウェブアプリ] を選択します。

428a1abcd0f90b23.png

次に、アプリケーションを登録して、[Firebase Hosting] が有効になっていることを確認します

586e44cb27dd8f39.png

[アプリの登録] をクリックした後で、構成を src/environments/environment.ts にコピーできます。

e30f142d79cecf8f.png

最終的に、構成ファイルは次のようになります。

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

11. Firestore にデータを移動する

Firebase SDK の設定が完了したので、@angular/fire を使用してデータを Firestore に移動しましょう。まず、AppModule で必要なモジュールをインポートします。

src/app/app.module.ts

...
import { environment } from 'src/environments/environment';
import { AngularFireModule } from '@angular/fire';
import { AngularFirestoreModule } from '@angular/fire/firestore';

@NgModule({
  declarations: [AppComponent, TaskDialogComponent, TaskComponent],
  imports: [
    ...
    AngularFireModule.initializeApp(environment.firebase),
    AngularFirestoreModule
  ],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}

この後 Firestore を使用するので、AppComponent のコンストラクタに AngularFirestore を注入する必要があります。

src/app/app.component.ts

...
import { AngularFirestore } from '@angular/fire/firestore';

@Component({...})
export class AppComponent {
  ...
  constructor(private dialog: MatDialog, private store: AngularFirestore) {}
  ...
}

次に、スイムレーン配列を初期化する方法を更新します。

src/app/app.component.ts

...

@Component({...})
export class AppComponent {
  todo = this.store.collection('todo').valueChanges({ idField: 'id' }) as Observable<Task[]>;
  inProgress = this.store.collection('inProgress').valueChanges({ idField: 'id' }) as Observable<Task[]>;
  done = this.store.collection('done').valueChanges({ idField: 'id' }) as Observable<Task[]>;
  ...
}

ここでは、AngularFirestore を使用して、データベースから直接コレクションの内容を取得しています。valueChanges は配列ではなくオブザーバブルを返す点に注意してください。また、このコレクション内のドキュメントの id フィールドの名前を、Task インターフェースで使用する名前に合わせて id としている点にも注意してください。valueChanges によって返されるオブザーバブルは、変更されるたびにタスクのコレクションを出力します。

配列ではなくオブザーバブルを処理するので、タスクの追加、削除、編集を行う方法と、スイムレーン間でタスクを移動する機能を更新する必要があります。メモリ内配列を変更する代わりに、Firebase SDK を使用してデータベース内のデータを更新します。

まず、並べ替えがどのようになるかを見てみましょう。src/app/app.component.tsdrop メソッドを次のコードで置き換えます。

src/app/app.component.ts

drop(event: CdkDragDrop<Task[]>): void {
  if (event.previousContainer === event.container) {
    return;
  }
  const item = event.previousContainer.data[event.previousIndex];
  this.store.firestore.runTransaction(() => {
    const promise = Promise.all([
      this.store.collection(event.previousContainer.id).doc(item.id).delete(),
      this.store.collection(event.container.id).add(item),
    ]);
    return promise;
  });
  transferArrayItem(
    event.previousContainer.data,
    event.container.data,
    event.previousIndex,
    event.currentIndex
  );
}

上記のスニペットでは、新しいコードはハイライト表示されています。現在のスイムレーンから目的のスイムレーンにタスクを移動するには、最初のコレクションからタスクを削除し、2 つ目のコレクションにそれを追加します。2 つのオペレーションが 1 つに見えるように実行する(つまり、オペレーションをアトミックにする)ため、Firestore トランザクションで実行します。

次に、Firestore を使用するように editTask メソッドを更新します。「閉じる」ダイアログ ハンドラ内で、次のコード行を変更する必要があります。

src/app/app.component.ts

...
dialogRef.afterClosed().subscribe((result: TaskDialogResult|undefined) => {
  if (!result) {
    return;
  }
  if (result.delete) {
    this.store.collection(list).doc(task.id).delete();
  } else {
    this.store.collection(list).doc(task.id).update(task);
  }
});
...

Firestore SDK を使用して操作するタスクに対応するターゲット ドキュメントにアクセスして、削除または更新します。

最後に、新しいタスクを作成するメソッドを更新する必要があります。this.todo.push('task')this.store.collection('todo').add(result.task) で置き換えます。

コレクションが配列ではなくオブザーバブルになっている点に注意してください。それらを視覚化するには、AppComponent のテンプレートを更新する必要があります。そのためには、todoinProgress、および done プロパティの各アクセスを、それぞれ todo | asyncinProgress | asyncdone | async に置き換えるだけです。

非同期パイプは、コレクションに関連付けられたオブザーバブルを自動的にサブスクライブ登録します。オブザーバブルが新しい値を出力すると、Angular は自動的に変更検出を実行し、出力された配列を処理します。

例として、todo スイムレーンで行う必要がある変更を見てみましょう。

src/app/app.component.html

<mat-card
  cdkDropList
  id="todo"
  #todoList="cdkDropList"
  [cdkDropListData]="todo | async"
  [cdkDropListConnectedTo]="[doneList, inProgressList]"
  (cdkDropListDropped)="drop($event)"
  class="list">
  <p class="empty-label" *ngIf="(todo | async)?.length === 0">Empty list</p>
  <app-task (edit)="editTask('todo', $event)" *ngFor="let task of todo | async" cdkDrag [task]="task"></app-task>
</mat-card>

データを cdkDropList ディレクティブに渡す際に、非同期パイプを適用します。*ngIf ディレクティブの内部と同じですが、todo | asyncnull または undefined でない場合にランタイム エラーが発生しないようにするため、length プロパティにアクセスする際にオプションのチェーン(Angular ではセーフ ナビゲーション演算子とも呼ばれます)を使用している点に注意してください。

以上により、ユーザー インターフェースで新しいタスクを作成して Firestore を開くと、次のように表示されます。

dd7ee20c0a10ebe2.png

12. 楽観的更新を改善する

現在、アプリケーションでは楽観的更新を行っています。Firestore には信頼できる情報源が存在しますが、同時にタスクのローカルコピーもあります。したがって、コレクションに関連付けられたオブザーバブルのいずれかが出力されたら、タスクの配列を取得します。ユーザー アクションによって状態が変化したら、最初にローカル値を更新し、次に Firestore に変更を伝播します。

あるスイムレーンから別のスイムレーンにタスクを移動するときは、各スイムレーン内のタスクを表す配列のローカル インスタンスで動作する transferArrayItem, を呼び出します。Firebase SDK は、これらの配列を不変なものとして扱います。つまり、Angular が次に変更検出を実行したら、配列の新しいインスタンスを取得します。これらは、前の状態をレンダリングしてからタスクを移動します。

同時に、Firestore の更新をトリガーします。Firebase SDK は正しい値による更新をトリガーするので、数ミリ秒でユーザー インターフェースが正しい状態になります。これにより、先ほど移動したタスクが最初のリストから次のリストに転送されます。このことは、次の GIF を見るとよくわかります。

70b946eebfa6f316.gif

この問題を解決する適切な方法はアプリケーションによって異なりますが、いずれの場合も、データが更新されるまで一貫した状態を維持する必要があります。

valueChanges から受け取った元のオブザーバーをラップする BehaviorSubject を利用できます。内部的に、BehaviorSubjecttransferArrayItem からの更新を永続的に保持する可変配列を保持します。

修正を実装するには、AppComponent を更新するだけです。

src/app/app.component.ts

...
import { AngularFirestore, AngularFirestoreCollection } from '@angular/fire/firestore';
import { BehaviorSubject } from 'rxjs';

const getObservable = (collection: AngularFirestoreCollection<Task>) => {
  const subject = new BehaviorSubject<Task[]>([]);
  collection.valueChanges({ idField: 'id' }).subscribe((val: Task[]) => {
    subject.next(val);
  });
  return subject;
};

@Component(...)
export class AppComponent {
  todo = getObservable(this.store.collection('todo')) as Observable<Task[]>;
  inProgress = getObservable(this.store.collection('inProgress')) as Observable<Task[]>;
  done = getObservable(this.store.collection('done')) as Observable<Task[]>;
...
}

上記のスニペットでは、コレクションに関連付けられたオブザーバブルが変更されるたびに値を出力する BehaviorSubject の作成のみを行っています。

BehaviorSubject は、変更検出の呼び出し全体で配列を再利用し、Firestore から新しい値を取得する場合にのみ更新されます。そのため、すべてが想定どおりに機能します。

13. アプリケーションをデプロイする

アプリをデプロイするには、次のコマンドを実行するだけです。

ng deploy

このコマンドは次のことを行います。

  1. コンパイル時の最適化を適用して、本番環境構成でアプリをビルドする。
  2. アプリを Firebase Hosting にデプロイする。
  3. 結果をプレビューできるように URL を出力する。

14. 完了

お疲れさまでした。これで、Angular と Firebase を使用してカンバンボードを作成することができました。

この演習では、異なるタスクのステータスを表す 3 つの列を含むユーザー インターフェースを作成しました。また、Angular CDK を使用して、各列の間でのタスクのドラッグ&ドロップを実装しました。次に、Angular マテリアルを使用して、新しいタスクの作成と既存のタスクの編集を行うためのフォームを作成しました。さらに、@angular/fire の使用方法を学び、アプリケーションのすべての状態を Firestore に移動しました。最後に、完成したアプリケーションを Firebase Hosting にデプロイしました。

次のステップ

この演習では、テスト構成を使用してアプリケーションをデプロイしました。アプリを本番環境にデプロイする前に、正しい権限を設定していることを確認する必要があります。そのための手順については、こちらをご覧ください。

現在は、特定のスイムレーン内の各タスクの順序を保持していません。この機能を実装するには、タスク ドキュメントの順序フィールドを使用し、それに基づいて並べ替えを行います。

また、この演習では単一ユーザー専用のカンバンボードを作成しました。つまり、アプリを開いたすべての人に対して同じカンバンボードが表示されます。アプリのユーザーごとに個別のボードを提供する機能を実装するには、データベース構造を変更する必要があります。Firestore のベスト プラクティスについては、こちらをご覧ください。