Categories
Angular

Add Keyboard Shortcut Feature to Your Angular App

Spread the love

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.

By John Au-Yeung

Web developer specializing in React, Vue, and front end development.

Leave a Reply

Your email address will not be published. Required fields are marked *