Building a web application with Angular and Firebase

1. Introduction

Last Updated: 2020-09-11

What you'll build

In this codelab, we'll build a web kanban board with Angular and Firebase! Our final app will have three categories of tasks: backlog, in progress, and completed. We'll be able to create, delete tasks, and transfer them from one category to another using drag and drop.

We'll develop the user interface using Angular and use Firestore as our persistent store. At the end of the codelab we'll deploy the app to Firebase Hosting using the Angular CLI.

b23bd3732d0206b.png

What you'll learn

  • How to use Angular material and the CDK.
  • How to add Firebase integration to your Angular app.
  • How to keep your persistent data in Firestore.
  • How to deploy your app to Firebase Hosting using the Angular CLI with a single command.

What you'll need

This codelab assumes that you have a Google account and a basic understanding of Angular and the Angular CLI.

Let's get started!

2. Creating a new project

First, let's create a new Angular workspace:

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

This step may take a few minutes. Angular CLI creates your project structure and installs all dependencies. When the installation process completes, go to the kanban-fire directory and start Angular CLI's development server:

ng serve

Open http://localhost:4200 and you should see an output similar to:

5ede7bc5b1109bf3.png

In your editor, open src/app/app.component.html and delete its entire content. When you navigate back to http://localhost:4200 you should see a blank page.

3. Adding Material and the CDK

Angular comes with implementing Material Design-compliant user interface components as part of the @angular/material package. One of the dependencies of @angular/material is the Component Development Kit, or the CDK. The CDK provides primitives, such as a11y utilities, drag and drop, and overlay. We distribute the CDK in the @angular/cdk package.

To add material to your app run:

ng add @angular/material

This command asks you to pick a theme, if you want to use the global material typography styles, and if you want to set up the browser animations for Angular Material. Pick "Indigo/Pink" to get the same result as in this codelab, and answer with "Yes" to the last two questions.

The ng add command installs @angular/material, its dependencies, and imports the BrowserAnimationsModule in AppModule. In the next step, we can start using the components this module offers!

First, let's add a toolbar and an icon to the AppComponent. Open app.component.html and add the following markup:

src/app/app.component.html

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

Here, we add a toolbar using the primary color of our Material Design theme and inside of it we use the local_fire_depeartment icon next to the label "Kanban Fire." If you look at your console now, you will see that Angular throws a few errors. To fix them, make sure you add the following imports to 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 { }

Since we use the Angular material toolbar and icon, we need to import the corresponding modules in AppModule.

On the screen you should now see the following:

a39cf8f8428a03bc.png

Not bad with just 4 lines of HTML and two imports!

4. Visualizing tasks

As the next step, let's create a component we can use to visualize the tasks in the kanban board.

Go to the src/app directory and run the following CLI command:

ng generate component task

This command generates the TaskComponent and adds its declaration to the AppModule. Inside the task directory, create a file called task.ts. We'll use this file to define the interface of the tasks in the kanban board. Each task will have an optional id, title, and description fields all of type string:

src/app/task/task.ts

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

Now let's update task.component.ts. We want TaskComponent to accept as an input an object of type Task, and we want it to be able to emit the "edit" outputs:

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

Edit TaskComponent's template! Open task.component.html and replace its content with the following 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>

Notice that we're now getting errors in the console:

'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

In the template above we're using the mat-card component from @angular/material, but we haven't imported its corresponding module in the app. To fix the error from above, we need to import the MatCardModule in AppModule:

src/app/app.module.ts

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

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

Next we'll create a few tasks in the AppComponent and visualize them using the TaskComponent!

In AppComponent define an array called todo and inside of it add two tasks:

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

Now, to the bottom of app.component.html add the following *ngFor directive:

src/app/app.component.html

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

When you open the browser you should see the following:

d96fccd13c63ceb1.png

5. Implementing drag and drop for tasks

We're ready for the fun part now! Let's create three swimlanes for the three different states tasks could be in, and using the Angular CDK, implement a drag-and-drop functionality.

In app.component.html, remove the app-task component with *ngFor directive on top and replace it with:

src/app/app.component.html

<div class="content-wrapper">
  <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>
</div>

There's a lot going on here. Let's look at the individual parts of this snippet step by step. This is the top-level structure of the template:

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>

Here we create a div that wraps all the three swimlanes, with the class name "container-wrapper." Each swimlane has a class name "container" and a title inside of an h2 tag.

Now let's look at the structure of the first swimlane:

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>
...

