From a88f6576bd06522f86fb2f8bd4b49f94bd4017c1 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Thu, 13 Feb 2025 21:37:55 +0100 Subject: [PATCH] feat(dashboard,admin-vite-plugin): Add support for outlet routes, loader, and handle (#11305) **What** - Add support for defining outlet routes using `@`, e.g. `/src/admin/routes/brands/@create/page.tsx` - Add support for exporting a `loader` from a route file. - Add support for exporting a `handle` from a route file. Example usage of a loader and handle: ```tsx // src/admin/routes/articles/[id]/page.tsx import { Button, Container, Heading } from "@medusajs/ui"; import { Link, LoaderFunctionArgs, Outlet, UIMatch, useLoaderData, } from "react-router-dom"; export async function loader({ params }: LoaderFunctionArgs) { const { id } = params; return { id, }; } export const handle = { breadcrumb: (match: UIMatch<{ id: string }>) => { const { id } = match.params; return `#${id}`; }, }; const ProfilePage = () => { const { id } = useLoaderData() as Awaited>; return (
Article {id} {/* This will be used for the next example of an Outlet route */}
); }; export default ProfilePage; ``` In the above example we are passing data to the route from a loader, and defining a breadcrumb using the handle. Example of a outlet route: ```tsx // src/admin/routes/articles/[id]/@edit/page.tsx import { Button, Container, Heading } from "@medusajs/ui"; const ProfileEditPage = () => { return (
{/* Form goes here */}
); }; export default ProfileEditPage; ``` This outlet route will be rendered in the in the above example when the URL is /articles/1/edit Resolves CMRC-913, CMRC-914, CMRC-915 --- .changeset/rude-balloons-report.md | 6 + .../routes/__tests__/generate-routes.spec.ts | 167 ++++++++++++++- .../src/routes/generate-routes.ts | 177 +++++++++++++--- .../dashboard/src/extensions/routes/utils.ts | 196 ++++++++++++++---- .../admin/dashboard/src/extensions/types.ts | 2 + .../router-provider/route-extensions.tsx | 1 + 6 files changed, 488 insertions(+), 61 deletions(-) create mode 100644 .changeset/rude-balloons-report.md diff --git a/.changeset/rude-balloons-report.md b/.changeset/rude-balloons-report.md new file mode 100644 index 0000000000..701b58c4a7 --- /dev/null +++ b/.changeset/rude-balloons-report.md @@ -0,0 +1,6 @@ +--- +"@medusajs/admin-vite-plugin": patch +"@medusajs/dashboard": patch +--- + +feat(dashboard,admin-vite-plugin): Add support for parallel routes diff --git a/packages/admin/admin-vite-plugin/src/routes/__tests__/generate-routes.spec.ts b/packages/admin/admin-vite-plugin/src/routes/__tests__/generate-routes.spec.ts index 74fb82c188..74191022db 100644 --- a/packages/admin/admin-vite-plugin/src/routes/__tests__/generate-routes.spec.ts +++ b/packages/admin/admin-vite-plugin/src/routes/__tests__/generate-routes.spec.ts @@ -54,15 +54,112 @@ const expectedRoutesWithoutLoaders = ` routes: [ { Component: RouteComponent0, - path: "/one", + path: "/one" }, { Component: RouteComponent1, - path: "/two", + path: "/two" } ] ` +const mockFileContentsWithParallel = [ + // Parent route + ` + import { defineRouteConfig } from "@medusajs/admin-sdk" + const Page = () => { + return
Brands
+ } + export const config = defineRouteConfig({ + label: "Brands", + }) + export default Page + `, + // Parallel route + ` + import { defineRouteConfig } from "@medusajs/admin-sdk" + const Page = () => { + return
Create Brand
+ } + export const config = defineRouteConfig({ + label: "Create Brand", + }) + export default Page + `, +] + +const expectedRoutesWithParallel = ` + routes: [ + { + Component: RouteComponent0, + path: "/brands", + children: [ + { + Component: RouteComponent1, + path: "/brands/create" + } + ] + } + ] +` + +const mockFileContentsWithHandleLoader = [ + ` + import { defineRouteConfig } from "@medusajs/admin-sdk" + + const Page = () => { + return
Page 1
+ } + + export const handle = { + someConfig: true + } + + export const loader = async () => { + return { data: true } + } + + export const config = defineRouteConfig({ + label: "Page 1", + }) + + export default Page + `, + ` + import { defineRouteConfig } from "@medusajs/admin-sdk" + + const Page = () => { + return
Page 2
+ } + + export async function loader() { + return { data: true } + } + + export const config = defineRouteConfig({ + label: "Page 2", + }) + + export default Page + `, +] + +const expectedRoutesWithHandleLoader = ` + routes: [ + { + Component: RouteComponent0, + path: "/one", + handle: handle0, + loader: loader0 + }, + { + Component: RouteComponent1, + path: "/two", + loader: loader1 + } + ] +` + describe("generateRoutes", () => { it("should generate routes", async () => { const mockFiles = [ @@ -103,4 +200,70 @@ describe("generateRoutes", () => { utils.normalizeString(expectedRoutesWithoutLoaders) ) }) + it("should handle parallel routes", async () => { + const mockFiles = [ + "Users/user/medusa/src/admin/routes/brands/page.tsx", + "Users/user/medusa/src/admin/routes/brands/@create/page.tsx", + ] + vi.mocked(utils.crawl).mockResolvedValue(mockFiles) + + vi.mocked(fs.readFile).mockImplementation(async (file) => + Promise.resolve( + mockFileContentsWithParallel[mockFiles.indexOf(file as string)] + ) + ) + + vi.mocked(fs.stat).mockRejectedValue(new Error("File not found")) + + const result = await generateRoutes( + new Set(["Users/user/medusa/src/admin"]) + ) + expect(utils.normalizeString(result.code)).toEqual( + utils.normalizeString(expectedRoutesWithParallel) + ) + }) + it("should handle parallel routes with windows paths", async () => { + const mockFiles = [ + "C:\\medusa\\src\\admin\\routes\\brands\\page.tsx", + "C:\\medusa\\src\\admin\\routes\\brands\\@create\\page.tsx", + ] + vi.mocked(utils.crawl).mockResolvedValue(mockFiles) + + vi.mocked(fs.readFile).mockImplementation(async (file) => + Promise.resolve( + mockFileContentsWithParallel[mockFiles.indexOf(file as string)] + ) + ) + + vi.mocked(fs.stat).mockRejectedValue(new Error("File not found")) + + const result = await generateRoutes(new Set(["C:\\medusa\\src\\admin"])) + + expect(utils.normalizeString(result.code)).toEqual( + utils.normalizeString(expectedRoutesWithParallel) + ) + }) + it("should handle routes with handle and loader exports", async () => { + const mockFiles = [ + "Users/user/medusa/src/admin/routes/one/page.tsx", + "Users/user/medusa/src/admin/routes/two/page.tsx", + ] + vi.mocked(utils.crawl).mockResolvedValue(mockFiles) + + vi.mocked(fs.readFile).mockImplementation(async (file) => + Promise.resolve( + mockFileContentsWithHandleLoader[mockFiles.indexOf(file as string)] + ) + ) + + vi.mocked(fs.stat).mockRejectedValue(new Error("File not found")) + + const result = await generateRoutes( + new Set(["Users/user/medusa/src/admin"]) + ) + + expect(utils.normalizeString(result.code)).toEqual( + utils.normalizeString(expectedRoutesWithHandleLoader) + ) + }) }) diff --git a/packages/admin/admin-vite-plugin/src/routes/generate-routes.ts b/packages/admin/admin-vite-plugin/src/routes/generate-routes.ts index 4e9575e270..830686bcc7 100644 --- a/packages/admin/admin-vite-plugin/src/routes/generate-routes.ts +++ b/packages/admin/admin-vite-plugin/src/routes/generate-routes.ts @@ -1,6 +1,6 @@ import fs from "fs/promises" import { outdent } from "outdent" -import { File, parse, ParseResult } from "../babel" +import { File, parse, ParseResult, traverse } from "../babel" import { logger } from "../logger" import { crawl, @@ -13,6 +13,9 @@ import { getRoute } from "./helpers" type Route = { Component: string path: string + handle?: string + loader?: string + children?: Route[] } type RouteResult = { @@ -42,9 +45,29 @@ function generateCode(results: RouteResult[]): string { } function formatRoute(route: Route): string { - return `{ + let base = `{ Component: ${route.Component}, - path: "${route.path}", + path: "${route.path}"` + + if (route.handle) { + base += `, + handle: ${route.handle}` + } + + if (route.loader) { + base += `, + loader: ${route.loader}` + } + + if (route.children?.length) { + return `${base}, + children: [ + ${route.children.map((child) => formatRoute(child)).join(",\n ")} + ] + }` + } + + return `${base} }` } @@ -63,29 +86,42 @@ async function getRouteResults(files: string[]): Promise { const results = (await Promise.all(files.map(parseFile))).filter( (result): result is RouteResult => result !== null ) - return results + + const routeMap = new Map() + + results.forEach((result) => { + const routePath = result.route.path + const isParallel = routePath.includes("/@") + + if (isParallel) { + const parentPath = routePath.split("/@")[0] + const parent = routeMap.get(parentPath) + if (parent) { + parent.route.children = parent.route.children || [] + + /** + * We do not want to include the @ in the final path, so we remove it. + */ + const finalRoute = { + ...result.route, + path: result.route.path.replace("@", ""), + } + + parent.route.children.push(finalRoute) + parent.imports.push(...result.imports) + } + } else { + routeMap.set(routePath, result) + } + }) + + return Array.from(routeMap.values()) } async function parseFile( file: string, index: number ): Promise { - if (!(await isValidRouteFile(file))) { - return null - } - - const routePath = getRoute(file) - - const imports = generateImports(file, index) - const route = generateRoute(routePath, index) - - return { - imports, - route, - } -} - -async function isValidRouteFile(file: string): Promise { const code = await fs.readFile(file, "utf-8") let ast: ParseResult | null = null @@ -97,9 +133,29 @@ async function isValidRouteFile(file: string): Promise { file, error: e, }) - return false + return null } + if (!(await isValidRouteFile(ast, file))) { + return null + } + + const { hasHandle, hasLoader } = await hasNamedExports(ast, file) + const routePath = getRoute(file) + + const imports = generateImports(file, index, hasHandle, hasLoader) + const route = generateRoute(routePath, index, hasHandle, hasLoader) + + return { + imports, + route, + } +} + +async function isValidRouteFile( + ast: ParseResult, + file: string +): Promise { try { return await hasDefaultExport(ast) } catch (e) { @@ -110,23 +166,96 @@ async function isValidRouteFile(file: string): Promise { } } -function generateImports(file: string, index: number): string[] { +function generateImports( + file: string, + index: number, + hasHandle: boolean, + hasLoader: boolean +): string[] { const imports: string[] = [] const route = generateRouteComponentName(index) const importPath = normalizePath(file) - imports.push(`import ${route} from "${importPath}"`) + if (!hasHandle && !hasLoader) { + imports.push(`import ${route} from "${importPath}"`) + } else { + const namedImports = [ + hasHandle && `handle as ${generateHandleName(index)}`, + hasLoader && `loader as ${generateLoaderName(index)}`, + ] + .filter(Boolean) + .join(", ") + imports.push(`import ${route}, { ${namedImports} } from "${importPath}"`) + } return imports } -function generateRoute(route: string, index: number): Route { +function generateRoute( + route: string, + index: number, + hasHandle: boolean, + hasLoader: boolean +): Route { return { Component: generateRouteComponentName(index), path: route, + handle: hasHandle ? generateHandleName(index) : undefined, + loader: hasLoader ? generateLoaderName(index) : undefined, } } function generateRouteComponentName(index: number): string { return `RouteComponent${index}` } + +function generateHandleName(index: number): string { + return `handle${index}` +} + +function generateLoaderName(index: number): string { + return `loader${index}` +} + +async function hasNamedExports( + ast: ParseResult, + file: string +): Promise<{ hasHandle: boolean; hasLoader: boolean }> { + let hasHandle = false + let hasLoader = false + + try { + traverse(ast, { + ExportNamedDeclaration(path) { + const declaration = path.node.declaration + + // Handle: export const handle = {...} + if (declaration?.type === "VariableDeclaration") { + declaration.declarations.forEach((decl) => { + if (decl.id.type === "Identifier" && decl.id.name === "handle") { + hasHandle = true + } + if (decl.id.type === "Identifier" && decl.id.name === "loader") { + hasLoader = true + } + }) + } + + // Handle: export function loader() or export async function loader() + if ( + declaration?.type === "FunctionDeclaration" && + declaration.id?.name === "loader" + ) { + hasLoader = true + } + }, + }) + } catch (e) { + logger.error("An error occurred while checking for named exports.", { + file, + error: e, + }) + } + + return { hasHandle, hasLoader } +} diff --git a/packages/admin/dashboard/src/extensions/routes/utils.ts b/packages/admin/dashboard/src/extensions/routes/utils.ts index 2512167ae9..af2eb1aa60 100644 --- a/packages/admin/dashboard/src/extensions/routes/utils.ts +++ b/packages/admin/dashboard/src/extensions/routes/utils.ts @@ -1,5 +1,5 @@ import { ComponentType } from "react" -import { RouteObject } from "react-router-dom" +import { LoaderFunction, RouteObject } from "react-router-dom" import { ErrorBoundary } from "../../components/utilities/error-boundary" import { RouteExtension, RouteModule } from "../types" @@ -21,50 +21,176 @@ export const getRouteExtensions = ( }) } +/** + * Creates a route object for a branch node in the route tree + * @param segment - The path segment for this branch + */ +const createBranchRoute = (segment: string): RouteObject => ({ + path: segment, + children: [], +}) + +/** + * Creates a route object for a leaf node with its component + * @param Component - The React component to render at this route + */ +const createLeafRoute = ( + Component: ComponentType, + loader?: LoaderFunction, + handle?: object +): RouteObject => ({ + path: "", + ErrorBoundary: ErrorBoundary, + async lazy() { + const result: { + Component: ComponentType + loader?: LoaderFunction + handle?: object + } = { Component } + + if (loader) { + result.loader = loader + } + + if (handle) { + result.handle = handle + } + + return result + }, +}) + +/** + * Creates a parallel route configuration + * @param path - The route path + * @param Component - The React component to render + */ +const createParallelRoute = ( + path: string, + Component: ComponentType, + loader?: LoaderFunction, + handle?: object +) => ({ + path, + async lazy() { + const result: { + Component: ComponentType + loader?: LoaderFunction + handle?: object + } = { Component } + + if (loader) { + result.loader = loader + } + + if (handle) { + result.handle = handle + } + + return result + }, +}) + +/** + * Processes parallel routes by cleaning their paths relative to the current path + * @param parallelRoutes - Array of parallel route extensions + * @param currentFullPath - The full path of the current route + */ +const processParallelRoutes = ( + parallelRoutes: RouteExtension[] | undefined, + currentFullPath: string +): RouteObject[] | undefined => { + return parallelRoutes + ?.map(({ path, Component, loader, handle }) => { + const childPath = path?.replace(currentFullPath, "").replace(/^\/+/, "") + if (!childPath) { + return null + } + return createParallelRoute(childPath, Component, loader, handle) + }) + .filter(Boolean) as RouteObject[] +} + +/** + * Recursively builds the route tree by adding routes at the correct level + * @param pathSegments - Array of remaining path segments to process + * @param Component - The React component for the route + * @param currentLevel - Current level in the route tree + * @param parallelRoutes - Optional parallel routes to add + * @param fullPath - The full path up to the current level + */ +const addRoute = ( + pathSegments: string[], + Component: ComponentType, + currentLevel: RouteObject[], + loader?: LoaderFunction, + handle?: object, + parallelRoutes?: RouteExtension[], + fullPath?: string +) => { + if (!pathSegments.length) { + return + } + + const [currentSegment, ...remainingSegments] = pathSegments + let route = currentLevel.find((r) => r.path === currentSegment) + + if (!route) { + route = createBranchRoute(currentSegment) + currentLevel.push(route) + } + + const currentFullPath = fullPath + ? `${fullPath}/${currentSegment}` + : currentSegment + + if (remainingSegments.length === 0) { + route.children ||= [] + const leaf = createLeafRoute(Component, loader) + + /** + * The handle needs to be set on the wrapper route object, + * in order for it to be resolved correctly thoughout + * the branch. + */ + if (handle) { + route.handle = handle + } + + leaf.children = processParallelRoutes(parallelRoutes, currentFullPath) + route.children.push(leaf) + } else { + route.children ||= [] + addRoute( + remainingSegments, + Component, + route.children, + loader, + handle, + parallelRoutes, + currentFullPath + ) + } +} + +/** + * Creates a complete route map from route extensions + * @param routes - Array of route extensions to process + * @param ignore - Optional path prefix to ignore when processing routes + * @returns An array of route objects forming a route tree + */ export const createRouteMap = ( routes: RouteExtension[], ignore?: string ): RouteObject[] => { const root: RouteObject[] = [] - const addRoute = ( - pathSegments: string[], - Component: ComponentType, - currentLevel: RouteObject[] - ) => { - if (!pathSegments.length) { - return - } - - const [currentSegment, ...remainingSegments] = pathSegments - let route = currentLevel.find((r) => r.path === currentSegment) - - if (!route) { - route = { path: currentSegment, children: [] } - currentLevel.push(route) - } - - if (remainingSegments.length === 0) { - route.children ||= [] - route.children.push({ - path: "", - ErrorBoundary: ErrorBoundary, - async lazy() { - return { Component } - }, - }) - } else { - route.children ||= [] - addRoute(remainingSegments, Component, route.children) - } - } - - routes.forEach(({ path, Component }) => { + routes.forEach(({ path, Component, loader, handle, children }) => { const cleanedPath = ignore ? path.replace(ignore, "").replace(/^\/+/, "") : path.replace(/^\/+/, "") const pathSegments = cleanedPath.split("/").filter(Boolean) - addRoute(pathSegments, Component, root) + addRoute(pathSegments, Component, root, loader, handle, children) }) return root diff --git a/packages/admin/dashboard/src/extensions/types.ts b/packages/admin/dashboard/src/extensions/types.ts index 666c86937c..4d42070d69 100644 --- a/packages/admin/dashboard/src/extensions/types.ts +++ b/packages/admin/dashboard/src/extensions/types.ts @@ -13,6 +13,8 @@ import { ZodFirstPartySchemaTypes } from "zod" export type RouteExtension = { Component: ComponentType loader?: LoaderFunction + handle?: object + children?: RouteExtension[] path: string } diff --git a/packages/admin/dashboard/src/providers/router-provider/route-extensions.tsx b/packages/admin/dashboard/src/providers/router-provider/route-extensions.tsx index 278c460b53..4e66e8c472 100644 --- a/packages/admin/dashboard/src/providers/router-provider/route-extensions.tsx +++ b/packages/admin/dashboard/src/providers/router-provider/route-extensions.tsx @@ -10,3 +10,4 @@ const routes = getRouteExtensions(routeModule, "core") * Core Route extensions. */ export const RouteExtensions = createRouteMap(routes) +console.log(RouteExtensions)