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
co-authored by Nicolas Gorga Oli Juhl
parent fea3d4ec49
commit 6dc0b8bed8
130 changed files with 5649 additions and 112 deletions
+1
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"
@@ -0,0 +1,2 @@
export * from "./steps"
export * from "./workflows"
@@ -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)
}
)
@@ -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)
}
)
@@ -0,0 +1,4 @@
export * from "./create-translations"
export * from "./delete-translations"
export * from "./update-translations"
export * from "./validate-translations"
@@ -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,
}))
)
}
)
@@ -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)
}
)
@@ -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)
)
}
)
@@ -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)
}
)
@@ -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,
})
}
)
@@ -0,0 +1,4 @@
export * from "./create-translations"
export * from "./delete-translations"
export * from "./update-translations"
export * from "./batch-translations"
@@ -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)
}
)
@@ -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)
}
})
})
@@ -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()
}
@@ -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"
+47 -1
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}`
)
@@ -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 {
@@ -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
}
}
+6
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)
+119
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,
}
)
}
}
+1
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"
@@ -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
* $
*/
+2
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"
@@ -0,0 +1,3 @@
import { BaseLocale } from "../common"
export interface AdminLocale extends BaseLocale {}
@@ -0,0 +1,3 @@
export * from "./entities"
export * from "./queries"
export * from "./responses"
@@ -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[]
}
@@ -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[]
}> {}
@@ -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
}
@@ -0,0 +1,2 @@
export * from "./admin"
export * from "./common"
@@ -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.
*/
@@ -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.
*/
@@ -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
}
@@ -0,0 +1,3 @@
export * from "./queries"
export * from "./responses"
export * from "./entities"
@@ -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[]
}
@@ -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
}
}
@@ -0,0 +1 @@
export * from "./admin"
+1
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"
@@ -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.
*/
@@ -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.
*/
@@ -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>
}
@@ -0,0 +1,3 @@
export * from "./common"
export * from "./mutations"
export * from "./service"
@@ -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>
}
@@ -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>
}
+1
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"
@@ -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)
})
})
})
@@ -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: {
+1
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"
@@ -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
}
+1
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")
@@ -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)
@@ -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(
@@ -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")
})
})
@@ -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)
}
}
@@ -0,0 +1 @@
export * from "./apply-translations"