feat(DML): Add a new translatable property modifier applicable on text (#14494)

* feat(DML): Add a new translatable property modifier applicable on text

* feat(DML): Add a new translatable property modifier applicable on text

* feat(DML): Add a new translatable property modifier applicable on text

* Create gold-bobcats-decide.md

* feat(DML): Add a new translatable property modifier applicable on text

* feat(DML): Add a new translatable property modifier applicable on text

* simplification
This commit is contained in:
Adrien de Peretti
2026-01-09 10:13:25 +01:00
committed by GitHub
parent baaee11114
commit 08c55e7035
7 changed files with 344 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
---
"@medusajs/types": patch
"@medusajs/utils": patch
---
feat(DML): Add a new translatable property modifier applicable on text

View File

@@ -38,6 +38,7 @@ describe("Base property", () => {
searchable: true,
},
},
defaultValue: undefined,
nullable: false,
computed: false,
indexes: [],

View File

@@ -36,4 +36,73 @@ describe("Text property", () => {
primaryKey: true,
})
})
test("should mark text property as translatable", () => {
const property = new TextProperty().translatable()
expectTypeOf(property["$dataType"]).toEqualTypeOf<string>()
expect(property.parse("name")).toEqual({
fieldName: "name",
dataType: {
name: "text",
options: { searchable: false, translatable: true },
},
nullable: false,
computed: false,
indexes: [],
relationships: [],
})
})
test("should chain translatable with searchable", () => {
const property = new TextProperty().searchable().translatable()
expectTypeOf(property["$dataType"]).toEqualTypeOf<string>()
expect(property.parse("name")).toEqual({
fieldName: "name",
dataType: {
name: "text",
options: { searchable: true, translatable: true },
},
nullable: false,
computed: false,
indexes: [],
relationships: [],
})
})
test("should chain translatable with nullable", () => {
const property = new TextProperty().translatable().nullable()
expectTypeOf(property["$dataType"]).toEqualTypeOf<string | null>()
expect(property.parse("description")).toEqual({
fieldName: "description",
dataType: {
name: "text",
options: { searchable: false, translatable: true },
},
nullable: true,
computed: false,
indexes: [],
relationships: [],
})
})
test("should chain translatable with default", () => {
const property = new TextProperty().default("Default Value").translatable()
expectTypeOf(property["$dataType"]).toEqualTypeOf<string>()
expect(property.parse("name")).toEqual({
fieldName: "name",
dataType: {
name: "text",
options: { searchable: false, translatable: true },
},
nullable: false,
computed: false,
defaultValue: "Default Value",
indexes: [],
relationships: [],
})
})
})

View File

@@ -7,6 +7,7 @@ import {
IDmlEntity,
IDmlEntityConfig,
InferDmlEntityNameFromConfig,
PropertyType,
QueryCondition,
} from "@medusajs/types"
import { isObject, isString, toCamelCase, upperCaseFirst } from "../common"
@@ -17,6 +18,32 @@ import { BelongsTo } from "./relations/belongs-to"
const IsDmlEntity = Symbol.for("isDmlEntity")
/**
* Represents an entity with translatable properties
*/
export type TranslatableEntityEntry = {
/**
* The entity name (PascalCase)
*/
entity: string
/**
* The list of translatable property names
*/
fields: string[]
}
/**
* Module-level storage for translatable entities using Map for O(1) lookups.
* Keys are entity names (PascalCase), values are Sets of field names.
*
* @example
* import { DmlEntity } from "@medusajs/framework/utils"
*
* const translatables = DmlEntity.getTranslatableEntities()
* // Returns: [{ entity: "Store", fields: ["name"] }]
*/
const TRANSLATABLE_ENTITIES = new Map<string, Set<string>>()
export type DMLEntitySchemaBuilder<Schema extends DMLSchema> =
DMLSchemaWithBigNumber<Schema> & DMLSchemaDefaults & Schema
@@ -86,6 +113,32 @@ export class DmlEntity<
this.schema = schema
this.name = name
this.#tableName = tableName
this.#registerTranslatableFields(name, schema)
}
/**
* Collects translatable fields from the schema and registers them
*/
#registerTranslatableFields(entityName: string, schema: Schema): void {
for (const [fieldName, property] of Object.entries(schema)) {
if (typeof (property as PropertyType<any>).parse !== "function") {
continue
}
const parsed = (property as PropertyType<any>).parse(fieldName)
if (!("fieldName" in parsed) || !parsed.dataType?.options?.translatable) {
continue
}
// Get or create the Set for this entity
const existingFields = TRANSLATABLE_ENTITIES.get(entityName)
if (existingFields) {
existingFields.add(fieldName)
} else {
TRANSLATABLE_ENTITIES.set(entityName, new Set([fieldName]))
}
}
}
/**
@@ -99,6 +152,36 @@ export class DmlEntity<
return !!entity?.[IsDmlEntity]
}
/**
* Returns all registered translatable entities with their translatable fields.
* Each entry contains the entity name (PascalCase) and an array
* of field names that are marked as translatable.
*
* @example
* import { DmlEntity } from "@medusajs/framework/utils"
*
* const translatables = DmlEntity.getTranslatableEntities()
* // Returns: [{ entity: "Store", fields: ["name"] }]
*
* @customNamespace Model Methods
*/
static getTranslatableEntities(): TranslatableEntityEntry[] {
return Array.from(TRANSLATABLE_ENTITIES.entries()).map(
([entity, fields]) => ({
entity,
fields: Array.from(fields),
})
)
}
/**
* Clears all registered translatable entities.
* This is primarily used for testing purposes.
*/
static clearTranslatableEntities(): void {
TRANSLATABLE_ENTITIES.clear()
}
/**
* Parse entity to get its underlying information
*/

