DEV Community

Jinto Jose
Jinto Jose

Posted on

Creating FullStack Todo App - Angular+Material+FireBase+GithubAction+UnitTest+ESLint

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 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 and Angular Material for the Frontend and Firebase 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

Final Screen2

Angular + Material Setup

Let's install Angular CLI and create the basic skeleton of the app.

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

setup

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

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

Firebase Web App

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

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

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

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

Enter fullscreen mode Exit fullscreen mode

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

Image description

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 )

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

Enter fullscreen mode Exit fullscreen mode

Authentication

For implementing the Authentication, I followed the documentation on AngularFire

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.

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


}
Enter fullscreen mode Exit fullscreen mode
 <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>
Enter fullscreen mode Exit fullscreen mode

Display Todos

We will use AngularFire for connecting with Firebase.

ng add @angular/fire

If we look at the QuickStart Guide, 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.

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

      }

});
}
Enter fullscreen mode Exit fullscreen mode

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.

We can use items$ in home.component.html. To give some basic styling, we will use the Card and List components from Material.

For Each Todo item, we will add a radio button, so that, we can mark the Todo as completed.

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

Enter fullscreen mode Exit fullscreen mode

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 to the proper styles and to have Form Control associated to it.

     <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>
Enter fullscreen mode Exit fullscreen mode

I added a reference addTodoInput to the input element so that, we can pass the value of input to the addTodo function.

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

  }


Enter fullscreen mode Exit fullscreen mode

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.

enum FilterState {
  ALL = 'All',
  ACTIVE = 'Active',
  COMPLETED = 'Completed'
}
Enter fullscreen mode Exit fullscreen mode

Lets update the template of HomeComponent

<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>
Enter fullscreen mode Exit fullscreen mode

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.

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

setFilterState(filterState: FilterState){
    this.activeFilter.next(filterState);
}
Enter fullscreen mode Exit fullscreen mode

Now, we can combine these two Observables ( activeFilter & items$ ) to create a new Observable. For that, we can use combineLatest operator.

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;
          });
        })
      );
   }
Enter fullscreen mode Exit fullscreen mode

Now, when any of the value changes, it will pipe through the map operator 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

<button mat-flat-button (click)="clearCompleted()">Clear Completed</button>
Enter fullscreen mode Exit fullscreen mode

Also, for each list item ( inside ngFor ), we will add the delete button

<button class="delete-todo-btn" mat-icon-button aria-label="Deleting Todo" (click)="deleteTodo(item)">
Delete          </button>
Enter fullscreen mode Exit fullscreen mode

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

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

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.

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();
        });
      });
    }
  }
Enter fullscreen mode Exit fullscreen mode

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

<mat-radio-button (change)="setAsCompleted(item)" [checked]="item.isCompleted" [disabled]="item.isCompleted">
              {{item.title}}
            </mat-radio-button>
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

And, added the following to app.component.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')
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

$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);
}
Enter fullscreen mode Exit fullscreen mode

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.

<button mat-icon-button aria-label="Change Theme" (click)="changeTheme()">
            <mat-icon class="icon-theme" [svgIcon]="currentThemeIcon"></mat-icon>
        </button>
Enter fullscreen mode Exit fullscreen mode

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

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');
    }
  }
Enter fullscreen mode Exit fullscreen mode

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.

<app-header [pageTitle]="pageTitle"></app-header>
<router-outlet (activate)="changePageHeader()"></router-outlet>
Enter fullscreen mode Exit fullscreen mode
changePageHeader(){
    this.pageTitle = this.router.url === '/login' ? 'Login' : 'Todo';
  }
Enter fullscreen mode Exit fullscreen mode

And, in Header component, we will get the value as Input and will display the page header accordingly.

@Input()
pageTitle = '';
Enter fullscreen mode Exit fullscreen mode

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,

import { BreakpointObserver } from '@angular/cdk/layout';
constructor(
    private breakpointObserver: BreakpointObserver
  ) {
     this.isSmallScreen = breakpointObserver.isMatched('(max-width: 375px)');
}
Enter fullscreen mode Exit fullscreen mode

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. So, we can use that instead of adding custom logic.

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 }
  }
];
Enter fullscreen mode Exit fullscreen mode

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

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();
  });
});
Enter fullscreen mode Exit fullscreen mode

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

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.

I updated .eslintrc.json to include the new rules

"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"],
Enter fullscreen mode Exit fullscreen mode

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

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

PR Check

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

PR Success

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

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

Top comments (0)