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
parent fea3d4ec49
commit 6dc0b8bed8
130 changed files with 5649 additions and 112 deletions

View File

@@ -101,6 +101,7 @@
"@medusajs/store": "2.12.1",
"@medusajs/tax": "2.12.1",
"@medusajs/telemetry": "2.12.1",
"@medusajs/translation": "2.12.1",
"@medusajs/user": "2.12.1",
"@medusajs/workflow-engine-inmemory": "2.12.1",
"@medusajs/workflow-engine-redis": "2.12.1",

View File

@@ -0,0 +1,28 @@
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { HttpTypes } from "@medusajs/framework/types"
export const GET = async (
req: MedusaRequest<HttpTypes.AdminLocaleParams>,
res: MedusaResponse<HttpTypes.AdminLocaleResponse>
) => {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const {
data: [locale],
} = await query.graph(
{
entity: "locale",
filters: {
code: req.params.code,
},
fields: req.queryConfig.fields,
},
{
cache: { enable: true },
throwIfKeyNotFound: true,
}
)
res.status(200).json({ locale })
}

View File

@@ -0,0 +1,27 @@
import { MiddlewareRoute } from "@medusajs/framework/http"
import { validateAndTransformQuery } from "@medusajs/framework"
import * as QueryConfig from "./query-config"
import { AdminGetLocalesParams, AdminGetLocaleParams } from "./validators"
export const adminLocalesRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["GET"],
matcher: "/admin/locales",
middlewares: [
validateAndTransformQuery(
AdminGetLocalesParams,
QueryConfig.listTransformQueryConfig
),
],
},
{
method: ["GET"],
matcher: "/admin/locales/:code",
middlewares: [
validateAndTransformQuery(
AdminGetLocaleParams,
QueryConfig.retrieveTransformQueryConfig
),
],
},
]

View File

@@ -0,0 +1,12 @@
export const defaultAdminLocaleFields = ["code", "name"]
export const retrieveTransformQueryConfig = {
defaults: defaultAdminLocaleFields,
isList: false,
}
export const listTransformQueryConfig = {
...retrieveTransformQueryConfig,
defaultLimit: 200,
isList: true,
}

View File

@@ -0,0 +1,29 @@
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { HttpTypes } from "@medusajs/framework/types"
export const GET = async (
req: MedusaRequest<HttpTypes.AdminLocaleListParams>,
res: MedusaResponse<HttpTypes.AdminLocaleListResponse>
) => {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const { data: locales, metadata } = await query.graph(
{
entity: "locale",
filters: req.filterableFields,
fields: req.queryConfig.fields,
pagination: req.queryConfig.pagination,
},
{
cache: { enable: true },
}
)
res.json({
locales,
count: metadata?.count ?? 0,
offset: metadata?.skip ?? 0,
limit: metadata?.take ?? 0,
})
}

View File

@@ -0,0 +1,18 @@
import { z } from "zod"
import { createFindParams, createSelectParams } from "../../utils/validators"
import { applyAndAndOrOperators } from "../../utils/common-validators"
export const AdminGetLocaleParams = createSelectParams()
export const AdminGetLocalesParamsFields = z.object({
q: z.string().optional(),
code: z.union([z.string(), z.array(z.string())]).optional(),
})
export type AdminGetLocalesParamsType = z.infer<typeof AdminGetLocalesParams>
export const AdminGetLocalesParams = createFindParams({
offset: 0,
limit: 200,
})
.merge(AdminGetLocalesParamsFields)
.merge(applyAndAndOrOperators(AdminGetLocalesParamsFields))

View File

@@ -3,6 +3,8 @@ export const defaultAdminStoreFields = [
"name",
"*supported_currencies",
"*supported_currencies.currency",
"*supported_locales",
"*supported_locales.locale_code",
"default_sales_channel_id",
"default_region_id",
"default_location_id",

View File

@@ -31,6 +31,14 @@ export const AdminUpdateStore = z.object({
})
)
.optional(),
supported_locales: z
.array(
z.object({
locale_code: z.string(),
is_default: z.boolean().optional(),
})
)
.optional(),
default_sales_channel_id: z.string().nullish(),
default_region_id: z.string().nullish(),
default_location_id: z.string().nullish(),

View File

