feat(): Introduce translation module and preliminary application of them (#14189)

* feat(): Translation first steps

* feat(): locale middleware

* feat(): readonly links

* feat(): feature flag

* feat(): modules sdk

* feat(): translation module re export

* start adding workflows

* update typings

* update typings

* test(): Add integration tests

* test(): centralize filters preparation

* test(): centralize filters preparation

* remove unnecessary importy

* fix workflows

* Define StoreLocale inside Store Module

* Link definition to extend Store with supported_locales

* store_locale migration

* Add supported_locales handling in Store Module

* Tests

* Accept supported_locales in Store endpoints

* Add locales to js-sdk

* Include locale list and default locale in Store Detail section

* Initialize local namespace in js-sdk

* Add locales route

* Make code primary key of locale table to facilitate upserts

* Add locales routes

* Show locale code as is

* Add list translations api route

* Batch endpoint

* Types

* New batchTranslationsWorkflow and various updates to existent ones

* Edit default locale UI

* WIP

* Apply translation agnostically

* middleware

* Apply translation agnostically

* fix Apply translation agnostically

* apply translations to product list

* Add feature flag

* fetch translations by batches of 250 max

* fix apply

* improve and test util

* apply to product list

* dont manage translations if no locale

* normalize locale

* potential todo

* Protect translations routes with feature flag

* Extract normalize locale util to core/utils

* Normalize locale on write

* Normalize locale for read

* Use feature flag to guard translations UI across the board

* Avoid throwing incorrectly when locale_code not present in partial updates

* move applyTranslations util

* remove old tests

* fix util tests

* fix(): product end points

* cleanup

* update lock

* remove unused var

* cleanup

* fix apply locale

* missing new dep for test utils

* Change entity_type, entity_id to reference, reference_id

* Remove comment

* Avoid registering translations route if ff not enabled

* Prevent registering express handler for disabled route via defineFileConfig

* Add tests

* Add changeset

* Update test

* fix integration tests, module and internals

* Add locale id plus fixed

* Allow to pass array of reference_id

* fix unit tests

* fix link loading

* fix store route

* fix sales channel test

* fix tests

---------

Co-authored-by: Nicolas Gorga <nicogorga11@gmail.com>
Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Adrien de Peretti
2025-12-08 19:33:08 +01:00
committed by GitHub
parent fea3d4ec49
commit 6dc0b8bed8
130 changed files with 5649 additions and 112 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/framework": patch
---
fix(framework): Prevent registering express handler for disabled routes

View File

@@ -26,6 +26,7 @@ packages/*
!packages/create-medusa-app
!packages/modules/product
!packages/modules/locking
!packages/modules/translation
!packages/core/orchestration
!packages/core/workflows-sdk
!packages/core/core-flows

View File

@@ -93,56 +93,57 @@ module.exports = {
"./packages/design-system/toolbox/tsconfig.json",
"./packages/cli/create-medusa-app/tsconfig.json",
"./packages/cli/medusa-cli/tsconfig.spec.json",
"./packages/cli/oas/medusa-oas-cli/tsconfig.spec.json",
"./packages/cli/medusa-cli/tsconfig.json",
"./packages/cli/oas/medusa-oas-cli/tsconfig.json",
"./packages/core/orchestration/tsconfig.json",
"./packages/core/workflows-sdk/tsconfig.spec.json",
"./packages/core/workflows-sdk/tsconfig.json",
"./packages/core/modules-sdk/tsconfig.json",
"./packages/core/js-sdk/tsconfig.json",
"./packages/core/types/tsconfig.json",
"./packages/core/utils/tsconfig.spec.json",
"./packages/core/utils/tsconfig.json",
"./packages/core/medusa-test-utils/tsconfig.json",
"./packages/modules/product/tsconfig.json",
"./packages/modules/event-bus-local/tsconfig.spec.json",
"./packages/modules/event-bus-redis/tsconfig.spec.json",
"./packages/modules/cache-redis/tsconfig.spec.json",
"./packages/modules/cache-inmemory/tsconfig.spec.json",
"./packages/modules/caching/tsconfig.spec.json",
"./packages/modules/workflow-engine-redis/tsconfig.spec.json",
"./packages/modules/workflow-engine-inmemory/tsconfig.spec.json",
"./packages/modules/fulfillment/tsconfig.spec.json",
"./packages/modules/api-key/tsconfig.spec.json",
"./packages/modules/auth/tsconfig.spec.json",
"./packages/modules/cart/tsconfig.spec.json",
"./packages/modules/currency/tsconfig.spec.json",
"./packages/modules/index/tsconfig.spec.json",
"./packages/modules/customer/tsconfig.spec.json",
"./packages/modules/file/tsconfig.spec.json",
"./packages/modules/inventory-next/tsconfig.spec.json",
"./packages/modules/stock-location-next/tsconfig.spec.json",
"./packages/modules/order/tsconfig.spec.json",
"./packages/modules/payment/tsconfig.spec.json",
"./packages/modules/pricing/tsconfig.spec.json",
"./packages/modules/promotion/tsconfig.spec.json",
"./packages/modules/region/tsconfig.spec.json",
"./packages/modules/sales-channel/tsconfig.spec.json",
"./packages/modules/store/tsconfig.spec.json",
"./packages/modules/tax/tsconfig.spec.json",
"./packages/modules/workflow-engine-inmemory/tsconfig.spec.json",
"./packages/modules/workflow-engine-redis/tsconfig.spec.json",
"./packages/modules/link-modules/tsconfig.spec.json",
"./packages/modules/user/tsconfig.spec.json",
"./packages/modules/locking/tsconfig.spec.json",
"./packages/modules/event-bus-local/tsconfig.json",
"./packages/modules/event-bus-redis/tsconfig.json",
"./packages/modules/cache-redis/tsconfig.json",
"./packages/modules/cache-inmemory/tsconfig.json",
"./packages/modules/caching/tsconfig.json",
"./packages/modules/workflow-engine-redis/tsconfig.json",
"./packages/modules/workflow-engine-inmemory/tsconfig.json",
"./packages/modules/fulfillment/tsconfig.json",
"./packages/modules/api-key/tsconfig.json",
"./packages/modules/auth/tsconfig.json",
"./packages/modules/cart/tsconfig.json",
"./packages/modules/currency/tsconfig.json",
"./packages/modules/index/tsconfig.json",
"./packages/modules/customer/tsconfig.json",
"./packages/modules/file/tsconfig.json",
"./packages/modules/inventory-next/tsconfig.json",
"./packages/modules/stock-location-next/tsconfig.json",
"./packages/modules/order/tsconfig.json",
"./packages/modules/payment/tsconfig.json",
"./packages/modules/pricing/tsconfig.json",
"./packages/modules/promotion/tsconfig.json",
"./packages/modules/region/tsconfig.json",
"./packages/modules/sales-channel/tsconfig.json",
"./packages/modules/store/tsconfig.json",
"./packages/modules/tax/tsconfig.json",
"./packages/modules/workflow-engine-inmemory/tsconfig.json",
"./packages/modules/workflow-engine-redis/tsconfig.json",
"./packages/modules/link-modules/tsconfig.json",
"./packages/modules/user/tsconfig.json",
"./packages/modules/locking/tsconfig.json",
"./packages/modules/translation/tsconfig.json",
"./packages/modules/providers/file-local/tsconfig.spec.json",
"./packages/modules/providers/file-s3/tsconfig.spec.json",
"./packages/modules/providers/fulfillment-manual/tsconfig.spec.json",
"./packages/modules/providers/payment-stripe/tsconfig.spec.json",
"./packages/modules/providers/locking-postgres/tsconfig.spec.json",
"./packages/modules/providers/locking-redis/tsconfig.spec.json",
"./packages/modules/providers/caching-redis/tsconfig.spec.json",
"./packages/modules/providers/file-local/tsconfig.json",
"./packages/modules/providers/file-s3/tsconfig.json",
"./packages/modules/providers/fulfillment-manual/tsconfig.json",
"./packages/modules/providers/payment-stripe/tsconfig.json",
"./packages/modules/providers/locking-postgres/tsconfig.json",
"./packages/modules/providers/locking-redis/tsconfig.json",
"./packages/modules/providers/caching-redis/tsconfig.json",
"./packages/framework/tsconfig.json",
],

View File

@@ -117,7 +117,8 @@ The code snippets in this section assume that your forked Medusa project and the
"@medusajs/draft-order": "file:../medusa/packages/plugins/draft-order",
"@medusajs/deps": "file:../medusa/packages/deps",
"@medusajs/caching-redis": "file:../medusa/packages/modules/providers/caching-redis",
"@medusajs/caching": "file:../medusa/packages/modules/caching"
"@medusajs/caching": "file:../medusa/packages/modules/caching",
"@medusajs/translation": "file:../medusa/packages/modules/translation",
}
```

View File

@@ -8,3 +8,7 @@ defineFileConfig({
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
res.json({ message: "Custom GET" })
}
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
res.json({ message: "Custom POST", body: req.validatedBody })
}

View File

@@ -0,0 +1,19 @@
import {
defineMiddlewares,
validateAndTransformBody,
} from "@medusajs/framework/http"
import { z } from "zod"
const CustomPostSchema = z.object({
foo: z.string(),
})
export default defineMiddlewares({
routes: [
{
method: ["POST"],
matcher: "/custom",
middlewares: [validateAndTransformBody(CustomPostSchema)],
},
],
})

View File

@@ -43,6 +43,23 @@ medusaIntegrationTestRunner({
it("should load endpoint when feature flag is enabled", async () => {
expect((await api.get("/custom")).status).toBe(200)
expect(
(
await api.post("/custom", {
foo: "test",
})
).status
).toBe(200)
})
it("should return 400 for POST route with invalid body when feature flag is enabled", async () => {
const response = await api
.post("/custom", {
invalid: 1,
})
.catch((e) => e)
expect(response.status).toBe(400)
})
})
},

View File

@@ -41,6 +41,16 @@ medusaIntegrationTestRunner({
it("should not load endpoint when feature flag is disabled", async () => {
expect(api.get("/custom")).rejects.toThrow()
})
it("should return 404 (not 400) for POST route with middleware when feature flag is disabled", async () => {
const { response } = await api
.post("/custom", {
invalid: "test",
})
.catch((e) => e)
expect(response.status).toBe(404)
})
})
},
})

View File

@@ -207,13 +207,20 @@ medusaIntegrationTestRunner({
})
)
expect(remainingPricePreferences).toEqual([
expect.objectContaining({
attribute: "currency_code",
value: "EUR",
is_tax_inclusive: true,
}),
])
expect(remainingPricePreferences).toEqual(
expect.arrayContaining([
expect.objectContaining({
attribute: "currency_code",
value: "EUR",
is_tax_inclusive: true,
}),
expect.objectContaining({
attribute: "currency_code",
value: "eur",
is_tax_inclusive: false,
}),
])
)
})
})
})

View File

@@ -67,7 +67,7 @@ medusaIntegrationTestRunner({
expect(response.status).toEqual(200)
expect(response.data.sales_channels).toBeTruthy()
expect(response.data.sales_channels.length).toBe(2)
expect(response.data.sales_channels.length).toBe(3) // includes the default sales channel
expect(response.data).toEqual(
expect.objectContaining({
sales_channels: expect.arrayContaining([

View File

@@ -31,6 +31,7 @@
"@medusajs/store": "workspace:^",
"@medusajs/tax": "workspace:^",
"@medusajs/test-utils": "workspace:*",
"@medusajs/translation": "workspace:*",
"@medusajs/user": "workspace:^",
"@medusajs/utils": "workspace:^",
"@medusajs/workflow-engine-inmemory": "workspace:*",

View File

@@ -1009,6 +1009,10 @@ export function getRouteMap({
path: "currencies",
lazy: () => import("../../routes/store/store-add-currencies"),
},
{
path: "locales",
lazy: () => import("../../routes/store/store-add-locales"),
},
{
path: "metadata/edit",
lazy: () => import("../../routes/store/store-metadata"),

View File

@@ -3,6 +3,7 @@ import { sdk } from "../../lib/client"
export type FeatureFlags = {
view_configurations?: boolean
translation?: boolean
[key: string]: boolean | undefined
}
@@ -10,13 +11,16 @@ export const useFeatureFlags = () => {
return useQuery<FeatureFlags>({
queryKey: ["admin", "feature-flags"],
queryFn: async () => {
const response = await sdk.client.fetch<{ feature_flags: FeatureFlags }>("/admin/feature-flags", {
method: "GET",
})
const response = await sdk.client.fetch<{ feature_flags: FeatureFlags }>(
"/admin/feature-flags",
{
method: "GET",
}
)
return response.feature_flags
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
cacheTime: 10 * 60 * 1000, // Keep in cache for 10 minutes
})
}
}

View File

@@ -11,6 +11,7 @@ export * from "./fulfillment-providers"
export * from "./fulfillment-sets"
export * from "./inventory"
export * from "./invites"
export * from "./locales"
export * from "./notification"
export * from "./orders"
export * from "./payment-collections"

View File

@@ -0,0 +1,52 @@
import { FetchError } from "@medusajs/js-sdk"
import { HttpTypes } from "@medusajs/types"
import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query"
import { sdk } from "../../lib/client"
import { queryKeysFactory } from "../../lib/query-key-factory"
const LOCALES_QUERY_KEY = "locales" as const
const localesQueryKeys = queryKeysFactory(LOCALES_QUERY_KEY)
export const useLocales = (
query?: HttpTypes.AdminLocaleListParams,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminLocaleListResponse,
FetchError,
HttpTypes.AdminLocaleListResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryFn: () => sdk.admin.locale.list(query),
queryKey: localesQueryKeys.list(query),
...options,
})
return { ...data, ...rest }
}
export const useLocale = (
id: string,
query?: HttpTypes.AdminLocaleParams,
options?: Omit<
UseQueryOptions<
HttpTypes.AdminLocaleResponse,
FetchError,
HttpTypes.AdminLocaleResponse,
QueryKey
>,
"queryFn" | "queryKey"
>
) => {
const { data, ...rest } = useQuery({
queryKey: localesQueryKeys.detail(id),
queryFn: async () => sdk.admin.locale.retrieve(id, query),
...options,
})
return { ...data, ...rest }
}

View File

@@ -9303,6 +9303,9 @@
"defaultCurrency": {
"type": "string"
},
"defaultLocale": {
"type": "string"
},
"defaultRegion": {
"type": "string"
},
@@ -9321,6 +9324,9 @@
"inviteLinkTemplate": {
"type": "string"
},
"locales": {
"type": "string"
},
"currencies": {
"type": "string"
},
@@ -9339,6 +9345,15 @@
"removeCurrencyWarning_other": {
"type": "string"
},
"removeLocaleWarning_one": {
"type": "string"
},
"removeLocaleWarning_other": {
"type": "string"
},
"localeAlreadyAdded": {
"type": "string"
},
"currencyAlreadyAdded": {
"type": "string"
},
@@ -9358,20 +9373,28 @@
"update": {
"type": "string"
},
"localesUpdated": {
"type": "string"
},
"currenciesUpdated": {
"type": "string"
},
"currenciesRemoved": {
"type": "string"
},
"localesRemoved": {
"type": "string"
},
"updatedTaxInclusivitySuccessfully": {
"type": "string"
}
},
"required": [
"update",
"localesUpdated",
"currenciesUpdated",
"currenciesRemoved",
"localesRemoved",
"updatedTaxInclusivitySuccessfully"
],
"additionalProperties": false
@@ -9382,19 +9405,24 @@
"manageYourStoresDetails",
"editStore",
"defaultCurrency",
"defaultLocale",
"defaultRegion",
"defaultSalesChannel",
"defaultLocation",
"swapLinkTemplate",
"paymentLinkTemplate",
"inviteLinkTemplate",
"locales",
"currencies",
"addCurrencies",
"enableTaxInclusivePricing",
"disableTaxInclusivePricing",
"removeCurrencyWarning_one",
"removeCurrencyWarning_other",
"removeLocaleWarning_one",
"removeLocaleWarning_other",
"currencyAlreadyAdded",
"localeAlreadyAdded",
"edit",
"toast"
],

View File

@@ -2497,26 +2497,33 @@
"manageYourStoresDetails": "Manage your store's details",
"editStore": "Edit store",
"defaultCurrency": "Default currency",
"defaultLocale": "Default locale",
"defaultRegion": "Default region",
"defaultSalesChannel": "Default sales channel",
"defaultLocation": "Default location",
"swapLinkTemplate": "Swap link template",
"paymentLinkTemplate": "Payment link template",
"inviteLinkTemplate": "Invite link template",
"locales": "Locales",
"currencies": "Currencies",
"addCurrencies": "Add currencies",
"enableTaxInclusivePricing": "Enable tax inclusive pricing",
"disableTaxInclusivePricing": "Disable tax inclusive pricing",
"removeCurrencyWarning_one": "You are about to remove {{count}} currency from your store. Ensure that you have removed all prices using the currency before proceeding.",
"removeCurrencyWarning_other": "You are about to remove {{count}} currencies from your store. Ensure that you have removed all prices using the currencies before proceeding.",
"removeLocaleWarning_one": "You are about to remove {{count}} locale from your store. Any translation using this locale will be removed.",
"removeLocaleWarning_other": "You are about to remove {{count}} locales from your store. Any translation using these locales will be removed.",
"currencyAlreadyAdded": "The currency has already been added to your store.",
"localeAlreadyAdded": "The locale has already been added to your store.",
"edit": {
"header": "Edit Store"
},
"toast": {
"update": "Store successfully updated",
"currenciesUpdated": "Currencies updated successfully",
"localesUpdated": "Locales updated successfully",
"currenciesRemoved": "Removed currencies from the store successfully",
"localesRemoved": "Removed locales from the store successfully",
"updatedTaxInclusivitySuccessfully": "Tax inclusive pricing updated successfully"
}
},

View File

@@ -2448,26 +2448,33 @@
"manageYourStoresDetails": "Gestiona los detalles de tu tienda",
"editStore": "Editar tienda",
"defaultCurrency": "Moneda por defecto",
"defaultLocale": "Idioma por defecto",
"defaultRegion": "Región por defecto",
"defaultSalesChannel": "Canal de ventas predeterminado",
"defaultLocation": "Ubicación predeterminada",
"swapLinkTemplate": "Plantilla de enlace de cambio",
"paymentLinkTemplate": "Plantilla de enlace de pago",
"inviteLinkTemplate": "Plantilla de enlace de invitación",
"locales": "Idiomas",
"currencies": "Monedas",
"addCurrencies": "Agregar monedas",
"enableTaxInclusivePricing": "Habilitar precios con impuestos incluidos",
"disableTaxInclusivePricing": "Deshabilitar precios con impuestos incluidos",
"removeCurrencyWarning_one": "Estás a punto de eliminar {{count}} moneda de tu tienda. Asegúrate de haber eliminado todos los precios que usen esta moneda antes de continuar.",
"removeCurrencyWarning_other": "Estás a punto de eliminar {{count}} monedas de tu tienda. Asegúrate de haber eliminado todos los precios que usen estas monedas antes de continuar.",
"removeLocaleWarning_one": "Estás a punto de eliminar {{count}} idioma de tu tienda. Cualquier traducción que use este idioma será eliminada.",
"removeLocaleWarning_other": "Estás a punto de eliminar {{count}} idiomas de tu tienda. Cualquier traducción que use estos idiomas será eliminada.",
"currencyAlreadyAdded": "La moneda ya ha sido agregada a tu tienda.",
"localeAlreadyAdded": "El idioma ya ha sido agregado a tu tienda.",
"edit": {
"header": "Editar Tienda"
},
"toast": {
"update": "Tienda actualizada exitosamente",
"currenciesUpdated": "Monedas actualizadas exitosamente",
"localesUpdated": "Idiomas actualizados exitosamente",
"currenciesRemoved": "Monedas eliminadas de la tienda exitosamente",
"localesRemoved": "Idiomas eliminados de la tienda exitosamente",
"updatedTaxInclusivitySuccessfully": "Precios con impuestos incluidos actualizados exitosamente"
}
},

View File

@@ -0,0 +1,29 @@
import { HttpTypes } from "@medusajs/types"
import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import {
TextCell,
TextHeader,
} from "../../../../components/table/table-cells/common/text-cell"
const columnHelper = createColumnHelper<HttpTypes.AdminLocale>()
export const useLocalesTableColumns = () => {
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.accessor("code", {
header: () => <TextHeader text={t("fields.code")} />,
cell: ({ getValue }) => <TextCell text={getValue()} />,
}),
columnHelper.accessor("name", {
header: () => <TextHeader text={t("fields.name")} />,
cell: ({ getValue }) => <TextCell text={getValue()} />,
}),
],
[t]
)
}

View File

@@ -0,0 +1,21 @@
import { useQueryParams } from "../../../../hooks/use-query-params"
export const useLocalesTableQuery = ({
pageSize = 10,
prefix,
}: {
pageSize?: number
prefix?: string
}) => {
const raw = useQueryParams(["order", "q", "offset"], prefix)
const { offset, ...rest } = raw
const searchParams = {
limit: pageSize,
offset: offset ? parseInt(offset) : 0,
...rest,
}
return { searchParams, raw }
}

View File

@@ -0,0 +1,240 @@
import { HttpTypes } from "@medusajs/types"
import { z } from "zod"
import { useLocalesTableQuery } from "../../../common/hooks/use-locales-table-query"
import { useRouteModal } from "../../../../../components/modals/route-modal-provider"
import { useTranslation } from "react-i18next"
import { useLocales, useUpdateStore } from "../../../../../hooks/api"
import { keepPreviousData } from "@tanstack/react-query"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import {
createColumnHelper,
OnChangeFn,
RowSelectionState,
} from "@tanstack/react-table"
import { useMemo, useState } from "react"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { Button, Checkbox, Hint, toast, Tooltip } from "@medusajs/ui"
import { RouteFocusModal } from "../../../../../components/modals/route-focus-modal"
import { KeyboundForm } from "../../../../../components/utilities/keybound-form"
import { _DataTable } from "../../../../../components/table/data-table"
import { useLocalesTableColumns } from "../../../common/hooks/use-locales-table-columns"
type AddLocalesFormProps = {
store: HttpTypes.AdminStore
}
const AddLocalesSchema = z.object({
locales: z.array(z.string()).min(1),
})
const PAGE_SIZE = 50
const PREFIX = "al"
export const AddLocalesForm = ({ store }: AddLocalesFormProps) => {
const { t } = useTranslation()
const { handleSuccess } = useRouteModal()
const { raw, searchParams } = useLocalesTableQuery({
pageSize: PAGE_SIZE,
prefix: PREFIX,
})
const {
locales,
count,
isPending: isLoading,
isError,
error,
} = useLocales(searchParams, {
placeholderData: keepPreviousData,
})
const form = useForm<z.infer<typeof AddLocalesSchema>>({
defaultValues: {
locales: [],
},
resolver: zodResolver(AddLocalesSchema),
})
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const { setValue } = form
const updater: OnChangeFn<RowSelectionState> = (fn) => {
const updated = typeof fn === "function" ? fn(rowSelection) : fn
const ids = Object.keys(updated)
setValue("locales", ids, {
shouldDirty: true,
shouldTouch: true,
})
setRowSelection(updated)
}
const preSelectedRows =
store.supported_locales?.map((l) => l.locale_code) ?? []
const columns = useColumns()
const { table } = useDataTable({
data: locales ?? [],
columns,
count: count,
getRowId: (row) => row.code,
enableRowSelection: (row) => !preSelectedRows.includes(row.original.code),
enablePagination: true,
pageSize: PAGE_SIZE,
prefix: PREFIX,
rowSelection: {
state: rowSelection,
updater,
},
})
const { mutateAsync, isPending } = useUpdateStore(store.id)
const handleSubmit = form.handleSubmit(async (data) => {
const locales = Array.from(
new Set([...data.locales, ...preSelectedRows])
) as string[]
let defaultLocale = store.supported_locales?.find(
(l) => l.is_default
)?.locale_code
if (!locales.includes(defaultLocale ?? "")) {
defaultLocale = locales?.[0]
}
await mutateAsync(
{
supported_locales: locales.map((l) => ({
locale_code: l,
is_default: l === defaultLocale,
})),
},
{
onSuccess: () => {
toast.success(t("store.toast.localesUpdated"))
handleSuccess()
},
onError: (error) => {
toast.error(error.message)
},
}
)
})
if (isError) {
throw error
}
return (
<RouteFocusModal.Form form={form}>
<KeyboundForm
onSubmit={handleSubmit}
className="flex h-full flex-col overflow-hidden"
>
<RouteFocusModal.Header>
<div className="flex flex-1 items-center justify-between">
<div className="flex items-center">
{form.formState.errors.locales && (
<Hint variant="error">
{form.formState.errors.locales.message}
</Hint>
)}
</div>
</div>
</RouteFocusModal.Header>
<RouteFocusModal.Body className="flex flex-1 flex-col overflow-hidden">
<_DataTable
table={table}
pageSize={PAGE_SIZE}
count={count}
columns={columns}
layout="fill"
pagination
search="autofocus"
prefix={PREFIX}
orderBy={[
{ key: "name", label: t("fields.name") },
{ key: "code", label: t("fields.code") },
]}
isLoading={isLoading}
queryObject={raw}
/>
</RouteFocusModal.Body>
<RouteFocusModal.Footer>
<div className="flex items-center justify-end gap-x-2">
<RouteFocusModal.Close asChild>
<Button size="small" variant="secondary">
{t("actions.cancel")}
</Button>
</RouteFocusModal.Close>
<Button size="small" type="submit" isLoading={isPending}>
{t("actions.save")}
</Button>
</div>
</RouteFocusModal.Footer>
</KeyboundForm>
</RouteFocusModal.Form>
)
}
const columnHelper = createColumnHelper<HttpTypes.AdminLocale>()
const useColumns = () => {
const { t } = useTranslation()
const base = useLocalesTableColumns()
return useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row }) => {
const isPreSelected = !row.getCanSelect()
const isSelected = row.getIsSelected() || isPreSelected
const Component = (
<Checkbox
checked={isSelected}
disabled={isPreSelected}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
if (isPreSelected) {
return (
<Tooltip content={t("store.localeAlreadyAdded")} side="right">
{Component}
</Tooltip>
)
}
return Component
},
}),
...base,
],
[t, base]
)
}

View File

@@ -0,0 +1 @@
export { StoreAddLocales as Component } from "./store-add-locales"

View File

@@ -0,0 +1,29 @@
import { RouteFocusModal } from "../../../components/modals/route-focus-modal"
import { useStore } from "../../../hooks/api"
import { AddLocalesForm } from "./components/add-locales-form/add-locales-form"
import { useFeatureFlag } from "../../../providers/feature-flag-provider"
import { useNavigate } from "react-router-dom"
export const StoreAddLocales = () => {
const isEnabled = useFeatureFlag("translation")
const navigate = useNavigate()
if (!isEnabled) {
navigate(-1)
return null
}
const { store, isPending, isError, error } = useStore()
const ready = !!store && !isPending
if (isError) {
throw error
}
return (
<RouteFocusModal>
{ready && <AddLocalesForm store={store} />}
</RouteFocusModal>
)
}

View File

@@ -7,6 +7,7 @@ import { Link } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { useSalesChannel, useStockLocation } from "../../../../../hooks/api"
import { useRegion } from "../../../../../hooks/api/regions"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
type StoreGeneralSectionProps = {
store: AdminStore
@@ -14,12 +15,14 @@ type StoreGeneralSectionProps = {
export const StoreGeneralSection = ({ store }: StoreGeneralSectionProps) => {
const { t } = useTranslation()
const isTranslationsEnabled = useFeatureFlag("translation")
const { region } = useRegion(store.default_region_id!, undefined, {
enabled: !!store.default_region_id,
})
const defaultCurrency = store.supported_currencies?.find((c) => c.is_default)
const defaultLocale = store.supported_locales?.find((l) => l.is_default)
const { sales_channel } = useSalesChannel(store.default_sales_channel_id!, {
enabled: !!store.default_sales_channel_id,
@@ -85,6 +88,27 @@ export const StoreGeneralSection = ({ store }: StoreGeneralSectionProps) => {
</Text>
)}
</div>
{isTranslationsEnabled && (
<div className="text-ui-fg-subtle grid grid-cols-2 px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("store.defaultLocale")}
</Text>
{defaultLocale ? (
<div className="flex items-center gap-x-2">
<Badge size="2xsmall">
{defaultLocale.locale_code?.toUpperCase()}
</Badge>
<Text size="small" leading="compact">
{defaultLocale.locale?.name}
</Text>
</div>
) : (
<Text size="small" leading="compact">
-
</Text>
)}
</div>
)}
<div className="text-ui-fg-subtle grid grid-cols-2 px-6 py-4">
<Text size="small" leading="compact" weight="plus">
{t("store.defaultRegion")}

View File

@@ -0,0 +1 @@
export * from "./store-locale-section"

View File

@@ -0,0 +1,290 @@
import { Plus, Trash } from "@medusajs/icons"
import { HttpTypes } from "@medusajs/types"
import {
Checkbox,
CommandBar,
Container,
Heading,
toast,
usePrompt,
} from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query"
import { RowSelectionState, createColumnHelper } from "@tanstack/react-table"
import { useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { ActionMenu } from "../../../../../components/common/action-menu"
import { _DataTable } from "../../../../../components/table/data-table"
import { useUpdateStore } from "../../../../../hooks/api/store"
import { useDataTable } from "../../../../../hooks/use-data-table"
import { useLocalesTableColumns } from "../../../common/hooks/use-locales-table-columns"
import { useLocalesTableQuery } from "../../../common/hooks/use-locales-table-query"
import { useLocales } from "../../../../../hooks/api"
type StoreLocaleSectionProps = {
store: HttpTypes.AdminStore
}
const PAGE_SIZE = 10
export const StoreLocaleSection = ({ store }: StoreLocaleSectionProps) => {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const { searchParams, raw } = useLocalesTableQuery({ pageSize: PAGE_SIZE })
const { locales, count, isPending, isError, error } = useLocales(
{
code: store.supported_locales?.map((l) => l.locale_code),
...searchParams,
},
{
placeholderData: keepPreviousData,
enabled: !!store.supported_locales?.length,
}
)
const columns = useColumns()
const { table } = useDataTable({
data: locales ?? [],
columns,
count: count,
getRowId: (row) => row.code,
rowSelection: {
state: rowSelection,
updater: setRowSelection,
},
enablePagination: true,
enableRowSelection: true,
pageSize: PAGE_SIZE,
meta: {
storeId: store.id,
supportedLocales: store.supported_locales,
defaultLocaleCode: store.supported_locales?.find((l) => l.is_default)
?.locale_code,
},
})
const { mutateAsync } = useUpdateStore(store.id)
const { t } = useTranslation()
const prompt = usePrompt()
const handleDeleteLocales = async () => {
const ids = Object.keys(rowSelection)
const result = await prompt({
title: t("general.areYouSure"),
description: t("store.removeLocaleWarning", {
count: ids.length,
}),
confirmText: t("actions.remove"),
cancelText: t("actions.cancel"),
})
if (!result) {
return
}
await mutateAsync(
{
supported_locales:
store.supported_locales?.filter(
(l) => !ids.includes(l.locale_code)
) ?? [],
},
{
onSuccess: () => {
setRowSelection({})
toast.success(t("store.toast.localesRemoved"))
},
onError: (e) => {
toast.error(e.message)
},
}
)
}
if (isError) {
throw error
}
const isLoading = isPending
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading level="h2">{t("store.locales")}</Heading>
<ActionMenu
groups={[
{
actions: [
{
icon: <Plus />,
label: t("actions.add"),
to: "locales",
},
],
},
]}
/>
</div>
<_DataTable
orderBy={[
{ key: "name", label: t("fields.name") },
{ key: "code", label: t("fields.code") },
]}
search
pagination
table={table}
pageSize={PAGE_SIZE}
columns={columns}
count={!store.supported_locales?.length ? 0 : count}
isLoading={!store.supported_locales?.length ? false : isLoading}
queryObject={raw}
/>
<CommandBar open={!!Object.keys(rowSelection).length}>
<CommandBar.Bar>
<CommandBar.Value>
{t("general.countSelected", {
count: Object.keys(rowSelection).length,
})}
</CommandBar.Value>
<CommandBar.Seperator />
<CommandBar.Command
action={handleDeleteLocales}
shortcut="r"
label={t("actions.remove")}
/>
</CommandBar.Bar>
</CommandBar>
</Container>
)
}
const LocaleActions = ({
storeId,
locale,
supportedLocales,
defaultLocaleCode,
}: {
storeId: string
locale: HttpTypes.AdminLocale
supportedLocales: HttpTypes.AdminStoreLocale[]
defaultLocaleCode: string
}) => {
const { mutateAsync } = useUpdateStore(storeId)
const { t } = useTranslation()
const prompt = usePrompt()
const handleRemove = async () => {
const result = await prompt({
title: t("general.areYouSure"),
description: t("store.removeLocaleWarning", {
count: 1,
}),
verificationInstruction: t("general.typeToConfirm"),
verificationText: locale.name,
confirmText: t("actions.remove"),
cancelText: t("actions.cancel"),
})
if (!result) {
return
}
await mutateAsync(
{
supported_locales: supportedLocales.filter(
(l) => l.locale_code !== locale.code
),
},
{
onSuccess: () => {
toast.success(t("store.toast.localesRemoved"))
},
onError: (e) => {
toast.error(e.message)
},
}
)
}
return (
<ActionMenu
groups={[
{
actions: [
{
icon: <Trash />,
label: t("actions.remove"),
onClick: handleRemove,
disabled: locale.code === defaultLocaleCode,
},
],
},
]}
/>
)
}
const columnHelper = createColumnHelper<HttpTypes.AdminLocale>()
const useColumns = () => {
const base = useLocalesTableColumns()
const { t } = useTranslation()
return useMemo(
() => [
columnHelper.display({
id: "select",
header: ({ table }) => {
return (
<Checkbox
checked={
table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected()
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
/>
)
},
cell: ({ row }) => {
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => {
e.stopPropagation()
}}
/>
)
},
}),
...base,
columnHelper.display({
id: "actions",
cell: ({ row, table }) => {
const { supportedLocales, storeId, defaultLocaleCode } = table.options
.meta as {
defaultLocaleCode: string
supportedLocales: HttpTypes.AdminStoreLocale[]
storeId: string
}
return (
<LocaleActions
storeId={storeId}
locale={row.original}
supportedLocales={supportedLocales}
defaultLocaleCode={defaultLocaleCode}
/>
)
},
}),
],
[base, t]
)
}

View File

@@ -8,9 +8,12 @@ import { SingleColumnPageSkeleton } from "../../../components/common/skeleton"
import { SingleColumnPage } from "../../../components/layout/pages"
import { useExtension } from "../../../providers/extension-provider"
import { StoreCurrencySection } from "./components/store-currency-section"
import { StoreLocaleSection } from "./components/store-locale-section"
import { useFeatureFlag } from "../../../providers/feature-flag-provider"
export const StoreDetail = () => {
const initialData = useLoaderData() as Awaited<ReturnType<typeof storeLoader>>
const isTranslationsEnabled = useFeatureFlag("translation")
const { store, isPending, isError, error } = useStore(undefined, {
initialData,
@@ -39,6 +42,7 @@ export const StoreDetail = () => {
>
<StoreGeneralSection store={store} />
<StoreCurrencySection store={store} />
{isTranslationsEnabled && <StoreLocaleSection store={store} />}
</SingleColumnPage>
)
}

View File

@@ -13,6 +13,7 @@ import { useUpdateStore } from "../../../../../hooks/api/store"
import { useComboboxData } from "../../../../../hooks/use-combobox-data"
import { sdk } from "../../../../../lib/client"
import { useDocumentDirection } from "../../../../../hooks/use-document-direction"
import { useFeatureFlag } from "../../../../../providers/feature-flag-provider"
type EditStoreFormProps = {
store: HttpTypes.AdminStore
@@ -21,6 +22,7 @@ type EditStoreFormProps = {
const EditStoreSchema = z.object({
name: z.string().min(1),
default_currency_code: z.string().optional(),
default_locale_code: z.string().optional(),
default_region_id: z.string().optional(),
default_sales_channel_id: z.string().optional(),
default_location_id: z.string().optional(),
@@ -28,12 +30,16 @@ const EditStoreSchema = z.object({
export const EditStoreForm = ({ store }: EditStoreFormProps) => {
const { t } = useTranslation()
const isTranslationsEnabled = useFeatureFlag("translation")
const { handleSuccess } = useRouteModal()
const direction = useDocumentDirection()
const direction = useDocumentDirection()
const form = useForm<z.infer<typeof EditStoreSchema>>({
defaultValues: {
name: store.name,
default_region_id: store.default_region_id || undefined,
default_locale_code:
store.supported_locales?.find((l) => l.is_default)?.locale_code ||
undefined,
default_currency_code:
store.supported_currencies?.find((c) => c.is_default)?.currency_code ||
undefined,
@@ -73,10 +79,14 @@ export const EditStoreForm = ({ store }: EditStoreFormProps) => {
})
const handleSubmit = form.handleSubmit(async (values) => {
const { default_currency_code, ...rest } = values
const { default_currency_code, default_locale_code, ...rest } = values
const normalizedMutation: HttpTypes.AdminUpdateStore = {
...rest,
supported_locales: store.supported_locales?.map((l) => ({
...l,
is_default: l.locale_code === default_locale_code,
})),
supported_currencies: store.supported_currencies?.map((c) => ({
...c,
is_default: c.currency_code === default_currency_code,
@@ -95,7 +105,10 @@ export const EditStoreForm = ({ store }: EditStoreFormProps) => {
return (
<RouteDrawer.Form form={form}>
<KeyboundForm onSubmit={handleSubmit} className="flex h-full flex-col overflow-hidden">
<KeyboundForm
onSubmit={handleSubmit}
className="flex h-full flex-col overflow-hidden"
>
<RouteDrawer.Body className="overflow-y-auto">
<div className="flex flex-col gap-y-8">
<Form.Field
@@ -143,6 +156,40 @@ export const EditStoreForm = ({ store }: EditStoreFormProps) => {
)
}}
/>
{isTranslationsEnabled && (
<Form.Field
control={form.control}
name="default_locale_code"
render={({ field: { onChange, ...field } }) => {
return (
<Form.Item>
<Form.Label>{t("store.defaultLocale")}</Form.Label>
<Form.Control>
<Select
dir={direction}
{...field}
onValueChange={onChange}
>
<Select.Trigger ref={field.ref}>
<Select.Value />
</Select.Trigger>
<Select.Content>
{store.supported_locales?.map((locale) => (
<Select.Item
key={locale.locale_code}
value={locale.locale_code}
>
{locale.locale_code}
</Select.Item>
))}
</Select.Content>
</Select>
</Form.Control>
</Form.Item>
)
}}
/>
)}
<Form.Field
control={form.control}
name="default_region_id"

View File

@@ -31,4 +31,5 @@ export * from "./shipping-profile"
export * from "./stock-location"
export * from "./store"
export * from "./tax"
export * from "./translation"
export * from "./user"

View File

@@ -0,0 +1,2 @@
export * from "./steps"
export * from "./workflows"

View File

@@ -0,0 +1,47 @@
import {
CreateTranslationDTO,
ITranslationModuleService,
} from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
export const createTranslationsStepId = "create-translations"
/**
* This step creates one or more translations.
*
* @example
* const data = createTranslationsStep([
* {
* reference_id: "prod_123",
* reference: "product",
* locale_code: "fr-FR",
* translations: { title: "Produit", description: "Description du produit" }
* }
* ])
*/
export const createTranslationsStep = createStep(
createTranslationsStepId,
async (data: CreateTranslationDTO[], { container }) => {
const service = container.resolve<ITranslationModuleService>(
Modules.TRANSLATION
)
const created = await service.createTranslations(data)
return new StepResponse(
created,
created.map((translation) => translation.id)
)
},
async (createdIds, { container }) => {
if (!createdIds?.length) {
return
}
const service = container.resolve<ITranslationModuleService>(
Modules.TRANSLATION
)
await service.deleteTranslations(createdIds)
}
)

View File

@@ -0,0 +1,36 @@
import { ITranslationModuleService } from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
/**
* The IDs of the translations to delete.
*/
export type DeleteTranslationsStepInput = string[]
export const deleteTranslationsStepId = "delete-translations"
/**
* This step deletes one or more translations.
*/
export const deleteTranslationsStep = createStep(
deleteTranslationsStepId,
async (ids: DeleteTranslationsStepInput, { container }) => {
const service = container.resolve<ITranslationModuleService>(
Modules.TRANSLATION
)
await service.softDeleteTranslations(ids)
return new StepResponse(void 0, ids)
},
async (prevIds, { container }) => {
if (!prevIds?.length) {
return
}
const service = container.resolve<ITranslationModuleService>(
Modules.TRANSLATION
)
await service.restoreTranslations(prevIds)
}
)

View File

@@ -0,0 +1,4 @@
export * from "./create-translations"
export * from "./delete-translations"
export * from "./update-translations"
export * from "./validate-translations"

View File

@@ -0,0 +1,113 @@
import {
FilterableTranslationProps,
ITranslationModuleService,
UpdateTranslationDTO,
} from "@medusajs/framework/types"
import {
MedusaError,
MedusaErrorTypes,
Modules,
} from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
/**
* The data to update translations.
*/
export type UpdateTranslationsStepInput =
| {
/**
* The filters to select the translations to update.
*/
selector: FilterableTranslationProps
/**
* The data to update in the translations.
*/
update: UpdateTranslationDTO
}
| {
translations: UpdateTranslationDTO[]
}
export const updateTranslationsStepId = "update-translations"
/**
* This step updates translations matching the specified filters.
*
* @example
* const data = updateTranslationsStep({
* selector: {
* reference_id: "prod_123",
* locale_code: "fr-FR"
* },
* update: {
* translations: { title: "Nouveau titre" }
* }
* })
*/
export const updateTranslationsStep = createStep(
updateTranslationsStepId,
async (data: UpdateTranslationsStepInput, { container }) => {
const service = container.resolve<ITranslationModuleService>(
Modules.TRANSLATION
)
if ("translations" in data) {
if (data.translations.some((t) => !t.id)) {
throw new MedusaError(
MedusaErrorTypes.INVALID_DATA,
"Translation ID is required when doing a batch update of translations"
)
}
if (!data.translations.length) {
return new StepResponse([], [])
}
const prevData = await service.listTranslations({
id: data.translations.map((t) => t.id) as string[],
})
const translations = await service.updateTranslations(data.translations)
return new StepResponse(translations, prevData)
}
const prevData = await service.listTranslations(data.selector, {
select: [
"id",
"reference_id",
"reference",
"locale_code",
"translations",
],
})
if (Object.keys(data.update).length === 0) {
return new StepResponse(prevData, [])
}
const translations = await service.updateTranslations({
selector: data.selector,
data: data.update,
})
return new StepResponse(translations, prevData)
},
async (prevData, { container }) => {
if (!prevData?.length) {
return
}
const service = container.resolve<ITranslationModuleService>(
Modules.TRANSLATION
)
await service.updateTranslations(
prevData.map((t) => ({
id: t.id,
reference_id: t.reference_id,
reference: t.reference,
locale_code: t.locale_code,
translations: t.translations,
}))
)
}
)

View File

@@ -0,0 +1,57 @@
import {
ContainerRegistrationKeys,
MedusaError,
MedusaErrorTypes,
} from "@medusajs/framework/utils"
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
import { CreateTranslationDTO, UpdateTranslationDTO } from "@medusajs/types"
export const validateTranslationsStepId = "validate-translations"
export type ValidateTranslationsStepInput =
| CreateTranslationDTO[]
| CreateTranslationDTO
| UpdateTranslationDTO[]
| UpdateTranslationDTO
// TODO: Do we want to validate anything else here?
export const validateTranslationsStep = createStep(
validateTranslationsStepId,
async (data: ValidateTranslationsStepInput, { container }) => {
const query = container.resolve(ContainerRegistrationKeys.QUERY)
const {
data: [store],
} = await query.graph(
{
entity: "store",
fields: ["supported_locales.*"],
pagination: {
take: 1,
},
},
{
cache: { enable: true },
}
)
const enabledLocales = (store.supported_locales ?? []).map(
(locale) => locale.locale_code
)
const normalizedInput = Array.isArray(data) ? data : [data]
const unsupportedLocales = normalizedInput
.filter((translation) => Boolean(translation.locale_code))
.map((translation) => translation.locale_code)
.filter((locale) => !enabledLocales.includes(locale ?? ""))
if (unsupportedLocales.length) {
throw new MedusaError(
MedusaErrorTypes.INVALID_DATA,
`The following locales are not supported in the store: ${unsupportedLocales.join(
", "
)}`
)
}
return new StepResponse(void 0)
}
)

View File

@@ -0,0 +1,44 @@
import {
createWorkflow,
parallelize,
transform,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import { CreateTranslationDTO, UpdateTranslationDTO } from "@medusajs/types"
import { createTranslationsWorkflow } from "./create-translations"
import { deleteTranslationsWorkflow } from "./delete-translations"
import { updateTranslationsWorkflow } from "./update-translations"
export const batchTranslationsWorkflowId = "batch-translations"
export type BatchTranslationsWorkflowInput = {
create: CreateTranslationDTO[]
update: UpdateTranslationDTO[]
delete: string[]
}
export const batchTranslationsWorkflow = createWorkflow(
batchTranslationsWorkflowId,
(input: BatchTranslationsWorkflowInput) => {
const [created, updated, deleted] = parallelize(
createTranslationsWorkflow.runAsStep({
input: {
translations: input.create,
},
}),
updateTranslationsWorkflow.runAsStep({
input: {
translations: input.update,
},
}),
deleteTranslationsWorkflow.runAsStep({
input: {
ids: input.delete,
},
})
)
return new WorkflowResponse(
transform({ created, updated, deleted }, (result) => result)
)
}
)

View File

@@ -0,0 +1,66 @@
import { CreateTranslationDTO, TranslationDTO } from "@medusajs/framework/types"
import {
WorkflowData,
WorkflowResponse,
createWorkflow,
transform,
} from "@medusajs/framework/workflows-sdk"
import { emitEventStep } from "../../common/steps/emit-event"
import { createTranslationsStep } from "../steps"
import { validateTranslationsStep } from "../steps"
export type CreateTranslationsWorkflowInput = {
translations: CreateTranslationDTO[]
}
export const createTranslationsWorkflowId = "create-translations"
/**
* This workflow creates one or more translations.
*
* You can use this workflow within your own customizations or custom workflows, allowing you
* to create translations in your custom flows.
*
* @example
* const { result } = await createTranslationsWorkflow(container)
* .run({
* input: {
* translations: [
* {
* reference_id: "prod_123",
* reference: "product",
* locale_code: "fr-FR",
* translations: { title: "Produit", description: "Description du produit" }
* }
* ]
* }
* })
*
* @summary
*
* Create one or more translations.
*/
export const createTranslationsWorkflow = createWorkflow(
createTranslationsWorkflowId,
(
input: WorkflowData<CreateTranslationsWorkflowInput>
): WorkflowResponse<TranslationDTO[]> => {
validateTranslationsStep(input.translations)
const translations = createTranslationsStep(input.translations)
const translationIdEvents = transform(
{ translations },
({ translations }) => {
return translations.map((t) => {
return { id: t.id }
})
}
)
emitEventStep({
eventName: "translation.created",
data: translationIdEvents,
})
return new WorkflowResponse(translations)
}
)

View File

@@ -0,0 +1,48 @@
import {
WorkflowData,
createWorkflow,
transform,
} from "@medusajs/framework/workflows-sdk"
import { emitEventStep } from "../../common/steps/emit-event"
import { deleteTranslationsStep } from "../steps"
export type DeleteTranslationsWorkflowInput = { ids: string[] }
export const deleteTranslationsWorkflowId = "delete-translations"
/**
* This workflow deletes one or more translations.
*
* You can use this workflow within your own customizations or custom workflows, allowing you
* to delete translations in your custom flows.
*
* @example
* const { result } = await deleteTranslationsWorkflow(container)
* .run({
* input: {
* ids: ["trans_123"]
* }
* })
*
* @summary
*
* Delete one or more translations.
*/
export const deleteTranslationsWorkflow = createWorkflow(
deleteTranslationsWorkflowId,
(
input: WorkflowData<DeleteTranslationsWorkflowInput>
): WorkflowData<void> => {
deleteTranslationsStep(input.ids)
const translationIdEvents = transform({ input }, ({ input }) => {
return input.ids?.map((id) => {
return { id }
})
})
emitEventStep({
eventName: "translation.deleted",
data: translationIdEvents,
})
}
)

View File

@@ -0,0 +1,4 @@
export * from "./create-translations"
export * from "./delete-translations"
export * from "./update-translations"
export * from "./batch-translations"

View File

@@ -0,0 +1,67 @@
import { TranslationDTO } from "@medusajs/framework/types"
import {
createWorkflow,
transform,
WorkflowData,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import { emitEventStep } from "../../common/steps/emit-event"
import { updateTranslationsStep, UpdateTranslationsStepInput } from "../steps"
import { validateTranslationsStep } from "../steps"
export type UpdateTranslationsWorkflowInput = UpdateTranslationsStepInput
export const updateTranslationsWorkflowId = "update-translations"
/**
* This workflow updates translations matching the specified filters.
*
* You can use this workflow within your own customizations or custom workflows, allowing you
* to update translations in your custom flows.
*
* @example
* const { result } = await updateTranslationsWorkflow(container)
* .run({
* input: {
* selector: {
* reference_id: "prod_123",
* locale_code: "fr-FR"
* },
* update: {
* translations: { title: "Nouveau titre" }
* }
* }
* })
*
* @summary
*
* Update translations.
*/
export const updateTranslationsWorkflow = createWorkflow(
updateTranslationsWorkflowId,
(
input: WorkflowData<UpdateTranslationsWorkflowInput>
): WorkflowResponse<TranslationDTO[]> => {
const validateInput = transform(input, (input) => {
return "translations" in input ? input.translations : [input.update]
})
validateTranslationsStep(validateInput)
const translations = updateTranslationsStep(input)
const translationIdEvents = transform(
{ translations },
({ translations }) => {
return translations?.map((t) => {
return { id: t.id }
})
}
)
emitEventStep({
eventName: "translation.updated",
data: translationIdEvents,
})
return new WorkflowResponse(translations)
}
)

View File

@@ -0,0 +1,112 @@
import { MedusaRequest, MedusaResponse } from "../types"
import { applyLocale } from "../middlewares/apply-locale"
import { MedusaContainer } from "@medusajs/types"
describe("applyLocale", () => {
let mockRequest: Partial<MedusaRequest>
let mockResponse: MedusaResponse
let nextFunction: jest.Mock
beforeEach(() => {
mockRequest = {
query: {},
get: jest.fn(),
scope: {
resolve: jest.fn().mockReturnValue({
graph: jest.fn().mockResolvedValue({
data: [{ supported_locales: [{ locale_code: "en-US" }] }],
}),
}),
} as unknown as MedusaContainer,
}
mockResponse = {} as MedusaResponse
nextFunction = jest.fn()
})
afterEach(() => {
jest.clearAllMocks()
})
it("should set locale from query parameter", async () => {
mockRequest.query = { locale: "en-US" }
await applyLocale(mockRequest as MedusaRequest, mockResponse, nextFunction)
expect(mockRequest.locale).toBe("en-US")
expect(nextFunction).toHaveBeenCalledTimes(1)
})
it("should set locale from Content-Language header when query param is not present", async () => {
mockRequest.query = {}
;(mockRequest.get as jest.Mock).mockImplementation((header: string) => {
if (header === "content-language") {
return "fr-FR"
}
return undefined
})
await applyLocale(mockRequest as MedusaRequest, mockResponse, nextFunction)
expect(mockRequest.locale).toBe("fr-FR")
expect(nextFunction).toHaveBeenCalledTimes(1)
})
it("should prioritize query parameter over Content-Language header", async () => {
mockRequest.query = { locale: "de-DE" }
;(mockRequest.get as jest.Mock).mockImplementation((header: string) => {
if (header === "content-language") {
return "fr-FR"
}
return undefined
})
await applyLocale(mockRequest as MedusaRequest, mockResponse, nextFunction)
expect(mockRequest.locale).toBe("de-DE")
expect(mockRequest.get).not.toHaveBeenCalled()
expect(nextFunction).toHaveBeenCalledTimes(1)
})
it("should not set locale when neither query param nor header is present", async () => {
mockRequest.query = {}
;(mockRequest.get as jest.Mock).mockReturnValue(undefined)
await applyLocale(mockRequest as MedusaRequest, mockResponse, nextFunction)
expect(mockRequest.locale).toBeUndefined()
expect(nextFunction).toHaveBeenCalledTimes(1)
})
it("should handle empty string in query parameter", async () => {
mockRequest.query = { locale: "" }
;(mockRequest.get as jest.Mock).mockImplementation((header: string) => {
if (header === "content-language") {
return "es-ES"
}
return undefined
})
await applyLocale(mockRequest as MedusaRequest, mockResponse, nextFunction)
// Empty string is falsy, so it should fall back to header
expect(mockRequest.locale).toBe("es-ES")
expect(nextFunction).toHaveBeenCalledTimes(1)
})
it("should handle various locale formats", async () => {
const locales = ["en", "en-US", "zh-Hans-CN", "pt-BR"]
for (const locale of locales) {
mockRequest.query = { locale }
mockRequest.locale = undefined
await applyLocale(
mockRequest as MedusaRequest,
mockResponse,
nextFunction
)
expect(mockRequest.locale).toBe(locale)
}
})
})

View File

@@ -0,0 +1,64 @@
import { ContainerRegistrationKeys, normalizeLocale } from "@medusajs/utils"
import type {
MedusaNextFunction,
MedusaRequest,
MedusaResponse,
} from "../types"
const CONTENT_LANGUAGE_HEADER = "content-language"
/**
* Middleware that resolves the locale for the current request.
*
* Resolution order:
* 1. Query parameter `?locale=en-US`
* 2. Content-Language header
*
* The resolved locale is set on `req.locale`.
*/
export async function applyLocale(
req: MedusaRequest,
_: MedusaResponse,
next: MedusaNextFunction
) {
// 1. Check query parameter
const queryLocale = req.query.locale as string | undefined
if (queryLocale) {
req.locale = normalizeLocale(queryLocale)
return next()
}
// 2. Check Content-Language header
const headerLocale = req.get(CONTENT_LANGUAGE_HEADER)
if (headerLocale) {
req.locale = normalizeLocale(headerLocale)
return next()
}
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const {
data: [store],
} = await query.graph(
{
entity: "store",
fields: ["id", "supported_locales"],
pagination: {
take: 1,
},
},
{
cache: {
enable: true,
},
}
)
if (store?.supported_locales?.length) {
req.locale = store.supported_locales.find(
(locale) => locale.is_default
)?.locale_code
return next()
}
return next()
}

View File

@@ -3,5 +3,6 @@ export * from "./error-handler"
export * from "./exception-formatter"
export * from "./apply-default-filters"
export * from "./apply-params-as-filters"
export * from "./apply-locale"
export * from "./clear-filters-by-key"
export * from "./set-context"

View File

@@ -1,4 +1,9 @@
import { ContainerRegistrationKeys, parseCorsOrigins, FeatureFlag } from "@medusajs/utils"
import {
ContainerRegistrationKeys,
FeatureFlag,
isFileDisabled,
parseCorsOrigins,
} from "@medusajs/utils"
import cors, { CorsOptions } from "cors"
import type {
ErrorRequestHandler,
@@ -20,6 +25,7 @@ import type {
} from "./types"
import { Logger, MedusaContainer } from "@medusajs/types"
import { join } from "path"
import { configManager } from "../config"
import { MiddlewareFileLoader } from "./middleware-file-loader"
import { authenticate, AuthType } from "./middlewares"
@@ -109,6 +115,38 @@ export class ApiLoader {
}
}
/**
* Checks if a route file is disabled for a given matcher and method
* by trying to find the corresponding route file path
*/
#isRouteFileDisabled(matcher: string): boolean {
const routePathSegments = matcher
.split("/")
.filter(Boolean)
.map((segment) => {
if (segment.startsWith(":")) {
return `[${segment.slice(1)}]`
}
return segment
})
for (const sourceDir of this.#sourceDirs) {
for (const ext of [".ts", ".js"]) {
const routeFilePath = join(
sourceDir,
...routePathSegments,
`route${ext}`
)
if (isFileDisabled(routeFilePath)) {
return true
}
}
}
return false
}
/**
* Registers a middleware or a route handler with Express
*/
@@ -145,6 +183,14 @@ export class ApiLoader {
? route.methods
: [route.methods]
methods.forEach((method) => {
const isDisabled = this.#isRouteFileDisabled(route.matcher)
if (isDisabled) {
this.#logger.debug(
`skipping disabled route middleware registration for ${method} ${route.matcher}`
)
return
}
this.#logger.debug(
`registering route middleware ${method} ${route.matcher}`
)

View File

@@ -183,6 +183,14 @@ export interface MedusaRequest<
* requests that allows for additional_data
*/
additionalDataValidator?: ZodOptional<ZodNullable<ZodObject<any, any>>>
/**
* The locale for the current request, resolved from:
* 1. Query parameter `?locale=`
* 2. Content-Language header
* 3. Store's default locale
*/
locale?: string
}
export interface AuthContext {

View File

@@ -27,6 +27,7 @@ import {
IStockLocationService,
IStoreModuleService,
ITaxModuleService,
ITranslationModuleService,
IUserModuleService,
IWorkflowEngineService,
Logger,
@@ -34,8 +35,8 @@ import {
RemoteQueryFunction,
} from "@medusajs/types"
import { ContainerRegistrationKeys, Modules } from "@medusajs/utils"
import { Knex } from "../deps/mikro-orm-knex"
import { AwilixContainer, ResolveOptions } from "../deps/awilix"
import { Knex } from "../deps/mikro-orm-knex"
declare module "@medusajs/types" {
export interface ModuleImplementations {
@@ -80,6 +81,7 @@ declare module "@medusajs/types" {
[Modules.SETTINGS]: ISettingsModuleService
[Modules.CACHING]: ICachingModuleService
[Modules.INDEX]: IIndexService
[Modules.TRANSLATION]: ITranslationModuleService
}
}

View File

@@ -45,6 +45,7 @@ import { User } from "./user"
import { Views } from "./views"
import { WorkflowExecution } from "./workflow-execution"
import { ShippingOptionType } from "./shipping-option-type"
import { Locale } from "./locale"
export class Admin {
/**
@@ -179,6 +180,10 @@ export class Admin {
* @tags currency
*/
public currency: Currency
/**
* @tags locale
*/
public locale: Locale
/**
* @tags payment
*/
@@ -265,6 +270,7 @@ export class Admin {
this.store = new Store(client)
this.productTag = new ProductTag(client)
this.user = new User(client)
this.locale = new Locale(client)
this.currency = new Currency(client)
this.payment = new Payment(client)
this.productVariant = new ProductVariant(client)

View File

@@ -0,0 +1,119 @@
import { HttpTypes } from "@medusajs/types"
import { Client } from "../client"
import { ClientHeaders } from "../types"
export class Locale {
/**
* @ignore
*/
private client: Client
/**
* @ignore
*/
constructor(client: Client) {
this.client = client
}
/**
* This method retrieves a paginated list of locales. It sends a request to the
* [List Locales](https://docs.medusajs.com/api/admin#locales_getlocales)
* API route.
*
* @param query - Filters and pagination configurations.
* @param headers - Headers to pass in the request.
* @returns The paginated list of locales.
*
* @example
* To retrieve the list of locales:
*
* ```ts
* sdk.admin.locales.list()
* .then(({ locales, count, limit, offset }) => {
* console.log(locales)
* })
* ```
*
* To configure the pagination, pass the `limit` and `offset` query parameters.
*
* For example, to retrieve only 10 items and skip 10 items:
*
* ```ts
* sdk.admin.locales.list({
* limit: 10,
* offset: 10
* })
* .then(({ locales, count, limit, offset }) => {
* console.log(locales)
* })
* ```
*
* Using the `fields` query parameter, you can specify the fields and relations to retrieve
* in each locale:
*
* ```ts
* sdk.admin.locales.list({
* fields: "code,name"
* })
* .then(({ locales, count, limit, offset }) => {
* console.log(locales)
* })
* ```
*
* Learn more about the `fields` property in the [API reference](https://docs.medusajs.com/api/store#select-fields-and-relations).
*/
async list(query?: HttpTypes.AdminLocaleListParams, headers?: ClientHeaders) {
return this.client.fetch<HttpTypes.AdminLocaleListResponse>(
`/admin/locales`,
{
headers,
query,
}
)
}
/**
* This method retrieves a locale by its code. It sends a request to the
* [Get Locale](https://docs.medusajs.com/api/admin#locales_getlocalescode) API route.
*
* @param code - The locale's code.
* @param query - Configure the fields to retrieve in the locale.
* @param headers - Headers to pass in the request
* @returns The locale's details.
*
* @example
* To retrieve a locale by its code:
*
* ```ts
* sdk.admin.locale.retrieve("en-US")
* .then(({ locale }) => {
* console.log(locale)
* })
* ```
*
* To specify the fields and relations to retrieve:
*
* ```ts
* sdk.admin.locale.retrieve("en-US", {
* fields: "code,name"
* })
* .then(({ locale }) => {
* console.log(locale)
* })
* ```
*
* Learn more about the `fields` property in the [API reference](https://docs.medusajs.com/api/store#select-fields-and-relations).
*/
async retrieve(
code: string,
query?: HttpTypes.AdminLocaleParams,
headers?: ClientHeaders
) {
return this.client.fetch<HttpTypes.AdminLocaleResponse>(
`/admin/locales/${code}`,
{
headers,
query,
}
)
}
}

View File

@@ -32,6 +32,7 @@ export * as StockLocationTypes from "./stock-location"
export * as StoreTypes from "./store"
export * as TaxTypes from "./tax"
export * as TransactionBaseTypes from "./transaction-base"
export * as TranslationTypes from "./translation"
export * as UserTypes from "./user"
export * as WorkflowTypes from "./workflow"
export * as WorkflowsSdkTypes from "./workflows-sdk"

View File

@@ -1,21 +1,21 @@
export interface BaseCurrency {
/**
* The currency's code.
*
*
* @example
* usd
*/
code: string
/**
* The currency's symbol.
*
*
* @example
* $
*/
symbol: string
/**
* The currency's symbol in its native language or country.
*
*
* @example
* $
*/

View File

@@ -7,6 +7,8 @@ export * from "./claim"
export * from "./collection"
export * from "./common"
export * from "./currency"
export * from "./locale"
export * from "./translations"
export * from "./customer"
export * from "./customer-group"
export * from "./draft-order"

View File

@@ -0,0 +1,3 @@
import { BaseLocale } from "../common"
export interface AdminLocale extends BaseLocale {}

View File

@@ -0,0 +1,3 @@
export * from "./entities"
export * from "./queries"
export * from "./responses"

View File

@@ -0,0 +1,17 @@
import { BaseFilterable } from "../../../dal"
import { FindParams, SelectParams } from "../../common"
export interface AdminLocaleParams extends SelectParams {}
export interface AdminLocaleListParams
extends FindParams,
BaseFilterable<AdminLocaleListParams> {
/**
* Query or keyword to search the locale's searchable fields.
*/
q?: string
/**
* Filter by locale code(s).
*/
code?: string | string[]
}

View File

@@ -0,0 +1,17 @@
import { PaginatedResponse } from "../../common"
import { AdminLocale } from "./entities"
export interface AdminLocaleResponse {
/**
* The locale's details.
*/
locale: AdminLocale
}
export interface AdminLocaleListResponse
extends PaginatedResponse<{
/**
* The list of locales.
*/
locales: AdminLocale[]
}> {}

View File

@@ -0,0 +1,28 @@
export interface BaseLocale {
/**
* The locale's code.
*
* @example
* en-US
*/
code: string
/**
* The locale's name.
*
* @example
* English (United States)
*/
name: string
/**
* The date the locale was created.
*/
created_at: string
/**
* The date the locale was updated.
*/
updated_at: string
/**
* The date the locale was deleted.
*/
deleted_at: string | null
}

View File

@@ -0,0 +1,2 @@
export * from "./admin"
export * from "./common"

View File

@@ -1,4 +1,5 @@
import { AdminCurrency } from "../../currency"
import { AdminLocale } from "../../locale"
export interface AdminStoreCurrency {
/**
@@ -7,7 +8,7 @@ export interface AdminStoreCurrency {
id: string
/**
* The currency code.
*
*
* @example
* "usd"
*/
@@ -38,6 +39,44 @@ export interface AdminStoreCurrency {
deleted_at: string | null
}
export interface AdminStoreLocale {
/**
* The locale's ID.
*/
id: string
/**
* The locale's code.
*
* @example
* "en-US"
*/
locale_code: string
/**
* The ID of the store that the locale belongs to.
*/
store_id: string
/**
* Whether the locale is the default locale for the store.
*/
is_default: boolean
/**
* The locale's details.
*/
locale: AdminLocale
/**
* The date the locale was created.
*/
created_at: string
/**
* The date the locale was updated.
*/
updated_at: string
/**
* The date the locale was deleted.
*/
deleted_at: string | null
}
export interface AdminStore {
/**
* The store's ID.
@@ -51,6 +90,10 @@ export interface AdminStore {
* The store's supported currencies.
*/
supported_currencies: AdminStoreCurrency[]
/**
* The store's supported locales.
*/
supported_locales: AdminStoreLocale[]
/**
* The store's default sales channel ID.
*/

View File

@@ -4,7 +4,7 @@
export interface AdminUpdateStoreSupportedCurrency {
/**
* The currency's ISO 3 code.
*
*
* @example
* usd
*/
@@ -15,12 +15,23 @@ export interface AdminUpdateStoreSupportedCurrency {
is_default?: boolean
/**
* Whether prices in this currency are tax inclusive.
*
*
* Learn more in [this documentation](https://docs.medusajs.com/resources/commerce-modules/pricing/tax-inclusive-pricing).
*/
is_tax_inclusive?: boolean
}
export interface AdminUpdateStoreSupportedLocale {
/**
* The locale's BCP 47 language tag.
*/
locale_code: string
/**
* Whether this locale is the default locale in the store.
*/
is_default?: boolean
}
/**
* The data to update in a store.
*/
@@ -33,6 +44,10 @@ export interface AdminUpdateStore {
* The supported currencies of the store.
*/
supported_currencies?: AdminUpdateStoreSupportedCurrency[]
/**
* The supported locales of the store.
*/
supported_locales?: AdminUpdateStoreSupportedLocale[]
/**
* The ID of the default sales channel of the store.
*/

View File

@@ -0,0 +1,41 @@
export interface AdminTranslation {
/**
* The ID of the translation.
*/
id: string
/**
* The ID of the entity being translated.
*/
reference_id: string
/**
* The type of entity being translated (e.g., "product", "product_variant").
*/
reference: string
/**
* The BCP 47 language tag code for this translation (e.g., "en-US", "fr-FR").
*/
locale_code: string
/**
* The translated fields as key-value pairs.
*/
translations: Record<string, unknown>
/**
* The date and time the translation was created.
*/
created_at: Date | string
/**
* The date and time the translation was last updated.
*/
updated_at: Date | string
/**
* The date and time the translation was deleted.
*/
deleted_at: Date | string | null
}

View File

@@ -0,0 +1,3 @@
export * from "./queries"
export * from "./responses"
export * from "./entities"

View File

@@ -0,0 +1,23 @@
import { BaseFilterable } from "../../.."
import { FindParams } from "../../common/request"
export interface AdminTranslationsListParams
extends FindParams,
BaseFilterable<AdminTranslationsListParams> {
/**
* Query or keywords to search the translations searchable fields.
*/
q?: string
/**
* Filter by entity ID.
*/
reference_id?: string | string[]
/**
* Filter by entity type.
*/
reference?: string
/**
* Filter by locale code.
*/
locale_code?: string | string[]
}

View File

@@ -0,0 +1,35 @@
import { PaginatedResponse } from "../../common"
import { AdminTranslation } from "./entities"
export interface AdminTranslationsResponse {
/**
* The list of translations.
*/
translation: AdminTranslation
}
export type AdminTranslationsListResponse = PaginatedResponse<{
/**
* The list of translations.
*/
translations: AdminTranslation[]
}>
export interface AdminTranslationsBatchResponse {
/**
* The created translations.
*/
created: AdminTranslation[]
/**
* The updated translations.
*/
updated: AdminTranslation[]
/**
* The deleted translations.
*/
deleted: {
ids: string[]
object: "translation"
deleted: boolean
}
}

View File

@@ -0,0 +1 @@
export * from "./admin"

View File

@@ -42,6 +42,7 @@ export * from "./store"
export * from "./tax"
export * from "./totals"
export * from "./transaction-base"
export * from "./translation"
export * from "./user"
export * from "./workflow"
export * from "./workflows"

View File

@@ -31,6 +31,37 @@ export interface StoreCurrencyDTO {
deleted_at: string | null
}
export interface StoreLocaleDTO {
/**
* The ID of the store locale.
*/
id: string
/**
* The locale code of the store locale.
*/
locale_code: string
/**
* Whether the locale is the default one for the store.
*/
is_default: boolean
/**
* The store ID associated with the locale.
*/
store_id: string
/**
* The created date of the locale.
*/
created_at: string
/**
* The updated date of the locale.
*/
updated_at: string
/**
* The deleted date of the locale.
*/
deleted_at: string | null
}
/**
* The store details.
*/
@@ -50,6 +81,11 @@ export interface StoreDTO {
*/
supported_currencies?: StoreCurrencyDTO[]
/**
* The supported locale codes of the store.
*/
supported_locales?: StoreLocaleDTO[]
/**
* The associated default sales channel's ID.
*/

View File

@@ -9,6 +9,17 @@ export interface CreateStoreCurrencyDTO {
is_default?: boolean
}
export interface CreateStoreLocaleDTO {
/**
* The locale code of the store locale.
*/
locale_code: string
/**
* Whether the locale is the default one for the store.
*/
is_default?: boolean
}
/**
* The store to be created.
*/
@@ -23,6 +34,11 @@ export interface CreateStoreDTO {
*/
supported_currencies?: CreateStoreCurrencyDTO[]
/**
* The suppoprted locale codes of the store.
*/
supported_locales?: CreateStoreLocaleDTO[]
/**
* The associated default sales channel's ID.
*/
@@ -68,6 +84,11 @@ export interface UpdateStoreDTO {
*/
supported_currencies?: CreateStoreCurrencyDTO[]
/**
* The supported locale codes of the store.
*/
supported_locales?: CreateStoreLocaleDTO[]
/**
* The associated default sales channel's ID.
*/

View File

@@ -0,0 +1,134 @@
import { BaseFilterable, OperatorMap } from "../dal"
/**
* The locale details.
*/
export interface LocaleDTO {
/**
* The ID of the locale.
*/
id: string
/**
* The BCP 47 language tag code of the locale (e.g., "en-US", "fr-FR").
*/
code: string
/**
* The human-readable name of the locale (e.g., "English (United States)").
*/
name: string
/**
* The date and time the locale was created.
*/
created_at: Date | string
/**
* The date and time the locale was last updated.
*/
updated_at: Date | string
/**
* The date and time the locale was deleted.
*/
deleted_at: Date | string | null
}
/**
* The translation details.
*/
export interface TranslationDTO {
/**
* The ID of the translation.
*/
id: string
/**
* The ID of the entity being translated.
*/
reference_id: string
/**
* The type of entity being translated (e.g., "product", "product_variant").
*/
reference: string
/**
* The BCP 47 language tag code for this translation (e.g., "en-US", "fr-FR").
*/
locale_code: string
/**
* The translated fields as key-value pairs.
*/
translations: Record<string, unknown>
/**
* The date and time the translation was created.
*/
created_at: Date | string
/**
* The date and time the translation was last updated.
*/
updated_at: Date | string
/**
* The date and time the translation was deleted.
*/
deleted_at: Date | string | null
}
/**
* The filters to apply on the retrieved locales.
*/
export interface FilterableLocaleProps
extends BaseFilterable<FilterableLocaleProps> {
/**
* The IDs to filter the locales by.
*/
id?: string[] | string | OperatorMap<string | string[]>
/**
* Filter locales by their code.
*/
code?: string | string[] | OperatorMap<string>
/**
* Filter locales by their name.
*/
name?: string | OperatorMap<string>
}
/**
* The filters to apply on the retrieved translations.
*/
export interface FilterableTranslationProps
extends BaseFilterable<FilterableTranslationProps> {
/**
* Search through translated content using this search term.
* This searches within the JSONB translations field values.
*/
q?: string
/**
* The IDs to filter the translations by.
*/
id?: string[] | string | OperatorMap<string | string[]>
/**
* Filter translations by entity ID.
*/
reference_id?: string | string[] | OperatorMap<string>
/**
* Filter translations by entity type.
*/
reference?: string | string[] | OperatorMap<string>
/**
* Filter translations by locale code.
*/
locale_code?: string | string[] | OperatorMap<string>
}

View File

@@ -0,0 +1,3 @@
export * from "./common"
export * from "./mutations"
export * from "./service"

View File

@@ -0,0 +1,144 @@
/**
* The locale to be created.
*/
export interface CreateLocaleDTO {
/**
* The ID of the locale to create.
*/
id?: string
/**
* The BCP 47 language tag code of the locale (e.g., "en-US", "fr-FR").
*/
code: string
/**
* The human-readable name of the locale (e.g., "English (United States)").
*/
name: string
}
/**
* The attributes to update in the locale.
*/
export interface UpdateLocaleDTO {
/**
* The ID of the locale to update.
*/
id: string
/**
* The BCP 47 language tag code of the locale.
*/
code?: string
/**
* The human-readable name of the locale.
*/
name?: string
}
/**
* The attributes in the locale to be created or updated.
*/
export interface UpsertLocaleDTO {
/**
* The ID of the locale in case of an update.
*/
id?: string
/**
* The BCP 47 language tag code of the locale.
*/
code?: string
/**
* The human-readable name of the locale.
*/
name?: string
}
/**
* The translation to be created.
*/
export interface CreateTranslationDTO {
/**
* The ID of the entity being translated.
*/
reference_id: string
/**
* The type of entity being translated (e.g., "product", "product_variant").
*/
reference: string
/**
* The BCP 47 language tag code for this translation (e.g., "en-US", "fr-FR").
*/
locale_code: string
/**
* The translated fields as key-value pairs.
*/
translations: Record<string, unknown>
}
/**
* The attributes to update in the translation.
*/
export interface UpdateTranslationDTO {
/**
* The ID of the translation to update.
*/
id: string
/**
* The ID of the entity being translated.
*/
reference_id?: string
/**
* The type of entity being translated.
*/
reference?: string
/**
* The BCP 47 language tag code for this translation.
*/
locale_code?: string
/**
* The translated fields as key-value pairs.
*/
translations?: Record<string, unknown>
}
/**
* The attributes in the translation to be created or updated.
*/
export interface UpsertTranslationDTO {
/**
* The ID of the translation in case of an update.
*/
id?: string
/**
* The ID of the entity being translated.
*/
reference_id?: string
/**
* The type of entity being translated.
*/
reference?: string
/**
* The BCP 47 language tag code for this translation.
*/
locale_code?: string
/**
* The translated fields as key-value pairs.
*/
translations?: Record<string, unknown>
}

View File

@@ -0,0 +1,292 @@
import { FindConfig } from "../common"
import { RestoreReturn, SoftDeleteReturn } from "../dal"
import { IModuleService } from "../modules-sdk"
import { Context } from "../shared-context"
import {
FilterableLocaleProps,
FilterableTranslationProps,
LocaleDTO,
TranslationDTO,
} from "./common"
import {
CreateLocaleDTO,
CreateTranslationDTO,
UpdateLocaleDTO,
UpdateTranslationDTO,
} from "./mutations"
/**
* The main service interface for the Translation Module.
* Method signatures match what MedusaService generates.
*/
export interface ITranslationModuleService extends IModuleService {
/**
* This method retrieves a locale by its ID.
*
* @param {string} id - The ID of the locale.
* @param {FindConfig<LocaleDTO>} config - The configurations determining how the locale is retrieved.
* @param {Context} sharedContext
* @returns {Promise<LocaleDTO>} The retrieved locale.
*/
retrieveLocale(
id: string,
config?: FindConfig<LocaleDTO>,
sharedContext?: Context
): Promise<LocaleDTO>
/**
* This method retrieves a paginated list of locales based on optional filters and configuration.
*
* @param {FilterableLocaleProps} filters - The filters to apply on the retrieved locales.
* @param {FindConfig<LocaleDTO>} config - The configurations determining how the locale is retrieved.
* @param {Context} sharedContext
* @returns {Promise<LocaleDTO[]>} The list of locales.
*/
listLocales(
filters?: FilterableLocaleProps,
config?: FindConfig<LocaleDTO>,
sharedContext?: Context
): Promise<LocaleDTO[]>
/**
* This method retrieves a paginated list of locales along with the total count.
*
* @param {FilterableLocaleProps} filters - The filters to apply on the retrieved locales.
* @param {FindConfig<LocaleDTO>} config - The configurations determining how the locale is retrieved.
* @param {Context} sharedContext
* @returns {Promise<[LocaleDTO[], number]>} The list of locales along with their total count.
*/
listAndCountLocales(
filters?: FilterableLocaleProps,
config?: FindConfig<LocaleDTO>,
sharedContext?: Context
): Promise<[LocaleDTO[], number]>
/**
* This method creates a locale.
*
* @param {CreateLocaleDTO} data - The locale to be created.
* @param {Context} sharedContext
* @returns {Promise<LocaleDTO>} The created locale.
*/
createLocales(
data: CreateLocaleDTO,
sharedContext?: Context
): Promise<LocaleDTO>
/**
* This method creates locales.
*
* @param {CreateLocaleDTO[]} data - The locales to be created.
* @param {Context} sharedContext
* @returns {Promise<LocaleDTO[]>} The created locales.
*/
createLocales(
data: CreateLocaleDTO[],
sharedContext?: Context
): Promise<LocaleDTO[]>
/**
* This method updates an existing locale. The ID should be included in the data object.
*
* @param {UpdateLocaleDTO} data - The attributes to update in the locale (including id).
* @param {Context} sharedContext
* @returns {Promise<LocaleDTO>} The updated locale.
*/
updateLocales(
data: UpdateLocaleDTO,
sharedContext?: Context
): Promise<LocaleDTO>
/**
* This method updates existing locales using an array or selector-based approach.
*
* @param {UpdateLocaleDTO[] | { selector: Record<string, any>; data: UpdateLocaleDTO | UpdateLocaleDTO[] }} dataOrOptions - The data or options for bulk update.
* @param {Context} sharedContext
* @returns {Promise<LocaleDTO[]>} The updated locales.
*/
updateLocales(
dataOrOptions:
| UpdateLocaleDTO[]
| {
selector: Record<string, any>
data: UpdateLocaleDTO | UpdateLocaleDTO[]
},
sharedContext?: Context
): Promise<LocaleDTO[]>
/**
* This method deletes locales by their IDs or objects.
*
* @param {string | object | string[] | object[]} primaryKeyValues - The IDs or objects identifying the locales to delete.
* @param {Context} sharedContext
* @returns {Promise<void>} Resolves when the locales are deleted.
*/
deleteLocales(
primaryKeyValues: string | object | string[] | object[],
sharedContext?: Context
): Promise<void>
/**
* This method soft deletes locales by their IDs or objects.
*
* @param {string | object | string[] | object[]} primaryKeyValues - The IDs or objects identifying the locales to soft delete.
* @param {SoftDeleteReturn<TReturnableLinkableKeys>} config - An object for related entities that should be soft-deleted.
* @param {Context} sharedContext
* @returns {Promise<Record<string, string[]> | void>} An object with IDs of related records that were also soft deleted.
*/
softDeleteLocales<TReturnableLinkableKeys extends string = string>(
primaryKeyValues: string | object | string[] | object[],
config?: SoftDeleteReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
/**
* This method restores soft deleted locales by their IDs or objects.
*
* @param {string | object | string[] | object[]} primaryKeyValues - The IDs or objects identifying the locales to restore.
* @param {RestoreReturn<TReturnableLinkableKeys>} config - Configurations determining which relations to restore.
* @param {Context} sharedContext
* @returns {Promise<Record<string, string[]> | void>} An object with IDs of related records that were restored.
*/
restoreLocales<TReturnableLinkableKeys extends string = string>(
primaryKeyValues: string | object | string[] | object[],
config?: RestoreReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
/**
* This method retrieves a translation by its ID.
*
* @param {string} id - The ID of the translation.
* @param {FindConfig<TranslationDTO>} config - The configurations determining how the translation is retrieved.
* @param {Context} sharedContext
* @returns {Promise<TranslationDTO>} The retrieved translation.
*/
retrieveTranslation(
id: string,
config?: FindConfig<TranslationDTO>,
sharedContext?: Context
): Promise<TranslationDTO>
/**
* This method retrieves a paginated list of translations based on optional filters and configuration.
*
* @param {FilterableTranslationProps} filters - The filters to apply on the retrieved translations.
* @param {FindConfig<TranslationDTO>} config - The configurations determining how the translation is retrieved.
* @param {Context} sharedContext
* @returns {Promise<TranslationDTO[]>} The list of translations.
*/
listTranslations(
filters?: FilterableTranslationProps,
config?: FindConfig<TranslationDTO>,
sharedContext?: Context
): Promise<TranslationDTO[]>
/**
* This method retrieves a paginated list of translations along with the total count.
*
* @param {FilterableTranslationProps} filters - The filters to apply on the retrieved translations.
* @param {FindConfig<TranslationDTO>} config - The configurations determining how the translation is retrieved.
* @param {Context} sharedContext
* @returns {Promise<[TranslationDTO[], number]>} The list of translations along with their total count.
*/
listAndCountTranslations(
filters?: FilterableTranslationProps,
config?: FindConfig<TranslationDTO>,
sharedContext?: Context
): Promise<[TranslationDTO[], number]>
/**
* This method creates a translation.
*
* @param {CreateTranslationDTO} data - The translation to be created.
* @param {Context} sharedContext
* @returns {Promise<TranslationDTO>} The created translation.
*/
createTranslations(
data: CreateTranslationDTO,
sharedContext?: Context
): Promise<TranslationDTO>
/**
* This method creates translations.
*
* @param {CreateTranslationDTO[]} data - The translations to be created.
* @param {Context} sharedContext
* @returns {Promise<TranslationDTO[]>} The created translations.
*/
createTranslations(
data: CreateTranslationDTO[],
sharedContext?: Context
): Promise<TranslationDTO[]>
/**
* This method updates an existing translation. The ID should be included in the data object.
*
* @param {UpdateTranslationDTO} data - The attributes to update in the translation (including id).
* @param {Context} sharedContext
* @returns {Promise<TranslationDTO>} The updated translation.
*/
updateTranslations(
data: UpdateTranslationDTO,
sharedContext?: Context
): Promise<TranslationDTO>
/**
* This method updates existing translations using an array or selector-based approach.
*
* @param {UpdateTranslationDTO[] | { selector: Record<string, any>; data: UpdateTranslationDTO | UpdateTranslationDTO[] }} dataOrOptions - The data or options for bulk update.
* @param {Context} sharedContext
* @returns {Promise<TranslationDTO[]>} The updated translations.
*/
updateTranslations(
dataOrOptions:
| UpdateTranslationDTO[]
| {
selector: Record<string, any>
data: UpdateTranslationDTO | UpdateTranslationDTO[]
},
sharedContext?: Context
): Promise<TranslationDTO[]>
/**
* This method deletes translations by their IDs or objects.
*
* @param {string | object | string[] | object[]} primaryKeyValues - The IDs or objects identifying the translations to delete.
* @param {Context} sharedContext
* @returns {Promise<void>} Resolves when the translations are deleted.
*/
deleteTranslations(
primaryKeyValues: string | object | string[] | object[],
sharedContext?: Context
): Promise<void>
/**
* This method soft deletes translations by their IDs or objects.
*
* @param {string | object | string[] | object[]} primaryKeyValues - The IDs or objects identifying the translations to soft delete.
* @param {SoftDeleteReturn<TReturnableLinkableKeys>} config - An object for related entities that should be soft-deleted.
* @param {Context} sharedContext
* @returns {Promise<Record<string, string[]> | void>} An object with IDs of related records that were also soft deleted.
*/
softDeleteTranslations<TReturnableLinkableKeys extends string = string>(
primaryKeyValues: string | object | string[] | object[],
config?: SoftDeleteReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
/**
* This method restores soft deleted translations by their IDs or objects.
*
* @param {string | object | string[] | object[]} primaryKeyValues - The IDs or objects identifying the translations to restore.
* @param {RestoreReturn<TReturnableLinkableKeys>} config - Configurations determining which relations to restore.
* @param {Context} sharedContext
* @returns {Promise<Record<string, string[]> | void>} An object with IDs of related records that were restored.
*/
restoreTranslations<TReturnableLinkableKeys extends string = string>(
primaryKeyValues: string | object | string[] | object[],
config?: RestoreReturn<TReturnableLinkableKeys>,
sharedContext?: Context
): Promise<Record<string, string[]> | void>
}

View File

@@ -19,4 +19,5 @@ export * as SearchUtils from "./search"
export * as ShippingProfileUtils from "./shipping"
export * as UserUtils from "./user"
export * as CachingUtils from "./caching"
export * as TranslationsUtils from "./translations"
export * as DevServerUtils from "./dev-server"

View File

@@ -0,0 +1,172 @@
import { normalizeLocale } from "../normalize-locale"
describe("normalizeLocale", function () {
it("should normalize single segment locales to lowercase", function () {
const expectations = [
{
input: "eN",
output: "en",
},
{
input: "EN",
output: "en",
},
{
input: "En",
output: "en",
},
{
input: "en",
output: "en",
},
{
input: "fr",
output: "fr",
},
{
input: "FR",
output: "fr",
},
{
input: "de",
output: "de",
},
]
expectations.forEach((expectation) => {
expect(normalizeLocale(expectation.input)).toEqual(expectation.output)
})
})
it("should normalize two segment locales (language-region)", function () {
const expectations = [
{
input: "en-Us",
output: "en-US",
},
{
input: "EN-US",
output: "en-US",
},
{
input: "en-us",
output: "en-US",
},
{
input: "En-Us",
output: "en-US",
},
{
input: "fr-FR",
output: "fr-FR",
},
{
input: "FR-fr",
output: "fr-FR",
},
{
input: "de-DE",
output: "de-DE",
},
{
input: "es-ES",
output: "es-ES",
},
{
input: "pt-BR",
output: "pt-BR",
},
]
expectations.forEach((expectation) => {
expect(normalizeLocale(expectation.input)).toEqual(expectation.output)
})
})
it("should normalize three segment locales (language-script-region)", function () {
const expectations = [
{
input: "RU-cYrl-By",
output: "ru-Cyrl-BY",
},
{
input: "ru-cyrl-by",
output: "ru-Cyrl-BY",
},
{
input: "RU-CYRL-BY",
output: "ru-Cyrl-BY",
},
{
input: "zh-Hans-CN",
output: "zh-Hans-CN",
},
{
input: "ZH-HANS-CN",
output: "zh-Hans-CN",
},
{
input: "sr-Latn-RS",
output: "sr-Latn-RS",
},
{
input: "SR-LATN-RS",
output: "sr-Latn-RS",
},
]
expectations.forEach((expectation) => {
expect(normalizeLocale(expectation.input)).toEqual(expectation.output)
})
})
it("should return locale as-is for more than three segments", function () {
const expectations = [
{
input: "en-US-x-private",
output: "en-US-x-private",
},
{
input: "en-US-x-private-extended",
output: "en-US-x-private-extended",
},
{
input: "en-US-x-private-extended-more",
output: "en-US-x-private-extended-more",
},
]
expectations.forEach((expectation) => {
expect(normalizeLocale(expectation.input)).toEqual(expectation.output)
})
})
it("should handle edge cases", function () {
const expectations = [
{
input: "",
output: "",
},
{
input: "a",
output: "a",
},
{
input: "A",
output: "a",
},
{
input: "a-B",
output: "a-B",
},
{
input: "a-b-C",
output: "a-B-C",
},
]
expectations.forEach((expectation) => {
expect(normalizeLocale(expectation.input)).toEqual(expectation.output)
})
})
})

View File

@@ -185,6 +185,9 @@ function resolveModules(
{ resolve: MODULE_PACKAGE_NAMES[Modules.ORDER] },
{ resolve: MODULE_PACKAGE_NAMES[Modules.SETTINGS] },
// TODO: re-enable this once we have the final release
// { resolve: MODULE_PACKAGE_NAMES[Modules.TRANSLATION] },
{
resolve: MODULE_PACKAGE_NAMES[Modules.AUTH],
options: {

View File

@@ -53,6 +53,7 @@ export * from "./medusa-container"
export * from "./merge-metadata"
export * from "./merge-plugin-modules"
export * from "./normalize-csv-value"
export * from "./normalize-locale"
export * from "./normalize-import-path-with-source"
export * from "./object-from-string-path"
export * from "./object-to-string-path"

View File

@@ -0,0 +1,40 @@
import { upperCaseFirst } from "./upper-case-first"
/**
* Normalizes a locale string to {@link https://developer.mozilla.org/en-US/docs/Glossary/BCP_47_language_tag|BCP 47 language tag format}
* @param locale - The locale string to normalize
* @returns The normalized locale string
*
* @example
* input: "en-Us"
* output: "en-US"
*
* @example
* input: "eN"
* output: "en"
*
* @example
* input: "RU-cYrl-By"
* output: "ru-Cyrl-BY"
*/
export function normalizeLocale(locale: string) {
const segments = locale.split("-")
if (segments.length === 1) {
return segments[0].toLowerCase()
}
// e.g en-US
if (segments.length === 2) {
return `${segments[0].toLowerCase()}-${segments[1].toUpperCase()}`
}
// e.g ru-Cyrl-BY
if (segments.length === 3) {
return `${segments[0].toLowerCase()}-${upperCaseFirst(
segments[1].toLowerCase()
)}-${segments[2].toUpperCase()}`
}
return locale
}

View File

@@ -30,6 +30,7 @@ export * from "./totals"
export * from "./totals/big-number"
export * from "./user"
export * from "./caching"
export * from "./translations"
export * from "./dev-server"
export const MedusaModuleType = Symbol.for("MedusaModule")

View File

@@ -1,5 +1,13 @@
import { LinkModulesExtraFields, ModuleJoinerConfig } from "@medusajs/types"
import { camelToSnakeCase, isObject, pluralize, toPascalCase } from "../common"
import {
camelToSnakeCase,
getCallerFilePath,
isFileDisabled,
isObject,
MEDUSA_SKIP_FILE,
pluralize,
toPascalCase,
} from "../common"
import { composeLinkName } from "../link/compose-link-name"
export const DefineLinkSymbol = Symbol.for("DefineLink")
@@ -193,6 +201,11 @@ export function defineLink(
rightService: DefineLinkInputSource | DefineReadOnlyLinkInputSource,
linkServiceOptions?: ExtraOptions | ReadOnlyExtraOptions
): DefineLinkExport {
const callerFilePath = getCallerFilePath()
if (isFileDisabled(callerFilePath ?? "")) {
return { [MEDUSA_SKIP_FILE]: true } as any
}
const serviceAObj = prepareServiceConfig(leftService)
const serviceBObj = prepareServiceConfig(rightService)

View File

@@ -28,6 +28,7 @@ export const Modules = {
LOCKING: "locking",
SETTINGS: "settings",
CACHING: "caching",
TRANSLATION: "translation",
} as const
export const MODULE_PACKAGE_NAMES = {
@@ -60,6 +61,7 @@ export const MODULE_PACKAGE_NAMES = {
[Modules.LOCKING]: "@medusajs/medusa/locking",
[Modules.SETTINGS]: "@medusajs/medusa/settings",
[Modules.CACHING]: "@medusajs/caching",
[Modules.TRANSLATION]: "@medusajs/translation",
}
export const REVERSED_MODULE_PACKAGE_NAMES = Object.entries(

View File

@@ -0,0 +1,363 @@
import { FeatureFlag } from "../../feature-flags"
import { applyTranslations } from "../apply-translations"
jest.mock("../../feature-flags/flag-router", () => ({
...jest.requireActual("../../feature-flags/flag-router"),
FeatureFlag: {
isFeatureEnabled: jest.fn(),
},
}))
const mockFeatureFlagIsEnabled = FeatureFlag.isFeatureEnabled as jest.Mock
describe("applyTranslations", () => {
let mockQuery: { graph: jest.Mock }
let mockContainer: { resolve: jest.Mock }
let mockReq: { locale?: string }
beforeEach(() => {
jest.clearAllMocks()
mockQuery = {
graph: jest.fn().mockResolvedValue({ data: [] }),
}
mockContainer = {
resolve: jest.fn().mockReturnValue(mockQuery),
}
mockReq = {
locale: "en-US",
}
})
beforeEach(() => {
mockFeatureFlagIsEnabled.mockReturnValue(true)
})
it("should apply translations to a simple object", async () => {
const inputObjects = [{ id: "prod_1", title: "Original Title" }]
mockQuery.graph.mockResolvedValue({
data: [
{
reference_id: "prod_1",
translations: { title: "Translated Title" },
},
],
})
await applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects,
container: mockContainer as any,
})
expect(inputObjects[0].title).toBe("Translated Title")
})
it("should apply translations to nested objects", async () => {
const inputObjects = [
{
id: "prod_1",
title: "Product Title",
category: {
id: "cat_1",
name: "Category Name",
},
},
]
mockQuery.graph.mockResolvedValue({
data: [
{
reference_id: "prod_1",
translations: { title: "Translated Product Title", category: true },
},
{
reference_id: "cat_1",
translations: { name: "Translated Category Name" },
},
],
})
await applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects,
container: mockContainer as any,
})
expect(inputObjects[0].title).toBe("Translated Product Title")
expect(inputObjects[0].category.name).toBe("Translated Category Name")
})
it("should apply translations to arrays of objects", async () => {
const inputObjects = [
{
id: "prod_1",
title: "Product Title",
variants: [
{ id: "var_1", name: "Variant 1" },
{ id: "var_2", name: "Variant 2" },
],
},
]
mockQuery.graph.mockResolvedValue({
data: [
{
reference_id: "prod_1",
translations: { title: "Translated Product" },
},
{
reference_id: "var_1",
translations: { name: "Translated Variant 1" },
},
{
reference_id: "var_2",
translations: { name: "Translated Variant 2" },
},
],
})
await applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects,
container: mockContainer as any,
})
expect(inputObjects[0].title).toBe("Translated Product")
expect(inputObjects[0].variants[0].name).toBe("Translated Variant 1")
expect(inputObjects[0].variants[1].name).toBe("Translated Variant 2")
})
it("should use the locale from the request", async () => {
mockReq.locale = "fr-FR"
const inputObjects = [{ id: "prod_1", title: "Original" }]
await applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects,
container: mockContainer as any,
})
expect(mockQuery.graph).toHaveBeenCalledWith(
expect.objectContaining({
filters: expect.objectContaining({
locale_code: "fr-FR",
}),
}),
expect.objectContaining({
cache: expect.objectContaining({
enable: true,
}),
})
)
})
it("should batch queries when there are more than 250 ids", async () => {
const inputObjects = Array.from({ length: 300 }, (_, i) => ({
id: `prod_${i}`,
title: `Product ${i}`,
}))
await applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects,
container: mockContainer as any,
})
expect(mockQuery.graph).toHaveBeenCalledTimes(2)
})
it("should apply translations to multiple input objects", async () => {
const inputObjects = [
{ id: "prod_1", title: "Product 1" },
{ id: "prod_2", title: "Product 2" },
]
mockQuery.graph.mockResolvedValue({
data: [
{ reference_id: "prod_1", translations: { title: "Translated 1" } },
{ reference_id: "prod_2", translations: { title: "Translated 2" } },
],
})
await applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects,
container: mockContainer as any,
})
expect(inputObjects[0].title).toBe("Translated 1")
expect(inputObjects[1].title).toBe("Translated 2")
})
it("should handle translations with null values", async () => {
const inputObjects = [{ id: "prod_1", title: "Original" }]
mockQuery.graph.mockResolvedValue({
data: [
{
reference_id: "prod_1",
translations: null,
},
],
})
await applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects,
container: mockContainer as any,
})
expect(inputObjects[0].title).toBe("Original")
})
it("should return early when feature flag is disabled", async () => {
mockFeatureFlagIsEnabled.mockReturnValue(false)
const inputObjects = [{ id: "prod_1", title: "Original" }]
await applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects,
container: mockContainer as any,
})
expect(mockContainer.resolve).not.toHaveBeenCalled()
expect(inputObjects[0].title).toBe("Original")
})
it("should not modify objects when no translations are found", async () => {
mockFeatureFlagIsEnabled.mockReturnValue(true)
const inputObjects = [{ id: "prod_1", title: "Original Title" }]
mockQuery.graph.mockResolvedValue({ data: [] })
await applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects,
container: mockContainer as any,
})
expect(inputObjects[0].title).toBe("Original Title")
})
it("should handle empty input array without errors", async () => {
mockFeatureFlagIsEnabled.mockReturnValue(true)
const inputObjects: Record<string, any>[] = []
await expect(
applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects,
container: mockContainer as any,
})
).resolves.not.toThrow()
})
it("should not modify properties that do not exist in the object", async () => {
mockFeatureFlagIsEnabled.mockReturnValue(true)
const inputObjects = [{ id: "prod_1", title: "Original" }]
mockQuery.graph.mockResolvedValue({
data: [
{
reference_id: "prod_1",
translations: { description: "Translated Description" },
},
],
})
await applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects,
container: mockContainer as any,
})
expect(inputObjects[0].title).toBe("Original")
expect(inputObjects[0]).not.toHaveProperty("description")
})
it("should handle objects with undefined id gracefully", async () => {
mockFeatureFlagIsEnabled.mockReturnValue(true)
const inputObjects = [{ id: undefined, title: "Original" }]
mockQuery.graph.mockResolvedValue({ data: [] })
await expect(
applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects as any,
container: mockContainer as any,
})
).resolves.not.toThrow()
})
it("should only apply translations to matching keys", async () => {
mockFeatureFlagIsEnabled.mockReturnValue(true)
const inputObjects = [
{ id: "prod_1", title: "Original Title", handle: "original-handle" },
]
mockQuery.graph.mockResolvedValue({
data: [
{
reference_id: "prod_1",
translations: { title: "Translated Title" },
},
],
})
await applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects,
container: mockContainer as any,
})
expect(inputObjects[0].title).toBe("Translated Title")
expect(inputObjects[0].handle).toBe("original-handle")
})
it("should handle deeply nested structures", async () => {
mockFeatureFlagIsEnabled.mockReturnValue(true)
const inputObjects = [
{
id: "prod_1",
title: "Product",
category: {
id: "cat_1",
name: "Category",
parent: {
id: "cat_parent",
name: "Parent Category",
},
},
},
]
mockQuery.graph.mockResolvedValue({
data: [
{
reference_id: "prod_1",
translations: { title: "Translated Product" },
},
{
reference_id: "cat_1",
translations: { name: "Translated Category" },
},
{
reference_id: "cat_parent",
translations: { name: "Translated Parent" },
},
],
})
await applyTranslations({
localeCode: mockReq.locale as string,
objects: inputObjects,
container: mockContainer as any,
})
expect(inputObjects[0].title).toBe("Translated Product")
expect(inputObjects[0].category.name).toBe("Translated Category")
expect(inputObjects[0].category.parent.name).toBe("Translated Parent")
})
})

