Spring Boot + Bootstrap + Thymeleaf Pagination (JPA, Liquibase, H2)

July 06, 2020 No comments Spring Boot Bootstrap Thymeleaf Pagination Liquibase JPA

Spring Boot + Bootstrap + Thymeleaf Pagination

1. Introduction

In this article, we are going to present Thymeleaf Pagination component based on the Bootstrap framework and Spring JPA. Dividing database results into pages is a common functionality used in many applications. This tutorial will show how to use the Thymeleaf template to create a nice pagination component.

For more useful informations about Thymeleaf and Bootstrap tables check the following links:
Thymeleaf Features
DataTable component with Thymeleaf

2. Dependencies

2.1. Maven dependencies

The POC (proof of concept) application was created as a Maven project using the following dependencies:

2.2. Front-End libraries
  • bootstrap - a frontend framework for creating responsive websites.

Project Maven pom.xml file has the following structure:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <artifactId>thymeleaf-bootstrap-pagination</artifactId>

    <properties>
        <bootstrap.version>4.0.0-2</bootstrap.version>
        <webjars-locator.version>0.30</webjars-locator.version>
        <lombok.version>1.18.2</lombok.version>
        <liquibase-core.version>3.8.8</liquibase-core.version>
        <h2.version>1.4.200</h2.version>
    </properties>

    <!-- Inherit defaults from Spring Boot -->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.5.RELEASE</version>
    </parent>

    <!-- Add typical dependencies for a web application -->
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <dependency>
            <groupId>org.liquibase</groupId>
            <artifactId>liquibase-core</artifactId>
            <version>${liquibase-core.version}</version>
        </dependency>

        <dependency>
            <groupId>org.webjars</groupId>
            <artifactId>bootstrap</artifactId>
            <version>${bootstrap.version}</version>
        </dependency>

        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <version>${h2.version}</version>
        </dependency>

        <dependency>
            <groupId>org.webjars</groupId>
            <artifactId>webjars-locator</artifactId>
            <version>${webjars-locator.version}</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>${lombok.version}</version>
            <scope>provided</scope>
        </dependency>
    </dependencies>

    <!-- Package as an executable jar -->
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

3. Model, View, Controller layers

The sample application using H2 Database and Liquibase for managing database changes.

So, first we need to configure these libraries in our application.properties file:

spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
spring.datasource.username=h2
spring.datasource.password=pass

spring.liquibase.change-log=classpath:/db-scripts/db-changelog-master.xml

In the database layer, we create a single entity Post with fields like title and body.

package com.frontbackend.thymeleaf.bootstrap.posts.entity;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import javax.persistence.Entity;
import javax.persistence.Id;

@Entity(name = "POSTS")
@NoArgsConstructor
@Setter
@Getter
public class Post {

    @Id
    private Long id;

    private String title;
    private String body;
}

From the Data Access Object (PostDAO), that extends JpaRepository, we will use the findAll method that takes the Pageable parameter. Pageable class stores all information we need to limit and sort the result records.

package com.frontbackend.thymeleaf.bootstrap.posts.control.dao;

import org.springframework.data.jpa.repository.JpaRepository;

import com.frontbackend.thymeleaf.bootstrap.posts.entity.Post;

public interface PostDAO extends JpaRepository<Post, Long> {
}

PostService class is responsible for preparing PageRequest class and for wrapping the results with Paged instance:

package com.frontbackend.thymeleaf.bootstrap.posts.control.service;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;

import com.frontbackend.thymeleaf.bootstrap.posts.control.dao.PostDAO;
import com.frontbackend.thymeleaf.bootstrap.posts.entity.Post;
import com.frontbackend.thymeleaf.bootstrap.posts.entity.paging.Paged;
import com.frontbackend.thymeleaf.bootstrap.posts.entity.paging.Paging;

@Service
public class PostService {

    private final PostDAO postDAO;

    @Autowired
    public PostService(PostDAO postDAO) {
        this.postDAO = postDAO;
    }

    public Paged<Post> getPage(int pageNumber, int size) {
        PageRequest request = PageRequest.of(pageNumber - 1, size, new Sort(Sort.Direction.ASC, "id"));
        Page<Post> postPage = postDAO.findAll(request);
        return new Paged<>(postPage, Paging.of(postPage.getTotalPages(), pageNumber, size));
    }
}

