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:
committed by
GitHub
parent
baaee11114
commit
08c55e7035
@@ -38,6 +38,7 @@ describe("Base property", () => {
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
defaultValue: undefined,
|
||||
nullable: false,
|
||||
computed: false,
|
||||
indexes: [],
|
||||
|
||||
@@ -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: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user