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:
committed by
GitHub
parent
fea3d4ec49
commit
6dc0b8bed8
@@ -9,4 +9,6 @@ export * from "./order-customer"
|
||||
export * from "./order-product"
|
||||
export * from "./order-region"
|
||||
export * from "./order-sales-channel"
|
||||
export * from "./product-translation"
|
||||
export * from "./store-currency"
|
||||
export * from "./store-locale"
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { ModuleJoinerConfig } from "@medusajs/framework/types"
|
||||
import {
|
||||
FeatureFlag,
|
||||
MEDUSA_SKIP_FILE,
|
||||
Modules,
|
||||
} from "@medusajs/framework/utils"
|
||||
|
||||
export const ProductTranslation: ModuleJoinerConfig = {
|
||||
[MEDUSA_SKIP_FILE]: !FeatureFlag.isFeatureEnabled("translation"),
|
||||
isLink: true,
|
||||
isReadOnlyLink: true,
|
||||
extends: [
|
||||
{
|
||||
serviceName: Modules.PRODUCT,
|
||||
entity: "Product",
|
||||
relationship: {
|
||||
serviceName: Modules.TRANSLATION,
|
||||
entity: "Translation",
|
||||
primaryKey: "reference_id",
|
||||
foreignKey: "id",
|
||||
alias: "translations",
|
||||
isList: true,
|
||||
args: {
|
||||
methodSuffix: "Translations",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
serviceName: Modules.PRODUCT,
|
||||
entity: "ProductVariant",
|
||||
relationship: {
|
||||
serviceName: Modules.TRANSLATION,
|
||||
entity: "Translation",
|
||||
primaryKey: "reference_id",
|
||||
foreignKey: "id",
|
||||
alias: "translations",
|
||||
isList: true,
|
||||
args: {
|
||||
methodSuffix: "Translations",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
serviceName: Modules.PRODUCT,
|
||||
entity: "ProductCategory",
|
||||
relationship: {
|
||||
serviceName: Modules.TRANSLATION,
|
||||
entity: "Translation",
|
||||
primaryKey: "reference_id",
|
||||
foreignKey: "id",
|
||||
alias: "translations",
|
||||
isList: true,
|
||||
args: {
|
||||
methodSuffix: "Translations",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
serviceName: Modules.PRODUCT,
|
||||
entity: "ProductCollection",
|
||||
relationship: {
|
||||
serviceName: Modules.TRANSLATION,
|
||||
entity: "Translation",
|
||||
primaryKey: "reference_id",
|
||||
foreignKey: "id",
|
||||
alias: "translations",
|
||||
isList: true,
|
||||
args: {
|
||||
methodSuffix: "Translations",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
serviceName: Modules.PRODUCT,
|
||||
entity: "ProductTag",
|
||||
relationship: {
|
||||
serviceName: Modules.TRANSLATION,
|
||||
entity: "Translation",
|
||||
primaryKey: "reference_id",
|
||||
foreignKey: "id",
|
||||
alias: "translations",
|
||||
isList: true,
|
||||
args: {
|
||||
methodSuffix: "Translations",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
serviceName: Modules.PRODUCT,
|
||||
entity: "ProductType",
|
||||
relationship: {
|
||||
serviceName: Modules.TRANSLATION,
|
||||
entity: "Translation",
|
||||
primaryKey: "reference_id",
|
||||
foreignKey: "id",
|
||||
alias: "translations",
|
||||
isList: true,
|
||||
args: {
|
||||
methodSuffix: "Translations",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
serviceName: Modules.PRODUCT,
|
||||
entity: "ProductOption",
|
||||
relationship: {
|
||||
serviceName: Modules.TRANSLATION,
|
||||
entity: "Translation",
|
||||
primaryKey: "reference_id",
|
||||
foreignKey: "id",
|
||||
alias: "translations",
|
||||
isList: true,
|
||||
args: {
|
||||
methodSuffix: "Translations",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
serviceName: Modules.PRODUCT,
|
||||
entity: "ProductOptionValue",
|
||||
relationship: {
|
||||
serviceName: Modules.TRANSLATION,
|
||||
entity: "Translation",
|
||||
primaryKey: "reference_id",
|
||||
foreignKey: "id",
|
||||
alias: "translations",
|
||||
isList: true,
|
||||
args: {
|
||||
methodSuffix: "Translations",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
serviceName: Modules.TRANSLATION,
|
||||
entity: "Translation",
|
||||
relationship: {
|
||||
serviceName: Modules.PRODUCT,
|
||||
entity: "Product",
|
||||
primaryKey: "id",
|
||||
foreignKey: "reference_id",
|
||||
alias: "product",
|
||||
args: {
|
||||
methodSuffix: "Products",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
} as ModuleJoinerConfig
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ModuleJoinerConfig } from "@medusajs/framework/types"
|
||||
import {
|
||||
FeatureFlag,
|
||||
MEDUSA_SKIP_FILE,
|
||||
Modules,
|
||||
} from "@medusajs/framework/utils"
|
||||
|
||||
export const StoreLocales: ModuleJoinerConfig = {
|
||||
[MEDUSA_SKIP_FILE]: !FeatureFlag.isFeatureEnabled("translation"),
|
||||
isLink: true,
|
||||
isReadOnlyLink: true,
|
||||
extends: [
|
||||
{
|
||||
serviceName: Modules.STORE,
|
||||
entity: "Store",
|
||||
relationship: {
|
||||
serviceName: Modules.TRANSLATION,
|
||||
entity: "Locale",
|
||||
primaryKey: "code",
|
||||
foreignKey: "supported_locales.locale_code",
|
||||
alias: "locale",
|
||||
args: {
|
||||
methodSuffix: "Locales",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
} as ModuleJoinerConfig
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
composeLinkName,
|
||||
composeTableName,
|
||||
ContainerRegistrationKeys,
|
||||
isFileSkipped,
|
||||
Modules,
|
||||
promiseAll,
|
||||
simpleHash,
|
||||
@@ -40,9 +41,9 @@ export const initialize = async (
|
||||
(mod) => Object.keys(mod)[0]
|
||||
)
|
||||
|
||||
const allLinksToLoad = Object.values(linkDefinitions).concat(
|
||||
pluginLinksDefinitions ?? []
|
||||
)
|
||||
const allLinksToLoad = Object.values(linkDefinitions)
|
||||
.concat(pluginLinksDefinitions ?? [])
|
||||
.filter((linkDefinition) => !isFileSkipped(linkDefinition))
|
||||
|
||||
await promiseAll(
|
||||
allLinksToLoad.map(async (linkDefinition) => {
|
||||
|
||||
@@ -6,6 +6,10 @@ export const createStoreFixture: StoreTypes.CreateStoreDTO = {
|
||||
{ currency_code: "usd" },
|
||||
{ currency_code: "eur", is_default: true },
|
||||
],
|
||||
supported_locales: [
|
||||
{ locale_code: "fr-FR" },
|
||||
{ locale_code: "en-US", is_default: true },
|
||||
],
|
||||
default_sales_channel_id: "test-sales-channel",
|
||||
default_region_id: "test-region",
|
||||
metadata: {
|
||||
|
||||
@@ -15,7 +15,11 @@ moduleIntegrationTestRunner<IStoreModuleService>({
|
||||
service: StoreModuleService,
|
||||
}).linkable
|
||||
|
||||
expect(Object.keys(linkable)).toEqual(["store", "storeCurrency"])
|
||||
expect(Object.keys(linkable)).toEqual([
|
||||
"store",
|
||||
"storeCurrency",
|
||||
"storeLocale",
|
||||
])
|
||||
|
||||
Object.keys(linkable).forEach((key) => {
|
||||
delete linkable[key].toJSON
|
||||
@@ -40,6 +44,15 @@ moduleIntegrationTestRunner<IStoreModuleService>({
|
||||
field: "storeCurrency",
|
||||
},
|
||||
},
|
||||
storeLocale: {
|
||||
id: {
|
||||
linkable: "store_locale_id",
|
||||
entity: "StoreLocale",
|
||||
primaryKey: "id",
|
||||
serviceName: "store",
|
||||
field: "storeLocale",
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -54,6 +67,10 @@ moduleIntegrationTestRunner<IStoreModuleService>({
|
||||
expect.objectContaining({ currency_code: "eur" }),
|
||||
expect.objectContaining({ currency_code: "usd" }),
|
||||
]),
|
||||
supported_locales: expect.arrayContaining([
|
||||
expect.objectContaining({ locale_code: "fr-FR" }),
|
||||
expect.objectContaining({ locale_code: "en-US" }),
|
||||
]),
|
||||
default_sales_channel_id: "test-sales-channel",
|
||||
default_region_id: "test-region",
|
||||
metadata: {
|
||||
@@ -75,6 +92,19 @@ moduleIntegrationTestRunner<IStoreModuleService>({
|
||||
"There should be a default currency set for the store"
|
||||
)
|
||||
})
|
||||
|
||||
it("should fail to get created if there is no default locale", async function () {
|
||||
const err = await service
|
||||
.createStores({
|
||||
...createStoreFixture,
|
||||
supported_locales: [{ locale_code: "en-US" }],
|
||||
})
|
||||
.catch((err) => err.message)
|
||||
|
||||
expect(err).toEqual(
|
||||
"There should be a default locale set for the store"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("upserting a store", () => {
|
||||
@@ -130,6 +160,19 @@ moduleIntegrationTestRunner<IStoreModuleService>({
|
||||
)
|
||||
})
|
||||
|
||||
it("should fail updating locales without a default one", async function () {
|
||||
const createdStore = await service.createStores(createStoreFixture)
|
||||
const updateErr = await service
|
||||
.updateStores(createdStore.id, {
|
||||
supported_locales: [{ locale_code: "en-US" }],
|
||||
})
|
||||
.catch((err) => err.message)
|
||||
|
||||
expect(updateErr).toEqual(
|
||||
"There should be a default locale set for the store"
|
||||
)
|
||||
})
|
||||
|
||||
it("should fail updating currencies where a duplicate currency code exists", async function () {
|
||||
const createdStore = await service.createStores(createStoreFixture)
|
||||
const updateErr = await service
|
||||
@@ -144,6 +187,20 @@ moduleIntegrationTestRunner<IStoreModuleService>({
|
||||
expect(updateErr).toEqual("Duplicate currency codes: usd")
|
||||
})
|
||||
|
||||
it("should fail updating locales where a duplicate locale code exists", async function () {
|
||||
const createdStore = await service.createStores(createStoreFixture)
|
||||
const updateErr = await service
|
||||
.updateStores(createdStore.id, {
|
||||
supported_locales: [
|
||||
{ locale_code: "en-US" },
|
||||
{ locale_code: "en-US" },
|
||||
],
|
||||
})
|
||||
.catch((err) => err.message)
|
||||
|
||||
expect(updateErr).toEqual("Duplicate locale codes: en-US")
|
||||
})
|
||||
|
||||
it("should fail updating currencies where there is more than 1 default currency", async function () {
|
||||
const createdStore = await service.createStores(createStoreFixture)
|
||||
const updateErr = await service
|
||||
@@ -157,6 +214,20 @@ moduleIntegrationTestRunner<IStoreModuleService>({
|
||||
|
||||
expect(updateErr).toEqual("Only one default currency is allowed")
|
||||
})
|
||||
|
||||
it("should fail updating locales where there is more than 1 default locale", async function () {
|
||||
const createdStore = await service.createStores(createStoreFixture)
|
||||
const updateErr = await service
|
||||
.updateStores(createdStore.id, {
|
||||
supported_locales: [
|
||||
{ locale_code: "en-US", is_default: true },
|
||||
{ locale_code: "fr-FR", is_default: true },
|
||||
],
|
||||
})
|
||||
.catch((err) => err.message)
|
||||
|
||||
expect(updateErr).toEqual("Only one default locale is allowed")
|
||||
})
|
||||
})
|
||||
|
||||
describe("deleting a store", () => {
|
||||
|
||||
@@ -101,9 +101,10 @@
|
||||
"keyName": "IDX_store_deleted_at",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_store_deleted_at\" ON \"store\" (deleted_at) WHERE deleted_at IS NULL"
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_store_deleted_at\" ON \"store\" (\"deleted_at\") WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "store_pkey",
|
||||
@@ -111,12 +112,14 @@
|
||||
"id"
|
||||
],
|
||||
"composite": false,
|
||||
"constraint": true,
|
||||
"primary": true,
|
||||
"unique": true
|
||||
}
|
||||
],
|
||||
"checks": [],
|
||||
"foreignKeys": {}
|
||||
"foreignKeys": {},
|
||||
"nativeEnums": {}
|
||||
},
|
||||
{
|
||||
"columns": {
|
||||
@@ -197,17 +200,19 @@
|
||||
"keyName": "IDX_store_currency_store_id",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_store_currency_store_id\" ON \"store_currency\" (store_id) WHERE deleted_at IS NULL"
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_store_currency_store_id\" ON \"store_currency\" (\"store_id\") WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "IDX_store_currency_deleted_at",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_store_currency_deleted_at\" ON \"store_currency\" (deleted_at) WHERE deleted_at IS NULL"
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_store_currency_deleted_at\" ON \"store_currency\" (\"deleted_at\") WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "store_currency_pkey",
|
||||
@@ -215,6 +220,7 @@
|
||||
"id"
|
||||
],
|
||||
"composite": false,
|
||||
"constraint": true,
|
||||
"primary": true,
|
||||
"unique": true
|
||||
}
|
||||
@@ -234,7 +240,131 @@
|
||||
"deleteRule": "cascade",
|
||||
"updateRule": "cascade"
|
||||
}
|
||||
}
|
||||
},
|
||||
"nativeEnums": {}
|
||||
},
|
||||
{
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"locale_code": {
|
||||
"name": "locale_code",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"is_default": {
|
||||
"name": "is_default",
|
||||
"type": "boolean",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"default": "false",
|
||||
"mappedType": "boolean"
|
||||
},
|
||||
"store_id": {
|
||||
"name": "store_id",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"length": 6,
|
||||
"default": "now()",
|
||||
"mappedType": "datetime"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"length": 6,
|
||||
"default": "now()",
|
||||
"mappedType": "datetime"
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"length": 6,
|
||||
"mappedType": "datetime"
|
||||
}
|
||||
},
|
||||
"name": "store_locale",
|
||||
"schema": "public",
|
||||
"indexes": [
|
||||
{
|
||||
"keyName": "IDX_store_locale_store_id",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_store_locale_store_id\" ON \"store_locale\" (\"store_id\") WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "IDX_store_locale_deleted_at",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_store_locale_deleted_at\" ON \"store_locale\" (\"deleted_at\") WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "store_locale_pkey",
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"composite": false,
|
||||
"constraint": true,
|
||||
"primary": true,
|
||||
"unique": true
|
||||
}
|
||||
],
|
||||
"checks": [],
|
||||
"foreignKeys": {
|
||||
"store_locale_store_id_foreign": {
|
||||
"constraintName": "store_locale_store_id_foreign",
|
||||
"columnNames": [
|
||||
"store_id"
|
||||
],
|
||||
"localTableName": "public.store_locale",
|
||||
"referencedColumnNames": [
|
||||
"id"
|
||||
],
|
||||
"referencedTableName": "public.store",
|
||||
"deleteRule": "cascade",
|
||||
"updateRule": "cascade"
|
||||
}
|
||||
},
|
||||
"nativeEnums": {}
|
||||
}
|
||||
]
|
||||
],
|
||||
"nativeEnums": {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
export class Migration20251202184737 extends Migration {
|
||||
|
||||
override async up(): Promise<void> {
|
||||
this.addSql(`create table if not exists "store_locale" ("id" text not null, "locale_code" text not null, "is_default" boolean not null default false, "store_id" text null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "store_locale_pkey" primary key ("id"));`);
|
||||
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_store_locale_store_id" ON "store_locale" ("store_id") WHERE deleted_at IS NULL;`);
|
||||
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_store_locale_deleted_at" ON "store_locale" ("deleted_at") WHERE deleted_at IS NULL;`);
|
||||
|
||||
this.addSql(`alter table if exists "store_locale" add constraint "store_locale_store_id_foreign" foreign key ("store_id") references "store" ("id") on update cascade on delete cascade;`);
|
||||
}
|
||||
|
||||
override async down(): Promise<void> {
|
||||
this.addSql(`drop table if exists "store_locale" cascade;`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as Store } from "./store"
|
||||
export { default as StoreCurrency } from "./currency"
|
||||
export { default as StoreLocale } from "./locale"
|
||||
|
||||
15
packages/modules/store/src/models/locale.ts
Normal file
15
packages/modules/store/src/models/locale.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { model } from "@medusajs/framework/utils"
|
||||
import Store from "./store"
|
||||
|
||||
const StoreLocale = model.define("StoreLocale", {
|
||||
id: model.id({ prefix: "stloc" }).primaryKey(),
|
||||
locale_code: model.text().searchable(),
|
||||
is_default: model.boolean().default(false),
|
||||
store: model
|
||||
.belongsTo(() => Store, {
|
||||
mappedBy: "supported_locales",
|
||||
})
|
||||
.nullable(),
|
||||
})
|
||||
|
||||
export default StoreLocale
|
||||
@@ -1,5 +1,6 @@
|
||||
import { model } from "@medusajs/framework/utils"
|
||||
import StoreCurrency from "./currency"
|
||||
import StoreLocale from "./locale"
|
||||
|
||||
const Store = model
|
||||
.define("Store", {
|
||||
@@ -12,9 +13,12 @@ const Store = model
|
||||
supported_currencies: model.hasMany(() => StoreCurrency, {
|
||||
mappedBy: "store",
|
||||
}),
|
||||
supported_locales: model.hasMany(() => StoreLocale, {
|
||||
mappedBy: "store",
|
||||
}),
|
||||
})
|
||||
.cascades({
|
||||
delete: ["supported_currencies"],
|
||||
delete: ["supported_currencies", "supported_locales"],
|
||||
})
|
||||
|
||||
export default Store
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
removeUndefined,
|
||||
} from "@medusajs/framework/utils"
|
||||
|
||||
import { Store, StoreCurrency } from "@models"
|
||||
import { Store, StoreCurrency, StoreLocale } from "@models"
|
||||
import { UpdateStoreInput } from "@types"
|
||||
|
||||
type InjectedDependencies = {
|
||||
@@ -32,7 +32,8 @@ export default class StoreModuleService
|
||||
extends MedusaService<{
|
||||
Store: { dto: StoreTypes.StoreDTO }
|
||||
StoreCurrency: { dto: StoreTypes.StoreCurrencyDTO }
|
||||
}>({ Store, StoreCurrency })
|
||||
StoreLocale: { dto: StoreTypes.StoreLocaleDTO }
|
||||
}>({ Store, StoreCurrency, StoreLocale })
|
||||
implements IStoreModuleService
|
||||
{
|
||||
protected baseRepository_: DAL.RepositoryService
|
||||
@@ -88,7 +89,7 @@ export default class StoreModuleService
|
||||
return (
|
||||
await this.storeService_.upsertWithReplace(
|
||||
normalizedInput,
|
||||
{ relations: ["supported_currencies"] },
|
||||
{ relations: ["supported_currencies", "supported_locales"] },
|
||||
sharedContext
|
||||
)
|
||||
).entities
|
||||
@@ -200,7 +201,7 @@ export default class StoreModuleService
|
||||
return (
|
||||
await this.storeService_.upsertWithReplace(
|
||||
normalizedInput,
|
||||
{ relations: ["supported_currencies"] },
|
||||
{ relations: ["supported_currencies", "supported_locales"] },
|
||||
sharedContext
|
||||
)
|
||||
).entities
|
||||
@@ -226,37 +227,56 @@ export default class StoreModuleService
|
||||
) {
|
||||
for (const store of stores) {
|
||||
if (store.supported_currencies?.length) {
|
||||
const duplicates = getDuplicates(
|
||||
store.supported_currencies?.map((c) => c.currency_code)
|
||||
StoreModuleService.validateSupportedItems(
|
||||
store.supported_currencies,
|
||||
(c) => c.currency_code,
|
||||
"currency"
|
||||
)
|
||||
|
||||
if (duplicates.length) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Duplicate currency codes: ${duplicates.join(", ")}`
|
||||
)
|
||||
}
|
||||
|
||||
let seenDefault = false
|
||||
store.supported_currencies?.forEach((c) => {
|
||||
if (c.is_default) {
|
||||
if (seenDefault) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Only one default currency is allowed`
|
||||
)
|
||||
}
|
||||
seenDefault = true
|
||||
}
|
||||
})
|
||||
|
||||
if (!seenDefault) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`There should be a default currency set for the store`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: If we are protecting this module behind a feature flag, we should check if the feature flag is enabled before validating the locales.
|
||||
if (store.supported_locales?.length) {
|
||||
StoreModuleService.validateSupportedItems(
|
||||
store.supported_locales,
|
||||
(l) => l.locale_code,
|
||||
"locale"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static validateSupportedItems<T extends { is_default?: boolean }>(
|
||||
items: T[],
|
||||
getCode: (item: T) => string,
|
||||
typeName: string
|
||||
) {
|
||||
const duplicates = getDuplicates(items.map(getCode))
|
||||
|
||||
if (duplicates.length) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Duplicate ${typeName} codes: ${duplicates.join(", ")}`
|
||||
)
|
||||
}
|
||||
|
||||
let seenDefault = false
|
||||
items.forEach((item) => {
|
||||
if (item.is_default) {
|
||||
if (seenDefault) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Only one default ${typeName} is allowed`
|
||||
)
|
||||
}
|
||||
seenDefault = true
|
||||
}
|
||||
})
|
||||
|
||||
if (!seenDefault) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`There should be a default ${typeName} set for the store`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
6
packages/modules/translation/.gitignore
vendored
Normal file
6
packages/modules/translation/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/dist
|
||||
node_modules
|
||||
.DS_store
|
||||
.env*
|
||||
.env
|
||||
*.sql
|
||||
@@ -0,0 +1,16 @@
|
||||
import { TranslationTypes } from "@medusajs/framework/types"
|
||||
|
||||
export const createLocaleFixture: TranslationTypes.CreateLocaleDTO = {
|
||||
code: "test-LC",
|
||||
name: "Test Locale",
|
||||
}
|
||||
|
||||
export const createTranslationFixture: TranslationTypes.CreateTranslationDTO = {
|
||||
reference_id: "prod_123",
|
||||
reference: "product",
|
||||
locale_code: "fr-FR",
|
||||
translations: {
|
||||
title: "Titre du produit",
|
||||
description: "Description du produit en français",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,651 @@
|
||||
import { ITranslationModuleService } from "@medusajs/framework/types"
|
||||
import { 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)
|
||||
|
||||
moduleIntegrationTestRunner<ITranslationModuleService>({
|
||||
moduleName: Modules.TRANSLATION,
|
||||
testSuite: ({ service }) => {
|
||||
describe("Translation Module Service", () => {
|
||||
it(`should export the appropriate linkable configuration`, () => {
|
||||
const linkable = Module(Modules.TRANSLATION, {
|
||||
service: TranslationModuleService,
|
||||
}).linkable
|
||||
|
||||
expect(Object.keys(linkable)).toEqual(["locale", "translation"])
|
||||
|
||||
Object.keys(linkable).forEach((key) => {
|
||||
delete linkable[key].toJSON
|
||||
})
|
||||
|
||||
expect(linkable).toEqual({
|
||||
locale: {
|
||||
id: {
|
||||
linkable: "locale_id",
|
||||
entity: "Locale",
|
||||
primaryKey: "id",
|
||||
serviceName: "translation",
|
||||
field: "locale",
|
||||
},
|
||||
},
|
||||
translation: {
|
||||
id: {
|
||||
linkable: "translation_id",
|
||||
entity: "Translation",
|
||||
primaryKey: "id",
|
||||
serviceName: "translation",
|
||||
field: "translation",
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe("Locale", () => {
|
||||
describe("creating a locale", () => {
|
||||
it("should create a locale successfully", async () => {
|
||||
const locale = await service.createLocales(createLocaleFixture)
|
||||
|
||||
expect(locale).toEqual(
|
||||
expect.objectContaining({
|
||||
code: "test-LC",
|
||||
name: "Test Locale",
|
||||
created_at: expect.any(Date),
|
||||
updated_at: expect.any(Date),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should create multiple locales successfully", async () => {
|
||||
const locales = await service.createLocales([
|
||||
createLocaleFixture,
|
||||
{ code: "test-LC2", name: "Test Locale 2" },
|
||||
])
|
||||
|
||||
expect(locales).toHaveLength(2)
|
||||
expect(locales[0].code).toEqual("test-LC")
|
||||
expect(locales[1].code).toEqual("test-LC2")
|
||||
})
|
||||
})
|
||||
|
||||
describe("retrieving a locale", () => {
|
||||
it("should retrieve a locale by id", async () => {
|
||||
const created = await service.createLocales(createLocaleFixture)
|
||||
const retrieved = await service.retrieveLocale(created.id)
|
||||
|
||||
expect(retrieved).toEqual(
|
||||
expect.objectContaining({
|
||||
id: created.id,
|
||||
code: created.code,
|
||||
name: "Test Locale",
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should throw when retrieving non-existent locale", async () => {
|
||||
const error = await service
|
||||
.retrieveLocale("non-existent-id")
|
||||
.catch((e) => e)
|
||||
|
||||
expect(error.message).toContain("Locale with id: non-existent-id")
|
||||
})
|
||||
})
|
||||
|
||||
describe("listing locales", () => {
|
||||
it("should list all locales including defaults", async () => {
|
||||
const locales = await service.listLocales()
|
||||
|
||||
expect(locales.length).toBeGreaterThanOrEqual(45)
|
||||
})
|
||||
|
||||
it("should filter locales by code", async () => {
|
||||
await service.createLocales(createLocaleFixture)
|
||||
const locales = await service.listLocales({ code: "test-LC" })
|
||||
|
||||
expect(locales).toHaveLength(1)
|
||||
expect(locales[0].code).toEqual("test-LC")
|
||||
})
|
||||
|
||||
it("should filter locales by name", async () => {
|
||||
const locales = await service.listLocales({
|
||||
name: "English (United States)",
|
||||
})
|
||||
|
||||
expect(locales).toHaveLength(1)
|
||||
expect(locales[0].code).toEqual("en-US")
|
||||
})
|
||||
|
||||
it("should support pagination", async () => {
|
||||
const paginatedLocales = await service.listLocales(
|
||||
{},
|
||||
{ take: 5, skip: 0 }
|
||||
)
|
||||
|
||||
expect(paginatedLocales).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe("listing and counting locales", () => {
|
||||
it("should list and count locales", async () => {
|
||||
const [locales, count] = await service.listAndCountLocales()
|
||||
|
||||
expect(count).toBeGreaterThanOrEqual(45)
|
||||
expect(locales.length).toEqual(count)
|
||||
})
|
||||
|
||||
it("should filter and count correctly", async () => {
|
||||
await service.createLocales([
|
||||
{ code: "custom-A", name: "Custom A" },
|
||||
{ code: "custom-B", name: "Custom B" },
|
||||
])
|
||||
|
||||
const [locales, count] = await service.listAndCountLocales({
|
||||
code: ["custom-A", "custom-B"],
|
||||
})
|
||||
|
||||
expect(count).toEqual(2)
|
||||
expect(locales).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("updating a locale", () => {
|
||||
it("should update a locale successfully", async () => {
|
||||
const created = await service.createLocales(createLocaleFixture)
|
||||
const updated = await service.updateLocales({
|
||||
id: created.id,
|
||||
code: created.code,
|
||||
name: "Updated Locale Name",
|
||||
})
|
||||
|
||||
expect(updated.name).toEqual("Updated Locale Name")
|
||||
expect(updated.code).toEqual("test-LC")
|
||||
})
|
||||
|
||||
it("should update multiple locales", async () => {
|
||||
const created = await service.createLocales([
|
||||
{ code: "upd-1", name: "Update 1" },
|
||||
{ code: "upd-2", name: "Update 2" },
|
||||
])
|
||||
|
||||
const updated = await service.updateLocales([
|
||||
{ id: created[0].id, code: created[0].code, name: "Updated 1" },
|
||||
{ id: created[1].id, code: created[1].code, name: "Updated 2" },
|
||||
])
|
||||
|
||||
expect(updated).toHaveLength(2)
|
||||
const updatedById = updated.reduce(
|
||||
(acc, l) => ({ ...acc, [l.code]: l }),
|
||||
{} as Record<string, any>
|
||||
)
|
||||
expect(updatedById[created[0].code].name).toEqual("Updated 1")
|
||||
expect(updatedById[created[1].code].name).toEqual("Updated 2")
|
||||
})
|
||||
})
|
||||
|
||||
describe("deleting a locale", () => {
|
||||
it("should delete a locale successfully", async () => {
|
||||
const created = await service.createLocales(createLocaleFixture)
|
||||
await service.deleteLocales(created.id)
|
||||
|
||||
const error = await service
|
||||
.retrieveLocale(created.id)
|
||||
.catch((e) => e)
|
||||
|
||||
expect(error.message).toContain("Locale with id")
|
||||
})
|
||||
|
||||
it("should delete multiple locales", async () => {
|
||||
const created = await service.createLocales([
|
||||
{ code: "del-1", name: "Delete 1" },
|
||||
{ code: "del-2", name: "Delete 2" },
|
||||
])
|
||||
|
||||
await service.deleteLocales([created[0].id, created[1].id])
|
||||
|
||||
const locales = await service.listLocales({
|
||||
code: ["del-1", "del-2"],
|
||||
})
|
||||
|
||||
expect(locales).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("soft deleting a locale", () => {
|
||||
it("should soft delete a locale", async () => {
|
||||
const created = await service.createLocales(createLocaleFixture)
|
||||
await service.softDeleteLocales(created.id)
|
||||
|
||||
const locales = await service.listLocales({ code: created.code })
|
||||
expect(locales).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("restoring a locale", () => {
|
||||
it("should restore a soft deleted locale", async () => {
|
||||
const created = await service.createLocales(createLocaleFixture)
|
||||
await service.softDeleteLocales(created.id)
|
||||
await service.restoreLocales(created.id)
|
||||
|
||||
const restored = await service.retrieveLocale(created.id)
|
||||
expect(restored.code).toEqual(created.code)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Translation", () => {
|
||||
describe("creating a translation", () => {
|
||||
it("should create a translation successfully", async () => {
|
||||
const translation = await service.createTranslations(
|
||||
createTranslationFixture
|
||||
)
|
||||
|
||||
expect(translation).toEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(/^trans_/),
|
||||
reference_id: "prod_123",
|
||||
reference: "product",
|
||||
locale_code: "fr-FR",
|
||||
translations: {
|
||||
title: "Titre du produit",
|
||||
description: "Description du produit en français",
|
||||
},
|
||||
created_at: expect.any(Date),
|
||||
updated_at: expect.any(Date),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should create multiple translations successfully", async () => {
|
||||
const translations = await service.createTranslations([
|
||||
createTranslationFixture,
|
||||
{
|
||||
reference_id: "prod_123",
|
||||
reference: "product",
|
||||
locale_code: "de-DE",
|
||||
translations: {
|
||||
title: "Produkttitel",
|
||||
description: "Produktbeschreibung auf Deutsch",
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
expect(translations).toHaveLength(2)
|
||||
expect(translations[0].locale_code).toEqual("fr-FR")
|
||||
expect(translations[1].locale_code).toEqual("de-DE")
|
||||
})
|
||||
|
||||
it("should fail when creating duplicate translation for same entity/type/locale", async () => {
|
||||
await service.createTranslations(createTranslationFixture)
|
||||
|
||||
const error = await service
|
||||
.createTranslations(createTranslationFixture)
|
||||
.catch((e) => e)
|
||||
|
||||
expect(error.message).toMatch(
|
||||
/unique|duplicate|constraint|already exists/i
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("retrieving a translation", () => {
|
||||
it("should retrieve a translation by id", async () => {
|
||||
const created = await service.createTranslations(
|
||||
createTranslationFixture
|
||||
)
|
||||
const retrieved = await service.retrieveTranslation(created.id)
|
||||
|
||||
expect(retrieved).toEqual(
|
||||
expect.objectContaining({
|
||||
id: created.id,
|
||||
reference_id: "prod_123",
|
||||
reference: "product",
|
||||
locale_code: "fr-FR",
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should throw when retrieving non-existent translation", async () => {
|
||||
const error = await service
|
||||
.retrieveTranslation("non-existent-id")
|
||||
.catch((e) => e)
|
||||
|
||||
expect(error.message).toContain(
|
||||
"Translation with id: non-existent-id"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("listing translations", () => {
|
||||
beforeEach(async () => {
|
||||
await service.createTranslations([
|
||||
{
|
||||
reference_id: "prod_1",
|
||||
reference: "product",
|
||||
locale_code: "fr-FR",
|
||||
translations: { title: "Produit Un" },
|
||||
},
|
||||
{
|
||||
reference_id: "prod_1",
|
||||
reference: "product",
|
||||
locale_code: "de-DE",
|
||||
translations: { title: "Produkt Eins" },
|
||||
},
|
||||
{
|
||||
reference_id: "prod_2",
|
||||
reference: "product",
|
||||
locale_code: "fr-FR",
|
||||
translations: { title: "Produit Deux" },
|
||||
},
|
||||
{
|
||||
reference_id: "cat_1",
|
||||
reference: "product_category",
|
||||
locale_code: "fr-FR",
|
||||
translations: { name: "Catégorie" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should list all translations", async () => {
|
||||
const translations = await service.listTranslations()
|
||||
|
||||
expect(translations.length).toBeGreaterThanOrEqual(4)
|
||||
})
|
||||
|
||||
it("should filter by reference_id", async () => {
|
||||
const translations = await service.listTranslations({
|
||||
reference_id: "prod_1",
|
||||
})
|
||||
|
||||
expect(translations).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should filter by reference", async () => {
|
||||
const translations = await service.listTranslations({
|
||||
reference: "product_category",
|
||||
})
|
||||
|
||||
expect(translations).toHaveLength(1)
|
||||
expect(translations[0].reference_id).toEqual("cat_1")
|
||||
})
|
||||
|
||||
it("should filter by locale_code", async () => {
|
||||
const translations = await service.listTranslations({
|
||||
locale_code: "de-DE",
|
||||
})
|
||||
|
||||
expect(translations).toHaveLength(1)
|
||||
expect(translations[0].reference_id).toEqual("prod_1")
|
||||
})
|
||||
|
||||
it("should filter by multiple criteria", async () => {
|
||||
const translations = await service.listTranslations({
|
||||
reference_id: "prod_1",
|
||||
locale_code: "fr-FR",
|
||||
})
|
||||
|
||||
expect(translations).toHaveLength(1)
|
||||
expect(translations[0].translations).toEqual({
|
||||
title: "Produit Un",
|
||||
})
|
||||
})
|
||||
|
||||
it("should support pagination", async () => {
|
||||
const translations = await service.listTranslations(
|
||||
{},
|
||||
{ take: 2, skip: 0 }
|
||||
)
|
||||
|
||||
expect(translations).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("listing translations with q filter (JSONB search)", () => {
|
||||
beforeEach(async () => {
|
||||
await service.createTranslations([
|
||||
{
|
||||
reference_id: "prod_search_1",
|
||||
reference: "product",
|
||||
locale_code: "fr-FR",
|
||||
translations: {
|
||||
title: "Chaussures de sport",
|
||||
description: "Des chaussures confortables pour le running",
|
||||
},
|
||||
},
|
||||
{
|
||||
reference_id: "prod_search_2",
|
||||
reference: "product",
|
||||
locale_code: "fr-FR",
|
||||
translations: {
|
||||
title: "T-shirt de sport",
|
||||
description: "Un t-shirt léger et respirant",
|
||||
},
|
||||
},
|
||||
{
|
||||
reference_id: "prod_search_3",
|
||||
reference: "product",
|
||||
locale_code: "de-DE",
|
||||
translations: {
|
||||
title: "Sportschuhe",
|
||||
description: "Bequeme Schuhe zum Laufen",
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should search within JSONB translations field", async () => {
|
||||
const translations = await service.listTranslations({
|
||||
q: "chaussures",
|
||||
})
|
||||
|
||||
expect(translations).toHaveLength(1)
|
||||
expect(translations[0].reference_id).toEqual("prod_search_1")
|
||||
})
|
||||
|
||||
it("should search case-insensitively", async () => {
|
||||
const translations = await service.listTranslations({
|
||||
q: "CHAUSSURES",
|
||||
})
|
||||
|
||||
expect(translations).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("should search across all JSONB values", async () => {
|
||||
const translations = await service.listTranslations({
|
||||
q: "running",
|
||||
})
|
||||
|
||||
expect(translations).toHaveLength(1)
|
||||
expect(translations[0].reference_id).toEqual("prod_search_1")
|
||||
})
|
||||
|
||||
it("should combine q filter with other filters", async () => {
|
||||
const translations = await service.listTranslations({
|
||||
q: "sport",
|
||||
locale_code: "fr-FR",
|
||||
})
|
||||
|
||||
expect(translations).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("should return empty array when q matches nothing", async () => {
|
||||
const translations = await service.listTranslations({
|
||||
q: "nonexistent-term-xyz",
|
||||
})
|
||||
|
||||
expect(translations).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("listing and counting translations", () => {
|
||||
beforeEach(async () => {
|
||||
await service.createTranslations([
|
||||
{
|
||||
reference_id: "cnt_1",
|
||||
reference: "product",
|
||||
locale_code: "fr-FR",
|
||||
translations: { title: "Un" },
|
||||
},
|
||||
{
|
||||
reference_id: "cnt_2",
|
||||
reference: "product",
|
||||
locale_code: "fr-FR",
|
||||
translations: { title: "Deux" },
|
||||
},
|
||||
{
|
||||
reference_id: "cnt_3",
|
||||
reference: "product",
|
||||
locale_code: "fr-FR",
|
||||
translations: { title: "Trois" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should list and count translations", async () => {
|
||||
const [translations, count] =
|
||||
await service.listAndCountTranslations({
|
||||
reference: "product",
|
||||
locale_code: "fr-FR",
|
||||
})
|
||||
|
||||
expect(count).toEqual(3)
|
||||
expect(translations).toHaveLength(3)
|
||||
})
|
||||
|
||||
it("should list and count with q filter", async () => {
|
||||
const [translations, count] =
|
||||
await service.listAndCountTranslations({
|
||||
q: "Deux",
|
||||
})
|
||||
|
||||
expect(count).toEqual(1)
|
||||
expect(translations).toHaveLength(1)
|
||||
expect(translations[0].reference_id).toEqual("cnt_2")
|
||||
})
|
||||
})
|
||||
|
||||
describe("updating a translation", () => {
|
||||
it("should update a translation successfully", async () => {
|
||||
const created = await service.createTranslations(
|
||||
createTranslationFixture
|
||||
)
|
||||
const updated = await service.updateTranslations({
|
||||
id: created.id,
|
||||
translations: {
|
||||
title: "Nouveau titre",
|
||||
description: "Nouvelle description",
|
||||
},
|
||||
})
|
||||
|
||||
expect(updated.translations).toEqual({
|
||||
title: "Nouveau titre",
|
||||
description: "Nouvelle description",
|
||||
})
|
||||
})
|
||||
|
||||
it("should update multiple translations", async () => {
|
||||
const created = await service.createTranslations([
|
||||
{
|
||||
reference_id: "upd_1",
|
||||
reference: "product",
|
||||
locale_code: "fr-FR",
|
||||
translations: { title: "Original 1" },
|
||||
},
|
||||
{
|
||||
reference_id: "upd_2",
|
||||
reference: "product",
|
||||
locale_code: "fr-FR",
|
||||
translations: { title: "Original 2" },
|
||||
},
|
||||
])
|
||||
|
||||
const updated = await service.updateTranslations([
|
||||
{ id: created[0].id, translations: { title: "Updated 1" } },
|
||||
{ id: created[1].id, translations: { title: "Updated 2" } },
|
||||
])
|
||||
|
||||
expect(updated).toHaveLength(2)
|
||||
const updatedById = updated.reduce(
|
||||
(acc, t) => ({ ...acc, [t.id]: t }),
|
||||
{} as Record<string, any>
|
||||
)
|
||||
expect(updatedById[created[0].id].translations).toEqual({
|
||||
title: "Updated 1",
|
||||
})
|
||||
expect(updatedById[created[1].id].translations).toEqual({
|
||||
title: "Updated 2",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("deleting a translation", () => {
|
||||
it("should delete a translation successfully", async () => {
|
||||
const created = await service.createTranslations(
|
||||
createTranslationFixture
|
||||
)
|
||||
await service.deleteTranslations(created.id)
|
||||
|
||||
const error = await service
|
||||
.retrieveTranslation(created.id)
|
||||
.catch((e) => e)
|
||||
|
||||
expect(error.message).toContain("Translation with id")
|
||||
})
|
||||
|
||||
it("should delete multiple translations", async () => {
|
||||
const created = await service.createTranslations([
|
||||
{
|
||||
reference_id: "del_1",
|
||||
reference: "product",
|
||||
locale_code: "fr-FR",
|
||||
translations: { title: "Delete 1" },
|
||||
},
|
||||
{
|
||||
reference_id: "del_2",
|
||||
reference: "product",
|
||||
locale_code: "fr-FR",
|
||||
translations: { title: "Delete 2" },
|
||||
},
|
||||
])
|
||||
|
||||
await service.deleteTranslations([created[0].id, created[1].id])
|
||||
|
||||
const translations = await service.listTranslations({
|
||||
reference_id: ["del_1", "del_2"],
|
||||
})
|
||||
|
||||
expect(translations).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("soft deleting a translation", () => {
|
||||
it("should soft delete a translation", async () => {
|
||||
const created = await service.createTranslations(
|
||||
createTranslationFixture
|
||||
)
|
||||
await service.softDeleteTranslations(created.id)
|
||||
|
||||
const translations = await service.listTranslations({
|
||||
id: created.id,
|
||||
})
|
||||
expect(translations).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("restoring a translation", () => {
|
||||
it("should restore a soft deleted translation", async () => {
|
||||
const created = await service.createTranslations(
|
||||
createTranslationFixture
|
||||
)
|
||||
await service.softDeleteTranslations(created.id)
|
||||
await service.restoreTranslations(created.id)
|
||||
|
||||
const restored = await service.retrieveTranslation(created.id)
|
||||
expect(restored.id).toEqual(created.id)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
15
packages/modules/translation/jest.config.js
Normal file
15
packages/modules/translation/jest.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const defineJestConfig = require("../../../define_jest_config")
|
||||
module.exports = defineJestConfig({
|
||||
moduleNameMapper: {
|
||||
"^@models$": "<rootDir>/src/models",
|
||||
"^@models/(.*)$": "<rootDir>/src/models/$1",
|
||||
"^@services$": "<rootDir>/src/services",
|
||||
"^@services/(.*)$": "<rootDir>/src/services/$1",
|
||||
"^@repositories$": "<rootDir>/src/repositories",
|
||||
"^@repositories/(.*)$": "<rootDir>/src/repositories/$1",
|
||||
"^@types$": "<rootDir>/src/types",
|
||||
"^@types/(.*)$": "<rootDir>/src/types/$1",
|
||||
"^@utils$": "<rootDir>/src/utils",
|
||||
"^@utils/(.*)$": "<rootDir>/src/utils/$1",
|
||||
},
|
||||
})
|
||||
7
packages/modules/translation/mikro-orm.config.dev.ts
Normal file
7
packages/modules/translation/mikro-orm.config.dev.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineMikroOrmCliConfig } from "@medusajs/framework/utils"
|
||||
import Locale from "./src/models/locale"
|
||||
import Translation from "./src/models/translation"
|
||||
|
||||
export default defineMikroOrmCliConfig("translation", {
|
||||
entities: [Locale, Translation],
|
||||
})
|
||||
45
packages/modules/translation/package.json
Normal file
45
packages/modules/translation/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@medusajs/translation",
|
||||
"version": "2.12.1",
|
||||
"description": "Medusa Translation module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/__tests__",
|
||||
"!dist/**/__mocks__",
|
||||
"!dist/**/__fixtures__"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/medusajs/medusa",
|
||||
"directory": "packages/modules/translation"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"author": "Medusa",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"watch": "yarn run -T tsc --build --watch",
|
||||
"watch:test": "yarn run -T tsc --build tsconfig.spec.json --watch",
|
||||
"resolve:aliases": "yarn run -T tsc --showConfig -p tsconfig.json > tsconfig.resolved.json && yarn run -T tsc-alias -p tsconfig.resolved.json && yarn run -T rimraf tsconfig.resolved.json",
|
||||
"build": "yarn run -T rimraf dist && yarn run -T tsc --build && npm run resolve:aliases",
|
||||
"test": "../../../node_modules/.bin/jest --passWithNoTests --bail --forceExit --testPathPattern=src",
|
||||
"test:integration": "../../../node_modules/.bin/jest --passWithNoTests --forceExit --testPathPattern=\"integration-tests/__tests__/.*\\.ts\"",
|
||||
"migration:initial": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts MIKRO_ORM_ALLOW_GLOBAL_CLI=true medusa-mikro-orm migration:create --initial",
|
||||
"migration:create": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts MIKRO_ORM_ALLOW_GLOBAL_CLI=true medusa-mikro-orm migration:create",
|
||||
"migration:up": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts MIKRO_ORM_ALLOW_GLOBAL_CLI=true medusa-mikro-orm migration:up",
|
||||
"orm:cache:clear": "MIKRO_ORM_CLI_CONFIG=./mikro-orm.config.dev.ts MIKRO_ORM_ALLOW_GLOBAL_CLI=true medusa-mikro-orm cache:clear"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@medusajs/framework": "2.12.1",
|
||||
"@medusajs/test-utils": "2.12.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@medusajs/framework": "2.12.1"
|
||||
}
|
||||
}
|
||||
10
packages/modules/translation/src/index.ts
Normal file
10
packages/modules/translation/src/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import TranslationModuleService from "@services/translation-module"
|
||||
import loadDefaults from "./loaders/defaults"
|
||||
import { Module } from "@medusajs/framework/utils"
|
||||
|
||||
export const TRANSLATION_MODULE = "translation"
|
||||
|
||||
export default Module(TRANSLATION_MODULE, {
|
||||
service: TranslationModuleService,
|
||||
loaders: [loadDefaults],
|
||||
})
|
||||
80
packages/modules/translation/src/loaders/defaults.ts
Normal file
80
packages/modules/translation/src/loaders/defaults.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
LoaderOptions,
|
||||
Logger,
|
||||
ModulesSdkTypes,
|
||||
} from "@medusajs/framework/types"
|
||||
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
|
||||
import Locale from "@models/locale"
|
||||
|
||||
/**
|
||||
* BCP 47 Language Tags
|
||||
* Common language-region codes following the IETF BCP 47 standard.
|
||||
* Format: language[-script][-region]
|
||||
* Examples: "en-US" (English, United States), "zh-Hans-CN" (Chinese Simplified, China)
|
||||
*/
|
||||
const defaultLocales = [
|
||||
{ code: "en-US", name: "English (United States)" },
|
||||
{ code: "en-GB", name: "English (United Kingdom)" },
|
||||
{ code: "en-AU", name: "English (Australia)" },
|
||||
{ code: "en-CA", name: "English (Canada)" },
|
||||
{ code: "es-ES", name: "Spanish (Spain)" },
|
||||
{ code: "es-MX", name: "Spanish (Mexico)" },
|
||||
{ code: "es-AR", name: "Spanish (Argentina)" },
|
||||
{ code: "fr-FR", name: "French (France)" },
|
||||
{ code: "fr-CA", name: "French (Canada)" },
|
||||
{ code: "fr-BE", name: "French (Belgium)" },
|
||||
{ code: "de-DE", name: "German (Germany)" },
|
||||
{ code: "de-AT", name: "German (Austria)" },
|
||||
{ code: "de-CH", name: "German (Switzerland)" },
|
||||
{ code: "it-IT", name: "Italian (Italy)" },
|
||||
{ code: "pt-BR", name: "Portuguese (Brazil)" },
|
||||
{ code: "pt-PT", name: "Portuguese (Portugal)" },
|
||||
{ code: "nl-NL", name: "Dutch (Netherlands)" },
|
||||
{ code: "nl-BE", name: "Dutch (Belgium)" },
|
||||
{ code: "da-DK", name: "Danish (Denmark)" },
|
||||
{ code: "sv-SE", name: "Swedish (Sweden)" },
|
||||
{ code: "nb-NO", name: "Norwegian Bokmål (Norway)" },
|
||||
{ code: "fi-FI", name: "Finnish (Finland)" },
|
||||
{ code: "pl-PL", name: "Polish (Poland)" },
|
||||
{ code: "cs-CZ", name: "Czech (Czech Republic)" },
|
||||
{ code: "sk-SK", name: "Slovak (Slovakia)" },
|
||||
{ code: "hu-HU", name: "Hungarian (Hungary)" },
|
||||
{ code: "ro-RO", name: "Romanian (Romania)" },
|
||||
{ code: "bg-BG", name: "Bulgarian (Bulgaria)" },
|
||||
{ code: "el-GR", name: "Greek (Greece)" },
|
||||
{ code: "tr-TR", name: "Turkish (Turkey)" },
|
||||
{ code: "ru-RU", name: "Russian (Russia)" },
|
||||
{ code: "uk-UA", name: "Ukrainian (Ukraine)" },
|
||||
{ code: "ar-SA", name: "Arabic (Saudi Arabia)" },
|
||||
{ code: "ar-AE", name: "Arabic (United Arab Emirates)" },
|
||||
{ code: "ar-EG", name: "Arabic (Egypt)" },
|
||||
{ code: "he-IL", name: "Hebrew (Israel)" },
|
||||
{ code: "hi-IN", name: "Hindi (India)" },
|
||||
{ code: "bn-BD", name: "Bengali (Bangladesh)" },
|
||||
{ code: "th-TH", name: "Thai (Thailand)" },
|
||||
{ code: "vi-VN", name: "Vietnamese (Vietnam)" },
|
||||
{ code: "id-ID", name: "Indonesian (Indonesia)" },
|
||||
{ code: "ms-MY", name: "Malay (Malaysia)" },
|
||||
{ code: "tl-PH", name: "Tagalog (Philippines)" },
|
||||
{ code: "zh-CN", name: "Chinese Simplified (China)" },
|
||||
{ code: "zh-TW", name: "Chinese Traditional (Taiwan)" },
|
||||
{ code: "zh-HK", name: "Chinese Traditional (Hong Kong)" },
|
||||
{ code: "ja-JP", name: "Japanese (Japan)" },
|
||||
{ code: "ko-KR", name: "Korean (South Korea)" },
|
||||
]
|
||||
|
||||
export default async ({ container }: LoaderOptions): Promise<void> => {
|
||||
const logger =
|
||||
container.resolve<Logger>(ContainerRegistrationKeys.LOGGER) ?? console
|
||||
const localeService_: ModulesSdkTypes.IMedusaInternalService<typeof Locale> =
|
||||
container.resolve("localeService")
|
||||
|
||||
try {
|
||||
const resp = await localeService_.upsert(defaultLocales)
|
||||
logger.debug(`Loaded ${resp.length} locales`)
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to load locales, skipping loader. Original error: ${error.message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
{
|
||||
"namespaces": [
|
||||
"public"
|
||||
],
|
||||
"name": "public",
|
||||
"tables": [
|
||||
{
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"code": {
|
||||
"name": "code",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"length": 6,
|
||||
"default": "now()",
|
||||
"mappedType": "datetime"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"length": 6,
|
||||
"default": "now()",
|
||||
"mappedType": "datetime"
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"length": 6,
|
||||
"mappedType": "datetime"
|
||||
}
|
||||
},
|
||||
"name": "locale",
|
||||
"schema": "public",
|
||||
"indexes": [
|
||||
{
|
||||
"keyName": "IDX_locale_deleted_at",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_locale_deleted_at\" ON \"locale\" (\"deleted_at\") WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "IDX_locale_code_unique",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_locale_code_unique\" ON \"locale\" (\"code\") WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "locale_pkey",
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"composite": false,
|
||||
"constraint": true,
|
||||
"primary": true,
|
||||
"unique": true
|
||||
}
|
||||
],
|
||||
"checks": [],
|
||||
"foreignKeys": {},
|
||||
"nativeEnums": {}
|
||||
},
|
||||
{
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"reference_id": {
|
||||
"name": "reference_id",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"reference": {
|
||||
"name": "reference",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"locale_code": {
|
||||
"name": "locale_code",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "text"
|
||||
},
|
||||
"translations": {
|
||||
"name": "translations",
|
||||
"type": "jsonb",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"mappedType": "json"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"length": 6,
|
||||
"default": "now()",
|
||||
"mappedType": "datetime"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"length": 6,
|
||||
"default": "now()",
|
||||
"mappedType": "datetime"
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"length": 6,
|
||||
"mappedType": "datetime"
|
||||
}
|
||||
},
|
||||
"name": "translation",
|
||||
"schema": "public",
|
||||
"indexes": [
|
||||
{
|
||||
"keyName": "IDX_translation_deleted_at",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_translation_deleted_at\" ON \"translation\" (\"deleted_at\") WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "IDX_translation_reference_id_locale_code_unique",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_translation_reference_id_locale_code_unique\" ON \"translation\" (\"reference_id\", \"locale_code\") WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "IDX_translation_reference_id_reference_locale_code",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_translation_reference_id_reference_locale_code\" ON \"translation\" (\"reference_id\", \"reference\", \"locale_code\") WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "IDX_translation_reference_locale_code",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_translation_reference_locale_code\" ON \"translation\" (\"reference\", \"locale_code\") WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "IDX_translation_reference_id_reference",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_translation_reference_id_reference\" ON \"translation\" (\"reference_id\", \"reference\") WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "IDX_translation_locale_code",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_translation_locale_code\" ON \"translation\" (\"locale_code\") WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "translation_pkey",
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"composite": false,
|
||||
"constraint": true,
|
||||
"primary": true,
|
||||
"unique": true
|
||||
}
|
||||
],
|
||||
"checks": [],
|
||||
"foreignKeys": {},
|
||||
"nativeEnums": {}
|
||||
}
|
||||
],
|
||||
"nativeEnums": {}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Migration } from "@medusajs/framework/mikro-orm/migrations"
|
||||
|
||||
export class Migration20251208124155 extends Migration {
|
||||
override async up(): Promise<void> {
|
||||
this.addSql(
|
||||
`alter table if exists "translation" drop constraint if exists "translation_reference_id_locale_code_unique";`
|
||||
)
|
||||
this.addSql(
|
||||
`alter table if exists "locale" drop constraint if exists "locale_code_unique";`
|
||||
)
|
||||
this.addSql(
|
||||
`create table if not exists "locale" ("id" text not null, "code" text not null, "name" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "locale_pkey" primary key ("id"));`
|
||||
)
|
||||
this.addSql(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_locale_deleted_at" ON "locale" ("deleted_at") WHERE deleted_at IS NULL;`
|
||||
)
|
||||
this.addSql(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_locale_code_unique" ON "locale" ("code") WHERE deleted_at IS NULL;`
|
||||
)
|
||||
|
||||
this.addSql(
|
||||
`create table if not exists "translation" ("id" text not null, "reference_id" text not null, "reference" text not null, "locale_code" text not null, "translations" jsonb not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "translation_pkey" primary key ("id"));`
|
||||
)
|
||||
this.addSql(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_translation_deleted_at" ON "translation" ("deleted_at") WHERE deleted_at IS NULL;`
|
||||
)
|
||||
this.addSql(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_translation_reference_id_locale_code_unique" ON "translation" ("reference_id", "locale_code") WHERE deleted_at IS NULL;`
|
||||
)
|
||||
this.addSql(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_translation_reference_id_reference_locale_code" ON "translation" ("reference_id", "reference", "locale_code") WHERE deleted_at IS NULL;`
|
||||
)
|
||||
this.addSql(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_translation_reference_locale_code" ON "translation" ("reference", "locale_code") WHERE deleted_at IS NULL;`
|
||||
)
|
||||
this.addSql(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_translation_reference_id_reference" ON "translation" ("reference_id", "reference") WHERE deleted_at IS NULL;`
|
||||
)
|
||||
this.addSql(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_translation_locale_code" ON "translation" ("locale_code") WHERE deleted_at IS NULL;`
|
||||
)
|
||||
}
|
||||
|
||||
override async down(): Promise<void> {
|
||||
this.addSql(`drop table if exists "locale" cascade;`)
|
||||
|
||||
this.addSql(`drop table if exists "translation" cascade;`)
|
||||
}
|
||||
}
|
||||
16
packages/modules/translation/src/models/locale.ts
Normal file
16
packages/modules/translation/src/models/locale.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { model } from "@medusajs/framework/utils"
|
||||
|
||||
const Locale = model
|
||||
.define("locale", {
|
||||
id: model.id({ prefix: "loc" }).primaryKey(),
|
||||
code: model.text().searchable(), // BCP 47 language tag, e.g., "en-US", "da-DK"
|
||||
name: model.text().searchable(), // Human-readable name, e.g., "English (US)", "Danish"
|
||||
})
|
||||
.indexes([
|
||||
{
|
||||
on: ["code"],
|
||||
unique: true,
|
||||
},
|
||||
])
|
||||
|
||||
export default Locale
|
||||
30
packages/modules/translation/src/models/translation.ts
Normal file
30
packages/modules/translation/src/models/translation.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { model } from "@medusajs/framework/utils"
|
||||
|
||||
const Translation = model
|
||||
.define("translation", {
|
||||
id: model.id({ prefix: "trans" }).primaryKey(),
|
||||
reference_id: model.text().searchable(),
|
||||
reference: model.text().searchable(), // e.g., "product", "product_variant", "product_category"
|
||||
locale_code: model.text().searchable(), // BCP 47 language tag, e.g., "en-US", "da-DK"
|
||||
translations: model.json(), // JSON object containing translated fields, e.g., { "title": "...", "description": "..." }
|
||||
})
|
||||
.indexes([
|
||||
{
|
||||
on: ["reference_id", "locale_code"],
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
on: ["reference_id", "reference", "locale_code"],
|
||||
},
|
||||
{
|
||||
on: ["reference", "locale_code"],
|
||||
},
|
||||
{
|
||||
on: ["reference_id", "reference"],
|
||||
},
|
||||
{
|
||||
on: ["locale_code"],
|
||||
},
|
||||
])
|
||||
|
||||
export default Translation
|
||||
193
packages/modules/translation/src/services/translation-module.ts
Normal file
193
packages/modules/translation/src/services/translation-module.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { raw } from "@medusajs/framework/mikro-orm/core"
|
||||
import {
|
||||
Context,
|
||||
CreateTranslationDTO,
|
||||
DAL,
|
||||
FilterableTranslationProps,
|
||||
FindConfig,
|
||||
ITranslationModuleService,
|
||||
LocaleDTO,
|
||||
ModulesSdkTypes,
|
||||
TranslationTypes,
|
||||
} from "@medusajs/framework/types"
|
||||
import {
|
||||
EmitEvents,
|
||||
InjectManager,
|
||||
MedusaContext,
|
||||
MedusaService,
|
||||
normalizeLocale,
|
||||
} from "@medusajs/framework/utils"
|
||||
import Locale from "@models/locale"
|
||||
import Translation from "@models/translation"
|
||||
|
||||
type InjectedDependencies = {
|
||||
baseRepository: DAL.RepositoryService
|
||||
translationService: ModulesSdkTypes.IMedusaInternalService<typeof Translation>
|
||||
localeService: ModulesSdkTypes.IMedusaInternalService<typeof Locale>
|
||||
}
|
||||
|
||||
export default class TranslationModuleService
|
||||
extends MedusaService<{
|
||||
Locale: {
|
||||
dto: TranslationTypes.LocaleDTO
|
||||
}
|
||||
Translation: {
|
||||
dto: TranslationTypes.TranslationDTO
|
||||
}
|
||||
}>({
|
||||
Locale,
|
||||
Translation,
|
||||
})
|
||||
implements ITranslationModuleService
|
||||
{
|
||||
protected baseRepository_: DAL.RepositoryService
|
||||
protected translationService_: ModulesSdkTypes.IMedusaInternalService<
|
||||
typeof Translation
|
||||
>
|
||||
protected localeService_: ModulesSdkTypes.IMedusaInternalService<
|
||||
typeof Locale
|
||||
>
|
||||
|
||||
constructor({
|
||||
baseRepository,
|
||||
translationService,
|
||||
localeService,
|
||||
}: InjectedDependencies) {
|
||||
super(...arguments)
|
||||
this.baseRepository_ = baseRepository
|
||||
this.translationService_ = translationService
|
||||
this.localeService_ = localeService
|
||||
}
|
||||
|
||||
static prepareFilters(
|
||||
filters: FilterableTranslationProps
|
||||
): FilterableTranslationProps {
|
||||
let { q, ...restFilters } = filters
|
||||
|
||||
if (q) {
|
||||
restFilters = {
|
||||
...restFilters,
|
||||
[raw(`translations::text ILIKE ?`, [`%${q}%`])]: [],
|
||||
}
|
||||
}
|
||||
|
||||
return restFilters
|
||||
}
|
||||
|
||||
@InjectManager()
|
||||
// @ts-expect-error
|
||||
async listTranslations(
|
||||
filters: FilterableTranslationProps = {},
|
||||
config: FindConfig<TranslationTypes.TranslationDTO> = {},
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<TranslationTypes.TranslationDTO[]> {
|
||||
const preparedFilters = TranslationModuleService.prepareFilters(filters)
|
||||
|
||||
const results = await this.translationService_.list(
|
||||
preparedFilters,
|
||||
config,
|
||||
sharedContext
|
||||
)
|
||||
|
||||
return await this.baseRepository_.serialize<
|
||||
TranslationTypes.TranslationDTO[]
|
||||
>(results)
|
||||
}
|
||||
|
||||
@InjectManager()
|
||||
// @ts-expect-error
|
||||
async listAndCountTranslations(
|
||||
filters: FilterableTranslationProps = {},
|
||||
config: FindConfig<TranslationTypes.TranslationDTO> = {},
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<[TranslationTypes.TranslationDTO[], number]> {
|
||||
const preparedFilters = TranslationModuleService.prepareFilters(filters)
|
||||
|
||||
const [results, count] = await this.translationService_.listAndCount(
|
||||
preparedFilters,
|
||||
config,
|
||||
sharedContext
|
||||
)
|
||||
|
||||
return [
|
||||
await this.baseRepository_.serialize<TranslationTypes.TranslationDTO[]>(
|
||||
results
|
||||
),
|
||||
count,
|
||||
]
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
createLocales(
|
||||
data: TranslationTypes.CreateLocaleDTO[],
|
||||
sharedContext?: Context
|
||||
): Promise<TranslationTypes.LocaleDTO[]>
|
||||
// @ts-expect-error
|
||||
createLocales(
|
||||
data: TranslationTypes.CreateLocaleDTO,
|
||||
sharedContext?: Context
|
||||
): Promise<TranslationTypes.LocaleDTO>
|
||||
|
||||
@InjectManager()
|
||||
@EmitEvents()
|
||||
// @ts-expect-error
|
||||
async createLocales(
|
||||
data: TranslationTypes.CreateLocaleDTO | TranslationTypes.CreateLocaleDTO[],
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<TranslationTypes.LocaleDTO | TranslationTypes.LocaleDTO[]> {
|
||||
const dataArray = Array.isArray(data) ? data : [data]
|
||||
const normalizedData = dataArray.map((locale) => ({
|
||||
...locale,
|
||||
code: normalizeLocale(locale.code),
|
||||
}))
|
||||
|
||||
const createdLocales = await this.localeService_.create(
|
||||
normalizedData,
|
||||
sharedContext
|
||||
)
|
||||
|
||||
const serialized = await this.baseRepository_.serialize<LocaleDTO[]>(
|
||||
createdLocales
|
||||
)
|
||||
return Array.isArray(data) ? serialized : serialized[0]
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
createTranslations(
|
||||
data: CreateTranslationDTO,
|
||||
sharedContext?: Context
|
||||
): Promise<TranslationTypes.TranslationDTO>
|
||||
|
||||
// @ts-expect-error
|
||||
createTranslations(
|
||||
data: CreateTranslationDTO[],
|
||||
sharedContext?: Context
|
||||
): Promise<TranslationTypes.TranslationDTO[]>
|
||||
|
||||
@InjectManager()
|
||||
@EmitEvents()
|
||||
// @ts-expect-error
|
||||
async createTranslations(
|
||||
data: CreateTranslationDTO | CreateTranslationDTO[],
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<
|
||||
TranslationTypes.TranslationDTO | TranslationTypes.TranslationDTO[]
|
||||
> {
|
||||
const dataArray = Array.isArray(data) ? data : [data]
|
||||
const normalizedData = dataArray.map((translation) => ({
|
||||
...translation,
|
||||
locale_code: normalizeLocale(translation.locale_code),
|
||||
}))
|
||||
|
||||
const createdTranslations = await this.translationService_.create(
|
||||
normalizedData,
|
||||
sharedContext
|
||||
)
|
||||
|
||||
const serialized = await this.baseRepository_.serialize<
|
||||
TranslationTypes.TranslationDTO[]
|
||||
>(createdTranslations)
|
||||
|
||||
return Array.isArray(data) ? serialized : serialized[0]
|
||||
}
|
||||
}
|
||||
12
packages/modules/translation/tsconfig.json
Normal file
12
packages/modules/translation/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../../_tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@models/*": ["./src/models/*"],
|
||||
"@services/*": ["./src/services/*"],
|
||||
"@repositories/*": ["./src/repositories/*"],
|
||||
"@types/*": ["./src/types/*"],
|
||||
"@utils/*": ["./src/utils/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user