From 4ff26d4d8ad89638853885dadce2f12fa07f9453 Mon Sep 17 00:00:00 2001 From: Tomi Virtanen Date: Thu, 3 Oct 2024 17:13:42 +0300 Subject: [PATCH] feat: add MenuConfiguration New public API for building application menu: adds `MenuConfiguration`, `MenuOptions` and `MenuOption` where `MenuConfiguration` is the main entry point to access menu data to build main menu. Fixes: #20063 --- .../java/com/vaadin/flow/router/MenuData.java | 21 +- .../internal/AbstractRouteRegistry.java | 2 +- .../flow/server/menu/MenuConfiguration.java | 36 +++ .../vaadin/flow/server/menu/MenuOption.java | 46 +++ .../vaadin/flow/server/menu/MenuOptions.java | 73 +++++ .../server/menu/MenuConfigurationTest.java | 288 ++++++++++++++++++ .../flow/server/menu/MenuRegistryTest.java | 28 +- 7 files changed, 463 insertions(+), 31 deletions(-) create mode 100644 flow-server/src/main/java/com/vaadin/flow/server/menu/MenuConfiguration.java create mode 100644 flow-server/src/main/java/com/vaadin/flow/server/menu/MenuOption.java create mode 100644 flow-server/src/main/java/com/vaadin/flow/server/menu/MenuOptions.java create mode 100644 flow-server/src/test/java/com/vaadin/flow/server/menu/MenuConfigurationTest.java diff --git a/flow-server/src/main/java/com/vaadin/flow/router/MenuData.java b/flow-server/src/main/java/com/vaadin/flow/router/MenuData.java index b59a659ed43..4fb439eb8b9 100644 --- a/flow-server/src/main/java/com/vaadin/flow/router/MenuData.java +++ b/flow-server/src/main/java/com/vaadin/flow/router/MenuData.java @@ -19,12 +19,14 @@ import java.io.Serializable; import java.util.Objects; +import com.vaadin.flow.component.Component; + /** * Data class for menu item information. *