View File

@@ -0,0 +1,139 @@
import { MedusaContainer, RemoteQueryFunction } from "@medusajs/types"
import { ContainerRegistrationKeys } from "../common/container"
import { isObject } from "../common/is-object"
import { FeatureFlag } from "../feature-flags/flag-router"
const excludedKeys = [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata",
]
function canApplyTranslationTo(object: Record<string, any>) {
return "id" in object && !!object.id
}
function gatherIds(object: Record<string, any>, gatheredIds: Set<string>) {
gatheredIds.add(object.id)
Object.entries(object).forEach(([, value]) => {
if (Array.isArray(value)) {
value.forEach((item) => item && gatherIds(item, gatheredIds))
} else if (isObject(value)) {
gatherIds(value, gatheredIds)
}
})
}
function applyTranslation(
object: Record<string, any>,
entityIdToTranslation: Map<string, Record<string, any>>
) {
const translation = entityIdToTranslation.get(object.id)
const hasTranslation = !!translation
Object.entries(object).forEach(([key, value]) => {
if (excludedKeys.includes(key)) {
return
}
if (hasTranslation) {
if (
key in translation &&
typeof object[key] === typeof translation[key]
) {
object[key] = translation[key]
return
}
}
if (Array.isArray(value)) {
value.forEach(
(item) =>
item &&
canApplyTranslationTo(item) &&
applyTranslation(item, entityIdToTranslation)
)
} else if (isObject(value) && canApplyTranslationTo(value)) {
applyTranslation(value, entityIdToTranslation)
}
})
}
export async function applyTranslations({
localeCode,
objects,
container,
}: {
localeCode: string | undefined
objects: Record<string, any>[]
container: MedusaContainer
}) {
const isTranslationEnabled = FeatureFlag.isFeatureEnabled("translation")
if (!isTranslationEnabled) {
return
}
const locale = localeCode
if (!locale) {
return
}
const objects_ = objects.filter((o) => !!o)
if (!objects_.length) {
return
}
const gatheredIds: Set<string> = new Set()
for (const inputObject of objects_) {
gatherIds(inputObject, gatheredIds)
}
const query = container.resolve<RemoteQueryFunction>(
ContainerRegistrationKeys.QUERY
)
const queryBatchSize = 250
const queryBatches = Math.ceil(gatheredIds.size / queryBatchSize)
const entityIdToTranslation = new Map<string, Record<string, any>>()
for (let i = 0; i < queryBatches; i++) {
// TODO: concurrently fetch if needed
const queryBatch = Array.from(gatheredIds)
.slice(i * queryBatchSize, (i + 1) * queryBatchSize)
.sort()
const { data: translations } = await query.graph(
{
entity: "translations",
fields: ["translations", "reference_id"],
filters: {
reference_id: queryBatch,
locale_code: locale,
},
pagination: {
take: queryBatchSize,
},
},
{
cache: { enable: true },
}
)
for (const translation of translations) {
entityIdToTranslation.set(
translation.reference_id,
translation.translations ?? {}
)
}
}
for (const inputObject of objects_) {
applyTranslation(inputObject, entityIdToTranslation)
}
}