First, we define the swimlane as a mat-card, which uses the cdkDropList directive. We use a mat-card because of the styles this component provides. The cdkDropList will later let us drop tasks inside of the element. We also set the following two inputs:

  • cdkDropListData - input of the drop list that allows us to specify the data array
  • cdkDropListConnectedTo - references to the other cdkDropLists the current cdkDropList is connected to. Setting this input we specify which other lists we can drop items into

Additionally, we want to handle the drop event using the cdkDropListDropped output. Once the cdkDropList emits this output, we're going to invoke the drop method declared inside AppComponent and pass the current event as an argument.

Notice that we also specify an id to use as an identifier for this container, and a class name so we can style it. Now let's look into the content children of the mat-card. The two elements we have there are:

  • A paragraph, which we use to show the "Empty list" text when there are no items in the todo list
  • The app-task component. Notice that here we're handling the edit output we declared originally by calling the editTask method with the name of the list and the $event object. This will help us replace the edited task from the correct list. Next, we iterate over the todo list as we did above and we pass the task input. This time, however, we also add the cdkDrag directive. It makes the individual tasks draggable.

To make all this work, we need to update the app.module.ts and include an import to the DragDropModule:

src/app/app.module.ts

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

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

We also need to declare the inProgress and done arrays, together with the editTask and drop methods:

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[]>): 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
    );
  }
}

Notice that in the drop method we first check that we're dropping in the same list as the task is coming from. If that's the case, then we immediately return. Otherwise, we transfer the current task to the destination swimlane.

The result should be:

460f86bcd10454cf.png

At this point you should already be able to transfer items between the two lists!

6. Creating new tasks

Now, let's implement a functionality for creating new tasks. For this purpose, let's update the template of 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>

We create a top-level div element around the container-wrapper and add a button with an "add" material icon next to a label "Add Task." We need the extra wrapper to position the button on top of the list of swimlanes, which we'll later place next to each other using flexbox. Since this button uses the material button component, we need to import the corresponding module in the AppModule:

src/app/app.module.ts

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

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

Now, let's implement the functionality for adding tasks in the AppComponent. We'll use a material dialog. In the dialog we'll have a form with two fields: title and description. When the user clicks on the "Add Task" button we'll open the dialog, and when the user submits the form we'll add the newly created task to the todo list.

Let's look at the high-level implementation of this functionality in the 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);
      });
  }
}

We declare a constructor in which we inject the MatDialog class. Inside the newTask we:

  • Open a new dialog using the TaskDialogComponent that we'll define in a little bit.
  • Specify that we want the dialog to have a width of 270px.
  • Pass an empty task to the dialog as data. In TaskDialogComponent we'll be able to get a reference to this data object.
  • We subscribe to the close event and add the task from the result object to the todo array.

To make sure this works, we first need to import the MatDialogModule in the AppModule:

src/app/app.module.ts

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

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

Now let's create the TaskDialogComponent. Navigate to the src/app directory and run:

ng generate component task-dialog

To implement its functionality, first open: src/app/task-dialog/task-dialog.component.html and replace its content with:

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>

In the template above we create a form with two fields for the title and the description. We use the cdkFocusInput directive to automatically focus the title input when the user opens the dialog.

Notice how inside the template we reference the data property of the component. This will be the same data we pass to the open method of the dialog in the AppComponent. To update the title and the description of the task when the user changes the content of the corresponding fields we use two-way data binding with ngModel.

When the user clicks the OK button, we automatically return the result { task: data.task }, which is the task that we mutated using the form fields in the template above.

Now, let's implement the controller of the component:

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

In the TaskDialogComponent we inject a reference to the dialog, so we can close it, and we also inject the value of the provider associated with the MAT_DIALOG_DATA token. This is the data object that we passed to the open method in the AppComponent above. We also declare the private property backupTask, which is a copy of the task we passed together with the data object.

When the user presses the cancel button, we restore the possibly changed properties of this.data.task and we close the dialog, passing this.data as the result.

There are two types that we referenced but didn't declare yet - TaskDialogData and TaskDialogResult. Inside src/app/task-dialog/task-dialog.component.ts add the following declarations to the bottom of the file:

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

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

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

The final thing we need to do before having the functionality ready is to import a few modules in the 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 { }

When you click the "Add Task" button now, you should see the following user interface:

33bcb987fade2a87.png

7. Improving the app's styles

To make the application more visually appealing, we'll improve its layout by tweaking its styles a little. We want to position the swimlanes next to each other. We also want some minor adjustments of the "Add Task" button and the empty list label.

