diff --git a/flow-server/src/main/java/com/vaadin/flow/server/menu/MenuRegistry.java b/flow-server/src/main/java/com/vaadin/flow/server/menu/MenuRegistry.java index 2ac6ba3e7b2..102ccc944aa 100644 --- a/flow-server/src/main/java/com/vaadin/flow/server/menu/MenuRegistry.java +++ b/flow-server/src/main/java/com/vaadin/flow/server/menu/MenuRegistry.java @@ -21,12 +21,15 @@ import java.net.MalformedURLException; import java.net.URL; import java.nio.file.Path; +import java.text.Collator; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -41,6 +44,7 @@ import com.vaadin.flow.component.Component; import com.vaadin.flow.router.BeforeEnterListener; +import com.vaadin.flow.router.MenuData; import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.RouteConfiguration; import com.vaadin.flow.router.RouteData; @@ -72,7 +76,48 @@ public class MenuRegistry { * @return routes with view information */ public static Map collectMenuItems() { - return new MenuRegistry().getMenuItems(true); + Map menuRoutes = new MenuRegistry() + .getMenuItems(true); + menuRoutes.entrySet() + .removeIf(entry -> Optional.ofNullable(entry.getValue()) + .map(AvailableViewInfo::menu).map(MenuData::isExclude) + .orElse(false)); + return menuRoutes; + } + + /** + * Collect ordered list of views with menu annotation for automatic menu + * population. All client views are collected and any accessible server + * views. + * + * @return ordered routes with view information + */ + public static List collectMenuItemsList() { + // en-US is used by default here to match with Hilla's + // createMenuItems.ts sorting algorithm. + return collectMenuItemsList(Locale.forLanguageTag("en-US")); + } + + /** + * Collect ordered list of views with menu annotation for automatic menu + * population. All client views are collected and any accessible server + * views. + * + * @param locale + * locale to use for ordering. null for default locale. + * @return ordered routes with view information + */ + public static List collectMenuItemsList(Locale locale) { + return collectMenuItems().entrySet().stream().map(entry -> { + AvailableViewInfo value = entry.getValue(); + return new AvailableViewInfo(value.title(), value.rolesAllowed(), + value.loginRequired(), entry.getKey(), value.lazy(), + value.register(), value.menu(), value.children(), + value.routeParameters(), value.flowLayout()); + }).sorted(getMenuOrderComparator( + (locale != null ? Collator.getInstance(locale) + : Collator.getInstance()))) + .toList(); } /** @@ -467,4 +512,16 @@ public static Map getClientRoutes( } return clientItems; } + + private static Comparator getMenuOrderComparator( + Collator collator) { + return (o1, o2) -> { + int ordersCompareTo = Optional.ofNullable(o1.menu()) + .map(MenuData::getOrder).orElse(Double.MAX_VALUE) + .compareTo(Optional.ofNullable(o2.menu()) + .map(MenuData::getOrder).orElse(Double.MAX_VALUE)); + return ordersCompareTo != 0 ? ordersCompareTo + : collator.compare(o1.route(), o2.route()); + }; + } } 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 b405003af20..dcfe0487253 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 @@ -21,7 +21,9 @@ import java.nio.file.Files; import java.security.Principal; import java.util.Arrays; +import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import jakarta.servlet.ServletContext; import net.jcip.annotations.NotThreadSafe; @@ -156,7 +158,7 @@ public void getMenuItemsNoFilteringContainsAllClientPaths() Assert.assertEquals(5, menuItems.size()); // Validate as if logged in as all routes should be available - assertClientRoutes(menuItems, true, true); + assertClientRoutes(menuItems, true, true, false); } @Test @@ -221,7 +223,7 @@ public void getMenuItemsContainBothClientAndServerPaths() } @Test - public void collectMenuItems_returnsCorrecPaths() throws IOException { + public void collectMenuItems_returnsCorrectPaths() throws IOException { File generated = tmpDir.newFolder(GENERATED); File clientFiles = new File(generated, FILE_ROUTES_JSON_NAME); Files.writeString(clientFiles.toPath(), testClientRouteFile); @@ -236,10 +238,10 @@ public void collectMenuItems_returnsCorrecPaths() throws IOException { Map menuItems = MenuRegistry .collectMenuItems(); - Assert.assertEquals(8, menuItems.size()); - assertClientRoutes(menuItems); + Assert.assertEquals(5, menuItems.size()); + assertClientRoutes(menuItems, false, false, true); assertServerRoutes(menuItems); - assertServerRoutesWithParameters(menuItems); + assertServerRoutesWithParameters(menuItems, true); } @Test @@ -257,7 +259,16 @@ public void testWithLoggedInUser_userHasRoles() throws IOException { .getMenuItems(true); Assert.assertEquals(5, menuItems.size()); - assertClientRoutes(menuItems, true, true); + assertClientRoutes(menuItems, true, true, false); + + // Verify that getMenuItemsList returns the same data + List menuItemsList = MenuRegistry + .collectMenuItemsList(); + Assert.assertEquals( + "List of menu items has incorrect size. Excluded menu item like /login is not expected.", + 4, menuItemsList.size()); + assertOrder(menuItemsList, + new String[] { "", "/about", "/hilla", "/hilla/sub" }); } @Test @@ -275,15 +286,62 @@ public void testWithLoggedInUser_noMatchingRoles() throws IOException { .getMenuItems(true); Assert.assertEquals(3, menuItems.size()); - assertClientRoutes(menuItems, true, false); + assertClientRoutes(menuItems, true, false, false); + } + + @Test + public void getMenuItemsList_returnsCorrectPaths() throws IOException { + File generated = tmpDir.newFolder(GENERATED); + File clientFiles = new File(generated, FILE_ROUTES_JSON_NAME); + Files.writeString(clientFiles.toPath(), testClientRouteFile); + + RouteConfiguration routeConfiguration = RouteConfiguration + .forRegistry(registry); + Arrays.asList(MyRoute.class, MyInfo.class, MyRequiredParamRoute.class, + MyRequiredAndOptionalParamRoute.class, + MyOptionalParamRoute.class, MyVarargsParamRoute.class) + .forEach(routeConfiguration::setAnnotatedRoute); + + List menuItems = MenuRegistry.collectMenuItemsList(); + Assert.assertEquals(5, menuItems.size()); + assertOrder(menuItems, new String[] { "", "/home", "/info", "/param", + "/param/varargs" }); + // verifying that data is same as with collectMenuItems + Map mapMenuItems = menuItems.stream() + .collect(Collectors.toMap(AvailableViewInfo::route, + item -> item)); + assertClientRoutes(mapMenuItems, false, false, true); + assertServerRoutes(mapMenuItems); + assertServerRoutesWithParameters(mapMenuItems, true); + } + + @Test + public void getMenuItemsList_assertOrder() { + RouteConfiguration routeConfiguration = RouteConfiguration + .forRegistry(registry); + Arrays.asList(TestRouteA.class, TestRouteB.class, TestRouteC.class, + TestRouteD.class, TestRouteDA.class, TestRouteDB.class) + .forEach(routeConfiguration::setAnnotatedRoute); + + List menuItems = MenuRegistry.collectMenuItemsList(); + Assert.assertEquals(4, menuItems.size()); + assertOrder(menuItems, + new String[] { "/d", "/c", "/a", "/b", "/d/a", "/d/b" }); + } + + private void assertOrder(List menuItems, + String[] expectedOrder) { + for (int i = 0; i < menuItems.size(); i++) { + Assert.assertEquals(expectedOrder[i], menuItems.get(i).route()); + } } private void assertClientRoutes(Map menuItems) { - assertClientRoutes(menuItems, false, false); + assertClientRoutes(menuItems, false, false, false); } private void assertClientRoutes(Map menuItems, - boolean authenticated, boolean hasRole) { + boolean authenticated, boolean hasRole, boolean excludeExpected) { Assert.assertTrue("Client route '' missing", menuItems.containsKey("")); Assert.assertEquals("Public", menuItems.get("").title()); Assert.assertNull("Public doesn't contain specific menu data", @@ -328,12 +386,17 @@ private void assertClientRoutes(Map menuItems, menuItems.containsKey("/hilla")); } - Assert.assertTrue("Client route 'login' missing", - menuItems.containsKey("/login")); - Assert.assertEquals("Login", menuItems.get("/login").title()); - Assert.assertNull(menuItems.get("/login").menu().title()); - Assert.assertTrue("Login view should be excluded", - menuItems.get("/login").menu().exclude()); + if (excludeExpected) { + Assert.assertFalse("Client route 'login' should be excluded", + menuItems.containsKey("/login")); + } else { + Assert.assertTrue("Client route 'login' missing", + menuItems.containsKey("/login")); + Assert.assertEquals("Login", menuItems.get("/login").title()); + Assert.assertNull(menuItems.get("/login").menu().title()); + Assert.assertTrue("Login view should be excluded", + menuItems.get("/login").menu().exclude()); + } } private void assertServerRoutes(Map menuItems) { @@ -350,17 +413,31 @@ private void assertServerRoutes(Map menuItems) { private void assertServerRoutesWithParameters( Map menuItems) { - Assert.assertTrue("Server route '/param/:param' missing", - menuItems.containsKey("/param/:param")); - Assert.assertTrue( - "Server route '/param/:param' should be excluded from menu", - menuItems.get("/param/:param").menu().exclude()); + assertServerRoutesWithParameters(menuItems, false); + } - Assert.assertTrue("Server route '/param/:param1' missing", - menuItems.containsKey("/param/:param1")); - Assert.assertTrue( - "Server route '/param/:param1' should be excluded from menu", - menuItems.get("/param/:param1").menu().exclude()); + private void assertServerRoutesWithParameters( + Map menuItems, boolean excludeExpected) { + if (excludeExpected) { + Assert.assertFalse( + "Server route '/param/:param' should be excluded", + menuItems.containsKey("/param/:param")); + Assert.assertFalse( + "Server route '/param/:param1' should be excluded", + menuItems.containsKey("/param/:param1")); + } else { + Assert.assertTrue("Server route '/param/:param' missing", + menuItems.containsKey("/param/:param")); + Assert.assertTrue( + "Server route '/param/:param' should be excluded from menu", + menuItems.get("/param/:param").menu().exclude()); + + Assert.assertTrue("Server route '/param/:param1' missing", + menuItems.containsKey("/param/:param1")); + Assert.assertTrue( + "Server route '/param/:param1' should be excluded from menu", + menuItems.get("/param/:param1").menu().exclude()); + } Assert.assertTrue( "Server route with optional parameters '/param' missing", @@ -413,6 +490,41 @@ private static class MyOptionalParamRoute extends Component { private static class MyVarargsParamRoute extends Component { } + @Tag("div") + @Route("a") + @Menu(order = 1.1) + private static class TestRouteA extends Component { + } + + @Tag("div") + @Route("b") + @Menu(order = 1.2) + private static class TestRouteB extends Component { + } + + @Tag("div") + @Route("c") + @Menu(order = 0.1) + private static class TestRouteC extends Component { + } + + @Tag("div") + @Route("d") + @Menu(order = 0) + private static class TestRouteD extends Component { + } + + @Tag("div") + @Route("d/b") + private static class TestRouteDB extends Component { + + } + + @Tag("div") + @Route("d/a") + private static class TestRouteDA extends Component { + } + /** * Extending class to let us mock the getRouteRegistry method for testing. */