View File

@@ -0,0 +1 @@
export * from "./apply-translations"

View File

@@ -25,6 +25,7 @@
"author": "Medusa",
"license": "MIT",
"devDependencies": {
"@medusajs/core-flows": "2.12.1",
"@medusajs/framework": "2.12.1"
},
"dependencies": {
@@ -35,10 +36,14 @@
"ulid": "^2.3.0"
},
"peerDependencies": {
"@medusajs/core-flows": "2.12.1",
"@medusajs/framework": "2.12.1",
"@medusajs/medusa": "2.12.1"
},
"peerDependenciesMeta": {
"@medusajs/core-flows": {
"optional": true
},
"@medusajs/medusa": {
"optional": true
}

View File

@@ -21,6 +21,7 @@ import {
} from "./medusa-test-runner-utils"
import { waitWorkflowExecutions } from "./medusa-test-runner-utils/wait-workflow-executions"
import { ulid } from "ulid"
import { createDefaultsWorkflow } from "@medusajs/core-flows"
export interface MedusaSuiteOptions {
dbConnection: any // knex instance
@@ -287,6 +288,7 @@ class MedusaTestRunner {
cwd: this.cwd,
})
await medusaAppLoader.runModulesLoader()
await createDefaultsWorkflow(copiedContainer).run()
} catch (error) {
await copiedContainer.dispose?.()
logger.error("Error running modules loaders:", error?.message)

View File

@@ -101,6 +101,7 @@
"@medusajs/store": "2.12.1",
"@medusajs/tax": "2.12.1",
"@medusajs/telemetry": "2.12.1",
"@medusajs/translation": "2.12.1",
"@medusajs/user": "2.12.1",
"@medusajs/workflow-engine-inmemory": "2.12.1",
"@medusajs/workflow-engine-redis": "2.12.1",

View File

@@ -0,0 +1,28 @@
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { HttpTypes } from "@medusajs/framework/types"
export const GET = async (
req: MedusaRequest<HttpTypes.AdminLocaleParams>,
res: MedusaResponse<HttpTypes.AdminLocaleResponse>
) => {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const {
data: [locale],
} = await query.graph(
{
entity: "locale",
filters: {
code: req.params.code,
},
fields: req.queryConfig.fields,
},
{
cache: { enable: true },
throwIfKeyNotFound: true,
}
)
res.status(200).json({ locale })
}

View File

@@ -0,0 +1,27 @@
import { MiddlewareRoute } from "@medusajs/framework/http"
import { validateAndTransformQuery } from "@medusajs/framework"
import * as QueryConfig from "./query-config"
import { AdminGetLocalesParams, AdminGetLocaleParams } from "./validators"
export const adminLocalesRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["GET"],
matcher: "/admin/locales",
middlewares: [
validateAndTransformQuery(
AdminGetLocalesParams,
QueryConfig.listTransformQueryConfig
),
],
},
{
method: ["GET"],
matcher: "/admin/locales/:code",
middlewares: [
validateAndTransformQuery(
AdminGetLocaleParams,
QueryConfig.retrieveTransformQueryConfig
),
],
},
]

