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

* feat(): Translation first steps

* feat(): locale middleware

* feat(): readonly links

* feat(): feature flag

* feat(): modules sdk

* feat(): translation module re export

* start adding workflows

* update typings

* update typings

* test(): Add integration tests

* test(): centralize filters preparation

* test(): centralize filters preparation

* remove unnecessary importy

* fix workflows

* Define StoreLocale inside Store Module

* Link definition to extend Store with supported_locales

* store_locale migration

* Add supported_locales handling in Store Module

* Tests

* Accept supported_locales in Store endpoints

* Add locales to js-sdk

* Include locale list and default locale in Store Detail section

* Initialize local namespace in js-sdk

* Add locales route

* Make code primary key of locale table to facilitate upserts

* Add locales routes

* Show locale code as is

* Add list translations api route

* Batch endpoint

* Types

* New batchTranslationsWorkflow and various updates to existent ones

* Edit default locale UI

* WIP

* Apply translation agnostically

* middleware

* Apply translation agnostically

* fix Apply translation agnostically

* apply translations to product list

* Add feature flag

* fetch translations by batches of 250 max

* fix apply

* improve and test util

* apply to product list

* dont manage translations if no locale

* normalize locale

* potential todo

* Protect translations routes with feature flag

* Extract normalize locale util to core/utils

* Normalize locale on write

* Normalize locale for read

* Use feature flag to guard translations UI across the board

* Avoid throwing incorrectly when locale_code not present in partial updates

* move applyTranslations util

* remove old tests

* fix util tests

* fix(): product end points

* cleanup

* update lock

* remove unused var

* cleanup

* fix apply locale

* missing new dep for test utils

* Change entity_type, entity_id to reference, reference_id

* Remove comment

* Avoid registering translations route if ff not enabled

* Prevent registering express handler for disabled route via defineFileConfig

* Add tests

* Add changeset

* Update test

* fix integration tests, module and internals

* Add locale id plus fixed

* Allow to pass array of reference_id

* fix unit tests

* fix link loading

* fix store route

* fix sales channel test

* fix tests

---------

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