From 800f6550806f7a8a7b9ad358d7d7aa44fb0acc90 Mon Sep 17 00:00:00 2001 From: Wim Deblauwe Date: Fri, 28 Jul 2023 13:49:52 +0200 Subject: [PATCH] Allow templates to be added as resolved Views Also as fully-formed ModelAndView, or (as before) as view name String to be resolved later. --- README.md | 14 +++++ .../hsbt/mvc/HtmxMvcConfiguration.java | 1 - .../wimdeblauwe/hsbt/mvc/HtmxResponse.java | 63 +++++++++++++++++-- .../hsbt/mvc/HtmxViewHandlerInterceptor.java | 49 ++++++++++----- .../HtmxPartialHandlerInterceptorTest.java | 35 ++++++++--- .../hsbt/mvc/HtmxResponseTest.java | 8 +-- .../hsbt/mvc/PartialsController.java | 26 +++++++- 7 files changed, 164 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 0dddb9f..f6190cf 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/main/java/io/github/wimdeblauwe/hsbt/mvc/HtmxMvcConfiguration.java b/src/main/java/io/github/wimdeblauwe/hsbt/mvc/HtmxMvcConfiguration.java index 2738891..25aad26 100644 --- a/src/main/java/io/github/wimdeblauwe/hsbt/mvc/HtmxMvcConfiguration.java +++ b/src/main/java/io/github/wimdeblauwe/hsbt/mvc/HtmxMvcConfiguration.java @@ -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; diff --git a/src/main/java/io/github/wimdeblauwe/hsbt/mvc/HtmxResponse.java b/src/main/java/io/github/wimdeblauwe/hsbt/mvc/HtmxResponse.java index f778dc2..835a88e 100644 --- a/src/main/java/io/github/wimdeblauwe/hsbt/mvc/HtmxResponse.java +++ b/src/main/java/io/github/wimdeblauwe/hsbt/mvc/HtmxResponse.java @@ -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. @@ -15,7 +22,7 @@ final public class HtmxResponse { private static final Logger LOG = LoggerFactory.getLogger(HtmxResponse.class); - private final Set templates; + private final Set templates; private final Map triggers; private final Map triggersAfterSettle; private final Map triggersAfterSwap; @@ -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; } @@ -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); @@ -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 triggers, Map otherTriggers) { otherTriggers.forEach((key, value) -> { if(LOG.isInfoEnabled()) { @@ -179,7 +232,7 @@ private void mergeMapAndLog(HxTriggerLifecycle receive, Map trig } - Collection getTemplates() { + Collection getTemplates() { return Collections.unmodifiableCollection(templates); } diff --git a/src/main/java/io/github/wimdeblauwe/hsbt/mvc/HtmxViewHandlerInterceptor.java b/src/main/java/io/github/wimdeblauwe/hsbt/mvc/HtmxViewHandlerInterceptor.java index a11c18d..e1fa82b 100644 --- a/src/main/java/io/github/wimdeblauwe/hsbt/mvc/HtmxViewHandlerInterceptor.java +++ b/src/main/java/io/github/wimdeblauwe/hsbt/mvc/HtmxViewHandlerInterceptor.java @@ -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 */ @@ -41,7 +48,8 @@ class HtmxViewHandlerInterceptor implements HandlerInterceptor { private final ObjectFactory locales; private final ObjectMapper objectMapper; - public HtmxViewHandlerInterceptor(ViewResolver views, ObjectFactory locales, ObjectMapper objectMapper) { + public HtmxViewHandlerInterceptor(ViewResolver views, ObjectFactory locales, + ObjectMapper objectMapper) { this.views = views; this.locales = locales; this.objectMapper = objectMapper; @@ -49,11 +57,15 @@ public HtmxViewHandlerInterceptor(ViewResolver views, ObjectFactory triggers, HttpServletResponse response) { + private void setTriggerHeader(HxTriggerLifecycle triggerHeader, Map triggers, + HttpServletResponse response) { if (triggers.isEmpty()) { return; } @@ -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(); }; diff --git a/src/test/java/io/github/wimdeblauwe/hsbt/mvc/HtmxPartialHandlerInterceptorTest.java b/src/test/java/io/github/wimdeblauwe/hsbt/mvc/HtmxPartialHandlerInterceptorTest.java index cd357c0..aa28c34 100644 --- a/src/test/java/io/github/wimdeblauwe/hsbt/mvc/HtmxPartialHandlerInterceptorTest.java +++ b/src/test/java/io/github/wimdeblauwe/hsbt/mvc/HtmxPartialHandlerInterceptorTest.java @@ -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; @@ -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 { @@ -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")) diff --git a/src/test/java/io/github/wimdeblauwe/hsbt/mvc/HtmxResponseTest.java b/src/test/java/io/github/wimdeblauwe/hsbt/mvc/HtmxResponseTest.java index bf51848..56a6f6f 100644 --- a/src/test/java/io/github/wimdeblauwe/hsbt/mvc/HtmxResponseTest.java +++ b/src/test/java/io/github/wimdeblauwe/hsbt/mvc/HtmxResponseTest.java @@ -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 @@ -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 @@ -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 @@ -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", diff --git a/src/test/java/io/github/wimdeblauwe/hsbt/mvc/PartialsController.java b/src/test/java/io/github/wimdeblauwe/hsbt/mvc/PartialsController.java index b715e9b..727f5e9 100644 --- a/src/test/java/io/github/wimdeblauwe/hsbt/mvc/PartialsController.java +++ b/src/test/java/io/github/wimdeblauwe/hsbt/mvc/PartialsController.java @@ -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 { @@ -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 model, HttpServletRequest request, + HttpServletResponse response) throws Exception { + response.getWriter().write("
  • A list
"); + } + + }); + } + + @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);