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:
@@ -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 »").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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user