@@ -0,0 +1,68 @@
import { batchTranslationsWorkflow } from "@medusajs/core-flows"
import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework"
import {
ContainerRegistrationKeys,
defineFileConfig,
FeatureFlag,
} from "@medusajs/framework/utils"
import { BatchMethodRequest, HttpTypes } from "@medusajs/types"
import { defaultAdminTranslationFields } from "../query-config"
import {
AdminCreateTranslationType,
AdminUpdateTranslationType,
} from "../validators"
import TranslationFeatureFlag from "../../../../feature-flags/translation"
export const POST = async (
req: AuthenticatedMedusaRequest<
BatchMethodRequest<AdminCreateTranslationType, AdminUpdateTranslationType>
>,
res: MedusaResponse<HttpTypes.AdminTranslationsBatchResponse>
) => {
const { create = [], update = [], delete: deleteIds = [] } = req.validatedBody
const { result } = await batchTranslationsWorkflow(req.scope).run({
input: {
create,
update,
delete: deleteIds,
},
})
const ids = Array.from(
new Set([
...result.created.map((t) => t.id),
...result.updated.map((t) => t.id),
])
)
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const { data: translations } = await query.graph({
entity: "translation",
fields: defaultAdminTranslationFields,
filters: {
id: ids,
},
})
const created = translations.filter((t) =>
result.created.some((r) => r.id === t.id)
)
const updated = translations.filter((t) =>
result.updated.some((r) => r.id === t.id)
)
return res.status(200).json({
created,
updated,
deleted: {
ids: deleteIds,
object: "translation",
deleted: true,
},
})
}
defineFileConfig({
isDisabled: () => !FeatureFlag.isFeatureEnabled(TranslationFeatureFlag.key),
})

View File

@@ -0,0 +1,32 @@
import {
MiddlewareRoute,
validateAndTransformBody,
validateAndTransformQuery,
} from "@medusajs/framework"
import {
AdminBatchTranslations,
AdminGetTranslationsParams,
} from "./validators"
import * as QueryConfig from "./query-config"
import { DEFAULT_BATCH_ENDPOINTS_SIZE_LIMIT } from "../../../utils"
export const adminTranslationsRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["GET"],
matcher: "/admin/translations",
middlewares: [
validateAndTransformQuery(
AdminGetTranslationsParams,
QueryConfig.listTransformQueryConfig
),
],
},
{
method: ["POST"],
matcher: "/admin/translations/batch",
bodyParser: {
sizeLimit: DEFAULT_BATCH_ENDPOINTS_SIZE_LIMIT,
},
middlewares: [validateAndTransformBody(AdminBatchTranslations)],
},
]

View File

@@ -0,0 +1,17 @@
export const defaultAdminTranslationFields = [
"id",
"reference_id",
"reference",
"locale_code",
"translations",
]
export const retrieveTransformQueryConfig = {
defaults: defaultAdminTranslationFields,
isList: false,
}
export const listTransformQueryConfig = {
...retrieveTransformQueryConfig,
isList: true,
}

View File

@@ -0,0 +1,38 @@
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
import { HttpTypes } from "@medusajs/framework/types"
import {
ContainerRegistrationKeys,
defineFileConfig,
FeatureFlag,
} from "@medusajs/framework/utils"
import TranslationFeatureFlag from "../../../feature-flags/translation"
export const GET = async (
req: MedusaRequest<HttpTypes.AdminTranslationsListParams>,
res: MedusaResponse<HttpTypes.AdminTranslationsListResponse>
) => {
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const { data: translations, metadata } = await query.graph(
{
entity: "translation",
fields: req.queryConfig.fields,
filters: req.filterableFields,
pagination: req.queryConfig.pagination,
},
{
cache: { enable: true },
}
)
return res.status(200).json({
translations,
count: metadata?.count ?? 0,
offset: metadata?.skip ?? 0,
limit: metadata?.take ?? 0,
})
}
defineFileConfig({
isDisabled: () => !FeatureFlag.isFeatureEnabled(TranslationFeatureFlag.key),
})

View File

@@ -0,0 +1,50 @@
import { applyAndAndOrOperators } from "../../utils/common-validators"
import {
createBatchBody,
createFindParams,
createSelectParams,
} from "../../utils/validators"
import { z } from "zod"
export const AdminGetTranslationParams = createSelectParams()
export const AdminGetTranslationParamsFields = z.object({
q: z.string().optional(),
reference_id: z.union([z.string(), z.array(z.string())]).optional(),
reference: z.string().optional(),
locale_code: z.string().optional(),
})
export type AdminGetTranslationsParamsType = z.infer<
typeof AdminGetTranslationsParams
>
export const AdminGetTranslationsParams = createFindParams({
limit: 20,
offset: 0,
})
.merge(AdminGetTranslationParamsFields)
.merge(applyAndAndOrOperators(AdminGetTranslationParamsFields))
export type AdminCreateTranslationType = z.infer<typeof AdminCreateTranslation>
export const AdminCreateTranslation = z.object({
reference_id: z.string(),
reference: z.string(),
locale_code: z.string(),
translations: z.record(z.string()),
})
export type AdminUpdateTranslationType = z.infer<typeof AdminUpdateTranslation>
export const AdminUpdateTranslation = z.object({
id: z.string(),
reference_id: z.string().optional(),
reference: z.string().optional(),
locale_code: z.string().optional(),
translations: z.record(z.string()).optional(),
})
export type AdminBatchTranslationsType = z.infer<typeof AdminBatchTranslations>
export const AdminBatchTranslations = createBatchBody(
AdminCreateTranslation,
AdminUpdateTranslation
)