View File

@@ -5,4 +5,6 @@ export * from "./helpers/create-mikro-orm-entity"
export * from "./relations/index"
export * from "./properties/index"
export { TranslatableEntityEntry } from "./entity"
export * from "./helpers/entity-builder/index"

View File

@@ -0,0 +1,161 @@
import { MetadataStorage } from "@medusajs/deps/mikro-orm/core"
import { DmlEntity } from "../../entity"
import { model } from "../../entity-builder"
import { mikroORMEntityBuilder } from "../../helpers/create-mikro-orm-entity"
describe("Translatable", () => {
beforeEach(() => {
MetadataStorage.clear()
mikroORMEntityBuilder.clear()
DmlEntity.clearTranslatableEntities()
})
describe("getTranslatableEntities", () => {
it("should collect translatable fields from a single entity", () => {
model.define("ProductVariant", {
id: model.id().primaryKey(),
name: model.text().translatable(),
default_sales_channel_id: model.text().nullable(),
})
const translatables = DmlEntity.getTranslatableEntities()
expect(translatables).toContainEqual({
entity: "ProductVariant",
fields: ["name"],
})
})
it("should collect multiple translatable fields from a single entity", () => {
model.define("product", {
id: model.id().primaryKey(),
title: model.text().translatable(),
description: model.text().translatable(),
handle: model.text(),
})
const translatables = DmlEntity.getTranslatableEntities()
expect(translatables).toContainEqual({
entity: "Product",
fields: ["title", "description"],
})
})
it("should collect translatable fields from multiple entities", () => {
model.define("store", {
id: model.id().primaryKey(),
name: model.text().translatable(),
})
model.define("product", {
id: model.id().primaryKey(),
title: model.text().translatable(),
})
model.define("region", {
id: model.id().primaryKey(),
name: model.text().translatable(),
currency_code: model.text(),
})
const translatables = DmlEntity.getTranslatableEntities()
expect(translatables).toContainEqual({
entity: "Store",
fields: ["name"],
})
expect(translatables).toContainEqual({
entity: "Product",
fields: ["title"],
})
expect(translatables).toContainEqual({
entity: "Region",
fields: ["name"],
})
})
it("should not include entities without translatable fields", () => {
model.define("store", {
id: model.id().primaryKey(),
name: model.text().translatable(),
})
model.define("currency", {
id: model.id().primaryKey(),
code: model.text(),
symbol: model.text(),
})
const translatables = DmlEntity.getTranslatableEntities()
expect(translatables).toContainEqual({
entity: "Store",
fields: ["name"],
})
const currencyEntry = translatables.find((e) => e.entity === "Currency")
expect(currencyEntry).toBeUndefined()
})
it("should work with translatable chained with other modifiers", () => {
model.define("product", {
id: model.id().primaryKey(),
title: model.text().searchable().translatable(),
subtitle: model.text().translatable().nullable(),
description: model.text().default("No description").translatable(),
})
const translatables = DmlEntity.getTranslatableEntities()
expect(translatables).toContainEqual({
entity: "Product",
fields: ["title", "subtitle", "description"],
})
})
it("should use PascalCase entity name", () => {
model.define("product_category", {
id: model.id().primaryKey(),
name: model.text().translatable(),
})
const translatables = DmlEntity.getTranslatableEntities()
expect(translatables).toContainEqual({
entity: "ProductCategory",
fields: ["name"],
})
})
it("should use name from config object", () => {
model.define(
{ name: "ProductCategory", tableName: "product_category" },
{
id: model.id().primaryKey(),
name: model.text().translatable(),
}
)
const translatables = DmlEntity.getTranslatableEntities()
expect(translatables).toContainEqual({
entity: "ProductCategory",
fields: ["name"],
})
})
it("should return a copy of the translatable entities array", () => {
model.define("store", {
id: model.id().primaryKey(),
name: model.text().translatable(),
})
const translatables1 = DmlEntity.getTranslatableEntities()
const translatables2 = DmlEntity.getTranslatableEntities()
expect(translatables1).not.toBe(translatables2)
expect(translatables1).toEqual(translatables2)
})
})
})

View File

@@ -10,6 +10,7 @@ export class TextProperty extends BaseProperty<string> {
options: {
prefix?: string
searchable: boolean
translatable?: boolean
}
} = {
name: "text",
@@ -57,4 +58,25 @@ export class TextProperty extends BaseProperty<string> {
return this
}
/**
* This method indicates that a text property is translatable.
* Translatable properties can have different values per locale.
*
* @example
* import { model } from "@medusajs/framework/utils"
*
* const Store = model.define("store", {
* name: model.text().translatable(),
* // ...
* })
*
* export default Store
*
* @customNamespace Property Configuration Methods
*/
translatable() {
this.dataType.options.translatable = true
return this
}
}