diff --git a/README.md b/README.md index ad38bdcc..ab247590 100644 --- a/README.md +++ b/README.md @@ -108,8 +108,17 @@ The first is to use annotations, e.g. `@HxTrigger`, and the second is to use the See [Response Headers Reference](https://htmx.org/reference/#response_headers) for the related htmx documentation. The following annotations are currently supported: +* [@HxLocation](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxLocation.html) +* [@HxPushUrl](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxPushUrl.html) +* [@HxRedirect](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxRedirect.html) * [@HxRefresh](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxRefresh.html) +* [@HxReplaceUrl](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxReplaceUrl.html) +* [@HxReselect](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxReselect.html) +* [@HxReswap](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxReswap.html) +* [@HxRetarget](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxRetarget.html) * [@HxTrigger](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxTrigger.html) +* [@HxTriggerAfterSettle](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxTriggerAfterSettle.html) +* [@HxTriggerAfterSwap](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxTriggerAfterSwap.html) >**Note** Please refer to the related Javadoc to learn more about the available options. diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerInterceptor.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerInterceptor.java index 7bf23b46..c393a03d 100644 --- a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerInterceptor.java +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerInterceptor.java @@ -1,11 +1,9 @@ package io.github.wimdeblauwe.htmx.spring.boot.mvc; -import static io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxResponseHeader.HX_REFRESH; -import static io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxResponseHeader.HX_TRIGGER; -import static io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxResponseHeader.HX_TRIGGER_AFTER_SETTLE; -import static io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxResponseHeader.HX_TRIGGER_AFTER_SWAP; +import static io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxResponseHeader.*; import java.lang.reflect.Method; +import java.time.Duration; import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.http.HttpHeaders; @@ -15,14 +13,34 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + public class HtmxHandlerInterceptor implements HandlerInterceptor { + + private final ObjectMapper objectMapper; + + public HtmxHandlerInterceptor(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + @Override public boolean preHandle(HttpServletRequest request, - HttpServletResponse response, - Object handler) { + HttpServletResponse response, + Object handler) { + if (handler instanceof HandlerMethod) { Method method = ((HandlerMethod) handler).getMethod(); + setHxLocation(response, method); + setHxPushUrl(response, method); + setHxRedirect(response, method); + setHxReplaceUrl(response, method); + setHxReswap(response, method); + setHxRetarget(response, method); + setHxReselect(response, method); setHxTrigger(response, method); + setHxTriggerAfterSettle(response, method); + setHxTriggerAfterSwap(response, method); setHxRefresh(response, method); setVary(request, response); } @@ -36,30 +54,175 @@ private void setVary(HttpServletRequest request, HttpServletResponse response) { } } + private void setHxLocation(HttpServletResponse response, Method method) { + HxLocation methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxLocation.class); + if (methodAnnotation != null) { + var location = convertToLocation(methodAnnotation); + if (location.hasContextData()) { + setHeaderJsonValue(response, HtmxResponseHeader.HX_LOCATION, location); + } else { + setHeader(response, HtmxResponseHeader.HX_LOCATION, location.getPath()); + } + } + } + + private void setHxPushUrl(HttpServletResponse response, Method method) { + HxPushUrl methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxPushUrl.class); + if (methodAnnotation != null) { + setHeader(response, HX_PUSH_URL, methodAnnotation.value()); + } + } + + private void setHxRedirect(HttpServletResponse response, Method method) { + HxRedirect methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxRedirect.class); + if (methodAnnotation != null) { + setHeader(response, HX_REDIRECT, methodAnnotation.value()); + } + } + + private void setHxReplaceUrl(HttpServletResponse response, Method method) { + HxReplaceUrl methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxReplaceUrl.class); + if (methodAnnotation != null) { + setHeader(response, HX_REPLACE_URL, methodAnnotation.value()); + } + } + + private void setHxReswap(HttpServletResponse response, Method method) { + HxReswap methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxReswap.class); + if (methodAnnotation != null) { + setHeader(response, HX_RESWAP, convertToReswap(methodAnnotation)); + } + } + + private void setHxRetarget(HttpServletResponse response, Method method) { + HxRetarget methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxRetarget.class); + if (methodAnnotation != null) { + setHeader(response, HX_RETARGET, methodAnnotation.value()); + } + } + + private void setHxReselect(HttpServletResponse response, Method method) { + HxReselect methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxReselect.class); + if (methodAnnotation != null) { + setHeader(response, HX_RESELECT, methodAnnotation.value()); + } + } + private void setHxTrigger(HttpServletResponse response, Method method) { HxTrigger methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxTrigger.class); if (methodAnnotation != null) { - response.setHeader(getHeaderName(methodAnnotation.lifecycle()), methodAnnotation.value()); + setHeader(response, convertToHeader(methodAnnotation.lifecycle()), methodAnnotation.value()); + } + } + + private void setHxTriggerAfterSettle(HttpServletResponse response, Method method) { + HxTriggerAfterSettle methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxTriggerAfterSettle.class); + if (methodAnnotation != null) { + setHeader(response, HtmxResponseHeader.HX_TRIGGER_AFTER_SETTLE, methodAnnotation.value()); + } + } + + private void setHxTriggerAfterSwap(HttpServletResponse response, Method method) { + HxTriggerAfterSwap methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxTriggerAfterSwap.class); + if (methodAnnotation != null) { + setHeader(response, HtmxResponseHeader.HX_TRIGGER_AFTER_SWAP, methodAnnotation.value()); } } private void setHxRefresh(HttpServletResponse response, Method method) { HxRefresh methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxRefresh.class); if (methodAnnotation != null) { - response.setHeader(HX_REFRESH.getValue(), "true"); + setHeader(response, HX_REFRESH, HtmxValue.TRUE); } } - private String getHeaderName(HxTriggerLifecycle lifecycle) { + private HtmxResponseHeader convertToHeader(HxTriggerLifecycle lifecycle) { switch (lifecycle) { case RECEIVE: - return HX_TRIGGER.getValue(); + return HX_TRIGGER; case SETTLE: - return HX_TRIGGER_AFTER_SETTLE.getValue(); + return HX_TRIGGER_AFTER_SETTLE; case SWAP: - return HX_TRIGGER_AFTER_SWAP.getValue(); + return HX_TRIGGER_AFTER_SWAP; default: throw new IllegalArgumentException("Unknown lifecycle:" + lifecycle); } } + + private void setHeaderJsonValue(HttpServletResponse response, HtmxResponseHeader header, Object value) { + try { + response.setHeader(header.getValue(), objectMapper.writeValueAsString(value)); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Unable to set header " + header.getValue() + " to " + value, e); + } + } + + private void setHeader(HttpServletResponse response, HtmxResponseHeader header, String value) { + response.setHeader(header.getValue(), value); + } + + private void setHeader(HttpServletResponse response, HtmxResponseHeader header, String[] values) { + response.setHeader(header.getValue(), String.join(",", values)); + } + + private HtmxLocation convertToLocation(HxLocation annotation) { + var location = new HtmxLocation(); + location.setPath(annotation.path()); + if (!annotation.source().isEmpty()) { + location.setSource(annotation.source()); + } + if (!annotation.event().isEmpty()) { + location.setEvent(annotation.event()); + } + if (!annotation.handler().isEmpty()) { + location.setHandler(annotation.handler()); + } + if (!annotation.target().isEmpty()) { + location.setTarget(annotation.target()); + } + if (!annotation.target().isEmpty()) { + location.setSwap(annotation.swap()); + } + return location; + } + + private String convertToReswap(HxReswap annotation) { + + var reswap = new HtmxReswap(annotation.value()); + if (annotation.swap() != -1) { + reswap.swap(Duration.ofMillis(annotation.swap())); + } + if (annotation.settle() != -1) { + reswap.swap(Duration.ofMillis(annotation.settle())); + } + if (annotation.transition()) { + reswap.transition(); + } + if (annotation.focusScroll() != HxReswap.FocusScroll.UNDEFINED) { + reswap.focusScroll(annotation.focusScroll() == HxReswap.FocusScroll.TRUE); + } + if (annotation.show() != HxReswap.Position.UNDEFINED) { + reswap.show(convertToPosition(annotation.show())); + if (!annotation.showTarget().isEmpty()) { + reswap.scrollTarget(annotation.showTarget()); + } + } + if (annotation.scroll() != HxReswap.Position.UNDEFINED) { + reswap.scroll(convertToPosition(annotation.scroll())); + if (!annotation.scrollTarget().isEmpty()) { + reswap.scrollTarget(annotation.scrollTarget()); + } + } + + return reswap.toString(); + } + + private HtmxReswap.Position convertToPosition(HxReswap.Position position) { + return switch (position) { + case TOP -> HtmxReswap.Position.TOP; + case BOTTOM -> HtmxReswap.Position.BOTTOM; + default -> throw new IllegalStateException("Unexpected value: " + position); + }; + } + } diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxMvcAutoConfiguration.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxMvcAutoConfiguration.java index f11347a5..3524d651 100644 --- a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxMvcAutoConfiguration.java +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxMvcAutoConfiguration.java @@ -43,7 +43,7 @@ public RequestMappingHandlerMapping getRequestMappingHandlerMapping() { @Override public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(new HtmxHandlerInterceptor()); + registry.addInterceptor(new HtmxHandlerInterceptor(objectMapper)); } @Override diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxValue.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxValue.java new file mode 100644 index 00000000..38f8a56a --- /dev/null +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxValue.java @@ -0,0 +1,18 @@ +package io.github.wimdeblauwe.htmx.spring.boot.mvc; + +/** + * Holder for constant values. + */ +public class HtmxValue { + + /** + * Constant for use in annotations that support a {@code false} value to disable functions. + */ + public static final String FALSE = "false"; + + /** + * Constant for use in annotations that support a {@code true} value to enable functions. + */ + public static final String TRUE = "true"; + +} diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxLocation.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxLocation.java new file mode 100644 index 00000000..ef234108 --- /dev/null +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxLocation.java @@ -0,0 +1,55 @@ +package io.github.wimdeblauwe.htmx.spring.boot.mvc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.core.annotation.AliasFor; + +/** + * Annotation to do a client side redirect that does not do a full page reload. + *

+ * Note that this annotation does not provide support for specifying {@code values} or {@code headers}. + * If you want to do this, use {@link HtmxResponse} instead. + * + * @see HX-Location Response Header + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface HxLocation { + + /** + * The url path to make the redirect. + *

This is an alias for {@link #path}. For example, + * {@code @HxLocation("/foo")} is equivalent to + * {@code @HxLocation(path="/foo")}. + */ + @AliasFor("path") + String value() default ""; + /** + * The url path to make the redirect. + */ + String path() default ""; + /** + * The source element of the request + */ + String source() default ""; + /** + * An event that "triggered" the request + */ + String event() default ""; + /** + * A callback that will handle the response HTML. + */ + String handler() default ""; + /** + * The target to swap the response into. + */ + String target() default ""; + /** + * How the response will be swapped in relative to the target + */ + String swap() default ""; + +} diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxPushUrl.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxPushUrl.java new file mode 100644 index 00000000..9af108e3 --- /dev/null +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxPushUrl.java @@ -0,0 +1,31 @@ +package io.github.wimdeblauwe.htmx.spring.boot.mvc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to push a URL into the browser + * location history. + *

+ * The possible values are: + *

+ * + * @see HX-Push-Url Response Header + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface HxPushUrl { + + /** + * The value for the {@code HX-Push-Url} response header. + */ + String value() default HtmxValue.TRUE; + +} diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxRedirect.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxRedirect.java new file mode 100644 index 00000000..4edf356a --- /dev/null +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxRedirect.java @@ -0,0 +1,22 @@ +package io.github.wimdeblauwe.htmx.spring.boot.mvc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to do a client-side redirect to a new location. + * + * @see HX-Redirect + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface HxRedirect { + + /** + * The URL to use to do a client-side redirect to a new location. + */ + String value(); + +} diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxReplaceUrl.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxReplaceUrl.java new file mode 100644 index 00000000..f0086ab6 --- /dev/null +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxReplaceUrl.java @@ -0,0 +1,32 @@ +package io.github.wimdeblauwe.htmx.spring.boot.mvc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to replace the current URL in the browser + * location history. + *

+ * The possible values are: + *

+ * + * @see HX-Replace-Url + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface HxReplaceUrl { + + /** + * The value for the {@code HX-Replace-Url} response header. + */ + String value(); + +} diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxReselect.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxReselect.java new file mode 100644 index 00000000..08bc4769 --- /dev/null +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxReselect.java @@ -0,0 +1,24 @@ +package io.github.wimdeblauwe.htmx.spring.boot.mvc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to specify a CSS selector that allows you to choose which part + * of the response is used to be swapped in. + * + * @see HX-Retarget + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface HxReselect { + + /** + * A CSS selector that allows you to choose which part of the response is used to be swapped in. + *

Overrides an existing hx-select on the triggering element. + */ + String value(); + +} diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxReswap.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxReswap.java new file mode 100644 index 00000000..813ee4a2 --- /dev/null +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxReswap.java @@ -0,0 +1,85 @@ +package io.github.wimdeblauwe.htmx.spring.boot.mvc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to specify how the response will be swapped. + * See hx-swap for possible values. + * + * @see HX-Reswap + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface HxReswap { + + /** + * A value to specify how the response will be swapped. + * + * @see hx-swap + */ + HxSwapType value() default HxSwapType.INNER_HTML; + + /** + * Set the time in milliseconds that should elapse after receiving a response to swap the content. + */ + long swap() default -1; + + /** + * Set the time in milliseconds that should elapse between the swap and the settle logic. + */ + long settle() default -1; + + /** + * Changes the scrolling behavior of the target element. + */ + Position scroll() default Position.UNDEFINED; + + /** + * Used to target a different element for scrolling. + */ + String scrollTarget() default ""; + + /** + * Changes the scrolling behavior of the target element. + */ + Position show() default Position.UNDEFINED; + + /** + * Used to target a different element for showing. + */ + String showTarget() default ""; + + /** + * Enables the use of the new + * View Transitions API + * when a swap occurs. + */ + boolean transition() default false; + + /** + * Enable or disable auto-scrolling to focused inputs between requests. + */ + FocusScroll focusScroll() default FocusScroll.UNDEFINED; + + /** + * Represents the values for {@link #focusScroll()} + */ + public enum FocusScroll { + TRUE, + FALSE, + UNDEFINED + } + + /** + * Represents the position values for {@link #show()} and {@link #scroll()} + */ + public enum Position { + TOP, + BOTTOM, + UNDEFINED + } + +} diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxRetarget.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxRetarget.java new file mode 100644 index 00000000..1e85ec86 --- /dev/null +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxRetarget.java @@ -0,0 +1,23 @@ +package io.github.wimdeblauwe.htmx.spring.boot.mvc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to specify a CSS selector that updates the target of + * the content update to a different element on the page. + * + * @see HX-Retarget + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface HxRetarget { + + /** + * A CSS selector that updates the target of the content update to a different element on the page. + */ + String value(); + +} diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxTrigger.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxTrigger.java index 57428e84..a0931120 100644 --- a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxTrigger.java +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxTrigger.java @@ -6,14 +6,24 @@ import java.lang.annotation.Target; /** - * Annotation to trigger client side actions on the target element within a response to htmx. + * Annotation to trigger client side events as soon as the response is received on the target element. + *
+ * You can trigger a single event or as many uniquely named events as you would like. * * @see HX-Trigger Response Headers */ @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface HxTrigger { - String value(); + /** + * The events to trigger as soon as the response is received on the target element. + */ + String[] value(); + + /** + * @deprecated use annotation {@link HxTriggerAfterSettle} or {@link HxTriggerAfterSwap} instead. + */ + @Deprecated HxTriggerLifecycle lifecycle() default HxTriggerLifecycle.RECEIVE; } diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxTriggerAfterSettle.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxTriggerAfterSettle.java new file mode 100644 index 00000000..bd44d208 --- /dev/null +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxTriggerAfterSettle.java @@ -0,0 +1,28 @@ +package io.github.wimdeblauwe.htmx.spring.boot.mvc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to trigger client side events after the + * settling step + * on the target element. + *
+ * You can trigger a single event or as many uniquely named events as you would like. + * + * @see HX-Trigger Response Headers + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface HxTriggerAfterSettle { + + /** + * The events to trigger after the + * settling step + * on the target element. + */ + String[] value(); + +} diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxTriggerAfterSwap.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxTriggerAfterSwap.java new file mode 100644 index 00000000..3d1c511d --- /dev/null +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxTriggerAfterSwap.java @@ -0,0 +1,28 @@ +package io.github.wimdeblauwe.htmx.spring.boot.mvc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to trigger client side events after the + * swap step + * on the target element. + *
+ * You can trigger a single event or as many uniquely named events as you would like. + * + * @see HX-Trigger Response Headers + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface HxTriggerAfterSwap { + + /** + * The events to trigger after the + * swap step + * on the target element. + */ + String[] value(); + +} diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxTriggerLifecycle.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxTriggerLifecycle.java index 775a2d47..418de349 100644 --- a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxTriggerLifecycle.java +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxTriggerLifecycle.java @@ -4,7 +4,9 @@ * Represents the HX-Trigger Response Headers. * * @see HX-Trigger Response Headers + * @deprecated use annotation {@link HxTriggerAfterSettle} or {@link HxTriggerAfterSwap} instead. */ +@Deprecated public enum HxTriggerLifecycle { /** * Trigger events as soon as the response is received. diff --git a/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerInterceptorTest.java b/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerInterceptorTest.java index 74b8e34c..d772088e 100644 --- a/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerInterceptorTest.java +++ b/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerInterceptorTest.java @@ -26,6 +26,13 @@ public void testHeaderIsSetOnResponseIfHxTriggerIsPresent() throws Exception { .andExpect(header().string("HX-Trigger", "eventTriggered")); } + @Test + public void testHeaderIsSetOnResponseWithMultipleEventsIfHxTriggerIsPresent() throws Exception { + mockMvc.perform(get("/with-trigger-multiple-events")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Trigger", "event1,event2")); + } + @Test public void testAfterSettleHeaderIsSetOnResponseIfHxTriggerIsPresent() throws Exception { mockMvc.perform(get("/with-trigger-settle")) @@ -40,6 +47,43 @@ public void testAfterSwapHeaderIsSetOnResponseIfHxTriggerIsPresent() throws Exce .andExpect(header().string("HX-Trigger-After-Swap", "eventTriggered")); } + @Test + public void testAfterSettleHeaderIsSetOnResponseIfHxTriggerAfterSettleIsPresent() throws Exception { + mockMvc.perform(get("/with-trigger-after-settle")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Trigger-After-Settle", "eventTriggered")); + } + + @Test + public void testAfterSettleHeaderIsSetOnResponseWithMultipleEventsIfHxTriggerAfterSettleIsPresent() throws Exception { + mockMvc.perform(get("/with-trigger-after-settle-multiple-events")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Trigger-After-Settle", "event1,event2")); + } + + @Test + public void testAfterSwapHeaderIsSetOnResponseIfHxTriggerAfterSwapIsPresent() throws Exception { + mockMvc.perform(get("/with-trigger-after-swap")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Trigger-After-Swap", "eventTriggered")); + } + + @Test + public void testAfterSwapHeaderIsSetOnResponseWithMultipleEventsIfHxTriggerAfterSwapIsPresent() throws Exception { + mockMvc.perform(get("/with-trigger-after-swap-multiple-events")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Trigger-After-Swap", "event1,event2")); + } + + @Test + public void testHeadersAreSetOnResponseIfHxTriggersArePresent() throws Exception { + mockMvc.perform(get("/with-triggers")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Trigger", "event1,event2")) + .andExpect(header().string("HX-Trigger-After-Settle", "event1,event2")) + .andExpect(header().string("HX-Trigger-After-Swap", "event1,event2")); + } + @Test public void testHeaderIsNotSetOnResponseIfHxTriggerNotPresent() throws Exception { mockMvc.perform(get("/without-trigger")) @@ -81,4 +125,61 @@ public void testHxRefresh() throws Exception { .andExpect(status().isOk()) .andExpect(header().string("HX-Refresh", "true")); } + + @Test + public void testHxLocationWithContextData() throws Exception { + mockMvc.perform(get("/hx-location-with-context-data")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Location", "{\"path\":\"/path\",\"source\":\"source\",\"event\":\"event\",\"handler\":\"handler\",\"target\":\"target\",\"swap\":\"swap\"}")); + } + + @Test + public void testHxLocationWithoutContextData() throws Exception { + mockMvc.perform(get("/hx-location-without-context-data")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Location", "/path")); + } + + @Test + public void testHxPushUrl() throws Exception { + mockMvc.perform(get("/hx-push-url")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Push-Url", "/path")); + } + + @Test + public void testHxRedirect() throws Exception { + mockMvc.perform(get("/hx-redirect")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Redirect", "/path")); + } + + @Test + public void testHxReplaceUrl() throws Exception { + mockMvc.perform(get("/hx-replace-url")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Replace-Url", "/path")); + } + + @Test + public void testHxReswap() throws Exception { + mockMvc.perform(get("/hx-reswap")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Reswap", "innerHTML swap:300ms")); + } + + @Test + public void testHxRetarget() throws Exception { + mockMvc.perform(get("/hx-retarget")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Retarget", "#target")); + } + + @Test + public void testHxReselect() throws Exception { + mockMvc.perform(get("/hx-reselect")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Reselect", "#target")); + } + } diff --git a/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/TestController.java b/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/TestController.java index f4cc67ea..85bfbc84 100644 --- a/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/TestController.java +++ b/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/TestController.java @@ -22,20 +22,64 @@ public String methodWithHxTrigger() { return ""; } + @GetMapping("/with-trigger-multiple-events") + @HxTrigger({ "event1", "event2" }) + @ResponseBody + public String methodWithHxTriggerAndMultipleEvents() { + return ""; + } + @GetMapping("/with-trigger-settle") @HxTrigger(value = "eventTriggered", lifecycle = HxTriggerLifecycle.SETTLE) @ResponseBody - public String methodWithHxTriggerAfterSettle() { + public String methodWithHxTriggerAndLifecycleSettle() { return ""; } @GetMapping("/with-trigger-swap") @HxTrigger(value = "eventTriggered", lifecycle = HxTriggerLifecycle.SWAP) @ResponseBody + public String methodWithHxTriggerAndLifecycleSwap() { + return ""; + } + + @GetMapping("/with-trigger-after-settle") + @HxTriggerAfterSettle("eventTriggered") + @ResponseBody + public String methodWithHxTriggerAfterSettle() { + return ""; + } + + @GetMapping("/with-trigger-after-settle-multiple-events") + @HxTriggerAfterSettle({ "event1", "event2" }) + @ResponseBody + public String methodWithHxTriggerAfterSettleAndMultipleEvents() { + return ""; + } + + @GetMapping("/with-trigger-after-swap") + @HxTriggerAfterSwap("eventTriggered") + @ResponseBody public String methodWithHxTriggerAfterSwap() { return ""; } + @GetMapping("/with-trigger-after-swap-multiple-events") + @HxTriggerAfterSwap({ "event1", "event2" }) + @ResponseBody + public String methodWithHxTriggerAfterSwapAndMultipleEvents() { + return ""; + } + + @GetMapping("/with-triggers") + @HxTrigger({ "event1", "event2" }) + @HxTriggerAfterSettle({ "event1", "event2" }) + @HxTriggerAfterSwap({ "event1", "event2" }) + @ResponseBody + public String methodWithHxTriggers() { + return ""; + } + @GetMapping("/updates-sidebar") @HxUpdatesSidebar @ResponseBody @@ -50,7 +94,6 @@ public String hxTriggerWithAliasForOverride() { return ""; } - @GetMapping("/hx-refresh") @HxRefresh @ResponseBody @@ -64,4 +107,60 @@ public String hxVary() { return ""; } + @GetMapping("/hx-location-without-context-data") + @HxLocation("/path") + @ResponseBody + public String hxLocationWithoutContextData() { + return ""; + } + + @GetMapping("/hx-location-with-context-data") + @HxLocation(path = "/path", source = "source", event = "event", handler = "handler", target = "target", swap = "swap") + @ResponseBody + public String hxLocationWithContextData() { + return ""; + } + + @GetMapping("/hx-push-url") + @HxPushUrl("/path") + @ResponseBody + public String hxPushUrl() { + return ""; + } + + @GetMapping("/hx-redirect") + @HxRedirect("/path") + @ResponseBody + public String hxRedirect() { + return ""; + } + + @GetMapping("/hx-replace-url") + @HxReplaceUrl("/path") + @ResponseBody + public String hxReplaceUrl() { + return ""; + } + + @GetMapping("/hx-reswap") + @HxReswap(value = HxSwapType.INNER_HTML, swap = 300) + @ResponseBody + public String hxReswap() { + return ""; + } + + @GetMapping("/hx-retarget") + @HxRetarget("#target") + @ResponseBody + public String hxRetarget() { + return ""; + } + + @GetMapping("/hx-reselect") + @HxReselect("#target") + @ResponseBody + public String hxReselect() { + return ""; + } + }