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:
Adrien de Peretti
2025-12-08 19:33:08 +01:00
committed by GitHub
co-authored by Nicolas Gorga Oli Juhl
parent fea3d4ec49
commit 6dc0b8bed8
130 changed files with 5649 additions and 112 deletions
@@ -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"
@@ -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
+5 -1
View File
@@ -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`
)
}
}