When pagination is performed on the backend side of a web application, we need to support this feature on the frontend. Read this post to see how you can easily handle Spring Boot Page object in an Angular app.
What we are going to build
The goal is to build a custom pagination component that accepts Spring Boot paginated response and displays it in a datatable. We will be able to control the amount of records fetched from the backend per page and which page should be displayed at the moment. After changing the number of requested elements or deleting a record we will be redirected back to the first page to avoid displaying an empty datatable.
Our example app will present a list of projects fetched from the "/api/projects"
endpoint exposed by the backend API.
The finished project is available in the GitHub repository – little-pinecone/scrum_ally.
You can see the datatable created in that project on the following screenshot:
Architecture overview
You can see the final directory tree for the pagination part of the frontend app on the image below:
Requirements
- Angular CLI – a command line interface tool that generates projects, components, modules, services.
I’m working on:
12345$ ng --versionAngular CLI: 6.0.8Node: 8.11.3OS: linux x64 - To see the backend part of this example web application, check out the Add pagination to a Spring Boot app post.
Handle pagination
Consume a page received from a Spring Boot app
Mirror the Page
instance that will be send by the API, you can define its model in three files:
1 2 3 4 5 |
// src/app/pagination/sort.ts export class Sort { sorted: boolean; unsorted: boolean; } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// src/app/pagination/pageable.ts import { Sort } from './sort'; export class Pageable { sort: Sort; pageSize: number; pageNumber: number; offset:number; unpaged:boolean; paged:boolean; static readonly DEFAULT_PAGE_SIZE = 3; static readonly FIRST_PAGE_NUMBER = 0; public constructor() { this.pageSize = Pageable.DEFAULT_PAGE_SIZE; this.pageNumber = Pageable.FIRST_PAGE_NUMBER; } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
//src/app/pagination/page.ts import { Sort } from './sort'; import { Pageable } from './pageable'; export class Page<T> { content: Array<T>; pageable: Pageable; last: boolean; totalPages: number; totalElements: number; first: boolean; sort: Sort; numberOfElements: number; size: number; number: number; public constructor() { this.pageable = new Pageable(); } } |
Adjust the default page size to your needs.
Add a service to handle base actions
We need a service that will update the request parameters after a user changes amount of elements per page or page number. Create the service with the following command:
1 |
$ ng generate service pagination/services/custom-pagination |
We are going to start from this scaffolding:
1 2 3 4 5 6 7 8 9 10 11 12 |
// src/app/pagination/services/custom-pagination.service.ts import { Injectable } from '@angular/core'; import { Page, Pageable } from '../../pagination/page'; @Injectable({ providedIn: 'root' }) export class CustomPaginationService { constructor() { } … } |
Now, we are going to add the function that handles requesting the next page:
1 2 3 4 5 6 7 8 9 |
// src/app/pagination/services/custom-pagination.service.ts … public getNextPage(page: Page<any>): Pageable { if(!page.last) { page.pageable.pageNumber = page.pageable.pageNumber+1; } return page.pageable; } … |
Furthermore, add the function for getting the previous page:
1 2 3 4 5 6 7 8 9 |
// src/app/pagination/services/custom-pagination.service.ts … public getPreviousPage(page: Page<any>): Pageable { if(!page.first) { page.pageable.pageNumber = page.pageable.pageNumber-1; } return page.pageable; } … |
We simply increase and decrease the pageNumber
variable here.
Finally, we need to handle a request that changes the amount of elements per page:
1 2 3 4 5 6 7 8 |
// src/app/pagination/services/custom-pagination.service.ts … public getPageInNewSize(page: Page<any>, pageSize: number): Pageable { page.pageable.pageSize = pageSize; page.pageable.pageNumber = Pageable.FIRST_PAGE_NUMBER; return page.pageable; } |
We need to show the first page when the size is being changed, therefore we need the page.pageable.pageNumber = Pageable.FIRST_PAGE_NUMBER; line.
All methods in this service return the pageable
object.
Create the pagination component
In the terminal, use Angular CLI to generate the following component:
1 |
$ ng generate component pagination/components/custom-pagination |
This component is going to utilize the functions provided by the custom-pagination.service
we’ve just created to allow maneuvering between pages and change number of displayed records. Copy the following code to your custom-pagination.component.ts
file:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
//src/app/pagination/components/custom-pagination/custom-pagination.component.ts import { Component, OnInit, EventEmitter, Input, Output } from '@angular/core'; import { Page } from '../../../pagination/page'; @Component({ selector: 'app-custom-pagination', templateUrl: './custom-pagination.component.html', styleUrls: ['./custom-pagination.component.scss'] }) export class CustomPaginationComponent implements OnInit { @Input() page: Page<any>; @Output() nextPageEvent = new EventEmitter(); @Output() previousPageEvent = new EventEmitter(); @Output() pageSizeEvent: EventEmitter<number> = new EventEmitter<number>(); constructor() { } ngOnInit() { } nextPage(): void { this.nextPageEvent.emit(null); } previousPage(): void { this.previousPageEvent.emit(null); } updatePageSize(pageSize: number): void { this.pageSizeEvent.emit(pageSize); } } |
The component requires an @Input() page: Page<any>
object and emits a suitable event for every click on the pagination controls – we will handle them in a minute – but first, let’s create an html
template for the component:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
<!-- src/app/pagination/components/custom-pagination/custom-pagination.component.html --> <div *ngIf="page" class="card-actions align-items-center justify-content-end"> <span class="align-self-center mb-1 mx-1 text-muted">Elements per page:</span> <div class="dropdown"> <button aria-expanded="false" aria-haspopup="true" class="btn btn-outline dropdown-toggle" data-toggle="dropdown" type="button"> {{page.size}} </button> <div class="dropdown-menu dropdown-menu-right menu"> <a class="dropdown-item active" [routerLink]="" (click)="updatePageSize(5)">3</a> <a class="dropdown-item" [routerLink]="" (click)="updatePageSize(20)">20</a> <a class="dropdown-item" [routerLink]="" (click)="updatePageSize(100)">100</a> </div> </div> <span class="align-self-center mb-1 mr-2 text-muted"> Shows {{page.numberOfElements}} of {{page.totalElements}} </span> <a [routerLink]="" class="btn btn-outline" [ngClass]="{'disabled': page.first }" (click)="previousPage()"> <i class="material-icons">chevron_left</i> </a> <a [routerLink]="" class="btn btn-outline" [ngClass]="{'disabled': page.last }" (click)="nextPage()"> <i class="material-icons">chevron_right</i> </a> </div> |
Let’s explain some code in this template.
We have a dropdown
with hardcoded values for available page sizes:
1 2 3 4 5 6 7 8 |
<!-- src/app/pagination/components/custom-pagination/custom-pagination.component.html --> … <div class="dropdown-menu dropdown-menu-right menu"> <a class="dropdown-item active" [routerLink]="" (click)="updatePageSize(5)">3</a> <a class="dropdown-item" [routerLink]="" (click)="updatePageSize(20)">20</a> <a class="dropdown-item" [routerLink]="" (click)="updatePageSize(100)">100</a> </div> … |
We also took care of disabling the previous
and next
controls in case the given page is first or last:
1 2 3 4 5 6 7 8 |
<!-- src/app/pagination/components/custom-pagination/custom-pagination.component.html --> … <a [routerLink]="" class="btn btn-outline" [ngClass]="{'disabled': page.first }" (click)="previousPage()"> <i class="material-icons">chevron_left</i> </a> … |
In my example project build with Daemonite Material theme (based on Bootstrap) the component looks like this:
Include the pagination component in a component that serves the datatable
I want the pagination to be displayed under the table with the projects, so I included the component in my projects-table.component
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
<!-- src/app/projects/components/data-table/projects-table/projects-table.component.html --> <div class="card mt-4"> … <table> … </table> <hr class="my-no w-100"> <app-custom-pagination [page]="page" (nextPageEvent)="getNextPage()" (previousPageEvent)="getPreviousPage()" (pageSizeEvent)="getPageInNewSize($event)"> </app-custom-pagination> </div> |
Here, the pagination component is given the page with projects and it can emit events notifying our projects-table.component
that a user requested for a different page or changed the size of the page. The ProjectsTableComponent
reacts for those events with the appropriate methods.
The projects-table.component
has to request for project list every time the page is loaded:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
// src/app/projects/components/data-table/projects-table/projects-table.component.ts import { Component, OnInit } from '@angular/core'; import { Project } from '../../../project'; import { Page } from '../../../../pagination/page'; import { Pageable } from '../../../../pagination/pageable'; import { ProjectDataService } from '../../../services/project-data.service'; import { CustomPaginationService } from '../../../../pagination/services/custom-pagination.service'; @Component({ selector: 'app-projects-table', templateUrl: './projects-table.component.html', styleUrls: ['./projects-table.component.scss'] }) export class ProjectsTableComponent implements OnInit { page: Page<Project> = new Page(); constructor( private projectDataService: ProjectDataService, private paginationService: CustomPaginationService ) { } ngOnInit() { this.getData(); } private getData(): void { this.projectDataService.getPage(this.page.pageable) .subscribe(page => this.page = page); } … } |
Now, we are going to add the following methods to handle user requests made in the pagination component (answer the emitted events):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// src/app/projects/components/data-table/projects-table/projects-table.component.ts … public getNextPage(): void { this.page.pageable = this.paginationService.getNextPage(this.page); this.getData(); } public getPreviousPage(): void { this.page.pageable = this.paginationService.getPreviousPage(this.page); this.getData(); } public getPageInNewSize(pageSize: number): void { this.page.pageable = this.paginationService.getPageInNewSize(this.page, pageSize); this.getData(); } … |
We simply modify the pageable
property every time a user manipulates pagination settings. Then, the getData()
method retrieves a new list of projects from the projectDataService
.
Use a designated service to get data
You can find the implementation of the getPage(pageable)
method from the ProjectDataService
below :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
// src/app/projects/services/project-data.service.ts import { Injectable } from '@angular/core'; import { environment } from '../../../environments/environment'; import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Project } from '../project' ; import { Page } from '../../pagination/page' ; import { Pageable } from '../../pagination/pageable' ; const httpOptions = { headers: new HttpHeaders({ 'Content-Type': 'application/json' }) }; const API_URL = environment.apiUrl; @Injectable({ providedIn: 'root' }) export class ProjectDataService { private projectsUrl = API_URL + '/projects'; constructor(private http: HttpClient) { } public getPage(pageable: Pageable): Observable<Page<Project>> { let url = this.projectsUrl + '?page=' + pageable.pageNumber + '&size=' + pageable.pageSize + '&sort=id'; return this.http.get<Page<Project>>(url, httpOptions); } … |
We use the default sorting – by id. Check out the Handle server-side sorting in an Angular application post to see how you can implement the interactive sorting fearure.
Test the pagination
I updated the example project to Angular 8 and realised that some fixes are required. You will find them in the commit 1057f352abc.
Updated CustomPaginationService test. |
When testing the CustomPaginationService
, we check whether the page number can be increased and decreased properly, and whether the change in page size redirects a user to the first page:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
import { TestBed, getTestBed } from '@angular/core/testing'; import { HttpClientTestingModule } from '@angular/common/http/testing'; import { CustomPaginationService } from './custom-pagination.service'; import { Page } from '../../pagination/page'; import { Pageable } from '../../pagination/pageable'; describe('CustomPaginationService', () => { let injector: TestBed; let service: CustomPaginationService; let page: Page<any> = new Page(); beforeEach(() => { TestBed.configureTestingModule({ providers: [CustomPaginationService], imports: [HttpClientTestingModule] }); injector = getTestBed(); service = injector.get(CustomPaginationService); }); it('should increase page number', () => { page.last = false; page.pageable.pageNumber = 0; let result = service.getNextPage(page); expect(result.pageNumber).toEqual(1); }); it('should not increase page number if it is currently on the last page', () => { page.last = true; page.pageable.pageNumber = 0; let result = service.getNextPage(page); expect(result.pageNumber).toEqual(0); }); it('should decrease page number', () => { page.first = false; page.pageable.pageNumber = 1; let result = service.getPreviousPage(page); expect(result.pageNumber).toEqual(0); }); it('should not decrease page number if it is currently on the first page', () => { page.first = true; page.pageable.pageNumber = Pageable.FIRST_PAGE_NUMBER; let result = service.getPreviousPage(page); expect(result.pageNumber).toEqual(Pageable.FIRST_PAGE_NUMBER); }); it('should refresh page number when getting page in new size', () => { page.pageable.pageSize = 10; page.pageable.pageNumber = 1; let result = service.getPageInNewSize(page, 20); expect(result.pageSize).toEqual(20); expect(result.pageNumber).toEqual(Pageable.FIRST_PAGE_NUMBER); }); }); |
When testing the ProjectDataService
we create a simple JSON with paginated projects and we flush it when the getPage()
method is tested:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
// src/app/projects/services/project-data.service.spec.ts import { getTestBed, TestBed } from '@angular/core/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { ProjectDataService } from './project-data.service'; import { Project } from '../project'; import { Page } from '../../pagination/page'; import { Pageable } from '../../pagination/pageable'; import { environment } from '../../../environments/environment.prod'; describe('ProjectDataService', () => { let injector: TestBed; let service: ProjectDataService; let httpMock: HttpTestingController; let apiProjectsUrl = environment.apiUrl + '/projects'; let projectList = [ {"name":"test1", "description":"test1", "id":1}, {"name":"test2", "id":2} ]; let page: Page<Project> = new Page(); let pageable: Pageable = new Pageable(); let paginatedProjects = { "content": [ { "name": "test_name_1", "description": "test_description", "id": 1 } ], "pageable": { "sort": { "sorted": false, "unsorted": true }, "pageSize": 20, "pageNumber": 0, "offset": 0, "unpaged": false, "paged": true }, "last": true, "totalElements": 1, "totalPages": 1, "first": true, "sort": { "sorted": false, "unsorted": true }, "numberOfElements": 1, "size": 20, "number": 0 } beforeEach(() => { TestBed.configureTestingModule({ providers: [ProjectDataService], imports: [HttpClientTestingModule] }); injector = getTestBed(); service = injector.get(ProjectDataService); httpMock = injector.get(HttpTestingController); }); afterEach(() => { httpMock.verify(); }); it('should retrieve default page with paginated projects', () => { service.getPage(pageable).subscribe((projects: any) => { expect(projects).toEqual(paginatedProjects); }); const req = httpMock.expectOne(`${apiProjectsUrl}?page=0&size=3&sort=id`); expect(req.request.method).toBe("GET"); req.flush(paginatedProjects); }); … |
Photo by Negative Space on StockSnap
Excellent tutorial! Thanks!
I want to say…God bless author of this article and website)
Hi Can you explain how can I use it with subscription
I have method as following
//in app.component.ts
// In app.component.ts
getObjects(){
this.objectService.findUsers(this.searchParams).subscribe
((response: any) => {
// do some operations here
});
}
// In objectService.ts
findObjects(searchParams: searchParams) { // This is common method which can be used without pagination too. therefore
we dont want to define Observable here, Can you help how can we achieve
return this.http.get(url, httpOptions);
}
Hi,
I’m afraid that making pagination code work without pagination is outside of the scope of this article. Although I can’t help you, I encourage you to start with writing a set of unit tests. It may help you to clarify the issue and requirements. Furthermore, the tests may show you the right direction to solve this advanced use case or assist you in getting better answers on StackOverflow.
Hello!
Could you give me a light on how the server-side paging would look, I’m implementing this example on the angular but on the server I still couldn’t do it on my webapi project.
Hi,
I built the backend with Spring Boot. Therefore, I could use the PagingAndSortingRepository which does all the work for me. I described it in the How to add pagination to a Spring Boot app post and implemented it in the scrum_ally project (e.g.
ProjectController::findAllForCurrentUser()
). ThePage<T>
class I created in this post has the same structure as the default json response returned from my Spring Boot repositories that extend the PagingAndSortingRepository.If the framework you use to build an API already handles pagination it can save you a lot of work. Otherwise, you can find some inspiration and good practices in articles like those:
Implementing the server side-paging is worth the work because it helps to keep performance of an API on a decent level. Even a simple usage of
limit
andoffset
parameters passed from frontend to our SQL queries is a great start.