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)