Creating FullStack Todo App - Angular+Material+FireBase+GithubAction+UnitTest+ESLint
Tags: fullstack, angular, material, cicd
Published: 2022-09-23 15:31:50

## Why another TODO Application

Todo Application don't need any explanation for the Product requirements. So, we can focus on learning about the technologies used for building the application.


## What we are building

I saw this challenge in [FrontendMentor](https://www.frontendmentor.io/challenges/todo-app-Su1_KokOW) for a todo application. I thought to make it a bit more interesting by adding user login and also, store the data on Backend. 

We will use [Angular](https://angular.io/) and [Angular Material](https://material.angular.io/) for the Frontend and [Firebase](https://firebase.google.com/) for storing the data and Authentication.

We will also add CI/CD with Github Actions and host the application on Firebase.

This is what the final version looks like.

![Final Screen1](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fexqjj22x8m5i4lw31ry.png)


![Final Screen2](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hyj92fzy0atiwp4ao0xp.png)


## Angular + Material Setup

Let's install [Angular CLI ](https://angular.io/guide/setup-local) and create the basic skeleton of the app.

`npm install -g @angular/cli`
`ng new angular-firebase-todo`

![setup](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tanm0v9ju6pq8x793j0a.png)

I selected scss as we need to create our own themes. We also need routing for login and home page.

Now, we will setup [Angular Material](https://material.angular.io/guide/getting-started). Angular Material has Angular CLI's installation schematic. We can use that

`ng add @angular/material`

This command will install the necessary packages and add some initial styles.


## Firebase Setup

We will go to Firebase Console and then create a new Project.

![Firebase Console](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/uiaawhqm6uosjcdayzf9.png)

We can add apps ( Android / IOS / We ) to the project. We will add a Web App.

![Firebase Web App](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/r3ok6v05toudy6ew83wp.png)

A firebase config will be generated. We will copy that and put it inside our src/environments file so that, we can use this to initialise Firebase.

For storing the data, we can use FireStore. We will 

For using Firebase in Angular, we can use FireStore. It's a Document based Database. Let's create a collection. A collection is the list of documents. 

Each Todo will be a Document. So, we will create a collection _todos_ and add a document. Each todo will have an id ( auto generated ), title, isCompleted and also we will need a userId field to associate with user.


![Firebase Collection](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/18829xs5t3gbd8hhk0dv.png)
 
We will also enable two providers ( Google and Email/Password ) in Firebase Authentication from the Firebase console.

## Initial Layout

We will need two pages. One for login and one to display the todo items.

We will create the components and add routes with those components.

`ng g c components/home`
`ng g c components/login`

```TS
const routes: Routes = [
  {
    path: 'login',
    component: LoginComponent    
  },
  {
    path: '',
    pathMatch: 'full',
    component: HomeComponent
  }
];

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

```
Now, if we look at the page layout we need to create, we need mainly a Header component, also, an AddTodo component


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wyq7zgrixopnht5dw9p2.png)

Lets create the components
`ng g c components/addTodo`
`ng g c components/header`

_app.component.html_ we will change to have only header and the router-outlet ( which will load the route components )

```TS
<div class="container">
  <app-header></app-header>
  <main>
    <router-outlet></router-outlet>
  </main>
</div>

```

## Authentication
For implementing the Authentication, I followed the documentation on [AngularFire](https://github.com/angular/angularfire/blob/master/docs/auth/getting-started.md)

AngularFireAuth provider has different methods, for signIn, signOut. We can sign in with different providers.

We will add the Google login and Email&Password Login.

We will need an Email and Password field, and to capture value from the inputs and to add validation, we will add FormControl. FormControl is from ReactiveFormsModule. We will import that to app.module.ts.



```TS
import {FormControl, Validators} from '@angular/forms';
import { AngularFireAuth } from '@angular/fire/compat/auth';
import firebase from 'firebase/compat/app';

@Component({
  selector: 'app-login',
  templateUrl: './login.component.html',
  styleUrls: ['./login.component.scss']
})
export class LoginComponent {
   email = new FormControl('', [Validators.required, Validators.email]);
  password = new FormControl('', [Validators.required]);
  constructor(private auth: AngularFireAuth) {
  }

  login() {
    if(this.email.value && this.password.value){
      this.auth.signInWithEmailPassword(this.email.value, this.password.value);
    }
  }

  signup(){
    if(this.email.value && this.password.value){
      this.auth.createUserWithEmailAndPassword(this.email.value, this.password.value);
    }
  }

  loginWithGoogle(){
    this.auth.signInWithPopup(new firebase.auth.GoogleAuthProvider());
    
  }

  getErrorMessage() {
    if (this.email.hasError('required')) {
      return 'You must enter a value';
    }

    return this.email.hasError('email') ? 'Not a valid email' : '';
  }


}
```

```html
 <mat-card>
    <mat-card-content>
        <mat-form-field appearance="fill">
            <mat-label>Enter your email</mat-label>
            <input matInput [formControl]="email" required>
            <mat-error *ngIf="email.invalid">{{getErrorMessage()}}</mat-error>
        </mat-form-field>
        <mat-form-field appearance="fill">
            <mat-label>Enter your Password</mat-label>
            <input matInput type="password" [formControl]="password" required>
            <mat-error *ngIf="password.invalid">
                Password is required
            </mat-error>
        </mat-form-field>
    </mat-card-content>
    <mat-card-actions>
        <button mat-raised-button color="primary" (click)="login()">Login</button>
        <button mat-raised-button color="accent" (click)="signup()">Signup</button>
        <button mat-raised-button color="warn" (click)="loginWithGoogle()">Google Login</button>
    </mat-card-actions>
</mat-card>
```

## Display Todos

We will use [AngularFire](https://github.com/angular/angularfire) for connecting with Firebase. 

`ng add @angular/fire`

If we look at the [QuickStart Guide](https://github.com/angular/angularfire/blob/master/docs/install-and-setup.md), we need to import the AngularFireModule. 

And, we will initialise AngularFireModule with the firebase config which we stored earlier in environment.ts file.

We also need to import AngularFirestoreModule to get the data.

In our home component, we will import AngularFireStore and read from the _todos_ collection.

We can use the _onAuthStateChanged_ method from AngularFireAuth to get the current user. The callback function will be executed whenever user is logged in or logged out. 

```TS
import { AngularFirestore } from '@angular/fire/compat/firestore';

@Component({
  selector: 'app-home',
  templateUrl: './home.component.html',
  styleUrls: ['./home.component.scss']
})
export class HomeComponent {
  items$?: Observable<any[]>;

  constructor(
    private firestore: AngularFirestore
  ) {
this.auth.onAuthStateChanged(user => {
      if(user){
                 this.items$ = firestore.collection('todos', ref => ref.where('userId', '==', user.uid))
                      .valueChanges({idField: 'id'});

      }

});
}
```

By default, Firestore collection wont return the id. So, we can add the idField property so that, id will be returned.
We are adding the **where** condition so that, only the todos of the logged in user are returned.

_valueChanges_ returns an [Observable](https://rxjs.dev/guide/observable).

We can use items$ in home.component.html. To give some basic styling, we will use the [Card](https://material.angular.io/components/card/overview) and [List](https://material.angular.io/components/list/overview) components from Material. 

For Each Todo item, we will add a [radio button](https://material.angular.io/components/radio/overview), so that, we can mark the Todo as completed.

```html
<mat-card> 
  <mat-card-content>
    <mat-list role="list" *ngIf="items$">
      <mat-list-item role="listitem"  *ngFor="let item of items$ | async">                  
            <mat-radio-button>
              {{item.title}}
            </mat-radio-button>          
      </mat-list-item>
    </mat-list>    
  </mat-card-content>   
</mat-card>

```

## Add Todo

For Adding the Todo Item, we just need a text field. To match it with the design provided, we will add a RadioButton also. 
We need to wrap the Input inside a [FormField](https://material.angular.io/components/form-field/overview) to the proper styles and to have Form Control associated to it.

```html
     <mat-card>    
         <mat-card-content>
              <mat-form-field class="full-width-form" appearance="fill">
                 <mat-label></mat-label>
                 <mat-radio-button matPrefix disabled></mat-radio-button>
                 <input type="text" matInput #addTodoInput (keyup.enter)="addTodo(addTodoInput.value)">
             </mat-form-field>
          </mat-card-content>
     </mat-card>
```
I added a reference _addTodoInput_ to the input element so that, we can pass the value of input to the addTodo function.

```TS
  addTodo(inputValue:string){
    if(inputValue && this.user){
      this.todosCollection.add({
        isCompleted: false,
        title: inputValue,
        userId: this.user.uid
      });
    }
    
  }


```
_todosCollection_ is the AngularFireStore collection.

## Filter Todos
Now, we need to filter the todos items based on the _isCompleted_ flag. 

We will create a FilterState enum to represent the different filter states.

```TS
enum FilterState {
  ALL = 'All',
  ACTIVE = 'Active',
  COMPLETED = 'Completed'
}
```

Lets update the template of HomeComponent

```html
<mat-card-footer>
    <span>{{totalActiveTodos}} items left</span>    
    <div class="filter-container">
      <button mat-flat-button [ngClass]="{'active-filter': isFilterActive(FilterState.ALL)}"  (click)="setFilterState(FilterState.ALL)">All</button>
      <button mat-flat-button [ngClass]="{'active-filter': isFilterActive(FilterState.ACTIVE)}" (click)="setFilterState(FilterState.ACTIVE)">Active</button>
      <button mat-flat-button [ngClass]="{'active-filter': isFilterActive(FilterState.COMPLETED)}" (click)="setFilterState(FilterState.COMPLETED)">Completed</button>      
    </div>    
  </mat-card-footer>
```
We are adding the ngClass to dynamically set _active-filter_ class to the button when active.

Now, we have two sources of changes. Whenever FireStore collection value changes, we need to display the updated value. And, whenever the filter state is changed, we need to change the todo items displayed on the page.

We will create a property to store the active filter state, and we will make it as a RxJS Subject so that, it can be combined with FireStore valueChanges observable.

```TS
activeFilter: BehaviorSubject<FilterState> = new BehaviorSubject<FilterState>(FilterState.ALL);

setFilterState(filterState: FilterState){
    this.activeFilter.next(filterState);
}
```

Now, we can combine these two Observables ( activeFilter & items$ ) to create a new Observable. For that, we can use [combineLatest](https://www.learnrxjs.io/learn-rxjs/operators/combination/combinelatest) operator.

```TS
filteredTodos$?: Observable<any[]>;

getTodos(){
     if(!this.items$){
       return;
     }

    this.filteredTodos$ = combineLatest(this.items$, this.activeFilter)
      .pipe(
        map(([todos, currentFilter]: [Todo[], FilterState]) => {
          if(currentFilter === FilterState.ALL){
            return todos;
          }
          return todos.filter((todo: Todo) => {
            const filterCondition = currentFilter === FilterState.COMPLETED ? true : false;
            return todo.isCompleted === filterCondition;
          });
        })
      );
   }
```

Now, when any of the value changes, it will pipe through the [map operator ](https://www.learnrxjs.io/learn-rxjs/operators/transformation/map)and get the updated list of todos.

In home.component.html, we will use _filteredTodos$_ instead of _items$_ for displaying the Todo items.


## Delete Todo/s

We have two delete functionalities. One is to delete a single Todo item. Other is the _Clear Completed_ button on footer on clicking of which we will delete all the completed Todo items.

In home.component.html, we will add the following to the mat-card-footer

```html
<button mat-flat-button (click)="clearCompleted()">Clear Completed</button>
```
Also, for each list item ( inside ngFor ), we will add the delete button

```html
<button class="delete-todo-btn" mat-icon-button aria-label="Deleting Todo" (click)="deleteTodo(item)">
Delete          </button>
```

Deleting a single Todo is a simple. We have to get the FireStore Document and it has delete method

```TS
  deleteTodo(todo:Todo){
    const todoDoc = this.firestore.doc(`todos/${todo.id}`);
    todoDoc.delete();
  }
```

For deleting all the todos which are completed, we need to query FireStore collection again as the original query is returning all the Todo items of the user.

```TS
import { AngularFirestore, QuerySnapshot, QueryDocumentSnapshot } from '@angular/fire/compat/firestore';

clearCompleted(){
    if(this.user && this.items$){
      this.firestore
        .collection<Todo>('todos', ref => ref
          .where('userId', '==', this.user?.uid)
          .where('isCompleted', '==', true)
      )
      .get()
      .pipe(
        map((qs: QuerySnapshot<Todo>) => {
          return qs.docs;
        })
      )
      .subscribe((docs: QueryDocumentSnapshot<Todo>[]) => {
        docs.forEach((doc: QueryDocumentSnapshot<Todo>) => {
          doc.ref.delete();
        });
      });
    }
  }
```

Here, we are using collection().get() instead of .valueChanges() which will provide QuerySnapshot and we can go through each QueryDocumentSnapshot and delete the Document.


## Set Todo as Completed
To set a Todo as completed, we need to set _isCompleted_ property of the Todo item to true. FireStore document provides an update method. 

We will update the radio button in home.component.html

```html
<mat-radio-button (change)="setAsCompleted(item)" [checked]="item.isCompleted" [disabled]="item.isCompleted">
              {{item.title}}
            </mat-radio-button>
```

Same like how we deleted the document, we can get the document using id and then update.

```TS
setAsCompleted(todo: Todo){
    const todoDoc = this.firestore.doc(`todos/${todo.id}`);
    todoDoc.update({
      isCompleted: true
    });
  }
```

## Custom Icon
We have some custom icons as per the design for Delete, Checkbox, and Theme switch. 

To add custom icon, I followed [this tutorial](https://www.digitalocean.com/community/tutorials/angular-custom-svg-icons-angular-material)

I copied all the images from the design to the assets/images folder. 

And, added the following to app.component.ts

```TS
import { MatIconRegistry } from "@angular/material/icon";
import { DomSanitizer } from "@angular/platform-browser";

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {

  constructor(
    private matIconRegistry: MatIconRegistry,
    private domSanitizer: DomSanitizer
  ){
    this.matIconRegistry.addSvgIcon(
      'icon-check',
      this.domSanitizer.bypassSecurityTrustResourceUrl('/assets/images/icon-check.svg')
    );
    this.matIconRegistry.addSvgIcon(
      'icon-cross',
      this.domSanitizer.bypassSecurityTrustResourceUrl('/assets/images/icon-cross.svg')
    );
    this.matIconRegistry.addSvgIcon(
      'icon-moon',
      this.domSanitizer.bypassSecurityTrustResourceUrl('/assets/images/icon-moon.svg')
    );
    this.matIconRegistry.addSvgIcon(
      'icon-sun',
      this.domSanitizer.bypassSecurityTrustResourceUrl('/assets/images/icon-sun.svg')
    );
  }
}
```

## Theme Switch
We need a dark theme and a light theme. 

Angular Material added a custom theme on setup. We will move the theme related styles to scss/_theme.scss

We also have to create another theme.

```scss
$angular-firebase-todo-light-theme: mat.define-light-theme((
  color: (
    primary: $angular-firebase-todo-primary,
    accent: $angular-firebase-todo-accent,
    warn: $angular-firebase-todo-warn,
  )
));

$angular-firebase-todo-dark-theme: mat.define-dark-theme((
  color: (
    primary: $angular-firebase-todo-primary,
    accent: $angular-firebase-todo-accent,
    warn: $angular-firebase-todo-warn,
  )
));


@include mat.all-component-themes($angular-firebase-todo-light-theme);

.dark-theme {
    @include mat.all-component-colors($angular-firebase-todo-dark-theme);
}
```

_mat.define-light-theme_ is used for creating light theme and 
_mat.define-dark-theme_ is used for creating dark theme.

We added dark theme inside a class selector ( .dark-theme ). So, the dark theme will be applied only when _dark-theme_ class is added to the root element.

We will inject _Renderer2_ from Angular to add or remove the dark-theme class to the **body** element. 

We will also store the current theme in sessionStorage so that, even on refresh, we will persist the theme selection.

Lets add the theme switcher button on header component. 

```html
<button mat-icon-button aria-label="Change Theme" (click)="changeTheme()">
            <mat-icon class="icon-theme" [svgIcon]="currentThemeIcon"></mat-icon>
        </button>
```

icon name is stored in a property so that, we can change it based on theme selection.

```TS
import { Renderer2 } from '@angular/core';
changeTheme(){
    if(this.currentTheme === Theme.LIGHT){
      sessionStorage.setItem('theme', Theme.DARK);
      this.currentTheme = Theme.DARK;
      this.currentThemeIcon = 'icon-sun';
      this.renderer.addClass(document.body, 'dark-theme');
    }
    else {
      sessionStorage.setItem('theme', Theme.LIGHT);
      this.currentTheme = Theme.LIGHT;
      this.currentThemeIcon = 'icon-moon';
      this.renderer.removeClass(document.body, 'dark-theme');
    }
  }
```

## Header

We want to show different page header for Login and Home Page. In AppComponent, we have the RouterOutlet which has Router activate event. We can rely on that, to know when the route is changed.

And, the updated title can be passed to the Header component.

```html
<app-header [pageTitle]="pageTitle"></app-header>
<router-outlet (activate)="changePageHeader()"></router-outlet>
```

```TS
changePageHeader(){
    this.pageTitle = this.router.url === '/login' ? 'Login' : 'Todo';
  }
```
And, in Header component, we will get the value as Input and will display the page header accordingly.

```TS
@Input()
pageTitle = '';
```

## Material CDK For Mobile Layout

Layout is different between Mobile view and Desktop view. On Mobile view, we need to show the Filter buttons on a separate box.

To achieve this using style can be more tricky. Instead, we can utilise the Material CDK to check if the current screen is a mobile view or not.

On HomeComponent,

```TS
import { BreakpointObserver } from '@angular/cdk/layout';
constructor(
    private breakpointObserver: BreakpointObserver
  ) {
     this.isSmallScreen = breakpointObserver.isMatched('(max-width: 375px)');
}
```
Now, we can display, different template based on _isSmallScreen_ property.

## Auth Guard

Home page should not be accessible to a Guest user as we are showing Todos of a user. So, we want to restrict the entry to Home page. For that, we can use AuthGuard of RouterModule. 
Route has canActivate guard which we can use to add some restricted entry.

AngularFire provides the [route guard](https://github.com/angular/angularfire/blob/master/docs/auth/router-guards.md). So, we can use that instead of adding custom logic.

```TS
import { AngularFireAuthGuard, redirectUnauthorizedTo } from '@angular/fire/compat/auth-guard';

const redirectUnauthorizedToLogin = () => redirectUnauthorizedTo(['login']);

const routes: Routes = [
  {
    path: 'login',
    component: LoginComponent    
  },
  {
    path: '',
    pathMatch: 'full',
    component: HomeComponent,
    canActivate: [AngularFireAuthGuard],
    data: { authGuardPipe: redirectUnauthorizedToLogin }
  }
];
```

## Unit Test

All the tests are failing mainly because of the Dependency Injection. We will mock the dependencies of component we are testing.

Lets take an example.For AddTodo Component, there is dependency for AngularFireStore. So, we will create a Mock instance of AngularFireStore and replace the original with the mock one. 

So, in add-todo.component.spec.ts

```TS
import { AngularFirestore } from '@angular/fire/compat/firestore';

const MockAngularFireStore = {
  collection: () => ({valueChanges: () => of([]), get: () => of([])}),
  doc: () => ({update: jasmine.createSpy(), delete: jasmine.createSpy()})
};

describe('AddTodoComponent', () => {
  let component: AddTodoComponent;
  let fixture: ComponentFixture<AddTodoComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ AddTodoComponent ],
      providers: [
        {provide: AngularFirestore, useValue: MockAngularFireStore}
      ]
    })
    .compileComponents();

    fixture = TestBed.createComponent(AddTodoComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});
```
We are replacing AngularFireStore using _useValue_. There is another option of _useClass_ as well.

## ESLint
ESLint setup is not enabled by default in Angular. if we run
`ng lint`
Angular CLI will ask whether to add the ESLint setup or not.


![ESLint](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xx3gn7a30twnh2jho6kb.png)

This will add the packages required and .eslintrc.json file will be added which contains all the ESLint rules. 

We will also add ESLint for typescript as documented [here](https://typescript-eslint.io/).

I updated .eslintrc.json to include the new rules
```json
"extends": [
        "eslint:recommended",
        "plugin:@typescript-eslint/recommended",
        "plugin:@angular-eslint/recommended",
        "plugin:@angular-eslint/template/process-inline-templates"
      ],
      "parser": "@typescript-eslint/parser",
      "plugins": ["@typescript-eslint"],
```

## Firebase Hosting

Now that the functionality is completed, lets deploy the application to Firebase.

We need Firebase CLI to be able to deploy from the terminal

`npm install -g firebase-tools`

And, then, we will initialise Firebase

`firebase init`

which will ask what all features we will need

We will select Hosting. 

It will also ask whether to setup automatic code deployment. On selecting yes, Github Action will be generated under .github/workflows

## Github Actions

[Github Actions](https://github.com/features/actions) are used to run some scripts on any event on the Repository. For eg. I can run a build script on every push to the main branch. 

Here, Firebase created two Github Actions. One to run on every push to _main_ branch, and another to run on every pull request.

As soon as we push the changes to the main branch, build will happen and firebase will deploy the latest changes.

## Branch Protection
Since any push to main branch will be automatically deployed, we don't want to allow direct push to main branch, especially when multiple people are working on the project.

Instead, every developer can work on a feature, create a feature branch and then raise Pull Request to main branch. 

How do we enforce this? We can use the Branch protection in Github.

We will block the direct push to main branch and also, we will set in such a way that, Pull Request can be merged only if the Github Action is passing.

![Github Branch Protection](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/df2cekn8bjvn2kmgyap2.jpg)

Now, we can see that, on creating PR, we cant merge immediately.


![PR Check](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9z4f4hce81p7ahwa9ooc.png)

Once the Github Action is completed successfully, we can merge.


![PR Success](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ht8sm74ltq5znmf5iga5.png)

Firebase also generates a preview link on the PR. We can see the changes on the PR in this link and only if all good, we need to merge this.

## Final


You can see the full code [here](https://github.com/jintoppy/angular-firebase-todo)  

If you prefer to watch the whole process, video given below.

{% embed https://www.youtube.com/watch?v=XO74cgUK_E0 %}