Baking pagination with Angular and Bootstrap 5

Zaki Mohammed Zaki Mohammed
February 08, 2021 | 8 min read | 449 Views

After having a nice cup of morning tea/coffee, users of your web app needs to get their data table/grid loaded in a blink of an eye. Pretty neat with data in the range of hundreds but when data is in range of "K" things are not okay. But with the help of paginated API and corresponding UI we can tackle this situation. Obviously, in this article will make and bake a paginated UI with sorting and filtering using Angular and Bootstrap 5.

Album Post:

For quick read. Checkout this amazing album!

Baking pagination with Angular and Bootstrap 5

Angular is quite tanky and can be used to build the pagination logic easily but for constructing UI will unleash the power of Bootstrap components; it provides a defined set of classes to create awesome looking but humble paginators, tables and dropdowns. In continuation of previous article Crafting paginated API with NodeJS and MSSQL, we will use the NodeJS paginated API which we constructed previously. From a UI perspective we simply need to consume the following API in our Angular:

GET http://localhost:3000/api/employees?page=1&size=5&search=al&orderBy=Name&orderDir=ASC

Ofcourse, we need to run our previous API project for that. Please checkout the previous reading to better understand what's happening at the backend.

Initializing Angular and Bootstrap 5

Let us start with creating a new Angular application and install the Bootstrap 5, a nicely written article about setting up Angular and Bootstrap 5 can be found here Exploring Bootstrap 5 with Angular. Will execute the following command to create an Angular app and install Bootstrap 5.

ng new pagination-app
npm i bootstrap@next

Adding Bootstrap 5 dependencies to angular.json file:

{
    ...
    "projects": {
        "pagination-app": {
            ...
            "architect": {
                "build": {
                    "options": {
                        "styles": [
                            "./node_modules/bootstrap/dist/css/bootstrap.min.css",
                            "src/styles.scss"
                        ],
                        "scripts": [
                            "./node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"
                        ]
                    }
                },
                ...
            }
        }
    }
}

Pagination App Component

The default setup of any Angular application remains the same. We will make use of the main app component to code our pagination logic. Following are the components, services and models we created:

src
|-- app
	|-- app-routing.module.ts
	|-- app.component.html
	|-- app.component.scss
	|-- app.component.spec.ts
	|-- app.component.ts
	|-- app.model.ts
	|-- app.module.ts
	|-- app.service.spec.ts
	|-- app.service.ts
|-- environments
	|-- environments.prod.ts
	|-- environments.ts

Let's begin exploring with app.model file, which is shown below:

app.model.ts

export interface Employee {
    Id: number;
    Code: string;
    Name: string;
    Job: string;
    Salary: number;
    Department: string;
}

export interface Response {
    records: Employee[];
    filtered: number;
    total: number;
}

export interface Options {
    orderBy: string;
    orderDir: 'ASC' | 'DESC';
    search: string,
    size: number,
    page: number;
}

We have created three interfaces, one is Employee interface which holds up properties of our table Employee, next is Response interface which have properties that are sent from the paginated NodeJS API, lastly we created Options interface which holds up the properties required by the paginated API.

In the environment file we have added the path to our paginated API as apiURL:

environments.ts

export const environment = {
  production: false,
  apiUrl: 'http://localhost:3000/api/',
};

We have added service to make HTTP call to our API, the generic code to do the needful is shown below:

environments.ts

import { HttpHeaders, HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { Options, Response } from './app.model';

@Injectable({
  providedIn: 'root'
})
export class AppService {

  url: string = environment.apiUrl + 'employees/';

  constructor(private http: HttpClient) {
  }

  getEmployees(options: Options): Observable {
    const url = `${this.url}?page=${options.page}&size=${options.size}&search=${options.search}&orderBy=${options.orderBy}&orderDir=${options.orderDir}`;
    return this.http.get(url).pipe(map(response => response));
  }
}

We simply have one method named getEmployees which requires options to make a call to API.

Now let us address the elephant in the room, the main app component’s HTML and its Typescript file. To understand things to cover from UI perspective, let us list out set of objectives and controls needed to make things work for us:

  1. Search control (input text element)
  2. Size dropdown (Bootstrap dropdown)
  3. Sorting (column header sort icon)
  4. Table to show records (Bootstrap table)
  5. Paginator (Bootstrap pagination, page number, next and previous buttons)

Will break down each elements to better understand the entire working, but starts with the skeleton for HTML and TypeScript as shown below:

app.component.html

<div class="container my-5">
  <div class="row">
    <div class="col">
      <h1 class="mb-4">?? Pagination Front to Back</h1>

      <!-- pagination UI goes here -->
    </div>
  </div>
</div>

app.component.ts

import { Component, OnDestroy, OnInit } from '@angular/core';
import { Subscription } from 'rxjs';
import { Options, Response } from './app.model';
import { AppService } from './app.service';

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

  options: Options = {
    orderBy: 'Name',
    orderDir: 'ASC',
    page: 1,
    search: '',
    size: 5
  };
  response: Response = null;
  getEmployeesSub: Subscription;

  constructor(private appService: AppService) {
  }

  ngOnInit(): void {
    this.getEmployees();
  }

  ngOnDestroy(): void {
    this.getEmployeesSub.unsubscribe();
  }

  getEmployees(): void {
    this.getEmployeesSub = this.appService.getEmployees(this.options).subscribe(response => this.response = response);
  }
}

