Spring Boot + Bootstrap + Thymeleaf Wizard

September 27, 2020 No comments Spring Boot Bootstrap Thymeleaf Wizard

Spring Boot + Bootstrap + Thymeleaf Wizard

1. Introduction

In this article, we are going to present the Thymeleaf Wizard component based on the jQuery plugin and handled by the Spring Boot application server. Wizard is a frequently used component that works well when we want to aggregate a large number of fields on a form into individual groups (steps).

To getting started with Thymeleaf, please check the following links:
Useful Thymeleaf Tutorials
Using forms in Thymeleaf

2. Dependencies

2.1. Maven dependencies

To show how to create Thymeleaf Wizard component with Thymeleaf we used a simple Spring Boot application created as a Maven project with the following dependencies:

2.2. Front-End libraries
  • bootstrap - a UI framework for creating responsive layouts,
  • jquery-steps - a powerful jQuery wizard plugin that supports accessibility and HTML5,
  • jquery-validate - Query Validation Plugin.

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-wizard</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>
    </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.webjars</groupId>
            <artifactId>bootstrap</artifactId>
            <version>${bootstrap.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, Web Controllers, and Main Application class

The model layer contains a single object that will hold all account information:

package com.frontbackend.thymeleaf.bootstrap.model;

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

@Setter
@Getter
@NoArgsConstructor
public class Account {

    private String username;
    private String password;
    private String name;
    private String surname;
    private String email;
    private String address;
    private Integer age;
    private boolean termsAccepted;
}

We will separate these fields on the different wizard steps:

  • username, password - 1 step,
  • name, surename, email, address, age - 2 step,
  • termsAccepted - 4 step.

The main controller that will present the initial (empty) wizard form on the index.html page is called: IndexController:

package com.frontbackend.thymeleaf.bootstrap.controller;

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 com.frontbackend.thymeleaf.bootstrap.model.Account;

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

    @GetMapping
    public String index(Model model) {
        model.addAttribute("account", new Account());
        return "index";
    }
}

Form will POST data to /account endpoint which is handled by AccountController class:

package com.frontbackend.thymeleaf.bootstrap.controller;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import com.frontbackend.thymeleaf.bootstrap.model.Account;

@Controller
@RequestMapping("/account")
public class AccountController {

    @PostMapping
    public String submit(Account account, Model model) {
        model.addAttribute("account", account);
        return "saved";
    }
}

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. Templates

In the presentation layer we have two Thymeleaf templates:

  • index.html - the main view that presents empty wizard form,
  • saved.html - the result page that presents submitted data.

The index.html template 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 Wizard</title>

    <link th:rel="stylesheet" th:href="@{/webjars/bootstrap/4.0.0-2/css/bootstrap.min.css} "/>
    <link th:rel="stylesheet" th:href="@{/assets/jquery-steps/jquery.steps.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 Wizard</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">
            <h2 class="mt-5">Create account</h2>

            <form id="example-advanced-form" action="/account" method="post" th:object="${account}">
                <h3>Account</h3>
                <fieldset>
                    <legend>Account Information</legend>

                    <div class="row">
                        <div class="col-5">
                            <div class="form-group">
                                <label for="userName-2">User name *</label>
                                <input id="userName-2" name="username" th:field="*{username}" type="text"
                                       class="form-control required">
                            </div>

                            <div class="form-group">
                                <label for="password-2">Password *</label>
                                <input id="password-2" name="password" th:field="*{password}" type="text"
                                       class="form-control required">
                            </div>

                            <div class="form-group">
                                <label for="confirm-2">Confirm Password *</label>
                                <input id="confirm-2" name="confirm" type="text" class="form-control required">
                            </div>
                        </div>
                    </div>
                    <p>(*) Mandatory</p>
                </fieldset>

                <h3>Profile</h3>
                <fieldset>
                    <legend>Profile Information</legend>

                    <div class="row">
                        <div class="col-5">
                            <div class="form-group">
                                <label for="name-2">First name *</label>
                                <input id="name-2" name="name" th:field="*{name}" type="text"
                                       class="form-control required">
                            </div>

                            <div class="form-group">
                                <label for="surname-2">Last name *</label>
                                <input id="surname-2" name="surname" th:field="*{surname}" type="text"
                                       class="form-control required">
                            </div>

                            <div class="form-group">
                                <label for="email-2">Email *</label>
                                <input id="email-2" name="email" th:field="*{email}" type="text"
                                       class="form-control required email">
                            </div>

                            <div class="form-group">
                                <label for="address-2">Address</label>
                                <input id="address-2" name="address" th:field="*{address}" type="text"
                                       class="form-control">
                            </div>

                            <div class="form-group">
                                <label for="age-2">Age *</label>
                                <input id="age-2" name="age" type="text" th:field="*{age}"
                                       class="form-control required number">
                                <small class="form-text text-muted">The warning step will show up if age is less than
                                    18</small>
                            </div>
                        </div>
                    </div>
                    <p>(*) Mandatory</p>
                </fieldset>

                <h3>Warning</h3>
                <fieldset>
                    <legend>You are to young</legend>

                    <p>Please go away ;-)</p>
                </fieldset>

                <h3>Finish</h3>
                <fieldset>
                    <legend>Terms and Conditions</legend>

                    <input id="acceptTerms-2" name="acceptTerms" th:field="*{termsAccepted}" type="checkbox"
                           class="required"> <label
                        for="acceptTerms-2">I agree with the Terms and Conditions.</label>
                </fieldset>
            </form>

        </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>
