diff --git a/packages/java/endpoint/src/test/java/com/vaadin/hilla/route/RouteUtilTest.java b/packages/java/endpoint/src/test/java/com/vaadin/hilla/route/RouteUtilTest.java index 6da3a03860..444b481025 100644 --- a/packages/java/endpoint/src/test/java/com/vaadin/hilla/route/RouteUtilTest.java +++ b/packages/java/endpoint/src/test/java/com/vaadin/hilla/route/RouteUtilTest.java @@ -36,7 +36,7 @@ public void test_role_allowed() { AvailableViewInfo config = new AvailableViewInfo("Test", new String[] { "ROLE_ADMIN" }, false, "/test", false, false, - null, null, null); + null, null, null, false); routeUtil.setRoutes(Collections.singletonMap("/test", config)); Assert.assertTrue("Route should be allowed for ADMIN role.", @@ -52,7 +52,7 @@ public void test_role_not_allowed() { AvailableViewInfo config = new AvailableViewInfo("Test", new String[] { "ROLE_ADMIN" }, false, "/test", false, false, - null, null, null); + null, null, null, false); routeUtil.setRoutes(Collections.singletonMap("/test", config)); Assert.assertFalse("USER role should not allow ADMIN route.", @@ -67,7 +67,7 @@ public void test_login_required() { request.setUserPrincipal(Mockito.mock(Principal.class)); AvailableViewInfo config = new AvailableViewInfo("Test", null, true, - "/test", false, false, null, null, null); + "/test", false, false, null, null, null, false); routeUtil.setRoutes(Collections.singletonMap("/test", config)); Assert.assertTrue("Request with user principal should be allowed", @@ -82,7 +82,7 @@ public void test_login_required_failed() { request.setUserPrincipal(null); AvailableViewInfo config = new AvailableViewInfo("Test", null, true, - "/test", false, false, null, null, null); + "/test", false, false, null, null, null, false); routeUtil.setRoutes(Collections.singletonMap("/test", config)); Assert.assertFalse("No login should be denied access", @@ -97,11 +97,11 @@ public void test_login_required_on_layout() { request.setUserPrincipal(null); AvailableViewInfo pageWithoutLogin = new AvailableViewInfo("Test Page", - null, false, "/test", false, false, null, null, null); + null, false, "/test", false, false, null, null, null, false); AvailableViewInfo layoutWithLogin = new AvailableViewInfo("Test Layout", null, true, "", false, false, null, - Collections.singletonList(pageWithoutLogin), null); + Collections.singletonList(pageWithoutLogin), null, false); routeUtil.setRoutes(Map.ofEntries(entry("/test", pageWithoutLogin), entry("", layoutWithLogin))); @@ -118,11 +118,11 @@ public void test_login_required_on_page() { request.setUserPrincipal(null); AvailableViewInfo pageWithLogin = new AvailableViewInfo("Test Page", - null, true, "/test", false, false, null, null, null); + null, true, "/test", false, false, null, null, null, false); AvailableViewInfo layoutWithoutLogin = new AvailableViewInfo( "Test Layout", null, false, "", false, false, null, - Collections.singletonList(pageWithLogin), null); + Collections.singletonList(pageWithLogin), null, false); routeUtil.setRoutes(Map.ofEntries(entry("/test", pageWithLogin), entry("", layoutWithoutLogin))); @@ -142,7 +142,7 @@ public void test_login_not_required_on_root() { request.setUserPrincipal(null); AvailableViewInfo config = new AvailableViewInfo("Root", null, false, - "", false, false, null, null, null); + "", false, false, null, null, null, false); routeUtil.setRoutes(Collections.singletonMap("", config)); Assert.assertTrue("Login no required should allow access", diff --git a/packages/java/endpoint/src/test/resources/META-INF/VAADIN/available-views-admin.json b/packages/java/endpoint/src/test/resources/META-INF/VAADIN/available-views-admin.json index 0bb1fa0c80..8b18d71b81 100644 --- a/packages/java/endpoint/src/test/resources/META-INF/VAADIN/available-views-admin.json +++ b/packages/java/endpoint/src/test/resources/META-INF/VAADIN/available-views-admin.json @@ -7,7 +7,8 @@ "route": "/foo", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/bar": { "params": {}, @@ -17,7 +18,8 @@ "route": "/bar", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/home": { "params": {}, @@ -27,7 +29,8 @@ "route": "/home", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/wildcard": { "params": { @@ -39,7 +42,8 @@ "route": "/wildcard", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/comments": { "params": { @@ -51,7 +55,8 @@ "route": "/comments", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/profile": { "params": {}, @@ -64,7 +69,8 @@ "route": "/profile", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/": { "params": {}, @@ -74,7 +80,8 @@ "route": "/", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/orders": { "params": {}, @@ -84,6 +91,7 @@ "route": "/orders", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false } } diff --git a/packages/java/endpoint/src/test/resources/META-INF/VAADIN/available-views-anonymous.json b/packages/java/endpoint/src/test/resources/META-INF/VAADIN/available-views-anonymous.json index 9f2bce8153..a51782d242 100644 --- a/packages/java/endpoint/src/test/resources/META-INF/VAADIN/available-views-anonymous.json +++ b/packages/java/endpoint/src/test/resources/META-INF/VAADIN/available-views-anonymous.json @@ -7,7 +7,8 @@ "route": "/home", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/foo": { "params": {}, @@ -17,7 +18,8 @@ "route": "/foo", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/wildcard": { "params": { @@ -29,7 +31,8 @@ "route": "/wildcard", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/bar": { "params": {}, @@ -39,7 +42,8 @@ "route": "/bar", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/comments": { "params": { @@ -51,7 +55,8 @@ "route": "/comments", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/": { "params": {}, @@ -61,7 +66,8 @@ "route": "/", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/orders": { "params": {}, @@ -71,6 +77,7 @@ "route": "/orders", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false } } diff --git a/packages/java/endpoint/src/test/resources/META-INF/VAADIN/available-views-user.json b/packages/java/endpoint/src/test/resources/META-INF/VAADIN/available-views-user.json index 0bb1fa0c80..8b18d71b81 100644 --- a/packages/java/endpoint/src/test/resources/META-INF/VAADIN/available-views-user.json +++ b/packages/java/endpoint/src/test/resources/META-INF/VAADIN/available-views-user.json @@ -7,7 +7,8 @@ "route": "/foo", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/bar": { "params": {}, @@ -17,7 +18,8 @@ "route": "/bar", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/home": { "params": {}, @@ -27,7 +29,8 @@ "route": "/home", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/wildcard": { "params": { @@ -39,7 +42,8 @@ "route": "/wildcard", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/comments": { "params": { @@ -51,7 +55,8 @@ "route": "/comments", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/profile": { "params": {}, @@ -64,7 +69,8 @@ "route": "/profile", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/": { "params": {}, @@ -74,7 +80,8 @@ "route": "/", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/orders": { "params": {}, @@ -84,6 +91,7 @@ "route": "/orders", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false } } diff --git a/packages/java/endpoint/src/test/resources/META-INF/VAADIN/only-client-views.json b/packages/java/endpoint/src/test/resources/META-INF/VAADIN/only-client-views.json index cc28c73ceb..c25c900683 100644 --- a/packages/java/endpoint/src/test/resources/META-INF/VAADIN/only-client-views.json +++ b/packages/java/endpoint/src/test/resources/META-INF/VAADIN/only-client-views.json @@ -7,7 +7,8 @@ "route": "/home", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/profile": { "params": {}, @@ -20,7 +21,8 @@ "route": "/profile", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/": { "params": {}, @@ -30,7 +32,8 @@ "route": "/", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/orders": { "params": {}, @@ -40,6 +43,7 @@ "route": "/orders", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false } } diff --git a/packages/java/endpoint/src/test/resources/META-INF/VAADIN/server-and-client-views.json b/packages/java/endpoint/src/test/resources/META-INF/VAADIN/server-and-client-views.json index 6ee29cb291..bb5dad1178 100644 --- a/packages/java/endpoint/src/test/resources/META-INF/VAADIN/server-and-client-views.json +++ b/packages/java/endpoint/src/test/resources/META-INF/VAADIN/server-and-client-views.json @@ -7,7 +7,8 @@ "route": "/foo", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/bar": { "params": {}, @@ -17,7 +18,8 @@ "route": "/bar", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/home": { "params": {}, @@ -27,7 +29,8 @@ "route": "/home", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/comments": { "params": { @@ -39,7 +42,8 @@ "route": "/comments", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/wildcard": { "params": { @@ -51,7 +55,8 @@ "route": "/wildcard", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false }, "/profile": { "params": {}, @@ -64,6 +69,7 @@ "route": "/profile", "lazy": false, "register": false, - "menu": null + "menu": null, + "flowLayout":false } } diff --git a/packages/ts/file-router/src/runtime/RouterConfigurationBuilder.ts b/packages/ts/file-router/src/runtime/RouterConfigurationBuilder.ts index 396339a55d..693b073da5 100644 --- a/packages/ts/file-router/src/runtime/RouterConfigurationBuilder.ts +++ b/packages/ts/file-router/src/runtime/RouterConfigurationBuilder.ts @@ -143,6 +143,51 @@ export class RouterConfigurationBuilder { return this; } + /** + * Adds the layoutComponent as the parent layout to views with the flowLayouts ViewConfiguration set. + * + * @param layoutComponent - layout component to use, usually Flow + */ + withLayout(layoutComponent: ComponentType): this { + function applyLayouts(routes: readonly RouteObject[]): readonly RouteObject[] { + const nestedRoutes = routes.map((route) => { + if (route.children === undefined) { + return route; + } + + return { + ...route, + children: applyLayouts(route.children), + } as RouteObject; + }); + return [ + { + element: createElement(layoutComponent), + children: nestedRoutes, + }, + ]; + } + + this.#modifiers.push((routes: readonly RouteObject[] | undefined) => { + if (!routes) { + return routes; + } + const withLayout = routes.filter((route) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const layout = typeof route.handle === 'object' && 'flowLayout' in route.handle && route.handle.flowLayout; + return layout; + }); + const allRoutes = routes.filter((route) => !withLayout.includes(route)); + const catchAll = [routes.find((route) => route.path === '*')].filter((route) => route !== undefined); + withLayout.push(...catchAll); // Add * fallback to all child routes + + allRoutes.unshift(...applyLayouts(withLayout)); + return allRoutes; + }); + + return this; + } + /** * Protects all the routes that require authentication. For more details see * {@link @vaadin/hilla-react-auth#protectRoutes} function. diff --git a/packages/ts/file-router/src/types.d.ts b/packages/ts/file-router/src/types.d.ts index 819937dbee..deff5b0436 100644 --- a/packages/ts/file-router/src/types.d.ts +++ b/packages/ts/file-router/src/types.d.ts @@ -25,6 +25,12 @@ export type ViewConfig = Readonly<{ */ route?: string; + /** + * Set to true to indicate that the view is using server side parent layout + * annotated with the Layout annotation. + */ + flowLayout?: boolean; + menu?: Readonly<{ /** * Title to use in the menu. Falls back the title property of the view diff --git a/packages/ts/file-router/test/runtime/RouterConfigurationBuilder.spec.tsx b/packages/ts/file-router/test/runtime/RouterConfigurationBuilder.spec.tsx index b3f955f48b..edb3480c74 100644 --- a/packages/ts/file-router/test/runtime/RouterConfigurationBuilder.spec.tsx +++ b/packages/ts/file-router/test/runtime/RouterConfigurationBuilder.spec.tsx @@ -1,5 +1,6 @@ import { expect, use } from '@esm-bundle/chai'; import chaiLike from 'chai-like'; +import { createElement } from 'react'; import sinonChai from 'sinon-chai'; import { RouterConfigurationBuilder } from '../../src/runtime/RouterConfigurationBuilder.js'; import { mockDocumentBaseURI } from '../mocks/dom.js'; @@ -81,6 +82,89 @@ describe('RouterBuilder', () => { ]); }); + it('should add layout routes under layout component', () => { + const serverWildcard = { + path: '*', + element: , + handle: { title: 'Server' }, + }; + const serverIndex = { + index: true, + element: , + handle: { title: 'Server' }, + }; + + const serverRoutes = [serverWildcard, serverIndex]; + + const { routes } = builder + .withReactRoutes([ + { + path: '', + handle: { + flowLayout: true, + }, + }, + { + path: '/test', + handle: { + flowLayout: true, + }, + children: [ + { + path: '/child', + }, + ], + }, + ]) + .withLayout(Server) + .build(); + + expect(routes).to.be.like([ + { + children: [ + { + path: '', + handle: { + flowLayout: true, + }, + }, + { + children: [ + { + children: [ + { + path: '/child', + }, + ], + element: createElement(Server), + }, + ], + path: '/test', + handle: { + flowLayout: true, + }, + }, + ], + element: createElement(Server), + }, + { + children: [ + { + path: '/test', + element:
Test
, + }, + ], + path: '', + }, + ]); + }); + + it('should not throw when no routes', () => { + const { routes } = new RouterConfigurationBuilder().withLayout(Server).build(); + + expect(routes).to.be.like([]); + }); + it('should merge file routes deeply', () => { const { routes } = builder .withFileRoutes([