feat(admin-*,dashboard): i18n labels for menu item extensions (#13843)

* i18n menu item labels

* changeset

* changeset
This commit is contained in:
Leonardo Benini
2025-11-06 13:42:32 +01:00
committed by GitHub
parent 3095b63784
commit 3852efbcff
8 changed files with 104 additions and 9 deletions

View File

@@ -0,0 +1,7 @@
---
"@medusajs/admin-vite-plugin": patch
"@medusajs/admin-sdk": patch
"@medusajs/dashboard": patch
---
feat(admin-\*,dashboard): i18n labels for menu item extensions

View File

@@ -30,6 +30,17 @@ export interface RouteConfig {
* The nested route to display under existing route in the sidebar.
*/
nested?: NestedRoutePosition
/**
* An optional i18n namespace for translating the label. When provided, the label will be treated as a translation key.
* @example
* ```ts
* label: "menuItems.customFeature"
* translationNs: "my-plugin"
* // Will translate using: t("menuItems.customFeature", { ns: "my-plugin" })
* ```
*/
translationNs?: string
}
export type CustomFormField<

View File

@@ -69,19 +69,22 @@ const expectedMenuItems = `
label: RouteConfig0.label,
icon: RouteConfig0.icon,
path: "/one",
nested: undefined
nested: undefined,
translationNs: undefined
},
{
label: RouteConfig1.label,
icon: undefined,
path: "/two",
nested: undefined
nested: undefined,
translationNs: undefined
},
{
label: RouteConfig2.label,
icon: RouteConfig2.icon,
path: "/three",
nested: "/products"
nested: "/products",
translationNs: undefined
}
]
`
@@ -137,4 +140,49 @@ describe("generateMenuItems", () => {
utils.normalizeString(expectedMenuItems)
)
})
it("should handle translationNs field", async () => {
const mockFileWithTranslation = `
import { defineRouteConfig } from "@medusajs/admin-sdk"
const Page = () => {
return <div>Custom Page</div>
}
export const config = defineRouteConfig({
label: "menuItems.customFeature",
translationNs: "my-plugin",
})
export default Page
`
const mockFiles = ["Users/user/medusa/src/admin/routes/custom/page.tsx"]
vi.mocked(utils.crawl).mockResolvedValue(mockFiles)
vi.mocked(fs.readFile).mockResolvedValue(mockFileWithTranslation)
const result = await generateMenuItems(
new Set(["Users/user/medusa/src/admin"])
)
expect(result.imports).toEqual([
`import { config as RouteConfig0 } from "Users/user/medusa/src/admin/routes/custom/page.tsx"`,
])
const expectedOutput = `
menuItems: [
{
label: RouteConfig0.label,
icon: undefined,
path: "/custom",
nested: undefined,
translationNs: RouteConfig0.translationNs
}
]
`
expect(utils.normalizeString(result.code)).toEqual(
utils.normalizeString(expectedOutput)
)
})
})

View File

@@ -28,6 +28,7 @@ type RouteConfig = {
label: boolean
icon: boolean
nested?: string
translationNs?: string
}
type MenuItem = {
@@ -35,6 +36,7 @@ type MenuItem = {
label: string
path: string
nested?: string
translationNs?: string
}
type MenuItemResult = {
@@ -64,12 +66,13 @@ function generateCode(results: MenuItemResult[]): string {
}
function formatMenuItem(route: MenuItem): string {
const { label, icon, path, nested } = route
const { label, icon, path, nested, translationNs } = route
return `{
label: ${label},
icon: ${icon || "undefined"},
path: "${path}",
nested: ${nested ? `"${nested}"` : "undefined"}
nested: ${nested ? `"${nested}"` : "undefined"},
translationNs: ${translationNs ? `${translationNs}` : "undefined"}
}`
}
@@ -130,6 +133,7 @@ function generateMenuItem(
icon: config.icon ? `${configName}.icon` : undefined,
path: getRoute(file),
nested: config.nested,
translationNs: config.translationNs ? `${configName}.translationNs` : undefined,
}
}
@@ -240,10 +244,22 @@ function processConfigProperties(
return null
}
const translationNs = properties.find(
(prop) =>
isObjectProperty(prop) && isIdentifier(prop.key, { name: "translationNs" })
) as ObjectProperty | undefined
let translationNsValue: string | undefined = undefined
if (isStringLiteral(translationNs?.value)) {
translationNsValue = translationNs.value.value
}
return {
label: hasLabel,
icon: hasProperty("icon"),
nested: nestedValue,
translationNs: translationNsValue,
}
}

View File

@@ -348,6 +348,7 @@ const ExtensionRouteSection = () => {
label={item.label}
icon={item.icon ? item.icon : <SquaresPlus />}
items={item.items}
translationNs={item.translationNs}
type="extension"
/>
)

View File

@@ -17,6 +17,7 @@ type ItemType = "core" | "extension" | "setting"
type NestedItemProps = {
label: string
to: string
translationNs?: string
}
export type INavItem = {
@@ -27,6 +28,7 @@ export type INavItem = {
type?: ItemType
from?: string
nested?: string
translationNs?: string
}
const BASE_NAV_LINK_CLASSES =
@@ -90,10 +92,15 @@ export const NavItem = ({
items,
type = "core",
from,
translationNs,
}: INavItem) => {
const { t } = useTranslation(translationNs as any)
const { pathname } = useLocation()
const [open, setOpen] = useState(getIsOpen(to, items, pathname))
// Use translation if translationNs is provided, otherwise use label as-is
const displayLabel: string = translationNs ? t(label) : label
useEffect(() => {
setOpen(getIsOpen(to, items, pathname))
}, [pathname, to, items])
@@ -150,7 +157,7 @@ export const NavItem = ({
</div>
)}
<Text size="small" weight="plus" leading="compact">
{label}
{displayLabel}
</Text>
</NavLink>
</NavItemTooltip>
@@ -166,7 +173,7 @@ export const NavItem = ({
<Icon icon={icon} type={type} />
</div>
<Text size="small" weight="plus" leading="compact">
{label}
{displayLabel}
</Text>
</RadixCollapsible.Trigger>
<RadixCollapsible.Content>
@@ -189,12 +196,15 @@ export const NavItem = ({
}}
>
<Text size="small" weight="plus" leading="compact">
{label}
{displayLabel}
</Text>
</NavLink>
</NavItemTooltip>
</li>
{items.map((item) => {
const { t: itemT } = useTranslation(item.translationNs as any)
const itemLabel: string = item.translationNs ? itemT(item.label) : item.label
return (
<li key={item.to} className="flex h-7 items-center">
<NavItemTooltip to={item.to}>
@@ -213,7 +223,7 @@ export const NavItem = ({
}}
>
<Text size="small" weight="plus" leading="compact">
{item.label}
{itemLabel}
</Text>
</NavLink>
</NavItemTooltip>

View File

@@ -187,6 +187,7 @@ export class DashboardApp {
icon: item.icon ? <item.icon /> : undefined,
items: [],
nested: item.nested,
translationNs: item.translationNs,
}
if (parentPath !== "/" && tempRegistry[parentPath]) {

View File

@@ -24,6 +24,7 @@ export type MenuItemExtension = {
path: string
icon?: ComponentType
nested?: NestedRoutePosition
translationNs?: string
}
export type WidgetExtension = {