From 99a8a0df42ad1c7cbeaaed28162812d7470c530d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 18:23:46 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_017ic4b7rnC7jppGGdHGESvy --- README.md | 1 + thymeleaf-htmx/.gitignore | 3 + thymeleaf-htmx/README.md | 60 +++++ thymeleaf-htmx/browser-check/check.mjs | 116 +++++++++ thymeleaf-htmx/browser-check/package.json | 2 + thymeleaf-htmx/output/00-mvn-test.txt | 1 + .../output/01-full-page-vs-fragment.txt | 190 ++++++++++++++ .../02-history-restore-gets-full-page.txt | 111 ++++++++ thymeleaf-htmx/output/03-htmx-webjar.txt | 3 + .../output/04-pagination-and-search.txt | 138 ++++++++++ .../output/05-create-validation-errors.txt | 64 +++++ thymeleaf-htmx/output/06-create-success.txt | 16 ++ thymeleaf-htmx/output/07-inline-edit.txt | 63 +++++ thymeleaf-htmx/output/08-delete.txt | 15 ++ .../output/09-delete-last-page-clamps.txt | 79 ++++++ thymeleaf-htmx/output/10-live-curl.txt | 35 +++ thymeleaf-htmx/output/11-browser-check.txt | 52 ++++ thymeleaf-htmx/pom.xml | 80 ++++++ thymeleaf-htmx/scripts/browser-check.sh | 10 + thymeleaf-htmx/scripts/live-demo.sh | 24 ++ thymeleaf-htmx/scripts/run-all.sh | 12 + .../com/ankurm/thymeleafhtmx/Contact.java | 45 ++++ .../thymeleafhtmx/ContactController.java | 152 +++++++++++ .../com/ankurm/thymeleafhtmx/ContactForm.java | 41 +++ .../thymeleafhtmx/ContactRepository.java | 15 ++ .../java/com/ankurm/thymeleafhtmx/Htmx.java | 18 ++ .../com/ankurm/thymeleafhtmx/SeedData.java | 27 ++ .../ThymeleafHtmxApplication.java | 11 + .../ankurm/thymeleafhtmx/VaryHtmxFilter.java | 23 ++ .../src/main/resources/application.properties | 2 + .../src/main/resources/static/css/app.css | 8 + .../templates/contacts/fragments.html | 45 ++++ .../resources/templates/contacts/list.html | 43 ++++ .../resources/templates/contacts/new.html | 30 +++ .../main/resources/templates/layout/base.html | 18 ++ .../ankurm/thymeleafhtmx/ContactUiTest.java | 237 ++++++++++++++++++ .../com/ankurm/thymeleafhtmx/Transcript.java | 50 ++++ 37 files changed, 1840 insertions(+) create mode 100644 thymeleaf-htmx/.gitignore create mode 100644 thymeleaf-htmx/README.md create mode 100644 thymeleaf-htmx/browser-check/check.mjs create mode 100644 thymeleaf-htmx/browser-check/package.json create mode 100644 thymeleaf-htmx/output/00-mvn-test.txt create mode 100644 thymeleaf-htmx/output/01-full-page-vs-fragment.txt create mode 100644 thymeleaf-htmx/output/02-history-restore-gets-full-page.txt create mode 100644 thymeleaf-htmx/output/03-htmx-webjar.txt create mode 100644 thymeleaf-htmx/output/04-pagination-and-search.txt create mode 100644 thymeleaf-htmx/output/05-create-validation-errors.txt create mode 100644 thymeleaf-htmx/output/06-create-success.txt create mode 100644 thymeleaf-htmx/output/07-inline-edit.txt create mode 100644 thymeleaf-htmx/output/08-delete.txt create mode 100644 thymeleaf-htmx/output/09-delete-last-page-clamps.txt create mode 100644 thymeleaf-htmx/output/10-live-curl.txt create mode 100644 thymeleaf-htmx/output/11-browser-check.txt create mode 100644 thymeleaf-htmx/pom.xml create mode 100755 thymeleaf-htmx/scripts/browser-check.sh create mode 100755 thymeleaf-htmx/scripts/live-demo.sh create mode 100755 thymeleaf-htmx/scripts/run-all.sh create mode 100644 thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/Contact.java create mode 100644 thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/ContactController.java create mode 100644 thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/ContactForm.java create mode 100644 thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/ContactRepository.java create mode 100644 thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/Htmx.java create mode 100644 thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/SeedData.java create mode 100644 thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/ThymeleafHtmxApplication.java create mode 100644 thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/VaryHtmxFilter.java create mode 100644 thymeleaf-htmx/src/main/resources/application.properties create mode 100644 thymeleaf-htmx/src/main/resources/static/css/app.css create mode 100644 thymeleaf-htmx/src/main/resources/templates/contacts/fragments.html create mode 100644 thymeleaf-htmx/src/main/resources/templates/contacts/list.html create mode 100644 thymeleaf-htmx/src/main/resources/templates/contacts/new.html create mode 100644 thymeleaf-htmx/src/main/resources/templates/layout/base.html create mode 100644 thymeleaf-htmx/src/test/java/com/ankurm/thymeleafhtmx/ContactUiTest.java create mode 100644 thymeleaf-htmx/src/test/java/com/ankurm/thymeleafhtmx/Transcript.java diff --git a/README.md b/README.md index c21d707..48345b7 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/thymeleaf-htmx/.gitignore b/thymeleaf-htmx/.gitignore new file mode 100644 index 0000000..3b56ea5 --- /dev/null +++ b/thymeleaf-htmx/.gitignore @@ -0,0 +1,3 @@ +target/ +browser-check/node_modules/ +browser-check/package-lock.json diff --git a/thymeleaf-htmx/README.md b/thymeleaf-htmx/README.md new file mode 100644 index 0000000..f43c2ec --- /dev/null +++ b/thymeleaf-htmx/README.md @@ -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. diff --git a/thymeleaf-htmx/browser-check/check.mjs b/thymeleaf-htmx/browser-check/check.mjs new file mode 100644 index 0000000..52e96a9 --- /dev/null +++ b/thymeleaf-htmx/browser-check/check.mjs @@ -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]', 'aarav.k@example.com'); +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]', 'zoya@example.com'); +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(//, ''); + 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(); diff --git a/thymeleaf-htmx/browser-check/package.json b/thymeleaf-htmx/browser-check/package.json new file mode 100644 index 0000000..e57bb40 --- /dev/null +++ b/thymeleaf-htmx/browser-check/package.json @@ -0,0 +1,2 @@ +{ "name": "thymeleaf-htmx-browser-check", "private": true, "type": "module", + "dependencies": { "playwright": "1.64.1" } } diff --git a/thymeleaf-htmx/output/00-mvn-test.txt b/thymeleaf-htmx/output/00-mvn-test.txt new file mode 100644 index 0000000..cc366e6 --- /dev/null +++ b/thymeleaf-htmx/output/00-mvn-test.txt @@ -0,0 +1 @@ +Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 11.50 s -- in com.ankurm.thymeleafhtmx.ContactUiTest diff --git a/thymeleaf-htmx/output/01-full-page-vs-fragment.txt b/thymeleaf-htmx/output/01-full-page-vs-fragment.txt new file mode 100644 index 0000000..8e9b191 --- /dev/null +++ b/thymeleaf-htmx/output/01-full-page-vs-fragment.txt @@ -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) ---- + + + + + + Contacts + + + + + + +
Contacts
+
+

Contacts

+ +

New contact

+ +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameEmailRole
Aarav Kulkarniaarav.kulkarni@example.comDeveloper + + +
Bhavna Joshibhavna.joshi@example.comTester + + +
Chinmay Desaichinmay.desai@example.comArchitect + + +
Divya Nairdivya.nair@example.comManager + + +
Eshaan Patileshaan.patil@example.comDeveloper + + +
+ +
+
+ + + +# 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) ---- +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameEmailRole
Aarav Kulkarniaarav.kulkarni@example.comDeveloper + + +
Bhavna Joshibhavna.joshi@example.comTester + + +
Chinmay Desaichinmay.desai@example.comArchitect + + +
Divya Nairdivya.nair@example.comManager + + +
Eshaan Patileshaan.patil@example.comDeveloper + + +
+ +
+ diff --git a/thymeleaf-htmx/output/02-history-restore-gets-full-page.txt b/thymeleaf-htmx/output/02-history-restore-gets-full-page.txt new file mode 100644 index 0000000..e414816 --- /dev/null +++ b/thymeleaf-htmx/output/02-history-restore-gets-full-page.txt @@ -0,0 +1,111 @@ +# htmx sends BOTH headers when the history cache misses; it swaps the reply into +# 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) ---- + + + + + + Contacts + + + + + + +
Contacts
+
+

Contacts

+ +

New contact

+ +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameEmailRole
Farah Sheikhfarah.sheikh@example.comTester + + +
Gauri Deshpandegauri.deshpande@example.comArchitect + + +
Harsh Mehtaharsh.mehta@example.comManager + + +
Isha Raoisha.rao@example.comDeveloper + + +
Jayesh Pawarjayesh.pawar@example.comTester + + +
+ +
+
+ + + diff --git a/thymeleaf-htmx/output/03-htmx-webjar.txt b/thymeleaf-htmx/output/03-htmx-webjar.txt new file mode 100644 index 0000000..f3ca9a5 --- /dev/null +++ b/thymeleaf-htmx/output/03-htmx-webjar.txt @@ -0,0 +1,3 @@ +# script tag as rendered (the template asks for /webjars/htmx.org/dist/htmx.min.js, no version) +# +# GET /webjars/htmx.org/2.0.11/dist/htmx.min.js -> 200, 52182 bytes, Content-Type text/javascript diff --git a/thymeleaf-htmx/output/04-pagination-and-search.txt b/thymeleaf-htmx/output/04-pagination-and-search.txt new file mode 100644 index 0000000..cd08e2e --- /dev/null +++ b/thymeleaf-htmx/output/04-pagination-and-search.txt @@ -0,0 +1,138 @@ +# request (curl notation, sent through MockMvc): curl -H 'HX-Request: true' '/contacts?page=4' +HTTP 200 +---- body (1763 chars) ---- +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameEmailRole
Uma Pillaiuma.pillai@example.comDeveloper + + +
Vikram Singhvikram.singh@example.comTester + + +
Yash Thakuryash.thakur@example.comArchitect + + +
+ +
+ +# request (curl notation, sent through MockMvc): curl -H 'HX-Request: true' '/contacts?page=99' +HTTP 200 +---- body (1763 chars) ---- +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameEmailRole
Uma Pillaiuma.pillai@example.comDeveloper + + +
Vikram Singhvikram.singh@example.comTester + + +
Yash Thakuryash.thakur@example.comArchitect + + +
+ +
+ +# request (curl notation, sent through MockMvc): curl -H 'HX-Request: true' '/contacts?q=KAMAT' +HTTP 200 +---- body (828 chars) ---- +
+ + + + + + + + + + + + + +
NameEmailRole
Sneha Kamatsneha.kamat@example.comArchitect + + +
+ +
+ diff --git a/thymeleaf-htmx/output/05-create-validation-errors.txt b/thymeleaf-htmx/output/05-create-validation-errors.txt new file mode 100644 index 0000000..7d74404 --- /dev/null +++ b/thymeleaf-htmx/output/05-create-validation-errors.txt @@ -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) ---- +
+

