Skip to content

Commit 14c1faa

Browse files
committed
Updates to WebMVC fragment rendering API
See gh-33162
1 parent 6ee8786 commit 14c1faa

File tree

6 files changed

+230
-27
lines changed

6 files changed

+230
-27
lines changed

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ModelAndViewMethodReturnValueHandler.java

+7-3
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
import org.springframework.web.servlet.ModelAndView;
2828
import org.springframework.web.servlet.SmartView;
2929
import org.springframework.web.servlet.View;
30-
import org.springframework.web.servlet.view.FragmentsView;
30+
import org.springframework.web.servlet.view.FragmentsRendering;
3131

3232
/**
3333
* Handles return values of type {@link ModelAndView} copying view and model
@@ -78,7 +78,7 @@ public boolean supportsReturnType(MethodParameter returnType) {
7878
if (Collection.class.isAssignableFrom(type)) {
7979
type = returnType.nested().getNestedParameterType();
8080
}
81-
return ModelAndView.class.isAssignableFrom(type);
81+
return (ModelAndView.class.isAssignableFrom(type) || FragmentsRendering.class.isAssignableFrom(type));
8282
}
8383

8484
@SuppressWarnings("unchecked")
@@ -92,7 +92,11 @@ public void handleReturnValue(@Nullable Object returnValue, MethodParameter retu
9292
}
9393

9494
if (returnValue instanceof Collection<?> mavs) {
95-
mavContainer.setView(FragmentsView.create((Collection<ModelAndView>) mavs));
95+
returnValue = FragmentsRendering.with((Collection<ModelAndView>) mavs).build();
96+
}
97+
98+
if (returnValue instanceof FragmentsRendering rendering) {
99+
mavContainer.setView(rendering);
96100
return;
97101
}
98102

spring-webmvc/src/main/java/org/springframework/web/servlet/view/FragmentsView.java renamed to spring-webmvc/src/main/java/org/springframework/web/servlet/view/DefaultFragmentsRendering.java

+7-21
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package org.springframework.web.servlet.view;
1818

1919
import java.io.IOException;
20+
import java.util.ArrayList;
2021
import java.util.Collection;
2122
import java.util.Locale;
2223
import java.util.Map;
@@ -31,27 +32,23 @@
3132
import org.springframework.lang.Nullable;
3233
import org.springframework.util.Assert;
3334
import org.springframework.web.servlet.ModelAndView;
34-
import org.springframework.web.servlet.SmartView;
3535
import org.springframework.web.servlet.View;
3636
import org.springframework.web.servlet.ViewResolver;
3737

3838
/**
39-
* {@link View} that enables rendering of a collection of fragments, each with
40-
* its own view and model, also inheriting common attributes from the top-level model.
39+
* Default implementation of {@link FragmentsRendering} that can render fragments
40+
* through the {@link org.springframework.web.servlet.SmartView} contract.
4141
*
4242
* @author Rossen Stoyanchev
4343
* @since 6.2
4444
*/
45-
public class FragmentsView implements SmartView {
45+
final class DefaultFragmentsRendering implements FragmentsRendering {
4646

4747
private final Collection<ModelAndView> modelAndViews;
4848

4949

50-
/**
51-
* Protected constructor to allow extension.
52-
*/
53-
protected FragmentsView(Collection<ModelAndView> modelAndViews) {
54-
this.modelAndViews = modelAndViews;
50+
DefaultFragmentsRendering(Collection<ModelAndView> modelAndViews) {
51+
this.modelAndViews = new ArrayList<>(modelAndViews);
5552
}
5653

5754

@@ -95,20 +92,9 @@ public void render(
9592
}
9693
}
9794

98-
9995
@Override
10096
public String toString() {
101-
return "FragmentsView " + this.modelAndViews;
102-
}
103-
104-
105-
/**
106-
* Factory method to create an instance with the given fragments.
107-
* @param modelAndViews the {@link ModelAndView} to use
108-
* @return the created {@code FragmentsView} instance
109-
*/
110-
public static FragmentsView create(Collection<ModelAndView> modelAndViews) {
111-
return new FragmentsView(modelAndViews);
97+
return "DefaultFragmentsRendering " + this.modelAndViews;
11298
}
11399

114100

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.web.servlet.view;
18+
19+
import java.util.ArrayList;
20+
import java.util.Collection;
21+
import java.util.Map;
22+
23+
import org.springframework.web.servlet.ModelAndView;
24+
25+
/**
26+
* Default {@link FragmentsRendering.Builder} implementation that collects the
27+
* fragments and creates a {@link DefaultFragmentsRendering}.
28+
*
29+
* @author Rossen Stoyanchev
30+
* @since 6.2
31+
*/
32+
final class DefaultFragmentsRenderingBuilder implements FragmentsRendering.Builder {
33+
34+
private final Collection<ModelAndView> fragments = new ArrayList<>();
35+
36+
37+
@Override
38+
public DefaultFragmentsRenderingBuilder fragment(String viewName, Map<String, Object> model) {
39+
return fragment(new ModelAndView(viewName, model));
40+
}
41+
42+
@Override
43+
public DefaultFragmentsRenderingBuilder fragment(String viewName) {
44+
return fragment(new ModelAndView(viewName));
45+
}
46+
47+
@Override
48+
public DefaultFragmentsRenderingBuilder fragment(ModelAndView fragment) {
49+
this.fragments.add(fragment);
50+
return this;
51+
}
52+
53+
@Override
54+
public DefaultFragmentsRenderingBuilder fragments(Collection<ModelAndView> fragments) {
55+
this.fragments.addAll(fragments);
56+
return this;
57+
}
58+
59+
@Override
60+
public FragmentsRendering build() {
61+
return new DefaultFragmentsRendering(this.fragments);
62+
}
63+
64+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*
2+
* Copyright 2002-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.web.servlet.view;
18+
19+
import java.util.Collection;
20+
import java.util.Map;
21+
22+
import org.springframework.web.servlet.ModelAndView;
23+
import org.springframework.web.servlet.SmartView;
24+
25+
/**
26+
* Public API for HTML rendering a collection fragments each with its own view
27+
* and model. For use with view technologies such as
28+
* <a href="https://htmx.org/">htmx</a> where multiple page fragments may be
29+
* rendered in a single response. Supported as a return value from a Spring MVC
30+
* controller method.
31+
*
32+
* @author Rossen Stoyanchev
33+
* @since 6.2
34+
*/
35+
public interface FragmentsRendering extends SmartView {
36+
37+
38+
/**
39+
* Create a builder for {@link FragmentsRendering}, adding a fragment with
40+
* the given view name and model.
41+
* @param viewName the name of the view for the fragment
42+
* @param model attributes for the fragment in addition to model
43+
* attributes inherited from the shared model for the request
44+
* @return the created builder
45+
*/
46+
static Builder with(String viewName, Map<String, Object> model) {
47+
return new DefaultFragmentsRenderingBuilder().fragment(viewName, model);
48+
}
49+
50+
/**
51+
* Variant of {@link #with(String, Map)} with a view name only, but also
52+
* inheriting model attributes from the shared model for the request.
53+
* @param viewName the name of the view for the fragment
54+
* @return the created builder
55+
*/
56+
static Builder with(String viewName) {
57+
return new DefaultFragmentsRenderingBuilder().fragment(viewName);
58+
}
59+
60+
/**
61+
* Variant of {@link #with(String, Map)} with a collection of fragments.
62+
* @param fragments the fragments to add; each fragment also inherits model
63+
* attributes from the shared model for the request
64+
* @return the created builder
65+
*/
66+
static Builder with(Collection<ModelAndView> fragments) {
67+
return new DefaultFragmentsRenderingBuilder().fragments(fragments);
68+
}
69+
70+
71+
/**
72+
* Defines a builder for {@link FragmentsRendering}.
73+
*/
74+
interface Builder {
75+
76+
/**
77+
* Add a fragment with a view name and a model.
78+
* @param viewName the name of the view for the fragment
79+
* @param model attributes for the fragment in addition to model
80+
* attributes inherited from the shared model for the request
81+
* @return this builder
82+
*/
83+
Builder fragment(String viewName, Map<String, Object> model);
84+
85+
/**
86+
* Add a fragment with a view name only, inheriting model attributes from
87+
* the model for the request.
88+
* @param viewName the name of the view for the fragment
89+
* @return this builder
90+
*/
91+
Builder fragment(String viewName);
92+
93+
/**
94+
* Add a fragment.
95+
* @param fragment the fragment to add; the fragment also inherits model
96+
* attributes from the shared model for the request
97+
* @return this builder
98+
*/
99+
Builder fragment(ModelAndView fragment);
100+
101+
/**
102+
* Add a collection of fragments.
103+
* @param fragments the fragments to add; each fragment also inherits model
104+
* attributes from the shared model for the request
105+
* @return this builder
106+
*/
107+
Builder fragments(Collection<ModelAndView> fragments);
108+
109+
/**
110+
* Build a {@link FragmentsRendering} instance.
111+
*/
112+
FragmentsRendering build();
113+
114+
}
115+
116+
}

spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ModelAndViewMethodReturnValueHandlerTests.java

+33
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
package org.springframework.web.servlet.mvc.method.annotation;
1818

1919
import java.lang.reflect.Method;
20+
import java.util.Collection;
21+
import java.util.List;
2022

2123
import org.junit.jupiter.api.BeforeEach;
2224
import org.junit.jupiter.api.Test;
@@ -26,7 +28,9 @@
2628
import org.springframework.web.context.request.ServletWebRequest;
2729
import org.springframework.web.method.support.ModelAndViewContainer;
2830
import org.springframework.web.servlet.ModelAndView;
31+
import org.springframework.web.servlet.SmartView;
2932
import org.springframework.web.servlet.mvc.support.RedirectAttributesModelMap;
33+
import org.springframework.web.servlet.view.FragmentsRendering;
3034
import org.springframework.web.servlet.view.RedirectView;
3135
import org.springframework.web.testfixture.servlet.MockHttpServletRequest;
3236

@@ -60,6 +64,9 @@ void setup() throws Exception {
6064
@Test
6165
void supportsReturnType() throws Exception {
6266
assertThat(handler.supportsReturnType(returnParamModelAndView)).isTrue();
67+
assertThat(handler.supportsReturnType(getReturnValueParam("fragmentsRendering"))).isTrue();
68+
assertThat(handler.supportsReturnType(getReturnValueParam("fragmentsCollection"))).isTrue();
69+
6370
assertThat(handler.supportsReturnType(getReturnValueParam("viewName"))).isFalse();
6471
}
6572

@@ -81,6 +88,22 @@ void handleViewInstance() throws Exception {
8188
assertThat(mavContainer.getModel().get("attrName")).isEqualTo("attrValue");
8289
}
8390

91+
@Test
92+
void handleFragmentsRendering() throws Exception {
93+
FragmentsRendering rendering = FragmentsRendering.with("viewName").build();
94+
95+
handler.handleReturnValue(rendering, returnParamModelAndView, mavContainer, webRequest);
96+
assertThat(mavContainer.getView()).isInstanceOf(SmartView.class);
97+
}
98+
99+
@Test
100+
void handleFragmentsCollection() throws Exception {
101+
Collection<ModelAndView> fragments = List.of(new ModelAndView("viewName"));
102+
103+
handler.handleReturnValue(fragments, returnParamModelAndView, mavContainer, webRequest);
104+
assertThat(mavContainer.getView()).isInstanceOf(SmartView.class);
105+
}
106+
84107
@Test
85108
void handleNull() throws Exception {
86109
handler.handleReturnValue(null, returnParamModelAndView, mavContainer, webRequest);
@@ -173,4 +196,14 @@ String viewName() {
173196
return null;
174197
}
175198

199+
@SuppressWarnings("unused")
200+
FragmentsRendering fragmentsRendering() {
201+
return null;
202+
}
203+
204+
@SuppressWarnings("unused")
205+
Collection<ModelAndView> fragmentsCollection() {
206+
return null;
207+
}
208+
176209
}

spring-webmvc/src/test/java/org/springframework/web/servlet/view/FragmentsViewTests.java renamed to spring-webmvc/src/test/java/org/springframework/web/servlet/view/DefaultFragmentsRenderingTests.java

+3-3
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,12 @@
3838
import static org.junit.jupiter.api.condition.JRE.JAVA_21;
3939

4040
/**
41-
* Tests for rendering through {@link FragmentsView}.
41+
* Tests for rendering through {@link DefaultFragmentsRendering}.
4242
*
4343
* @author Rossen Stoyanchev
4444
*/
4545
@DisabledForJreRange(min = JAVA_21, disabledReason = "Kotlin doesn't support Java 21+ yet")
46-
public class FragmentsViewTests {
46+
public class DefaultFragmentsRenderingTests {
4747

4848

4949
@Test
@@ -59,7 +59,7 @@ void render() throws Exception {
5959
MockHttpServletRequest request = new MockHttpServletRequest();
6060
MockHttpServletResponse response = new MockHttpServletResponse();
6161

62-
FragmentsView view = FragmentsView.create(List.of(
62+
DefaultFragmentsRendering view = new DefaultFragmentsRendering(List.of(
6363
new ModelAndView("fragment1", Map.of("foo", "Foo")),
6464
new ModelAndView("fragment2", Map.of("bar", "Bar"))));
6565

0 commit comments

Comments
 (0)