Skip to content

Commit

Permalink
Allow templates to be added as resolved Views
Browse files Browse the repository at this point in the history
Also as fully-formed ModelAndView, or (as before) as view name String
to be resolved later.
  • Loading branch information
wimdeblauwe authored and dsyer committed Aug 3, 2023
1 parent f4998bd commit 800f655
Show file tree
Hide file tree
Showing 7 changed files with 164 additions and 32 deletions.
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,20 @@ public HtmxResponse getMainAndPartial(Model model){
}
```

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:

```java
@GetMapping("/partials/main-and-partial")
public HtmxResponse getMainAndPartial(Model model){
return new HtmxResponse()
.addTemplate(new ModelAndView("users :: list")
.addTemplate(new ModelAndView("users :: count", Map.of("userCount",5));
}
```

Using `ModelAndView` means that each fragment can have its own model (which is merged with the controller model before rendering).

### Spring Security

The library has an `HxRefreshHeaderAuthenticationEntryPoint` that you can use to have htmx force a full page browser
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.autoconfigure.web.servlet.WebMvcRegistrations;
import org.springframework.context.ApplicationContext;
import org.springframework.util.Assert;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.LocaleResolver;
Expand Down
63 changes: 58 additions & 5 deletions src/main/java/io/github/wimdeblauwe/hsbt/mvc/HtmxResponse.java
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
package io.github.wimdeblauwe.hsbt.mvc;

import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.Assert;

import java.util.*;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.View;

/**
* Representation of HTMX partials.
Expand All @@ -15,7 +22,7 @@
final public class HtmxResponse {
private static final Logger LOG = LoggerFactory.getLogger(HtmxResponse.class);

private final Set<String> templates;
private final Set<ModelAndView> templates;
private final Map<String, String> triggers;
private final Map<String, String> triggersAfterSettle;
private final Map<String, String> triggersAfterSwap;
Expand All @@ -39,6 +46,34 @@ public HtmxResponse() {
*/
public HtmxResponse addTemplate(String template) {
Assert.hasText(template, "template should not be blank");
if (!templates.stream().anyMatch(mav -> template.equals(mav.getViewName()))) {
templates.add(new ModelAndView(template));
}
return this;
}

/**
* Append the rendered template or fragment as a resolved {@link View}.
*
* @param template must not be {@literal null}.
* @return same HtmxResponse for chaining
*/
public HtmxResponse addTemplate(View template) {
Assert.notNull(template, "template should not be null");
if (!templates.stream().anyMatch(mav -> template.equals(mav.getView()))) {
templates.add(new ModelAndView(template));
}
return this;
}

/**
* Append the rendered template or fragment as a {@link ModelAndView}.
*
* @param template must not be {@literal null}.
* @return same HtmxResponse for chaining
*/
public HtmxResponse addTemplate(ModelAndView template) {
Assert.notNull(template, "template should not be null");
templates.add(template);
return this;
}
Expand Down Expand Up @@ -142,8 +177,10 @@ public HtmxResponse retarget(String cssSelector) {
*/
public HtmxResponse and(HtmxResponse otherResponse){
otherResponse.templates.forEach(otherTemplate -> {
if(!this.templates.add(otherTemplate)) {
if(this.templates.stream().anyMatch(mav -> same(otherTemplate, mav))) {
LOG.info("Duplicate template '{}' found while merging HtmxResponse", otherTemplate);
} else {
templates.add(otherTemplate);
}
});
mergeMapAndLog(HxTriggerLifecycle.RECEIVE, this.triggers, otherResponse.triggers);
Expand All @@ -166,6 +203,22 @@ public HtmxResponse and(HtmxResponse otherResponse){
return this;
}

private boolean same(ModelAndView one, ModelAndView two) {
if (one == two) {
return true;
}
if (one == null || two == null) {
return false;
}
if (one.getViewName() !=null && one.getViewName().equals(two.getViewName())) {
return true;
}
if (one.getView() !=null && one.getView().equals(two.getView())) {
return true;
}
return false;
}

private void mergeMapAndLog(HxTriggerLifecycle receive, Map<String, String> triggers, Map<String, String> otherTriggers) {
otherTriggers.forEach((key, value) -> {
if(LOG.isInfoEnabled()) {
Expand All @@ -179,7 +232,7 @@ private void mergeMapAndLog(HxTriggerLifecycle receive, Map<String, String> trig
}


Collection<String> getTemplates() {
Collection<ModelAndView> getTemplates() {
return Collections.unmodifiableCollection(templates);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,29 @@
*/
package io.github.wimdeblauwe.hsbt.mvc;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.Locale;
import java.util.Map;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectFactory;
import org.springframework.util.Assert;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.*;
import org.springframework.web.servlet.HandlerInterceptor;
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.util.ContentCachingResponseWrapper;

import java.util.Locale;
import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

/**
* A {@link HandlerInterceptor} that turns {@link HtmxResponse} instances returned from controller methods into a
* A {@link HandlerInterceptor} that turns {@link HtmxResponse} instances
* returned from controller methods into a
*
* @author Oliver Drotbohm
*/
Expand All @@ -41,19 +48,24 @@ class HtmxViewHandlerInterceptor implements HandlerInterceptor {
private final ObjectFactory<LocaleResolver> locales;
private final ObjectMapper objectMapper;

public HtmxViewHandlerInterceptor(ViewResolver views, ObjectFactory<LocaleResolver> locales, ObjectMapper objectMapper) {
public HtmxViewHandlerInterceptor(ViewResolver views, ObjectFactory<LocaleResolver> locales,
ObjectMapper objectMapper) {
this.views = views;
this.locales = locales;
this.objectMapper = objectMapper;
}

/*
* (non-Javadoc)
* @see org.springframework.web.servlet.HandlerInterceptor#postHandle(javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse, java.lang.Object, org.springframework.web.servlet.ModelAndView)
*
* @see
* org.springframework.web.servlet.HandlerInterceptor#postHandle(javax.servlet.
* http.HttpServletRequest, javax.servlet.http.HttpServletResponse,
* java.lang.Object, org.springframework.web.servlet.ModelAndView)
*/
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
ModelAndView modelAndView) throws Exception {
ModelAndView modelAndView) throws Exception {

if (modelAndView == null || !HandlerMethod.class.isInstance(handler)) {
return;
Expand Down Expand Up @@ -101,7 +113,8 @@ private void addHxHeaders(HtmxResponse htmxResponse, HttpServletResponse respons
}
}

private void setTriggerHeader(HxTriggerLifecycle triggerHeader, Map<String, String> triggers, HttpServletResponse response) {
private void setTriggerHeader(HxTriggerLifecycle triggerHeader, Map<String, String> triggers,
HttpServletResponse response) {
if (triggers.isEmpty()) {
return;
}
Expand Down Expand Up @@ -132,10 +145,18 @@ private View toView(HtmxResponse partials) {
return (model, request, response) -> {
Locale locale = locales.getObject().resolveLocale(request);
ContentCachingResponseWrapper wrapper = new ContentCachingResponseWrapper(response);
for (String template : partials.getTemplates()) {
View view = views.resolveViewName(template, locale);
for (ModelAndView template : partials.getTemplates()) {
View view = template.getView();
if (view == null) {
view = views.resolveViewName(template.getViewName(), locale);
}
for (String key: model.keySet()) {
if(!template.getModel().containsKey(key)) {
template.getModel().put(key, model.get(key));
}
}
Assert.notNull(view, "Template '" + template + "' could not be resolved");
view.render(model, request, wrapper);
view.render(template.getModel(), request, wrapper);
}
wrapper.copyBodyToResponse();
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
package io.github.wimdeblauwe.hsbt.mvc;

import static io.github.wimdeblauwe.hsbt.mvc.support.PartialXpathResultMatchers.partialXpath;
import static org.hamcrest.core.StringContains.containsString;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.xpath;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
Expand All @@ -8,13 +19,6 @@
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.result.MockMvcResultHandlers;

import static io.github.wimdeblauwe.hsbt.mvc.support.PartialXpathResultMatchers.partialXpath;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(PartialsController.class)
@WithMockUser
class HtmxPartialHandlerInterceptorTest {
Expand All @@ -34,6 +38,23 @@ public void testASinglePartialCanBeReturned() throws Exception {
.andExpect(xpath("/ul[@hx-swap-oob='true']").doesNotExist());
}

@Test
public void testASingleViewCanBeReturned() throws Exception {
mockMvc.perform(get("/partials/view"))
.andDo(MockMvcResultHandlers.print())
.andExpect(status().isOk())
.andExpect(xpath("/ul").exists());
}

@Test
public void testASingleModelAndViewCanBeReturned() throws Exception {
mockMvc.perform(get("/partials/mav"))
.andDo(MockMvcResultHandlers.print())
.andExpect(status().isOk())
.andExpect(partialXpath("/span[@id='item']").exists())
.andExpect(content().string(containsString("Foo")));
}

@Test
public void testAMainChange() throws Exception {
mockMvc.perform(get("/partials/main-and-partial"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public void addingATemplateShouldWork() {
String myTemplate = "myTemplate";
sut.addTemplate(myTemplate);

assertThat(sut.getTemplates()).containsExactly(myTemplate);
assertThat(sut.getTemplates()).extracting(mav -> mav.getViewName()).containsExactly(myTemplate);
}

@Test
Expand All @@ -38,7 +38,7 @@ public void addingTheSameTemplateASecondTimeShouldIgnoreDuplicates() {
sut.addTemplate(myTemplateAndFragment);
sut.addTemplate(myTemplate);

assertThat(sut.getTemplates()).containsExactly(myTemplate, myTemplateAndFragment);
assertThat(sut.getTemplates()).extracting(mav -> mav.getViewName()).containsExactly(myTemplate, myTemplateAndFragment);
}

@Test
Expand Down Expand Up @@ -91,7 +91,7 @@ public void addedTemplatesPreserveTheirOrder() {
sut.addTemplate(template1);
sut.addTemplate(template2);

assertThat(sut.getTemplates()).containsExactly(template1, template2);
assertThat(sut.getTemplates()).extracting(mav -> mav.getViewName()).containsExactly(template1, template2);
}

@Test
Expand All @@ -103,7 +103,7 @@ public void mergingResponsesPreserveTemplateOrder() {

response1.and(response2);

assertThat(response1.getTemplates())
assertThat(response1.getTemplates()).extracting(mav -> mav.getViewName())
.hasSize(4)
.containsExactly("response1 :: template 11",
"response1 :: template 12",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
package io.github.wimdeblauwe.hsbt.mvc;

import static io.github.wimdeblauwe.hsbt.mvc.CommonHtmxResponses.sendAlertPartial;

import java.util.Map;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.view.AbstractView;

import static io.github.wimdeblauwe.hsbt.mvc.CommonHtmxResponses.sendAlertPartial;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

@Controller
public class PartialsController {
Expand Down Expand Up @@ -41,6 +48,23 @@ public HtmxResponse getFirstPartials() {
return new HtmxResponse().addTemplate("users :: list");
}

@GetMapping("/partials/view")
public HtmxResponse getFirstView() {
return new HtmxResponse().addTemplate(new AbstractView() {
@Override
protected void renderMergedOutputModel(Map<String, Object> model, HttpServletRequest request,
HttpServletResponse response) throws Exception {
response.getWriter().write("<ul><li>A list</li></ul>");
}

});
}

@GetMapping("/partials/mav")
public HtmxResponse getFirstModelAndView() {
return new HtmxResponse().addTemplate(new ModelAndView("fragments :: todoItem", Map.of("item", new TodoItem("Foo"))));
}

@GetMapping("/partials/main-and-partial")
public HtmxResponse getMainAndPartial(Model model) {
model.addAttribute("userCountOob", true);
Expand Down

0 comments on commit 800f655

Please sign in to comment.