- Thymeleaf Overview
- Basic Setup
- CSS Integration
- Spring MVC Architecture
- Form Handling
- HTTP Methods
- Form Data Binding
- Form Elements
- Form Validation
- Complete Examples
- Java templating engine for generating HTML views
- General-purpose templating engine (not limited to web apps)
- Separate project from Spring (www.thymeleaf.org)
- Processes on the server-side
- Can access Java code, objects, and Spring beans
Browser Request → Spring Controller → Thymeleaf Template (Server) → HTML Response → Browser
File: pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>Result: Spring Boot auto-configures Thymeleaf templates.
File: DemoController.java
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class DemoController {
@GetMapping("/")
public String sayHello(Model theModel) {
theModel.addAttribute("theDate", java.time.LocalDateTime.now());
return "helloworld";
}
}Key Points:
- Returns view name:
"helloworld" - Spring looks for:
src/main/resources/templates/helloworld.html
File: src/main/resources/templates/helloworld.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Thymeleaf Demo</title>
</head>
<body>
<p th:text="'Time on the server is ' + ${theDate}" />
</body>
</html>Output:
Time on the server is 2025-03-30T11:27:52.297247
Template Structure:
xmlns:th="http://www.thymeleaf.org"- Enable Thymeleaf expressions${theDate}- Access model attributeth:text- Set text content dynamically
src/main/resources/templates/
├── helloworld.html
├── customer-form.html
└── student-form.html
Rules:
- Templates must be in
templates/directory - Use
.htmlextension for web apps
Directory Structure:
src/main/resources/static/
└── css/
└── demo.css
File: demo.css
.funny {
font-style: italic;
color: green;
}Note: You can create custom subdirectories:
static/css/static/images/static/js/
File: helloworld.html
<head>
<title>Thymeleaf Demo</title>
<!-- Reference CSS file -->
<link rel="stylesheet" th:href="@{/css/demo.css}" />
</head>Key Syntax:
@{...}- Reference application context path (app root)- Path is relative to
static/directory
<body>
<p th:text="'Time on the server is ' + ${theDate}" class="funny" />
</body>Spring Boot searches directories in this order:
/src/main/resources/
1. /META-INF/resources
2. /resources
3. /static
4. /public
- Download Bootstrap files
- Place in
/static/css/directory
<head>
<link rel="stylesheet" th:href="@{/css/bootstrap.min.css}" />
</head><head>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" />
</head>| Component | Description | Created By |
|---|---|---|
| Web Pages | UI layout | Developer |
| Beans | Controllers, Services | Developer |
| Configuration | XML, Annotations, Java | Developer |
DispatcherServlet (Front Controller):
- Part of Spring Framework (pre-built)
- Routes all requests
- Coordinates between components
Developer Creates:
- Controllers (yellow) - Business logic
- Model objects (orange) - Data containers
- View templates (dark green) - Display pages
Browser → DispatcherServlet → Controller → Model → View Template → HTML Response
@Controller
public class MyController {
@GetMapping("/example")
public String handleRequest(Model model) {
// 1. Handle the request
// 2. Store/retrieve data (DB, web service)
// 3. Place data in model
// 4. Return view name
model.addAttribute("data", someData);
return "view-name";
}
}- Container for application data
- Can hold any Java object/collection
- Shared between Controller and View
- Data retrieved from backend systems (DB, web services)
Supported by Spring MVC:
- Thymeleaf (Recommended)
- Groovy
- Velocity
- Freemarker
More info: www.luv2code.com/spring-mvc-views
User → Form Page → Submit → Controller → Process → Confirmation Page
Browser Request
↓
HelloWorldController (@RequestMapping("/showForm"))
↓
helloworld-form.html (Display Form)
↓
User Submits Form
↓
HelloWorldController (@RequestMapping("/processForm"))
↓
helloworld.html (Confirmation Page)
File: HelloWorldController.java
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
public class HelloWorldController {
// Show initial HTML form
@RequestMapping("/showForm")
public String showForm() {
return "helloworld-form";
}
// Process HTML form
@RequestMapping("/processForm")
public String processForm() {
return "helloworld";
}
}- Create Controller class
- Show HTML form
- Create controller method
- Create view page for form
- Process HTML form
- Create controller method to process
- Create confirmation view page
The Model is a container for application data accessible in views.
Controller Method:
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import jakarta.servlet.http.HttpServletRequest;
@Controller
public class HelloWorldController {
@RequestMapping("/processFormVersionTwo")
public String letsShoutDude(HttpServletRequest request, Model model) {
// Read request parameter from HTML form
String theName = request.getParameter("studentName");
// Convert to uppercase
theName = theName.toUpperCase();
// Create message
String result = "Yo! " + theName;
// Add message to model
model.addAttribute("message", result);
return "helloworld";
}
}File: helloworld.html
<html xmlns:th="http://www.thymeleaf.org">
<body>
Hello World of Spring!
The message: <span th:text="${message}" />
</body>
</html>@RequestMapping("/example")
public String example(Model model) {
// Get data
String result = "Hello";
List<Student> theStudentList = studentService.getStudents();
ShoppingCart theShoppingCart = cartService.getCart();
// Add data to model
model.addAttribute("message", result);
model.addAttribute("students", theStudentList);
model.addAttribute("shoppingCart", theShoppingCart);
return "view-name";
}@RequestMapping("/processFormVersionTwo")
public String letsShoutDude(HttpServletRequest request, Model model) {
// Manually read parameter
String theName = request.getParameter("studentName");
// Process...
model.addAttribute("message", theName.toUpperCase());
return "helloworld";
}import org.springframework.web.bind.annotation.RequestParam;
@RequestMapping("/processFormVersionTwo")
public String letsShoutDude(
@RequestParam("studentName") String theName,
Model model) {
// Spring automatically binds parameter to variable
model.addAttribute("message", theName.toUpperCase());
return "helloworld";
}How it works:
- Spring reads
studentNameparameter from request - Binds it to
theNamevariable - Cleaner and more readable code
| Method | Description | Use Case |
|---|---|---|
| GET | Requests data from resource | Read operations, bookmarkable URLs |
| POST | Submits data to resource | Create/update, sending sensitive data |
| Feature | GET | POST |
|---|---|---|
| Data Location | URL query string | Request body |
| Bookmarkable | ✅ Yes | ❌ No |
| Data Length | Limited (~2000 chars) | No limit |
| Security | Visible in URL | Hidden in body |
| Binary Data | ❌ No | ✅ Yes |
| Debugging | Easy (visible in URL) | Harder |
HTML Form:
<form th:action="@{/processForm}" method="GET">
<input type="text" name="studentName" />
<input type="submit" value="Submit" />
</form>URL Format:
http://example.com/processForm?studentName=John&email=john@example.com
HTML Form:
<form th:action="@{/processForm}" method="POST">
<input type="text" name="studentName" />
<input type="submit" value="Submit" />
</form>Request Structure:
POST /processForm HTTP/1.1
Headers...
Body:
studentName=John&email=john@example.com
@RequestMapping("/processForm")
public String processForm(...) {
// Handles GET, POST, PUT, DELETE, etc.
return "view";
}Long Form:
@RequestMapping(path="/processForm", method=RequestMethod.GET)
public String processForm(...) {
// Only handles GET
return "view";
}Shortcut Annotations (Recommended):
@GetMapping("/processForm")
public String processForm(...) {
// Only handles GET
return "view";
}
@PostMapping("/processForm")
public String processForm(...) {
// Only handles POST
return "view";
}Other Shortcuts:
@PutMapping@DeleteMapping@PatchMapping
Spring MVC forms use data binding to automatically:
- Set data from Java objects to form fields (display)
- Retrieve data from form fields to Java objects (submit)
1. Controller adds Student object to Model
2. Form displays with Student data (via getters)
3. User submits form
4. Spring creates Student object and populates fields (via setters)
5. Controller receives populated Student object
6. Confirmation page displays Student data
File: Student.java
public class Student {
private String firstName;
private String lastName;
public Student() {
}
// Getters and Setters
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
}File: StudentController.java
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class StudentController {
@GetMapping("/showStudentForm")
public String showForm(Model theModel) {
// Create model attribute to hold form data
theModel.addAttribute("student", new Student());
return "student-form";
}
}File: student-form.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Student Form</title>
</head>
<body>
<h3>Student Registration Form</h3>
<form th:action="@{/processStudentForm}"
th:object="${student}"
method="POST">
First name: <input type="text" th:field="*{firstName}" />
<br><br>
Last name: <input type="text" th:field="*{lastName}" />
<br><br>
<input type="submit" value="Submit" />
</form>
</body>
</html>Key Attributes:
th:object="${student}"- Bind form to model attributeth:field="*{firstName}"- Bind input to property*{...}- Shortcut for${student.firstName}
When Form Loads:
// Spring calls getters to populate fields
student.getFirstName()
student.getLastName()When Form Submits:
// Spring creates Student instance and calls setters
student.setFirstName(...)
student.setLastName(...)File: StudentController.java
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
@Controller
public class StudentController {
@PostMapping("/processStudentForm")
public String processForm(@ModelAttribute("student") Student theStudent) {
// Log input data
System.out.println("theStudent: " + theStudent.getFirstName()
+ " " + theStudent.getLastName());
return "student-confirmation";
}
}File: student-confirmation.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Student Confirmation</title>
</head>
<body>
<h3>Student Confirmation</h3>
The student is confirmed:
<span th:text="${student.firstName} + ' ' + ${student.lastName}" />
</body>
</html><select name="country">
<option value="BR">Brazil</option>
<option value="FR">France</option>
<option value="DE">Germany</option>
<option value="IN">India</option>
</select>File: student-form.html
<form th:action="@{/processStudentForm}" th:object="${student}" method="POST">
Country:
<select th:field="*{country}">
<option th:value="'Brazil'">Brazil</option>
<option th:value="'France'">France</option>
<option th:value="'Germany'">Germany</option>
<option th:value="'India'">India</option>
</select>
<input type="submit" value="Submit" />
</form>public class Student {
private String firstName;
private String lastName;
private String country; // New property
// Getters and setters for country
public String getCountry() {
return country;
}
public void setCountry(String country) {
this.country = country;
}
}<body>
Student: <span th:text="${student.firstName} + ' ' + ${student.lastName}" />
<br><br>
Country: <span th:text="${student.country}" />
</body>File: student-form.html
<form th:action="@{/processStudentForm}" th:object="${student}" method="POST">
Favorite Programming Language:
<br>
<input type="radio" th:field="*{favoriteLanguage}" th:value="'Java'" /> Java
<br>
<input type="radio" th:field="*{favoriteLanguage}" th:value="'Python'" /> Python
<br>
<input type="radio" th:field="*{favoriteLanguage}" th:value="'Go'" /> Go
<br>
<input type="radio" th:field="*{favoriteLanguage}" th:value="'JavaScript'" /> JavaScript
<br><br>
<input type="submit" value="Submit" />
</form>Key Points:
- All radio buttons share same
th:field(property name) - Different
th:valuefor each option - Binds to
Student.favoriteLanguageproperty
public class Student {
private String firstName;
private String lastName;
private String favoriteLanguage; // New property
// Getter and setter
public String getFavoriteLanguage() {
return favoriteLanguage;
}
public void setFavoriteLanguage(String favoriteLanguage) {
this.favoriteLanguage = favoriteLanguage;
}
}File: student-form.html
<form th:action="@{/processStudentForm}" th:object="${student}" method="POST">
Favorite Operating Systems:
<br>
<input type="checkbox" th:field="*{favoriteSystems}" th:value="'Linux'" /> Linux
<br>
<input type="checkbox" th:field="*{favoriteSystems}" th:value="'macOS'" /> macOS
<br>
<input type="checkbox" th:field="*{favoriteSystems}" th:value="'Microsoft Windows'" /> Microsoft Windows
<br><br>
<input type="submit" value="Submit" />
</form>public class Student {
private String firstName;
private String lastName;
private String[] favoriteSystems; // Array for multiple selections
// Getter and setter
public String[] getFavoriteSystems() {
return favoriteSystems;
}
public void setFavoriteSystems(String[] favoriteSystems) {
this.favoriteSystems = favoriteSystems;
}
}<body>
Favorite Systems:
<ul>
<li th:each="system : ${student.favoriteSystems}" th:text="${system}" />
</ul>
</body>Java's standard Bean Validation API provides:
- Metadata model for entity validation
- Support in Spring Boot and Thymeleaf
- Built-in validation rules
- Custom validation capabilities
Reference: http://www.beanvalidation.org
| Annotation | Description |
|---|---|
@NotNull |
Must not be null |
@Size |
String length constraints |
@Min |
Minimum numeric value |
@Max |
Maximum numeric value |
@Pattern |
Regex pattern matching |
@Email |
Valid email format |
| Custom | Create your own |
- Create Customer class with validation rules
- Add Controller code to show HTML form
- Develop HTML form with validation support
- Perform validation in Controller
- Create confirmation page
File: Customer.java
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public class Customer {
private String firstName;
@NotNull(message = "is required")
@Size(min=1, message = "is required")
private String lastName = "";
public Customer() {
}
// Getters and Setters
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
}Why both @NotNull and @Size?
@NotNull- Prevents null values@Size(min=1)- Prevents empty strings
File: CustomerController.java
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class CustomerController {
@GetMapping("/")
public String showForm(Model theModel) {
theModel.addAttribute("customer", new Customer());
return "customer-form";
}
}File: customer-form.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Customer Form</title>
<style>
.error {
color: red;
}
</style>
</head>
<body>
<h3>Customer Registration Form</h3>
<form th:action="@{/processForm}" th:object="${customer}" method="POST">
First name: <input type="text" th:field="*{firstName}" />
<br><br>
Last name (*): <input type="text" th:field="*{lastName}" />
<!-- Show error message if present -->
<span th:if="${#fields.hasErrors('lastName')}"
th:errors="*{lastName}"
class="error"></span>
<br><br>
<input type="submit" value="Submit" />
</form>
</body>
</html>Validation Display Logic:
${#fields.hasErrors('lastName')}- Check if field has errorsth:errors="*{lastName}"- Display error messageclass="error"- Apply CSS styling
File: CustomerController.java
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.PostMapping;
import jakarta.validation.Valid;
@Controller
public class CustomerController {
@PostMapping("/processForm")
public String processForm(
@Valid @ModelAttribute("customer") Customer theCustomer,
BindingResult theBindingResult) {
// Check for validation errors
if (theBindingResult.hasErrors()) {
// Return to form if errors exist
return "customer-form";
} else {
// Proceed to confirmation if valid
return "customer-confirmation";
}
}
}Key Points:
@Valid- Tells Spring to perform validationBindingResult- Holds validation results- Must come immediately after
@Validparameter
File: customer-confirmation.html
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Customer Confirmation</title>
</head>
<body>
<h3>Customer Confirmation</h3>
The customer is confirmed:
<span th:text="${customer.firstName} + ' ' + ${customer.lastName}" />
</body>
</html>Previous validation allows whitespace-only input to pass validation.
Example: " " (spaces only) passes @Size(min=1) check ❌
Pre-processor that executes before each web request.
File: CustomerController.java
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.beans.propertyeditors.StringTrimmerEditor;
@Controller
public class CustomerController {
@InitBinder
public void initBinder(WebDataBinder dataBinder) {
StringTrimmerEditor stringTrimmerEditor = new StringTrimmerEditor(true);
dataBinder.registerCustomEditor(String.class, stringTrimmerEditor);
}
// ... other methods
}How it works:
@InitBinderruns before each requestStringTrimmerEditor(true)- Trim leading/trailing whitespacetrueparameter - Convert whitespace-only strings tonull- Registers editor for all
Stringfields
Result: Whitespace-only input now fails @NotNull validation ✅
Use Case: Free passes field accepts only 0-10.
File: Customer.java
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Max;
public class Customer {
private String firstName;
@NotNull(message = "is required")
@Size(min=1, message = "is required")
private String lastName = "";
@Min(value=0, message="must be greater than or equal to zero")
@Max(value=10, message="must be less than or equal to 10")
private int freePasses;
// Getters and setters
public int getFreePasses() {
return freePasses;
}
public void setFreePasses(int freePasses) {
this.freePasses = freePasses;
}
}File: customer-form.html
<form th:action="@{/processForm}" th:object="${customer}" method="POST">
First name: <input type="text" th:field="*{firstName}" />
<br><br>
Last name (*): <input type="text" th:field="*{lastName}" />
<span th:if="${#fields.hasErrors('lastName')}"
th:errors="*{lastName}"
class="error"></span>
<br><br>
Free passes (0-10): <input type="text" th:field="*{freePasses}" />
<span th:if="${#fields.hasErrors('freePasses')}"
th:errors="*{freePasses}"
class="error"></span>
<br><br>
<input type="submit" value="Submit" />
</form><body>
Customer: <span th:text="${customer.firstName} + ' ' + ${customer.lastName}" />
<br><br>
Free passes: <span th:text="${customer.freePasses}" />
</body>Note: Controller validation code remains the same - Spring handles it automatically!
Requirement: Postal code must be exactly 5 alphanumeric characters.
File: Customer.java
import jakarta.validation.constraints.Pattern;
public class Customer {
private String firstName;
@NotNull(message = "is required")
@Size(min=1, message = "is required")
private String lastName = "";
@Pattern(regexp="^[a-zA-Z0-9]{5}", message="only 5 chars/digits")
private String postalCode;
// Getter and setter
public String getPostalCode() {
return postalCode;
}
public void setPostalCode(String postalCode) {
this.postalCode = postalCode;
}
}Regex Breakdown:
^- Start of string[a-zA-Z0-9]- Any letter (upper/lower) or digit{5}- Exactly 5 characters
<form th:action="@{/processForm}" th:object="${customer}" method="POST">
Postal Code: <input type="text" th:field="*{postalCode}" />
<span th:if="${#fields.hasErrors('postalCode')}"
th:errors="*{postalCode}"
class="error"></span>
<br><br>
<input type="submit" value="Submit" />
</form>| Pattern | Description | Example |
|---|---|---|
^[a-zA-Z0-9]{5} |
5 alphanumeric chars | ABC12 |
^\d{5} |
5 digits | 12345 |
^\d{5}(-\d{4})?$ |
US ZIP code | 12345 or 12345-6789 |
^[A-Z]{2}\d{2}[A-Z]{2}$ |
Custom format | AB12CD |
^[a-zA-Z\s]+$ |
Letters and spaces only | John Doe |
Regex Resources:
- Oracle Java Tutorial: https://docs.oracle.com/javase/tutorial/essential/regex/
Use Case: Course code must start with "LUV".
- Create custom validation rule
- a. Create
@CourseCodeannotation - b. Create
CourseCodeConstraintValidator
- a. Create
- Add validation rule to Customer class
- Display error messages on HTML form
- Update confirmation page
File: CourseCode.java
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Constraint(validatedBy = CourseCodeConstraintValidator.class)
@Target({ ElementType.METHOD, ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
public @interface CourseCode {
// Define default course code
public String value() default "LUV";
// Define default error message
public String message() default "must start with LUV";
// Define default groups (required by Bean Validation spec)
public Class<?>[] groups() default {};
// Define default payloads (required by Bean Validation spec)
public Class<? extends Payload>[] payload() default {};
}Annotation Breakdown:
@Constraint(validatedBy = ...)- Links to validator class@Target- Where annotation can be applied (methods, fields)@Retention- Annotation available at runtimevalue()- Default prefix to checkmessage()- Default error messagegroups()andpayload()- Required by Bean Validation spec
File: CourseCodeConstraintValidator.java
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class CourseCodeConstraintValidator
implements ConstraintValidator<CourseCode, String> {
private String coursePrefix;
@Override
public void initialize(CourseCode theCourseCode) {
// Initialize with value from annotation
coursePrefix = theCourseCode.value();
}
@Override
public boolean isValid(String theCode,
ConstraintValidatorContext theConstraintValidatorContext) {
boolean result;
if (theCode != null) {
result = theCode.startsWith(coursePrefix);
} else {
// Allow null values (use @NotNull for null check)
result = true;
}
return result;
}
}Key Methods:
initialize()- Called once when validation is set upisValid()- Called for each validation check- Returns
trueif valid - Returns
falseif invalid
- Returns
Generic Types:
- First type:
CourseCode- Annotation type - Second type:
String- Data type being validated
File: Customer.java
public class Customer {
private String firstName;
@NotNull(message = "is required")
@Size(min=1, message = "is required")
private String lastName = "";
@CourseCode(value="LUV", message="must start with LUV")
private String courseCode;
// Getter and setter
public String getCourseCode() {
return courseCode;
}
public void setCourseCode(String courseCode) {
this.courseCode = courseCode;
}
}Usage Examples:
// Using custom prefix
@CourseCode(value="TECH", message="must start with TECH")
private String courseCode;
// Using default values
@CourseCode // Uses default: value="LUV", message="must start with LUV"
private String courseCode;File: customer-form.html
<form th:action="@{/processForm}" th:object="${customer}" method="POST">
First name: <input type="text" th:field="*{firstName}" />
<br><br>
Last name (*): <input type="text" th:field="*{lastName}" />
<span th:if="${#fields.hasErrors('lastName')}"
th:errors="*{lastName}"
class="error"></span>
<br><br>
Course Code: <input type="text" th:field="*{courseCode}" />
<span th:if="${#fields.hasErrors('courseCode')}"
th:errors="*{courseCode}"
class="error"></span>
<br><br>
<input type="submit" value="Submit" />
</form><body>
<h3>Customer Confirmation</h3>
Customer: <span th:text="${customer.firstName} + ' ' + ${customer.lastName}" />
<br><br>
Course Code: <span th:text="${customer.courseCode}" />
</body>When user enters text in an integer field (e.g., "abc" for freePasses), Spring throws a type mismatch exception before validation runs.
Create a properties file to customize error messages.
File: src/main/resources/messages.properties
# Custom error message for type mismatch
typeMismatch.customer.freePasses=Invalid numberRegister Properties File (if needed):
File: MyAppConfig.java
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ResourceBundleMessageSource;
@Configuration
public class MyAppConfig {
@Bean
public MessageSource messageSource() {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.setBasename("messages");
return messageSource;
}
}Note: Spring Boot auto-configures this in most cases.
Customer.java:
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Pattern;
public class Customer {
private String firstName;
@NotNull(message = "is required")
@Size(min=1, message = "is required")
private String lastName = "";
@Min(value=0, message="must be greater than or equal to zero")
@Max(value=10, message="must be less than or equal to 10")
private Integer freePasses;
@Pattern(regexp="^[a-zA-Z0-9]{5}", message="only 5 chars/digits")
private String postalCode;
@CourseCode(value="LUV", message="must start with LUV")
private String courseCode;
// Constructors
public Customer() {
}
// Getters and Setters
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
public Integer getFreePasses() {
return freePasses;
}
public void setFreePasses(Integer freePasses) {
this.freePasses = freePasses;
}
public String getPostalCode() {
return postalCode;
}
public void setPostalCode(String postalCode) {
this.postalCode = postalCode;
}
public String getCourseCode() {
return courseCode;
}
public void setCourseCode(String courseCode) {
this.courseCode = courseCode;
}
}CustomerController.java:
import org.springframework.beans.propertyeditors.StringTrimmerEditor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import jakarta.validation.Valid;
@Controller
public class CustomerController {
// Pre-process all String form data
@InitBinder
public void initBinder(WebDataBinder dataBinder) {
StringTrimmerEditor stringTrimmerEditor = new StringTrimmerEditor(true);
dataBinder.registerCustomEditor(String.class, stringTrimmerEditor);
}
@GetMapping("/")
public String showForm(Model theModel) {
theModel.addAttribute("customer", new Customer());
return "customer-form";
}
@PostMapping("/processForm")
public String processForm(
@Valid @ModelAttribute("customer") Customer theCustomer,
BindingResult theBindingResult) {
// Log validation errors (for debugging)
if (theBindingResult.hasErrors()) {
System.out.println("Validation errors:");
theBindingResult.getAllErrors().forEach(error -> {
System.out.println(error.getDefaultMessage());
});
return "customer-form";
} else {
return "customer-confirmation";
}
}
}customer-form.html:
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Customer Registration Form</title>
<style>
.error {
color: red;
font-weight: bold;
}
</style>
</head>
<body>
<h2>Customer Registration Form</h2>
<i>Fill out the form. Asterisk (*) means required.</i>
<br><br>
<form th:action="@{/processForm}" th:object="${customer}" method="POST">
First name:
<input type="text" th:field="*{firstName}" />
<br><br>
Last name (*):
<input type="text" th:field="*{lastName}" />
<span th:if="${#fields.hasErrors('lastName')}"
th:errors="*{lastName}"
class="error"></span>
<br><br>
Free passes (0-10):
<input type="text" th:field="*{freePasses}" />
<span th:if="${#fields.hasErrors('freePasses')}"
th:errors="*{freePasses}"
class="error"></span>
<br><br>
Postal Code:
<input type="text" th:field="*{postalCode}" />
<span th:if="${#fields.hasErrors('postalCode')}"
th:errors="*{postalCode}"
class="error"></span>
<br><br>
Course Code:
<input type="text" th:field="*{courseCode}" />
<span th:if="${#fields.hasErrors('courseCode')}"
th:errors="*{courseCode}"
class="error"></span>
<br><br>
<input type="submit" value="Submit" />
</form>
</body>
</html>customer-confirmation.html:
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Customer Confirmation</title>
</head>
<body>
<h2>Customer Confirmation</h2>
<p>
The customer is confirmed:
<span th:text="${customer.firstName} + ' ' + ${customer.lastName}" />
</p>
<p>Free passes: <span th:text="${customer.freePasses}" /></p>
<p>Postal Code: <span th:text="${customer.postalCode}" /></p>
<p>Course Code: <span th:text="${customer.courseCode}" /></p>
<br><br>
<a th:href="@{/}">Back to Form</a>
</body>
</html>Student.java:
public class Student {
private String firstName;
private String lastName;
private String country;
private String favoriteLanguage;
private String[] favoriteSystems;
public Student() {
}
// Full getters and setters
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
public String getCountry() {
return country;
}
public void setCountry(String country) {
this.country = country;
}
public String getFavoriteLanguage() {
return favoriteLanguage;
}
public void setFavoriteLanguage(String favoriteLanguage) {
this.favoriteLanguage = favoriteLanguage;
}
public String[] getFavoriteSystems() {
return favoriteSystems;
}
public void setFavoriteSystems(String[] favoriteSystems) {
this.favoriteSystems = favoriteSystems;
}
}StudentController.java:
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
@Controller
public class StudentController {
@GetMapping("/showStudentForm")
public String showForm(Model theModel) {
// Create student object
Student theStudent = new Student();
// Add to model
theModel.addAttribute("student", theStudent);
return "student-form";
}
@PostMapping("/processStudentForm")
public String processForm(@ModelAttribute("student") Student theStudent) {
// Log the input data
System.out.println("theStudent: " + theStudent.getFirstName()
+ " " + theStudent.getLastName());
System.out.println("Country: " + theStudent.getCountry());
System.out.println("Favorite Language: " + theStudent.getFavoriteLanguage());
return "student-confirmation";
}
}student-form.html:
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Student Registration Form</title>
</head>
<body>
<h3>Student Registration Form</h3>
<form th:action="@{/processStudentForm}" th:object="${student}" method="POST">
First name: <input type="text" th:field="*{firstName}" />
<br><br>
Last name: <input type="text" th:field="*{lastName}" />
<br><br>
Country:
<select th:field="*{country}">
<option th:value="'Brazil'">Brazil</option>
<option th:value="'France'">France</option>
<option th:value="'Germany'">Germany</option>
<option th:value="'India'">India</option>
<option th:value="'United States'">United States</option>
</select>
<br><br>
Favorite Programming Language:
<br>
<input type="radio" th:field="*{favoriteLanguage}" th:value="'Java'" /> Java
<br>
<input type="radio" th:field="*{favoriteLanguage}" th:value="'Python'" /> Python
<br>
<input type="radio" th:field="*{favoriteLanguage}" th:value="'C++'" /> C++
<br>
<input type="radio" th:field="*{favoriteLanguage}" th:value="'JavaScript'" /> JavaScript
<br><br>
Favorite Operating Systems:
<br>
<input type="checkbox" th:field="*{favoriteSystems}" th:value="'Linux'" /> Linux
<br>
<input type="checkbox" th:field="*{favoriteSystems}" th:value="'macOS'" /> macOS
<br>
<input type="checkbox" th:field="*{favoriteSystems}" th:value="'Microsoft Windows'" /> Microsoft Windows
<br><br>
<input type="submit" value="Submit" />
</form>
</body>
</html>student-confirmation.html:
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Student Confirmation</title>
</head>
<body>
<h3>Student Confirmation</h3>
The student is confirmed:
<span th:text="${student.firstName} + ' ' + ${student.lastName}" />
<br><br>
Country: <span th:text="${student.country}" />
<br><br>
Favorite Language: <span th:text="${student.favoriteLanguage}" />
<br><br>
Favorite Operating Systems:
<ul>
<li th:each="system : ${student.favoriteSystems}" th:text="${system}" />
</ul>
</body>
</html>- Start Application: Run Spring Boot application
- Access Form: Navigate to
http://localhost:8080/ - Test Validation:
- Submit empty required fields
- Enter invalid numbers
- Enter invalid postal code
- Enter invalid course code
- Verify Error Messages: Check that appropriate errors display
- Submit Valid Data: Ensure confirmation page displays correctly
| Test Case | Expected Result |
|---|---|
| Empty last name | Error: "is required" |
| Whitespace-only last name | Error: "is required" (with @InitBinder) |
| Free passes = -1 | Error: "must be greater than or equal to zero" |
| Free passes = 15 | Error: "must be less than or equal to 10" |
| Free passes = "abc" | Error: "Invalid number" |
| Postal code = "ABC" | Error: "only 5 chars/digits" |
| Postal code = "ABC123" | Pass (6 chars) - adjust regex if needed |
| Course code = "TECH101" | Error: "must start with LUV" |
| Course code = "LUV101" | Pass |
- Add Thymeleaf dependency to
pom.xml - Create Controller with
@Controller - Add model attributes in controller methods
- Create HTML templates in
src/main/resources/templates/ - Use
xmlns:th="http://www.thymeleaf.org"in HTML - Test basic page rendering
- Create CSS files in
src/main/resources/static/css/ - Reference CSS with
th:href="@{/css/filename.css}" - Test styling displays correctly
- Create model class (POJO)
- Add
@GetMappingto show form - Add model attribute in GET method
- Create HTML form with
th:objectandth:field - Add
@PostMappingto process form - Use
@ModelAttributeto bind form data - Create confirmation page
- Add validation annotations to model class
- Add
@Validto controller POST method - Add
BindingResultparameter after@Valid - Display errors in HTML with
th:ifandth:errors - Add
@InitBinderfor whitespace trimming - Test all validation scenarios
- Create custom annotation with
@interface - Create constraint validator implementing
ConstraintValidator - Apply custom annotation to model fields
- Test custom validation logic
- Thymeleaf Official: www.thymeleaf.org
- Spring MVC Views: www.luv2code.com/spring-mvc-views
- Bean Validation API: http://www.beanvalidation.org
- Java Regex Tutorial: https://docs.oracle.com/javase/tutorial/essential/regex/
- Template Location: Must be in
src/main/resources/templates/ - Static Resources: Go in
src/main/resources/static/ - Thymeleaf Namespace: Always include
xmlns:th="http://www.thymeleaf.org" - Model Attribute Names: Must match between controller and view
- Validation Order:
@Validparameter must come beforeBindingResult - @InitBinder: Required to handle whitespace properly
- Integer Fields: Use
Integer(wrapper) instead ofintfor optional numeric fields - Custom Validation: Returning
trueinisValid()allows null values - Error Messages: Display with
th:ifandth:errorscombination - Form Methods: Use
@GetMappingto show,@PostMappingto process
Solution: Ensure property names match exactly (case-sensitive) between:
- Model class getter/setter names
th:fieldvalues in HTML- Form field names
Solution: Check that:
@Validis present on controller parameterBindingResultcomes immediately after@Validparameter- Validation annotations are from
jakarta.validation.constraints
Solution: Implement @InitBinder with StringTrimmerEditor
Solution: Create messages.properties file with custom error messages
Solution: Verify:
@Constraint(validatedBy = YourValidator.class)on annotation- Validator implements
ConstraintValidator<YourAnnotation, DataType> initialize()andisValid()methods are implemented
Problem: Thymeleaf variable shows ${variableName} instead of actual value
Solutions:
<!-- ❌ WRONG - Missing th: namespace -->
<input value="${user.name}" />
<!-- ✅ CORRECT - Use Thymeleaf attributes -->
<input th:value="${user.name}" />
<!-- ❌ WRONG - Invalid reference -->
<p th:text="${undefined.property}">Default</p>
<!-- ✅ CORRECT - Use safe navigation or null check -->
<p th:text="${user?.name}">Default</p>Problem: Static resources (CSS, JavaScript) not loading in Thymeleaf templates
Solutions:
<!-- ❌ WRONG - Absolute path doesn't work with Spring context -->
<link rel="stylesheet" href="/css/style.css" />
<!-- ✅ CORRECT - Use th:href with @ syntax -->
<link rel="stylesheet" th:href="@{/css/style.css}" />
<!-- ✅ CORRECT - Static resources go in src/main/resources/static/ -->
src/main/resources/
├── static/
│ ├── css/
│ │ └── style.css
│ └── js/
│ └── script.js
└── templates/
└── index.htmlProblem: Form data not binding to controller model
// ❌ WRONG - No @ModelAttribute or wrong object name
@PostMapping("/save")
public String saveUser(User user) { // Object name mismatch
return "redirect:/success";
}
// ✅ CORRECT - Explicit model attribute binding
@PostMapping("/save")
public String saveUser(@ModelAttribute("user") User user, BindingResult result) {
if (result.hasErrors()) {
return "userForm";
}
return "redirect:/success";
}Problem: th:each not iterating over list
<!-- ❌ WRONG - Incorrect syntax -->
<tr th:each="${user of users}">
<!-- ✅ CORRECT - Use 'in' keyword -->
<tr th:each="user : ${users}">
<td th:text="${user.firstName}"></td>
<td th:text="${user.lastName}"></td>
</tr>
<!-- ✅ CORRECT - Access iteration metadata -->
<tr th:each="user, iterStat : ${users}">
<td th:text="${iterStat.count}"></td> <!-- 1-based index -->
<td th:text="${iterStat.index}"></td> <!-- 0-based index -->
<td th:text="${iterStat.first}"></td> <!-- true/false -->
<td th:text="${iterStat.last}"></td> <!-- true/false -->
</tr>Problem: th:if not evaluating conditions
<!-- ❌ WRONG - Complex logic without parentheses -->
<p th:if="${user.age} > 18">Adult</p>
<!-- ✅ CORRECT - Wrap in parentheses -->
<p th:if="${user.age > 18}">Adult</p>
<!-- ✅ CORRECT - Use ternary operator -->
<p th:text="${user.age > 18} ? 'Adult' : 'Minor'"></p>
<!-- ✅ CORRECT - Use th:switch/th:case -->
<div th:switch="${user.role}">
<p th:case="ADMIN">Administrator</p>
<p th:case="USER">Regular User</p>
<p th:case="*">Unknown Role</p>
</div>Problem: Dates showing as timestamps or incorrect format
<!-- ❌ WRONG - No format specified -->
<p th:text="${user.createdAt}"></p> <!-- Shows: 2023-01-15T10:30:45 -->
<!-- ✅ CORRECT - Format using # objects.formatDate -->
<p th:text="${#dates.format(user.createdAt, 'dd/MM/yyyy')}"></p>
<!-- ✅ CORRECT - Use model attribute with @DateTimeFormat -->
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate dateOfBirth;Problem: Thymeleaf fragments not included properly
<!-- header.html - Define fragment -->
<div th:fragment="header">
<h1>My Application</h1>
</div>
<!-- ❌ WRONG - Incorrect fragment syntax -->
<div th:include="header"></div>
<!-- ✅ CORRECT - Use fragment notation -->
<div th:insert="~{header :: header}"></div>
<!-- ✅ CORRECT - Shorthand for insert -->
<div th:insert="header :: header"></div>
<!-- ✅ CORRECT - Use replace for complete replacement -->
<div th:replace="header :: header"></div><!-- HTML5 validation + th:errors for server-side -->
<form method="POST" th:object="${user}">
<input type="email" th:field="*{email}" required />
<span th:if="${#fields.hasErrors('email')}" th:errors="*{email}"></span>
<input type="password" th:field="*{password}"
pattern=".{8,}" title="Must be at least 8 characters" required />
<span th:if="${#fields.hasErrors('password')}" th:errors="*{password}"></span>
<button type="submit">Register</button>
</form># messages.properties
NotBlank.user.firstName=First name is required
Size.user.password=Password must be between {min} and {max} characters
Email.user.email=Please enter a valid email address<div th:if="${#fields.hasErrors('*')}">
<div class="alert alert-danger">
<ul>
<li th:each="err : ${#fields.errors('*')}" th:text="${err}"></li>
</ul>
</div>
</div><!-- Base layout -->
<html th:fragment="layout">
<head>
<title th:insert=":: title"></title>
</head>
<body>
<nav th:insert=":: nav"></nav>
<main th:insert=":: content"></main>
<footer th:insert=":: footer"></footer>
</body>
</html>
<!-- Child page -->
<div th:fragment="content">
<h1>Welcome</h1>
</div><!-- #dates - Date utilities -->
<p th:text="${#dates.format(date, 'dd/MM/yyyy')}"></p>
<p th:text="${#dates.year(date)}"></p>
<!-- #strings - String utilities -->
<p th:text="${#strings.toUpperCase(text)}"></p>
<p th:text="${#strings.substring(text, 0, 5)}"></p>
<!-- #numbers - Number formatting -->
<p th:text="${#numbers.formatDecimal(price, 1, 2)}"></p>
<!-- #lists - List operations -->
<p th:text="${#lists.size(items)}"></p>
<p th:text="${#lists.isEmpty(items)}"></p>
<!-- #objects - Object utilities -->
<p th:if="${#objects.nullSafe(user.profile.avatar, 'default.jpg')}"></p><div th:classappend="${user.active} ? 'active' : 'inactive'">
User Status
</div>
<!-- Multiple conditions -->
<button th:classappend="${form.hasErrors()} ? 'error' : ''"
th:disabled="${form.isSubmitted()}">
Submit
</button><!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My App</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" th:href="@{/css/custom.css}">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<!-- Navigation content -->
</nav>
<div class="container mt-5">
<!-- Main content -->
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script th:src="@{/js/custom.js}"></script>
</body>
</html><form method="POST" th:object="${employee}" class="form-horizontal">
<div class="mb-3">
<label for="firstName" class="form-label">First Name</label>
<input type="text" class="form-control" id="firstName" th:field="*{firstName}" />
<div class="invalid-feedback" th:if="${#fields.hasErrors('firstName')}">
<span th:errors="*{firstName}"></span>
</div>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" th:field="*{email}" />
</div>
<button type="submit" class="btn btn-primary">Save</button>
<a href="/" class="btn btn-secondary">Cancel</a>
</form>File: src/main/java/com/example/entity/Employee.java
package com.example.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
@Entity
@Table(name = "employees")
public class Employee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@NotBlank(message = "First name is required")
@Size(min = 2, max = 50, message = "First name must be between 2 and 50 characters")
@Column(name = "first_name")
private String firstName;
@NotBlank(message = "Last name is required")
@Size(min = 2, max = 50, message = "Last name must be between 2 and 50 characters")
@Column(name = "last_name")
private String lastName;
@Email(message = "Email should be valid")
@NotBlank(message = "Email is required")
@Column(name = "email")
private String email;
@NotBlank(message = "Department is required")
@Column(name = "department")
private String department;
@Column(name = "salary")
private Double salary;
// Constructors
public Employee() {
}
public Employee(String firstName, String lastName, String email,
String department, Double salary) {
this.firstName = firstName;
this.lastName = lastName;
this.email = email;
this.department = department;
this.salary = salary;
}
// Getters and Setters
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getFirstName() {
return firstName;
}
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public String getLastName() {
return lastName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getDepartment() {
return department;
}
public void setDepartment(String department) {
this.department = department;
}
public Double getSalary() {
return salary;
}
public void setSalary(Double salary) {
this.salary = salary;
}
@Override
public String toString() {
return "Employee{" + "id=" + id + ", firstName='" + firstName + '\'' +
", lastName='" + lastName + '\'' + ", email='" + email + '\'' +
", department='" + department + '\'' + ", salary=" + salary + '}';
}
}File: src/main/java/com/example/repository/EmployeeRepository.java
package com.example.repository;
import com.example.entity.Employee;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface EmployeeRepository extends JpaRepository<Employee, Integer> {
// Find by department
List<Employee> findByDepartment(String department);
// Find by last name (containing)
List<Employee> findByLastNameContainingIgnoreCase(String lastName);
// Custom query - find by email
@Query("SELECT e FROM Employee e WHERE e.email = :email")
Employee findByEmail(@Param("email") String email);
// Find high salary employees
@Query("SELECT e FROM Employee e WHERE e.salary > :salary ORDER BY e.salary DESC")
List<Employee> findHighSalaryEmployees(@Param("salary") Double salary);
}File: src/main/java/com/example/service/EmployeeService.java
package com.example.service;
import com.example.entity.Employee;
import com.example.repository.EmployeeRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Service
@Transactional
public class EmployeeService {
@Autowired
private EmployeeRepository employeeRepository;
// Get all employees
public List<Employee> getAllEmployees() {
return employeeRepository.findAll();
}
// Get employee by ID
public Employee getEmployeeById(Integer id) {
Optional<Employee> employee = employeeRepository.findById(id);
return employee.orElse(null);
}
// Save employee
public Employee saveEmployee(Employee employee) {
return employeeRepository.save(employee);
}
// Update employee
public Employee updateEmployee(Employee employee) {
return employeeRepository.save(employee);
}
// Delete employee
public void deleteEmployee(Integer id) {
employeeRepository.deleteById(id);
}
// Find by department
public List<Employee> findByDepartment(String department) {
return employeeRepository.findByDepartment(department);
}
// Search by last name
public List<Employee> searchByLastName(String lastName) {
return employeeRepository.findByLastNameContainingIgnoreCase(lastName);
}
// Get total count
public long getTotalEmployeeCount() {
return employeeRepository.count();
}
}File: src/main/java/com/example/controller/EmployeeController.java
package com.example.controller;
import com.example.entity.Employee;
import com.example.service.EmployeeService;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
@Controller
@RequestMapping("/employees")
public class EmployeeController {
@Autowired
private EmployeeService employeeService;
// Display all employees
@GetMapping("/list")
public String listEmployees(Model model) {
model.addAttribute("employees", employeeService.getAllEmployees());
model.addAttribute("totalCount", employeeService.getTotalEmployeeCount());
return "employee/list";
}
// Show add employee form
@GetMapping("/showForm")
public String showForm(Model model) {
model.addAttribute("employee", new Employee());
return "employee/form";
}
// Process form submission
@PostMapping("/save")
public String saveEmployee(@Valid @ModelAttribute("employee") Employee employee,
BindingResult bindingResult,
Model model) {
// Check for validation errors
if (bindingResult.hasErrors()) {
return "employee/form";
}
// Save employee
employeeService.saveEmployee(employee);
// Add success message
model.addAttribute("successMessage", "Employee saved successfully!");
// Redirect to list
return "redirect:/employees/list";
}
// Show edit form
@GetMapping("/edit/{id}")
public String editForm(@PathVariable Integer id, Model model) {
Employee employee = employeeService.getEmployeeById(id);
if (employee == null) {
return "redirect:/employees/list";
}
model.addAttribute("employee", employee);
return "employee/form";
}
// Delete employee
@GetMapping("/delete/{id}")
public String deleteEmployee(@PathVariable Integer id) {
employeeService.deleteEmployee(id);
return "redirect:/employees/list";
}
// Search employees
@GetMapping("/search")
public String searchEmployees(@RequestParam(required = false) String lastName,
@RequestParam(required = false) String department,
Model model) {
if (lastName != null && !lastName.isEmpty()) {
model.addAttribute("employees", employeeService.searchByLastName(lastName));
} else if (department != null && !department.isEmpty()) {
model.addAttribute("employees", employeeService.findByDepartment(department));
} else {
model.addAttribute("employees", employeeService.getAllEmployees());
}
return "employee/list";
}
}File: src/main/resources/templates/employee/list.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Employee Management</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body>
<div class="container mt-5">
<div class="card">
<div class="card-header bg-primary text-white">
<div class="row">
<div class="col">
<h3>Employee Directory</h3>
</div>
<div class="col text-end">
<span class="badge bg-light text-dark">Total: <span th:text="${totalCount}">0</span></span>
</div>
</div>
</div>
<div class="card-body">
<!-- Add Button -->
<a href="/employees/showForm" class="btn btn-success mb-3">
<i class="bi bi-plus-circle"></i> Add New Employee
</a>
<!-- Search Form -->
<form th:action="@{/employees/search}" method="GET" class="mb-3">
<div class="row g-2">
<div class="col-md-4">
<input type="text" class="form-control" name="lastName"
placeholder="Search by Last Name" />
</div>
<div class="col-md-4">
<input type="text" class="form-control" name="department"
placeholder="Filter by Department" />
</div>
<div class="col-md-4">
<button type="submit" class="btn btn-primary w-100">Search</button>
</div>
</div>
</form>
<!-- No employees message -->
<div th:if="${employees.isEmpty()}" class="alert alert-info">
No employees found. <a href="/employees/showForm">Add one now!</a>
</div>
<!-- Employees table -->
<table class="table table-striped table-hover" th:unless="${employees.isEmpty()}">
<thead class="table-dark">
<tr>
<th>ID</th>
<th>First Name</th>
<th>Last Name</th>
<th>Email</th>
<th>Department</th>
<th>Salary</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr th:each="employee : ${employees}">
<td th:text="${employee.id}"></td>
<td th:text="${employee.firstName}"></td>
<td th:text="${employee.lastName}"></td>
<td th:text="${employee.email}"></td>
<td>
<span class="badge bg-info" th:text="${employee.department}"></span>
</td>
<td th:text="${'$' + #numbers.formatDecimal(employee.salary, 1, 2)}"></td>
<td>
<a th:href="@{/employees/edit/{id}(id=${employee.id})}"
class="btn btn-sm btn-primary">Edit</a>
<a th:href="@{/employees/delete/{id}(id=${employee.id})}"
class="btn btn-sm btn-danger"
onclick="return confirm('Are you sure?');">Delete</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>File: src/main/resources/templates/employee/form.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Employee Form</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body>
<div class="container mt-5">
<div class="row">
<div class="col-md-6 offset-md-3">
<div class="card">
<div class="card-header bg-primary text-white">
<h3 th:if="${employee.id == null}">Add New Employee</h3>
<h3 th:if="${employee.id != null}">Edit Employee</h3>
</div>
<div class="card-body">
<form th:action="@{/employees/save}"
th:object="${employee}"
method="POST">
<!-- Hidden ID field -->
<input type="hidden" th:field="*{id}" />
<!-- First Name -->
<div class="mb-3">
<label for="firstName" class="form-label">First Name *</label>
<input type="text" class="form-control" id="firstName"
th:field="*{firstName}"
th:classappend="${#fields.hasErrors('firstName') ? 'is-invalid' : ''}"
required>
<div class="invalid-feedback"
th:if="${#fields.hasErrors('firstName')}">
<span th:errors="*{firstName}"></span>
</div>
</div>
<!-- Last Name -->
<div class="mb-3">
<label for="lastName" class="form-label">Last Name *</label>
<input type="text" class="form-control" id="lastName"
th:field="*{lastName}"
th:classappend="${#fields.hasErrors('lastName') ? 'is-invalid' : ''}"
required>
<div class="invalid-feedback"
th:if="${#fields.hasErrors('lastName')}">
<span th:errors="*{lastName}"></span>
</div>
</div>
<!-- Email -->
<div class="mb-3">
<label for="email" class="form-label">Email *</label>
<input type="email" class="form-control" id="email"
th:field="*{email}"
th:classappend="${#fields.hasErrors('email') ? 'is-invalid' : ''}"
required>
<div class="invalid-feedback"
th:if="${#fields.hasErrors('email')}">
<span th:errors="*{email}"></span>
</div>
</div>
<!-- Department -->
<div class="mb-3">
<label for="department" class="form-label">Department *</label>
<select class="form-select" id="department"
th:field="*{department}"
th:classappend="${#fields.hasErrors('department') ? 'is-invalid' : ''}"
required>
<option value="">Select Department</option>
<option value="IT">IT</option>
<option value="HR">HR</option>
<option value="Finance">Finance</option>
<option value="Marketing">Marketing</option>
<option value="Operations">Operations</option>
</select>
<div class="invalid-feedback"
th:if="${#fields.hasErrors('department')}">
<span th:errors="*{department}"></span>
</div>
</div>
<!-- Salary -->
<div class="mb-3">
<label for="salary" class="form-label">Salary</label>
<input type="number" class="form-control" id="salary"
th:field="*{salary}"
step="0.01" min="0">
</div>
<!-- Submit Button -->
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">Save Employee</button>
<a href="/employees/list" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>File: src/main/resources/application.properties
# Server Configuration
server.port=8080
server.servlet.context-path=/
# Database Configuration
spring.datasource.url=jdbc:mysql://localhost:3306/employee_db
spring.datasource.username=root
spring.datasource.password=password
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
# Hibernate/JPA Configuration
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=false
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
spring.jpa.properties.hibernate.format_sql=true
# Thymeleaf Configuration
spring.thymeleaf.cache=false
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
spring.thymeleaf.mode=HTML
# Logging
logging.level.com.example=DEBUG
logging.level.org.springframework.web=INFOFile: src/test/java/com/example/controller/EmployeeControllerTest.java
package com.example.controller;
import com.example.entity.Employee;
import com.example.repository.EmployeeRepository;
import com.example.service.EmployeeService;
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.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
public class EmployeeControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private EmployeeService employeeService;
@Autowired
private EmployeeRepository employeeRepository;
@BeforeEach
public void setup() {
// Clear database before each test
employeeRepository.deleteAll();
// Add test employee
Employee emp = new Employee("John", "Doe", "john@example.com", "IT", 50000.0);
employeeService.saveEmployee(emp);
}
@Test
public void testListEmployees() throws Exception {
mockMvc.perform(get("/employees/list"))
.andExpect(status().isOk())
.andExpect(view().name("employee/list"))
.andExpect(model().attributeExists("employees"))
.andExpect(model().attributeExists("totalCount"));
}
@Test
public void testShowForm() throws Exception {
mockMvc.perform(get("/employees/showForm"))
.andExpect(status().isOk())
.andExpect(view().name("employee/form"))
.andExpect(model().attributeExists("employee"));
}
@Test
public void testSaveEmployeeSuccess() throws Exception {
mockMvc.perform(post("/employees/save")
.param("firstName", "Jane")
.param("lastName", "Smith")
.param("email", "jane@example.com")
.param("department", "HR")
.param("salary", "60000"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/employees/list"));
}
@Test
public void testSaveEmployeeValidationError() throws Exception {
mockMvc.perform(post("/employees/save")
.param("firstName", "") // Empty - will fail validation
.param("lastName", "Smith")
.param("email", "jane@example.com")
.param("department", "HR")
.param("salary", "60000"))
.andExpect(status().isOk())
.andExpect(view().name("employee/form"));
}
@Test
public void testEditForm() throws Exception {
Employee emp = employeeRepository.findAll().get(0);
mockMvc.perform(get("/employees/edit/" + emp.getId()))
.andExpect(status().isOk())
.andExpect(view().name("employee/form"))
.andExpect(model().attributeExists("employee"));
}
@Test
public void testDeleteEmployee() throws Exception {
Employee emp = employeeRepository.findAll().get(0);
mockMvc.perform(get("/employees/delete/" + emp.getId()))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/employees/list"));
}
}@SpringBootTest
public class TemplateTest {
@Autowired
private WebApplicationContext context;
private MockMvc mockMvc;
@BeforeEach
public void setup() {
mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
}
@Test
public void testUserDisplayInTemplate() throws Exception {
mockMvc.perform(get("/users/1"))
.andExpect(status().isOk())
.andExpect(view().name("user/detail"))
.andExpect(model().attributeExists("user"))
.andExpect(model().attribute("user",
hasProperty("firstName", equalTo("John"))));
}
@Test
public void testErrorMessageDisplay() throws Exception {
mockMvc.perform(post("/users")
.param("firstName", "") // Empty - will cause validation error
.param("lastName", "Doe"))
.andExpect(status().isOk())
.andExpect(view().name("user/form"))
.andExpect(model().attributeHasFieldErrors("user", "firstName"));
}
}Happy Coding! 🚀