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:
@@ -9,6 +9,7 @@ files.
|
||||
|
||||
| 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 |
|
||||
| [`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 |
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
target/
|
||||
browser-check/node_modules/
|
||||
browser-check/package-lock.json
|
||||
@@ -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.
|
||||
@@ -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" } }
|
||||
@@ -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 »</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 »</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">« 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 »</a>
|
||||
</nav>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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">« 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">« 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&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) ----
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">« Prev</a>
|
||||
<span>Page 4 of 4 (20 contacts)</span>
|
||||
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -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=-
|
||||
@@ -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>
|
||||
Executable
+10
@@ -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)
|
||||
Executable
+24
@@ -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
|
||||
Executable
+12
@@ -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">« 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