Skip to content

Commit 9ee1966

Browse files
committed
- unit tests
- javadoc - module doc
1 parent dbcd7e9 commit 9ee1966

20 files changed

Lines changed: 1273 additions & 62 deletions

File tree

docs/asciidoc/modules/htmx.adoc

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
== HTMX
2+
3+
https://htmx.org[HTMX] first-class support for Jooby.
4+
5+
The HTMX module provides a seamless bridge between modern, reactive Single Page Application (SPA) mechanics and traditional server-side rendering. It offers both a memory-safe Imperative Builder and a powerful Declarative Annotation API (via APT) to orchestrate HTMX responses without repetitive boilerplate.
6+
7+
*Note:* `HtmxTemplateEngine` acts as a composite delegator. You must also install a backing template engine (like Handlebars, Freemarker, or Pebble) to actually render the views.
8+
9+
=== Usage
10+
11+
1) Add the dependencies (HTMX and your preferred template engine):
12+
13+
[dependency, artifactId="jooby-htmx, jooby-handlebars:Handlebars Module"]
14+
.
15+
16+
2) Write your templates inside the `views` folder. Notice how the layout dynamically embeds the requested partial using `childView`.
17+
18+
.views/layout.hbs
19+
[source, html]
20+
----
21+
<!DOCTYPE html>
22+
<html>
23+
<body>
24+
<nav>My App</nav>
25+
<main>
26+
{{> (lookup childView) }}
27+
</main>
28+
</body>
29+
</html>
30+
----
31+
32+
.views/tasks.hbs
33+
[source, html]
34+
----
35+
<ul id="task-list">
36+
{{#each tasks}}
37+
<li>{{title}}</li>
38+
{{/each}}
39+
</ul>
40+
----
41+
42+
3) Install the module and write your controller.
43+
44+
.Java
45+
[source, java, role="primary"]
46+
----
47+
import io.jooby.htmx.HtmxModule;
48+
import io.jooby.handlebars.HandlebarsModule;
49+
import io.jooby.annotation.htmx.HxView;
50+
51+
{
52+
install(new HandlebarsModule()); <1>
53+
install(new HtmxModule()); <2>
54+
55+
mvc(new TaskUIHtmx_()); <3>
56+
}
57+
58+
public class TaskUI {
59+
60+
@GET("/tasks")
61+
@HxView(value = "tasks.hbs", layout = "layout.hbs")
62+
public Map<String, Object> getTasks() {
63+
return Map.of("tasks", List.of(new Task("Buy milk")));
64+
}
65+
}
66+
----
67+
68+
.Kotlin
69+
[source, kt, role="secondary"]
70+
----
71+
import io.jooby.htmx.HtmxModule
72+
import io.jooby.handlebars.HandlebarsModule
73+
import io.jooby.annotation.htmx.HxView
74+
75+
{
76+
install(HandlebarsModule()) <1>
77+
install(HtmxModule()) <2>
78+
79+
mvc(TaskUIHtmx_()) <3>
80+
}
81+
82+
class TaskUI {
83+
84+
@GET("/tasks")
85+
@HxView(value = "tasks.hbs", layout = "layout.hbs")
86+
fun getTasks(): Map<String, Any> {
87+
return mapOf("tasks" to listOf(Task("Buy milk")))
88+
}
89+
}
90+
----
91+
92+
<1> Install your base template engine
93+
<2> Install the HTMX engine
94+
<3> Add generated `Htmx_` controller
95+
96+
=== The SPA Shell Layout Engine
97+
98+
The `@HxView` annotation implements a secure, Fail-Fast Guard Clause for layout management.
99+
100+
When you define a `layout` attribute, the framework intelligently checks the origin of the request:
101+
102+
* **HTMX AJAX Requests:** The layout is ignored. The framework responds only with the fast, targeted partial view (`tasks.hbs`).
103+
* **Direct Browser Requests (F5 / Bookmarks):** The framework intercepts the request, blocks the raw fragment from rendering, and automatically injects the partial inside your defined `layout.hbs` (passed as the `childView` attribute).
104+
105+
If a method returns a dynamic HTMX fragment but *lacks* a layout, direct browser access is automatically blocked via a `406 Not Acceptable` exception.
106+
107+
=== Declarative API (Annotations)
108+
109+
When using Jooby's MVC routes, you can orchestrate complex UI state entirely through annotations:
110+
111+
.Java
112+
[source, java]
113+
----
114+
@POST("/tasks")
115+
@HxView("task_row.hbs")
116+
@HxOob("task_counter.hbs") // Automatically appends an Out-Of-Band swap
117+
@HxTrigger("taskAdded") // Triggers a client-side JS event
118+
@HxError("task_error.hbs") // Scoped Error Handler: Catches validation errors
119+
public Task addTask(@Valid TaskDto dto) {
120+
return db.save(dto);
121+
}
122+
----
123+
124+
==== Scoped Error Handling & Validation
125+
The `@HxError` annotation acts as a "UI Janitor" for **Scoped Errors** (such as HTTP 400 Bad Request or 422 Unprocessable Entity). If Bean Validation fails, it catches the exception and renders your targeted error template.
126+
127+
* **Validation Integration:** The model passed to your error template automatically includes a `validationResult` object that perfectly follows the `io.jooby.validation.ValidationResult` format. This allows seamless integration with Jooby's Jakarta validation modules (`hibernate-validator` or `avaje-validator`).
128+
* **Auto-Clearing:** Crucially, on a *successful* request, the framework automatically appends an empty OOB swap for the error template, instantly clearing the UI of any previous error messages.
129+
130+
=== Imperative API (HtmxResponse)
131+
132+
For scenarios lacking a primary view (like a `DELETE` operation), use the fluent `HtmxResponse` builder to explicitly chain events, headers, and OOB updates.
133+
134+
.Java
135+
[source, java, role="primary"]
136+
----
137+
@DELETE("/tasks/{id}")
138+
public HtmxResponse deleteTask(@PathParam String id) {
139+
db.delete(id);
140+
141+
return HtmxResponse.empty()
142+
.addOob("task_counter.hbs", Map.of("activeCount", db.getActiveCount()))
143+
.triggerAfterSettle("showToast", Map.of("message", "Task deleted!"));
144+
}
145+
----
146+
147+
.Kotlin
148+
[source, kt, role="secondary"]
149+
----
150+
@DELETE("/tasks/{id}")
151+
fun deleteTask(@PathParam id: String): HtmxResponse {
152+
db.delete(id)
153+
154+
return HtmxResponse.empty()
155+
.addOob("task_counter.hbs", mapOf("activeCount" to db.getActiveCount()))
156+
.triggerAfterSettle("showToast", mapOf("message" to "Task deleted!"))
157+
}
158+
----
159+
160+
=== Global Error Handling
161+
162+
While `@HxError` handles scoped validation, you can seamlessly convert **Global Application Errors** (like 500 Server Crashes) into graceful HTMX responses (like OOB toast notifications) by passing a custom `HtmxErrorHandler` to the module during installation.
163+
164+
**Smart Interception:** This global handler is highly intelligent. It *only* intercepts requests that contain the `HX-Request: true` header. If a standard browser request crashes (e.g., a normal page load or hitting F5), this handler is safely bypassed, and the default Jooby global application error handler takes over to display a standard error page.
165+
166+
.Java
167+
[source, java, role="primary"]
168+
----
169+
import io.jooby.htmx.HtmxModule;
170+
171+
{
172+
install(new HtmxModule((ctx, cause, code) -> {
173+
// Convert the crash into a safe UI notification without breaking the DOM
174+
return HtmxResponse.empty(code)
175+
.addOob("toast.hbs", Map.of("error", cause.getMessage()));
176+
}));
177+
}
178+
----
179+
180+
.Kotlin
181+
[source, kt, role="secondary"]
182+
----
183+
import io.jooby.htmx.HtmxModule
184+
185+
{
186+
install(HtmxModule { ctx, cause, code ->
187+
HtmxResponse.empty(code)
188+
.addOob("toast.hbs", mapOf("error" to cause.message))
189+
})
190+
}
191+
----

docs/asciidoc/modules/modules.adoc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,11 @@ Modules are distributed as separate dependencies. Below is the catalog of offici
5555
* link:{uiVersion}/modules/openapi[OpenAPI]: OpenAPI supports.
5656

5757
==== Template Engine
58+
* link:{uiVersion}/modules/freemarker[Freemarker]: Freemarker template engine.
5859
* link:{uiVersion}/modules/handlebars[Handlebars]: Handlebars template engine.
60+
* link:{uiVersion}/modules/htmx[HTMX]: First-class HTMX support with declarative annotations and SPA layout management.
5961
* link:{uiVersion}/modules/jstachio[JStachio]: JStachio template engine.
6062
* link:{uiVersion}/modules/jte[jte]: jte template engine.
61-
* link:{uiVersion}/modules/freemarker[Freemarker]: Freemarker template engine.
6263
* link:{uiVersion}/modules/pebble[Pebble]: Pebble template engine.
6364
* link:{uiVersion}/modules/rocker[Rocker]: Rocker template engine.
6465
* link:{uiVersion}/modules/thymeleaf[Thymeleaf]: Thymeleaf template engine.

modules/jooby-apt/src/main/java/io/jooby/internal/apt/htmx/HtmxRoute.java

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -527,24 +527,85 @@ private void appendDeclarativeHeaders(List<String> buffer, boolean kt, int inden
527527
semicolon(kt)));
528528
}
529529