Open src/app/app.component.css and add the following styles to the bottom:

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

In the snippet above, we adjust the layout of the toolbar and its label. We also ensure that the content is horizontally aligned by setting its width to 1400px and its margin to auto. Next, by using flexbox, we put the swimlanes next to each other, and finally make some adjustments in how we visualize tasks and empty lists.

Once your app reloads, you should see the following user interface:

69225f0b1aa5cb50.png

Although we significantly improved our app's styles, we still have an annoying issue when we move tasks around:

f9aae712027624af.png

When we start dragging the "Buy milk" task, we see two cards for the same task - the one we're dragging and the one in the swimlane. The Angular CDK provides us with CSS class names we can use to fix this problem.

Add the following style overrides to the bottom of src/app/app.component.css:

src/app/app.component.css

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

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

While we drag an element, the Angular CDK's drag and drop clones it and inserts it into the position where we're going to drop the original. To make sure this element is not visible we set the opacity property in the cdk-drag-placeholder class, which the CDK is going to add to the placeholder.

Additionally, when we drop an element, the CDK adds the cdk-drag-animating class. To show a smooth animation instead of directly snapping the element, we define a transition with duration 250ms.

We also want to make some minor adjustments of the styles of our tasks. In task.component.css let's set the host element's display to block and set some margins:

src/app/task/task.component.css

:host {
  display: block;
}

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

8. Editing and deleting existing tasks

To edit and remove existing tasks, we'll reuse most of the functionality we already implemented! When the user double-clicks a task we'll open the TaskDialogComponent and populate the two fields in the form with the task's title and description.

To the TaskDialogComponent we'll also add a delete button. When the user clicks on it, we'll pass a delete instruction, which will end up in the AppComponent.

The only change we need to make in TaskDialogComponent is in its template:

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>

This button shows the delete material icon. When the user clicks on it, we'll close the dialog and pass the object literal { task: data.task, delete: true } as a result. Also notice that we make the button circular using mat-fab, set its color to be primary, and show it only when the dialog data has deletion enabled.

The rest of the implementation of the edit and delete functionality is in the AppComponent. Replace its editTask method with the following:

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

Let's look at the arguments of the editTask method:

  • A list of type 'done' | 'todo' | 'inProgress', which is a string literal union type with values corresponding to the properties associated with the individual swimlanes.
  • The current task we want to edit.

In the method's body we first open an instance of the TaskDialogComponent. As its data we pass an object literal, which specifies the task we want to edit, and also enables the edit button in the form by setting the enableDelete property to true.

When we get the result from the dialog we handle two scenarios:

  • When the delete flag is set to true (i.e., when the user has pressed the delete button), we remove the task from the corresponding list.
  • Alternatively, we just replace the task on the given index with the task we got from the dialog result.

9. Creating a new Firebase project

Now, let's create a new Firebase project!

10. Adding Firebase to the project

In this section we'll integrate our project with Firebase! The Firebase team offers the package @angular/fire, which provides integration between the two technologies. To add Firebase support to your app open your workspace's root directory and run:

ng add @angular/fire

This command installs the @angular/fire package and asks you a few questions. In your terminal, you should see something like:

9ba88c0d52d18d0.png

In the meantime, the installation opens a browser window so you can authenticate with your Firebase account. Finally, it asks you to choose a Firebase project and creates some files on your disk.

Next, we need to create a Firestore database! Under "Cloud Firestore" click "Create Database."

1e4a08b5a2462956.png

After that, create a database in test mode:

ac1181b2c32049f9.png

Finally, select a region:

34bb94cc542a0597.png

The only thing left now is to add the Firebase configuration to your environment. You can find your project configuration in the Firebase Console.

  • Click the Gear icon next to Project Overview.
  • Choose Project Settings.

c8253a20031de8a9.png

Under "Your apps", select a "Web app":

428a1abcd0f90b23.png

Next, register your application and make sure you enable "Firebase Hosting":

586e44cb27dd8f39.png

After you click "Register app", you can copy your configuration into src/environments/environment.ts:

e30f142d79cecf8f.png

At the end, your configuration file should look like this:

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. Moving the data to Firestore

Now that we've set up the Firebase SDK, let's use @angular/fire to move our data to the Firestore! First, let's import the modules we need in 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 {}

Since we'll be using Firestore, we need to inject AngularFirestore in AppComponent's constructor:

src/app/app.component.ts

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

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

