Recently I had a to help a team add pagination in their application. The application was built using:
- Spring Boot 2.x
- Angular 7
- Spring Data JPA
In this blog I will quickly show how to add pagination to a Spring Boot 2.2.6 and Angular 9.1.1 application.
To quickly build the application we will use the spring-boot-maven-angular-starter project I wrote a couple of years. It is updated to the latest version of Spring Boot and Angular.
Let’s start by cloning the Github repository.
$ git clone git@github.com:shekhargulati/spring-boot-maven-angular-starter.git
You don’t have to type
$
. It signifies command prompt.
The code is divided into two folders.
backend
: This contains Java code of the application.ui
: This contains source code for the Angular based frontend.
In this tutorial, we will build a simple todo application as an example.
Building the Todo application backend
Open the code in your favourite editor/IDE. I use IntelliJ Idea.
We will quickly build the backend of a simple Todo application. As we will use in-memory relational database H2 for storing the data we will add spring-boot-starter-data-jpa
and h2
dependency in the backend/pom.xml
in the dependencies
section.
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> </dependency>
Now, we will write our entity class Todo
import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.Table; @Entity @Table(name = "todos") public class Todo { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long id; private String task; private boolean done; Todo() { } public Todo(String task) { this.task = task; } public void done() { this.done = true; } public void undone() { this.done = false; } public Long getId() { return id; } public String getTask() { return task; } public boolean isDone() { return done; } }
The code shown above:
- Create a
Todo
entity class. We annotated it with JPA annotations. Todo
has three fieldsid
,task
, anddone
that will be persisted.- The class has two methods
done
andundone
that provides behavior to marking a todo done or undone.
Next, we will create the repository interface TodoRepository
import com.shekhargulati.app.domain.Todo; import org.springframework.data.repository.PagingAndSortingRepository; public interface TodoRepository extends PagingAndSortingRepository<Todo, Long> { }
TodoRepository
interface extends PagingAndSortingRepository
interface. This provides implementations for findAll
methods that support pagination.
Next, we will create our REST API controller TodoResource
import static java.util.stream.Collectors.toList; @RestController @RequestMapping(path = "/todos") public class TodoResource { private final TodoRepository todoRepository; @Autowired public TodoResource(TodoRepository todoRepository) { this.todoRepository = todoRepository; } @PostMapping public ResponseEntity<Void> create(@RequestBody TodoRequest todoRequest) { Todo saved = this.todoRepository.save( new Todo(todoRequest.getTask()) ); return ResponseEntity.status(HttpStatus.CREATED).build(); } @PostMapping(path = "/bulk") public ResponseEntity<Void> bulkCreate() { for (int i = 1; i <= 100; i++) { todoRepository.save(new Todo("Do task " + i)); } return ResponseEntity.status(HttpStatus.CREATED).build(); } @PutMapping(path = "/{id}/done") public ResponseEntity<Void> done(@PathVariable("id") Long todoId) { Optional<Todo> found = this.todoRepository.findById(todoId); Todo task = found.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); task.done(); todoRepository.save(task); return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } @PutMapping(path = "/{id}/undone") public ResponseEntity<Void> undone(@PathVariable("id") Long todoId) { Optional<Todo> found = this.todoRepository.findById(todoId); Todo task = found.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); task.undone(); todoRepository.save(task); return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } @GetMapping public Page<TodoResponse> list(@RequestParam(name = "page", defaultValue = "0") int page, @RequestParam(name = "size", defaultValue = "10") int size) { PageRequest pageRequest = PageRequest.of(page, size); Page<Todo> pageResult = todoRepository.findAll(pageRequest); List<TodoResponse> todos = pageResult .stream() .map(TodoResponse::new) .collect(toList()); return new PageImpl<>(todos, pageRequest, pageResult.getTotalElements()); } }
In the code shown above:
- We have five operations on the
/todos
endpoint. -
The POST methods creates the todo, two PATCHs are used to change task state from
done
toundone
and vice-versa. - Finally we have GET
list
method that return data in paginated manner. If user does not specify pagination parameters then defaults are assumed else we use what is provided in the request. We fetch the page data from the database using thefindAll(Pageable)
method on the repository interface. Once we have page of the data we transform the domain class to a DTO and return a new Page object.
Building the Angular frontend
We will build the simple Angular frontend for the listing tasks to showcase pagination. In Angular, you can add pagination in two ways:
- Using Angular Material DataTable
- Using normal table
Let’s start by adding Angular material dependency. In the ui
folder run the following command.
$ ng add @angular/material
We will update our AppComponent to render two links that we can use to view both implementations.
Change the app.component.html
to following.
<h1>Spring Boot Angular Pagination Example App</h1> <nav> <ul> <li><a routerLink="/todos1" routerLinkActive="active">DataTable Pagination Example</a></li> <li><a routerLink="/todos2" routerLinkActive="active">Normal Table Example</a></li> </ul> </nav> <router-outlet></router-outlet>
Next, we will change the app.component.ts
import { Component } from '@angular/core'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'] }) export class AppComponent { }
We wil configure our app to use Angular Material components mainly MatTable
and MatPaginator
. To do that we replace app.module.ts
with the following.
import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import {HttpClientModule} from '@angular/common/http'; import { MatTableModule } from '@angular/material/table'; import { MatPaginatorModule } from '@angular/material/paginator'; import { Todo2Component } from './todo2/todo2.component'; import { Todo1Component } from './todo1/todo1.component'; @NgModule({ declarations: [ AppComponent, Todo1Component, Todo2Component ], imports: [ BrowserModule, AppRoutingModule, BrowserAnimationsModule, HttpClientModule, MatTableModule, MatPaginatorModule ], providers: [], bootstrap: [AppComponent] }) export class AppModule { }
Next, we will generate two components todo1
and todo2
$ ng generate component todo1 $ ng generate component todo2
Now we will update app-routing.module.ts
to do correct routing.
import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; import { Todo2Component } from './todo2/todo2.component'; import { Todo1Component } from './todo1/todo1.component'; const routes: Routes = [ {path : 'todos1', component: Todo1Component}, {path : 'todos2', component: Todo2Component} ]; @NgModule({ imports: [RouterModule.forRoot(routes)], exports: [RouterModule] }) export class AppRoutingModule { }
One last thing you should change is configure base API URL in the environment.ts
export const environment = { production: false, apiUrl: "http://localhost:8080/api" };
If you run the frontend now you should two links that will take you to Todo1Component
and Todo2Component
.
Pagination Solution 1: Pagination using Angular Material DataTable
In the first solution we will add pagination to Angular Material Data table using MatPaginator.
We will start by creating the table in todo1.component.html
<table mat-table [dataSource]="todoDatasource" class="mat-elevation-z8"> <!--- Note that these columns can be defined in any order. The actual rendered columns are set as a property on the row definition" --> <ng-container matColumnDef="id"> <th mat-header-cell *matHeaderCellDef> Id </th> <td mat-cell *matCellDef="let element"> {{element.id}} </td> </ng-container> <!-- Name Column --> <ng-container matColumnDef="task"> <th mat-header-cell *matHeaderCellDef> Task </th> <td mat-cell *matCellDef="let element"> {{element.task}} </td> </ng-container> <!-- Weight Column --> <ng-container matColumnDef="done"> <th mat-header-cell *matHeaderCellDef> Done </th> <td mat-cell *matCellDef="let element"> {{element.done}} </td> </ng-container> <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> <tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr> </table> <mat-paginator [pageSizeOptions]="[10, 25, 100]" [pageSize]="10"></mat-paginator>
The above uses todoDatasource
that will be responsible for fetching the data from the REST API
Next, we will update our todo1.component.ts
to fetch the data and manage pagination.
import { Component, ViewChild } from '@angular/core'; import { TodoDataSource } from '../todo.datasource'; import { MatPaginator } from '@angular/material/paginator'; import { TodoService } from '../todo.service'; import { tap } from 'rxjs/operators'; @Component({ selector: 'app-todo2', templateUrl: './todo1.component.html', styleUrls: ['./todo1.component.css'] }) export class Todo1Component { displayedColumns = ['id', 'task', 'done']; todoDatasource: TodoDataSource; @ViewChild(MatPaginator) paginator: MatPaginator; constructor(private todoService: TodoService) { } ngOnInit() { this.todoDatasource = new TodoDataSource(this.todoService); this.todoDatasource.loadTodos(); } ngAfterViewInit() { this.todoDatasource.counter$ .pipe( tap((count) => { this.paginator.length = count; }) ) .subscribe(); this.paginator.page .pipe( tap(() => this.loadTodos()) ) .subscribe(); } loadTodos() { this.todoDatasource.loadTodos(this.paginator.pageIndex, this.paginator.pageSize); } }
The class above uses TodoDataSource
that will call the todo.service.ts
to fetch list of the todo items page by page.
import { DataSource } from '@angular/cdk/table'; import { TodoListResponse, Todo } from './todo'; import { CollectionViewer } from '@angular/cdk/collections'; import { Observable, BehaviorSubject, of } from "rxjs"; import { TodoService } from './todo.service'; import { catchError, finalize } from "rxjs/operators"; export class TodoDataSource implements DataSource<Todo>{ private todoSubject = new BehaviorSubject<Todo[]>([]); private loadingSubject = new BehaviorSubject<boolean>(false); private countSubject = new BehaviorSubject<number>(0); public counter$ = this.countSubject.asObservable(); constructor(private todoService: TodoService) { } connect(collectionViewer: CollectionViewer): Observable<Todo[]> { return this.todoSubject.asObservable(); } disconnect(collectionViewer: CollectionViewer): void { this.todoSubject.complete(); this.loadingSubject.complete(); this.countSubject.complete(); } loadTodos(pageNumber = 0, pageSize = 10) { this.loadingSubject.next(true); this.todoService.listTodos({ page: pageNumber, size: pageSize }) .pipe( catchError(() => of([])), finalize(() => this.loadingSubject.next(false)) ) .subscribe((result: TodoListResponse) => { this.todoSubject.next(result.content); this.countSubject.next(result.totalElements); } ); } }
The todo.service.ts
is shown below.
import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { environment } from '../environments/environment'; @Injectable({ providedIn: 'root' }) export class TodoService { constructor(private http: HttpClient) { } listTodos(request) { const endpoint = environment.apiUrl + "/todos"; const params = request; return this.http.get(endpoint, { params }); } }
The TodoListResponse.ts
is shown below.
export interface TodoListResponse { content: Todo[]; totalElements: number; } export interface Todo{ id: number; task: string; done: boolean }
If you run the app now you will see data fetched from the backend. Please make sure backend is up and running.
When you click next you will see page by page data fetched from the backend.
You can also read about the detailed explanation in this post by Angular University.
Pagination Solution 2: Using normal table
We can use MatPaginator
with the normal table as well.
Update the todo2.component.html
with the following code.
<div> <table class="mat-table"> <thead> <tr class="mat-header-row"> <th class='mat-header-cell'>Id</th> <th class='mat-header-cell'>Task</th> <th class='mat-header-cell'>Done</th> </tr> </thead> <tbody> <tr class="mat-row" *ngFor="let todo of todos"> <td class="mat-cell">{{todo.id}}</td> <td class="mat-cell">{{todo.task}}</td> <td class="mat-cell">{{todo.done}}</td> </tr> </tbody> </table> <mat-paginator [pageSizeOptions]="[10, 25, 100]" [pageSize]="10" [length]="totalElements" (page)="nextPage($event)"> </mat-paginator> </div>
As you can see in the above code we used normal table with only material style. We used mat-paginator
as we used in the first solution. But this time we are relying on page event of MatPaginator instead.
The todo2.component.ts
that takes care of calling the TodoService is shown below.
import { Component, OnInit } from '@angular/core'; import { TodoService } from '../todo.service'; import { Todo } from '../todo'; import { PageEvent } from '@angular/material/paginator'; @Component({ selector: 'app-todo2', templateUrl: './todo2.component.html', styleUrls: ['./todo2.component.css'] }) export class Todo2Component implements OnInit { totalElements: number = 0; todos: Todo[] = []; loading: boolean; constructor(private todoService: TodoService) { } ngOnInit(): void { this.getTodos({ page: "0", size: "10" }); } private getTodos(request) { this.loading = true; this.todoService.listTodos(request) .subscribe(data => { this.todos = data['content']; this.totalElements = data['totalElements']; this.loading = false; }, error => { this.loading = false; }); } nextPage(event: PageEvent) { const request = {}; request['page'] = event.pageIndex.toString(); request['size'] = event.pageSize.toString(); this.getTodos(request); } }
Now if go to the UI you will see todos2
route also working.
Conclusion
In this post I wanted to show how you can quickly add pagination in an Angular Spring Boot application. I suggest you don’t consider pagination late in the application as it becomes very difficult to add in the later stages of the project. Add pagination from the day 1. You will thank me later.
The complete code is available on Github: boot-angular-pagination-example-app
Excellent, works well, thank you.