View File

@@ -66,6 +66,8 @@ import { storeReturnReasonRoutesMiddlewares } from "./store/return-reasons/middl
import { storeShippingOptionRoutesMiddlewares } from "./store/shipping-options/middlewares"
import { adminShippingOptionTypeRoutesMiddlewares } from "./admin/shipping-option-types/middlewares"
import { adminIndexRoutesMiddlewares } from "./admin/index/middlewares"
import { adminLocalesRoutesMiddlewares } from "./admin/locales/middlewares"
import { adminTranslationsRoutesMiddlewares } from "./admin/translations/middlewares"
export default defineMiddlewares([
...storeRoutesMiddlewares,
@@ -94,10 +96,12 @@ export default defineMiddlewares([
...adminInviteRoutesMiddlewares,
...adminTaxRateRoutesMiddlewares,
...adminTaxRegionRoutesMiddlewares,
...adminTranslationsRoutesMiddlewares,
...adminApiKeyRoutesMiddlewares,
...hooksRoutesMiddlewares,
...adminStoreRoutesMiddlewares,
...adminCurrencyRoutesMiddlewares,
...adminLocalesRoutesMiddlewares,
...storeCurrencyRoutesMiddlewares,
...adminProductRoutesMiddlewares,
...adminPaymentRoutesMiddlewares,

View File

@@ -1,6 +1,11 @@
import { MedusaResponse } from "@medusajs/framework/http"
import { HttpTypes } from "@medusajs/framework/types"
import { isPresent, MedusaError, QueryContext } from "@medusajs/framework/utils"
import {
applyTranslations,
isPresent,
MedusaError,
QueryContext,
} from "@medusajs/framework/utils"
import { wrapVariantsWithInventoryQuantityForSalesChannel } from "../../../utils/middlewares"
import {
filterOutInternalProductCategories,
@@ -69,5 +74,10 @@ export const GET = async (
}
await wrapProductsWithTaxPrices(req, [product])
await applyTranslations({
localeCode: req.locale,
objects: [product],
container: req.scope,
})
res.json({ product })
}

View File

@@ -1,6 +1,7 @@
import { validateAndTransformQuery } from "@medusajs/framework"
import {
applyDefaultFilters,
applyLocale,
applyParamsAsFilters,
authenticate,
clearFiltersByKey,
@@ -63,6 +64,10 @@ async function applyMaybeLinkFilterIfNecessary(
}
export const storeProductRoutesMiddlewares: MiddlewareRoute[] = [
{
matcher: "/store/products/*",
middlewares: [applyLocale],
},
{
method: ["GET"],
matcher: "/store/products",

View File

@@ -1,6 +1,7 @@
import { MedusaResponse } from "@medusajs/framework/http"
import { HttpTypes, QueryContextType } from "@medusajs/framework/types"
import {
applyTranslations,
ContainerRegistrationKeys,
FeatureFlag,
isPresent,
@@ -86,6 +87,13 @@ async function getProductsWithIndexEngine(
}
await wrapProductsWithTaxPrices(req, products)
await applyTranslations({
localeCode: req.locale,
objects: products,
container: req.scope,
})
res.json({
products,
count: metadata!.estimate_count,
@@ -141,6 +149,13 @@ async function getProducts(
}
await wrapProductsWithTaxPrices(req, products)
await applyTranslations({
localeCode: req.locale,
objects: products,
container: req.scope,
})
res.json({
products,
count: metadata!.count,

View File

@@ -0,0 +1,10 @@
import { FlagSettings } from "@medusajs/framework/feature-flags"
const TranslationFeatureFlag: FlagSettings = {
key: "translation",
default_val: false,
env_key: "MEDUSA_FF_TRANSLATION",
description: "Enable multi-language support and entity translations",
}
export default TranslationFeatureFlag

View File

@@ -0,0 +1,6 @@
import TranslationModule from "@medusajs/translation"
export * from "@medusajs/translation"
export default TranslationModule
export const discoveryPath = require.resolve("@medusajs/translation")