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
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user