Skip to content

Commit

Permalink
fix: Add util method for detecting Hilla auto layout (#20245) (#20250)
Browse files Browse the repository at this point in the history
Adds a new util method that detects layout entries in file-routes.json.

Related-to vaadin/hilla#2825
  • Loading branch information
mshabarov authored Oct 15, 2024
1 parent 068c137 commit f40c6de
Show file tree
Hide file tree
Showing 3 changed files with 253 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Predicate;
Expand Down Expand Up @@ -74,6 +75,27 @@ public class MenuRegistry {
private static final Logger log = LoggerFactory
.getLogger(MenuRegistry.class);

/**
* File routes lazy loading and caching.
*/
private enum FileRoutesCache {
INSTANCE;

private List<AvailableViewInfo> cachedResource;

private List<AvailableViewInfo> get(
AbstractConfiguration configuration) {
if (cachedResource == null) {
cachedResource = loadClientMenuItems(configuration);
}
return cachedResource;
}

private void clear() {
cachedResource = null;
}
}

/**
* Collect views with menu annotation for automatic menu population. All
* client views are collected and any accessible server views.
Expand Down Expand Up @@ -303,41 +325,99 @@ public static Map<String, AvailableViewInfo> collectClientMenuItems(
boolean filterClientViews, AbstractConfiguration configuration,
VaadinRequest vaadinRequest) {

URL viewsJsonAsResource = getViewsJsonAsResource(configuration);
if (viewsJsonAsResource == null) {
LoggerFactory.getLogger(MenuRegistry.class).debug(
"No {} found under {} directory. Skipping client route registration.",
FILE_ROUTES_JSON_NAME,
configuration.isProductionMode() ? "'META-INF/VAADIN'"
: "'frontend/generated'");
return Collections.emptyMap();
}

Map<String, AvailableViewInfo> configurations = new HashMap<>();

try (InputStream source = viewsJsonAsResource.openStream()) {
if (source != null) {
ObjectMapper mapper = new ObjectMapper().configure(
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
false);
mapper.readValue(source,
new TypeReference<List<AvailableViewInfo>>() {
}).forEach(clientViewConfig -> collectClientViews("",
clientViewConfig, configurations));
}
} catch (IOException e) {
LoggerFactory.getLogger(MenuRegistry.class).warn(
"Failed load {} from {}", FILE_ROUTES_JSON_NAME,
viewsJsonAsResource.getPath(), e);
}
collectClientMenuItems(configuration).forEach(
viewInfo -> collectClientViews("", viewInfo, configurations));

if (filterClientViews) {
if (filterClientViews && !configurations.isEmpty()) {
filterClientViews(configurations, vaadinRequest);
}

return configurations;
}

/**
* Determines whether the application contains a Hilla automatic main
* layout.
* <p>
* This method detects only a top-level main layout, when the following
* conditions are met:
* <ul>
* <li>only one single root element is present in
* {@code file-routes.json}</li>
* <li>this element has no or blank {@code route} parameter</li>
* <li>this element has non-null children array, which may or may not be
* empty</li>
* </ul>
* <p>
* This method doesn't check nor does it detect the nested layouts, i.e.
* that are not root entries.
*
* @param configuration
* the {@link AbstractConfiguration} containing the application
* configuration
* @return {@code true} if a Hilla automatic main layout is present in the
* configuration, {@code false} otherwise
*/
public static boolean hasHillaMainLayout(
AbstractConfiguration configuration) {
List<AvailableViewInfo> viewInfos = collectClientMenuItems(
configuration);
return viewInfos.size() == 1
&& isMainLayout(viewInfos.iterator().next());
}

private static boolean isMainLayout(AvailableViewInfo viewInfo) {
return (viewInfo.route() == null || viewInfo.route().isBlank())
&& viewInfo.children() != null;
}

/**
* Caches the loaded file routes data in production. Always loads from a
* local file in development.
*
* @param configuration
* application configuration
* @return file routes data loaded from {@code file-routes.json}
*/
private static List<AvailableViewInfo> collectClientMenuItems(
AbstractConfiguration configuration) {
if (configuration.isProductionMode()) {
return FileRoutesCache.INSTANCE.get(configuration);
} else {
return loadClientMenuItems(configuration);
}
}

private static List<AvailableViewInfo> loadClientMenuItems(
AbstractConfiguration configuration) {
Objects.requireNonNull(configuration);
URL viewsJsonAsResource = getViewsJsonAsResource(configuration);
if (viewsJsonAsResource != null) {
try (InputStream source = viewsJsonAsResource.openStream()) {
if (source != null) {
ObjectMapper mapper = new ObjectMapper().configure(
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
false);
return mapper.readValue(source, new TypeReference<>() {
});
}
} catch (IOException e) {
LoggerFactory.getLogger(MenuRegistry.class).warn(
"Failed load {} from {}", FILE_ROUTES_JSON_NAME,
viewsJsonAsResource.getPath(), e);
}
} else {
LoggerFactory.getLogger(MenuRegistry.class).debug(
"No {} found under {} directory. Skipping client route registration.",
FILE_ROUTES_JSON_NAME,
configuration.isProductionMode() ? "'META-INF/VAADIN'"
: "'frontend/generated'");
}
return Collections.emptyList();
}

private static void collectClientViews(String basePath,
AvailableViewInfo viewConfig,
Map<String, AvailableViewInfo> configurations) {
Expand Down Expand Up @@ -495,6 +575,16 @@ public static boolean hasClientRoute(String route) {
return hasClientRoute(route, false);
}

/**
* For internal use only.
* <p>
* Clears file routes cache when running in production. Only used in tests
* and should not be needed in projects.
*/
public static void clearFileRoutesCache() {
FileRoutesCache.INSTANCE.clear();
}

/**
* See if there is a client route available for given route path, optionally
* excluding layouts (routes with children) from the check.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
import com.vaadin.flow.di.Lookup;
import com.vaadin.flow.di.ResourceProvider;
import com.vaadin.flow.internal.UsageStatistics;
import com.vaadin.flow.internal.menu.MenuRegistry;
import com.vaadin.flow.router.QueryParameters;
import com.vaadin.flow.server.AppShellRegistry;
import com.vaadin.flow.server.BootstrapHandler;
Expand Down Expand Up @@ -126,6 +127,8 @@ public void setUp() throws Exception {
.mock(ApplicationConfiguration.class);
Mockito.when(context.getAttribute(ApplicationConfiguration.class))
.thenReturn(applicationConfiguration);

MenuRegistry.clearFileRoutesCache();
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@
import com.vaadin.flow.server.VaadinSession;
import com.vaadin.flow.server.startup.ApplicationRouteRegistry;

import elemental.json.JsonArray;
import elemental.json.JsonObject;
import elemental.json.impl.JsonUtil;
import static com.vaadin.flow.server.frontend.FrontendUtils.GENERATED;
import static com.vaadin.flow.internal.menu.MenuRegistry.FILE_ROUTES_JSON_NAME;
import static com.vaadin.flow.internal.menu.MenuRegistry.FILE_ROUTES_JSON_PROD_PATH;
Expand Down Expand Up @@ -331,6 +334,60 @@ public void getMenuItemsList_assertOrder() {
new String[] { "/d", "/c", "/a", "/b", "/d/a", "/d/b" });
}

@Test
public void hasHillaAutoLayout_fileRoutesHasSingleRootLayout_true()
throws IOException {
JsonArray fileRoutes = JsonUtil.parse(testClientRouteFile);
JsonObject layout = fileRoutes.getObject(0);
JsonArray children = layout.getArray("children");
Assert.assertNotNull(children);

assertHasHillaMainLayout(testClientRouteFile, true);
}

@Test
public void hasHillaAutoLayout_fileRoutesHasEmptyChildren_true()
throws IOException {
JsonArray fileRoutes = JsonUtil.parse(emptyChildren);
JsonObject layout = fileRoutes.getObject(0);
JsonArray children = layout.getArray("children");
Assert.assertNotNull(children);
Assert.assertEquals(0, children.length());

assertHasHillaMainLayout(emptyChildren, true);
}

@Test
public void hasHillaAutoLayout_fileRoutesHasSingleRootRoute_false()
throws IOException {
Assert.assertFalse(singleRoute.contains("\"children\""));

assertHasHillaMainLayout(singleRoute, false);
}

@Test
public void hasHillaAutoLayout_fileRoutesHasMultipleRootRoutes_false()
throws IOException {
assertHasHillaMainLayout(multipleRootRoutes, false);
}

@Test
public void hasHillaAutoLayout_fileRoutesHasNonEmptyRoute_false()
throws IOException {
assertHasHillaMainLayout(nonEmptyRoute, false);
}

private void assertHasHillaMainLayout(String fileRoutes, boolean expected)
throws IOException {
File generated = tmpDir.newFolder(GENERATED);
File clientFiles = new File(generated, FILE_ROUTES_JSON_NAME);
Files.writeString(clientFiles.toPath(), fileRoutes);

boolean hasHillaMainLayout = MenuRegistry
.hasHillaMainLayout(vaadinService.getDeploymentConfiguration());
Assert.assertEquals(expected, hasHillaMainLayout);
}

private void assertOrder(List<AvailableViewInfo> menuItems,
String[] expectedOrder) {
for (int i = 0; i < menuItems.size(); i++) {
Expand Down Expand Up @@ -683,4 +740,81 @@ public Instantiator getInstantiator() {
}
]
""";

String emptyChildren = """
[
{
"route": "",
"title": "Main Layout",
"children": []
}
]
""";

String nonEmptyRoute = """
[
{
"route": "foo",
"title": "Main Layout",
"children": [
{
"route": "hilla",
"flowLayout": false,
"params": {},
"title": "Hilla view"
}
]
}
]
""";

String singleRoute = """
[
{
"route": "",
"menu": {
"title": "Public page",
"icon": "vaadin:group"
},
"flowLayout": false,
"params": {},
"title": "Public"
}
]
""";

String multipleRootRoutes = """
[
{
"route": "hilla",
"flowLayout": false,
"params": {},
"children": [
{
"route": "",
"flowLayout": false,
"params": {},
"title": "Layout"
}
]
},
{
"route": "",
"flowLayout": false,
"params": {},
"title": "Layout",
"children": [
{
"route": "components",
"menu": {
"title": "React Components"
},
"flowLayout": false,
"params": {},
"title": "Components"
}
]
}
]
""";
}

0 comments on commit f40c6de

Please sign in to comment.