As shown above we are doing the setup by adding the app service as dependency and making a call to the paginated API with default options and then finally removing the subscription once the component is destroyed.

Bringing the objectives back and completing them one by one; starts with adding the search as shown below.

1. Search control (input text element)

app.component.html

<!-- controls -->
<div class="input-group mb-3">

  <!-- search -->
  <input type="text" class="form-control" placeholder="Search..." (keyup)="search($event)">

  <!-- size -->
</div>

And here goes the logic for search method on keyup event:

app.component.ts

search($event: any): void {
  const text = $event.target.value;
  this.options.search = text;
  this.options.page = 1;
  this.getEmployees();
}

Simply changing the search properties value of the option object, setting the page to the first page and calling to API again.

2. Size dropdown (Bootstrap dropdown)

app.component.html

<!-- controls -->
<div class="input-group mb-3">

  <!-- search -->

  <!-- size -->
  <button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
	Size: {{options.size}}
  </button>
  <ul class="dropdown-menu dropdown-menu-end">
    <li><button class="dropdown-item" type="button" (click)="size(5)">5</button></li>
    <li><button class="dropdown-item" type="button" (click)="size(10)">10</button></li>
    <li><button class="dropdown-item" type="button" (click)="size(20)">20</button></li>
    <li><button class="dropdown-item" type="button" (click)="size(50)">50</button></li>
    <li><button class="dropdown-item" type="button" (click)="size(100)">100</button></li>
  </ul>
</div>

The size is fetched from the option’s size property. The size method will handle the click event as below:

app.component.ts

size(size: number) {
  this.options.size = size;
  this.options.page = 1;
  this.getEmployees();
}

Setting the size to the selected size and setting the page to the first page and calling to API again.

3. Sorting (column header sort icon)

app.component.html

<!-- table -->
<div class="card mb-3" *ngIf="response">
  <div class="card-body">
    <table class="table" style="table-layout: fixed;">
  	  <thead>
  	    <tr>
          <th (click)="order('Code')" role="button" style="width: 10%;">
            # <span *ngIf="by('Code')">{{ direction }}</span>
          </th>
          <th (click)="order('Name')" role="button">
            Name <span *ngIf="by('Name')">{{ direction }}</span>
          </th>
          <th (click)="order('Job')" role="button">
            Job <span *ngIf="by('Job')">{{ direction }}</span>
          </th>
          <th (click)="order('Salary')" role="button" style="width: 15%;">
            Salary <span *ngIf="by('Salary')">{{ direction }}</span>
          </th>
          <th (click)="order('Department')" role="button">
            Department <span *ngIf="by('Department')">{{ direction }}</span>
          </th>
  	    </tr>
  	  </thead>
  	  <tbody></tbody>
    </table>
  </div>
</div>

When the table’s column is clicked the expected behaviour is to sort the column based on the direction by toggling the direction state; also an icon needs to be shown against the column to let the user know based on which column the sorting is working.

So for handling the click event we have added the order method on each column and for showing the icon we are using the by method which decides which column is currently ordering the record and the direction icon is decided based on direction property. Checkout the code logic behind these 2 methods and property:

app.component.ts

order(by: string) {
  if (this.options.orderBy === by) {
    this.options.orderDir = this.options.orderDir === 'ASC' ? 'DESC' : 'ASC';
  } else {
    this.options.orderBy = by;
  }
  this.getEmployees();
}

by(order: string) {
  return this.options.orderBy === order;
}

get direction() {
  return this.options.orderDir === 'ASC' ? '?' : '?';
}

The order method toggles the order direction when the order by value remains the same otherwise it updates the order by value and keeps the direction as previous, then it calls the API. The method returns true if the current order state is the same as the column header. Finally the direction property will return the icon based on the current direction state of the order.