Next, we update the way we initialize the swimlane arrays:

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[]>;
  ...
}

Here we use the AngularFirestore to get the collection's content directly from the database. Notice that valueChanges returns an observable instead of an array, and also that we specify that the id field for the documents in this collection should be called id to match the name we use in the Task interface. The observable returned by valueChanges emits a collection of tasks any time it changes.

Since we are working with observables instead of arrays, we need to update the way we add, remove, and edit tasks, and the functionality for moving tasks between swimlanes. Instead of mutating our in-memory arrays, we'll use the Firebase SDK to update the data in the database.

First, let's look at how reordering would look. Replace the drop method in src/app/app.component.ts with:

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

In the snippet above, the new code is highlighted. To move a task from the current swimlane to the target one, we're going to remove the task from the first collection and add it to the second one. Since we perform two operations that we want to look like one (i.e., make the operation atomic), we run them in a Firestore transaction.

Next, let's update the editTask method to use Firestore! Inside of the close dialog handler, we need to change the following lines of code:

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

We access the target document corresponding to the task we manipulate using the Firestore SDK and delete or update it.

Finally, we need to update the method for creating new tasks. Replace this.todo.push('task') with: this.store.collection('todo').add(result.task).

Notice that now our collections are not arrays, but observables. To be able to visualize them we need to update the template of the AppComponent. Just replace every access of the todo, inProgress, and done properties with todo | async, inProgress | async, and done | async respectively.

The async pipe automatically subscribes to the observables associated with the collections. When the observables emit a new value, Angular automatically runs change detection and processes the emitted array.

For example, let's look into the changes we need to make in the todo swimlane:

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>

When we pass the data to the cdkDropList directive we apply the async pipe. It's the same inside of the *ngIf directive, but note that there we also use optional chaining (also known as safe navigation operator in Angular), when accessing the length property to ensure we don't get a runtime error if todo | async is not null or undefined.

Now when you create a new task in the user interface and open Firestore, you should see something like this:

dd7ee20c0a10ebe2.png

12. Improving optimistic updates

In the application we're currently performing optimistic updates. We have our source of truth in Firestore, but at the same time we have local copies of the tasks; when any of the observables associated with the collections emit, we get an array of tasks. When a user action mutates the state, we first update the local values and then propagate the change to Firestore.

When we move a task from one swimlane to another, we invoke transferArrayItem, which operates on local instances of the arrays representing the tasks in each swimlane. The Firebase SDK treats these arrays as immutable, meaning that the next time Angular runs change detection we'll get new instances of them, which will render the previous state before we've transferred the task.

At the same time, we trigger a Firestore update and the Firebase SDK triggers an update with the correct values, so in a few milliseconds the user interface will get to its correct state. This makes the task we just transferred jump from the first list to the next one. You can see this well on the GIF below:

70b946eebfa6f316.gif

The right way to solve this problem varies from application to application, but in all cases we need to ensure that we maintain a consistent state until our data updates.

We can take advantage of BehaviorSubject, which wraps the original observer we receive from valueChanges. Under the hood, BehaviorSubject keeps a mutable array that persists the update from transferArrayItem.

To implement a fix, all we need to do is update the 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[]>;
...
}

All we do in the snippet above is to create a BehaviorSubject, which emits a value every time the observable associated with the collection changes.

Everything works as expected, because the BehaviorSubject reuses the array across change detection invocations and only updates when we get a new value from Firestore.

13. Deploying the application

All we need to do to deploy our app is run:

ng deploy

This command will:

  1. Build your app with its production configuration, applying compile-time optimizations.
  2. Deploy your app to Firebase Hosting.
  3. Output a URL so you can preview the result.

14. Congratulations

Congratulations, you've successfully built a kanban board with Angular and Firebase!

You created a user interface with three columns representing the status of different tasks. Using the Angular CDK, you implemented drag and drop of tasks across the columns. Then, using Angular material, you built a form for creating new tasks and editing existing ones. Next, you learned how to use @angular/fire and moved all the application state to Firestore. Finally, you deployed your application to Firebase Hosting.

What's next?

Remember that we deployed the application using test configurations. Before deploying your app to production make sure you set up the correct permissions. You can learn how to do this here.

Currently, we don't preserve the order of the individual tasks in a particular swimlane. To implement this, you can use an order field in the task document and sort based on it.

Additionally, we built the kanban board for only a single user, which means that we have a single kanban board for anyone who opens the app. To implement separate boards for different users of your app, you will need to change your database structure. Learn about Firestore's best practices here.