+ + Name is required +

+

+ + Email must look like name@host +

+

+ + +

+ + Cancel +
+ +# request (curl notation, sent through MockMvc): curl -d 'name=&email=x@y.z&role=Tester' /contacts (no JavaScript, no HX header) +HTTP 422 +---- body (1461 chars) ---- + + + + + + New contact + + + + + + +
Contacts
+
+

New contact

+ + +
+

+ + Name is required +

+

+ + +

+

+ + +

+ + Cancel +
+
+ + + diff --git a/thymeleaf-htmx/output/06-create-success.txt b/thymeleaf-htmx/output/06-create-success.txt new file mode 100644 index 0000000..eceb77f --- /dev/null +++ b/thymeleaf-htmx/output/06-create-success.txt @@ -0,0 +1,16 @@ +# request (curl notation, sent through MockMvc): curl -H 'HX-Request: true' -d 'name=Zoya Khan&email=zoya@example.com&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=zubin@example.com&role=Tester' /contacts +HTTP 302 +Vary: HX-Request +Vary: HX-History-Restore-Request +Location: /contacts +---- body (0 chars) ---- + + diff --git a/thymeleaf-htmx/output/07-inline-edit.txt b/thymeleaf-htmx/output/07-inline-edit.txt new file mode 100644 index 0000000..13243d3 --- /dev/null +++ b/thymeleaf-htmx/output/07-inline-edit.txt @@ -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) ---- + + + + + + + + + + + + + + + + + + + +# 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) ---- + + + + Name is required + + + + Email must look like name@host + + + + + + + + + + + +# request (curl notation, sent through MockMvc): curl -X PUT -H 'HX-Request: true' -d 'name=Aarav K.&email=aarav@example.com&role=Architect' /contacts/1 (Save, valid) +HTTP 200 +---- body (382 chars) ---- + + Aarav K. + aarav@example.com + Architect + + + + + + diff --git a/thymeleaf-htmx/output/08-delete.txt b/thymeleaf-htmx/output/08-delete.txt new file mode 100644 index 0000000..193ea62 --- /dev/null +++ b/thymeleaf-htmx/output/08-delete.txt @@ -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) ---- + + diff --git a/thymeleaf-htmx/output/09-delete-last-page-clamps.txt b/thymeleaf-htmx/output/09-delete-last-page-clamps.txt new file mode 100644 index 0000000..82ff849 --- /dev/null +++ b/thymeleaf-htmx/output/09-delete-last-page-clamps.txt @@ -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) ---- +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameEmailRole
Pooja Vermapooja.verma@example.comManager + + +
Qasim Ansariqasim.ansari@example.comDeveloper + + +
Rohan Sawantrohan.sawant@example.comTester + + +
Sneha Kamatsneha.kamat@example.comArchitect + + +
Tanmay Ghoshtanmay.ghosh@example.comManager + + +
+ +
+ diff --git a/thymeleaf-htmx/output/10-live-curl.txt b/thymeleaf-htmx/output/10-live-curl.txt new file mode 100644 index 0000000..ed1fc9a --- /dev/null +++ b/thymeleaf-htmx/output/10-live-curl.txt @@ -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 + + + + + + +$ 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 + +
+ + +$ 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 diff --git a/thymeleaf-htmx/output/11-browser-check.txt b/thymeleaf-htmx/output/11-browser-check.txt new file mode 100644 index 0000000..4e02302 --- /dev/null +++ b/thymeleaf-htmx/output/11-browser-check.txt @@ -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=- diff --git a/thymeleaf-htmx/pom.xml b/thymeleaf-htmx/pom.xml new file mode 100644 index 0000000..482097b --- /dev/null +++ b/thymeleaf-htmx/pom.xml @@ -0,0 +1,80 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.2 + + + + com.ankurm + thymeleaf-htmx + 1.0.0 + thymeleaf-htmx + Server-side CRUD UI with Spring Boot 4, Thymeleaf and htmx: no JS framework + + + 25 + 2.0.11 + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.h2database + h2 + runtime + + + + + org.webjars.npm + htmx.org + ${htmx.version} + + + + org.webjars + webjars-locator-lite + + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.boot + spring-boot-starter-thymeleaf-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/thymeleaf-htmx/scripts/browser-check.sh b/thymeleaf-htmx/scripts/browser-check.sh new file mode 100755 index 0000000..8817c54 --- /dev/null +++ b/thymeleaf-htmx/scripts/browser-check.sh @@ -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) diff --git a/thymeleaf-htmx/scripts/live-demo.sh b/thymeleaf-htmx/scripts/live-demo.sh new file mode 100755 index 0000000..32deffb --- /dev/null +++ b/thymeleaf-htmx/scripts/live-demo.sh @@ -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 diff --git a/thymeleaf-htmx/scripts/run-all.sh b/thymeleaf-htmx/scripts/run-all.sh new file mode 100755 index 0000000..a3860d1 --- /dev/null +++ b/thymeleaf-htmx/scripts/run-all.sh @@ -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 diff --git a/thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/Contact.java b/thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/Contact.java new file mode 100644 index 0000000..fd2fa36 --- /dev/null +++ b/thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/Contact.java @@ -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; } +} diff --git a/thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/ContactController.java b/thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/ContactController.java new file mode 100644 index 0000000..587d7cd --- /dev/null +++ b/thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/ContactController.java @@ -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 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 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 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)); + } +} diff --git a/thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/ContactForm.java b/thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/ContactForm.java new file mode 100644 index 0000000..ed6a46c --- /dev/null +++ b/thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/ContactForm.java @@ -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; } +} diff --git a/thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/ContactRepository.java b/thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/ContactRepository.java new file mode 100644 index 0000000..edd1c23 --- /dev/null +++ b/thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/ContactRepository.java @@ -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 { + + Page findByNameContainingIgnoreCaseOrEmailContainingIgnoreCase( + String name, String email, Pageable pageable); + + boolean existsByEmailIgnoreCase(String email); + + boolean existsByEmailIgnoreCaseAndIdNot(String email, Long id); +} diff --git a/thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/Htmx.java b/thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/Htmx.java new file mode 100644 index 0000000..af805dc --- /dev/null +++ b/thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/Htmx.java @@ -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; + } +} diff --git a/thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/SeedData.java b/thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/SeedData.java new file mode 100644 index 0000000..ec73e26 --- /dev/null +++ b/thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/SeedData.java @@ -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])); + } + }; + } +} diff --git a/thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/ThymeleafHtmxApplication.java b/thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/ThymeleafHtmxApplication.java new file mode 100644 index 0000000..06f97f5 --- /dev/null +++ b/thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/ThymeleafHtmxApplication.java @@ -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); + } +} diff --git a/thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/VaryHtmxFilter.java b/thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/VaryHtmxFilter.java new file mode 100644 index 0000000..52168c4 --- /dev/null +++ b/thymeleaf-htmx/src/main/java/com/ankurm/thymeleafhtmx/VaryHtmxFilter.java @@ -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); + } +} diff --git a/thymeleaf-htmx/src/main/resources/application.properties b/thymeleaf-htmx/src/main/resources/application.properties new file mode 100644 index 0000000..335470b --- /dev/null +++ b/thymeleaf-htmx/src/main/resources/application.properties @@ -0,0 +1,2 @@ +spring.application.name=thymeleaf-htmx +spring.jpa.open-in-view=false diff --git a/thymeleaf-htmx/src/main/resources/static/css/app.css b/thymeleaf-htmx/src/main/resources/static/css/app.css new file mode 100644 index 0000000..244e1d9 --- /dev/null +++ b/thymeleaf-htmx/src/main/resources/static/css/app.css @@ -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; } diff --git a/thymeleaf-htmx/src/main/resources/templates/contacts/fragments.html b/thymeleaf-htmx/src/main/resources/templates/contacts/fragments.html new file mode 100644 index 0000000..70a5546 --- /dev/null +++ b/thymeleaf-htmx/src/main/resources/templates/contacts/fragments.html @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/thymeleaf-htmx/src/main/resources/templates/contacts/list.html b/thymeleaf-htmx/src/main/resources/templates/contacts/list.html new file mode 100644 index 0000000..3002efd --- /dev/null +++ b/thymeleaf-htmx/src/main/resources/templates/contacts/list.html @@ -0,0 +1,43 @@ + + +Contacts + +
+