4. Table to show records (Bootstrap table)

app.component.html

<!-- table -->
<div class="card mb-3" *ngIf="response">
  <div class="card-body">
    <table class="table" style="table-layout: fixed;">
  	  <thead></thead>
  	  <tbody>
  	    <tr *ngFor="let employee of response.records">
  	      <td>{{employee.Code}}</td>
  	      <td>{{employee.Name}}</td>
  	      <td>{{employee.Job}}</td>
  	      <td>{{employee.Salary | currency:'INR'}}</td>
  	      <td>{{employee.Department}}</td>
  	    </tr>
  	    <tr *ngIf="!response.records.length">
  	      <td colspan="5" class="text-center p-5">No records found</td>
  	    </tr>
      </tbody>
    </table>
  </div>
</div>

NgFor directives will take care of showing the records and properties, we have also checked empty record condition and shown appropriate message.

5. Paginator (Pagination, page numbers, next and previous buttons)

app.component.html

<!-- paginator -->
<nav *ngIf="numbers.length > 1">
  <ul class="pagination justify-content-center">
    <li id="prev" class="page-item" [ngClass]="{ 'disabled': options.page === 1 }">
      <a class="page-link" href="#" (click)="prev()">Previous</a>
    </li>
    <ng-container *ngIf="response">
      <li class="page-item" *ngFor="let number of numbers" [ngClass]="{ 'active': options.page === number }">
        <a class="page-link" href="#" (click)="to(number)">{{number}}</a>
      </li>
    </ng-container>
    <li id="next" class="page-item" [ngClass]="{ 'disabled': options.page === numbers.length }">
      <a class="page-link" href="#" (click)="next()" disabled="true">Next</a>
    </li>
  </ul>
</nav>

We are generating the page numbers based on the records present with the help of numbers property and then iterating them using NgFor. When the paginator numbers are clicked we are calling method “to” to show the record of a particular page. The next and previous actions are taken care by the next and previous methods respectively, additionally they are disabled and enabled based on the current state of pages. The code logic for these methods are shown below:

app.component.ts

get numbers(): number[] {
  const limit = Math.ceil((this.response && this.response.filtered) / this.options.size);
  return Array.from({ length: limit }, (_, i) => i + 1);
}

next() {
  this.options.page++;
  this.getEmployees();
}

prev() {
  this.options.page--;
  this.getEmployees();
}

Fear the math! For generating the page numbers we are finding the limit by dividing the filtered count by size, and then using the "from" method we are creating a dynamic number array. The next and prev methods are self explanatory.

Well that finishes up all of our objectives and the entire app.component HTML and TypeScript is shown below:

import { Component, OnDestroy, OnInit } from '@angular/core';
import { Subscription } from 'rxjs';
import { Options, Response } from './app.model';
import { AppService } from './app.service';

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

  options: Options = {
    orderBy: 'Name',
    orderDir: 'ASC',
    page: 1,
    search: '',
    size: 5
  };
  response: Response = null;

  getEmployeesSub: Subscription;

  constructor(private appService: AppService) {
  }

  get numbers(): number[] {
    const limit = Math.ceil((this.response && this.response.filtered) / this.options.size);
    return Array.from({ length: limit }, (_, i) => i + 1);
  }

  get direction() {
    return this.options.orderDir === 'ASC' ? '?' : '?';
  }

  ngOnInit(): void {
    this.getEmployees();
  }

  ngOnDestroy(): void {
    this.getEmployeesSub.unsubscribe();
  }

  getEmployees(): void {
    this.getEmployeesSub = this.appService.getEmployees(this.options).subscribe(response => this.response = response);
  }

  order(by: string) {
    if (this.options.orderBy === by) {
      this.options.orderDir = this.options.orderDir === 'ASC' ? 'DESC' : 'ASC';
    } else {
      this.options.orderBy = by;
    }
    this.getEmployees();
  }

  size(size: number) {
    this.options.size = size;
    this.options.page = 1;
    this.getEmployees();
  }

  search($event: any): void {
    const text = $event.target.value;
    this.options.search = text;
    this.options.page = 1;
    this.getEmployees();
  }

  next() {
    this.options.page++;
    this.getEmployees();
  }

  prev() {
    this.options.page--;
    this.getEmployees();
  }

  to(page: number) {
    this.options.page = page;
    this.getEmployees();
  }

  by(order: string) {
    return this.options.orderBy === order;
  }
}

Zaki Mohammed
Zaki Mohammed
Learner, developer, coder and an exceptional omelet lover. Knows how to flip arrays or omelet or arrays of omelet.