From 48ab83e02d9519098e25f2849dbc77b5006828e6 Mon Sep 17 00:00:00 2001 From: Vaadin Bot Date: Wed, 9 Oct 2024 13:34:41 +0200 Subject: [PATCH] feat: Add a helper method for menu's page title (#20165) (#20194) Adds a new static helper method that gives a page title for views shown using menu. Fixes #20158 Co-authored-by: Mikhail Shabarov <61410877+mshabarov@users.noreply.github.com> --- .../flow/internal/menu/MenuRegistry.java | 11 +- .../AbstractNavigationStateRenderer.java | 6 +- .../flow/router/internal/RouteUtil.java | 18 ++ .../flow/server/menu/MenuConfiguration.java | 112 +++++++- .../server/menu/MenuConfigurationTest.java | 269 ++++++++++++++++++ 5 files changed, 404 insertions(+), 12 deletions(-) diff --git a/flow-server/src/main/java/com/vaadin/flow/internal/menu/MenuRegistry.java b/flow-server/src/main/java/com/vaadin/flow/internal/menu/MenuRegistry.java index 0186c50c417..76eac093ea1 100644 --- a/flow-server/src/main/java/com/vaadin/flow/internal/menu/MenuRegistry.java +++ b/flow-server/src/main/java/com/vaadin/flow/internal/menu/MenuRegistry.java @@ -80,7 +80,7 @@ public class MenuRegistry { * @return routes with view information */ public static Map collectMenuItems() { - Map menuRoutes = new MenuRegistry() + Map menuRoutes = MenuRegistry .getMenuItems(true); menuRoutes.entrySet() .removeIf(entry -> Optional.ofNullable(entry.getValue()) @@ -132,15 +132,14 @@ public static List collectMenuItemsList(Locale locale) { * {@code true} to filter routes by authentication status * @return routes with view information */ - public Map getMenuItems( + public static Map getMenuItems( boolean filterClientViews) { RouteConfiguration routeConfiguration = RouteConfiguration .forApplicationScope(); - Map menuRoutes = new HashMap<>(); - - menuRoutes.putAll(collectClientMenuItems(filterClientViews, - VaadinService.getCurrent().getDeploymentConfiguration())); + Map menuRoutes = new HashMap<>( + collectClientMenuItems(filterClientViews, VaadinService + .getCurrent().getDeploymentConfiguration())); collectAndAddServerMenuItems(routeConfiguration, menuRoutes); diff --git a/flow-server/src/main/java/com/vaadin/flow/router/internal/AbstractNavigationStateRenderer.java b/flow-server/src/main/java/com/vaadin/flow/router/internal/AbstractNavigationStateRenderer.java index 747fc23b4b7..393acc47286 100644 --- a/flow-server/src/main/java/com/vaadin/flow/router/internal/AbstractNavigationStateRenderer.java +++ b/flow-server/src/main/java/com/vaadin/flow/router/internal/AbstractNavigationStateRenderer.java @@ -960,11 +960,7 @@ private static void updatePageTitle(NavigationEvent navigationEvent, routeTarget).map(PageTitle::value).orElse(""); // check for HasDynamicTitle in current router targets chain - String title = navigationEvent.getUI().getInternals() - .getActiveRouterTargetsChain().stream() - .filter(HasDynamicTitle.class::isInstance) - .map(tc -> ((HasDynamicTitle) tc).getPageTitle()) - .filter(Objects::nonNull).findFirst() + String title = RouteUtil.getDynamicTitle(navigationEvent.getUI()) .orElseGet(() -> Optional .ofNullable( MenuRegistry.getClientRoutes(true).get(route)) diff --git a/flow-server/src/main/java/com/vaadin/flow/router/internal/RouteUtil.java b/flow-server/src/main/java/com/vaadin/flow/router/internal/RouteUtil.java index 3949a4af6fd..11d7d77c8aa 100644 --- a/flow-server/src/main/java/com/vaadin/flow/router/internal/RouteUtil.java +++ b/flow-server/src/main/java/com/vaadin/flow/router/internal/RouteUtil.java @@ -37,6 +37,7 @@ import com.vaadin.flow.di.Lookup; import com.vaadin.flow.internal.AnnotationReader; import com.vaadin.flow.router.DefaultRoutePathProvider; +import com.vaadin.flow.router.HasDynamicTitle; import com.vaadin.flow.router.Layout; import com.vaadin.flow.router.ParentLayout; import com.vaadin.flow.router.Route; @@ -581,4 +582,21 @@ public static boolean isAutolayoutEnabled(Class target, String path) { && target.getAnnotation(Route.class).layout().equals(UI.class); } + + /** + * Get optional dynamic page title from the active router targets chain of a + * given UI instance. + * + * @param ui + * instance of UI, not {@code null} + * @return dynamic page title found in the routes chain, or empty optional + * if no implementor of {@link HasDynamicTitle} was found + */ + public static Optional getDynamicTitle(UI ui) { + return Objects.requireNonNull(ui).getInternals() + .getActiveRouterTargetsChain().stream() + .filter(HasDynamicTitle.class::isInstance) + .map(element -> ((HasDynamicTitle) element).getPageTitle()) + .filter(Objects::nonNull).findFirst(); + } } diff --git a/flow-server/src/main/java/com/vaadin/flow/server/menu/MenuConfiguration.java b/flow-server/src/main/java/com/vaadin/flow/server/menu/MenuConfiguration.java index b51b1f336b8..842c7a86d9c 100644 --- a/flow-server/src/main/java/com/vaadin/flow/server/menu/MenuConfiguration.java +++ b/flow-server/src/main/java/com/vaadin/flow/server/menu/MenuConfiguration.java @@ -16,11 +16,19 @@ package com.vaadin.flow.server.menu; -import java.io.Serializable; import java.util.List; import java.util.Locale; +import java.util.Objects; +import java.util.Optional; +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.Tag; +import com.vaadin.flow.component.UI; import com.vaadin.flow.internal.menu.MenuRegistry; +import com.vaadin.flow.router.HasDynamicTitle; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.internal.PathUtil; +import com.vaadin.flow.router.internal.RouteUtil; /** * Menu configuration helper class to retrieve available menu entries for @@ -55,6 +63,108 @@ public static List getMenuEntries(Locale locale) { .map(MenuConfiguration::createMenuEntry).toList(); } + /** + * Retrieves the page header of the currently shown view. Can be used in + * Flow main layouts to render a page header. + *