<script th:src="@{assets/jquery-steps/jquery.steps.min.js}"></script>
<script th:src="@{assets/jquery-validate/jquery.validate.min.js}"></script>

<script>
    var form = $("#example-advanced-form").show();

    form.steps({
        headerTag: "h3",
        bodyTag: "fieldset",
        transitionEffect: "slideLeft",
        onStepChanging: function (event, currentIndex, newIndex) {
            // Allways allow previous action even if the current form is not valid!
            if (currentIndex > newIndex) {
                return true;
            }
            // Forbid next action on "Warning" step if the user is to young
            if (newIndex === 3 && Number($("#age-2").val()) < 18) {
                return false;
            }
            // Needed in some cases if the user went back (clean up)
            if (currentIndex < newIndex) {
                // To remove error styles
                form.find(".body:eq(" + newIndex + ") label.error").remove();
                form.find(".body:eq(" + newIndex + ") .error").removeClass("error");
            }
            form.validate().settings.ignore = ":disabled,:hidden";
            return form.valid();
        },
        onStepChanged: function (event, currentIndex, priorIndex) {
            // Used to skip the "Warning" step if the user is old enough.
            if (currentIndex === 2 && Number($("#age-2").val()) >= 18) {
                form.steps("next");
            }
            // Used to skip the "Warning" step if the user is old enough and wants to the previous step.
            if (currentIndex === 2 && priorIndex === 3) {
                form.steps("previous");
            }
        },
        onFinishing: function (event, currentIndex) {
            form.validate().settings.ignore = ":disabled";
            return form.valid();
        },
        onFinished: function (event, currentIndex) {
            form.submit();
        }
    }).validate({
        errorPlacement: function errorPlacement(error, element) {
            element.before(error);
        },
        rules: {
            confirm: {
                equalTo: "#password-2"
            }
        }
    });
</script>

</body>
</html>

In this application we used https://github.com/rstaib/jquery-step component for creating a lightweight wizards account form. The wizard contains 4 steps:

  • 1) with username and password,
  • 2) with personal information (like first name, last name, address, age, etc.),
  • 3) that will appear conditionally when the user provides age under 18 years,
  • 4) the last step with accepting the 'Terms and Conditions' option.

The saved.html template will presents submitted data:

<!DOCTYPE HTML>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8"/>
    <title>Spring Boot Thymeleaf Application - Bootstrap Wizard</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 Wizard</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">
    <h1 class="mt-4">Account created - <strong th:text="${account.username}"></strong></h1>
    <p>First name: <strong th:text="${account.name}"></strong></p>
    <p>Last name: <strong th:text="${account.surname}"></strong></p>
    <p>Address: <strong th:text="${account.address}"></strong></p>
    <p>Email: <strong th:text="${account.email}"></strong></p>
    <p>Age: <strong th:text="${account.age}"></strong></p>

    <a th:href="@{/}" class="btn btn-primary">Go back</a>
</div>

</body>
</html>

5. The output

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

Thymeleaf bootstrap wizard

6. Conclusion

In this article, we show how to use Bootstrap Wizard components in Thymeleaf templates. Wizard are UI elements that could be used to separate multiple fields into smaller parts. Aggregated fields will not scare the user away, so it will be the correct procedure to improve the user experience on the website.

As usual code used in this tutorial is availabe under GitHub repository.

{{ message }}

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