530-
List<String> triggers =
531-
extractRepeatableValues(
532-
"io.jooby.annotation.htmx.HxTrigger", "io.jooby.annotation.htmx.HxTriggers");
530+
// NEW: Specialized trigger extraction
531+
appendTriggers(buffer, kt, indent);
532+
}
533+
534+
private void appendTriggers(List<String> buffer, boolean kt, int indent) {
535+
// Use LinkedHashMap to ensure deterministic code generation order
536+
java.util.Map<String, List<String>> triggersByHeader = new java.util.LinkedHashMap<>();
537+
538+
// 1. Process Single Annotation
539+
var singleMirror =
540+
AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.htmx.HxTrigger");
541+
if (singleMirror != null) {
542+
extractTriggerData(singleMirror, triggersByHeader);
543+
}
544+
545+
// 2. Process Repeatable Container
546+
var containerMirror =
547+
AnnotationSupport.findAnnotationByName(method, "io.jooby.annotation.htmx.HxTriggers");
548+
if (containerMirror != null) {
549+
for (var entry : containerMirror.getElementValues().entrySet()) {
550+
if (entry.getKey().getSimpleName().contentEquals("value")) {
551+
var nestedList =
552+
(java.util.List<? extends javax.lang.model.element.AnnotationValue>)
553+
entry.getValue().getValue();
533554

534-
if (!triggers.isEmpty()) {
535-
String combinedTriggers = String.join(", ", triggers);
555+
for (var nestedItem : nestedList) {
556+
if (nestedItem.getValue()
557+
instanceof javax.lang.model.element.AnnotationMirror nestedMirror) {
558+
extractTriggerData(nestedMirror, triggersByHeader);
559+
}
560+
}
561+
}
562+
}
563+
}
564+
565+
// 3. Write out the grouped headers
566+
for (var entry : triggersByHeader.entrySet()) {
567+
String headerName = entry.getKey();
568+
String combinedValues = String.join(", ", entry.getValue());
536569
buffer.add(
537570
statement(
538571
indent(indent),
539572
"ctx.setResponseHeader(",
540-
string("HX-Trigger"),
573+
string(headerName),
541574
", ",
542-
string(combinedTriggers),
575+
string(combinedValues),
543576
")",
544577
semicolon(kt)));
545578
}
546579
}
547580

581+
private void extractTriggerData(
582+
AnnotationMirror mirror, java.util.Map<String, List<String>> map) {
583+
String eventName =
584+
AnnotationSupport.findAnnotationValue(mirror, "value"::equals).stream()
585+
.map(Object::toString)
586+
.findFirst()
587+
.orElse("");
588+
589+
if (eventName.isEmpty()) return;
590+
591+
// Default header if phase is omitted
592+
var headerName = "HX-Trigger";
593+
594+
// Extract the phase enum if present
595+
var phaseValues = AnnotationSupport.findAnnotationValue(mirror, "phase"::equals);
596+
if (!phaseValues.isEmpty()) {
597+
var phaseRaw = phaseValues.getFirst();
598+
599+
if (phaseRaw.endsWith("AFTER_SETTLE")) {
600+
headerName = "HX-Trigger-After-Settle";
601+
} else if (phaseRaw.endsWith("AFTER_SWAP")) {
602+
headerName = "HX-Trigger-After-Swap";
603+
}
604+
}
605+
606+
map.computeIfAbsent(headerName, k -> new ArrayList<>()).add(eventName);
607+
}
608+
548609
private void writeStringHeader(
549610
List<String> buffer, boolean kt, int indent, String annotationFqn, String headerName) {
550611
var annotation = AnnotationSupport.findAnnotationByName(method, annotationFqn);

modules/jooby-apt/src/test/java/tests/htmx/HtmxTest.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,29 @@ public Object updateUser(io.jooby.Context ctx) throws Exception {
129129
});
130130
}
131131

