Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
Expand Down Expand Up @@ -53,6 +54,7 @@
import com.vaadin.flow.router.RouteData;
import com.vaadin.flow.router.RouteParameterData;
import com.vaadin.flow.router.RouteParameters;
import com.vaadin.flow.router.RouteParentReference;
import com.vaadin.flow.router.internal.ParameterInfo;
import com.vaadin.flow.router.internal.RouteUtil;
import com.vaadin.flow.server.AbstractConfiguration;
Expand Down Expand Up @@ -152,6 +154,162 @@ public static List<AvailableViewInfo> collectMenuItemsList(Locale locale) {
.toList();
}

/**
* Collect the menu items as a hierarchy of root entries, each carrying its
* nested {@link AvailableViewInfo#children() children}.
* <p>
* Uses {@code en-US} locale for ordering to match
* {@link #collectMenuItemsList()}.
*
* @return ordered root view infos, each with its nested children populated
*/
public static List<AvailableViewInfo> collectMenuItemsTree() {
// en-US is used by default here to match with Hilla's
// createMenuItems.ts sorting algorithm.
return collectMenuItemsTree(Locale.forLanguageTag("en-US"));
}

/**
* Collect the menu items as a hierarchy of root entries, each carrying its
* nested {@link AvailableViewInfo#children() children}.
* <p>
* The same set of views as {@link #collectMenuItemsList(Locale)} is
* returned, but instead of a flat list the views are nested:
* <ul>
* <li>server views (with a {@link com.vaadin.flow.router.Menu @Menu} source
* class) are nested according to the route hierarchy resolved by
* {@link RouteConfiguration#getRouteHierarchy(Class, RouteParameters)}
* &mdash; i.e. honouring
* {@link com.vaadin.flow.router.RouteParent @RouteParent} with URL-prefix
* walking as fallback;</li>
* <li>client views are nested according to their file-system route nesting
* (longest path prefix).</li>
* </ul>
* A view is attached to its nearest <em>included</em> ancestor; if none of
* its ancestors are part of the menu, it becomes a root entry. The children
* of each entry are ordered with the same comparator as the flat list.
*
* @param locale
* locale to use for ordering. null for default locale.
* @return ordered root view infos, each with its nested children populated
*/
public static List<AvailableViewInfo> collectMenuItemsTree(Locale locale) {
RouteConfiguration routeConfiguration = RouteConfiguration
.forApplicationScope();

// Reuse the flat collection: it already normalizes each route to its
// menu link and sorts globally by (menu order, route). Because that
// order is global, grouping the list in-order yields correctly ordered
// siblings, so no further sorting is needed here.
List<AvailableViewInfo> menuItems = collectMenuItemsList(locale);

// Index by normalized path, and map each server view's @Menu class to
// its path so route-hierarchy ancestors can be resolved to a menu path.
Map<String, AvailableViewInfo> included = new LinkedHashMap<>();
Map<Class<?>, String> serverClassToPath = new HashMap<>();
for (AvailableViewInfo view : menuItems) {
included.put(view.route(), view);
if (isServerMenuView(view)) {
serverClassToPath.put(view.menu().menuClass(), view.route());
}
}

// Resolve each view's parent path (server via the route hierarchy,
// client via longest path prefix) and group children under it.
// Iterating the already-sorted list keeps each child list in sibling
// order.
Map<String, List<AvailableViewInfo>> childrenByParent = new HashMap<>();
List<AvailableViewInfo> roots = new ArrayList<>();
for (AvailableViewInfo view : menuItems) {
Optional<String> parent = isServerMenuView(view)
? resolveServerParentPath(view.menu().menuClass(),
serverClassToPath, routeConfiguration)
: resolveClientParentPath(view.route(), included);
if (parent.isPresent()) {
childrenByParent
.computeIfAbsent(parent.get(), key -> new ArrayList<>())
.add(view);
} else {
roots.add(view);
}
}

return roots.stream()
.map(root -> attachChildren(root, childrenByParent)).toList();
}

private static boolean isServerMenuView(AvailableViewInfo view) {
return view.menu() != null && view.menu().menuClass() != null;
}

/**
* Resolves the menu path of the nearest ancestor of {@code menuClass} that
* is part of the menu, walking the route hierarchy (honouring
* {@code @RouteParent} with URL-prefix fallback).
*/
private static Optional<String> resolveServerParentPath(
Class<? extends Component> menuClass,
Map<Class<?>, String> serverClassToPath,
RouteConfiguration routeConfiguration) {
List<RouteParentReference> hierarchy = routeConfiguration
.getRouteHierarchy(menuClass, RouteParameters.empty());
// Root-first and inclusive of menuClass itself (last element); walk
// from the immediate parent upwards and pick the first included
// ancestor.
for (int i = hierarchy.size() - 2; i >= 0; i--) {
String path = serverClassToPath
.get(hierarchy.get(i).navigationTarget());
if (path != null) {
return Optional.of(path);
}
}
return Optional.empty();
}

/**
* Resolves the parent of a client view as the longest other included client
* path that is a strict prefix of {@code path}.
*/
private static Optional<String> resolveClientParentPath(String path,
Map<String, AvailableViewInfo> included) {
String best = null;
for (Map.Entry<String, AvailableViewInfo> entry : included.entrySet()) {
String candidate = entry.getKey();
if (candidate.equals(path) || isServerMenuView(entry.getValue())) {
continue;
}
boolean isPrefix = "/".equals(candidate)
? path.startsWith("/") && !path.equals("/")
: path.startsWith(candidate + "/");
if (isPrefix
&& (best == null || candidate.length() > best.length())) {
best = candidate;
}
}
return Optional.ofNullable(best);
}

