Taming Thymeleaf Practical Guide 2022
Taming Thymeleaf Practical Guide 2022
https://t.me/javalib
Taming Thymeleaf
Practical Guide to building a web application with Spring Boot
and Thymeleaf
Wim Deblauwe
Dedication . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
Acknowledgements . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4
Source code . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5
1.3. Thymeleaf. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
2. Getting started . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
2.1. Prerequisites . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
2.1.1. macOS/Linux . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
2.1.2. Windows . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 8
2.3. Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 14
3. Thymeleaf introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
3.4.2. Text . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
3.6. Preprocessing . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27
3.7. Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
4. Thyme Wizards . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29
4.3.1. Tailwind UI . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 45
4.4. Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 62
5. Fragments . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 63
5.8. Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 82
6. Layouts . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 83
6.5. Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 92
7. Controllers . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 93
8. Internationalization . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 107
Taming Thymeleaf
© 2022 Wim Deblauwe. All rights reserved. Version 2.0.2.
No part of this publication may be reproduced, stored in a retrieval system or transmitted in any form
or by any means, electronic, mechanical, photocopying, recoding, scanning or otherwise except as
permitted under Sections 107 or 108 of the 1976 United States Copyright Act, without the prior
written permission of the publisher.
While every precaution has been taken in the preparation of this book, the publisher and author
assume no responsibility for errors and omissions, or for any damage resulting from the use of the
information contained herein. The book solely reflects the author’s views.
Taming Thymeleaf | 1
Taming Thymeleaf
Dedication
I would like to dedicate this book to my wife Sofie and sons Victor and Jules. Their continued support
for all my endeavours means the world to me.
2 | Dedication
Taming Thymeleaf
Acknowledgements
I would like to thank all the people that made Spring and Spring Boot a reality. It is really an amazing
piece of software.
I would also like to thank everbody that created and/or contributed to Thymeleaf. I remain convinced
that it is still one of the best ways of creating an HTML frontend for many use-cases.
I also want to send a big thank you to the people that created Asciidoctor and to Alexander Schwartz
for his amazing work on the IntelliJ Asciidoc plugin. It made writing this book extremely enjoyable.
Further, I also want to thank my sister-in-law Jasmine Verhaeghe for the work on the book’s cover. I
am really happy with how it looks.
Finally, I want to thank Philip Riecks for reviewing the book. His feedback has been invaluable for
making this book the best it can be.
Acknowledgements | 3
Taming Thymeleaf
Introduction
I have been working with Spring Boot for over four years and it has made development a tremendous
joy. It is important in our fast-paced world to be able to prototype quickly, but also to ensure that you
are not doing any wasted work.
For me, this is one of the major strengths of Spring Boot. The smallest application can fit in a tweet ,
yet your application will scale to Internet scale with the greatest of ease.
Combine Spring Boot with Spring Data and Spring Security and you can have something up and
running in no time. And it is not just "something", it is a solid base to build upon.
For the "front" of a web application, there are 2 categories: so called "traditional" server-side rendered
HTML, or Single Page Applications (SPA). Thymeleaf uses server-side rendered HTML which is still a
very valid implementation choice today, as confirmed by many of the people that Marco Behler
interviewed for his blog. JavaScript-heavy SPA’s surely also have their place, but in most cases they are
overkill.
This book is the culmination of four years of working with Spring Boot on a variety of projects. This is
the book I wished I had when starting out building back-end applications with Java, Spring Boot and
Thymeleaf. It will give you all the basics you need to start developing an application using Thymeleaf
and Spring Boot.
Through the creation of an application for a fictional basketball team called Thyme Wizards, you will
learn about Spring, Spring Boot, Spring Security, Spring Data and Thymeleaf. You will also learn to use
unit and integration tests to ensure the proper code functionality and build a maintainable code base
that you can expand upon.
This book assumes you have enough basic Java knowledge to be able to follow. However, proficiency
in similar languages like C# should be sufficient.
Thank you so much for buying this book. I really hope it helps you in your journey to learn Spring Boot
and Thymeleaf. Please let me know if it did via @wimdeblauwe on Twitter or email me at
[email protected]. Thanks again!
4 | Introduction
Taming Thymeleaf
Source code
The source code of the book can be found on GitHub at https://github.com/wimdeblauwe/taming-
thymeleaf-sources
There is a directory per chapter with how the code is suppose to look like by the end of each chapter.
Source code | 5
Taming Thymeleaf
One of the key things you need to understand is that Spring is based on the concept of "beans" or
"components", which are basically singletons without the drawbacks of the traditional singleton
pattern.
With dependency injection, each component just declares the collaborators it needs, and Spring
provides them at run time. The biggest advantage is that you can easily inject different instances for
different deployment scenarios of your application (e.g., staging versus production versus unit tests).
The Spring portfolio includes a lot of sub-projects ranging from database access over security to cloud
services.
Spring Data
Spring Data’s mission is to provide a familiar and consistent, Spring-based programming model for
data access while still retaining the special traits of the underlying data store.
Spring Security
Spring Security is a powerful and highly customizable authentication and access-control
framework. It is the de-facto standard for securing Spring-based applications.
You can learn more about the core Spring Framework at Spring Framework Documentation.
Spring Boot makes it easy to create stand-alone, production-grade Spring based Applications
that you can "just run". We take an opinionated view of the Spring platform and third-party
libraries so you can get started with minimum fuss. Most Spring Boot applications need very little
Spring configuration.
With Spring Boot, you get up and running with your Spring application in no time, without the need to
deploy to a container like Tomcat or Jetty. You can just run the application right from your IDE.
Spring Boot also ensures that you get a list of versions of libraries inside and outside of the Spring
portfolio that are guaranteed to work together without problems.
You can learn more about Spring Boot from the excellent Spring Boot Reference Documentation.
1.3. Thymeleaf
Thymeleaf is a server-side Java template engine that uses natural templates to generate HTML pages.
Natural templates are HTML templates that can correctly be displayed in browsers and work as static
prototypes.
If you are familiar with PHP, you can think of Thymeleaf like Blade templates.
2.1. Prerequisites
To be able to create a Spring Boot application, we need to install Java and Maven or Gradle as a build
tool.
In this book, we will be using Maven, but Gradle will work equally well.
We will use Java 17, which is the current LTS (Long Term Support) version of Java.
2.1.1. macOS/Linux
Use SDKMAN! to install Java and Maven.
2. Install Java:
Use sdk list java to see a list of all possible Java versions that can be
installed.
3. Install Maven:
4. Run mvn --version to see if both are configured correctly. The output should look similar to this:
2.1.2. Windows
Use Chocolatey to install Java and Maven.
3. Install Maven
4. Run mvn -v to see if both are configured correctly. The output should look similar to this:
Project
Maven project
Language
Java
Spring Boot
2.6.2
Packaging
Jar [1]
Java
17
Dependencies
Select Spring Web and Thymeleaf
You can use Gradle as your build system or one of the other supported JVM
languages if you prefer to. The main principles explained in the book remain the
same.
Use the Generate button to download a zip file with the project, or use Explore if you want to see what
would be generated.
├── .gitignore ①
├── .mvn ②
├── HELP.md ③
├── mvnw
├── mvnw.cmd
├── pom.xml ④
└── src
├── main
│ ├── java
│ │ └── com
│ │ └── tamingthymeleaf
│ │ └── application
│ │ └── TamingThymeleafApplication.java ⑤
│ └── resources
│ ├── application.properties ⑥
│ ├── static
│ └── templates
└── test
└── java
└── com
└── tamingthymeleaf
└── application
└── TamingThymeleafApplicationTests.java ⑦
① A Git ignore file that has decent defaults for Maven, Spring Tool Suite, IntelliJ, Netbeans and Visual
Studio Code
② The .mvn directory (with the mvnw executables) allows running Maven without it being installed on
the system.
③ A help file that is generated with contents that is tailored to the dependencies that have been
selected. It contains links to documentation of those dependencies.
④ Maven project pom.xml file that is configured with the selected dependencies and the spring-
boot-maven-plugin to generate the standalone executable JAR file.
⑤ Main application file. The entry point of our Spring Boot application.
⑥ Properties file that allows to customize various parts of Spring Boot.
⑦ Integration test that will start the application to ensure the Application Context loads.
mvnw verify
...
[INFO]
------------------------------------------------------------------------
[INFO] BUILD SUCCESS ①
[INFO]
------------------------------------------------------------------------
[INFO] Total time: 5.341 s
[INFO] Finished at: 2021-12-11T20:16:43+01:00
[INFO]
------------------------------------------------------------------------
Now that we have build the code, let’s run it to see what it does. You can run the application from
your IDE or through Maven with mvn spring-boot:run. See Running your application in the Spring
Boot documentation for more information.
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v2.6.2)
/Users/wdb/Projects/personal/taming-thymeleaf/example-code/chapter02/01
- Generated project)
2021-12-11 20:17:17.600 INFO 13980 --- [ main]
c.t.a.TamingThymeleafApplication : No active profile set,
falling back to default profiles: default
2021-12-11 20:17:18.062 INFO 13980 --- [ main]
o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with
port(s): 8081 (http)
2021-12-11 20:17:18.069 INFO 13980 --- [ main]
o.apache.catalina.core.StandardService : Starting service [Tomcat]
2021-12-11 20:17:18.069 INFO 13980 --- [ main]
org.apache.catalina.core.StandardEngine : Starting Servlet engine:
[Apache Tomcat/9.0.55]
2021-12-11 20:17:18.108 INFO 13980 --- [ main]
o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded
WebApplicationContext
2021-12-11 20:17:18.108 INFO 13980 --- [ main]
w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext:
initialization completed in 480 ms
2021-12-11 20:17:18.246 WARN 13980 --- [ main]
ion$DefaultTemplateResolverConfiguration : Cannot find template
location: classpath:/templates/ (please add some templates or check your
Thymeleaf configuration)
2021-12-11 20:17:18.298 INFO 13980 --- [ main]
o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s):
8081 (http) with context path ''
2021-12-11 20:17:18.306 INFO 13980 --- [ main]
c.t.a.TamingThymeleafApplication : Started
TamingThymeleafApplication in 0.944 seconds (JVM running for 1.173)
What we see is the start of an embedded Tomcat running on port 8080. How much code does this
need? Very little if we check out the TamingThymeleafApplication.java file:
package com.tamingthymeleaf.application;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class TamingThymeleafApplication {
The @SpringBootApplication marks the class as a Spring Boot application and that will trigger all
the magic of Spring Boot at startup. It scans the classpath to see what dependencies are available and
will configure everything according to sensible defaults.
One of those defaults is the port 8080. Luckily, everything in Spring Boot is configurable in a multitude
of ways.
As an example, you can change the port by adding the following to the application.properties
file:
application.properties
server.port=8081
Restart the application and Tomcat will now run at 8081 instead.
Two other important ways to configure properties are using the command line or via
environment variables. You can view a list of the most common properties at
Appendix A. Common application properties of the Spring Boot reference
documentation.
There is a still a lot more to learn about building and deploying Spring Boot applications, but you will
learn more about them as needed as we create our application throughout the book.
2.3. Summary
In this chapter, you learned:
If you ever get stuck following along, you can refer to the full source code on GitHub:
https://github.com/wimdeblauwe/taming-thymeleaf-sources
[1] Selecting Jar will produce a standalone application with an embedded web server. Select War if you need to deploy to a
standalone web server.
Thymeleaf templates are plain HTML files and are stored in the src/main/resources/templates
folder in a Spring Boot application. [1]
GET /
1. The browser starts by doing a GET request over the network to the server where the application
runs.
2. The application will match the requested path of the URL to a Controller. This is a piece of
software in our application that will build a kind of Map of java objects that will be used by the
template during rendering. We call this map the Model.
3. The application finds the Thymeleaf template to use for rendering.
4. The application uses the Thymeleaf engine (also running inside the application) to combine the
template with the Java objects in the model. This results in an HTML page.
5. The application returns the generated HTML page to the browser where the browser renders it.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Taming Thymeleaf</title>
</head>
<body>
<h1>Taming Thymeleaf</h1>
<div>This is a thymeleaf page</div>
</body>
</html>
Start the Spring Boot application and open http://localhost:8080 in your browser. The result should be
similar to this:
Of course, there is currently nothing on the page where Thymeleaf actually has to do something. Let’s
put the template engine to work.
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org" ①
lang="en">
<head>
<meta charset="UTF-8">
<title>Taming Thymeleaf</title>
</head>
<body>
<h1>Taming Thymeleaf</h1>
<div th:text="|Sum of 2 + 2 = ${ 2 + 2 }|"></div> ②
</body>
</html>
① Thymeleaf th: namespace declaration. Thymeleaf adds custom tags and attributes to HTML. To
avoid naming conflicts and for clarity, those tags and attributes are put in an XML namespace.
② Use a Thymeleaf expression via the th:text attribute. Thymeleaf will first evaluate the expression
inside the attribute and put the result as the body of the <div> tag that contains the th:text
attribute.
Use the 'View source' functionality of your browser to see the exact HTML that Thymeleaf has
rendered.
Natural templates
Thymeleaf uses natural templates. These are HTML files where the basic structure is still normal
HTML tags, but where the dynamic behaviour is defined by Thymeleaf attributes.
The advantage here is that the styling could be done outside of the running application, which
might be easier for a designer.
We will set up a live-reload system for the running application later so we don’t
need to use static templates for styling, while still having a very fast
development cycle.
We are using Thymeleaf with Spring MVC. MVC is an acronym for Model View Controller. It is a well-
known design pattern. You can find more information about it at Model–view–controller on Wikipedia.
• The View part is what we have already done when we created our index.html template. It is the
visual part of the design pattern.
• The Controller is what we are going to create next. The controller is responsible for fetching the
data from the application, and showing the correct view according to the requested URL. It usually
has no actual business logic, but delegates to other components in the application.
• The Model is the object that the controller passes to the view with the data to be used for the
rendering of the template.
A controller in Spring MVC is a simple Java class that is annotated with @Controller:
package com.tamingthymeleaf.application;
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 java.util.List;
@Controller ①
@RequestMapping("/") ②
public class RootController {
@GetMapping ③
public String index(Model model) { ④
model.addAttribute("pageTitle", "Taming Thymeleaf"); ⑤
model.addAttribute("scientists", List.of("Albert Einstein",
"Niels Bohr",
"James Clerk
Maxwell")); ⑥
return "index"; ⑦
}
}
① The @Controller annotation indicates to Spring Boot that this class is a controller. The
component scanning will automatically create an instance of this class and add it to the Spring
Context.
② The @RequestMapping annotation sets the root path of the URL for all methods of the class.
③ GetMapping indicates that an HTTP GET will call this method. Note that the name of the method
(index in the example) really does not matter at all for the working of the application.
④ Controller methods can declare parameters of certain types that Spring MVC will inject with the
proper instances. We will later see some other examples, but Model is one of the most important
ones. It allows adding attributes that Thymeleaf can use to render the data.
⑤ We add a simple String value under the pageTitle key to the model.
Component scanning
Spring heavily utilizes something called component scanning. At startup, Spring will search for
classes on the classpath that are annotated with certain annotations like @Component,
@Configuration, @Controller, @Service, @Repository, …
When it finds those, it will automatically register them in the context by creating an instance,
and injecting any declared dependencies (in the constructor usually).
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
lang="en">
<head>
<meta charset="UTF-8">
<title>Taming Thymeleaf</title>
</head>
<body>
<h1 th:text="${pageTitle}">Taming Thymeleaf</h1> ①
<div>
<ul>
<li th:each="scientist : ${scientists}"> ②
<span th:text="${scientist}"></span>
</li>
</ul>
</div>
</body>
</html>
① Use the pageTitle model attribute. Note that the actual text Taming Thymeleaf inside the <h1>
tag does not matter at all. Thymeleaf will overwrite it with the contents of pageTitle.
3.4.1. Variables
When a Thymeleaf template gets processed, the application will put variables in the context via the
controller. Those variables can be referenced in the templates via the ${…} syntax.
For example:
<div th:text="${username}"></div>
Suppose there is a String in the Thymeleaf context with the name username that has the value John
<div>John Doe</div>
The variable in the context does not need to be a String. Other types will have their toString()
method invoked.
The Thymeleaf variable syntax is not limited to the exact object that is placed on the context. We can
call methods:
<div th:text="${user.getName()}"></div>
Or if the method name adheres to the JavaBean specification, we can simulate property access:
<div th:text="${user.name}"></div>
Map
If the object in the context is a Map, then dot notation can be used to access a value via its key:
<div th:text="${capitalsOfTheWorld.Belgium}"></div>
Alternatively, use the bracket syntax. For certain keys (E.g. if they contain spaces) you will need to use
the bracket syntax:
Array or List
<div th:text="${vehiclesList[0].name}"></div>
3.4.2. Text
Most applications will require that they can be translated into the language of the user. Even if it is not
a requirement at the start, you probably want to do this in case it ever becomes a requirement.
<h1 th:text="#{dashboard.title}"></h1>
src/main/resources/messages.properties
dashboard.title=Dashboard
Additional languages can be added by adding another such file, but postfixing the file name with the
locale. For example, use messages_nl.properties for Dutch translations.
<div>
<p>Brand: <span th:text="${car.brand}"></span></p>
<p>Type: <span th:text="${car.type}"></span></p>
<p>Fuel: <span th:text="${car.fuelType}"></span></p>
<p>Color: <span th:text="${car.color}"></span></p>
</div>
You can avoid the duplication of the car variable by selecting the variable with the th:object
attribute and refer to the properties of the selected object using the *{…} syntax:
<div th:object="${car}">
<p>Brand: <span th:text="*{brand}"></span></p>
<p>Type: <span th:text="*{type}"></span></p>
<p>Fuel: <span th:text="*{fuelType}"></span></p>
<p>Color: <span th:text="*{color}"></span></p>
</div>
<a th:href="@{https://www.google.com/search?q=thymeleaf}"></a>
or a relative URL:
<a th:href="@{/users}"></a>
This is equivalent to the first example, given searchTerm is a context variable that contains the
thymeleaf string.
<a th:href="@{https://www.google.com/search(q=${searchTerm})"></a>
If the variable is not referenced in the URL itself, it is added as a query parameter. If it is referenced, it
can be used as a path variable:
Thymeleaf has a shortcut syntax that is equivalent using the pipe (|) symbol:
<div th:id="|container-${index}|"></div>
Instead of using th:* tags to use variables, it might be desirable at times to directly put a variable
result in HTML. This is possible in Thymeleaf using expression inlining.
For example:
19.99</span></span>
th:text will place the result of the expression inside the tag it is declared on.
For example:
<div th:text="${username}">Bob</div>
<div>Jane</div>
th:id will add an id attribute with the result of the expression on the tag it is declared on.
For example:
<div th:id="|container-${userId}|"></div>
<div id="container-1"></div>
th:if will render the tag it is declared on only if the expression evaluates to true.
For example:
Given the user.followerCount variable in the context a value greater than 10. If the variable is less
than 10, the <div> will not be rendered in the output.
th:unless will render the tag it is declared on only if the expression evaluates to false.
For example:
Given the user.followerCount variable in the context is exactly 0. If the variable is greater than 0,
the <div> will not be rendered in the output.
Thymeleaf has no if/else statement, but this can be easily done by combining
th:if with th:unless. For example:
Either the first <div> or the 2nd one will be rendered depending on the value of
user.followerCount.
3.5.5. Iteration
th:each allows iterating over a collection. It will create as many tags as there are items in the
collection.
For example:
<ul>
<li th:each="scientist : ${scientists}" th:text=
"${scientist.name}"></li>
</ul>
<ul>
<li>Marie Curie</li>
<li>Erwin Schrödinger</li>
<li>Max Planck</li>
</ul>
Given the scientists variable in the context references a collection of objects that have a name
property.
Thymeleaf will do the "right thing" you expect in the above example and first
evaluate the th:each and then the th:text. There is a defined precedence in the
attribute processing that ensures the proper order. See Attribute Precedence on the
Thymeleaf website for the exact details.
3.6. Preprocessing
Thymeleaf has a preprocessing expression. This allows to first execute the preprocessing and use the
result of that in the final rendering of the templates.
This will be especially useful for Fragments which we will cover later.
<h1 th:text="#{__${title}__}"></h1>
Thymeleaf will first substitute ${title} with the value of that parameter. Assume users.title for
example.
<h1 th:text="#{users.title}"></h1>
In a 2nd step, Thymeleaf will now execute the th:text and search for the translation key (Due to #{…
}) of users.title and display that in the <h1>.
Final result:
<h1>Users</h1>
3.7. Summary
In this chapter, you learned:
[1] If you want to use another directory, see Changing the Thymeleaf Template Directory in Spring Boot for more info on how to do
that.
In this chapter and all following ones, the book will always explain the general
concepts first and then apply them to our example application. Keep that in mind, so
you don’t immediately try to apply the concepts. The book will guide you step-by-
step along the path to Thymeleaf mastery.
src/main/resources/static/css/application.css
h1 {
color: #5f5f5f;
border-bottom: 5px solid darkseagreen;
}
ul {
font-variant: small-caps;
}
To have our HTML use the CSS, we need to link to it in the template:
src/main/resources/templates/index.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
lang="en">
<head>
<meta charset="UTF-8">
<title>Taming Thymeleaf</title>
<link rel="stylesheet" th:href="@{/css/application.css}"> ①
</head>
...
The result:
Tailwind CSS is a utility-first CSS framework. You will learn about it as we build the application, but if
you want some good introduction, checkout the very informative screencasts on the website.
In a nutshell, the goal of Tailwind is that you need almost no custom CSS. You apply ready-made
classes to your HTML.
As stated on the Tailwind website itself as well: You are probably thinking that this is a bad idea to
have all those class in the HTML. I can assure you that after working with it on an actual project, I
would not use anything else anymore. See Utility first for a more detailed explanation why:
But once you’ve actually built something this way, you’ll quickly notice some really important
benefits:
• You aren’t wasting energy inventing class names. No more adding silly class names like
sidebar-inner-wrapper just to be able to style something, and no more agonizing over the
perfect abstract name for something that’s really just a flex container.
• Your CSS stops growing. Using a traditional approach, your CSS files get bigger every time
you add a new feature. With utilities, everything is reusable so you rarely need to write new
CSS.
• Making changes feels safer. CSS is global and you never know what you’re breaking when
you make a change. Classes in your HTML are local, so you can change them without
worrying about something else breaking.
If you are doubting, give it the benefit of the doubt as I did when I started out with it. I am sure it will
grow on you.
If you don’t have npm installed, do so now by following the instructions at Downloading and installing
Node.js and npm
macOS or Linux
If you are on macOS or Linux, install nvm (Node Version Manager) first:
nvm install-latest-npm
Windows
package.json
"name": "taming-thymeleaf-app"
}
1. Update package.json:
{
"name": "taming-thymeleaf-app",
"devDependencies": {
"autoprefixer": "^10.4.0",
"postcss": "^8.4.4",
"tailwindcss": "^3.0.1"
}
}
2. Create a package-lock.json file. This file should be committed as it contains the exact version
of each dependency that the application will use.
3. Download tailwindcss into the node_modules directory.
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}
We now need to update our application.css file to use the tailwind classes:
@tailwind base;
@tailwind components;
@tailwind utilities;
Our build system will now have to turn those @tailwind directives into actual CSS that the browser
will understand.
Now install gulp (along with some other dependencies we will need) as a development dependency:
Next, create a gulpfile.js at the root of the project with the following contents:
gulp.task('watch', () => {
browserSync.init({
proxy: 'localhost:8080',
});
gulp.watch(['src/main/resources/**/*.html'], gulp.series('copy-
html+css-and-reload'));
gulp.watch(['src/main/resources/**/*.css'], gulp.series('copy-css-
and-reload'));
gulp.watch(['src/main/resources/**/*.js'], gulp.series('copy-js-and-
reload'));
});
gulp.task('copy-html', () =>
gulp.src(['src/main/resources/**/*.html'])
.pipe(gulp.dest('target/classes/'))
);
gulp.task('copy-css', () =>
gulp.src(['src/main/resources/**/*.css'])
.pipe(postcss())
.pipe(production(uglifycss()))
.pipe(gulp.dest('target/classes/'))
);
gulp.task('copy-js', () =>
gulp.src(['src/main/resources/**/*.js'])
.pipe(babel())
.pipe(production(terser()))
.pipe(gulp.dest('target/classes/'))
);
// When the HTML changes, we need to copy the CSS also because
// the Tailwind CSS JIT compiler might generate new CSS
gulp.task('copy-html+css-and-reload', gulp.series('copy-html', 'copy-
css', reload));
gulp.task('copy-css-and-reload', gulp.series('copy-css', reload));
gulp.task('copy-js-and-reload', gulp.series('copy-js', reload));
function reload(done) {
browserSync.reload();
done();
}
• build: builds the HTML, Javascript and CSS and copies it to the target/classes directory where
Spring Boot expects them
• watch: Watches the HTML, Javascript and CSS source files for changes and automatically runs
build when they changed.
To start the Gulp tasks via npm, we add the following scripts to package.json:
{
"name": "taming-thymeleaf-app",
"scripts": {
"watch": "gulp watch",
"build": "gulp build",
"build-prod": "NODE_ENV='production' gulp build --env production"
},
...
}
One last thing before we can run the scripts is the configuration of Tailwind.
Tailwind uses a Just In Time (JIT) compiler that will only generate the classes that are actually used in
the HTML. To make this work, we need to configure Tailwind so it knows where our HTML files are
located.
The npx command is bundled with NPM. It allows to execute tools from the npm
Now update the file so that the purging is aware of the Thymeleaf templates:
tailwind.config.js
module.exports = {
content: ['./src/main/resources/templates/**/*.html'],
theme: {
extend: {},
},
plugins: [],
}
We also have uglifycss configured. This will compress the CSS as much as possible by removing
whitespace.
Now run npm run build to run the build. If all goes well, there should be an
target/classes/static/css/application.css file present that contains the generated CSS.
*,::before,::after{box-sizing:border-box;border-width:0;border-style
:solid;border-color:currentColor}...
<properties>
<java.version>17</java.version>
<frontend-maven-plugin.version>1.12.0</frontend-maven-
plugin.version> ①
<frontend-maven-plugin.nodeVersion>v16.13.1</frontend-maven-
plugin.nodeVersion> ②
<frontend-maven-plugin.npmVersion>8.1.2</frontend-maven-
plugin.npmVersion> ③
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<resources>
<resource>
<directory>src/main/resources</directory>
<excludes> ④
<exclude>**/*.html</exclude>
<exclude>**/*.css</exclude>
<exclude>**/*.js</exclude>
</excludes>
</resource>
</resources>
<pluginManagement>
<plugins>
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>${frontend-maven-plugin.version}</version>
<executions>
<execution> ⑤
<id>install-frontend-tooling</id>
<goals>
<goal>install-node-and-npm</goal>
</goals>
<configuration>
<nodeVersion>${frontend-maven-
plugin.nodeVersion}</nodeVersion>
<npmVersion>${frontend-maven-
plugin.npmVersion}</npmVersion>
</configuration>
</execution>
<execution>⑥
<id>run-gulp-build</id>
<goals>
<goal>npm</goal>
</goals>
<configuration>
<arguments>run build</arguments>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</pluginManagement>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin> ⑦
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>release</id>
<build>
<plugins>
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<executions>
<execution> ⑧
<id>run-gulp-build</id>
<goals>
<goal>npm</goal>
</goals>
<configuration>
<arguments>run build-
prod</arguments>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>
② Specify the version of node to use (Use node --version to find out your current installed
version).
③ Specify the version of npm to use (Use npm --version to find out your current installed version).
④ Configure excludes for all HTML, Javascript and CSS files. We will copy those to the
target/classes output directory via the frontend-maven-plugin, so we don’t want Maven
itself to copy those.
⑤ Configure the first execution of the frontend-maven-plugin to install npm and node so the build
also works without having those tools installed.
⑥ Configure the second execution of the frontend-maven-plugin to run the Gulp build
For comparison, run mvn verify -Prelease after that and check the contents of the CSS file again.
The file should be minified.
src/main/resources/application-local.properties
spring.thymeleaf.cache=false
spring.web.resources.chain.strategy.content.enabled=true
spring.web.resources.chain.strategy.content.paths=/**
Now start the Spring Boot application with the local profile enabled. If you use IntelliJ, you can do
this from the run configuration dialog, at the Active profiles setting:
Finally, open a command line terminal at the root of the project and run:
This will first build the frontend and then watch for any changes. It will also automatically open your
default browser at http://localhost:3000
If you look at the Developer Tools of your browser, you will see that not application.css is loaded,
but something like application-20a16208bdedf3ce24834bc96a8374d4.css.
Thymeleaf will also have updated the link to the CSS correspondingly:
By having a unique name each time the CSS changes, we ensure the live reload works fine.
To test out the live reload, edit the index.html. For example, change the <h1> tag to be:
Save the file and watch the browser automatically display the updated content. Result after adding
some more Tailwind CSS classes:
Figure 9. Live reload in browser with some Tailwind CSS classes applied
This is one of the big advantages of Tailwind CSS. It can be configured to only expose colors, margins,
… that we want to allow according to our own application style.
Let us customize the default Tailwind CSS theme and add our custom color:
module.exports = {
content: ['./src/main/resources/templates/**/*.html'],
theme: {
extend: {
colors: {
'taming-thymeleaf-green':'darkseagreen'
}
},
},
plugins: [],
}
After this, we can start using taming-thymeleaf-green as a color in our HTML. The JIT compiler
from Tailwind CSS will start generating the corresponding classes as we start using them in our HTML.
Run npm run build to generate the CSS again. It should now contain something like this:
.border-taming-thymeleaf-green {
--tw-border-opacity: 1;
border-color: rgb(143 188 143 / var(--tw-border-opacity));
}
You can extend the base theme from Tailwind, or create your own custom one from scratch. See
Tailwind CSS configuration for more details on all the ways you can customize Tailwind for your
project.
4.3.1. Tailwind UI
We’ll start our application UI by implementing the application shell. This includes the menu items to
navigate to each section of the app, and a user avatar with user name to display the currently logged
on user.
As this is a fictional application, there is no design from an actual designer to work with. So we wil use
the designs from Tailwind UI as this allows us to create something beautiful quickly.
module.exports = {
content: ['./src/main/resources/templates/**/*.html'],
theme: {
extend: {
fontFamily: {
sans: ['Inter var', ...defaultTheme.fontFamily.sans],
},
},
plugins: [],
}
};
If you have a Tailwind UI license, you can copy the HTML from the Tailwind UI website into the
src/main/resources/templates/index.html page. I took the Light sidebar with light header
application shell, but you can use whatever you want obviously. If you don’t have a license, you can
see the HTML at the end of this section, or copy it from the accompanying sources of the book.
The Light sidebar with light header requires the form plugin, so we need to add it to our package.json
like this:
...
plugins: [
require('@tailwindcss/forms')
],
We can see that the popup menu is shown immediately on page load and cannot be hidden by
clicking the avatar.
Tailwind UI recommends AlpineJS for server-rendered website like the one we are building, so we will
go with that.
We will also remove the search bar and the notification bell icon from the application shell we choose
from Tailwind UI as we won’t be using that for now.
To start, we add AlpineJS via a CDN, and add some custom Javascript to control the user popup menu:
<script src="https://unpkg.com/[email protected]/dist/cdn.min.js"
defer></script>
<script>
function userPopupMenu() { ①
return {
show: false, ②
toggleVisibility() { ③
this.show = !this.show;
},
close() { ④
this.show = false;
},
isVisible() { ⑤
return this.show === true;
}
};
}
</script>
② Keep track of the visibility of the popup menu in the show variable
③ Defines a method that will allow to toggle the visibility from visible to invisible and back
④ The close() method will make the popup invisible
⑤ Returns if the popup menu should be visible or not. This method will be bound via AlpineJS to the
HTML element to show and hide.
<div>
<button class="max-w-xs bg-white flex items-center text-sm
rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2
focus:ring-indigo-500" id="user-menu" aria-haspopup="true"
@click="toggleVisibility"> ④
<span class="sr-only">Open user menu</span>
<img class="h-8 w-8 rounded-full"
src="https://images.unsplash.com/photo-1472099645785-
5658abf4ff4e?ixlib=rb-
1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256
&h=256&q=80"
alt="">
</button>
</div>
<!--
Profile dropdown panel, show/hide based on dropdown state.
-->
<div class="origin-top-right absolute right-0 mt-2 w-48 rounded-md
shadow-lg"
x-show="isVisible()" ⑤
x-cloak> ⑥
...
</div>
</div>
① x-data declares a new component scope. By returning the result of the userPopupMenu()
function, we will be able to access the data and methods on all HTML elements inside this <div>.
② @click is a shortcut for x-on:click. With the .away modifier, our event handler will be executed
when the event originates from a source different than itself (or its children). So
@click.away="close" ensures the popup is closed when the user clicks anywhere else in the
application.
③ @keydown.window.escape also binds the close function as an event listener when somebody
presses the ESC key.
④ @click allows to configure an event listener when the <button> is clicked. In this case, it will
toggle the visibility each time the button is clicked.
⑤ x-show allows to bind an expression to the display: none; style for the element. By binding to
isVisible, we will effectively show and hide the menu component.
⑥ x-cloak: When JavaScript is disabled, the popup menu should not be shown by default. By using
x-cloak combined with a small bit of custom CSS, we avoid that the popup menu is visible. See x-
cloak on the Alpine.js website for more info.
Be sure to update application.css as well:
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
[x-cloak] {
display: none;
}
}
With this minimal JavaScript, we implemented the following behaviours for our popup user menu:
To make the opening and closing a bit fancier, we can add a bit of transition. Using AlpineJS, this is as
simple as adding .transition to the x-show directive:
We won’t go into detail for the left menu, but this is the resulting index.html after implementing the
left side menu for mobile and the top user menu:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
lang="en">
<head>
<meta charset="UTF-8">
<title>Taming Thymeleaf</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-
scale=1"/>
stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-
linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0
001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-
1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Dashboard
</a>
</div>
</div>
Team
</a>
</a>
</div>
<div class="ml-4 flex items-center md:ml-6">
function sidebarMenu() {
return {
show: false,
openSidebar() {
this.show = true;
},
closeSidebar() {
this.show = false;
},
isVisible() {
return this.show === true;
}
};
}
function userPopupMenu() {
return {
show: false,
toggleVisibility() {
this.show = !this.show;
},
close() {
this.show = false;
},
isVisible() {
return this.show === true;
}
};
}
</script>
</body>
</html>
Try the application on different browser sizes and you will see that the left menu will appear or
disappear as needed.
One final thing to do to have our application shell look good is adding our own logo.
Spring Boot serves whatever files we put in src/main/resources/public. We can use this to serve
the application logo. Create an img directory inside src/main/resources/public and put the logo
there.
with:
This needs to be done in 2 places (One for the desktop sidebar, one for the mobile overlay).
4.4. Summary
In this chapter, you learned:
Chapter 5. Fragments
Our index.html page has currently quite some duplication going on. If you are not familiar with
Tailwind CSS, the many classes probably are the first thing you would tackle by creating custom CSS
classes.
However, it is better to think in terms of full components that can be re-used. When we do that, there
will be no need to define our own CSS classes to reduce the duplication.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div th:fragment="separator"> ①
<div class="border-dashed border-2 border-red-300 mx-4">
</div>
</div>
</body>
</html>
You can define a fragment on any HTML tag, it does not need to be a <div>.
<div>
<div>There is some content here.</div>
<div th:insert="~{fragments :: separator}"></div> ①
<div>There is some more content here.</div>
</div>
By using th:insert, Thymeleaf will insert the content of the fragment as a child of the declared tag.
Chapter 5. Fragments | 63
Taming Thymeleaf
The th:insert attribute expects a fragment expression, which is defined by the ~{…} syntax.
However, it is also possible for non-complex fragment expression to leave out the ~{…} part, so the
example becomes:
<div>
<div>There is some content here.</div>
<div th:insert="fragments :: separator"></div>
<div>There is some more content here.</div>
</div>
② div from the fragment itself (The one that has the th:fragment="separator" attribute)
To avoid the nested <div> tags, we can declare the fragment like this:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div th:fragment="separator" class="border-dashed border-2 border-
red-300 mx-4"> ①
</div>
</body>
</html>
64 | Chapter 5. Fragments
Taming Thymeleaf
</div>
<div>There is some more content here.</div>
It is perfectly possible to have attributes on the <div> (or any other HTML tag you want to use) that
has the th:fragment declaration.
So far, we inserted the fragment, but we can also have Thymeleaf replace the tag from the "parent"
document. To do that, use th:replace instead of th:insert:
<div>
<div>There is some content here.</div>
<div th:replace="fragments :: separator"></div> ①
<div>There is some more content here.</div>
</div>
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<a th:fragment="menu-item(title, link)" ①
th:text="${title}" ②
th:href="${link}" ③
class="flex items-center px-2 py-2 text-base leading-6 font-medium
text-gray-900">
</a>
</body>
</html>
Chapter 5. Fragments | 65
Taming Thymeleaf
Notice how there is really no need to create a custom CSS class for the classes that are the same over
all menu items as we now have a fragment that has all the knowledge about how a menu item should
look.
but I would not recommend that as it makes it harder for users of the fragment to
see what parameters are needed. See Fragment local variables without fragment
arguments if you would like to use that.
If you want to make the parameters explicit on the calling side, that is possible like this:
When you do that, you can also change the order. So this would also give the same final result:
66 | Chapter 5. Fragments
Taming Thymeleaf
If we want to create a parameterized fragment out of this, we need a way to pass the <svg> element
content from the parent into the fragment.
③ Have the title argument as the text of the menu item using Expression inlining
We cannot using th:text there because that completely replaces the body of the
<a> tag with the text. If we did that, the SVG would not be visible in the rendered
HTML.
Chapter 5. Fragments | 67
Taming Thymeleaf
We gave the <svg> an id attribute of dashboard-icon so we can reference it when calling the
fragment using the fragment expression ~{::#dashboard-icon}.
<a href="/dashboard"
class="bg-gray-100 text-gray-900 group flex items-center px-2 py-2
text-sm font-medium rounded-md">
<svg id="dashboard-icon"
class="text-gray-500 mr-3 h-6 w-6"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-
width="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-
2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1
1 0 001 1m-6 0h6"></path>
</svg>
Dashboard
</a>
Better is that we can just use the .svg extension for them as we normally would. To make that
possible, we do the following steps.
src/main/resources/templates/svg/dashboard.svg
68 | Chapter 5. Fragments
Taming Thymeleaf
</svg>
Next, we instruct Thymeleaf to search for fragments in the svg directory using the .svg suffix (as
opposed to the default .html suffix). For this, we add the following Spring Boot configuration:
package com.tamingthymeleaf.application;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import
org.thymeleaf.spring5.templateresolver.SpringResourceTemplateResolver;
import org.thymeleaf.templateresolver.ITemplateResolver;
@Configuration
public class TamingThymeleafApplicationConfiguration {
@Bean
public ITemplateResolver svgTemplateResolver() {
SpringResourceTemplateResolver resolver = new
SpringResourceTemplateResolver();
resolver.setPrefix("classpath:/templates/svg/");
resolver.setSuffix(".svg");
resolver.setTemplateMode("XML");
return resolver;
}
}
We also have to make sure that the default HTML template resolver has priority over our custom SVG
resolver. For this, set the spring.thymeleaf.template-resolver-order property to 0 in
application.properties:
src/main/resources/application.properties
# This ensures that the default HTML template resolver of Thymeleaf has
priority over our custom SVG resolver
spring.thymeleaf.template-resolver-order=0
If you get:
Chapter 5. Fragments | 69
Taming Thymeleaf
<div>
<svg th:replace="dashboard"></svg>
</div>
<div>
<svg class="text-gray-500 mr-3 h-6 w-6"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-
width="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-
2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1
1 0 001 1m-6 0h6"/>
</svg>
</div>
have done for the logo. The problem with using SVG images with an <img> tag, is
that you cannot style them using CSS.
70 | Chapter 5. Fragments
Taming Thymeleaf
We will add the mobile and desktop sidebar menu to sidebar-menu.html fragment and create top-
menu.html for the top bar menu.
Move the relevant <div> section into the fragments and give them a name using th:fragment:
src/main/resources/templates/fragments/sidebar-menu.html
<html xmlns:th="http://www.thymeleaf.org">
<div th:fragment="mobile"
class="md:hidden"
x-show="isVisible()">
...
</div>
<div th:fragment="desktop" class="hidden md:flex md:flex-shrink-0">
...
</div>
</html>
src/main/resources/templates/fragments/top-menu.html
<html xmlns:th="http://www.thymeleaf.org">
<div th:fragment="menu" class="relative z-10 flex-shrink-0 flex h-16 bg-
white shadow">
...
</div>
</html>
When we now use those fragments, our index.html becomes a lot more readable:
Chapter 5. Fragments | 71
Taming Thymeleaf
We are not restricted to pure HTML in fragment, we can just as easily move our JavaScript into a
Thymeleaf fragment.
Our index.html has currently this JavaScript for opening and closing the user menu when clicking
the avatar:
function userPopupMenu() {
return {
show: false,
toggleVisibility() {
this.show = !this.show;
},
close() {
this.show = false;
},
isVisible() {
return this.show === true;
}
};
}
72 | Chapter 5. Fragments
Taming Thymeleaf
<script th:fragment="user-popup-menu-js">
function userPopupMenu() {
return {
show: false,
toggleVisibility() {
this.show = !this.show;
},
close() {
this.show = false;
},
isVisible() {
return this.show === true;
}
};
}
</script>
<script src="https://unpkg.com/[email protected]/dist/cdn.min.js"
defer></script>
<script th:replace="fragments/top-menu :: user-popup-menu-js"></script>
<script src="https://unpkg.com/[email protected]/dist/cdn.min.js"
defer></script>
<script>
function userPopupMenu() {
return {
show: false,
toggleVisibility() {
this.show = !this.show;
},
close() {
this.show = false;
},
isVisible() {
return this.show === true;
}
};
}
Chapter 5. Fragments | 73
Taming Thymeleaf
</script>
Including JavaScript like this puts the JavaScript inside the actual HTML. This can be convenient at
times, but there is an alternative that is normally used more often. Put the JavaScript into its own file
and reference that.
function userPopupMenu() {
return {
show: false,
toggleVisibility() {
this.show = !this.show;
},
close() {
this.show = false;
},
isVisible() {
return this.show === true;
}
};
}
<script src="https://unpkg.com/[email protected]/dist/cdn.min.js"
defer></script>
<script th:src="@{/js/user-popup-menu.js}"></script>
<script src="https://unpkg.com/[email protected]/dist/cdn.min.js"
defer></script>
<script src="/js/user-popup-menu.js"></script>
In the menu on the left side, each menu item looks similar to this:
74 | Chapter 5. Fragments
Taming Thymeleaf
• <a> HTML tag to identify this as a link that can be clicked to navigate to that part of the application
• class attribute to style the menu item (NOTE: The comments indicate how the styles should
change when the menu item is selected)
• <svg> child element with the relevant icon for the menu item
Let’s create a fragment for this. Start with a file sidebar-buttons.html to put our fragment in:
src/main/java/resources/templates/fragments/sidebar-buttons.html
<html xmlns:th="http://www.thymeleaf.org">
<!--/*@thymesVar id="link" type="java.lang.String"*/-->
<!--/*@thymesVar id="title" type="java.lang.String"*/-->
<a th:fragment="desktop-button(link, title)"
th:href="${link}">
[[${title}]]
</a>
</html>
This gives us the basic structure to work with. We declare 2 parameters to our fragment: link and
title
Chapter 5. Fragments | 75
Taming Thymeleaf
The @thymesVar comment allows IntelliJ IDEA to know that there will be a variable
with the given name and type available in the Thymeleaf context. Due to this, the
editor can provide coding assistance.
<a href="#">
Dashboard
</a>
We will now add the class attribute. As the link can have 2 states (The menu item is selected, or it is
not selected), we have to be careful how we do this.
The "Dashboard" menu item has the list of classes that should be used when a menu item is selected:
We can look at any other menu item to check what classes are needed for an unselected item:
Looking closely, and based on the comments in the Tailwind UI template, these are the classes that
are the same between those:
src/main/java/resources/templates/fragments/sidebar-buttons.html
<html xmlns:th="http://www.thymeleaf.org">
<!--/*@thymesVar id="link" type="java.lang.String"*/-->
<!--/*@thymesVar id="title" type="java.lang.String"*/-->
<a th:fragment="desktop-button(link, title)"
th:href="${link}"
class="group flex items-center px-2 py-2 text-sm font-medium rounded-
md">
76 | Chapter 5. Fragments
Taming Thymeleaf
[[${title}]]
</a>
</html>
For the classes that are different, we will introduce another parameter to our fragment. We can call it
menuItem. Later on, we will also ensure that there will be an activeMenuItem parameter in the
Thymeleaf context so our component can correctly render itself.
To conditionally add CSS classes in Thymeleaf, we can use the th:classappend attribute. This will
append to the list of classes that ara present in the normal class attribute.
src/main/java/resources/templates/fragments/sidebar-buttons.html
<html xmlns:th="http://www.thymeleaf.org">
<!--/*@thymesVar id="link" type="java.lang.String"*/-->
<!--/*@thymesVar id="title" type="java.lang.String"*/-->
<!--/*@thymesVar id="menuItem" type="java.lang.String"*/-->
<!--/*@thymesVar id="activeMenuItem" type="java.lang.String"*/-->
<a th:fragment="desktop-button(link, title, menuItem)"
th:href="${link}"
class="group flex items-center px-2 py-2 text-sm font-medium rounded-
md"
th:classappend="${activeMenuItem == menuItem}? 'bg-gray-100 text-
gray-900' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'"
>
[[${title}]]
</a>
</html>
We also have to pass in the new parameter where we use the fragment:
Now that our CSS styling is ok, we need to look into adding the SVG icon. Since the SVG icons also
need to be styled using CSS, we need to inline them.
We start with copying the <svg></svg> contents of each of the icons to their own files, removing the
class attribute.
E.g. dashboard.svg:
src/main/resources/templates/svg/dashboard.svg
Chapter 5. Fragments | 77
Taming Thymeleaf
stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-
width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-
2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1
0 001 1m-6 0h6" />
</svg>
If you remember from Update the CSS file, we created a gulpfile.js to be able to have live
reloading while we are editing. This is configured to copy over HTML, CSS and JavaScript files, but not
SVG. We will expand the build to also include the SVG files.
Note that we also copy the CSS whenever the SVG changes, because SVG’s can also be styled using the
class attribute. When that happens, the Tailwind JIT compiler might need to generate new classes.
To ensure the Tailwind JIT compiler takes those SVG’s into account, update the tailwind.config.js
file:
module.exports = {
content: ['./src/main/resources/templates/**/*.html',
'./src/main/resources/templates/**/*.svg'], ①
theme: {
extend: {
fontFamily: {
sans: ['Inter var', ...defaultTheme.fontFamily.sans],
},
}
},
plugins: [
require('@tailwindcss/forms')
]
};
① Add the SVG files as content for the Tailwind JIT compiler
78 | Chapter 5. Fragments
Taming Thymeleaf
gulp.task('watch', () => {
browserSync.init({proxy: 'localhost:8080',});
gulp.watch(['src/main/resources/**/*.html'], gulp.series('copy-
html+css-and-reload'));
gulp.watch(['src/main/resources/**/*.svg'], gulp.series('copy-
svg+css-and-reload'));
gulp.watch(['src/main/resources/**/*.css'], gulp.series('copy-css-
and-reload'));
gulp.watch(['src/main/resources/**/*.js'], gulp.series('copy-js-and-
reload'));
});
Since we copy the SVG icons with gulp, we don’t need Maven to copy them. Update pom.xml to also
exclude them like we did for HTML, JS and CSS:
<resources>
<resource>
<directory>src/main/resources</directory>
<excludes>
<exclude>**/*.html</exclude>
<exclude>**/*.css</exclude>
<exclude>**/*.js</exclude>
<exclude>**/*.svg</exclude>①
</excludes>
</resource>
</resources>
After this, run the following to have live reloading working again:
Since we removed the class styling on the <svg> element itself, we will have to wrap our icons in a
<div> to apply the same styling.
The styling for the icon when in a selected menu item is:
Chapter 5. Fragments | 79
Taming Thymeleaf
Again separating out the common styles like we did before, we end up with:
Note how we introduced a fourth parameter icon to pass in the name of the icon.
The parameter icon does not include the .svg file extension as our custom
template resolver automatically adds the extension.
With this in place, we can replace the 50+ lines of HTML that makes up the menu for desktop, with the
following:
80 | Chapter 5. Fragments
Taming Thymeleaf
Which is a lot more readable to say the least. Also, all the duplication of the CSS that we had, has
disappeared by creating this re-usable Thymeleaf fragment.
All that is left now is do the same for the mobile menu items.
Chapter 5. Fragments | 81
Taming Thymeleaf
After all this, our application looks exactly the same to our users, but we have a much healthier code
base to work on.
5.8. Summary
In this chapter, you learned:
82 | Chapter 5. Fragments
Taming Thymeleaf
Chapter 6. Layouts
In the previous chapter, we explained how to use fragments to re-use parts of your HTML, or to just
better structure the HTML for a clearer overview.
If we start to think about all the pages the application will consist of, each page will need to display the
side menu, the top menu, maybe a footer, and of course the central content that the page will be
about. This can be a list of users, or the properties of a single user, or some other piece of information
that might be editable or not.
Ideally, we define the template with the auxiliary content once and just add the content that is needed
for the page in question. The Thymeleaf Layout Dialect allows us to do exactly that.
To use the Thymeleaf Layout Dialect, we need to add an extra dependency in the pom.xml:
pom.xml
src/main/resources/templates/layout/layout.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">①
Chapter 6. Layouts | 83
Taming Thymeleaf
<head>
<link rel="stylesheet" th:href="@{/css/application.css}">
</head>
<body>
<nav class="h-12 pl-4 bg-gray-100 shadow flex items-center justify-
start">
<a href="#" class="border-b-2 border-indigo-500 h-full inline-flex
items-center">Menu Item 1</a>
<a href="#" class="ml-6">Menu Item 2</a>
</nav>
<section layout:fragment="page-content" class="text-base text-gray-700
ml-4 mt-4"> ②
</section>
<script src="https://unpkg.com/[email protected]/dist/cdn.min.js"
defer></script>
<th:block layout:fragment="page-scripts">③
</th:block>
</body>
</html>
③ Use a th:block as a layout fragment called page-scripts. A th:block tag itself is not rendered,
but will render the content we put in the extension point as we use this template.
This layout we defined contains a minimal <head> section, a <body> with a menu and a <section>
for the main content, as well as an extension point to add extra JavaScript.
src/main/resources/templates/index.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/layout}">①
<head>
<title>Thymeleaf Layout Dialect</title> ②
</head>
<body>
<section layout:fragment="page-content"> ③
<div>Main content of the page goes here</div>
</section>
</body>
84 | Chapter 6. Layouts
Taming Thymeleaf
<th:block layout:fragment="page-scripts"> ④
<script>
function someFunction() {
}
</script>
</th:block>
</html>
① Use layout:decorate to indicate what layout should be used to decorate the current page. We
use the fragment expression syntax (~) to link to the layout. The first layout refers to the directory
(relative to src/main/resources/templates) where the layout can be found. The second
layout refers to the file name (layout.html).
③ Use layout:fragment to indicate that you want to have the content of this <section> tag
inserted at the page-content extension point of the layout.
④ Use the page-scripts extension point to add some JavaScript to the page.
When Thymeleaf renders this, the resulting HTML looks like this:
<!DOCTYPE html>
<html>
<head> ①
<title>Thymeleaf Layout Dialect</title>
<link rel="stylesheet" href="/css/application.css">
</head>
<body>
<nav class="h-12 pl-4 bg-gray-100 shadow flex items-center justify-
start"> ②
<a href="#" class="border-b-2 border-indigo-500 h-full inline-flex
items-center">Menu Item 1</a>
<a href="#" class="ml-6">Menu Item 2</a>
</nav>
<section class="text-base text-gray-700 ml-4 mt-4">
<div>Main content of the page goes here</div> ③
</section>
<script src="https://unpkg.com/[email protected]/dist/cdn.min.js"
defer></script>
<script> ④
function someFunction() {
}
</script>
</body>
Chapter 6. Layouts | 85
Taming Thymeleaf
</html>
① The <head> contains the items from the layout and from the page
src/main/resources/templates/layout/admonition.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<body>
86 | Chapter 6. Layouts
Taming Thymeleaf
Since we don’t want to use the full HTML page, but only the <div> and its children, we define a
fragment name using layout:fragment. We also specify that the layout takes a type parameter. If
the parameter has the value NOTE, we color the background yellow, otherwise, we color it blue.
<section layout:fragment="page-content">
<div class="mb-4">Main content of the page goes here</div>
<div layout:replace="~{layout/admonition ::
admonition(type='NOTE')}"> ①
<div layout:fragment="message"> ②
This is an example note message.
</div>
</div>
<div layout:replace="~{layout/admonition ::
admonition(type='TIP')}"> ③
<div layout:fragment="message">
You can use <span class="italic">parameters</span> with
layouts.
</div>
</div>
</section>
① Use layout:replace to have this <div> we declare here replaced with the content of the
admonition template. We pass in the type parameter with value NOTE.
② Our template has a message extension point where we can add any HTML content we like.
Chapter 6. Layouts | 87
Taming Thymeleaf
</div>
</div>
<div class="flex mb-4 p-2 w-2/4 bg-blue-100">
<div class="mr-4">TIP</div>
<div>
You can use <span class="italic">parameters</span> with
layouts.
</div>
</div>
</section>
If we define <title> each time in each page, we have to keep repeating the first part, making it hard
88 | Chapter 6. Layouts
Taming Thymeleaf
to change afterwards.
A better way to do this, is using layout:title-pattern. It allows to define the structure of the title
at the layout level and add the correct suffix at the page level.
<head>
<title layout:title-pattern="$LAYOUT_TITLE - $CONTENT_TITLE">My
Application</title>
<link rel="stylesheet" th:href="@{/css/application.css}">
</head>
If we have a content page that uses the above layout like this:
<head>
<title>Users</title>
</head>
<head>
<title>My Application - Users</title>
<link rel="stylesheet" href="/css/application.css">
</head>
In this example, we used static text inside the <title> tag, but we can also use dynamic text with
th:text.
src/main/resources/templates/layout/layout.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
lang="en">
<head>
<meta charset="UTF-8">
Chapter 6. Layouts | 89
Taming Thymeleaf
90 | Chapter 6. Layouts
Taming Thymeleaf
};
}
</script>
<th:block layout:fragment="page-scripts"> ③
</th:block>
</body>
</html>
① Set the <title> tag so that view using this layout can add to it
src/main/resources/templates/index.html
<!DOCTYPE html>
<html
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/layout}">
<head>
<title>Dashboard</title>
</head>
<body>
<div layout:fragment="page-content">
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
<h1 class="text-2xl font-semibold text-gray-900">Dashboard</h1>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
<div class="py-4">
<div class="border-4 border-dashed border-gray-200 rounded-
lg h-96">
<div></div>
</div>
</div>
</div>
</div>
</body>
</html>
This will again visually in the browser won’t change a thing, but it does set us up to quickly create
other pages.
So far, we have been using index.html because Spring Boot will automatically serve this when
present. In the next chapter, we will look into adding our own routes and adding more pages to our
Chapter 6. Layouts | 91
Taming Thymeleaf
application.
6.5. Summary
In this chapter, you learned:
92 | Chapter 6. Layouts
Taming Thymeleaf
Chapter 7. Controllers
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller ①
@RequestMapping("/teams") ②
public class TeamController {
@GetMapping("/all") ③
public String index() {
return "index"; ④
}
}
① The @Controller annotation will make Spring find the class automatically and register it as a
controller in the context.
② The @RequestMapping defines what path this controller controls. All @GetMapping annotated
methods in this class will have an effective path that is relative to what is specified here.
③ The @GetMapping annotation indicates that a HTTP GET request to /teams/all will end up calling
this index() method.
④ Methods in controllers can use a number of different return types. One of them is a plain String,
which will be interpreted by Spring as the name of the Thymeleaf view to render (relative to
src/main/resources/templates and without the .html extension of the file).
Each controller defines what path sections of the application it is responsible for. If there would be
multiple controllers referring to the same path, then Spring will signal this and refuse to start:
Chapter 7. Controllers | 93
Taming Thymeleaf
Developers from a different background sometimes find this strange at first. They might be used to a
single file containing all routes for the whole application. One could argue that you lack the overview
of the routes, but in my experience, I never had a problem with that.
Note that different controllers can refer to the same path on a class level, but not on
a method level.
@Controller
@RequestMapping("/teams")
public class TeamController {
@GetMapping("/all")
public String listAll() {
...
}
}
@Controller
@RequestMapping("/teams")
public class TeamHistoryController {
@GetMapping("/history")
public String listHistory() {
...
}
}
Our controller method returned a String which is interpreted as the path to the Thymeleaf template
to render. There are however many more return types possible. See Handler Method Return Values
for the full details on what is possible.
One of the types that can be injected into a controller method is org.springframework.ui.Model.
This class allows to add data (via the addAttribute(String, Object) method) that will be
available to the view for rendering.
An example:
@Controller
@RequestMapping("/teams")
94 | Chapter 7. Controllers
Taming Thymeleaf
@GetMapping("/all")
public String index(Model model) { ②
SortedSet<Team> teams = service.getTeams();
model.addAttribute("teams", teams); ③
return "teams/list";
}
}
① Inject the service that has the business logic to get the set of teams.
② Add Model as method parameter. Spring wil inject an instance of this class at runtime.
③ Add the set of teams under the teams key to the model.
We can now use that attribute in our Thymeleaf template like this:
<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/layout}">
<head>
<title>Teams</title>
</head>
<body>
<div layout:fragment="page-content">
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
<h1 class="text-2xl font-semibold text-gray-900">Teams</h1>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
<div class="py-4">
<ol class="ml-4">
<li th:each="team : ${teams}" class="list-disc"> ①
<span th:text="${team.name}"></span> ②
</li>
</ol>
</div>
</div>
Chapter 7. Controllers | 95
Taming Thymeleaf
</div>
</body>
</html>
① Use the exposed teams attribute to display the name of each team.
② We can call any method on the Team object to display information in the view. We could have used
${team.getName()}, but Thymeleaf also supports the shorter property notation ${team.name}.
Because we again used our layout, we immediately get a nicely rendered page with the menu and
everything:
Figure 18. Using @GetMapping and Model to display the list of teams
Notice also that the title of the page is rendered as Taming Thymeleaf - Teams because of that
layout:title-pattern that we used.
Request handler methods are very flexible in terms of what method arguments they can declare. You
can view the full list at Handler Methods Method Arguments. We will cover a few of those later on.
96 | Chapter 7. Controllers
Taming Thymeleaf
In our example, we could show more information about each team, at /teams/<id> where <id>
represents something that is unique about each team. In many cases, the primary key of the entity
will be used, but that does not necessarily be the case.
@Controller
@RequestMapping("/teams")
public class TeamController {
...
@GetMapping("/{id}") ①
public String teamInfo(@PathVariable("id") String teamId, ②
Model model) {
model.addAttribute("teamInfo", service.getTeamInfo(teamId)); ③
return "teams/info";
}
}
① Use the curly braces syntax to define that the part after /teams/ should be captured for usage as
a path variable.
② Have Spring inject the captured path variable as the String teamId into the controller method.
③ Use the teamId to get information about the team from the service and pass it as an attribute to
the view.
@Controller
@RequestMapping("/teams")
public class TeamController {
...
@GetMapping("/{teamId}/players/{playerId}") ①
public String playerOnTeamInfo(@PathVariable("teamId") String
teamId,
@PathVariable("playerId") String
playerId,②
Model model) {
model.addAttribute("player", service.getPlayerOnTeam(teamId,
playerId));
return "teams/info";
}
}
Chapter 7. Controllers | 97
Taming Thymeleaf
Suppose we have a form to change the name of a team. The controller method to make that possible
could look something like this:
@Controller
@RequestMapping("/teams")
public class TeamController {
...
@PostMapping("/{id}") ①
public String editTeamName(@PathVariable("id") String teamId,
@ModelAttribute("editTeamFormData")
EditTeamFormData formData) { ②
service.changeTeamName(teamId, formData.getTeamName()); ③
return "redirect:/teams/all"; ④
}
}
① Use the @PostMapping annotation to indicate that a POST call to /teams/<id> should be handled
by this method.
② EditTeamFormData is a simple POJO that you need to create to match the fields of the form you
are POST’ing.
③ Use the data to update the team name.
④ By using redirect: in the returned String, we instruct Spring to redirect to another page after the
POST. This is a pattern called Post/Redirect/Get that is used a lot in web development. By
redirecting, you avoid that the POST could be submitted twice if the user would refresh.
This is the basics of a @PostMapping. We will go into more detail later about how the form and the
form data Java object exactly should match up. We will also learn about proper error handling as this
example is lacking that for the moment.
98 | Chapter 7. Controllers
Taming Thymeleaf
All these HTTP verbs are nice, but web browsers only support GET and POST. If you want to use the
other 3 as well in your web application, you need to use the
org.springframework.web.filter.HiddenHttpMethodFilter. You enable this in Spring Boot
by setting the following property:
src/main/resources/application.properties
spring.mvc.hiddenmethod.filter.enabled=true
This allows to add a hidden input field in the form named _method that contains the wanted HTTP
method (PUT, PATCH or DELETE)
Chapter 13 will explain this in more detail and show an example of using this with the DELETE HTTP
method.
com.tamingthymeleaf.application.user.web.UserController
package com.tamingthymeleaf.application.user.web;
Chapter 7. Controllers | 99
Taming Thymeleaf
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/users")
public class UserController {
@GetMapping
public String index(Model model) {
return "users/list";
}
}
com.tamingthymeleaf.application.team.web.TeamController
package com.tamingthymeleaf.application.team.web;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/teams")
public class TeamController {
@GetMapping
public String index(Model model) {
return "teams/list";
}
}
• templates/users/list.html
• templates/teams/list.html
This is the source listing for the users/list.html view, but the other one is almost the same except
for the text in the title.
src/main/resources/templates/users/list.html
<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/layout}">
<head>
<title>Users</title>
</head>
<body>
<div layout:fragment="page-content">
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
<h1 class="text-2xl font-semibold text-gray-900">Users</h1>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
<div class="py-4">
<span>TODO show list of users</span>
</div>
</div>
</div>
</body>
</html>
Next, we change the menu items to have "Users" and "Teams". For the icon, we can use the icons
from the Heroicons set, as that is what the Tailwind UI application shell is using already.
Copy the users.svg and the user-group.svg icons from the website into the
src/main/resources/templates/svg directory.
Adjust the templates/fragments/sidebar-menu.html to use the icons and change the name of
the menu items. This needs to be done for the mobile menu and the desktop menu. As an example,
this is the desktop menu code:
① The "Users" menu link. The link parameter now refers to our UserController via the
@{/users} value.
With this in place, we can navigate between both pages using the desktop or mobile menu:
This is because the default controller that served the index.html is no longer active
as we now started adding our own controllers. Be sure to go to http://localhost:8080/
When looking at the screenshots, it becomes clear that we are missing an indication in the menu of
which menu item is currently selected.
If you remember from the chapter on fragments, we included an activeMenuItem property in our
fragment:
<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/layout}"
th:with="activeMenuItem='users'"> ①
<head>
<title>Users</title>
</head>
<body>
<div layout:fragment="page-content">
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
<h1 class="text-2xl font-semibold text-gray-900">Users</h1>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
<div class="py-4">
<span>TODO show list of users</span>
</div>
</div>
</div>
</body>
</html>
For reference, here is the menu item code for the "Users" menu:
Because the value of menuItem of our desktop-button now matches with the value of the
activeMenuItem (set using th:with on the page itself), the "Users" menu will be shown highlighted:
Figure 22. The Users page with the menu item highlighted
As a last thing before we close down this chapter, we will fix the problem that the root url
(http://localhost:8080) is no longer working. Since we only have the Users and the Teams pages, we
can redirect to either one of them. Let’s implement a redirect from the root to /users as an example.
package com.tamingthymeleaf.application.infrastructure.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/") ①
public class RootController {
@GetMapping
public String root() {
return "redirect:/users"; ②
}
}
7.7. Summary
In this chapter, you learned:
• What are controllers and how are they linked to the routes the application exposes
• The different request mapping annotations, and the link to the corresponding HTTP methods
• How to implement highlighting the active menu item
Chapter 8. Internationalization
I live in Belgium where we have 3 official languages. It might be the reason I find internationalization
(or i18n as it is called sometimes) important.
Using Spring Boot and Thymeleaf, it is really not that difficult to support multiple languages.
spring.messages.basename=i18n/messages
users.title=Users
This file has to contain a key and a value for each word or sentence that needs to be translated.
We can now refer to the translated text by using the key with the #{…} syntax in the th:text
attribute:
Let’s add another language, for example Dutch. The ISO 6319-1 language code for Dutch is nl, so we
should create a file called messages_nl.properties:
users.title=Gebruikers
We use the same users.title key again here, but with the Dutch translation this time.
It is also possible to add a country code variant to the name of the messages file. Use
messages_nl_BE.properties and messages_nl_NL.properties if you’d like to
use different translations for people from Belgium speaking Dutch, as compared to
people from the Netherlands speaking Dutch.
See List of ISO 3166 country codes for the full list of possible country codes.
To test this in Chrome, you can use the Advanced Page Language Switcher Chrome extension. This
extension changes the Accept-Language header that the browser sends out, so our application will
return the translated page:
We can add a query parameter that will set the language by creating a class that implements
org.springframework.web.servlet.config.annotation.WebMvcConfigurer with a
LocaleChangeInterceptor:
package com.tamingthymeleaf.application.infrastructure.web;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.LocaleResolver;
import
org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import
org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.i18n.CookieLocaleResolver;
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
@Configuration ①
public class WebMvcConfiguration implements WebMvcConfigurer { ②
@Bean
public LocaleResolver localeResolver() {
return new CookieLocaleResolver(); ③
}
@Bean
public LocaleChangeInterceptor localeInterceptor() { ④
LocaleChangeInterceptor localeInterceptor = new
LocaleChangeInterceptor();
localeInterceptor.setParamName("lang");
return localeInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) { ⑤
registry.addInterceptor(localeInterceptor());
}
}
① @Configuration ensures Spring Boot will find this class when scanning.
② Implement the
org.springframework.web.servlet.config.annotation.WebMvcConfigurer interface
that defines all callback methods that can be used to configure the Spring Web MVC setup.
③ Create a CookieLocaleResolver to store the selected language in a browser cookie.
We can now open http://localhost:8080/users?lang=nl in the browser and the Dutch translation will be
used (although the default language of the browser is English):
If you now remove the query parameter and access http://localhost:8080/users, you will notice that
the language remains Dutch. This is due to the cookie that has been stored. You can check this in the
Developer Tools:
Remove the cookie or add /?lang=en to the URL to go back to the English translations.
users.title=Users
menu.dashboard=Dashboard
menu.users=Users
menu.teams=Teams
menu.calendar=Calendar
menu.documents=Documents
menu.reports=Reports
and messages_nl.properties:
users.title=Gebruikers
menu.dashboard=Dashboard
menu.users=Gebruikers
menu.teams=Teams
menu.calendar=Kalender
menu.documents=Documenten
menu.reports=Rapporten
As you can see, we can just use the translations as values for fragment arguments.
If you forget to restart, or there is a key that is not in the translations files, then
Thymeleaf will output the key surrounded with ??:
8.4. Summary
In this chapter, you learned:
For demonstration purposes, and because a lot of projects use it, we will implement the 4 CRUD
(Create, Read, Update and Delete) actions using Spring Data JPA with Hibernate as the persistence
provider.
the possible implementations of that specification. If you are still confused about the
difference between JPA and Hibernate, have a look at this excellent stackoverflow
answer for more details.
To quickly spin up a PostgreSQL database, we can use Docker. Be sure to install Docker if you haven’t
before.
docker-compose.yaml
version: '3'
services:
db:
image: 'postgres:12'
ports:
- "5432:5432"
environment:
POSTGRES_DB: ${POSTGRES_DATABASE}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
Also create a .env file to specify the database, user and password to use:
POSTGRES_DATABASE=tamingthymeleafdb
POSTGRES_USER=postgres
POSTGRES_PASSWORD=FILL_IN
Add .env to your .gitignore file to avoid that you commit it by accident. In my
projects, I do commit an .env.example file so other developers can create their
own .env file easily.
docker-compose up -d
If you are using IntelliJ, you can also the green arrows in the gutter when the docker-compose.yaml
file is open in the editor:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId> ①
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId> ②
</dependency>
<dependency>
<groupId>io.github.wimdeblauwe</groupId>
<artifactId>jpearl-core</artifactId> ③
<version>${jpearl.version}</version>
</dependency>
Further, add this to the project > build > pluginManagement > plugins section of the
pom.xml:
<plugin>
<groupId>io.github.wimdeblauwe</groupId>
<artifactId>jpearl-maven-plugin</artifactId>
<version>${jpearl.version}</version>
<configuration>
<basePackage>
com.tamingthymeleaf.application</basePackage>
</configuration>
</plugin>
The JPA Early Primary Key Library was created for 2 main reasons:
• It helps to implement early primary key generation whereby the primary key of the entities are
generated before storing the object to the database.
◦ By doing so, we avoid that there are objects that don’t have a primary key yet until they are
persisted to the database.
◦ It makes implementing equals() and hashcode simpler since we know the primary key is
passed at construction time and thus always present. If you are not using early primary key
generation, be sure to follow the advice of Vlad Mihalcea at The best way to implement equals,
hashCode, and toString with JPA and Hibernate.
• It helps to use dedicated primary key classes which has the following advantages:
◦ It more clearly expresses the intent. If a variable is of type UserId, it is clear what you are
talking about, as opposed to a simple long or UUID.
◦ It is impossible to assign a value that is a UserId to an OrderId or a BookId. This reduces the
chance of putting a wrong ID somewhere.
◦ If you want to change from UUID to long or vice versa for the primary key, you will be able to
do so with minimal changes to the application code.
These ideas are something I picked up from reading Implementing Domain-Driven
Design by Vaughn Vernon.
If you are not familiar with the term entity, you should think of it as anything in your
application that you want to identify and track over the lifetime of the thing. This can
be users of an application, teams in our example application, orders in an e-
commerce application, …
This is in contrast with value objects. Those represent things that have no own
identity. For example, an object that represents a distance, or an amount of money,
…
Using the JPearl Maven Plugin, we can generate the basic structure of our entity and the Spring Data
JPA Repository.
Run:
You need to add the following to your ~/.m2/settings.xml for the command to
work:
<settings>
<pluginGroups>
<pluginGroup>io.github.wimdeblauwe</pluginGroup>
</pluginGroups>
</settings>
package com.tamingthymeleaf.application.user;
import io.github.wimdeblauwe.jpearl.AbstractEntity;
import javax.persistence.Entity;
@Entity ①
public class User extends AbstractEntity<UserId> { ②
/**
* Default constructor for JPA
*/
protected User() { ③
}
① Each entity must be annotated with the JPA defined @Entity annotation so our persistence library
(Hibernate) knows that we want to persist these kind of objects.
② We extend from AbstractEntity which is a jpearl base class that defines that an entity must
have a (unique) identifier. We are not using Long or UUID directly here, but a value object named
UserId.
③ Hibernate requires a default constructor. We make it protected since our application code
should never use that constructor directly.
④ The "normal" constructor requires an instance of the identifier.
package com.tamingthymeleaf.application.user;
import io.github.wimdeblauwe.jpearl.AbstractEntityId;
import java.util.UUID;
/**
* Default constructor for JPA
*/
protected UserId() { ②
}
① The class extends from AbstractEntityId which is the base class for the identifier value objects.
We use generics to specify that the underlying identifier is a UUID. If preferred, a Long could also
be used.
② Hibernate requires a default constructor. We make it protected since our application code
should never use that constructor directly.
③ The "normal" constructor requires an instance of the underlying identifier.
The reason for using a value object for the primary key is that it makes method
signatures a lot clearer. It avoids mistakes where the primary key of one entity type
is mistakenly used where the primary key of another entity type is required.
Suppose you have a method to add a user to a team. When using Long, you would
have:
It becomes impossible to mistakenly using the id of a user for the team and vice
versa.
• UserRepository
• UserRepositoryCustom
• UserRepositoryImpl
Looking at UserRepository:
package com.tamingthymeleaf.application.user;
import org.springframework.data.repository.CrudRepository;
import org.springframework.transaction.annotation.Transactional;
@Transactional(readOnly = true)
public interface UserRepository extends CrudRepository<User, UserId>,
UserRepositoryCustom {
}
This is just an interface that extends from the Spring Data JPA interface CrudRepository using our
User and UserId as generics arguments.
If you want to let the database generate primary keys upon saving the entity, you only need this
interface. However, we want to repository to generate unique id’s. For this purpose, we need a
UserRepositoryCustom interface:
package com.tamingthymeleaf.application.user;
package com.tamingthymeleaf.application.user;
import io.github.wimdeblauwe.jpearl.UniqueIdGenerator;
import java.util.UUID;
@Override
public UserId nextId() {
return new UserId(generator.getNextUniqueId()); ②
}
}
① Inject a UniqueIdGenerator<UUID>. This object is a Spring bean that will be responsible for
generating unique UUID objects. JPearl has the InMemoryUniqueIdGenerator class that can do
this for UUIDs. If you want to use Long objects instead, you will need to write your own
implementation.
② Use the UniqueIdGenerator to get a new unique id and create a UserId instance.
At runtime, Spring Data JPA will combine their implementation of the CrudRepository with our
custom Java code from UserRepositoryImpl. So if we inject the UserRepository interface into
another object, that object can use the methods from the CrudRepository and the
UserRepositoryCustom interfaces combined.
com.tamingthymeleaf.application.TamingThymeleafApplicationConfiguration
@Bean
public UniqueIdGenerator<UUID> uniqueIdGenerator() {
return new InMemoryUniqueIdGenerator();
}
Package structure
The code in this book uses package by feature. This way of structuring packages creates a
separate package for each feature in the application. So user, team, game, … Inside each
package, we will find the domain objects and services (For the user package this would be
User, UserService, UserRepository, … ).
The things that are not directly related to the domain like web controllers are put in a web sub-
package.
Next to those feature packages, there is also 1 infrastructure package that contains all code
related to infrastructure concerns like security, validation, serialization, …
Since we are writing very little actual code, but rely on JPA annotations and Spring Data JPA, it is better
to write an integration test to ensure everything is working well. A unit test in the strict sense would
have little benefit here.
package com.tamingthymeleaf.application.user;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.util.UUID;
@DataJpaTest ①
class UserRepositoryTest {
private final UserRepository repository;
private final JdbcTemplate jdbcTemplate;
@PersistenceContext ②
private EntityManager entityManager;
@Autowired
UserRepositoryTest(UserRepository repository,
JdbcTemplate jdbcTemplate) { ③
this.repository = repository;
this.jdbcTemplate = jdbcTemplate;
}
@BeforeEach
void validatePreconditions() { ④
assertThat(repository.count()).isZero();
}
@Test
void testSaveUser() { ⑤
UserId id = repository.nextId();
repository.save(new User(id)); ⑥
entityManager.flush(); ⑦
① @DataJpaTest instructs the Spring testing support that this test only needs "things" related to
database and persistence. Services and web controllers will not be started to speed up the tests.
② We inject the EntityManager via the @PersistenceContext annotation so we can flush the JPA
statements and validate the actual database tables.
③ We inject the UserRepository since that is the object we want to test, and the JdbcTemplate
which will help us validate the contents of the database.
④ Before each test starts, we validate that the database is empty. This ensures we start each test
from a valid state.
⑤ testSaveUser is our actual test method.
⑥ We use the save() method from UserRepository to store an instance of the user.
⑨ Assert that the id from the database matches with the generated id.
Before we can run the test, we need database tables. We can have Hibernate do this automatically,
but that is not a good solution for an actual production-grade application.
It is better to use either Flyway or Liquibase. We will go with Flyway in our application.
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
We are using tt_user here as table name instead of user as PostgreSQL does not
allow it (unless you quote the table name always).
As a result of this, we need to tell Hibernate to use that table name via the @Table
annotation:
@Entity
@Table(name = "tt_user")
public class User extends AbstractEntity<UserId> {
Flyway will automatically run the V1.0__init.sql script at startup of the application or a
@DataJpaTest. It will also make note of this in a special table so it won’t run the script again on next
starts of the application.
The final step before we can run our database test is having a database to run against. We could use
an in-memory database like H2 for that, however, it is better to test against an actual PostgreSQL
database. This used to be a big hassle to set up, but thanks to Docker and Testcontainers, this is no
longer the case.
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
We can now configure UserRepositoryTest to use an actual PostgreSQL database started via
Testcontainers:
package com.tamingthymeleaf.application.user;
import io.github.wimdeblauwe.jpearl.InMemoryUniqueIdGenerator;
import io.github.wimdeblauwe.jpearl.UniqueIdGenerator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import
org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDataba
se;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.util.UUID;
@DataJpaTest
@ActiveProfiles("data-jpa-test") ①
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace
.NONE) ②
class UserRepositoryTest {
private final UserRepository repository;
private final JdbcTemplate jdbcTemplate;
@PersistenceContext
private EntityManager entityManager;
@Autowired
UserRepositoryTest(UserRepository repository,
JdbcTemplate jdbcTemplate) {
this.repository = repository;
this.jdbcTemplate = jdbcTemplate;
}
@BeforeEach
void validatePreconditions() {
assertThat(repository.count()).isZero();
}
@Test
void testSaveUser() {
UserId id = repository.nextId();
repository.save(new User(id));
entityManager.flush();
@TestConfiguration ④
static class TestConfig {
@Bean
public UniqueIdGenerator<UUID> uniqueIdGenerator() { ⑤
return new InMemoryUniqueIdGenerator();
}
}
}
① The ActiveProfiles annotation allows to activate a certain profile when the test runs. By
specifying data-jpa-test, we can set properties in an application-data-jpa-
test.properties file and they will be used when the test runs.
② Spring Test will by default try to setup an in-memory database. We need to opt-out of that by using
@AutoConfigureTestDatabase since we are using Testcontainers.
④ An inner class that is annotated with @TestConfiguration will be added to the Spring context
that is started by the Spring Testing framework. This allows us to define the UniqueIdGenerator
bean that our repository needs.
We are using the JDBC support of Testcontainers by using a special JDBC URL which will instruct
Testcontainers to start a Docker image with PostgreSQL and make it available to our test. By activating
the data-jpa-test profile, the test will read the application-data-jpa-test.properties file,
that has the needed properties to make it all work:
src/test/resources/application-data-jpa-test.properties
spring.datasource.url=jdbc:tc:postgresql:12:///tamingthymeleafdb?TC_TMPF
S=/testtmpfs:rw ①
spring.datasource.driver-class-name
=org.testcontainers.jdbc.ContainerDatabaseDriver ②
spring.datasource.username=user ③
spring.datasource.password=password ④
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect ⑤
spring.jpa.hibernate.ddl-auto=validate ⑥
logging.level.org.hibernate.SQL=DEBUG ⑦
spring.jpa.properties.hibernate.show_sql=false ⑧
1. Test starts.
2. A Docker container is created using the postgres:12 image.
So far, our User only has a surrogate primary key (the id). Let’s add some more fields to make things
interesting:
package com.tamingthymeleaf.application.user;
import io.github.wimdeblauwe.jpearl.AbstractEntity;
import javax.persistence.Entity;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.Table;
import javax.validation.constraints.NotNull;
import java.time.LocalDate;
@Entity
@Table(name = "tt_user")
public class User extends AbstractEntity<UserId> {
@NotNull
private UserName userName; ①
@NotNull
@Enumerated(EnumType.STRING)
private Gender gender; ②
@NotNull
private LocalDate birthday; ③
@NotNull
private Email email; ④
@NotNull
private PhoneNumber phoneNumber; ⑤
protected User() {
}
this.email = email;
this.phoneNumber = phoneNumber;
}
① UserName is a value object that contains the firstName and lastName for a user.
③ The birthday field is using LocalDate to store the day a user was born.
package com.tamingthymeleaf.application.user;
import javax.persistence.AttributeConverter;
import javax.persistence.Converter;
@Converter(autoApply = true) ①
public class PhoneNumberAttributeConverter implements
AttributeConverter<PhoneNumber, String> {
@Override
public String convertToDatabaseColumn(PhoneNumber attribute) {
return attribute.asString();
}
@Override
public PhoneNumber convertToEntityAttribute(String dbData) {
return new PhoneNumber(dbData);
}
}
① Spring Boot will automatically apply the converter. The autoApply indicates that this converter
should be used for all PhoneNumber typed fields across the application.
• Hibernate can automatically map from a LocalDate to the DATE column type.
See the sources on GitHub for the full details of UserName, Email and PhoneNumber
value objects, as well as EmailAttributeConverter and
PhoneNumberAttributeConverter.
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>${guava.version}</version>
</dependency>
com.tamingthymeleaf.application.user.UserRepositoryTest
@Test
void testSaveUser() {
UserId id = repository.nextId();
repository.save(new User(id,
entityManager.flush();
Wait! If we would run this now, it would fail as our Flyway migration script is not yet updated. Update
it now to this:
src/main/resources/db/migration/V1.0__init.sql
The TamingThymeleafApplicationTests will still fail. We need to also start a Testcontainers based
database for it to work.
Add the @ActiveProfiles and @AutoConfigureTestDatabase annotations to the test class like
this:
com.tamingthymeleaf.application.user.TamingThymeleafApplicationTests.java
@SpringBootTest
@ActiveProfiles("spring-boot-test")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace
.NONE)
class TamingThymeleafApplicationTests {
src/test/resources/application-spring-boot-test.properties
spring.datasource.url=jdbc:tc:postgresql:12:///tamingthymeleafdb?TC_TMPF
S=/testtmpfs:rw
spring.datasource.driver-class-name
=org.testcontainers.jdbc.ContainerDatabaseDriver
spring.datasource.username=user
spring.datasource.password=password
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.hibernate.ddl-auto=validate
logging.level.org.hibernate.SQL=DEBUG
spring.jpa.properties.hibernate.show_sql=false
The application will not start at this point if you try to start it. It will fail with:
***************************
APPLICATION FAILED TO START
***************************
Description:
Failed to configure a DataSource: 'url' attribute is not
specified and no embedded datasource could be configured.
Do not worry. We will fix this in the next chapter when we will display data from the
database in our application.
9.4. Summary
In this chapter, you learned:
By only enabling the bean when the init-db profile is active, we can toggle if the database should be
populated at startup or not.
package com.tamingthymeleaf.application;
import com.github.javafaker.Faker;
import com.github.javafaker.Name;
import com.tamingthymeleaf.application.user.*;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.ZoneId;
@Component
@Profile("init-db") ①
public class DatabaseInitializer implements CommandLineRunner { ②
private final Faker faker = new Faker(); ③
private final UserService userService;
@Override
public void run(String... args) {
for (int i = 0; i < 20; i++) { ⑤
CreateUserParameters parameters = newRandomUserParameters();
userService.createUser(parameters);
}
}
① Only have this @Component active when the init-db profile is active.
② Implement CommandLineRunner interface so that Spring calls the run() method at startup.
<dependency>
<groupId>com.github.javafaker</groupId>
<artifactId>javafaker</artifactId>
<version>${javafaker.version}</version>
</dependency>
④ Our initializer will use the UserService interface to create and persist User objects.
UserService is in fact an interface that exposes methods related to the User entity and internally
will depend on the UserRepository to persist the entities. The UserController will also need this
service as that will contain all the business logic, as to keep the controller as small as possible.
package com.tamingthymeleaf.application.user;
The UserService currently only has a single method createUser with a single argument
CreateUserParameters:
package com.tamingthymeleaf.application.user;
import java.time.LocalDate;
}
Throughout the book, there will be …Parameters classes and …FormData classes.
A FormData class is always used as a form backing object for a HTML form. It will typically be
mutable as Spring MVC needs to bind the changes from the form on the object. It will not throw
NullPointerException or IllegalArgumentException when there is invalid data, because
the class exists to model invalid data entered by a user in a form. We need to have that invalid
data when we re-render the page to show what is wrong and give the user a chance to fix their
mistake.
It will also typically use String typing since that is easy to use for binding. It is part of the web
layer within the feature package and should only be used by the controller.
A Parameters object is part of the domain layer. Such objects will be immutable and they will
validate all constructor arguments, throwing NullPointerException,
IllegalArgumentException or other exception types as needed.
and:
Since the web layer can depend on the domain layer, but not vice-versa, the FormData object
will usually have a toParameters() method to convert from the form representation to the
domain-level Parameters representation.
@Service ①
@Transactional ②
public class UserServiceImpl implements UserService {
private final UserRepository repository;
@Override
public User createUser(CreateUserParameters parameters) {
UserId userId = repository.nextId(); ③
User user = new User(userId,
parameters.getUserName(),
parameters.getGender(),
parameters.getBirthday(),
parameters.getEmail(),
parameters.getPhoneNumber()); ④
return repository.save(user); ⑤
}
}
① Add the @Service annotation so Spring automatically creates an instance in the context.
Before we now can start our application to generate the 20 random users, we need to tell it where to
find the database. If you followed along, you should have a PostgreSQL database running in Docker.
spring.thymeleaf.cache=false
# Database setup
spring.datasource.url=jdbc:postgresql://localhost/tamingthymeleafdb
spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.username=postgres
spring.datasource.password=PUT_YOUR_PWD_HERE
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.hibernate.ddl-auto=validate
Don’t forget to set the correct password you used in your .env file for the
spring.datasource.password property.
Connect to the database using your favorite tool (I just use IntelliJ IDEA) and see that our 20 users are
now present in the tt_user table:
Figure 29. The tt_user database table with 20 randomly generated users
Stop the application again and ensure you start with the local profile only the next time, or another
20 users will be added to the database.
To get the users from the database, we update UserService with a getAllUsers method:
@Override
public ImmutableSet<User> getAllUsers() {
return ImmutableSet.copyOf(repository.findAll());
}
This uses the ImmutableSet class from Guava since the result of the method call should not be
altered.
Next, we inject UserService into UserController and put the users in the model under the users
key:
package com.tamingthymeleaf.application.user.web;
import com.tamingthymeleaf.application.user.UserService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/users")
public class UserController {
@GetMapping
public String index(Model model) {
model.addAttribute("users", service.getAllUsers()); ①
return "users/list"; ②
}
}
① Store the returned users under the users key in the model.
② Return the name of the Thymeleaf view to render. users/list means that the view at
src/main/resources/templates/users/list.html will be used.
<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/layout}"
th:with="activeMenuItem='users'">
<head>
<title>Users</title>
</head>
<body>
<div layout:fragment="page-content">
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
<h1 class="text-2xl font-semibold text-gray-900"
th:text="#{users.title}">Users</h1>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
<div class="py-4">
<div class="flex flex-col">
<div class="-my-2 py-2 overflow-x-auto sm:-mx-6 sm:px-6
lg:-mx-8 lg:px-8">
<div class="align-middle inline-block min-w-full
shadow overflow-hidden rounded-md sm:rounded-lg border-b border-gray-
200">
<table class="min-w-full">
<thead>
<tr>
<th class="px-6 py-3 border-b border-
gray-200 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-
500 uppercase tracking-wider">
Name
</th>
<th class="px-6 py-3 border-b border-
gray-200 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-
500 uppercase tracking-wider">
Gender
</th>
<th class="px-6 py-3 border-b border-
gray-200 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-
500 uppercase tracking-wider">
Birthday
</th>
<th class="px-6 py-3 border-b border-
gray-200 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-
500 uppercase tracking-wider">
Email
</th>
<th class="px-6 py-3 border-b border-
gray-200 bg-gray-50"></th>
</tr>
</thead>
<tbody>
We need to update UserName with the getFullName() method to make this work:
com.tamingthymeleaf.application.user.UserName
}
<tr>
<th class="px-6 py-3 border-b border-gray-200 bg-gray-50 text-left
text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
Create a new fragment table.html to put in all the table related fragments. Add a fragment header
that represents how we want to style our table headers:
<th th:fragment="header(title)"
class="px-6 py-3 border-b border-gray-200 bg-gray-50 text-
left text-xs leading-4 font-medium text-gray-500 uppercase tracking-
wider"
th:text="${title}">
Header title
</th>
Be careful not to use the name of a HTML tag as the fragment name. Because
Thymeleaf can use CSS selectors to match a fragment, it would lead to confusing
results. See Appendix C: Markup Selector Syntax in the Thymeleaf documentation for
more details.
The fragments accepts a single argument for the title text of the header. Using this fragment in
users/list.html, makes the HTML page a lot more readable (Remember to add each of the
translation keys to src/main/resources/i18n/messages.properties):
<tr>
<th th:replace="fragments/table :: header(#{user.name})"></th>
<th th:replace="fragments/table :: header(#{user.gender})"></th>
<th th:replace="fragments/table :: header(#{user.birthday})"></th>
<th th:replace="fragments/table :: header(#{user.email})"></th>
<th th:replace="fragments/table :: header('')"></th>
</tr>
We can now do the same for the body of the table. This is the current body:
1. The first <td> defines font-medium text-gray-900 so the text is bigger and bolder there.
2. The "normal" <td> for the remaining columns that display data.
3. The last <td> column that has a child tag with an <a> which will lead to the edit page later.
We can create a single fragment with an optional parameter for the first 2 cases:
<td th:fragment="data(contents)"
th:with="primary=${primary?: false}"
class="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-
gray-500"
th:classappend="${primary?'font-medium text-gray-900':''}"
th:text="${contents}">
Table data contents
</td>
To have a fragment with an optional parameter in Thymleaf, we can use the th:with attribute. In this
example, we define primary inside th:with. If the caller passes in primary, we use that value. If
not, we default to false via the "elvis operator" (?:). We then use the value of primary to define if
This is the fragment for the <td> that has the link:
① We set primary to true to have the first column use the bolder styling. Note how we need to
provide the contents parameter name as well.
② Use the dataWithLink fragment, passing in the link text and link URL (which is a placeholder for
now)
The resulting rendering in the browser has not changed by this, but our code is now in a much better
shape.
Spring Data JPA makes it very easy to implement pagination. We need to change our
UserRepository to extend PagingAndSortingRepository as opposed to CrudRepository:
package com.tamingthymeleaf.application.user;
import org.springframework.data.repository.PagingAndSortingRepository;
import org.springframework.transaction.annotation.Transactional;
@Transactional(readOnly = true)
public interface UserRepository extends PagingAndSortingRepository<User,
UserId>, UserRepositoryCustom {
}
By doing so, our repository now allows to get entities in pages. From the
PagingAndSortingRepository source code:
/**
* Returns a {@link Page} of entities meeting the paging restriction
provided in the {@code Pageable} object.
*
* @param pageable
* @return a page of entities
*/
Page<T> findAll(Pageable pageable);
Pageable represents the input parameters that will allow the database to return the correct set of
results. Page represents those results together with some metadata like total number of pages, total
number of elements, …
We can test our new paging capability by writing an extra test in UserRepositoryTest:
@Test
void testFindAllPageable() {
saveUsers(8); ①
assertThat(repository.findAll(PageRequest.of(1, 5, sort))) ⑦
.hasSize(3)
.extracting(user -> user.getUserName().getFullName())
.containsExactly("Tommy2 Walton", "Tommy4 Walton",
"Tommy6 Walton");
assertThat(repository.findAll(PageRequest.of(2, 5, sort
))).isEmpty(); ⑧
}
Run the UserRepositoryTest, all should be green. The SQL logging will also show that the sorting is
applied on the database level:
Next up, we change UserService to use our new findAll(Pageable) method on the
UserRepository. We replace ImmutableSet<User> getAllUsers() with getUsers(Pageable):
package com.tamingthymeleaf.application.user;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@Override
public Page<User> getUsers(Pageable pageable) {
return repository.findAll(pageable);
}
We can now update the controller to make use of the new service method:
package com.tamingthymeleaf.application.user.web;
import com.tamingthymeleaf.application.user.UserService;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.SortDefault;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/users")
public class UserController {
@GetMapping
public String index(Model model,
@SortDefault.SortDefaults({
@SortDefault("userName.lastName"),
@SortDefault("userName.firstName")})
Pageable pageable) { ①
model.addAttribute("users", service.getUsers(pageable)); ②
return "users/list";
}
}
① We add an extra parameter of type Pageable with the @SortDefault annotation to set the
default sort order. Spring MVC will inject a correct instance of the Pageable object depending on
the query parameters used.
Before we test this, we can set the global page size Spring Data should use:
application.properties
spring.data.web.pageable.default-page-size=10
Running the application, we now get a table with the first 10 users, sorted by last name (and first
name):
We can get to the other pages by manipulating the URL to add the page query parameter:
Manually manipulating the URL is obviously bad UX, so let’s add some pagination controls to our UI.
We can start by copying the HTML of one of the pagination controls of Tailwind UI into
fragments/pagination.html. Using the fragment for our list of users gives us:
This already looks great, but it doesn’t work yet. We need to implement the following behaviour in our
pagination fragment:
In order to build a fully re-usable pagination component, the fragment will take a page variable of the
type org.springframework.data.domain.Page. We will also use the
org.springframework.web.servlet.support.ServletUriComponentsBuilder class from
Spring to build the URLs for each page button. Let’s go through the code bit by bit.
<div th:fragment="controls"
class="bg-white px-4 py-3 flex items-center justify-between border-
t border-gray-200 sm:px-6"
th:with="urlBuilder=${T(org.springframework.web.servlet.support.ServletU
riComponentsBuilder)}">
That top-level <div> has 2 child tags: one for mobile and one for desktop.
For mobile, we only have a 'Previous' and 'Next' button with no indication of the current page. We use
the page.isFirst() method to conditionally enable or disable the 'Previous' button. For the 'Next'
button, we use page.isLast().
Make particular note to how the href is build via the urlBuilder variable:
In case your are wondering: replaceQueryParam also works if the param is not
there yet. It will be added in that case to the URL.
The pagination summary part is fairly straight forward using the Page methods getSize(),
getNumber(), getNumberOfElements() and getTotalElements():
<div>
<p id="pagination-summary" class="text-sm leading-5 text-
gray-700">
Showing
<span class="font-medium" th:text="${(page.getSize() *
page.getNumber()) + 1}">1</span>
to
<span class="font-medium" th:text="${(page.getSize() *
page.getNumber()) + page.getNumberOfElements()}">10</span>
of
<span class="font-medium"
th:text="${page.getTotalElements()}">97</span>
results
</p>
</div>
This solution glosses over the details of providing proper translation of the
pagination summary. We should not create translation keys for Showing, to, of and
results parts separately since we cannot be sure the order will the be same in other
languages.
One possible solution is to add the <span> tags to the actual translation to keep the
styling:
{0}, {1} and {2} are placeholders where the actual values can be passed in from
the HTML:
We need to use th:utext (as opposed to th:text) to avoid that Thymeleaf would
escape the <span> tag.
The previous/next arrows are very similar to the mobile versions. this is the source for the 'Previous'
button:
<a id="pagination-previous"
th:href="${page.isFirst()}?'javascript:void(0)':${urlBuilder.fromCurrent
Request().replaceQueryParam('page', page.number - 1).toUriString()}"
class="relative inline-flex items-center px-2 py-2
rounded-l-md border bg-white text-sm leading-5 font-medium"
th:aria-label="#{pagination.previous}"
th:classappend="${page.isFirst()?'pointer-events-none
text-gray-200 border-gray-200':'border-gray-300 text-gray-500
hover:text-gray-400 focus:z-10 focus:outline-none focus:border-blue-300
focus:shadow-outline-blue active:bg-gray-100 active:text-gray-500
transition ease-in-out duration-150'}"
th:disabled="${page.isFirst()}">
<svg class="h-5 w-5" fill="currentColor" viewBox="0
0 20 20">
<path fill-rule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414
10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0
011.414 0z"
clip-rule="evenodd"/>
</svg>
</a>
<th:block
th:with="startPage=${T(Math).max(1,
page.getNumber() - 1)},endPage=${T(Math).min(startPage + 4,
page.getTotalPages())}">
<a th:each="pageNumber :
${#numbers.sequence(startPage, endPage)}"
th:id="${'pagination-page-' + pageNumber}"
th:href="${urlBuilder.fromCurrentRequest().replaceQueryParam('page',
pageNumber - 1).toUriString()}"
class="-ml-px relative inline-flex items-center
px-4 py-2 border border-gray-300 bg-white text-sm leading-5 font-medium
text-gray-700 hover:text-gray-500 focus:z-10 focus:outline-none
focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-100
active:text-gray-700 transition ease-in-out duration-150"
th:classappend="${page.number == pageNumber -
1?'font-bold':''}"
>
<span th:text="${pageNumber}" th:remove=
"tag"></span>
</a>
</th:block>
We use a th:block here to generate a maximum of 5 buttons around the current page number,
taking into account that we cannot go below 1 or above the total number of pages. The result of those
calculations are put in the startPage and endPage variables in the th:with attribute. Using those
variables, we iterate over a sequence of numbers from startPage to endPage to generate the
buttons. Thymeleaf has the built-in #numbers.sequence(start,end) to do that. Also note how we
bold the current page number in the th:classappend attribute.
With our pagination fragment complete, all that is left is to call it with the appropriate variable in
users/list.html:
If you want to test the pagination some more, you can append size=3 to the URL to
set the page size. This will give you more pages for testing:
If we only want to keep the 'Name' and 'Edit' columns, we should hide the other columns using
Tailwind. If we follow the recommendation on Targeting mobile screens, we should add the hidden
sm:table-cell styles to the columns we want to hide. This will ensure they are hidden on the small
screen and become visible as soon as the screen is bigger.
This is the adjusted header fragment with a new parameter hideOnMobile to add the extra CSS
classes:
<th th:fragment="header(title)"
th:with="hideOnMobile=${hideOnMobile?:false}"
class="px-6 py-3 border-b border-gray-200 bg-gray-50 text-left text-
xs leading-4 font-medium text-gray-500 uppercase tracking-wider"
th:classappend="${hideOnMobile?'hidden sm:table-cell':''}"
th:text="${title}">
Header title
</th>
<tr>
<th th:replace="fragments/table :: header(#{user.name})"></th>
<th th:replace="fragments/table ::
header(title=#{user.gender},hideOnMobile=true)"></th>
<th th:replace="fragments/table ::
header(title=#{user.birthday},hideOnMobile=true)"></th>
<th th:replace="fragments/table ::
header(title=#{user.email},hideOnMobile=true)"></th>
<th th:replace="fragments/table :: header('')"></th>
</tr>
<td th:fragment="data(contents)"
th:with="primary=${primary?:
false},hideOnMobile=${hideOnMobile?:false}"
class="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-500"
th:classappend="|${primary?'font-medium text-gray-900':''}
${hideOnMobile?'hidden sm:table-cell':''}|"
th:text="${contents}">
Table data contents
</td>
Note the Literal substitutions syntax to be able to have multiple conditions in the th:classappend
attribute.
10.6. Summary
In this chapter, you learned:
4. If there are no validation errors, the browser gets redirected to avoid double submissions.
5. If there are validation errors, the form remains in place so the user can correct the information.
GET /users/create
POST /users/create
Store user
GET /users
As a first implementation step, we need to create an object that will map each HTML form input to a
property of the Java object:
package com.tamingthymeleaf.application.user.web;
import com.tamingthymeleaf.application.user.CreateUserParameters;
import com.tamingthymeleaf.application.user.Gender;
import com.tamingthymeleaf.application.user.PhoneNumber;
import com.tamingthymeleaf.application.user.UserName;
import org.springframework.format.annotation.DateTimeFormat;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
import java.time.LocalDate;
Compared to the CreateUserParameters object that uses rich value objects, we restrict ourselves
here to mainly String types and no nesting (like with UserName). This will make it easier to map the
fields of the form to the CreateUserFormData object.
• birthday: This is a LocalDate and needs the DateTimeFormat annotation to indicate how the
date will be present in the HTML form input.
The annotations @NotBlank, @NotNull, @Email and @Pattern will validate the input we receive
from the form. This is what is called server-side validation, which is the type of validation that you
always need to perform since you cannot trust if client-side validation has actually happened.
If you are wondering if you need both types of validation, I usually think of it this way: client-side
validation is needed to have better usability, server-side validation is needed to protect the application
from invalid data.
com.tamingthymeleaf.application.user.web.UserController
@GetMapping("/create") ①
public String createUserForm(Model model) { ②
model.addAttribute("user", new CreateUserFormData()); ③
model.addAttribute("genders", List.of(Gender.MALE, Gender.
FEMALE, Gender.OTHER)); ④
return "users/edit"; ⑤
}
③ Add an empty CreateUserFormData object to the model under the user key.
④ Add the list of possible genders. This will be used to generate a radio button for each option.
⑤ Return the path to the Thymeleaf template that will render the form.
With our controller updated, we can now add our Thymeleaf template that contains the form input
controls. This is the full source of edit.html so you can get an overview of the code. We will break it
down piece by piece below the code block.
src/main/resources/templates/users/edit.html
<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/layout}"
th:with="activeMenuItem='users'">
<head>
<title>Users</title>
</head>
<body>
<div layout:fragment="page-content">
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
<h1 class="text-2xl font-semibold text-gray-900"
th:text="#{user.create}">Create user</h1>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
<div class="py-4">
<div class="bg-white shadow px-4 py-5 sm:rounded-lg sm:p-6">
<form id="user-form"
th:object="${user}"
th:action="@{/users/create}"
method="post">
<div>
</th:block>
</div>
</div>
<div class="sm:col-span-3">
<label for="firstName" class="block
text-sm font-medium text-gray-700"
th:text="#{user.firstName}">
First name
</label>
<div class="mt-1 rounded-md shadow-sm">
<input id="firstName"
type="text"
th:field="*{firstName}"
class="shadow-sm focus:ring-
green-500 focus:border-green-500 block w-full sm:text-sm border-gray-300
rounded-md">
</div>
</div>
<div class="sm:col-span-3">
<label for="lastName" class="block text-
sm font-medium text-gray-700"
th:text="#{user.lastName}">
Last name
</label>
<div class="mt-1 rounded-md shadow-sm">
<input id="lastName"
type="text"
th:field="*{lastName}"
class="shadow-sm focus:ring-
green-500 focus:border-green-500 block w-full sm:text-sm border-gray-300
rounded-md">
</div>
</div>
<div class="sm:col-span-4">
<label for="email" class="block text-sm
font-medium leading-5 text-gray-700"
th:text="#{user.email}">
Email address
</label>
<div class="mt-1 rounded-md shadow-sm">
<input id="email"
type="email"
th:field="*{email}"
class="shadow-sm focus:ring-
green-500 focus:border-green-500 block w-full sm:text-sm border-gray-300
rounded-md">
</div>
</div>
<div class="sm:col-span-4">
<label for="phoneNumber" class="block
text-sm font-medium leading-5 text-gray-700"
th:text="#{user.phoneNumber}">
Phone number
</label>
<div class="mt-1 rounded-md shadow-sm">
<input id="phoneNumber"
type="text"
th:field="*{phoneNumber}"
class="shadow-sm focus:ring-
green-500 focus:border-green-500 block w-full sm:text-sm border-gray-300
rounded-md">
</div>
</div>
<div class="sm:col-span-2"></div>
<div class="sm:col-span-2">
<label for="birthday" class="block text-
sm font-medium leading-5 text-gray-700"
th:text="#{user.birthday}">
Birthday
</label>
<div class="mt-1 rounded-md shadow-sm">
<input id="birthday"
type="text"
th:field="*{birthday}"
th:placeholder="#{user.birthday.placeholder}"
class="shadow-sm focus:ring-
green-500 focus:border-green-500 block w-full sm:text-sm border-gray-300
rounded-md">
</div>
</div>
</div>
</div>
<div class="mt-8 border-t border-gray-200 pt-5">
<div class="flex justify-end">
<span class="inline-flex rounded-md shadow-sm">
<button type="button"
class="bg-white py-2 px-4 border border-gray-300
rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-
500"
th:text="#{cancel}">
Cancel
</button>
</span>
<span class="ml-3 inline-flex rounded-md
shadow-sm">
<button type="submit"
<html
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/layout}"
th:with="activeMenuItem='users'">
At the top of the page, we define the layout we use, just as we did for users/list.html. This
ensures we have the menu on the left side and the avatar on the top present when rendering this
page.
<form id="user-form"
th:object="${user}"
th:action="@{/users/create}"
method="post">
• th:object: This defines the binding object for the form elements. user has to match with the
name we used in the controller when we added the CreateUserFormData instance to the model.
• th:action: This indicates the URL that the form will POST to.
<div class="sm:col-span-3">
<label for="firstName" class="block text-sm font-medium text-gray-
700"
th:text="#{user.firstName}">
First name
</label>
<div class="mt-1 rounded-md shadow-sm">
<input id="firstName"
type="text"
th:field="*{firstName}"
class="shadow-sm focus:ring-green-500 focus:border-green-
500 block w-full sm:text-sm border-gray-300 rounded-md">
</div>
</div>
Each of the inputs looks similar to the one above for firstName. The most important part here is the
th:field=*{firstName} attribute which binds the value of the HTML input to the firstName
property of the CreateUserFormData instance. Note the *{} syntax used. See Selected objects for a
refresher how this works exactly if needed.
The other input fields are very similar, except for the radio button group to select the gender:
<div class="sm:col-span-6">
<label class="block text-sm font-medium text-gray-700"
th:text="#{user.gender}">
Gender
</label>
<div>
<th:block th:each="possibleGender,iter : ${genders}">
<input type="radio"
th:id="${'gender-'+possibleGender}"
th:field="*{gender}"
th:value="${possibleGender}"
class="mr-1 focus:ring-green-500 h-4 w-4 text-green-
500 border-gray-300"
th:classappend="${iter.index > 0 ?'ml-4':''}"
> <!-- mr-1 transition duration-150 ease-in-out sm:text-sm
sm:leading-5 text-green-500 focus:shadow-outline-green-->
<label th:for="${'gender-'+possibleGender}"
th:text="#{'Gender.'+${possibleGender}}"
class="text-sm font-medium text-gray-700">
<!-- sm:text-sm sm:leading-5 -->
</label>
</th:block>
</div>
</div>
We iterate over the genders list that was put in the model to generate an <input> tag for each
possible gender. We give each <input> a unique id, based on the name() of the Gender enum. For
each <input>, we have a corresponding <label> so that we can properly style the text next to the
radio button.
The th:classappend checks the current iteration index to add a left margin to each element, except
the first.
To ensure the 'Save' button actually works, we need to implement handling the POST in
UserController:
com.tamingthymeleaf.application.user.web.UserController
@PostMapping("/create") ①
public String doCreateUser(@Valid @ModelAttribute("user")
CreateUserFormData formData, ②
BindingResult bindingResult, Model model)
{ ③
if (bindingResult.hasErrors()) { ④
model.addAttribute("genders", List.of(Gender.MALE, Gender
.FEMALE, Gender.OTHER)); ⑤
return "users/edit"; ⑥
}
service.createUser(formData.toParameters()); ⑦
return "redirect:/users"; ⑧
}
② Inject the CreateUserFormData instance that has been put in the model under the user key. This
one will have the values from the HTML form. The @Valid annotation is required to have Spring
MVC check the validity of CreateUserFormData according to the validation annotations that we
have used.
③ Inject a BindingResult instance.
④ Check the bindingResult instance if there are validation errors. If so, we display the HTML page
again.
⑤ Add the list of genders again so we can render the radio buttons after a validation error.
⑥ Return the name of the Thymeleaf template to render.
⑦ Convert the formData to domain parameters object CreateUserParameters and have the
service create the user.
The BindingResult must be immediately following the object that is annotated with
@Valid.
Now try to save with invalid or missing input. The form remains visible. This is great as we don’t get
invalid input this way. However, there is no indication of what is wrong.
Thymleaf has the #fields.hasErrors method available in templates that allows checking if there is
a validation error on a field or not.
<div class="sm:col-span-3">
The changes:
• An extra <div> is added that contains the SVG error icon. By using
th:if="${#fields.hasErrors('firstName')}", this tag is only present when there is an
actual error on the firstName field.
• An extra <p> with the error message text.
If we apply this to all the input fields, we get the following error messages if we try to submit an empty
form:
However, we have now a hardcoded error message for each field. This means that for fields that can
have multiple reasons that the validation fails, we will not be able to show a good error message as it
is now.
As an example, take the birthday property. These are the validation rules defined in
CreateUserFormData:
@NotNull
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate birthday;
This means that the value cannot be null, but it also has to conform to the pattern.
We can ask for the exact problem(s) on a property using #fields.errors('property'). If we apply
this to the birthday input, we get:
<div class="sm:col-span-2">
<label for="birthday" class="block text-sm font-medium leading-5
text-gray-700"
th:text="#{user.birthday}">
Birthday
</label>
<div class="mt-1 relative rounded-md shadow-sm">
<input id="birthday"
type="text"
th:field="*{birthday}"
th:placeholder="#{user.birthday.placeholder}"
class="shadow-sm block w-full sm:text-sm border-gray-300
rounded-md"
th:classappend="${#fields.hasErrors('birthday')?'border-
red-300 focus:border-red-300 focus:ring-red-500':'focus:ring-green-500
focus:border-green-500'}">
<div th:if="${#fields.hasErrors('birthday')}"
class="absolute inset-y-0 right-0 pr-3 flex items-center
pointer-events-none">
<svg class="h-5 w-5 text-red-500" fill="currentColor"
viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0
11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"/>
</svg>
</div>
</div>
<p th:if="${#fields.hasErrors('birthday')}"
th:text="${#strings.listJoin(#fields.errors('birthday'), ', ')}"
class="mt-2 text-sm text-red-600" id="birthday-error">Birthday
validation error message(s).</p>
</div>
We first get the list of errors for the birthday property (Technically a List<String>) and
concatenate those to form a comma separated list.
We have now very technically correct error messages, but not so user friendly. Let’s fix this in the next
part.
We can change the error message for all validation errors of a certain type (E.g. all @NotNull
violations), or we can precisely change the message for a single property on a single model attribute.
• NotBlank: This will affect all fields from all objects that use the @NotBlank validation annotation.
The {0} is replaced with the name of the field. An example error message would be: The
property 'First name' should not be blank.
• NotBlank.email: This will affect all fields named email from all objects that use the @NotBlank
validation annotation.
• NotBlank.java.lang.String: This will affect all fields from all objects that use the @NotBlank
validation annotation and are of type java.lang.String.
• NotBlank.user.birthday: This message will only be shown for the birthday field of a model
attribute named user that is annotated with @NotBlank.
They can also be translated by adding the same translation keys to the other messages files (E.g.
messages_nl.properties)
Some validation annotations allow to pass extra arguments to the translations. For example Size:
@NotBlank
@Size(min = 2, max = 200)
private String firstName;
If the name is too long, the error message will print: The first name should be between 2 and
200 characters.
We will create a custom validator that validates the whole object. For example, it would be good to
validate if there is already a known user with the given email address since the email address of each
user should be unique.
package com.tamingthymeleaf.application.user.web;
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.TYPE) ①
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = NotExistingUserValidator.class) ②
public @interface NotExistingUser {
String message() default "{UserAlreadyExisting}";
package com.tamingthymeleaf.application.user.web;
import com.tamingthymeleaf.application.user.Email;
import com.tamingthymeleaf.application.user.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.StringUtils;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
@Autowired
public NotExistingUserValidator(UserService userService) { ①
this.userService = userService;
}
return false; ⑧
}
return true;
}
}
① Inject the UserService Spring component so we can we check if there is already an existing user
with the given email address.
② initialize method is not needed in this example. This is useful when your custom annotation
has extra parameters you want to read out.
③ Check if there is an existing user via userService. Note how we also need to check if the email is
not empty first. This is because our validator currently runs before the field validations @NotBlank
and @Email. The section Validation groups and order further in the book will show how we can
make those run first and avoid that check.
④ Disables the default constraint violation: Because our validator is used with an annotation on class
level, Spring will register a constraint violation at the "global" level by default. We don’t want this in
this example, as we will register it on the email property level. If we would not call this method,
there would be the same error message twice. Once at the global level and once for the email
property.
⑤ Create a ConstraintViolationBuilder. By using the {…} syntax, the actual message can come
from the messages.properties file (and can be translated).
We use the {…} annotation to be able to translate the error messages. To be able to read those
translations from messages.properties, we need to configure it. We do this by creating our own
instance of
org.springframework.validation.beanvalidation.LocalValidatorFactoryBean in
TamingThymeleafApplicationConfiguration:
package com.tamingthymeleaf.application;
import io.github.wimdeblauwe.jpearl.InMemoryUniqueIdGenerator;
import io.github.wimdeblauwe.jpearl.UniqueIdGenerator;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import
org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import
org.thymeleaf.spring5.templateresolver.SpringResourceTemplateResolver;
import org.thymeleaf.templateresolver.ITemplateResolver;
import java.util.UUID;
@Configuration
public class TamingThymeleafApplicationConfiguration {
@Bean
public ITemplateResolver svgTemplateResolver() {
SpringResourceTemplateResolver resolver = new
SpringResourceTemplateResolver();
resolver.setPrefix("classpath:/templates/svg/");
resolver.setSuffix(".svg");
resolver.setTemplateMode("XML");
return resolver;
}
@Bean
public UniqueIdGenerator<UUID> uniqueIdGenerator() {
return new InMemoryUniqueIdGenerator();
}
@Bean
① Declare a new bean and have Spring inject the MessageSource instance.
com.tamingthymeleaf.application.user.web.CreateUserFormData
@NotExistingUser
public class CreateUserFormData {
...
}
After also updating UserService, UserServiceImpl and UserRepository with the supporting
code to check if there is already a user with the given email present, we get the following result:
To implement this with Thymeleaf, we create a fragment fielderrors to iterate over all errors and
display them in a bullet list.
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
lang="en">
<div th:fragment="fielderrors"
class="rounded-md bg-red-50 p-4 mb-4"
th:if="${#fields.hasErrors()}"> ①
<div class="flex">
<div class="flex-shrink-0">
② Set the summary title and pass in the number of errors found.
③ Create <li> tags for each error with the error message.
<form id="user-form"
th:object="${user}"
th:action="@{/users/create}"
method="post">
<div>
<div th:replace="fragments/forms :: fielderrors"></div>
<div class="grid grid-cols-1 row-gap-6 col-gap-4 sm:grid-cols-
6">
...
If you test this a bit, you might notice that the order of the error messages changes
(almost) every time. If this is undesired, you can sort the messages based on the
name of the corresponding field for example.
com.tamingthymeleaf.application.infrastructure.web.Detailed
ErrorComparator())}" th:text=
"${detailedError.message}"></li>
</ul>
package com.tamingthymeleaf.application.infrastructure.web;
import org.thymeleaf.spring5.util.DetailedError;
import java.util.Comparator;
@Override
public int compare(DetailedError o1, DetailedError o2)
{
return o1.getFieldName().compareTo(o2.
getFieldName());
}
}
We can influence the processing order of the validations by using validation groups.
@NotExistingUser
public class CreateUserFormData {
@NotBlank
@Size(min = 1, max = 200)
private String firstName;
@NotBlank
@Size(min = 1, max = 200)
private String lastName;
@NotNull
private Gender gender;
@NotBlank
@Email
private String email;
@NotNull
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate birthday;
@NotBlank
@Pattern(regexp = "[0-9.\\-() x/+]+")
private String phoneNumber;
Without using validation groups, all validations are triggered at the same time, resulting in 2
problems:
• There is no defined order, so our @NotExistingUser annotation cannot be sure that the email
is not blank and a valid email address.
• The user is shown 2 error messages for empty input fields. Once for @NotBlank and once for
@Size.
Figure 48. Without validation groups, multiple errors per input field are shown
package com.tamingthymeleaf.application.infrastructure.validation;
package com.tamingthymeleaf.application.infrastructure.validation;
Next, we define the validation order in another marker interface that is annotated with
@GroupSequence:
package com.tamingthymeleaf.application.infrastructure.validation;
import javax.validation.GroupSequence;
import javax.validation.groups.Default;
@GroupSequence({Default.class, ValidationGroupOne.class,
ValidationGroupTwo.class})
public interface ValidationGroupSequence {
}
The order of the arguments of @GroupSequence defines the order of validation processing.
For each of the validation annotations, we assign them to the default group, or one of our new
groups:
package com.tamingthymeleaf.application.user.web;
import
com.tamingthymeleaf.application.infrastructure.validation.ValidationGrou
pOne;
import
com.tamingthymeleaf.application.infrastructure.validation.ValidationGrou
pTwo;
import com.tamingthymeleaf.application.user.CreateUserParameters;
import com.tamingthymeleaf.application.user.Gender;
import com.tamingthymeleaf.application.user.PhoneNumber;
import com.tamingthymeleaf.application.user.UserName;
import org.springframework.format.annotation.DateTimeFormat;
import javax.validation.constraints.*;
import java.time.LocalDate;
@NotExistingUser(groups = ValidationGroupTwo.class)
public class CreateUserFormData {
@NotBlank
@Size(min = 1, max = 200, groups = ValidationGroupOne.class)
private String firstName;
@NotBlank
@Size(min = 1, max = 200, groups = ValidationGroupOne.class)
private String lastName;
@NotNull
private Gender gender;
@NotBlank
@Email(groups = ValidationGroupOne.class)
private String email;
@NotNull
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate birthday;
@NotBlank
@Pattern(regexp = "[0-9.\\-() x/+]+", groups = ValidationGroupOne
.class)
private String phoneNumber;
@PostMapping("/create")
public String doCreateUser(@Validated(ValidationGroupSequence.class)
@ModelAttribute("user") CreateUserFormData formData,
BindingResult bindingResult, Model model)
{
if (bindingResult.hasErrors()) {
model.addAttribute("genders", List.of(Gender.MALE, Gender
.FEMALE, Gender.OTHER));
return "users/edit";
}
service.createUser(formData.toParameters());
return "redirect:/users";
}
• All annotations that do not have the groups variable set are part of the default group and are
evaluated first. If they fail, the validations from the other groups (ValidationGroupOne and
ValidationGroupTwo) are not evaluated.
• If everything is ok for the default group, all validations from ValidationGroupOne are evaluated.
return false;
}
return true;
}
11.7. Summary
In this chapter, you learned:
• How to create an HTML form and submit the data from it.
• How to validate user input on the server-side.
• How to display custom error messages.
• How to write your own custom validator.
Since we will have multiple pages with such a button in the top right corner, we will create a fragment
for it:
src/main/resources/templates/fragments/titles.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
lang="en">
<div th:fragment="title-with-button(title, buttonIcon, buttonText,
buttonLink)"
④ Use the name of the button icon with Thymeleaf preprocessing so the name is replaced first,
before the th:replace kicks in to get the actual SVG icon.
Do not use th:text on <button> as that would hide the child <svg> tag.
Most important here is that this will guard the user from concurrent updates (either by another user,
or by himself in another tab for example).
We need to update our SQL creation script to have the new version field:
src/main/resources/db/migration/V1.0__init.sql
Because we now edit the SQL script, we will have to drop the database tables and
have Flyway create them again:
If you want to avoid that, create a 2nd migration script with an ALTER TABLE
statement to add the version column.
I like to edit the initial script as long as I am developing to avoid having many
alterations that will really serve no purpose once the software has the first release.
Once the first release is done, it is important to not alter this file anymore and create
com.tamingthymeleaf.application.user.UserService
Since we will allow to edit all parameters used at creation time, we can extend from
CreateUserParameters and add the version field:
package com.tamingthymeleaf.application.user;
import java.time.LocalDate;
com.tamingthymeleaf.application.user.UserServiceImpl
@Override
public User editUser(UserId userId, EditUserParameters parameters) {
User user = repository.findById(userId)
.orElseThrow(() -> new
UserNotFoundException(userId)); ①
if (parameters.getVersion() != user.getVersion()) { ②
throw new ObjectOptimisticLockingFailureException(User.
class, user.getId().asString());
}
parameters.update(user); ③
return user;
}
① Get the user for the given UserId from the database. If there is no matching user, throw a
UserNotFoundException.
② Check if the version that is passed from the parameters (which will come from the HTML form) is
equal to the current version in the database.
③ Have the parameters object update the properties of the user.
package com.tamingthymeleaf.application.user;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public class UserNotFoundException extends RuntimeException {
public UserNotFoundException(UserId userId) {
super(String.format("User with id %s not found", userId.
asString()));
}
}
com.tamingthymeleaf.application.user.EditUserParameters
We can now focus on the web part of updating the user. We again need an object that represents the
form data: EditUserFormData
package com.tamingthymeleaf.application.user.web;
import com.tamingthymeleaf.application.user.*;
return result;
}
① Extend from CreateUserFormData since we allow to edit all the same fields.
③ Add the version field to keep track of the version of the User that we edit.
⑤ A conversion method to convert the form data to the rich value object EditUserParameters.
We again have to implement the GET-POST-REDIRECT cycle as we did for user creation. This is the GET
mapping in UserController:
com.tamingthymeleaf.application.user.web.UserController
@GetMapping("/{id}") ①
public String editUserForm(@PathVariable("id") UserId userId, ②
Model model) {
User user = service.getUser(userId)
.orElseThrow(() -> new UserNotFoundException
(userId)); ③
model.addAttribute("user", EditUserFormData.fromUser(user)); ④
model.addAttribute("genders", List.of(Gender.MALE, Gender.
FEMALE, Gender.OTHER));
model.addAttribute("editMode", EditMode.UPDATE); ⑤
return "users/edit"; ⑥
}
① The URL for editing will be /users/{id} where {id} is the textual representation of the UserId.
② Map the {id} part of the URL to the UserId variable via the @PathVariable annotation.
③ Get the User from the database so we can display the current user values in the form.
④ Create an EditUserFormData instance which we will bind to the form fields in the HTML page.
⑤ Since we will share the same users/edit.html template for user creation and user editing, we
need to know in what "mode" we are currently working. For that reason, a model attribute
editMode is added to the model. In the createUserForm method, we need to add
model.addAttribute("editMode", EditMode.CREATE);
These are the sources for the EditMode enum:
package com.tamingthymeleaf.application.infrastructure.web;
We need to extend UserService and UserServiceImpl to be able to get a User given a certain
com.tamingthymeleaf.application.user.UserServiceImpl
@Override
public Optional<User> getUser(UserId userId) {
return repository.findById(userId);
}
To be able to use the UserId as @PathVariable, we have to tell Spring how to convert from the
String that the URL is, to our value object. This is done by implementing an
org.springframework.core.convert.converter.Converter:
package com.tamingthymeleaf.application.user.web;
import com.tamingthymeleaf.application.user.UserId;
import org.springframework.core.convert.converter.Converter;
import org.springframework.stereotype.Component;
import java.util.UUID;
@Component
public class StringToUserIdConverter implements Converter<String,
UserId> { ①
@Override
public UserId convert(String s) {
return new UserId(UUID.fromString(s)); ②
}
}
We are getting close to already showing the current values of a user. We just need to make the 'Edit'
links in the list of users point to the good URL:
src/main/resources/templates/users/list.html
data(contents=${user.email.asString()},hideOnMobile=true)"></td>
<td th:replace="fragments/table :: dataWithLink('Edit', @{'/users/'+
${user.id.asString()}})"></td> ①
</tr>
① Get the string representation of the UserId and build the good URL
Starting the application shows the correct links on in the list of users:
Figure 50. Hovering over 'Edit' shows the link in the bottom left corner
Clicking on the 'Edit' link shows the details of the selected user:
This is already nice, but we are not done yet. Clicking 'Save' now would trigger the user creation, not
the user editing. Stop the application and let’s finish the implementation.
<form id="user-form"
th:object="${user}"
th:action="${editMode?.name() ==
'UPDATE'}?@{/users/{id}(id=${user.id})}:@{/users/create}"
method="post">
2. Add the version field as a hidden input. We need to know the version value when the POST is
done, so we can check if it still matches with the database:
...
<div th:replace="fragments/forms :: fielderrors"></div>
<div class="grid grid-cols-1 row-gap-6 col-gap-4 sm:grid-cols-6">
<input type="hidden" th:field="*{version}"
th:if="${editMode?.name() == 'UPDATE'}">
...
<button type="submit"
class="ml-3 inline-flex justify-center py-2 px-4 border
border-transparent shadow-sm text-sm font-medium rounded-md text-
white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2
focus:ring-offset-2 focus:ring-green-500"
th:text="${editMode?.name() == 'UPDATE'}?#{save}:#{create}">
Save
</button>
The create form now has a 'Create' text on the save button:
Figure 52. Create user showing 'Create' a text on the primary button
The edit form now has a proper title and 'Save' as primary action:
The next step is now implementing the actual save operation via a POST call in the controller:
com.tamingthymeleaf.application.user.web.UserController
@PostMapping("/{id}") ①
public String doEditUser(@PathVariable("id") UserId userId, ②
@Validated(ValidationGroupSequence.class)
@ModelAttribute("user") EditUserFormData formData, ③
BindingResult bindingResult, ④
Model model) {
if (bindingResult.hasErrors()) { ⑤
model.addAttribute("genders", List.of(Gender.MALE, Gender
.FEMALE, Gender.OTHER));
model.addAttribute("editMode", EditMode.UPDATE);
return "users/edit";
}
service.editUser(userId, formData.toParameters()); ⑥
return "redirect:/users"; ⑦
}
① Use the @PostMapping annotation to indicate that POST request to /users/{id} will call this
controller method.
② Map the {id} part of the URL to the UserId variable via the @PathVariable annotation.
③ Inject the EditUserFormData instance that has the values from the HTML form.
Figure 54. User already exists validation error when trying to update a user
This is obviously not what we want. The reason we get this is because EditUserFormData extends
from CreateUserFormData which is annotated with @NotExistingUser(groups =
ValidationGroupTwo.class) and in the controller we ask for validation using this sequence:
@GroupSequence({Default.class, ValidationGroupOne.class,
ValidationGroupTwo.class})
package com.tamingthymeleaf.application.user.web;
import
com.tamingthymeleaf.application.infrastructure.validation.ValidationGrou
pOne;
import javax.validation.GroupSequence;
import javax.validation.groups.Default;
@GroupSequence({Default.class, ValidationGroupOne.class})
public interface EditUserValidationGroupSequence {
}
com.tamingthymeleaf.application.user.web.UserController
@PostMapping("/{id}")
public String doEditUser(@PathVariable("id") UserId userId,
@Validated(EditUserValidationGroupSequence
.class) @ModelAttribute("user") EditUserFormData formData, ①
BindingResult bindingResult,
Model model) {
if (bindingResult.hasErrors()) {
model.addAttribute("genders", List.of(Gender.MALE, Gender
.FEMALE, Gender.OTHER));
model.addAttribute("editMode", EditMode.UPDATE);
return "users/edit";
}
service.editUser(userId, formData.toParameters());
return "redirect:/users";
}
This is probably a bit at the limit of what is still clear. Another way would be to copy
the fields of CreateUserFormData into EditUserFormData and not have the latter
extend from the former. This has the obvious drawback of code duplication, but will
be easier to understand for the future reader of the code.
src/main/resources/templates/users/edit.html
<div class="sm:col-span-3">
<label for="firstName" class="block text-sm font-medium text-gray-
700"
th:text="#{user.firstName}">
First name
</label>
<div class="mt-1 relative rounded-md shadow-sm">
<input id="firstName"
type="text"
th:field="*{firstName}"
class="shadow-sm block w-full sm:text-sm border-gray-300
rounded-md"
th:classappend="${#fields.hasErrors('firstName')?'border-
red-300 focus:border-red-300 focus:ring-red-500':'focus:ring-green-500
focus:border-green-500'}">
<div th:if="${#fields.hasErrors('firstName')}"
class="absolute inset-y-0 right-0 pr-3 flex items-center
pointer-events-none">
<svg class="h-5 w-5 text-red-500" fill="currentColor"
viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0
11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"/>
</svg>
</div>
</div>
<p th:if="${#fields.hasErrors('firstName')}"
th:text="${#strings.listJoin(#fields.errors('firstName'), ', ')}"
class="mt-2 text-sm text-red-600" id="firstName-error">First name
validation error message(s).</p>
</div>
Looking at the other inputs, we see these differences between all of them:
</div>
<p th:if="${#fields.hasErrors('__${fieldName}__')}"
th:text="${#strings.listJoin(#fields.errors('__${fieldName}__'),
', ')}"
class="mt-2 text-sm text-red-600" th:id="'__${fieldName}__'+ '-
error'">Field validation error message(s).</p>
</div>
• labelText: the (translated) name for the label to show to the user
• fieldName: the name of the field that this input should use. By using Preprocessing, we can fill in
the field name and then have Thymeleaf render the whole thing.
• cssClass: The CSS class to use on the top-level <div> for the layout of the input inside the form.
• inputType: Allows to set the type of the <input>. If not set, defaults to text.
Short and sweet! We went from around 120 lines of code to 6 and we made it a whole lot more
readable in doing so.
We did not create a fragment for gender since it is the only use for a radio button input for now. We
can always do this later using the same technique we used here.
Figure 55. Spring Boot shows a default whitelabel error page for unhandled errors
Obviously not very user friendly. The log file will show that the optimistic locking kicked in:
org.springframework.orm.ObjectOptimisticLockingFailureException: Object
of class [com.tamingthymeleaf.application.user.User] with identifier
[0a88cdb3-b183-436b-9db3-fca9bca364cc]: optimistic locking failed
So, it is great that we did not get a lost update, but we should handle the error better.
To do this, we create an @ControllerAdvice annotated class that will handle the exception and
show the appropriate view:
package com.tamingthymeleaf.application.infrastructure.web;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
@ControllerAdvice ①
public class GlobalControllerAdvice {
@ResponseStatus(HttpStatus.CONFLICT) ②
@ExceptionHandler({DataIntegrityViolationException.class,
ObjectOptimisticLockingFailureException.class}) ③
public ModelAndView handleConflict(HttpServletRequest request,
Exception e) { ④
ModelAndView result = new ModelAndView("error/409"); ⑤
result.addObject("url", request.getRequestURL()); ⑥
return result;
}
}
① Annotate the class with @ControllerAdvice so that everything in this class will be applied to all
controllers.
② Return status code 409 CONFLICT.
③ The handleConflict method should be called for any DataIntegrityViolationException or
ObjectOptimisticLockingFailureException.
④ Inject the HttpServletRequest and the Exception into the method. See ExceptionHandler
Javadoc for more information on all possible arguments that can be used.
⑤ Have Thymeleaf render the error/409.html page
⑥ Add the request URL as url in the model so the error page can use this.
With this in place, we can add our error/409.html template that show the error message to the
user:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
layout:decorate="~{layout/layout}"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
lang="en">
<head>
<title th:text="#{error}">Error</title>
</head>
<body>
<!--/*@thymesVar id="url" type="String"*/-->
<div layout:fragment="page-content">
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
<div class="py-4">
<div class="text-gray-500">
<p th:text="#{error.version.conflict}" class="mb-6"
>Somebody else has edited the same thing as you.
Please reload the
page and redo your edit.</p>
<a th:href="${url}" class="flex items-center text-sm
text-green-600 hover:text-green-900">
<svg viewBox="0 0 20 20" fill="currentColor"
class="w-4 h-4 mr-2">
<path fill-rule="evenodd"
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1
0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293
4.293a1 1 0 010 1.414z"
clip-rule="evenodd"></path>
</svg>
<span th:text="#{back.to.previous.page}">Back to
previous page</span> </a>
</div>
</div>
</div>
</div>
</body>
</html>
If we add a Thymeleaf template at templates/error with the error code as page name, then Spring
Boot will automatically use that template when there is such an error code. For example, we can add a
404.html page:
src/main/resources/templates/error/404.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
layout:decorate="~{layout/layout}"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
lang="en">
<head>
<title th:text="#{error}">Error</title>
</head>
<body>
<!--/*@thymesVar id="url" type="String"*/-->
<div layout:fragment="page-content">
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
<div class="py-4">
<div class="text-gray-500">
<p th:text="#{error.page.not.found}" class="mb-6">Page
not found.</p>
<a th:href="@{/}" class="flex items-center text-sm text-
green-600 hover:text-green-900">
<svg viewBox="0 0 20 20" fill="currentColor"
class="w-4 h-4 mr-2">
<path fill-rule="evenodd"
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1
0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293
4.293a1 1 0 010 1.414z"
clip-rule="evenodd"></path>
</svg>
<span th:text="#{back.to.home.page}">Back to home
page</span> </a>
</div>
</div>
</div>
</div>
</body>
</html>
If you access a URL that does not exist like http://localhost:8080/nonexisting, there is a nice error
message:
Figure 57. Custom error page for a 404 NOT FOUND error
If you want to have a single error page for multiple error codes, you can do that as well. For example,
to have a single template to use for all errors in the 500-599 range, you can use
templates/error/5xx.html as template name.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
layout:decorate="~{layout/layout}"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
lang="en">
<head>
<title th:text="#{error}">Error</title>
</head>
<body>
<!--/*@thymesVar id="url" type="String"*/-->
<div layout:fragment="page-content">
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
<div class="py-4">
<div class="text-gray-500">
To test this behaviour, you can add this method to the UserController for example:
@GetMapping("/ex")
It does not show the 'Technical Information' block. This is because Spring Boot will not include the
exception or trace variables by default for the template to render. We can change this behaviour
with the following properties:
src/main/resources/application.properties
server.error.include-exception=true
server.error.include-stacktrace=always
Figure 59. Custom error page showing exception and stacktrace information
In most cases, you will set those properties only in a development or staging
environment. Don’t show these technical details in production. This can be easily
12.6. Summary
In this chapter, you learned:
Web browsers only support GET and POST. We don’t get to use all the other HTTP request methods (or
HTTP verbs as they are sometimes called) that REST API developers can use.
• Use a dedicated URL. For example, we can allow a POST on /users/<id>/delete to delete a
user.
• Do a POST with the “real” method as an additional parameter (modeled as a hidden input field in
an HTML form).
There is also the option to use JavaScript to call DELETE on a REST API endpoint (e.g.
/api/users/<id>), but that would lead us too far.
com.tamingthymeleaf.application.user.web.UserController
@PostMapping("/{id}/delete")
public String doDeleteUser(@PathVariable("id") UserId userId) {
service.deleteUser(userId);
return "redirect:/users";
}
com.tamingthymeleaf.application.user.UserServiceImpl
@Override
public void deleteUser(UserId userId) {
repository.deleteById(userId); ①
}
Now, on the HTML side, we do have a lot of work. The goal is to add 'Delete' links on each row next to
the 'Edit' link that is already there.
When it is pressed, a modal should ask for confirmation and once confirmed, the user should get
deleted.
For the design, we will use 'Simple alert' from the modals section in Tailwind UI:
The modal has a semi-transparent backdrop that will need to cover the complete application. To make
that possible, we need to add a new layout-hook in our templates/layout/layout.html file. If we
would use the existing page-content layout-hook, we would not cover the complete application.
src/main/resources/templates/layout/layout.html
...
<div layout:fragment="modals-content">
</div>
...
Now we can edit templates/users/list.html to use that modals-content layout fragment and
copy the modal code:
<div layout:fragment="modals-content">
<div class="fixed z-10 inset-0 overflow-y-auto">
<div class="flex items-end justify-center min-h-screen pt-4 px-4
pb-20 text-center sm:block sm:p-0">
<!--
Background overlay, show/hide based on modal state.
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500">
Are you sure you want to deactivate your
account? All of your data will be permanently removed from our servers
forever. This action cannot be undone.
</p>
</div>
</div>
</div>
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button type="button" class="w-full inline-flex
justify-center rounded-md border border-transparent shadow-sm px-4 py-2
bg-red-600 text-base font-medium text-white hover:bg-red-700
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500
sm:ml-3 sm:w-auto sm:text-sm">
Deactivate
</button>
<button type="button" class="mt-3 w-full inline-flex
justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-
white text-base font-medium text-gray-700 hover:bg-gray-50
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-
500 sm:mt-0 sm:w-auto sm:text-sm">
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
This already looks good, but it does not do anything useful. Let’s change that.
src/main/resources/templates/users/list.html
src/main/resources/templates/users/list.html
</td>
• th:x-data will have Thymeleaf render an x-data attribute containing the data that will be sent
by AlpineJS from the 'Delete' link to the modal dialog.
• The href element on the <a> tag uses javascript:void(0) to avoid that browser goes to
another page. We just want it to trigger the click event.
• @click is the AlpineJS way to registering a callback when the click event is triggered. When it is,
we dispatch an open-delete-modal event and give it the name and deleteUrl from the x-data
attribute. The name is used to show the name of the user that will be deleted. The deleteUrl is
the URL that we will POST too, to trigger the actual delete of the user.
-->
<div class="inline-block align-bottom bg-white rounded-lg
px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-
all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6"
role="dialog" aria-modal="true" aria-labelledby="modal-
headline"
x-show="isVisible()"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4
sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0
sm:scale-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0
sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4
sm:translate-y-0 sm:scale-95"> ③
<div class="sm:flex sm:items-start">
<div class="mx-auto flex-shrink-0 flex items-center
justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-
10">
<!-- Heroicon name: outline/exclamation -->
<svg class="h-6 w-6 text-red-600" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-
linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938
4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-
3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4
sm:text-left">
<h3 class="text-lg leading-6 font-medium text-
gray-900" id="modal-headline">
Delete user
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500"> ④
Are you sure you want to delete user
<span class="italic"
x-text="getName()"></span>?
</p>
</div>
</div>
</div>
<form id="deleteForm" enctype="multipart/form-data"
method="post"
x-bind:action="getDeleteUrl()"> ⑤
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-
reverse">
<span class="flex w-full rounded-md shadow-sm sm:ml-3 sm:w-
auto">
<button type="submit"
class="w-full inline-flex justify-center rounded-md
border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-
medium text-white hover:bg-red-700 focus:outline-none focus:ring-2
focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm">
Delete
</button>
</span>
<span class="mt-3 flex w-full rounded-md shadow-
sm sm:mt-0 sm:w-auto">
<button type="button"
@click="hideModal"
class="mt-3 w-full inline-flex justify-center rounded-
md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-
medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2
focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:w-auto sm:text-sm">
⑥
Cancel
</button>
</span>
</div>
</form>
</div>
</div>
</div>
</div>
user to delete.
③ We again have x-show="isVisible()" here. The reason we don’t have it just on the top <div> is
to make the x-transition attributes work. They ensure we have some nice fade-in and fade-out
effects when showing or hiding the modal dialog.
④ x-text="getName()" will replace the inner HTML of the <span> with the text returned by the
method. This is very similar to th:text. The big difference is that th:text is processed by
Thymeleaf, so it is done once when the page is rendered. We now need the text to change
dynamically with JavaScript each time a different 'Delete' link is clicked, so we need to use AlpineJS'
x-text instead.
⑤ We need a <form> to do the POST request. Since we need to update the URL for each to-be-
deleted user, we also need a JavaScript based way of updating the target URL. This is done via the
x-bind:action="getDeleteUrl()" attribute. The AlpineJS instruction x-bind can target any
attribute. Here we use it to update the action of the form to have the correct URL to delete the
user.
⑥ For the 'Cancel' button, we hide the modal when the click event happens.
Because we have not added Security yet, we do the POST without CSRF protection.
Once we add security in the next chapter, we will need to add this to our form to
make it work:
<input type="hidden"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}"/> ①
① Thymeleaf normally adds a hidden input with a CSRF token to all forms
automatically, but only if you set a th:action on the form. Because we don’t use
th:action, but set the action in JavaScript, we need to manually add a hidden
input with the CSRF token. We can use the _csrf value that is always present to
do so.
As became apparent during the explanation of the above HTML, we need some JavaScript to make it
all work:
<th:block layout:fragment="page-scripts">
<script>
function modalDeleteConfirmation() {
return {
show: false,
name: '',
deleteUrl: '',
hideModal() {
this.show = false;
},
isVisible() {
return this.show === true;
},
getName() {
return this.name;
},
getDeleteUrl() {
return this.deleteUrl;
},
showModal($event) { ①
this.name = $event.detail.name;
this.deleteUrl = $event.detail.deleteUrl;
this.show = true;
}
};
}
</script>
</th:block>
① Using the passed in $event parameter, we can extract the name and deleteUrl properties. Due
to the use of x-show, x-text and x-bind in the HTML, the modal dialog will show the name of
the to-be-deleted user with the correct <form> target URL in place as soon as the showModal()
method is called.
With all this, we now have a list of users with a 'Delete' link:
Confirming the question will delete the user in the database and redirect the browser to the list of
users.
As browsers only allow GET and POST, we need support from Spring Boot to turn the POST from the
browser into a DELETE. This supports comes in the form of the HiddenHttpMethodFilter class and
can easily be enabled by setting the spring.mvc.hiddenmethod.filter.enabled property in the
application.properties file:
src/main/resources/application.properties
spring.mvc.hiddenmethod.filter.enabled=true
We can update UserController to map our doDeleteMethod on the DELETE HTTP method:
com.tamingthymeleaf.application.user.web.UserController
@DeleteMapping("/{id}") ①
public String doDeleteUser(@PathVariable("id") UserId userId) {
service.deleteUser(userId);
return "redirect:/users";
}
src/main/resources/templates/users/list.html
src/main/resources/templates/users/list.html
Thymeleaf will render the <form> with a method="post" and also include a hidden input that has
the actual HTTP method we want to execute.
If we now restart everything, we will see that the delete also works fine this way.
It does not really matter if you use the dedicated URL or the DELETE mapping. Use
what makes most sense to you.
Just one thing to be careful about is that Spring Boot made the
HiddenHttpMethodFilter opt-in after some bug reports (#16953, #18088) when
the filter was used. If you have a similar use-case as the reports, you might want to
avoid the filter.
Spring MVC has the concept of flash attributes. These attributes can be added in the POST method of
the controller and will be made available in the GET method following the redirect we do at the end of
the @PostMapping method.
com.tamingthymeleaf.application.user.web.UserController
@PostMapping("/{id}/delete")
public String doDeleteUser(@PathVariable("id") UserId userId,
RedirectAttributes redirectAttributes) {
①
User user = service.getUser(userId)
.orElseThrow(() -> new UserNotFoundException
(userId)); ②
service.deleteUser(userId);
redirectAttributes.addFlashAttribute("deletedUserName",
user.getUserName
().getFullName()); ③
return "redirect:/users";
}
Next step is adding a new Thymeleaf fragment alerts.html for the success message. It contains of 2
parts:
• The first part is a <div> that has the HTML for the alert message, taking a single argument being
the String that should be displayed.
• The second part is a bit of JavaScript to make the close button on the alert message work.
src/main/resources/templates/fragments/alerts.html
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
lang="en">
<div th:fragment="success(message)"
class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8 my-3"
x-data="successMessageAlert()"
x-show="isAlertVisible()"> ①
<div class="rounded-md bg-green-50 p-4">
<div class="flex">
<div class="flex-shrink-0">
<!-- Heroicon name: solid/check-circle -->
<svg class="h-5 w-5 text-green-400"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-
9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2
2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-green-800"
th:text="${message}"> ②
Successfully uploaded
</p>
</div>
<div class="ml-auto pl-3">
<div class="-mx-1.5 -my-1.5">
<button class="inline-flex bg-green-50 rounded-md p-
1.5 text-green-500 hover:bg-green-100 focus:outline-none focus:ring-2
focus:ring-offset-2 focus:ring-offset-green-50 focus:ring-green-600"
@click="hideAlert"> ③
<span class="sr-only">Dismiss</span>
<!-- Heroicon name: solid/x -->
<svg class="h-5 w-5"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true">
<path fill-rule="evenodd"
① Define a success fragment with the x-data and x-show attributes for the JavaScript.
③ Add a click listener to remove the alert when the user clicks on the x icon to close it.
④ Define a success-js fragment containing the relevant JavaScript.
src/main/resources/templates/users/list.html
<div layout:fragment="page-content">
...
<div th:if="${deletedUserName}"> ①
<div th:replace="fragments/alerts ::
success(#{user.delete.success(${deletedUserName})})"></div> ②
</div>
</div>
① Use th:if to only render the alert when the deletedUserName flash attribute is present
src/main/resources/i18n/messages.properties
src/main/resources/templates/users/list.html
<th:block layout:fragment="page-scripts">
...
<script th:replace="fragments/alerts :: success-js"></script>
</th:block>
Figure 63. Alert message confirming that the delete was a success
If you now refresh the user list page manually, the green alert message will no longer be there. This is
because the flash attribute is automatically removed after it was used during the redirect.
Due to the JavaScript we added, the user can also close the alert with the cross icon on the right side
of the alert.
13.4. Summary
In this chapter, you learned:
• Authentication: Determine if a user is who the user claims he or she is. In simple terms, this is the
part that verifies if the username and password match. That way, we can be sure users are really
who they claim they are.
• Authorization: Once we know who the user is, we need to determine its access rights. What part of
the application can the user access and what part is hidden or forbidden for them?
To get started, we will add the dependency on Spring Security and the corresponding test utilities to
the pom.xml:
<dependencies>
...
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
...
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Joking aside, we do have an application that is secured now. By default, there is a single user with
username user and an auto-generated password. If you run the application and check the console
output, you should see something like this:
Access the application on http://localhost:8080 and you will now be greeted with a login form:
Use user and the auto-generated password and you will see the application again.
If you manually enter http://localhost:8080/logout after the login was ok, then you get a confirmation
request:
Figure 66. Default confirmation request from Spring Security to log out
So, while this works with a minimal effort, there are quite some things missing:
• We only have a single user. The security users and the users in our application are not the same.
• The login form is not styled according to our application.
• We cannot choose the password for the users. On top of that, it is different for each run of the
application.
The heart of security configuration always starts from an @Configuration class that extends
org.springframework.security.config.annotation.web.configuration.WebSecurityCon
figurerAdapter. I usually place this in the infrastructure.security package and name it
WebSecurityConfiguration:
package com.tamingthymeleaf.application.infrastructure.security;
import org.springframework.context.annotation.Configuration;
import
org.springframework.security.config.annotation.authentication.builders.A
uthenticationManagerBuilder;
import
org.springframework.security.config.annotation.web.configuration.WebSecu
rityConfigurerAdapter;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration ①
public class WebSecurityConfiguration extends
WebSecurityConfigurerAdapter { ②
@Override
protected void configure(AuthenticationManagerBuilder auth) throws
Exception { ④
auth.inMemoryAuthentication() ⑤
.withUser("user") ⑥
.password(passwordEncoder.encode("verysecure")) ⑦
.roles("USER"); ⑧
}
}
① Annotate the class with @Configuration so the component scanning will pick it up automatically.
There is no PasswordEncoder created by Spring Boot by default, so we need to create such a bean
ourselves in TamingThymeleafApplicationConfiguration:
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.
createDelegatingPasswordEncoder();
}
We use the DelegatingPasswordEncoder here which contains all the known encoding schemes
that Spring Security has. The advantage of using this is that the password is prefixed with the used
encoding scheme when stored in the database.
{bcrypt}$2a$10$NjRCquznza.Q2CDHwSgTu.U6WfEYLg3sOUWudKRGK3G..A7uK9iLm
This allows us to use different encoding schemes depending on the needed security and allows
migrating to more secure schemes if the need would arise.
We have used the single role USER so far, but most applications have multiple roles for their users.
This allows to only allow certain operations (e.g. to delete a user) for certain roles (e.g.
administrators).
As an example of how this works, we will create a second hardcoded user admin which has the USER
and ADMIN roles:
com.tamingthymeleaf.application.infrastructure.security.WebSecurityConfiguration
@Override
protected void configure(AuthenticationManagerBuilder auth) throws
Exception {
auth.inMemoryAuthentication()
.withUser("user")
.password(passwordEncoder.encode("verysecure"))
.roles("USER")
.and()
.withUser("admin")
.password(passwordEncoder.encode("evenmoresecure"))
.roles("USER", "ADMIN");
}
We can now override the configure(HttpSecurity http) method to determine what user role is
allowed to access what part of the application:
com.tamingthymeleaf.application.infrastructure.security.WebSecurityConfiguration
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests() ①
.antMatchers("/users/create").hasRole("ADMIN") ②
.antMatchers("/users/*/delete").hasRole("ADMIN") ③
.antMatchers(HttpMethod.GET, "/users/*").hasRole("USER") ④
.antMatchers(HttpMethod.POST, "/users/*").hasRole("ADMIN")
⑤
.and()
.formLogin().permitAll() ⑥
.and()
.logout().permitAll(); ⑦
}
③ Only a user with ADMIN role can access a URL that matches with /users/*/delete. The * means
any character except /.
④ We can also secure a path with a specific HTTP method. Here we state that a user in the USER role
can only do a GET on any sub-path of /users.
⑤ Using that same method, we allow ADMIN users to do a POST on those sub-paths.
⑥ We want the default form login. Any user (authenticated or not) should be able to access the login
form.
⑦ Provide default logout support and allow everybody access. This will make Spring Security add a
handler for a POST on /logout to log out the current logged on user.
With this configuration in place, the admin user will be able to do everything. The user will no longer
be able to create users, edit users or delete users.
After login with user, we still see the create button and the edit and delete links:
This is normal as we did not specify anything in our Thymeleaf templates that some parts should be
made invisible for certain roles.
As soon as we try something (E.g. the 'Create user' button), we get a 403 FORBIDDEN response:
We can make this nicer using the mechanism we discussed at Custom error pages.
Create templates/error/403.html:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
layout:decorate="~{layout/layout}"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
lang="en">
<head>
<title th:text="#{error}">Error</title>
</head>
<body>
<!--/*@thymesVar id="url" type="String"*/-->
<div layout:fragment="page-content">
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
<div class="py-4">
<div class="text-gray-500">
<p th:text="#{error.page.forbidden}" class="mb-6">The
current user is not allowed to access this page.</p>
Trying to access /users/create with user will now show a nicer error page:
@Configuration
@EnableGlobalMethodSecurity(securedEnabled = true)
public class WebSecurityConfiguration extends
WebSecurityConfigurerAdapter {
Adding this annotation enables the use of specific annotations on the controller methods. Using
securedEnabled=true allows us to use
org.springframework.security.access.annotation.Secured which is a Spring specific
annotation.
@Configuration
@EnableGlobalMethodSecurity(jsr250Enabled = true)
public class WebSecurityConfiguration extends
WebSecurityConfigurerAdapter {
With this in place, we can now add the @Secured annotation with the name of the role to secure
certain URLs:
@GetMapping("/create")
@Secured("ROLE_ADMIN") ①
public String createUserForm(Model model) {
model.addAttribute("user", new CreateUserFormData());
model.addAttribute("genders", List.of(Gender.MALE, Gender.
FEMALE, Gender.OTHER));
model.addAttribute("editMode", EditMode.CREATE);
return "users/edit";
}
① The @Secured annotation will ensure that only users with the ADMIN role will be able to access the
/users/create URL.
...
.roles("USER","ADMIN")
@Secured("ROLE_ADMIN")
<dependencies>
...
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>
...
</dependencies>
We can now edit templates/users/list.html to take the current user role into account when
rendering the template:
src/main/resources/templates/users/list.html
By adding the sec:authorize attribute, we control the rendering of the <div> based on the
expression we give to the attribute. In this example, only users with the ADMIN role will view the
'Create user' button.
We can do something similar for the 'Edit' and 'Delete' links. First, hide the table header for non-admin
users:
src/main/resources/templates/users/list.html
<tr>
<th th:replace="fragments/table :: header(#{user.name})"></th>
<th th:replace="fragments/table ::
header(title=#{user.gender},hideOnMobile=true)"></th>
<th th:replace="fragments/table ::
header(title=#{user.birthday},hideOnMobile=true)"></th>
<th th:replace="fragments/table ::
header(title=#{user.email},hideOnMobile=true)"></th>
<th:block sec:authorize="hasRole('ADMIN')"> ①
<th th:replace="fragments/table :: header('')"></th>
<th th:replace="fragments/table :: header('')"></th>
</th:block>
</tr>
src/main/resources/templates/users/list.html
① Wrap the 'Edit' and 'Delete' links with a <th:block> that has the sec:authorize attribute to
indicate the role that can view the links.
<html
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-
security"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/layout}"
th:with="activeMenuItem='users'">
Figure 70. Create button and edit links are gone when loggin on with user
We can use this to show the username of the current user for example.
The user popup menu now shows the name and roles of the current logged on user:
There are some other more advanced things that are possible. See Thymeleaf - Spring Security
integration modules for more information.
• Design a login page and write a Thymeleaf template to match that design. The page will need a
<form> to submit the username and password with 2 inputs that use username and password as
names for the inputs.
• Create a controller that will return the template at /login.
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
lang="en">
<head>
<meta charset="UTF-8">
<title>Taming Thymeleaf - Login</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-
scale=1"/>
<div class="mt-6">
<label for="password" class="block text-sm font-
medium leading-5 text-gray-700"
th:text="#{login.password}">
Password
</label>
<div class="mt-1 rounded-md shadow-sm">
<input id="password"
class="appearance-none block w-full px-3
py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400
focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-
sm"
type="password"
required
name="password"> ③
</div>
</div>
<div class="mt-6">
<span class="block w-full rounded-md shadow-sm">
<button type="submit"
class="w-full flex justify-center py-2 px-4 border
border-transparent rounded-md shadow-sm text-sm font-medium text-white
bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2
focus:ring-offset-2 focus:ring-green-500">
Sign in
</button>
</span>
</div>
</form>
</div>
</div>
</div>
</body>
</html>
① Set the action of the form to /login as this is what Spring Security expects.
package com.tamingthymeleaf.application.infrastructure.web;
import
org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class LoginController {
@GetMapping("/login") ①
public String login(@AuthenticationPrincipal UserDetails
userDetails) { ②
if (userDetails == null) { ③
return "login"; ④
} else {
return "redirect:/"; ⑤
}
}
}
① The POST to /login is handled by Spring Security, but we have to implement the GET.
② We have Spring inject the details of the currently logged on user via the
@AuthenticationPrincipal.
⑤ If somebody manually uses the /login URL in the browser while already logged on, we redirect
away from the login page. This makes for a nice UX to avoid showing the login page to an already
logged on user.
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.requestMatchers(PathRequest.toStaticResources
().atCommonLocations()).permitAll() ①
.antMatchers("/img/*").permitAll() ②
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login") ③
.permitAll()
.and()
.logout().permitAll();
}
① Because we now need access to the CSS before we are logged on, we need to expose that.
PathRequest.toStaticResources().atCommonLocations() is a convenience method to
expose /css/*, /js/*, /images/*, /webjars/* and favicon.ico.
② Our images are located under /img/, so we also need to allow those paths to everybody.
③ This specifies the URL where the login page lives. So this needs to match with our @GetMapping in
the LoginController.
With this in place, we are now greeted with our custom login page:
For convenience, let’s make the logout link in the user menu also work.
src/main/resources/templates/fragments/top-menu.html
with:
src/main/resources/templates/fragments/top-menu.html
</div>
① Add a <form> with /logout as the action. Spring Security will handle the POST to log out the
current user and redirect back to the /login page.
When Spring Security redirects back to the login page after the logout, it adds a query parameter to
the url of logout. We can use this to display a confirmation message about the logout.
src/main/resources/templates/login.html
<th:block th:if="${param.logout}"> ①
<div th:replace="fragments/alerts ::
success(message=#{login.logout.confirmation},useHorizontalPadding=false)
"></div>
</th:block>
① Query parameters are available in a Thymeleaf template under the param key.
For the alert message, we can re-use the success fragment from alerts.html. To make the alert
look nice on the login form, we added a new useHorizontalPadding parameter to the fragment
with a default value of true. This allows use to remove the horizontal padding dynamically since we
don’t need it here:
src/main/resources/templates/fragments/alerts.html
<div th:fragment="success(message)"
class="max-w-7xl mx-auto my-3"
th:with="useHorizontalPadding=${useHorizontalPadding?:'true'}"
th:classappend="${useHorizontalPadding?'px-4 sm:px-6 md:px-8':''}"
x-data="successMessageAlert()"
x-show="isAlertVisible()">
The final part of our custom logon form is handling a login error. When the user was not found, or the
password did not match, Spring Security will add a query parameter of error to the /login URL. We
can use this to display an error message:
src/main/resources/templates/login.html
<th:block th:if="${param.error}"> ①
<div th:replace="fragments/alerts ::
error(message=#{login.error},useHorizontalPadding=false)"></div>
</th:block>
src/main/resources/templates/fragments/alerts.html
<div th:fragment="error(message)"
class="max-w-7xl mx-auto my-3"
th:with="useHorizontalPadding=${useHorizontalPadding?:'true'}"
th:classappend="${useHorizontalPadding?'px-4 sm:px-6 md:px-8':''}"
x-data="messageAlert()"
x-show="isAlertVisible()">
</div>
<div class="ml-3">
<p class="text-sm font-medium text-red-800"
th:text="${message}">
Successfully uploaded
</p>
</div>
<div class="ml-auto pl-3">
<div class="-mx-1.5 -my-1.5">
<button class="inline-flex rounded-md p-1.5 text-
red-500 hover:bg-red-100 focus:outline-none focus:bg-red-100 transition
ease-in-out duration-150"
@click="hideAlert">
<span class="sr-only">Dismiss</span>
<!-- Heroicon name: solid/x -->
<svg class="h-5 w-5"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true">
<path fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10
8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-
1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293
5.707a1 1 0 010-1.414z"
clip-rule="evenodd"/>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
Try it out. Enter a wrong username and/or password and the browser should show this:
We will start with updating the User entity as we need to store 2 things extra for each User now:
package com.tamingthymeleaf.application.user;
Then we update the User entity by adding roles and password fields:
com.tamingthymeleaf.application.user.User
@Entity
@Table(name = "tt_user")
public class User extends AbstractVersionedEntity<UserId> {
@ElementCollection(targetClass = UserRole.class) ①
@Enumerated(EnumType.STRING) ②
@CollectionTable(name = "user_roles") ③
@Column(name = "role") ④
private Set<UserRole> roles;
@NotNull
private String password; ⑤
...
}
① Instruct Hibernate that we want to store this as a collection of the "enumerated" basic type.
② We want to use the String representation of the enum (default is the ordinal of the enum).
③ Specify user_roles as the name of the collection database table.
④ Specify the name of the column where the enum value will be stored in the user_roles table.
Based on these changes, we now need to change our Flyway migration script:
src/main/resources/db/migration/V1.0__init.sql
);
Because we change the migration script, we will need to clear the database. If you
don’t want that, create a new file V1.1__update-user-for-security.sql which
As we are still in early development phase, I usually edit the single Flyway script.
Once we do a first release, this should of course no longer happen.
Still in User.java, we update the constructor with the extra fields and we create 2 factory methods:
one to create a normal user, and one to create an administrator user:
com.tamingthymeleaf.application.user.User
UserName userName,
String encodedPassword,
Gender gender,
LocalDate birthday,
Email email,
PhoneNumber phoneNumber) {
return new User(id, Set.of(UserRole.USER), userName,
encodedPassword, gender, birthday, email, phoneNumber);
}
We also update UserService and UserServiceImpl to create users with the different roles:
com.tamingthymeleaf.application.user.UserService
com.tamingthymeleaf.application.user.UserServiceImpl
@Override
public User createUser(CreateUserParameters parameters) {
LOGGER.debug("Creating user {} ({})", parameters.getUserName
().getFullName(), parameters.getEmail().asString());
UserId userId = repository.nextId();
String encodedPassword = passwordEncoder.encode(parameters
.getPassword()); ①
User user = User.createUser(userId,
parameters.getUserName(),
encodedPassword,
parameters.getGender(),
parameters.getBirthday(),
parameters.getEmail(),
parameters.getPhoneNumber());
return repository.save(user);
}
@Override
public User createAdministrator(CreateUserParameters parameters) {
LOGGER.debug("Creating administrator {} ({})", parameters
.getUserName().getFullName(), parameters.getEmail().asString());
UserId userId = repository.nextId();
User user = User.createAdministrator(userId,
parameters.getUserName(),
passwordEncoder.encode
(parameters.getPassword()),
parameters.getGender(),
parameters.getBirthday(),
parameters.getEmail(),
parameters.
getPhoneNumber());
return repository.save(user);
}
① The CreateUserParameters contain the password in clear text. We need to encode it using the
passwordEncoder before we pass it to the User.createUser() factory method as we need the
password to be encoded in the database.
With this in place, we can update DatabaseInitializer to create some default users and an
administrator:
com.tamingthymeleaf.application.DatabaseInitializer
@Component
@Profile("init-db")
public class DatabaseInitializer implements CommandLineRunner {
private final Faker faker = new Faker();
private final UserService userService;
@Override
public void run(String... args) {
for (int i = 0; i < 20; i++) { ①
CreateUserParameters parameters = newRandomUserParameters();
userService.createUser(parameters);
}
① Create 20 users
② Use the first name as password (This is obviously a bad security practise, but is ok since we only
this for local testing)
③ Create 1 administrator
The next step is connecting our User entity with Spring Security.
• An implementation of the
org.springframework.security.core.userdetails.UserDetailsService interface.
• An implementation of the
org.springframework.security.core.userdetails.UserDetails interface.
The UserDetailsService interface has a single method that takes a String. This parameter is the
username or the email depending on what users should use to log on. The method returns an
instance of UserDetails or throws an
org.springframework.security.core.userdetails.UsernameNotFoundException if no user
with the given username (or email) could be found:
org.springframework.security.core.userdetails.UserDetailsService
package com.tamingthymeleaf.application.infrastructure.security;
import com.tamingthymeleaf.application.user.Email;
import com.tamingthymeleaf.application.user.User;
import com.tamingthymeleaf.application.user.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import
org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional(readOnly = true)
public class DatabaseUserDetailsService implements UserDetailsService {
@Autowired
public DatabaseUserDetailsService(UserRepository userRepository) {
①
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws
UsernameNotFoundException {
User user = userRepository.findByEmail(new Email(username)) ②
.orElseThrow(() -> new
UsernameNotFoundException(
format("User with email %s
could not be found", username))); ③
② Search for a user by email address as we will use emails for login.
④ Return an instance of ApplicationUserDetails containing the information of the user from the
database.
package com.tamingthymeleaf.application.infrastructure.security;
import com.tamingthymeleaf.application.user.User;
import com.tamingthymeleaf.application.user.UserId;
import org.springframework.security.core.GrantedAuthority;
import
org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.Set;
import java.util.stream.Collectors;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
② The id is not needed to satisfy the UserDetails interface, but it is usually convenient to have it.
④ To have something to display nicely in the user interface, we keep track of the full name as
displayName property.
⑤ The password is needed as Spring Security will use this to validate the password if the user logs
on. Note that this is the encrypted password.
⑥ We need to map our UserRole enum to something that Spring Security understands, which are
GrantedAuthority instances.
The last part of the puzzle is updating the WebSecurityConfiguration to make use of all this:
@Configuration
@EnableGlobalMethodSecurity(securedEnabled = true)
public class WebSecurityConfiguration extends
WebSecurityConfigurerAdapter {
private final PasswordEncoder passwordEncoder;
private final UserDetailsService userDetailsService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws
Exception {
auth.userDetailsService(userDetailsService) ②
.passwordEncoder(passwordEncoder); ③
}
...
}
③ Also pass in the PasswordEncoder so Spring Security can work with the encoded passwords in
our database.
logging.level.com.tamingthymeleaf.application=DEBUG
So we can see the users that get created in the logging output.
It should look similar to this when running the application (Remember to use the init-db profile so
users get created):
([email protected])
2020-08-29 09:13:42.787 DEBUG 43847 --- [ main]
c.t.application.user.UserServiceImpl : Creating user Hildegarde
Borer ([email protected])
2020-08-29 09:13:42.867 DEBUG 43847 --- [ main]
c.t.application.user.UserServiceImpl : Creating user Hollis O'Reilly
([email protected])
2020-08-29 09:13:42.948 DEBUG 43847 --- [ main]
c.t.application.user.UserServiceImpl : Creating user Bess Dietrich
([email protected])
2020-08-29 09:13:43.029 DEBUG 43847 --- [ main]
c.t.application.user.UserServiceImpl : Creating user Davis Jast
([email protected])
2020-08-29 09:13:43.115 DEBUG 43847 --- [ main]
c.t.application.user.UserServiceImpl : Creating user Solomon Windler
([email protected])
2020-08-29 09:13:43.194 DEBUG 43847 --- [ main]
c.t.application.user.UserServiceImpl : Creating user Mathew
Gulgowski ([email protected])
2020-08-29 09:13:43.282 DEBUG 43847 --- [ main]
c.t.application.user.UserServiceImpl : Creating administrator
Shamika Olson ([email protected])
You should now be able to log on using any of those users. For example, use
[email protected] with password Davis to log on as a user. Or use
[email protected] with password Shamika to log on as an administrator.
As an example, we can update top-menu.html to show the displayName and the username
information from ApplicationUserDetails:
② Show the username of the logged on user (Which is actually the email address in this case)
Figure 76. Popup menu showing the display name and the email address of the logged on user
We used the DatabaseInitializer to create some users, but now we also need to make the 'create
user' form work again with the 2 roles and the password field.
We’ll start with adding the user role selection. We will use a HTML <select> for this (also sometimes
called a dropdown or combobox).
src/main/resources/templates/users/edit.html
<div class="sm:col-span-2">
<label for="userRole" class="block text-sm font-medium text-gray-
700">User
Role</label>
<select id="userRole"
class="max-w-lg block focus:ring-green-500 focus:border-
green-500 w-full shadow-sm sm:max-w-xs sm:text-sm border-gray-300
rounded-md"
th:field="*{userRole}"> ①
<option th:each="role : ${possibleRoles}"
th:text="#{'UserRole.' + ${role.name()}}"
th:value="${role.name()}">User ②
</option>
</select>
</div>
① The <select> needs to bind to the userRole field in the CreateUserFormData object.
② We need to add an <option> tag for each possible role. We will update the UserController to
add the list of possible roles in the model under the possibleRoles key. This list will contain
UserRole enum instances.
The value attribute of the <option> is the name() of the UserRole enum. The value should
always be something fixed that is not translated. So the name() of an enum is a good candidate, or
a primary key value for example.
For the text that we show to the user, we use the #{'UserRole.' + ${role.name}}
construction. This allows to translate the enum values like this in messages.properties:
UserRole.USER=User
UserRole.ADMIN=Administrator
placeholder=#{user.birthday.placeholder})"></div>
① We can re-use the textinput fragment with an inputType of password to easily add the
password field to the form.
② To ensure the administrator has no typo while typing the password for the user, they will be
required to enter the password twice.
@NotExistingUser(groups = ValidationGroupTwo.class)
@PasswordsMatch(groups = ValidationGroupTwo.class)
public class CreateUserFormData {
@NotNull
private UserRole userRole; ①
@NotBlank
@Size(min = 1, max = 200, groups = ValidationGroupOne.class)
private String firstName;
@NotBlank
@Size(min = 1, max = 200, groups = ValidationGroupOne.class)
private String lastName;
@NotBlank
private String password; ②
@NotBlank
private String passwordRepeated; ③
...
}
Next to the new fields in the CreateUserFormData class, we see an extra validation annotation
@PasswordsMatch that we will use to validate if password and passwordRepeated are exactly the
same:
package com.tamingthymeleaf.application.user.web;
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordsMatchValidator.class)
public @interface PasswordsMatch {
String message() default "{PasswordsNotMatching}";
package com.tamingthymeleaf.application.user.web;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
}
@Override
public boolean isValid(CreateUserFormData value,
ConstraintValidatorContext context) {
if (!value.getPassword().equals(value.getPasswordRepeated())) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate
("{PasswordsNotMatching}")
.addPropertyNode("passwordRepeated")
.addConstraintViolation();
return false;
}
return true;
}
}
Finally, we need to update UserController to add the possibleRoles to the model. We need to do
this in the @GetMapping and @PostMapping for the both the create and the edit situation.
This is an example for the @GetMapping of /users/create, but the others are similar:
com.tamingthymeleaf.application.user.web.UserController
@GetMapping("/create")
@Secured("ROLE_ADMIN")
public String createUserForm(Model model) {
model.addAttribute("user", new CreateUserFormData());
model.addAttribute("genders", List.of(Gender.MALE, Gender.
FEMALE, Gender.OTHER));
model.addAttribute("possibleRoles", List.of(UserRole.values()));
①
model.addAttribute("editMode", EditMode.CREATE);
return "users/edit";
}
The create user form will now render like this with our extra fields:
Figure 77. Create user form with role selection and password fields
This works now fine for creating new users, but due to edit.html being used for creating and editing
users and the EditUserFormData extending CreateUserFormData, we now have to enter a
password just for editing a user as well. Probably not what we want.
We start by creating an abstract super class from CreateUserFormData extracting most fields,
except for password and passwordRepeated:
package com.tamingthymeleaf.application.user.web;
import
com.tamingthymeleaf.application.infrastructure.validation.ValidationGrou
pOne;
import
com.tamingthymeleaf.application.infrastructure.validation.ValidationGrou
pTwo;
import com.tamingthymeleaf.application.user.Gender;
import com.tamingthymeleaf.application.user.UserRole;
import org.springframework.format.annotation.DateTimeFormat;
import javax.validation.constraints.*;
import java.time.LocalDate;
@NotExistingUser(groups = ValidationGroupTwo.class)
public class AbstractUserFormData {
@NotNull
private UserRole userRole; ①
@NotBlank
@Size(min = 1, max = 200, groups = ValidationGroupOne.class)
private String firstName;
@NotBlank
@Size(min = 1, max = 200, groups = ValidationGroupOne.class)
private String lastName;
@NotNull
private Gender gender;
@NotBlank
@Email(groups = ValidationGroupOne.class)
private String email;
@NotNull
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate birthday;
@NotBlank
@Pattern(regexp = "[0-9.\\-() x/+]+", groups = ValidationGroupOne
.class)
private String phoneNumber;
package com.tamingthymeleaf.application.user.web;
import
com.tamingthymeleaf.application.infrastructure.validation.ValidationGrou
pTwo;
import com.tamingthymeleaf.application.user.CreateUserParameters;
import com.tamingthymeleaf.application.user.PhoneNumber;
import com.tamingthymeleaf.application.user.UserName;
import javax.validation.constraints.NotBlank;
@PasswordsMatch(groups = ValidationGroupTwo.class) ①
public class CreateUserFormData extends AbstractUserFormData {
@NotBlank
private String password;
@NotBlank
private String passwordRepeated;
package com.tamingthymeleaf.application.user.web;
import com.tamingthymeleaf.application.user.Email;
import com.tamingthymeleaf.application.user.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
@Autowired
public NotExistingUserValidator(UserService userService) {
this.userService = userService;
}
return false;
}
return true;
}
}
package com.tamingthymeleaf.application.user.web;
import com.tamingthymeleaf.application.user.*;
return result;
}
Finally, we update the edit.html to only have the password fields upon creation:
① Use the editMode model property to render the password fields when the template is used in
CREATE mode.
Figure 79. Edit user does not display the password fields
14.7. Summary
In this chapter, you learned:
We have only scratched the surface of all that is possible with Spring Security. I
would recommend taking a look at the reference documentation or one of the
various books on the topic for more information.
Spring Boot has the concept of test slices that allows to start for example only the database layer
(@DataJpaTest), or only the web layer (@WebMvcTest), or only the JSON converters (@JsonTest), ….
Here we will use @WebMvcTest since this is the test slice want for testing our UserController. The
@WebMvcTest annotation will automatically configure a mock servlet environment. Using MockMvc,
we can interact with our controller and validate requests and responses.
• All @Controller beans (or only a single one when specifying the class name on the @WebMvcTest
annotation).
• All @ControllerAdvice beans.
• All @JsonComponent beans so custom JSON serializers and deserializers will be active.
• org.springframework.core.convert.converter.Converter beans.
• Filter beans
• WebMvcConfigurer beans
• HandlerMethodArgumentResolver beans
• @Component
• @Service
• @Repository
• @Configuration
of the application.
Moreover, as a good rule of thumb, a controller should not contain much logic, but
delegate to helper classes (Services and/or repositories).
Given all that, the value of a plain unit test would be very limited.
package com.tamingthymeleaf.application.user.web;
import com.tamingthymeleaf.application.user.UserService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import
org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import
org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(UserController.class)
①
class UserControllerTest {
@Autowired
private MockMvc mockMvc; ②
@MockBean
@Test
void testGetUsersRedirectsToLoginWhenNotAuthenticated() throws
Exception { ④
mockMvc.perform(get("/users")) ⑤
.andDo(print()) ⑥
.andExpect(status().is3xxRedirection()) ⑦
.andExpect(redirectedUrl("http://localhost/login")); ⑧
}
@TestConfiguration
static class TestConfig { ⑨
@Bean
public PasswordEncoder passwordEncoder() { ⑩
return PasswordEncoderFactories
.createDelegatingPasswordEncoder();
}
}
}
① Annotate the test class with @WebMvcTest so the testing infrastructure will be started. We indicate
what controller we want to test by adding the class name of our UserController as an argument
to the annotation.
If we don’t specify any controller class, then all controllers of the application will
be loaded.
② Spring Test will automatically configure a MockMvc instance that we can autowire.
③ Our UserController has a dependency of a UserService. Spring Test will not create the
UserServiceImpl bean automatically, so either we need to create it ourselves, or we can ask
Mockito to create a mock instance. Usually, you will create a mock instance and add it to the test
context. This is easily done by using the @MockBean annotation.
⑦ We can add expectations about the response status via the andExpect() method on MockMvc in
combintation with the static status() method. The status method is statically imported from
org.springframework.test.web.servlet.result.MockMvcResultMatchers.status.
⑧ Create an inner class annotated with @TestConfiguration. The Spring testing framework will
pick up this class and augment the Spring context that is created due to the @WebMvcTest
annotation.
⑨ Create a PasswordEncoder bean, which will be added to the test context. We need this since the
test will import our WebSecurityConfiguration as the testing framework will not only create
the controller, but also the web security related beans. In the production application, we create
this in TamingThymeleafApplicationConfiguration, but in the test, this class is not loaded,
so we need to add it here.
If we now run this test, it should be ok and will output something like:
MockHttpServletRequest:
HTTP Method = GET
Request URI = /users
Parameters = {}
Headers = []
Body = <no character encoding set>
Session Attrs =
{SPRING_SECURITY_SAVED_REQUEST=DefaultSavedRequest[http://localhost/user
s]}
Handler:
Type = null
Async:
Async started = false
Async result = null
Resolved Exception:
Type = null
ModelAndView:
View name = null
View = null
Model = null
FlashMap:
Attributes = null
MockHttpServletResponse:
Status = 302
Error message = null
Headers = [X-Content-Type-Options:"nosniff", X-XSS-
Protection:"1; mode=block", Cache-Control:"no-cache, no-store, max-
age=0, must-revalidate", Pragma:"no-cache", Expires:"0", X-Frame-
Options:"DENY", Location:"http://localhost/login"]
Content type = null
Body =
Because we have our security configured and we are not authenticated in our test, we expect to be
redirected to the login page which is exactly what we test here.
The printed details of the request and response show the following information:
• MockHttpServletRequest: Details about the request that was done including any parameters or
headers that are sent.
• Handler: This normally shows the class that handled the request. Because Spring Security
handled the request before it got to our handlers in this test, nothing is shown here.
• Async
• Resolved Exception: The exception class when there was an exception during the request
processing.
• ModelAndView: Details about the model parameters and the view template.
• MockHttpServletResponse: Details about the response that was returned, inclusing headers
and the response body.
Since our UserController has all its methods secured, we need a way to simulate a user logging in
so we can do some actual validation on the logic of the controller.
Spring Security test has some helper annotations like @WithMockUser and @WithUserDetails to
help us. In our production security configuration, we use DatabaseUserDetailsService and
ApplicationUserDetails classes. As we don’t want to involve a database, we can create a
StubUserDetailsService alternative for the automated test:
package com.tamingthymeleaf.application.infrastructure.security;
import com.tamingthymeleaf.application.user.*;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import
org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@Override
public UserDetails loadUserByUsername(String username) throws
UsernameNotFoundException {
return Optional.ofNullable(users.get(username)) ④
.orElseThrow(() -> new UsernameNotFoundException
(username));
}
456"));
}
}
③ Add 2 users in the constructor so they are available for the tests.
④ Use the users map to search for a matching user.
We create a 'user' user and an 'admin' user, so we can test both security roles. Using that
StubUserDetailsService helper class, we can write a test that uses it:
package com.tamingthymeleaf.application.user.web;
import
com.tamingthymeleaf.application.infrastructure.security.StubUserDetailsS
ervice;
import com.tamingthymeleaf.application.user.UserService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import
org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.core.userdetails.UserDetailsService;
import
org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import
org.springframework.security.test.context.support.WithUserDetails;
import org.springframework.test.web.servlet.MockMvc;
import
org.thymeleaf.spring5.templateresolver.SpringResourceTemplateResolver;
import org.thymeleaf.templateresolver.ITemplateResolver;
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void testGetUsersRedirectsToLoginWhenNotAuthenticated() throws
Exception {
mockMvc.perform(get("/users"))
.andDo(print())
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("http://localhost/login"));
}
@Test
@WithUserDetails(USERNAME_USER) ①
void testGetUsersAsUser() throws Exception {
when(userService.getUsers(any(Pageable.class))) ②
.thenReturn(Page.empty());
mockMvc.perform(get("/users"))
.andDo(print())
.andExpect(status().isOk()); ③
}
@TestConfiguration
static class TestConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories
.createDelegatingPasswordEncoder();
}
@Bean
return resolver;
}
@Bean
public UserDetailsService userDetailsService(PasswordEncoder
passwordEncoder) { ⑤
return new StubUserDetailsService(passwordEncoder);
}
}
① Use @WithUserDetails to instruct Spring Security test to simulate that there is a logged on user.
The passed in parameter of the annotation will be used to ask the UserDetailsService for the
user.
② Setup Mockito so that the call to the userService that is done in UserController is mocked.
Run the test and it should be green. In the output of the test, you will see all the HTML that Thymeleaf
rendered.
We have now written a test using a mock servlet environment where can be sure that a user with the
appropriate authorization can access the application. However, we have not tested anything that is
'visible' in the HTML page. We could take a look at the resulting HTML body and use xpath expressions
or text searches for that. But that would be extremely brittle.
<dependency>
<groupId>net.sourceforge.htmlunit</groupId>
<artifactId>htmlunit</artifactId>
<scope>test</scope>
</dependency>
We can now again create an @WebMvcTest test, but we will interact with the web page under test
using com.gargoylesoftware.htmlunit.WebClient instead of MockMvc.
package com.tamingthymeleaf.application.user.web;
import com.gargoylesoftware.htmlunit.WebClient;
import com.gargoylesoftware.htmlunit.html.*;
import
com.tamingthymeleaf.application.infrastructure.security.StubUserDetailsS
ervice;
import com.tamingthymeleaf.application.user.UserName;
import com.tamingthymeleaf.application.user.UserService;
import com.tamingthymeleaf.application.user.Users;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import
org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.security.core.userdetails.UserDetailsService;
import
org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import
org.springframework.security.test.context.support.WithUserDetails;
import
org.thymeleaf.spring5.templateresolver.SpringResourceTemplateResolver;
import org.thymeleaf.templateresolver.ITemplateResolver;
import java.util.List;
@WebMvcTest(UserController.class)
①
class UserControllerHtmlUnitTest {
@Autowired
private WebClient webClient; ②
@MockBean
private UserService userService;
@BeforeEach
void setup() { ③
webClient.getOptions().setCssEnabled(false);
webClient.getOptions().setJavaScriptEnabled(false);
}
@Test
@WithUserDetails(USERNAME_ADMIN)
④
void testGetUsersAsAdmin() throws Exception {
when(userService.getUsers(any(Pageable.class))) ⑤
.thenReturn(new
PageImpl<>(List.of(Users.createUser(new UserName("Kaden", "Whyte")),
Users.createUser(new UserName("Charlton", "Faulkner")),
Users.createUser(new UserName("Yuvaan", "Mcpherson"))
)));
@TestConfiguration
static class TestConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories
.createDelegatingPasswordEncoder();
}
@Bean
public ITemplateResolver svgTemplateResolver() {
SpringResourceTemplateResolver resolver = new
SpringResourceTemplateResolver();
resolver.setPrefix("classpath:/templates/svg/");
resolver.setSuffix(".svg");
resolver.setTemplateMode("XML");
return resolver;
}
@Bean
public UserDetailsService userDetailsService(PasswordEncoder
passwordEncoder) {
return new StubUserDetailsService(passwordEncoder);
}
}
}
① Annotate the test with @WebMvcTest so that the mock servlet infrastructure will be set up.
③ HtmlUnit does not play so well with Tailwind CSS and AlpineJS, so we disable CSS and JavaScript.
④ We add @WithUserDetails so we are using an administrator user when doing the interaction
with the webpage.
⑤ The UserController interacts with the UserService to get the users. We setup the Mockito
expectations here so we know what to expect in the HTML table that displays the information of
the users.
⑥ Ask HtmlUnit to go to the /users url and return the HtmlPage that the browser would see.
⑧ Assert that only 1 tag should be present and it should contain the text 'Users'.
⑨ Get the HtmlTable that has the id users-table. We updated list.html for this:
Throughout this chapter, some of the HTML elements will get an explicit id like we
just did for the users-table. This makes it easy to find the elements from a test.
The book will not show every update to that, so if you are following along and a test
fails, be sure to check the GitHub sources if you might be missing an id.
package com.tamingthymeleaf.application.user;
import java.time.LocalDate;
import java.util.UUID;
Creating a helper class to create pre-polulated objects for testing is a pattern I use a
lot. It is called Object Mother.
If I have a class User, I will create a class Users with static methods to generate test
users to make the tests more readable. The naming convention is to just add s. So:
• User → Users
• Product → Products
• Address → AddressObjectMother
We can also use HtmlUnit to enter form data. This test simulates an adminstrator clicking on the 'Add
user' link, filling out all needed data and clicking on the submit button to save the new user:
@Test
@WithUserDetails(USERNAME_ADMIN)
void testAddUser() throws IOException {
when(userService.getUsers(any(Pageable.class)))
.thenReturn(Page.empty());
createUserFormPage.getElementById("gender-MALE").click(); ④
createUserFormPage.<HtmlTextInput>getElementByName("firstName"
).setText("John"); ⑤
createUserFormPage.<HtmlTextInput>getElementByName("lastName"
).setText("Millen");
createUserFormPage.<HtmlEmailInput>getElementByName("email"
).setText("[email protected]");
createUserFormPage.<HtmlPasswordInput>getElementByName
("password").setText("verysecure");
createUserFormPage.<HtmlPasswordInput>getElementByName
("passwordRepeated").setText("verysecure");
createUserFormPage.<HtmlTextInput>getElementByName(
"phoneNumber").setText("+555 123 456");
createUserFormPage.<HtmlTextInput>getElementByName("birthday"
).setText("2004-03-27");
② Simulate a click on the link. The result of the click() method is the page that the browser
redirects to.
③ Search for the <h1> element and assert the title text is 'Create user'.
⑤ Find the firstName input and simulate entering some text in the input.
⑨ Get the CreateUserParameters object and assert each field to see if it matches with the data
• There might be a difference between the mock servlet environment and the actual behaviour of
Tomcat.
• We are not doing an end-to-end test from HTML to database. That interaction might have some
hidden bugs that we might not find by using Mockito mock services.
• Writing HtmlUnit tests is not very visual. You need to start the actual application to reference a bit
what the result is of the Thymeleaf templating.
• HtmlUnit is not using an actual browser, so the execution of JavaScript and CSS might be different.
In this section, we will write an end-to-end test using Cypress to address these drawbacks. Cypress is a
front end testing tool, similar to Selenium. See Cypress Features for some more details about Cypress.
We will start the full application with a PostgreSQL database in Docker, the Spring Boot application
locally and the Cypress test runner also in Docker. Cypress will start a browser (e.g. Chrome) and run
the tests (written in JavaScript).
src/test/e2e/package.json
{
"name": "taming-thymeleaf-tests"
}
{
"name": "taming-thymeleaf-tests",
"devDependencies": {
"cypress": "^5.1.0"
}
}
Now run:
This will start the Cypress desktop application and create example tests in
cypress/integration/examples:
Click on actions.spec.js to see Cypress in action. It will start the browser you select in the top-right
corner and run the tests.
As an alternative, we can expose certain REST API endpoints that will put the database in a well-known
state. This could be completely empty, or with a few test users, or with many users to test pagination,
…
package com.tamingthymeleaf.application.infrastructure.test;
import com.tamingthymeleaf.application.user.*;
import org.springframework.context.annotation.Profile;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate;
@RestController ①
@RequestMapping("/api/integration-test") ②
@Profile("integration-test") ③
public class IntegrationTestController {
private final UserService userService;
@PostMapping("/reset-db")
public void resetDatabase() { ⑤
userService.deleteAllUsers();
addUser();
addAdministrator();
}
"Strator"), ⑦
"admin-pwd",
Gender.FEMALE,
LocalDate.parse("2008-09-25"),
new Email
("[email protected]"),
new PhoneNumber("+555 123
456")));
}
}
① Create a @RestController so we can call the endpoints from the Cypress tests.
③ These endpoints should only be started when running as a test. It is very important that this is not
exposed when running on production as it wipes the complete database.
④ Inject the UserService to be able to create the default users.
⑤ Add an endpoint /reset-db so the Cypress test can do a POST call on it.
Since we only start this controller when running tests, we can safely open up the security and allow
everybody to access the /api/integration-test endpoint. This will avoid that we need to
authenticate from the Cypress tests to call the endpoint:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf(httpSecurityCsrfConfigurer ->
httpSecurityCsrfConfigurer.
ignoringAntMatchers("/api/integration-test/**")); ①
http.authorizeRequests()
.requestMatchers(PathRequest.toStaticResources
().atCommonLocations()).permitAll()
.antMatchers("/api/integration-test/**").permitAll() ②
.antMatchers("/img/*").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.logout().permitAll();
}
We can now write our first Cypress test. Create a new file in src/test/e2e/cypress/integration
called auth.spec.js with this content:
describe('Authentication', () => { ①
beforeEach(() => { ②
cy.setCookie(
'org.springframework.web.servlet.i18n.CookieLocaleResolver.LOCALE', 'en
'); ③
cy.request({ ④
method: 'POST',
url: 'api/integration-test/reset-db',
followRedirect: false
}).then((response) => {
expect(response.status).to.eq(200); ⑤
});
});
② beforeEach (also from Mocha) allows to execute some code before each test, similar to the
@BeforeEach annotation of JUnit 5.
③ Use cy.setCookie to set the language cookie that Spring Boot will read. This can be very useful
to test multi-language support.
④ Using cy.request, we can do a REST call to our api/integration-test/reset-db endpoint
⑤ expect is a Chai assertion. We test if we got a 200 OK response from the REST call.
In the test itself, we use relative URLs. We will set the base URl in the Cypress settings. Update
src/test/e2e/cypress.json to set the base URL that all Cypress tests should use. We will also set
the default viewport that the browser will get to render the content:
{
"baseUrl": "http://localhost:8080",
"viewportWidth": 1100,
"viewportHeight": 800
}
That viewport size is only a default. It is perfectly possible to change the viewport
during the test. That allows for example to check if certain elements are visible on
desktop, but not on mobile.
• Add an extra run configuration in your IDE that enables the integration-test profile, or ensure
you add the integration-test profile when running from the command line:
This will start the test and should look like this when the test is done:
Figure 81. Cypress runner showing the test on the left and the browser contents on the right
You can now interact with our application in the Cypress controlled browser. You can for example try
to log on using one of the users we created via the IntegrationTestController.
We can now expand the test to actually log in and check that we get redirected to the /users URL
after login:
cy.get('#username').type('[email protected]'); ②
cy.get('#password').type('user-pwd'); ③
cy.get('#submit-button').click(); ④
cy.url().should('include', '/users'); ⑤
});
② Search for the HTML element with id username and type [email protected] in the input
field.
③ Search for the HTML element with id password and type user-pwd in the input field.
You can just keep the Cypress desktop application open while coding the test. Each time you save, the
test will re-run automatically.
cy.get('#username').type('[email protected]');
cy.get('#password').type('admin-pwd');
cy.get('#submit-button').click();
cy.url().should('include', '/users');
});
Result:
As an alternative, we will write a Cypress command. Cypress commands allow to create helper
functions that can be used in the tests. This command will do the login by directly submitting the form
data and not going to the login page and type username/email and password.
return cy.request('/login')
.its('body')
.then((body) => {
// we can use Cypress.$ to parse the string body
// thus enabling us to query into it easily
const $html = Cypress.$(body);
const csrf = $html.find('input[name=_csrf]').val();
You don’t need to know this code in detail. Just know that it adds an extra function on the cy object in
your tests that will allow to login with:
cy.loginByForm('[email protected]', 'admin-pwd');
cy.loginByForm('[email protected]', 'admin-pwd'); ①
cy.visit('/users'); ②
});
cy.url().should('include', '/users/create'); ④
});
});
① Use the loginByForm function to quickly log in with the admin user
With all this in place, we have a framework for writing tests for all the functionality we have added to
our application already. In the next section, we will integrate the Cypress tests with the other
automated tests.
To avoid that we need to install Cypress for running the tests, we will use Testcontainers with a
Docker image that contains Cypress.
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.github.wimdeblauwe</groupId>
<artifactId>testcontainers-cypress</artifactId>
<version>${testcontainers-cypress.version}</version>
<scope>test</scope>
</dependency>
We will start with an integration test that starts PostgreSQL in Docker and the full application locally
on a random port:
package com.tamingthymeleaf.application;
import com.tamingthymeleaf.application.user.UserService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment
.RANDOM_PORT) ①
@Testcontainers ②
public class CypressE2eTests {
@Container ③
private static final PostgreSQLContainer postgresqlContainer = new
PostgreSQLContainer<>("postgres:12")
.withDatabaseName("tamingthymeleafdb")
.withUsername("user")
.withPassword("secret");
@LocalServerPort ④
private int port;
@Autowired
private UserService userService; ⑤
@DynamicPropertySource
static void setup(DynamicPropertyRegistry registry) { ⑥
registry.add("spring.datasource.url", postgresqlContainer:
:getJdbcUrl);
registry.add("spring.datasource.username", postgresqlContainer:
:getUsername);
registry.add("spring.datasource.password", postgresqlContainer:
:getPassword);
}
@BeforeEach
void validatePreconditions() {
assertThat(userService.countUsers()).isZero(); ⑦
}
@Test
void test() {
System.out.println("port = " + port);
System.out.println("Application started");
}
③ The @Container annotation will start and stop the Docker container via Testcontainers at the
appropriate points in the JUnit lifecycle. By default, PostgreSQLContainer uses version 9.6.12,
but by specifying the name of the docker image, we can use a more recent version. See Postgres
on Docker Hub for all available versions.
④ Because the application starts at a random port, we will need to know what port that is so we can
point Cypress to the good URL. Using @LocalServerPort, Spring will inject the chosen port into
the port variable.
⑤ We can autowire any bean from the Spring context in our test if needed.
⑥ We can dynamically set the JDBC URL, database username and password by adding this method
annotated with @DynamicPropertySource. It is an easy way to pass the information from the
PostgreSQLContainer to our application.
Run this test. It should start PostgreSQL and the application, print the port and shutdown again.
Building upon this test, we can create the full integration between Cypress and JUnit.
This adds the mochawesome reporter to the project which generates test results in JSON format.
testcontainers-cypress will read those reports to report the results back to JUnit.
3. Update src/test/e2e/cypress.json:
{
"baseUrl": "http://localhost:8080",
"viewportWidth": 1100,
"viewportHeight": 800,
"reporter": "cypress-multi-reporters",
"reporterOptions": {
"configFile": "reporter-config.json"
}
}
{
"reporterEnabled": "spec, mochawesome",
"mochawesomeReporterOptions": {
"reportDir": "cypress/reports/mochawesome",
"overwrite": false,
"html": false,
"json": true
}
}
src/test/e2e/cypress/reports
src/test/e2e/cypress/videos
src/test/e2e/cypress/screenshots
6. Update the Maven pom.xml so the Cypress tests are made available as test resources:
<project>
<build>
...
<testResources>
<testResource>
<directory>src/test/resources</directory>
</testResource>
<testResource>
<directory>src/test/e2e</directory>
<targetPath>e2e</targetPath>
</testResource>
</testResources>
...
</build>
</project>
Now, we will start the Cypress Docker container in our JUnit test and run the Cypress tests:
package com.tamingthymeleaf.application;
import com.tamingthymeleaf.application.user.UserService;
import io.github.wimdeblauwe.testcontainers.cypress.CypressContainer;
import io.github.wimdeblauwe.testcontainers.cypress.CypressTestResults;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.io.IOException;
import java.util.concurrent.TimeoutException;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment
.RANDOM_PORT)
@Testcontainers
@ActiveProfiles("integration-test") ①
public class CypressE2eTests {
@Container
private static final PostgreSQLContainer postgresqlContainer = new
PostgreSQLContainer<>("postgres:12")
.withDatabaseName("tamingthymeleafdb")
.withUsername("user")
.withPassword("secret");
@LocalServerPort
private int port;
@Autowired
@DynamicPropertySource
static void setup(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgresqlContainer:
:getJdbcUrl);
registry.add("spring.datasource.username", postgresqlContainer:
:getUsername);
registry.add("spring.datasource.password", postgresqlContainer:
:getPassword);
}
@BeforeEach
void validatePreconditions() {
assertThat(userService.countUsers()).isZero();
}
@Test
void runTests() throws InterruptedException, IOException,
TimeoutException {
// Ensure that the Cypress container can access the Spring Boot
app running on port `port` via `host.testcontainers.internal`
org.testcontainers.Testcontainers.exposeHostPorts(port); ②
try (CypressContainer container = new CypressContainer() ③
.withLocalServerPort(port)) { ④
container.start(); ⑤
CypressTestResults testResults = container.getTestResults();
⑥
assertThat(testResults.getNumberOfFailingTests()) ⑦
.describedAs("%s", testResults)
.isZero();
}
}
③ Declare a CypressContainer with a custom Docker image name so we can match the Cypress
version with the one we have been using before.
④ Pass the random port that Spring Boot started on to CypressContainer so the base URL for the
Cypress tests can be set correctly.
⑤ Start the container.
⑥ Wait for the tests to finish and get the results.
⑦ Assert that there should be no failing tests.
If you like to see the output from the Cypress container, add the following to application-
integration-test.properties:
src/test/resources/application-integration-test.properties
logging.level.io.github.wimdeblauwe.testcontainers=DEBUG
If all goes well, the Cypress tests should run and the test should succeed.
Cypress automatically creates videos of each test. You can view this video at target/test-
classes/e2e/cypress/videos.
When we run the CypressE2eTests from our IDE, we only see 1 test:
This is because the tests run in the Cypress Docker container and we only get the results at the end of
the full test run.
It would be a lot better if we could see the Cypress tests individually, so we can know exactly what test
failed. This is possible by using the JUnit 5 support for dynamic tests:
package com.tamingthymeleaf.application;
import com.tamingthymeleaf.application.user.UserService;
import io.github.wimdeblauwe.testcontainers.cypress.CypressContainer;
import io.github.wimdeblauwe.testcontainers.cypress.CypressTest;
import io.github.wimdeblauwe.testcontainers.cypress.CypressTestResults;
import io.github.wimdeblauwe.testcontainers.cypress.CypressTestSuite;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DynamicContainer;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeoutException;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment
.RANDOM_PORT)
@Testcontainers
@ActiveProfiles("integration-test")
public class CypressE2eTests {
@Container
private static final PostgreSQLContainer postgresqlContainer = new
PostgreSQLContainer<>("postgres:12")
.withDatabaseName("tamingthymeleafdb")
.withUsername("user")
.withPassword("secret");
@LocalServerPort
private int port;
@Autowired
private UserService userService;
@DynamicPropertySource
static void setup(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgresqlContainer:
:getJdbcUrl);
registry.add("spring.datasource.username", postgresqlContainer:
:getUsername);
registry.add("spring.datasource.password", postgresqlContainer:
:getPassword);
}
@BeforeEach
void validatePreconditions() {
assertThat(userService.countUsers()).isZero();
}
@TestFactory ①
List<DynamicContainer> runTests() throws InterruptedException,
IOException, TimeoutException { ②
// Ensure that the Cypress container can access the Spring Boot
app running on port `port` via `host.testcontainers.internal`
org.testcontainers.Testcontainers.exposeHostPorts(port);
try (CypressContainer container = new CypressContainer()
.withLocalServerPort(port)) {
container.start();
CypressTestResults testResults = container.getTestResults();
return convertToJUnitDynamicTests(testResults); ③
}
}
① Replace @Test with @TestFactory to indicate that this method will return a collection of tests.
③ Convert the test results from the CypressContainer to a List<DynamicContainer using the
convertToJUnitDynamicTests and createContainerFromSuite helper functions.
If we now run the test again from our IDE, we see in the end a nice overview of all individual tests:
This concludes the section on testing. To learn more about Cypress, check out the excellent Cypress
Documentation.
15.3. Summary
In this chapter, you learned:
If you want to learn more about testing with Spring Boot, be sure to check out the
excellent Testing Spring Boot Applications Masterclass by Philip Riecks.
By default, Spring Boot has 'Open Session In View' enabled. However, this is considered to be an anti-
pattern by many. See The Open Session In View Anti-Pattern and Open session in view is evil.
Why is that?
To answer that question, let’s first explain what Open Session In View does:
Put simple: The 'Session' is what JPA/Hibernate needs to do the database work. When a controller
calls a service, a transaction is started and spans all database calls that the service does (via a
repository). When the service returns the deserialized objects to the controller, the
transaction/session is closed.
As a performance optimization, some references of a returned entity are lazy loaded. So only if you
call the getter method, the actual values are queried from the database. However, if you call this
getter in the controller, there is a problem. Hibernate wants to do a database query for the additional
information, but there is no session. As a result, it will throw a LazyInitializationException.
To avoid that LazyInitializationException, the Open Session In View pattern was invented. The
session is kept open for the controller, so if Hibernate wants to do an additional query, it can do so
without the exception.
It is bad because:
• The transaction is closed at the service layer. The additional statements are done under auto-
commit, which causes a lot of I/O pressure on the database.
• The controller triggers 'hidden' queries which might lead to N+1 query problems.
• The database connection is held longer then necessairy, so the overall throughput is limited.
For all of these reasons, I turn off Open Session In View in my applications by adding this line to
application.properties:
spring.jpa.open-in-view=false
You could make all associations Eager, but then you fetch too much data all the time. It is better to
make associations Lazy by default and use join fetch when reading the data.
A few tips:
• Use JOIN FETCH when information from the associations is needed (If you get a
LazyInitializationException, it is needed).
Example of a User entity that has an association with a Set of Vehicle entities:
@Entity
public class User {
This will get the matching user with the vehicles set fully initialized in a single query, thus avoiding the
N+1 query problem.
To end, I want to point out that for many small applications, having OSIV enabled might not be a
performance problem at all. So don’t feel bad if you want to keep using it, just be aware of what it
does exactly, and the trade-offs that are there.
16.2. StringTrimmerEditor
Users of your application might add one or more extra spaces when they need to input some data.
You could ensure to trim that in each of the form data backing objects, but that would get tedious
fast.
package com.tamingthymeleaf.application.infrastructure.web;
import org.springframework.beans.propertyeditors.StringTrimmerEditor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
@ControllerAdvice
public class GlobalControllerAdvice {
@ResponseStatus(HttpStatus.CONFLICT)
@ExceptionHandler({DataIntegrityViolationException.class,
ObjectOptimisticLockingFailureException.class})
public ModelAndView handleConflict(HttpServletRequest request,
Exception e) {
ModelAndView result = new ModelAndView("error/409");
result.addObject("url", request.getRequestURL());
return result;
}
@InitBinder ①
public void initBinder(WebDataBinder binder) {
StringTrimmerEditor stringtrimmer = new StringTrimmerEditor
(false); ②
binder.registerCustomEditor(String.class, stringtrimmer); ③
}
}
① Methods annotated with @InitBinder will be called by the framework to initialize the
WebDataBinder.
② Create a StringTrimmerEditor instance. The boolean flag indicates if you want to have an
empty string returned as null (use true), or if an empty string should remain an empty string
(use false).
③ Register the StringTimmerEditor to the binder for all fields of type String.
Now all excess whitespace will be trimmed when the values are taken from the <input> fields and
put in the form data object.
If we look at the various methods in UserController, we see that some of the model attributes are
added to the model in each method.
@GetMapping("/create")
@Secured("ROLE_ADMIN")
public String createUserForm(Model model) {
model.addAttribute("user", new CreateUserFormData());
model.addAttribute("genders", List.of(Gender.MALE, Gender.
FEMALE, Gender.OTHER));
model.addAttribute("possibleRoles", List.of(UserRole.values()));
model.addAttribute("editMode", EditMode.CREATE);
return "users/edit";
}
The genders and possibleRoles attribute is also added in 3 other methods of the
UserController.
We could of course, just create a method and call that method from all places, but there is also an
other way: add a method annotated with @ModelAttribute in UserController.
com.tamingthymeleaf.application.user.web.UserController
@ModelAttribute("genders")
public List<Gender> genders() {
return List.of(Gender.MALE, Gender.FEMALE, Gender.OTHER);
}
@ModelAttribute("possibleRoles")
public List<UserRole> possibleRoles() {
return List.of(UserRole.values());
}
We can now remove adding those attributes to the Model in the actual controller methods:
com.tamingthymeleaf.application.user.web.UserController
@GetMapping("/create")
@Secured("ROLE_ADMIN")
public String createUserForm(Model model) {
model.addAttribute("user", new CreateUserFormData());
model.addAttribute("editMode", EditMode.CREATE);
return "users/edit";
}
An example is adding a footer with the application version on each page of the application.
@ControllerAdvice
public class GlobalControllerAdvice {
@Value("${application.version}") ①
private String version;
@ModelAttribute("version") ②
public String getVersion() {
return version;
}
...
}
① Read the application.version property and inject it into the version field.
② Declare the result of the method call as the version model attribute.
src/main/resources/application.properties
application.version=1.0.0-SNAPSHOT
src/main/resources/application.properties
And configure Maven resource filtering to replace the @project.version@ with the
<project>
<build>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>
application.properties</include>
</includes>
</resource>
</resources>
</build>
</project>
We can now use version in all Thymeleaf templates. For example, add this to login.html:
This will allow us to map a selected file in an <input type="file"> from the <form> to the
avatarFile field.
com.tamingthymeleaf.application.user.CreateUserParameters
@Nullable
public MultipartFile getAvatar() {
return avatar;
}
com.tamingthymeleaf.application.user.web.CreateUserFormData
if (getAvatarFile() != null
&& !getAvatarFile().isEmpty()) { ①
parameters.setAvatar(getAvatarFile());
}
return parameters;
}
① If the form data has a valid MultipartFile, pass it to the CreateUserParameters object.
com.tamingthymeleaf.application.user.web.EditUserFormData
if (getAvatarFile() != null
&& !getAvatarFile().isEmpty()) {
parameters.setAvatar(getAvatarFile());
}
return parameters;
}
We will store the image into the database. We need to update the User entity for this:
/**
* The avatar image of the driver. Null if no avatar has been set.
*
* @return the image bytes
*/
public byte[] getAvatar() {
return avatar;
}
/**
* Set the avatar image of the driver.
*
* @param avatar the image bytes
*/
public void setAvatar(byte[] avatar) {
this.avatar = avatar;
}
}
We also need to change the Flyway scripts to allow storing the avatar byte[]:
Next, update UserServiceImpl to take the MultipartFile and store the bytes in the database:
com.tamingthymeleaf.application.user.UserServiceImpl
@Override
public User createUser(CreateUserParameters parameters) {
LOGGER.debug("Creating user {} ({})", parameters.getUserName
().getFullName(), parameters.getEmail().asString());
UserId userId = repository.nextId();
String encodedPassword = passwordEncoder.encode(parameters
.getPassword());
User user = User.createUser(userId,
parameters.getUserName(),
encodedPassword,
parameters.getGender(),
parameters.getBirthday(),
parameters.getEmail(),
parameters.getPhoneNumber());
storeAvatarIfPresent(parameters, user); ①
return repository.save(user);
}
③ Get the bytes from the MultipartFile and store them in the User entity.
If you want to resize the selected file before storing it, you can use the
Thumbnailator library.
With all this Java code in place, we can update edit.html to allow the user to select a file. We will also
support showing the current image from the database when editing a user. To make that possible, we
need do to one last change to the Java code:
package com.tamingthymeleaf.application.user.web;
import com.tamingthymeleaf.application.user.*;
import java.util.Base64;
if (user.getAvatar() != null) {
String encoded = Base64.getEncoder().encodeToString(user
.getAvatar()); ②
result.setAvatarBase64Encoded(encoded);
}
return result;
}
...
① The avatarBase64Encoded field with contain the avatar in Base64 encoding so we can display it
using an <img> tag.
Now onto edit.html. This is the <div> that has the relevant code for adding or editing an avatar:
① The <img> tag will either show the current avatar of the user, or it will show a placeholder SVG.
② This is the <input> that is mapped on the MultipartFile field of the form data objects. Note
that the name needs to match with the name of the field in the Java objects. We make the input
hidden because we don’t want to use the standard file upload button in this example.
Because we hide the file input, we need a bit of JavaScript to trigger the file input when the user clicks
on the preview image or the button next to it:
<th:block layout:fragment="page-scripts">
<script>
document.querySelector('#selectAvatarButton').addEventListener(
'click', evt => { ①
document.querySelector('#selectAvatarButton').blur();
document.querySelector('#avatarFile').click();
});
document.querySelector('#avatarImage').addEventListener('click',
evt => { ②
document.querySelector('#avatarImage').blur();
document.querySelector('#avatarFile').click();
});
document.querySelector('#avatarFile').addEventListener('change',
evt => { ③
previewImage();
});
function previewImage() {
var uploader = document.querySelector('#avatarFile');
if (uploader.files && uploader.files[0]) {
document.querySelector('#avatarImage').src = window.URL
.createObjectURL(uploader.files[0]); ④
document.querySelector('#avatarImage').classList.remove
('p-6'); ⑤
}
}
</script>
</th:block>
① Attach a click listener on the button so the file <input> is triggered when the button is clicked.
② Attach a click listener to the preview image so the file <input> is triggered when the button is
clicked.
③ When the actual selected file changes, update the preview image
④ Set the src of the preview image to the uploaded file. Since we only allow to select a single file, we
can safely use uploader.files[0].
⑤ Remove the padding we need for the default SVG image when an actual avatar is now showing.
The user can select an avatar by either clicking on the dummy image, or by clicking on the 'Add
picture' button.
When editing a user, we immediately see the preview if the user has an avatar:
16.5.1. Implementation
A common requirement in an application is selecting an entity from a list of entities to link that entity
to another entity. It is probably a bit hard to understand that sentence, so let’s make it practical as
follows:
We will create a Team entity. Each team has a coach. When we create a form to create or edit a Team,
we will have a combobox to select a coach. That combobox will contain all users of the application.
Expanding on that generated code, we have our Team entity like this:
package com.tamingthymeleaf.application.team;
import com.tamingthymeleaf.application.user.User;
import io.github.wimdeblauwe.jpearl.AbstractVersionedEntity;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.ManyToOne;
import javax.validation.constraints.NotBlank;
@Entity
public class Team extends AbstractVersionedEntity<TeamId> {
@NotBlank
private String name;
@ManyToOne(fetch = FetchType.LAZY)
private User coach; ①
/**
* Default constructor for JPA
*/
protected Team() {
}
① Create a link between Team and User using the many-to-one relationship (A single coach might
coach different teams)
src/main/resources/db/migration/V1.1__add-team.sql
package com.tamingthymeleaf.application.team;
import com.tamingthymeleaf.application.user.User;
import com.tamingthymeleaf.application.user.UserId;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.Optional;
void deleteAllTeams();
}
To keep things a bit simpler, we did not create a CreateTeamParameters object like
The TeamServiceImpl will use the TeamRepository for the database interaction:
package com.tamingthymeleaf.application.team;
import com.tamingthymeleaf.application.user.User;
import com.tamingthymeleaf.application.user.UserId;
import com.tamingthymeleaf.application.user.UserNotFoundException;
import com.tamingthymeleaf.application.user.UserService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
@Service
@Transactional
public class TeamServiceImpl implements TeamService {
private static final Logger LOGGER = LoggerFactory.getLogger
(TeamServiceImpl.class);
private final TeamRepository repository;
private final UserService userService;
@Override
@Transactional(readOnly = true)
public Page<TeamSummary> getTeams(Pageable pageable) {
return repository.findAllSummary(pageable);
}
@Override
public Team createTeam(String name, User coach) {
LOGGER.info("Creating team {} with coach {} ({})", name, coach
.getUserName().getFullName(), coach.getId());
return repository.save(new Team(repository.nextId(), name,
coach));
}
@Override
public Team createTeam(String name, UserId coachId) {
User coach = getCoach(coachId);
return createTeam(name, coach);
}
@Override
public Optional<Team> getTeam(TeamId teamId) {
return repository.findById(teamId);
}
@Override
public Team editTeam(TeamId teamId, long version, String name,
UserId coachId) {
Team team = getTeam(teamId)
.orElseThrow(() -> new TeamNotFoundException(teamId));
if (team.getVersion() != version) {
throw new ObjectOptimisticLockingFailureException(User.
class, team.getId().asString());
}
team.setName(name);
team.setCoach(getCoach(coachId));
return team;
}
@Override
public void deleteTeam(TeamId teamId) {
repository.deleteById(teamId);
}
@Override
public void deleteAllTeams() {
repository.deleteAll();
}
Data Transfer Object (DTO). This allows us to only return the fields that we really need in an efficient
query:
package com.tamingthymeleaf.application.team;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.transaction.annotation.Transactional;
@Transactional(readOnly = true)
public interface TeamRepository extends CrudRepository<Team, TeamId>,
TeamRepositoryCustom {
@Query("SELECT new
com.tamingthymeleaf.application.team.TeamSummary(t.id, t.name,
t.coach.id, t.coach.userName) FROM Team t")
Page<TeamSummary> findAllSummary(Pageable pageable);
}
package com.tamingthymeleaf.application.team;
import com.tamingthymeleaf.application.user.UserId;
import com.tamingthymeleaf.application.user.UserName;
For the create/edit form, we need a Java form data object to match. We will create
CreateTeamFormData and EditTeamFormData for that purpose:
package com.tamingthymeleaf.application.team.web;
import com.tamingthymeleaf.application.user.UserId;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
Note how we are not adding a User coach field, but a UserId coachId field. The HTML <select>
will match on the String representation of the UserId, not on the full Java object. For that reason, it is
easier to implement this using UserId.
This is also why it is important to not just use your entity and map that to your form. It is much better
to use dedicated form data objects like we do here.
package com.tamingthymeleaf.application.team.web;
import com.tamingthymeleaf.application.team.Team;
}
}
Next, we create the TeamController, which is very similar to the UserController we created
before:
package com.tamingthymeleaf.application.team.web;
import com.tamingthymeleaf.application.infrastructure.web.EditMode;
import com.tamingthymeleaf.application.team.Team;
import com.tamingthymeleaf.application.team.TeamId;
import com.tamingthymeleaf.application.team.TeamNotFoundException;
import com.tamingthymeleaf.application.team.TeamService;
import com.tamingthymeleaf.application.user.UserService;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.SortDefault;
import org.springframework.security.access.annotation.Secured;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import javax.validation.Valid;
@Controller
@RequestMapping("/teams")
public class TeamController {
@GetMapping
public String index(Model model,
@SortDefault.SortDefaults(@SortDefault("name"))
Pageable pageable) {
model.addAttribute("teams", service.getTeams(pageable));
return "teams/list";
}
@GetMapping("/create")
@Secured("ROLE_ADMIN")
public String createTeamForm(Model model) {
model.addAttribute("team", new CreateTeamFormData());
model.addAttribute("users", userService.getAllUsersNameAndId());
return "teams/edit";
}
@PostMapping("/create")
@Secured("ROLE_ADMIN")
public String doCreateTeam(@Valid @ModelAttribute("team")
CreateTeamFormData formData,
BindingResult bindingResult, Model model)
{
if (bindingResult.hasErrors()) {
model.addAttribute("editMode", EditMode.CREATE);
model.addAttribute("users", userService.
getAllUsersNameAndId());
return "teams/edit";
}
service.createTeam(formData.getName(), formData.getCoachId());
return "redirect:/teams";
}
@GetMapping("/{id}")
public String editTeamForm(@PathVariable("id") TeamId teamId,
Model model) {
Team team = service.getTeam(teamId)
.orElseThrow(() -> new TeamNotFoundException
(teamId));
model.addAttribute("team", EditTeamFormData.fromTeam(team));
model.addAttribute("users", userService.getAllUsersNameAndId());
model.addAttribute("editMode", EditMode.UPDATE);
return "teams/edit";
}
@PostMapping("/{id}")
@Secured("ROLE_ADMIN")
public String doEditTeam(@PathVariable("id") TeamId teamId,
@Valid @ModelAttribute("team")
EditTeamFormData formData,
BindingResult bindingResult,
Model model) {
if (bindingResult.hasErrors()) {
model.addAttribute("editMode", EditMode.UPDATE);
model.addAttribute("users", userService.
getAllUsersNameAndId());
return "teams/edit";
}
return "redirect:/teams";
}
@PostMapping("/{id}/delete")
@Secured("ROLE_ADMIN")
public String doDeleteTeam(@PathVariable("id") TeamId teamId,
RedirectAttributes redirectAttributes) {
Team team = service.getTeam(teamId)
.orElseThrow(() -> new TeamNotFoundException
(teamId));
service.deleteTeam(teamId);
redirectAttributes.addFlashAttribute("deletedTeamName",
team.getName());
return "redirect:/teams";
}
}
What is important here is that we pass in the list of current users as users in the model. We don’t
pass the full User object as we only need the user name and id. For that purpose, a DTO was created:
package com.tamingthymeleaf.application.user;
}
com.tamingthymeleaf.application.user.UserService
ImmutableSortedSet<UserNameAndId> getAllUsersNameAndId();
<!DOCTYPE html>
<html
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/layout}"
th:with="activeMenuItem='teams'">
<head>
<title>Teams</title>
</head>
<body>
<div layout:fragment="page-content">
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
<h1 class="text-2xl font-semibold text-gray-900"
th:text="${editMode?.name() ==
'UPDATE'}?#{team.edit}:#{team.create}">Create team</h1>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
<div class="py-4">
<div class="bg-white shadow px-4 py-5 sm:rounded-lg sm:p-6">
<form id="team-form"
th:object="${team}"
th:action="${editMode?.name() ==
'UPDATE'}?@{/teams/{id}(id=${team.id})}:@{/teams/create}"
method="post"
enctype="multipart/form-data">
<div>
<div th:replace="fragments/forms ::
fielderrors"></div>
<div class="mt-6 grid grid-cols-1 gap-y-6 gap-x-
4 sm:grid-cols-6">
<input type="hidden" th:field="*{version}"
th:if="${editMode?.name() == 'UPDATE'}">
<div th:replace="fragments/forms ::
textinput(#{team.name}, 'name', 'sm:col-span-3')"></div>
<div class="sm:col-span-3"></div>
<div class="sm:col-span-3">
<label for="coachId" class="block text-
sm font-medium text-gray-700"
th:text="#{team.coach}">
</label>
<div class="mt-1 rounded-md shadow-sm">
<select th:field="*{coachId}"
class="max-w-lg block
focus:ring-green-500 focus:border-green-500 w-full shadow-sm sm:max-w-xs
sm:text-sm border-gray-300 rounded-md">
<option th:each="user :
${users}"
th:text="${user.userName.fullName}"
th:value="${user.id.asString()}">
</select>
</div>
</div>
</div>
</div>
<div class="mt-8 border-t border-gray-200 pt-5">
<div class="flex justify-end">
<span class="inline-flex rounded-md shadow-sm">
<button type="button"
class="bg-white py-2 px-4 border border-gray-300
rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-
500"
th:text="#{cancel}">
Cancel
</button>
</span>
<span class="ml-3 inline-flex rounded-md
shadow-sm">
<button id="submit-button"
type="submit"
class="ml-3 inline-flex justify-center py-2 px-4 border
border-transparent shadow-sm text-sm font-medium rounded-md text-white
bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2
focus:ring-offset-2 focus:ring-green-500"
th:text="${editMode?.name() ==
'UPDATE'}?#{save}:#{create}">
Save
</button>
</span>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</body>
</html>
<div class="sm:col-span-3">
<label for="coachId" class="block text-sm font-medium text-gray-700"
th:text="#{team.coach}">
</label>
<div class="mt-1 rounded-md shadow-sm">
<select th:field="*{coachId}"
class="max-w-lg block focus:ring-green-500 focus:border-
green-500 w-full shadow-sm sm:max-w-xs sm:text-sm border-gray-300
rounded-md">
<option th:each="user : ${users}"
th:text="${user.userName.fullName}"
th:value="${user.id.asString()}">
</select>
</div>
</div>
Important points:
• The <select> has a th:field attribute that references to the coachId property of the
CreateTeamFormData and EditTeamFormData objects.
• We create as many <option> subtags as there are users.
• Use th:text for the visible text that the user will see.
• Use th:value for the value associated with the option (The primary key of the user in our case)
Thymeleaf will set the <option> to selected automatically for the tag where the value matches
with the current coachId.
If we look at the page source in the browser, it will look something like this:
16.5.2. Tests
To ensure everything works fine, we can add a few Cypress tests. Create
src/test/e2e/cypress/integration/team-management.spec.js and add a test to add a team:
cy.visit('/teams');
});
cy.url().should('include', '/teams/create');
cy.get('#name').type('Wizards'); ③
cy.get('#coachId').select('Admin Strator'); ④
cy.get('#submit-button').click(); ⑤
cy.get('#teams-table').find('tbody tr').should('have.length',
1); ⑥
});
});
We can also test if the delete functionality works fine. Let’s first add a new endpoint to
IntegrationTestController so there is a team present we can delete in the test:
@PostMapping("/reset-db")
public void resetDatabase() {
teamService.deleteAllTeams(); ①
userService.deleteAllUsers();
addUser();
addAdministrator();
}
@PostMapping("/add-test-team")
public void addTestTeam() {
UserNameAndId userNameAndId = userService.getAllUsersNameAndId()
.first(); ②
teamService.createTeam("Test Team", userNameAndId.getId()); ③
}
① Delete all the teams when resetting the database. If we did not do this, PostgreSQL would give an
exception due to foreign key constraints when we try to delete a user that is linked to a team.
② Get a random user to use as coach.
③ Create the test team.
cy.get('[id^=delete-link-]').click(); ③
cy.get('#delete-modal-message').contains('Are you sure you want
to delete team Test Team?'); ④
cy.get('#delete-modal-submit-button').click(); ⑤
cy.reload(); ⑧
cy.get('#success-alert-message').should('not.exist'); ⑨
});
③ Find the delete link. Each delete link was given a unique id like delete-link-<teamId>. Using
get('[id^=delete-link-]) allows us to match an element where the id starts with delete-
link-. As we only have 1 team, there will be only 1 such link in this test.
④ Check if the modal message is showing the name of the team that is about to be deleted.
⑤ Confirm the delete.
While working on Cypress tests, it can be convenient to work on a single test only to
avoid that all tests are run for each change you do. Replace it with it.only so that
Cypress will run that test only:
Each row allows to select a user and his position on the team. The 'Add' button below the rows allows
to add an extra row. The 'Remove' link at each row allows to remove a single row, to remove a user
from the team.
16.6.1. Entities
We will start by adding a TeamPlayer entity. This entity represents a player on a team at a certain
position:
package com.tamingthymeleaf.application.team;
import com.tamingthymeleaf.application.user.User;
import io.github.wimdeblauwe.jpearl.AbstractEntity;
import javax.persistence.*;
import javax.validation.constraints.NotNull;
@Entity
public class TeamPlayer extends AbstractEntity<TeamPlayerId> {
@ManyToOne(fetch = FetchType.LAZY)
@NotNull
private Team team; ①
@OneToOne
@NotNull
private User player; ②
@Enumerated(EnumType.STRING)
@NotNull
private PlayerPosition position; ③
protected TeamPlayer() {
}
① A TeamPlayer has a reference to the Team they belong to. This is a @ManyToOne relation since
many players make up a team.
② The player field is the reference to the User object.
③ PlayerPosition is an enum that indicates what position this player will on the team.
package com.tamingthymeleaf.application.team;
/**
* See https://en.wikipedia.org/wiki/Basketball_positions
*/
public enum PlayerPosition {
POINT_GUARD,
SHOOTING_GUARD,
SMALL_FORWARD,
POWER_FORWARD,
CENTER
}
Note that we did not use mvn jpearl:generate here. This is because TeamPlayer exists only in the
context of the Team. In Domain-Driven Design terminology, both Team and TeamPlayer are entities.
Together, they form an aggregate. Team is the aggregate root.
It is common to only create a repository for the aggregate, not separate repositories for each entity in
the aggregate. For that reason, we will not create a separate TeamPlayerRepository, but will
expand the TeamRepository.
package com.tamingthymeleaf.application.team;
TeamPlayerId nextPlayerId(); ①
}
com.tamingthymeleaf.application.team.TeamRepositoryImpl
@Override
public TeamPlayerId nextPlayerId() {
return new TeamPlayerId(generator.getNextUniqueId());
}
src/main/resources/db/migration/1.1__add-team.sql
⑤ Foreign key constraints between the team player and the team, and the team player and the user.
Let’s make sure this all works fine by adding a test on TeamRepositoryTest:
com.tamingthymeleaf.application.team.TeamRepositoryTest
@Test
void testSaveTeamWithPlayers() {
User coach = userRepository.save(Users.createUser(new UserName
("Coach", "1")));
User player1 = userRepository.save(Users.createUser(new
UserName("Player", "1")));
User player2 = userRepository.save(Users.createUser(new
UserName("Player", "2")));
User player3 = userRepository.save(Users.createUser(new
UserName("Player", "3")));
TeamId id = repository.nextId();
Team team = new Team(id, "Initiates", coach);
team.addPlayer(new TeamPlayer(repository.nextPlayerId(),
player1, PlayerPosition.POINT_GUARD));
team.addPlayer(new TeamPlayer(repository.nextPlayerId(),
player2, PlayerPosition.SHOOTING_GUARD));
team.addPlayer(new TeamPlayer(repository.nextPlayerId(),
player3, PlayerPosition.CENTER));
repository.save(team);
entityManager.flush();
entityManager.clear();
assertThat(repository.findById(id))
.hasValueSatisfying(team1 -> {
assertThat(team1.getId()).isEqualTo(id);
assertThat(team1.getCoach().getId()).isEqualTo(
coach.getId());
assertThat(team1.getPlayers()).hasSize(3);
});
}
The form that edits a Team has a backing form object CreateTeamFormData and
EditTeamFormData. To allow adding/editing the users on that team, we will need to expand those
objects.
We start by creating TeamPlayerFormData which contains the information of a single player and his
position on the team:
package com.tamingthymeleaf.application.team.web;
import com.tamingthymeleaf.application.team.PlayerPosition;
import com.tamingthymeleaf.application.team.TeamPlayer;
import com.tamingthymeleaf.application.user.UserId;
import javax.validation.constraints.NotNull;
return result;
}
}
We can now use this in CreateTeamFormData to model the information of the players in the HTML
form:
package com.tamingthymeleaf.application.team.web;
import com.tamingthymeleaf.application.user.UserId;
import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
@NotNull
@Size(min = 1)
@Valid
private TeamPlayerFormData[] players; ①
public CreateTeamFormData() {
this.players = new TeamPlayerFormData[]{new
TeamPlayerFormData()}; ②
}
① Use an array of TeamPlayerFormData objects to store the information that will be edited in the
HTML form.
The @NotNull and @Size(min = 1) annotations ensure that there is always at least 1 player in a
team.
The @Valid annotation ensures that the validation annotations on TeamPlayerFormData itself
are also validated.
② We need to ensure the players property has a valid value because Spring MVC/Thymeleaf will
bind to that.
With our form data updated, we can now turn our attention to teams/edit.html. To get started we
iterate over all known players and output a fragment that allows editing the player information for
each player:
src/main/resources/templates/teams/edit.html
<h3>Players</h3>
<div class="col-span-6 ml-4">
<div id="teamplayer-forms"> ①
<th:block th:each="player, iter : ${team.players}"> ②
<div th:replace="teams/edit-teamplayer-fragment ::
teamplayer-form(index=${iter.index})"></div> ③
</th:block>
</div>
<div class="mt-4">
<a href="#"
class="py-2 px-4 border border-gray-300 rounded-md text-sm
font-medium text-gray-700 hover:text-gray-500 focus:outline-none
focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50
active:text-gray-800"
id="add-extra-teamplayer-form-button"
th:text="#{team.player.add.extra}"
></a> ④
</div>
</div>
① The teamplayer-forms <div> contains all forms that edit the players on the team.
src/main/resources/templates/teams/edit-teamplayer-fragment.html
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
lang="en">
<div th:fragment="teamplayer-form"
class="col-span-6 flex items-stretch"
th:id="${'teamplayer-form-section-' + __${index}__}"> ①
<div class="grid grid-cols-1 row-gap-6 col-gap-4 sm:grid-cols-6">
<div class="sm:col-span-2">
<div class="mt-1 rounded-md shadow-sm">
<select class="max-w-lg block focus:ring-green-500
focus:border-green-500 w-full shadow-sm sm:max-w-xs sm:text-sm border-
gray-300 rounded-md"
th:field="*{players[__${index}__].playerId}"> ②
<option th:each="user : ${users}"
th:text="${user.userName.fullName}"
th:value="${user.id.asString()}">
</select>
</div>
</div>
<div class="sm:col-span-2">
<div class="mt-1 rounded-md shadow-sm">
<select class="max-w-lg block focus:ring-green-500
focus:border-green-500 w-full shadow-sm sm:max-w-xs sm:text-sm border-
gray-300 rounded-md"
th:field="*{players[__${index}__].position}"> ③
<option th:each="position : ${positions}"
th:text="#{'PlayerPosition.' + ${position}}"
th:value="${position}">
</select>
</div>
</div>
<div class="ml-1 sm:col-span-2 flex items-center text-green-600
hover:text-green-900">
① Set the id to a unique name using the index parameter that should be passed to the template.
② Bind the <select> to the playerId property of the current player (using again the index
parameter).
③ Bind the <select> to the position property of the current player (Both playerId and this
position refer to the TeamPlayerFormData class on the Java side).
④ Add a remove button to remove the player again from the team. This does not do anything yet.
team.player.add.extra=Add
team.player.remove=Remove
PlayerPosition.POINT_GUARD=Point Guard
PlayerPosition.SHOOTING_GUARD=Shooting Guard
PlayerPosition.SMALL_FORWARD=Small Forward
PlayerPosition.POWER_FORWARD=Power Forward
PlayerPosition.CENTER=Center
Finally, we also need to update TeamController to add the list of possible PlayerPosition values:
@GetMapping("/create")
@Secured("ROLE_ADMIN")
public String createTeamForm(Model model) {
model.addAttribute("team", new CreateTeamFormData());
model.addAttribute("users", userService.getAllUsersNameAndId());
model.addAttribute("positions", PlayerPosition.values()); ①
return "teams/edit";
}
If we now test the code, the browser should look similar to this when adding a team:
The code will also work for editing a team as this only needs the server-side Thymeleaf rendering we
already implemented:
However, as we can’t add players yet through the UI currently, we need to generate them when
seeding the database.
com.tamingthymeleaf.application.DatabaseInitializer
Streams.forEachPair(generatedUsers.stream().limit(TEAM_NAMES.length),
Arrays.stream(TEAM_NAMES),
(user, teamName) -> {
System.out.println(user);
Team team = teamService.createTeam(teamName,
user);
team = teamService.addPlayer(team.getId(), team
.getVersion(),
randomUser(
generatedUsers), PlayerPosition.SMALL_FORWARD); ①
team = teamService.addPlayer(team.getId(), team
.getVersion(),
randomUser(
generatedUsers), PlayerPosition.SHOOTING_GUARD);
teamService.addPlayer(team.getId(), team
.getVersion(),
randomUser(
generatedUsers), PlayerPosition.CENTER);
});
com.tamingthymeleaf.application.team.TeamService
com.tamingthymeleaf.application.team.TeamServiceImpl
@Override
public Team addPlayer(TeamId teamId, long version, UserId userId,
PlayerPosition position) {
Team team = getTeam(teamId)
.orElseThrow(() -> new TeamNotFoundException(teamId));
if (team.getVersion() != version) {
throw new ObjectOptimisticLockingFailureException(User.
class, team.getId().asString());
}
team.addPlayer(new TeamPlayer(repository.nextPlayerId(),
getUser(userId),
position));
return team;
}
We also need to edit the EditTeamFormData to take the players into account when converting from
the entity Team to the EditTeamFormData form backing object:
com.tamingthymeleaf.application.team.web.EditTeamFormData
return result;
}
com.tamingthymeleaf.application.team.web.TeamController
@GetMapping("/{id}")
public String editTeamForm(@PathVariable("id") TeamId teamId,
Model model) {
Team team = service.getTeamWithPlayers(teamId) ①
.orElseThrow(() -> new TeamNotFoundException
(teamId));
model.addAttribute("team", EditTeamFormData.fromTeam(team));
model.addAttribute("users", userService.getAllUsersNameAndId());
model.addAttribute("positions", PlayerPosition.values()); ②
model.addAttribute("editMode", EditMode.UPDATE);
return "teams/edit";
}
① Because the @OneToMany mapping of players on the Team entity is lazy, we need to create a
dedicated TeamService method that returns the Team with all the players.
To avoid the problem, we add an extra method in TeamRepository to retrieve the team with the
linked players:
package com.tamingthymeleaf.application.team;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
@Transactional(readOnly = true)
public interface TeamRepository extends CrudRepository<Team, TeamId>,
TeamRepositoryCustom {
@Query("SELECT new
com.tamingthymeleaf.application.team.TeamSummary(t.id, t.name,
t.coach.id, t.coach.userName) FROM Team t")
Page<TeamSummary> findAllSummary(Pageable pageable);
① Use JOIN FETCH to retrieve the team with the linked players in a single SQL statement.
Now run the application again with the init-db profile so the teams are generated with players in
them. Editing a team should show the names of the players and their position in the team.
@PostMapping("/{id}")
@Secured("ROLE_ADMIN")
public String doEditTeam(@PathVariable("id") TeamId teamId,
@Valid @ModelAttribute("team")
EditTeamFormData formData,
BindingResult bindingResult,
Model model) {
if (bindingResult.hasErrors()) {
model.addAttribute("editMode", EditMode.UPDATE);
model.addAttribute("users", userService.
getAllUsersNameAndId());
model.addAttribute("positions", PlayerPosition.values());
return "teams/edit";
}
return "redirect:/teams";
}
We could expand the editTeam method with an extra parameter, but it will be better to use a
Parameters object.
package com.tamingthymeleaf.application.team;
import com.tamingthymeleaf.application.user.UserId;
import javax.validation.constraints.NotNull;
package com.tamingthymeleaf.application.team;
import com.tamingthymeleaf.application.user.UserId;
import java.util.Set;
this.name = name;
this.coachId = coachId;
this.players = players;
}
And EditTeamParameters:
package com.tamingthymeleaf.application.team;
import com.tamingthymeleaf.application.user.UserId;
import java.util.Set;
@Override
public Team editTeam(TeamId teamId, EditTeamParameters parameters) {
Team team = getTeam(teamId)
.orElseThrow(() -> new TeamNotFoundException(teamId));
if (team.getVersion() != parameters.getVersion()) {
throw new ObjectOptimisticLockingFailureException(User.
class, team.getId().asString());
}
team.setName(parameters.getName());
team.setCoach(getUser(parameters.getCoachId()));
team.setPlayers(parameters.getPlayers().stream()
.map(teamPlayerParameters -> new
TeamPlayer(repository.nextPlayerId(), getUser(teamPlayerParameters
.getPlayerId()), teamPlayerParameters.getPosition()))
.collect(Collectors.toSet()));
return team;
}
...
With our domain layer refactored, we can proceed to refactor the web layer. CreateTeamFormData
now gets a method to convert to CreateTeamParameters:
@Nonnull
protected Set<TeamPlayerParameters> getTeamPlayerParameters() {
return Arrays.stream(players)
.map(teamPlayerFormData -> new
TeamPlayerParameters(teamPlayerFormData.getPlayerId(),
teamPlayerFormData.getPosition()))
.collect(Collectors.toSet());
}
}
@Override
public EditTeamParameters toParameters() {
return new EditTeamParameters(version,
getName(),
getCoachId(),
getTeamPlayerParameters());
}
}
com.tamingthymeleaf.application.team.web.TeamController
@PostMapping("/create")
@Secured("ROLE_ADMIN")
service.createTeam(formData.toParameters());
return "redirect:/teams";
}
@PostMapping("/{id}")
@Secured("ROLE_ADMIN")
public String doEditTeam(@PathVariable("id") TeamId teamId,
@Valid @ModelAttribute("team")
EditTeamFormData formData,
BindingResult bindingResult,
Model model) {
if (bindingResult.hasErrors()) {
model.addAttribute("editMode", EditMode.UPDATE);
model.addAttribute("users", userService.
getAllUsersNameAndId());
model.addAttribute("positions", PlayerPosition.values());
return "teams/edit";
}
service.editTeam(teamId,
formData.toParameters());
return "redirect:/teams";
}
Run the application again. You should be able to edit the user at a position in a team, or edit the
position of a user.
<div th:fragment="teamplayer-form"
class="col-span-6 flex items-stretch"
th:id="${'teamplayer-form-section-' + __${index}__}"
th:object="${__${teamObjectName}__}"> ①
We need to add an extra parameter teamObjectName that will tell the fragment the name of the
TeamFormData binding object in the Model. When rendering "normally", this will be the name of the
th:object we already have on the full form in teams/edit.html. When Thymeleaf needs to render
the fragment alone, we will need to pass in a dummy object there, otherwise, Thymeleaf will not be
able to render the fragment as there is no th:object to refer to for statements like
th:field="*{players[${index}].playerId}
We need to update teams/edit.html to use that extra parameter in the fragment. While we are
changing that, we will also do 2 other changes:
src/main/resources/templates/teams/edit.html
<h3>Players</h3>
<div class="col-span-6 ml-4">
<div id="teamplayer-forms"
th:data-teamplayers-count="${team.players.length}"> ①
<th:block th:each="player, iter : ${team.players}">
<div th:replace="teams/edit-teamplayer-fragment ::
teamplayer-form(index=${iter.index}, teamObjectName='team')"></div> ②
</th:block>
</div>
<div class="mt-4">
<a href="#"
class="py-2 px-4 border border-gray-300 rounded-md text-sm
font-medium text-gray-700 hover:text-gray-500 focus:outline-none
focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50
active:text-gray-800"
id="add-extra-teamplayer-form-button"
th:text="#{team.player.add.extra}"
@click="addExtraTeamPlayerForm()"
></a> ③
</div>
</div>
① Add an attribute data-teamplayers-count that will help the JavaScript code to know what index
to use next.
Add a method to TeamController that returns the fragment. Note how we can use the same ::
syntax as we do when referencing a Thymeleaf fragment normally from another template:
com.tamingthymeleaf.application.team.web.TeamController
@GetMapping("/edit-teamplayer-fragment")
@Secured("ROLE_ADMIN")
public String getEditTeamPlayerFragment(Model model,
@RequestParam("index") int
index) { ①
model.addAttribute("index", index); ②
model.addAttribute("users", userService.getAllUsersNameAndId());
③
model.addAttribute("positions", PlayerPosition.values()); ④
model.addAttribute("teamObjectName", "dummyTeam"); ⑤
model.addAttribute("dummyTeam", new
DummyTeamForTeamPlayerFragment()); ⑥
return "teams/edit-teamplayer-fragment :: teamplayer-form"; ⑦
}
① The fragment has an index parameter that we need to fill in. We will receive this from the
JavaScript AJAX call as a query parameter.
② Set the value of the query parameter as a model attribute. This will allow Thymeleaf to use it when
rendering the fragment.
③ Pass in the users since the fragment needs that to render the dropdown with all the user names.
⑤ Set the teamObjectName to dummyTeam (This can be really any value you want, as long as it
matches with the next line)
⑥ Add our DummyTeamForTeamPlayerFragment instance so Thymeleaf can do its binding stuff.
Note that this is only done to render the HTML. We will not actually bind on
The last piece of the puzzle is the the AJAX call implementation in JavaScript to add a new row
dynamically in teams/edit.html:
src/main/resources/templates/teams/edit.html
<th:block layout:fragment="page-scripts">
<script>
function addExtraTeamPlayerForm() { ①
const teamPlayerForms = document.getElementById('teamplayer-
forms'); ②
const count = teamPlayerForms.getAttribute('data-
teamplayers-count'); ③
fetch(`/teams/edit-teamplayer-fragment?index=${count}`) ④
.then(response => response.text()) ⑤
.then(fragment => {
teamPlayerForms.appendChild(htmlToElement
(fragment)); ⑥
teamPlayerForms.setAttribute('data-teamplayers-
count', parseInt(count) + 1); ⑦
});
}
function htmlToElement(html) {
const template = document.createElement('template');
html = html.trim(); // Never return a text node of
whitespace as the result
template.innerHTML = html;
return template.content.firstChild;
}
</script>
</th:block>
① Declare a function to add an extra row. This will be bound to the 'Add' button using
@click="addExtraTeamPlayerForm()"
② Get the <div> with the teamplayer-forms id since we will add a new row there.
③ Get the current count which is added as an attribute when Thymeleaf renders the page.
④ Do the AJAX call using the Fetch API. Pass in the current count as the index query parameter.
Pressing the 'Add' button now gives us a new row each time:
src/main/resources/templates/teams/edit-teamplayer-fragment.html
</a>
</div>
• th:attr: to add the current index parameter of the fragment so we can read this from
JavaScript and know what row we should delete.
• @click: trigger the actual removal of the row when the link is clicked.
$el.dataset.formindex refers to the data-formindex attribute of this <a> tag that we
add using th:attr.
src/main/resources/templates/teams/edit.html
function removeTeamPlayerForm(formIndex) {
const teamplayerForm = document.getElementById('teamplayer-form-
section-' + formIndex);
teamplayerForm.parentElement.removeChild(teamplayerForm);
}
Try this out, you should see that rows can now be removed as well.
There is however one situation that is not working properly and that is removing a row "in the
middle". If you add a few rows and then remove one of the earlier rows and try to save, you will get a
validation error.
This is because the HTML looks something like this with 3 rows for example:
When the form is bound back the TeamFormData object when saving, Spring will insert an empty
TeamPlayerFormData object at index 1. Becuase the properties of TeamPlayerFormData are
@NotNull, the validation fails.
We can avoid this by removing those empty TeamPlayerFormData objects before the actual
validation runs.
com.tamingthymeleaf.application.team.web.TeamController
@Override
public boolean supports(@Nonnull Class<?> clazz) {
return validator.supports(clazz);
}
@Override
public void validate(@Nonnull Object target, @Nonnull Errors
errors) {
if (target instanceof CreateTeamFormData) { ③
CreateTeamFormData formData = (CreateTeamFormData)
target;
formData.removeEmptyTeamPlayerForms(); ④
}
validator.validate(target, errors);
}
}
③ Check if the object we are validating is our CreateTeamFormData (or the EditTeamFormData
subclass).
④ Tell the form data object to remove the empty forms.
To have the framework use this inner class of TeamController, we need to register it to the
WebDataBinder:
com.tamingthymeleaf.application.team.web.TeamController
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.setValidator(new RemoveUnusedTeamPlayersValidator(binder
.getValidator()));
}
com.tamingthymeleaf.application.team.web.CreateTeamFormData
Try it out, even deleting rows in between other rows should work fine now.
This concludes this section on dynamically adding and removing rows in a form using a bit of
JavaScript to avoid page refreshes. I hope this has given you valuable insights on how this can be
implemented if you need to do this on your own projects.
However, there might be cases where you want to directly bind to a richer object. This can be done by
implementing a custom property editor or a custom formatter.
@NotBlank
@Pattern(regexp = "[0-9.\\-() x/+]+", groups = ValidationGroupOne
.class)
private String phoneNumber;
If we want to use the PhoneNumber class instead of String, we need to create a custom property
editor:
package com.tamingthymeleaf.application.user;
import org.apache.commons.lang3.StringUtils;
import java.beans.PropertyEditorSupport;
@Override
public void setAsText(String text) throws IllegalArgumentException {
②
if (StringUtils.isNotBlank(text)) {
this.setValue(new PhoneNumber(text)); ③
} else {
this.setValue(null); ④
}
}
@Override
public String getAsText() { ⑤
PhoneNumber value = (PhoneNumber) getValue(); ⑥
return value != null ? value.asString() : ""; ⑦
}
① Extend from PropertyEditorSupport (Which is a Java SDK class, not a Spring class)
② Override the setAsText(String text) method. This method must implement the conversion
from String to the custom type (PhoneNumber in our example).
③ If the text is not blank, create a PhoneNumber instance and set it as the value.
⑤ Override the getAsText() method to implement the conversion from the custom type to
String.
⑥ Get the value property and cast it to PhoneNumber. We are sure this will be a PhoneNumber
instance given the implementation of setAsText.
⑦ Return either an empty String if the value is null, or return a properly formatted String
representation of the phone number.
This editor is not active by default, you need register it on the controller where you want to use it:
com.tamingthymeleaf.application.user.web.UserController
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.registerCustomEditor(PhoneNumber.class, new
PhoneNumberPropertyEditor());
}
If you want to enable it for all controllers, add it to a @ControllerAdvice class like
we did for StringTrimmerEditor.
com.tamingthymeleaf.application.user.web.AbstractUserFormData
@NotNull
private PhoneNumber phoneNumber;
If you compare this to what we had before, you will note that the @NotBlank and @Pattern
annotations are now replaced with @NotNull. This is because @NotBlank and @Pattern only work
for String values, not PhoneNumber values. To ensure the phone number is still a valid format, we
have to move the validation logic inside PhoneNumber:
protected PhoneNumber() {
}
...
}
One might argue that it should have been there already in the first place, as it is important for a value
object to protect its invariants.
Because of the change in validation annotations, we also need to change the messages to show to the
user.
The NotNull is triggered when the input is empty because we set the value in the editor to null and
we have the @NotNull annotation in AbstractUserFormData.
The typeMismatch is triggered when the Assert.isTrue() line in the constructor of PhoneNumber
fails.
package com.tamingthymeleaf.application.user;
import org.springframework.format.Formatter;
import javax.annotation.Nonnull;
import java.text.ParseException;
import java.util.Locale;
@Nonnull
@Override
public String print(@Nonnull PhoneNumber object, @Nonnull Locale
locale) {
return object.asString(); ③
}
}
② Use the parse method to convert from the user string to a PhoneNumber object.
Note that the Spring framework guarantees no null values are passed to the Formatter, so this
simplifies the implementation a bit.
We don’t configure formatters on a controller-base, but globally for the application via the
WebMvcConfigurer:
package com.tamingthymeleaf.application.infrastructure.web;
import com.tamingthymeleaf.application.user.PhoneNumberFormatter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.LocaleResolver;
import
org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import
org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.i18n.CookieLocaleResolver;
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {
@Bean
@Bean
public LocaleChangeInterceptor localeInterceptor() {
LocaleChangeInterceptor localeInterceptor = new
LocaleChangeInterceptor();
localeInterceptor.setParamName("lang");
return localeInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localeInterceptor());
}
@Override
public void addFormatters(FormatterRegistry registry) { ①
registry.addFormatter(new PhoneNumberFormatter()); ②
}
}
To test this, remove PhoneNumberPropertyEditor and the @InitBinder annotated method from
UserController that uses it. The application itself will work exactly the same as it did with the
property editor.
PropertyEditor or Formatter?
When should you use PropertyEditor and when Formatter is a question you
might have after reading this chapter.
Roughly, you can state that PropertyEditor is best used if you need to register
You can read more about formatters in the Spring Field Formatting chapter of the
Spring reference documentation.
There are many date picker components freely available. We will implement one of them here to
show how it can be done using Thymeleaf.
We start by adding the JavaScript and CSS of the component to the <head> section of our
layout.html base template:
src/main/resources/templates/layout/layout.html
<head>
<meta charset="UTF-8">
<title layout:title-pattern="$LAYOUT_TITLE - $CONTENT_TITLE">Taming
Thymeleaf</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-
scale=1"/>
<script type="module"
src="https://cdn.jsdelivr.net/npm/@duetds/date-
[email protected]/dist/duet/duet.esm.js"></script>
<script nomodule src="https://cdn.jsdelivr.net/npm/@duetds/date-
[email protected]/dist/duet/duet.js"></script>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@duetds/date-
[email protected]/dist/duet/themes/default.css"/>
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
<link rel="stylesheet" th:href="@{/css/application.css}">
</head>
src/main/resources/templates/fragments/forms.html
th:value="*{__${fieldName}__}"
th:name="${fieldName}"
class="w-full sm:text-sm sm:leading-5"
th:classappend="${#fields.hasErrors('__${fieldName}__')?'error-
border':''}">
</duet-date-picker>
<div th:if="${#fields.hasErrors('__${fieldName}__')}"
class="absolute inset-y-0 right-0 pr-14 flex items-center
pointer-events-none">
<svg class="h-5 w-5 text-red-500" fill="currentColor"
viewBox="0 0 20 20">
<path fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0
11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"/>
</svg>
</div>
</div>
<p th:if="${#fields.hasErrors('__${fieldName}__')}"
th:text="${#strings.listJoin(#fields.errors('__${fieldName}__'),
', ')}"
class="mt-2 text-sm text-red-600" th:id="'__${fieldName}__'+ '-
error'">Field validation error message(s).</p>
</div>
This fragment is heavily based upon the textinput fragment we already had with the following
changes:
• Because the component is not a simple <input> but a collection of <div> tags with an <input>
somewhere, we need to follow the documentation of the component and set the name and the
value attributes on <duet-date-picker>. We do this by using:
◦ th:value="*{__${fieldName}__}"
◦ th:name="${fieldName}"
• Set an error-border CSS class when there is validation error so the component can be styled the
same way as our other <input> elements.
• Change the right padding for the validation error icon from pr-3 to pr-14. Otherwise, it would be
below the icon to open the date picker.
Figure 96. No value selected shows error message and error icon
To make all date pickers in the application consistent, we can add the JavaScript to layout.html.
src/main/resources/templates/layout/layout.html
<head>
...
<script
src="https://cdn.jsdelivr.net/npm/[email protected]/build/global/luxon.min.js
"></script>
...
</head>
src/main/resources/templates/layout/layout.html
<script>
const picker = document.querySelector('duet-date-picker');
if(picker) {
picker.dateAdapter = {
parse(value = '', createDate) { ①
try {
let fromFormat = luxon.DateTime.fromFormat(value,
'yyyy-LL-dd');
if (fromFormat.isValid) {
return createDate(fromFormat.year, fromFormat
.month, fromFormat.day);
} else {
console.log('fromFormat not valid');
}
} catch (e) {
console.log(e);
}
},
format(date) { ②
var DateTime = luxon.DateTime;
return DateTime.fromJSDate(date) ③
.setLocale('[[${#strings.replace(#locale, '_', '
-')}]]') ④
.toFormat('d LLLL yyyy'); ⑤
},
};
}
</script>
① The parse function is used when a user manually types in the input field of the date picker to
parse whatever the user is typing into a date. The code of this method allows to type in ISO-8601
format.
② The format function is the function that is called to format the date in the input field.
③ Create a Luxon DateTime object from the passed in JavaScript date object.
④ Pass the current locale to the Luxon object. Thymeleaf has the built-in #locale variable out-of-
the-box. However, this is represented as en_US for example, while Luxon expects en-US. For that
reason, we need to use the #strings.replace() function.
Feel free to contact me at [email protected] or via Twitter if you have any remarks on the
book.
If you want some further reading on Thymeleaf, I found these resources to be highly valuable:
• The official Thymeleaf documentation, especially the appendix on Expression Utility Objects shows
some very interesting helper functions that Thymeleaf has built-in.
• Spring Web MVC documentation
• My personal blog where I continue to write about Thymeleaf-related topics.
• If you are stuck on a particular problem, Stack Overflow is the best place to ask your question. Be
sure to tag it with the Thymeleaf tag.
• The Cypress docs if you want to know more details about writing tests with Cypress.
• The Tailwind CSS documentation is very nice if you want to learn about about this utility-first CSS
framework.
2.0.2
Oct 19, 2022
2.0.1
Dec 31, 2021
2.0.0
Dec 23, 2021
• Update for Java 17, Spring Boot 2.6 and Tailwind CSS 3
• Also updated to Thymeleaf Layout Dialect 3.0.0, Alpine 3.7.0, Testcontainers 1.16.2, Guava 31.0.1
and Cypress 9.1.0
1.1.1
Apr 13, 2021
• Fix purging for production. To do this properly with Tailwind CSS 2, we need to use the purge
option in tailwind.config.js. Updated chapter 4 to reflect this.
1.1.0
Feb 16, 2021
1.0.1
Dec 26, 2020
• Chapter 14.4.1: Add code to show that the table header also needs to be wrapped with
<th:block sec:authorize="hasRole('ADMIN')">
• Chapter 15.1: Fix typo
• Chapter 15.1.3: Add note that tests require an id on the HTML elements so the tests can easily
1.0.0
Dec 5, 2020
• Initial release