diff --git a/packages/admin/admin-sdk/src/config/types.ts b/packages/admin/admin-sdk/src/config/types.ts
index f3e8b75959a33..a41b7737ad20e 100644
--- a/packages/admin/admin-sdk/src/config/types.ts
+++ b/packages/admin/admin-sdk/src/config/types.ts
@@ -4,6 +4,7 @@ import type {
CustomFieldModelContainerMap,
CustomFieldModelFormTabsMap,
InjectionZone,
+ NestedRoutePosition,
} from "@medusajs/admin-shared"
import type { ComponentType } from "react"
import { ZodFirstPartySchemaTypes } from "zod"
@@ -24,6 +25,11 @@ export interface RouteConfig {
* An optional icon to display in the sidebar together with the label. If no label is provided, the icon will be ignored.
*/
icon?: ComponentType
+
+ /**
+ * The nested route to display under existing route in the sidebar.
+ */
+ nested?: NestedRoutePosition
}
export type CustomFormField<
diff --git a/packages/admin/admin-shared/src/extensions/routes/constants.ts b/packages/admin/admin-shared/src/extensions/routes/constants.ts
new file mode 100644
index 0000000000000..3e1f89ba65f8d
--- /dev/null
+++ b/packages/admin/admin-shared/src/extensions/routes/constants.ts
@@ -0,0 +1,8 @@
+export const NESTED_ROUTE_POSITIONS = [
+ "/orders",
+ "/products",
+ "/inventory",
+ "/customers",
+ "/promotions",
+ "/price-lists",
+] as const
diff --git a/packages/admin/admin-shared/src/extensions/routes/index.ts b/packages/admin/admin-shared/src/extensions/routes/index.ts
new file mode 100644
index 0000000000000..6c07e3e75f6dc
--- /dev/null
+++ b/packages/admin/admin-shared/src/extensions/routes/index.ts
@@ -0,0 +1,2 @@
+export * from "./constants"
+export * from "./types"
diff --git a/packages/admin/admin-shared/src/extensions/routes/types.ts b/packages/admin/admin-shared/src/extensions/routes/types.ts
new file mode 100644
index 0000000000000..068f3739b1bd7
--- /dev/null
+++ b/packages/admin/admin-shared/src/extensions/routes/types.ts
@@ -0,0 +1,3 @@
+import { NESTED_ROUTE_POSITIONS } from "./constants"
+
+export type NestedRoutePosition = (typeof NESTED_ROUTE_POSITIONS)[number]
diff --git a/packages/admin/admin-shared/src/index.ts b/packages/admin/admin-shared/src/index.ts
index 19779d6831dc1..676ccd4cf021a 100644
--- a/packages/admin/admin-shared/src/index.ts
+++ b/packages/admin/admin-shared/src/index.ts
@@ -1,3 +1,4 @@
export * from "./extensions/custom-fields"
+export * from "./extensions/routes"
export * from "./extensions/widgets"
export * from "./virtual-modules"
diff --git a/packages/admin/admin-vite-plugin/src/routes/__tests__/generate-menu-items.spec.ts b/packages/admin/admin-vite-plugin/src/routes/__tests__/generate-menu-items.spec.ts
index cb8ccd98eaa39..07f13b3cfbb25 100644
--- a/packages/admin/admin-vite-plugin/src/routes/__tests__/generate-menu-items.spec.ts
+++ b/packages/admin/admin-vite-plugin/src/routes/__tests__/generate-menu-items.spec.ts
@@ -44,6 +44,21 @@ const mockFileContents = [
label: "Page 2",
})
+ export default Page
+ `,
+ `
+ import { defineRouteConfig } from "@medusajs/admin-sdk"
+
+ const Page = () => {
+ return
Page 2
+ }
+
+ export const config = defineRouteConfig({
+ label: "Page 3",
+ icon: "icon1",
+ nested: "/products"
+ })
+
export default Page
`,
]
@@ -54,11 +69,19 @@ const expectedMenuItems = `
label: RouteConfig0.label,
icon: RouteConfig0.icon,
path: "/one",
+ nested: undefined
},
{
label: RouteConfig1.label,
icon: undefined,
path: "/two",
+ nested: undefined
+ },
+ {
+ label: RouteConfig2.label,
+ icon: RouteConfig2.icon,
+ path: "/three",
+ nested: "/products"
}
]
`
@@ -68,6 +91,7 @@ describe("generateMenuItems", () => {
const mockFiles = [
"Users/user/medusa/src/admin/routes/one/page.tsx",
"Users/user/medusa/src/admin/routes/two/page.tsx",
+ "Users/user/medusa/src/admin/routes/three/page.tsx",
]
vi.mocked(utils.crawl).mockResolvedValue(mockFiles)
@@ -82,6 +106,7 @@ describe("generateMenuItems", () => {
expect(result.imports).toEqual([
`import { config as RouteConfig0 } from "Users/user/medusa/src/admin/routes/one/page.tsx"`,
`import { config as RouteConfig1 } from "Users/user/medusa/src/admin/routes/two/page.tsx"`,
+ `import { config as RouteConfig2 } from "Users/user/medusa/src/admin/routes/three/page.tsx"`,
])
expect(utils.normalizeString(result.code)).toEqual(
utils.normalizeString(expectedMenuItems)
@@ -93,6 +118,7 @@ describe("generateMenuItems", () => {
const mockFiles = [
"C:\\medusa\\src\\admin\\routes\\one\\page.tsx",
"C:\\medusa\\src\\admin\\routes\\two\\page.tsx",
+ "C:\\medusa\\src\\admin\\routes\\three\\page.tsx",
]
vi.mocked(utils.crawl).mockResolvedValue(mockFiles)
@@ -105,6 +131,7 @@ describe("generateMenuItems", () => {
expect(result.imports).toEqual([
`import { config as RouteConfig0 } from "C:/medusa/src/admin/routes/one/page.tsx"`,
`import { config as RouteConfig1 } from "C:/medusa/src/admin/routes/two/page.tsx"`,
+ `import { config as RouteConfig2 } from "C:/medusa/src/admin/routes/three/page.tsx"`,
])
expect(utils.normalizeString(result.code)).toEqual(
utils.normalizeString(expectedMenuItems)
diff --git a/packages/admin/admin-vite-plugin/src/routes/generate-menu-items.ts b/packages/admin/admin-vite-plugin/src/routes/generate-menu-items.ts
index b473bf98f5db2..9210050964b2c 100644
--- a/packages/admin/admin-vite-plugin/src/routes/generate-menu-items.ts
+++ b/packages/admin/admin-vite-plugin/src/routes/generate-menu-items.ts
@@ -16,11 +16,19 @@ import {
normalizePath,
} from "../utils"
import { getRoute } from "./helpers"
+import { NESTED_ROUTE_POSITIONS } from "@medusajs/admin-shared"
+
+type RouteConfig = {
+ label: boolean
+ icon: boolean
+ nested?: string
+}
type MenuItem = {
icon?: string
label: string
path: string
+ nested?: string
}
type MenuItemResult = {
@@ -32,13 +40,10 @@ export async function generateMenuItems(sources: Set) {
const files = await getFilesFromSources(sources)
const results = await getMenuItemResults(files)
- const imports = results.map((result) => result.import).flat()
+ const imports = results.map((result) => result.import)
const code = generateCode(results)
- return {
- imports,
- code,
- }
+ return { imports, code }
}
function generateCode(results: MenuItemResult[]): string {
@@ -53,10 +58,12 @@ function generateCode(results: MenuItemResult[]): string {
}
function formatMenuItem(route: MenuItem): string {
+ const { label, icon, path, nested } = route
return `{
- label: ${route.label},
- icon: ${route.icon ? route.icon : "undefined"},
- path: "${route.path}",
+ label: ${label},
+ icon: ${icon || "undefined"},
+ path: "${path}",
+ nested: ${nested ? `"${nested}"` : "undefined"}
}`
}
@@ -107,25 +114,21 @@ function generateImport(file: string, index: number): string {
}
function generateMenuItem(
- config: { label: boolean; icon: boolean },
+ config: RouteConfig,
file: string,
index: number
): MenuItem {
const configName = generateRouteConfigName(index)
- const routePath = getRoute(file)
-
return {
label: `${configName}.label`,
icon: config.icon ? `${configName}.icon` : undefined,
- path: routePath,
+ path: getRoute(file),
+ nested: config.nested,
}
}
-async function getRouteConfig(
- file: string
-): Promise<{ label: boolean; icon: boolean } | null> {
+async function getRouteConfig(file: string): Promise {
const code = await fs.readFile(file, "utf-8")
-
let ast: ParseResult | null = null
try {
@@ -138,32 +141,50 @@ async function getRouteConfig(
return null
}
- let config: { label: boolean; icon: boolean } | null = null
+ let config: RouteConfig | null = null
try {
traverse(ast, {
ExportNamedDeclaration(path) {
const properties = getConfigObjectProperties(path)
-
if (!properties) {
return
}
- const hasLabel = properties.some(
- (prop) =>
- isObjectProperty(prop) && isIdentifier(prop.key, { name: "label" })
- )
+ const hasProperty = (name: string) =>
+ properties.some(
+ (prop) => isObjectProperty(prop) && isIdentifier(prop.key, { name })
+ )
+ const hasLabel = hasProperty("label")
if (!hasLabel) {
return
}
- const hasIcon = properties.some(
+ const nested = properties.find(
(prop) =>
- isObjectProperty(prop) && isIdentifier(prop.key, { name: "icon" })
+ isObjectProperty(prop) && isIdentifier(prop.key, { name: "nested" })
)
- config = { label: hasLabel, icon: hasIcon }
+ const nestedValue = nested ? (nested as any).value.value : undefined
+
+ if (nestedValue && !NESTED_ROUTE_POSITIONS.includes(nestedValue)) {
+ logger.error(
+ `Invalid nested route position: "${nestedValue}". Allowed values are: ${NESTED_ROUTE_POSITIONS.join(
+ ", "
+ )}`,
+ {
+ file,
+ }
+ )
+ return
+ }
+
+ config = {
+ label: hasLabel,
+ icon: hasProperty("icon"),
+ nested: nestedValue,
+ }
},
})
} catch (e) {
diff --git a/packages/admin/dashboard/src/components/layout/main-layout/main-layout.tsx b/packages/admin/dashboard/src/components/layout/main-layout/main-layout.tsx
index 7742af1d67ad3..9f382466230fb 100644
--- a/packages/admin/dashboard/src/components/layout/main-layout/main-layout.tsx
+++ b/packages/admin/dashboard/src/components/layout/main-layout/main-layout.tsx
@@ -284,6 +284,19 @@ const Searchbar = () => {
const CoreRouteSection = () => {
const coreRoutes = useCoreRoutes()
+ const { getMenu } = useDashboardExtension()
+
+ const menuItems = getMenu("coreExtensions")
+
+ menuItems.forEach((item) => {
+ if (item.nested) {
+ const route = coreRoutes.find((route) => route.to === item.nested)
+ if (route) {
+ route.items?.push(item)
+ }
+ }
+ })
+
return (