From da936cb25d97bce0afe2ebc66487c958791ea27e Mon Sep 17 00:00:00 2001 From: xhaggi Date: Fri, 1 Nov 2024 11:56:24 +0100 Subject: [PATCH 1/5] Move creation of HtmxRequest into static method HtmxRequest#fromRequest --- .../HtmxExpressionObjectFactory.java | 8 +-- .../HtmxHandlerMethodArgumentResolver.java | 45 ++++------------ .../htmx/spring/boot/mvc/HtmxRequest.java | 53 +++++++++++++++++++ 3 files changed, 69 insertions(+), 37 deletions(-) diff --git a/htmx-spring-boot-thymeleaf/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/thymeleaf/HtmxExpressionObjectFactory.java b/htmx-spring-boot-thymeleaf/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/thymeleaf/HtmxExpressionObjectFactory.java index a2976db4..3e0974c6 100644 --- a/htmx-spring-boot-thymeleaf/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/thymeleaf/HtmxExpressionObjectFactory.java +++ b/htmx-spring-boot-thymeleaf/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/thymeleaf/HtmxExpressionObjectFactory.java @@ -1,6 +1,6 @@ package io.github.wimdeblauwe.htmx.spring.boot.thymeleaf; -import io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxHandlerMethodArgumentResolver; +import io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxRequest; import jakarta.servlet.http.HttpServletRequest; import org.thymeleaf.context.IExpressionContext; import org.thymeleaf.context.IWebContext; @@ -34,8 +34,10 @@ public Set getAllExpressionObjectNames() { public Object buildObject(final IExpressionContext context, final String expressionObjectName) { if (HTMX_REQUEST_EXPRESSION_OBJECT_NAME.equals(expressionObjectName) && context instanceof IWebContext webContext) { IWebExchange exchange = webContext.getExchange(); - IServletWebRequest request = (IServletWebRequest) exchange.getRequest(); - return HtmxHandlerMethodArgumentResolver.createHtmxRequest((HttpServletRequest) request.getNativeRequestObject()); + IServletWebRequest webRequest = (IServletWebRequest) exchange.getRequest(); + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequestObject(); + + return HtmxRequest.fromRequest(request); } return null; diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerMethodArgumentResolver.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerMethodArgumentResolver.java index bdfee278..8abe0190 100644 --- a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerMethodArgumentResolver.java +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerMethodArgumentResolver.java @@ -2,6 +2,8 @@ import jakarta.servlet.http.HttpServletRequest; import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; import org.springframework.web.bind.support.WebDataBinderFactory; import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.method.support.HandlerMethodArgumentResolver; @@ -12,47 +14,22 @@ import static io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxRequestHeader.*; public class HtmxHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver { + @Override public boolean supportsParameter(MethodParameter parameter) { - return parameter.getParameterType().equals(HtmxRequest.class); + return HtmxRequest.class.isAssignableFrom(parameter.getParameterType()); } @Override public Object resolveArgument(MethodParameter parameter, - ModelAndViewContainer mavContainer, + @Nullable ModelAndViewContainer mavContainer, NativeWebRequest webRequest, - WebDataBinderFactory binderFactory) throws Exception { - return createHtmxRequest(Objects.requireNonNull(webRequest.getNativeRequest(HttpServletRequest.class))); - } + @Nullable WebDataBinderFactory binderFactory) throws Exception { - public static HtmxRequest createHtmxRequest(HttpServletRequest request) { - String hxRequestHeader = request.getHeader(HX_REQUEST.getValue()); - if (hxRequestHeader == null) { - return HtmxRequest.empty(); - } - - HtmxRequest.Builder builder = HtmxRequest.builder(); - if (request.getHeader(HX_BOOSTED.getValue()) != null) { - builder.boosted(true); - } - if (request.getHeader(HX_CURRENT_URL.getValue()) != null) { - builder.currentUrl(request.getHeader(HX_CURRENT_URL.getValue())); - } - if (request.getHeader(HX_HISTORY_RESTORE_REQUEST.getValue()) != null) { - builder.historyRestoreRequest(true); - } - if (request.getHeader(HX_PROMPT.getValue()) != null) { - builder.promptResponse(request.getHeader(HX_PROMPT.getValue())); - } - if (request.getHeader(HX_TARGET.getValue()) != null) { - builder.target(request.getHeader(HX_TARGET.getValue())); - } - if (request.getHeader(HX_TRIGGER_NAME.getValue()) != null) { - builder.triggerName(request.getHeader(HX_TRIGGER_NAME.getValue())); - } - if (request.getHeader(HX_TRIGGER.getValue()) != null) { - builder.triggerId(request.getHeader(HX_TRIGGER.getValue())); - } - return builder.build(); + HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + Assert.notNull(request, "HttpServletRequest must not be null"); + + return HtmxRequest.fromRequest(request); } + } diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxRequest.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxRequest.java index 7a78a906..04cec270 100644 --- a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxRequest.java +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxRequest.java @@ -1,5 +1,8 @@ package io.github.wimdeblauwe.htmx.spring.boot.mvc; +import static io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxRequestHeader.*; + +import jakarta.servlet.http.HttpServletRequest; import org.springframework.lang.Nullable; /** @@ -31,14 +34,64 @@ public final class HtmxRequest { private final String triggerName; private final String triggerId; + /** + * Return a {@link Builder} to create a {@link HtmxRequest}. + * + * @return the builder + */ public static Builder builder() { return new Builder(); } + /** + * Create an empty {@link HtmxRequest}. + * + * @return the empty HtmxRequest + */ public static HtmxRequest empty() { return new HtmxRequest(false, false, null, false, null, null, null, null); } + /** + * Create a new {@link HtmxRequest} from the given {@link HttpServletRequest}. + * + * @param request the request to create the HtmxRequest from + * @return the HtmxRequest + * @since 3.6.0 + */ + public static HtmxRequest fromRequest(HttpServletRequest request) { + + String hxRequestHeader = request.getHeader(HX_REQUEST.getValue()); + if (hxRequestHeader == null) { + return empty(); + } + + HtmxRequest.Builder builder = builder(); + if (request.getHeader(HX_BOOSTED.getValue()) != null) { + builder.boosted(true); + } + if (request.getHeader(HX_CURRENT_URL.getValue()) != null) { + builder.currentUrl(request.getHeader(HX_CURRENT_URL.getValue())); + } + if (request.getHeader(HX_HISTORY_RESTORE_REQUEST.getValue()) != null) { + builder.historyRestoreRequest(true); + } + if (request.getHeader(HX_PROMPT.getValue()) != null) { + builder.promptResponse(request.getHeader(HX_PROMPT.getValue())); + } + if (request.getHeader(HX_TARGET.getValue()) != null) { + builder.target(request.getHeader(HX_TARGET.getValue())); + } + if (request.getHeader(HX_TRIGGER_NAME.getValue()) != null) { + builder.triggerName(request.getHeader(HX_TRIGGER_NAME.getValue())); + } + if (request.getHeader(HX_TRIGGER.getValue()) != null) { + builder.triggerId(request.getHeader(HX_TRIGGER.getValue())); + } + + return builder.build(); + } + HtmxRequest(boolean htmxRequest, boolean boosted, String currentUrl, boolean historyRestoreRequest, String promptResponse, String target, String triggerName, String triggerId) { this.htmxRequest = htmxRequest; this.boosted = boosted; From cc3bda06835d28a72fb1022a3f11c2497083402a Mon Sep 17 00:00:00 2001 From: xhaggi Date: Sat, 2 Nov 2024 13:18:07 +0100 Subject: [PATCH 2/5] Rewrite HtmxResponse for use as handler method argument --- .../boot/mvc/HtmxHandlerInterceptor.java | 17 +- .../htmx/spring/boot/mvc/HtmxResponse.java | 261 ++++++++++++---- ...ResponseHandlerMethodArgumentResolver.java | 26 +- .../spring/boot/mvc/RequestContextUtils.java | 22 ++ ...onseHandlerMethodArgumentResolverTest.java | 293 ++++++++++++++++++ 5 files changed, 541 insertions(+), 78 deletions(-) create mode 100644 htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponseHandlerMethodArgumentResolverTest.java 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 d914ca3a..18f420b2 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 @@ -13,6 +13,7 @@ import java.lang.reflect.Method; import java.time.Duration; +import java.util.Map; import static io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxResponseHeader.*; @@ -28,21 +29,18 @@ public HtmxHandlerInterceptor(ObjectMapper objectMapper, HtmxResponseHandlerMeth @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { - if (modelAndView != null) { - for (Object value : modelAndView.getModel().values()) { - if (value instanceof HtmxResponse res) { - buildAndRender(res, modelAndView, request, response); - } else if (value instanceof HtmxResponse.Builder builder) { - buildAndRender(builder.build(), modelAndView, request, response); - } - } + + HtmxResponse htmxResponse = RequestContextUtils.getHtmxResponse(request); + if (htmxResponse != null) { + buildAndRender(htmxResponse, modelAndView, request, response); } } private void buildAndRender(HtmxResponse htmxResponse, ModelAndView mav, HttpServletRequest request, HttpServletResponse response) { View v = htmxResponseHandlerMethodReturnValueHandler.toView(htmxResponse); try { - v.render(mav.getModel(), request, response); + Map model = mav != null ? mav.getModel() : Map.of(); + v.render(model, request, response); // ModelAndViewContainer is not available here, so flash attributes won't work htmxResponseHandlerMethodReturnValueHandler.addHxHeaders(htmxResponse, request, response, null); } catch (Exception e) { @@ -270,5 +268,4 @@ private String getRequestUrl(HttpServletRequest request) { return path; } - } diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponse.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponse.java index 34904a5c..7dcb7cc0 100644 --- a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponse.java +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponse.java @@ -21,14 +21,13 @@ public final class HtmxResponse { private static final Logger LOGGER = LoggerFactory.getLogger(HtmxResponse.class); - private final Set views; - private final Set triggers; - private final Set triggersAfterSettle; - private final Set triggersAfterSwap; - private final String replaceUrl; - private final String reselect; - private final boolean contextRelative; - // TODO should also be final after switching to builder pattern + private Set views = new LinkedHashSet<>(); + private Set triggers = new LinkedHashSet<>(); + private Set triggersAfterSettle = new LinkedHashSet<>(); + private Set triggersAfterSwap = new LinkedHashSet<>(); + private String replaceUrl; + private String reselect; + private boolean contextRelative = true; private String retarget; private boolean refresh; private String redirect; @@ -46,37 +45,189 @@ public static Builder builder() { } /** - * @deprecated use {@link #builder()} instead. Will be removed in 4.0. + * Create a new HtmxResponse. */ - @Deprecated public HtmxResponse() { - this.views = new LinkedHashSet<>(); - this.triggers = new LinkedHashSet<>(); - this.triggersAfterSettle = new LinkedHashSet<>(); - this.triggersAfterSwap = new LinkedHashSet<>(); + } + + /** + * Adds an event that will be triggered once the response is received. + *

