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 9f233bcabeb..b59a659ed43 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 @@ -24,31 +24,7 @@ *

* Only for read as data is immutable. */ -public class MenuData implements Serializable { - - private final String title; - private final Double order; - private final boolean exclude; - private final String icon; - - /** - * Creates a new instance of the menu data. - * - * @param title - * the title of the menu item - * @param order - * the order of the menu item - * @param exclude - * whether the menu item should be excluded - * @param icon - * the icon of the menu item - */ - public MenuData(String title, Double order, boolean exclude, String icon) { - this.title = title; - this.order = order; - this.exclude = exclude; - this.icon = icon; - } +public record MenuData(String title, Double order, boolean exclude, String icon) implements Serializable { /** * Gets the title of the menu item. diff --git a/flow-server/src/main/java/com/vaadin/flow/router/RouteConfiguration.java b/flow-server/src/main/java/com/vaadin/flow/router/RouteConfiguration.java index 0e8c18b1cda..538c26dfb19 100644 --- a/flow-server/src/main/java/com/vaadin/flow/router/RouteConfiguration.java +++ b/flow-server/src/main/java/com/vaadin/flow/router/RouteConfiguration.java @@ -18,13 +18,16 @@ import java.io.Serializable; import java.lang.annotation.Annotation; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Optional; import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.UI; import com.vaadin.flow.internal.CurrentInstance; import com.vaadin.flow.router.internal.AbstractRouteRegistry; +import com.vaadin.flow.router.internal.BeforeEnterHandler; import com.vaadin.flow.router.internal.HasUrlParameterFormat; import com.vaadin.flow.router.internal.PathUtil; import com.vaadin.flow.router.internal.RouteUtil; @@ -32,6 +35,7 @@ import com.vaadin.flow.server.InvalidRouteConfigurationException; import com.vaadin.flow.server.RouteRegistry; import com.vaadin.flow.server.SessionRouteRegistry; +import com.vaadin.flow.server.VaadinRequest; import com.vaadin.flow.server.VaadinService; import com.vaadin.flow.server.VaadinServlet; import com.vaadin.flow.server.VaadinSession; @@ -531,4 +535,41 @@ private final boolean isAnnotatedParameter( return false; } + /** + * Get the {@link RouteData} for all accessible registered navigation + * targets with a menu information. Access checking depends on the active + * {@link VaadinService} and {@link VaadinRequest}. + *

+ * Automatically adds access controls from UI if available. + * + * @return list of accessible menu routes available for handled registry + */ + public List getRegisteredAccessibleMenuRoutes() { + UI ui = UI.getCurrent(); + if (ui != null) { + List accessControls = ui.getInternals() + .getListeners(BeforeEnterHandler.class).stream() + .filter(BeforeEnterListener.class::isInstance) + .map(BeforeEnterListener.class::cast).toList(); + return getRegisteredAccessibleMenuRoutes(accessControls); + } + + return getRegisteredAccessibleMenuRoutes(Collections.emptyList()); + } + + /** + * Get the {@link RouteData} for all accessible registered navigation + * targets with a menu information. Access checking depends on the active + * {@link VaadinService} and {@link VaadinRequest} and the given collection + * of access controls. + * + * @param accessControls + * the access controls to use for checking access + * @return list of accessible menu routes available for handled registry + */ + public List getRegisteredAccessibleMenuRoutes( + Collection accessControls) { + return getHandledRegistry().getRegisteredAccessibleMenuRoutes( + VaadinRequest.getCurrent(), accessControls); + } } 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 510481cbcef..f7fffdb900f 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 @@ -54,6 +54,7 @@ import com.vaadin.flow.server.auth.NavigationAccessControl; import com.vaadin.flow.server.auth.NavigationContext; import com.vaadin.flow.server.auth.ViewAccessChecker; +import com.vaadin.flow.server.menu.MenuRegistry; import com.vaadin.flow.shared.Registration; import static java.util.stream.Collectors.toList; @@ -308,7 +309,10 @@ private void populateRegisteredRoutes(ConfiguredRoutes configuration, MenuData menuData = AnnotationReader .getAnnotationFor(target, Menu.class) - .map(menu -> new MenuData(menu.title(), + .map(menu -> new MenuData( + (menu.title() == null || menu.title().isBlank()) + ? MenuRegistry.getTitle(target) + : menu.title(), (Objects.equals(menu.order(), Double.MIN_VALUE)) ? null : menu.order(), false, menu.icon())) diff --git a/flow-server/src/main/java/com/vaadin/flow/router/internal/ParameterInfo.java b/flow-server/src/main/java/com/vaadin/flow/router/internal/ParameterInfo.java new file mode 100644 index 00000000000..0291badb794 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/router/internal/ParameterInfo.java @@ -0,0 +1,92 @@ +/* + * 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.router.internal; + +import java.io.Serializable; +import java.util.Optional; + +/** + * Define a route url parameter details. + *

+ * For internal use only. May be renamed or removed in a future release. + */ +public class ParameterInfo implements Serializable { + + private final String name; + + private final String template; + + private final boolean optional; + + private final boolean varargs; + + private final String regex; + + public ParameterInfo(String template) { + this.template = template; + + if (!RouteFormat.isParameter(template)) { + throw new IllegalArgumentException( + "The given string is not a parameter template."); + } + + optional = RouteFormat.isOptionalParameter(template); + if (optional) { + template = template.replaceFirst("\\?", ""); + } + varargs = RouteFormat.isVarargsParameter(template); + if (varargs) { + template = template.replaceFirst("\\*", ""); + } + + // Remove : + template = template.substring(1); + + // Extract the template defining the value of the parameter. + final int regexStartIndex = template.indexOf('('); + if (regexStartIndex != -1) { + + name = template.substring(0, regexStartIndex); + + regex = template.substring(regexStartIndex + 1, + template.length() - 1); + } else { + name = template; + regex = null; + } + } + + public String getName() { + return name; + } + + public String getTemplate() { + return template; + } + + public boolean isOptional() { + return optional; + } + + public boolean isVarargs() { + return varargs; + } + + public Optional getRegex() { + return Optional.ofNullable(regex); + } +} \ No newline at end of file diff --git a/flow-server/src/main/java/com/vaadin/flow/router/internal/RouteFormat.java b/flow-server/src/main/java/com/vaadin/flow/router/internal/RouteFormat.java index 8053c4d1d6c..33bf6e3ed39 100644 --- a/flow-server/src/main/java/com/vaadin/flow/router/internal/RouteFormat.java +++ b/flow-server/src/main/java/com/vaadin/flow/router/internal/RouteFormat.java @@ -173,74 +173,4 @@ static Optional formatSegmentRegex(RouteSegment segment, } } - /** - * Define a route url parameter details. - */ - static class ParameterInfo implements Serializable { - - private final String name; - - private final String template; - - private final boolean optional; - - private final boolean varargs; - - private final String regex; - - ParameterInfo(String template) { - this.template = template; - - if (!isParameter(template)) { - throw new IllegalArgumentException( - "The given string is not a parameter template."); - } - - optional = isOptionalParameter(template); - if (optional) { - template = template.replaceFirst("\\?", ""); - } - varargs = isVarargsParameter(template); - if (varargs) { - template = template.replaceFirst("\\*", ""); - } - - // Remove : - template = template.substring(1); - - // Extract the template defining the value of the parameter. - final int regexStartIndex = template.indexOf('('); - if (regexStartIndex != -1) { - - name = template.substring(0, regexStartIndex); - - regex = template.substring(regexStartIndex + 1, - template.length() - 1); - } else { - name = template; - regex = null; - } - } - - public String getName() { - return name; - } - - public String getTemplate() { - return template; - } - - public boolean isOptional() { - return optional; - } - - public boolean isVarargs() { - return varargs; - } - - public Optional getRegex() { - return Optional.ofNullable(regex); - } - } - } diff --git a/flow-server/src/main/java/com/vaadin/flow/router/internal/RouteSegment.java b/flow-server/src/main/java/com/vaadin/flow/router/internal/RouteSegment.java index 903666ad5ae..547fbcedf0b 100644 --- a/flow-server/src/main/java/com/vaadin/flow/router/internal/RouteSegment.java +++ b/flow-server/src/main/java/com/vaadin/flow/router/internal/RouteSegment.java @@ -65,7 +65,7 @@ final class RouteSegment implements Serializable { /** * Parameter details. */ - private RouteFormat.ParameterInfo info; + private ParameterInfo info; /** * Parameter matching regex. @@ -118,7 +118,7 @@ private RouteSegment(String segmentTemplate, boolean isRoot) { this.isRoot = isRoot; if (RouteFormat.isParameter(segmentTemplate)) { - info = new RouteFormat.ParameterInfo(segmentTemplate); + info = new ParameterInfo(segmentTemplate); getRegex().ifPresent(s -> pattern = Pattern.compile(s)); diff --git a/flow-server/src/main/java/com/vaadin/flow/server/menu/AvailableViewInfo.java b/flow-server/src/main/java/com/vaadin/flow/server/menu/AvailableViewInfo.java new file mode 100644 index 00000000000..1df18f5d5af --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/server/menu/AvailableViewInfo.java @@ -0,0 +1,85 @@ +/* + * 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.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import com.vaadin.flow.router.MenuData; + +/** + * Represents a view configuration for use with a menu. + * + * @param title + * @param rolesAllowed + * @param loginRequired + * @param route + * @param lazy + * @param register + * @param menu + * @param children + * @param routeParameters + */ +public record AvailableViewInfo(String title, String[] rolesAllowed, + boolean loginRequired, String route, boolean lazy, + boolean register, MenuData menu, + List children, @JsonProperty( + "params") Map routeParameters) implements Serializable { + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } else if (o == null || getClass() != o.getClass()) { + return false; + } + final AvailableViewInfo that = (AvailableViewInfo) o; + return Objects.equals(title, that.title) + && Arrays.equals(rolesAllowed, that.rolesAllowed) + && Objects.equals(loginRequired, that.loginRequired) + && Objects.equals(route, that.route) + && Objects.equals(lazy, that.lazy) + && Objects.equals(register, that.register) + && Objects.equals(menu, that.menu) + && Objects.equals(routeParameters, that.routeParameters); + } + + @Override + public int hashCode() { + int result = Objects.hash(title, loginRequired, route, lazy, register, menu, routeParameters); + result = 31 * result + Arrays.hashCode(rolesAllowed); + return result; + } + + @Override + public String toString() { + return "AvailableViewInfo{" + "title='" + title + + '\'' + ", rolesAllowed=" + Arrays.toString(rolesAllowed) + + ", loginRequired=" + loginRequired + + ", route='" + route + '\'' + + ", lazy=" + lazy + + ", register=" + register + + ", menu=" + menu + + ", routeParameters=" + routeParameters + '}'; + } + +} 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 new file mode 100644 index 00000000000..a20d68d7150 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/server/menu/MenuRegistry.java @@ -0,0 +1,261 @@ +/* + * 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.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.file.Path; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.LoggerFactory; + +import com.vaadin.flow.component.Component; +import com.vaadin.flow.function.DeploymentConfiguration; +import com.vaadin.flow.router.BeforeEnterListener; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.RouteConfiguration; +import com.vaadin.flow.router.RouteData; +import com.vaadin.flow.router.internal.ParameterInfo; +import com.vaadin.flow.server.VaadinService; +import com.vaadin.flow.server.frontend.FrontendUtils; + +import static com.vaadin.flow.server.frontend.FrontendUtils.GENERATED; + +/** + * Registry for getting the menu items available for the current state of the + * application. + * + * Only returns views that are accessible at the moment and leaves out routes + * that require path parameters. + */ +public class MenuRegistry { + + /** + * Collect views with menu annotation for automatic menu population. All + * client views are collected and any accessible server views. + * + * @return routes with view information + */ + public static Map collectMenuItems() { + return new MenuRegistry().getMenuItems(); + } + + /** + * Collect views with menu annotation for automatic menu population. All + * client views are collected and any accessible server views. + * + * @return routes with view information + */ + public Map getMenuItems() { + RouteConfiguration routeConfiguration = RouteConfiguration + .forApplicationScope(); + + Map menuRoutes = new HashMap<>(); + + menuRoutes.putAll(collectClientMenuItems()); + + collectAndAddServerMenuItems(routeConfiguration, menuRoutes); + + return menuRoutes; + } + + /** + * Collect all active and accessible server menu items. + * + * @param routeConfiguration + * routeConfiguration to use + * @param menuRoutes + * map to add route data into + */ + public static void collectAndAddServerMenuItems( + RouteConfiguration routeConfiguration, + Map menuRoutes) { + List registeredAccessibleMenuRoutes = routeConfiguration + .getRegisteredAccessibleMenuRoutes(); + + addMenuRoutes(menuRoutes, registeredAccessibleMenuRoutes); + } + + /** + * Collect all active and accessible server menu items. + * + * @param routeConfiguration + * routeConfiguration to use + * @param accessControls + * extra access controls if needed + * @param menuRoutes + * map to add route data into + */ + public static void collectAndAddServerMenuItems( + RouteConfiguration routeConfiguration, + List accessControls, + Map menuRoutes) { + List registeredAccessibleMenuRoutes = routeConfiguration + .getRegisteredAccessibleMenuRoutes(accessControls); + + addMenuRoutes(menuRoutes, registeredAccessibleMenuRoutes); + } + + private static void addMenuRoutes(Map menuRoutes, + List registeredAccessibleMenuRoutes) { + for (RouteData route : registeredAccessibleMenuRoutes) { + String title = getTitle(route.getNavigationTarget()); + Map parameters = getParameters(route); + menuRoutes.put("/" + route.getTemplate(), + new AvailableViewInfo(title, null, false, + "/" + route.getTemplate(), false, false, + route.getMenuData(), null, parameters)); + } + } + + /** + * Get page title for route or simple name if no PageTitle is set. + * + * @param target + * route class to get title for + * @return title to use for route + */ + public static String getTitle(Class target) { + return Optional.ofNullable(target.getAnnotation(PageTitle.class)) + .map(PageTitle::value).orElse(target.getSimpleName()); + } + + /** + * Map route parameters to {@link RouteParamType}. + * + * @param route + * route to get params for + * @return RouteParamType for parameter + */ + private static Map getParameters(RouteData route) { + Map parameters = new HashMap<>(); + + route.getRouteParameters().forEach((paramTemplate, param) -> { + ParameterInfo parameterInfo = new ParameterInfo( + param.getTemplate()); + parameters.put(param.getTemplate(), + RouteParamType.getType(parameterInfo)); + }); + return parameters; + } + + private Map collectClientMenuItems() { + List clientRoutes = FrontendUtils.getClientRoutes(); + + if (clientRoutes.isEmpty()) { + // No client routes no need to do more work here. + return Collections.emptyMap(); + } + + DeploymentConfiguration deploymentConfiguration = VaadinService + .getCurrent().getDeploymentConfiguration(); + URL viewsJsonAsResource = getViewsJsonAsResource( + deploymentConfiguration); + if (viewsJsonAsResource == null) { + LoggerFactory.getLogger(MenuRegistry.class).debug( + "No {} found under {} directory. Skipping client route registration.", + FILE_ROUTES_JSON_NAME, + deploymentConfiguration.isProductionMode() + ? "'META-INF/VAADIN'" + : "'frontend/generated'"); + return Collections.emptyMap(); + } + + Map configurations = new HashMap<>(); + + try (InputStream source = viewsJsonAsResource.openStream()) { + if (source != null) { + ObjectMapper mapper = new ObjectMapper(); + mapper.readValue(source, + new TypeReference>() { + }).forEach(clientViewConfig -> collectClientViews("", + clientViewConfig, configurations)); + } + } catch (IOException e) { + LoggerFactory.getLogger(MenuRegistry.class).warn( + "Failed load {} from {}", FILE_ROUTES_JSON_NAME, + viewsJsonAsResource.getPath(), e); + } + + for (String route : new HashSet<>(configurations.keySet())) { + if (!clientRoutes.contains(route.replaceFirst("/", ""))) { + configurations.remove(route); + } + } + + return configurations; + } + + private void collectClientViews(String basePath, + AvailableViewInfo viewConfig, + Map configurations) { + String path = viewConfig.route() == null || viewConfig.route().isEmpty() + ? basePath + : basePath + '/' + viewConfig.route(); + configurations.put(path, viewConfig); + if (viewConfig.children() != null) { + viewConfig.children().forEach( + child -> collectClientViews(path, child, configurations)); + } + } + + public static final String FILE_ROUTES_JSON_NAME = "file-routes.json"; + public static final String FILE_ROUTES_JSON_PROD_PATH = "/META-INF/VAADIN/" + + FILE_ROUTES_JSON_NAME; + + private URL getViewsJsonAsResource( + DeploymentConfiguration deploymentConfiguration) { + var isProductionMode = deploymentConfiguration.isProductionMode(); + if (isProductionMode) { + return getClassLoader().getResource(FILE_ROUTES_JSON_PROD_PATH); + } + try { + Path fileRoutes = deploymentConfiguration.getFrontendFolder() + .toPath().resolve(GENERATED).resolve(FILE_ROUTES_JSON_NAME); + if (fileRoutes.toFile().exists()) { + return fileRoutes.toUri().toURL(); + } + return null; + } catch (MalformedURLException e) { + LoggerFactory.getLogger(MenuRegistry.class).warn( + "Failed to find {} under frontend/generated", + FILE_ROUTES_JSON_NAME, e); + throw new RuntimeException(e); + } + } + + /** + * Get the ClassLoader. + *

+ * Note! package protected for testing. + * + * @return ClassLoader + */ + ClassLoader getClassLoader() { + return Thread.currentThread().getContextClassLoader(); + } + +} diff --git a/flow-server/src/main/java/com/vaadin/flow/server/menu/RouteParamType.java b/flow-server/src/main/java/com/vaadin/flow/server/menu/RouteParamType.java new file mode 100644 index 00000000000..5e246e425f6 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/server/menu/RouteParamType.java @@ -0,0 +1,37 @@ +/* + * 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 com.fasterxml.jackson.annotation.JsonProperty; + +import com.vaadin.flow.router.internal.ParameterInfo; + +public enum RouteParamType { + // @formatter:off + @JsonProperty("req") REQUIRED, + @JsonProperty("opt") OPTIONAL, + @JsonProperty("*") WILDCARD; + // @formatter:on + + public static RouteParamType getType(ParameterInfo parameterInfo) { + if (parameterInfo.isVarargs()) + return WILDCARD; + if (parameterInfo.isOptional()) + return OPTIONAL; + return REQUIRED; + } +} 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 new file mode 100644 index 00000000000..a7e71ee4acf --- /dev/null +++ b/flow-server/src/test/java/com/vaadin/flow/server/menu/MenuRegistryTest.java @@ -0,0 +1,337 @@ +/* + * 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.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; + +import jakarta.servlet.ServletContext; +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.component.Component; +import com.vaadin.flow.component.Tag; +import com.vaadin.flow.di.DefaultInstantiator; +import com.vaadin.flow.di.Instantiator; +import com.vaadin.flow.di.Lookup; +import com.vaadin.flow.function.DeploymentConfiguration; +import com.vaadin.flow.internal.CurrentInstance; +import com.vaadin.flow.router.Menu; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.router.RouteConfiguration; +import com.vaadin.flow.router.internal.ClientRoutesProvider; +import com.vaadin.flow.server.MockServletContext; +import com.vaadin.flow.server.MockVaadinContext; +import com.vaadin.flow.server.MockVaadinSession; +import com.vaadin.flow.server.RouteRegistry; +import com.vaadin.flow.server.VaadinRequest; +import com.vaadin.flow.server.VaadinService; +import com.vaadin.flow.server.VaadinServletContext; +import com.vaadin.flow.server.VaadinServletService; +import com.vaadin.flow.server.VaadinSession; +import com.vaadin.flow.server.startup.ApplicationRouteRegistry; + +import static com.vaadin.flow.server.menu.MenuRegistry.FILE_ROUTES_JSON_NAME; +import static com.vaadin.flow.server.menu.MenuRegistry.FILE_ROUTES_JSON_PROD_PATH; + +@NotThreadSafe +public class MenuRegistryTest { + @Rule + public TemporaryFolder tmpDir = new TemporaryFolder(); + + private ApplicationRouteRegistry registry; + @Mock + private MockService vaadinService; + private VaadinSession session; + private ServletContext servletContext; + private VaadinServletContext vaadinContext; + @Mock + private DeploymentConfiguration deploymentConfiguration; + @Mock + private ClientRoutesProvider provider; + @Mock + private VaadinRequest request; + + private AutoCloseable closeable; + + @Before + public void init() { + closeable = MockitoAnnotations.openMocks(this); + servletContext = new MockServletContext(); + vaadinContext = new MockVaadinContext(servletContext); + Lookup lookup = vaadinContext.getAttribute(Lookup.class); + + Mockito.when(lookup.lookupAll(ClientRoutesProvider.class)) + .thenReturn(Collections.singleton(provider)); + + Mockito.when(provider.getClientRoutes()) + .thenReturn(Arrays.asList("", "about", "hilla", "login")); + + 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); + } + + @After + public void cleanup() throws Exception { + closeable.close(); + CurrentInstance.clearAll(); + } + + @Test + public void getMenuItemsContainsExpectedClientPaths() throws IOException { + File generated = tmpDir.newFolder("generated"); + File clientFiles = new File(generated, FILE_ROUTES_JSON_NAME); + Files.writeString(clientFiles.toPath(), testClientRouteFile); + + Map menuItems = new MenuRegistry() + .getMenuItems(); + + Assert.assertEquals(4, menuItems.size()); + asssertClientRoutes(menuItems); + } + + @Test + public void productionMode_getMenuItemsContainsExpectedClientPaths() + throws IOException { + Mockito.when(deploymentConfiguration.isProductionMode()) + .thenReturn(true); + + tmpDir.newFolder("META-INF", "VAADIN"); + File clientFiles = new File(tmpDir.getRoot(), + FILE_ROUTES_JSON_PROD_PATH); + Files.writeString(clientFiles.toPath(), testClientRouteFile); + + ClassLoader mockClassLoader = Mockito.mock(ClassLoader.class); + Mockito.when(mockClassLoader.getResource(FILE_ROUTES_JSON_PROD_PATH)) + .thenReturn(clientFiles.toURI().toURL()); + + Map menuItems = new MenuRegistry() { + @Override + ClassLoader getClassLoader() { + return mockClassLoader; + } + }.getMenuItems(); + + Assert.assertEquals(4, menuItems.size()); + asssertClientRoutes(menuItems); + } + + @Test + public void getMenuItemsContainsExpectedServerPaths() { + Mockito.when(request.getService()).thenReturn(vaadinService); + CurrentInstance.set(VaadinRequest.class, request); + RouteConfiguration routeConfiguration = RouteConfiguration + .forRegistry(registry); + Arrays.asList(MyRoute.class, MyInfo.class) + .forEach(routeConfiguration::setAnnotatedRoute); + + Map menuItems = new MenuRegistry() + .getMenuItems(); + + Assert.assertEquals(2, menuItems.size()); + assertServerRoutes(menuItems); + } + + @Test + public void getMenuItemsContainBothClientAndServerPaths() + throws IOException { + File generated = tmpDir.newFolder("generated"); + File clientFiles = new File(generated, FILE_ROUTES_JSON_NAME); + Files.writeString(clientFiles.toPath(), testClientRouteFile); + + Mockito.when(request.getService()).thenReturn(vaadinService); + CurrentInstance.set(VaadinRequest.class, request); + RouteConfiguration routeConfiguration = RouteConfiguration + .forRegistry(registry); + Arrays.asList(MyRoute.class, MyInfo.class) + .forEach(routeConfiguration::setAnnotatedRoute); + + Map menuItems = new MenuRegistry() + .getMenuItems(); + + Assert.assertEquals(6, menuItems.size()); + asssertClientRoutes(menuItems); + assertServerRoutes(menuItems); + } + + @Test + public void collectMenuItems_returnsCorrecPaths() throws IOException { + File generated = tmpDir.newFolder("generated"); + File clientFiles = new File(generated, FILE_ROUTES_JSON_NAME); + Files.writeString(clientFiles.toPath(), testClientRouteFile); + + Mockito.when(request.getService()).thenReturn(vaadinService); + CurrentInstance.set(VaadinRequest.class, request); + RouteConfiguration routeConfiguration = RouteConfiguration + .forRegistry(registry); + Arrays.asList(MyRoute.class, MyInfo.class) + .forEach(routeConfiguration::setAnnotatedRoute); + + Map menuItems = MenuRegistry + .collectMenuItems(); + + Assert.assertEquals(6, menuItems.size()); + asssertClientRoutes(menuItems); + assertServerRoutes(menuItems); + } + + private void asssertClientRoutes(Map menuItems) { + Assert.assertTrue("Client route '' missing", menuItems.containsKey("")); + Assert.assertEquals("Public", menuItems.get("").title()); + Assert.assertNull("Public doesn't contain specific menu data", + menuItems.get("").menu()); + + Assert.assertTrue("Client route 'about' view missing", + menuItems.containsKey("/about")); + Assert.assertEquals("About", menuItems.get("/about").title()); + Assert.assertTrue("Login should be required", + menuItems.get("/about").loginRequired()); + Assert.assertNull("About doesn't contain specific menu data", + menuItems.get("/about").menu()); + + Assert.assertTrue("Client route 'hilla' view missing", + menuItems.containsKey("/hilla")); + Assert.assertEquals("Hilla", menuItems.get("/hilla").title()); + Assert.assertTrue("Login should be required", + menuItems.get("/hilla").loginRequired()); + Assert.assertArrayEquals("Faulty roles fo hilla", + new String[] { "ROLE_USER" }, + menuItems.get("/hilla").rolesAllowed()); + Assert.assertNull("Hilla doesn't contain specific menu data", + menuItems.get("/hilla").menu()); + + Assert.assertTrue("Client route 'login' view 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) { + Assert.assertTrue("Server route 'home' missing", + menuItems.containsKey("/home")); + Assert.assertEquals("MyRoute", menuItems.get("/home").title()); + Assert.assertEquals("Home", menuItems.get("/home").menu().title()); + + Assert.assertTrue("Server route 'info' missing", + menuItems.containsKey("/info")); + Assert.assertEquals("MyInfo", menuItems.get("/info").title()); + Assert.assertEquals("MyInfo", menuItems.get("/info").menu().title()); + } + + @Tag("div") + @Route("home") + @Menu(title = "Home") + private static class MyRoute extends Component { + } + + @Tag("div") + @Route("info") + @Menu + private static class MyInfo extends Component { + } + + /** + * Extending class to let us mock the getRouteRegistry method for testing. + */ + private static class MockService extends VaadinServletService { + + @Override + public RouteRegistry getRouteRegistry() { + return super.getRouteRegistry(); + } + + @Override + public Instantiator getInstantiator() { + return new DefaultInstantiator(this); + } + } + + String testClientRouteFile = """ + [ + { + "route": "", + "params": {}, + "title": "Layout", + "children": [ + { + "route": "", + "params": {}, + "title": "Public" + }, + { + "route": "about", + "loginRequired": true, + "params": {}, + "title": "About" + }, + { + "route": "hilla", + "loginRequired": true, + "rolesAllowed": [ + "ROLE_USER" + ], + "params": {}, + "title": "Hilla" + }, + { + "route": "login", + "menu": { + "exclude": true + }, + "params": {}, + "title": "Login" + } + ] + } + ] + """; +} \ No newline at end of file diff --git a/flow-test-generic/src/main/java/com/vaadin/flow/testutil/ClassesSerializableTest.java b/flow-test-generic/src/main/java/com/vaadin/flow/testutil/ClassesSerializableTest.java index 22fbfeed57c..56cbaa37115 100644 --- a/flow-test-generic/src/main/java/com/vaadin/flow/testutil/ClassesSerializableTest.java +++ b/flow-test-generic/src/main/java/com/vaadin/flow/testutil/ClassesSerializableTest.java @@ -161,6 +161,7 @@ protected Stream getExcludedPatterns() { "com\\.vaadin\\.flow\\.server\\.communication\\.PushHandler(\\$.*)?", "com\\.vaadin\\.flow\\.server\\.communication\\.PushRequestHandler(\\$.*)?", "com\\.vaadin\\.flow\\.server\\.communication\\.JavaScriptBootstrapHandler(\\$.*)?", + "com\\.vaadin\\.flow\\.server\\.menu\\.MenuRegistry(\\$.*)?", "com\\.vaadin\\.flow\\.templatemodel\\.PathLookup", "com\\.vaadin\\.flow\\.server\\.startup\\.ErrorNavigationTargetInitializer", "com\\.vaadin\\.flow\\.server\\.startup\\.RouteRegistryInitializer",