feat(translation,fulfillment,customer,product,region,tax,core-flows,medusa,types): Implement dynamic translation settings management (#14536)

* Add is_active field to translation_settings model

* Types

* Workflows

* Api layer

* Tests

* Add changeset

* Add comment

* Hook to create or deactivate translatable entities on startup

* Cleanup old code

* Configure translatable option for core entities

* Validation step and snake case correction

* Cleanup

* Tests

* Comment in PR

* Update changeset

* Mock DmlEntity.getTranslatableEntities

* Move validation to module service layer

* Remove validation from remaining workflow

* Return object directly

* Type improvements

* Remove .only from tests

* Apply snakeCase

* Fix tests

* Fix tests

* Remove unnecessary map and use set instead

* Fix tests

* Comments

* Include translatable product properties

* Avoid race condition in translations tests

* Update test
This commit is contained in:
Nicolas Gorga
2026-01-14 07:09:49 -03:00
committed by GitHub
parent 42235825ee
commit d60ea7268a
50 changed files with 1397 additions and 199 deletions

View File

@@ -5,7 +5,7 @@ import { CustomerGroupCustomer } from "@models"
const CustomerGroup = model
.define("CustomerGroup", {
id: model.id({ prefix: "cusgroup" }).primaryKey(),
name: model.text().searchable(),
name: model.text().searchable().translatable(),
metadata: model.json().nullable(),
created_by: model.text().nullable(),
customers: model.manyToMany(() => Customer, {

View File

@@ -4,8 +4,8 @@ import { ShippingOption } from "./shipping-option"
export const ShippingOptionType = model.define("shipping_option_type", {
id: model.id({ prefix: "sotype" }).primaryKey(),
label: model.text().searchable(),
description: model.text().searchable().nullable(),
label: model.text().searchable().translatable(),
description: model.text().searchable().translatable().nullable(),
code: model.text().searchable(),
shipping_options: model.hasMany(() => ShippingOption, {
mappedBy: "type",

View File

@@ -10,7 +10,7 @@ import { ShippingProfile } from "./shipping-profile"
export const ShippingOption = model
.define("shipping_option", {
id: model.id({ prefix: "so" }).primaryKey(),
name: model.text().searchable(),
name: model.text().searchable().translatable(),
price_type: model
.enum(ShippingOptionPriceType)
.default(ShippingOptionPriceType.FLAT),

View File

@@ -4,8 +4,8 @@ import Product from "./product"
const ProductCategory = model
.define("ProductCategory", {
id: model.id({ prefix: "pcat" }).primaryKey(),
name: model.text().searchable(),
description: model.text().searchable().default(""),
name: model.text().searchable().translatable(),
description: model.text().searchable().translatable().default(""),
handle: model.text().searchable(),
mpath: model.text(),
is_active: model.boolean().default(false),

View File

@@ -4,7 +4,7 @@ import Product from "./product"
const ProductCollection = model
.define("ProductCollection", {
id: model.id({ prefix: "pcol" }).primaryKey(),
title: model.text().searchable(),
title: model.text().searchable().translatable(),
handle: model.text(),
metadata: model.json().nullable(),
products: model.hasMany(() => Product, {

View File

@@ -4,7 +4,7 @@ import { ProductOption, ProductVariant } from "./index"
const ProductOptionValue = model
.define("ProductOptionValue", {
id: model.id({ prefix: "optval" }).primaryKey(),
value: model.text(),
value: model.text().translatable(),
metadata: model.json().nullable(),
option: model
.belongsTo(() => ProductOption, {

View File

@@ -5,7 +5,7 @@ import ProductOptionValue from "./product-option-value"
const ProductOption = model
.define("ProductOption", {
id: model.id({ prefix: "opt" }).primaryKey(),
title: model.text().searchable(),
title: model.text().searchable().translatable(),
metadata: model.json().nullable(),
product: model.belongsTo(() => Product, {
mappedBy: "options",

View File

@@ -6,7 +6,7 @@ const ProductTag = model
{ tableName: "product_tag", name: "ProductTag" },
{
id: model.id({ prefix: "ptag" }).primaryKey(),
value: model.text().searchable(),
value: model.text().searchable().translatable(),
metadata: model.json().nullable(),
products: model.manyToMany(() => Product, {
mappedBy: "tags",

View File

@@ -4,7 +4,7 @@ import { Product } from "@models"
const ProductType = model
.define("ProductType", {
id: model.id({ prefix: "ptyp" }).primaryKey(),
value: model.text().searchable(),
value: model.text().searchable().translatable(),
metadata: model.json().nullable(),
products: model.hasMany(() => Product, {
mappedBy: "type",

View File

@@ -5,7 +5,7 @@ import ProductVariantProductImage from "./product-variant-product-image"
const ProductVariant = model
.define("ProductVariant", {
id: model.id({ prefix: "variant" }).primaryKey(),
title: model.text().searchable(),
title: model.text().searchable().translatable(),
sku: model.text().searchable().nullable(),
barcode: model.text().searchable().nullable(),
ean: model.text().searchable().nullable(),
@@ -15,7 +15,7 @@ const ProductVariant = model
hs_code: model.text().nullable(),
origin_country: model.text().nullable(),
mid_code: model.text().nullable(),
material: model.text().nullable(),
material: model.text().translatable().nullable(),
weight: model.number().nullable(),
length: model.number().nullable(),
height: model.number().nullable(),

View File

@@ -11,10 +11,10 @@ import ProductVariant from "./product-variant"
const Product = model
.define("Product", {
id: model.id({ prefix: "prod" }).primaryKey(),
title: model.text().searchable(),
title: model.text().searchable().translatable(),
handle: model.text(),
subtitle: model.text().searchable().nullable(),
description: model.text().searchable().nullable(),
subtitle: model.text().searchable().translatable().nullable(),
description: model.text().searchable().translatable().nullable(),
is_giftcard: model.boolean().default(false),
status: model
.enum(ProductUtils.ProductStatus)
@@ -27,7 +27,7 @@ const Product = model
origin_country: model.text().nullable(),
hs_code: model.text().nullable(),
mid_code: model.text().nullable(),
material: model.text().nullable(),
material: model.text().translatable().nullable(),
discountable: model.boolean().default(true),
external_id: model.text().nullable(),
metadata: model.json().nullable(),

View File

@@ -3,7 +3,7 @@ import RegionCountry from "./country"
export default model.define("region", {
id: model.id({ prefix: "reg" }).primaryKey(),
name: model.text().searchable(),
name: model.text().searchable().translatable(),
currency_code: model.text().searchable(),
automatic_taxes: model.boolean().default(true),
countries: model.hasMany(() => RegionCountry),

View File

@@ -7,7 +7,7 @@ const TaxRate = model
id: model.id({ prefix: "txr" }).primaryKey(),
rate: model.float().nullable(),
code: model.text().searchable(),
name: model.text().searchable(),
name: model.text().searchable().translatable(),
is_default: model.boolean().default(false),
is_combinable: model.boolean().default(false),
tax_region: model.belongsTo(() => TaxRegion, {

View File

@@ -1,15 +1,42 @@
import { ITranslationModuleService } from "@medusajs/framework/types"
import { Module, Modules } from "@medusajs/framework/utils"
import { DmlEntity, Module, Modules } from "@medusajs/framework/utils"
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
import TranslationModuleService from "@services/translation-module"
import { createLocaleFixture, createTranslationFixture } from "../__fixtures__"
jest.setTimeout(100000)
// Set up the mock before module initialization
let mockGetTranslatableEntities: jest.SpyInstance
moduleIntegrationTestRunner<ITranslationModuleService>({
moduleName: Modules.TRANSLATION,
hooks: {
beforeModuleInit: async () => {
mockGetTranslatableEntities = jest.spyOn(
DmlEntity,
"getTranslatableEntities"
)
mockGetTranslatableEntities.mockReturnValue([
{
entity: "Product",
fields: ["title", "description", "subtitle", "material"],
},
{ entity: "ProductVariant", fields: ["title", "material"] },
{ entity: "ProductCategory", fields: ["name"] },
])
},
},
testSuite: ({ service }) => {
describe("Translation Module Service", () => {
beforeEach(async () => {
await service.__hooks?.onApplicationStart?.().catch(() => {})
})
afterAll(() => {
// Restore the mock after all tests complete
mockGetTranslatableEntities.mockRestore()
})
it(`should export the appropriate linkable configuration`, () => {
const linkable = Module(Modules.TRANSLATION, {
service: TranslationModuleService,

View File

@@ -1,12 +1,11 @@
import "./types"
import { Module } from "@medusajs/framework/utils"
import TranslationModuleService from "@services/translation-module"
import loadConfig from "./loaders/config"
import loadDefaults from "./loaders/defaults"
export const TRANSLATION_MODULE = "translation"
export default Module(TRANSLATION_MODULE, {
service: TranslationModuleService,
loaders: [loadDefaults, loadConfig],
loaders: [loadDefaults],
})

View File

@@ -1,60 +0,0 @@
import {
LoaderOptions,
Logger,
ModulesSdkTypes,
} from "@medusajs/framework/types"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
import { TRANSLATABLE_FIELDS_CONFIG_KEY } from "@utils/constants"
import { asValue } from "awilix"
import { translatableFieldsConfig } from "../utils/translatable-fields"
import Settings from "@models/settings"
import type { TranslationModuleOptions } from "../types"
export default async ({
container,
options,
}: LoaderOptions<TranslationModuleOptions>): Promise<void> => {
const logger =
container.resolve<Logger>(ContainerRegistrationKeys.LOGGER) ?? console
const settingsService: ModulesSdkTypes.IMedusaInternalService<
typeof Settings
> = container.resolve("translationSettingsService")
const mergedConfig: Record<string, string[]> = translatableFieldsConfig
const userProvidedFields = options?.entities ?? []
for (const field of userProvidedFields) {
mergedConfig[field.type] ??= []
mergedConfig[field.type] = Array.from(
new Set([...(mergedConfig[field.type] ?? []), ...field.fields])
)
}
try {
const existingSettings = await settingsService.list(
{},
{ select: ["id", "entity_type"] }
)
const existingByEntityType = new Map(
existingSettings.map((s) => [s.entity_type, s.id])
)
const settingsToUpsert = Object.entries(mergedConfig).map(
([entityType, fields]) => {
const existingId = existingByEntityType.get(entityType)
return existingId
? { id: existingId, entity_type: entityType, fields }
: { entity_type: entityType, fields }
}
)
const resp = await settingsService.upsert(settingsToUpsert)
logger.debug(`Loaded ${resp.length} translation settings`)
} catch (error) {
logger.warn(
`Failed to load translation settings, skipping loader. Original error: ${error.message}`
)
}
container.register(TRANSLATABLE_FIELDS_CONFIG_KEY, asValue(mergedConfig))
}

View File

@@ -293,6 +293,16 @@
"nullable": false,
"mappedType": "json"
},
"is_active": {
"name": "is_active",
"type": "boolean",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"default": "true",
"mappedType": "boolean"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",

View File

@@ -0,0 +1,13 @@
import { Migration } from "@medusajs/framework/mikro-orm/migrations";
export class Migration20260108122757 extends Migration {
override async up(): Promise<void> {
this.addSql(`alter table if exists "translation_settings" add column if not exists "is_active" boolean not null default true;`);
}
override async down(): Promise<void> {
this.addSql(`alter table if exists "translation_settings" drop column if exists "is_active";`);
}
}

View File

@@ -18,6 +18,10 @@ const Settings = model
* ["title", "description", "material"]
*/
fields: model.json(),
/**
* Wether the entity translatable status is enabled.
*/
is_active: model.boolean().default(true),
})
.indexes([
{

View File

@@ -2,6 +2,7 @@ import { raw } from "@medusajs/framework/mikro-orm/core"
import {
Context,
CreateTranslationDTO,
CreateTranslationSettingsDTO,
DAL,
FilterableTranslationProps,
FindConfig,
@@ -9,21 +10,25 @@ import {
LocaleDTO,
ModulesSdkTypes,
TranslationTypes,
UpdateTranslationSettingsDTO,
} from "@medusajs/framework/types"
import { SqlEntityManager } from "@medusajs/framework/mikro-orm/postgresql"
import {
arrayDifference,
DmlEntity,
EmitEvents,
InjectManager,
MedusaContext,
MedusaError,
MedusaErrorTypes,
MedusaService,
normalizeLocale,
toSnakeCase,
} from "@medusajs/framework/utils"
import Locale from "@models/locale"
import Translation from "@models/translation"
import Settings from "@models/settings"
import { computeTranslatedFieldCount } from "@utils/compute-translated-field-count"
import { TRANSLATABLE_FIELDS_CONFIG_KEY } from "@utils/constants"
import { filterTranslationFields } from "@utils/filter-translation-fields"
type InjectedDependencies = {
@@ -33,7 +38,6 @@ type InjectedDependencies = {
translationSettingsService: ModulesSdkTypes.IMedusaInternalService<
typeof Settings
>
[TRANSLATABLE_FIELDS_CONFIG_KEY]: Record<string, string[]>
}
export default class TranslationModuleService
@@ -78,6 +82,55 @@ export default class TranslationModuleService
this.settingsService_ = translationSettingsService
}
__hooks = {
onApplicationStart: async () => {
return this.onApplicationStart_()
},
}
protected async onApplicationStart_() {
const translatableEntities = DmlEntity.getTranslatableEntities()
const translatableEntitiesSet = new Set(
translatableEntities.map((entity) => toSnakeCase(entity.entity))
)
const currentTranslationSettings = await this.settingsService_.list()
const currentTranslationSettingsSet = new Set(
currentTranslationSettings.map((setting) => setting.entity_type)
)
const settingsToUpsert: (
| CreateTranslationSettingsDTO
| UpdateTranslationSettingsDTO
)[] = []
for (const setting of currentTranslationSettings) {
if (
!translatableEntitiesSet.has(setting.entity_type) &&
setting.is_active
) {
settingsToUpsert.push({
id: setting.id,
is_active: false,
})
}
}
for (const entity of translatableEntities) {
const snakeCaseEntityType = toSnakeCase(entity.entity)
const hasCurrentSettings =
currentTranslationSettingsSet.has(snakeCaseEntityType)
if (!hasCurrentSettings) {
settingsToUpsert.push({
entity_type: snakeCaseEntityType,
fields: entity.fields,
})
}
}
await this.settingsService_.upsert(settingsToUpsert)
}
@InjectManager()
async getTranslatableFields(
entityType?: string,
@@ -90,7 +143,8 @@ export default class TranslationModuleService
sharedContext
)
return settings.reduce((acc, setting) => {
acc[setting.entity_type] = setting.fields as unknown as string[]
acc[toSnakeCase(setting.entity_type)] =
setting.fields as unknown as string[]
return acc
}, {} as Record<string, string[]>)
}
@@ -377,6 +431,42 @@ export default class TranslationModuleService
return Array.isArray(data) ? serialized : serialized[0]
}
@InjectManager()
@EmitEvents()
// @ts-expect-error
async createTranslationSettings(
data: CreateTranslationSettingsDTO[] | CreateTranslationSettingsDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<
| TranslationTypes.TranslationSettingsDTO
| TranslationTypes.TranslationSettingsDTO[]
> {
const dataArray = Array.isArray(data) ? data : [data]
await this.validateSettings_(dataArray, sharedContext)
// @ts-expect-error TS can't match union type to overloads
return await super.createTranslationSettings(data, sharedContext)
}
@InjectManager()
@EmitEvents()
// @ts-expect-error
async updateTranslationSettings(
data: UpdateTranslationSettingsDTO | UpdateTranslationSettingsDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<
| TranslationTypes.TranslationSettingsDTO[]
| TranslationTypes.TranslationSettingsDTO
> {
const dataArray = Array.isArray(data) ? data : [data]
await this.validateSettings_(dataArray, sharedContext)
// @ts-expect-error TS can't match union type to overloads
return await super.updateTranslationSettings(data, sharedContext)
}
@InjectManager()
async getStatistics(
input: TranslationTypes.TranslationStatisticsInput,
@@ -492,4 +582,79 @@ export default class TranslationModuleService
return result
}
/**
* Validates the translation settings to create or update against the translatable entities and their translatable fields configuration.
* @param dataToValidate - The data to validate.
* @param sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
*/
@InjectManager()
protected async validateSettings_(
dataToValidate: (
| CreateTranslationSettingsDTO
| UpdateTranslationSettingsDTO
)[],
@MedusaContext() sharedContext: Context = {}
) {
const translatableEntities = DmlEntity.getTranslatableEntities()
const translatableEntitiesMap = new Map(
translatableEntities.map((entity) => [toSnakeCase(entity.entity), entity])
)
const invalidSettings: {
entity_type: string
is_invalid_entity: boolean
invalidFields?: string[]
}[] = []
for (const item of dataToValidate) {
let itemEntityType = item.entity_type
if (!itemEntityType) {
const translationSetting = await this.retrieveTranslationSettings(
//@ts-expect-error - if no entity_type, we are on an update
item.id,
{ select: ["entity_type"] },
sharedContext
)
itemEntityType = translationSetting.entity_type
}
const entity = translatableEntitiesMap.get(itemEntityType)
if (!entity) {
invalidSettings.push({
entity_type: itemEntityType,
is_invalid_entity: true,
})
} else {
const invalidFields = arrayDifference(item.fields ?? [], entity.fields)
if (invalidFields.length) {
invalidSettings.push({
entity_type: itemEntityType,
is_invalid_entity: false,
invalidFields,
})
}
}
}
if (invalidSettings.length) {
throw new MedusaError(
MedusaErrorTypes.INVALID_DATA,
"Invalid translation settings:\n" +
invalidSettings
.map(
(setting) =>
`- ${setting.entity_type} ${
setting.is_invalid_entity
? "is not a translatable entity"
: `doesn't have the following fields set as translatable: ${setting.invalidFields?.join(
", "
)}`
}`
)
.join("\n")
)
}
}
}

View File

@@ -1 +0,0 @@
export const TRANSLATABLE_FIELDS_CONFIG_KEY = "translatableFieldsConfig"

View File

@@ -1,41 +0,0 @@
export const PRODUCT_TRANSLATABLE_FIELDS = [
"title",
"description",
"material",
"subtitle",
]
export const PRODUCT_VARIANT_TRANSLATABLE_FIELDS = ["title", "material"]
export const PRODUCT_TYPE_TRANSLATABLE_FIELDS = ["value"]
export const PRODUCT_COLLECTION_TRANSLATABLE_FIELDS = ["title"]
export const PRODUCT_CATEGORY_TRANSLATABLE_FIELDS = ["name", "description"]
export const PRODUCT_TAG_TRANSLATABLE_FIELDS = ["value"]
export const PRODUCT_OPTION_TRANSLATABLE_FIELDS = ["title"]
export const PRODUCT_OPTION_VALUE_TRANSLATABLE_FIELDS = ["value"]
export const REGION_TRANSLATABLE_FIELDS = ["name"]
export const CUSTOMER_GROUP_TRANSLATABLE_FIELDS = ["name"]
export const SHIPPING_OPTION_TRANSLATABLE_FIELDS = ["name"]
export const SHIPPING_OPTION_TYPE_TRANSLATABLE_FIELDS = ["label", "description"]
export const TAX_RATE_TRANSLATABLE_FIELDS = ["name"]
// export const RETURN_REASON_TRANSLATABLE_FIELDS = [
// "value",
// "label",
// "description",
// ]
export const translatableFieldsConfig = {
product: PRODUCT_TRANSLATABLE_FIELDS,
product_variant: PRODUCT_VARIANT_TRANSLATABLE_FIELDS,
product_type: PRODUCT_TYPE_TRANSLATABLE_FIELDS,
product_collection: PRODUCT_COLLECTION_TRANSLATABLE_FIELDS,
product_category: PRODUCT_CATEGORY_TRANSLATABLE_FIELDS,
product_tag: PRODUCT_TAG_TRANSLATABLE_FIELDS,
product_option: PRODUCT_OPTION_TRANSLATABLE_FIELDS,
product_option_value: PRODUCT_OPTION_VALUE_TRANSLATABLE_FIELDS,
region: REGION_TRANSLATABLE_FIELDS,
customer_group: CUSTOMER_GROUP_TRANSLATABLE_FIELDS,
shipping_option: SHIPPING_OPTION_TRANSLATABLE_FIELDS,
shipping_option_type: SHIPPING_OPTION_TYPE_TRANSLATABLE_FIELDS,
tax_rate: TAX_RATE_TRANSLATABLE_FIELDS,
// return_reason: RETURN_REASON_TRANSLATABLE_FIELDS,
}