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:
1 parent
baaee11114
commit
08c55e7035
7 files changed
+344
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/types": patch
|
||||||
|
"@medusajs/utils": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
feat(DML): Add a new translatable property modifier applicable on text
|
||||||
@@ -38,6 +38,7 @@ describe("Base property", () => {
|
|||||||
searchable: true,
|
searchable: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
defaultValue: undefined,
|
||||||
nullable: false,
|
nullable: false,
|
||||||
computed: false,
|
computed: false,
|
||||||
indexes: [],
|
indexes: [],
|
||||||
|
|||||||
@@ -36,4 +36,73 @@ describe("Text property", () => {
|
|||||||
primaryKey: true,
|
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,
|
IDmlEntity,
|
||||||
IDmlEntityConfig,
|
IDmlEntityConfig,
|
||||||
InferDmlEntityNameFromConfig,
|
InferDmlEntityNameFromConfig,
|
||||||
|
PropertyType,
|
||||||
QueryCondition,
|
QueryCondition,
|
||||||
} from "@medusajs/types"
|
} from "@medusajs/types"
|
||||||
import { isObject, isString, toCamelCase, upperCaseFirst } from "../common"
|
import { isObject, isString, toCamelCase, upperCaseFirst } from "../common"
|
||||||
@@ -17,6 +18,32 @@ import { BelongsTo } from "./relations/belongs-to"
|
|||||||
|
|
||||||
const IsDmlEntity = Symbol.for("isDmlEntity")
|
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> =
|
export type DMLEntitySchemaBuilder<Schema extends DMLSchema> =
|
||||||
DMLSchemaWithBigNumber<Schema> & DMLSchemaDefaults & Schema
|
DMLSchemaWithBigNumber<Schema> & DMLSchemaDefaults & Schema
|
||||||
|
|
||||||
@@ -86,6 +113,32 @@ export class DmlEntity<
|
|||||||
this.schema = schema
|
this.schema = schema
|
||||||
this.name = name
|
this.name = name
|
||||||
this.#tableName = tableName
|
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]
|
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
|
* Parse entity to get its underlying information
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,4 +5,6 @@ export * from "./helpers/create-mikro-orm-entity"
|
|||||||
export * from "./relations/index"
|
export * from "./relations/index"
|
||||||
export * from "./properties/index"
|
export * from "./properties/index"
|
||||||
|
|
||||||
|
export { TranslatableEntityEntry } from "./entity"
|
||||||
|
|
||||||
export * from "./helpers/entity-builder/index"
|
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: {
|
options: {
|
||||||
prefix?: string
|
prefix?: string
|
||||||
searchable: boolean
|
searchable: boolean
|
||||||
|
translatable?: boolean
|
||||||
}
|
}
|
||||||
} = {
|
} = {
|
||||||
name: "text",
|
name: "text",
|
||||||
@@ -57,4 +58,25 @@ export class TextProperty extends BaseProperty<string> {
|
|||||||
|
|
||||||
return this
|
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