Thymeleaf template layouts in Spring MVC application with no extensions
After some years with JSP/JSTL and Apache Tiles I started discovering Thymeleaf for my Spring MVC applications. Thymeleaf is a really great view engine and it simplifies and speeds up the development despite that lack of good IntelliJ (vote here: http://youtrack.jetbrains.com/issue/IDEABKL-6713) support at the moment (there is an Eclipse plugin though). While learning how to use Thymeleaf I investigated different possibilities of working with layouts.
Apart from the native fragment inclusion mechanism there are at least two options to work with layouts: Thymeleaf integration with Apache Tile and Thymeleaf Layout Dialect. Both seem to work fine, but inspired by this comment about a simple and custom option, I gave it a try. In this post I will show I created the solution.
Change Log
- 17/01/2017 - Source code updated. Fixed bug with casting in the interceptor. Library upgrade.
- 04/12/2016 - Source code updated. Thymeleaf 3.0.2
- 26/07/2016 - Source code updated. No Layout option introduced (@Layout(Layout.NONE)), integration tests, POM improvements and updates.
Create a Spring MVC project with Thymeleaf support
To get started quickly I used my Spring MVC Archetype with Thymeleaf 2.1 support. I created a project by simply invoking the archetype and then imported it to IntellJ.
Creating the layout file
In WEB-INF/views directory I created a layouts folder where I placed the my first layout file called default.html:
<!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"> <head>...</head> <body> <div th:raplace="fragments/header :: header"> Header </div> <div th:replace="${view} :: content"> Content </div> <div th:replace="fragments/footer :: footer"> Footer </div> </body> </html>The ${view} variable will contain the view name returned by the @Controller and the content fragment from ${view} file will be placed here.
Creating the view file
I edited WEB-INF/views/homeNotSignedIn.html and I defined the content fragment like this:
<!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"> <head>...</head> <body> <div class="container" th:fragment="content"> <!-- /* Handle the flash message */--> <th:block th:if="${message != null}"> <div th:replace="fragments/alert :: alert (type=${#strings.toLowerCase(message.type)}, message=${message.message})"> </div> </th:block> <p> Hello <span th:text="${#authentication.name}">User</span>! Welcome to the Spring MVC Quickstart application! </p> </div> </body> </html>So the only change was defining the fragment named content and removing duplicated fragment inclusions. No additional changes are required. The @Controller returns the original view name, as it was before:
@Controller class HomeController { @RequestMapping(value = "/", method = RequestMethod.GET) String index(Principal principal) { return principal != null ? "home/homeSignedIn" : "home/homeNotSignedIn"; } }I changed other views accordingly.
Creating the interceptor and integrating with Spring MVC
To finish the "new layout framework" I created a handler interceptor that will do the work:
public class ThymeleafLayoutInterceptor extends HandlerInterceptorAdapter { private static final String DEFAULT_LAYOUT = "layouts/default"; private static final String DEFAULT_VIEW_ATTRIBUTE_NAME = "view"; @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { if (modelAndView == null || !modelAndView.hasView()) { return; } String originalViewName = modelAndView.getViewName(); modelAndView.setViewName(DEFAULT_LAYOUT); modelAndView.addObject(DEFAULT_VIEW_ATTRIBUTE_NAME, originalViewName); } }ThymeleafLayoutInterceptor gets the original view name returned from the handler's method and replaces it with the layout name (that is defined in WEB-INF/views/layouts/default.html). The original view is placed in the model as a view variable, so it can be used in the layout file. I overrode the postHandle method, as it is executed just before rendering the view.
Adding the interceptor is easy:
@Configuration public class WebMvcConfig extends WebMvcConfigurationSupport { @Override protected void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new ThymeleafLayoutInterceptor()); } }And that's it for the basic configuration. Not a rocket since. The result after going to localhost:8080. This is what I expected. Works like a charm. So I try to signup for an account and what I see after submitting a form:
500 returned for /signup with message Error resolving template "redirect:/", template might not exist or might not be accessible by any of the configured Template ResolversOf course, redirect:/ after the form submission. I needed to modify the interceptor like this:
public class ThymeleafLayoutInterceptor extends HandlerInterceptorAdapter { private static final String DEFAULT_LAYOUT = "layouts/default"; private static final String DEFAULT_VIEW_ATTRIBUTE_NAME = "view"; @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { if (modelAndView == null || !modelAndView.hasView()) { return; } String originalViewName = modelAndView.getViewName(); if (isRedirectOrForward(originalViewName)) { return; } modelAndView.setViewName(DEFAULT_LAYOUT); modelAndView.addObject(DEFAULT_VIEW_ATTRIBUTE_NAME, originalViewName); } private boolean isRedirectOrForward(String viewName) { return viewName.startsWith("redirect:") || viewName.startsWith("forward:"); } }And it worked as expected. But I realized that I need to define and additional layout because Signup and Signin used this before, but not after applying the above changes.
Creating additional layouts
I created a new layout called blank.html and placed it to WEB-INF/views/layouts folder. But how to use select the layout? Probably there are many ways to do this. One of the easiest I though is to return the layout name from the @Controller by simply adding a model attribute named layout. If no layout is given, default one is used, otherwise the given one. Simple. But I wanted a more robust solution. So I thought maybe an annotation that I could use like this:
@Controller class SigninController { @Layout(value = "layouts/blank") @RequestMapping(value = "signin") String signin() { return "signin/signin"; } }To me it sounded like a good solution. So I implemented it.
Selecting the layout
I created a method level @Layout annotation that I placed in org.thymeleaf.spring.support package (together with ThymeleafLayoutInterceptor):
package org.thymeleaf.spring.support; import java.lang.annotation.*; @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Layout { String value() default ""; }I changed the interceptor as follows:
public class ThymeleafLayoutInterceptor extends HandlerInterceptorAdapter { private static final String DEFAULT_LAYOUT = "layouts/default"; private static final String DEFAULT_VIEW_ATTRIBUTE_NAME = "view"; @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { if (modelAndView == null || !modelAndView.hasView()) { return; } String originalViewName = modelAndView.getViewName(); if (isRedirectOrForward(originalViewName)) { return; } String layoutName = getLayoutName(handler); modelAndView.setViewName(layoutName); modelAndView.addObject(DEFAULT_VIEW_ATTRIBUTE_NAME, originalViewName); } private boolean isRedirectOrForward(String viewName) { return viewName.startsWith("redirect:") || viewName.startsWith("forward:"); } private String getLayoutName(Object handler) { HandlerMethod handlerMethod = (HandlerMethod) handler; Layout layout = handlerMethod.getMethodAnnotation(Layout.class); if (layout == null) { return DEFAULT_LAYOUT; } else { return layout.value(); } } }Now, when the handler method is annotated with @Layout annotation, it get its value attribute. Works great. But when I started to change SignupController I realized I need to annotate both methods. It would be better if my annotation can be used for all methods at once, by annotating the @Controller class:
@Controller @Layout(value = "layouts/blank") class SignupController { }So I did.
Final touches
Firstly, I changed the annotation so it can be targeted at the type level:
@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface Layout { String value() default ""; }And the interceptor:
public class ThymeleafLayoutInterceptor extends HandlerInterceptorAdapter { private static final String DEFAULT_LAYOUT = "layouts/default"; private static final String DEFAULT_VIEW_ATTRIBUTE_NAME = "view"; private String defaultLayout = DEFAULT_LAYOUT; private String viewAttributeName = DEFAULT_VIEW_ATTRIBUTE_NAME; public void setDefaultLayout(String defaultLayout) { Assert.hasLength(defaultLayout); this.defaultLayout = defaultLayout; } public void setViewAttributeName(String viewAttributeName) { Assert.hasLength(defaultLayout); this.viewAttributeName = viewAttributeName; } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { if (modelAndView == null || !modelAndView.hasView()) { return; } String originalViewName = modelAndView.getViewName(); if (isRedirectOrForward(originalViewName)) { return; } String layoutName = getLayoutName(handler); modelAndView.setViewName(layoutName); modelAndView.addObject(this.viewAttributeName, originalViewName); } private boolean isRedirectOrForward(String viewName) { return viewName.startsWith("redirect:") || viewName.startsWith("forward:"); } private String getLayoutName(Object handler) { if (handler instanceof HandlerMethod) { HandlerMethod handlerMethod = (HandlerMethod) handler; Layout layout = getMethodOrTypeAnnotation(handlerMethod); if (layout != null) { return layout.value(); } } return this.defaultLayout; } private Layout getMethodOrTypeAnnotation(HandlerMethod handlerMethod) { Layout layout = handlerMethod.getMethodAnnotation(Layout.class); if (layout == null) { return handlerMethod.getBeanType().getAnnotation(Layout.class); } return layout; } }As you can see method level annotation is more important than type level annotation, which gives some flexibility. In addition, I added a possibility to configure the interceptor. I thought, that setting the default layout name and view attribute name may be useful.
Summary
The presented solution may need some polishing in order to use it in production, but it shows how simply we can build template layouts without adding extra libraries to our project and utilizing only core Thymeleaf features. Please share you comments and opinions about the solution.
Please find the source code on GitHub: https://github.com/kolorobot/thymeleaf-custom-layout
That looks great!
ReplyDeleteisRedirectOrForward is what I haven't written in my "draft" but of course I have the same code :)
The @Layout annotation is what I haven't thought about but it's great!
I was using the same technique with JSPs few years ago, but parameterized th:replace looks much better!
Thanks for this blog post - I hop to see it on DZone - you get my +1!
regards
Grzegorz Grzybek
Grzegorz,
ReplyDeleteThanks for the comment and for the inspiration.
Hello
ReplyDeleteI hope you don't mind I've wrote this: http://forum.thymeleaf.org/Layouts-and-quot-tiles-quot-without-Apache-Tiles-and-without-LayoutDialect-td4027049.html ?
regards
Grzegorz Grzybek
Of couse not. Spread the message. I wonder if I should include this in my archetype (https://github.com/kolorobot/spring-mvc-quickstart-archetype) as it is now supports Thymeleaf 2.1. In case you have any suggestions regarding the archetype itself, I am also open to hear to them!
ReplyDeleteHi
ReplyDeleteI am new to thymeleaf and your article was very clear and useful for any beginner like me who has a great passion for learning new technologies..Anyway My small suggestion is that it would b more clear and helpful if you add some simple examples along with the html pages in githib..
Thanks for the comment!
DeleteI will try to improve!
Looks great, I'll try it now!
ReplyDeleteHi, thanks for the comment! Good luck!
DeleteThank you for the great tutorial. It helped my immensely with my Spring/Thymeleaf studies.
ReplyDeleteI had to change WebMvcConfig to extend from WebMvcConfigurerAdapter instead of WebMvcConfigurationSupport to get @AuthenticationPrincipal to work.
ReplyDeleteWhen extending from WebMvcConfigurationSupport it seems like @EnableWebMvcSecurity is ignored and the AuthenticationPrincipalArgumentResolver is never added to the argument resolvers in the WebMvcSecurityConfiguration class.
Extending from WebMvcConfigurerAdapter instead fixed this problem for me.
Hello, Your post is really good. When i am using Thymeleaf and JSP view resolver together, my thymeleaf view resolver not work, by default the call goes to JSP view resolver. Following is my question detail :
ReplyDeletehttp://stackoverflow.com/questions/28494408/integrate-thymeleaf-tiles-and-jsp-view-resolver-together-in-spring-mvc
Hi, Your post help me a lot but I still got problem with resource references in head tag of layout file for view placed under subfolder.
ReplyDeleteTemplates/
| layouts/
| |__default.html
| |__blank.html
| user/
| |__view1.html
| |__view2.html
|__sharedview1.html
|__sharedview2.html
....
Any help would be much appreciated.
Hi!
ReplyDeleteNice tutorial! I have implemented it and it works like a charm!
Thought, i don't understand the usage of blank.html file. I dont know when I jhave to use the annotation, maybe for my english level :(. Could you giveme any more clue? All my Webapp is working well and i dont use the anotation,
The unique thing I had to change was for ajax post, that I modified the Interceptor like this:
Add this in the postHandle method:
if(isFragment(originalViewName)){
return;
}
and the method is as follows
private boolean isFragment(String viewName){
return viewName.contains("::");
}
Maybe with the Layout annotation this change is not needed?
Thanks again for this tutoriall!
The idea of the annotation is to have the possibility to use multiple layouts in one project. That is basically it!
DeleteYay! Think I finally got it!
Deleteso for example if I have an AJAX call, the controller that take care of this call should be annotated with the blank layout, because it return a fragment that a $load call render.
Hi,
ReplyDeletei am working on a small prototype using this method, and was testing, if it is possible to extend it to spring-webflow as well. unfortunately i dont have so easy access to the resolved view name in webflow as far as i see. Do you have any idea, if it is possible to achieve the same result ? I tried it using FlowExecutionListeners and Custom FlowHandlerAdapters to no avail, but maybe im missing something.
I am not working with WF so I can't help. Sorry. Maybe someone else?
DeleteNice post. I'm using your solution. Thanks
ReplyDeleteThanks for Great Explanation ... two thumbs up
ReplyDeletenice
ReplyDeleteHey, thanks your solution really works, any way I found a native solution for that http://stackoverflow.com/a/20449189/2979435
ReplyDeleteIn Thymeleaf 3 it is even much better to create layouts natively. Stay tuned. I am updating the article.
DeleteNice solution!
ReplyDelete... but I have a problem using it. When I'm not using it, my css-file is working as expected, but when I use it, the css file doesn't affect the page. When I inspect the page with the developer tools in the Opera browser, the link to the css file is the same.
Hello,
DeleteThanks for your comment.
Do you put your CSS file in layout file? It should work with no problem (in this example layouts/default.html).
I have it like this in the head section in default.html: link rel="stylesheet" th:href="@{/css/mycssfile.css}" href="/css/mycssfile.css"/
ReplyDeleteThe css file is located in /resources/static/css.
I have tried to put the css folder directly under /resources, and hav tried tu put the following into WebMvcConfig:
@Override
public void addResourceHandlers(final ResourceHandlerRegistry registry) {
registry.addResourceHandler("/css/**")
.addResourceLocations("/resources/css/", "/css/")
.setCachePeriod(3600)
.resourceChain(true)
.addResolver(new PathResourceResolver());
}
This link works in other pages when the interceptor is not initialized (if I comment out):
//@Override
//protected void addInterceptors(InterceptorRegistry registry) {
// registry.addInterceptor(new ThymeleafLayoutInterceptor());
//}
Forget about the last comment. For some reason I got i t to work. :-)
ReplyDeleteThank you for trying to help and for a fantastic solution. I find it very elegant!
Great!!
ReplyDeleteFirstly I Thank you. Everything is working fine. But css resources are giving error.
ReplyDeleteRefused to apply style from 'http://localhost:8080/css/font-awesome.min.css' because its MIME type ('application/json') is not a supported stylesheet MIME type, and strict MIME checking is enabled.
I have found that the content type in the postHandle method is null. I am trying to find out the solution. I will be pleased, if you help me.
Thanks again.