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 (