* Only for read as data is immutable. */ -public record MenuData(String title, Double order, boolean exclude, String icon) implements Serializable { +public record MenuData(String title, Double order, boolean exclude, String icon, Class menuClass) implements Serializable { /** * Gets the title of the menu item. @@ -65,20 +67,7 @@ public String getIcon() { @Override public String toString() { return "MenuData{" + "title='" + title + '\'' + ", order=" + order - + ", exclude=" + exclude + ", icon='" + icon + '\'' + '}'; - } - - @Override - public boolean equals(Object obj) { - return obj instanceof MenuData other - && Objects.equals(title, other.title) - && Objects.equals(order, other.order) - && Objects.equals(exclude, other.exclude) - && Objects.equals(icon, other.icon); - } - - @Override - public int hashCode() { - return Objects.hash(title, order, exclude, icon); + + ", exclude=" + exclude + ", icon='" + icon + "', menuClass='" + + menuClass + "'" + '}'; } } diff --git a/flow-server/src/main/java/com/vaadin/flow/router/internal/AbstractRouteRegistry.java b/flow-server/src/main/java/com/vaadin/flow/router/internal/AbstractRouteRegistry.java index ea650fd8dfe..3c34bf291bd 100644 --- a/flow-server/src/main/java/com/vaadin/flow/router/internal/AbstractRouteRegistry.java +++ b/flow-server/src/main/java/com/vaadin/flow/router/internal/AbstractRouteRegistry.java @@ -324,7 +324,7 @@ private void populateRegisteredRoutes(ConfiguredRoutes configuration, (Objects.equals(menu.order(), Double.MIN_VALUE)) ? null : menu.order(), excludeFromMenu, - (menu.icon().isBlank() ? null : menu.icon()))) + (menu.icon().isBlank() ? null : menu.icon()), target)) .orElse(null); RouteData route = new RouteData(parentLayouts, template, parameters, 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 new file mode 100644 index 00000000000..40560678ad5 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/server/menu/MenuConfiguration.java @@ -0,0 +1,36 @@ +/* + * Copyright 2000-2024 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ + +package com.vaadin.flow.server.menu; + +import java.io.Serializable; + +/** + * Menu configuration helper class to retrieve available menu options for + * application main menu. + */ +public class MenuConfiguration implements Serializable { + + /** + * Get the {@link MenuOptions} instance containing all menu options for the + * application. + * + * @return the {@link MenuOptions} instance + */ + public static MenuOptions getMenuOptions() { + return MenuOptions.getInstance(); + } +} diff --git a/flow-server/src/main/java/com/vaadin/flow/server/menu/MenuOption.java b/flow-server/src/main/java/com/vaadin/flow/server/menu/MenuOption.java new file mode 100644 index 00000000000..bb57140098b --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/server/menu/MenuOption.java @@ -0,0 +1,46 @@ +/* + * Copyright 2000-2024 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ + +package com.vaadin.flow.server.menu; + +import java.io.Serializable; + +import com.vaadin.flow.component.Component; + +/** + * Menu option for the main menu. + * + * @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 exclude + * whether to exclude the menu option + * @param icon + * Icon to use in the menu or null for no icon. Value can go inside a + * {@code } element's {@code icon} attribute which + * accepts icon group and name like 'vaadin:file'. Or it can go to a + * {@code } element's {@code src} attribute which takes + * path to the icon. E.g. 'line-awesome/svg/lock-open-solid.svg'. + * @param menuClass + * the source class with {@link com.vaadin.flow.router.Menu} + * annotation or null if not available + */ +public record MenuOption(String path, String title, Double order, + boolean exclude, String icon, Class menuClass) implements Serializable { +} diff --git a/flow-server/src/main/java/com/vaadin/flow/server/menu/MenuOptions.java b/flow-server/src/main/java/com/vaadin/flow/server/menu/MenuOptions.java new file mode 100644 index 00000000000..25b08682da3 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/server/menu/MenuOptions.java @@ -0,0 +1,73 @@ +/* + * Copyright 2000-2024 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ + +package com.vaadin.flow.server.menu; + +import java.io.Serializable; +import java.util.List; +import java.util.stream.Stream; + +/** + * {@link MenuOptions} holds a list of menu options. + */ +public class MenuOptions implements Serializable { + + private final List menuOptions; + + private MenuOptions(List menuOptions) { + this.menuOptions = menuOptions; + } + + /** + * Construct a new instance containing all available menu options. + */ + public static MenuOptions getInstance() { + return new MenuOptions( + MenuRegistry.collectMenuItemsList().stream().map(viewInfo -> { + if (viewInfo.menu() == null) { + return new MenuOption(viewInfo.route(), + viewInfo.title(), null, false, null, null); + } + return new MenuOption(viewInfo.route(), + (viewInfo.menu().title() != null + && !viewInfo.menu().title().isBlank() + ? viewInfo.menu().title() + : viewInfo.title()), + viewInfo.menu().order(), viewInfo.menu().exclude(), + viewInfo.menu().icon(), + viewInfo.menu().menuClass()); + }).toList()); + } + + /** + * Get a stream of the menu options. + * + * @return the menu options stream + */ + public Stream stream() { + return menuOptions.stream(); + } + + /** + * Get the list of the menu options. Returned list is mutable and any + * changes to it will also change {@link MenuOptions} state. + * + * @return the menu options list + */ + public List get() { + return menuOptions; + } +} diff --git a/flow-server/src/test/java/com/vaadin/flow/server/menu/MenuConfigurationTest.java b/flow-server/src/test/java/com/vaadin/flow/server/menu/MenuConfigurationTest.java new file mode 100644 index 00000000000..5cf7506b36f --- /dev/null +++ b/flow-server/src/test/java/com/vaadin/flow/server/menu/MenuConfigurationTest.java @@ -0,0 +1,288 @@ +/* + * Copyright 2000-2024 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ + +package com.vaadin.flow.server.menu; + +import jakarta.servlet.ServletContext; +import java.io.File; +import java.io.IOException; +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 net.jcip.annotations.NotThreadSafe; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + +import com.vaadin.flow.di.DefaultInstantiator; +import com.vaadin.flow.function.DeploymentConfiguration; +import com.vaadin.flow.internal.CurrentInstance; +import com.vaadin.flow.router.RouteConfiguration; +import com.vaadin.flow.server.MockServletContext; +import com.vaadin.flow.server.MockVaadinContext; +import com.vaadin.flow.server.MockVaadinSession; +import com.vaadin.flow.server.VaadinRequest; +import com.vaadin.flow.server.VaadinService; +import com.vaadin.flow.server.VaadinServletContext; +import com.vaadin.flow.server.VaadinSession; +import com.vaadin.flow.server.startup.ApplicationRouteRegistry; + +import static com.vaadin.flow.server.frontend.FrontendUtils.GENERATED; +import static com.vaadin.flow.server.menu.MenuRegistry.FILE_ROUTES_JSON_NAME; + +@NotThreadSafe +public class MenuConfigurationTest { + + @Rule + public TemporaryFolder tmpDir = new TemporaryFolder(); + + private ApplicationRouteRegistry registry; + @Mock + private MenuRegistryTest.MockService vaadinService; + private VaadinSession session; + private ServletContext servletContext; + private VaadinServletContext vaadinContext; + @Mock + private DeploymentConfiguration deploymentConfiguration; + @Mock + private VaadinRequest request; + + private AutoCloseable closeable; + + @Before + public void init() { + closeable = MockitoAnnotations.openMocks(this); + servletContext = new MockServletContext(); + vaadinContext = new MockVaadinContext(servletContext); + + registry = ApplicationRouteRegistry.getInstance(vaadinContext); + + Mockito.when(vaadinService.getRouteRegistry()).thenReturn(registry); + Mockito.when(vaadinService.getContext()).thenReturn(vaadinContext); + Mockito.when(vaadinService.getInstantiator()) + .thenReturn(new DefaultInstantiator(vaadinService)); + + Mockito.when(vaadinService.getDeploymentConfiguration()) + .thenReturn(deploymentConfiguration); + + Mockito.when(deploymentConfiguration.getFrontendFolder()) + .thenReturn(tmpDir.getRoot()); + + VaadinService.setCurrent(vaadinService); + + session = new MockVaadinSession(vaadinService) { + @Override + public VaadinService getService() { + return vaadinService; + } + }; + + VaadinSession.setCurrent(session); + + Mockito.when(request.getService()).thenReturn(vaadinService); + CurrentInstance.set(VaadinRequest.class, request); + } + + @After + public void cleanup() throws Exception { + closeable.close(); + CurrentInstance.clearAll(); + } + + @Test + public void testWithLoggedInUser_userHasRoles() throws IOException { + Mockito.when(request.getUserPrincipal()) + .thenReturn(Mockito.mock(Principal.class)); + Mockito.when(request.isUserInRole(Mockito.anyString())) + .thenReturn(true); + + File generated = tmpDir.newFolder(GENERATED); + File clientFiles = new File(generated, FILE_ROUTES_JSON_NAME); + Files.writeString(clientFiles.toPath(), + MenuRegistryTest.testClientRouteFile); + + List menuOptions = MenuConfiguration.getMenuOptions().get(); + Assert.assertEquals( + "List of menu items has incorrect size. Excluded menu item like /login is not expected.", + 4, menuOptions.size()); + assertOrder(menuOptions, + new String[] { "", "/about", "/hilla", "/hilla/sub" }); + } + + @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(), + MenuRegistryTest.testClientRouteFile); + + RouteConfiguration routeConfiguration = RouteConfiguration + .forRegistry(registry); + Arrays.asList(MenuRegistryTest.MyRoute.class, + MenuRegistryTest.MyInfo.class, + MenuRegistryTest.MyRequiredParamRoute.class, + MenuRegistryTest.MyRequiredAndOptionalParamRoute.class, + MenuRegistryTest.MyOptionalParamRoute.class, + MenuRegistryTest.MyVarargsParamRoute.class) + .forEach(routeConfiguration::setAnnotatedRoute); + + List menuOptions = MenuConfiguration.getMenuOptions().get(); + Assert.assertEquals(5, menuOptions.size()); + assertOrder(menuOptions, new String[] { "", "/home", "/info", "/param", + "/param/varargs" }); + + Map mapMenuItems = menuOptions.stream() + .collect(Collectors.toMap(MenuOption::path, item -> item)); + assertClientRoutes(mapMenuItems, false, false, true); + assertServerRoutes(mapMenuItems); + assertServerRoutesWithParameters(mapMenuItems, true); + } + + @Test + public void getMenuItemsList_assertOrder() { + RouteConfiguration routeConfiguration = RouteConfiguration + .forRegistry(registry); + Arrays.asList(MenuRegistryTest.TestRouteA.class, + MenuRegistryTest.TestRouteB.class, + MenuRegistryTest.TestRouteC.class, + MenuRegistryTest.TestRouteD.class, + MenuRegistryTest.TestRouteDA.class, + MenuRegistryTest.TestRouteDB.class) + .forEach(routeConfiguration::setAnnotatedRoute); + + List menuOptions = MenuConfiguration.getMenuOptions().get(); + ; + Assert.assertEquals(4, menuOptions.size()); + assertOrder(menuOptions, + new String[] { "/d", "/c", "/a", "/b", "/d/a", "/d/b" }); + } + + private void assertOrder(List menuOptions, + String[] expectedOrder) { + for (int i = 0; i < menuOptions.size(); i++) { + Assert.assertEquals(expectedOrder[i], menuOptions.get(i).path()); + } + } + + private void assertClientRoutes(Map menuOptions, + boolean authenticated, boolean hasRole, boolean excludeExpected) { + Assert.assertTrue("Client route '' missing", + menuOptions.containsKey("")); + Assert.assertEquals("Public", menuOptions.get("").title()); + + if (authenticated) { + Assert.assertTrue("Client route 'about' missing", + menuOptions.containsKey("/about")); + Assert.assertEquals("About", menuOptions.get("/about").title()); + + if (hasRole) { + Assert.assertTrue("Client route 'hilla' missing", + menuOptions.containsKey("/hilla")); + Assert.assertEquals("Hilla", menuOptions.get("/hilla").title()); + + Assert.assertTrue("Client child route 'hilla/sub' missing", + menuOptions.containsKey("/hilla/sub")); + Assert.assertEquals("Hilla Sub", + menuOptions.get("/hilla/sub").title()); + } else { + Assert.assertFalse( + "Roles do not match no hilla should be available", + menuOptions.containsKey("/hilla")); + } + } else { + Assert.assertFalse( + "Not authenticated about view should not be available", + menuOptions.containsKey("/about")); + Assert.assertFalse( + "Not authenticated hilla view should not be available", + menuOptions.containsKey("/hilla")); + } + + if (excludeExpected) { + Assert.assertFalse("Client route 'login' should be excluded", + menuOptions.containsKey("/login")); + } else { + Assert.assertTrue("Client route 'login' missing", + menuOptions.containsKey("/login")); + Assert.assertEquals("Login", menuOptions.get("/login").title()); + Assert.assertNull(menuOptions.get("/login").title()); + Assert.assertTrue("Login view should be excluded", + menuOptions.get("/login").exclude()); + } + } + + private void assertServerRoutes(Map menuItems) { + Assert.assertTrue("Server route 'home' missing", + menuItems.containsKey("/home")); + Assert.assertEquals("Home", menuItems.get("/home").title()); + Assert.assertEquals(MenuRegistryTest.MyRoute.class, + menuItems.get("/home").menuClass()); + + Assert.assertTrue("Server route 'info' missing", + menuItems.containsKey("/info")); + Assert.assertEquals("MyInfo", menuItems.get("/info").title()); + Assert.assertEquals(MenuRegistryTest.MyInfo.class, + menuItems.get("/info").menuClass()); + } + + 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").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").exclude()); + } + + Assert.assertTrue( + "Server route with optional parameters '/param' missing", + menuItems.containsKey("/param")); + Assert.assertFalse( + "Server route '/param' should be included in the menu", + menuItems.get("/param").exclude()); + + Assert.assertTrue( + "Server route with optional parameters '/param/varargs' missing", + menuItems.containsKey("/param/varargs")); + Assert.assertFalse( + "Server route '/param/varargs' should be included in the menu", + menuItems.get("/param/varargs").exclude()); + } +} 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 dcfe0487253..14b6424bdbc 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 @@ -457,78 +457,78 @@ private void assertServerRoutesWithParameters( @Tag("div") @Route("home") @Menu(title = "Home") - private static class MyRoute extends Component { + public static class MyRoute extends Component { } @Tag("div") @Route("info") @Menu - private static class MyInfo extends Component { + public static class MyInfo extends Component { } @Tag("div") @Route("param/:param") @Menu - private static class MyRequiredParamRoute extends Component { + public static class MyRequiredParamRoute extends Component { } @Tag("div") @Route("param/:param1/:param2?") @Menu - private static class MyRequiredAndOptionalParamRoute extends Component { + public static class MyRequiredAndOptionalParamRoute extends Component { } @Tag("div") @Route("param/:param1?/:param2?(edit)") @Menu - private static class MyOptionalParamRoute extends Component { + public static class MyOptionalParamRoute extends Component { } @Tag("div") @Route("param/varargs/:param*") @Menu - private static class MyVarargsParamRoute extends Component { + public static class MyVarargsParamRoute extends Component { } @Tag("div") @Route("a") @Menu(order = 1.1) - private static class TestRouteA extends Component { + public static class TestRouteA extends Component { } @Tag("div") @Route("b") @Menu(order = 1.2) - private static class TestRouteB extends Component { + public static class TestRouteB extends Component { } @Tag("div") @Route("c") @Menu(order = 0.1) - private static class TestRouteC extends Component { + public static class TestRouteC extends Component { } @Tag("div") @Route("d") @Menu(order = 0) - private static class TestRouteD extends Component { + public static class TestRouteD extends Component { } @Tag("div") @Route("d/b") - private static class TestRouteDB extends Component { + public static class TestRouteDB extends Component { } @Tag("div") @Route("d/a") - private static class TestRouteDA extends Component { + public static class TestRouteDA extends Component { } /** * Extending class to let us mock the getRouteRegistry method for testing. */ - private static class MockService extends VaadinServletService { + public static class MockService extends VaadinServletService { @Override public RouteRegistry getRouteRegistry() { @@ -541,7 +541,7 @@ public Instantiator getInstantiator() { } } - String testClientRouteFile = """ + public static String testClientRouteFile = """ [ { "route": "",