View File

@@ -0,0 +1,12 @@
export const defaultAdminLocaleFields = ["code", "name"]
export const retrieveTransformQueryConfig = {
defaults: defaultAdminLocaleFields,
isList: false,
}
export const listTransformQueryConfig = {
...retrieveTransformQueryConfig,
defaultLimit: 200,
isList: true,
}

View File

@@ -0,0 +1,29 @@
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { HttpTypes } from "@medusajs/framework/types"
export const GET = async (
req: MedusaRequest<HttpTypes.AdminLocaleListParams>,
res: MedusaResponse<HttpTypes.AdminLocaleListResponse>
) => {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const { data: locales, metadata } = await query.graph(
{
entity: "locale",
filters: req.filterableFields,
fields: req.queryConfig.fields,
pagination: req.queryConfig.pagination,
},
{
cache: { enable: true },
}
)
res.json({
locales,
count: metadata?.count ?? 0,
offset: metadata?.skip ?? 0,
limit: metadata?.take ?? 0,
})
}

View File

@@ -0,0 +1,18 @@
import { z } from "zod"
import { createFindParams, createSelectParams } from "../../utils/validators"
import { applyAndAndOrOperators } from "../../utils/common-validators"
export const AdminGetLocaleParams = createSelectParams()
export const AdminGetLocalesParamsFields = z.object({
q: z.string().optional(),
code: z.union([z.string(), z.array(z.string())]).optional(),
})
export type AdminGetLocalesParamsType = z.infer<typeof AdminGetLocalesParams>
export const AdminGetLocalesParams = createFindParams({
offset: 0,
limit: 200,
})
.merge(AdminGetLocalesParamsFields)
.merge(applyAndAndOrOperators(AdminGetLocalesParamsFields))

