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 authored and GitHub committed 2024-12-19 12:23:21 +00:00
1 parent 16ae192456
commit 3efd25d06d
11 files changed
+130 -25

No files matched your search

@@ -4,6 +4,7 @@ import type {
CustomFieldModelContainerMap, CustomFieldModelContainerMap,
CustomFieldModelFormTabsMap, CustomFieldModelFormTabsMap,
InjectionZone, InjectionZone,
NestedRoutePosition,
} from "@medusajs/admin-shared" } from "@medusajs/admin-shared"
import type { ComponentType } from "react" import type { ComponentType } from "react"
import { ZodFirstPartySchemaTypes } from "zod" 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. * An optional icon to display in the sidebar together with the label. If no label is provided, the icon will be ignored.
*/ */
icon?: ComponentType icon?: ComponentType
/**
* The nested route to display under existing route in the sidebar.
*/
nested?: NestedRoutePosition
} }
export type CustomFormField< export type CustomFormField<
@@ -0,0 +1,8 @@
export const NESTED_ROUTE_POSITIONS = [
"/orders",
"/products",
"/inventory",
"/customers",
"/promotions",
"/price-lists",
] as const
@@ -0,0 +1,2 @@
export * from "./constants"
export * from "./types"
@@ -0,0 +1,3 @@
import { NESTED_ROUTE_POSITIONS } from "./constants"
export type NestedRoutePosition = (typeof NESTED_ROUTE_POSITIONS)[number]
+1
View File
@@ -1,3 +1,4 @@
export * from "./extensions/custom-fields" export * from "./extensions/custom-fields"
export * from "./extensions/routes"
export * from "./extensions/widgets" export * from "./extensions/widgets"
export * from "./virtual-modules" export * from "./virtual-modules"
@@ -44,6 +44,21 @@ const mockFileContents = [
label: "Page 2", 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 export default Page
`, `,
] ]
@@ -54,11 +69,19 @@ const expectedMenuItems = `
label: RouteConfig0.label, label: RouteConfig0.label,
icon: RouteConfig0.icon, icon: RouteConfig0.icon,
path: "/one", path: "/one",
nested: undefined
}, },
{ {
label: RouteConfig1.label, label: RouteConfig1.label,
icon: undefined, icon: undefined,
path: "/two", path: "/two",
nested: undefined
},
{
label: RouteConfig2.label,
icon: RouteConfig2.icon,
path: "/three",
nested: "/products"
} }
] ]
` `
@@ -68,6 +91,7 @@ describe("generateMenuItems", () => {
const mockFiles = [ const mockFiles = [
"Users/user/medusa/src/admin/routes/one/page.tsx", "Users/user/medusa/src/admin/routes/one/page.tsx",
"Users/user/medusa/src/admin/routes/two/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) vi.mocked(utils.crawl).mockResolvedValue(mockFiles)
@@ -82,6 +106,7 @@ describe("generateMenuItems", () => {
expect(result.imports).toEqual([ expect(result.imports).toEqual([
`import { config as RouteConfig0 } from "Users/user/medusa/src/admin/routes/one/page.tsx"`, `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 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( expect(utils.normalizeString(result.code)).toEqual(
utils.normalizeString(expectedMenuItems) utils.normalizeString(expectedMenuItems)
@@ -93,6 +118,7 @@ describe("generateMenuItems", () => {
const mockFiles = [ const mockFiles = [
"C:\\medusa\\src\\admin\\routes\\one\\page.tsx", "C:\\medusa\\src\\admin\\routes\\one\\page.tsx",
"C:\\medusa\\src\\admin\\routes\\two\\page.tsx", "C:\\medusa\\src\\admin\\routes\\two\\page.tsx",
"C:\\medusa\\src\\admin\\routes\\three\\page.tsx",
] ]
vi.mocked(utils.crawl).mockResolvedValue(mockFiles) vi.mocked(utils.crawl).mockResolvedValue(mockFiles)
@@ -105,6 +131,7 @@ describe("generateMenuItems", () => {
expect(result.imports).toEqual([ expect(result.imports).toEqual([
`import { config as RouteConfig0 } from "C:/medusa/src/admin/routes/one/page.tsx"`, `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 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( expect(utils.normalizeString(result.code)).toEqual(
utils.normalizeString(expectedMenuItems) utils.normalizeString(expectedMenuItems)
@@ -16,11 +16,19 @@ import {
normalizePath, normalizePath,
} from "../utils" } from "../utils"
import { getRoute } from "./helpers" import { getRoute } from "./helpers"
import { NESTED_ROUTE_POSITIONS } from "@medusajs/admin-shared"
type RouteConfig = {
label: boolean
icon: boolean
nested?: string
}
type MenuItem = { type MenuItem = {
icon?: string icon?: string
label: string label: string
path: string path: string
nested?: string
} }
type MenuItemResult = { type MenuItemResult = {
@@ -32,13 +40,10 @@ export async function generateMenuItems(sources: Set<string>) {
const files = await getFilesFromSources(sources) const files = await getFilesFromSources(sources)
const results = await getMenuItemResults(files) const results = await getMenuItemResults(files)
const imports = results.map((result) => result.import).flat() const imports = results.map((result) => result.import)
const code = generateCode(results) const code = generateCode(results)
return { return { imports, code }
imports,
code,
}
} }
function generateCode(results: MenuItemResult[]): string { function generateCode(results: MenuItemResult[]): string {
@@ -53,10 +58,12 @@ function generateCode(results: MenuItemResult[]): string {
} }
function formatMenuItem(route: MenuItem): string { function formatMenuItem(route: MenuItem): string {
const { label, icon, path, nested } = route
return `{ return `{
label: ${route.label}, label: ${label},
icon: ${route.icon ? route.icon : "undefined"}, icon: ${icon || "undefined"},
path: "${route.path}", path: "${path}",
nested: ${nested ? `"${nested}"` : "undefined"}
}` }`
} }
@@ -107,25 +114,21 @@ function generateImport(file: string, index: number): string {
} }
function generateMenuItem( function generateMenuItem(
config: { label: boolean; icon: boolean }, config: RouteConfig,
file: string, file: string,
index: number index: number
): MenuItem { ): MenuItem {
const configName = generateRouteConfigName(index) const configName = generateRouteConfigName(index)
const routePath = getRoute(file)
return { return {
label: `${configName}.label`, label: `${configName}.label`,
icon: config.icon ? `${configName}.icon` : undefined, icon: config.icon ? `${configName}.icon` : undefined,
path: routePath, path: getRoute(file),
nested: config.nested,
} }
} }
async function getRouteConfig( async function getRouteConfig(file: string): Promise<RouteConfig | null> {
file: string
): Promise<{ label: boolean; icon: boolean } | null> {
const code = await fs.readFile(file, "utf-8") const code = await fs.readFile(file, "utf-8")
let ast: ParseResult<File> | null = null let ast: ParseResult<File> | null = null
try { try {
@@ -138,32 +141,50 @@ async function getRouteConfig(
return null return null
} }
let config: { label: boolean; icon: boolean } | null = null let config: RouteConfig | null = null
try { try {
traverse(ast, { traverse(ast, {
ExportNamedDeclaration(path) { ExportNamedDeclaration(path) {
const properties = getConfigObjectProperties(path) const properties = getConfigObjectProperties(path)
if (!properties) { if (!properties) {
return return
} }
const hasLabel = properties.some( const hasProperty = (name: string) =>
(prop) => properties.some(
isObjectProperty(prop) && isIdentifier(prop.key, { name: "label" }) (prop) => isObjectProperty(prop) && isIdentifier(prop.key, { name })
) )
const hasLabel = hasProperty("label")
if (!hasLabel) { if (!hasLabel) {
return return
} }
const hasIcon = properties.some( const nested = properties.find(
(prop) => (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) { } catch (e) {
@@ -284,6 +284,19 @@ const Searchbar = () => {
const CoreRouteSection = () => { const CoreRouteSection = () => {
const coreRoutes = useCoreRoutes() 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 ( return (
<nav className="flex flex-col gap-y-1 py-3"> <nav className="flex flex-col gap-y-1 py-3">
<Searchbar /> <Searchbar />
@@ -298,7 +311,7 @@ const ExtensionRouteSection = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { getMenu } = useDashboardExtension() const { getMenu } = useDashboardExtension()
const menuItems = getMenu("coreExtensions") const menuItems = getMenu("coreExtensions").filter((item) => !item.nested)
if (!menuItems.length) { if (!menuItems.length) {
return null return null
@@ -26,6 +26,7 @@ export type INavItem = {
items?: NestedItemProps[] items?: NestedItemProps[]
type?: ItemType type?: ItemType
from?: string from?: string
nested?: string
} }
const BASE_NAV_LINK_CLASSES = const BASE_NAV_LINK_CLASSES =
@@ -4,6 +4,7 @@ import {
CustomFieldFormZone, CustomFieldFormZone,
CustomFieldModel, CustomFieldModel,
InjectionZone, InjectionZone,
NESTED_ROUTE_POSITIONS,
} from "@medusajs/admin-shared" } from "@medusajs/admin-shared"
import * as React from "react" import * as React from "react"
import { INavItem } from "../../components/layout/nav-item" import { INavItem } from "../../components/layout/nav-item"
@@ -112,11 +113,31 @@ export class DashboardExtensionManager {
return // Skip this item entirely 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 = { const navItem: INavItem = {
label: item.label, label: item.label,
to: item.path, to: item.path,
icon: item.icon ? <item.icon /> : undefined, icon: item.icon ? <item.icon /> : undefined,
items: [], items: [],
nested: item.nested,
} }
if (parentPath !== "/" && tempRegistry[parentPath]) { if (parentPath !== "/" && tempRegistry[parentPath]) {
@@ -4,6 +4,7 @@ import {
CustomFieldFormZone, CustomFieldFormZone,
CustomFieldModel, CustomFieldModel,
InjectionZone, InjectionZone,
NestedRoutePosition,
} from "@medusajs/admin-shared" } from "@medusajs/admin-shared"
import { ComponentType } from "react" import { ComponentType } from "react"
import { LoaderFunction } from "react-router-dom" import { LoaderFunction } from "react-router-dom"
@@ -19,6 +20,7 @@ export type MenuItemExtension = {
label: string label: string
path: string path: string
icon?: ComponentType icon?: ComponentType
nested?: NestedRoutePosition
} }
export type WidgetExtension = { export type WidgetExtension = {