Working with menus
Across Web provides some infrastructure to build a tree-like menu. This can be used for rendering for example hierarchical menus on a web page, though the class structure can be used for many things.
Several standard modules like AdminWebModule, EntityModule or BootstrapUiModule use these features to allow creation, customization and easy rendering of menus. In this section we will explain the basics of working with the Across Web menu features.
Creating a menu
The base class for the menu tree structure is com.foreach.across.modules.web.menu.Menu
.
A single Menu
can have several child Menu
items, which in turn can have children of their own.
As such, a Menu
can optionally have a single parent Menu
.
The top-most item without a parent is called the root of the menu tree.
ROOT
+ l1: item one
- l2: item one
+ l2: item two
- l3: item one
- l1: item two
The Menu
class has some properties that are common for visual menu rendering, like a title
and an url
.
Every Menu
also has a path
property which is usually used to uniquely identify an item inside the entire menu tree.
Apart from some other direct properties, every Menu
has an attributes
property which is a Map
of custom key/value pairs.
These can be used to store additional custom information on a menu item, something which is used by many implementation.
Suppose you have a menu item that links to a web page, backed by a WebPage
object.
You could put the title of the web page as the Menu
title, and the url to the request path where the page is served.
The attributes
collection could then hold the actual WebPage
instance that the menu item represents.
The following table gives a short overview of the different Menu
properties:
Property | Description |
---|---|
|
Holds the optional parent |
|
Properties mainly used to identify a |
|
Common (visualization) metadata for a |
|
|
|
This flag is mostly used to indicate an item that has children, but is not a regular item in itself. Though implementation dependent, this is often used for an item that for example has no url set (and would not be clickable), but is still expected to be a heading in a visualized menu tree. Note that the value of |
|
Properties to influence how a menu tree should be sorted. See the section on sorting a menu for more information. |
|
Indicates if the |
|
Indicates if a |
|
Contains the list of direct children of this |
How the different properties of a Menu are being used is entirely up to the developer.
Menu in itself is nothing more than a class structure for representing a tree of items with those properties.
|
A Menu
can be constructed and modified directly, it is never an immutable instance.
Menu root = new Menu( "ROOT" );
Menu childOne = new Menu( "l1: item one" );
Menu childOfChildOne = new Menu( "l2: item one" );
Menu childTwo = new Menu( "l1: item two" );
root.addItem( childOne );
root.addItem( childTwo );
childOne.addItem( childOfChildOne );
Because of the hierarchical nature of a menu however, usually constructing a complex Menu
is done using a path based builder.
Sorting a menu
A Menu
can be sorted once its items have been added, simply by calling the sort()
method.
Sorting a menu will sort its direct child items, and in turn call sort()
on every child item, resulting in the entire menu tree being sorted.
Sorting is done using a Comparator<Menu>
instance.
The default comparator will sort menu items first on the value of their order
property, and then alphabetically on their title
.
You can set a different Comparator
that should be used directly on a Menu
instance.
This means you can use different comparators for sub-trees of a menu tree.
Usually a single comparator is responsible for the entire sub-tree, but you can make exceptions there as well.
Selecting menu items
A Menu
item in the tree can be selected
.
When a Menu
is selected and it has a parent, its parent Menu
will also be selected, all the way up to the root of the tree.
As a result, as soon as there is a single item selected, you can retrieve a selected path containing all the Menu
items that are selected top-down.
This is a useful feature if you want to create a breadcrumb for example.
Useful methods for Menu
:
-
isSelected()
to check if theMenu
itself is selected -
getSelectedItem()
to get its first selected child -
getLowestSelectedItem()
to get the leaf item oo the selected path -
getSelectedItemPath()
to retrieve the full selected path starting from thisMenu
.
The current Menu implementation only allows a single selected path in a menu tree.
You cannot have more than one leaf item selected.
|
Using a MenuSelector
MenuSelector
is a strategy interface for finding or selecting a Menu
in a tree.
There are several implementations available as factory methods on the MenuSelector
class.
The default implementations will traverse the entire Menu
tree to find the lowest item that matches the predicate.
myMenu.select( MenuSelector.byPath( "path-to-select" ) );
HTTP request selector
A common case for using Menu
in a web scenario is selecting the menu item based on the path of the current web request.
The RequestMenuSelector
is a specific MenuSelector
implementation that does exactly that.
HttpServletRequest currentRequest;
myMenu.select( MenuSelector.byHttpServletRequest( currentRequest ) );
The RequestMenuSelector
uses a scoring mechanism to find the best matching item for the current request.
It will look at the current url, servlet path and query string, and will select the menu item that has the best match.
It will inspect the url
property of a Menu
but also take the value of RequestMenuSelector.ATTRIBUTE_MATCHERS
into account.
The latter is an optional attribute that can be registered on a Menu
, with its value expected to be a Collection
of strings that represent urls or paths this item represents.
Suppose the current url is http://my.domain/my-page?id=10
, then RequestMenuSelector
would select the items the following order:
-
http://my.domain/my-page/create?id=10
-
/my-page/create?id=10
-
/my-page/create
-
/my-page
Note that even if an item matches only a prefix of the requested path, it will match if there is none more specific.
The RequestMenuSelector is the default selector that is automatically used when using the `MenuFactory`to publish a menu for configuration.
|
Path based menu building
Instead of manually assembling a Menu
, it is usually easier to use a PathBasedMenuBuilder
for configuration of an entire menu tree using a single class.
The fastest way to create a new PathBasedMenuBuilder
builder is with Menu.builder()
.
The builder allows you to register items as a flat list, with each item being identified by a unique path.
The path can have several segments which are separate with a /
(forward slash) character.
Every item with a path that is also the prefix of another item’s path, will become the parent item of those other items.
The flat list of items will only be turned into a Menu
tree when calling the build()
method.
A PathBasedMenuBuilder
not only allows you to create a new Menu
instance using build()
, it can also be used to update/extend already existing menu trees using the merge(Menu)
method.
A simple example
Suppose you register the following menu items in order:
-
/my-group/item-1
-
/my-group
-
/my-item
-
/my-group/item-2
-
/my-other-group/single-item
In Java code this would look like:
Menu menu = Menu.builder()
.item( "/my-group/item-1" ).and()
.item( "/my-group" ).and()
.item( "/my-item" ).and()
.item( "/my-group/item-2" ).and()
.item( "/my-other-group/single-item" ).and()
.item( "/my-group:item-3" ).and()
.build()
The resulting Menu
then contains the following hierarchy:
ROOT (1)
+ /my-group (2)
| + /my-group/item-1
| + /my-group/item-2
+ /my-group:item-3 (3)
+ /my-item
+ /my-other-group/single-item (4)
1 | By default the top-most item of the menu has no specific path.
Setting a path on the root item can be done by calling its item builder using builder.root(String) , but this will have no impact on the hierarchy being created.
The root path of a Menu is only relevant in specialized cases where you want to merge the result of a builder into an already existing Menu . |
2 | The presence of the item with path /my-group causes the other 2 items starting with the same path prefix to be added as child items of this one. |
3 | Because /my-group:item-3 does not have the right path separator (it has a : instead of a / ), it is still a separate item instead of a child of /my-group . |
4 | A parent item does not automatically get created based on path separation.
There is no item /my-other-group , so this item remains a direct child of the root. |
Items should peferably not be registered with a trailing slash to ensure correct conversion to a menu tree. |
Fluent API examples
The PathBasedMenuBuilder
provides a fluent API to add items, modify them and remove them, and move them around by manipulating their paths.
It allows you to change the registered paths of an item before Menu
building, thus influencing the actual menu tree that gets created.
Creating an item
builder.item( "/item-path" )
This will create an item with that path if it does not yet exist. Once the item has been registered, the same item builder will always be returned on subsequent calls.
Setting item properties
builder.item( "/item-path" ).title( "My item").attribute( "key", "value" )
Changing an item only if it is present
builder.optionalItem( "/item-path" ).url( "update url" )
This will return a valid item builder that allows all actions to be performed, but will in fact do nothing unless that item was registered previously. Useful if you are not sure the item has been added, for example in menu publishing scenarios.
Removing an item and all items that would become children
builder.item( "/item-path" ).remove( true )
builder.removeItems( "/item-path", true );
The true
argument indicates that all other items having the specified path as prefix should also be removed.
Removing an item but not its possible children
builder.item( "/item-path" ).remove( false )
builder.removeItems( "/item-path", false );
The false
argument indicates that only the item with that exact path should be removed.
Removing an item that might not be present
builder.optionalItem( "/item-path" ).remove( true|false ) (1)
builder.removeItems( "/item-path", true|false ) (2)
1 | In this case nothing will be removed if the original /item-path item is not present, even if the method argument is true . |
2 | When the argument is true , this will always attempt to remove all items starting with that prefix.
It does not matter if the exact /item-path is present or not. |
Changing the path of an item and all its possible children
builder.item( "/original" ).changePathTo( "/new" )
builder.changeItemPath( "/original", "/new" )
This will replace the /original
path prefix in all items with the /new
value.
Changing the path of an item but not its possible children
builder.item( "/original" ).changePathTo( "/new", false )
builder.changeItemPath( "/original", "/new", false )
The false
argument indicates that only the item with the exact path should updated.
In this case /original
would be changed to /new
, but /original/item
would not be modified.
Changing the path of child items to-be, but not their parent item
builder.changeItemPath( "/original/", "/new/" )
In this case I update all items where the path starts with /original/
.
It is the trailing slash that ensures we do not modify the /original
item.
Actions performed on a builder are immediate, that means after you change an item path, or remove an item, you can no longer refer to it in the same way. If you do you will simply re-create a new item with that path. |
Delayed configuration
Builders are used extensively when publishing a menu, allowing different classes to configure a single menu using event listeners. The same builder is then passed to the different event handling methods, and these modify the previous configuration performed on the builder.
Sometimes you want to modify a menu builder, but you want to be sure that all other configuration has been applied first.
You can do so by registering an additional consumer using andThen()
.
// This will NOT work (1)
builder.item( "/one" ).title( "One" ).and()
.item( "/one" ).changePathTo( "/two" ).and()
.item( "/one/child" ).title( "Child of one" );
// This will work as expected (2)
builder.item( "/one" ).title( "One" ).and()
.andThen( builder -> builder.item( "/one" ).changePathTo( "/two" ) );
builder.item( "/one/child" ).title( "Child of one" );
1 | In this case the path of /one is changed to /two before item /one/child is registered.
The resulting menu tree will contain 2 children of the root node: /two and /one/child . |
2 | The path prefix /one is updated after the initial configuration has been applied, by calling the separate consumer.
The resulting menu tree will have a single child of the root node and two items in total: /two and /two/child . |
MenuItemBuilderProcessor
The PathBasedMenuBuilder
also allows you to register a MenuItemBuilderProcessor
instance, that can be used to post-process generated Menu
items right after they have been created.
An example where this could be useful is to transparently translate context-relative urls to absolute or domain relative urls. Please see the javadoc and source code for more information on this.
Publishing a menu
Menus are often used to allow other modules to configure items to them.
An example is AdminWebModule which builds a custom Menu
for the navigation items on the UI.
It publishes an event that any component in any module can listen for, and use it to register its own navigational items.
Behind the scenes request based selecting is then used to automatically select & highlight the active nav item.
Anyone can publish an event for building a menu, using the MenuFactory
.
@Autowired
MenuFactory menuFactory;
Menu myMenu = new Menu( "myMenu" );
menuFactory.buildMenu( myMenu );
When publishing a menu with the MenuFactory
this way, the following things happen:
-
a
PathBasedMenuBuilder
for the menu is created -
a
RequestMenuSelector
is created for selecting the active menu items -
a
BuildMenuEvent
is published, embedding the original menu, the selector and the builder-
any event listener can make modifications, register items, change paths or replace the selector
-
-
after the event has been handled the menu is sorted and the active items are selected (using the configured selector)
-
if there are any post-processors registered on the
BuildMenuEvent
, these will be executed before returning to the original caller
Simple event listening can be used to customize a published menu.
@EventListener( condition = "#menu.menuName == 'myMenu'" )
void registerMenuItem( BuildMenuEvent menu ) {
menu.item( "/my-item" ).title( "my custom item" );
}
A BuildMenuEvent
has both a generic type (the specific class of the Menu
instance) and a menuName
(name of menu instance), that can be used to match the specific event.
class MyCustomMenu extends Menu {
}
MyCustomMenu myMenu = new MyCustomMenu();
menuFactory.buildMenu( myMenu );
@EventListener
void registerMenuItem( BuildMenuEvent<MyCustomMenu> menu ) {
menu.item( "/my-item" ).title( "my custom item" );
}
MenuFactory
has several other variations for building events.
Please refer to the javadoc for an overview.
More advanced configurations often have custom types for both Menu and BuildMenuEvent , exposing more context information.
This requires you to customize the MenuFactory configuration.
We invite you to look at both the javadoc and source code if you want to implement a similar scenario.
|
Post-processing a menu
If you need to post-process a generated Menu
after all items have been registered, the menu has been built, sorted and active items selected; you can register a Consumer<Menu>
using event.addMenuPostProcessor()
.
Automatic menu publishing from handler methods
MenuFactory
provides integration with handler methods through means of an argument resolver.
If you specify a menu argument, the instance will be created and published automatically, provided there is a parameter-less constructor available if you use a specific menu type.
@GetMapping( "/" )
String renderHomepage( MyCustomMenu navMenu ) {
...
}
When the handler method is being called, the MenuFactory
will be checked for the presence of a menu named navMenu
.
If there is none available, a new MyCustomMenu
will be created with name navMenu
, and the menu will be published.
When a menu is generated this way, it is also available as a request attribute with the menu name.
In our example, a request attribute called navMenu
would be available in the View
.
A single handler method can have as many menu references as it wants, as long as the names are different.
Every menu will only be built once per request.
In the above example, subsequent calls to menuFactory.buildMenu( "navMenu" )
would always return the same instance.
Rendering a menu
Across Web itself does not in any way determine how a Menu
is visualized, this is up to the application.
<ol class="breadcrumb">
<li th:each="item : ${menu.selectedItemPath}"
th:unless="${item.disabled}"
th:classappend="${itemStat.last} ? 'active'"
th:if="${item.hasTitle()}">
<span th:if="${itemStat.last}" th:text="${item.title}">
title selected item
</span>
<div th:unless="${itemStat.last}" th:remove="tag">
<span th:if="${!item.hasUrl() and (!item.isGroup() or !item.firstItem.hasUrl())}"
th:text="${item.title}">title if no url</span>
<a th:if="${item.hasUrl() and !itemStat.last}"
th:href="@{${item.url}}"
th:text="${item.title}">title with url</a>
<a th:if="${!item.hasUrl() and item.isGroup() and item.firstItem.hasUrl()}"
th:href="@{${item.firstItem.url}}"
th:text="${item.title}">title with first item url</a>
</div>
</li>
</ol>
A module like BootstrapUiModule provides components for rendering Menu
instances.