From ed54b824297f6fe4d714c41df767b91ad82b4f4b Mon Sep 17 00:00:00 2001 From: Artur Signell Date: Mon, 8 Jun 2026 15:37:50 +0300 Subject: [PATCH] feat: hierarchical menu entries based on route hierarchy Add MenuConfiguration.getMenuEntriesTree() returning the menu views nested according to the route hierarchy instead of as a flat list. Server views are nested via RouteUtil.getRouteHierarchy (honouring @RouteParent with URL-prefix fallback) and client views via their file-system route nesting; each view attaches to its nearest included ancestor. The existing MenuEntry record gains a self-nesting List children component. An explicit no-children constructor keeps the previous 5-arg construction source- and binary-compatible, and the compact constructor normalizes children to a non-null unmodifiable list. The flat getMenuEntries() is unchanged and returns entries with empty children. The tree assembly lives in MenuRegistry.collectMenuItemsTree(), which builds directly on collectMenuItemsList(): that already normalizes routes to menu links and sorts globally, so the tree only has to resolve parents and group the already-ordered entries. --- .../flow/internal/menu/MenuRegistry.java | 158 ++++++++++++++++++ .../flow/server/menu/MenuConfiguration.java | 45 +++++ .../vaadin/flow/server/menu/MenuEntry.java | 40 ++++- .../flow/server/menu/MenuRegistryTest.java | 92 ++++++++++ 4 files changed, 334 insertions(+), 1 deletion(-) diff --git a/flow-server/src/main/java/com/vaadin/flow/internal/menu/MenuRegistry.java b/flow-server/src/main/java/com/vaadin/flow/internal/menu/MenuRegistry.java index f7b15f2d893..1b588465c5e 100644 --- a/flow-server/src/main/java/com/vaadin/flow/internal/menu/MenuRegistry.java +++ b/flow-server/src/main/java/com/vaadin/flow/internal/menu/MenuRegistry.java @@ -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; @@ -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; @@ -152,6 +154,162 @@ public static List collectMenuItemsList(Locale locale) { .toList(); } + /** + * Collect the menu items as a hierarchy of root entries, each carrying its + * nested {@link AvailableViewInfo#children() children}. + *

+ * 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 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}. + *

+ * The same set of views as {@link #collectMenuItemsList(Locale)} is + * returned, but instead of a flat list the views are nested: + *

    + *
  • 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)} + * — i.e. honouring + * {@link com.vaadin.flow.router.RouteParent @RouteParent} with URL-prefix + * walking as fallback;
  • + *
  • client views are nested according to their file-system route nesting + * (longest path prefix).
  • + *
+ * A view is attached to its nearest included 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 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 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 included = new LinkedHashMap<>(); + Map, 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> childrenByParent = new HashMap<>(); + List roots = new ArrayList<>(); + for (AvailableViewInfo view : menuItems) { + Optional 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 resolveServerParentPath( + Class menuClass, + Map, String> serverClassToPath, + RouteConfiguration routeConfiguration) { + List 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 resolveClientParentPath(String path, + Map included) { + String best = null; + for (Map.Entry 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> childrenByParent) { + List 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 nested = (children == null) ? null + : children.stream() + .map(child -> attachChildren(child, childrenByParent)) + .toList(); + return withChildren(view, nested); + } + + private static AvailableViewInfo withChildren(AvailableViewInfo source, + List 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. diff --git a/flow-server/src/main/java/com/vaadin/flow/server/menu/MenuConfiguration.java b/flow-server/src/main/java/com/vaadin/flow/server/menu/MenuConfiguration.java index 6c608d83e1d..a8b7e149032 100644 --- a/flow-server/src/main/java/com/vaadin/flow/server/menu/MenuConfiguration.java +++ b/flow-server/src/main/java/com/vaadin/flow/server/menu/MenuConfiguration.java @@ -68,6 +68,42 @@ public static List getMenuEntries(Locale locale) { .map(MenuConfiguration::createMenuEntry).toList(); } + /** + * Collect the menu entries as a hierarchy for menu population, nested + * according to the route hierarchy. + *

+ * 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 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 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. @@ -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 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); + } } diff --git a/flow-server/src/main/java/com/vaadin/flow/server/menu/MenuEntry.java b/flow-server/src/main/java/com/vaadin/flow/server/menu/MenuEntry.java index 020cc2ddcf1..a8827a1ecc3 100644 --- a/flow-server/src/main/java/com/vaadin/flow/server/menu/MenuEntry.java +++ b/flow-server/src/main/java/com/vaadin/flow/server/menu/MenuEntry.java @@ -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. + *

+ * 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 @@ -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 menuClass) implements Serializable { + Class menuClass, + List 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 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); + } } diff --git a/flow-server/src/test/java/com/vaadin/flow/server/menu/MenuRegistryTest.java b/flow-server/src/test/java/com/vaadin/flow/server/menu/MenuRegistryTest.java index da686348d1e..e386c18f039 100644 --- a/flow-server/src/test/java/com/vaadin/flow/server/menu/MenuRegistryTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/server/menu/MenuRegistryTest.java @@ -56,6 +56,7 @@ import com.vaadin.flow.router.Route; import com.vaadin.flow.router.RouteConfiguration; import com.vaadin.flow.router.RouteParameters; +import com.vaadin.flow.router.RouteParent; import com.vaadin.flow.router.internal.RouteUtil; import com.vaadin.flow.server.InvalidRouteConfigurationException; import com.vaadin.flow.server.MockServletContext; @@ -467,6 +468,62 @@ void getMenuItemsList_assertOrder() { new String[] { "/d", "/c", "/a", "/b", "/d/a", "/d/b" }); } + @Test + void collectMenuItemsTree_nestsByRouteParent_notUrlPrefix() { + RouteConfiguration routeConfiguration = RouteConfiguration + .forRegistry(registry); + Arrays.asList(TreeDashboard.class, TreeReports.class, TreeSales.class, + TreeSettings.class, TreeBilling.class) + .forEach(routeConfiguration::setAnnotatedRoute); + + List tree = MenuRegistry.collectMenuItemsTree(); + + // Single conceptual root: Dashboard at "/". + assertEquals(1, tree.size()); + AvailableViewInfo dashboard = tree.get(0); + assertEquals("/", dashboard.route()); + + // Dashboard's children in @Menu order: Reports then Settings. + assertEquals(List.of("/reports", "/settings"), + routesOf(dashboard.children())); + + // Reports -> Sales. + AvailableViewInfo reports = dashboard.children().get(0); + assertEquals(List.of("/reports/sales"), routesOf(reports.children())); + + // The showcase: Billing lives at /billing but @RouteParent nests it + // under Settings - URL-prefix walking would have made it a root. + AvailableViewInfo settings = dashboard.children().get(1); + assertEquals(List.of("/billing"), routesOf(settings.children())); + } + + @Test + void getMenuEntriesTree_exposesNestedEntries() { + RouteConfiguration routeConfiguration = RouteConfiguration + .forRegistry(registry); + Arrays.asList(TreeDashboard.class, TreeSettings.class, + TreeBilling.class) + .forEach(routeConfiguration::setAnnotatedRoute); + + List tree = MenuConfiguration.getMenuEntriesTree(); + + assertEquals(1, tree.size()); + MenuEntry dashboard = tree.get(0); + assertEquals("Dashboard", dashboard.title()); + assertEquals("/", dashboard.path()); + + MenuEntry settings = dashboard.children().get(0); + assertEquals("/settings", settings.path()); + + MenuEntry billing = settings.children().get(0); + assertEquals("Billing", billing.title()); + assertEquals("/billing", billing.path()); + } + + private static List routesOf(List infos) { + return infos.stream().map(AvailableViewInfo::route).toList(); + } + @Test void hasHillaAutoLayout_fileRoutesHasSingleRootLayout_true() throws IOException { @@ -726,6 +783,41 @@ public static class MyOptionalParamRoute extends Component { public static class MyVarargsParamRoute extends Component { } + @Tag("div") + @Route("") + @Menu(title = "Dashboard", order = 1) + public static class TreeDashboard extends Component { + } + + @Tag("div") + @Route("reports") + @RouteParent(TreeDashboard.class) + @Menu(title = "Reports", order = 2) + public static class TreeReports extends Component { + } + + @Tag("div") + @Route("reports/sales") + @RouteParent(TreeReports.class) + @Menu(title = "Sales") + public static class TreeSales extends Component { + } + + @Tag("div") + @Route("settings") + @RouteParent(TreeDashboard.class) + @Menu(title = "Settings", order = 3) + public static class TreeSettings extends Component { + } + + // URL /billing is NOT under /settings, but @RouteParent nests it there. + @Tag("div") + @Route("billing") + @RouteParent(TreeSettings.class) + @Menu(title = "Billing") + public static class TreeBilling extends Component { + } + @Tag("div") @Route("a") @Menu(order = 1.1)