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 +} +