Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Add util method for detecting Hilla auto layout #20245

Merged
merged 7 commits into from
Oct 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
caalador marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* 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"
}
]
}
]
""";
}