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.
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:
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:
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:
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 arraycdkDropListConnectedTo
- references to the othercdkDropList
s the currentcdkDropList
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 theedit
output we declared originally by calling theeditTask
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 thetodo
list as we did above and we pass thetask
input. This time, however, we also add thecdkDrag
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:
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 thetodo
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:
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:
Although we significantly improved our app's styles, we still have an annoying issue when we move tasks around:
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 totrue
(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!
- Go to Firebase Console.
- Create a new project with the name "KanbanFire".
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:
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."
After that, create a database in test mode:
Finally, select a region:
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.
Under "Your apps", select a "Web app":
Next, register your application and make sure you enable "Firebase Hosting":
After you click "Register app", you can copy your configuration into src/environments/environment.ts
:
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:
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:
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:
- Build your app with its production configuration, applying compile-time optimizations.
- Deploy your app to Firebase Hosting.
- 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.