132+
@Test
133+
public void shouldGenerateTriggers() throws Exception {
134+
new ProcessorRunner(new TriggersHx())
135+
.withHtmxCode(
136+
source -> {
137+
assertThat(source)
138+
.containsIgnoringWhitespaces(
139+
"""
140+
public Object triggers(io.jooby.Context ctx) throws Exception {
141+
var c = this.factory.apply(ctx);
142+
if (!ctx.header("HX-Request").booleanValue(false)) {
143+
throw new io.jooby.htmx.HtmxDirectAccessException("Direct browser access to this HTMX fragment is not allowed.");
144+
}
145+
var result_ = c.triggers();
146+
ctx.setResponseHeader("HX-Trigger", "t1");
147+
ctx.setResponseHeader("HX-Trigger-After-Settle", "t2");
148+
ctx.setResponseHeader("HX-Trigger-After-Swap", "t3");
149+
return io.jooby.ModelAndView.of("users/profile.hbs", result_);
150+
}
151+
""");
152+
});
153+
}
154+
132155
@Test
133156
public void shouldDoDynamicResponse() throws Exception {
134157
new ProcessorRunner(new DynamicResponseHx())
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package tests.htmx;
7+
8+
import java.util.Map;
9+
10+
import io.jooby.annotation.GET;
11+
import io.jooby.annotation.Path;
12+
import io.jooby.annotation.htmx.HxTrigger;
13+
import io.jooby.annotation.htmx.HxView;
14+
15+
@Path("/users")
16+
public class TriggersHx {
17+
18+
@GET
19+
@HxView(value = "users/profile.hbs")
20+
@HxTrigger(value = "t1", phase = HxTrigger.Phase.TRIGGER)
21+
@HxTrigger(value = "t2", phase = HxTrigger.Phase.AFTER_SETTLE)
22+
@HxTrigger(value = "t3", phase = HxTrigger.Phase.AFTER_SWAP)
23+
public Map<String, Object> triggers() {
24+
return Map.of();
25+
}
26+
}

modules/jooby-htmx/pom.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,10 @@
3131
<classifier>runtime</classifier>
3232
<scope>test</scope>
3333
</dependency>
34+
<dependency>
35+
<groupId>org.mockito</groupId>
36+
<artifactId>mockito-core</artifactId>
37+
<scope>test</scope>
38+
</dependency>
3439
</dependencies>
3540
</project>

modules/jooby-htmx/src/main/java/io/jooby/annotation/htmx/HxTrigger.java

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,6 @@
2727
*/
2828
String value();
2929

30-
/**
31-
* An optional JSON payload string to pass with the event. Example: {@code "{\"level\":
32-
* \"info\"}"}
33-
*
34-
* @return The JSON payload, or empty string if none.
35-
*/
36-
String payload() default "";
37-
3830
/**
3931
* The lifecycle phase at which the event should be triggered.
4032
*

0 commit comments

Comments
 (0)