diff --git a/.changeset/metal-lamps-film.md b/.changeset/metal-lamps-film.md
new file mode 100644
index 0000000000..383e3e9d70
--- /dev/null
+++ b/.changeset/metal-lamps-film.md
@@ -0,0 +1,7 @@
+---
+"@medusajs/admin-vite-plugin": patch
+"@medusajs/admin-sdk": patch
+"@medusajs/dashboard": patch
+---
+
+feat(dashboard): add custom admin route ranking feature
diff --git a/packages/admin/admin-sdk/src/config/types.ts b/packages/admin/admin-sdk/src/config/types.ts
index d2bf36232e..ee9fc9c1d7 100644
--- a/packages/admin/admin-sdk/src/config/types.ts
+++ b/packages/admin/admin-sdk/src/config/types.ts
@@ -31,6 +31,12 @@ export interface RouteConfig {
*/
nested?: NestedRoutePosition
+ /**
+ * The ranking of the route among sibling routes. Routes are sorted in ascending order (lower rank appears first).
+ * If not provided, the route will be ranked after all routes with explicit ranks.
+ */
+ rank?: number
+
/**
* An optional i18n namespace for translating the label. When provided, the label will be treated as a translation key.
* @example
diff --git a/packages/admin/admin-vite-plugin/src/babel.ts b/packages/admin/admin-vite-plugin/src/babel.ts
index 697278bc70..888d7d2e51 100644
--- a/packages/admin/admin-vite-plugin/src/babel.ts
+++ b/packages/admin/admin-vite-plugin/src/babel.ts
@@ -11,6 +11,7 @@ import {
isJSXElement,
isJSXFragment,
isMemberExpression,
+ isNumericLiteral,
isObjectExpression,
isObjectProperty,
isStringLiteral,
@@ -47,6 +48,7 @@ export {
isJSXElement,
isJSXFragment,
isMemberExpression,
+ isNumericLiteral,
isObjectExpression,
isObjectProperty,
isStringLiteral,
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 0f509e003f..9d443cd54d 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
@@ -70,6 +70,7 @@ const expectedMenuItems = `
icon: RouteConfig0.icon,
path: "/one",
nested: undefined,
+ rank: undefined,
translationNs: undefined
},
{
@@ -77,6 +78,7 @@ const expectedMenuItems = `
icon: undefined,
path: "/two",
nested: undefined,
+ rank: undefined,
translationNs: undefined
},
{
@@ -84,6 +86,7 @@ const expectedMenuItems = `
icon: RouteConfig2.icon,
path: "/three",
nested: "/products",
+ rank: undefined,
translationNs: undefined
}
]
@@ -141,6 +144,82 @@ describe("generateMenuItems", () => {
)
})
+ it("should include rank property in generated menu items", async () => {
+ const mockFilesWithRank = [
+ "Users/user/medusa/src/admin/routes/analytics/page.tsx",
+ "Users/user/medusa/src/admin/routes/reports/page.tsx",
+ ]
+
+ const mockFileContentsWithRank = [
+ `
+ import { defineRouteConfig } from "@medusajs/admin-sdk"
+
+ const Page = () => {
+ return
Analytics
+ }
+
+ export const config = defineRouteConfig({
+ label: "Analytics",
+ icon: "ChartBar",
+ rank: 1,
+ })
+
+ export default Page
+ `,
+ `
+ import { defineRouteConfig } from "@medusajs/admin-sdk"
+
+ const Page = () => {
+ return Reports
+ }
+
+ export const config = defineRouteConfig({
+ label: "Reports",
+ rank: 2,
+ })
+
+ export default Page
+ `,
+ ]
+
+ vi.mocked(utils.crawl).mockResolvedValue(mockFilesWithRank)
+
+ vi.mocked(fs.readFile).mockImplementation(async (file) =>
+ Promise.resolve(
+ mockFileContentsWithRank[mockFilesWithRank.indexOf(file as string)]
+ )
+ )
+
+ const result = await generateMenuItems(
+ new Set(["Users/user/medusa/src/admin"])
+ )
+
+ const expectedMenuItemsWithRank = `
+ menuItems: [
+ {
+ label: RouteConfig0.label,
+ icon: RouteConfig0.icon,
+ path: "/analytics",
+ nested: undefined,
+ rank: 1,
+ translationNs: undefined
+ },
+ {
+ label: RouteConfig1.label,
+ icon: undefined,
+ path: "/reports",
+ nested: undefined,
+ rank: 2,
+ translationNs: undefined
+ }
+ ]
+ `
+
+ expect(utils.normalizeString(result.code)).toEqual(
+ utils.normalizeString(expectedMenuItemsWithRank)
+ )
+ })
+
it("should handle translationNs field", async () => {
const mockFileWithTranslation = `
import { defineRouteConfig } from "@medusajs/admin-sdk"
@@ -176,6 +255,7 @@ describe("generateMenuItems", () => {
icon: undefined,
path: "/custom",
nested: undefined,
+ rank: undefined,
translationNs: RouteConfig0.translationNs
}
]
@@ -185,4 +265,99 @@ describe("generateMenuItems", () => {
utils.normalizeString(expectedOutput)
)
})
+
+ it("should handle mixed ranked and unranked routes", async () => {
+ const mockMixedFiles = [
+ "Users/user/medusa/src/admin/routes/first/page.tsx",
+ "Users/user/medusa/src/admin/routes/second/page.tsx",
+ "Users/user/medusa/src/admin/routes/third/page.tsx",
+ ]
+
+ const mockMixedContents = [
+ `
+ import { defineRouteConfig } from "@medusajs/admin-sdk"
+
+ const Page = () => {
+ return First
+ }
+
+ export const config = defineRouteConfig({
+ label: "First",
+ rank: 1,
+ })
+
+ export default Page
+ `,
+ `
+ import { defineRouteConfig } from "@medusajs/admin-sdk"
+
+ const Page = () => {
+ return Second
+ }
+
+ export const config = defineRouteConfig({
+ label: "Second",
+ })
+
+ export default Page
+ `,
+ `
+ import { defineRouteConfig } from "@medusajs/admin-sdk"
+
+ const Page = () => {
+ return Third
+ }
+
+ export const config = defineRouteConfig({
+ label: "Third",
+ rank: 0,
+ })
+
+ export default Page
+ `,
+ ]
+
+ vi.mocked(utils.crawl).mockResolvedValue(mockMixedFiles)
+
+ vi.mocked(fs.readFile).mockImplementation(async (file) =>
+ Promise.resolve(mockMixedContents[mockMixedFiles.indexOf(file as string)])
+ )
+
+ const result = await generateMenuItems(
+ new Set(["Users/user/medusa/src/admin"])
+ )
+
+ const expectedMixedMenuItems = `
+ menuItems: [
+ {
+ label: RouteConfig0.label,
+ icon: undefined,
+ path: "/first",
+ nested: undefined,
+ rank: 1,
+ translationNs: undefined
+ },
+ {
+ label: RouteConfig1.label,
+ icon: undefined,
+ path: "/second",
+ nested: undefined,
+ rank: undefined,
+ translationNs: undefined
+ },
+ {
+ label: RouteConfig2.label,
+ icon: undefined,
+ path: "/third",
+ nested: undefined,
+ rank: 0,
+ translationNs: undefined
+ }
+ ]
+ `
+
+ expect(utils.normalizeString(result.code)).toEqual(
+ utils.normalizeString(expectedMixedMenuItems)
+ )
+ })
})
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 aec298ff7a..b55c81b0e6 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
@@ -7,6 +7,7 @@ import { outdent } from "outdent"
import {
File,
isIdentifier,
+ isNumericLiteral,
isObjectProperty,
isStringLiteral,
Node,
@@ -27,7 +28,8 @@ import { getRoute } from "./helpers"
type RouteConfig = {
label: boolean
icon: boolean
- nested?: string
+ nested?: NestedRoutePosition
+ rank?: number
translationNs?: string
}
@@ -35,7 +37,8 @@ type MenuItem = {
icon?: string
label: string
path: string
- nested?: string
+ nested?: NestedRoutePosition
+ rank?: number
translationNs?: string
}
@@ -66,12 +69,13 @@ function generateCode(results: MenuItemResult[]): string {
}
function formatMenuItem(route: MenuItem): string {
- const { label, icon, path, nested, translationNs } = route
+ const { label, icon, path, nested, rank, translationNs } = route
return `{
label: ${label},
icon: ${icon || "undefined"},
path: "${path}",
nested: ${nested ? `"${nested}"` : "undefined"},
+ rank: ${rank !== undefined ? rank : "undefined"},
translationNs: ${translationNs ? `${translationNs}` : "undefined"}
}`
}
@@ -133,7 +137,10 @@ function generateMenuItem(
icon: config.icon ? `${configName}.icon` : undefined,
path: getRoute(file),
nested: config.nested,
- translationNs: config.translationNs ? `${configName}.translationNs` : undefined,
+ rank: config.rank,
+ translationNs: config.translationNs
+ ? `${configName}.translationNs`
+ : undefined,
}
}
@@ -225,7 +232,7 @@ function processConfigProperties(
isObjectProperty(prop) && isIdentifier(prop.key, { name: "nested" })
) as ObjectProperty | undefined
- let nestedValue: string | undefined = undefined
+ let nestedValue: string | undefined
if (isStringLiteral(nested?.value)) {
nestedValue = nested.value.value
@@ -255,10 +262,22 @@ function processConfigProperties(
translationNsValue = translationNs.value.value
}
+ const rank = properties.find(
+ (prop) =>
+ isObjectProperty(prop) && isIdentifier(prop.key, { name: "rank" })
+ ) as ObjectProperty | undefined
+
+ let rankValue: number | undefined
+
+ if (isNumericLiteral(rank?.value)) {
+ rankValue = rank.value.value
+ }
+
return {
label: hasLabel,
icon: hasProperty("icon"),
- nested: nestedValue,
+ nested: nestedValue as NestedRoutePosition | undefined,
+ rank: rankValue,
translationNs: translationNsValue,
}
}
diff --git a/packages/admin/dashboard/src/dashboard-app/__tests__/sort-menu-items-by-rank.spec.ts b/packages/admin/dashboard/src/dashboard-app/__tests__/sort-menu-items-by-rank.spec.ts
new file mode 100644
index 0000000000..60de1112fd
--- /dev/null
+++ b/packages/admin/dashboard/src/dashboard-app/__tests__/sort-menu-items-by-rank.spec.ts
@@ -0,0 +1,169 @@
+import { describe, expect, it } from "vitest"
+import { sortMenuItemsByRank } from "../utils/sort-menu-items-by-rank"
+import { INavItem } from "../../components/layout/nav-item"
+
+describe("sortMenuItemsByRank", () => {
+ it("should sort items by rank in ascending order", () => {
+ const items: INavItem[] = [
+ { label: "Third", to: "/third", rank: 3 },
+ { label: "First", to: "/first", rank: 1 },
+ { label: "Second", to: "/second", rank: 2 },
+ ]
+
+ const sorted = sortMenuItemsByRank(items)
+
+ expect(sorted[0].label).toBe("First")
+ expect(sorted[1].label).toBe("Second")
+ expect(sorted[2].label).toBe("Third")
+ })
+
+ it("should place items with rank before items without rank", () => {
+ const items: INavItem[] = [
+ { label: "No Rank", to: "/no-rank" },
+ { label: "Ranked 2", to: "/ranked-2", rank: 2 },
+ { label: "Ranked 1", to: "/ranked-1", rank: 1 },
+ { label: "Also No Rank", to: "/also-no-rank" },
+ ]
+
+ const sorted = sortMenuItemsByRank(items)
+
+ expect(sorted[0].label).toBe("Ranked 1")
+ expect(sorted[1].label).toBe("Ranked 2")
+ expect(sorted[2].label).toBe("No Rank")
+ expect(sorted[3].label).toBe("Also No Rank")
+ })
+
+ it("should handle items with rank 0", () => {
+ const items: INavItem[] = [
+ { label: "Rank 2", to: "/rank-2", rank: 2 },
+ { label: "Rank 0", to: "/rank-0", rank: 0 },
+ { label: "Rank 1", to: "/rank-1", rank: 1 },
+ ]
+
+ const sorted = sortMenuItemsByRank(items)
+
+ expect(sorted[0].label).toBe("Rank 0")
+ expect(sorted[1].label).toBe("Rank 1")
+ expect(sorted[2].label).toBe("Rank 2")
+ })
+
+ it("should handle negative ranks", () => {
+ const items: INavItem[] = [
+ { label: "Rank 1", to: "/rank-1", rank: 1 },
+ { label: "Rank -1", to: "/rank-minus-1", rank: -1 },
+ { label: "Rank 0", to: "/rank-0", rank: 0 },
+ ]
+
+ const sorted = sortMenuItemsByRank(items)
+
+ expect(sorted[0].label).toBe("Rank -1")
+ expect(sorted[1].label).toBe("Rank 0")
+ expect(sorted[2].label).toBe("Rank 1")
+ })
+
+ it("should sort nested items independently", () => {
+ const items: INavItem[] = [
+ {
+ label: "Parent 2",
+ to: "/parent-2",
+ rank: 2,
+ items: [
+ { label: "Child 2B", to: "/child-2b", rank: 2 },
+ { label: "Child 2A", to: "/child-2a", rank: 1 },
+ ],
+ },
+ {
+ label: "Parent 1",
+ to: "/parent-1",
+ rank: 1,
+ items: [
+ { label: "Child 1C", to: "/child-1c", rank: 3 },
+ { label: "Child 1A", to: "/child-1a", rank: 1 },
+ { label: "Child 1B", to: "/child-1b", rank: 2 },
+ ],
+ },
+ ]
+
+ const sorted = sortMenuItemsByRank(items)
+
+ // Parents should be sorted
+ expect(sorted[0].label).toBe("Parent 1")
+ expect(sorted[1].label).toBe("Parent 2")
+
+ // Parent 1's children should be sorted
+ expect(sorted[0].items![0].label).toBe("Child 1A")
+ expect(sorted[0].items![1].label).toBe("Child 1B")
+ expect(sorted[0].items![2].label).toBe("Child 1C")
+
+ // Parent 2's children should be sorted
+ expect(sorted[1].items![0].label).toBe("Child 2A")
+ expect(sorted[1].items![1].label).toBe("Child 2B")
+ })
+
+ it("should handle nested items with mixed ranked and unranked", () => {
+ const items: INavItem[] = [
+ {
+ label: "Parent",
+ to: "/parent",
+ rank: 1,
+ items: [
+ { label: "No Rank Child", to: "/no-rank" },
+ { label: "Rank 1 Child", to: "/rank-1", rank: 1 },
+ { label: "Rank 2 Child", to: "/rank-2", rank: 2 },
+ ],
+ },
+ ]
+
+ const sorted = sortMenuItemsByRank(items)
+
+ expect(sorted[0].items![0].label).toBe("Rank 1 Child")
+ expect(sorted[0].items![1].label).toBe("Rank 2 Child")
+ expect(sorted[0].items![2].label).toBe("No Rank Child")
+ })
+
+ it("should handle empty items array", () => {
+ const items: INavItem[] = []
+
+ const sorted = sortMenuItemsByRank(items)
+
+ expect(sorted).toEqual([])
+ })
+
+ it("should handle single item", () => {
+ const items: INavItem[] = [{ label: "Only Item", to: "/only", rank: 1 }]
+
+ const sorted = sortMenuItemsByRank(items)
+
+ expect(sorted).toHaveLength(1)
+ expect(sorted[0].label).toBe("Only Item")
+ })
+
+ it("should preserve items without nested arrays", () => {
+ const items: INavItem[] = [
+ { label: "Item 1", to: "/item-1", rank: 2 },
+ { label: "Item 2", to: "/item-2", rank: 1 },
+ ]
+
+ const sorted = sortMenuItemsByRank(items)
+
+ expect(sorted[0].items).toBeUndefined()
+ expect(sorted[1].items).toBeUndefined()
+ })
+
+ it("should handle duplicate rank values", () => {
+ const items: INavItem[] = [
+ { label: "Item C", to: "/item-c", rank: 1 },
+ { label: "Item A", to: "/item-a", rank: 1 },
+ { label: "Item B", to: "/item-b", rank: 1 },
+ ]
+
+ const sorted = sortMenuItemsByRank(items)
+
+ // All should have rank 1, order should be stable
+ expect(sorted[0].rank).toBe(1)
+ expect(sorted[1].rank).toBe(1)
+ expect(sorted[2].rank).toBe(1)
+ expect(sorted).toHaveLength(3)
+ })
+})
+
diff --git a/packages/admin/dashboard/src/dashboard-app/dashboard-app.tsx b/packages/admin/dashboard/src/dashboard-app/dashboard-app.tsx
index acc7fff552..bf66a3e5dd 100644
--- a/packages/admin/dashboard/src/dashboard-app/dashboard-app.tsx
+++ b/packages/admin/dashboard/src/dashboard-app/dashboard-app.tsx
@@ -18,6 +18,7 @@ import { Providers } from "../providers"
import coreTranslations from "../i18n/translations"
import { getRouteMap } from "./routes/get-route.map"
import { createRouteMap, getRouteExtensions } from "./routes/utils"
+import { sortMenuItemsByRank } from "./utils/sort-menu-items-by-rank"
import {
ConfigExtension,
ConfigField,
@@ -181,12 +182,13 @@ export class DashboardApp {
return
}
- const navItem: INavItem = {
+ const navItem: INavItem & { rank?: number } = {
label: item.label,
to: item.path,
icon: item.icon ? : undefined,
items: [],
nested: item.nested,
+ rank: item.rank,
translationNs: item.translationNs,
}
@@ -205,6 +207,12 @@ export class DashboardApp {
tempRegistry[item.path] = navItem
})
+ // Sort menu items by rank (ascending order, undefined ranks come last)
+ registry.forEach((items, key) => {
+ const sorted = sortMenuItemsByRank(items)
+ registry.set(key, sorted)
+ })
+
return registry
}
diff --git a/packages/admin/dashboard/src/dashboard-app/types.ts b/packages/admin/dashboard/src/dashboard-app/types.ts
index 245fd2628e..c2b0de588c 100644
--- a/packages/admin/dashboard/src/dashboard-app/types.ts
+++ b/packages/admin/dashboard/src/dashboard-app/types.ts
@@ -24,6 +24,7 @@ export type MenuItemExtension = {
path: string
icon?: ComponentType
nested?: NestedRoutePosition
+ rank?: number
translationNs?: string
}
diff --git a/packages/admin/dashboard/src/dashboard-app/utils/sort-menu-items-by-rank.ts b/packages/admin/dashboard/src/dashboard-app/utils/sort-menu-items-by-rank.ts
new file mode 100644
index 0000000000..d3c2b89c03
--- /dev/null
+++ b/packages/admin/dashboard/src/dashboard-app/utils/sort-menu-items-by-rank.ts
@@ -0,0 +1,43 @@
+import { INavItem } from "../../components/layout/nav-item"
+
+/**
+ * Sort menu items by rank in ascending order.
+ * Items with rank come first, sorted by rank value.
+ * Items without rank come last, maintaining their original order.
+ * Recursively sorts nested items independently.
+ */
+export function sortMenuItemsByRank(
+ items: (INavItem & { rank?: number })[]
+): INavItem[] {
+ // Sort items by rank (ascending order)
+ // Items with rank come first, sorted by rank value
+ // Items without rank come last, maintaining their original order
+ const sortedItems = items.sort((a, b) => {
+ // If both have rank, sort by rank value
+ if (a.rank !== undefined && b.rank !== undefined) {
+ return a.rank - b.rank
+ }
+ // If only a has rank, it comes first
+ if (a.rank !== undefined) {
+ return -1
+ }
+ // If only b has rank, it comes first
+ if (b.rank !== undefined) {
+ return 1
+ }
+ // If neither has rank, maintain original order
+ return 0
+ })
+
+ // Recursively sort nested items
+ sortedItems.forEach((item) => {
+ if (item.items && item.items.length > 0) {
+ item.items = sortMenuItemsByRank(
+ item.items as (INavItem & { rank?: number })[]
+ )
+ }
+ })
+
+ return sortedItems
+}
+