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,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">« 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 »</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 »").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