Multiple trigger were automatically be merged into the same header. + * + * @param eventName the event name + * @see HX-Trigger Response Headers + * @deprecated Return value is changed to void in 4.0. + */ + @Deprecated + public HtmxResponse addTrigger(String eventName) { + Assert.hasText(eventName, "eventName should not be blank"); + triggers.add(new HtmxTrigger(eventName, null)); + return this; + } + + /** + * Adds an event that will be triggered once the response is received. + *

Multiple trigger were automatically be merged into the same header. + * + * @param eventName the event name + * @param eventDetail details along with the event + * @see HX-Trigger Response Headers + * @since 3.6.0 + */ + public void addTrigger(String eventName, Object eventDetail) { + Assert.hasText(eventName, "eventName should not be blank"); + triggers.add(new HtmxTrigger(eventName, eventDetail)); + } + + /** + * Adds an event that will be triggered after the settling step. + *

Multiple triggers were automatically be merged into the same header. + * + * @param eventName the event name + * @see HX-Trigger Response Headers + * @since 3.6.0 + */ + public void addTriggerAfterSettle(String eventName) { + Assert.hasText(eventName, "eventName should not be blank"); + triggersAfterSettle.add(new HtmxTrigger(eventName, null)); + } + + /** + * Adds an event that will be triggered after the settling step. + *

Multiple triggers were automatically be merged into the same header. + * + * @param eventName the event name + * @param eventDetail details along with the event + * @see HX-Trigger Response Headers + * @since 3.6.0 + */ + public void addTriggerAfterSettle(String eventName, Object eventDetail) { + Assert.hasText(eventName, "eventName should not be blank"); + triggersAfterSettle.add(new HtmxTrigger(eventName, eventDetail)); + } + + /** + * Adds an event that will be triggered after the swap step. + *

Multiple triggers were automatically be merged into the same header. + * + * @param eventName the event name + * @see HX-Trigger Response Headers + * @since 3.6.0 + */ + public void addTriggerAfterSwap(String eventName) { + Assert.hasText(eventName, "eventName should not be blank"); + triggersAfterSwap.add(new HtmxTrigger(eventName, null)); + } + + /** + * Adds an event that will be triggered after the swap step. + *

Multiple triggers were automatically be merged into the same header. + * + * @param eventName the event name + * @param eventDetail details along with the event + * @return the builder + * @see HX-Trigger Response Headers + * @since 3.6.0 + */ + public void addTriggerAfterSwap(String eventName, Object eventDetail) { + Assert.hasText(eventName, "eventName should not be blank"); + triggersAfterSwap.add(new HtmxTrigger(eventName, eventDetail)); + } + + /** + * Prevents the browser history stack from being updated. + * + * @see HX-Push-Url Response Header documentation + * @see HX-Replace-Url Response Header + * @since 3.6.0 + */ + public void preventHistoryUpdate() { + this.pushUrl = "false"; this.replaceUrl = null; - this.reselect = null; - this.contextRelative = true; - } - - HtmxResponse(Set views, Set triggers, Set triggersAfterSettle, - Set triggersAfterSwap, String retarget, boolean refresh, String redirect, - String pushUrl, String replaceUrl, String reselect, HtmxReswap reswap, HtmxLocation location, boolean contextRelative) { - this.views = views; - this.triggers = triggers; - this.triggersAfterSettle = triggersAfterSettle; - this.triggersAfterSwap = triggersAfterSwap; - this.retarget = retarget; - this.refresh = refresh; - this.redirect = redirect; - this.pushUrl = pushUrl; - this.replaceUrl = replaceUrl; - this.reselect = reselect; - this.reswap = reswap; - this.location = location; + } + + /** + * Set whether URLs used in the htmx response that starts with a slash ("/") should be interpreted as + * relative to the current ServletContext, i.e. as relative to the web application root. + * Default is "true": A URL that starts with a slash will be interpreted as relative to + * the web application root, i.e. the context path will be prepended to the URL. + * + * @param contextRelative whether to interpret URLs in the htmx response as relative to the current ServletContext + * @return the builder + */ + public void setContextRelative(boolean contextRelative) { this.contextRelative = contextRelative; } + /** + * Pushes a new URL into the history stack of the browser. + *

+ * If you want to prevent the history stack from being updated, use {@link #preventHistoryUpdate()}. + * + * @param url the URL to push into the history stack. The URL can be any URL in the same origin as the current URL. + * @see HX-Push Response Header documentation + * @see history.pushState() + * @since 3.6.0 + */ + public void setPushUrl(String url) { + Assert.hasText(url, "url should not be blank"); + this.pushUrl = url; + this.replaceUrl = null; + } + + /** + * Allows you to replace the most recent entry, i.e. the current URL, in the browser history stack. + *

+ * If you want to prevent the history stack from being updated, use {@link #preventHistoryUpdate()}. + * + * @param url the URL to replace in the history stack. The URL can be any URL in the same origin as the current URL. + * @see HX-Replace-Url Response Header + * @see history.replaceState() + * @since 3.6.0 + */ + public void setReplaceUrl(String url) { + this.replaceUrl = url; + this.pushUrl = null; + } + + /** + * Set 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. + * + * @param cssSelector the CSS selector + * @see HX-Reselect + * @since 3.6.0 + */ + public void setReselect(String cssSelector) { + Assert.hasText(cssSelector, "cssSelector should not be blank"); + this.reselect = cssSelector; + } + + /** + * Allows you to specify how the response will be swapped. + * See hx-swap for possible values. + * + * @param reswap the reswap options. + * @see HX-Reswap + * @since 3.6.0 + */ + public void setReswap(HtmxReswap reswap) { + Assert.notNull(reswap, "reswap should not be null"); + this.reswap = reswap; + } + + /** + * Set a CSS selector that updates the target of the content update to a different element on the page + * + * @param cssSelector the CSS selector + * @see HX-Retarget + * @since 3.6.0 + */ + public void setRetarget(String cssSelector) { + Assert.hasText(cssSelector, "cssSelector should not be blank"); + this.retarget = cssSelector; + } + /** * Append the rendered template or fragment. * @@ -123,20 +274,6 @@ public HtmxResponse addTemplate(ModelAndView template) { return this; } - /** - * Set a HX-Trigger header. Multiple trigger were automatically be merged into the same header. - * - * @param eventName must not be {@literal null} or empty. - * @return same HtmxResponse for chaining - * @see HX-Trigger Response Headers - * @deprecated use {@link Builder#trigger(String)} instead. Will be removed in 4.0. - */ - @Deprecated - public HtmxResponse addTrigger(String eventName) { - Assert.hasText(eventName, "eventName should not be blank"); - return addTrigger(eventName, null, HxTriggerLifecycle.RECEIVE); - } - /** * Set a HX-Trigger (or HX-Trigger-After-Settle or HX-Trigger-After-Swap headers. * Multiple trigger were @@ -464,20 +601,22 @@ public Builder and(HtmxResponse otherResponse) { } public HtmxResponse build() { - return new HtmxResponse( - views, - triggers, - triggersAfterSettle, - triggersAfterSwap, - retarget, - refresh, - redirect, - pushUrl, - replaceUrl, - reselect, - reswap, - location, - contextRelative); + var htmxResponse = new HtmxResponse(); + htmxResponse.views = views; + htmxResponse.triggers = triggers; + htmxResponse.triggersAfterSettle = triggersAfterSettle; + htmxResponse.triggersAfterSwap = triggersAfterSwap; + htmxResponse.retarget = retarget; + htmxResponse.refresh = refresh; + htmxResponse.redirect = redirect; + htmxResponse.pushUrl = pushUrl; + htmxResponse.replaceUrl = replaceUrl; + htmxResponse.reselect = reselect; + htmxResponse.reswap = reswap; + htmxResponse.location = location; + htmxResponse.contextRelative = contextRelative; + + return htmxResponse; } /** diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponseHandlerMethodArgumentResolver.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponseHandlerMethodArgumentResolver.java index 8072e9c7..be01a02d 100644 --- a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponseHandlerMethodArgumentResolver.java +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponseHandlerMethodArgumentResolver.java @@ -1,26 +1,38 @@ package io.github.wimdeblauwe.htmx.spring.boot.mvc; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; import org.springframework.web.bind.support.WebDataBinderFactory; import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; public class HtmxResponseHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver { + @Override public boolean supportsParameter(MethodParameter parameter) { - return parameter.getParameterType().equals(HtmxResponse.Builder.class); + return (parameter.getParameterType() == HtmxResponse.class || + parameter.getParameterType() == HtmxResponse.Builder.class); } @Override public Object resolveArgument(MethodParameter parameter, - ModelAndViewContainer mavContainer, + @Nullable ModelAndViewContainer mavContainer, NativeWebRequest webRequest, - WebDataBinderFactory binderFactory) throws Exception { - HtmxResponse.Builder htmxResponseBuilder = HtmxResponse.builder(); - if(mavContainer != null) { - mavContainer.addAttribute(htmxResponseBuilder); + @Nullable WebDataBinderFactory binderFactory) throws Exception { + + HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + + if (parameter.getParameterType() == HtmxResponse.class) { + var htmxResponse = new HtmxResponse(); + request.setAttribute(RequestContextUtils.HTMX_RESPONSE_CONTEXT_ATTRIBUTE, htmxResponse); + return htmxResponse; + } else { + HtmxResponse.Builder htmxResponseBuilder = HtmxResponse.builder(); + request.setAttribute(RequestContextUtils.HTMX_RESPONSE_CONTEXT_ATTRIBUTE, htmxResponseBuilder); + return htmxResponseBuilder; } - return htmxResponseBuilder; } + } diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/RequestContextUtils.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/RequestContextUtils.java index ce58a973..7a3e90fa 100644 --- a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/RequestContextUtils.java +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/RequestContextUtils.java @@ -2,8 +2,15 @@ import jakarta.servlet.http.HttpServletRequest; +/** + * Utility class for working with the request context. + * + * @since 3.6.0 + */ final class RequestContextUtils { + public static final String HTMX_RESPONSE_CONTEXT_ATTRIBUTE = "htmxResponse"; + /** * Creates a URL by prepending the context path if {@code contextRelative} * is {@code true} and the URL starts with a slash ("/"). @@ -20,6 +27,21 @@ static String createUrl(HttpServletRequest request, String url, boolean contextR return url; } + static HtmxResponse getHtmxResponse(HttpServletRequest request) { + + Object contextAttribute = request.getAttribute(HTMX_RESPONSE_CONTEXT_ATTRIBUTE); + if (contextAttribute instanceof HtmxResponse response) { + return response; + } else if (contextAttribute instanceof HtmxResponse.Builder builder) { + return builder.build(); + } + return null; + } + + static HtmxResponse.Builder getHtmxResponseBuilder(HttpServletRequest request) { + return (HtmxResponse.Builder) request.getAttribute(HTMX_RESPONSE_CONTEXT_ATTRIBUTE); + } + private static String getContextPath(HttpServletRequest request) { String contextPath = request.getContextPath(); while (contextPath.startsWith("//")) { diff --git a/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponseHandlerMethodArgumentResolverTest.java b/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponseHandlerMethodArgumentResolverTest.java new file mode 100644 index 00000000..dec155c5 --- /dev/null +++ b/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponseHandlerMethodArgumentResolverTest.java @@ -0,0 +1,293 @@ +package io.github.wimdeblauwe.htmx.spring.boot.mvc; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.stereotype.Controller; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +import java.time.Duration; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(HtmxResponseHandlerMethodArgumentResolverTest.TestController.class) +@ContextConfiguration(classes = HtmxResponseHandlerMethodArgumentResolverTest.TestController.class) +@WithMockUser +public class HtmxResponseHandlerMethodArgumentResolverTest { + + @Autowired + private MockMvc mockMvc; + + @Test + public void testPreventHistoryUpdate() throws Exception { + + mockMvc.perform(get("/prevent-history-update")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Push-Url", "false")) + .andExpect(header().doesNotExist("HX-Replace-Url")); + } + + @Test + public void testPushUrl() throws Exception { + + mockMvc.perform(get("/push-url")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Push-Url", "/path")) + .andExpect(header().doesNotExist("HX-Replace-Url")); + } + + @Test + public void testPushUrlContextRelative() throws Exception { + + mockMvc.perform(get("/push-url")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Push-Url", "/path")) + .andExpect(header().doesNotExist("HX-Replace-Url")); + } + + @Test + public void testReplaceUrlContextRelative() throws Exception { + + mockMvc.perform(get("/contextpath/replace-url").contextPath("/contextpath")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Replace-Url", "/contextpath/path")) + .andExpect(header().doesNotExist("HX-Push-Url")); + } + + @Test + public void testReselect() throws Exception { + + mockMvc.perform(get("/reselect")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Reselect", "#container")); + } + + @Test + public void testReswap() throws Exception { + + mockMvc.perform(get("/reswap")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Reswap", "innerHTML transition:true focus-scroll:true swap:0ms settle:500ms scroll:#scrollTarget:top show:#showTarget:bottom")); + } + + @Test + public void testReswapAfterBegin() throws Exception { + + mockMvc.perform(get("/reswap-after-begin")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Reswap", "afterbegin")); + } + + @Test + public void testReswapAfterEnd() throws Exception { + + mockMvc.perform(get("/reswap-after-end")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Reswap", "afterend")); + } + + @Test + public void testReswapBeforeBegin() throws Exception { + + mockMvc.perform(get("/reswap-before-begin")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Reswap", "beforebegin")); + } + + @Test + public void testReswapBeforeEnd() throws Exception { + + mockMvc.perform(get("/reswap-before-end")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Reswap", "beforeend")); + } + + @Test + public void testReswapDelete() throws Exception { + + mockMvc.perform(get("/reswap-delete")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Reswap", "delete")); + } + + @Test + public void testReswapInnerHtml() throws Exception { + + mockMvc.perform(get("/reswap-inner-html")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Reswap", "innerHTML")); + } + + @Test + public void testReswapNone() throws Exception { + + mockMvc.perform(get("/reswap-none")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Reswap", "none")); + } + + @Test + public void testReswapOuterHtml() throws Exception { + + mockMvc.perform(get("/reswap-outer-html")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Reswap", "outerHTML")); + } + + @Test + public void testRetarget() throws Exception { + + mockMvc.perform(get("/retarget")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Retarget", "#container")); + } + + @Test + public void testTrigger() throws Exception { + + mockMvc.perform(get("/trigger")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Trigger", "trigger1,trigger2")); + } + + @Test + public void testTriggerAfterSettle() throws Exception { + + mockMvc.perform(get("/trigger-after-settle")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Trigger-After-Settle", "trigger1,trigger2")); + } + + @Test + public void testTriggerAfterSwap() throws Exception { + + mockMvc.perform(get("/trigger-after-swap")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Trigger-After-Swap", "trigger1,trigger2")); + } + + @Controller + static class TestController { + + @GetMapping("/prevent-history-update") + @ResponseBody + public void preventHistoryUpdate(HtmxResponse response) { + response.preventHistoryUpdate(); + } + + @GetMapping("/push-url") + @ResponseBody + public void pushUrl(HtmxResponse response) { + response.setPushUrl("/path"); + } + + @GetMapping("/replace-url") + @ResponseBody + public void replaceUrl(HtmxResponse response) { + response.setReplaceUrl("/path"); + } + + @GetMapping("/reselect") + @ResponseBody + public void reselect(HtmxResponse response) { + response.setReselect("#container"); + } + + @GetMapping("/reswap") + @ResponseBody + public void reswap(HtmxResponse response) { + response.setReswap(HtmxReswap.innerHtml() + .swap(Duration.ZERO) + .settle(Duration.ofMillis(500)) + .scroll(HtmxReswap.Position.TOP) + .scrollTarget("#scrollTarget") + .show(HtmxReswap.Position.BOTTOM) + .showTarget("#showTarget") + .transition() + .focusScroll(true)); + } + + @GetMapping("/reswap-after-begin") + @ResponseBody + public void reswapAfterBegin(HtmxResponse response) { + response.setReswap(HtmxReswap.afterBegin()); + } + + @GetMapping("/reswap-after-end") + @ResponseBody + public void reswapAfterEnd(HtmxResponse response) { + response.setReswap(HtmxReswap.afterEnd()); + } + + @GetMapping("/reswap-before-begin") + @ResponseBody + public void reswapBeforeBegin(HtmxResponse response) { + response.setReswap(HtmxReswap.beforeBegin()); + } + + @GetMapping("/reswap-before-end") + @ResponseBody + public void reswapBeforeEnd(HtmxResponse response) { + response.setReswap(HtmxReswap.beforeEnd()); + } + + @GetMapping("/reswap-delete") + @ResponseBody + public void reswapDelete(HtmxResponse response) { + response.setReswap(HtmxReswap.delete()); + } + + @GetMapping("/reswap-inner-html") + @ResponseBody + public void reswapInnerHtml(HtmxResponse response) { + response.setReswap(HtmxReswap.innerHtml()); + } + + @GetMapping("/reswap-none") + @ResponseBody + public void reswapNone(HtmxResponse response) { + response.setReswap(HtmxReswap.none()); + } + + @GetMapping("/reswap-outer-html") + @ResponseBody + public void reswapOuterHtml(HtmxResponse response) { + response.setReswap(HtmxReswap.outerHtml()); + } + + @GetMapping("/retarget") + @ResponseBody + public void retarget(HtmxResponse response) { + response.setRetarget("#container"); + } + + @GetMapping("/trigger") + @ResponseBody + public void trigger(HtmxResponse response) { + response.addTrigger("trigger1"); + response.addTrigger("trigger2"); + } + + @GetMapping("/trigger-after-settle") + @ResponseBody + public void triggerAfterSettle(HtmxResponse response) { + response.addTriggerAfterSettle("trigger1"); + response.addTriggerAfterSettle("trigger2"); + } + + @GetMapping("/trigger-after-swap") + @ResponseBody + public void triggerAfterSwap(HtmxResponse response) { + response.addTriggerAfterSwap("trigger1"); + response.addTriggerAfterSwap("trigger2"); + } + + } + +} From 738672a880605c2c802272058cfc069b7a895674 Mon Sep 17 00:00:00 2001 From: xhaggi Date: Mon, 4 Nov 2024 10:29:01 +0100 Subject: [PATCH 3/5] Drop support for rendering views from the HtmxHandlerInterceptor This was introduced by #128, but a HandlerInterceptor should never be responsible for rendering views. --- .../htmx/spring/boot/mvc/HtmxHandlerInterceptor.java | 3 --- 1 file changed, 3 deletions(-) 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 18f420b2..604eeb4a 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 @@ -37,10 +37,7 @@ public void postHandle(HttpServletRequest request, HttpServletResponse response, } private void buildAndRender(HtmxResponse htmxResponse, ModelAndView mav, HttpServletRequest request, HttpServletResponse response) { - View v = htmxResponseHandlerMethodReturnValueHandler.toView(htmxResponse); try { - Map model = mav != null ? mav.getModel() : Map.of(); - v.render(model, request, response); // ModelAndViewContainer is not available here, so flash attributes won't work htmxResponseHandlerMethodReturnValueHandler.addHxHeaders(htmxResponse, request, response, null); } catch (Exception e) { From 9cc73313f8e62e083b4ef3194575982e118b0876 Mon Sep 17 00:00:00 2001 From: xhaggi Date: Sat, 2 Nov 2024 12:48:14 +0100 Subject: [PATCH 4/5] Introduce HtmxView, HtmxRedirectView, HtmxLocationRedirectView and HtmxRefreshView --- README.md | 206 ++++++------- .../boot/mvc/HtmxHandlerInterceptor.java | 81 ++++- .../htmx/spring/boot/mvc/HtmxLocation.java | 8 +- .../boot/mvc/HtmxLocationRedirectView.java | 168 ++++++++++ .../boot/mvc/HtmxMvcAutoConfiguration.java | 25 +- .../spring/boot/mvc/HtmxRedirectView.java | 64 ++++ .../htmx/spring/boot/mvc/HtmxRefreshView.java | 28 ++ .../htmx/spring/boot/mvc/HtmxResponse.java | 44 ++- ...sponseHandlerMethodReturnValueHandler.java | 6 + .../htmx/spring/boot/mvc/HtmxView.java | 124 ++++++++ .../mvc/HtmxViewMethodReturnValueHandler.java | 91 ++++++ .../boot/mvc/HtmxHandlerMethodTest.java | 290 ++++++++++++++++++ .../src/test/resources/templates/view1.html | 1 + .../src/test/resources/templates/view2.html | 1 + 14 files changed, 993 insertions(+), 144 deletions(-) create mode 100644 htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxLocationRedirectView.java create mode 100644 htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxRedirectView.java create mode 100644 htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxRefreshView.java create mode 100644 htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxView.java create mode 100644 htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxViewMethodReturnValueHandler.java create mode 100644 htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerMethodTest.java create mode 100644 htmx-spring-boot/src/test/resources/templates/view1.html create mode 100644 htmx-spring-boot/src/test/resources/templates/view2.html diff --git a/README.md b/README.md index f22f876c..e2ef2099 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,12 @@ # Spring Boot and Thymeleaf library for htmx -This project provides annotations, helper classes and a [Thymeleaf](https://www.thymeleaf.org/) dialect -to make it easy to work with [htmx](https://htmx.org/) -in a [Spring Boot](https://spring.io/projects/spring-boot) application. +The project simplifies the integration of [htmx](https://htmx.org/) with [Spring Boot](https://spring.io/projects/spring-boot) / [Spring Web MVC](https://docs.spring.io/spring-framework/reference/web/webmvc.html) applications. +It provides a set of views, annotations, and argument resolvers for controllers to easily handle htmx-related request and response headers. +This ensures seamless interaction between the frontend and backend, especially for dynamic content updates via htmx. -More information about htmx can be viewed on [their website](https://htmx.org/). +Additionally, the project includes a custom [Thymeleaf](https://www.thymeleaf.org/) dialect to enable smooth rendering of htmx-specific attributes within Thymeleaf templates. +With these tools, developers can quickly implement htmx-driven interactions, such as AJAX-based partial page updates, with minimal configuration. ## Maven configuration @@ -45,7 +46,7 @@ Provides a [Thymeleaf](https://www.thymeleaf.org/) dialect to easily work with h The included Spring Boot Auto-configuration will enable the htmx integrations. -### Mapping controller methods to htmx requests +### Mapping Requests Controller methods can be annotated with [HxRequest](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HxRequest.html) @@ -57,8 +58,8 @@ The following method is called only if the request was made by htmx. ```java @HxRequest @GetMapping("/users") -public String htmxRequest(){ - return "partial"; +public String users() { + return "view"; } ``` @@ -70,8 +71,8 @@ or [HxRequest#triggerName](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spr ```java @HxRequest("my-element") @GetMapping("/users") -public String htmxRequest(){ - return "partial"; +public String users() { + return "view"; } ``` If you want to restrict the invocation of a controller method to having a specific target element defined, @@ -80,34 +81,66 @@ use [HxRequest#target](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring- ```java @HxRequest(target = "my-target") @GetMapping("/users") -public String htmxRequest(){ - return "partial"; +public String users() { + return "view"; } ``` -#### Using HtmxRequest to access HTTP request headers sent by htmx +### Request Headers -The [HtmxRequest](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxRequest.html) object can be used as controller method parameter to access the various [htmx Request Headers](https://htmx.org/reference/#request_headers). +To access the various [htmx Request Headers](https://htmx.org/reference/#request_headers) in a controller method, you can use the class [HtmxRequest](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxRequest.html) +as a controller method argument. ```java @HxRequest @GetMapping("/users") -public String htmxRequest(HtmxRequest htmxRequest) { - if(htmxRequest.isHistoryRestoreRequest()){ +public String users(HtmxRequest htmxRequest) { + if (htmxRequest.isHistoryRestoreRequest()) { // do something } - return "partial"; + return "view"; } ``` ### Response Headers -There are two ways to set [htmx Response Headers](https://htmx.org/reference/#response_headers) on controller methods. -The first is to use annotations, e.g. `@HxTrigger`, and the second is to use the class [HtmxResponse](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponse.html) as the return type of the controller method. +There are two ways to set [htmx Response Headers](https://htmx.org/reference/#response_headers) in controller methods. The first is to use [HtmxResponse](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponse.html) +as controller method argument in combination with different Views e.g. [HtmxRedirectView](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxRedirectView.html) +as return value. The second is to use annotations, e.g. `@HxTrigger` to set the necessary response headers. The first method is more flexible and allows you to dynamically set the response headers based on the request. -See [Response Headers Reference](https://htmx.org/reference/#response_headers) for the related htmx documentation. +#### HtmxResponse and Views + +Most of the [htmx Response Headers](https://htmx.org/reference/#response_headers) can be set by using [HtmxResponse](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponse.html) as controller method argument, +except for some control flow response headers such as [HX-Redirect](https://htmx.org/headers/hx-redirect/). For these response headers, you have to use a corresponding view as return value of the controller method. + +* [HtmxRedirectView](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxRedirectView.html) - sets the [HX-Redirect](https://htmx.org/headers/hx-redirect/) header to do a client-side redirect. +* [HtmxLocationRedirectView](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxLocationRedirectView.html) - sets the [HX-Location](https://htmx.org/headers/hx-location/) header to do a client-side redirect without reloading the whole page. +* [HtmxRefreshView](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxRefreshView.html) - sets the [HX-Refresh](https://htmx.org/headers/hx-refresh/) header to do a client-side refresh of the current page. + + +```java +@HxRequest +@PostMapping("/user/{id}") +public Object user(@PathVariable Long id, @ModelAttribute @Valid UserForm form, + BindingResult bindingResult, RedirectAttributes redirectAttributes, + HtmxResponse htmxResponse) { + + if (bindingResult.hasErrors()) { + return "user/form"; + } + + // update user ... + redirectAttributes.addFlashAttribute("successMessage", "User has been successfully updated."); + htmxResponse.addTrigger("user-updated"); + + return new HtmxRedirectView("/user/list"); +} +``` + +#### Annotations + +The following annotations can be used on controller methods to set the necessary response headers. -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) @@ -122,115 +155,69 @@ The following annotations are currently supported: >**Note** Please refer to the related Javadoc to learn more about the available options. -#### Examples - If you want htmx to trigger an event after the response is processed, you can use the annotation `@HxTrigger` which sets the necessary response header [HX-Trigger](https://htmx.org/headers/hx-trigger/). ```java @HxRequest @HxTrigger("userUpdated") // the event 'userUpdated' will be triggered by htmx @GetMapping("/users") -public String hxUpdateUser(){ - return "partial"; -} -``` - -If you want to do the same, but in a more flexible way, you can use `HtmxResponse` as the return type in the controller method instead. -```java -@HxRequest -@GetMapping("/users") -public HtmxResponse hxUpdateUser(){ - return HtmxResponse.builder() - .trigger("userUpdated") // the event 'userUpdated' will be triggered by htmx - .view("partial") - .build(); +public String users() { + return "view"; } ``` -### Out Of Band Swaps +### HTML Fragments -htmx supports updating multiple targets by returning multiple partials in a single response, which is called [Out Of Band Swaps](https://htmx.org/docs/#oob_swaps). -For this purpose, use [HtmxResponse](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponse.html) -as the return type of a controller method, where you can add multiple templates. +In Spring MVC, view rendering typically involves specifying one view and one model. However, in htmx a common capability is to send multiple HTML fragments that +htmx can use to update different parts of the page, which is called [Out Of Band Swaps](https://htmx.org/docs/#oob_swaps). For this, controller methods can return +[HtmxView](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxView.html) ```java @HxRequest -@GetMapping("/partials/main-and-partial") -public HtmxResponse getMainAndPartial(Model model){ - model.addAttribute("userCount", 5); - return HtmxResponse.builder() - .view("users-list") - .view("users-count") - .build(); +@GetMapping("/users") +public View users(Model model) { + model.addAttribute("users", userRepository.findAll()); + model.addAttribute("count", userRepository.count()); + + var view = new HtmxView(); + view.add("users/list"); + view.add("users/count"); + + return view; } ``` -An `HtmxResponse` can be formed from view names, as above, or fully resolved `View` instances, if the controller knows how -to do that, or from `ModelAndView` instances (resolved or unresolved). For example: +An `HtmxView` can be formed from view names, as above, or fully resolved `View` instances, if the controller knows how +to do that, or from `ModelAndView` instances (resolved or unresolved). Each fragment can have its own model, which is merged with the controller model before rendering. ```java @HxRequest -@GetMapping("/partials/main-and-partial") -public HtmxResponse getMainAndPartial(Model model){ - return HtmxResponse.builder() - .view(new ModelAndView("users-list") - .view(new ModelAndView("users-count", Map.of("userCount",5)) - .build(); -} -``` - -Using `ModelAndView` means that each fragment can have its own model (which is merged with the controller model before rendering). - -### HtmxResponse.Builder as an argument - -An `HtmxReponse.Builder` can be injected as a controller method. This creates the parameter and adds it to the model, -allowing it to be used without requiring it be the method return value. This is useful when the return value is needed for -the template. - -This allows for the following usage: - -```java -@GetMapping("/endpoint") -public String endpoint(HtmxResponse.Builder htmxResponse, Model model) { - htmxResponse.trigger("event1"); - model.addAttribute("aField", "aValue"); - return "endpointTemplate"; -} -``` - -For example the [JTE templating library](https://jte.gg/) supports statically typed templates and can be used like so: +@GetMapping("/users") +public View users(Model model) { + var view = new HtmxView(); + view.add("users/list", Map.of("users", userRepository.findAll())); + view.add("users/count", Map.of("count", userRepository.count())); -```java -@GetMapping("/endpoint") -public JteModel endpoint(HtmxResponse.Builder htmxResponse) { - htmxResponse.trigger("event1"); - String aField = "aValue"; - return templates.endpointTemplate(aField); + return view; } ``` -### Error handlers +### Exceptions -It is possible to use `HtmxResponse` as a return type from error handlers. -This makes it quite easy to declare a global error handler that will show a message somewhere whenever there is an error -by declaring a global error handler like this: +It is also possible to use `HtmxRequest` and `HtmxResponse` as method argument in handler methods annotated with `@ExceptionHandler`. ```java @ExceptionHandler(Exception.class) -public HtmxResponse handleError(Exception ex) { - return HtmxResponse.builder() - .reswap(HtmxReswap.none()) - .view(new ModelAndView("fragments :: error-message", Map.of("message", ex.getMessage()))) - .build(); +public String handleError(Exception ex, HtmxRequest htmxRequest, HtmxResponse htmxResponse) { + if (htmxRequest.isHtmxRequest()) { + htmxResponse.setRetarget("#error-message"); + } + return "error"; } ``` -This will override the normal swapping behaviour of any htmx request that has an exception to avoid swapping to occur. -If the `error-message` fragment is declared as an Out Of Band Swap and your page layout has an empty div to "receive" -that piece of HTML, then only that will be placed on the screen. - ### Spring Security The library has an `HxRefreshHeaderAuthenticationEntryPoint` that you can use to have htmx force a full page browser @@ -243,20 +230,18 @@ To use it, add it to your security configuration like this: ```java @Bean -public SecurityFilterChain filterChain(HttpSecurity http)throws Exception{ +public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // probably some other configurations here - var entryPoint = new HxRefreshHeaderAuthenticationEntryPoint(); var requestMatcher = new RequestHeaderRequestMatcher("HX-Request"); - http.exceptionHandling(exception -> - exception.defaultAuthenticationEntryPointFor(entryPoint, requestMatcher)); + http.exceptionHandling(configurer -> configurer.defaultAuthenticationEntryPointFor(entryPoint, requestMatcher)); return http.build(); } ``` ### Thymeleaf -#### Markup Selectors and Out Of Band Swaps +#### Markup Selectors and HTML Fragments The Thymeleaf integration for Spring supports the specification of a [Markup Selector](https://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html#appendix-c-markup-selector-syntax) for views. The Markup Selector will be used for selecting the section @@ -270,13 +255,16 @@ fragment `count` (th:fragment="count") from the template `users`. ```java @HxRequest -@GetMapping("/partials/main-and-partial") -public HtmxResponse getMainAndPartial(Model model){ - model.addAttribute("userCount", 5); - return HtmxResponse.builder() - .view("users :: list") - .view("users :: count") - .build(); +@GetMapping("/users") +public View users(Model model) { + model.addAttribute("users", userRepository.findAll()); + model.addAttribute("count", userRepository.count()); + + var view = new HtmxView(); + view.add("users :: list"); + view.add("users :: count"); + + return view; } ``` 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 604eeb4a..ae617adc 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 @@ -9,39 +9,65 @@ import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.ModelAndView; -import org.springframework.web.servlet.View; import java.lang.reflect.Method; import java.time.Duration; -import java.util.Map; +import java.util.Collection; +import java.util.HashMap; +import java.util.stream.Collectors; import static io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxResponseHeader.*; +/** + * HandlerInterceptor that adds htmx specific headers to the response. + */ public class HtmxHandlerInterceptor implements HandlerInterceptor { private final ObjectMapper objectMapper; - private final HtmxResponseHandlerMethodReturnValueHandler htmxResponseHandlerMethodReturnValueHandler; - public HtmxHandlerInterceptor(ObjectMapper objectMapper, HtmxResponseHandlerMethodReturnValueHandler htmxResponseHandlerMethodReturnValueHandler) { + public HtmxHandlerInterceptor(ObjectMapper objectMapper) { this.objectMapper = objectMapper; - this.htmxResponseHandlerMethodReturnValueHandler = htmxResponseHandlerMethodReturnValueHandler; } @Override - public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { HtmxResponse htmxResponse = RequestContextUtils.getHtmxResponse(request); if (htmxResponse != null) { - buildAndRender(htmxResponse, modelAndView, request, response); - } - } - - private void buildAndRender(HtmxResponse htmxResponse, ModelAndView mav, HttpServletRequest request, HttpServletResponse response) { - try { - // ModelAndViewContainer is not available here, so flash attributes won't work - htmxResponseHandlerMethodReturnValueHandler.addHxHeaders(htmxResponse, request, response, null); - } catch (Exception e) { - throw new RuntimeException(e); + addHxTriggerHeaders(response, HtmxResponseHeader.HX_TRIGGER, htmxResponse.getTriggersInternal()); + addHxTriggerHeaders(response, HtmxResponseHeader.HX_TRIGGER_AFTER_SETTLE, htmxResponse.getTriggersAfterSettleInternal()); + addHxTriggerHeaders(response, HtmxResponseHeader.HX_TRIGGER_AFTER_SWAP, htmxResponse.getTriggersAfterSwapInternal()); + + if (htmxResponse.getLocation() != null) { + HtmxLocation location = htmxResponse.getLocation(); + if (location.hasContextData()) { + location.setPath(RequestContextUtils.createUrl(request, location.getPath(), htmxResponse.isContextRelative())); + setHeaderJsonValue(response, HtmxResponseHeader.HX_LOCATION, location); + } else { + response.setHeader(HtmxResponseHeader.HX_LOCATION.getValue(), RequestContextUtils.createUrl(request, location.getPath(), htmxResponse.isContextRelative())); + } + } + if (htmxResponse.getReplaceUrl() != null) { + response.setHeader(HtmxResponseHeader.HX_REPLACE_URL.getValue(), RequestContextUtils.createUrl(request, htmxResponse.getReplaceUrl(), htmxResponse.isContextRelative())); + } + if (htmxResponse.getPushUrl() != null) { + response.setHeader(HtmxResponseHeader.HX_PUSH_URL.getValue(), RequestContextUtils.createUrl(request, htmxResponse.getPushUrl(), htmxResponse.isContextRelative())); + } + if (htmxResponse.getRedirect() != null) { + response.setHeader(HtmxResponseHeader.HX_REDIRECT.getValue(), RequestContextUtils.createUrl(request, htmxResponse.getRedirect(), htmxResponse.isContextRelative())); + } + if (htmxResponse.isRefresh()) { + response.setHeader(HtmxResponseHeader.HX_REFRESH.getValue(), "true"); + } + if (htmxResponse.getRetarget() != null) { + response.setHeader(HtmxResponseHeader.HX_RETARGET.getValue(), htmxResponse.getRetarget()); + } + if (htmxResponse.getReselect() != null) { + response.setHeader(HtmxResponseHeader.HX_RESELECT.getValue(), htmxResponse.getReselect()); + } + if (htmxResponse.getReswap() != null) { + response.setHeader(HtmxResponseHeader.HX_RESWAP.getValue(), htmxResponse.getReswap().toHeaderValue()); + } } } @@ -265,4 +291,27 @@ private String getRequestUrl(HttpServletRequest request) { return path; } + private void addHxTriggerHeaders(HttpServletResponse response, HtmxResponseHeader headerName, Collection triggers) { + if (triggers.isEmpty()) { + return; + } + + // separate event names by commas if no additional details are available + if (triggers.stream().allMatch(t -> t.getEventDetail() == null)) { + String value = triggers.stream() + .map(HtmxTrigger::getEventName) + .collect(Collectors.joining(",")); + + response.setHeader(headerName.getValue(), value); + return; + } + + // multiple events with or without details + var triggerMap = new HashMap(); + for (HtmxTrigger trigger : triggers) { + triggerMap.put(trigger.getEventName(), trigger.getEventDetail()); + } + setHeaderJsonValue(response, headerName, triggerMap); + } + } diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxLocation.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxLocation.java index 5c94a03b..28f23757 100644 --- a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxLocation.java +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxLocation.java @@ -1,11 +1,10 @@ package io.github.wimdeblauwe.htmx.spring.boot.mvc; -import java.util.Map; -import java.util.Objects; - +import com.fasterxml.jackson.annotation.JsonInclude; import org.springframework.util.CollectionUtils; -import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.Map; +import java.util.Objects; /** * Represents the HX-Location response header value. @@ -122,4 +121,5 @@ public void setTarget(String target) { public void setValues(Map values) { this.values = values; } + } diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxLocationRedirectView.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxLocationRedirectView.java new file mode 100644 index 00000000..76dddf52 --- /dev/null +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxLocationRedirectView.java @@ -0,0 +1,168 @@ +package io.github.wimdeblauwe.htmx.spring.boot.mvc; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.json.JsonMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.servlet.view.RedirectView; + +import java.io.IOException; +import java.util.Map; + +/** + * A specialization of {@link RedirectView} that can be used to signal htmx to perform a client-side redirect without reloading the page. + * This View supports all the features of RedirectView e.g. exposing model attributes, flash attributes, etc. + * + * @see HX-Location Response Header + * @since 3.6.0 + */ +public class HtmxLocationRedirectView extends RedirectView { + + private final JsonMapper jsonMapper = new JsonMapper(); + + private String source; + private String event; + private String handler; + private String target; + private String swap; + private Map values; + private Map headers; + + /** + * Create a new HtmxLocationRedirectView. + */ + public HtmxLocationRedirectView() { + } + + /** + * Create a new HtmxLocationRedirectView with the given URL. + * + *

The given URL will be considered as relative to the web server, not as relative to the current ServletContext. + * + * @param url the URL to redirect to + * @see #HtmxLocationRedirectView(String, boolean) + */ + public HtmxLocationRedirectView(String url) { + super(url); + } + + /** + * Create a new HtmxLocationRedirectView with the given URL. + * + * @param url the URL to redirect to + * @param contextRelative whether to interpret the given URL as relative to the current ServletContext + */ + public HtmxLocationRedirectView(String url, boolean contextRelative) { + super(url, contextRelative); + } + + /** + * Create a new HtmxLocationRedirectView with the given URL. + * + * @param url the URL to redirect to + * @param contextRelative whether to interpret the given URL as relative to the current ServletContext + * @param exposeModelAttributes whether model attributes should be exposed as query parameters + */ + public HtmxLocationRedirectView(String url, boolean contextRelative, boolean exposeModelAttributes) { + super(url, contextRelative, exposeModelAttributes); + } + + /** + * Set the event that “triggered” the request. + * + * @param event an event name + */ + public void setEvent(String event) { + this.event = event; + } + + /** + * Set a callback that will handle the response HTML. + * + * @param handler a handler callback + */ + public void setHandler(String handler) { + this.handler = handler; + } + + /** + * Set headers to submit with the request. + * + * @param headers the headers + */ + public void setHeaders(Map headers) { + this.headers = headers; + } + + /** + * Set the source element of the request. + * + * @param source the source element + */ + public void setSource(String source) { + this.source = source; + } + + /** + * Set how the response will be swapped in relative to the target. + * + * @param swap the swap mode + */ + public void setSwap(String swap) { + this.swap = swap; + } + + /** + * Set the target to swap the response into. + * + * @param target the target + */ + public void setTarget(String target) { + this.target = target; + } + + /** + * Set the values to submit with the request. + * + * @param values the values + */ + public void setValues(Map values) { + this.values = values; + } + + @Override + protected void sendRedirect(HttpServletRequest request, HttpServletResponse response, String targetUrl, boolean http10Compatible) throws IOException { + + String encodedURL = (isRemoteHost(targetUrl) ? targetUrl : response.encodeRedirectURL(targetUrl)); + HtmxLocation location = createLocation(encodedURL); + + if (location.hasContextData()) { + setHeaderAsJson(response, HtmxResponseHeader.HX_LOCATION.getValue(), location); + } else { + response.setHeader(HtmxResponseHeader.HX_LOCATION.getValue(), location.getPath()); + } + } + + private HtmxLocation createLocation(String url) { + + var location = new HtmxLocation(); + location.setPath(url); + location.setSource(source); + location.setEvent(event); + location.setHandler(handler); + location.setTarget(target); + location.setSwap(swap); + location.setValues(values); + location.setHeaders(headers); + + return location; + } + + private void setHeaderAsJson(HttpServletResponse response, String name, Object value) { + try { + response.setHeader(name, jsonMapper.writeValueAsString(value)); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Unable to set header " + name + " to " + value, e); + } + } +} 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 bac5dee4..77e0d81a 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 @@ -22,17 +22,18 @@ @ConditionalOnWebApplication public class HtmxMvcAutoConfiguration implements WebMvcRegistrations, WebMvcConfigurer { - private final ObjectFactory resolver; - private final ObjectFactory locales; + private final ObjectFactory viewResolverObjectFactory; + private final ObjectFactory localeResolverObjectFactory; private final ObjectMapper objectMapper; - HtmxMvcAutoConfiguration(@Qualifier("viewResolver") ObjectFactory resolver, - ObjectFactory locales) { - Assert.notNull(resolver, "ViewResolver must not be null!"); - Assert.notNull(locales, "LocaleResolver must not be null!"); + HtmxMvcAutoConfiguration(@Qualifier("viewResolver") ObjectFactory viewResolverObjectFactory, + ObjectFactory localeResolverObjectFactory) { - this.resolver = resolver; - this.locales = locales; + Assert.notNull(viewResolverObjectFactory, "viewResolverObjectFactory must not be null!"); + Assert.notNull(localeResolverObjectFactory, "localeResolverObjectFactory must not be null!"); + + this.viewResolverObjectFactory = viewResolverObjectFactory; + this.localeResolverObjectFactory = localeResolverObjectFactory; this.objectMapper = JsonMapper.builder().build(); } @@ -43,7 +44,7 @@ public RequestMappingHandlerMapping getRequestMappingHandlerMapping() { @Override public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(new HtmxHandlerInterceptor(objectMapper, createHtmxReponseHandler())); + registry.addInterceptor(new HtmxHandlerInterceptor(objectMapper)); } @Override @@ -54,10 +55,8 @@ public void addArgumentResolvers(List resolvers) @Override public void addReturnValueHandlers(List handlers) { - handlers.add(createHtmxReponseHandler()); + handlers.add(new HtmxResponseHandlerMethodReturnValueHandler(viewResolverObjectFactory.getObject(), localeResolverObjectFactory, objectMapper)); + handlers.add(new HtmxViewMethodReturnValueHandler(viewResolverObjectFactory.getObject(), localeResolverObjectFactory.getObject())); } - private HtmxResponseHandlerMethodReturnValueHandler createHtmxReponseHandler() { - return new HtmxResponseHandlerMethodReturnValueHandler(resolver.getObject(), locales, objectMapper); - } } diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxRedirectView.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxRedirectView.java new file mode 100644 index 00000000..9ffe5402 --- /dev/null +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxRedirectView.java @@ -0,0 +1,64 @@ +package io.github.wimdeblauwe.htmx.spring.boot.mvc; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.servlet.view.RedirectView; + +import java.io.IOException; + +/** + * A specialization of {@link RedirectView} that can be used to signal htmx to perform a client-side redirect. + * This View supports all the features of RedirectView e.g. exposing model attributes, flash attributes, etc. + * + * @see HX-Redirect Response Header + * @since 3.6.0 + */ +public class HtmxRedirectView extends RedirectView { + + /** + * Create a new HtmxRedirectView. + */ + public HtmxRedirectView() { + } + + /** + * Create a new HtmxRedirectView with the given URL. + * + *

The given URL will be considered as relative to the web server, not as relative to the current ServletContext. + * + * @param url the URL to redirect to + * @see #HtmxRedirectView(String, boolean) + */ + public HtmxRedirectView(String url) { + super(url); + } + + /** + * Create a new HtmxRedirectView with the given URL. + * + * @param url the URL to redirect to + * @param contextRelative whether to interpret the given URL as relative to the current ServletContext + */ + public HtmxRedirectView(String url, boolean contextRelative) { + super(url, contextRelative); + } + + /** + * Create a new HtmxRedirectView with the given URL. + * + * @param url the URL to redirect to + * @param contextRelative whether to interpret the given URL as relative to the current ServletContext + * @param exposeModelAttributes whether model attributes should be exposed as query parameters + */ + public HtmxRedirectView(String url, boolean contextRelative, boolean exposeModelAttributes) { + super(url, contextRelative, false, exposeModelAttributes); + } + + @Override + protected void sendRedirect(HttpServletRequest request, HttpServletResponse response, String targetUrl, boolean http10Compatible) throws IOException { + + String encodedURL = (isRemoteHost(targetUrl) ? targetUrl : response.encodeRedirectURL(targetUrl)); + response.setHeader(HtmxResponseHeader.HX_REDIRECT.getValue(), encodedURL); + } + +} diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxRefreshView.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxRefreshView.java new file mode 100644 index 00000000..a594e9ea --- /dev/null +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxRefreshView.java @@ -0,0 +1,28 @@ +package io.github.wimdeblauwe.htmx.spring.boot.mvc; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.servlet.SmartView; +import org.springframework.web.servlet.View; + +import java.util.Map; + +/** + * A View that can be used to signal htmx to refresh the page. + * + * @see HX-Refresh Response Header + * @since 3.6.0 + */ +public class HtmxRefreshView implements View, SmartView { + + @Override + public boolean isRedirectView() { + return true; + } + + @Override + public void render(Map model, HttpServletRequest request, HttpServletResponse response) throws Exception { + response.setHeader(HtmxResponseHeader.HX_REFRESH.getValue(), HtmxValue.TRUE); + } + +} diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponse.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponse.java index 7dcb7cc0..a66d5083 100644 --- a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponse.java +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponse.java @@ -9,8 +9,16 @@ import java.util.*; /** - * Used as a controller method return type to specify htmx-related response headers - * and returning multiple template partials in a single response. + * A holder for htmx-related response headers that can be used as method argument in controllers. + * + *

Example usage in an {@code @Controller}: + *

+ * @GetMapping(value = "/user")
+ * public String view(@RequestParam Long id, HtmxResponse htmxResponse) {
+ *   htmxResponse.addTrigger("user-viewed");
+ *   return "/user/view";
+ * }
+ * 
* * @author Oliver Drotbohm * @author Clint Checketts @@ -39,7 +47,11 @@ public final class HtmxResponse { * Return a builder to build a {@link HtmxResponse}. * * @return the builder + * @deprecated use {@link HtmxResponse} as handler method argument and {@link HtmxView}, + * {@link HtmxRedirectView} or {@link HtmxLocationRedirectView} as handler method return + * type instead. Will be removed in 4.0. */ + @Deprecated public static Builder builder() { return new Builder(); } @@ -460,6 +472,10 @@ public Collection getTemplates() { return Collections.unmodifiableCollection(views); } + /** + * @deprecated Replaced by {@link HtmxLocationRedirectView}. Will be removed in 4.0. + */ + @Deprecated public HtmxLocation getLocation() { return location; } @@ -468,6 +484,10 @@ public String getPushUrl() { return pushUrl; } + /** + * @deprecated Replaced by {@link HtmxRedirectView}. Will be removed in 4.0. + */ + @Deprecated public String getRedirect() { return redirect; } @@ -512,10 +532,18 @@ Collection getTriggersAfterSwapInternal() { return Collections.unmodifiableCollection(this.triggersAfterSwap); } + /** + * @deprecated Replaced by {@link HtmxView}. Will be removed in 4.0. + */ + @Deprecated public Collection getViews() { return Collections.unmodifiableCollection(views); } + /** + * @deprecated Replaced by {@link HtmxRefreshView}. Will be removed in 4.0. + */ + @Deprecated public boolean isRefresh() { return refresh; } @@ -536,6 +564,12 @@ private Map getTriggersAsMap(Collection triggers) { return Collections.unmodifiableMap(map); } + /** + * @deprecated use {@link HtmxResponse} as handler method argument + * and {@link HtmxView}, {@link HtmxRedirectView} or {@link HtmxLocationRedirectView} + * as handler method return type instead. Will be removed in 4.0. + */ + @Deprecated public static final class Builder { private Set views = new LinkedHashSet<>(); @@ -600,6 +634,12 @@ public Builder and(HtmxResponse otherResponse) { return this; } + /** + * @deprecated use {@link HtmxResponse} as handler method argument + * and {@link HtmxView}, {@link HtmxRedirectView} or {@link HtmxLocationRedirectView} + * as handler method return type instead. Will be removed in 4.0. + */ + @Deprecated public HtmxResponse build() { var htmxResponse = new HtmxResponse(); htmxResponse.views = views; diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponseHandlerMethodReturnValueHandler.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponseHandlerMethodReturnValueHandler.java index 6427d5fb..5469f3c1 100644 --- a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponseHandlerMethodReturnValueHandler.java +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxResponseHandlerMethodReturnValueHandler.java @@ -26,6 +26,12 @@ import java.util.Map; import java.util.stream.Collectors; +/** + * @deprecated Using {@link HtmxResponse} as handler method return value is deprecated, + * use {@link HtmxView}, {@link HtmxRedirectView} or {@link HtmxLocationRedirectView} instead. + * Will be removed in 4.0.0. + */ +@Deprecated public class HtmxResponseHandlerMethodReturnValueHandler implements HandlerMethodReturnValueHandler { private final ViewResolver views; private final ObjectFactory locales; diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxView.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxView.java new file mode 100644 index 00000000..20d6cdd0 --- /dev/null +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxView.java @@ -0,0 +1,124 @@ +package io.github.wimdeblauwe.htmx.spring.boot.mvc; + +import org.springframework.lang.Nullable; +import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.servlet.View; + +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +/** + * View that can be used to return multiple views as fragments to be rendered together. + * + *

In Spring MVC, view rendering typically involves specifying one view and one model. + * However, in htmx a common capability is to send multiple HTML fragments that the browser + * can use to update different parts of the page. For this, controller methods can return + * this class. + * + * @since 3.6.0 + */ +public class HtmxView { + + private final Set views = new LinkedHashSet<>(); + + /** + * Create a new HtmxView. + */ + public HtmxView() { + } + + /** + * Create a new HtmxView with the given view names. + * + * @param viewNames the view names + */ + public HtmxView(String... viewNames) { + for (String name : viewNames) { + this.views.add(new ModelAndView(name)); + } + } + + /** + * Create a new HtmxView with the given Views. + * + * @param views the views + */ + public HtmxView(View... views) { + for (View view : views) { + this.views.add(new ModelAndView(view)); + } + } + + /** + * Create a new HtmxView with the given ModelAndViews + * + * @param mavs + */ + public HtmxView(ModelAndView... mavs) { + for (ModelAndView mav : mavs) { + this.views.add(mav); + } + } + + /** + * Add a ModelAndView with the given view name to the list of views to render. + * + * @param viewName name of the View to render, to be resolved by the DispatcherServlet's ViewResolver + */ + public void add(String viewName) { + this.views.add(new ModelAndView(viewName)); + } + + /** + * Add a ModelAndView with the given view name and a model to the list of views to render. + * + * @param viewName name of the View to render, to be resolved by the DispatcherServlet's ViewResolver + * @param model a Map of model names (Strings) to model objects (Objects). + * Model entries may not be {@code null}, but the model Map may be + * {@code null} if there is no model data. + */ + public void add(String viewName, @Nullable Map model) { + this.views.add(new ModelAndView(viewName, model)); + } + + /** + * Add a ModelAndView with the given View to the list of views to render. + * + * @param view the View object to render + */ + public void add(View view) { + this.views.add(new ModelAndView(view)); + } + + /** + * Add a ModelAndView with the given View to the list of views to render. + * + * @param view the View object to render + * @param model a Map of model names (Strings) to model objects (Objects). + * Model entries may not be {@code null}, but the model Map may be + * {@code null} if there is no model data. + */ + public void add(View view, @Nullable Map model) { + this.views.add(new ModelAndView(view)); + } + + /** + * Add a ModelAndView to the list of views to render. + * + * @param mav the ModelAndView + */ + public void add(ModelAndView mav) { + this.views.add(mav); + } + + /** + * Return the views to render. + * + * @return the views + */ + public Set getViews() { + return views; + } + +} diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxViewMethodReturnValueHandler.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxViewMethodReturnValueHandler.java new file mode 100644 index 00000000..28de723f --- /dev/null +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxViewMethodReturnValueHandler.java @@ -0,0 +1,91 @@ +package io.github.wimdeblauwe.htmx.spring.boot.mvc; + +import com.fasterxml.jackson.core.JsonProcessingException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.core.MethodParameter; +import org.springframework.lang.Nullable; +import org.springframework.ui.ModelMap; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodReturnValueHandler; +import org.springframework.web.method.support.ModelAndViewContainer; +import org.springframework.web.servlet.LocaleResolver; +import org.springframework.web.servlet.ModelAndView; +import org.springframework.web.servlet.View; +import org.springframework.web.servlet.ViewResolver; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Handles return values that are of type {@link HtmxView}. + * + * @since 3.6.0 + */ +public class HtmxViewMethodReturnValueHandler implements HandlerMethodReturnValueHandler { + + private final ViewResolver viewResolver; + private final LocaleResolver localeResolver; + + public HtmxViewMethodReturnValueHandler(ViewResolver viewResolver, LocaleResolver localeResolver) { + this.viewResolver = viewResolver; + this.localeResolver = localeResolver; + } + + @Override + public boolean supportsReturnType(MethodParameter returnType) { + return HtmxView.class.isAssignableFrom(returnType.getParameterType()); + } + + @Override + public void handleReturnValue(Object returnValue, + MethodParameter returnType, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest) throws Exception { + + if (returnValue instanceof HtmxView htmxView) { + mavContainer.setView(toView(htmxView)); + } else if (returnValue != null) { + throw new UnsupportedOperationException("Unexpected return type: " + returnType.getParameterType().getName() + " in method: " + returnType.getMethod()); + } + } + + private View toView(HtmxView htmxView) { + + return (model, request, response) -> { + Locale locale = localeResolver.resolveLocale(request); + ContentCachingResponseWrapper wrapper = new ContentCachingResponseWrapper(response); + + for (ModelAndView mav : htmxView.getViews()) { + View view; + if (mav.isReference()) { + view = viewResolver.resolveViewName(mav.getViewName(), locale); + if (view == null) { + throw new IllegalArgumentException("Could not resolve view with name '" + mav.getViewName() + "'."); + } + } else { + view = mav.getView(); + } + + for (String key : model.keySet()) { + if (!mav.getModel().containsKey(key)) { + mav.getModel().put(key, model.get(key)); + } + } + + view.render(mav.getModel(), request, wrapper); + } + + wrapper.copyBodyToResponse(); + }; + } + +} diff --git a/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerMethodTest.java b/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerMethodTest.java new file mode 100644 index 00000000..d17eda4c --- /dev/null +++ b/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerMethodTest.java @@ -0,0 +1,290 @@ +package io.github.wimdeblauwe.htmx.spring.boot.mvc; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.HttpHeaders; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.stereotype.Controller; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import java.util.Map; +import java.util.TreeMap; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(HtmxHandlerMethodTest.TestController.class) +@ContextConfiguration(classes = HtmxHandlerMethodTest.TestController.class) +@WithMockUser +public class HtmxHandlerMethodTest { + + @Autowired + private MockMvc mockMvc; + + private static HttpHeaders htmxRequest() { + + var headers = new HttpHeaders(); + headers.add(HtmxRequestHeader.HX_REQUEST.getValue(), HtmxValue.TRUE); + return headers; + } + + @Test + public void testExceptionHandler() throws Exception { + + mockMvc.perform(get("/throw-exception").headers(htmxRequest())) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Retarget", "#container")) + .andExpect(content().string("View1\n")); + } + + @Test + public void testLocationRedirect() throws Exception { + + mockMvc.perform(get("/location-redirect").headers(htmxRequest())) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Location", "/path")); + } + + @Test + public void testLocationRedirectExposingModelAttributes() throws Exception { + + mockMvc.perform(get("/location-redirect-expose-model-attributes").headers(htmxRequest())) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Location", "/path?attr=value")); + } + + @Test + public void testLocationRedirectWithContextData() throws Exception { + + mockMvc.perform(get("/location-redirect-with-context-data").headers(htmxRequest())) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Location", "{\"path\":\"/path\",\"source\":\"source\",\"event\":\"event\",\"handler\":\"handler\",\"target\":\"target\",\"swap\":\"swap\",\"values\":{\"value1\":\"v1\",\"value2\":\"v2\"},\"headers\":{\"header1\":\"v1\",\"header2\":\"v2\"}}")); + } + + @Test + public void testLocationRedirectWithContextDataAndFlashAttributes() throws Exception { + + mockMvc.perform(get("/location-redirect-context-data-flash-attributes").headers(htmxRequest())) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Location", "{\"path\":\"/path\",\"source\":\"source\",\"event\":\"event\",\"handler\":\"handler\",\"target\":\"target\",\"swap\":\"swap\",\"values\":{\"value1\":\"v1\",\"value2\":\"v2\"},\"headers\":{\"header1\":\"v1\",\"header2\":\"v2\"}}")) + .andExpect(flash().attribute("flash", "test")); + } + + @Test + public void testLocationRedirectWithContextDataExposingModelAttributes() throws Exception { + + mockMvc.perform(get("/location-redirect-with-context-data-expose-model-attributes").headers(htmxRequest())) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Location", "{\"path\":\"/path?attr=value\",\"source\":\"source\",\"event\":\"event\",\"handler\":\"handler\",\"target\":\"target\",\"swap\":\"swap\",\"values\":{\"value1\":\"v1\",\"value2\":\"v2\"},\"headers\":{\"header1\":\"v1\",\"header2\":\"v2\"}}")); + } + + @Test + public void testLocationRedirectWithFlashAttributes() throws Exception { + + mockMvc.perform(get("/location-redirect-flash-attributes").headers(htmxRequest())) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Location", "/path")) + .andExpect(flash().attribute("flash", "test")); + } + + @Test + public void testMultipleViews() throws Exception { + + mockMvc.perform(get("/multiple-views").headers(htmxRequest())) + .andExpect(status().isOk()) + .andExpect(content().string("View1\nView2\n")); + } + + @Test + public void testRedirect() throws Exception { + + mockMvc.perform(get("/redirect").headers(htmxRequest())) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Redirect", "/test")); + } + + @Test + public void testRedirectExposingModelAttributes() throws Exception { + + mockMvc.perform(get("/redirect-expose-model-attributes").headers(htmxRequest())) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Redirect", "/test?attr=value")); + } + + @Test + public void testRedirectWithContextPath() throws Exception { + + mockMvc.perform(get("/contextpath/redirect-context-relative").headers(htmxRequest()).contextPath("/contextpath")) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Redirect", "/contextpath/test")); + } + + @Test + public void testRedirectWithFlashAttributes() throws Exception { + + mockMvc.perform(get("/redirect-flash-attributes").headers(htmxRequest())) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Redirect", "/test")) + .andExpect(flash().attribute("flash", "test")); + } + + @Test + public void testRefresh() throws Exception { + + mockMvc.perform(get("/refresh").headers(htmxRequest())) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Refresh", HtmxValue.TRUE)); + } + + @Test + public void testSingleView() throws Exception { + + mockMvc.perform(get("/single-view").headers(htmxRequest())) + .andExpect(status().isOk()) + .andExpect(content().string("View1\n")); + } + + @Controller + static class TestController { + + @ExceptionHandler(RuntimeException.class) + public String handleError(RuntimeException ex, HtmxRequest htmxRequest, HtmxResponse htmxResponse) { + if (htmxRequest.isHtmxRequest()) { + htmxResponse.setRetarget("#container"); + } + return "view1"; + } + + @HxRequest + @GetMapping("/location-redirect") + public HtmxLocationRedirectView locationRedirect() { + return new HtmxLocationRedirectView("/path"); + } + + @HxRequest + @GetMapping("/location-redirect-expose-model-attributes") + public HtmxLocationRedirectView locationRedirectExposingModelAttributes(RedirectAttributes redirectAttributes) { + redirectAttributes.addAttribute("attr", "value"); + return new HtmxLocationRedirectView("/path"); + } + + @HxRequest + @GetMapping("/location-redirect-with-context-data") + public HtmxLocationRedirectView locationRedirectWithContextData() { + + var redirectView = new HtmxLocationRedirectView("/path"); + redirectView.setSource("source"); + redirectView.setEvent("event"); + redirectView.setHandler("handler"); + redirectView.setTarget("target"); + redirectView.setSwap("swap"); + redirectView.setValues(new TreeMap<>(Map.of("value1", "v1", "value2", "v2"))); + redirectView.setHeaders(new TreeMap<>(Map.of("header1", "v1", "header2", "v2"))); + + return redirectView; + } + + @HxRequest + @GetMapping("/location-redirect-context-data-flash-attributes") + public HtmxLocationRedirectView locationRedirectWithContextDataAndFlashAttributes(RedirectAttributes redirectAttributes) { + + var redirectView = new HtmxLocationRedirectView("/path"); + redirectView.setSource("source"); + redirectView.setEvent("event"); + redirectView.setHandler("handler"); + redirectView.setTarget("target"); + redirectView.setSwap("swap"); + redirectView.setValues(new TreeMap<>(Map.of("value1", "v1", "value2", "v2"))); + redirectView.setHeaders(new TreeMap<>(Map.of("header1", "v1", "header2", "v2"))); + + redirectAttributes.addFlashAttribute("flash", "test"); + + return redirectView; + } + + @HxRequest + @GetMapping("/location-redirect-with-context-data-expose-model-attributes") + public HtmxLocationRedirectView locationRedirectWithContextDataExposingModelAttributes(RedirectAttributes redirectAttributes) { + + var redirectView = new HtmxLocationRedirectView("/path"); + redirectView.setSource("source"); + redirectView.setEvent("event"); + redirectView.setHandler("handler"); + redirectView.setTarget("target"); + redirectView.setSwap("swap"); + redirectView.setValues(new TreeMap<>(Map.of("value1", "v1", "value2", "v2"))); + redirectView.setHeaders(new TreeMap<>(Map.of("header1", "v1", "header2", "v2"))); + + redirectAttributes.addAttribute("attr", "value"); + + return redirectView; + } + + @HxRequest + @GetMapping("/location-redirect-flash-attributes") + public HtmxLocationRedirectView locationRedirectWithFlashAttributes(RedirectAttributes redirectAttributes) { + + redirectAttributes.addFlashAttribute("flash", "test"); + return new HtmxLocationRedirectView("/path"); + } + + @HxRequest + @GetMapping("/multiple-views") + public HtmxView multipleViews() { + return new HtmxView("view1", "view2"); + } + + @HxRequest + @GetMapping("/redirect") + public HtmxRedirectView redirect() { + return new HtmxRedirectView("/test"); + } + + @HxRequest + @GetMapping("/redirect-context-relative") + public HtmxRedirectView redirectContextRelative() { + return new HtmxRedirectView("/test", true); + } + + @HxRequest + @GetMapping("/redirect-expose-model-attributes") + public HtmxRedirectView redirectExposingModelAttributes(RedirectAttributes attributes) { + + attributes.addAttribute("attr", "value"); + return new HtmxRedirectView("/test"); + } + + @HxRequest + @GetMapping("/redirect-flash-attributes") + public HtmxRedirectView redirectWithFlashAttributes(RedirectAttributes redirectAttributes) { + + redirectAttributes.addFlashAttribute("flash", "test"); + return new HtmxRedirectView("/test"); + } + + @HxRequest + @GetMapping("/refresh") + public HtmxRefreshView refresh() { + return new HtmxRefreshView(); + } + + @HxRequest + @GetMapping("/single-view") + public HtmxView singleView() { + return new HtmxView("view1"); + } + + @HxRequest + @GetMapping("/throw-exception") + public void throwException() { + throw new RuntimeException(); + } + + } + +} diff --git a/htmx-spring-boot/src/test/resources/templates/view1.html b/htmx-spring-boot/src/test/resources/templates/view1.html new file mode 100644 index 00000000..17b8b0f6 --- /dev/null +++ b/htmx-spring-boot/src/test/resources/templates/view1.html @@ -0,0 +1 @@ +View1 diff --git a/htmx-spring-boot/src/test/resources/templates/view2.html b/htmx-spring-boot/src/test/resources/templates/view2.html new file mode 100644 index 00000000..ff43f7a8 --- /dev/null +++ b/htmx-spring-boot/src/test/resources/templates/view2.html @@ -0,0 +1 @@ +View2 From a1d2f743cc697f7700a2603633f874812f97d856 Mon Sep 17 00:00:00 2001 From: xhaggi Date: Thu, 7 Nov 2024 08:35:02 +0100 Subject: [PATCH 5/5] Add HtmxViewResolver to handle special view name prefixes for redirect and refresh --- README.md | 10 +- .../boot/mvc/HtmxMvcAutoConfiguration.java | 16 ++ .../spring/boot/mvc/HtmxViewResolver.java | 140 ++++++++++++++++++ .../boot/mvc/HtmxHandlerMethodTest.java | 58 ++++++++ 4 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxViewResolver.java diff --git a/README.md b/README.md index e2ef2099..6726d51c 100644 --- a/README.md +++ b/README.md @@ -117,11 +117,17 @@ except for some control flow response headers such as [HX-Redirect](https://htmx * [HtmxLocationRedirectView](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxLocationRedirectView.html) - sets the [HX-Location](https://htmx.org/headers/hx-location/) header to do a client-side redirect without reloading the whole page. * [HtmxRefreshView](https://javadoc.io/doc/io.github.wimdeblauwe/htmx-spring-boot/latest/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxRefreshView.html) - sets the [HX-Refresh](https://htmx.org/headers/hx-refresh/) header to do a client-side refresh of the current page. +##### Special view name prefixes +For these views, there is also a special view name handling if you prefer to return a view name instead of a view instance. + +* Redirect URLs can be specified via `htmx:redirect:`, e.g. `htmx:redirect:/path`, which causes htmx to perform a redirect to the specified URL. +* Location redirect URLs can be specified via `htmx:location:`, e.g. `htmx:location:/path`, which causes htmx to perform a client-side redirect without reloading the entire page. +* A refresh of the current page can be specified using `htmx:refresh`. ```java @HxRequest @PostMapping("/user/{id}") -public Object user(@PathVariable Long id, @ModelAttribute @Valid UserForm form, +public String user(@PathVariable Long id, @ModelAttribute @Valid UserForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes, HtmxResponse htmxResponse) { @@ -133,7 +139,7 @@ public Object user(@PathVariable Long id, @ModelAttribute @Valid UserForm form, redirectAttributes.addFlashAttribute("successMessage", "User has been successfully updated."); htmxResponse.addTrigger("user-updated"); - return new HtmxRedirectView("/user/list"); + return "htmx:redirect:/user/list"; } ``` 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 77e0d81a..336380d4 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 @@ -5,16 +5,23 @@ import org.springframework.beans.factory.ObjectFactory; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.autoconfigure.web.servlet.WebMvcRegistrations; +import org.springframework.context.annotation.Bean; +import org.springframework.core.Ordered; import org.springframework.util.Assert; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.HandlerMethodReturnValueHandler; import org.springframework.web.servlet.LocaleResolver; +import org.springframework.web.servlet.View; import org.springframework.web.servlet.ViewResolver; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.servlet.view.BeanNameViewResolver; import java.util.List; @@ -59,4 +66,13 @@ public void addReturnValueHandlers(List handler handlers.add(new HtmxViewMethodReturnValueHandler(viewResolverObjectFactory.getObject(), localeResolverObjectFactory.getObject())); } + @Bean + @ConditionalOnBean(View.class) + @ConditionalOnMissingBean + public HtmxViewResolver htmxViewResolver() { + HtmxViewResolver resolver = new HtmxViewResolver(); + resolver.setOrder(Ordered.LOWEST_PRECEDENCE - 10); + return resolver; + } + } diff --git a/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxViewResolver.java b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxViewResolver.java new file mode 100644 index 00000000..7e2be59b --- /dev/null +++ b/htmx-spring-boot/src/main/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxViewResolver.java @@ -0,0 +1,140 @@ +package io.github.wimdeblauwe.htmx.spring.boot.mvc; + +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.core.Ordered; +import org.springframework.lang.Nullable; +import org.springframework.web.context.support.WebApplicationObjectSupport; +import org.springframework.web.servlet.View; +import org.springframework.web.servlet.ViewResolver; +import org.springframework.web.servlet.view.RedirectView; + +import java.util.Locale; + +/** + * A simple implementation of {@link org.springframework.web.servlet.ViewResolver} + * that interprets a view name as htmx specific operations e.g. redirecting to a URL. + * + * @since 3.6.0 + */ +public class HtmxViewResolver extends WebApplicationObjectSupport implements ViewResolver, Ordered { + + /** + * Prefix for special view names that specify a redirect URL + * that htmx should navigate to. + */ + public static final String REDIRECT_URL_PREFIX = "htmx:redirect:"; + + /** + * Prefix for special view names that specify a redirect URL that + * htmx should navigate to without a full page reload. + */ + public static final String LOCATION_URL_PREFIX = "htmx:location:"; + + /** + * Prefix for special view names that specify a refresh of the current page. + */ + public static final String REFRESH_VIEW_NAME = "htmx:refresh"; + + private int order = Ordered.LOWEST_PRECEDENCE; + + private boolean redirectContextRelative = true; + + @Nullable + private String[] redirectHosts; + + @Override + public int getOrder() { + return order; + } + + /** + * Return the configured application hosts for redirect purposes. + */ + @Nullable + public String[] getRedirectHosts() { + return this.redirectHosts; + } + + @Override + public View resolveViewName(String viewName, Locale locale) throws Exception { + + if (viewName.equals(REFRESH_VIEW_NAME)) { + return new HtmxRefreshView(); + } + + if (viewName.startsWith(REDIRECT_URL_PREFIX)) { + String redirectUrl = viewName.substring(REDIRECT_URL_PREFIX.length()); + RedirectView view = new HtmxRedirectView(redirectUrl, isRedirectContextRelative()); + String[] hosts = getRedirectHosts(); + if (hosts != null) { + view.setHosts(hosts); + } + return view; + } + + if (viewName.startsWith(LOCATION_URL_PREFIX)) { + String redirectUrl = viewName.substring(LOCATION_URL_PREFIX.length()); + RedirectView view = new HtmxLocationRedirectView(redirectUrl, isRedirectContextRelative()); + String[] hosts = getRedirectHosts(); + if (hosts != null) { + view.setHosts(hosts); + } + return view; + } + + return null; + } + + /** + * Specify the order value for this ViewResolver bean. + *

The default value is {@code Ordered.LOWEST_PRECEDENCE}, meaning non-ordered. + * + * @see org.springframework.core.Ordered#getOrder() + */ + public void setOrder(int order) { + this.order = order; + } + + /** + * Set whether to interpret a given redirect URL that starts with a + * slash ("/") as relative to the current ServletContext, i.e. as + * relative to the web application root. + * + *

Default is {@code true}: A redirect URL that starts with a slash will be + * interpreted as relative to the web application root, i.e. the context + * path will be prepended to the URL. + * + * @see HtmxRedirectView#setContextRelative + * @see HtmxLocationRedirectView#setContextRelative + * @see #REDIRECT_URL_PREFIX + */ + public void setRedirectContextRelative(boolean redirectContextRelative) { + this.redirectContextRelative = redirectContextRelative; + } + + /** + * Configure one or more hosts associated with the application. + * All other hosts will be considered external hosts. + * + *

In effect, this property provides a way turn off encoding on redirect + * via {@link HttpServletResponse#encodeRedirectURL} for URLs that have a + * host and that host is not listed as a known host. + * + *

If not set (the default) all URLs are encoded through the response. + * + * @param redirectHosts one or more application hosts + */ + public void setRedirectHosts(@Nullable String... redirectHosts) { + this.redirectHosts = redirectHosts; + } + + /** + * Return whether to interpret a given redirect URL that starts with a + * slash ("/") as relative to the current ServletContext, i.e. as + * relative to the web application root. + */ + protected boolean isRedirectContextRelative() { + return this.redirectContextRelative; + } + +} diff --git a/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerMethodTest.java b/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerMethodTest.java index d17eda4c..5b716da0 100644 --- a/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerMethodTest.java +++ b/htmx-spring-boot/src/test/java/io/github/wimdeblauwe/htmx/spring/boot/mvc/HtmxHandlerMethodTest.java @@ -58,6 +58,22 @@ public void testLocationRedirectExposingModelAttributes() throws Exception { .andExpect(header().string("HX-Location", "/path?attr=value")); } + @Test + public void testLocationRedirectViewNamePrefix() throws Exception { + + mockMvc.perform(get("/location-redirect-view-name-prefix").headers(htmxRequest())) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Location", "/path")); + } + + @Test + public void testLocationRedirectViewNamePrefixContextRelative() throws Exception { + + mockMvc.perform(get("/contextpath/location-redirect-view-name-prefix").contextPath("/contextpath").headers(htmxRequest())) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Location", "/contextpath/path")); + } + @Test public void testLocationRedirectWithContextData() throws Exception { @@ -116,6 +132,22 @@ public void testRedirectExposingModelAttributes() throws Exception { .andExpect(header().string("HX-Redirect", "/test?attr=value")); } + @Test + public void testRedirectViewNamePrefix() throws Exception { + + mockMvc.perform(get("/redirect-view-name-prefix").headers(htmxRequest())) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Redirect", "/test")); + } + + @Test + public void testRedirectViewNamePrefixContextRelative() throws Exception { + + mockMvc.perform(get("/contextpath/redirect-view-name-prefix").contextPath("/contextpath").headers(htmxRequest())) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Redirect", "/contextpath/test")); + } + @Test public void testRedirectWithContextPath() throws Exception { @@ -141,6 +173,14 @@ public void testRefresh() throws Exception { .andExpect(header().string("HX-Refresh", HtmxValue.TRUE)); } + @Test + public void testRefreshViewName() throws Exception { + + mockMvc.perform(get("/refresh-view-name").headers(htmxRequest())) + .andExpect(status().isOk()) + .andExpect(header().string("HX-Refresh", HtmxValue.TRUE)); + } + @Test public void testSingleView() throws Exception { @@ -173,6 +213,12 @@ public HtmxLocationRedirectView locationRedirectExposingModelAttributes(Redirect return new HtmxLocationRedirectView("/path"); } + @HxRequest + @GetMapping("/location-redirect-view-name-prefix") + public String locationRedirectViewNamePrefix() { + return "htmx:location:/path"; + } + @HxRequest @GetMapping("/location-redirect-with-context-data") public HtmxLocationRedirectView locationRedirectWithContextData() { @@ -259,6 +305,12 @@ public HtmxRedirectView redirectExposingModelAttributes(RedirectAttributes attri return new HtmxRedirectView("/test"); } + @HxRequest + @GetMapping("/redirect-view-name-prefix") + public String redirectViewNamePrefix() { + return "htmx:redirect:/test"; + } + @HxRequest @GetMapping("/redirect-flash-attributes") public HtmxRedirectView redirectWithFlashAttributes(RedirectAttributes redirectAttributes) { @@ -273,6 +325,12 @@ public HtmxRefreshView refresh() { return new HtmxRefreshView(); } + @HxRequest + @GetMapping("/refresh-view-name") + public String refreshViewName() { + return "htmx:refresh"; + } + @HxRequest @GetMapping("/single-view") public HtmxView singleView() {