feat(dashboard): ability to locate new admin route under existing route (#10587)

This PR add ability to locate new admin route under existing route in sidebar.

For example, new route Brands
![image](https://github.com/user-attachments/assets/1b297fb0-296c-4e94-a4cb-f84f4c676c53)
![image](https://github.com/user-attachments/assets/80336909-1c0a-49c9-b8e1-3b1137ae2e48)

https://github.com/user-attachments/assets/b46b1813-e92e-4b67-84a1-84660023ac7c
This commit is contained in:
Eugene Pro
2024-12-19 14:23:21 +02:00
committed by GitHub
parent 16ae192456
commit 3efd25d06d
11 changed files with 131 additions and 26 deletions

View File

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

View File

@@ -0,0 +1,8 @@
export const NESTED_ROUTE_POSITIONS = [
"/orders",
"/products",
"/inventory",
"/customers",
"/promotions",
"/price-lists",
] as const

View File

@@ -0,0 +1,2 @@
export * from "./constants"
export * from "./types"

View File

@@ -0,0 +1,3 @@
import { NESTED_ROUTE_POSITIONS } from "./constants"
export type NestedRoutePosition = (typeof NESTED_ROUTE_POSITIONS)[number]

View File

@@ -1,3 +1,4 @@
export * from "./extensions/custom-fields"
export * from "./extensions/routes"
export * from "./extensions/widgets"
export * from "./virtual-modules"

View File

@@ -44,6 +44,21 @@ const mockFileContents = [
label: "Page 2",
})
export default Page
`,
`
import { defineRouteConfig } from "@medusajs/admin-sdk"
const Page = () => {
return <div>Page 2</div>
}
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)

View File

@@ -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<string>) {
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<RouteConfig | null> {
const code = await fs.readFile(file, "utf-8")
let ast: ParseResult<File> | 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) {

View File

@@ -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 (
<nav className="flex flex-col gap-y-1 py-3">
<Searchbar />
@@ -298,7 +311,7 @@ const ExtensionRouteSection = () => {
const { t } = useTranslation()
const { getMenu } = useDashboardExtension()
const menuItems = getMenu("coreExtensions")
const menuItems = getMenu("coreExtensions").filter((item) => !item.nested)
if (!menuItems.length) {
return null

View File

@@ -26,6 +26,7 @@ export type INavItem = {
items?: NestedItemProps[]
type?: ItemType
from?: string
nested?: string
}
const BASE_NAV_LINK_CLASSES =

View File

@@ -4,6 +4,7 @@ import {
CustomFieldFormZone,
CustomFieldModel,
InjectionZone,
NESTED_ROUTE_POSITIONS,
} from "@medusajs/admin-shared"
import * as React from "react"
import { INavItem } from "../../components/layout/nav-item"
@@ -112,11 +113,31 @@ export class DashboardExtensionManager {
return // Skip this item entirely
}
// Find the parent item if it exists
const parentItem = menuItems.find(
(menuItem) => menuItem.path === parentPath
)
// Check if parent item is a nested route under existing route
if (
parentItem?.nested &&
NESTED_ROUTE_POSITIONS.includes(parentItem?.nested) &&
pathParts.length > 1
) {
if (process.env.NODE_ENV === "development") {
console.warn(
`[@medusajs/dashboard] Nested menu item "${item.path}" can't be added to the sidebar as it is nested under "${parentItem.nested}".`
)
}
return
}
const navItem: INavItem = {
label: item.label,
to: item.path,
icon: item.icon ? <item.icon /> : undefined,
items: [],
nested: item.nested,
}
if (parentPath !== "/" && tempRegistry[parentPath]) {

View File

@@ -4,6 +4,7 @@ import {
CustomFieldFormZone,
CustomFieldModel,
InjectionZone,
NestedRoutePosition,
} from "@medusajs/admin-shared"
import { ComponentType } from "react"
import { LoaderFunction } from "react-router-dom"
@@ -19,6 +20,7 @@ export type MenuItemExtension = {
label: string
path: string
icon?: ComponentType
nested?: NestedRoutePosition
}
export type WidgetExtension = {