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,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());
}
}