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<ReturnType<typeof loader>>;

  return (
    <div>
      <Container className="flex justify-between items-center">
        <Heading>Article {id}</Heading>
        <Button size="small" variant="secondary" asChild>
          <Link to="edit">Edit</Link>
        </Button>
      </Container>
      {/* This will be used for the next example of an Outlet route */}
      <Outlet />
    </div>
  );
};

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 (
    <div>
      {/* Form goes here */}
    </div>
  );
};

export default ProfileEditPage;
```
This outlet route will be rendered in the <Outlet /> in the above example when the URL is /articles/1/edit

Resolves CMRC-913, CMRC-914, CMRC-915
This commit is contained in:
Kasper Fabricius Kristensen
2025-02-13 21:37:55 +01:00
committed by GitHub
parent c08e6ad5cf
commit a88f6576bd
6 changed files with 488 additions and 61 deletions

View File

@@ -0,0 +1,6 @@
---
"@medusajs/admin-vite-plugin": patch
"@medusajs/dashboard": patch
---
feat(dashboard,admin-vite-plugin): Add support for parallel routes

View File

@@ -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 <div>Brands</div>
}
export const config = defineRouteConfig({
label: "Brands",
})
export default Page
`,
// Parallel route
`
import { defineRouteConfig } from "@medusajs/admin-sdk"
const Page = () => {
return <div>Create Brand</div>
}
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 <div>Page 1</div>
}
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 <div>Page 2</div>
}
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)
)
})
})

View File

@@ -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<RouteResult[]> {
const results = (await Promise.all(files.map(parseFile))).filter(
(result): result is RouteResult => result !== null
)
return results
const routeMap = new Map<string, RouteResult>()
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<RouteResult | null> {
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<boolean> {
const code = await fs.readFile(file, "utf-8")
let ast: ParseResult<File> | null = null
@@ -97,9 +133,29 @@ async function isValidRouteFile(file: string): Promise<boolean> {
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>,
file: string
): Promise<boolean> {
try {
return await hasDefaultExport(ast)
} catch (e) {
@@ -110,23 +166,96 @@ async function isValidRouteFile(file: string): Promise<boolean> {
}
}
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>,
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 }
}

View File

@@ -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

View File

@@ -13,6 +13,8 @@ import { ZodFirstPartySchemaTypes } from "zod"
export type RouteExtension = {
Component: ComponentType
loader?: LoaderFunction
handle?: object
children?: RouteExtension[]
path: string
}

View File

@@ -10,3 +10,4 @@ const routes = getRouteExtensions(routeModule, "core")
* Core Route extensions.
*/
export const RouteExtensions = createRouteMap(routes)
console.log(RouteExtensions)