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
+1
View File
@@ -9,6 +9,7 @@ files.
| Directory | Article | What it demonstrates | | Directory | Article | What it demonstrates |
|---|---|---| |---|---|---|
| [`thymeleaf-htmx/`](thymeleaf-htmx) | [Spring Boot 4 + Thymeleaf + htmx: Server-Side CRUD UI in 2026](https://ankurm.com/spring-boot-thymeleaf-htmx-crud-ui/) | one URL answering as full page or fragment on `HX-Request`, a Thymeleaf layout, 422 validation re-render, pagination, inline edit and delete, 10 MockMvc tests plus a headless-Chromium check that observed htmx 2 ignoring a 204 and (without a config tag) a 422 |
| [`sbom/`](sbom) | [Spring Boot SBOM in Practice: the tools jar-mode sbom Command, CycloneDX and Dependency Scanning](https://ankurm.com/spring-boot-sbom-tools-jarmode-cyclonedx-dependency-scanning/) | the 4.2.0-RC1 `java -Djarmode=tools -jar app.jar sbom` command (absent in 4.1.2), the CycloneDX document the build puts in the jar, `/actuator/sbom`, an SBOM that lists 8 starters not in the jar and misses the jar mode's own library, a licence rule that wrongly flags dual-licensed logback, and an OSV scan that finds Log4Shell in a deliberately old build | | [`sbom/`](sbom) | [Spring Boot SBOM in Practice: the tools jar-mode sbom Command, CycloneDX and Dependency Scanning](https://ankurm.com/spring-boot-sbom-tools-jarmode-cyclonedx-dependency-scanning/) | the 4.2.0-RC1 `java -Djarmode=tools -jar app.jar sbom` command (absent in 4.1.2), the CycloneDX document the build puts in the jar, `/actuator/sbom`, an SBOM that lists 8 starters not in the jar and misses the jar mode's own library, a licence rule that wrongly flags dual-licensed logback, and an OSV scan that finds Log4Shell in a deliberately old build |
| [`hexagonal/`](hexagonal) | [Hexagonal Architecture (Ports and Adapters) in Spring Boot 4](https://ankurm.com/hexagonal-architecture-ports-and-adapters-spring-boot-4/) | a 4-module Maven reactor that makes "the core has no Spring dependency" something `mvn dependency:tree` proves rather than a naming convention, testing the core with zero Spring context, and a Spring Boot 4 test-slice-artifact split (`@DataJpaTest`, `@WebMvcTest`, `TestRestTemplate`) hit and fixed along the way | | [`hexagonal/`](hexagonal) | [Hexagonal Architecture (Ports and Adapters) in Spring Boot 4](https://ankurm.com/hexagonal-architecture-ports-and-adapters-spring-boot-4/) | a 4-module Maven reactor that makes "the core has no Spring dependency" something `mvn dependency:tree` proves rather than a naming convention, testing the core with zero Spring context, and a Spring Boot 4 test-slice-artifact split (`@DataJpaTest`, `@WebMvcTest`, `TestRestTemplate`) hit and fixed along the way |
| [`actuator-in-production/`](actuator-in-production) | [Spring Boot Actuator in Production](https://ankurm.com/spring-boot-actuator-production-endpoints-security-health-indicators/) | endpoint exposure defaults, securing Actuator, custom health indicators and how they hang | | [`actuator-in-production/`](actuator-in-production) | [Spring Boot Actuator in Production](https://ankurm.com/spring-boot-actuator-production-endpoints-security-health-indicators/) | endpoint exposure defaults, securing Actuator, custom health indicators and how they hang |
+3
View File
@@ -0,0 +1,3 @@
target/
browser-check/node_modules/
browser-check/package-lock.json
+60
View File
@@ -0,0 +1,60 @@
# thymeleaf-htmx
Companion project for **Spring Boot 4 + Thymeleaf + htmx: Server-Side CRUD UI in 2026** on **[ankurm.com](https://ankurm.com)**.
A contacts list with search, pagination, create-with-validation, inline edit and delete. All HTML is rendered by Thymeleaf; htmx only decides *which part* of a page to replace. About 25 lines of JavaScript-free attributes, no JS framework, no build step.
Every console block in the article is quoted from `output/`. The MockMvc transcripts are written by the tests themselves (they assert on the same data), and `output/11-browser-check.txt` comes from headless Chromium driving the packaged jar.
## Versions
| | |
|---|---|
| Spring Boot | 4.1.2 (latest GA on Maven Central; 4.2.0-RC1 exists but is not GA) |
| Thymeleaf | 3.1.5.RELEASE (managed by Boot) |
| htmx | 2.0.11 as the WebJar `org.webjars.npm:htmx.org` (htmx 4.0.0 exists on npm `next`; see the article) |
| JDK | 25 |
| Test tooling | `spring-boot-starter-webmvc-test`, `spring-boot-starter-thymeleaf-test`; Playwright 1.64.1 for the browser check (optional) |
## Run
```bash
export JAVA_HOME=/path/to/jdk-25
mvn spring-boot:run # http://localhost:8080/contacts
mvn test # 10 MockMvc tests, rewrite output/01..09
./scripts/run-all.sh # tests + live curl + browser check, regenerates all of output/
```
The browser check needs Node 20+ and a one-time `npx playwright install chromium`.
## Where things are
| Concern | File |
|---|---|
| Layout fragment (`layout(title, content)`), htmx config meta tag, WebJar script tag | `templates/layout/base.html` |
| List page + the `table` fragment htmx receives | `templates/contacts/list.html` |
| Row, edit-row (also the error state) | `templates/contacts/fragments.html` |
| Create form (full page and `form` fragment) | `templates/contacts/new.html` |
| Fragment vs full page decision (`HX-Request`, `HX-History-Restore-Request`) | `Htmx.java`, `ContactController.java` |
| `Vary` header | `VaryHtmxFilter.java` |
| Contract tests | `ContactUiTest.java`, `Transcript.java` |
| Real-browser behaviour checks | `browser-check/check.mjs` |
## Output
| File | Shows |
|---|---|
| `output/00-mvn-test.txt` | surefire summary |
| `output/01-full-page-vs-fragment.txt` | same URL, with and without `HX-Request` |
| `output/02-history-restore-gets-full-page.txt` | the history-miss case |
| `output/03-htmx-webjar.txt` | version-less WebJar path resolved to the pinned version |
| `output/04-pagination-and-search.txt` | last page, page past the end, search |
| `output/05-create-validation-errors.txt` | 422 with errors, fragment and full page |
| `output/06-create-success.txt` | `HX-Redirect` vs 302 |
| `output/07-inline-edit.txt` | edit row, failed save, successful save |
| `output/08-delete.txt` | 200 + `HX-Trigger`, 404 |
| `output/09-delete-last-page-clamps.txt` | pager after deleting the last page's rows |
| `output/10-live-curl.txt` | real Tomcat answering with curl |
| `output/11-browser-check.txt` | Chromium: swaps, history, 204 and 422 traps |
Not covered: Spring Security / CSRF (no Security dependency in this module), file uploads, and htmx 4.
+116
View File
@@ -0,0 +1,116 @@
// Drives the running app in headless Chromium so the htmx behaviour claims are observed, not assumed.
// usage: node check.mjs http://localhost:18083
import { chromium } from 'playwright';
const base = process.argv[2] ?? 'http://localhost:18083';
const out = [];
const log = (s) => { out.push(s); console.log(s); };
const rows = (page) => page.locator('#contact-table tbody tr[id]');
const names = async (page) => (await rows(page).locator('td:first-child').allTextContents()).map(s => s.trim());
const browser = await chromium.launch();
const ctx = await browser.newContext();
const page = await ctx.newPage();
const requests = [];
page.on('request', r => { if (r.url().startsWith(base) && !r.url().includes('/webjars/') && !r.url().endsWith('.css'))
requests.push(`${r.method()} ${r.url().replace(base, '')} HX-Request=${r.headers()['hx-request'] ?? '-'} HX-History-Restore-Request=${r.headers()['hx-history-restore-request'] ?? '-'}`); });
log('== 1. load, then search without a page reload');
await page.goto(base + '/contacts');
await page.evaluate(() => { window.__marker = 'same-document'; });
log('htmx loaded: ' + await page.evaluate(() => typeof htmx + ' ' + htmx.version));
log('rows on first page: ' + (await rows(page).count()));
await page.fill('input[name=q]', 'kamat');
await page.waitForFunction(() => document.querySelectorAll('#contact-table tbody tr[id]').length === 1);
log('after typing "kamat": ' + JSON.stringify(await names(page)));
log('address bar: ' + page.url().replace(base, ''));
log('elements with id contact-table: ' + await page.locator('#contact-table').count());
log('document survived (no reload): ' + (await page.evaluate(() => window.__marker)));
log('\n== 2. pagination + back button');
await page.fill('input[name=q]', '');
await page.waitForFunction(() => document.querySelector('.pager span')?.textContent.includes('23 contacts'));
// no history snapshots: Back is a cache miss, so htmx must ask the server for the whole page
await page.evaluate(() => { htmx.config.historyCacheSize = 0; localStorage.removeItem('htmx-history-cache'); });
await page.click('text=Next');
await page.waitForFunction(() => document.querySelector('.pager span')?.textContent.startsWith('Page 2'));
log('page 2 names: ' + JSON.stringify(await names(page)));
log('address bar: ' + page.url().replace(base, ''));
const mark = requests.length;
await page.goBack();
await page.waitForFunction(() => document.querySelector('.pager span')?.textContent.startsWith('Page 1'));
log('Back request: ' + requests.slice(mark).join(' | '));
log('after Back: h1=' + JSON.stringify(await page.locator('h1').textContent()) + ', pager=' + JSON.stringify(await page.locator('.pager span').textContent()));
log('\n== 3. inline edit with a validation error');
const first = rows(page).first();
await first.locator('text=Edit').click();
await page.waitForSelector('tr.editing');
await page.fill('tr.editing input[name=name]', '');
await page.fill('tr.editing input[name=email]', 'nope');
await page.click('tr.editing >> text=Save');
await page.waitForSelector('tr.editing small.error');
log('row still editing, errors: ' + JSON.stringify(await page.locator('tr.editing small.error').allTextContents()));
await page.fill('tr.editing input[name=name]', 'Aarav K.');
await page.fill('tr.editing input[name=email]', '[email protected]');
await page.click('tr.editing >> text=Save');
await page.waitForSelector('tr:not(.editing) td:text-is("Aarav K.")');
log('saved, row is read-only again: ' + JSON.stringify(await names(page)));
log('\n== 4. delete with confirm');
const beforeDelete = requests.length;
page.once('dialog', d => { log('confirm dialog: ' + JSON.stringify(d.message())); d.accept(); });
await rows(page).first().locator('text=Delete').click();
await page.waitForFunction(() => document.querySelector('.pager span')?.textContent.includes('22 contacts'));
log('pager after delete: ' + JSON.stringify(await page.locator('.pager span').textContent()));
await page.waitForTimeout(500);
log('requests caused by one delete: ' + JSON.stringify(requests.slice(beforeDelete)));
log('\n== 5. create form: 422 re-render, then success');
await page.goto(base + '/contacts/new');
await page.fill('input[name=name]', '');
await page.fill('input[name=email]', 'x');
await page.fill('input[name=role]', 'Tester');
await page.click('button[type=submit]');
await page.waitForSelector('small.error');
log('errors shown in place: ' + JSON.stringify(await page.locator('small.error').allTextContents()));
log('still on: ' + page.url().replace(base, '') + ', typed email kept: ' + await page.inputValue('input[name=email]'));
await page.fill('input[name=name]', 'Zoya Khan');
await page.fill('input[name=email]', '[email protected]');
await page.click('button[type=submit]');
await page.waitForURL(base + '/contacts');
log('HX-Redirect landed on: ' + page.url().replace(base, ''));
log('\n== 6. the two status-code traps, observed');
// (a) a 204 answer to DELETE: htmx 2 does not swap it, the row stays
await page.route('**/contacts/*', async route => {
if (route.request().method() === 'DELETE') return route.fulfill({ status: 204 });
return route.continue();
});
const before = await rows(page).count();
page.once('dialog', d => d.accept());
await rows(page).first().locator('text=Delete').click();
await page.waitForTimeout(800);
log(`DELETE answered 204: rows before=${before}, after=${await rows(page).count()} (row not removed)`);
await page.unroute('**/contacts/*');
// (b) the same 422 without the htmx-config meta tag
await page.route('**/contacts/*/edit', route => route.continue());
await page.route('**/contacts', async route => {
if (route.request().method() !== 'GET' || route.request().headers()['hx-request']) return route.continue();
const resp = await route.fetch();
const html = (await resp.text()).replace(/<meta name="htmx-config"[\s\S]*?>/, '');
return route.fulfill({ response: resp, body: html });
});
await page.goto(base + '/contacts');
log('htmx-config meta present: ' + (await page.locator('meta[name=htmx-config]').count()));
await rows(page).first().locator('text=Edit').click();
await page.waitForSelector('tr.editing');
await page.fill('tr.editing input[name=name]', '');
await page.click('tr.editing >> text=Save');
await page.waitForTimeout(800);
log('Save answered 422 without the meta tag: errors visible = ' + (await page.locator('tr.editing small.error').count()));
log('\n== requests the browser made (excluding static files)');
for (const r of requests) log(r);
await browser.close();
@@ -0,0 +1,2 @@
{ "name": "thymeleaf-htmx-browser-check", "private": true, "type": "module",
"dependencies": { "playwright": "1.64.1" } }
+1
View File
@@ -0,0 +1 @@
Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 11.50 s -- in com.ankurm.thymeleafhtmx.ContactUiTest
@@ -0,0 +1,190 @@
# request (curl notation, sent through MockMvc): curl /contacts
HTTP 200
Vary: HX-Request
Vary: HX-History-Restore-Request
---- body (3831 chars) ----
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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" href="/css/app.css">
<script src="/webjars/htmx.org/2.0.11/dist/htmx.min.js" defer></script>
</head>
<body>
<header><a href="/contacts">Contacts</a></header>
<main>
<h1>Contacts</h1>
<p><a href="/contacts/new">New contact</a></p>
<form action="/contacts" method="get" role="search">
<input type="search" name="q" value="" 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" hx-trigger="contactsChanged from:body" hx-target="this" hx-swap="outerHTML" hx-get="/contacts?page=0">
<table>
<thead><tr><th>Name</th><th>Email</th><th>Role</th><th></th></tr></thead>
<tbody>
<tr id="contact-1">
<td>Aarav Kulkarni</td>
<td>[email protected]</td>
<td>Developer</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/1/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/1" hx-confirm="Delete Aarav Kulkarni?">Delete</button>
</td>
</tr>
<tr id="contact-2">
<td>Bhavna Joshi</td>
<td>[email protected]</td>
<td>Tester</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/2/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/2" hx-confirm="Delete Bhavna Joshi?">Delete</button>
</td>
</tr>
<tr id="contact-3">
<td>Chinmay Desai</td>
<td>[email protected]</td>
<td>Architect</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/3/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/3" hx-confirm="Delete Chinmay Desai?">Delete</button>
</td>
</tr>
<tr id="contact-4">
<td>Divya Nair</td>
<td>[email protected]</td>
<td>Manager</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/4/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/4" hx-confirm="Delete Divya Nair?">Delete</button>
</td>
</tr>
<tr id="contact-5">
<td>Eshaan Patil</td>
<td>[email protected]</td>
<td>Developer</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/5/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/5" hx-confirm="Delete Eshaan Patil?">Delete</button>
</td>
</tr>
</tbody>
</table>
<nav class="pager" aria-label="Pages">
<span>Page 1 of 5 (23 contacts)</span>
<a href="/contacts?page=1" hx-target="#contact-table" hx-push-url="true" hx-get="/contacts?page=1">Next &raquo;</a>
</nav>
</div>
</main>
</body>
</html>
# request (curl notation, sent through MockMvc): curl -H 'HX-Request: true' /contacts
HTTP 200
Vary: HX-Request
Vary: HX-History-Restore-Request
---- body (2598 chars) ----
<div id="contact-table" hx-trigger="contactsChanged from:body" hx-target="this" hx-swap="outerHTML" hx-get="/contacts?page=0">
<table>
<thead><tr><th>Name</th><th>Email</th><th>Role</th><th></th></tr></thead>
<tbody>
<tr id="contact-1">
<td>Aarav Kulkarni</td>
<td>[email protected]</td>
<td>Developer</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/1/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/1" hx-confirm="Delete Aarav Kulkarni?">Delete</button>
</td>
</tr>
<tr id="contact-2">
<td>Bhavna Joshi</td>
<td>[email protected]</td>
<td>Tester</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/2/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/2" hx-confirm="Delete Bhavna Joshi?">Delete</button>
</td>
</tr>
<tr id="contact-3">
<td>Chinmay Desai</td>
<td>[email protected]</td>
<td>Architect</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/3/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/3" hx-confirm="Delete Chinmay Desai?">Delete</button>
</td>
</tr>
<tr id="contact-4">
<td>Divya Nair</td>
<td>[email protected]</td>
<td>Manager</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/4/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/4" hx-confirm="Delete Divya Nair?">Delete</button>
</td>
</tr>
<tr id="contact-5">
<td>Eshaan Patil</td>
<td>[email protected]</td>
<td>Developer</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/5/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/5" hx-confirm="Delete Eshaan Patil?">Delete</button>
</td>
</tr>
</tbody>
</table>
<nav class="pager" aria-label="Pages">
<span>Page 1 of 5 (23 contacts)</span>
<a href="/contacts?page=1" hx-target="#contact-table" hx-push-url="true" hx-get="/contacts?page=1">Next &raquo;</a>
</nav>
</div>
@@ -0,0 +1,111 @@
# htmx sends BOTH headers when the history cache misses; it swaps the reply into <body>
# request (curl notation, sent through MockMvc): curl -H 'HX-Request: true' -H 'HX-History-Restore-Request: true' '/contacts?page=1'
HTTP 200
Vary: HX-Request
Vary: HX-History-Restore-Request
---- body (3937 chars) ----
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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" href="/css/app.css">
<script src="/webjars/htmx.org/2.0.11/dist/htmx.min.js" defer></script>
</head>
<body>
<header><a href="/contacts">Contacts</a></header>
<main>
<h1>Contacts</h1>
<p><a href="/contacts/new">New contact</a></p>
<form action="/contacts" method="get" role="search">
<input type="search" name="q" value="" 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" hx-trigger="contactsChanged from:body" hx-target="this" hx-swap="outerHTML" hx-get="/contacts?page=1">
<table>
<thead><tr><th>Name</th><th>Email</th><th>Role</th><th></th></tr></thead>
<tbody>
<tr id="contact-6">
<td>Farah Sheikh</td>
<td>[email protected]</td>
<td>Tester</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/6/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/6" hx-confirm="Delete Farah Sheikh?">Delete</button>
</td>
</tr>
<tr id="contact-7">
<td>Gauri Deshpande</td>
<td>[email protected]</td>
<td>Architect</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/7/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/7" hx-confirm="Delete Gauri Deshpande?">Delete</button>
</td>
</tr>
<tr id="contact-8">
<td>Harsh Mehta</td>
<td>[email protected]</td>
<td>Manager</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/8/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/8" hx-confirm="Delete Harsh Mehta?">Delete</button>
</td>
</tr>
<tr id="contact-9">
<td>Isha Rao</td>
<td>[email protected]</td>
<td>Developer</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/9/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/9" hx-confirm="Delete Isha Rao?">Delete</button>
</td>
</tr>
<tr id="contact-10">
<td>Jayesh Pawar</td>
<td>[email protected]</td>
<td>Tester</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/10/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/10" hx-confirm="Delete Jayesh Pawar?">Delete</button>
</td>
</tr>
</tbody>
</table>
<nav class="pager" aria-label="Pages">
<a href="/contacts?page=0" hx-target="#contact-table" hx-push-url="true" hx-get="/contacts?page=0">&laquo; Prev</a>
<span>Page 2 of 5 (23 contacts)</span>
<a href="/contacts?page=2" hx-target="#contact-table" hx-push-url="true" hx-get="/contacts?page=2">Next &raquo;</a>
</nav>
</div>
</main>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
# script tag as rendered (the template asks for /webjars/htmx.org/dist/htmx.min.js, no version)
# <script src="/webjars/htmx.org/2.0.11/dist/htmx.min.js" defer></script>
# GET /webjars/htmx.org/2.0.11/dist/htmx.min.js -> 200, 52182 bytes, Content-Type text/javascript
@@ -0,0 +1,138 @@
# request (curl notation, sent through MockMvc): curl -H 'HX-Request: true' '/contacts?page=4'
HTTP 200
---- body (1763 chars) ----
<div id="contact-table" hx-trigger="contactsChanged from:body" hx-target="this" hx-swap="outerHTML" hx-get="/contacts?page=4">
<table>
<thead><tr><th>Name</th><th>Email</th><th>Role</th><th></th></tr></thead>
<tbody>
<tr id="contact-21">
<td>Uma Pillai</td>
<td>[email protected]</td>
<td>Developer</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/21/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/21" hx-confirm="Delete Uma Pillai?">Delete</button>
</td>
</tr>
<tr id="contact-22">
<td>Vikram Singh</td>
<td>[email protected]</td>
<td>Tester</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/22/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/22" hx-confirm="Delete Vikram Singh?">Delete</button>
</td>
</tr>
<tr id="contact-23">
<td>Yash Thakur</td>
<td>[email protected]</td>
<td>Architect</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/23/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/23" hx-confirm="Delete Yash Thakur?">Delete</button>
</td>
</tr>
</tbody>
</table>
<nav class="pager" aria-label="Pages">
<a href="/contacts?page=3" hx-target="#contact-table" hx-push-url="true" hx-get="/contacts?page=3">&laquo; Prev</a>
<span>Page 5 of 5 (23 contacts)</span>
</nav>
</div>
# request (curl notation, sent through MockMvc): curl -H 'HX-Request: true' '/contacts?page=99'
HTTP 200
---- body (1763 chars) ----
<div id="contact-table" hx-trigger="contactsChanged from:body" hx-target="this" hx-swap="outerHTML" hx-get="/contacts?page=4">
<table>
<thead><tr><th>Name</th><th>Email</th><th>Role</th><th></th></tr></thead>
<tbody>
<tr id="contact-21">
<td>Uma Pillai</td>
<td>[email protected]</td>
<td>Developer</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/21/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/21" hx-confirm="Delete Uma Pillai?">Delete</button>
</td>
</tr>
<tr id="contact-22">
<td>Vikram Singh</td>
<td>[email protected]</td>
<td>Tester</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/22/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/22" hx-confirm="Delete Vikram Singh?">Delete</button>
</td>
</tr>
<tr id="contact-23">
<td>Yash Thakur</td>
<td>[email protected]</td>
<td>Architect</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/23/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/23" hx-confirm="Delete Yash Thakur?">Delete</button>
</td>
</tr>
</tbody>
</table>
<nav class="pager" aria-label="Pages">
<a href="/contacts?page=3" hx-target="#contact-table" hx-push-url="true" hx-get="/contacts?page=3">&laquo; Prev</a>
<span>Page 5 of 5 (23 contacts)</span>
</nav>
</div>
# request (curl notation, sent through MockMvc): curl -H 'HX-Request: true' '/contacts?q=KAMAT'
HTTP 200
---- body (828 chars) ----
<div id="contact-table" hx-trigger="contactsChanged from:body" hx-target="this" hx-swap="outerHTML" hx-get="/contacts?q=KAMAT&amp;page=0">
<table>
<thead><tr><th>Name</th><th>Email</th><th>Role</th><th></th></tr></thead>
<tbody>
<tr id="contact-19">
<td>Sneha Kamat</td>
<td>[email protected]</td>
<td>Architect</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/19/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/19" hx-confirm="Delete Sneha Kamat?">Delete</button>
</td>
</tr>
</tbody>
</table>
<nav class="pager" aria-label="Pages">
<span>Page 1 of 1 (1 contacts)</span>
</nav>
</div>
@@ -0,0 +1,64 @@
# request (curl notation, sent through MockMvc): curl -H 'HX-Request: true' -d 'name=&email=not-an-email&role=Developer' /contacts
HTTP 422
---- body (636 chars) ----
<form id="contact-form" action="/contacts" method="post"
hx-post="/contacts" hx-target="this" hx-swap="outerHTML">
<p>
<label>Name <input id="name" name="name" value="" class="invalid"></label>
<small class="error">Name is required</small>
</p>
<p>
<label>Email <input id="email" name="email" value="not-an-email" class="invalid"></label>
<small class="error">Email must look like name@host</small>
</p>
<p>
<label>Role <input id="role" name="role" value="Developer"></label>
</p>
<button type="submit">Create</button>
<a href="/contacts">Cancel</a>
</form>
# request (curl notation, sent through MockMvc): curl -d 'name=&[email protected]&role=Tester' /contacts (no JavaScript, no HX header)
HTTP 422
---- body (1461 chars) ----
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>New contact</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" href="/css/app.css">
<script src="/webjars/htmx.org/2.0.11/dist/htmx.min.js" defer></script>
</head>
<body>
<header><a href="/contacts">Contacts</a></header>
<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" action="/contacts" method="post"
hx-post="/contacts" hx-target="this" hx-swap="outerHTML">
<p>
<label>Name <input id="name" name="name" value="" class="invalid"></label>
<small class="error">Name is required</small>
</p>
<p>
<label>Email <input id="email" name="email" value="[email protected]"></label>
</p>
<p>
<label>Role <input id="role" name="role" value="Tester"></label>
</p>
<button type="submit">Create</button>
<a href="/contacts">Cancel</a>
</form>
</main>
</body>
</html>
@@ -0,0 +1,16 @@
# request (curl notation, sent through MockMvc): curl -H 'HX-Request: true' -d 'name=Zoya Khan&[email protected]&role=Developer' /contacts
HTTP 200
Vary: HX-Request
Vary: HX-History-Restore-Request
HX-Redirect: /contacts
---- body (0 chars) ----
# request (curl notation, sent through MockMvc): curl -d 'name=Zubin Irani&[email protected]&role=Tester' /contacts
HTTP 302
Vary: HX-Request
Vary: HX-History-Restore-Request
Location: /contacts
---- body (0 chars) ----
+63
View File
@@ -0,0 +1,63 @@
# request (curl notation, sent through MockMvc): curl -H 'HX-Request: true' /contacts/1/edit (Edit button)
HTTP 200
---- body (523 chars) ----
<tr id="contact-1" class="editing">
<td>
<input name="name" value="Aarav Kulkarni">
</td>
<td>
<input name="email" value="[email protected]">
</td>
<td>
<input name="role" value="Developer">
</td>
<td>
<button type="button"
hx-include="closest tr" hx-target="closest tr" hx-swap="outerHTML" hx-put="/contacts/1">Save</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/1">Cancel</button>
</td>
</tr>
# request (curl notation, sent through MockMvc): curl -X PUT -H 'HX-Request: true' -d 'name=&email=broken&role=Architect' /contacts/1 (Save, invalid)
HTTP 422
---- body (625 chars) ----
<tr id="contact-1" class="editing">
<td>
<input name="name" value="" class="invalid">
<small class="error">Name is required</small>
</td>
<td>
<input name="email" value="broken" class="invalid">
<small class="error">Email must look like name@host</small>
</td>
<td>
<input name="role" value="Architect">
</td>
<td>
<button type="button"
hx-include="closest tr" hx-target="closest tr" hx-swap="outerHTML" hx-put="/contacts/1">Save</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/1">Cancel</button>
</td>
</tr>
# request (curl notation, sent through MockMvc): curl -X PUT -H 'HX-Request: true' -d 'name=Aarav K.&[email protected]&role=Architect' /contacts/1 (Save, valid)
HTTP 200
---- body (382 chars) ----
<tr id="contact-1">
<td>Aarav K.</td>
<td>[email protected]</td>
<td>Architect</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/1/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/1" hx-confirm="Delete Aarav K.?">Delete</button>
</td>
</tr>
+15
View File
@@ -0,0 +1,15 @@
# request (curl notation, sent through MockMvc): curl -X DELETE -H 'HX-Request: true' /contacts/1
HTTP 200
Vary: HX-Request
Vary: HX-History-Restore-Request
HX-Trigger: contactsChanged
---- body (0 chars) ----
# request (curl notation, sent through MockMvc): curl -X DELETE -H 'HX-Request: true' /contacts/999999
HTTP 404
Vary: HX-Request
Vary: HX-History-Restore-Request
---- body (0 chars) ----
@@ -0,0 +1,79 @@
# deleted the 3 rows on page 5, then the table (which re-fetches page 5 after HX-Trigger) asks for page index 4
# request (curl notation, sent through MockMvc): curl -H 'HX-Request: true' '/contacts?page=4'
HTTP 200
---- body (2602 chars) ----
<div id="contact-table" hx-trigger="contactsChanged from:body" hx-target="this" hx-swap="outerHTML" hx-get="/contacts?page=3">
<table>
<thead><tr><th>Name</th><th>Email</th><th>Role</th><th></th></tr></thead>
<tbody>
<tr id="contact-16">
<td>Pooja Verma</td>
<td>[email protected]</td>
<td>Manager</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/16/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/16" hx-confirm="Delete Pooja Verma?">Delete</button>
</td>
</tr>
<tr id="contact-17">
<td>Qasim Ansari</td>
<td>[email protected]</td>
<td>Developer</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/17/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/17" hx-confirm="Delete Qasim Ansari?">Delete</button>
</td>
</tr>
<tr id="contact-18">
<td>Rohan Sawant</td>
<td>[email protected]</td>
<td>Tester</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/18/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/18" hx-confirm="Delete Rohan Sawant?">Delete</button>
</td>
</tr>
<tr id="contact-19">
<td>Sneha Kamat</td>
<td>[email protected]</td>
<td>Architect</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/19/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/19" hx-confirm="Delete Sneha Kamat?">Delete</button>
</td>
</tr>
<tr id="contact-20">
<td>Tanmay Ghosh</td>
<td>[email protected]</td>
<td>Manager</td>
<td>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-get="/contacts/20/edit">Edit</button>
<button type="button"
hx-target="closest tr" hx-swap="outerHTML" hx-delete="/contacts/20" hx-confirm="Delete Tanmay Ghosh?">Delete</button>
</td>
</tr>
</tbody>
</table>
<nav class="pager" aria-label="Pages">
<a href="/contacts?page=2" hx-target="#contact-table" hx-push-url="true" hx-get="/contacts?page=2">&laquo; Prev</a>
<span>Page 4 of 4 (20 contacts)</span>
</nav>
</div>
+35
View File
@@ -0,0 +1,35 @@
$ curl -si http://localhost:18082/contacts | tr -d '\r' | grep -v -E '^(Date|Transfer-Encoding)' | head -10
HTTP/1.1 200
Vary: HX-Request
Vary: HX-History-Restore-Request
Content-Type: text/html;charset=UTF-8
Content-Language: en-US
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
$ curl -si -H 'HX-Request: true' http://localhost:18082/contacts | tr -d '\r' | grep -v -E '^(Date|Transfer-Encoding)' | head -8
HTTP/1.1 200
Vary: HX-Request
Vary: HX-History-Restore-Request
Content-Type: text/html;charset=UTF-8
Content-Language: en-US
<div id="contact-table" hx-trigger="contactsChanged from:body" hx-target="this" hx-swap="outerHTML" hx-get="/contacts?page=0">
<table>
$ curl -s -o /dev/null -w '%{http_code} %{content_type}\n' http://localhost:18082/webjars/htmx.org/dist/htmx.min.js
200 text/javascript
$ curl -si -X DELETE -H 'HX-Request: true' http://localhost:18082/contacts/23
HTTP/1.1 200
Vary: HX-Request
Vary: HX-History-Restore-Request
HX-Trigger: contactsChanged
Content-Length: 0
$ curl -si -X DELETE -H 'HX-Request: true' http://localhost:18082/contacts/23 (again)
HTTP/1.1 404
@@ -0,0 +1,52 @@
== 1. load, then search without a page reload
htmx loaded: object 2.0.11
rows on first page: 5
after typing "kamat": ["Sneha Kamat"]
address bar: /contacts?q=kamat
elements with id contact-table: 1
document survived (no reload): same-document
== 2. pagination + back button
page 2 names: ["Farah Sheikh","Gauri Deshpande","Harsh Mehta","Isha Rao","Jayesh Pawar"]
address bar: /contacts?page=1
Back request: GET /contacts?q= HX-Request=true HX-History-Restore-Request=true
after Back: h1="Contacts", pager="Page 1 of 5 (23 contacts)"
== 3. inline edit with a validation error
row still editing, errors: ["Name is required","Email must look like name@host"]
saved, row is read-only again: ["Aarav K.","Bhavna Joshi","Chinmay Desai","Divya Nair","Eshaan Patil"]
== 4. delete with confirm
confirm dialog: "Delete Aarav K.?"
pager after delete: "Page 1 of 5 (22 contacts)"
requests caused by one delete: ["DELETE /contacts/1 HX-Request=true HX-History-Restore-Request=-","GET /contacts?page=0 HX-Request=true HX-History-Restore-Request=-"]
== 5. create form: 422 re-render, then success
errors shown in place: ["Name is required","Email must look like name@host"]
still on: /contacts/new, typed email kept: x
HX-Redirect landed on: /contacts
== 6. the two status-code traps, observed
DELETE answered 204: rows before=5, after=5 (row not removed)
htmx-config meta present: 0
Save answered 422 without the meta tag: errors visible = 0
== requests the browser made (excluding static files)
GET /contacts HX-Request=- HX-History-Restore-Request=-
GET /contacts?q=kamat HX-Request=true HX-History-Restore-Request=-
GET /contacts?q= HX-Request=true HX-History-Restore-Request=-
GET /contacts?page=1 HX-Request=true HX-History-Restore-Request=-
GET /contacts?q= HX-Request=true HX-History-Restore-Request=true
GET /contacts/1/edit HX-Request=true HX-History-Restore-Request=-
PUT /contacts/1 HX-Request=true HX-History-Restore-Request=-
PUT /contacts/1 HX-Request=true HX-History-Restore-Request=-
DELETE /contacts/1 HX-Request=true HX-History-Restore-Request=-
GET /contacts?page=0 HX-Request=true HX-History-Restore-Request=-
GET /contacts/new HX-Request=- HX-History-Restore-Request=-
POST /contacts HX-Request=true HX-History-Restore-Request=-
POST /contacts HX-Request=true HX-History-Restore-Request=-
GET /contacts HX-Request=- HX-History-Restore-Request=-
DELETE /contacts/2 HX-Request=true HX-History-Restore-Request=-
GET /contacts HX-Request=- HX-History-Restore-Request=-
GET /contacts/2/edit HX-Request=true HX-History-Restore-Request=-
PUT /contacts/2 HX-Request=true HX-History-Restore-Request=-
+80
View File
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.2</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>thymeleaf-htmx</artifactId>
<version>1.0.0</version>
<name>thymeleaf-htmx</name>
<description>Server-side CRUD UI with Spring Boot 4, Thymeleaf and htmx: no JS framework</description>
<properties>
<java.version>25</java.version>
<htmx.version>2.0.11</htmx.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- htmx as a WebJar: the jar is pinned in this pom, served from /webjars/**. -->
<dependency>
<groupId>org.webjars.npm</groupId>
<artifactId>htmx.org</artifactId>
<version>${htmx.version}</version>
</dependency>
<!-- lets templates ask for /webjars/htmx.org/dist/htmx.min.js without the version -->
<dependency>
<groupId>org.webjars</groupId>
<artifactId>webjars-locator-lite</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Needs Node 20+ and a one-time `npx playwright install chromium`. Starts a FRESH jar (data is mutated).
set -euo pipefail
cd "$(dirname "$0")/.."
PORT=${PORT:-18083}
java -Xmx256m -jar target/thymeleaf-htmx-1.0.0.jar --server.port=$PORT > /tmp/thymeleaf-htmx-browser.log 2>&1 &
PID=$!
trap 'kill $PID 2>/dev/null || true' EXIT
for i in $(seq 1 60); do curl -s -o /dev/null localhost:$PORT/contacts && break; sleep 1; done
(cd browser-check && npm install --silent && node check.mjs http://localhost:$PORT)
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Boots the packaged jar on a private port and shows the same URL answering twice.
set -euo pipefail
cd "$(dirname "$0")/.."
PORT=${PORT:-18082}
java -Xmx256m -jar target/thymeleaf-htmx-1.0.0.jar --server.port=$PORT > /tmp/thymeleaf-htmx-live.log 2>&1 &
PID=$!
trap 'kill $PID 2>/dev/null || true' EXIT
for i in $(seq 1 60); do curl -s -o /dev/null localhost:$PORT/contacts && break; sleep 1; done
B=http://localhost:$PORT
echo "\$ curl -si $B/contacts | tr -d '\r' | grep -v -E '^(Date|Transfer-Encoding)' | head -10"
curl -si $B/contacts | tr -d '\r' | grep -v -E '^(Date|Transfer-Encoding)' | head -10
echo
echo "\$ curl -si -H 'HX-Request: true' $B/contacts | tr -d '\r' | grep -v -E '^(Date|Transfer-Encoding)' | head -8"
curl -si -H 'HX-Request: true' $B/contacts | tr -d '\r' | grep -v -E '^(Date|Transfer-Encoding)' | head -8
echo
echo "\$ curl -s -o /dev/null -w '%{http_code} %{content_type}\n' $B/webjars/htmx.org/dist/htmx.min.js"
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' $B/webjars/htmx.org/dist/htmx.min.js
echo
echo "\$ curl -si -X DELETE -H 'HX-Request: true' $B/contacts/23"
curl -si -X DELETE -H 'HX-Request: true' $B/contacts/23 | tr -d '\r' | grep -v '^Date'
echo
echo "\$ curl -si -X DELETE -H 'HX-Request: true' $B/contacts/23 (again)"
curl -si -X DELETE -H 'HX-Request: true' $B/contacts/23 | head -1
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Regenerates every file in output/ : the test suite writes the MockMvc transcripts,
# then a real Tomcat is started and driven with curl for 10-live-*.txt.
set -euo pipefail
cd "$(dirname "$0")/.."
rm -rf output && mkdir -p output
mvn -B -q test > /tmp/thymeleaf-htmx-mvn.log 2>&1 || { cat /tmp/thymeleaf-htmx-mvn.log; exit 1; }
mvn -B -q -DskipTests package > /dev/null 2>&1
./scripts/live-demo.sh > output/10-live-curl.txt
grep -E 'Tests run' target/surefire-reports/*.txt | sed 's#.*/##' > output/00-mvn-test.txt
if [ -d browser-check/node_modules ] || command -v node >/dev/null; then ./scripts/browser-check.sh > output/11-browser-check.txt; fi
ls output
@@ -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());
}
}