Contacts

+ +

New contact

+ +
+ + + + + +
+
Nameemailrole + + +
+ + error + + + error + + + error + + + +
+ + + + + + + +
NameEmailRole
No contacts match.
+ +
+ + + diff --git a/thymeleaf-htmx/src/main/resources/templates/contacts/new.html b/thymeleaf-htmx/src/main/resources/templates/contacts/new.html new file mode 100644 index 0000000..363c676 --- /dev/null +++ b/thymeleaf-htmx/src/main/resources/templates/contacts/new.html @@ -0,0 +1,30 @@ + + +New contact + +
+

New contact

+ + +
+

+ + error +

+

+ + error +

+

+ + error +

+ + Cancel +
+
+ + diff --git a/thymeleaf-htmx/src/main/resources/templates/layout/base.html b/thymeleaf-htmx/src/main/resources/templates/layout/base.html new file mode 100644 index 0000000..8bbfd18 --- /dev/null +++ b/thymeleaf-htmx/src/main/resources/templates/layout/base.html @@ -0,0 +1,18 @@ + + + + + + Contacts + + + + + + +
Contacts
+
page content goes here
+ + diff --git a/thymeleaf-htmx/src/test/java/com/ankurm/thymeleafhtmx/ContactUiTest.java b/thymeleaf-htmx/src/test/java/com/ankurm/thymeleafhtmx/ContactUiTest.java new file mode 100644 index 0000000..c9b0e05 --- /dev/null +++ b/thymeleaf-htmx/src/test/java/com/ankurm/thymeleafhtmx/ContactUiTest.java @@ -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("", "Contacts", "
", ""); + assertThat(h).doesNotContain("", "
"); + assertThat(h.strip()).startsWith("
"); + assertThat(h).contains("
", "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("", "Contacts"); + new Transcript("02-history-restore-gets-full-page.txt") + .note("htmx sends BOTH headers when the history cache misses; it swaps the reply into ") + .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(""); + 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("", "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=x@y.z&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", "AARAV.KULKARNI@example.com").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", "zoya@example.com").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", "zubin@example.com").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=zoya@example.com&role=Developer' /contacts", hx, HEADERS) + .exchange("curl -d 'name=Zubin Irani&email=zubin@example.com&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("Aarav K.").doesNotContain("class=\"editing\""); + assertThat(repo.findById(id).orElseThrow().getEmail()).isEqualTo("aarav@example.com"); + + MvcResult cancel = mvc.perform(get("/contacts/" + id).header("HX-Request", "true")).andReturn(); + assertThat(cancel.getResponse().getContentAsString()).contains(""); + + 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=aarav@example.com&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(); + } +} diff --git a/thymeleaf-htmx/src/test/java/com/ankurm/thymeleafhtmx/Transcript.java b/thymeleaf-htmx/src/test/java/com/ankurm/thymeleafhtmx/Transcript.java new file mode 100644 index 0000000..b28d201 --- /dev/null +++ b/thymeleaf-htmx/src/test/java/com/ankurm/thymeleafhtmx/Transcript.java @@ -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 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()); + } +}
Aarav K.