View File

@@ -3,6 +3,8 @@ export const defaultAdminStoreFields = [
"name",
"*supported_currencies",
"*supported_currencies.currency",
"*supported_locales",
"*supported_locales.locale_code",
"default_sales_channel_id",
"default_region_id",
"default_location_id",

View File

@@ -31,6 +31,14 @@ export const AdminUpdateStore = z.object({
})
)
.optional(),
supported_locales: z
.array(
z.object({
locale_code: z.string(),
is_default: z.boolean().optional(),
})
)
.optional(),
default_sales_channel_id: z.string().nullish(),
default_region_id: z.string().nullish(),
default_location_id: z.string().nullish(),

View File

@@ -0,0 +1,68 @@
import { batchTranslationsWorkflow } from "@medusajs/core-flows"
import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework"
import {
ContainerRegistrationKeys,
defineFileConfig,
FeatureFlag,
} from "@medusajs/framework/utils"
import { BatchMethodRequest, HttpTypes } from "@medusajs/types"
import { defaultAdminTranslationFields } from "../query-config"
import {
AdminCreateTranslationType,
AdminUpdateTranslationType,
} from "../validators"
import TranslationFeatureFlag from "../../../../feature-flags/translation"
export const POST = async (
req: AuthenticatedMedusaRequest<
BatchMethodRequest<AdminCreateTranslationType, AdminUpdateTranslationType>
>,
res: MedusaResponse<HttpTypes.AdminTranslationsBatchResponse>
) => {
const { create = [], update = [], delete: deleteIds = [] } = req.validatedBody
const { result } = await batchTranslationsWorkflow(req.scope).run({
input: {
create,
update,
delete: deleteIds,
},
})
const ids = Array.from(
new Set([
...result.created.map((t) => t.id),
...result.updated.map((t) => t.id),
])
)
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const { data: translations } = await query.graph({
entity: "translation",
fields: defaultAdminTranslationFields,
filters: {
id: ids,
},
})
const created = translations.filter((t) =>
result.created.some((r) => r.id === t.id)
)
const updated = translations.filter((t) =>
result.updated.some((r) => r.id === t.id)
)
return res.status(200).json({
created,
updated,
deleted: {
ids: deleteIds,
object: "translation",
deleted: true,
},
})
}
defineFileConfig({
isDisabled: () => !FeatureFlag.isFeatureEnabled(TranslationFeatureFlag.key),
})

