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:
co-authored by
Nicolas Gorga
Oli Juhl
parent
fea3d4ec49
commit
6dc0b8bed8
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
* $
|
||||
*/
|
||||
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user