+ * Attempts to retrieve header from the following sources: + *

    + *
  • from {@code ViewConfig.title} of the client-side views;
  • + *
  • from {@link HasDynamicTitle#getPageTitle()} if present, then from + * {@link PageTitle} value of the server-side route
  • + *
+ *

+ * For server-side routes it falls back to route's Java class name, if a + * non-null {@code content} is given. For client-side views it falls back to + * the React element's function name, if a page header couldn't be retrieved + * from the {@code ViewConfig}. + *

+ * Use {@link #getPageHeader()} method, if a content object is not + * available. + * + * @param content + * as a {@link Component} class that represents a content in + * layout, can be {@code null}, if unavailable. + * @return optional page header for layout + */ + public static Optional getPageHeader(Component content) { + if (isServerSideContent(content)) { + UI ui = UI.getCurrent(); + if (ui != null) { + Optional maybeTitle = RouteUtil.getDynamicTitle(ui); + if (maybeTitle.isPresent()) { + return maybeTitle; + } + } + + return Optional.of(MenuRegistry.getTitle(content.getClass())); + } + return getPageHeaderFromMenuItems(); + } + + /** + * Retrieves the page header of the currently shown view. Can be used in + * Flow main layouts to render a page header. + *

+ * Attempts to retrieve header from the following sources: + *

    + *
  • from {@code ViewConfig.title} of the client-side views;
  • + *
  • from {@link HasDynamicTitle#getPageTitle()} if present, then from + * {@link PageTitle} value of the server-side route
  • + *
+ *

+ * For server-side routes it falls back to route's Java class name. For + * client-side views it falls back to the React element's function name, if + * a page header couldn't be retrieved from the {@code ViewConfig}. + *

+ * Note that the possible sources of page header are limited to only + * available views in automatic menu configuration. If a route has a + * mandatory route parameters or has a route template, then it won't be used + * as a possible header source, even if it's shown. + *

+ * Use {@link #getPageHeader(Component)} if content object is available, + * e.g. in {@link com.vaadin.flow.router.RouterLayout} based layouts. + * + * @return optional page header for layout + */ + public static Optional getPageHeader() { + return getPageHeader(null); + } + + private static boolean isServerSideContent(Component content) { + if (content == null) { + return false; + } else { + Tag tag = content.getClass().getAnnotation(Tag.class); + // client-side view if it is wrapped into ReactRouterOutlet + return tag == null || !"react-router-outlet".equals(tag.value()); + } + } + + private static Optional getPageHeaderFromMenuItems() { + UI ui = UI.getCurrent(); + if (ui != null) { + // Flow main layout + client views case: + // layout may have dynamic title + Optional maybeTitle = RouteUtil.getDynamicTitle(ui); + if (maybeTitle.isPresent()) { + return maybeTitle; + } + + String activeLocation = PathUtil.trimPath( + ui.getInternals().getActiveViewLocation().getPath()); + + List menuItems = MenuRegistry.getMenuItems(false) + .values().stream().toList(); + + return menuItems.stream() + .filter(menuItem -> PathUtil.trimPath(menuItem.route()) + .equals(activeLocation)) + .map(AvailableViewInfo::title).findFirst(); + } + return Optional.empty(); + } + private static MenuEntry createMenuEntry(AvailableViewInfo viewInfo) { if (viewInfo.menu() == null) { return new MenuEntry(viewInfo.route(), viewInfo.title(), null, 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 index e1f2d5fe3e8..4f3aa585964 100644 --- 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 @@ -22,8 +22,10 @@ import java.nio.file.Files; import java.security.Principal; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.stream.Collectors; import net.jcip.annotations.NotThreadSafe; @@ -37,9 +39,23 @@ import org.mockito.Mockito; import org.mockito.MockitoAnnotations; +import com.vaadin.flow.component.Component; +import com.vaadin.flow.component.HasElement; +import com.vaadin.flow.component.Tag; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.internal.UIInternals; import com.vaadin.flow.di.DefaultInstantiator; +import com.vaadin.flow.dom.Element; import com.vaadin.flow.function.DeploymentConfiguration; import com.vaadin.flow.internal.CurrentInstance; +import com.vaadin.flow.router.BeforeEvent; +import com.vaadin.flow.router.HasDynamicTitle; +import com.vaadin.flow.router.HasUrlParameter; +import com.vaadin.flow.router.Location; +import com.vaadin.flow.router.Menu; +import com.vaadin.flow.router.OptionalParameter; +import com.vaadin.flow.router.PageTitle; +import com.vaadin.flow.router.Route; import com.vaadin.flow.router.RouteConfiguration; import com.vaadin.flow.server.MockServletContext; import com.vaadin.flow.server.MockVaadinContext; @@ -180,6 +196,179 @@ public void getMenuItemsList_assertOrder() { new String[] { "/d", "/c", "/a", "/b", "/d/a", "/d/b" }); } + @Test + public void getPageHeader_serverSideRoutes_withContentComponent_pageHeadersFromAnnotationAndName() { + RouteConfiguration routeConfiguration = RouteConfiguration + .forRegistry(registry); + Arrays.asList(NormalRoute.class, NormalRouteWithPageTitle.class, + MandatoryParameterRouteWithPageTitle.class) + .forEach(routeConfiguration::setAnnotatedRoute); + + UI mockUi = Mockito.mock(UI.class); + UIInternals uiInternals = Mockito.mock(UIInternals.class); + Location location = Mockito.mock(Location.class); + Mockito.when(mockUi.getInternals()).thenReturn(uiInternals); + Mockito.when(uiInternals.getActiveViewLocation()).thenReturn(location); + Mockito.when(uiInternals.getActiveRouterTargetsChain()) + .thenReturn(Collections.emptyList()); + + final UI currentUi = UI.getCurrent(); + + try { + UI.setCurrent(mockUi); + + Mockito.when(location.getPath()).thenReturn("/normal-route"); + Optional header = MenuConfiguration + .getPageHeader(new NormalRoute()); + Assert.assertTrue(header.isPresent()); + // directly from class name + Assert.assertEquals("NormalRoute", header.get()); + + Mockito.when(location.getPath()) + .thenReturn("normal-route-with-page-title"); + header = MenuConfiguration + .getPageHeader(new NormalRouteWithPageTitle()); + Assert.assertTrue(header.isPresent()); + // directly from @PageTitle + Assert.assertEquals("My Normal Route", header.get()); + + Mockito.when(uiInternals.getActiveRouterTargetsChain()) + .thenReturn(List.of(new RouteOrLayoutWithDynamicTitle())); + header = MenuConfiguration.getPageHeader(new NormalRoute()); + Assert.assertTrue(header.isPresent()); + // from HasDynamicTitle + Assert.assertEquals("My Route with dynamic title", header.get()); + Mockito.when(uiInternals.getActiveRouterTargetsChain()) + .thenReturn(Collections.emptyList()); + + Mockito.when(location.getPath()) + .thenReturn("mandatory-parameter-route"); + header = MenuConfiguration + .getPageHeader(new MandatoryParameterRouteWithPageTitle()); + Assert.assertTrue(header.isPresent()); + // directly from class name + Assert.assertEquals("MandatoryParameterRouteWithPageTitle", + header.get()); + + } finally { + UI.setCurrent(currentUi); + } + } + + @Test + public void getPageHeader_serverSideRoutes_noContentComponent_pageHeadersOnlyForMenuEntries() { + RouteConfiguration routeConfiguration = RouteConfiguration + .forRegistry(registry); + Arrays.asList(NormalRoute.class, NormalRouteWithPageTitle.class, + OptionalParameterRouteWithPageTitle.class, + MandatoryParameterRouteWithPageTitle.class) + .forEach(routeConfiguration::setAnnotatedRoute); + + UI mockUi = Mockito.mock(UI.class); + UIInternals uiInternals = Mockito.mock(UIInternals.class); + Location location = Mockito.mock(Location.class); + Mockito.when(mockUi.getInternals()).thenReturn(uiInternals); + Mockito.when(uiInternals.getActiveViewLocation()).thenReturn(location); + + final UI currentUi = UI.getCurrent(); + + try { + UI.setCurrent(mockUi); + + Mockito.when(location.getPath()).thenReturn("/normal-route"); + Optional header = MenuConfiguration.getPageHeader(); + Assert.assertTrue(header.isPresent()); + // from class name, from menu config + Assert.assertEquals("NormalRoute", header.get()); + + Mockito.when(location.getPath()) + .thenReturn("normal-route-with-page-title"); + header = MenuConfiguration.getPageHeader(); + // no @Menu annotation -> no available view info + Assert.assertFalse(header.isPresent()); + + Mockito.when(location.getPath()) + .thenReturn("mandatory-parameter-route"); + header = MenuConfiguration.getPageHeader(); + // mandatory route parameter -> no menu entry -> no available view + // info + Assert.assertFalse(header.isPresent()); + + Mockito.when(location.getPath()) + .thenReturn("optional-parameter-route"); + header = MenuConfiguration.getPageHeader(); + // optional route parameter -> menu is eligible + Assert.assertTrue(header.isPresent()); + Assert.assertEquals("OptionalParameterRouteWithPageTitle", + header.get()); + + } finally { + UI.setCurrent(currentUi); + } + } + + @Test + public void testGetPageHeader_clientViews_pageHeaderFromTitle() + 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(), + MenuConfigurationTest.testPageHeaderClientRouteFile); + + UI mockUi = Mockito.mock(UI.class); + UIInternals uiInternals = Mockito.mock(UIInternals.class); + Location location = Mockito.mock(Location.class); + Mockito.when(mockUi.getInternals()).thenReturn(uiInternals); + Mockito.when(uiInternals.getActiveViewLocation()).thenReturn(location); + + final UI currentUi = UI.getCurrent(); + + try { + UI.setCurrent(mockUi); + + Mockito.when(location.getPath()).thenReturn("/"); + Optional header = MenuConfiguration.getPageHeader(); + Assert.assertTrue(header.isPresent()); + // from ViewConfig.title + Assert.assertEquals("Public", header.get()); + + Mockito.when(location.getPath()).thenReturn("/about"); + header = MenuConfiguration.getPageHeader(); + Assert.assertTrue(header.isPresent()); + // from ViewConfig.title, with exclude=true + Assert.assertEquals("About", header.get()); + + Mockito.when(location.getPath()).thenReturn("/other"); + header = MenuConfiguration.getPageHeader(); + Assert.assertTrue(header.isPresent()); + // from ViewConfig.title, with menu config + Assert.assertEquals("Other", header.get()); + + Mockito.when(location.getPath()).thenReturn("/hilla"); + header = MenuConfiguration.getPageHeader(); + Assert.assertTrue(header.isPresent()); + // from ViewConfig.title, when flow layout is false + Assert.assertEquals("Hilla", header.get()); + + Mockito.when(uiInternals.getActiveRouterTargetsChain()) + .thenReturn(List.of(new RouteOrLayoutWithDynamicTitle())); + header = MenuConfiguration.getPageHeader(); + Assert.assertTrue(header.isPresent()); + // from HasDynamicTitle + Assert.assertEquals("My Route with dynamic title", header.get()); + Mockito.when(uiInternals.getActiveRouterTargetsChain()) + .thenReturn(Collections.emptyList()); + + } finally { + UI.setCurrent(currentUi); + } + } + private void assertOrder(List menuEntries, String[] expectedOrder) { for (int i = 0; i < menuEntries.size(); i++) { @@ -285,4 +474,84 @@ private void assertServerRoutesWithParameters( "Server route '/param/varargs' should be included in the menu", menuItems.get("/param/varargs").exclude()); } + + @Tag("some-tag") + @Route("normal-route") + @Menu(title = "Normal Route") + public static class NormalRoute extends Component { + } + + @Tag("some-tag") + @PageTitle("My Normal Route") + @Route("normal-route-with-page-title") + public static class NormalRouteWithPageTitle extends Component { + } + + public static class RouteOrLayoutWithDynamicTitle + implements HasDynamicTitle, HasElement { + @Override + public String getPageTitle() { + return "My Route with dynamic title"; + } + + @Override + public Element getElement() { + return null; + } + } + + @Tag("some-tag") + @Route("optional-parameter-route") + @Menu(title = "Optional Param route") + public static class OptionalParameterRouteWithPageTitle extends Component + implements HasUrlParameter { + @Override + public void setParameter(BeforeEvent event, + @OptionalParameter String parameter) { + + } + } + + @Tag("some-tag") + @Route("mandatory-parameter-route") + @Menu(title = "Mandatory Param route") + public static class MandatoryParameterRouteWithPageTitle extends Component + implements HasUrlParameter { + @Override + public void setParameter(BeforeEvent event, String parameter) { + + } + } + + public static String testPageHeaderClientRouteFile = """ + [ + { + "route": "", + "params": {}, + "title": "Layout", + "children": [ + { + "route": "", + "params": {}, + "title": "Public" + }, + { + "route": "about", + "menu": { "exclude": true }, + "title": "About" + }, + { + "route": "other", + "menu": { "title": "Other" }, + "title": "Other" + }, + { + "route": "hilla", + "title": "Hilla", + "flowLayout": false + } + ] + } + ] + """; }