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.
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
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.
We can add apps ( Android / IOS / We ) to the project. We will add a 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.
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 { }
Now, if we look at the page layout we need to create, we need mainly a Header component, also, an AddTodo component
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>
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' : '';
}
}
<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 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'});
}
});
}
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>
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>
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
});
}
}
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'
}
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>
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);
}
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;
});
})
);
}
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>
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>
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();
}
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();
});
});
}
}
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>
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
});
}
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')
);
}
}
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);
}
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>
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');
}
}
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>
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.
@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,
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. 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 }
}
];
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();
});
});
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.
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"],
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.
Now, we can see that, on creating PR, we cant merge immediately.
Once the Github Action is completed successfully, we can merge.
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)