From a07e2ad4927e7a244659bedf59e826803a760613 Mon Sep 17 00:00:00 2001 From: Mikhail Shabarov <61410877+mshabarov@users.noreply.github.com> Date: Tue, 9 Apr 2024 17:03:17 +0300 Subject: [PATCH] feat: Use a new RouterBuilder API for routes.tsx (#19020) Removes the buildRoutes method and replaces it with a new RouterBuilder API that automatically injects server side routes where needed and adds the auth level. Related-to vaadin/hilla#2238 Fixes #18988 --- .../plugin/maven/BuildFrontendMojoTest.java | 6 +- .../flow/server/frontend/FrontendUtils.java | 32 +-- .../frontend/TaskGenerateReactFiles.java | 124 +++++----- .../com/vaadin/flow/server/frontend/Flow.tsx | 28 +-- .../flow/server/frontend/index-react.tsx | 2 +- .../flow/server/frontend/routes-flow.tsx | 19 ++ .../vaadin/flow/server/frontend/routes.tsx | 78 +++++-- .../server/frontend/FrontendUtilsTest.java | 54 ++--- .../frontend/TaskGenerateReactFilesTest.java | 217 +++++++++++------- .../vite-basics/src/main/frontend/routes.tsx | 17 +- .../src/main/frontend/routes.tsx | 18 +- 11 files changed, 336 insertions(+), 259 deletions(-) create mode 100644 flow-server/src/main/resources/com/vaadin/flow/server/frontend/routes-flow.tsx diff --git a/flow-plugins/flow-maven-plugin/src/test/java/com/vaadin/flow/plugin/maven/BuildFrontendMojoTest.java b/flow-plugins/flow-maven-plugin/src/test/java/com/vaadin/flow/plugin/maven/BuildFrontendMojoTest.java index 7d0d21fbc5a..d1208542243 100644 --- a/flow-plugins/flow-maven-plugin/src/test/java/com/vaadin/flow/plugin/maven/BuildFrontendMojoTest.java +++ b/flow-plugins/flow-maven-plugin/src/test/java/com/vaadin/flow/plugin/maven/BuildFrontendMojoTest.java @@ -533,7 +533,8 @@ public void mavenGoal_generateOpenApiJson_when_itIsInClientSideMode() { element: , handle: { title: 'Main' } - } + }, + ...serverSideRoutes ] as RouteObject[]; @@ -557,7 +558,8 @@ public void mavenGoal_generateTsFiles_when_enabled() throws Exception { { element: , handle: { title: 'Main' } - } + }, + ...serverSideRoutes ] as RouteObject[]; diff --git a/flow-server/src/main/java/com/vaadin/flow/server/frontend/FrontendUtils.java b/flow-server/src/main/java/com/vaadin/flow/server/frontend/FrontendUtils.java index 68070373742..c312b54e34f 100644 --- a/flow-server/src/main/java/com/vaadin/flow/server/frontend/FrontendUtils.java +++ b/flow-server/src/main/java/com/vaadin/flow/server/frontend/FrontendUtils.java @@ -237,9 +237,9 @@ public class FrontendUtils { public static final String ROUTES_TSX = "routes.tsx"; - public static final String ROUTES_JS = "routes.js"; + public static final String ROUTES_FLOW_TSX = "routes-flow.tsx"; - public static final String VIEWS_TS = "views.ts"; + public static final String ROUTES_JS = "routes.js"; /** * Default generated path for generated frontend files. @@ -339,15 +339,6 @@ public class FrontendUtils { private static final Pattern SERVER_SIDE_ROUTES_PATTERN = Pattern.compile( "(?<=\\s|^)\\.{3}serverSideRoutes(?=\\s|$)", Pattern.MULTILINE); - // Regex pattern matches "buildRoute(" - private static final Pattern BUILDROUTE_FUNCTION_PATTERN = Pattern.compile( - "(?<=\\s|^)buildRoute\\((?=[\\s\\S]*|$)", Pattern.MULTILINE); - - // Regex pattern matches "buildRoute()" - private static final Pattern BUILDROUTE_FUNCTION_NOARGS_PATTERN = Pattern - .compile("(?<=\\s|^)buildRoute\\(\\)(?=[\\s\\S]*|$)", - Pattern.MULTILINE); - // Regex pattern matches everything between "const|let|var routes = [" (or // "const routes: RouteObject[] = [") and "...serverSideRoutes" private static final Pattern CLIENT_SIDE_ROUTES_PATTERN = Pattern.compile( @@ -1380,10 +1371,10 @@ private static boolean isRoutesContentUsingHillaViews( private static boolean isRoutesTsxContentUsingHillaViews( String routesContent) { routesContent = StringUtil.removeComments(routesContent); - // Note that here we assume that Frontend/views doesn't have views. - // buildRoute() adds therefore only server side routes. - if (hasBuildRouteFunction(routesContent)) { - return !hasBuildRouteFunctionWithoutArguments(routesContent); + // Note that here we assume that Frontend/views doesn't have views and + // routes.tsx isn't the auto-generated one + if (hasFileOrReactRoutesFunction(routesContent)) { + return true; } return isRoutesContentUsingHillaViews(routesContent); } @@ -1392,13 +1383,10 @@ private static boolean missingServerSideRoutes(String routesContent) { return !SERVER_SIDE_ROUTES_PATTERN.matcher(routesContent).find(); } - private static boolean hasBuildRouteFunctionWithoutArguments( - String routesContent) { - return BUILDROUTE_FUNCTION_NOARGS_PATTERN.matcher(routesContent).find(); - } - - private static boolean hasBuildRouteFunction(String routesContent) { - return BUILDROUTE_FUNCTION_PATTERN.matcher(routesContent).find(); + private static boolean hasFileOrReactRoutesFunction(String routesContent) { + return !routesContent.isBlank() + && (routesContent.contains("withFileRoutes(") + || routesContent.contains("withReactRoutes(")); } private static boolean mayHaveClientSideRoutes(String routesContent) { diff --git a/flow-server/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateReactFiles.java b/flow-server/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateReactFiles.java index 011838a9bbe..6a00dca0fda 100644 --- a/flow-server/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateReactFiles.java +++ b/flow-server/src/main/java/com/vaadin/flow/server/frontend/TaskGenerateReactFiles.java @@ -27,6 +27,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.vaadin.flow.internal.StringUtil; import com.vaadin.flow.router.Route; import com.vaadin.flow.server.ExecutionFailedException; @@ -54,43 +55,41 @@ public class TaskGenerateReactFiles implements FallibleCommand { public static final String CLASS_PACKAGE = "com/vaadin/flow/server/frontend/%s"; private Options options; protected static String NO_IMPORT = """ - Faulty configuration of serverSideRoutes. + Faulty configuration of server-side routes. The server route definition is missing from the '%1$s' file To have working Flow routes add the following to the '%1$s' file: - - import { buildRoute } from "Frontend/generated/flow/Flow"; - - call buildRoute optionally with routes and position for server side routes as shown below: + import Flow from 'Frontend/generated/flow/Flow'; + import { RouterConfigurationBuilder } from '@vaadin/hilla-file-router/runtime.js'; + export const { router, routes } = new RouterConfigurationBuilder() + .withFallback(Flow) + // .withFileRoutes() or .withReactRoutes() + // ... + .build(); - let routing = [ - { - element: , - handle: { title: 'Main' }, - children: [ - { path: '/hilla', element: , handle: { title: 'Hilla' } } - ], - }, - ] as RouteObject[]; - export const routes = buildRoute(routing, routing[0].children); - OR - - import { serverSideRoutes } from "Frontend/generated/flow/Flow"; - - route '...serverSideRoutes' into the routes definition as shown below: + OR + + import { createBrowserRouter, RouteObject } from 'react-router-dom'; + import { serverSideRoutes } from 'Frontend/generated/flow/Flow'; + + function build() { + const routes = [...serverSideRoutes] as RouteObject[]; + return { + router: createBrowserRouter(routes), + routes + }; + } + export const { router, routes } = build(); - export const routes = [ - { - element: , - handle: { title: 'Main' }, - children: [ - { path: '/', element: , handle: { title: 'Hello World' } }, - ...serverSideRoutes - ], - }, - ] as RouteObject[]; """; protected static String MISSING_ROUTES_EXPORT = """ Routes need to be exported as 'routes' for server navigation handling. - routes.tsx should at least contain + routes.tsx should contain + 'export const { router, routes } = new RouterConfigurationBuilder() + // routes building + .build();' + OR 'export const routes = [...serverSideRoutes] as RouteObject[];' - but can have react routes also defined. """; private static final String FLOW_TSX = "Flow.tsx"; @@ -98,13 +97,22 @@ public class TaskGenerateReactFiles implements FallibleCommand { static final String FLOW_FLOW_TSX = "flow/" + FLOW_TSX; static final String FLOW_REACT_ADAPTER_TSX = "flow/" + REACT_ADAPTER_TSX; private static final String ROUTES_JS_IMPORT_PATH_TOKEN = "%routesJsImportPath%"; - static final String VIEWS_TS_FALLBACK = """ - const routes = { path: "", module: undefined, children: [] }; - export default routes; - """; - private static Pattern SERVER_ROUTE_PATTERN = Pattern.compile( - "import[\\s\\S]?\\{[\\s\\S]*(?:serverSideRoutes|buildRoute)+[\\s\\S]*\\}[\\s\\S]?from[\\s\\S]?(\"|'|`)Frontend\\/generated\\/flow\\/Flow(\\.js)?\\1;"); + // matches setting the server-side routes from Flow.tsx: + // import { serverSideRoutes } from "Frontend/generated/flow/Flow"; + private static final Pattern SERVER_ROUTE_PATTERN = Pattern.compile( + "import\\s+\\{[\\s\\S]*(?:serverSideRoutes)+[\\s\\S]*\\}\\s+from\\s+(\"|'|`)Frontend\\/generated\\/flow\\/Flow(\\.js)?\\1;[\\s\\S]+\\.{3}serverSideRoutes"); + + // matches setting the fallback component to RouterConfigurationBuilder, + // e.g. Flow component from Flow.tsx: + // import Flow from 'Frontend/generated/flow/Flow'; + // ... + // .withFallback(Flow) + private static final Pattern FALLBACK_COMPONENT_PATTERN = Pattern.compile( + "import\\s+(\\w+)\\s+from\\s+(\"|'|`)Frontend\\/generated\\/flow\\/Flow(\\.js)?\\2;[\\s\\S]+withFallback\\(\\s*\\1\\s*\\)"); + + private static final Pattern ROUTES_EXPORT_PATTERN = Pattern.compile( + "export\\s+const\\s+(\\{[\\s\\S]*(router[\\s\\S]*routes|routes[\\s\\S]*router)[\\s\\S]*}|routes)"); /** * Create a task to generate index.js if necessary. @@ -129,8 +137,6 @@ private void doExecute() throws ExecutionFailedException { File frontendDirectory = options.getFrontendDirectory(); File frontendGeneratedFolder = options.getFrontendGeneratedFolder(); File flowTsx = new File(frontendGeneratedFolder, FLOW_FLOW_TSX); - File viewsTs = new File(frontendGeneratedFolder, - FrontendUtils.VIEWS_TS); File reactAdapterTsx = new File(frontendGeneratedFolder, FLOW_REACT_ADAPTER_TSX); File routesTsx = new File(frontendDirectory, FrontendUtils.ROUTES_TSX); @@ -141,21 +147,23 @@ private void doExecute() throws ExecutionFailedException { if (fileAvailable(REACT_ADAPTER_TSX)) { writeFile(reactAdapterTsx, getFileContent(REACT_ADAPTER_TSX)); } - if (!viewsTs.exists()) { - writeFile(viewsTs, VIEWS_TS_FALLBACK); - } if (!routesTsx.exists()) { + boolean isHillaUsed = FrontendUtils.isHillaUsed( + frontendDirectory, options.getClassFinder()); writeFile(frontendGeneratedFolderRoutesTsx, - getFileContent(FrontendUtils.ROUTES_TSX)); + getFileContent(isHillaUsed ? FrontendUtils.ROUTES_TSX + : FrontendUtils.ROUTES_FLOW_TSX)); } else { String routesContent = FileUtils.readFileToString(routesTsx, UTF_8); + routesContent = StringUtil.removeComments(routesContent); + if (missingServerRouteImport(routesContent) && serverRoutesAvailable()) { throw new ExecutionFailedException( String.format(NO_IMPORT, routesTsx.getPath())); } - if (!routesContent.contains("export const routes")) { + if (missingRoutesExport(routesContent)) { throw new ExecutionFailedException(MISSING_ROUTES_EXPORT); } } @@ -209,38 +217,13 @@ private void cleanup() throws ExecutionFailedException { private String getFlowTsxFileContent(boolean frontendRoutesTsExists) throws IOException { - String content = getFileContent(FLOW_TSX).replace( - ROUTES_JS_IMPORT_PATH_TOKEN, + return getFileContent(FLOW_TSX).replace(ROUTES_JS_IMPORT_PATH_TOKEN, (frontendRoutesTsExists) ? FrontendUtils.FRONTEND_FOLDER_ALIAS + FrontendUtils.ROUTES_JS : FrontendUtils.FRONTEND_FOLDER_ALIAS + FrontendUtils.GENERATED + FrontendUtils.ROUTES_JS); - ; - if (FrontendUtils.isHillaUsed(options.getFrontendDirectory(), - options.getClassFinder())) { - return content.replace("//%toReactRouterImport%", - "import { toReactRouter } from '@vaadin/hilla-file-router/runtime.js';") - .replace("//%viewsJsImport%", - "import views from 'Frontend/generated/views.js';") - .replace("//%buildRouteFunction%", - """ - if(!routes) { - // @ts-ignore - const route: RouteObject = toReactRouter(views); - if(route.children && route.children.length > 0) { - serverSidePosition = route.children; - if (route.element) { - routes = [route]; - } else { - routes = route.children; - } - } - } - """); - } - return content; } private boolean fileAvailable(String fileName) { @@ -249,7 +232,8 @@ private boolean fileAvailable(String fileName) { } private boolean missingServerRouteImport(String routesContent) { - return !SERVER_ROUTE_PATTERN.matcher(routesContent).find(); + return !FALLBACK_COMPONENT_PATTERN.matcher(routesContent).find() + && !SERVER_ROUTE_PATTERN.matcher(routesContent).find(); } private boolean serverRoutesAvailable() { @@ -257,6 +241,10 @@ private boolean serverRoutesAvailable() { .isEmpty(); } + private static boolean missingRoutesExport(String routesContent) { + return !ROUTES_EXPORT_PATTERN.matcher(routesContent).find(); + } + private void writeFile(File target, String content) throws ExecutionFailedException { diff --git a/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx b/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx index b3fffb1ea67..101031a6731 100644 --- a/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx +++ b/flow-server/src/main/resources/com/vaadin/flow/server/frontend/Flow.tsx @@ -19,12 +19,9 @@ import { matchRoutes, NavigateFunction, useLocation, - useNavigate, - RouteObject + useNavigate } from "react-router-dom"; import { routes } from "%routesJsImportPath%"; -//%viewsJsImport% -//%toReactRouterImport% const flow = new _Flow({ imports: () => import("Frontend/generated/flow/generated-flow-imports.js") @@ -460,26 +457,3 @@ export const createWebComponent = (tag: string, props?: Properties, onload?: () } return React.createElement(tag); }; - -/** - * Build routes for the application. Combines server side routes and FS routes. - * - * @param routes optional routes are for adding own route definition, giving routes will skip FS routes - * @param serverSidePosition optional position where server routes should be put. - * If non given they go to the root of the routes []. - * - * @returns RouteObject[] with combined routes - */ -export const buildRoute = (routes?: RouteObject[], serverSidePosition?: RouteObject[]): RouteObject[] => { - let combinedRoutes = [] as RouteObject[]; - //%buildRouteFunction% - if(serverSidePosition) { - serverSidePosition.push(...serverSideRoutes); - } else { - combinedRoutes.push(...serverSideRoutes); - } - if(routes) { - combinedRoutes.push(...routes); - } - return combinedRoutes; -}; \ No newline at end of file diff --git a/flow-server/src/main/resources/com/vaadin/flow/server/frontend/index-react.tsx b/flow-server/src/main/resources/com/vaadin/flow/server/frontend/index-react.tsx index 0dbc1eb5e3f..dbf1598c4b2 100644 --- a/flow-server/src/main/resources/com/vaadin/flow/server/frontend/index-react.tsx +++ b/flow-server/src/main/resources/com/vaadin/flow/server/frontend/index-react.tsx @@ -13,7 +13,7 @@ import { createElement } from 'react'; import { createRoot } from 'react-dom/client'; import { RouterProvider } from 'react-router-dom'; -import router from '%routesJsImportPath%'; +import { router } from '%routesJsImportPath%'; function App() { return ; diff --git a/flow-server/src/main/resources/com/vaadin/flow/server/frontend/routes-flow.tsx b/flow-server/src/main/resources/com/vaadin/flow/server/frontend/routes-flow.tsx new file mode 100644 index 00000000000..186e35d0bff --- /dev/null +++ b/flow-server/src/main/resources/com/vaadin/flow/server/frontend/routes-flow.tsx @@ -0,0 +1,19 @@ +/****************************************************************************** + * This file is auto-generated by Vaadin. + * It configures React Router automatically by adding server-side (Flow) routes, + * which is enough for Vaadin Flow applications. + * Once any `.tsx` or `.jsx` React routes are added into + * `src/main/frontend/views/` directory, this route configuration is + * re-generated automatically by Vaadin. + ******************************************************************************/ +import { createBrowserRouter, RouteObject } from 'react-router-dom'; +import { serverSideRoutes } from 'Frontend/generated/flow/Flow'; + +function build() { + const routes = [...serverSideRoutes] as RouteObject[]; + return { + router: createBrowserRouter(routes), + routes + }; +} +export const { router, routes } = build() diff --git a/flow-server/src/main/resources/com/vaadin/flow/server/frontend/routes.tsx b/flow-server/src/main/resources/com/vaadin/flow/server/frontend/routes.tsx index b1e0f6a0b1b..c1adca50388 100644 --- a/flow-server/src/main/resources/com/vaadin/flow/server/frontend/routes.tsx +++ b/flow-server/src/main/resources/com/vaadin/flow/server/frontend/routes.tsx @@ -1,18 +1,62 @@ -import { createBrowserRouter, RouteObject } from 'react-router-dom'; -import { buildRoute } from "Frontend/generated/flow/Flow"; +/****************************************************************************** + * This file is auto-generated by Vaadin. + * It configures React Router automatically by looking for React views files, + * located in `src/main/frontend/views/` directory. + * A manual configuration can be done as well, you have to: + * - copy this file or create your own `routes.tsx` in your frontend directory, + * then modify this copied/created file. By default, the `routes.tsx` file + * should be in `src/main/frontend/` folder; + * - use `RouterConfigurationBuilder` API to configure routes for the application; + * - restart the application, so that the imports get re-generated. + * + * `RouterConfigurationBuilder` combines a File System-based route configuration + * or your explicit routes configuration with the server-side routes. + * + * It has the following methods: + * - `withFileRoutes` enables the File System-based routes autoconfiguration; + * - `withReactRoutes` adds manual explicit route hierarchy. Allows also to add + * an individual route, which then merged into File System-based routes, + * e.g. Log In view; + * - `withFallback` adds a given component, e.g. server-side routes, + * to each branch of the current list of routes; + * - `protect` optional method that adds an authentication later to the routes. + * May be used with no parameters or with a path to redirect to, if the user is + * not authenticated. + * - `build` terminal build operation that returns the final routes array + * RouterObject[] and router object. + * + * NOTE: + * - You need to restart the dev-server after adding the new `routes.tsx` file. + * After that, all modifications to `routes.tsx` are recompiled automatically. + * - You may need to change a routes import in `index.tsx`, if `index.tsx` + * exists in the frontend folder (not in generated folder) and you copied the file, + * as the import isn't updated automatically by Vaadin in this case. + ******************************************************************************/ +import { RouterConfigurationBuilder } from '@vaadin/hilla-file-router/runtime.js'; +import Flow from 'Frontend/generated/flow/Flow'; +import fileRoutes from 'Frontend/generated/file-routes.js'; -export const routes = buildRoute(); - -// To define routes manually, use the following code as an example and remove the above code: -// let routing = [ -// { -// element: , -// handle: { title: 'Main' }, -// children: [ -// { path: '/hilla', element: , handle: { title: 'Hilla' } } -// ], -// }, -// ] as RouteObject[]; -// export const routes = buildRoute(routing, routing[0].children); - -export default createBrowserRouter(routes); +export const { router, routes } = new RouterConfigurationBuilder() + .withFileRoutes(fileRoutes) // (1) + // To define routes manually or adding an individual route, use the + // following code and remove (1): + // .withReactRoutes( + // { + // element: , + // handle: { title: 'Main' }, + // children: [ + // { path: '/hilla', element: , handle: { title: 'Hilla' } } + // ], + // }, + // { path: '/login', element: , handle: { title: 'Login' } } + // ) + // OR + // .withReactRoutes( + // { path: '/login', element: , handle: { title: 'Login' } }, + // ) + .withFallback(Flow) + // Optional method that adds an authentication for routes. + // Can take an optional path to redirect to, if not authenticated: + // .protect('/login'); + .protect() + .build(); diff --git a/flow-server/src/test/java/com/vaadin/flow/server/frontend/FrontendUtilsTest.java b/flow-server/src/test/java/com/vaadin/flow/server/frontend/FrontendUtilsTest.java index 473e6a24f45..3c629362fab 100644 --- a/flow-server/src/test/java/com/vaadin/flow/server/frontend/FrontendUtilsTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/server/frontend/FrontendUtilsTest.java @@ -176,23 +176,29 @@ public class FrontendUtilsTest { ]; """; - private static final String ROUTES_CONTENT_WITH_BUILD_ROUTE_TSX = """ - import { buildRoute } from "Frontend/generated/flow/Flow"; - export const routes = buildRoute(); + private static final String ROUTES_CONTENT_WITH_WITH_FILE_ROUTES = """ + import { RouterConfigurationBuilder } from '@vaadin/hilla-file-router/runtime.js'; + import Flow from 'Frontend/generated/flow/Flow'; + import fileRoutes from 'Frontend/generated/file-routes'; + + export const { router, routes } = new RouterConfigurationBuilder() + .withFileRoutes(fileRoutes) + .withFallback(Flow) + .build(); """; - private static final String ROUTES_CONTENT_WITH_BUILD_ROUTE_WITH_ARGS_TSX = """ - import { buildRoute } from "Frontend/generated/flow/Flow"; - let routing: RouteObject[] = [ + private static final String ROUTES_CONTENT_WITH_WITH_REACT_ROUTES = """ + import { RouterConfigurationBuilder } from '@vaadin/hilla-file-router/runtime.js'; + import Flow from 'Frontend/generated/flow/Flow'; + + export const { router, routes } = new RouterConfigurationBuilder() + .withReactRoutes([ { element: , - handle: { title: 'Hilla CRM' }, - children: [ - ...serverSideRoutes - ], + handle: { title: 'Hilla CRM' } }, - ]; - export const routes = buildRoute(routing, routing[0].children); + ]) + .withFallback(Flow).build(); """; private static final String HILLA_VIEW_TSX = """ @@ -634,29 +640,19 @@ public void isHillaViewsUsed_serverSideRoutesMainLayoutTsx_true() } @Test - public void isHillaViewsUsed_buildRouteTsxWithoutArgs_false() - throws IOException { - File frontend = prepareFrontendForRoutesFile(FrontendUtils.ROUTES_TSX, - ROUTES_CONTENT_WITH_BUILD_ROUTE_TSX); - Assert.assertFalse("hilla-views are not expected", - FrontendUtils.isHillaViewsUsed(frontend)); - } - - @Test - public void isHillaViewsUsed_buildRouteTsxWithoutArgsFSViewExists_false() - throws IOException { + public void isHillaViewsUsed_withFileRoutes_true() throws IOException { File frontend = prepareFrontendForRoutesFile(FrontendUtils.ROUTES_TSX, - ROUTES_CONTENT_WITH_BUILD_ROUTE_TSX, true); - Assert.assertTrue("hilla-views are expected", + ROUTES_CONTENT_WITH_WITH_FILE_ROUTES); + Assert.assertTrue("hilla-views are expected, as withFileRoutes is used", FrontendUtils.isHillaViewsUsed(frontend)); } @Test - public void isHillaViewsUsed_buildRouteWithArgsTsx_true() - throws IOException { + public void isHillaViewsUsed_withReactRoutes_true() throws IOException { File frontend = prepareFrontendForRoutesFile(FrontendUtils.ROUTES_TSX, - ROUTES_CONTENT_WITH_BUILD_ROUTE_WITH_ARGS_TSX); - Assert.assertTrue("hilla-views are expected", + ROUTES_CONTENT_WITH_WITH_REACT_ROUTES); + Assert.assertTrue( + "hilla-views are expected, as withReactRoutes is used", FrontendUtils.isHillaViewsUsed(frontend)); } diff --git a/flow-server/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateReactFilesTest.java b/flow-server/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateReactFilesTest.java index 134dc3f0522..c538f02bbd5 100644 --- a/flow-server/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateReactFilesTest.java +++ b/flow-server/src/test/java/com/vaadin/flow/server/frontend/TaskGenerateReactFilesTest.java @@ -31,7 +31,6 @@ import com.vaadin.flow.component.Component; import com.vaadin.flow.component.Tag; -import com.vaadin.flow.di.Lookup; import com.vaadin.flow.router.Route; import com.vaadin.flow.server.ExecutionFailedException; import com.vaadin.flow.server.frontend.scanner.ClassFinder; @@ -86,33 +85,35 @@ public void reactFilesAreWrittenToFrontend() } @Test - public void routesContainImport_serverSideRoutes_noExceptionThrown() + public void routesContainImportAndUsage_serverSideRoutes_noExceptionThrown() throws IOException, ExecutionFailedException { String content = """ import HelloWorldView from 'Frontend/views/helloworld/HelloWorldView.js'; import MainLayout from 'Frontend/views/MainLayout.js'; import { lazy } from 'react'; - import { createBrowserRouter, RouteObject } from 'react-router-dom'; - import {serverSideRoutes} from "Frontend/generated/flow/Flow"; + import { RouterConfigurationBuilder } from '@vaadin/hilla-file-router/runtime.js'; + import Flow from 'Frontend/generated/flow/Flow'; import {protectRoutes} from "@hilla/react-auth"; import LoginView from "Frontend/views/LoginView"; const AboutView = lazy(async () => import('Frontend/views/about/AboutView.js')); - - export const routes: RouteObject[] = protectRoutes([ - { - element: , - handle: { title: 'Main' }, - children: [ - { path: '/', element: , handle: { title: 'Hello World', rolesAllowed: ['USER'] } }, - { path: '/about', element: , handle: { title: 'About' } }, - ...serverSideRoutes - ], - }, - { path: '/login', element: }, - ]); - - export default createBrowserRouter(routes); + export const { router, routes } = new RouterConfigurationBuilder() + .withReactRoutes([ + { + element: , + handle: { title: 'Main' }, + children: [ + { path: '/', element: , handle: { title: 'Hello World', rolesAllowed: ['USER'] } }, + { path: '/about', element: , handle: { title: 'About' } }, + ], + }, + ]) + .withFallback(Flow) + .withReactRoutes([ + { path: '/login', element: , handle: { title: 'Login' } }, + ]) + .protect() + .build(); """; FileUtils.write(routesTsx, content, StandardCharsets.UTF_8); @@ -123,31 +124,39 @@ public void routesContainImport_serverSideRoutes_noExceptionThrown() } @Test - public void routesContainImport_buildRoutes_noExceptionThrown() - throws IOException, ExecutionFailedException { + public void routesContainOnlyImport_serverSideRoutes_exceptionThrown() + throws IOException { String content = """ - import HelloWorldView from 'Frontend/views/helloworld/HelloWorldView.js'; - import MainLayout from 'Frontend/views/MainLayout.js'; - import { createBrowserRouter, RouteObject } from 'react-router-dom'; - import { buildRoutes } from "Frontend/generated/flow/Flow"; - import LoginView from "Frontend/views/LoginView"; + import { serverSideRoutes } from 'Frontend/generated/flow/Flow'; + """; - const AboutView = lazy(async () => import('Frontend/views/about/AboutView.js')); + FileUtils.write(routesTsx, content, StandardCharsets.UTF_8); - export const routes: RouteObject[] = buildRoutes( - [ - { path: '/', element: , handle: { title: 'Hello World', rolesAllowed: ['USER'] } }, - { path: '/about', element: , handle: { title: 'About' } } - ]); + TaskGenerateReactFiles task = new TaskGenerateReactFiles(options); - export default createBrowserRouter(routes); + Exception exception = Assert.assertThrows( + ExecutionFailedException.class, () -> task.execute()); + Assert.assertEquals(String.format(TaskGenerateReactFiles.NO_IMPORT, + routesTsx.getPath()), exception.getMessage()); + } + + @Test + public void routesContainNoImport_serverSideRoutes_exceptionThrown() + throws IOException { + String content = """ + export const routes = [ + ...serverSideRoutes + ] as RouteObject[]; """; FileUtils.write(routesTsx, content, StandardCharsets.UTF_8); TaskGenerateReactFiles task = new TaskGenerateReactFiles(options); - task.execute(); + Exception exception = Assert.assertThrows( + ExecutionFailedException.class, () -> task.execute()); + Assert.assertEquals(String.format(TaskGenerateReactFiles.NO_IMPORT, + routesTsx.getPath()), exception.getMessage()); } @Test @@ -157,7 +166,7 @@ public void routesContainMultipleFlowImports_noExceptionThrown() import HelloWorldView from 'Frontend/views/helloworld/HelloWorldView.js'; import MainLayout from 'Frontend/views/MainLayout.js'; import { createBrowserRouter, RouteObject } from 'react-router-dom'; - import { tea, buildRoutes, serverSideRoutes, coffee } from "Frontend/generated/flow/Flow"; + import { tea, serverSideRoutes, coffee } from "Frontend/generated/flow/Flow"; import LoginView from "Frontend/views/LoginView"; const AboutView = lazy(async () => import('Frontend/views/about/AboutView.js')); @@ -186,7 +195,7 @@ public void routesContainMultipleFlowImports_noExceptionThrown() } @Test - public void routesMissingImport_noBuildOrServerSideRoutes_exceptionThrown() + public void routesMissingImportAndUsage_noBuildOrServerSideRoutes_exceptionThrown() throws IOException, ExecutionFailedException { String content = """ import HelloWorldView from 'Frontend/views/helloworld/HelloWorldView.js'; @@ -223,7 +232,7 @@ public void routesMissingImport_noBuildOrServerSideRoutes_exceptionThrown() } @Test - public void routesMissingImport_expectionThrown() throws IOException { + public void routesMissingImport_exceptionThrown() throws IOException { String content = """ import HelloWorldView from 'Frontend/views/helloworld/HelloWorldView.js'; import MainLayout from 'Frontend/views/MainLayout.js'; @@ -260,14 +269,13 @@ public void routesMissingImport_expectionThrown() throws IOException { } @Test - public void routesContainsRoutesExport_noExceptionThrown() + public void missingImport_noServerRoutesDefined_noExceptionThrown() throws IOException, ExecutionFailedException { String content = """ import HelloWorldView from 'Frontend/views/helloworld/HelloWorldView.js'; import MainLayout from 'Frontend/views/MainLayout.js'; import { lazy } from 'react'; import { createBrowserRouter, RouteObject } from 'react-router-dom'; - import {serverSideRoutes} from "Frontend/generated/flow/Flow"; import {protectRoutes} from "@hilla/react-auth"; import LoginView from "Frontend/views/LoginView"; @@ -279,8 +287,7 @@ public void routesContainsRoutesExport_noExceptionThrown() handle: { title: 'Main' }, children: [ { path: '/', element: , handle: { title: 'Hello World', rolesAllowed: ['USER'] } }, - { path: '/about', element: , handle: { title: 'About' } }, - ...serverSideRoutes + { path: '/about', element: , handle: { title: 'About' } } ], }, { path: '/login', element: }, @@ -291,76 +298,105 @@ public void routesContainsRoutesExport_noExceptionThrown() FileUtils.write(routesTsx, content, StandardCharsets.UTF_8); + Mockito.when(classFinder.getAnnotatedClasses(Route.class)) + .thenReturn(Collections.emptySet()); + TaskGenerateReactFiles task = new TaskGenerateReactFiles(options); task.execute(); } @Test - public void missingImport_noServerRoutesDefined_noExceptionThrown() - throws IOException, ExecutionFailedException { + public void routesExportMissing_exceptionThrown() throws IOException { String content = """ import HelloWorldView from 'Frontend/views/helloworld/HelloWorldView.js'; import MainLayout from 'Frontend/views/MainLayout.js'; import { lazy } from 'react'; - import { createBrowserRouter, RouteObject } from 'react-router-dom'; + import { RouterConfigurationBuilder } from '@vaadin/hilla-file-router/runtime.js'; + import Flow from 'Frontend/generated/flow/Flow'; import {protectRoutes} from "@hilla/react-auth"; import LoginView from "Frontend/views/LoginView"; const AboutView = lazy(async () => import('Frontend/views/about/AboutView.js')); - - export const routes: RouteObject[] = protectRoutes([ - { - element: , - handle: { title: 'Main' }, - children: [ - { path: '/', element: , handle: { title: 'Hello World', rolesAllowed: ['USER'] } }, - { path: '/about', element: , handle: { title: 'About' } } - ], - }, - { path: '/login', element: }, - ]); - - export default createBrowserRouter(routes); + export const { router } = new RouterConfigurationBuilder() + .withReactRoutes([ + { + element: , + handle: { title: 'Main' }, + children: [ + { path: '/', element: , handle: { title: 'Hello World', rolesAllowed: ['USER'] } }, + { path: '/about', element: , handle: { title: 'About' } }, + ], + }, + ]) + .withFallback(Flow) + .withReactRoutes([ + { path: '/login', element: , handle: { title: 'Login' } }, + ]) + .protect() + .build(); """; FileUtils.write(routesTsx, content, StandardCharsets.UTF_8); - Mockito.when(classFinder.getAnnotatedClasses(Route.class)) - .thenReturn(Collections.emptySet()); - TaskGenerateReactFiles task = new TaskGenerateReactFiles(options); - task.execute(); + Exception exception = Assert.assertThrows( + ExecutionFailedException.class, () -> task.execute()); + Assert.assertEquals(TaskGenerateReactFiles.MISSING_ROUTES_EXPORT, + exception.getMessage()); } @Test - public void routesexportMissing_expectionThrown() throws IOException { + public void withFallbackMissing_exceptionThrown() throws IOException { String content = """ import HelloWorldView from 'Frontend/views/helloworld/HelloWorldView.js'; import MainLayout from 'Frontend/views/MainLayout.js'; import { lazy } from 'react'; - import { createBrowserRouter, RouteObject } from 'react-router-dom'; - import {serverSideRoutes} from "Frontend/generated/flow/Flow"; + import { RouterConfigurationBuilder } from '@vaadin/hilla-file-router/runtime.js'; + import Flow from 'Frontend/generated/flow/Flow'; import {protectRoutes} from "@hilla/react-auth"; import LoginView from "Frontend/views/LoginView"; const AboutView = lazy(async () => import('Frontend/views/about/AboutView.js')); + export const { router, routes } = new RouterConfigurationBuilder() + .withReactRoutes([ + { + element: , + handle: { title: 'Main' }, + children: [ + { path: '/', element: , handle: { title: 'Hello World', rolesAllowed: ['USER'] } }, + { path: '/about', element: , handle: { title: 'About' } }, + ], + }, + ]) + .withReactRoutes([ + { path: '/login', element: , handle: { title: 'Login' } }, + ]) + .protect() + .build(); + """; - const routes: RouteObject[] = protectRoutes([ - { - element: , - handle: { title: 'Main' }, - children: [ - { path: '/', element: , handle: { title: 'Hello World', rolesAllowed: ['USER'] } }, - { path: '/about', element: , handle: { title: 'About' } }, - ...serverSideRoutes - ], - }, - { path: '/login', element: }, - ]); + FileUtils.write(routesTsx, content, StandardCharsets.UTF_8); - export default createBrowserRouter(routes); + TaskGenerateReactFiles task = new TaskGenerateReactFiles(options); + + Exception exception = Assert.assertThrows( + ExecutionFailedException.class, () -> task.execute()); + Assert.assertEquals(String.format(TaskGenerateReactFiles.NO_IMPORT, + routesTsx.getPath()), exception.getMessage()); + } + + @Test + public void withFallbackReceivesDifferentObject_exceptionThrown() + throws IOException { + String content = """ + import { RouterConfigurationBuilder } from '@vaadin/hilla-file-router/runtime.js'; + import foo from 'Frontend/generated/flow/Flow'; + + export const { router, routes } = new RouterConfigurationBuilder() + .withFallback(Flow) + .build(); """; FileUtils.write(routesTsx, content, StandardCharsets.UTF_8); @@ -369,8 +405,29 @@ public void routesexportMissing_expectionThrown() throws IOException { Exception exception = Assert.assertThrows( ExecutionFailedException.class, () -> task.execute()); - Assert.assertEquals(TaskGenerateReactFiles.MISSING_ROUTES_EXPORT, - exception.getMessage()); + Assert.assertEquals(String.format(TaskGenerateReactFiles.NO_IMPORT, + routesTsx.getPath()), exception.getMessage()); + } + + @Test + public void withFallbackMissesImport_exceptionThrown() throws IOException { + String content = """ + import { RouterConfigurationBuilder } from '@vaadin/hilla-file-router/runtime.js'; + + const AboutView = lazy(async () => import('Frontend/views/about/AboutView.js')); + export const { router, routes } = new RouterConfigurationBuilder() + .withFallback(Flow) + .build(); + """; + + FileUtils.write(routesTsx, content, StandardCharsets.UTF_8); + + TaskGenerateReactFiles task = new TaskGenerateReactFiles(options); + + Exception exception = Assert.assertThrows( + ExecutionFailedException.class, () -> task.execute()); + Assert.assertEquals(String.format(TaskGenerateReactFiles.NO_IMPORT, + routesTsx.getPath()), exception.getMessage()); } @Test diff --git a/flow-tests/test-frontend/vite-basics/src/main/frontend/routes.tsx b/flow-tests/test-frontend/vite-basics/src/main/frontend/routes.tsx index 183b4813a99..1163597e694 100644 --- a/flow-tests/test-frontend/vite-basics/src/main/frontend/routes.tsx +++ b/flow-tests/test-frontend/vite-basics/src/main/frontend/routes.tsx @@ -2,9 +2,14 @@ import { createBrowserRouter, RouteObject } from 'react-router-dom'; import { serverSideRoutes } from 'Frontend/generated/flow/Flow'; import ReactComponents from './ReactComponents'; -export const routes = [ - { path: 'react-components', element: }, - ...serverSideRoutes -] as RouteObject[]; - -export default createBrowserRouter(routes); +function build() { + const routes = [ + { path: 'react-components', element: }, + ...serverSideRoutes + ] as RouteObject[]; + return { + router: createBrowserRouter(routes), + routes + }; +} +export const { router, routes } = build() diff --git a/flow-tests/test-react-router/src/main/frontend/routes.tsx b/flow-tests/test-react-router/src/main/frontend/routes.tsx index 8f19b38777d..c7a047e0efd 100644 --- a/flow-tests/test-react-router/src/main/frontend/routes.tsx +++ b/flow-tests/test-react-router/src/main/frontend/routes.tsx @@ -2,11 +2,15 @@ import ReactView from "Frontend/ReactView"; import { serverSideRoutes } from "Frontend/generated/flow/Flow"; import { createBrowserRouter, RouteObject } from "react-router-dom"; - -export const routes = [ - {path: '/react', element: , handle: { title: 'React Test View' }}, - ...serverSideRoutes -] as RouteObject[]; - -export default createBrowserRouter([...routes], { basename: new URL(document.baseURI).pathname }); +function build() { + const routes = [ + {path: '/react', element: , handle: { title: 'React Test View' }}, + ...serverSideRoutes + ] as RouteObject[]; + return { + router: createBrowserRouter([...routes], { basename: new URL(document.baseURI).pathname }), + routes + }; +} +export const { router, routes } = build()