Paged, PageItem, PageItemType and Paging are utility classes used for preparing pagination component.

The Paged class has the following structure:

package com.frontbackend.thymeleaf.bootstrap.posts.entity.paging;

import org.springframework.data.domain.Page;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Paged<T> {

    private Page<T> page;

    private Paging paging;

}

PageItem has the following structure:

package com.frontbackend.thymeleaf.bootstrap.posts.entity.paging;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Setter
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PageItem {

    private PageItemType pageItemType;

    private int index;

    private boolean active;

}

PageItemType is an enum that holds two values:

package com.frontbackend.thymeleaf.bootstrap.posts.entity.paging;

public enum PageItemType {

    DOTS,
    PAGE

}

Paging class is responsible for calculating how pages and dots will be displayed in the pagination component:

package com.frontbackend.thymeleaf.bootstrap.posts.entity.paging;

import java.util.ArrayList;
import java.util.List;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Setter
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Paging {

    private static final int PAGINATION_STEP = 3;

    private boolean nextEnabled;
    private boolean prevEnabled;
    private int pageSize;
    private int pageNumber;

    private List<PageItem> items = new ArrayList<>();

    public void addPageItems(int from, int to, int pageNumber) {
        for (int i = from; i < to; i++) {
            items.add(PageItem.builder()
                              .active(pageNumber != i)
                              .index(i)
                              .pageItemType(PageItemType.PAGE)
                              .build());
        }
    }

    public void last(int pageSize) {
        items.add(PageItem.builder()
                          .active(false)
                          .pageItemType(PageItemType.DOTS)
                          .build());

        items.add(PageItem.builder()
                          .active(true)
                          .index(pageSize)
                          .pageItemType(PageItemType.PAGE)
                          .build());
    }

    public void first(int pageNumber) {
        items.add(PageItem.builder()
                          .active(pageNumber != 1)
                          .index(1)
                          .pageItemType(PageItemType.PAGE)
                          .build());

        items.add(PageItem.builder()
                          .active(false)
                          .pageItemType(PageItemType.DOTS)
                          .build());
    }

    public static Paging of(int totalPages, int pageNumber, int pageSize) {
        Paging paging = new Paging();
        paging.setPageSize(pageSize);
        paging.setNextEnabled(pageNumber != totalPages);
        paging.setPrevEnabled(pageNumber != 1);
        paging.setPageNumber(pageNumber);

        if (totalPages < PAGINATION_STEP * 2 + 6) {
            paging.addPageItems(1, totalPages + 1, pageNumber);

        } else if (pageNumber < PAGINATION_STEP * 2 + 1) {
            paging.addPageItems(1, PAGINATION_STEP * 2 + 4, pageNumber);
            paging.last(totalPages);

        } else if (pageNumber > totalPages - PAGINATION_STEP * 2) {
            paging.first(pageNumber);
            paging.addPageItems(totalPages - PAGINATION_STEP * 2 - 2, totalPages + 1, pageNumber);

        } else {
            paging.first(pageNumber);
            paging.addPageItems(pageNumber - PAGINATION_STEP, pageNumber + PAGINATION_STEP + 1, pageNumber);
            paging.last(totalPages);
        }

        return paging;
    }
}

GET requests are handled by the PostController class. To change the current page and page size we need to add two request parameters:

  • pageNumber - current page number (default first page),
  • size - page size (default 5 rows):
package com.frontbackend.thymeleaf.bootstrap.posts.boundary;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;

import com.frontbackend.thymeleaf.bootstrap.posts.control.service.PostService;

@Controller
@RequestMapping("/")
public class PostController {

    private final PostService postService;

    @Autowired
    public PostController(PostService postService) {
        this.postService = postService;
    }

    @GetMapping
    public String posts(@RequestParam(value = "pageNumber", required = false, defaultValue = "1") int pageNumber,
            @RequestParam(value = "size", required = false, defaultValue = "5") int size, Model model) {
        model.addAttribute("posts", postService.getPage(pageNumber, size));
        return "posts";
    }
}

