When our frontend gets a paginated result from an API, we have to handle not only paging, but also sorting of the outcome. In this post, I show how to create a custom sorting component that works well with a standard Page
object returned by a Spring Boot API.
What we are going to build
The goal is to build a custom sorting component that accepts a paginated response from a Spring Boot API and displays it in a data table. We will be able to control the sorting direction and a column that will be used to arrange records.
Our example app will be displaying 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 from that project, with sorting, on the following screenshot:
Architecture overview
You can see the final directory tree for the sorting 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.
- You need to handle the backend pagination. Make sure to read the Handle server-side pagination in an Angular application post first.
Handle sorting
Consume a page received from a Spring Boot app
Make sure, that the frontend is able to consume a page received from an API. We are doing it by mirroring the Page
instance (code from the Handle server-side pagination in an Angular application post):
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 pageSize
to your needs.
Create a sortable-column class
We need a class that will carry the internal name, displayed title and sorting direction of a column:
1 2 3 4 5 6 7 8 9 10 11 12 |
// src/app/sorting/sortable-column.ts export class SortableColumn { name: string; title: string; direction: string; public constructor(name: string, title: string, direction: string) { this.name = name; this.title = title; this.direction = direction; } } |
Let’s add a function for toggling the direction of the sorting, it will be called every time a user clicks a column title:
1 2 3 4 5 6 7 8 9 10 11 12 |
// src/app/sorting/components/sortable-column.component.ts … public toggleDirection() { if(this.direction == 'desc') { this.direction = null; } else if(this.direction == 'asc') { this.direction = 'desc'; } else { this.direction = 'asc'; } } } |
The first click results in setting the ascending order, the second click gives the descending order and the third click clears sorting (the arrow won’t be shown and the records will be sorted by id, ascending, by default).
Create a sortable-header component
Generate the component with the following command:
1 |
$ ng generate component sorting/components/sortable-headers |
Copy the following code to make the component accept an array filled with sortable-column objects and emit events when users trigger sorting:
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 |
// src/app/sorting/components/sortable-headers/sortable-headers.component.ts import { Component, OnInit, Input, EventEmitter, Output } from '@angular/core'; import { SortableColumn} from "../../sortable-column"; @Component({ selector: 'app-sortable-headers', templateUrl: './sortable-headers.component.html', styleUrls: ['./sortable-headers.component.scss'] }) export class SortableHeadersComponent implements OnInit { @Input() sortableColumns: Array<SortableColumn>; @Output() sortEvent: EventEmitter<SortableColumn> = new EventEmitter<SortableColumn>(); constructor() { } ngOnInit() { } sort(column: SortableColumn): void { column.toggleDirection(); this.sortEvent.emit(column); } } |
An html template displays columns titles in <th>
elements. On the right of every title there is a place for a sort icon – it reflects the chosen sorting direction. The icon is displayed only for a column for which the sorting was activated:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
<!--src/app/sorting/components/sortable-headers/sortable-headers.component.html--> <th scope="col" *ngFor="let column of sortableColumns" (click)="sort(column)"> {{column.title}} <span *ngIf="column.direction != null"> <span *ngIf="column.direction=='asc'; then sortAscending else sortDescending"></span> </span> </th> <ng-template #sortDescending> <i class="material-icons"> arrow_drop_down </i> </ng-template> <ng-template #sortAscending> <i class="material-icons"> arrow_drop_up </i> </ng-template> |
A user triggers sort()
function by clicking the column title.
Add a service to handle base actions
Use the following command to generate a service:
1 |
$ ng generate service sorting/services/custom-sorting |
In this service we are going to add two public methods. One for getting the column that has a non-null sort direction set – that’s the column chosen by a user:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// src/app/sorting/services/custom-sorting.service.ts import { Injectable } from '@angular/core'; import {SortableColumn} from "../sortable-column"; @Injectable({ providedIn: 'root' }) export class CustomSortingService { constructor() { } public getSortableColumn(sortableColumns: SortableColumn[]): SortableColumn { return sortableColumns.find( column => column.direction != null ); } … |
The other, for setting direction in all other columns to null
– it will remove a sorting icon displayed in a datatable from a previously chosen column:
1 2 3 4 5 6 7 8 9 10 |
// src/app/sorting/services/custom-sorting.service.ts … public clearPreviousSorting(chosenColumn: SortableColumn, sortableColumns: SortableColumn[]) { sortableColumns.filter( column => column != chosenColumn ).forEach( column => column.direction = null ); } … |
The service and our component are ready to be used in all components that serves datatables.
Include our sorting feature in a datatable
In the template with the datatable (e.g. projects-table.component.html
), replace the old <th>
elements (e.g
<th scope="col">Project name</th> ) with the sortable-header
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--> … <table class="table table-hover"> <thead> <tr> <app-sortable-headers [sortableColumns]="sortableColumns" (sortEvent)="sort($event)"> </app-sortable-headers> <!--other columns that are non-sortable--> <th scope="col" class="text-right">Actions</th> </tr> </thead> … |
Now, we need to provide the actual sortableColumns
array. Declare it in the component that serves the view (e.g. projects-table.component.ts
):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// src/app/projects/components/data-table/projects-table/projects-table.component.ts import {CustomSortingService} from "../../../../sorting/services/custom-sorting.service"; … export class ProjectsTableComponent implements OnInit { … sortableColumns: Array<SortableColumn> = [ new SortableColumn('name', 'Name', 'asc'), new SortableColumn('otherColumn', 'Other Title', null), … ]; … constructor( … private sortingService: CustomSortingService ) { } ngOnInit() { this.getData(); } |
If you provide column names, titles (displayed in the view) and sort order (for the one you choose as a default sorting column), the records will be sorted when your app loads the view.
When adding more columns, remember to leave the third parameter, direction
, set to null
. Only one column should have the direction set to 'asc'
or 'desc'
value. The direction
in the rest of them has to be set to null
.
Let’s provide the implementation of the sort()
method, called when a user clicks a column title:
1 2 3 4 5 6 7 |
// src/app/projects/components/data-table/projects-table/projects-table.component.ts … public sort(sortableColumn: SortableColumn): void { this.sortingService.clearPreviousSorting(sortableColumn, this.sortableColumns); this.getData(); } … |
When the sort()
method is called, the column for which it was triggered is filtered out from the list of all columns so we can set the direction
field in the rest of them to null.
Then, we call the getData()
function, so let’s take a look at the implementation:
1 2 3 4 5 6 7 8 |
// src/app/projects/components/data-table/projects-table/projects-table.component.ts … private getData(): void { let column = this.sortingService.getSortableColumn(this.sortableColumns); this.projectDataService.getPage(this.page.pageable, column) .subscribe(page => this.page = page); } … |
The getData()
function is called every time the page is loaded, refreshed, the pagination has been changed, so it has to be able to retrieve the sorting column. It does so by using the sortingSercive
we created previously. Next, it can pass the chosen column to the projectDataService
. Now, we can move on to the next step – adjusting the service so it can pass sorting parameters to the backend.
Update a service that handles requests
The getPage()
method accepts two arguments: pageable
and sortableColumn
. If the sortableColumn is null, the response will be sorted by the id of the records by default. However, if it is set, the column name and the sort direction will be passed to the backend, as you can see below:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// src/app/projects/services/project-data.service.ts … public getPage(pageable: Pageable, sortableColumn: SortableColumn) : Observable<Page<Project>> { let url = this.projectsUrl + '?page=' + pageable.pageNumber + '&size=' + pageable.pageSize + this.getSortParameters(sortableColumn) return this.http.get<Page<Project>>(url, httpOptions); } private getSortParameters(sortableColumn: SortableColumn): string { if(sortableColumn == null) { return '&sort=id'; } return '&sort=' + sortableColumn.name + ',' + sortableColumn.direction; } … |
Thanks to that, you are able to pass the sort parameters alongside the paging parameters to the API and receive a nice, paginated and already sorted response.
Test the sorting feature
I updated the example project to Angular 8 and realised that some fixes are required. You will find them in the commit 1057f352abc.
Updated CustomSortingService test. |
Add those tests to our custom-sorting.service.spec.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 35 36 37 38 39 40 41 42 |
// src/app/sorting/services/custom-sorting.service.spec.ts import {getTestBed, TestBed} from '@angular/core/testing'; import { CustomSortingService } from './custom-sorting.service'; import {SortableColumn} from "../sortable-column"; describe('CustomSortingService', () => { let injector: TestBed; let service: CustomSortingService; let sortableColumns: Array<SortableColumn> = [ new SortableColumn('id', 'Id', null), new SortableColumn('name', 'Name', 'asc') ]; beforeEach(() => { TestBed.configureTestingModule({ providers: [CustomSortingService] }); injector = getTestBed(); service = injector.get(CustomSortingService); }); it('should be created', () => { const service: CustomSortingService = TestBed.get(CustomSortingService); expect(service).toBeTruthy(); }); it('should get a column chosen for sorting', () => { let result = service.getSortableColumn(sortableColumns); expect(result.name).toEqual('name'); }); it('should clear direction form previously chosen column, when new sorting is issued', () => { sortableColumns[0].direction = 'desc'; service.clearPreviousSorting(sortableColumns[0], sortableColumns); expect(sortableColumns[0].name).toEqual('id'); expect(sortableColumns[0].direction).toEqual('desc'); expect(sortableColumns[1].name).toEqual('name'); expect(sortableColumns[1].direction).toEqual(null); }); }); |
You also need to updated tests for all components that uses the SortableHeaderComponents
(eg. projects-table.component.spec.ts
) :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// src/app/projects/components/data-table/projects-table/projects-table.component.spec.ts import { SortableColumn } from 'src/app/sorting/sortable-column'; … @Component({selector: 'app-sortable-headers', template: ''}) class SortableHeadersComponent { @Input() sortableColumns: Array<SortableColumn>; } … beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ … SortableHeadersComponent ], … |
Photo by Ivan Cujic on StockSnap
Server side, the WebApi controller s Get method. Further, there must be still some issues in my code: when I change the page, the server gets the correct params and returns the next N items; if I set a breakpoint in the JS success handler I can browse these new items; but at the end the view is not refreshed, and I keep seeing the first page instead of the second. Naftis Jul 22 ’13 at 18:16
Hi,
If I read the description correctly, the issue occurs when you use the pagination component – clicking the next page retrieves a new page from the backend, but the frontend does not refresh the table content. There isn’t much info here to help you with debugging, but the following few things come to my mind:
1. Errors in the developer console
Do you get any errors when you click the Next page icon and have the developer console open? If any errors are displayed in the console, you can use them to debug/google the problem more effectively.
2. Are all tests passing?
Make sure that all frontend tests are passing ($ ng test).
If you work on your personal project, check out the example tests from the repo:
* for a service retrieving data from the API
* for a custom pagination service
and try to implement them. It may help.
3. Is the new page retrieved from the API really passed to the component with datatable?
To clarify, in the component that serves the list of requested items, you have a function for requesting a new page:
// 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();
}
and the getData implementation subscribes the result when it is retrieved from the API (.subscribe(page => this.page = page)):
// src/app/projects/components/data-table/projects-table/projects-table.component.ts
private getData(): void {
let column = this.sortingService.getSortableColumn(this.sortableColumns);
this.projectDataService.getPage(this.page.pageable, column)
.subscribe(page => this.page = page);
}
and in the template of this component, you pass this page to the table:
<!–src/app/projects/components/data-table/projects-table/projects-table.component.html–>
<tbody *ngIf=”page”>
<tr *ngFor=”let project of page.content”>
and in the same template, you pass this page to the custom pagination component ([page]=”page”):
<!–src/app/projects/components/data-table/projects-table/projects-table.component.html–>
<app-custom-pagination
[page]=”page”
(nextPageEvent)=”getNextPage()”
(previousPageEvent)=”getPreviousPage()”
(pageSizeEvent)=”getPageInNewSize($event)”>
</app-custom-pagination>
I hope you will solve the issue soon. In case you are working on a direct clone from the scrum_ally repo (master branch) , without custom changes, please create an issue in the repo, providing enough info to replicate this problem (e.g. a failing test).
Regards, little_pinecone
Hi,
I’ve been visiting your website a few times and decided to give you some positive feedback because I find it very useful. Well done.
I was wondering if you as someone with experience of creating a useful website could help me out with my new site by giving some feedback about what I could improve?
You can find my site by searching for “casino gorilla” in Google (it’s the gorilla themed online casino comparison).
I would appreciate if you could check it out quickly and tell me what you think.
casinogorilla.com
Thank you for help and I wish you a great week!