Skip to content

Commit

Permalink
Rewrite HtmxResponse for use as handler method argument
Browse files Browse the repository at this point in the history
  • Loading branch information
xhaggi committed Nov 4, 2024
1 parent e4b389f commit f22ae27
Show file tree
Hide file tree
Showing 5 changed files with 546 additions and 78 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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.*;

Expand All @@ -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<String, Object> 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) {
Expand Down Expand Up @@ -270,5 +268,4 @@ private String getRequestUrl(HttpServletRequest request) {
return path;
}


}
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,13 @@ public final class HtmxResponse {

private static final Logger LOGGER = LoggerFactory.getLogger(HtmxResponse.class);

private final Set<ModelAndView> views;
private final Set<HtmxTrigger> triggers;
private final Set<HtmxTrigger> triggersAfterSettle;
private final Set<HtmxTrigger> 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<ModelAndView> views = new LinkedHashSet<>();
private Set<HtmxTrigger> triggers = new LinkedHashSet<>();
private Set<HtmxTrigger> triggersAfterSettle = new LinkedHashSet<>();
private Set<HtmxTrigger> triggersAfterSwap = new LinkedHashSet<>();
private String replaceUrl;
private String reselect;
private boolean contextRelative = true;
private String retarget;
private boolean refresh;
private String redirect;
Expand All @@ -46,37 +45,199 @@ 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.
* <p>Multiple trigger were automatically be merged into the same header.
*
* @param eventName the event name
* @see <a href="https://htmx.org/headers/hx-trigger/">HX-Trigger Response Headers</a>
* @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.
* <p>Multiple trigger were automatically be merged into the same header.
*
* @param eventName the event name
* @param eventDetail details along with the event
* @see <a href="https://htmx.org/headers/hx-trigger/">HX-Trigger Response Headers</a>
* @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 <a href="https://htmx.org/docs/#request-operations">settling step</a>.
* <p>Multiple triggers were automatically be merged into the same header.
*
* @param eventName the event name
* @see <a href="https://htmx.org/headers/hx-trigger/">HX-Trigger Response Headers</a>
* @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 <a href="https://htmx.org/docs/#request-operations">settling step</a>.
* <p>Multiple triggers were automatically be merged into the same header.
*
* @param eventName the event name
* @param eventDetail details along with the event
* @see <a href="https://htmx.org/headers/hx-trigger/">HX-Trigger Response Headers</a>
* @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 <a href="https://htmx.org/docs/#request-operations">swap step</a>.
* <p>Multiple triggers were automatically be merged into the same header.
*
* @param eventName the event name
* @see <a href="https://htmx.org/headers/hx-trigger/">HX-Trigger Response Headers</a>
* @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 <a href="https://htmx.org/docs/#request-operations">swap step</a>.
* <p>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 <a href="https://htmx.org/headers/hx-trigger/">HX-Trigger Response Headers</a>
* @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 <a href="https://htmx.org/headers/hx-push-url/">HX-Push-Url Response Header</a> documentation
* @see <a href="https://htmx.org/headers/hx-replace-url/">HX-Replace-Url Response Header</a>
* @since 3.6.0
*/
public void preventHistoryUpdate() {
this.pushUrl = "false";
this.replaceUrl = null;
this.reselect = null;
this.contextRelative = true;
}

HtmxResponse(Set<ModelAndView> views, Set<HtmxTrigger> triggers, Set<HtmxTrigger> triggersAfterSettle,
Set<HtmxTrigger> 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;
}

/**
* Causes a client-side refresh of the page.
*
* @see <a href="https://htmx.org/reference/#response_headers">HX-Refresh</a>
* @since 3.6.0
*/
public void refresh() {
this.refresh = true;
}

/**
* 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.
* <p>
* 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 <a href="https://htmx.org/headers/hx-push/">HX-Push Response Header</a> documentation
* @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/History/replaceState">history.pushState()</a>
* @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.
* <p>
* 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 <a href="https://htmx.org/headers/hx-replace-url/">HX-Replace-Url Response Header</a>
* @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/History/replaceState">history.replaceState()</a>
* @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 <a href="https://htmx.org/attributes/hx-select/">hx-select</a> on the triggering element.
*
* @param cssSelector the CSS selector
* @see <a href="https://htmx.org/reference/#response_headers">HX-Reselect</a>
* @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 <a href="https://htmx.org/attributes/hx-swap/">hx-swap</a> for possible values.
*
* @param reswap the reswap options.
* @see <a href="https://htmx.org/reference/#response_headers">HX-Reswap</a>
* @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 <a href="https://htmx.org/reference/#response_headers">HX-Retarget</a>
* @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.
*
Expand Down Expand Up @@ -123,20 +284,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 <a href="https://htmx.org/headers/hx-trigger/">HX-Trigger Response Headers</a>
* @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
Expand Down Expand Up @@ -464,20 +611,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;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}

}
Loading

0 comments on commit f22ae27

Please sign in to comment.