The Application is the Java class with the main method that starts the Spring Boot application server:

package com.frontbackend.thymeleaf.bootstrap;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

4. Thymeleaf template

The presentation layer contains single Thymeleaf template posts.html:

The posts.html view has the following structure:

<!DOCTYPE HTML>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8"/>
    <title>Spring Boot Thymeleaf Application - Bootstrap Pagination</title>

    <link th:rel="stylesheet" th:href="@{webjars/bootstrap/4.0.0-2/css/bootstrap.min.css} "/>

</head>
<body>

<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark static-top">
    <div class="container">
        <a class="navbar-brand" href="/">Thymeleaf - Bootstrap Pagination</a>
        <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarResponsive"
                aria-controls="navbarResponsive"
                aria-expanded="false" aria-label="Toggle navigation">
            <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarResponsive">
            <ul class="navbar-nav ml-auto">
                <li class="nav-item active">
                    <a class="nav-link" href="#">Home
                        <span class="sr-only">(current)</span>
                    </a>
                </li>
                <li class="nav-item">
                    <a class="nav-link" href="#">About</a>
                </li>
                <li class="nav-item">
                    <a class="nav-link" href="#">Services</a>
                </li>
                <li class="nav-item">
                    <a class="nav-link" href="#">Contact</a>
                </li>
            </ul>
        </div>
    </div>
</nav>

<div class="container">
    <div class="row">
        <div class="col-lg-10 mt-5 mb-5">
            <table id="posts" class="table table-bordered table-responsive-sm">
                <thead>
                <tr>
                    <th>ID</th>
                    <th>Title</th>
                    <th>Body</th>
                </tr>
                </thead>
                <tbody>
                <tr th:each="post : ${posts.page}">
                    <td th:text="${post.id}">id</td>
                    <td th:text="${post.title}">title</td>
                    <td th:text="${post.body}">body</td>
                </tr>
                </tbody>
            </table>

            <nav aria-label="Page navigation" class="paging">
                <ul class="pagination" th:if="${posts.page.totalPages > 1}">
                    <li class="page-item" th:classappend="${!posts.paging.isPrevEnabled()? 'disabled' : ''}">
                        <a class="page-link" th:href="@{'/?pageNumber=' + ${posts.paging.pageNumber - 1}}"
                           tabindex="-1">Previous</a>
                    </li>
                    <th:block th:each="item : ${posts.paging.getItems()}">
                        <li class="page-item" th:classappend="${item.index == posts.paging.pageNumber? 'active' : ''}"
                            th:if="${item.pageItemType.name() == 'PAGE'}">
                            <a class="page-link" th:href="@{'/?pageNumber=' + ${item.index}}"
                               th:text="${item.index}"></a>
                        </li>
                        <li class="page-item disabled" th:if="${item.pageItemType.name() == 'DOTS'}">
                            <a class="page-link" href="#">...</a>
                        </li>
                    </th:block>
                    <li class="page-item" th:classappend="${!posts.paging.isNextEnabled()? 'disabled' : ''}">
                        <a class="page-link" th:href="@{'/?pageNumber=' + ${posts.paging.pageNumber + 1}}">Next</a>
                    </li>
                </ul>
            </nav>

        </div>
    </div>
</div>

<script th:src="@{/webjars/jquery/jquery.min.js}"></script>
<script th:src="@{/webjars/popper.js/umd/popper.min.js}"></script>
<script th:src="@{/webjars/bootstrap/js/bootstrap.min.js}"></script>

</body>
</html>

The pagination component is stored in the nav tag: <nav aria-label="Page navigation" class="paging">...</nav>. This code could be easily moved to fragments but for the clarity of the sample application we keep it with table in a single file.

5. The output

The running application is available under http://locahost:8080 URL and presents the following functionality:

Thymeleaf bootstrap pagination

6. Conclusion

In this article, we presented how to build Thymeleaf Pagination component based on Spring JPA and Bootstrap framework. We used the Spring Boot application with H2 Memory Database, JPA, and Liquibase for creating database structure and fill it with sample 100 records.

As usual, the code used in this article is available under our GitHub repository.

{{ message }}

{{ 'Comments are closed.' | trans }}