View File

@@ -0,0 +1,32 @@
import {
MiddlewareRoute,
validateAndTransformBody,
validateAndTransformQuery,
} from "@medusajs/framework"
import {
AdminBatchTranslations,
AdminGetTranslationsParams,
} from "./validators"
import * as QueryConfig from "./query-config"
import { DEFAULT_BATCH_ENDPOINTS_SIZE_LIMIT } from "../../../utils"
export const adminTranslationsRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["GET"],
matcher: "/admin/translations",
middlewares: [
validateAndTransformQuery(
AdminGetTranslationsParams,
QueryConfig.listTransformQueryConfig
),
],
},
{
method: ["POST"],
matcher: "/admin/translations/batch",
bodyParser: {
sizeLimit: DEFAULT_BATCH_ENDPOINTS_SIZE_LIMIT,
},
middlewares: [validateAndTransformBody(AdminBatchTranslations)],
},
]

View File

@@ -0,0 +1,17 @@
export const defaultAdminTranslationFields = [
"id",
"reference_id",
"reference",
"locale_code",
"translations",
]
export const retrieveTransformQueryConfig = {
defaults: defaultAdminTranslationFields,
isList: false,
}
export const listTransformQueryConfig = {
...retrieveTransformQueryConfig,
isList: true,
}

