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
@@ -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