feat(admin): add custom admin route ranking feature (#13946)
* ✨ Add custom admin routes ranking * 🐛 Fix sorting * 📝 Update admin ui-routes documentation * ✅ Add admin menu items spec * 🔧 Add changeset * 🐛 Remove redundant undefined initializations * 🔥 🔥 Move the documentation to a separate PR * ♻️ Move sorting logic to utils * 🔧 Update changeset --------- Co-authored-by: Bastien MONTOIS <bqst@bqst-hqckintosh.home>
This commit is contained in:
1 parent
aae92d5447
commit
213c344804
9 files changed
+437
-7
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/admin-vite-plugin": patch
|
||||||
|
"@medusajs/admin-sdk": patch
|
||||||
|
"@medusajs/dashboard": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
feat(dashboard): add custom admin route ranking feature
|
||||||
@@ -31,6 +31,12 @@ export interface RouteConfig {
|
|||||||
*/
|
*/
|
||||||
nested?: NestedRoutePosition
|
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.
|
* An optional i18n namespace for translating the label. When provided, the label will be treated as a translation key.
|
||||||
* @example
|
* @example
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
isJSXElement,
|
isJSXElement,
|
||||||
isJSXFragment,
|
isJSXFragment,
|
||||||
isMemberExpression,
|
isMemberExpression,
|
||||||
|
isNumericLiteral,
|
||||||
isObjectExpression,
|
isObjectExpression,
|
||||||
isObjectProperty,
|
isObjectProperty,
|
||||||
isStringLiteral,
|
isStringLiteral,
|
||||||
@@ -47,6 +48,7 @@ export {
|
|||||||
isJSXElement,
|
isJSXElement,
|
||||||
isJSXFragment,
|
isJSXFragment,
|
||||||
isMemberExpression,
|
isMemberExpression,
|
||||||
|
isNumericLiteral,
|
||||||
isObjectExpression,
|
isObjectExpression,
|
||||||
isObjectProperty,
|
isObjectProperty,
|
||||||
isStringLiteral,
|
isStringLiteral,
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ const expectedMenuItems = `
|
|||||||
icon: RouteConfig0.icon,
|
icon: RouteConfig0.icon,
|
||||||
path: "/one",
|
path: "/one",
|
||||||
nested: undefined,
|
nested: undefined,
|
||||||
|
rank: undefined,
|
||||||
translationNs: undefined
|
translationNs: undefined
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -77,6 +78,7 @@ const expectedMenuItems = `
|
|||||||
icon: undefined,
|
icon: undefined,
|
||||||
path: "/two",
|
path: "/two",
|
||||||
nested: undefined,
|
nested: undefined,
|
||||||
|
rank: undefined,
|
||||||
translationNs: undefined
|
translationNs: undefined
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -84,6 +86,7 @@ const expectedMenuItems = `
|
|||||||
icon: RouteConfig2.icon,
|
icon: RouteConfig2.icon,
|
||||||
path: "/three",
|
path: "/three",
|
||||||
nested: "/products",
|
nested: "/products",
|
||||||
|
rank: undefined,
|
||||||
translationNs: 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 <div>Analytics</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = defineRouteConfig({
|
||||||
|
label: "Analytics",
|
||||||
|
icon: "ChartBar",
|
||||||
|
rank: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default Page
|
||||||
|
`,
|
||||||
|
`
|
||||||
|
import { defineRouteConfig } from "@medusajs/admin-sdk"
|
||||||
|
|
||||||
|
const Page = () => {
|
||||||
|
return <div>Reports</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
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 () => {
|
it("should handle translationNs field", async () => {
|
||||||
const mockFileWithTranslation = `
|
const mockFileWithTranslation = `
|
||||||
import { defineRouteConfig } from "@medusajs/admin-sdk"
|
import { defineRouteConfig } from "@medusajs/admin-sdk"
|
||||||
@@ -176,6 +255,7 @@ describe("generateMenuItems", () => {
|
|||||||
icon: undefined,
|
icon: undefined,
|
||||||
path: "/custom",
|
path: "/custom",
|
||||||
nested: undefined,
|
nested: undefined,
|
||||||
|
rank: undefined,
|
||||||
translationNs: RouteConfig0.translationNs
|
translationNs: RouteConfig0.translationNs
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -185,4 +265,99 @@ describe("generateMenuItems", () => {
|
|||||||
utils.normalizeString(expectedOutput)
|
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 <div>First</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = defineRouteConfig({
|
||||||
|
label: "First",
|
||||||
|
rank: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default Page
|
||||||
|
`,
|
||||||
|
`
|
||||||
|
import { defineRouteConfig } from "@medusajs/admin-sdk"
|
||||||
|
|
||||||
|
const Page = () => {
|
||||||
|
return <div>Second</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = defineRouteConfig({
|
||||||
|
label: "Second",
|
||||||
|
})
|
||||||
|
|
||||||
|
export default Page
|
||||||
|
`,
|
||||||
|
`
|
||||||
|
import { defineRouteConfig } from "@medusajs/admin-sdk"
|
||||||
|
|
||||||
|
const Page = () => {
|
||||||
|
return <div>Third</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -7,6 +7,7 @@ import { outdent } from "outdent"
|
|||||||
import {
|
import {
|
||||||
File,
|
File,
|
||||||
isIdentifier,
|
isIdentifier,
|
||||||
|
isNumericLiteral,
|
||||||
isObjectProperty,
|
isObjectProperty,
|
||||||
isStringLiteral,
|
isStringLiteral,
|
||||||
Node,
|
Node,
|
||||||
@@ -27,7 +28,8 @@ import { getRoute } from "./helpers"
|
|||||||
type RouteConfig = {
|
type RouteConfig = {
|
||||||
label: boolean
|
label: boolean
|
||||||
icon: boolean
|
icon: boolean
|
||||||
nested?: string
|
nested?: NestedRoutePosition
|
||||||
|
rank?: number
|
||||||
translationNs?: string
|
translationNs?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +37,8 @@ type MenuItem = {
|
|||||||
icon?: string
|
icon?: string
|
||||||
label: string
|
label: string
|
||||||
path: string
|
path: string
|
||||||
nested?: string
|
nested?: NestedRoutePosition
|
||||||
|
rank?: number
|
||||||
translationNs?: string
|
translationNs?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,12 +69,13 @@ function generateCode(results: MenuItemResult[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatMenuItem(route: MenuItem): string {
|
function formatMenuItem(route: MenuItem): string {
|
||||||
const { label, icon, path, nested, translationNs } = route
|
const { label, icon, path, nested, rank, translationNs } = route
|
||||||
return `{
|
return `{
|
||||||
label: ${label},
|
label: ${label},
|
||||||
icon: ${icon || "undefined"},
|
icon: ${icon || "undefined"},
|
||||||
path: "${path}",
|
path: "${path}",
|
||||||
nested: ${nested ? `"${nested}"` : "undefined"},
|
nested: ${nested ? `"${nested}"` : "undefined"},
|
||||||
|
rank: ${rank !== undefined ? rank : "undefined"},
|
||||||
translationNs: ${translationNs ? `${translationNs}` : "undefined"}
|
translationNs: ${translationNs ? `${translationNs}` : "undefined"}
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
@@ -133,7 +137,10 @@ function generateMenuItem(
|
|||||||
icon: config.icon ? `${configName}.icon` : undefined,
|
icon: config.icon ? `${configName}.icon` : undefined,
|
||||||
path: getRoute(file),
|
path: getRoute(file),
|
||||||
nested: config.nested,
|
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" })
|
isObjectProperty(prop) && isIdentifier(prop.key, { name: "nested" })
|
||||||
) as ObjectProperty | undefined
|
) as ObjectProperty | undefined
|
||||||
|
|
||||||
let nestedValue: string | undefined = undefined
|
let nestedValue: string | undefined
|
||||||
|
|
||||||
if (isStringLiteral(nested?.value)) {
|
if (isStringLiteral(nested?.value)) {
|
||||||
nestedValue = nested.value.value
|
nestedValue = nested.value.value
|
||||||
@@ -255,10 +262,22 @@ function processConfigProperties(
|
|||||||
translationNsValue = translationNs.value.value
|
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 {
|
return {
|
||||||
label: hasLabel,
|
label: hasLabel,
|
||||||
icon: hasProperty("icon"),
|
icon: hasProperty("icon"),
|
||||||
nested: nestedValue,
|
nested: nestedValue as NestedRoutePosition | undefined,
|
||||||
|
rank: rankValue,
|
||||||
translationNs: translationNsValue,
|
translationNs: translationNsValue,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ import { Providers } from "../providers"
|
|||||||
import coreTranslations from "../i18n/translations"
|
import coreTranslations from "../i18n/translations"
|
||||||
import { getRouteMap } from "./routes/get-route.map"
|
import { getRouteMap } from "./routes/get-route.map"
|
||||||
import { createRouteMap, getRouteExtensions } from "./routes/utils"
|
import { createRouteMap, getRouteExtensions } from "./routes/utils"
|
||||||
|
import { sortMenuItemsByRank } from "./utils/sort-menu-items-by-rank"
|
||||||
import {
|
import {
|
||||||
ConfigExtension,
|
ConfigExtension,
|
||||||
ConfigField,
|
ConfigField,
|
||||||
@@ -181,12 +182,13 @@ export class DashboardApp {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItem: INavItem = {
|
const navItem: INavItem & { rank?: number } = {
|
||||||
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,
|
nested: item.nested,
|
||||||
|
rank: item.rank,
|
||||||
translationNs: item.translationNs,
|
translationNs: item.translationNs,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +207,12 @@ export class DashboardApp {
|
|||||||
tempRegistry[item.path] = navItem
|
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
|
return registry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export type MenuItemExtension = {
|
|||||||
path: string
|
path: string
|
||||||
icon?: ComponentType
|
icon?: ComponentType
|
||||||
nested?: NestedRoutePosition
|
nested?: NestedRoutePosition
|
||||||
|
rank?: number
|
||||||
translationNs?: string
|
translationNs?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
Reference in new issue
Block a user