Skip to content

Commit

Permalink
feat: HTMX
Browse files Browse the repository at this point in the history
Close: #660

- Defines constants for HTMX Request Header and HTMX Response Headers
- Defines HTMXResponse for out of band swaps.
- Defines HtmxRequestUtils

Allows to bind a HttpRequestHeaders to a controller method.
  • Loading branch information
sdelamo committed Mar 13, 2024
1 parent 65f5315 commit 3285620
Show file tree
Hide file tree
Showing 40 changed files with 1,391 additions and 32 deletions.
1 change: 1 addition & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ include 'views-freemarker'
include 'views-handlebars'
include 'views-thymeleaf'
include 'views-turbo'
include 'views-htmx'
include 'views-velocity'
include 'views-rocker'
include 'views-pebble'
Expand Down
5 changes: 5 additions & 0 deletions src/main/docs/guide/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ views:
fieldsetAnnotations: Fieldset Annotations
fieldsetFetcher: Radio, Checkbox and Option Fetcher
customFormElement: Custom Form Elements
htmx:
title: HTMX
htmxRequestHeaders: HTMX Request Headers
outOfBandSwaps: Out of Band Swaps
htmxResponseHeaders: HTMX Response Headers
turbo:
title: Turbo
turboFrameView: TurboFrameView annotation
Expand Down
3 changes: 3 additions & 0 deletions src/main/docs/guide/views/htmx.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
To integrate with https://htmx.org[HTMX], add the following dependency:

dependency:micronaut-views-htmx[groupId="io.micronaut.views"]
6 changes: 6 additions & 0 deletions src/main/docs/guide/views/htmx/htmxRequestHeaders.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
You can bind api:views.htmx.http.HtmxRequestHeaders[] in a controller method.

In the following example, the parameter is bound if the request is an HTMX request. If it is not an HTMX request,
the parameter is bound as null.

snippet::io.micronaut.views.docs.htmx.HtmxTest[tags="htmxRequestHeaders",indent=0]
3 changes: 3 additions & 0 deletions src/main/docs/guide/views/htmx/htmxResponseHeaders.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
The class api:views.htmx.http.HtmxResponseHeaders[] defines constants for the https://htmx.org/reference/#response_headers[HTMX Response headers].

snippet::io.micronaut.views.docs.htmx.HtmxTest[tags="htmxResponseHeaders",indent=0]
3 changes: 3 additions & 0 deletions src/main/docs/guide/views/htmx/outOfBandSwaps.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
You can return an API:views.htmx.HtmxResponse[] in a controller method to render multiple views in a single HTMX response—for example, to do https://htmx.org/docs/#oob_swaps[Out Of Band Swaps].

snippet::io.micronaut.views.docs.htmx.HtmxTest[tags="outOfBandSwaps",indent=0]
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
<root level="info">
<appender-ref ref="STDOUT" />
</root>
<logger name="com.projectcheckins.logging" level="TRACE"/>
<logger name="io.micronaut.http.client" level="TRACE"/>
<logger name="io.micronaut.data.query" level="TRACE"/>
</configuration>
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
plugins {
id 'groovy'
id "io.micronaut.build.internal.views-tests"
groovy
id("io.micronaut.build.internal.views-tests")
}

