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:
committed by
GitHub
parent
fea3d4ec49
commit
6dc0b8bed8
@@ -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"
|
||||
|
||||
40
packages/core/utils/src/common/normalize-locale.ts
Normal file
40
packages/core/utils/src/common/normalize-locale.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { upperCaseFirst } from "./upper-case-first"
|
||||
|
||||
/**
|
||||
* Normalizes a locale string to {@link https://developer.mozilla.org/en-US/docs/Glossary/BCP_47_language_tag|BCP 47 language tag format}
|
||||
* @param locale - The locale string to normalize
|
||||
* @returns The normalized locale string
|
||||
*
|
||||
* @example
|
||||
* input: "en-Us"
|
||||
* output: "en-US"
|
||||
*
|
||||
* @example
|
||||
* input: "eN"
|
||||
* output: "en"
|
||||
*
|
||||
* @example
|
||||
* input: "RU-cYrl-By"
|
||||
* output: "ru-Cyrl-BY"
|
||||
*/
|
||||
export function normalizeLocale(locale: string) {
|
||||
const segments = locale.split("-")
|
||||
|
||||
if (segments.length === 1) {
|
||||
return segments[0].toLowerCase()
|
||||
}
|
||||
|
||||
// e.g en-US
|
||||
if (segments.length === 2) {
|
||||
return `${segments[0].toLowerCase()}-${segments[1].toUpperCase()}`
|
||||
}
|
||||
|
||||
// e.g ru-Cyrl-BY
|
||||
if (segments.length === 3) {
|
||||
return `${segments[0].toLowerCase()}-${upperCaseFirst(
|
||||
segments[1].toLowerCase()
|
||||
)}-${segments[2].toUpperCase()}`
|
||||
}
|
||||
|
||||
return locale
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
139
packages/core/utils/src/translations/apply-translations.ts
Normal file
139
packages/core/utils/src/translations/apply-translations.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { MedusaContainer, RemoteQueryFunction } from "@medusajs/types"
|
||||
import { ContainerRegistrationKeys } from "../common/container"
|
||||
import { isObject } from "../common/is-object"
|
||||
import { FeatureFlag } from "../feature-flags/flag-router"
|
||||
|
||||
const excludedKeys = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"deleted_at",
|
||||
"metadata",
|
||||
]
|
||||
|
||||
function canApplyTranslationTo(object: Record<string, any>) {
|
||||
return "id" in object && !!object.id
|
||||
}
|
||||
|
||||
function gatherIds(object: Record<string, any>, gatheredIds: Set<string>) {
|
||||
gatheredIds.add(object.id)
|
||||
Object.entries(object).forEach(([, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => item && gatherIds(item, gatheredIds))
|
||||
} else if (isObject(value)) {
|
||||
gatherIds(value, gatheredIds)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function applyTranslation(
|
||||
object: Record<string, any>,
|
||||
entityIdToTranslation: Map<string, Record<string, any>>
|
||||
) {
|
||||
const translation = entityIdToTranslation.get(object.id)
|
||||
const hasTranslation = !!translation
|
||||
|
||||
Object.entries(object).forEach(([key, value]) => {
|
||||
if (excludedKeys.includes(key)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (hasTranslation) {
|
||||
if (
|
||||
key in translation &&
|
||||
typeof object[key] === typeof translation[key]
|
||||
) {
|
||||
object[key] = translation[key]
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(
|
||||
(item) =>
|
||||
item &&
|
||||
canApplyTranslationTo(item) &&
|
||||
applyTranslation(item, entityIdToTranslation)
|
||||
)
|
||||
} else if (isObject(value) && canApplyTranslationTo(value)) {
|
||||
applyTranslation(value, entityIdToTranslation)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function applyTranslations({
|
||||
localeCode,
|
||||
objects,
|
||||
container,
|
||||
}: {
|
||||
localeCode: string | undefined
|
||||
objects: Record<string, any>[]
|
||||
container: MedusaContainer
|
||||
}) {
|
||||
const isTranslationEnabled = FeatureFlag.isFeatureEnabled("translation")
|
||||
|
||||
if (!isTranslationEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const locale = localeCode
|
||||
|
||||
if (!locale) {
|
||||
return
|
||||
}
|
||||
|
||||
const objects_ = objects.filter((o) => !!o)
|
||||
if (!objects_.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const gatheredIds: Set<string> = new Set()
|
||||
|
||||
for (const inputObject of objects_) {
|
||||
gatherIds(inputObject, gatheredIds)
|
||||
}
|
||||
|
||||
const query = container.resolve<RemoteQueryFunction>(
|
||||
ContainerRegistrationKeys.QUERY
|
||||
)
|
||||
|
||||
const queryBatchSize = 250
|
||||
const queryBatches = Math.ceil(gatheredIds.size / queryBatchSize)
|
||||
|
||||
const entityIdToTranslation = new Map<string, Record<string, any>>()
|
||||
|
||||
for (let i = 0; i < queryBatches; i++) {
|
||||
// TODO: concurrently fetch if needed
|
||||
const queryBatch = Array.from(gatheredIds)
|
||||
.slice(i * queryBatchSize, (i + 1) * queryBatchSize)
|
||||
.sort()
|
||||
|
||||
const { data: translations } = await query.graph(
|
||||
{
|
||||
entity: "translations",
|
||||
fields: ["translations", "reference_id"],
|
||||
filters: {
|
||||
reference_id: queryBatch,
|
||||
locale_code: locale,
|
||||
},
|
||||
pagination: {
|
||||
take: queryBatchSize,
|
||||
},
|
||||
},
|
||||
{
|
||||
cache: { enable: true },
|
||||
}
|
||||
)
|
||||
|
||||
for (const translation of translations) {
|
||||
entityIdToTranslation.set(
|
||||
translation.reference_id,
|
||||
translation.translations ?? {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const inputObject of objects_) {
|
||||
applyTranslation(inputObject, entityIdToTranslation)
|
||||
}
|
||||
}
|
||||
1
packages/core/utils/src/translations/index.ts
Normal file
1
packages/core/utils/src/translations/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./apply-translations"
|
||||
Reference in New Issue
Block a user