Skip to content

Commit 213c344

Browse files
bqstBastien MONTOIS
andauthored
feat(admin): add custom admin route ranking feature (medusajs#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 <[email protected]>
1 parent aae92d5 commit 213c344

File tree

9 files changed

+437
-7
lines changed

9 files changed

+437
-7
lines changed

.changeset/metal-lamps-film.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@medusajs/admin-vite-plugin": patch
3+
"@medusajs/admin-sdk": patch
4+
"@medusajs/dashboard": patch
5+
---
6+
7+
feat(dashboard): add custom admin route ranking feature

packages/admin/admin-sdk/src/config/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ export interface RouteConfig {
3131
*/
3232
nested?: NestedRoutePosition
3333

34+
/**
35+
* The ranking of the route among sibling routes. Routes are sorted in ascending order (lower rank appears first).
36+
* If not provided, the route will be ranked after all routes with explicit ranks.
37+
*/
38+
rank?: number
39+
3440
/**
3541
* An optional i18n namespace for translating the label. When provided, the label will be treated as a translation key.
3642
* @example

packages/admin/admin-vite-plugin/src/babel.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
isJSXElement,
1212
isJSXFragment,
1313
isMemberExpression,
14+
isNumericLiteral,
1415
isObjectExpression,
1516
isObjectProperty,
1617
isStringLiteral,
@@ -47,6 +48,7 @@ export {
4748
isJSXElement,
4849
isJSXFragment,
4950
isMemberExpression,
51+
isNumericLiteral,
5052
isObjectExpression,
5153
isObjectProperty,
5254
isStringLiteral,

packages/admin/admin-vite-plugin/src/routes/__tests__/generate-menu-items.spec.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,20 +70,23 @@ const expectedMenuItems = `
7070
icon: RouteConfig0.icon,
7171
path: "/one",
7272
nested: undefined,
73+
rank: undefined,
7374
translationNs: undefined
7475
},
7576
{
7677
label: RouteConfig1.label,
7778
icon: undefined,
7879
path: "/two",
7980
nested: undefined,
81+
rank: undefined,
8082
translationNs: undefined
8183
},
8284
{
8385
label: RouteConfig2.label,
8486
icon: RouteConfig2.icon,
8587
path: "/three",
8688
nested: "/products",
89+
rank: undefined,
8790
translationNs: undefined
8891
}
8992
]
@@ -141,6 +144,82 @@ describe("generateMenuItems", () => {
141144
)
142145
})
143146

147+
it("should include rank property in generated menu items", async () => {
148+
const mockFilesWithRank = [
149+
"Users/user/medusa/src/admin/routes/analytics/page.tsx",
150+
"Users/user/medusa/src/admin/routes/reports/page.tsx",
151+
]
152+
153+
const mockFileContentsWithRank = [
154+
`
155+
import { defineRouteConfig } from "@medusajs/admin-sdk"
156+
157+
const Page = () => {
158+
return <div>Analytics</div>
159+
}
160+
161+
export const config = defineRouteConfig({
162+
label: "Analytics",
163+
icon: "ChartBar",
164+
rank: 1,
165+
})
166+
167+
export default Page
168+
`,
169+
`
170+
import { defineRouteConfig } from "@medusajs/admin-sdk"
171+
172+
const Page = () => {
173+
return <div>Reports</div>
174+
}
175+
176+
export const config = defineRouteConfig({
177+
label: "Reports",
178+
rank: 2,
179+
})
180+
181+
export default Page
182+
`,
183+
]
184+
185+
vi.mocked(utils.crawl).mockResolvedValue(mockFilesWithRank)
186+
187+
vi.mocked(fs.readFile).mockImplementation(async (file) =>
188+
Promise.resolve(
189+
mockFileContentsWithRank[mockFilesWithRank.indexOf(file as string)]
190+
)
191+
)
192+
193+
const result = await generateMenuItems(
194+
new Set(["Users/user/medusa/src/admin"])
195+
)
196+
197+
const expectedMenuItemsWithRank = `
198+
menuItems: [
199+
{
200+
label: RouteConfig0.label,
201+
icon: RouteConfig0.icon,
202+
path: "/analytics",
203+
nested: undefined,
204+
rank: 1,
205+
translationNs: undefined
206+
},
207+
{
208+
label: RouteConfig1.label,
209+
icon: undefined,
210+
path: "/reports",
211+
nested: undefined,
212+
rank: 2,
213+
translationNs: undefined
214+
}
215+
]
216+
`
217+
218+
expect(utils.normalizeString(result.code)).toEqual(
219+
utils.normalizeString(expectedMenuItemsWithRank)
220+
)
221+
})
222+
144223
it("should handle translationNs field", async () => {
145224
const mockFileWithTranslation = `
146225
import { defineRouteConfig } from "@medusajs/admin-sdk"
@@ -176,6 +255,7 @@ describe("generateMenuItems", () => {
176255
icon: undefined,
177256
path: "/custom",
178257
nested: undefined,
258+
rank: undefined,
179259
translationNs: RouteConfig0.translationNs
180260
}
181261
]
@@ -185,4 +265,99 @@ describe("generateMenuItems", () => {
185265
utils.normalizeString(expectedOutput)
186266
)
187267
})
268+
269+
it("should handle mixed ranked and unranked routes", async () => {
270+
const mockMixedFiles = [
271+
"Users/user/medusa/src/admin/routes/first/page.tsx",
272+
"Users/user/medusa/src/admin/routes/second/page.tsx",
273+
"Users/user/medusa/src/admin/routes/third/page.tsx",
274+
]
275+
276+
const mockMixedContents = [
277+
`
278+
import { defineRouteConfig } from "@medusajs/admin-sdk"
279+
280+
const Page = () => {
281+
return <div>First</div>
282+
}
283+
284+
export const config = defineRouteConfig({
285+
label: "First",
286+
rank: 1,
287+
})
288+
289+
export default Page
290+
`,
291+
`
292+
import { defineRouteConfig } from "@medusajs/admin-sdk"
293+
294+
const Page = () => {
295+
return <div>Second</div>
296+
}
297+
298+
export const config = defineRouteConfig({
299+
label: "Second",
300+
})
301+
302+
export default Page
303+
`,
304+
`
305+
import { defineRouteConfig } from "@medusajs/admin-sdk"
306+
307+
const Page = () => {
308+
return <div>Third</div>
309+
}
310+
311+
export const config = defineRouteConfig({
312+
label: "Third",
313+
rank: 0,
314+
})
315+
316+
export default Page
317+
`,
318+
]
319+
320+
vi.mocked(utils.crawl).mockResolvedValue(mockMixedFiles)
321+
322+
vi.mocked(fs.readFile).mockImplementation(async (file) =>
323+
Promise.resolve(mockMixedContents[mockMixedFiles.indexOf(file as string)])
324+
)
325+
326+
const result = await generateMenuItems(
327+
new Set(["Users/user/medusa/src/admin"])
328+
)
329+
330+
const expectedMixedMenuItems = `
331+
menuItems: [
332+
{
333+
label: RouteConfig0.label,
334+
icon: undefined,
335+
path: "/first",
336+
nested: undefined,
337+
rank: 1,
338+
translationNs: undefined
339+
},
340+
{
341+
label: RouteConfig1.label,
342+
icon: undefined,
343+
path: "/second",
344+
nested: undefined,
345+
rank: undefined,
346+
translationNs: undefined
347+
},
348+
{
349+
label: RouteConfig2.label,
350+
icon: undefined,
351+
path: "/third",
352+
nested: undefined,
353+
rank: 0,
354+
translationNs: undefined
355+
}
356+
]
357+
`
358+
359+
expect(utils.normalizeString(result.code)).toEqual(
360+
utils.normalizeString(expectedMixedMenuItems)
361+
)
362+
})
188363
})

packages/admin/admin-vite-plugin/src/routes/generate-menu-items.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { outdent } from "outdent"
77
import {
88
File,
99
isIdentifier,
10+
isNumericLiteral,
1011
isObjectProperty,
1112
isStringLiteral,
1213
Node,
@@ -27,15 +28,17 @@ import { getRoute } from "./helpers"
2728
type RouteConfig = {
2829
label: boolean
2930
icon: boolean
30-
nested?: string
31+
nested?: NestedRoutePosition
32+
rank?: number
3133
translationNs?: string
3234
}
3335

3436
type MenuItem = {
3537
icon?: string
3638
label: string
3739
path: string
38-
nested?: string
40+
nested?: NestedRoutePosition
41+
rank?: number
3942
translationNs?: string
4043
}
4144

@@ -66,12 +69,13 @@ function generateCode(results: MenuItemResult[]): string {
6669
}
6770

6871
function formatMenuItem(route: MenuItem): string {
69-
const { label, icon, path, nested, translationNs } = route
72+
const { label, icon, path, nested, rank, translationNs } = route
7073
return `{
7174
label: ${label},
7275
icon: ${icon || "undefined"},
7376
path: "${path}",
7477
nested: ${nested ? `"${nested}"` : "undefined"},
78+
rank: ${rank !== undefined ? rank : "undefined"},
7579
translationNs: ${translationNs ? `${translationNs}` : "undefined"}
7680
}`
7781
}
@@ -133,7 +137,10 @@ function generateMenuItem(
133137
icon: config.icon ? `${configName}.icon` : undefined,
134138
path: getRoute(file),
135139
nested: config.nested,
136-
translationNs: config.translationNs ? `${configName}.translationNs` : undefined,
140+
rank: config.rank,
141+
translationNs: config.translationNs
142+
? `${configName}.translationNs`
143+
: undefined,
137144
}
138145
}
139146

@@ -225,7 +232,7 @@ function processConfigProperties(
225232
isObjectProperty(prop) && isIdentifier(prop.key, { name: "nested" })
226233
) as ObjectProperty | undefined
227234

228-
let nestedValue: string | undefined = undefined
235+
let nestedValue: string | undefined
229236

230237
if (isStringLiteral(nested?.value)) {
231238
nestedValue = nested.value.value
@@ -255,10 +262,22 @@ function processConfigProperties(
255262
translationNsValue = translationNs.value.value
256263
}
257264

265+
const rank = properties.find(
266+
(prop) =>
267+
isObjectProperty(prop) && isIdentifier(prop.key, { name: "rank" })
268+
) as ObjectProperty | undefined
269+
270+
let rankValue: number | undefined
271+
272+
if (isNumericLiteral(rank?.value)) {
273+
rankValue = rank.value.value
274+
}
275+
258276
return {
259277
label: hasLabel,
260278
icon: hasProperty("icon"),
261-
nested: nestedValue,
279+
nested: nestedValue as NestedRoutePosition | undefined,
280+
rank: rankValue,
262281
translationNs: translationNsValue,
263282
}
264283
}

0 commit comments

Comments
 (0)