dependencies {
Expand All @@ -19,14 +19,15 @@ dependencies {
testImplementation(mnSerde.micronaut.serde.api)
testImplementation(mnSerde.micronaut.serde.jackson)

testImplementation('org.apache.groovy:groovy-json')
testImplementation projects.micronautViewsSoy
testImplementation("org.apache.groovy:groovy-json")
testImplementation(projects.micronautViewsHtmx)
testImplementation(projects.micronautViewsSoy)
testImplementation(projects.micronautViewsTurbo)
testImplementation projects.micronautViewsVelocity
testImplementation projects.micronautViewsHandlebars
testImplementation(projects.micronautViewsVelocity)
testImplementation(projects.micronautViewsHandlebars)
testRuntimeOnly(mnLogging.logback.classic)
}

tasks.named('test') {
tasks.withType<Test> {
useJUnitPlatform()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package io.micronaut.views.docs.htmx

import io.micronaut.context.annotation.Property
import io.micronaut.context.annotation.Requires
import io.micronaut.core.annotation.NonNull
import io.micronaut.core.annotation.Nullable
import io.micronaut.core.util.StringUtils
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.Post
import io.micronaut.http.client.BlockingHttpClient
import io.micronaut.http.client.HttpClient
import io.micronaut.http.client.annotation.Client
import io.micronaut.test.extensions.spock.annotation.MicronautTest
import io.micronaut.views.ModelAndView
import io.micronaut.views.docs.turbo.Fruit
import io.micronaut.views.htmx.http.HtmxRequestHeaders
import io.micronaut.views.htmx.http.HtmxResponse
import io.micronaut.views.htmx.http.HtmxResponseHeaders
import jakarta.inject.Inject
import spock.lang.Specification

@Property(name = "micronaut.views.soy.enabled", value = StringUtils.FALSE)
@Property(name = "micronaut.security.enabled", value = StringUtils.FALSE)
@Property(name = "spec.name", value = "HtmxRequestHeadersTest")
@MicronautTest
class HtmxTest extends Specification {

@Inject
@Client("/")
HttpClient httpClient

void testHtmxRequestHeaders() {
given:
BlockingHttpClient client = httpClient.toBlocking()

when:
String html = client.retrieve(HttpRequest.GET("/fruits/htmx").header("HX-Request", StringUtils.TRUE))
then:
'<h1>fruit: Apple</h1>\n<h2>color: Red</h2>\n' == html
when:
html = client.retrieve(HttpRequest.GET("/fruits/htmx"))
then:
html.contains("<!DOCTYPE html>")
}

void testOutOfBandSwaps() {
given:
BlockingHttpClient client = httpClient.toBlocking()
when:
String html = client.retrieve(HttpRequest.POST("/fruits/htmx", Collections.emptyMap()).header("HX-Request", StringUtils.TRUE));
then:
'<h1>fruit: Apple</h1>\n<h2>color: Red</h2>\n<div id="message" hx-swap-oob="true">Swap me directly!</div>' == html
}

void htmxResponseHeaders() {
given:
BlockingHttpClient client = httpClient.toBlocking();

when:
HttpResponse<?> response = client.exchange(HttpRequest.GET("/fruits/htmx/responseHeaders").header("HX-Request", StringUtils.TRUE));

then:
StringUtils.TRUE == response.getHeaders().get("HX-Refresh")
}

@Requires(property = "spec.name", value = "HtmxRequestHeadersTest")
@Controller("/fruits/htmx")
static class HtmxRequestHeadersController {
//tag::htmxRequestHeaders[]
@Get
ModelAndView<Map<String, Object>> index(@Nullable HtmxRequestHeaders htmxRequestHeaders) {
Map<String, Object> model = [fruit: new Fruit("Apple", "Red")]
if (htmxRequestHeaders != null) {
return new ModelAndView<>("fruit", model)
}
new ModelAndView<>("fruits", model)
}
//end::htmxRequestHeaders[]

//tag::outOfBandSwaps[]
@Post
HtmxResponse<?> outOfBandSwaps(@NonNull HtmxRequestHeaders htmxRequestHeaders) {
HtmxResponse.builder()
.modelAndView(new ModelAndView<>("fruit", [fruit: new Fruit("Apple", "Red")]))
.modelAndView(new ModelAndView<>("swap", [:]))
.build()
}
//end::outOfBandSwaps[]

//tag::htmxResponseHeaders[]
@Get("/responseHeaders")
HttpResponse<?> htmxResponseHeaders(@NonNull HtmxRequestHeaders htmxRequestHeaders) {
HttpResponse.ok().header(HtmxResponseHeaders.HX_REFRESH, StringUtils.TRUE);
}
//end::htmxResponseHeaders[]
}
}
1 change: 1 addition & 0 deletions test-suite-groovy/src/test/resources/views/swap.vm
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div id="message" hx-swap-oob="true">Swap me directly!</div>
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.kapt)
id "io.micronaut.build.internal.views-tests"
id("io.micronaut.build.internal.views-tests")
}

dependencies {
Expand All @@ -18,20 +18,21 @@ dependencies {
testImplementation(mnSerde.micronaut.serde.api)
testImplementation(mnSerde.micronaut.serde.jackson)

testImplementation(projects.micronautViewsHtmx)
testImplementation(mnSerde.micronaut.serde.jackson)
testImplementation(mn.micronaut.http.server.netty)
testImplementation(mn.micronaut.http.client)
testImplementation projects.micronautViewsSoy
testImplementation(projects.micronautViewsSoy)
testImplementation(projects.micronautViewsTurbo)
testImplementation(libs.kotlinx.coroutines.core)
testImplementation projects.micronautViewsVelocity
testImplementation projects.micronautViewsHandlebars
testImplementation(projects.micronautViewsVelocity)
testImplementation(projects.micronautViewsHandlebars)

testRuntimeOnly(libs.junit.jupiter.engine)
testRuntimeOnly(mnLogging.logback.classic)
}

tasks.named('test') {
tasks.withType<Test> {
useJUnitPlatform()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package io.micronaut.views.docs.htmx

import io.micronaut.context.annotation.Property
import io.micronaut.context.annotation.Requires
import io.micronaut.core.annotation.NonNull
import io.micronaut.core.util.StringUtils
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.annotation.Controller
import io.micronaut.http.annotation.Get
import io.micronaut.http.annotation.Post
import io.micronaut.http.client.HttpClient
import io.micronaut.http.client.annotation.Client
import io.micronaut.test.extensions.junit5.annotation.MicronautTest
import io.micronaut.views.ModelAndView
import io.micronaut.views.docs.turbo.Fruit
import io.micronaut.views.htmx.http.HtmxRequestHeaders
import io.micronaut.views.htmx.http.HtmxResponse
import io.micronaut.views.htmx.http.HtmxResponseHeaders
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

@Property(name = "micronaut.views.soy.enabled", value = StringUtils.FALSE)
@Property(name = "micronaut.security.enabled", value = StringUtils.FALSE)
@Property(name = "spec.name", value = "HtmxRequestHeadersTest")
@MicronautTest
internal class HtmxTest {
@Test
fun testHtmxRequestHeaders(@Client("/") httpClient: HttpClient) {
val client = httpClient.toBlocking()
var html = client.retrieve(HttpRequest.GET<Any>("/fruits/htmx").header("HX-Request", StringUtils.TRUE))
assertEquals(
"""
<h1>fruit: Apple</h1>
<h2>color: Red</h2>
""".trimIndent(), html
)
html = client.retrieve(HttpRequest.GET<Any>("/fruits/htmx"))
assertTrue(html.contains("<!DOCTYPE html>"))
}

@Test
fun testOutOfBandSwaps(@Client("/") httpClient: HttpClient) {
val client = httpClient.toBlocking()
val html = client.retrieve(
HttpRequest.POST("/fruits/htmx", emptyMap<Any, Any>()).header("HX-Request", StringUtils.TRUE)
)
assertEquals(
"""
<h1>fruit: Apple</h1>
<h2>color: Red</h2>
<div id="message" hx-swap-oob="true">Swap me directly!</div>
""".trimIndent(), html
)
}

@Test
fun htmxResponseHeaders(@Client("/") httpClient: HttpClient) {
val client = httpClient.toBlocking()
val response: HttpResponse<*> = client.exchange<Any, Any>(
HttpRequest.GET<Any>("/fruits/htmx/responseHeaders").header("HX-Request", StringUtils.TRUE)
)
assertEquals(
StringUtils.TRUE,
response.headers["HX-Refresh"]
)
}

@Requires(property = "spec.name", value = "HtmxRequestHeadersTest")
@Controller("/fruits/htmx")
internal class HtmxRequestHeadersController {
//tag::htmxRequestHeaders[]
@Get
fun index(htmxRequestHeaders: HtmxRequestHeaders?): ModelAndView<Map<String, Any>> {
val model = mapOf("fruit" to Fruit("Apple", "Red"))
if (htmxRequestHeaders != null) {
return ModelAndView("fruit", model)
}
return ModelAndView("fruits", model)
}
//end::htmxRequestHeaders[]

//tag::outOfBandSwaps[]
@Post
fun outOfBandSwaps(htmxRequestHeaders: HtmxRequestHeaders): HtmxResponse<*> {
return HtmxResponse.builder<Any>()
.modelAndView(ModelAndView("fruit", mapOf("fruit" to Fruit("Apple", "Red"))))
.modelAndView(ModelAndView("swap", emptyMap<Any, Any>()))
.build()
}
//end::outOfBandSwaps[]

//tag::htmxResponseHeaders[]
@Get("/responseHeaders")
fun htmxResponseHeaders(htmxRequestHeaders: @NonNull HtmxRequestHeaders): HttpResponse<*> {
return HttpResponse.ok<Any>().header(HtmxResponseHeaders.HX_REFRESH, StringUtils.TRUE)
}
//end::htmxResponseHeaders[]


}
}
1 change: 1 addition & 0 deletions test-suite-kotlin/src/test/resources/views/swap.vm
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div id="message" hx-swap-oob="true">Swap me directly!</div>
15 changes: 8 additions & 7 deletions test-suite/build.gradle → test-suite/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
plugins {
id 'java-library'
id "io.micronaut.build.internal.views-tests"
`java-library`
id("io.micronaut.build.internal.views-tests")
}

dependencies {
Expand All @@ -23,13 +23,14 @@ dependencies {
testImplementation(mnSerde.micronaut.serde.api)
testImplementation(mnSerde.micronaut.serde.jackson)

testImplementation projects.micronautViewsVelocity
testImplementation projects.micronautViewsCore
testImplementation(projects.micronautViewsHtmx)
testImplementation(projects.micronautViewsVelocity)
testImplementation(projects.micronautViewsCore)
testImplementation(projects.micronautViewsTurbo)
testImplementation projects.micronautViewsSoy
testImplementation projects.micronautViewsHandlebars
testImplementation(projects.micronautViewsSoy)
testImplementation(projects.micronautViewsHandlebars)
}

tasks.named('test') {
tasks.withType<Test> {
useJUnitPlatform()
}
Loading

0 comments on commit 3285620

Please sign in to comment.