private static AvailableViewInfo attachChildren(AvailableViewInfo view,
Map<String, List<AvailableViewInfo>> childrenByParent) {
List<AvailableViewInfo> children = childrenByParent.get(view.route());
// Rebuild with the resolved children (null when none) so that any
// original client child list is replaced uniformly by the resolved
// hierarchy.
List<AvailableViewInfo> nested = (children == null) ? null
: children.stream()
.map(child -> attachChildren(child, childrenByParent))
.toList();
return withChildren(view, nested);
}

private static AvailableViewInfo withChildren(AvailableViewInfo source,
List<AvailableViewInfo> children) {
return new AvailableViewInfo(source.title(), source.rolesAllowed(),
source.loginRequired(), source.route(), source.lazy(),
source.register(), source.menu(), children,
source.routeParameters(), source.flowLayout(), source.detail());
}

/**
* Collect views with menu annotation for automatic menu population. All
* client views are collected and any accessible server views.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,42 @@ public static List<MenuEntry> getMenuEntries(Locale locale) {
.map(MenuConfiguration::createMenuEntry).toList();
}

/**
* Collect the menu entries as a hierarchy for menu population, nested
* according to the route hierarchy.
* <p>
* The same views as {@link #getMenuEntries()} are returned, but as a tree:
* server views are nested according to
* {@link com.vaadin.flow.router.RouteParent @RouteParent} (with URL-prefix
* walking as fallback), and client views according to their file-system
* route nesting. Only root entries are returned; the descendants are
* reachable via {@link MenuEntry#children()}.
*
* @return ordered list of root {@link MenuEntry} instances with nested
* children
*/
public static List<MenuEntry> getMenuEntriesTree() {
UsageStatistics.markAsUsed(STATISTICS_DYNAMIC_MENU_ENTRIES, null);
return MenuRegistry.collectMenuItemsTree().stream()
.map(MenuConfiguration::createMenuTreeEntry).toList();
}

/**
* Collect the menu entries as a hierarchy for menu population, nested
* according to the route hierarchy.
*
* @param locale
* locale to use for ordering. null for default locale.
* @return ordered list of root {@link MenuEntry} instances with nested
* children
* @see #getMenuEntriesTree()
*/
public static List<MenuEntry> getMenuEntriesTree(Locale locale) {
UsageStatistics.markAsUsed(STATISTICS_DYNAMIC_MENU_ENTRIES, null);
return MenuRegistry.collectMenuItemsTree(locale).stream()
.map(MenuConfiguration::createMenuTreeEntry).toList();
}

/**
* Retrieves the page header of the currently shown view. Can be used in
* Flow main layouts to render a page header.
Expand Down Expand Up @@ -184,4 +220,13 @@ private static MenuEntry createMenuEntry(AvailableViewInfo viewInfo) {
viewInfo.menu().order(), viewInfo.menu().icon(),
viewInfo.menu().menuClass());
}

private static MenuEntry createMenuTreeEntry(AvailableViewInfo viewInfo) {
MenuEntry entry = createMenuEntry(viewInfo);
List<MenuEntry> children = viewInfo.children() == null ? List.of()
: viewInfo.children().stream()
.map(MenuConfiguration::createMenuTreeEntry).toList();
return new MenuEntry(entry.path(), entry.title(), entry.order(),
entry.icon(), entry.menuClass(), children);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,17 @@
package com.vaadin.flow.server.menu;

import java.io.Serializable;
import java.util.List;

import com.vaadin.flow.component.Component;

/**
* Menu entry for the main menu.
* <p>
* An entry may carry nested {@link #children() children} when obtained from
* {@link MenuConfiguration#getMenuEntriesTree()}, forming a hierarchical menu.
* The flat {@link MenuConfiguration#getMenuEntries()} returns entries with no
* children.
*
* @param path
* the path to navigate to
Expand All @@ -38,7 +44,39 @@
* the source class with {@link com.vaadin.flow.router.Menu}
* annotation or null if not available. Always null for
* Hilla/TypeScript client views.
* @param children
* the entries nested under this entry, never {@code null} but empty
* for a flat (non-hierarchical) entry
*/
public record MenuEntry(String path, String title, Double order, String icon,
Class<? extends Component> menuClass) implements Serializable {
Class<? extends Component> menuClass,
List<MenuEntry> children) implements Serializable {

/**
* Creates a flat menu entry with no children.
*
* @param path
* the path to navigate to
* @param title
* the title to display
* @param order
* the order in the menu or null for default order
* @param icon
* the icon to use in the menu or null for no icon
* @param menuClass
* the source {@code @Menu} class or null if not available
*/
public MenuEntry(String path, String title, Double order, String icon,
Class<? extends Component> menuClass) {
this(path, title, order, icon, menuClass, List.of());
}

/**
* Normalizes {@code children} to a non-null, unmodifiable list so that
* {@link #children()} is never {@code null} (e.g. after deserialization of
* an entry written before children existed).
*/
public MenuEntry {
children = children == null ? List.of() : List.copyOf(children);
}
}
Loading
Loading