DEV Community

Zaki Mohammed
Zaki Mohammed

Posted on • Originally published at towardsdev.com on

Baking pagination with Angular and Bootstrap 5

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.

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](http://localhost:3000/api/employees?page=1&size=5&search=al&orderBy=Name&orderDir=ASC)
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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/'](http://localhost:3000/api/'),
};
Enter fullscreen mode Exit fullscreen mode

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

app.service.ts

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

[@Injectable](http://twitter.com/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));
  }
}
Enter fullscreen mode Exit fullscreen mode

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

app.component.ts

import { Component, OnDestroy, OnInit } from '[@angular/core](http://twitter.com/angular/core)';
import { Subscription } from 'rxjs';
import { Options, Response } from './app.model';
import { AppService } from './app.service';

[@Component](http://twitter.com/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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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

app.component.ts

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

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

<!-- size -->
</div>
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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](http://twitter.com/angular/core)';
import { Subscription } from 'rxjs';
import { Options, Response } from './app.model';
import { AppService } from './app.service';

[@Component](http://twitter.com/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;
  }
}
Enter fullscreen mode Exit fullscreen mode

Git Repository

Check out the git repository for this project or download the code.

Download Code

Git Repository

Summary

Creating a custom pagination component and reinventing the wheel all together is not required in many projects as many of the enterprise applications are heavily dependent on libraries (PrimeNg, NgBootstrap etc.) which can do all of the above tasks without hesitation. But some time for some wild projects it seems good to gain full control over every single line of code for handling such scenarios as it gives you a window to explore and bake something fresh from your personalized oven.

Hope this article helps.

Originally published at https://codeomelet.com.


Top comments (0)