Add thymeleaf-htmx module: Spring Boot 4.1.2 + Thymeleaf 3.1.5 + htmx 2.0.11 CRUD UI with fragment rendering, 422 validation re-render, pagination, inline edit, delete, MockMvc tests and a Chromium check

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ic4b7rnC7jppGGdHGESvy
This commit is contained in:
Claude
2026-10-28 18:23:46 +00:00
parent 93c709c34d
commit 99a8a0df42
37 changed files with 1840 additions and 0 deletions
@@ -0,0 +1,45 @@
package com.ankurm.thymeleafhtmx;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
/** The persistent record. The form is a separate type ({@link ContactForm}) so that
* validation rules and request binding never touch the entity. */
@Entity
public class Contact {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String role;
protected Contact() { }
public Contact(String name, String email, String role) {
this.name = name;
this.email = email;
this.role = role;
}
public void apply(ContactForm form) {
this.name = form.getName();
this.email = form.getEmail();
this.role = form.getRole();
}
public Long getId() { return id; }
public String getName() { return name; }
public String getEmail() { return email; }
public String getRole() { return role; }
}
@@ -0,0 +1,152 @@
package com.ankurm.thymeleafhtmx;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.util.UriComponentsBuilder;
@Controller
@RequestMapping("/contacts")
class ContactController {
static final int PAGE_SIZE = 5;
private final ContactRepository repo;
ContactController(ContactRepository repo) {
this.repo = repo;
}
// ---- list + search + pagination: one handler, two representations ----------------------
@GetMapping
String list(@RequestParam(defaultValue = "") String q,
@RequestParam(defaultValue = "0") int page,
Model model, HttpServletRequest request) {
Page<Contact> result = find(q, page);
if (page > 0 && result.getTotalPages() > 0 && page >= result.getTotalPages()) {
// Page.getNumber() echoes the requested index even past the end, so compare against
// getTotalPages(). Clamp to the last page (what happens after deleting its last row).
result = find(q, result.getTotalPages() - 1);
}
model.addAttribute("page", result);
model.addAttribute("q", q);
model.addAttribute("selfUrl", url(q, result.getNumber()));
model.addAttribute("prevUrl", result.hasPrevious() ? url(q, result.getNumber() - 1) : null);
model.addAttribute("nextUrl", result.hasNext() ? url(q, result.getNumber() + 1) : null);
return Htmx.wantsFragment(request) ? "contacts/list :: table" : "contacts/list";
}
/** Built here, not in the template, so an empty search never produces "?q=&page=1". */
private static String url(String q, int page) {
return UriComponentsBuilder.fromPath("/contacts")
.queryParamIfPresent("q", q.isBlank() ? java.util.Optional.empty() : java.util.Optional.of(q))
.queryParam("page", page)
.encode().build().toUriString();
}
private Page<Contact> find(String q, int page) {
Pageable pageable = PageRequest.of(Math.max(page, 0), PAGE_SIZE, Sort.by("name").and(Sort.by("id")));
return q.isBlank()
? repo.findAll(pageable)
: repo.findByNameContainingIgnoreCaseOrEmailContainingIgnoreCase(q, q, pageable);
}
// ---- create -----------------------------------------------------------------------------
@GetMapping("/new")
String newForm(Model model, HttpServletRequest request) {
model.addAttribute("contactForm", new ContactForm());
return Htmx.wantsFragment(request) ? "contacts/new :: form" : "contacts/new";
}
@PostMapping
Object create(@Valid @ModelAttribute ContactForm contactForm, BindingResult errors,
HttpServletRequest request, HttpServletResponse response) {
if (repo.existsByEmailIgnoreCase(contactForm.getEmail() == null ? "" : contactForm.getEmail())) {
errors.rejectValue("email", "duplicate", "That email is already in the list");
}
if (errors.hasErrors()) {
response.setStatus(HttpStatus.UNPROCESSABLE_ENTITY.value());
return Htmx.wantsFragment(request) ? "contacts/new :: form" : "contacts/new";
}
repo.save(newContact(contactForm));
if (Htmx.wantsFragment(request)) {
// htmx follows HX-Redirect with a full page navigation
return ResponseEntity.ok().header("HX-Redirect", "/contacts").build();
}
return "redirect:/contacts"; // classic post-redirect-get for plain browsers
}
private static Contact newContact(ContactForm f) {
Contact c = new Contact(f.getName(), f.getEmail(), f.getRole());
return c;
}
// ---- inline edit: three row states (read, edit, edit-with-errors) ------------------------
@GetMapping("/{id}")
String row(@PathVariable Long id, Model model) {
model.addAttribute("contact", load(id));
return "contacts/fragments :: row";
}
@GetMapping("/{id}/edit")
String editRow(@PathVariable Long id, Model model) {
Contact c = load(id);
model.addAttribute("contact", c);
model.addAttribute("contactForm", ContactForm.of(c));
return "contacts/fragments :: editRow";
}
@PutMapping("/{id}")
String update(@PathVariable Long id, @Valid @ModelAttribute ContactForm contactForm,
BindingResult errors, Model model, HttpServletResponse response) {
Contact c = load(id);
model.addAttribute("contact", c);
String email = contactForm.getEmail() == null ? "" : contactForm.getEmail();
if (repo.existsByEmailIgnoreCaseAndIdNot(email, id)) {
errors.rejectValue("email", "duplicate", "That email is already in the list");
}
if (errors.hasErrors()) {
response.setStatus(HttpStatus.UNPROCESSABLE_ENTITY.value());
return "contacts/fragments :: editRow";
}
c.apply(contactForm);
repo.save(c);
return "contacts/fragments :: row";
}
// ---- delete -----------------------------------------------------------------------------
/** 200 with an empty body, NOT 204: htmx 2 does not swap a 204, so the row would stay.
* HX-Trigger tells the table to refresh itself so the pager and page contents stay honest. */
@DeleteMapping("/{id}")
ResponseEntity<Void> delete(@PathVariable Long id) {
load(id);
repo.deleteById(id);
return ResponseEntity.ok().header("HX-Trigger", "contactsChanged").build();
}
private Contact load(Long id) {
return repo.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}
}
@@ -0,0 +1,41 @@
package com.ankurm.thymeleafhtmx;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/** What the browser posts. A plain getter/setter bean: th:field reads properties through getters
* and this is the shape the Thymeleaf-Spring binding documentation shows. (Whether a record
* would also work was not tested here.) */
public class ContactForm {
@NotBlank(message = "Name is required")
@Size(max = 60, message = "Name must be at most 60 characters")
private String name;
@NotBlank(message = "Email is required")
@Email(message = "Email must look like name@host")
private String email;
@NotBlank(message = "Role is required")
private String role;
public ContactForm() { }
public ContactForm(String name, String email, String role) {
this.name = name;
this.email = email;
this.role = role;
}
public static ContactForm of(Contact c) {
return new ContactForm(c.getName(), c.getEmail(), c.getRole());
}
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getRole() { return role; }
public void setRole(String role) { this.role = role; }
}
@@ -0,0 +1,15 @@
package com.ankurm.thymeleafhtmx;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ContactRepository extends JpaRepository<Contact, Long> {
Page<Contact> findByNameContainingIgnoreCaseOrEmailContainingIgnoreCase(
String name, String email, Pageable pageable);
boolean existsByEmailIgnoreCase(String email);
boolean existsByEmailIgnoreCaseAndIdNot(String email, Long id);
}
@@ -0,0 +1,18 @@
package com.ankurm.thymeleafhtmx;
import jakarta.servlet.http.HttpServletRequest;
/** The two request headers that decide "fragment or full page". */
final class Htmx {
private Htmx() { }
/** True for an htmx-issued request that wants a fragment.
* htmx also sends HX-Request on a history-cache miss, but then it swaps the response
* into the whole body, so that case needs the full page. */
static boolean wantsFragment(HttpServletRequest request) {
boolean hx = "true".equals(request.getHeader("HX-Request"));
boolean historyRestore = "true".equals(request.getHeader("HX-History-Restore-Request"));
return hx && !historyRestore;
}
}
@@ -0,0 +1,27 @@
package com.ankurm.thymeleafhtmx;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/** 23 rows, so that a page size of 5 gives five pages and the last one is short. */
@Configuration
class SeedData {
@Bean
ApplicationRunner seed(ContactRepository repo) {
return args -> {
if (repo.count() > 0) return;
String[] names = {"Aarav Kulkarni", "Bhavna Joshi", "Chinmay Desai", "Divya Nair", "Eshaan Patil",
"Farah Sheikh", "Gauri Deshpande", "Harsh Mehta", "Isha Rao", "Jayesh Pawar",
"Kavya Menon", "Lakshman Iyer", "Meera Shah", "Neel Bhatt", "Omkar Gokhale",
"Pooja Verma", "Qasim Ansari", "Rohan Sawant", "Sneha Kamat", "Tanmay Ghosh",
"Uma Pillai", "Vikram Singh", "Yash Thakur"};
String[] roles = {"Developer", "Tester", "Architect", "Manager"};
for (int i = 0; i < names.length; i++) {
String local = names[i].toLowerCase().replace(' ', '.');
repo.save(new Contact(names[i], local + "@example.com", roles[i % roles.length]));
}
};
}
}
@@ -0,0 +1,11 @@
package com.ankurm.thymeleafhtmx;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ThymeleafHtmxApplication {
public static void main(String[] args) {
SpringApplication.run(ThymeleafHtmxApplication.class, args);
}
}
@@ -0,0 +1,23 @@
package com.ankurm.thymeleafhtmx;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/** The same URL now has two representations. Tell caches, or the back button will show a fragment. */
@Component
class VaryHtmxFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
res.addHeader("Vary", "HX-Request");
res.addHeader("Vary", "HX-History-Restore-Request");
chain.doFilter(req, res);
}
}
@@ -0,0 +1,2 @@
spring.application.name=thymeleaf-htmx
spring.jpa.open-in-view=false
@@ -0,0 +1,8 @@
body { font-family: system-ui, sans-serif; max-width: 52rem; margin: 0 auto; padding: 1rem; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: .4rem .6rem; border-bottom: 1px solid #ddd; }
input.invalid { border: 2px solid #c0392b; }
.error { color: #c0392b; display: block; }
.pager { display: flex; gap: 1rem; margin-top: .75rem; }
.htmx-request { opacity: .6; }
tr.htmx-swapping { opacity: 0; transition: opacity .3s ease-out; }
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<!-- read-only row -->
<tr th:fragment="row" th:id="${'contact-' + contact.id}">
<td th:text="${contact.name}">Name</td>
<td th:text="${contact.email}">email</td>
<td th:text="${contact.role}">role</td>
<td>
<button type="button"
th:attr="hx-get=@{/contacts/{id}/edit(id=${contact.id})}"
hx-target="closest tr" hx-swap="outerHTML">Edit</button>
<button type="button"
th:attr="hx-delete=@{/contacts/{id}(id=${contact.id})},hx-confirm='Delete ' + ${contact.name} + '?'"
hx-target="closest tr" hx-swap="outerHTML">Delete</button>
</td>
</tr>
<!-- edit row: also what a failed save returns, with errors -->
<tr th:fragment="editRow" th:id="${'contact-' + contact.id}" class="editing">
<td>
<input name="name" th:value="${contactForm.name}" th:classappend="${#fields.hasErrors('contactForm.name')} ? 'invalid'">
<small class="error" th:if="${#fields.hasErrors('contactForm.name')}" th:errors="${contactForm.name}">error</small>
</td>
<td>
<input name="email" th:value="${contactForm.email}" th:classappend="${#fields.hasErrors('contactForm.email')} ? 'invalid'">
<small class="error" th:if="${#fields.hasErrors('contactForm.email')}" th:errors="${contactForm.email}">error</small>
</td>
<td>
<input name="role" th:value="${contactForm.role}" th:classappend="${#fields.hasErrors('contactForm.role')} ? 'invalid'">
<small class="error" th:if="${#fields.hasErrors('contactForm.role')}" th:errors="${contactForm.role}">error</small>
</td>
<td>
<button type="button"
th:attr="hx-put=@{/contacts/{id}(id=${contact.id})}"
hx-include="closest tr" hx-target="closest tr" hx-swap="outerHTML">Save</button>
<button type="button"
th:attr="hx-get=@{/contacts/{id}(id=${contact.id})}"
hx-target="closest tr" hx-swap="outerHTML">Cancel</button>
</td>
</tr>
</body>
</html>
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
th:replace="~{layout/base :: layout(~{::title}, ~{::main})}">
<head><title>Contacts</title></head>
<body>
<main>
<h1>Contacts</h1>
<p><a th:href="@{/contacts/new}">New contact</a></p>
<form th:action="@{/contacts}" method="get" role="search">
<input type="search" name="q" th:value="${q}" placeholder="Search name or email"
hx-get="/contacts" hx-trigger="input changed delay:300ms, search"
hx-target="#contact-table" hx-swap="outerHTML" hx-push-url="true">
<noscript><button type="submit">Search</button></noscript>
</form>
<!-- th:fragment "table" is exactly what an htmx request gets back -->
<div id="contact-table" th:fragment="table"
th:attr="hx-get=${selfUrl}"
hx-trigger="contactsChanged from:body" hx-target="this" hx-swap="outerHTML">
<table>
<thead><tr><th>Name</th><th>Email</th><th>Role</th><th></th></tr></thead>
<tbody>
<th:block th:each="contact : ${page.content}">
<tr th:replace="~{contacts/fragments :: row}"></tr>
</th:block>
<tr th:if="${page.empty}"><td colspan="4">No contacts match.</td></tr>
</tbody>
</table>
<nav class="pager" aria-label="Pages">
<a th:if="${prevUrl != null}"
th:href="${prevUrl}" th:attr="hx-get=${prevUrl}"
hx-target="#contact-table" hx-push-url="true">&laquo; Prev</a>
<span th:text="${'Page ' + (page.number + 1) + ' of ' + (page.totalPages == 0 ? 1 : page.totalPages) + ' (' + page.totalElements + ' contacts)'}">Page 1 of 1</span>
<a th:if="${nextUrl != null}"
th:href="${nextUrl}" th:attr="hx-get=${nextUrl}"
hx-target="#contact-table" hx-push-url="true">Next &raquo;</a>
</nav>
</div>
</main>
</body>
</html>
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
th:replace="~{layout/base :: layout(~{::title}, ~{::main})}">
<head><title>New contact</title></head>
<body>
<main>
<h1>New contact</h1>
<!-- With JS: hx-post swaps this form for the re-rendered one on a 422.
Without JS: the same form posts normally and the full page comes back. -->
<form id="contact-form" th:fragment="form" th:action="@{/contacts}" method="post"
th:object="${contactForm}" hx-post="/contacts" hx-target="this" hx-swap="outerHTML">
<p>
<label>Name <input th:field="*{name}" th:errorclass="invalid"></label>
<small class="error" th:if="${#fields.hasErrors('name')}" th:errors="*{name}">error</small>
</p>
<p>
<label>Email <input th:field="*{email}" th:errorclass="invalid"></label>
<small class="error" th:if="${#fields.hasErrors('email')}" th:errors="*{email}">error</small>
</p>
<p>
<label>Role <input th:field="*{role}" th:errorclass="invalid"></label>
<small class="error" th:if="${#fields.hasErrors('role')}" th:errors="*{role}">error</small>
</p>
<button type="submit">Create</button>
<a th:href="@{/contacts}">Cancel</a>
</form>
</main>
</body>
</html>
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"
th:fragment="layout(title, content)">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:replace="${title}">Contacts</title>
<!-- htmx 2 swaps 2xx/3xx only. 422 must be allowed to swap, or validation errors never appear. -->
<meta name="htmx-config"
content='{"responseHandling":[{"code":"204","swap":false},{"code":"[23]..","swap":true},{"code":"422","swap":true,"error":false},{"code":"[45]..","swap":false,"error":true},{"code":"...","swap":false}]}'>
<link rel="stylesheet" th:href="@{/css/app.css}">
<script th:src="@{/webjars/htmx.org/dist/htmx.min.js}" defer></script>
</head>
<body>
<header><a th:href="@{/contacts}">Contacts</a></header>
<main th:replace="${content}">page content goes here</main>
</body>
</html>
@@ -0,0 +1,237 @@
package com.ankurm.thymeleafhtmx;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.transaction.annotation.Transactional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** The whole UI contract through MockMvc. The seed data is 23 contacts sorted by name, page size 5. */
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class ContactUiTest {
@Autowired MockMvc mvc;
@Autowired ContactRepository repo;
private static final String[] HEADERS = {"Vary", "HX-Redirect", "HX-Trigger", "Location"};
// ---- 1. same URL, two representations -----------------------------------------------
@Test
void fullPageVersusFragment() throws Exception {
MvcResult full = mvc.perform(get("/contacts")).andExpect(status().isOk()).andReturn();
MvcResult frag = mvc.perform(get("/contacts").header("HX-Request", "true"))
.andExpect(status().isOk()).andReturn();
String f = full.getResponse().getContentAsString();
String h = frag.getResponse().getContentAsString();
assertThat(f).contains("<!DOCTYPE html>", "<title>Contacts</title>", "<header>", "<table>");
assertThat(h).doesNotContain("<!DOCTYPE", "<html", "<head", "<title>", "<header>");
assertThat(h.strip()).startsWith("<div id=\"contact-table\"").endsWith("</div>");
assertThat(h).contains("<table>", "Page 1 of 5 (23 contacts)");
assertThat(h.length()).isLessThan(f.length());
// a cache must key on the header, or the back button shows a fragment
assertThat(full.getResponse().getHeaders("Vary")).contains("HX-Request", "HX-History-Restore-Request");
assertThat(frag.getResponse().getHeaders("Vary")).contains("HX-Request", "HX-History-Restore-Request");
new Transcript("01-full-page-vs-fragment.txt")
.exchange("curl /contacts", full, HEADERS)
.exchange("curl -H 'HX-Request: true' /contacts", frag, HEADERS)
.write();
}
@Test
void historyRestoreMissNeedsTheFullPage() throws Exception {
MvcResult r = mvc.perform(get("/contacts?page=1")
.header("HX-Request", "true").header("HX-History-Restore-Request", "true"))
.andExpect(status().isOk()).andReturn();
assertThat(r.getResponse().getContentAsString()).contains("<!DOCTYPE html>", "<title>Contacts</title>");
new Transcript("02-history-restore-gets-full-page.txt")
.note("htmx sends BOTH headers when the history cache misses; it swaps the reply into <body>")
.exchange("curl -H 'HX-Request: true' -H 'HX-History-Restore-Request: true' '/contacts?page=1'",
r, "Vary")
.write();
}
@Test
void layoutWiresHtmxFromTheWebJar() throws Exception {
String page = mvc.perform(get("/contacts")).andReturn().getResponse().getContentAsString();
assertThat(page).contains("<script src=\"/webjars/htmx.org/2.0.11/dist/htmx.min.js\" defer></script>");
MvcResult js = mvc.perform(get("/webjars/htmx.org/2.0.11/dist/htmx.min.js")).andExpect(status().isOk()).andReturn();
assertThat(js.getResponse().getContentAsString()).contains("2.0.11");
new Transcript("03-htmx-webjar.txt")
.note("script tag as rendered (the template asks for /webjars/htmx.org/dist/htmx.min.js, no version)")
.note(page.lines().filter(l -> l.contains("htmx.min.js")).findFirst().orElseThrow().strip())
.note("GET /webjars/htmx.org/2.0.11/dist/htmx.min.js -> " + js.getResponse().getStatus()
+ ", " + js.getResponse().getContentLength() + " bytes, Content-Type "
+ js.getResponse().getContentType())
.write();
}
// ---- 2. pagination and search ------------------------------------------------------
@Test
void pagination() throws Exception {
MvcResult last = mvc.perform(get("/contacts?page=4").header("HX-Request", "true")).andReturn();
String body = last.getResponse().getContentAsString();
assertThat(body).contains("Page 5 of 5 (23 contacts)", "Yash Thakur", "Vikram Singh")
.doesNotContain("Next &raquo;").contains("Prev");
assertThat(body.split("<tr id=", -1).length - 1).isEqualTo(3);
// past the end clamps to the last page rather than rendering an empty table
MvcResult beyond = mvc.perform(get("/contacts?page=99").header("HX-Request", "true")).andReturn();
assertThat(beyond.getResponse().getContentAsString()).contains("Page 5 of 5");
MvcResult search = mvc.perform(get("/contacts?q=KAMAT").header("HX-Request", "true")).andReturn();
String s = search.getResponse().getContentAsString();
assertThat(s).contains("Sneha Kamat", "Page 1 of 1 (1 contacts)").doesNotContain("Aarav");
new Transcript("04-pagination-and-search.txt")
.exchange("curl -H 'HX-Request: true' '/contacts?page=4'", last)
.exchange("curl -H 'HX-Request: true' '/contacts?page=99'", beyond)
.exchange("curl -H 'HX-Request: true' '/contacts?q=KAMAT'", search)
.write();
}
// ---- 3. create form with validation -----------------------------------------------
@Test
void invalidCreateReRendersTheFormWith422() throws Exception {
MvcResult hx = mvc.perform(post("/contacts").header("HX-Request", "true")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("name", "").param("email", "not-an-email").param("role", "Developer"))
.andExpect(status().isUnprocessableContent()).andReturn();
String b = hx.getResponse().getContentAsString();
assertThat(b).startsWith("<form id=\"contact-form\"").doesNotContain("<html");
assertThat(b).contains("Name is required", "Email must look like name@host");
assertThat(b).contains("value=\"not-an-email\"", "value=\"Developer\""); // user input preserved
assertThat(b).contains("class=\"invalid\"");
assertThat(repo.count()).isEqualTo(23);
MvcResult plain = mvc.perform(post("/contacts").contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("name", "").param("email", "[email protected]").param("role", "Tester"))
.andExpect(status().isUnprocessableContent()).andReturn();
assertThat(plain.getResponse().getContentAsString()).contains("<!DOCTYPE html>", "Name is required");
new Transcript("05-create-validation-errors.txt")
.exchange("curl -H 'HX-Request: true' -d 'name=&email=not-an-email&role=Developer' /contacts", hx)
.exchange("curl -d 'name=&[email protected]&role=Tester' /contacts (no JavaScript, no HX header)", plain)
.write();
}
@Test
void duplicateEmailIsAFieldError() throws Exception {
MvcResult r = mvc.perform(post("/contacts").header("HX-Request", "true")
.param("name", "Someone").param("email", "[email protected]").param("role", "Tester"))
.andExpect(status().isUnprocessableContent()).andReturn();
assertThat(r.getResponse().getContentAsString()).contains("That email is already in the list");
assertThat(repo.count()).isEqualTo(23);
}
@Test
void validCreateRedirects() throws Exception {
MvcResult hx = mvc.perform(post("/contacts").header("HX-Request", "true")
.param("name", "Zoya Khan").param("email", "[email protected]").param("role", "Developer"))
.andExpect(status().isOk()).andReturn();
assertThat(hx.getResponse().getHeader("HX-Redirect")).isEqualTo("/contacts");
assertThat(hx.getResponse().getContentAsString()).isEmpty();
assertThat(repo.count()).isEqualTo(24);
MvcResult plain = mvc.perform(post("/contacts")
.param("name", "Zubin Irani").param("email", "[email protected]").param("role", "Tester"))
.andExpect(status().isFound()).andReturn();
assertThat(plain.getResponse().getHeader("Location")).isEqualTo("/contacts");
assertThat(repo.count()).isEqualTo(25);
new Transcript("06-create-success.txt")
.exchange("curl -H 'HX-Request: true' -d 'name=Zoya Khan&[email protected]&role=Developer' /contacts", hx, HEADERS)
.exchange("curl -d 'name=Zubin Irani&[email protected]&role=Tester' /contacts", plain, HEADERS)
.write();
}
// ---- 4. inline edit ----------------------------------------------------------------
@Test
void inlineEditCycle() throws Exception {
Long id = repo.findAll().stream().filter(c -> c.getName().equals("Aarav Kulkarni")).findFirst().orElseThrow().getId();
MvcResult edit = mvc.perform(get("/contacts/" + id + "/edit").header("HX-Request", "true"))
.andExpect(status().isOk()).andReturn();
String e = edit.getResponse().getContentAsString();
assertThat(e).contains("class=\"editing\"", "value=\"Aarav Kulkarni\"", "hx-put=\"/contacts/" + id + "\"");
assertThat(e).doesNotContain("<html", "<table");
MvcResult bad = mvc.perform(put("/contacts/" + id).header("HX-Request", "true")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("name", "").param("email", "broken").param("role", "Architect"))
.andExpect(status().isUnprocessableContent()).andReturn();
assertThat(bad.getResponse().getContentAsString()).contains("class=\"editing\"", "Name is required", "Email must look like");
assertThat(repo.findById(id).orElseThrow().getName()).isEqualTo("Aarav Kulkarni"); // unchanged
MvcResult ok = mvc.perform(put("/contacts/" + id).header("HX-Request", "true")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("name", "Aarav K.").param("email", "[email protected]").param("role", "Architect"))
.andExpect(status().isOk()).andReturn();
String o = ok.getResponse().getContentAsString();
assertThat(o).contains("<td>Aarav K.</td>").doesNotContain("class=\"editing\"");
assertThat(repo.findById(id).orElseThrow().getEmail()).isEqualTo("[email protected]");
MvcResult cancel = mvc.perform(get("/contacts/" + id).header("HX-Request", "true")).andReturn();
assertThat(cancel.getResponse().getContentAsString()).contains("<td>Aarav K.</td>");
new Transcript("07-inline-edit.txt")
.exchange("curl -H 'HX-Request: true' /contacts/" + id + "/edit (Edit button)", edit)
.exchange("curl -X PUT -H 'HX-Request: true' -d 'name=&email=broken&role=Architect' /contacts/" + id + " (Save, invalid)", bad)
.exchange("curl -X PUT -H 'HX-Request: true' -d 'name=Aarav K.&[email protected]&role=Architect' /contacts/" + id + " (Save, valid)", ok)
.write();
}
// ---- 5. delete ---------------------------------------------------------------------
@Test
void deleteReturns200NotNoContent() throws Exception {
Long id = repo.findAll().get(0).getId();
MvcResult r = mvc.perform(delete("/contacts/" + id).header("HX-Request", "true")).andReturn();
assertThat(r.getResponse().getStatus()).isEqualTo(200); // 204 would not be swapped
assertThat(r.getResponse().getContentAsString()).isEmpty();
assertThat(r.getResponse().getHeader("HX-Trigger")).isEqualTo("contactsChanged");
assertThat(repo.existsById(id)).isFalse();
MvcResult missing = mvc.perform(delete("/contacts/999999").header("HX-Request", "true")).andReturn();
assertThat(missing.getResponse().getStatus()).isEqualTo(404);
new Transcript("08-delete.txt")
.exchange("curl -X DELETE -H 'HX-Request: true' /contacts/" + id, r, HEADERS)
.exchange("curl -X DELETE -H 'HX-Request: true' /contacts/999999", missing, HEADERS)
.write();
}
@Test
void deletingTheLastRowOfTheLastPageClampsThePager() throws Exception {
// 23 contacts, 5 per page: page 5 has 3. Delete all three, then reload page 5 (index 4).
var last = repo.findAll(org.springframework.data.domain.PageRequest.of(4, 5,
org.springframework.data.domain.Sort.by("name").and(org.springframework.data.domain.Sort.by("id")))).getContent();
assertThat(last).hasSize(3);
for (Contact c : last) mvc.perform(delete("/contacts/" + c.getId()).header("HX-Request", "true"));
MvcResult r = mvc.perform(get("/contacts?page=4").header("HX-Request", "true")).andReturn();
String body = r.getResponse().getContentAsString();
assertThat(body).contains("Page 4 of 4 (20 contacts)");
new Transcript("09-delete-last-page-clamps.txt")
.note("deleted the 3 rows on page 5, then the table (which re-fetches page 5 after HX-Trigger) asks for page index 4")
.exchange("curl -H 'HX-Request: true' '/contacts?page=4'", r)
.write();
}
}
@@ -0,0 +1,50 @@
package com.ankurm.thymeleafhtmx;
import org.springframework.test.web.servlet.MvcResult;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/** Writes what a request really returned to output/NN-name.txt. Every console block in the
* article is quoted from one of these files, and the test that wrote it asserts on the same data. */
final class Transcript {
private final StringBuilder sb = new StringBuilder();
private final String file;
Transcript(String file) {
this.file = file;
}
/** Request line, the headers we care about, then the body exactly as returned. */
Transcript exchange(String request, MvcResult r, String... headerNames) throws Exception {
sb.append("# request (curl notation, sent through MockMvc): ").append(request).append('\n');
sb.append("HTTP ").append(r.getResponse().getStatus()).append('\n');
List<String> names = new ArrayList<>(List.of(headerNames));
for (String h : names) {
for (String v : r.getResponse().getHeaders(h)) {
sb.append(h).append(": ").append(v).append('\n');
}
}
String body = r.getResponse().getContentAsString();
sb.append("---- body (").append(body.length()).append(" chars) ----\n");
sb.append(body);
if (!body.endsWith("\n")) sb.append('\n');
sb.append('\n');
return this;
}
Transcript note(String text) {
sb.append("# ").append(text).append('\n');
return this;
}
void write() throws IOException {
Path out = Path.of("output", file);
Files.createDirectories(out.getParent());
Files.writeString(out, sb.toString());
}
}