Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 7 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,9 @@ public String users() {
### HTML Fragments

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)
htmx can use to update different parts of the page, which is called [Out Of Band Swaps](https://htmx.org/docs/#oob_swaps). Spring offers the ability to return
multiple HTML fragments using `Collection<ModelAndView>` or `FragmentsRendering` as return type of controller. Further information on this can be found in the
Spring Framework documentation under [HTML Fragments](https://docs.spring.io/spring-framework/reference/web/webmvc-view/mvc-fragments.html).

```java
@HxRequest
Expand All @@ -185,30 +186,13 @@ 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;
return FragmentsRendering
.with("users/list")
.fragment("users/count")
.build();
}
```

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("/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()));

return view;
}
```


### Exceptions

It is also possible to use `HtmxRequest` and `HtmxResponse` as method argument in handler methods annotated with `@ExceptionHandler`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver;
Expand All @@ -26,7 +27,8 @@ public HtmxExceptionHandlerExceptionResolver(HtmxHandlerMethodAnnotationHandler
@Override
protected ModelAndView doResolveHandlerMethodException(HttpServletRequest request, HttpServletResponse response, HandlerMethod handlerMethod, Exception exception) {

ServletInvocableHandlerMethod exceptionHandlerMethod = getExceptionHandlerMethod(handlerMethod, exception);
ServletWebRequest webRequest = new ServletWebRequest(request, response);
ServletInvocableHandlerMethod exceptionHandlerMethod = this.getExceptionHandlerMethod(handlerMethod, exception, webRequest);
if (exceptionHandlerMethod != null) {
Method method = exceptionHandlerMethod.getMethod();
handlerMethodAnnotationHandler.handleMethod(method, request, response);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,31 +31,16 @@ public void afterCompletion(HttpServletRequest request, HttpServletResponse resp

HtmxResponse htmxResponse = RequestContextUtils.getHtmxResponse(request);
if (htmxResponse != null) {
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()));
}
}
addHxTriggerHeaders(response, HtmxResponseHeader.HX_TRIGGER, htmxResponse.getTriggers());
addHxTriggerHeaders(response, HtmxResponseHeader.HX_TRIGGER_AFTER_SETTLE, htmxResponse.getTriggersAfterSettle());
addHxTriggerHeaders(response, HtmxResponseHeader.HX_TRIGGER_AFTER_SWAP, htmxResponse.getTriggersAfterSwap());

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());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,10 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.http.HttpHeaders;

import java.lang.reflect.Method;
import java.time.Duration;

import static io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxResponseHeader.*;

/**
* A handler for processing htmx annotations present on exception handler methods.
*
Expand Down Expand Up @@ -56,56 +53,56 @@ private void setHxPushUrl(HttpServletRequest request, HttpServletResponse respon
HxPushUrl methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxPushUrl.class);
if (methodAnnotation != null) {
if (HtmxValue.TRUE.equals(methodAnnotation.value())) {
setHeader(response, HX_PUSH_URL, getRequestUrl(request));
setHeader(response, HtmxResponseHeader.HX_PUSH_URL, getRequestUrl(request));
} else {
setHeader(response, HX_PUSH_URL, RequestContextUtils.createUrl(request, methodAnnotation.value(), methodAnnotation.contextRelative()));
setHeader(response, HtmxResponseHeader.HX_PUSH_URL, RequestContextUtils.createUrl(request, methodAnnotation.value(), methodAnnotation.contextRelative()));
}
}
}

private void setHxRedirect(HttpServletRequest request, HttpServletResponse response, Method method) {
HxRedirect methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxRedirect.class);
if (methodAnnotation != null) {
setHeader(response, HX_REDIRECT, RequestContextUtils.createUrl(request, methodAnnotation.value(), methodAnnotation.contextRelative()));
setHeader(response, HtmxResponseHeader.HX_REDIRECT, RequestContextUtils.createUrl(request, methodAnnotation.value(), methodAnnotation.contextRelative()));
}
}

private void setHxReplaceUrl(HttpServletRequest request, HttpServletResponse response, Method method) {
HxReplaceUrl methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxReplaceUrl.class);
if (methodAnnotation != null) {
if (HtmxValue.TRUE.equals(methodAnnotation.value())) {
setHeader(response, HX_REPLACE_URL, getRequestUrl(request));
setHeader(response, HtmxResponseHeader.HX_REPLACE_URL, getRequestUrl(request));
} else {
setHeader(response, HX_REPLACE_URL, RequestContextUtils.createUrl(request, methodAnnotation.value(), methodAnnotation.contextRelative()));
setHeader(response, HtmxResponseHeader.HX_REPLACE_URL, RequestContextUtils.createUrl(request, methodAnnotation.value(), methodAnnotation.contextRelative()));
}
}
}

private void setHxReswap(HttpServletResponse response, Method method) {
HxReswap methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxReswap.class);
if (methodAnnotation != null) {
setHeader(response, HX_RESWAP, convertToReswap(methodAnnotation));
setHeader(response, HtmxResponseHeader.HX_RESWAP, convertToReswap(methodAnnotation));
}
}

private void setHxRetarget(HttpServletResponse response, Method method) {
HxRetarget methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxRetarget.class);
if (methodAnnotation != null) {
setHeader(response, HX_RETARGET, methodAnnotation.value());
setHeader(response, HtmxResponseHeader.HX_RETARGET, methodAnnotation.value());
}
}

private void setHxReselect(HttpServletResponse response, Method method) {
HxReselect methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxReselect.class);
if (methodAnnotation != null) {
setHeader(response, HX_RESELECT, methodAnnotation.value());
setHeader(response, HtmxResponseHeader.HX_RESELECT, methodAnnotation.value());
}
}

private void setHxTrigger(HttpServletResponse response, Method method) {
HxTrigger methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxTrigger.class);
if (methodAnnotation != null) {
setHeader(response, convertToHeader(methodAnnotation.lifecycle()), methodAnnotation.value());
setHeader(response, HtmxResponseHeader.HX_TRIGGER, methodAnnotation.value());
}
}

Expand All @@ -126,20 +123,7 @@ private void setHxTriggerAfterSwap(HttpServletResponse response, Method method)
private void setHxRefresh(HttpServletResponse response, Method method) {
HxRefresh methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, HxRefresh.class);
if (methodAnnotation != null) {
setHeader(response, HX_REFRESH, HtmxValue.TRUE);
}
}

private HtmxResponseHeader convertToHeader(HxTriggerLifecycle lifecycle) {
switch (lifecycle) {
case RECEIVE:
return HX_TRIGGER;
case SETTLE:
return HX_TRIGGER_AFTER_SETTLE;
case SWAP:
return HX_TRIGGER_AFTER_SWAP;
default:
throw new IllegalArgumentException("Unknown lifecycle:" + lifecycle);
setHeader(response, HtmxResponseHeader.HX_REFRESH, HtmxValue.TRUE);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,6 @@
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

import java.util.Objects;

import static io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxRequestHeader.*;

public class HtmxHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver {

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,16 @@

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
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.WebMvcConfigurer;
import org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver;
Expand All @@ -28,19 +23,10 @@
@ConditionalOnWebApplication
public class HtmxMvcAutoConfiguration implements WebMvcRegistrations, WebMvcConfigurer {

private final ObjectFactory<ViewResolver> viewResolverObjectFactory;
private final ObjectFactory<LocaleResolver> localeResolverObjectFactory;
private final ObjectMapper objectMapper;
private final HtmxHandlerMethodAnnotationHandler handlerMethodAnnotationHandler;

HtmxMvcAutoConfiguration(@Qualifier("viewResolver") ObjectFactory<ViewResolver> viewResolverObjectFactory,
ObjectFactory<LocaleResolver> localeResolverObjectFactory) {

Assert.notNull(viewResolverObjectFactory, "viewResolverObjectFactory must not be null!");
Assert.notNull(localeResolverObjectFactory, "localeResolverObjectFactory must not be null!");

this.viewResolverObjectFactory = viewResolverObjectFactory;
this.localeResolverObjectFactory = localeResolverObjectFactory;
HtmxMvcAutoConfiguration() {
this.objectMapper = JsonMapper.builder().build();
this.handlerMethodAnnotationHandler = new HtmxHandlerMethodAnnotationHandler(this.objectMapper);
}
Expand All @@ -63,8 +49,7 @@ public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers)

@Override
public void addReturnValueHandlers(List<HandlerMethodReturnValueHandler> handlers) {
handlers.add(new HtmxResponseHandlerMethodReturnValueHandler(viewResolverObjectFactory.getObject(), localeResolverObjectFactory, objectMapper));
handlers.add(new HtmxViewMethodReturnValueHandler(viewResolverObjectFactory.getObject(), localeResolverObjectFactory.getObject()));
handlers.add(new HtmxViewMethodReturnValueHandler());
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
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;

import static io.github.wimdeblauwe.htmx.spring.boot.mvc.HtmxRequestHeader.*;

/**
* This class can be used as a controller method argument to access
* the <a href="https://htmx.org/reference/#request_headers">htmx Request Headers</a>.
Expand Down Expand Up @@ -188,98 +188,36 @@ public static final class Builder {
private Builder() {
}

/**
* @deprecated use {@link #boosted(boolean)} instead. Will be removed in 4.0.
*/
@Deprecated
public Builder withBoosted(boolean boosted) {
return boosted(boosted);
}

public Builder boosted(boolean boosted) {
this.boosted = boosted;
return this;
}

/**
* @deprecated use {@link #currentUrl(String)} instead. Will be removed in 4.0.
*/
@Deprecated
public Builder withCurrentUrl(String currentUrl) {
this.currentUrl = currentUrl;
return this;
}

public Builder currentUrl(String currentUrl) {
this.currentUrl = currentUrl;
return this;
}

/**
* @deprecated use {@link #historyRestoreRequest(boolean)} instead. Will be removed in 4.0.
*/
@Deprecated
public Builder withHistoryRestoreRequest(boolean historyRestoreRequest) {
this.historyRestoreRequest = historyRestoreRequest;
return this;
}

public Builder historyRestoreRequest(boolean historyRestoreRequest) {
this.historyRestoreRequest = historyRestoreRequest;
return this;
}

/**
* @deprecated use {@link #promptResponse(String)} instead. Will be removed in 4.0.
*/
@Deprecated
public Builder withPromptResponse(String promptResponse) {
this.promptResponse = promptResponse;
return this;
}

public Builder promptResponse(String promptResponse) {
this.promptResponse = promptResponse;
return this;
}

/**
* @deprecated use {@link #target(String)} instead. Will be removed in 4.0.
*/
@Deprecated
public Builder withTarget(String target) {
this.target = target;
return this;
}

public Builder target(String target) {
this.target = target;
return this;
}

/**
* @deprecated use {@link #triggerName(String)} instead. Will be removed in 4.0.
*/
@Deprecated
public Builder withTriggerName(String triggerName) {
this.triggerName = triggerName;
return this;
}

public Builder triggerName(String triggerName) {
this.triggerName = triggerName;
return this;
}

/**
* @deprecated use {@link #triggerId(String)} instead. Will be removed in 4.0.
*/
@Deprecated
public Builder withTriggerId(String triggerId) {
this.triggerId = triggerId;
return this;
}

public Builder triggerId(String triggerId) {
this.triggerId = triggerId;
return this;
Expand Down
Loading