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 extends Component> 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",