View File

@@ -0,0 +1,38 @@
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { HttpTypes } from "@medusajs/framework/types"
import {
ContainerRegistrationKeys,
defineFileConfig,
FeatureFlag,
} from "@medusajs/framework/utils"
import TranslationFeatureFlag from "../../../feature-flags/translation"
export const GET = async (
req: MedusaRequest<HttpTypes.AdminTranslationsListParams>,
res: MedusaResponse<HttpTypes.AdminTranslationsListResponse>
) => {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const { data: translations, metadata } = await query.graph(
{
entity: "translation",
fields: req.queryConfig.fields,
filters: req.filterableFields,
pagination: req.queryConfig.pagination,
},
{
cache: { enable: true },
}
)
return res.status(200).json({
translations,
count: metadata?.count ?? 0,
offset: metadata?.skip ?? 0,
limit: metadata?.take ?? 0,
})
}
defineFileConfig({
isDisabled: () => !FeatureFlag.isFeatureEnabled(TranslationFeatureFlag.key),
})

View File

@@ -0,0 +1,50 @@
import { applyAndAndOrOperators } from "../../utils/common-validators"
import {
createBatchBody,
createFindParams,
createSelectParams,
} from "../../utils/validators"
import { z } from "zod"
export const AdminGetTranslationParams = createSelectParams()
export const AdminGetTranslationParamsFields = z.object({
q: z.string().optional(),
reference_id: z.union([z.string(), z.array(z.string())]).optional(),
reference: z.string().optional(),
locale_code: z.string().optional(),
})
export type AdminGetTranslationsParamsType = z.infer<
typeof AdminGetTranslationsParams
>
export const AdminGetTranslationsParams = createFindParams({
limit: 20,
offset: 0,
})
.merge(AdminGetTranslationParamsFields)
.merge(applyAndAndOrOperators(AdminGetTranslationParamsFields))
export type AdminCreateTranslationType = z.infer<typeof AdminCreateTranslation>
export const AdminCreateTranslation = z.object({
reference_id: z.string(),
reference: z.string(),
locale_code: z.string(),
translations: z.record(z.string()),
})
export type AdminUpdateTranslationType = z.infer<typeof AdminUpdateTranslation>
export const AdminUpdateTranslation = z.object({
id: z.string(),
reference_id: z.string().optional(),
reference: z.string().optional(),
locale_code: z.string().optional(),
translations: z.record(z.string()).optional(),
})
export type AdminBatchTranslationsType = z.infer<typeof AdminBatchTranslations>
export const AdminBatchTranslations = createBatchBody(
AdminCreateTranslation,
AdminUpdateTranslation
)

