After some time of testing 1.1.3-SNAPSHOT we have released a maintenance release 1.1.3 of Across.
This release contains bugfixes in Across core. You can see the full list on the 1.1.3 Release notes.
Alongside 1.1.3 Across core, we also fixed some issues in the following two modules:
- OAuth2Module 1.1.2.RELEASE (see OA2M Release notes)
- EntityModule 1.1.1.RELEASE (see EUM Release notes)
The Across Platform bom was also updated to 1.1.3 to relected the changes in the modules.
An introduction to Across was given at Devoxx Antwerp last week. Slides can be found here, the entire presentation is available on Youtube.
About the Across primer series
This is the second article in the Across primer series. Previous articles:
What we'll do in this article
In this article we'll be focusing on some of the templating infrastructure that AcrossWebModule provides. We'll create a layout template for our pages, show a way to register static resources programmatically and show an easy way to set up a dynamic top navigation that can later be extended at runtime.
Source code
All source code can be found on our Bitbucket repository: https://bitbucket.org/beforeach/across-primer/branch/part-2
Most steps in this article correspond with a single commit, you can checkout out to any of those revisions and start building from there on.
Creating a drop-down menu item
Currently the browse item in the top navigation is a simple button that navigates to the very first category. Let's turn it into a drop-down listing all available categories. The user can then directly select the category he wants and navigate to it.
Creating drop-down markup
Change the homepage template accordingly:
<ul class="nav navbar-nav" th:inline="text"> <li th:classappend="${selectedCategory == null} ? active"><a th:href="@{/}">[[#{nav.home}]]</a></li> <li class="dropdown" th:classappend="${selectedCategory != null} ? active"> <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">[[#{nav.browse}]] <span class="caret"></span></a> <ul class="dropdown-menu"> <li th:each="category: ${categories}" th:classappend="${selectedCategory == category} ? active"> <a th:href="@{'/category/' + ${category.path}}">[[${category.name}]]</a> </li> </ul> </li> </ul>
Add required javascript
For the Bootstrap drop-down to work, we need to add JQuery along with Bootstrap javascript. At it at the bottom of the homepage template:
<th:block th:fragment="bottom-scripts"> <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script> <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js"></script> </th:block>
We have wrapped this in a th:block because we need to include the same scripts in the categoryItems template. A th:block will not render any output itself, only the content of it. Update the categoryItems template accordingly before the closing body tag:
<th:block th:replace="th/catalog/homepage :: bottom-scripts" />
Refreshing the page should now give us a working drop-down:
However, if we were to create more pages, we would have to repeat this process everywhere, including the same bottom and head sections from the homepage. To avoid this we can switch to a layout template for all pages.
Creating a layout template
Creating the layout bean and template file
There are several libraries you could use for creating a layout. For example there is the Thymeleaf layout dialect you could use to define your layouts from within Thymeleaf itself. However in our case, we'll make use of the templating infrastructure that AcrossWebModule packs.
AcrossWebModule allows you to determine the layout to use on a controller level. A layout template is configured by creating a LayoutTemplateProcessorAdapterBean
that registers the unique name of the layout, and specifies the Thymeleaf template it should use. Create a MainTemplate
as follows:
@Component public class MainTemplate extends LayoutTemplateProcessorAdapterBean { public static final String NAME = "MainTemplate"; public MainTemplate() { super(NAME, "th/catalog/layouts/main"); } }
Put the Thymeleaf template in the right location as well:
<html> <head> <title th:replace="${childPage} :: title">page specific title</title> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css"/> <link rel="stylesheet" th:href="@{/across/resources/static/catalog/css/catalog.css}"/> </head> <body> <div class="container"> <nav class="navbar navbar-default"> <div class="navbar-header"> <div class="logo"> </div> <ul class="nav navbar-nav" th:inline="text"> <li th:classappend="${selectedCategory == null} ? active"><a th:href="@{/}">[[#{nav.home}]]</a></li> <li class="dropdown" th:classappend="${selectedCategory != null} ? active"> <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">[[#{nav.browse}]] <span class="caret"></span></a> <ul class="dropdown-menu"> <li th:each="category: ${categories}" th:classappend="${selectedCategory == category} ? active"> <a th:href="@{'/category/' + ${category.path}}">[[${category.name}]]</a> </li> </ul> </li> </ul> </div> </nav> <div th:replace="${childPage} :: content"> here comes the body content </div> </div> <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery.min.js"></script> <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js"></script> </body> </html>
Looking at the source code of main.thtml, you can see it looks very much like the homepage template. The important part is the section <div th:replace="${childPage} :: content">
. This basically tells the layout to inject the fragment identified as content from the original template that was requested.
The AcrossWebModule web templates work on an interceptor level. When requesting a particular view, the controller will be inspected to determine if a layout should be applied. If that is the case, the original view name will be stored in a childPage request attribute, and the layout template will be rendered instead. Using Thymeleaf include mechanism can then be used to render the right fragments from the original page.
Modifying homepage and categoryItems
In order for the original templates to work with the layout, we need to change them to include a content fragment. We can also remove all markup (css includes etc) that is no longer required because it is now rendered by the layout template. The latter is not strictly necessary however. Thymeleaf strongly stresses a natural templating approach. To have a better idea what that means and how you can harness the power of Thymeleaf to do so, we gladly refer you to the Thymeleaf user documentation.
In our case, let's alter the original templates like so:
<div class="container"> <div th:fragment="content" class="jumbotron"> <h1 th:text="#{welcome.message}">Hello world!</h1> </div> </div>
<html> <head> <title th:inline="text">Category: [[${selectedCategory.name}]]</title> </head> <body> <div class="container"> <div th:fragment="content" class="row"> ... </div> </div> </body> </html>
Specifying the layout on the controllers
We have created the layout and made sure our templates can work with it, however we still need to specify which layout the controllers should use. We can do this by adding the @Template
annotation on either @Controller
or @RequestMapping
level. Add the annotation to both HomepageController
and CategoryItemsController.
@Controller @Template(MainTemplate.NAME) public class HomepageController {
Clear the template on the exception handler
There is a problem however. Try calling the category detail with an invalid category path, eg /category/iDontExist. You will notice that now we get a non-working page. There is no body content and the top navigation is not complete.
The reason for this is that the MainTemplate
is being applied because we specified it on the CategoryController
. Often however when there is an exception, there is a good change we cannot render the layout because some of the required model attributes might not have been set. So in this case we do not want the layout to be used.
There's two easy ways to solve this:
- specify the
@Template
on the individual@RequestMapping
methods instead of on the controller - add a
@ClearTemplate
annotation on those mappings where we do not want the template to apply
In our case, let's try the latter:
@ClearTemplate @ExceptionHandler(IllegalArgumentException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public String categoryNotFound(Exception e, Model model) { model.addAttribute("feedback", e.getMessage()); return "th/catalog/404"; }
Restarting the application and calling the error page (or if you have spring-boot-devtools, just recompiling should do the trick) should now yield the following result:
Now we get an unstyled error page because no layout has been applied, and we removed the css includes from the homepage head section.
If you still get styles, you probably did not remove the head section in the homepage template. That's okay as well
Creating a separate error template
Instead of no template, let's create a separate template for errors instead. Repeat the process above and create a LayoutTemplateProcessorAdapterBean
and the layout file it references:
Finally, replace the @ClearTemplate
with a separate @Template
on the exception handler:
@Template(ErrorTemplate.NAME) @ExceptionHandler(IllegalArgumentException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public String categoryNotFound(Exception e, Model model) { model.addAttribute("feedback", e.getMessage()); return "th/catalog/404"; }
If you reload you should see the styled error page again.
We now have more than one @Template
annotation in our controller class. However a @Template
on request mapping level will always take precedence over the annotation on the controller level.
Registering the default template
It's not always very convenient to have to specify the @Template
on every separate controller. Often there is a common layout that is being applied and only some controllers use a different one. One strategy would be to inherit from a base class or interface, but apart from tighter coupling, we would still have to remember doing that for every controller.
There is a different approach we can take. All web templates are registered in the WebTemplateRegistry
and whenever a request comes in, AcrossWebModule will check which template should be applied. By default if there is none specified the original view name will be rendered. However we can also specify the name of the default template that should be used. In that case, if there is no @Template
or @ClearTemplate
specified for the requested handler, the template with the default name will be used.
So let's add an @Autowired
method on the MainTemplate
and have the template register itself as the default in the registry:
@Component public class MainTemplate extends LayoutTemplateProcessorAdapterBean { public static final String NAME = "MainTemplate"; public MainTemplate() { super(NAME, "th/catalog/layouts/main"); } @Autowired public void registerAsDefaultTemplate(WebTemplateRegistry webTemplateRegistry) { webTemplateRegistry.setDefaultTemplateName(NAME); } }
We can now remove the @Template
on the HomepageController
and CategoryController
. Of course, the @Template(ErrorTemplate.NAME)
on the exception handler should be kept in place!
Dynamically registering web resources
While we now have two separate layouts with their own template files. But we still need to add the static resources to each template separately. We could ofcourse include fragments from each other, but let's show a different way. Let's register these resources from the backend, allowing also controllers to extend resources dynamically.
Register web resources from the template bean
The LayoutTemplateProcessorAdapterBean
contains a couple of empty adapter methods. One of those is registerWebResources
that takes a single WebResourceRegistry
as a parameter. The WebResourceRegistry
is a map construct that allows you to add a (static) resources. As a minimum you need to specify:
- the type of resource - this determines the collection to which you want to add the resource
- the resource data - usually a url or relative path, but this could also be a custom object that needs to be converted to JSON
- the resource location - specifying if the data is an absolute url, relative path or something else
Start by extending the layout beans and register the resources accordingly:
Once resources have been added to the WebResourceRegistry
they still need to be rendered in your templates. The registry is available as a request attribute called webResourceRegistry. Modify both main.thtml and error.thtml as in the example below, rendering the web resources collections.
If you compare the original template versus the Java registration of resources, you will notice that we no longer specify the /across/resources prefix on our catalog.css in Java code. That is because the location WebResource.VIEWS
specifies that it is a static resource according to the Across conventions, which will be served under the static path of the AcrossWebModule. This is handy as the static resources path is configurable on the AcrossWebModule.
The initial WebResourceRegistry
is filled by our template bean in this example, but it can also be wired as an argument on a @RequestMapping
or @ModelAttribute
method. This allows request specific methods to add static resources, without the template having to contain all possible options. Multiple beans can add resources to the registry, independently of each other. It is also very important that this independence is respected, as there you cannot rely on the order of resources specified previously by a different component.
Creating a WebResourcePackage
Even though we're now using a WebResourceRegistry
we just replaced the resource duplication from the Thymeleaf templates to the template beans. One way would be to introduce inheritance between the two template beans, but we can also use a WebResourcePackage
. A WebResourcePackage
is a nothing but a bundle of web resources that is known under unique name. Said bundle is added to a WebResourceRegistry
by specifying its name, after which the seperate web resources it contains will be added to the registry.
For that to be possible, the WebResourcePackage
must be registerd in the WebResourcePackageManager
first.
Let's create a separate CatalogWebResources
bean that holds the necessary static resources we need. In development mode we will return non-minified versions of all resources. This logic is now contained in our WebResourcePackage
, neither template nor registry is aware of the different resources depending on the application environment. Furthermore, we could unit test the behaviour if we so wanted. Using WebResourcePackage
instances is also the easiest way to define dependencies between resources.
@Component public class CatalogWebResources extends SimpleWebResourcePackage { public static final String NAME = "CatalogWebResources"; @Autowired public CatalogWebResources(AcrossDevelopmentMode developmentMode) { String minified = developmentMode.isActive() ? "" : ".min"; List<WebResource> webResources = new ArrayList<>(); webResources.add(new WebResource( CSS, "bootstrap-css", "https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap" + minified + ".css", EXTERNAL )); webResources.add(new WebResource( CSS, "catalog-css", "/static/catalog/css/catalog.css", VIEWS )); webResources.add(new WebResource( JAVASCRIPT_PAGE_END, "jquery", "https://ajax.googleapis.com/ajax/libs/jquery/1.11.3/jquery" + minified + ".js", EXTERNAL )); webResources.add(new WebResource( JAVASCRIPT_PAGE_END, "boostrap-js", "https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap" + minified + ".js", EXTERNAL )); setWebResources(webResources); } @Autowired public void registerInPackageManager(WebResourcePackageManager packageManager) { packageManager.register(NAME, this); } }
Once the WebResourcePackage
is defined, we can simplify the template beans:
@Component public class MainTemplate extends LayoutTemplateProcessorAdapterBean { public static final String NAME = "MainTemplate"; ... @Override protected void registerWebResources(WebResourceRegistry registry) { registry.addPackage(CatalogWebResources.NAME); } }
Using a Menu to build the navigation
Every controller that uses a certain template needs to make sure that all required metadata for the template is present. In our current implementation that means the list of categories is available as a categories model attribute. We currently set it in both HomepageController
and CategoryController
. Let's remove some duplication here.
As usual there's more than one way to go about this. We could manually add the attribute in the template bean, or use a @ControllerAdvice
to set a general model attribute. AcrossWebModule gives us another construct however, specifically aimed at building tree-based navigation structures that have a single leaf selected.
We can have our template bean create a named Menu
. This will publish an event that any other bean can handle in order to add items to the menu. Just as with building the WebResourceRegistry
, adding menu items can be done by independent components, without any idea of the order of the calls. In order to build a safe hierarchical structure, items are registered using a unique hierarchical path as we will see below.
Start by building a menu named navigationMenu in our MainTemplate
. Do this by implementing the buildMenus(MenuFactory)
adapter method:
@Component public class MainTemplate extends LayoutTemplateProcessorAdapterBean { public static final String NAME = "MainTemplate"; ... @Override protected void buildMenus(MenuFactory menuFactory) { menuFactory.buildMenu("navigationMenu"); } }
This will publish a BuildMenuEvent
with the name navigationMenu. Any bean - from any possible AcrossModule - can listen for this event. This means that every separate controller could register its own navigational items for example. In our example, let's create a separate component called NavigationMenuBuilder
that is responsible for building the top navigation menu.
@Component public class NavigationMenuBuilder { private final MessageSource messageSource; @Autowired public NavigationMenuBuilder(MessageSource messageSource) { this.messageSource = messageSource; } @Event public void buildMainMenu(@EventName("navigationMenu") BuildMenuEvent<Menu> menuEvent) { menuEvent.builder() .item("/home", message("nav.home"), "/").and() .group("/category", message("nav.browse")); registerCategories(menuEvent.builder()); } private void registerCategories(PathBasedMenuBuilder builder) { Category.LIST.forEach(c -> builder.item("/category/" + c.getPath(), c.getName())); } private String message(String messageCode) { return messageSource.getMessage(messageCode, null, LocaleContextHolder.getLocale()); } }
Notice the @Event
annotated method. The annotation specified that the method is an event listener, the method parameter specified the type of event we're listening to, in this case BuildMenuEvent
. Because our navigation menu does not implement a specific type, the generic type of the event is just Menu
. Because I am only interested in handling the navigation menu event, I specify @EventName("navigationMenu")
limiting my handler to events with both the type and the event name.
Instead of directly modifying the Menu
, we use the builder()
component. The latter is much more flexible for independent extending of the menu, using a unique canonical path for menu items. When specifying a menu item, we usually provide three arguments:
- the canonical path
- the titel of the menu item
- the url - if omitted the path will be used as url property as well
In our example above, we specify one group item. This is a special demarcation for an item that contains only childen but is not an item itself. Usually that means the group has a label but is itself not clickable. In our case the Browse button triggers the dropdown menu, but only its children navigate to a category.
Building a menu using the MenuFactory
will also make it available as request attribute using the name specified. All that's left to do is to modify main.thtml to render the top navigation using the navigationMenu.
<html> <head> <title th:replace="${childPage} :: title">page specific title</title> <link rel="stylesheet" type="text/css" th:each="css : ${webResourceRegistry.getResources('css')}" th:href="@{${css.data}}"/> </head> <body> <div class="container"> <nav class="navbar navbar-default"> <div class="navbar-header"> <div class="logo"> </div> <ul class="nav navbar-nav" th:inline="text"> <li th:each="item : ${navigationMenu.items}" th:class="${item.group} ? dropdown" th:classappend="${item.selected} ? active"> <a th:unless="${item.group}" th:href="@{${item.url}}">[[${item.title}]]</a> <a th:if="${item.group}" href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">[[${item.title}]] <span class="caret"></span></a> <ul th:if="${item.group}" class="dropdown-menu"> <li th:each="subItem: ${item.items}" th:classappend="${subItem.selected} ? active"> <a th:href="@{${subItem.url}}">[[${subItem.title}]]</a> </li> </ul> </li> </ul> </div> </nav> <div th:replace="${childPage} :: content"> here comes the body content </div> </div> <script th:each="javascript : ${webResourceRegistry.getResources('javascript-page-end')}" th:src="@{${javascript.data}}" th:if="${javascript.location != 'inline'}"></script> </body> </html>
When refreshing the page, the top navigation should still work. But there's a couple of things to notice:
The structure we used to render is hierarchical but the builder we used was not
When building the menu, the items we specified by canonical path are actually converted into a tree structure. Based on the example above, the resulting menu is:
- /home
- /category
- /category/tv
- /category/radio
The correct menu item is selected even though we did not specify any item to select
A Menu
can have a single item selected. Selecting an item will also make all its parents selected. This means there is something as a top-down selected path you can take when navigating a menu. This is very useful to render for example a breadcrumb navigation.
When using the MenuFactory
to create a menu, a default RequestMenuSelector
will be used to determine which item in the menu should be selected. It does so by inspecting the current request and selecting the most specific item that matches the request url with its own url. Unless you are using very specific and variable urls, this usually works out of the box. If not there are plenty ways to customize how the request should be match, and if you need to use a different selector you can set through the BuildMenuEvent
.
Ordering menu items
Last thing to notice is that our menu items are not ordered correctly. That is unless they are explicitly ordered, they will be sorted alphabetically. Having the categories sorted alphabetically is just fine, but the main menu items we'd like in a fix order, so let's specify one.
@Component public class NavigationMenuBuilder { private final MessageSource messageSource; @Event public void buildMainMenu(@EventName("navigationMenu") BuildMenuEvent<Menu> menuEvent) { menuEvent.builder() .item("/home", message("nav.home"), "/").order(0).and() .group("/category", message("nav.browse")).order(1); registerCategories(menuEvent.builder()); } ... }
It is now no longer necessary to have the categories model attribute and you can remove it from HomepageController
.
Changing the left navigation on CategoryController
We still can't remove the model attribute from CategoryController
, as the left navigation uses the categories as well. However, nothing stops us to use the same navigationMenu to generate the left nav. We only need to access the sub-tree of category items:
<html> <head> <title th:inline="text">Category: [[${selectedCategory.name}]]</title> </head> <body> <div class="container"> <div th:fragment="content" class="row"> <div class="col-md-2"> <ul class="nav nav-pills nav-stacked"> <li th:each="categoryItem : ${navigationMenu.getItemWithPath('/category').items}" th:classappend="${categoryItem.selected} ? active"> <a th:href="@{${categoryItem.url}}" href="#" th:text="${categoryItem.title}">category name</a> </li> </ul> </div> ... </div> </div> </body> </html>
And that allows us to remove the model attribute from the CategoryController
as well.
Adding an extra menu item
As a final example, let's add a separate menu item that actually points to an external website. All we have to do to make it work is add it to the menu builder, our template already supports it.
As stated, extending the menu can be done through any even handler, even handlers from other modules. You will see plenty examples of this approach when using AdminWebModule or DebugWebModule, and in the final part of this primer we'll do exactly the same thing when creating multiple applications.
Wrapping up
That wraps up this part of the primer series. We mainly zoomed in on some web templating features that AcrossWebModule brings to the game. We showed how to create layouts and link backend code with Thymeleaf template files. Additionally we've seen how constructs like WebResourceRegistry
and Menu
can help you to dynamically configure templates from Java code.
In the next part of the series we'll continue extending our example by switching to JPA entities and building a basic administration UI.