Keyboard shortcuts are a very convenient feature for users. It allows them to do things without many clicks, increasing productivity. Keyboard shortcut handling can easily be added to Angular apps with the angular2-hotkeys library.
In this article, we will write a diet tracker app that lets users enter their calories eaten for a given day. They can use keyboard shortcuts to open the modal to add an entry and also to delete the latest entry. To start the project, we install the Angular CLI by running npm i -g @angular/cli
. Next we run the Angular CLI to create the project by typing:
ng new diet-tracker
In the setup wizard, we choose to include routing and use SCSS as our CSS preprocessor.
Then we install some packages. We need the angular2-hotkeys package we mentioned above, Ngx-Bootstrap for styling, as well as MobX to store the data in a shared store. To install them, we run:
npm i ngx-bootatrap angular2-hotkeys mobx mobx-angular
Next we create our components and services. To do this, we run:
ng g component dietForm
ng g component homePage
ng g service diet
ng g class calorie
ng g class dietStore
Now we are ready to write some code. In diet-form.component.html
, we replace the existing code with:
<form (ngSubmit)="save(dietForm)" #dietForm="ngForm">
<div class="form-group">
<label>Date</label>
<input
type="text"
class="form-control"
placeholder="Date"
#date="ngModel"
name="date"
[(ngModel)]="form.date"
required
pattern="([12]d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]d|3[01]))"
/>
<div *ngIf="date?.invalid && (date.dirty || date.touched)">
<div *ngIf="date.errors.required">
Date is required.
</div>
<div *ngIf="date.invalid">
Date is invalid.
</div>
</div>
</div>
<div class="form-group">
<label>Amount of Calories</label>
<input
type="text"
class="form-control"
placeholder="Amount"
#amount="ngModel"
name="amount"
[(ngModel)]="form.amount"
required
pattern="^(d*.)?d+"
/>
<div *ngIf="amount?.invalid && (amount.dirty || amount.touched)">
<div *ngIf="amount.errors.required">
Amount is required.
</div>
<div *ngIf="amount.invalid">
Amount is invalid.
</div>
</div>
</div>
<button class="btn btn-primary">Save</button>
</form>
We add the form to let users enter the date they ate and the amount of calories eaten on the given day. We use Angular’s template driven form validation to check if everything is filled in, to verify that the date is in YYYY-MM-DD format, and to check if the calorie count is a non-negative number. We also have a Save button to save the data when it’s clicked. This form is used for both adding and editing entries.
Next in diet-form.component.ts
, we replace the existing code with:
import { Component, OnInit, Output, EventEmitter, Input, SimpleChanges } from '@angular/core';
import { NgForm } from '@angular/forms';
import { DietService } from '../diet.service';
import { caloriesStore } from '../diet-store';
import { Calorie } from '../calorie';
@Component({
selector: 'app-diet-form',
templateUrl: './diet-form.component.html',
styleUrls: ['./diet-form.component.scss']
})
export class DietFormComponent implements OnInit {
form: any = <any>{};
@Output('saved') saved = new EventEmitter();
@Input() edit: boolean;
@Input() selectedCalorie: any;
store = caloriesStore;
constructor(private dietService: DietService) { }
ngOnInit() {
}
ngOnChanges(changes: SimpleChanges) {
this.form = Object.assign({}, this.selectedCalorie);
}
save(dietForm: NgForm) {
if (dietForm.invalid) {
return;
}
if (this.edit) {
this.dietService.editCaloriesEaten(this.form)
.subscribe(res => {
this.getCaloriesEaten()
this.saved.emit();
})
}
else {
this.dietService.addCaloriesEaten(this.form)
.subscribe(res => {
this.getCaloriesEaten()
this.saved.emit();
})
}
}
getCaloriesEaten() {
this.dietService.getCaloriesEaten()
.subscribe((res: Calorie[]) => {
res = res.sort((a, b) => +new Date(a.date) - +new Date(b.date));
this.store.setCalories(res);
})
}
}
This file has the functions that we called in the previous template like the save
function. We also have the Inputs to get the data from the home page, as well as an Output to emit a saved
event to the home page. Since we use the form for editing, we also need to pass in the selected entry with the selectedCalorie
Input so that it can be edited. To update the form
object with the selectedCalorie
values, we copied the value whenever the selectedCalorie
Input changes.
In the save
function, we validate the form and call different functions for saving depending on whether the form is being used for adding or editing an entry. The latest entries will be populated in our MobX store by calling the getCaloriesEaten
function and the saved
event will be emitted once that’s done.
Next in home-page.component.html
, we replace the code with:
<ng-template #addTemplate>
<div class="modal-header">
<h2 class="modal-title pull-left">Add Calories Eaten</h2>
</div>
<div class="modal-body">
<app-diet-form (saved)="closeModals()"></app-diet-form>
</div>
</ng-template>
<ng-template #editTemplate>
<div class="modal-header">
<h2 class="modal-title pull-left">Edit Calories Eaten</h2>
</div>
<div class="modal-body">
<app-diet-form
[edit]="true"
(saved)="closeModals()"
[selectedCalorie]="selectedCalorie"
></app-diet-form>
</div>
</ng-template>
<h1 class="text-center">Diet Tracker</h1>
<div class="btn-group" role="group">
<button
type="button"
class="btn btn-secondary"
(click)="openAddModal(addTemplate)"
>
Add Calories Eaten
</button>
</div>
<div class="table-responsive">
<br />
<table class="table">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Calories Eaten</th>
<th scope="col">Edit</th>
<th scope="col">Delete</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let c of store.calories">
<th scope="row">{{ c.date | date }}</th>
<td>{{ c.amount }}</td>
<td>
<button
type="button"
class="btn btn-secondary"
(click)="openEditModal(editTemplate, c)"
>
Edit
</button>
</td>
<td>
<button
type="button"
class="btn btn-secondary"
(click)="deleteCaloriesEaten(c.id)"
>
Delete
</button>
</td>
</tr>
</tbody>
</table>
</div>
This creates buttons for adding, editing, and deleting entries, as well as a table for showing the entries. Also, we have the modals for adding and editing entries that we open with the Add and Edit buttons respectively.
Next in home-page.component.ts
, we replace the existing code with:
import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
import { BsModalService, BsModalRef } from 'ngx-bootstrap/modal';
import { caloriesStore } from '../diet-store';
import { DietService } from '../diet.service';
import { HotkeysService, Hotkey } from 'angular2-hotkeys';
import { Calorie } from '../calorie';
@Component({
selector: 'app-home-page',
templateUrl: './home-page.component.html',
styleUrls: ['./home-page.component.scss']
})
export class HomePageComponent implements OnInit {
addModalRef: BsModalRef;
editModalRef: BsModalRef;
selectedCalorie: any = <any>{};
store = caloriesStore;
@ViewChild('addTemplate', undefined) addTemplate: TemplateRef<any>;
constructor(
private modalService: BsModalService,
private dietService: DietService,
private _hotkeysService: HotkeysService
) { }
ngOnInit() {
this.getCaloriesEaten();
this.addHotKeys();
}
addHotKeys() {
this._hotkeysService.add(new Hotkey(['ctrl+shift+a', 'ctrl+shift+d'], (event: KeyboardEvent, combo: string): boolean => {
if (combo === 'ctrl+shift+a') {
this.openAddModal(this.addTemplate);
}
if (combo === 'ctrl+shift+d') {
if (Array.isArray(this.store.calories) && this.store.calories[0]) {
this.deleteCaloriesEaten(this.store.calories[0].id);
}
}
return false;
}));
}
getCaloriesEaten() {
this.dietService.getCaloriesEaten()
.subscribe((res: any[]) => {
res = res.sort((a, b) => +new Date(a.date) - +new Date(b.date));
this.store.setCalories(res);
})
}
openAddModal(template: TemplateRef<any>) {
this.addModalRef = this.modalService.show(template);
}
openEditModal(template: TemplateRef<any>, calorie) {
this.editModalRef = this.modalService.show(template);
this.selectedCalorie = calorie;
}
closeModals() {
this.addModalRef && this.addModalRef.hide();
this.editModalRef && this.editModalRef.hide();
}
deleteCaloriesEaten(id) {
this.dietService.deleteCaloriesEaten(id)
.subscribe((res: Calorie[]) => {
this.getCaloriesEaten();
})
}
}
We have the openAddModal
and openEditModal
functions to open the Add and Edit modals. The closeModals
function is for closing the modals when things are saved in the app-diet-form
component. The deleteCaloriesEaten
function is for deleting the calories, and the getCaloriesEaten
is used for getting the entries when the page loads and when items are deleted. It also puts the items in our store so every component can access it.
We also have the addHotKeys
function to add the hotkeys to our app for the users convenience. The HotKeyService
is from the angular2-hotkeys library. We inject it and then define the hotkeys. We defined Ctrl+Shift+A to open the add modal, and the Ctrl+Shift+D combo to delete the first entry on the list. The return false
statement in the callback is to prevent the event from bubbling.
In app-routing.module.ts
, we put:
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/route';
import { HomePageComponent } from './home-page/home-page.component';
const routes: Routes = [
{ path: '', component: HomePageComponent }
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
This is so users can see the pages we just added when they click on the links or enter the URLs.
Next in app.component.html
, we put:
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" routerLink="/">Diet Tracker</a>
<button
class="navbar-toggler"
type="button"
data-toggle="collapse"
data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav mr-auto">
<li class="nav-item active">
<a class="nav-link" routerLink="/">Home </a>
</li>
</ul>
</div>
</nav>
<div class="page">
<router-outlet></router-outlet>
</div>
This adds the links to our pages and exposes the router-outlet
so users can see our pages.
Then in app.component.scss
, we add:
.page {
padding: 20px;
}
nav {
background-color: lightsalmon !important;
}
This adds padding to our pages and changes the color of our Bootstrap navigation bar.
In app.module.ts
, we replace the existing code with:
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { HomePageComponent } from './home-page/home-page.component';
import { DietFormComponent } from './diet-form/diet-form.component';
import { ModalModule } from 'ngx-bootstrap/modal';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { MobxAngularModule } from 'mobx-angular';
import { HotkeyModule } from 'angular2-hotkeys';
@NgModule({
declarations: [
AppComponent,
HomePageComponent,
DietFormComponent
],
imports: [
BrowserModule,
AppRoutingModule,
ModalModule.forRoot(),
MobxAngularModule,
FormsModule,
HttpClientModule,
HotkeyModule.forRoot()
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
This adds our components, services, and libraries that we use in our app.
In calorie.ts
, we add:
export class Calorie {
public date: string;
public amount: string;
}
This adds types to our calorie form model.
Then in dietStore.ts
, we add:
import { observable, action } from 'mobx-angular';
class CaloriesStore {
@observable calories = [];
@action setCalories(calories) {
this.calories = calories;
}
}
export const caloriesStore = new CaloriesStore();
This creates the MobX store to get our components and share the data. Whenever we call this.store.setCalories
in our components ,we set the calories data in this store since we added the @action
decorator before it. When we call this.store.calories
in our component code, we are always getting the latest value from this store since it has the @observable
decorator.
Then in diet.service.ts
, we replace the existing code with:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from 'src/environments/environment';
@Injectable({
providedIn: 'root'
})
export class DietService {
constructor(private http: HttpClient) { }
getCaloriesEaten() {
return this.http.get(`${environment.apiUrl}/calories`);
}
addCaloriesEaten(data) {
return this.http.post(`${environment.apiUrl}/calories`, data);
}
editCaloriesEaten(data) {
return this.http.put(`${environment.apiUrl}/calories/${data.id}`, data);
}
deleteCaloriesEaten(id) {
return this.http.delete(`${environment.apiUrl}/calories/${id}`);
}
}
This is so that we can make HTTP requests to our backend to get, save and delete the user’s entries.
Next in environment.prod.ts
and environment.ts
, we replace the existing code with:
export const environment = {
production: true,
apiUrl: 'http://localhost:3000'
};
This adds our API’s URL.
Finally, in index.html
, we replace the code with:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Diet Tracker</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link
href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css"
rel="stylesheet"
/>
<script
src="https://code.jquery.com/jquery-3.3.1.slim.min.js"
integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo"
crossorigin="anonymous"
></script>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js"
integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1"
crossorigin="anonymous"
></script>
<script
src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"
integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM"
crossorigin="anonymous"
></script>
</head>
<body>
<app-root></app-root>
</body>
</html>
to add the Bootstrap CSS and JavaScript dependencies into our app, as well as changing the title.
After all the work, we can run ng serve
to run the app.
To start the back end, we first install the json-server
package by running npm i json-server
. Then, go to our project folder and run:
json-server --watch db.json
In db.json
, change the text to:
{
"calories": [
]
}
So we have the calories
endpoints defined in the requests.js
available.