View File

@@ -66,6 +66,8 @@ import { storeReturnReasonRoutesMiddlewares } from "./store/return-reasons/middl
import { storeShippingOptionRoutesMiddlewares } from "./store/shipping-options/middlewares"
import { adminShippingOptionTypeRoutesMiddlewares } from "./admin/shipping-option-types/middlewares"
import { adminIndexRoutesMiddlewares } from "./admin/index/middlewares"
import { adminLocalesRoutesMiddlewares } from "./admin/locales/middlewares"
import { adminTranslationsRoutesMiddlewares } from "./admin/translations/middlewares"
export default defineMiddlewares([
...storeRoutesMiddlewares,
@@ -94,10 +96,12 @@ export default defineMiddlewares([
...adminInviteRoutesMiddlewares,
...adminTaxRateRoutesMiddlewares,
...adminTaxRegionRoutesMiddlewares,
...adminTranslationsRoutesMiddlewares,
...adminApiKeyRoutesMiddlewares,
...hooksRoutesMiddlewares,
...adminStoreRoutesMiddlewares,
...adminCurrencyRoutesMiddlewares,
...adminLocalesRoutesMiddlewares,
...storeCurrencyRoutesMiddlewares,
...adminProductRoutesMiddlewares,
...adminPaymentRoutesMiddlewares,

View File

@@ -1,6 +1,11 @@
import { MedusaResponse } from "@medusajs/framework/http"
import { HttpTypes } from "@medusajs/framework/types"
import { isPresent, MedusaError, QueryContext } from "@medusajs/framework/utils"
import {
applyTranslations,
isPresent,
MedusaError,
QueryContext,
} from "@medusajs/framework/utils"
import { wrapVariantsWithInventoryQuantityForSalesChannel } from "../../../utils/middlewares"
import {
filterOutInternalProductCategories,
@@ -69,5 +74,10 @@ export const GET = async (
}
await wrapProductsWithTaxPrices(req, [product])
await applyTranslations({
localeCode: req.locale,
objects: [product],
container: req.scope,
})
res.json({ product })
}

View File

@@ -1,6 +1,7 @@
import { validateAndTransformQuery } from "@medusajs/framework"
import {
applyDefaultFilters,
applyLocale,
applyParamsAsFilters,
authenticate,
clearFiltersByKey,
@@ -63,6 +64,10 @@ async function applyMaybeLinkFilterIfNecessary(
}
export const storeProductRoutesMiddlewares: MiddlewareRoute[] = [
{
matcher: "/store/products/*",
middlewares: [applyLocale],
},
{
method: ["GET"],
matcher: "/store/products",

Some files were not shown because too many files have changed in this diff Show More