From cebdcadaaf69553273c0ec50fe7ae602d7284b4e Mon Sep 17 00:00:00 2001 From: James Fredley Date: Sun, 3 May 2026 15:47:13 -0400 Subject: [PATCH] Add grails-htmx v8 guide Worked example for HTMX-driven interactive UI on a Grails 8 GSP backend. A small task tracker with server-rendered initial page, then HTMX-driven inline editing, live search, toggle, and optimistic delete with confirm. Sample-app flavour. Greenfield (no upstream rename) - the new grails-guides/grails-htmx repo was created public on the grails8 default branch. The 14 chapters walk through: - Getting Started (whatYouWillBuild, requirements, howto) - Creating the Application - The Task Domain Class - Adding HTMX to the Layout (script tag with SRI hash) - URL Mappings (explicit method per route) - The TaskController (7 actions, 6 of which return GSP partials) - The Initial Page (server-rendered first paint) - The Row Partial (one template, four call sites) - Inline Edit With Validation (422 returns the same edit fragment with field errors rendered inline) - Live Search (hx-trigger="keyup changed delay:300ms") - CSRF With HTMX (htmx:configRequest hook reads token from a meta tag) - HTMX vs an SPA (when each fits, cross-references the upcoming grails-vite-spa guide) Verified locally: ./gradlew validateGuides -PvalidationMode=both [validateGuides] mode=both: 86 guide(s) parsed, 0 SKIP-warned, 0 errors ./gradlew renderGuide_grails_htmx_8 --rerun-tasks --no-configuration-cache Renders all 14 chapter HTML pages, no Unresolved directive errors. Assisted-by: claude-code:claude-opus-4-7 --- conf/guides.yml | 51 +++++++++++++ guides/grails-htmx/v8/guide/controller.adoc | 14 ++++ guides/grails-htmx/v8/guide/createApp.adoc | 11 +++ guides/grails-htmx/v8/guide/csrf.adoc | 23 ++++++ .../grails-htmx/v8/guide/gettingStarted.adoc | 5 ++ .../grails-htmx/v8/guide/helpWithGrails.adoc | 1 + guides/grails-htmx/v8/guide/howto.adoc | 11 +++ guides/grails-htmx/v8/guide/indexPage.adoc | 15 ++++ guides/grails-htmx/v8/guide/inlineEdit.adoc | 13 ++++ .../grails-htmx/v8/guide/layoutWithHtmx.adoc | 11 +++ guides/grails-htmx/v8/guide/liveSearch.adoc | 31 ++++++++ guides/grails-htmx/v8/guide/requirements.adoc | 4 + guides/grails-htmx/v8/guide/rowPartial.adoc | 28 +++++++ .../grails-htmx/v8/guide/spaComparison.adoc | 16 ++++ guides/grails-htmx/v8/guide/taskDomain.adoc | 9 +++ guides/grails-htmx/v8/guide/urlMappings.adoc | 9 +++ .../v8/guide/whatYouWillBuild.adoc | 9 +++ .../controllers/example/TaskController.groovy | 76 +++++++++++++++++++ .../controllers/example/UrlMappings.groovy | 20 +++++ .../grails-app/domain/example/Task.groovy | 20 +++++ .../grails-app/views/layouts/main.gsp | 32 ++++++++ .../snippets/grails-app/views/task/_task.gsp | 31 ++++++++ .../grails-app/views/task/_taskEdit.gsp | 23 ++++++ .../grails-app/views/task/_taskRows.gsp | 6 ++ .../snippets/grails-app/views/task/index.gsp | 40 ++++++++++ 25 files changed, 509 insertions(+) create mode 100644 guides/grails-htmx/v8/guide/controller.adoc create mode 100644 guides/grails-htmx/v8/guide/createApp.adoc create mode 100644 guides/grails-htmx/v8/guide/csrf.adoc create mode 100644 guides/grails-htmx/v8/guide/gettingStarted.adoc create mode 100644 guides/grails-htmx/v8/guide/helpWithGrails.adoc create mode 100644 guides/grails-htmx/v8/guide/howto.adoc create mode 100644 guides/grails-htmx/v8/guide/indexPage.adoc create mode 100644 guides/grails-htmx/v8/guide/inlineEdit.adoc create mode 100644 guides/grails-htmx/v8/guide/layoutWithHtmx.adoc create mode 100644 guides/grails-htmx/v8/guide/liveSearch.adoc create mode 100644 guides/grails-htmx/v8/guide/requirements.adoc create mode 100644 guides/grails-htmx/v8/guide/rowPartial.adoc create mode 100644 guides/grails-htmx/v8/guide/spaComparison.adoc create mode 100644 guides/grails-htmx/v8/guide/taskDomain.adoc create mode 100644 guides/grails-htmx/v8/guide/urlMappings.adoc create mode 100644 guides/grails-htmx/v8/guide/whatYouWillBuild.adoc create mode 100644 guides/grails-htmx/v8/snippets/grails-app/controllers/example/TaskController.groovy create mode 100644 guides/grails-htmx/v8/snippets/grails-app/controllers/example/UrlMappings.groovy create mode 100644 guides/grails-htmx/v8/snippets/grails-app/domain/example/Task.groovy create mode 100644 guides/grails-htmx/v8/snippets/grails-app/views/layouts/main.gsp create mode 100644 guides/grails-htmx/v8/snippets/grails-app/views/task/_task.gsp create mode 100644 guides/grails-htmx/v8/snippets/grails-app/views/task/_taskEdit.gsp create mode 100644 guides/grails-htmx/v8/snippets/grails-app/views/task/_taskRows.gsp create mode 100644 guides/grails-htmx/v8/snippets/grails-app/views/task/index.gsp diff --git a/conf/guides.yml b/conf/guides.yml index 9a0bcf68f2c..cd22255f5de 100644 --- a/conf/guides.yml +++ b/conf/guides.yml @@ -2429,6 +2429,57 @@ guides: helpWithGrails: title: Help with Grails + - name: 'grails-htmx' + title: 'HTMX with Grails 8' + subtitle: 'Build a small task tracker with server-rendered Grails 8 GSP and HTMX-driven inline editing, live search, optimistic delete, and toggle - no SPA, no JSON.' + authors: + - 'James Fredley' + category: 'Web Layer' + publicationDate: '2026-05-03' + versions: + '8': + sourcePath: guides/grails-htmx/v8 + publicationDate: '2026-05-03' + tags: + - 'htmx' + - 'gsp' + - 'partials' + - 'no-spa' + - 'grails8' + sampleRef: + repo: 'grails-guides/grails-htmx' + branch: 'grails8' + toc: + gettingStarted: + title: Getting Started + whatYouWillBuild: What You Will Build + requirements: What You Will Need + howto: How to Complete the Guide + createApp: + title: Creating the Application + taskDomain: + title: The Task Domain Class + layoutWithHtmx: + title: Adding HTMX to the Layout + urlMappings: + title: URL Mappings + controller: + title: The TaskController + indexPage: + title: The Initial Page + rowPartial: + title: The Row Partial + inlineEdit: + title: Inline Edit With Validation + liveSearch: + title: Live Search + csrf: + title: CSRF With HTMX + spaComparison: + title: HTMX vs an SPA + helpWithGrails: + title: Do you need help with Grails? + - name: 'grails-javamelody' title: 'JavaMelody monitoring with Grails 3' subtitle: 'Learn how to setup and monitor your application using JavaMelody' diff --git a/guides/grails-htmx/v8/guide/controller.adoc b/guides/grails-htmx/v8/guide/controller.adoc new file mode 100644 index 00000000000..57e945df115 --- /dev/null +++ b/guides/grails-htmx/v8/guide/controller.adoc @@ -0,0 +1,14 @@ +`TaskController` has seven actions, but only `index` returns a full page. Every other action returns a single GSP partial via `render template: '...'`: + +[source,groovy] +.grails-app/controllers/example/TaskController.groovy +---- +include::../snippets/grails-app/controllers/example/TaskController.groovy[] +---- + +A few patterns to point at: + +* `static allowedMethods = [...]` rejects mismatched HTTP verbs at the framework level. Sending `GET /tasks` to `create` returns 405, not silently calling the action. +* The `@Transactional(readOnly = true)` class annotation flips read-only mode on for the whole controller; individual mutating actions override with their own `@Transactional`. +* On a validation failure (`!t.save()`), the controller returns a 422 with the error fragment. HTMX swaps that fragment into the page, the user sees the error inline, and the form remains in the DOM ready for another attempt. +* `delete` returns an empty body. Combined with `hx-swap="outerHTML"` on the delete button, this removes the row from the DOM. diff --git a/guides/grails-htmx/v8/guide/createApp.adoc b/guides/grails-htmx/v8/guide/createApp.adoc new file mode 100644 index 00000000000..0ba7cae7c51 --- /dev/null +++ b/guides/grails-htmx/v8/guide/createApp.adoc @@ -0,0 +1,11 @@ +Generate a fresh Apache Grails 8 web application from link:https://prev-snapshot.grails.org[prev-snapshot.grails.org]: + +[source,bash] +---- +curl -L -o htmx-app.zip \ + "https://prev-snapshot.grails.org/create/web/example.htmxtasks?lang=GROOVY&build=GRADLE&test=SPOCK&javaVersion=JDK_21" +unzip htmx-app.zip +cd htmxtasks +---- + +The default `web` starter ships GSP, the asset pipeline, and Bootstrap 5 webjars - all of which we will reuse. diff --git a/guides/grails-htmx/v8/guide/csrf.adoc b/guides/grails-htmx/v8/guide/csrf.adoc new file mode 100644 index 00000000000..a8b21a72ce0 --- /dev/null +++ b/guides/grails-htmx/v8/guide/csrf.adoc @@ -0,0 +1,23 @@ +Grails' `` tag automatically inserts a `SYNCHRONIZER_TOKEN` parameter when the controller declares `static withForm = ...`. HTMX requests do not go through ``, so the token has to be wired in differently. + +The HTMX configuration block in `main.gsp` reads the token from a `` tag and forwards it as a custom header on every request: + +[source,html] +---- + + +---- + +A matching `before` interceptor checks the header on every state-changing request and rejects mismatches with a 403. For a sample app the token check is overkill; for a real back-office the few lines of plumbing buy you the same protection `` provides for traditional forms. + +[NOTE] +==== +The `Origin` and `Referer` headers also defeat CSRF for fetch-based clients. Modern browsers send them automatically and a same-origin check is one line of interceptor code. Belt-and-braces shops do both; pick the discipline that matches your threat model. +==== diff --git a/guides/grails-htmx/v8/guide/gettingStarted.adoc b/guides/grails-htmx/v8/guide/gettingStarted.adoc new file mode 100644 index 00000000000..40b3cdd7f27 --- /dev/null +++ b/guides/grails-htmx/v8/guide/gettingStarted.adoc @@ -0,0 +1,5 @@ +In this guide you will build a small task tracker on Apache Grails 8 with link:https://htmx.org[HTMX] driving the UI - server-rendered initial page, then HTMX-driven inline editing, live search, optimistic delete with confirm, and toggle, all without committing to a full SPA. + +The pattern is a natural fit for Grails: GSP partials become HTMX response fragments. Your controllers return small chunks of HTML instead of JSON; HTMX swaps them into the right slot. Forms post to controllers via `hx-post` and validation errors come back as the same partial with the errors rendered inline. + +This guide targets Apache Grails 8 / HTMX 2.x. diff --git a/guides/grails-htmx/v8/guide/helpWithGrails.adoc b/guides/grails-htmx/v8/guide/helpWithGrails.adoc new file mode 100644 index 00000000000..e062f614b1a --- /dev/null +++ b/guides/grails-htmx/v8/guide/helpWithGrails.adoc @@ -0,0 +1 @@ +include::{commondir}/common-helpWithGrails.adoc[] diff --git a/guides/grails-htmx/v8/guide/howto.adoc b/guides/grails-htmx/v8/guide/howto.adoc new file mode 100644 index 00000000000..be41d409ba9 --- /dev/null +++ b/guides/grails-htmx/v8/guide/howto.adoc @@ -0,0 +1,11 @@ +You can either type the code in this guide as you read or skip ahead and clone the finished sample: + +[source,bash] +---- +git clone -b grails8 https://github.com/grails-guides/grails-htmx.git +cd grails-htmx/complete +./gradlew bootRun +# open http://localhost:8080/tasks +---- + +`initial/` is a vanilla Grails 8 starter. `complete/` adds the Task domain, the controller, the GSP partials, and the HTMX script tag. diff --git a/guides/grails-htmx/v8/guide/indexPage.adoc b/guides/grails-htmx/v8/guide/indexPage.adoc new file mode 100644 index 00000000000..ccc6259c15e --- /dev/null +++ b/guides/grails-htmx/v8/guide/indexPage.adoc @@ -0,0 +1,15 @@ +The full page is rendered once on `GET /tasks`: + +[source,html] +.grails-app/views/task/index.gsp +---- +include::../snippets/grails-app/views/task/index.gsp[] +---- + +Three HTMX patterns are in play: + +* The *add-task form* posts to `/tasks` (`hx-post`) and prepends the response to `#taskList` (`hx-target="#taskList"`, `hx-swap="afterbegin"`). The `hx-on::after-request="this.reset()"` clears the input after a successful submission. +* The *live-search input* fires a `GET /tasks/search?q=...` whenever the user pauses typing for 300 ms (`hx-trigger="keyup changed delay:300ms"`). The response replaces the contents of `#taskList`. +* The *initial render* uses `` to populate the list server-side. HTMX takes over from there. + +This is the canonical HTMX recipe: render once on the server, then let small fragments shuttle in and out of well-defined slots in the DOM. diff --git a/guides/grails-htmx/v8/guide/inlineEdit.adoc b/guides/grails-htmx/v8/guide/inlineEdit.adoc new file mode 100644 index 00000000000..5ffa7a32522 --- /dev/null +++ b/guides/grails-htmx/v8/guide/inlineEdit.adoc @@ -0,0 +1,13 @@ +Clicking a task title swaps the row out for an edit form. Saving swaps it back. The state never leaves the server. + +[source,html] +.grails-app/views/task/_taskEdit.gsp +---- +include::../snippets/grails-app/views/task/_taskEdit.gsp[] +---- + +Three patterns to highlight: + +* The form's `hx-patch` posts to `/tasks/{id}` with the PATCH method. The response is either the read-only `_task.gsp` (success) or the same edit form with the validation errors rendered (failure). +* `` renders the field errors inline. Because Grails populates `task.errors` when `save()` fails, the same partial template handles both success and failure. The HTTP status (200 vs 422) is what tells HTMX which one - both swap into the same slot. +* The Cancel button does a `hx-get` for `/tasks/{id}` (which returns the read-only row partial) so cancelling does not require any new wiring. diff --git a/guides/grails-htmx/v8/guide/layoutWithHtmx.adoc b/guides/grails-htmx/v8/guide/layoutWithHtmx.adoc new file mode 100644 index 00000000000..9d284cb6acf --- /dev/null +++ b/guides/grails-htmx/v8/guide/layoutWithHtmx.adoc @@ -0,0 +1,11 @@ +HTMX is a single ~14 KB script. The starter's layout already loads stylesheets and the Grails javascript bundle; add the HTMX script tag in ``: + +[source,html] +.grails-app/views/layouts/main.gsp +---- +include::../snippets/grails-app/views/layouts/main.gsp[] +---- + +The `integrity=` attribute uses a Subresource Integrity hash that the browser verifies before executing - protecting you against a CDN compromise. Keep it in sync with the version in the `src` URL. + +For production, vendor the file under `grails-app/assets/javascripts/htmx.min.js` and load via `` so deployments do not depend on the CDN. diff --git a/guides/grails-htmx/v8/guide/liveSearch.adoc b/guides/grails-htmx/v8/guide/liveSearch.adoc new file mode 100644 index 00000000000..2db2d973b42 --- /dev/null +++ b/guides/grails-htmx/v8/guide/liveSearch.adoc @@ -0,0 +1,31 @@ +Live search is one input element with three HTMX attributes: + +[source,html] +---- + +---- + +The `hx-trigger="keyup changed delay:300ms"` modifier composition is the entire UX: + +* `keyup` - fire on every key release. +* `changed` - skip if the input value did not actually change since the last request (so arrow keys, modifiers, and Ctrl+A do not trigger a network round-trip). +* `delay:300ms` - wait 300 ms after the last keyup before sending. New keypresses cancel the pending request and reset the timer. + +The server-side action is one line of GORM: + +[source,groovy] +---- +def search() { + String q = (params.q ?: '') as String + List rows = q ? Task.findAllByTitleIlike("%${q}%") : Task.list() + render template: 'taskRows', model: [tasks: rows] +} +---- + +`findAllByTitleIlike` is GORM's case-insensitive LIKE; it forwards to a parameterised SQL query, so no SQL injection concerns. The `_taskRows.gsp` partial handles both the populated and empty states. diff --git a/guides/grails-htmx/v8/guide/requirements.adoc b/guides/grails-htmx/v8/guide/requirements.adoc new file mode 100644 index 00000000000..27148f50b79 --- /dev/null +++ b/guides/grails-htmx/v8/guide/requirements.adoc @@ -0,0 +1,4 @@ +To complete this guide you will need: + +* JDK 21 +* About 30 minutes diff --git a/guides/grails-htmx/v8/guide/rowPartial.adoc b/guides/grails-htmx/v8/guide/rowPartial.adoc new file mode 100644 index 00000000000..ec32447db02 --- /dev/null +++ b/guides/grails-htmx/v8/guide/rowPartial.adoc @@ -0,0 +1,28 @@ +Every row is a `_task.gsp` partial. The same partial is used by: + +* The initial server render (via `_taskRows.gsp` which iterates `_task.gsp`). +* The "create" response after a successful POST. +* The "toggle done" response. +* The "save edit" response. + +[source,html] +.grails-app/views/task/_task.gsp +---- +include::../snippets/grails-app/views/task/_task.gsp[] +---- + +Three HTMX-specific things in this partial: + +* The toggle-done button uses `hx-target="closest li"` `hx-swap="outerHTML"`. After the POST, the new HTML replaces *this entire row* in place. The `closest` modifier walks up the DOM until it finds an `
  • `. +* Clicking the title fires a `hx-get` for the edit form. Same swap target. The result is the inline edit form, replacing the read-only row. +* The delete button uses `hx-confirm` for an in-browser confirm dialog, `hx-swap="outerHTML swap:200ms"` for a 200 ms fade-out animation, and a DELETE that returns an empty body. + +The list partial is even simpler: + +[source,html] +.grails-app/views/task/_taskRows.gsp +---- +include::../snippets/grails-app/views/task/_taskRows.gsp[] +---- + +It iterates `_task.gsp` and falls back to a placeholder when the list is empty. diff --git a/guides/grails-htmx/v8/guide/spaComparison.adoc b/guides/grails-htmx/v8/guide/spaComparison.adoc new file mode 100644 index 00000000000..54e8d235277 --- /dev/null +++ b/guides/grails-htmx/v8/guide/spaComparison.adoc @@ -0,0 +1,16 @@ +When does HTMX fit and when do you reach for an SPA? + +HTMX wins when: + +* The UI is fundamentally CRUD - lists, forms, partial updates, optimistic deletes. The task tracker in this guide is the canonical shape. +* SEO matters - HTMX pages are server-rendered HTML on first paint, so search engines and link previews see the real content. +* The team is more comfortable on the server than in JavaScript - HTMX shifts complexity away from the SPA's reactive state model into plain controller actions and GSP partials. +* The deploy story is simpler when there is one bootJar instead of a backend + a frontend build pipeline. + +SPAs (React, Vue, Svelte) win when: + +* The UI is genuinely client-stateful - a real-time editor, a Kanban board with drag-and-drop, an analytics dashboard with cross-filter interactions. HTMX can do these but you fight the framework. +* Offline-first matters - HTMX is built around server round-trips by design. +* The same backend serves multiple frontends (web + native mobile) and you want a single JSON API surface for both. + +Most internal back-office apps land squarely in HTMX's sweet spot. The link:../grails-vite-spa/8/guide/index.html[Grails 8 + Vite SPA guide] covers the SPA path for the cases that need it. diff --git a/guides/grails-htmx/v8/guide/taskDomain.adoc b/guides/grails-htmx/v8/guide/taskDomain.adoc new file mode 100644 index 00000000000..28a92ecc217 --- /dev/null +++ b/guides/grails-htmx/v8/guide/taskDomain.adoc @@ -0,0 +1,9 @@ +A small Task domain class drives the UI: + +[source,groovy] +.grails-app/domain/example/Task.groovy +---- +include::../snippets/grails-app/domain/example/Task.groovy[] +---- + +`title blank: false, maxSize: 255` is the only constraint that matters for this guide; we will exercise it via the validation feedback path on inline edit. `dateCreated` is a GORM-managed timestamp. `static mapping { sort 'dateCreated'; order 'desc' }` makes new tasks appear at the top of the list. diff --git a/guides/grails-htmx/v8/guide/urlMappings.adoc b/guides/grails-htmx/v8/guide/urlMappings.adoc new file mode 100644 index 00000000000..df7e33b5572 --- /dev/null +++ b/guides/grails-htmx/v8/guide/urlMappings.adoc @@ -0,0 +1,9 @@ +Each HTMX endpoint is its own URL with an explicit method: + +[source,groovy] +.grails-app/controllers/example/UrlMappings.groovy +---- +include::../snippets/grails-app/controllers/example/UrlMappings.groovy[] +---- + +The repetition (`/tasks` for both index and create, distinguished by method) is intentional: HTMX requests carry their semantics in the HTTP method, exactly as REST does. Using `'/tasks'(resources: 'task')` would also work, but the explicit-route form makes the surface easier to read for an author who has never seen Grails' resource-mapping shorthand. diff --git a/guides/grails-htmx/v8/guide/whatYouWillBuild.adoc b/guides/grails-htmx/v8/guide/whatYouWillBuild.adoc new file mode 100644 index 00000000000..743ddd1b4e2 --- /dev/null +++ b/guides/grails-htmx/v8/guide/whatYouWillBuild.adoc @@ -0,0 +1,9 @@ +By the end of the guide your application will have: + +* A `Task` domain with `title` (required) and `done` (boolean), with sample data seeded by `BootStrap` so the page is non-empty on first load. +* A `TaskController` with seven actions: `index` (full page), `search` (live filter), `create` (new task), `editForm` + `update` (inline edit), `toggle` (flip the done flag), `delete`. Every non-`index` action returns a tiny GSP partial, not a full page. +* GSP partials at `_task.gsp`, `_taskRows.gsp`, and `_taskEdit.gsp` that the controller renders via ``. One partial per logical state. +* The four core HTMX attributes (`hx-get`, `hx-post`, `hx-patch`, `hx-delete`) plus the swap targeting attributes (`hx-target`, `hx-swap`). +* Live search via `hx-trigger="keyup changed delay:300ms"` so the rows partial refreshes after the user pauses typing. +* Optimistic delete with confirm via `hx-confirm` and an outerHTML swap that animates the row out. +* CSRF token forwarding configured globally so HTMX requests carry the right header on every POST/PATCH/DELETE. diff --git a/guides/grails-htmx/v8/snippets/grails-app/controllers/example/TaskController.groovy b/guides/grails-htmx/v8/snippets/grails-app/controllers/example/TaskController.groovy new file mode 100644 index 00000000000..293ce747e14 --- /dev/null +++ b/guides/grails-htmx/v8/snippets/grails-app/controllers/example/TaskController.groovy @@ -0,0 +1,76 @@ +package example + +import grails.gorm.transactions.Transactional +import org.springframework.http.HttpStatus + +@Transactional(readOnly = true) +class TaskController { + + static allowedMethods = [ + create: 'POST', update: 'PATCH', delete: 'DELETE', toggle: 'POST' + ] + + /** Full page (index.gsp). */ + def index() { + respond Task.list(params), model: [tasks: Task.list(params), q: ''] + } + + /** Live-search endpoint - returns just the rows partial. */ + def search() { + String q = (params.q ?: '') as String + List rows = q ? Task.findAllByTitleIlike("%${q}%") : Task.list() + render template: 'taskRows', model: [tasks: rows] + } + + /** Add a new task - returns the new row prepended to the list. */ + @Transactional + def create() { + Task t = new Task(title: params.title) + if (!t.save()) { + response.status = HttpStatus.UNPROCESSABLE_ENTITY.value() + render template: 'taskForm', model: [task: t] + return + } + render template: 'task', model: [task: t] + } + + /** Inline-edit endpoint - returns the edit form for a single row. */ + def editForm(Long id) { + Task t = Task.get(id) + if (!t) { response.status = 404; return } + render template: 'taskEdit', model: [task: t] + } + + /** Apply an inline edit - returns the read-only row partial. */ + @Transactional + def update(Long id) { + Task t = Task.get(id) + if (!t) { response.status = 404; return } + t.properties = params + if (!t.save()) { + response.status = HttpStatus.UNPROCESSABLE_ENTITY.value() + render template: 'taskEdit', model: [task: t] + return + } + render template: 'task', model: [task: t] + } + + /** Toggle the `done` flag - returns the row partial. */ + @Transactional + def toggle(Long id) { + Task t = Task.get(id) + if (!t) { response.status = 404; return } + t.done = !t.done + t.save() + render template: 'task', model: [task: t] + } + + /** Delete - HTMX swaps the row out via hx-target="closest tr" hx-swap="outerHTML". */ + @Transactional + def delete(Long id) { + Task t = Task.get(id) + if (!t) { response.status = 404; return } + t.delete() + render '' + } +} diff --git a/guides/grails-htmx/v8/snippets/grails-app/controllers/example/UrlMappings.groovy b/guides/grails-htmx/v8/snippets/grails-app/controllers/example/UrlMappings.groovy new file mode 100644 index 00000000000..6efdd1e1b17 --- /dev/null +++ b/guides/grails-htmx/v8/snippets/grails-app/controllers/example/UrlMappings.groovy @@ -0,0 +1,20 @@ +package example + +class UrlMappings { + + static mappings = { + + '/tasks'(controller: 'task', action: 'index') + '/tasks/search'(controller: 'task', action: 'search') + '/tasks'(controller: 'task', action: 'create', method: 'POST') + '/tasks/$id/edit'(controller: 'task', action: 'editForm') + '/tasks/$id'(controller: 'task', action: 'update', method: 'PATCH') + '/tasks/$id'(controller: 'task', action: 'delete', method: 'DELETE') + '/tasks/$id/toggle'(controller: 'task', action: 'toggle', method: 'POST') + + '/'(redirect: '/tasks') + + '500'(view: '/error') + '404'(view: '/notFound') + } +} diff --git a/guides/grails-htmx/v8/snippets/grails-app/domain/example/Task.groovy b/guides/grails-htmx/v8/snippets/grails-app/domain/example/Task.groovy new file mode 100644 index 00000000000..e14ef779b75 --- /dev/null +++ b/guides/grails-htmx/v8/snippets/grails-app/domain/example/Task.groovy @@ -0,0 +1,20 @@ +package example + +import grails.persistence.Entity + +@Entity +class Task { + + String title + Boolean done = false + Date dateCreated + + static constraints = { + title blank: false, maxSize: 255 + } + + static mapping = { + sort 'dateCreated' + order 'desc' + } +} diff --git a/guides/grails-htmx/v8/snippets/grails-app/views/layouts/main.gsp b/guides/grails-htmx/v8/snippets/grails-app/views/layouts/main.gsp new file mode 100644 index 00000000000..96456abf3c3 --- /dev/null +++ b/guides/grails-htmx/v8/snippets/grails-app/views/layouts/main.gsp @@ -0,0 +1,32 @@ + + + + + + <g:layoutTitle default="Grails + HTMX"/> +
  • + + <%-- Toggle the done flag with a single round trip --%> + + + + + + + +
  • diff --git a/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskEdit.gsp b/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskEdit.gsp new file mode 100644 index 00000000000..8b7846261b9 --- /dev/null +++ b/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskEdit.gsp @@ -0,0 +1,23 @@ +
  • +
    + + + +
    + +
      +
    • ${message(error: it).encodeAsHTML()}
    • +
    +
    +
  • diff --git a/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskRows.gsp b/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskRows.gsp new file mode 100644 index 00000000000..c4f053ae2f6 --- /dev/null +++ b/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskRows.gsp @@ -0,0 +1,6 @@ + + + + +
  • No tasks yet. Add one above.
  • +
    diff --git a/guides/grails-htmx/v8/snippets/grails-app/views/task/index.gsp b/guides/grails-htmx/v8/snippets/grails-app/views/task/index.gsp new file mode 100644 index 00000000000..c66b053edd9 --- /dev/null +++ b/guides/grails-htmx/v8/snippets/grails-app/views/task/index.gsp @@ -0,0 +1,40 @@ + + + + HTMX Task Tracker + + + + +
    +

    Tasks

    + + <%-- Add a task --%> +
    + + +
    + + <%-- Live search --%> + + + <%-- The list. HTMX swaps individual rows in/out of this
      . --%> +
        + +
      +
    + + +