From 6dc0b8bed84a330c1c24f4f1331f1ed078430a47 Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Mon, 8 Dec 2025 19:33:08 +0100 Subject: [PATCH] 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 Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> --- .changeset/nine-paths-relax.md | 5 + .eslintignore | 1 + .eslintrc.js | 83 +-- CONTRIBUTING.md | 3 +- .../feature-flag/src/api/custom/route.ts | 4 + .../feature-flag/src/api/middlewares.ts | 19 + .../feature-flag/resources-with-flags.spec.ts | 17 + .../resources-without-flags.spec.ts | 10 + .../admin/price-preference.spec.ts | 21 +- .../sales-channel/admin/sales-channel.spec.ts | 2 +- integration-tests/http/package.json | 1 + .../dashboard-app/routes/get-route.map.tsx | 4 + .../dashboard/src/hooks/api/feature-flags.tsx | 14 +- .../admin/dashboard/src/hooks/api/index.ts | 1 + .../admin/dashboard/src/hooks/api/locales.tsx | 52 ++ .../src/i18n/translations/$schema.json | 28 + .../dashboard/src/i18n/translations/en.json | 7 + .../dashboard/src/i18n/translations/es.json | 7 + .../hooks/use-locales-table-columns.tsx | 29 + .../common/hooks/use-locales-table-query.tsx | 21 + .../add-locales-form/add-locales-form.tsx | 240 +++++++ .../routes/store/store-add-locales/index.ts | 1 + .../store-add-locales/store-add-locales.tsx | 29 + .../store-general-section.tsx | 24 + .../components/store-locale-section/index.ts | 1 + .../store-locale-section.tsx | 290 ++++++++ .../store/store-detail/store-detail.tsx | 4 + .../edit-store-form/edit-store-form.tsx | 53 +- packages/core/core-flows/src/index.ts | 1 + .../core/core-flows/src/translation/index.ts | 2 + .../translation/steps/create-translations.ts | 47 ++ .../translation/steps/delete-translations.ts | 36 + .../core-flows/src/translation/steps/index.ts | 4 + .../translation/steps/update-translations.ts | 113 +++ .../steps/validate-translations.ts | 57 ++ .../workflows/batch-translations.ts | 44 ++ .../workflows/create-translations.ts | 66 ++ .../workflows/delete-translations.ts | 48 ++ .../src/translation/workflows/index.ts | 4 + .../workflows/update-translations.ts | 67 ++ .../src/http/__tests__/apply-locale.spec.ts | 112 +++ .../src/http/middlewares/apply-locale.ts | 64 ++ .../framework/src/http/middlewares/index.ts | 1 + packages/core/framework/src/http/router.ts | 48 +- packages/core/framework/src/http/types.ts | 8 + .../core/framework/src/types/container.ts | 4 +- packages/core/js-sdk/src/admin/index.ts | 6 + packages/core/js-sdk/src/admin/locale.ts | 119 ++++ packages/core/types/src/bundles.ts | 1 + .../core/types/src/http/currency/common.ts | 6 +- packages/core/types/src/http/index.ts | 2 + .../types/src/http/locale/admin/entities.ts | 3 + .../core/types/src/http/locale/admin/index.ts | 3 + .../types/src/http/locale/admin/queries.ts | 17 + .../types/src/http/locale/admin/responses.ts | 17 + packages/core/types/src/http/locale/common.ts | 28 + packages/core/types/src/http/locale/index.ts | 2 + .../types/src/http/store/admin/entities.ts | 45 +- .../types/src/http/store/admin/payloads.ts | 19 +- .../src/http/translations/admin/entities.ts | 41 ++ .../src/http/translations/admin/index.ts | 3 + .../src/http/translations/admin/queries.ts | 23 + .../src/http/translations/admin/responses.ts | 35 + .../core/types/src/http/translations/index.ts | 1 + packages/core/types/src/index.ts | 1 + packages/core/types/src/store/common/store.ts | 36 + .../core/types/src/store/mutations/store.ts | 21 + packages/core/types/src/translation/common.ts | 134 ++++ packages/core/types/src/translation/index.ts | 3 + .../core/types/src/translation/mutations.ts | 144 ++++ .../core/types/src/translation/service.ts | 292 ++++++++ packages/core/utils/src/bundles.ts | 1 + .../common/__tests__/normalize-locale.spec.ts | 172 +++++ .../core/utils/src/common/define-config.ts | 3 + packages/core/utils/src/common/index.ts | 1 + .../core/utils/src/common/normalize-locale.ts | 40 ++ packages/core/utils/src/index.ts | 1 + .../core/utils/src/modules-sdk/define-link.ts | 15 +- .../core/utils/src/modules-sdk/definition.ts | 2 + .../__tests__/apply-translations.spec.ts | 363 ++++++++++ .../src/translations/apply-translations.ts | 139 ++++ packages/core/utils/src/translations/index.ts | 1 + packages/medusa-test-utils/package.json | 5 + .../src/medusa-test-runner.ts | 2 + packages/medusa/package.json | 1 + .../src/api/admin/locales/[code]/route.ts | 28 + .../src/api/admin/locales/middlewares.ts | 27 + .../src/api/admin/locales/query-config.ts | 12 + .../medusa/src/api/admin/locales/route.ts | 29 + .../src/api/admin/locales/validators.ts | 18 + .../src/api/admin/stores/query-config.ts | 2 + .../medusa/src/api/admin/stores/validators.ts | 8 + .../src/api/admin/translations/batch/route.ts | 68 ++ .../src/api/admin/translations/middlewares.ts | 32 + .../api/admin/translations/query-config.ts | 17 + .../src/api/admin/translations/route.ts | 38 + .../src/api/admin/translations/validators.ts | 50 ++ packages/medusa/src/api/middlewares.ts | 4 + .../src/api/store/products/[id]/route.ts | 12 +- .../src/api/store/products/middlewares.ts | 5 + .../medusa/src/api/store/products/route.ts | 15 + .../medusa/src/feature-flags/translation.ts | 10 + packages/medusa/src/modules/translation.ts | 6 + .../src/definitions/readonly/index.ts | 2 + .../readonly/product-translation.ts | 148 ++++ .../src/definitions/readonly/store-locale.ts | 28 + .../link-modules/src/initialize/index.ts | 7 +- .../integration-tests/__fixtures__/index.ts | 4 + .../__tests__/store-module-service.spec.ts | 73 +- .../migrations/.snapshot-medusa-store.json | 142 +++- .../src/migrations/Migration20251202184737.ts | 17 + packages/modules/store/src/models/index.ts | 1 + packages/modules/store/src/models/locale.ts | 15 + packages/modules/store/src/models/store.ts | 6 +- .../src/services/store-module-service.ts | 86 ++- packages/modules/translation/.gitignore | 6 + .../integration-tests/__fixtures__/index.ts | 16 + .../translation-module-service.spec.ts | 651 ++++++++++++++++++ packages/modules/translation/jest.config.js | 15 + .../translation/mikro-orm.config.dev.ts | 7 + packages/modules/translation/package.json | 45 ++ packages/modules/translation/src/index.ts | 10 + .../translation/src/loaders/defaults.ts | 80 +++ .../.snapshot-medusa-translation.json | 259 +++++++ .../src/migrations/Migration20251208124155.ts | 49 ++ .../modules/translation/src/models/locale.ts | 16 + .../translation/src/models/translation.ts | 30 + .../src/services/translation-module.ts | 193 ++++++ packages/modules/translation/tsconfig.json | 12 + yarn.lock | 17 + 130 files changed, 5649 insertions(+), 112 deletions(-) create mode 100644 .changeset/nine-paths-relax.md create mode 100644 integration-tests/http/__fixtures__/feature-flag/src/api/middlewares.ts create mode 100644 packages/admin/dashboard/src/hooks/api/locales.tsx create mode 100644 packages/admin/dashboard/src/routes/store/common/hooks/use-locales-table-columns.tsx create mode 100644 packages/admin/dashboard/src/routes/store/common/hooks/use-locales-table-query.tsx create mode 100644 packages/admin/dashboard/src/routes/store/store-add-locales/components/add-locales-form/add-locales-form.tsx create mode 100644 packages/admin/dashboard/src/routes/store/store-add-locales/index.ts create mode 100644 packages/admin/dashboard/src/routes/store/store-add-locales/store-add-locales.tsx create mode 100644 packages/admin/dashboard/src/routes/store/store-detail/components/store-locale-section/index.ts create mode 100644 packages/admin/dashboard/src/routes/store/store-detail/components/store-locale-section/store-locale-section.tsx create mode 100644 packages/core/core-flows/src/translation/index.ts create mode 100644 packages/core/core-flows/src/translation/steps/create-translations.ts create mode 100644 packages/core/core-flows/src/translation/steps/delete-translations.ts create mode 100644 packages/core/core-flows/src/translation/steps/index.ts create mode 100644 packages/core/core-flows/src/translation/steps/update-translations.ts create mode 100644 packages/core/core-flows/src/translation/steps/validate-translations.ts create mode 100644 packages/core/core-flows/src/translation/workflows/batch-translations.ts create mode 100644 packages/core/core-flows/src/translation/workflows/create-translations.ts create mode 100644 packages/core/core-flows/src/translation/workflows/delete-translations.ts create mode 100644 packages/core/core-flows/src/translation/workflows/index.ts create mode 100644 packages/core/core-flows/src/translation/workflows/update-translations.ts create mode 100644 packages/core/framework/src/http/__tests__/apply-locale.spec.ts create mode 100644 packages/core/framework/src/http/middlewares/apply-locale.ts create mode 100644 packages/core/js-sdk/src/admin/locale.ts create mode 100644 packages/core/types/src/http/locale/admin/entities.ts create mode 100644 packages/core/types/src/http/locale/admin/index.ts create mode 100644 packages/core/types/src/http/locale/admin/queries.ts create mode 100644 packages/core/types/src/http/locale/admin/responses.ts create mode 100644 packages/core/types/src/http/locale/common.ts create mode 100644 packages/core/types/src/http/locale/index.ts create mode 100644 packages/core/types/src/http/translations/admin/entities.ts create mode 100644 packages/core/types/src/http/translations/admin/index.ts create mode 100644 packages/core/types/src/http/translations/admin/queries.ts create mode 100644 packages/core/types/src/http/translations/admin/responses.ts create mode 100644 packages/core/types/src/http/translations/index.ts create mode 100644 packages/core/types/src/translation/common.ts create mode 100644 packages/core/types/src/translation/index.ts create mode 100644 packages/core/types/src/translation/mutations.ts create mode 100644 packages/core/types/src/translation/service.ts create mode 100644 packages/core/utils/src/common/__tests__/normalize-locale.spec.ts create mode 100644 packages/core/utils/src/common/normalize-locale.ts create mode 100644 packages/core/utils/src/translations/__tests__/apply-translations.spec.ts create mode 100644 packages/core/utils/src/translations/apply-translations.ts create mode 100644 packages/core/utils/src/translations/index.ts create mode 100644 packages/medusa/src/api/admin/locales/[code]/route.ts create mode 100644 packages/medusa/src/api/admin/locales/middlewares.ts create mode 100644 packages/medusa/src/api/admin/locales/query-config.ts create mode 100644 packages/medusa/src/api/admin/locales/route.ts create mode 100644 packages/medusa/src/api/admin/locales/validators.ts create mode 100644 packages/medusa/src/api/admin/translations/batch/route.ts create mode 100644 packages/medusa/src/api/admin/translations/middlewares.ts create mode 100644 packages/medusa/src/api/admin/translations/query-config.ts create mode 100644 packages/medusa/src/api/admin/translations/route.ts create mode 100644 packages/medusa/src/api/admin/translations/validators.ts create mode 100644 packages/medusa/src/feature-flags/translation.ts create mode 100644 packages/medusa/src/modules/translation.ts create mode 100644 packages/modules/link-modules/src/definitions/readonly/product-translation.ts create mode 100644 packages/modules/link-modules/src/definitions/readonly/store-locale.ts create mode 100644 packages/modules/store/src/migrations/Migration20251202184737.ts create mode 100644 packages/modules/store/src/models/locale.ts create mode 100644 packages/modules/translation/.gitignore create mode 100644 packages/modules/translation/integration-tests/__fixtures__/index.ts create mode 100644 packages/modules/translation/integration-tests/__tests__/translation-module-service.spec.ts create mode 100644 packages/modules/translation/jest.config.js create mode 100644 packages/modules/translation/mikro-orm.config.dev.ts create mode 100644 packages/modules/translation/package.json create mode 100644 packages/modules/translation/src/index.ts create mode 100644 packages/modules/translation/src/loaders/defaults.ts create mode 100644 packages/modules/translation/src/migrations/.snapshot-medusa-translation.json create mode 100644 packages/modules/translation/src/migrations/Migration20251208124155.ts create mode 100644 packages/modules/translation/src/models/locale.ts create mode 100644 packages/modules/translation/src/models/translation.ts create mode 100644 packages/modules/translation/src/services/translation-module.ts create mode 100644 packages/modules/translation/tsconfig.json diff --git a/.changeset/nine-paths-relax.md b/.changeset/nine-paths-relax.md new file mode 100644 index 0000000000..dcd497a0a1 --- /dev/null +++ b/.changeset/nine-paths-relax.md @@ -0,0 +1,5 @@ +--- +"@medusajs/framework": patch +--- + +fix(framework): Prevent registering express handler for disabled routes diff --git a/.eslintignore b/.eslintignore index 27fb3ea52b..e6c28cff98 100644 --- a/.eslintignore +++ b/.eslintignore @@ -26,6 +26,7 @@ packages/* !packages/create-medusa-app !packages/modules/product !packages/modules/locking +!packages/modules/translation !packages/core/orchestration !packages/core/workflows-sdk !packages/core/core-flows diff --git a/.eslintrc.js b/.eslintrc.js index bda128f8b0..986b8571b0 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -93,56 +93,57 @@ module.exports = { "./packages/design-system/toolbox/tsconfig.json", "./packages/cli/create-medusa-app/tsconfig.json", - "./packages/cli/medusa-cli/tsconfig.spec.json", - "./packages/cli/oas/medusa-oas-cli/tsconfig.spec.json", + "./packages/cli/medusa-cli/tsconfig.json", + "./packages/cli/oas/medusa-oas-cli/tsconfig.json", "./packages/core/orchestration/tsconfig.json", - "./packages/core/workflows-sdk/tsconfig.spec.json", + "./packages/core/workflows-sdk/tsconfig.json", "./packages/core/modules-sdk/tsconfig.json", "./packages/core/js-sdk/tsconfig.json", "./packages/core/types/tsconfig.json", - "./packages/core/utils/tsconfig.spec.json", + "./packages/core/utils/tsconfig.json", "./packages/core/medusa-test-utils/tsconfig.json", "./packages/modules/product/tsconfig.json", - "./packages/modules/event-bus-local/tsconfig.spec.json", - "./packages/modules/event-bus-redis/tsconfig.spec.json", - "./packages/modules/cache-redis/tsconfig.spec.json", - "./packages/modules/cache-inmemory/tsconfig.spec.json", - "./packages/modules/caching/tsconfig.spec.json", - "./packages/modules/workflow-engine-redis/tsconfig.spec.json", - "./packages/modules/workflow-engine-inmemory/tsconfig.spec.json", - "./packages/modules/fulfillment/tsconfig.spec.json", - "./packages/modules/api-key/tsconfig.spec.json", - "./packages/modules/auth/tsconfig.spec.json", - "./packages/modules/cart/tsconfig.spec.json", - "./packages/modules/currency/tsconfig.spec.json", - "./packages/modules/index/tsconfig.spec.json", - "./packages/modules/customer/tsconfig.spec.json", - "./packages/modules/file/tsconfig.spec.json", - "./packages/modules/inventory-next/tsconfig.spec.json", - "./packages/modules/stock-location-next/tsconfig.spec.json", - "./packages/modules/order/tsconfig.spec.json", - "./packages/modules/payment/tsconfig.spec.json", - "./packages/modules/pricing/tsconfig.spec.json", - "./packages/modules/promotion/tsconfig.spec.json", - "./packages/modules/region/tsconfig.spec.json", - "./packages/modules/sales-channel/tsconfig.spec.json", - "./packages/modules/store/tsconfig.spec.json", - "./packages/modules/tax/tsconfig.spec.json", - "./packages/modules/workflow-engine-inmemory/tsconfig.spec.json", - "./packages/modules/workflow-engine-redis/tsconfig.spec.json", - "./packages/modules/link-modules/tsconfig.spec.json", - "./packages/modules/user/tsconfig.spec.json", - "./packages/modules/locking/tsconfig.spec.json", + "./packages/modules/event-bus-local/tsconfig.json", + "./packages/modules/event-bus-redis/tsconfig.json", + "./packages/modules/cache-redis/tsconfig.json", + "./packages/modules/cache-inmemory/tsconfig.json", + "./packages/modules/caching/tsconfig.json", + "./packages/modules/workflow-engine-redis/tsconfig.json", + "./packages/modules/workflow-engine-inmemory/tsconfig.json", + "./packages/modules/fulfillment/tsconfig.json", + "./packages/modules/api-key/tsconfig.json", + "./packages/modules/auth/tsconfig.json", + "./packages/modules/cart/tsconfig.json", + "./packages/modules/currency/tsconfig.json", + "./packages/modules/index/tsconfig.json", + "./packages/modules/customer/tsconfig.json", + "./packages/modules/file/tsconfig.json", + "./packages/modules/inventory-next/tsconfig.json", + "./packages/modules/stock-location-next/tsconfig.json", + "./packages/modules/order/tsconfig.json", + "./packages/modules/payment/tsconfig.json", + "./packages/modules/pricing/tsconfig.json", + "./packages/modules/promotion/tsconfig.json", + "./packages/modules/region/tsconfig.json", + "./packages/modules/sales-channel/tsconfig.json", + "./packages/modules/store/tsconfig.json", + "./packages/modules/tax/tsconfig.json", + "./packages/modules/workflow-engine-inmemory/tsconfig.json", + "./packages/modules/workflow-engine-redis/tsconfig.json", + "./packages/modules/link-modules/tsconfig.json", + "./packages/modules/user/tsconfig.json", + "./packages/modules/locking/tsconfig.json", + "./packages/modules/translation/tsconfig.json", - "./packages/modules/providers/file-local/tsconfig.spec.json", - "./packages/modules/providers/file-s3/tsconfig.spec.json", - "./packages/modules/providers/fulfillment-manual/tsconfig.spec.json", - "./packages/modules/providers/payment-stripe/tsconfig.spec.json", - "./packages/modules/providers/locking-postgres/tsconfig.spec.json", - "./packages/modules/providers/locking-redis/tsconfig.spec.json", - "./packages/modules/providers/caching-redis/tsconfig.spec.json", + "./packages/modules/providers/file-local/tsconfig.json", + "./packages/modules/providers/file-s3/tsconfig.json", + "./packages/modules/providers/fulfillment-manual/tsconfig.json", + "./packages/modules/providers/payment-stripe/tsconfig.json", + "./packages/modules/providers/locking-postgres/tsconfig.json", + "./packages/modules/providers/locking-redis/tsconfig.json", + "./packages/modules/providers/caching-redis/tsconfig.json", "./packages/framework/tsconfig.json", ], diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3e23d54d4b..dfee1cd65e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -117,7 +117,8 @@ The code snippets in this section assume that your forked Medusa project and the "@medusajs/draft-order": "file:../medusa/packages/plugins/draft-order", "@medusajs/deps": "file:../medusa/packages/deps", "@medusajs/caching-redis": "file:../medusa/packages/modules/providers/caching-redis", - "@medusajs/caching": "file:../medusa/packages/modules/caching" + "@medusajs/caching": "file:../medusa/packages/modules/caching", + "@medusajs/translation": "file:../medusa/packages/modules/translation", } ``` diff --git a/integration-tests/http/__fixtures__/feature-flag/src/api/custom/route.ts b/integration-tests/http/__fixtures__/feature-flag/src/api/custom/route.ts index 757aad658d..a0afc88145 100644 --- a/integration-tests/http/__fixtures__/feature-flag/src/api/custom/route.ts +++ b/integration-tests/http/__fixtures__/feature-flag/src/api/custom/route.ts @@ -8,3 +8,7 @@ defineFileConfig({ export const GET = async (req: MedusaRequest, res: MedusaResponse) => { res.json({ message: "Custom GET" }) } + +export const POST = async (req: MedusaRequest, res: MedusaResponse) => { + res.json({ message: "Custom POST", body: req.validatedBody }) +} diff --git a/integration-tests/http/__fixtures__/feature-flag/src/api/middlewares.ts b/integration-tests/http/__fixtures__/feature-flag/src/api/middlewares.ts new file mode 100644 index 0000000000..2f6b38a473 --- /dev/null +++ b/integration-tests/http/__fixtures__/feature-flag/src/api/middlewares.ts @@ -0,0 +1,19 @@ +import { + defineMiddlewares, + validateAndTransformBody, +} from "@medusajs/framework/http" +import { z } from "zod" + +const CustomPostSchema = z.object({ + foo: z.string(), +}) + +export default defineMiddlewares({ + routes: [ + { + method: ["POST"], + matcher: "/custom", + middlewares: [validateAndTransformBody(CustomPostSchema)], + }, + ], +}) diff --git a/integration-tests/http/__tests__/feature-flag/resources-with-flags.spec.ts b/integration-tests/http/__tests__/feature-flag/resources-with-flags.spec.ts index ec3d60a765..c98dbbdbe9 100644 --- a/integration-tests/http/__tests__/feature-flag/resources-with-flags.spec.ts +++ b/integration-tests/http/__tests__/feature-flag/resources-with-flags.spec.ts @@ -43,6 +43,23 @@ medusaIntegrationTestRunner({ it("should load endpoint when feature flag is enabled", async () => { expect((await api.get("/custom")).status).toBe(200) + expect( + ( + await api.post("/custom", { + foo: "test", + }) + ).status + ).toBe(200) + }) + + it("should return 400 for POST route with invalid body when feature flag is enabled", async () => { + const response = await api + .post("/custom", { + invalid: 1, + }) + .catch((e) => e) + + expect(response.status).toBe(400) }) }) }, diff --git a/integration-tests/http/__tests__/feature-flag/resources-without-flags.spec.ts b/integration-tests/http/__tests__/feature-flag/resources-without-flags.spec.ts index 48e16592fa..1c84fa4bfb 100644 --- a/integration-tests/http/__tests__/feature-flag/resources-without-flags.spec.ts +++ b/integration-tests/http/__tests__/feature-flag/resources-without-flags.spec.ts @@ -41,6 +41,16 @@ medusaIntegrationTestRunner({ it("should not load endpoint when feature flag is disabled", async () => { expect(api.get("/custom")).rejects.toThrow() }) + + it("should return 404 (not 400) for POST route with middleware when feature flag is disabled", async () => { + const { response } = await api + .post("/custom", { + invalid: "test", + }) + .catch((e) => e) + + expect(response.status).toBe(404) + }) }) }, }) diff --git a/integration-tests/http/__tests__/price-preference/admin/price-preference.spec.ts b/integration-tests/http/__tests__/price-preference/admin/price-preference.spec.ts index c82c89dc80..18ca37b04e 100644 --- a/integration-tests/http/__tests__/price-preference/admin/price-preference.spec.ts +++ b/integration-tests/http/__tests__/price-preference/admin/price-preference.spec.ts @@ -207,13 +207,20 @@ medusaIntegrationTestRunner({ }) ) - expect(remainingPricePreferences).toEqual([ - expect.objectContaining({ - attribute: "currency_code", - value: "EUR", - is_tax_inclusive: true, - }), - ]) + expect(remainingPricePreferences).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + attribute: "currency_code", + value: "EUR", + is_tax_inclusive: true, + }), + expect.objectContaining({ + attribute: "currency_code", + value: "eur", + is_tax_inclusive: false, + }), + ]) + ) }) }) }) diff --git a/integration-tests/http/__tests__/sales-channel/admin/sales-channel.spec.ts b/integration-tests/http/__tests__/sales-channel/admin/sales-channel.spec.ts index 53b3b4ae52..c2b6dc1df5 100644 --- a/integration-tests/http/__tests__/sales-channel/admin/sales-channel.spec.ts +++ b/integration-tests/http/__tests__/sales-channel/admin/sales-channel.spec.ts @@ -67,7 +67,7 @@ medusaIntegrationTestRunner({ expect(response.status).toEqual(200) expect(response.data.sales_channels).toBeTruthy() - expect(response.data.sales_channels.length).toBe(2) + expect(response.data.sales_channels.length).toBe(3) // includes the default sales channel expect(response.data).toEqual( expect.objectContaining({ sales_channels: expect.arrayContaining([ diff --git a/integration-tests/http/package.json b/integration-tests/http/package.json index 9378631f6d..8a41805fa2 100644 --- a/integration-tests/http/package.json +++ b/integration-tests/http/package.json @@ -31,6 +31,7 @@ "@medusajs/store": "workspace:^", "@medusajs/tax": "workspace:^", "@medusajs/test-utils": "workspace:*", + "@medusajs/translation": "workspace:*", "@medusajs/user": "workspace:^", "@medusajs/utils": "workspace:^", "@medusajs/workflow-engine-inmemory": "workspace:*", diff --git a/packages/admin/dashboard/src/dashboard-app/routes/get-route.map.tsx b/packages/admin/dashboard/src/dashboard-app/routes/get-route.map.tsx index 28e591fa71..e2a7732707 100644 --- a/packages/admin/dashboard/src/dashboard-app/routes/get-route.map.tsx +++ b/packages/admin/dashboard/src/dashboard-app/routes/get-route.map.tsx @@ -1009,6 +1009,10 @@ export function getRouteMap({ path: "currencies", lazy: () => import("../../routes/store/store-add-currencies"), }, + { + path: "locales", + lazy: () => import("../../routes/store/store-add-locales"), + }, { path: "metadata/edit", lazy: () => import("../../routes/store/store-metadata"), diff --git a/packages/admin/dashboard/src/hooks/api/feature-flags.tsx b/packages/admin/dashboard/src/hooks/api/feature-flags.tsx index 8b81583719..5a422f74b0 100644 --- a/packages/admin/dashboard/src/hooks/api/feature-flags.tsx +++ b/packages/admin/dashboard/src/hooks/api/feature-flags.tsx @@ -3,6 +3,7 @@ import { sdk } from "../../lib/client" export type FeatureFlags = { view_configurations?: boolean + translation?: boolean [key: string]: boolean | undefined } @@ -10,13 +11,16 @@ export const useFeatureFlags = () => { return useQuery({ queryKey: ["admin", "feature-flags"], queryFn: async () => { - const response = await sdk.client.fetch<{ feature_flags: FeatureFlags }>("/admin/feature-flags", { - method: "GET", - }) - + const response = await sdk.client.fetch<{ feature_flags: FeatureFlags }>( + "/admin/feature-flags", + { + method: "GET", + } + ) + return response.feature_flags }, staleTime: 5 * 60 * 1000, // Cache for 5 minutes cacheTime: 10 * 60 * 1000, // Keep in cache for 10 minutes }) -} \ No newline at end of file +} diff --git a/packages/admin/dashboard/src/hooks/api/index.ts b/packages/admin/dashboard/src/hooks/api/index.ts index aed6edadeb..de53536740 100644 --- a/packages/admin/dashboard/src/hooks/api/index.ts +++ b/packages/admin/dashboard/src/hooks/api/index.ts @@ -11,6 +11,7 @@ export * from "./fulfillment-providers" export * from "./fulfillment-sets" export * from "./inventory" export * from "./invites" +export * from "./locales" export * from "./notification" export * from "./orders" export * from "./payment-collections" diff --git a/packages/admin/dashboard/src/hooks/api/locales.tsx b/packages/admin/dashboard/src/hooks/api/locales.tsx new file mode 100644 index 0000000000..b4187e5a68 --- /dev/null +++ b/packages/admin/dashboard/src/hooks/api/locales.tsx @@ -0,0 +1,52 @@ +import { FetchError } from "@medusajs/js-sdk" +import { HttpTypes } from "@medusajs/types" +import { QueryKey, UseQueryOptions, useQuery } from "@tanstack/react-query" + +import { sdk } from "../../lib/client" +import { queryKeysFactory } from "../../lib/query-key-factory" + +const LOCALES_QUERY_KEY = "locales" as const +const localesQueryKeys = queryKeysFactory(LOCALES_QUERY_KEY) + +export const useLocales = ( + query?: HttpTypes.AdminLocaleListParams, + options?: Omit< + UseQueryOptions< + HttpTypes.AdminLocaleListResponse, + FetchError, + HttpTypes.AdminLocaleListResponse, + QueryKey + >, + "queryFn" | "queryKey" + > +) => { + const { data, ...rest } = useQuery({ + queryFn: () => sdk.admin.locale.list(query), + queryKey: localesQueryKeys.list(query), + ...options, + }) + + return { ...data, ...rest } +} + +export const useLocale = ( + id: string, + query?: HttpTypes.AdminLocaleParams, + options?: Omit< + UseQueryOptions< + HttpTypes.AdminLocaleResponse, + FetchError, + HttpTypes.AdminLocaleResponse, + QueryKey + >, + "queryFn" | "queryKey" + > +) => { + const { data, ...rest } = useQuery({ + queryKey: localesQueryKeys.detail(id), + queryFn: async () => sdk.admin.locale.retrieve(id, query), + ...options, + }) + + return { ...data, ...rest } +} diff --git a/packages/admin/dashboard/src/i18n/translations/$schema.json b/packages/admin/dashboard/src/i18n/translations/$schema.json index 91df7475d9..d1da922930 100644 --- a/packages/admin/dashboard/src/i18n/translations/$schema.json +++ b/packages/admin/dashboard/src/i18n/translations/$schema.json @@ -9303,6 +9303,9 @@ "defaultCurrency": { "type": "string" }, + "defaultLocale": { + "type": "string" + }, "defaultRegion": { "type": "string" }, @@ -9321,6 +9324,9 @@ "inviteLinkTemplate": { "type": "string" }, + "locales": { + "type": "string" + }, "currencies": { "type": "string" }, @@ -9339,6 +9345,15 @@ "removeCurrencyWarning_other": { "type": "string" }, + "removeLocaleWarning_one": { + "type": "string" + }, + "removeLocaleWarning_other": { + "type": "string" + }, + "localeAlreadyAdded": { + "type": "string" + }, "currencyAlreadyAdded": { "type": "string" }, @@ -9358,20 +9373,28 @@ "update": { "type": "string" }, + "localesUpdated": { + "type": "string" + }, "currenciesUpdated": { "type": "string" }, "currenciesRemoved": { "type": "string" }, + "localesRemoved": { + "type": "string" + }, "updatedTaxInclusivitySuccessfully": { "type": "string" } }, "required": [ "update", + "localesUpdated", "currenciesUpdated", "currenciesRemoved", + "localesRemoved", "updatedTaxInclusivitySuccessfully" ], "additionalProperties": false @@ -9382,19 +9405,24 @@ "manageYourStoresDetails", "editStore", "defaultCurrency", + "defaultLocale", "defaultRegion", "defaultSalesChannel", "defaultLocation", "swapLinkTemplate", "paymentLinkTemplate", "inviteLinkTemplate", + "locales", "currencies", "addCurrencies", "enableTaxInclusivePricing", "disableTaxInclusivePricing", "removeCurrencyWarning_one", "removeCurrencyWarning_other", + "removeLocaleWarning_one", + "removeLocaleWarning_other", "currencyAlreadyAdded", + "localeAlreadyAdded", "edit", "toast" ], diff --git a/packages/admin/dashboard/src/i18n/translations/en.json b/packages/admin/dashboard/src/i18n/translations/en.json index b3eefb9a31..6c7ee59d6c 100644 --- a/packages/admin/dashboard/src/i18n/translations/en.json +++ b/packages/admin/dashboard/src/i18n/translations/en.json @@ -2497,26 +2497,33 @@ "manageYourStoresDetails": "Manage your store's details", "editStore": "Edit store", "defaultCurrency": "Default currency", + "defaultLocale": "Default locale", "defaultRegion": "Default region", "defaultSalesChannel": "Default sales channel", "defaultLocation": "Default location", "swapLinkTemplate": "Swap link template", "paymentLinkTemplate": "Payment link template", "inviteLinkTemplate": "Invite link template", + "locales": "Locales", "currencies": "Currencies", "addCurrencies": "Add currencies", "enableTaxInclusivePricing": "Enable tax inclusive pricing", "disableTaxInclusivePricing": "Disable tax inclusive pricing", "removeCurrencyWarning_one": "You are about to remove {{count}} currency from your store. Ensure that you have removed all prices using the currency before proceeding.", "removeCurrencyWarning_other": "You are about to remove {{count}} currencies from your store. Ensure that you have removed all prices using the currencies before proceeding.", + "removeLocaleWarning_one": "You are about to remove {{count}} locale from your store. Any translation using this locale will be removed.", + "removeLocaleWarning_other": "You are about to remove {{count}} locales from your store. Any translation using these locales will be removed.", "currencyAlreadyAdded": "The currency has already been added to your store.", + "localeAlreadyAdded": "The locale has already been added to your store.", "edit": { "header": "Edit Store" }, "toast": { "update": "Store successfully updated", "currenciesUpdated": "Currencies updated successfully", + "localesUpdated": "Locales updated successfully", "currenciesRemoved": "Removed currencies from the store successfully", + "localesRemoved": "Removed locales from the store successfully", "updatedTaxInclusivitySuccessfully": "Tax inclusive pricing updated successfully" } }, diff --git a/packages/admin/dashboard/src/i18n/translations/es.json b/packages/admin/dashboard/src/i18n/translations/es.json index febc099f2c..e062b78241 100644 --- a/packages/admin/dashboard/src/i18n/translations/es.json +++ b/packages/admin/dashboard/src/i18n/translations/es.json @@ -2448,26 +2448,33 @@ "manageYourStoresDetails": "Gestiona los detalles de tu tienda", "editStore": "Editar tienda", "defaultCurrency": "Moneda por defecto", + "defaultLocale": "Idioma por defecto", "defaultRegion": "Región por defecto", "defaultSalesChannel": "Canal de ventas predeterminado", "defaultLocation": "Ubicación predeterminada", "swapLinkTemplate": "Plantilla de enlace de cambio", "paymentLinkTemplate": "Plantilla de enlace de pago", "inviteLinkTemplate": "Plantilla de enlace de invitación", + "locales": "Idiomas", "currencies": "Monedas", "addCurrencies": "Agregar monedas", "enableTaxInclusivePricing": "Habilitar precios con impuestos incluidos", "disableTaxInclusivePricing": "Deshabilitar precios con impuestos incluidos", "removeCurrencyWarning_one": "Estás a punto de eliminar {{count}} moneda de tu tienda. Asegúrate de haber eliminado todos los precios que usen esta moneda antes de continuar.", "removeCurrencyWarning_other": "Estás a punto de eliminar {{count}} monedas de tu tienda. Asegúrate de haber eliminado todos los precios que usen estas monedas antes de continuar.", + "removeLocaleWarning_one": "Estás a punto de eliminar {{count}} idioma de tu tienda. Cualquier traducción que use este idioma será eliminada.", + "removeLocaleWarning_other": "Estás a punto de eliminar {{count}} idiomas de tu tienda. Cualquier traducción que use estos idiomas será eliminada.", "currencyAlreadyAdded": "La moneda ya ha sido agregada a tu tienda.", + "localeAlreadyAdded": "El idioma ya ha sido agregado a tu tienda.", "edit": { "header": "Editar Tienda" }, "toast": { "update": "Tienda actualizada exitosamente", "currenciesUpdated": "Monedas actualizadas exitosamente", + "localesUpdated": "Idiomas actualizados exitosamente", "currenciesRemoved": "Monedas eliminadas de la tienda exitosamente", + "localesRemoved": "Idiomas eliminados de la tienda exitosamente", "updatedTaxInclusivitySuccessfully": "Precios con impuestos incluidos actualizados exitosamente" } }, diff --git a/packages/admin/dashboard/src/routes/store/common/hooks/use-locales-table-columns.tsx b/packages/admin/dashboard/src/routes/store/common/hooks/use-locales-table-columns.tsx new file mode 100644 index 0000000000..73854e397f --- /dev/null +++ b/packages/admin/dashboard/src/routes/store/common/hooks/use-locales-table-columns.tsx @@ -0,0 +1,29 @@ +import { HttpTypes } from "@medusajs/types" +import { createColumnHelper } from "@tanstack/react-table" +import { useMemo } from "react" +import { useTranslation } from "react-i18next" + +import { + TextCell, + TextHeader, +} from "../../../../components/table/table-cells/common/text-cell" + +const columnHelper = createColumnHelper() + +export const useLocalesTableColumns = () => { + const { t } = useTranslation() + + return useMemo( + () => [ + columnHelper.accessor("code", { + header: () => , + cell: ({ getValue }) => , + }), + columnHelper.accessor("name", { + header: () => , + cell: ({ getValue }) => , + }), + ], + [t] + ) +} diff --git a/packages/admin/dashboard/src/routes/store/common/hooks/use-locales-table-query.tsx b/packages/admin/dashboard/src/routes/store/common/hooks/use-locales-table-query.tsx new file mode 100644 index 0000000000..6343c89250 --- /dev/null +++ b/packages/admin/dashboard/src/routes/store/common/hooks/use-locales-table-query.tsx @@ -0,0 +1,21 @@ +import { useQueryParams } from "../../../../hooks/use-query-params" + +export const useLocalesTableQuery = ({ + pageSize = 10, + prefix, +}: { + pageSize?: number + prefix?: string +}) => { + const raw = useQueryParams(["order", "q", "offset"], prefix) + + const { offset, ...rest } = raw + + const searchParams = { + limit: pageSize, + offset: offset ? parseInt(offset) : 0, + ...rest, + } + + return { searchParams, raw } +} diff --git a/packages/admin/dashboard/src/routes/store/store-add-locales/components/add-locales-form/add-locales-form.tsx b/packages/admin/dashboard/src/routes/store/store-add-locales/components/add-locales-form/add-locales-form.tsx new file mode 100644 index 0000000000..0785c02884 --- /dev/null +++ b/packages/admin/dashboard/src/routes/store/store-add-locales/components/add-locales-form/add-locales-form.tsx @@ -0,0 +1,240 @@ +import { HttpTypes } from "@medusajs/types" +import { z } from "zod" +import { useLocalesTableQuery } from "../../../common/hooks/use-locales-table-query" +import { useRouteModal } from "../../../../../components/modals/route-modal-provider" +import { useTranslation } from "react-i18next" +import { useLocales, useUpdateStore } from "../../../../../hooks/api" +import { keepPreviousData } from "@tanstack/react-query" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { + createColumnHelper, + OnChangeFn, + RowSelectionState, +} from "@tanstack/react-table" +import { useMemo, useState } from "react" +import { useDataTable } from "../../../../../hooks/use-data-table" +import { Button, Checkbox, Hint, toast, Tooltip } from "@medusajs/ui" +import { RouteFocusModal } from "../../../../../components/modals/route-focus-modal" +import { KeyboundForm } from "../../../../../components/utilities/keybound-form" +import { _DataTable } from "../../../../../components/table/data-table" +import { useLocalesTableColumns } from "../../../common/hooks/use-locales-table-columns" + +type AddLocalesFormProps = { + store: HttpTypes.AdminStore +} + +const AddLocalesSchema = z.object({ + locales: z.array(z.string()).min(1), +}) + +const PAGE_SIZE = 50 +const PREFIX = "al" + +export const AddLocalesForm = ({ store }: AddLocalesFormProps) => { + const { t } = useTranslation() + const { handleSuccess } = useRouteModal() + + const { raw, searchParams } = useLocalesTableQuery({ + pageSize: PAGE_SIZE, + prefix: PREFIX, + }) + + const { + locales, + count, + isPending: isLoading, + isError, + error, + } = useLocales(searchParams, { + placeholderData: keepPreviousData, + }) + + const form = useForm>({ + defaultValues: { + locales: [], + }, + resolver: zodResolver(AddLocalesSchema), + }) + + const [rowSelection, setRowSelection] = useState({}) + + const { setValue } = form + + const updater: OnChangeFn = (fn) => { + const updated = typeof fn === "function" ? fn(rowSelection) : fn + + const ids = Object.keys(updated) + setValue("locales", ids, { + shouldDirty: true, + shouldTouch: true, + }) + + setRowSelection(updated) + } + + const preSelectedRows = + store.supported_locales?.map((l) => l.locale_code) ?? [] + + const columns = useColumns() + + const { table } = useDataTable({ + data: locales ?? [], + columns, + count: count, + getRowId: (row) => row.code, + enableRowSelection: (row) => !preSelectedRows.includes(row.original.code), + enablePagination: true, + pageSize: PAGE_SIZE, + prefix: PREFIX, + rowSelection: { + state: rowSelection, + updater, + }, + }) + + const { mutateAsync, isPending } = useUpdateStore(store.id) + + const handleSubmit = form.handleSubmit(async (data) => { + const locales = Array.from( + new Set([...data.locales, ...preSelectedRows]) + ) as string[] + + let defaultLocale = store.supported_locales?.find( + (l) => l.is_default + )?.locale_code + + if (!locales.includes(defaultLocale ?? "")) { + defaultLocale = locales?.[0] + } + + await mutateAsync( + { + supported_locales: locales.map((l) => ({ + locale_code: l, + is_default: l === defaultLocale, + })), + }, + { + onSuccess: () => { + toast.success(t("store.toast.localesUpdated")) + handleSuccess() + }, + onError: (error) => { + toast.error(error.message) + }, + } + ) + }) + + if (isError) { + throw error + } + + return ( + + + +
+
+ {form.formState.errors.locales && ( + + {form.formState.errors.locales.message} + + )} +
+
+
+ + <_DataTable + table={table} + pageSize={PAGE_SIZE} + count={count} + columns={columns} + layout="fill" + pagination + search="autofocus" + prefix={PREFIX} + orderBy={[ + { key: "name", label: t("fields.name") }, + { key: "code", label: t("fields.code") }, + ]} + isLoading={isLoading} + queryObject={raw} + /> + + +
+ + + + +
+
+
+
+ ) +} + +const columnHelper = createColumnHelper() + +const useColumns = () => { + const { t } = useTranslation() + const base = useLocalesTableColumns() + + return useMemo( + () => [ + columnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + table.toggleAllPageRowsSelected(!!value) + } + /> + ) + }, + cell: ({ row }) => { + const isPreSelected = !row.getCanSelect() + const isSelected = row.getIsSelected() || isPreSelected + + const Component = ( + row.toggleSelected(!!value)} + onClick={(e) => { + e.stopPropagation() + }} + /> + ) + + if (isPreSelected) { + return ( + + {Component} + + ) + } + + return Component + }, + }), + ...base, + ], + [t, base] + ) +} diff --git a/packages/admin/dashboard/src/routes/store/store-add-locales/index.ts b/packages/admin/dashboard/src/routes/store/store-add-locales/index.ts new file mode 100644 index 0000000000..fe3fd5cf79 --- /dev/null +++ b/packages/admin/dashboard/src/routes/store/store-add-locales/index.ts @@ -0,0 +1 @@ +export { StoreAddLocales as Component } from "./store-add-locales" diff --git a/packages/admin/dashboard/src/routes/store/store-add-locales/store-add-locales.tsx b/packages/admin/dashboard/src/routes/store/store-add-locales/store-add-locales.tsx new file mode 100644 index 0000000000..5d98076c69 --- /dev/null +++ b/packages/admin/dashboard/src/routes/store/store-add-locales/store-add-locales.tsx @@ -0,0 +1,29 @@ +import { RouteFocusModal } from "../../../components/modals/route-focus-modal" +import { useStore } from "../../../hooks/api" +import { AddLocalesForm } from "./components/add-locales-form/add-locales-form" +import { useFeatureFlag } from "../../../providers/feature-flag-provider" +import { useNavigate } from "react-router-dom" + +export const StoreAddLocales = () => { + const isEnabled = useFeatureFlag("translation") + const navigate = useNavigate() + + if (!isEnabled) { + navigate(-1) + return null + } + + const { store, isPending, isError, error } = useStore() + + const ready = !!store && !isPending + + if (isError) { + throw error + } + + return ( + + {ready && } + + ) +} diff --git a/packages/admin/dashboard/src/routes/store/store-detail/components/store-general-section/store-general-section.tsx b/packages/admin/dashboard/src/routes/store/store-detail/components/store-general-section/store-general-section.tsx index a590dde31b..3c2140dc28 100644 --- a/packages/admin/dashboard/src/routes/store/store-detail/components/store-general-section/store-general-section.tsx +++ b/packages/admin/dashboard/src/routes/store/store-detail/components/store-general-section/store-general-section.tsx @@ -7,6 +7,7 @@ import { Link } from "react-router-dom" import { ActionMenu } from "../../../../../components/common/action-menu" import { useSalesChannel, useStockLocation } from "../../../../../hooks/api" import { useRegion } from "../../../../../hooks/api/regions" +import { useFeatureFlag } from "../../../../../providers/feature-flag-provider" type StoreGeneralSectionProps = { store: AdminStore @@ -14,12 +15,14 @@ type StoreGeneralSectionProps = { export const StoreGeneralSection = ({ store }: StoreGeneralSectionProps) => { const { t } = useTranslation() + const isTranslationsEnabled = useFeatureFlag("translation") const { region } = useRegion(store.default_region_id!, undefined, { enabled: !!store.default_region_id, }) const defaultCurrency = store.supported_currencies?.find((c) => c.is_default) + const defaultLocale = store.supported_locales?.find((l) => l.is_default) const { sales_channel } = useSalesChannel(store.default_sales_channel_id!, { enabled: !!store.default_sales_channel_id, @@ -85,6 +88,27 @@ export const StoreGeneralSection = ({ store }: StoreGeneralSectionProps) => { )} + {isTranslationsEnabled && ( +
+ + {t("store.defaultLocale")} + + {defaultLocale ? ( +
+ + {defaultLocale.locale_code?.toUpperCase()} + + + {defaultLocale.locale?.name} + +
+ ) : ( + + - + + )} +
+ )}
{t("store.defaultRegion")} diff --git a/packages/admin/dashboard/src/routes/store/store-detail/components/store-locale-section/index.ts b/packages/admin/dashboard/src/routes/store/store-detail/components/store-locale-section/index.ts new file mode 100644 index 0000000000..985846714c --- /dev/null +++ b/packages/admin/dashboard/src/routes/store/store-detail/components/store-locale-section/index.ts @@ -0,0 +1 @@ +export * from "./store-locale-section" diff --git a/packages/admin/dashboard/src/routes/store/store-detail/components/store-locale-section/store-locale-section.tsx b/packages/admin/dashboard/src/routes/store/store-detail/components/store-locale-section/store-locale-section.tsx new file mode 100644 index 0000000000..28317e201a --- /dev/null +++ b/packages/admin/dashboard/src/routes/store/store-detail/components/store-locale-section/store-locale-section.tsx @@ -0,0 +1,290 @@ +import { Plus, Trash } from "@medusajs/icons" +import { HttpTypes } from "@medusajs/types" +import { + Checkbox, + CommandBar, + Container, + Heading, + toast, + usePrompt, +} from "@medusajs/ui" +import { keepPreviousData } from "@tanstack/react-query" +import { RowSelectionState, createColumnHelper } from "@tanstack/react-table" +import { useMemo, useState } from "react" +import { useTranslation } from "react-i18next" + +import { ActionMenu } from "../../../../../components/common/action-menu" +import { _DataTable } from "../../../../../components/table/data-table" +import { useUpdateStore } from "../../../../../hooks/api/store" +import { useDataTable } from "../../../../../hooks/use-data-table" +import { useLocalesTableColumns } from "../../../common/hooks/use-locales-table-columns" +import { useLocalesTableQuery } from "../../../common/hooks/use-locales-table-query" +import { useLocales } from "../../../../../hooks/api" + +type StoreLocaleSectionProps = { + store: HttpTypes.AdminStore +} + +const PAGE_SIZE = 10 + +export const StoreLocaleSection = ({ store }: StoreLocaleSectionProps) => { + const [rowSelection, setRowSelection] = useState({}) + + const { searchParams, raw } = useLocalesTableQuery({ pageSize: PAGE_SIZE }) + + const { locales, count, isPending, isError, error } = useLocales( + { + code: store.supported_locales?.map((l) => l.locale_code), + ...searchParams, + }, + { + placeholderData: keepPreviousData, + enabled: !!store.supported_locales?.length, + } + ) + + const columns = useColumns() + + const { table } = useDataTable({ + data: locales ?? [], + columns, + count: count, + getRowId: (row) => row.code, + rowSelection: { + state: rowSelection, + updater: setRowSelection, + }, + enablePagination: true, + enableRowSelection: true, + pageSize: PAGE_SIZE, + meta: { + storeId: store.id, + supportedLocales: store.supported_locales, + defaultLocaleCode: store.supported_locales?.find((l) => l.is_default) + ?.locale_code, + }, + }) + + const { mutateAsync } = useUpdateStore(store.id) + const { t } = useTranslation() + const prompt = usePrompt() + + const handleDeleteLocales = async () => { + const ids = Object.keys(rowSelection) + + const result = await prompt({ + title: t("general.areYouSure"), + description: t("store.removeLocaleWarning", { + count: ids.length, + }), + confirmText: t("actions.remove"), + cancelText: t("actions.cancel"), + }) + + if (!result) { + return + } + + await mutateAsync( + { + supported_locales: + store.supported_locales?.filter( + (l) => !ids.includes(l.locale_code) + ) ?? [], + }, + { + onSuccess: () => { + setRowSelection({}) + toast.success(t("store.toast.localesRemoved")) + }, + onError: (e) => { + toast.error(e.message) + }, + } + ) + } + + if (isError) { + throw error + } + + const isLoading = isPending + + return ( + +
+ {t("store.locales")} + , + label: t("actions.add"), + to: "locales", + }, + ], + }, + ]} + /> +
+ <_DataTable + orderBy={[ + { key: "name", label: t("fields.name") }, + { key: "code", label: t("fields.code") }, + ]} + search + pagination + table={table} + pageSize={PAGE_SIZE} + columns={columns} + count={!store.supported_locales?.length ? 0 : count} + isLoading={!store.supported_locales?.length ? false : isLoading} + queryObject={raw} + /> + + + + {t("general.countSelected", { + count: Object.keys(rowSelection).length, + })} + + + + + +
+ ) +} + +const LocaleActions = ({ + storeId, + locale, + supportedLocales, + defaultLocaleCode, +}: { + storeId: string + locale: HttpTypes.AdminLocale + supportedLocales: HttpTypes.AdminStoreLocale[] + defaultLocaleCode: string +}) => { + const { mutateAsync } = useUpdateStore(storeId) + const { t } = useTranslation() + const prompt = usePrompt() + + const handleRemove = async () => { + const result = await prompt({ + title: t("general.areYouSure"), + description: t("store.removeLocaleWarning", { + count: 1, + }), + verificationInstruction: t("general.typeToConfirm"), + verificationText: locale.name, + confirmText: t("actions.remove"), + cancelText: t("actions.cancel"), + }) + + if (!result) { + return + } + + await mutateAsync( + { + supported_locales: supportedLocales.filter( + (l) => l.locale_code !== locale.code + ), + }, + { + onSuccess: () => { + toast.success(t("store.toast.localesRemoved")) + }, + onError: (e) => { + toast.error(e.message) + }, + } + ) + } + + return ( + , + label: t("actions.remove"), + onClick: handleRemove, + disabled: locale.code === defaultLocaleCode, + }, + ], + }, + ]} + /> + ) +} + +const columnHelper = createColumnHelper() + +const useColumns = () => { + const base = useLocalesTableColumns() + const { t } = useTranslation() + + return useMemo( + () => [ + columnHelper.display({ + id: "select", + header: ({ table }) => { + return ( + + table.toggleAllPageRowsSelected(!!value) + } + /> + ) + }, + cell: ({ row }) => { + return ( + row.toggleSelected(!!value)} + onClick={(e) => { + e.stopPropagation() + }} + /> + ) + }, + }), + ...base, + columnHelper.display({ + id: "actions", + cell: ({ row, table }) => { + const { supportedLocales, storeId, defaultLocaleCode } = table.options + .meta as { + defaultLocaleCode: string + supportedLocales: HttpTypes.AdminStoreLocale[] + storeId: string + } + + return ( + + ) + }, + }), + ], + [base, t] + ) +} diff --git a/packages/admin/dashboard/src/routes/store/store-detail/store-detail.tsx b/packages/admin/dashboard/src/routes/store/store-detail/store-detail.tsx index fd75a4b524..0d0890a53b 100644 --- a/packages/admin/dashboard/src/routes/store/store-detail/store-detail.tsx +++ b/packages/admin/dashboard/src/routes/store/store-detail/store-detail.tsx @@ -8,9 +8,12 @@ import { SingleColumnPageSkeleton } from "../../../components/common/skeleton" import { SingleColumnPage } from "../../../components/layout/pages" import { useExtension } from "../../../providers/extension-provider" import { StoreCurrencySection } from "./components/store-currency-section" +import { StoreLocaleSection } from "./components/store-locale-section" +import { useFeatureFlag } from "../../../providers/feature-flag-provider" export const StoreDetail = () => { const initialData = useLoaderData() as Awaited> + const isTranslationsEnabled = useFeatureFlag("translation") const { store, isPending, isError, error } = useStore(undefined, { initialData, @@ -39,6 +42,7 @@ export const StoreDetail = () => { > + {isTranslationsEnabled && } ) } diff --git a/packages/admin/dashboard/src/routes/store/store-edit/components/edit-store-form/edit-store-form.tsx b/packages/admin/dashboard/src/routes/store/store-edit/components/edit-store-form/edit-store-form.tsx index 8d1359be29..bd1bc317bf 100644 --- a/packages/admin/dashboard/src/routes/store/store-edit/components/edit-store-form/edit-store-form.tsx +++ b/packages/admin/dashboard/src/routes/store/store-edit/components/edit-store-form/edit-store-form.tsx @@ -13,6 +13,7 @@ import { useUpdateStore } from "../../../../../hooks/api/store" import { useComboboxData } from "../../../../../hooks/use-combobox-data" import { sdk } from "../../../../../lib/client" import { useDocumentDirection } from "../../../../../hooks/use-document-direction" +import { useFeatureFlag } from "../../../../../providers/feature-flag-provider" type EditStoreFormProps = { store: HttpTypes.AdminStore @@ -21,6 +22,7 @@ type EditStoreFormProps = { const EditStoreSchema = z.object({ name: z.string().min(1), default_currency_code: z.string().optional(), + default_locale_code: z.string().optional(), default_region_id: z.string().optional(), default_sales_channel_id: z.string().optional(), default_location_id: z.string().optional(), @@ -28,12 +30,16 @@ const EditStoreSchema = z.object({ export const EditStoreForm = ({ store }: EditStoreFormProps) => { const { t } = useTranslation() + const isTranslationsEnabled = useFeatureFlag("translation") const { handleSuccess } = useRouteModal() - const direction = useDocumentDirection() + const direction = useDocumentDirection() const form = useForm>({ defaultValues: { name: store.name, default_region_id: store.default_region_id || undefined, + default_locale_code: + store.supported_locales?.find((l) => l.is_default)?.locale_code || + undefined, default_currency_code: store.supported_currencies?.find((c) => c.is_default)?.currency_code || undefined, @@ -73,10 +79,14 @@ export const EditStoreForm = ({ store }: EditStoreFormProps) => { }) const handleSubmit = form.handleSubmit(async (values) => { - const { default_currency_code, ...rest } = values + const { default_currency_code, default_locale_code, ...rest } = values const normalizedMutation: HttpTypes.AdminUpdateStore = { ...rest, + supported_locales: store.supported_locales?.map((l) => ({ + ...l, + is_default: l.locale_code === default_locale_code, + })), supported_currencies: store.supported_currencies?.map((c) => ({ ...c, is_default: c.currency_code === default_currency_code, @@ -95,7 +105,10 @@ export const EditStoreForm = ({ store }: EditStoreFormProps) => { return ( - +
{ ) }} /> + {isTranslationsEnabled && ( + { + return ( + + {t("store.defaultLocale")} + + + + + ) + }} + /> + )} { + const service = container.resolve( + Modules.TRANSLATION + ) + + const created = await service.createTranslations(data) + + return new StepResponse( + created, + created.map((translation) => translation.id) + ) + }, + async (createdIds, { container }) => { + if (!createdIds?.length) { + return + } + + const service = container.resolve( + Modules.TRANSLATION + ) + + await service.deleteTranslations(createdIds) + } +) diff --git a/packages/core/core-flows/src/translation/steps/delete-translations.ts b/packages/core/core-flows/src/translation/steps/delete-translations.ts new file mode 100644 index 0000000000..64138fee69 --- /dev/null +++ b/packages/core/core-flows/src/translation/steps/delete-translations.ts @@ -0,0 +1,36 @@ +import { ITranslationModuleService } from "@medusajs/framework/types" +import { Modules } from "@medusajs/framework/utils" +import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" + +/** + * The IDs of the translations to delete. + */ +export type DeleteTranslationsStepInput = string[] + +export const deleteTranslationsStepId = "delete-translations" +/** + * This step deletes one or more translations. + */ +export const deleteTranslationsStep = createStep( + deleteTranslationsStepId, + async (ids: DeleteTranslationsStepInput, { container }) => { + const service = container.resolve( + Modules.TRANSLATION + ) + + await service.softDeleteTranslations(ids) + + return new StepResponse(void 0, ids) + }, + async (prevIds, { container }) => { + if (!prevIds?.length) { + return + } + + const service = container.resolve( + Modules.TRANSLATION + ) + + await service.restoreTranslations(prevIds) + } +) diff --git a/packages/core/core-flows/src/translation/steps/index.ts b/packages/core/core-flows/src/translation/steps/index.ts new file mode 100644 index 0000000000..cc9acf5555 --- /dev/null +++ b/packages/core/core-flows/src/translation/steps/index.ts @@ -0,0 +1,4 @@ +export * from "./create-translations" +export * from "./delete-translations" +export * from "./update-translations" +export * from "./validate-translations" diff --git a/packages/core/core-flows/src/translation/steps/update-translations.ts b/packages/core/core-flows/src/translation/steps/update-translations.ts new file mode 100644 index 0000000000..3183e556cf --- /dev/null +++ b/packages/core/core-flows/src/translation/steps/update-translations.ts @@ -0,0 +1,113 @@ +import { + FilterableTranslationProps, + ITranslationModuleService, + UpdateTranslationDTO, +} from "@medusajs/framework/types" +import { + MedusaError, + MedusaErrorTypes, + Modules, +} from "@medusajs/framework/utils" +import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk" + +/** + * The data to update translations. + */ +export type UpdateTranslationsStepInput = + | { + /** + * The filters to select the translations to update. + */ + selector: FilterableTranslationProps + /** + * The data to update in the translations. + */ + update: UpdateTranslationDTO + } + | { + translations: UpdateTranslationDTO[] + } + +export const updateTranslationsStepId = "update-translations" +/** + * This step updates translations matching the specified filters. + * + * @example + * const data = updateTranslationsStep({ + * selector: { + * reference_id: "prod_123", + * locale_code: "fr-FR" + * }, + * update: { + * translations: { title: "Nouveau titre" } + * } + * }) + */ +export const updateTranslationsStep = createStep( + updateTranslationsStepId, + async (data: UpdateTranslationsStepInput, { container }) => { + const service = container.resolve( + Modules.TRANSLATION + ) + + if ("translations" in data) { + if (data.translations.some((t) => !t.id)) { + throw new MedusaError( + MedusaErrorTypes.INVALID_DATA, + "Translation ID is required when doing a batch update of translations" + ) + } + + if (!data.translations.length) { + return new StepResponse([], []) + } + + const prevData = await service.listTranslations({ + id: data.translations.map((t) => t.id) as string[], + }) + + const translations = await service.updateTranslations(data.translations) + return new StepResponse(translations, prevData) + } + + const prevData = await service.listTranslations(data.selector, { + select: [ + "id", + "reference_id", + "reference", + "locale_code", + "translations", + ], + }) + + if (Object.keys(data.update).length === 0) { + return new StepResponse(prevData, []) + } + + const translations = await service.updateTranslations({ + selector: data.selector, + data: data.update, + }) + + return new StepResponse(translations, prevData) + }, + async (prevData, { container }) => { + if (!prevData?.length) { + return + } + + const service = container.resolve( + Modules.TRANSLATION + ) + + await service.updateTranslations( + prevData.map((t) => ({ + id: t.id, + reference_id: t.reference_id, + reference: t.reference, + locale_code: t.locale_code, + translations: t.translations, + })) + ) + } +) diff --git a/packages/core/core-flows/src/translation/steps/validate-translations.ts b/packages/core/core-flows/src/translation/steps/validate-translations.ts new file mode 100644 index 0000000000..4473e3c66d --- /dev/null +++ b/packages/core/core-flows/src/translation/steps/validate-translations.ts @@ -0,0 +1,57 @@ +import { + ContainerRegistrationKeys, + MedusaError, + MedusaErrorTypes, +} from "@medusajs/framework/utils" +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" +import { CreateTranslationDTO, UpdateTranslationDTO } from "@medusajs/types" + +export const validateTranslationsStepId = "validate-translations" + +export type ValidateTranslationsStepInput = + | CreateTranslationDTO[] + | CreateTranslationDTO + | UpdateTranslationDTO[] + | UpdateTranslationDTO + +// TODO: Do we want to validate anything else here? +export const validateTranslationsStep = createStep( + validateTranslationsStepId, + async (data: ValidateTranslationsStepInput, { container }) => { + const query = container.resolve(ContainerRegistrationKeys.QUERY) + const { + data: [store], + } = await query.graph( + { + entity: "store", + fields: ["supported_locales.*"], + pagination: { + take: 1, + }, + }, + { + cache: { enable: true }, + } + ) + + const enabledLocales = (store.supported_locales ?? []).map( + (locale) => locale.locale_code + ) + const normalizedInput = Array.isArray(data) ? data : [data] + + const unsupportedLocales = normalizedInput + .filter((translation) => Boolean(translation.locale_code)) + .map((translation) => translation.locale_code) + .filter((locale) => !enabledLocales.includes(locale ?? "")) + + if (unsupportedLocales.length) { + throw new MedusaError( + MedusaErrorTypes.INVALID_DATA, + `The following locales are not supported in the store: ${unsupportedLocales.join( + ", " + )}` + ) + } + return new StepResponse(void 0) + } +) diff --git a/packages/core/core-flows/src/translation/workflows/batch-translations.ts b/packages/core/core-flows/src/translation/workflows/batch-translations.ts new file mode 100644 index 0000000000..bd5416afd5 --- /dev/null +++ b/packages/core/core-flows/src/translation/workflows/batch-translations.ts @@ -0,0 +1,44 @@ +import { + createWorkflow, + parallelize, + transform, + WorkflowResponse, +} from "@medusajs/framework/workflows-sdk" +import { CreateTranslationDTO, UpdateTranslationDTO } from "@medusajs/types" +import { createTranslationsWorkflow } from "./create-translations" +import { deleteTranslationsWorkflow } from "./delete-translations" +import { updateTranslationsWorkflow } from "./update-translations" + +export const batchTranslationsWorkflowId = "batch-translations" + +export type BatchTranslationsWorkflowInput = { + create: CreateTranslationDTO[] + update: UpdateTranslationDTO[] + delete: string[] +} +export const batchTranslationsWorkflow = createWorkflow( + batchTranslationsWorkflowId, + (input: BatchTranslationsWorkflowInput) => { + const [created, updated, deleted] = parallelize( + createTranslationsWorkflow.runAsStep({ + input: { + translations: input.create, + }, + }), + updateTranslationsWorkflow.runAsStep({ + input: { + translations: input.update, + }, + }), + deleteTranslationsWorkflow.runAsStep({ + input: { + ids: input.delete, + }, + }) + ) + + return new WorkflowResponse( + transform({ created, updated, deleted }, (result) => result) + ) + } +) diff --git a/packages/core/core-flows/src/translation/workflows/create-translations.ts b/packages/core/core-flows/src/translation/workflows/create-translations.ts new file mode 100644 index 0000000000..97e642c4d9 --- /dev/null +++ b/packages/core/core-flows/src/translation/workflows/create-translations.ts @@ -0,0 +1,66 @@ +import { CreateTranslationDTO, TranslationDTO } from "@medusajs/framework/types" +import { + WorkflowData, + WorkflowResponse, + createWorkflow, + transform, +} from "@medusajs/framework/workflows-sdk" +import { emitEventStep } from "../../common/steps/emit-event" +import { createTranslationsStep } from "../steps" +import { validateTranslationsStep } from "../steps" + +export type CreateTranslationsWorkflowInput = { + translations: CreateTranslationDTO[] +} + +export const createTranslationsWorkflowId = "create-translations" +/** + * This workflow creates one or more translations. + * + * You can use this workflow within your own customizations or custom workflows, allowing you + * to create translations in your custom flows. + * + * @example + * const { result } = await createTranslationsWorkflow(container) + * .run({ + * input: { + * translations: [ + * { + * reference_id: "prod_123", + * reference: "product", + * locale_code: "fr-FR", + * translations: { title: "Produit", description: "Description du produit" } + * } + * ] + * } + * }) + * + * @summary + * + * Create one or more translations. + */ +export const createTranslationsWorkflow = createWorkflow( + createTranslationsWorkflowId, + ( + input: WorkflowData + ): WorkflowResponse => { + validateTranslationsStep(input.translations) + const translations = createTranslationsStep(input.translations) + + const translationIdEvents = transform( + { translations }, + ({ translations }) => { + return translations.map((t) => { + return { id: t.id } + }) + } + ) + + emitEventStep({ + eventName: "translation.created", + data: translationIdEvents, + }) + + return new WorkflowResponse(translations) + } +) diff --git a/packages/core/core-flows/src/translation/workflows/delete-translations.ts b/packages/core/core-flows/src/translation/workflows/delete-translations.ts new file mode 100644 index 0000000000..50ae303af8 --- /dev/null +++ b/packages/core/core-flows/src/translation/workflows/delete-translations.ts @@ -0,0 +1,48 @@ +import { + WorkflowData, + createWorkflow, + transform, +} from "@medusajs/framework/workflows-sdk" +import { emitEventStep } from "../../common/steps/emit-event" +import { deleteTranslationsStep } from "../steps" + +export type DeleteTranslationsWorkflowInput = { ids: string[] } + +export const deleteTranslationsWorkflowId = "delete-translations" +/** + * This workflow deletes one or more translations. + * + * You can use this workflow within your own customizations or custom workflows, allowing you + * to delete translations in your custom flows. + * + * @example + * const { result } = await deleteTranslationsWorkflow(container) + * .run({ + * input: { + * ids: ["trans_123"] + * } + * }) + * + * @summary + * + * Delete one or more translations. + */ +export const deleteTranslationsWorkflow = createWorkflow( + deleteTranslationsWorkflowId, + ( + input: WorkflowData + ): WorkflowData => { + deleteTranslationsStep(input.ids) + + const translationIdEvents = transform({ input }, ({ input }) => { + return input.ids?.map((id) => { + return { id } + }) + }) + + emitEventStep({ + eventName: "translation.deleted", + data: translationIdEvents, + }) + } +) diff --git a/packages/core/core-flows/src/translation/workflows/index.ts b/packages/core/core-flows/src/translation/workflows/index.ts new file mode 100644 index 0000000000..c2c3830095 --- /dev/null +++ b/packages/core/core-flows/src/translation/workflows/index.ts @@ -0,0 +1,4 @@ +export * from "./create-translations" +export * from "./delete-translations" +export * from "./update-translations" +export * from "./batch-translations" diff --git a/packages/core/core-flows/src/translation/workflows/update-translations.ts b/packages/core/core-flows/src/translation/workflows/update-translations.ts new file mode 100644 index 0000000000..bcedc0c80d --- /dev/null +++ b/packages/core/core-flows/src/translation/workflows/update-translations.ts @@ -0,0 +1,67 @@ +import { TranslationDTO } from "@medusajs/framework/types" +import { + createWorkflow, + transform, + WorkflowData, + WorkflowResponse, +} from "@medusajs/framework/workflows-sdk" +import { emitEventStep } from "../../common/steps/emit-event" +import { updateTranslationsStep, UpdateTranslationsStepInput } from "../steps" +import { validateTranslationsStep } from "../steps" + +export type UpdateTranslationsWorkflowInput = UpdateTranslationsStepInput + +export const updateTranslationsWorkflowId = "update-translations" +/** + * This workflow updates translations matching the specified filters. + * + * You can use this workflow within your own customizations or custom workflows, allowing you + * to update translations in your custom flows. + * + * @example + * const { result } = await updateTranslationsWorkflow(container) + * .run({ + * input: { + * selector: { + * reference_id: "prod_123", + * locale_code: "fr-FR" + * }, + * update: { + * translations: { title: "Nouveau titre" } + * } + * } + * }) + * + * @summary + * + * Update translations. + */ +export const updateTranslationsWorkflow = createWorkflow( + updateTranslationsWorkflowId, + ( + input: WorkflowData + ): WorkflowResponse => { + const validateInput = transform(input, (input) => { + return "translations" in input ? input.translations : [input.update] + }) + validateTranslationsStep(validateInput) + + const translations = updateTranslationsStep(input) + + const translationIdEvents = transform( + { translations }, + ({ translations }) => { + return translations?.map((t) => { + return { id: t.id } + }) + } + ) + + emitEventStep({ + eventName: "translation.updated", + data: translationIdEvents, + }) + + return new WorkflowResponse(translations) + } +) diff --git a/packages/core/framework/src/http/__tests__/apply-locale.spec.ts b/packages/core/framework/src/http/__tests__/apply-locale.spec.ts new file mode 100644 index 0000000000..9b260f067d --- /dev/null +++ b/packages/core/framework/src/http/__tests__/apply-locale.spec.ts @@ -0,0 +1,112 @@ +import { MedusaRequest, MedusaResponse } from "../types" +import { applyLocale } from "../middlewares/apply-locale" +import { MedusaContainer } from "@medusajs/types" + +describe("applyLocale", () => { + let mockRequest: Partial + let mockResponse: MedusaResponse + let nextFunction: jest.Mock + + beforeEach(() => { + mockRequest = { + query: {}, + get: jest.fn(), + scope: { + resolve: jest.fn().mockReturnValue({ + graph: jest.fn().mockResolvedValue({ + data: [{ supported_locales: [{ locale_code: "en-US" }] }], + }), + }), + } as unknown as MedusaContainer, + } + mockResponse = {} as MedusaResponse + nextFunction = jest.fn() + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it("should set locale from query parameter", async () => { + mockRequest.query = { locale: "en-US" } + + await applyLocale(mockRequest as MedusaRequest, mockResponse, nextFunction) + + expect(mockRequest.locale).toBe("en-US") + expect(nextFunction).toHaveBeenCalledTimes(1) + }) + + it("should set locale from Content-Language header when query param is not present", async () => { + mockRequest.query = {} + ;(mockRequest.get as jest.Mock).mockImplementation((header: string) => { + if (header === "content-language") { + return "fr-FR" + } + return undefined + }) + + await applyLocale(mockRequest as MedusaRequest, mockResponse, nextFunction) + + expect(mockRequest.locale).toBe("fr-FR") + expect(nextFunction).toHaveBeenCalledTimes(1) + }) + + it("should prioritize query parameter over Content-Language header", async () => { + mockRequest.query = { locale: "de-DE" } + ;(mockRequest.get as jest.Mock).mockImplementation((header: string) => { + if (header === "content-language") { + return "fr-FR" + } + return undefined + }) + + await applyLocale(mockRequest as MedusaRequest, mockResponse, nextFunction) + + expect(mockRequest.locale).toBe("de-DE") + expect(mockRequest.get).not.toHaveBeenCalled() + expect(nextFunction).toHaveBeenCalledTimes(1) + }) + + it("should not set locale when neither query param nor header is present", async () => { + mockRequest.query = {} + ;(mockRequest.get as jest.Mock).mockReturnValue(undefined) + + await applyLocale(mockRequest as MedusaRequest, mockResponse, nextFunction) + + expect(mockRequest.locale).toBeUndefined() + expect(nextFunction).toHaveBeenCalledTimes(1) + }) + + it("should handle empty string in query parameter", async () => { + mockRequest.query = { locale: "" } + ;(mockRequest.get as jest.Mock).mockImplementation((header: string) => { + if (header === "content-language") { + return "es-ES" + } + return undefined + }) + + await applyLocale(mockRequest as MedusaRequest, mockResponse, nextFunction) + + // Empty string is falsy, so it should fall back to header + expect(mockRequest.locale).toBe("es-ES") + expect(nextFunction).toHaveBeenCalledTimes(1) + }) + + it("should handle various locale formats", async () => { + const locales = ["en", "en-US", "zh-Hans-CN", "pt-BR"] + + for (const locale of locales) { + mockRequest.query = { locale } + mockRequest.locale = undefined + + await applyLocale( + mockRequest as MedusaRequest, + mockResponse, + nextFunction + ) + + expect(mockRequest.locale).toBe(locale) + } + }) +}) diff --git a/packages/core/framework/src/http/middlewares/apply-locale.ts b/packages/core/framework/src/http/middlewares/apply-locale.ts new file mode 100644 index 0000000000..d30865b3f0 --- /dev/null +++ b/packages/core/framework/src/http/middlewares/apply-locale.ts @@ -0,0 +1,64 @@ +import { ContainerRegistrationKeys, normalizeLocale } from "@medusajs/utils" +import type { + MedusaNextFunction, + MedusaRequest, + MedusaResponse, +} from "../types" + +const CONTENT_LANGUAGE_HEADER = "content-language" + +/** + * Middleware that resolves the locale for the current request. + * + * Resolution order: + * 1. Query parameter `?locale=en-US` + * 2. Content-Language header + * + * The resolved locale is set on `req.locale`. + */ +export async function applyLocale( + req: MedusaRequest, + _: MedusaResponse, + next: MedusaNextFunction +) { + // 1. Check query parameter + const queryLocale = req.query.locale as string | undefined + if (queryLocale) { + req.locale = normalizeLocale(queryLocale) + return next() + } + + // 2. Check Content-Language header + const headerLocale = req.get(CONTENT_LANGUAGE_HEADER) + if (headerLocale) { + req.locale = normalizeLocale(headerLocale) + return next() + } + + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + const { + data: [store], + } = await query.graph( + { + entity: "store", + fields: ["id", "supported_locales"], + pagination: { + take: 1, + }, + }, + { + cache: { + enable: true, + }, + } + ) + + if (store?.supported_locales?.length) { + req.locale = store.supported_locales.find( + (locale) => locale.is_default + )?.locale_code + return next() + } + + return next() +} diff --git a/packages/core/framework/src/http/middlewares/index.ts b/packages/core/framework/src/http/middlewares/index.ts index 2c3e6aaf61..55983af8b4 100644 --- a/packages/core/framework/src/http/middlewares/index.ts +++ b/packages/core/framework/src/http/middlewares/index.ts @@ -3,5 +3,6 @@ export * from "./error-handler" export * from "./exception-formatter" export * from "./apply-default-filters" export * from "./apply-params-as-filters" +export * from "./apply-locale" export * from "./clear-filters-by-key" export * from "./set-context" diff --git a/packages/core/framework/src/http/router.ts b/packages/core/framework/src/http/router.ts index 0037539b2e..177925570b 100644 --- a/packages/core/framework/src/http/router.ts +++ b/packages/core/framework/src/http/router.ts @@ -1,4 +1,9 @@ -import { ContainerRegistrationKeys, parseCorsOrigins, FeatureFlag } from "@medusajs/utils" +import { + ContainerRegistrationKeys, + FeatureFlag, + isFileDisabled, + parseCorsOrigins, +} from "@medusajs/utils" import cors, { CorsOptions } from "cors" import type { ErrorRequestHandler, @@ -20,6 +25,7 @@ import type { } from "./types" import { Logger, MedusaContainer } from "@medusajs/types" +import { join } from "path" import { configManager } from "../config" import { MiddlewareFileLoader } from "./middleware-file-loader" import { authenticate, AuthType } from "./middlewares" @@ -109,6 +115,38 @@ export class ApiLoader { } } + /** + * Checks if a route file is disabled for a given matcher and method + * by trying to find the corresponding route file path + */ + #isRouteFileDisabled(matcher: string): boolean { + const routePathSegments = matcher + .split("/") + .filter(Boolean) + .map((segment) => { + if (segment.startsWith(":")) { + return `[${segment.slice(1)}]` + } + return segment + }) + + for (const sourceDir of this.#sourceDirs) { + for (const ext of [".ts", ".js"]) { + const routeFilePath = join( + sourceDir, + ...routePathSegments, + `route${ext}` + ) + + if (isFileDisabled(routeFilePath)) { + return true + } + } + } + + return false + } + /** * Registers a middleware or a route handler with Express */ @@ -145,6 +183,14 @@ export class ApiLoader { ? route.methods : [route.methods] methods.forEach((method) => { + const isDisabled = this.#isRouteFileDisabled(route.matcher) + if (isDisabled) { + this.#logger.debug( + `skipping disabled route middleware registration for ${method} ${route.matcher}` + ) + return + } + this.#logger.debug( `registering route middleware ${method} ${route.matcher}` ) diff --git a/packages/core/framework/src/http/types.ts b/packages/core/framework/src/http/types.ts index 6a0412f469..64f952b627 100644 --- a/packages/core/framework/src/http/types.ts +++ b/packages/core/framework/src/http/types.ts @@ -183,6 +183,14 @@ export interface MedusaRequest< * requests that allows for additional_data */ additionalDataValidator?: ZodOptional>> + + /** + * The locale for the current request, resolved from: + * 1. Query parameter `?locale=` + * 2. Content-Language header + * 3. Store's default locale + */ + locale?: string } export interface AuthContext { diff --git a/packages/core/framework/src/types/container.ts b/packages/core/framework/src/types/container.ts index 22e13d21d9..aa5569af55 100644 --- a/packages/core/framework/src/types/container.ts +++ b/packages/core/framework/src/types/container.ts @@ -27,6 +27,7 @@ import { IStockLocationService, IStoreModuleService, ITaxModuleService, + ITranslationModuleService, IUserModuleService, IWorkflowEngineService, Logger, @@ -34,8 +35,8 @@ import { RemoteQueryFunction, } from "@medusajs/types" import { ContainerRegistrationKeys, Modules } from "@medusajs/utils" -import { Knex } from "../deps/mikro-orm-knex" import { AwilixContainer, ResolveOptions } from "../deps/awilix" +import { Knex } from "../deps/mikro-orm-knex" declare module "@medusajs/types" { export interface ModuleImplementations { @@ -80,6 +81,7 @@ declare module "@medusajs/types" { [Modules.SETTINGS]: ISettingsModuleService [Modules.CACHING]: ICachingModuleService [Modules.INDEX]: IIndexService + [Modules.TRANSLATION]: ITranslationModuleService } } diff --git a/packages/core/js-sdk/src/admin/index.ts b/packages/core/js-sdk/src/admin/index.ts index bc300e5571..d482d65b18 100644 --- a/packages/core/js-sdk/src/admin/index.ts +++ b/packages/core/js-sdk/src/admin/index.ts @@ -45,6 +45,7 @@ import { User } from "./user" import { Views } from "./views" import { WorkflowExecution } from "./workflow-execution" import { ShippingOptionType } from "./shipping-option-type" +import { Locale } from "./locale" export class Admin { /** @@ -179,6 +180,10 @@ export class Admin { * @tags currency */ public currency: Currency + /** + * @tags locale + */ + public locale: Locale /** * @tags payment */ @@ -265,6 +270,7 @@ export class Admin { this.store = new Store(client) this.productTag = new ProductTag(client) this.user = new User(client) + this.locale = new Locale(client) this.currency = new Currency(client) this.payment = new Payment(client) this.productVariant = new ProductVariant(client) diff --git a/packages/core/js-sdk/src/admin/locale.ts b/packages/core/js-sdk/src/admin/locale.ts new file mode 100644 index 0000000000..758b35db76 --- /dev/null +++ b/packages/core/js-sdk/src/admin/locale.ts @@ -0,0 +1,119 @@ +import { HttpTypes } from "@medusajs/types" +import { Client } from "../client" +import { ClientHeaders } from "../types" + +export class Locale { + /** + * @ignore + */ + private client: Client + /** + * @ignore + */ + constructor(client: Client) { + this.client = client + } + + /** + * This method retrieves a paginated list of locales. It sends a request to the + * [List Locales](https://docs.medusajs.com/api/admin#locales_getlocales) + * API route. + * + * @param query - Filters and pagination configurations. + * @param headers - Headers to pass in the request. + * @returns The paginated list of locales. + * + * @example + * To retrieve the list of locales: + * + * ```ts + * sdk.admin.locales.list() + * .then(({ locales, count, limit, offset }) => { + * console.log(locales) + * }) + * ``` + * + * To configure the pagination, pass the `limit` and `offset` query parameters. + * + * For example, to retrieve only 10 items and skip 10 items: + * + * ```ts + * sdk.admin.locales.list({ + * limit: 10, + * offset: 10 + * }) + * .then(({ locales, count, limit, offset }) => { + * console.log(locales) + * }) + * ``` + * + * Using the `fields` query parameter, you can specify the fields and relations to retrieve + * in each locale: + * + * ```ts + * sdk.admin.locales.list({ + * fields: "code,name" + * }) + * .then(({ locales, count, limit, offset }) => { + * console.log(locales) + * }) + * ``` + * + * Learn more about the `fields` property in the [API reference](https://docs.medusajs.com/api/store#select-fields-and-relations). + */ + async list(query?: HttpTypes.AdminLocaleListParams, headers?: ClientHeaders) { + return this.client.fetch( + `/admin/locales`, + { + headers, + query, + } + ) + } + + /** + * This method retrieves a locale by its code. It sends a request to the + * [Get Locale](https://docs.medusajs.com/api/admin#locales_getlocalescode) API route. + * + * @param code - The locale's code. + * @param query - Configure the fields to retrieve in the locale. + * @param headers - Headers to pass in the request + * @returns The locale's details. + * + * @example + * To retrieve a locale by its code: + * + * ```ts + * sdk.admin.locale.retrieve("en-US") + * .then(({ locale }) => { + * console.log(locale) + * }) + * ``` + * + * To specify the fields and relations to retrieve: + * + * ```ts + * sdk.admin.locale.retrieve("en-US", { + * fields: "code,name" + * }) + * .then(({ locale }) => { + * console.log(locale) + * }) + * ``` + * + * Learn more about the `fields` property in the [API reference](https://docs.medusajs.com/api/store#select-fields-and-relations). + */ + async retrieve( + code: string, + query?: HttpTypes.AdminLocaleParams, + headers?: ClientHeaders + ) { + return this.client.fetch( + `/admin/locales/${code}`, + { + headers, + query, + } + ) + } +} diff --git a/packages/core/types/src/bundles.ts b/packages/core/types/src/bundles.ts index ab16f34014..7d2091f27d 100644 --- a/packages/core/types/src/bundles.ts +++ b/packages/core/types/src/bundles.ts @@ -32,6 +32,7 @@ export * as StockLocationTypes from "./stock-location" export * as StoreTypes from "./store" export * as TaxTypes from "./tax" export * as TransactionBaseTypes from "./transaction-base" +export * as TranslationTypes from "./translation" export * as UserTypes from "./user" export * as WorkflowTypes from "./workflow" export * as WorkflowsSdkTypes from "./workflows-sdk" diff --git a/packages/core/types/src/http/currency/common.ts b/packages/core/types/src/http/currency/common.ts index 966b13af4d..5e6b3079d3 100644 --- a/packages/core/types/src/http/currency/common.ts +++ b/packages/core/types/src/http/currency/common.ts @@ -1,21 +1,21 @@ export interface BaseCurrency { /** * The currency's code. - * + * * @example * usd */ code: string /** * The currency's symbol. - * + * * @example * $ */ symbol: string /** * The currency's symbol in its native language or country. - * + * * @example * $ */ diff --git a/packages/core/types/src/http/index.ts b/packages/core/types/src/http/index.ts index bf16412062..a8d62525b0 100644 --- a/packages/core/types/src/http/index.ts +++ b/packages/core/types/src/http/index.ts @@ -7,6 +7,8 @@ export * from "./claim" export * from "./collection" export * from "./common" export * from "./currency" +export * from "./locale" +export * from "./translations" export * from "./customer" export * from "./customer-group" export * from "./draft-order" diff --git a/packages/core/types/src/http/locale/admin/entities.ts b/packages/core/types/src/http/locale/admin/entities.ts new file mode 100644 index 0000000000..93c504664d --- /dev/null +++ b/packages/core/types/src/http/locale/admin/entities.ts @@ -0,0 +1,3 @@ +import { BaseLocale } from "../common" + +export interface AdminLocale extends BaseLocale {} diff --git a/packages/core/types/src/http/locale/admin/index.ts b/packages/core/types/src/http/locale/admin/index.ts new file mode 100644 index 0000000000..020c34f02c --- /dev/null +++ b/packages/core/types/src/http/locale/admin/index.ts @@ -0,0 +1,3 @@ +export * from "./entities" +export * from "./queries" +export * from "./responses" diff --git a/packages/core/types/src/http/locale/admin/queries.ts b/packages/core/types/src/http/locale/admin/queries.ts new file mode 100644 index 0000000000..1c2888ff52 --- /dev/null +++ b/packages/core/types/src/http/locale/admin/queries.ts @@ -0,0 +1,17 @@ +import { BaseFilterable } from "../../../dal" +import { FindParams, SelectParams } from "../../common" + +export interface AdminLocaleParams extends SelectParams {} + +export interface AdminLocaleListParams + extends FindParams, + BaseFilterable { + /** + * Query or keyword to search the locale's searchable fields. + */ + q?: string + /** + * Filter by locale code(s). + */ + code?: string | string[] +} diff --git a/packages/core/types/src/http/locale/admin/responses.ts b/packages/core/types/src/http/locale/admin/responses.ts new file mode 100644 index 0000000000..6623379adb --- /dev/null +++ b/packages/core/types/src/http/locale/admin/responses.ts @@ -0,0 +1,17 @@ +import { PaginatedResponse } from "../../common" +import { AdminLocale } from "./entities" + +export interface AdminLocaleResponse { + /** + * The locale's details. + */ + locale: AdminLocale +} + +export interface AdminLocaleListResponse + extends PaginatedResponse<{ + /** + * The list of locales. + */ + locales: AdminLocale[] + }> {} diff --git a/packages/core/types/src/http/locale/common.ts b/packages/core/types/src/http/locale/common.ts new file mode 100644 index 0000000000..e9d3c9f7b0 --- /dev/null +++ b/packages/core/types/src/http/locale/common.ts @@ -0,0 +1,28 @@ +export interface BaseLocale { + /** + * The locale's code. + * + * @example + * en-US + */ + code: string + /** + * The locale's name. + * + * @example + * English (United States) + */ + name: string + /** + * The date the locale was created. + */ + created_at: string + /** + * The date the locale was updated. + */ + updated_at: string + /** + * The date the locale was deleted. + */ + deleted_at: string | null +} diff --git a/packages/core/types/src/http/locale/index.ts b/packages/core/types/src/http/locale/index.ts new file mode 100644 index 0000000000..1b64bd005a --- /dev/null +++ b/packages/core/types/src/http/locale/index.ts @@ -0,0 +1,2 @@ +export * from "./admin" +export * from "./common" diff --git a/packages/core/types/src/http/store/admin/entities.ts b/packages/core/types/src/http/store/admin/entities.ts index f7086737d7..4be4e64d4c 100644 --- a/packages/core/types/src/http/store/admin/entities.ts +++ b/packages/core/types/src/http/store/admin/entities.ts @@ -1,4 +1,5 @@ import { AdminCurrency } from "../../currency" +import { AdminLocale } from "../../locale" export interface AdminStoreCurrency { /** @@ -7,7 +8,7 @@ export interface AdminStoreCurrency { id: string /** * The currency code. - * + * * @example * "usd" */ @@ -38,6 +39,44 @@ export interface AdminStoreCurrency { deleted_at: string | null } +export interface AdminStoreLocale { + /** + * The locale's ID. + */ + id: string + /** + * The locale's code. + * + * @example + * "en-US" + */ + locale_code: string + /** + * The ID of the store that the locale belongs to. + */ + store_id: string + /** + * Whether the locale is the default locale for the store. + */ + is_default: boolean + /** + * The locale's details. + */ + locale: AdminLocale + /** + * The date the locale was created. + */ + created_at: string + /** + * The date the locale was updated. + */ + updated_at: string + /** + * The date the locale was deleted. + */ + deleted_at: string | null +} + export interface AdminStore { /** * The store's ID. @@ -51,6 +90,10 @@ export interface AdminStore { * The store's supported currencies. */ supported_currencies: AdminStoreCurrency[] + /** + * The store's supported locales. + */ + supported_locales: AdminStoreLocale[] /** * The store's default sales channel ID. */ diff --git a/packages/core/types/src/http/store/admin/payloads.ts b/packages/core/types/src/http/store/admin/payloads.ts index 3c74e539a7..6c0095fc6f 100644 --- a/packages/core/types/src/http/store/admin/payloads.ts +++ b/packages/core/types/src/http/store/admin/payloads.ts @@ -4,7 +4,7 @@ export interface AdminUpdateStoreSupportedCurrency { /** * The currency's ISO 3 code. - * + * * @example * usd */ @@ -15,12 +15,23 @@ export interface AdminUpdateStoreSupportedCurrency { is_default?: boolean /** * Whether prices in this currency are tax inclusive. - * + * * Learn more in [this documentation](https://docs.medusajs.com/resources/commerce-modules/pricing/tax-inclusive-pricing). */ is_tax_inclusive?: boolean } +export interface AdminUpdateStoreSupportedLocale { + /** + * The locale's BCP 47 language tag. + */ + locale_code: string + /** + * Whether this locale is the default locale in the store. + */ + is_default?: boolean +} + /** * The data to update in a store. */ @@ -33,6 +44,10 @@ export interface AdminUpdateStore { * The supported currencies of the store. */ supported_currencies?: AdminUpdateStoreSupportedCurrency[] + /** + * The supported locales of the store. + */ + supported_locales?: AdminUpdateStoreSupportedLocale[] /** * The ID of the default sales channel of the store. */ diff --git a/packages/core/types/src/http/translations/admin/entities.ts b/packages/core/types/src/http/translations/admin/entities.ts new file mode 100644 index 0000000000..626b39d367 --- /dev/null +++ b/packages/core/types/src/http/translations/admin/entities.ts @@ -0,0 +1,41 @@ +export interface AdminTranslation { + /** + * The ID of the translation. + */ + id: string + + /** + * The ID of the entity being translated. + */ + reference_id: string + + /** + * The type of entity being translated (e.g., "product", "product_variant"). + */ + reference: string + + /** + * The BCP 47 language tag code for this translation (e.g., "en-US", "fr-FR"). + */ + locale_code: string + + /** + * The translated fields as key-value pairs. + */ + translations: Record + + /** + * The date and time the translation was created. + */ + created_at: Date | string + + /** + * The date and time the translation was last updated. + */ + updated_at: Date | string + + /** + * The date and time the translation was deleted. + */ + deleted_at: Date | string | null +} diff --git a/packages/core/types/src/http/translations/admin/index.ts b/packages/core/types/src/http/translations/admin/index.ts new file mode 100644 index 0000000000..62871a5e38 --- /dev/null +++ b/packages/core/types/src/http/translations/admin/index.ts @@ -0,0 +1,3 @@ +export * from "./queries" +export * from "./responses" +export * from "./entities" diff --git a/packages/core/types/src/http/translations/admin/queries.ts b/packages/core/types/src/http/translations/admin/queries.ts new file mode 100644 index 0000000000..3c872857f2 --- /dev/null +++ b/packages/core/types/src/http/translations/admin/queries.ts @@ -0,0 +1,23 @@ +import { BaseFilterable } from "../../.." +import { FindParams } from "../../common/request" + +export interface AdminTranslationsListParams + extends FindParams, + BaseFilterable { + /** + * Query or keywords to search the translations searchable fields. + */ + q?: string + /** + * Filter by entity ID. + */ + reference_id?: string | string[] + /** + * Filter by entity type. + */ + reference?: string + /** + * Filter by locale code. + */ + locale_code?: string | string[] +} diff --git a/packages/core/types/src/http/translations/admin/responses.ts b/packages/core/types/src/http/translations/admin/responses.ts new file mode 100644 index 0000000000..058fe553e6 --- /dev/null +++ b/packages/core/types/src/http/translations/admin/responses.ts @@ -0,0 +1,35 @@ +import { PaginatedResponse } from "../../common" +import { AdminTranslation } from "./entities" + +export interface AdminTranslationsResponse { + /** + * The list of translations. + */ + translation: AdminTranslation +} + +export type AdminTranslationsListResponse = PaginatedResponse<{ + /** + * The list of translations. + */ + translations: AdminTranslation[] +}> + +export interface AdminTranslationsBatchResponse { + /** + * The created translations. + */ + created: AdminTranslation[] + /** + * The updated translations. + */ + updated: AdminTranslation[] + /** + * The deleted translations. + */ + deleted: { + ids: string[] + object: "translation" + deleted: boolean + } +} diff --git a/packages/core/types/src/http/translations/index.ts b/packages/core/types/src/http/translations/index.ts new file mode 100644 index 0000000000..26b8eb9dad --- /dev/null +++ b/packages/core/types/src/http/translations/index.ts @@ -0,0 +1 @@ +export * from "./admin" diff --git a/packages/core/types/src/index.ts b/packages/core/types/src/index.ts index 0bb67735f5..0d4f4357f6 100644 --- a/packages/core/types/src/index.ts +++ b/packages/core/types/src/index.ts @@ -42,6 +42,7 @@ export * from "./store" export * from "./tax" export * from "./totals" export * from "./transaction-base" +export * from "./translation" export * from "./user" export * from "./workflow" export * from "./workflows" diff --git a/packages/core/types/src/store/common/store.ts b/packages/core/types/src/store/common/store.ts index a34ae9c81d..c157173528 100644 --- a/packages/core/types/src/store/common/store.ts +++ b/packages/core/types/src/store/common/store.ts @@ -31,6 +31,37 @@ export interface StoreCurrencyDTO { deleted_at: string | null } +export interface StoreLocaleDTO { + /** + * The ID of the store locale. + */ + id: string + /** + * The locale code of the store locale. + */ + locale_code: string + /** + * Whether the locale is the default one for the store. + */ + is_default: boolean + /** + * The store ID associated with the locale. + */ + store_id: string + /** + * The created date of the locale. + */ + created_at: string + /** + * The updated date of the locale. + */ + updated_at: string + /** + * The deleted date of the locale. + */ + deleted_at: string | null +} + /** * The store details. */ @@ -50,6 +81,11 @@ export interface StoreDTO { */ supported_currencies?: StoreCurrencyDTO[] + /** + * The supported locale codes of the store. + */ + supported_locales?: StoreLocaleDTO[] + /** * The associated default sales channel's ID. */ diff --git a/packages/core/types/src/store/mutations/store.ts b/packages/core/types/src/store/mutations/store.ts index b9f26be28e..b650ea1d73 100644 --- a/packages/core/types/src/store/mutations/store.ts +++ b/packages/core/types/src/store/mutations/store.ts @@ -9,6 +9,17 @@ export interface CreateStoreCurrencyDTO { is_default?: boolean } +export interface CreateStoreLocaleDTO { + /** + * The locale code of the store locale. + */ + locale_code: string + /** + * Whether the locale is the default one for the store. + */ + is_default?: boolean +} + /** * The store to be created. */ @@ -23,6 +34,11 @@ export interface CreateStoreDTO { */ supported_currencies?: CreateStoreCurrencyDTO[] + /** + * The suppoprted locale codes of the store. + */ + supported_locales?: CreateStoreLocaleDTO[] + /** * The associated default sales channel's ID. */ @@ -68,6 +84,11 @@ export interface UpdateStoreDTO { */ supported_currencies?: CreateStoreCurrencyDTO[] + /** + * The supported locale codes of the store. + */ + supported_locales?: CreateStoreLocaleDTO[] + /** * The associated default sales channel's ID. */ diff --git a/packages/core/types/src/translation/common.ts b/packages/core/types/src/translation/common.ts new file mode 100644 index 0000000000..b659bed4ba --- /dev/null +++ b/packages/core/types/src/translation/common.ts @@ -0,0 +1,134 @@ +import { BaseFilterable, OperatorMap } from "../dal" + +/** + * The locale details. + */ +export interface LocaleDTO { + /** + * The ID of the locale. + */ + id: string + + /** + * The BCP 47 language tag code of the locale (e.g., "en-US", "fr-FR"). + */ + code: string + + /** + * The human-readable name of the locale (e.g., "English (United States)"). + */ + name: string + + /** + * The date and time the locale was created. + */ + created_at: Date | string + + /** + * The date and time the locale was last updated. + */ + updated_at: Date | string + + /** + * The date and time the locale was deleted. + */ + deleted_at: Date | string | null +} + +/** + * The translation details. + */ +export interface TranslationDTO { + /** + * The ID of the translation. + */ + id: string + + /** + * The ID of the entity being translated. + */ + reference_id: string + + /** + * The type of entity being translated (e.g., "product", "product_variant"). + */ + reference: string + + /** + * The BCP 47 language tag code for this translation (e.g., "en-US", "fr-FR"). + */ + locale_code: string + + /** + * The translated fields as key-value pairs. + */ + translations: Record + + /** + * The date and time the translation was created. + */ + created_at: Date | string + + /** + * The date and time the translation was last updated. + */ + updated_at: Date | string + + /** + * The date and time the translation was deleted. + */ + deleted_at: Date | string | null +} + +/** + * The filters to apply on the retrieved locales. + */ +export interface FilterableLocaleProps + extends BaseFilterable { + /** + * The IDs to filter the locales by. + */ + id?: string[] | string | OperatorMap + + /** + * Filter locales by their code. + */ + code?: string | string[] | OperatorMap + + /** + * Filter locales by their name. + */ + name?: string | OperatorMap +} + +/** + * The filters to apply on the retrieved translations. + */ +export interface FilterableTranslationProps + extends BaseFilterable { + /** + * Search through translated content using this search term. + * This searches within the JSONB translations field values. + */ + q?: string + + /** + * The IDs to filter the translations by. + */ + id?: string[] | string | OperatorMap + + /** + * Filter translations by entity ID. + */ + reference_id?: string | string[] | OperatorMap + + /** + * Filter translations by entity type. + */ + reference?: string | string[] | OperatorMap + + /** + * Filter translations by locale code. + */ + locale_code?: string | string[] | OperatorMap +} diff --git a/packages/core/types/src/translation/index.ts b/packages/core/types/src/translation/index.ts new file mode 100644 index 0000000000..0c73656566 --- /dev/null +++ b/packages/core/types/src/translation/index.ts @@ -0,0 +1,3 @@ +export * from "./common" +export * from "./mutations" +export * from "./service" diff --git a/packages/core/types/src/translation/mutations.ts b/packages/core/types/src/translation/mutations.ts new file mode 100644 index 0000000000..bc7f1ffb91 --- /dev/null +++ b/packages/core/types/src/translation/mutations.ts @@ -0,0 +1,144 @@ +/** + * The locale to be created. + */ +export interface CreateLocaleDTO { + /** + * The ID of the locale to create. + */ + id?: string + + /** + * The BCP 47 language tag code of the locale (e.g., "en-US", "fr-FR"). + */ + code: string + + /** + * The human-readable name of the locale (e.g., "English (United States)"). + */ + name: string +} + +/** + * The attributes to update in the locale. + */ +export interface UpdateLocaleDTO { + /** + * The ID of the locale to update. + */ + id: string + + /** + * The BCP 47 language tag code of the locale. + */ + code?: string + + /** + * The human-readable name of the locale. + */ + name?: string +} + +/** + * The attributes in the locale to be created or updated. + */ +export interface UpsertLocaleDTO { + /** + * The ID of the locale in case of an update. + */ + id?: string + + /** + * The BCP 47 language tag code of the locale. + */ + code?: string + + /** + * The human-readable name of the locale. + */ + name?: string +} + +/** + * The translation to be created. + */ +export interface CreateTranslationDTO { + /** + * The ID of the entity being translated. + */ + reference_id: string + + /** + * The type of entity being translated (e.g., "product", "product_variant"). + */ + reference: string + + /** + * The BCP 47 language tag code for this translation (e.g., "en-US", "fr-FR"). + */ + locale_code: string + + /** + * The translated fields as key-value pairs. + */ + translations: Record +} + +/** + * The attributes to update in the translation. + */ +export interface UpdateTranslationDTO { + /** + * The ID of the translation to update. + */ + id: string + + /** + * The ID of the entity being translated. + */ + reference_id?: string + + /** + * The type of entity being translated. + */ + reference?: string + + /** + * The BCP 47 language tag code for this translation. + */ + locale_code?: string + + /** + * The translated fields as key-value pairs. + */ + translations?: Record +} + +/** + * The attributes in the translation to be created or updated. + */ +export interface UpsertTranslationDTO { + /** + * The ID of the translation in case of an update. + */ + id?: string + + /** + * The ID of the entity being translated. + */ + reference_id?: string + + /** + * The type of entity being translated. + */ + reference?: string + + /** + * The BCP 47 language tag code for this translation. + */ + locale_code?: string + + /** + * The translated fields as key-value pairs. + */ + translations?: Record +} diff --git a/packages/core/types/src/translation/service.ts b/packages/core/types/src/translation/service.ts new file mode 100644 index 0000000000..c8e3869d32 --- /dev/null +++ b/packages/core/types/src/translation/service.ts @@ -0,0 +1,292 @@ +import { FindConfig } from "../common" +import { RestoreReturn, SoftDeleteReturn } from "../dal" +import { IModuleService } from "../modules-sdk" +import { Context } from "../shared-context" +import { + FilterableLocaleProps, + FilterableTranslationProps, + LocaleDTO, + TranslationDTO, +} from "./common" +import { + CreateLocaleDTO, + CreateTranslationDTO, + UpdateLocaleDTO, + UpdateTranslationDTO, +} from "./mutations" + +/** + * The main service interface for the Translation Module. + * Method signatures match what MedusaService generates. + */ +export interface ITranslationModuleService extends IModuleService { + /** + * This method retrieves a locale by its ID. + * + * @param {string} id - The ID of the locale. + * @param {FindConfig} config - The configurations determining how the locale is retrieved. + * @param {Context} sharedContext + * @returns {Promise} The retrieved locale. + */ + retrieveLocale( + id: string, + config?: FindConfig, + sharedContext?: Context + ): Promise + + /** + * This method retrieves a paginated list of locales based on optional filters and configuration. + * + * @param {FilterableLocaleProps} filters - The filters to apply on the retrieved locales. + * @param {FindConfig} config - The configurations determining how the locale is retrieved. + * @param {Context} sharedContext + * @returns {Promise} The list of locales. + */ + listLocales( + filters?: FilterableLocaleProps, + config?: FindConfig, + sharedContext?: Context + ): Promise + + /** + * This method retrieves a paginated list of locales along with the total count. + * + * @param {FilterableLocaleProps} filters - The filters to apply on the retrieved locales. + * @param {FindConfig} config - The configurations determining how the locale is retrieved. + * @param {Context} sharedContext + * @returns {Promise<[LocaleDTO[], number]>} The list of locales along with their total count. + */ + listAndCountLocales( + filters?: FilterableLocaleProps, + config?: FindConfig, + sharedContext?: Context + ): Promise<[LocaleDTO[], number]> + + /** + * This method creates a locale. + * + * @param {CreateLocaleDTO} data - The locale to be created. + * @param {Context} sharedContext + * @returns {Promise} The created locale. + */ + createLocales( + data: CreateLocaleDTO, + sharedContext?: Context + ): Promise + + /** + * This method creates locales. + * + * @param {CreateLocaleDTO[]} data - The locales to be created. + * @param {Context} sharedContext + * @returns {Promise} The created locales. + */ + createLocales( + data: CreateLocaleDTO[], + sharedContext?: Context + ): Promise + + /** + * This method updates an existing locale. The ID should be included in the data object. + * + * @param {UpdateLocaleDTO} data - The attributes to update in the locale (including id). + * @param {Context} sharedContext + * @returns {Promise} The updated locale. + */ + updateLocales( + data: UpdateLocaleDTO, + sharedContext?: Context + ): Promise + + /** + * This method updates existing locales using an array or selector-based approach. + * + * @param {UpdateLocaleDTO[] | { selector: Record; data: UpdateLocaleDTO | UpdateLocaleDTO[] }} dataOrOptions - The data or options for bulk update. + * @param {Context} sharedContext + * @returns {Promise} The updated locales. + */ + updateLocales( + dataOrOptions: + | UpdateLocaleDTO[] + | { + selector: Record + data: UpdateLocaleDTO | UpdateLocaleDTO[] + }, + sharedContext?: Context + ): Promise + + /** + * This method deletes locales by their IDs or objects. + * + * @param {string | object | string[] | object[]} primaryKeyValues - The IDs or objects identifying the locales to delete. + * @param {Context} sharedContext + * @returns {Promise} Resolves when the locales are deleted. + */ + deleteLocales( + primaryKeyValues: string | object | string[] | object[], + sharedContext?: Context + ): Promise + + /** + * This method soft deletes locales by their IDs or objects. + * + * @param {string | object | string[] | object[]} primaryKeyValues - The IDs or objects identifying the locales to soft delete. + * @param {SoftDeleteReturn} config - An object for related entities that should be soft-deleted. + * @param {Context} sharedContext + * @returns {Promise | void>} An object with IDs of related records that were also soft deleted. + */ + softDeleteLocales( + primaryKeyValues: string | object | string[] | object[], + config?: SoftDeleteReturn, + sharedContext?: Context + ): Promise | void> + + /** + * This method restores soft deleted locales by their IDs or objects. + * + * @param {string | object | string[] | object[]} primaryKeyValues - The IDs or objects identifying the locales to restore. + * @param {RestoreReturn} config - Configurations determining which relations to restore. + * @param {Context} sharedContext + * @returns {Promise | void>} An object with IDs of related records that were restored. + */ + restoreLocales( + primaryKeyValues: string | object | string[] | object[], + config?: RestoreReturn, + sharedContext?: Context + ): Promise | void> + + /** + * This method retrieves a translation by its ID. + * + * @param {string} id - The ID of the translation. + * @param {FindConfig} config - The configurations determining how the translation is retrieved. + * @param {Context} sharedContext + * @returns {Promise} The retrieved translation. + */ + retrieveTranslation( + id: string, + config?: FindConfig, + sharedContext?: Context + ): Promise + + /** + * This method retrieves a paginated list of translations based on optional filters and configuration. + * + * @param {FilterableTranslationProps} filters - The filters to apply on the retrieved translations. + * @param {FindConfig} config - The configurations determining how the translation is retrieved. + * @param {Context} sharedContext + * @returns {Promise} The list of translations. + */ + listTranslations( + filters?: FilterableTranslationProps, + config?: FindConfig, + sharedContext?: Context + ): Promise + + /** + * This method retrieves a paginated list of translations along with the total count. + * + * @param {FilterableTranslationProps} filters - The filters to apply on the retrieved translations. + * @param {FindConfig} config - The configurations determining how the translation is retrieved. + * @param {Context} sharedContext + * @returns {Promise<[TranslationDTO[], number]>} The list of translations along with their total count. + */ + listAndCountTranslations( + filters?: FilterableTranslationProps, + config?: FindConfig, + sharedContext?: Context + ): Promise<[TranslationDTO[], number]> + + /** + * This method creates a translation. + * + * @param {CreateTranslationDTO} data - The translation to be created. + * @param {Context} sharedContext + * @returns {Promise} The created translation. + */ + createTranslations( + data: CreateTranslationDTO, + sharedContext?: Context + ): Promise + + /** + * This method creates translations. + * + * @param {CreateTranslationDTO[]} data - The translations to be created. + * @param {Context} sharedContext + * @returns {Promise} The created translations. + */ + createTranslations( + data: CreateTranslationDTO[], + sharedContext?: Context + ): Promise + + /** + * This method updates an existing translation. The ID should be included in the data object. + * + * @param {UpdateTranslationDTO} data - The attributes to update in the translation (including id). + * @param {Context} sharedContext + * @returns {Promise} The updated translation. + */ + updateTranslations( + data: UpdateTranslationDTO, + sharedContext?: Context + ): Promise + + /** + * This method updates existing translations using an array or selector-based approach. + * + * @param {UpdateTranslationDTO[] | { selector: Record; data: UpdateTranslationDTO | UpdateTranslationDTO[] }} dataOrOptions - The data or options for bulk update. + * @param {Context} sharedContext + * @returns {Promise} The updated translations. + */ + updateTranslations( + dataOrOptions: + | UpdateTranslationDTO[] + | { + selector: Record + data: UpdateTranslationDTO | UpdateTranslationDTO[] + }, + sharedContext?: Context + ): Promise + + /** + * This method deletes translations by their IDs or objects. + * + * @param {string | object | string[] | object[]} primaryKeyValues - The IDs or objects identifying the translations to delete. + * @param {Context} sharedContext + * @returns {Promise} Resolves when the translations are deleted. + */ + deleteTranslations( + primaryKeyValues: string | object | string[] | object[], + sharedContext?: Context + ): Promise + + /** + * This method soft deletes translations by their IDs or objects. + * + * @param {string | object | string[] | object[]} primaryKeyValues - The IDs or objects identifying the translations to soft delete. + * @param {SoftDeleteReturn} config - An object for related entities that should be soft-deleted. + * @param {Context} sharedContext + * @returns {Promise | void>} An object with IDs of related records that were also soft deleted. + */ + softDeleteTranslations( + primaryKeyValues: string | object | string[] | object[], + config?: SoftDeleteReturn, + sharedContext?: Context + ): Promise | void> + + /** + * This method restores soft deleted translations by their IDs or objects. + * + * @param {string | object | string[] | object[]} primaryKeyValues - The IDs or objects identifying the translations to restore. + * @param {RestoreReturn} config - Configurations determining which relations to restore. + * @param {Context} sharedContext + * @returns {Promise | void>} An object with IDs of related records that were restored. + */ + restoreTranslations( + primaryKeyValues: string | object | string[] | object[], + config?: RestoreReturn, + sharedContext?: Context + ): Promise | void> +} diff --git a/packages/core/utils/src/bundles.ts b/packages/core/utils/src/bundles.ts index d26d9748f9..29ea1ec04c 100644 --- a/packages/core/utils/src/bundles.ts +++ b/packages/core/utils/src/bundles.ts @@ -19,4 +19,5 @@ export * as SearchUtils from "./search" export * as ShippingProfileUtils from "./shipping" export * as UserUtils from "./user" export * as CachingUtils from "./caching" +export * as TranslationsUtils from "./translations" export * as DevServerUtils from "./dev-server" diff --git a/packages/core/utils/src/common/__tests__/normalize-locale.spec.ts b/packages/core/utils/src/common/__tests__/normalize-locale.spec.ts new file mode 100644 index 0000000000..46935a482e --- /dev/null +++ b/packages/core/utils/src/common/__tests__/normalize-locale.spec.ts @@ -0,0 +1,172 @@ +import { normalizeLocale } from "../normalize-locale" + +describe("normalizeLocale", function () { + it("should normalize single segment locales to lowercase", function () { + const expectations = [ + { + input: "eN", + output: "en", + }, + { + input: "EN", + output: "en", + }, + { + input: "En", + output: "en", + }, + { + input: "en", + output: "en", + }, + { + input: "fr", + output: "fr", + }, + { + input: "FR", + output: "fr", + }, + { + input: "de", + output: "de", + }, + ] + + expectations.forEach((expectation) => { + expect(normalizeLocale(expectation.input)).toEqual(expectation.output) + }) + }) + + it("should normalize two segment locales (language-region)", function () { + const expectations = [ + { + input: "en-Us", + output: "en-US", + }, + { + input: "EN-US", + output: "en-US", + }, + { + input: "en-us", + output: "en-US", + }, + { + input: "En-Us", + output: "en-US", + }, + { + input: "fr-FR", + output: "fr-FR", + }, + { + input: "FR-fr", + output: "fr-FR", + }, + { + input: "de-DE", + output: "de-DE", + }, + { + input: "es-ES", + output: "es-ES", + }, + { + input: "pt-BR", + output: "pt-BR", + }, + ] + + expectations.forEach((expectation) => { + expect(normalizeLocale(expectation.input)).toEqual(expectation.output) + }) + }) + + it("should normalize three segment locales (language-script-region)", function () { + const expectations = [ + { + input: "RU-cYrl-By", + output: "ru-Cyrl-BY", + }, + { + input: "ru-cyrl-by", + output: "ru-Cyrl-BY", + }, + { + input: "RU-CYRL-BY", + output: "ru-Cyrl-BY", + }, + { + input: "zh-Hans-CN", + output: "zh-Hans-CN", + }, + { + input: "ZH-HANS-CN", + output: "zh-Hans-CN", + }, + { + input: "sr-Latn-RS", + output: "sr-Latn-RS", + }, + { + input: "SR-LATN-RS", + output: "sr-Latn-RS", + }, + ] + + expectations.forEach((expectation) => { + expect(normalizeLocale(expectation.input)).toEqual(expectation.output) + }) + }) + + it("should return locale as-is for more than three segments", function () { + const expectations = [ + { + input: "en-US-x-private", + output: "en-US-x-private", + }, + { + input: "en-US-x-private-extended", + output: "en-US-x-private-extended", + }, + { + input: "en-US-x-private-extended-more", + output: "en-US-x-private-extended-more", + }, + ] + + expectations.forEach((expectation) => { + expect(normalizeLocale(expectation.input)).toEqual(expectation.output) + }) + }) + + it("should handle edge cases", function () { + const expectations = [ + { + input: "", + output: "", + }, + { + input: "a", + output: "a", + }, + { + input: "A", + output: "a", + }, + { + input: "a-B", + output: "a-B", + }, + { + input: "a-b-C", + output: "a-B-C", + }, + ] + + expectations.forEach((expectation) => { + expect(normalizeLocale(expectation.input)).toEqual(expectation.output) + }) + }) +}) diff --git a/packages/core/utils/src/common/define-config.ts b/packages/core/utils/src/common/define-config.ts index d5d90d99bf..031014f924 100644 --- a/packages/core/utils/src/common/define-config.ts +++ b/packages/core/utils/src/common/define-config.ts @@ -185,6 +185,9 @@ function resolveModules( { resolve: MODULE_PACKAGE_NAMES[Modules.ORDER] }, { resolve: MODULE_PACKAGE_NAMES[Modules.SETTINGS] }, + // TODO: re-enable this once we have the final release + // { resolve: MODULE_PACKAGE_NAMES[Modules.TRANSLATION] }, + { resolve: MODULE_PACKAGE_NAMES[Modules.AUTH], options: { diff --git a/packages/core/utils/src/common/index.ts b/packages/core/utils/src/common/index.ts index f2942236e1..c6b75b1b93 100644 --- a/packages/core/utils/src/common/index.ts +++ b/packages/core/utils/src/common/index.ts @@ -53,6 +53,7 @@ export * from "./medusa-container" export * from "./merge-metadata" export * from "./merge-plugin-modules" export * from "./normalize-csv-value" +export * from "./normalize-locale" export * from "./normalize-import-path-with-source" export * from "./object-from-string-path" export * from "./object-to-string-path" diff --git a/packages/core/utils/src/common/normalize-locale.ts b/packages/core/utils/src/common/normalize-locale.ts new file mode 100644 index 0000000000..139ea9a3a1 --- /dev/null +++ b/packages/core/utils/src/common/normalize-locale.ts @@ -0,0 +1,40 @@ +import { upperCaseFirst } from "./upper-case-first" + +/** + * Normalizes a locale string to {@link https://developer.mozilla.org/en-US/docs/Glossary/BCP_47_language_tag|BCP 47 language tag format} + * @param locale - The locale string to normalize + * @returns The normalized locale string + * + * @example + * input: "en-Us" + * output: "en-US" + * + * @example + * input: "eN" + * output: "en" + * + * @example + * input: "RU-cYrl-By" + * output: "ru-Cyrl-BY" + */ +export function normalizeLocale(locale: string) { + const segments = locale.split("-") + + if (segments.length === 1) { + return segments[0].toLowerCase() + } + + // e.g en-US + if (segments.length === 2) { + return `${segments[0].toLowerCase()}-${segments[1].toUpperCase()}` + } + + // e.g ru-Cyrl-BY + if (segments.length === 3) { + return `${segments[0].toLowerCase()}-${upperCaseFirst( + segments[1].toLowerCase() + )}-${segments[2].toUpperCase()}` + } + + return locale +} diff --git a/packages/core/utils/src/index.ts b/packages/core/utils/src/index.ts index 0f5a72e759..d1268afa20 100644 --- a/packages/core/utils/src/index.ts +++ b/packages/core/utils/src/index.ts @@ -30,6 +30,7 @@ export * from "./totals" export * from "./totals/big-number" export * from "./user" export * from "./caching" +export * from "./translations" export * from "./dev-server" export const MedusaModuleType = Symbol.for("MedusaModule") diff --git a/packages/core/utils/src/modules-sdk/define-link.ts b/packages/core/utils/src/modules-sdk/define-link.ts index 8ebc095e3a..af7beb7d01 100644 --- a/packages/core/utils/src/modules-sdk/define-link.ts +++ b/packages/core/utils/src/modules-sdk/define-link.ts @@ -1,5 +1,13 @@ import { LinkModulesExtraFields, ModuleJoinerConfig } from "@medusajs/types" -import { camelToSnakeCase, isObject, pluralize, toPascalCase } from "../common" +import { + camelToSnakeCase, + getCallerFilePath, + isFileDisabled, + isObject, + MEDUSA_SKIP_FILE, + pluralize, + toPascalCase, +} from "../common" import { composeLinkName } from "../link/compose-link-name" export const DefineLinkSymbol = Symbol.for("DefineLink") @@ -193,6 +201,11 @@ export function defineLink( rightService: DefineLinkInputSource | DefineReadOnlyLinkInputSource, linkServiceOptions?: ExtraOptions | ReadOnlyExtraOptions ): DefineLinkExport { + const callerFilePath = getCallerFilePath() + if (isFileDisabled(callerFilePath ?? "")) { + return { [MEDUSA_SKIP_FILE]: true } as any + } + const serviceAObj = prepareServiceConfig(leftService) const serviceBObj = prepareServiceConfig(rightService) diff --git a/packages/core/utils/src/modules-sdk/definition.ts b/packages/core/utils/src/modules-sdk/definition.ts index a642f3917a..164eb12158 100644 --- a/packages/core/utils/src/modules-sdk/definition.ts +++ b/packages/core/utils/src/modules-sdk/definition.ts @@ -28,6 +28,7 @@ export const Modules = { LOCKING: "locking", SETTINGS: "settings", CACHING: "caching", + TRANSLATION: "translation", } as const export const MODULE_PACKAGE_NAMES = { @@ -60,6 +61,7 @@ export const MODULE_PACKAGE_NAMES = { [Modules.LOCKING]: "@medusajs/medusa/locking", [Modules.SETTINGS]: "@medusajs/medusa/settings", [Modules.CACHING]: "@medusajs/caching", + [Modules.TRANSLATION]: "@medusajs/translation", } export const REVERSED_MODULE_PACKAGE_NAMES = Object.entries( diff --git a/packages/core/utils/src/translations/__tests__/apply-translations.spec.ts b/packages/core/utils/src/translations/__tests__/apply-translations.spec.ts new file mode 100644 index 0000000000..f736e197d5 --- /dev/null +++ b/packages/core/utils/src/translations/__tests__/apply-translations.spec.ts @@ -0,0 +1,363 @@ +import { FeatureFlag } from "../../feature-flags" +import { applyTranslations } from "../apply-translations" + +jest.mock("../../feature-flags/flag-router", () => ({ + ...jest.requireActual("../../feature-flags/flag-router"), + FeatureFlag: { + isFeatureEnabled: jest.fn(), + }, +})) + +const mockFeatureFlagIsEnabled = FeatureFlag.isFeatureEnabled as jest.Mock + +describe("applyTranslations", () => { + let mockQuery: { graph: jest.Mock } + let mockContainer: { resolve: jest.Mock } + let mockReq: { locale?: string } + + beforeEach(() => { + jest.clearAllMocks() + mockQuery = { + graph: jest.fn().mockResolvedValue({ data: [] }), + } + mockContainer = { + resolve: jest.fn().mockReturnValue(mockQuery), + } + mockReq = { + locale: "en-US", + } + }) + + beforeEach(() => { + mockFeatureFlagIsEnabled.mockReturnValue(true) + }) + + it("should apply translations to a simple object", async () => { + const inputObjects = [{ id: "prod_1", title: "Original Title" }] + + mockQuery.graph.mockResolvedValue({ + data: [ + { + reference_id: "prod_1", + translations: { title: "Translated Title" }, + }, + ], + }) + + await applyTranslations({ + localeCode: mockReq.locale as string, + objects: inputObjects, + container: mockContainer as any, + }) + + expect(inputObjects[0].title).toBe("Translated Title") + }) + + it("should apply translations to nested objects", async () => { + const inputObjects = [ + { + id: "prod_1", + title: "Product Title", + category: { + id: "cat_1", + name: "Category Name", + }, + }, + ] + + mockQuery.graph.mockResolvedValue({ + data: [ + { + reference_id: "prod_1", + translations: { title: "Translated Product Title", category: true }, + }, + { + reference_id: "cat_1", + translations: { name: "Translated Category Name" }, + }, + ], + }) + + await applyTranslations({ + localeCode: mockReq.locale as string, + objects: inputObjects, + container: mockContainer as any, + }) + + expect(inputObjects[0].title).toBe("Translated Product Title") + expect(inputObjects[0].category.name).toBe("Translated Category Name") + }) + + it("should apply translations to arrays of objects", async () => { + const inputObjects = [ + { + id: "prod_1", + title: "Product Title", + variants: [ + { id: "var_1", name: "Variant 1" }, + { id: "var_2", name: "Variant 2" }, + ], + }, + ] + + mockQuery.graph.mockResolvedValue({ + data: [ + { + reference_id: "prod_1", + translations: { title: "Translated Product" }, + }, + { + reference_id: "var_1", + translations: { name: "Translated Variant 1" }, + }, + { + reference_id: "var_2", + translations: { name: "Translated Variant 2" }, + }, + ], + }) + + await applyTranslations({ + localeCode: mockReq.locale as string, + objects: inputObjects, + container: mockContainer as any, + }) + + expect(inputObjects[0].title).toBe("Translated Product") + expect(inputObjects[0].variants[0].name).toBe("Translated Variant 1") + expect(inputObjects[0].variants[1].name).toBe("Translated Variant 2") + }) + + it("should use the locale from the request", async () => { + mockReq.locale = "fr-FR" + const inputObjects = [{ id: "prod_1", title: "Original" }] + + await applyTranslations({ + localeCode: mockReq.locale as string, + objects: inputObjects, + container: mockContainer as any, + }) + + expect(mockQuery.graph).toHaveBeenCalledWith( + expect.objectContaining({ + filters: expect.objectContaining({ + locale_code: "fr-FR", + }), + }), + expect.objectContaining({ + cache: expect.objectContaining({ + enable: true, + }), + }) + ) + }) + + it("should batch queries when there are more than 250 ids", async () => { + const inputObjects = Array.from({ length: 300 }, (_, i) => ({ + id: `prod_${i}`, + title: `Product ${i}`, + })) + + await applyTranslations({ + localeCode: mockReq.locale as string, + objects: inputObjects, + container: mockContainer as any, + }) + + expect(mockQuery.graph).toHaveBeenCalledTimes(2) + }) + + it("should apply translations to multiple input objects", async () => { + const inputObjects = [ + { id: "prod_1", title: "Product 1" }, + { id: "prod_2", title: "Product 2" }, + ] + + mockQuery.graph.mockResolvedValue({ + data: [ + { reference_id: "prod_1", translations: { title: "Translated 1" } }, + { reference_id: "prod_2", translations: { title: "Translated 2" } }, + ], + }) + + await applyTranslations({ + localeCode: mockReq.locale as string, + objects: inputObjects, + container: mockContainer as any, + }) + + expect(inputObjects[0].title).toBe("Translated 1") + expect(inputObjects[1].title).toBe("Translated 2") + }) + + it("should handle translations with null values", async () => { + const inputObjects = [{ id: "prod_1", title: "Original" }] + + mockQuery.graph.mockResolvedValue({ + data: [ + { + reference_id: "prod_1", + translations: null, + }, + ], + }) + + await applyTranslations({ + localeCode: mockReq.locale as string, + objects: inputObjects, + container: mockContainer as any, + }) + + expect(inputObjects[0].title).toBe("Original") + }) + + it("should return early when feature flag is disabled", async () => { + mockFeatureFlagIsEnabled.mockReturnValue(false) + const inputObjects = [{ id: "prod_1", title: "Original" }] + + await applyTranslations({ + localeCode: mockReq.locale as string, + objects: inputObjects, + container: mockContainer as any, + }) + + expect(mockContainer.resolve).not.toHaveBeenCalled() + expect(inputObjects[0].title).toBe("Original") + }) + + it("should not modify objects when no translations are found", async () => { + mockFeatureFlagIsEnabled.mockReturnValue(true) + const inputObjects = [{ id: "prod_1", title: "Original Title" }] + + mockQuery.graph.mockResolvedValue({ data: [] }) + + await applyTranslations({ + localeCode: mockReq.locale as string, + objects: inputObjects, + container: mockContainer as any, + }) + + expect(inputObjects[0].title).toBe("Original Title") + }) + + it("should handle empty input array without errors", async () => { + mockFeatureFlagIsEnabled.mockReturnValue(true) + const inputObjects: Record[] = [] + + await expect( + applyTranslations({ + localeCode: mockReq.locale as string, + objects: inputObjects, + container: mockContainer as any, + }) + ).resolves.not.toThrow() + }) + + it("should not modify properties that do not exist in the object", async () => { + mockFeatureFlagIsEnabled.mockReturnValue(true) + const inputObjects = [{ id: "prod_1", title: "Original" }] + + mockQuery.graph.mockResolvedValue({ + data: [ + { + reference_id: "prod_1", + translations: { description: "Translated Description" }, + }, + ], + }) + + await applyTranslations({ + localeCode: mockReq.locale as string, + objects: inputObjects, + container: mockContainer as any, + }) + + expect(inputObjects[0].title).toBe("Original") + expect(inputObjects[0]).not.toHaveProperty("description") + }) + + it("should handle objects with undefined id gracefully", async () => { + mockFeatureFlagIsEnabled.mockReturnValue(true) + const inputObjects = [{ id: undefined, title: "Original" }] + + mockQuery.graph.mockResolvedValue({ data: [] }) + + await expect( + applyTranslations({ + localeCode: mockReq.locale as string, + objects: inputObjects as any, + container: mockContainer as any, + }) + ).resolves.not.toThrow() + }) + + it("should only apply translations to matching keys", async () => { + mockFeatureFlagIsEnabled.mockReturnValue(true) + const inputObjects = [ + { id: "prod_1", title: "Original Title", handle: "original-handle" }, + ] + + mockQuery.graph.mockResolvedValue({ + data: [ + { + reference_id: "prod_1", + translations: { title: "Translated Title" }, + }, + ], + }) + + await applyTranslations({ + localeCode: mockReq.locale as string, + objects: inputObjects, + container: mockContainer as any, + }) + + expect(inputObjects[0].title).toBe("Translated Title") + expect(inputObjects[0].handle).toBe("original-handle") + }) + + it("should handle deeply nested structures", async () => { + mockFeatureFlagIsEnabled.mockReturnValue(true) + const inputObjects = [ + { + id: "prod_1", + title: "Product", + category: { + id: "cat_1", + name: "Category", + parent: { + id: "cat_parent", + name: "Parent Category", + }, + }, + }, + ] + + mockQuery.graph.mockResolvedValue({ + data: [ + { + reference_id: "prod_1", + translations: { title: "Translated Product" }, + }, + { + reference_id: "cat_1", + translations: { name: "Translated Category" }, + }, + { + reference_id: "cat_parent", + translations: { name: "Translated Parent" }, + }, + ], + }) + + await applyTranslations({ + localeCode: mockReq.locale as string, + objects: inputObjects, + container: mockContainer as any, + }) + + expect(inputObjects[0].title).toBe("Translated Product") + expect(inputObjects[0].category.name).toBe("Translated Category") + expect(inputObjects[0].category.parent.name).toBe("Translated Parent") + }) +}) diff --git a/packages/core/utils/src/translations/apply-translations.ts b/packages/core/utils/src/translations/apply-translations.ts new file mode 100644 index 0000000000..0214130ff6 --- /dev/null +++ b/packages/core/utils/src/translations/apply-translations.ts @@ -0,0 +1,139 @@ +import { MedusaContainer, RemoteQueryFunction } from "@medusajs/types" +import { ContainerRegistrationKeys } from "../common/container" +import { isObject } from "../common/is-object" +import { FeatureFlag } from "../feature-flags/flag-router" + +const excludedKeys = [ + "id", + "created_at", + "updated_at", + "deleted_at", + "metadata", +] + +function canApplyTranslationTo(object: Record) { + return "id" in object && !!object.id +} + +function gatherIds(object: Record, gatheredIds: Set) { + gatheredIds.add(object.id) + Object.entries(object).forEach(([, value]) => { + if (Array.isArray(value)) { + value.forEach((item) => item && gatherIds(item, gatheredIds)) + } else if (isObject(value)) { + gatherIds(value, gatheredIds) + } + }) +} + +function applyTranslation( + object: Record, + entityIdToTranslation: Map> +) { + const translation = entityIdToTranslation.get(object.id) + const hasTranslation = !!translation + + Object.entries(object).forEach(([key, value]) => { + if (excludedKeys.includes(key)) { + return + } + + if (hasTranslation) { + if ( + key in translation && + typeof object[key] === typeof translation[key] + ) { + object[key] = translation[key] + return + } + } + + if (Array.isArray(value)) { + value.forEach( + (item) => + item && + canApplyTranslationTo(item) && + applyTranslation(item, entityIdToTranslation) + ) + } else if (isObject(value) && canApplyTranslationTo(value)) { + applyTranslation(value, entityIdToTranslation) + } + }) +} + +export async function applyTranslations({ + localeCode, + objects, + container, +}: { + localeCode: string | undefined + objects: Record[] + container: MedusaContainer +}) { + const isTranslationEnabled = FeatureFlag.isFeatureEnabled("translation") + + if (!isTranslationEnabled) { + return + } + + const locale = localeCode + + if (!locale) { + return + } + + const objects_ = objects.filter((o) => !!o) + if (!objects_.length) { + return + } + + const gatheredIds: Set = new Set() + + for (const inputObject of objects_) { + gatherIds(inputObject, gatheredIds) + } + + const query = container.resolve( + ContainerRegistrationKeys.QUERY + ) + + const queryBatchSize = 250 + const queryBatches = Math.ceil(gatheredIds.size / queryBatchSize) + + const entityIdToTranslation = new Map>() + + for (let i = 0; i < queryBatches; i++) { + // TODO: concurrently fetch if needed + const queryBatch = Array.from(gatheredIds) + .slice(i * queryBatchSize, (i + 1) * queryBatchSize) + .sort() + + const { data: translations } = await query.graph( + { + entity: "translations", + fields: ["translations", "reference_id"], + filters: { + reference_id: queryBatch, + locale_code: locale, + }, + pagination: { + take: queryBatchSize, + }, + }, + { + cache: { enable: true }, + } + ) + + for (const translation of translations) { + entityIdToTranslation.set( + translation.reference_id, + translation.translations ?? {} + ) + } + } + + for (const inputObject of objects_) { + applyTranslation(inputObject, entityIdToTranslation) + } +} diff --git a/packages/core/utils/src/translations/index.ts b/packages/core/utils/src/translations/index.ts new file mode 100644 index 0000000000..aef62a261b --- /dev/null +++ b/packages/core/utils/src/translations/index.ts @@ -0,0 +1 @@ +export * from "./apply-translations" diff --git a/packages/medusa-test-utils/package.json b/packages/medusa-test-utils/package.json index 1919935891..f5f09d9d85 100644 --- a/packages/medusa-test-utils/package.json +++ b/packages/medusa-test-utils/package.json @@ -25,6 +25,7 @@ "author": "Medusa", "license": "MIT", "devDependencies": { + "@medusajs/core-flows": "2.12.1", "@medusajs/framework": "2.12.1" }, "dependencies": { @@ -35,10 +36,14 @@ "ulid": "^2.3.0" }, "peerDependencies": { + "@medusajs/core-flows": "2.12.1", "@medusajs/framework": "2.12.1", "@medusajs/medusa": "2.12.1" }, "peerDependenciesMeta": { + "@medusajs/core-flows": { + "optional": true + }, "@medusajs/medusa": { "optional": true } diff --git a/packages/medusa-test-utils/src/medusa-test-runner.ts b/packages/medusa-test-utils/src/medusa-test-runner.ts index 25dd509e58..8f370b3b08 100644 --- a/packages/medusa-test-utils/src/medusa-test-runner.ts +++ b/packages/medusa-test-utils/src/medusa-test-runner.ts @@ -21,6 +21,7 @@ import { } from "./medusa-test-runner-utils" import { waitWorkflowExecutions } from "./medusa-test-runner-utils/wait-workflow-executions" import { ulid } from "ulid" +import { createDefaultsWorkflow } from "@medusajs/core-flows" export interface MedusaSuiteOptions { dbConnection: any // knex instance @@ -287,6 +288,7 @@ class MedusaTestRunner { cwd: this.cwd, }) await medusaAppLoader.runModulesLoader() + await createDefaultsWorkflow(copiedContainer).run() } catch (error) { await copiedContainer.dispose?.() logger.error("Error running modules loaders:", error?.message) diff --git a/packages/medusa/package.json b/packages/medusa/package.json index d15f0cbfbb..6d4b4fce84 100644 --- a/packages/medusa/package.json +++ b/packages/medusa/package.json @@ -101,6 +101,7 @@ "@medusajs/store": "2.12.1", "@medusajs/tax": "2.12.1", "@medusajs/telemetry": "2.12.1", + "@medusajs/translation": "2.12.1", "@medusajs/user": "2.12.1", "@medusajs/workflow-engine-inmemory": "2.12.1", "@medusajs/workflow-engine-redis": "2.12.1", diff --git a/packages/medusa/src/api/admin/locales/[code]/route.ts b/packages/medusa/src/api/admin/locales/[code]/route.ts new file mode 100644 index 0000000000..20316c7b83 --- /dev/null +++ b/packages/medusa/src/api/admin/locales/[code]/route.ts @@ -0,0 +1,28 @@ +import { ContainerRegistrationKeys } from "@medusajs/framework/utils" +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { HttpTypes } from "@medusajs/framework/types" + +export const GET = async ( + req: MedusaRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + + const { + data: [locale], + } = await query.graph( + { + entity: "locale", + filters: { + code: req.params.code, + }, + fields: req.queryConfig.fields, + }, + { + cache: { enable: true }, + throwIfKeyNotFound: true, + } + ) + + res.status(200).json({ locale }) +} diff --git a/packages/medusa/src/api/admin/locales/middlewares.ts b/packages/medusa/src/api/admin/locales/middlewares.ts new file mode 100644 index 0000000000..468b696f2d --- /dev/null +++ b/packages/medusa/src/api/admin/locales/middlewares.ts @@ -0,0 +1,27 @@ +import { MiddlewareRoute } from "@medusajs/framework/http" +import { validateAndTransformQuery } from "@medusajs/framework" +import * as QueryConfig from "./query-config" +import { AdminGetLocalesParams, AdminGetLocaleParams } from "./validators" + +export const adminLocalesRoutesMiddlewares: MiddlewareRoute[] = [ + { + method: ["GET"], + matcher: "/admin/locales", + middlewares: [ + validateAndTransformQuery( + AdminGetLocalesParams, + QueryConfig.listTransformQueryConfig + ), + ], + }, + { + method: ["GET"], + matcher: "/admin/locales/:code", + middlewares: [ + validateAndTransformQuery( + AdminGetLocaleParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, +] diff --git a/packages/medusa/src/api/admin/locales/query-config.ts b/packages/medusa/src/api/admin/locales/query-config.ts new file mode 100644 index 0000000000..dbb67f0c08 --- /dev/null +++ b/packages/medusa/src/api/admin/locales/query-config.ts @@ -0,0 +1,12 @@ +export const defaultAdminLocaleFields = ["code", "name"] + +export const retrieveTransformQueryConfig = { + defaults: defaultAdminLocaleFields, + isList: false, +} + +export const listTransformQueryConfig = { + ...retrieveTransformQueryConfig, + defaultLimit: 200, + isList: true, +} diff --git a/packages/medusa/src/api/admin/locales/route.ts b/packages/medusa/src/api/admin/locales/route.ts new file mode 100644 index 0000000000..fbba5cee20 --- /dev/null +++ b/packages/medusa/src/api/admin/locales/route.ts @@ -0,0 +1,29 @@ +import { ContainerRegistrationKeys } from "@medusajs/framework/utils" +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { HttpTypes } from "@medusajs/framework/types" + +export const GET = async ( + req: MedusaRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + + const { data: locales, metadata } = await query.graph( + { + entity: "locale", + filters: req.filterableFields, + fields: req.queryConfig.fields, + pagination: req.queryConfig.pagination, + }, + { + cache: { enable: true }, + } + ) + + res.json({ + locales, + count: metadata?.count ?? 0, + offset: metadata?.skip ?? 0, + limit: metadata?.take ?? 0, + }) +} diff --git a/packages/medusa/src/api/admin/locales/validators.ts b/packages/medusa/src/api/admin/locales/validators.ts new file mode 100644 index 0000000000..d2ae775bb9 --- /dev/null +++ b/packages/medusa/src/api/admin/locales/validators.ts @@ -0,0 +1,18 @@ +import { z } from "zod" +import { createFindParams, createSelectParams } from "../../utils/validators" +import { applyAndAndOrOperators } from "../../utils/common-validators" + +export const AdminGetLocaleParams = createSelectParams() + +export const AdminGetLocalesParamsFields = z.object({ + q: z.string().optional(), + code: z.union([z.string(), z.array(z.string())]).optional(), +}) + +export type AdminGetLocalesParamsType = z.infer +export const AdminGetLocalesParams = createFindParams({ + offset: 0, + limit: 200, +}) + .merge(AdminGetLocalesParamsFields) + .merge(applyAndAndOrOperators(AdminGetLocalesParamsFields)) diff --git a/packages/medusa/src/api/admin/stores/query-config.ts b/packages/medusa/src/api/admin/stores/query-config.ts index 5a53afc3c6..cd1bab7beb 100644 --- a/packages/medusa/src/api/admin/stores/query-config.ts +++ b/packages/medusa/src/api/admin/stores/query-config.ts @@ -3,6 +3,8 @@ export const defaultAdminStoreFields = [ "name", "*supported_currencies", "*supported_currencies.currency", + "*supported_locales", + "*supported_locales.locale_code", "default_sales_channel_id", "default_region_id", "default_location_id", diff --git a/packages/medusa/src/api/admin/stores/validators.ts b/packages/medusa/src/api/admin/stores/validators.ts index b3547d61f4..a9e3cafd4e 100644 --- a/packages/medusa/src/api/admin/stores/validators.ts +++ b/packages/medusa/src/api/admin/stores/validators.ts @@ -31,6 +31,14 @@ export const AdminUpdateStore = z.object({ }) ) .optional(), + supported_locales: z + .array( + z.object({ + locale_code: z.string(), + is_default: z.boolean().optional(), + }) + ) + .optional(), default_sales_channel_id: z.string().nullish(), default_region_id: z.string().nullish(), default_location_id: z.string().nullish(), diff --git a/packages/medusa/src/api/admin/translations/batch/route.ts b/packages/medusa/src/api/admin/translations/batch/route.ts new file mode 100644 index 0000000000..cd7450cd9b --- /dev/null +++ b/packages/medusa/src/api/admin/translations/batch/route.ts @@ -0,0 +1,68 @@ +import { batchTranslationsWorkflow } from "@medusajs/core-flows" +import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework" +import { + ContainerRegistrationKeys, + defineFileConfig, + FeatureFlag, +} from "@medusajs/framework/utils" +import { BatchMethodRequest, HttpTypes } from "@medusajs/types" +import { defaultAdminTranslationFields } from "../query-config" +import { + AdminCreateTranslationType, + AdminUpdateTranslationType, +} from "../validators" +import TranslationFeatureFlag from "../../../../feature-flags/translation" + +export const POST = async ( + req: AuthenticatedMedusaRequest< + BatchMethodRequest + >, + res: MedusaResponse +) => { + const { create = [], update = [], delete: deleteIds = [] } = req.validatedBody + + const { result } = await batchTranslationsWorkflow(req.scope).run({ + input: { + create, + update, + delete: deleteIds, + }, + }) + + const ids = Array.from( + new Set([ + ...result.created.map((t) => t.id), + ...result.updated.map((t) => t.id), + ]) + ) + + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + const { data: translations } = await query.graph({ + entity: "translation", + fields: defaultAdminTranslationFields, + filters: { + id: ids, + }, + }) + + const created = translations.filter((t) => + result.created.some((r) => r.id === t.id) + ) + const updated = translations.filter((t) => + result.updated.some((r) => r.id === t.id) + ) + + return res.status(200).json({ + created, + updated, + deleted: { + ids: deleteIds, + object: "translation", + deleted: true, + }, + }) +} + +defineFileConfig({ + isDisabled: () => !FeatureFlag.isFeatureEnabled(TranslationFeatureFlag.key), +}) diff --git a/packages/medusa/src/api/admin/translations/middlewares.ts b/packages/medusa/src/api/admin/translations/middlewares.ts new file mode 100644 index 0000000000..d68d96a358 --- /dev/null +++ b/packages/medusa/src/api/admin/translations/middlewares.ts @@ -0,0 +1,32 @@ +import { + MiddlewareRoute, + validateAndTransformBody, + validateAndTransformQuery, +} from "@medusajs/framework" +import { + AdminBatchTranslations, + AdminGetTranslationsParams, +} from "./validators" +import * as QueryConfig from "./query-config" +import { DEFAULT_BATCH_ENDPOINTS_SIZE_LIMIT } from "../../../utils" + +export const adminTranslationsRoutesMiddlewares: MiddlewareRoute[] = [ + { + method: ["GET"], + matcher: "/admin/translations", + middlewares: [ + validateAndTransformQuery( + AdminGetTranslationsParams, + QueryConfig.listTransformQueryConfig + ), + ], + }, + { + method: ["POST"], + matcher: "/admin/translations/batch", + bodyParser: { + sizeLimit: DEFAULT_BATCH_ENDPOINTS_SIZE_LIMIT, + }, + middlewares: [validateAndTransformBody(AdminBatchTranslations)], + }, +] diff --git a/packages/medusa/src/api/admin/translations/query-config.ts b/packages/medusa/src/api/admin/translations/query-config.ts new file mode 100644 index 0000000000..375633f91b --- /dev/null +++ b/packages/medusa/src/api/admin/translations/query-config.ts @@ -0,0 +1,17 @@ +export const defaultAdminTranslationFields = [ + "id", + "reference_id", + "reference", + "locale_code", + "translations", +] + +export const retrieveTransformQueryConfig = { + defaults: defaultAdminTranslationFields, + isList: false, +} + +export const listTransformQueryConfig = { + ...retrieveTransformQueryConfig, + isList: true, +} diff --git a/packages/medusa/src/api/admin/translations/route.ts b/packages/medusa/src/api/admin/translations/route.ts new file mode 100644 index 0000000000..a806951eda --- /dev/null +++ b/packages/medusa/src/api/admin/translations/route.ts @@ -0,0 +1,38 @@ +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { HttpTypes } from "@medusajs/framework/types" +import { + ContainerRegistrationKeys, + defineFileConfig, + FeatureFlag, +} from "@medusajs/framework/utils" +import TranslationFeatureFlag from "../../../feature-flags/translation" + +export const GET = async ( + req: MedusaRequest, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + + const { data: translations, metadata } = await query.graph( + { + entity: "translation", + fields: req.queryConfig.fields, + filters: req.filterableFields, + pagination: req.queryConfig.pagination, + }, + { + cache: { enable: true }, + } + ) + + return res.status(200).json({ + translations, + count: metadata?.count ?? 0, + offset: metadata?.skip ?? 0, + limit: metadata?.take ?? 0, + }) +} + +defineFileConfig({ + isDisabled: () => !FeatureFlag.isFeatureEnabled(TranslationFeatureFlag.key), +}) diff --git a/packages/medusa/src/api/admin/translations/validators.ts b/packages/medusa/src/api/admin/translations/validators.ts new file mode 100644 index 0000000000..2eb3396b0a --- /dev/null +++ b/packages/medusa/src/api/admin/translations/validators.ts @@ -0,0 +1,50 @@ +import { applyAndAndOrOperators } from "../../utils/common-validators" +import { + createBatchBody, + createFindParams, + createSelectParams, +} from "../../utils/validators" +import { z } from "zod" + +export const AdminGetTranslationParams = createSelectParams() + +export const AdminGetTranslationParamsFields = z.object({ + q: z.string().optional(), + reference_id: z.union([z.string(), z.array(z.string())]).optional(), + reference: z.string().optional(), + locale_code: z.string().optional(), +}) + +export type AdminGetTranslationsParamsType = z.infer< + typeof AdminGetTranslationsParams +> + +export const AdminGetTranslationsParams = createFindParams({ + limit: 20, + offset: 0, +}) + .merge(AdminGetTranslationParamsFields) + .merge(applyAndAndOrOperators(AdminGetTranslationParamsFields)) + +export type AdminCreateTranslationType = z.infer +export const AdminCreateTranslation = z.object({ + reference_id: z.string(), + reference: z.string(), + locale_code: z.string(), + translations: z.record(z.string()), +}) + +export type AdminUpdateTranslationType = z.infer +export const AdminUpdateTranslation = z.object({ + id: z.string(), + reference_id: z.string().optional(), + reference: z.string().optional(), + locale_code: z.string().optional(), + translations: z.record(z.string()).optional(), +}) + +export type AdminBatchTranslationsType = z.infer +export const AdminBatchTranslations = createBatchBody( + AdminCreateTranslation, + AdminUpdateTranslation +) diff --git a/packages/medusa/src/api/middlewares.ts b/packages/medusa/src/api/middlewares.ts index 7f0e85422c..35fb554d47 100644 --- a/packages/medusa/src/api/middlewares.ts +++ b/packages/medusa/src/api/middlewares.ts @@ -66,6 +66,8 @@ import { storeReturnReasonRoutesMiddlewares } from "./store/return-reasons/middl import { storeShippingOptionRoutesMiddlewares } from "./store/shipping-options/middlewares" import { adminShippingOptionTypeRoutesMiddlewares } from "./admin/shipping-option-types/middlewares" import { adminIndexRoutesMiddlewares } from "./admin/index/middlewares" +import { adminLocalesRoutesMiddlewares } from "./admin/locales/middlewares" +import { adminTranslationsRoutesMiddlewares } from "./admin/translations/middlewares" export default defineMiddlewares([ ...storeRoutesMiddlewares, @@ -94,10 +96,12 @@ export default defineMiddlewares([ ...adminInviteRoutesMiddlewares, ...adminTaxRateRoutesMiddlewares, ...adminTaxRegionRoutesMiddlewares, + ...adminTranslationsRoutesMiddlewares, ...adminApiKeyRoutesMiddlewares, ...hooksRoutesMiddlewares, ...adminStoreRoutesMiddlewares, ...adminCurrencyRoutesMiddlewares, + ...adminLocalesRoutesMiddlewares, ...storeCurrencyRoutesMiddlewares, ...adminProductRoutesMiddlewares, ...adminPaymentRoutesMiddlewares, diff --git a/packages/medusa/src/api/store/products/[id]/route.ts b/packages/medusa/src/api/store/products/[id]/route.ts index 38c9e9caee..c1eba9fd46 100644 --- a/packages/medusa/src/api/store/products/[id]/route.ts +++ b/packages/medusa/src/api/store/products/[id]/route.ts @@ -1,6 +1,11 @@ import { MedusaResponse } from "@medusajs/framework/http" import { HttpTypes } from "@medusajs/framework/types" -import { isPresent, MedusaError, QueryContext } from "@medusajs/framework/utils" +import { + applyTranslations, + isPresent, + MedusaError, + QueryContext, +} from "@medusajs/framework/utils" import { wrapVariantsWithInventoryQuantityForSalesChannel } from "../../../utils/middlewares" import { filterOutInternalProductCategories, @@ -69,5 +74,10 @@ export const GET = async ( } await wrapProductsWithTaxPrices(req, [product]) + await applyTranslations({ + localeCode: req.locale, + objects: [product], + container: req.scope, + }) res.json({ product }) } diff --git a/packages/medusa/src/api/store/products/middlewares.ts b/packages/medusa/src/api/store/products/middlewares.ts index 1a2f6d3d0e..a2d33dbcb1 100644 --- a/packages/medusa/src/api/store/products/middlewares.ts +++ b/packages/medusa/src/api/store/products/middlewares.ts @@ -1,6 +1,7 @@ import { validateAndTransformQuery } from "@medusajs/framework" import { applyDefaultFilters, + applyLocale, applyParamsAsFilters, authenticate, clearFiltersByKey, @@ -63,6 +64,10 @@ async function applyMaybeLinkFilterIfNecessary( } export const storeProductRoutesMiddlewares: MiddlewareRoute[] = [ + { + matcher: "/store/products/*", + middlewares: [applyLocale], + }, { method: ["GET"], matcher: "/store/products", diff --git a/packages/medusa/src/api/store/products/route.ts b/packages/medusa/src/api/store/products/route.ts index edf17588de..1f165b80d3 100644 --- a/packages/medusa/src/api/store/products/route.ts +++ b/packages/medusa/src/api/store/products/route.ts @@ -1,6 +1,7 @@ import { MedusaResponse } from "@medusajs/framework/http" import { HttpTypes, QueryContextType } from "@medusajs/framework/types" import { + applyTranslations, ContainerRegistrationKeys, FeatureFlag, isPresent, @@ -86,6 +87,13 @@ async function getProductsWithIndexEngine( } await wrapProductsWithTaxPrices(req, products) + + await applyTranslations({ + localeCode: req.locale, + objects: products, + container: req.scope, + }) + res.json({ products, count: metadata!.estimate_count, @@ -141,6 +149,13 @@ async function getProducts( } await wrapProductsWithTaxPrices(req, products) + + await applyTranslations({ + localeCode: req.locale, + objects: products, + container: req.scope, + }) + res.json({ products, count: metadata!.count, diff --git a/packages/medusa/src/feature-flags/translation.ts b/packages/medusa/src/feature-flags/translation.ts new file mode 100644 index 0000000000..6387b6edee --- /dev/null +++ b/packages/medusa/src/feature-flags/translation.ts @@ -0,0 +1,10 @@ +import { FlagSettings } from "@medusajs/framework/feature-flags" + +const TranslationFeatureFlag: FlagSettings = { + key: "translation", + default_val: false, + env_key: "MEDUSA_FF_TRANSLATION", + description: "Enable multi-language support and entity translations", +} + +export default TranslationFeatureFlag diff --git a/packages/medusa/src/modules/translation.ts b/packages/medusa/src/modules/translation.ts new file mode 100644 index 0000000000..5f3cafdff7 --- /dev/null +++ b/packages/medusa/src/modules/translation.ts @@ -0,0 +1,6 @@ +import TranslationModule from "@medusajs/translation" + +export * from "@medusajs/translation" + +export default TranslationModule +export const discoveryPath = require.resolve("@medusajs/translation") diff --git a/packages/modules/link-modules/src/definitions/readonly/index.ts b/packages/modules/link-modules/src/definitions/readonly/index.ts index c8fcb3e1c2..1bf9a12ebd 100644 --- a/packages/modules/link-modules/src/definitions/readonly/index.ts +++ b/packages/modules/link-modules/src/definitions/readonly/index.ts @@ -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" diff --git a/packages/modules/link-modules/src/definitions/readonly/product-translation.ts b/packages/modules/link-modules/src/definitions/readonly/product-translation.ts new file mode 100644 index 0000000000..8dcf419c93 --- /dev/null +++ b/packages/modules/link-modules/src/definitions/readonly/product-translation.ts @@ -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 diff --git a/packages/modules/link-modules/src/definitions/readonly/store-locale.ts b/packages/modules/link-modules/src/definitions/readonly/store-locale.ts new file mode 100644 index 0000000000..81eff3b6ec --- /dev/null +++ b/packages/modules/link-modules/src/definitions/readonly/store-locale.ts @@ -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 diff --git a/packages/modules/link-modules/src/initialize/index.ts b/packages/modules/link-modules/src/initialize/index.ts index 937890a311..a74adb5209 100644 --- a/packages/modules/link-modules/src/initialize/index.ts +++ b/packages/modules/link-modules/src/initialize/index.ts @@ -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) => { diff --git a/packages/modules/store/integration-tests/__fixtures__/index.ts b/packages/modules/store/integration-tests/__fixtures__/index.ts index 2b65438d17..7088c063aa 100644 --- a/packages/modules/store/integration-tests/__fixtures__/index.ts +++ b/packages/modules/store/integration-tests/__fixtures__/index.ts @@ -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: { diff --git a/packages/modules/store/integration-tests/__tests__/store-module-service.spec.ts b/packages/modules/store/integration-tests/__tests__/store-module-service.spec.ts index 40e1c33fc8..0d04ca15cd 100644 --- a/packages/modules/store/integration-tests/__tests__/store-module-service.spec.ts +++ b/packages/modules/store/integration-tests/__tests__/store-module-service.spec.ts @@ -15,7 +15,11 @@ moduleIntegrationTestRunner({ 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({ field: "storeCurrency", }, }, + storeLocale: { + id: { + linkable: "store_locale_id", + entity: "StoreLocale", + primaryKey: "id", + serviceName: "store", + field: "storeLocale", + }, + }, }) }) @@ -54,6 +67,10 @@ moduleIntegrationTestRunner({ 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({ "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({ ) }) + 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({ 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({ 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", () => { diff --git a/packages/modules/store/src/migrations/.snapshot-medusa-store.json b/packages/modules/store/src/migrations/.snapshot-medusa-store.json index cd4b7579cf..30f592ce9d 100644 --- a/packages/modules/store/src/migrations/.snapshot-medusa-store.json +++ b/packages/modules/store/src/migrations/.snapshot-medusa-store.json @@ -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": {} } diff --git a/packages/modules/store/src/migrations/Migration20251202184737.ts b/packages/modules/store/src/migrations/Migration20251202184737.ts new file mode 100644 index 0000000000..8687cd502e --- /dev/null +++ b/packages/modules/store/src/migrations/Migration20251202184737.ts @@ -0,0 +1,17 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20251202184737 extends Migration { + + override async up(): Promise { + 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 { + this.addSql(`drop table if exists "store_locale" cascade;`); + } + +} diff --git a/packages/modules/store/src/models/index.ts b/packages/modules/store/src/models/index.ts index bca1be4340..456cea3525 100644 --- a/packages/modules/store/src/models/index.ts +++ b/packages/modules/store/src/models/index.ts @@ -1,2 +1,3 @@ export { default as Store } from "./store" export { default as StoreCurrency } from "./currency" +export { default as StoreLocale } from "./locale" diff --git a/packages/modules/store/src/models/locale.ts b/packages/modules/store/src/models/locale.ts new file mode 100644 index 0000000000..88806a6f2c --- /dev/null +++ b/packages/modules/store/src/models/locale.ts @@ -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 diff --git a/packages/modules/store/src/models/store.ts b/packages/modules/store/src/models/store.ts index 7a19e130ae..23159f7812 100644 --- a/packages/modules/store/src/models/store.ts +++ b/packages/modules/store/src/models/store.ts @@ -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 diff --git a/packages/modules/store/src/services/store-module-service.ts b/packages/modules/store/src/services/store-module-service.ts index cf3e63d693..33ceafc85d 100644 --- a/packages/modules/store/src/services/store-module-service.ts +++ b/packages/modules/store/src/services/store-module-service.ts @@ -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( + 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` + ) } } diff --git a/packages/modules/translation/.gitignore b/packages/modules/translation/.gitignore new file mode 100644 index 0000000000..874c6c69d3 --- /dev/null +++ b/packages/modules/translation/.gitignore @@ -0,0 +1,6 @@ +/dist +node_modules +.DS_store +.env* +.env +*.sql diff --git a/packages/modules/translation/integration-tests/__fixtures__/index.ts b/packages/modules/translation/integration-tests/__fixtures__/index.ts new file mode 100644 index 0000000000..c11ffd5ae8 --- /dev/null +++ b/packages/modules/translation/integration-tests/__fixtures__/index.ts @@ -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", + }, +} diff --git a/packages/modules/translation/integration-tests/__tests__/translation-module-service.spec.ts b/packages/modules/translation/integration-tests/__tests__/translation-module-service.spec.ts new file mode 100644 index 0000000000..b27ec72a63 --- /dev/null +++ b/packages/modules/translation/integration-tests/__tests__/translation-module-service.spec.ts @@ -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({ + 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 + ) + 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 + ) + 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) + }) + }) + }) + }) + }, +}) diff --git a/packages/modules/translation/jest.config.js b/packages/modules/translation/jest.config.js new file mode 100644 index 0000000000..d0ba24e1e1 --- /dev/null +++ b/packages/modules/translation/jest.config.js @@ -0,0 +1,15 @@ +const defineJestConfig = require("../../../define_jest_config") +module.exports = defineJestConfig({ + moduleNameMapper: { + "^@models$": "/src/models", + "^@models/(.*)$": "/src/models/$1", + "^@services$": "/src/services", + "^@services/(.*)$": "/src/services/$1", + "^@repositories$": "/src/repositories", + "^@repositories/(.*)$": "/src/repositories/$1", + "^@types$": "/src/types", + "^@types/(.*)$": "/src/types/$1", + "^@utils$": "/src/utils", + "^@utils/(.*)$": "/src/utils/$1", + }, +}) diff --git a/packages/modules/translation/mikro-orm.config.dev.ts b/packages/modules/translation/mikro-orm.config.dev.ts new file mode 100644 index 0000000000..d3ba9b7d88 --- /dev/null +++ b/packages/modules/translation/mikro-orm.config.dev.ts @@ -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], +}) diff --git a/packages/modules/translation/package.json b/packages/modules/translation/package.json new file mode 100644 index 0000000000..578fcc091b --- /dev/null +++ b/packages/modules/translation/package.json @@ -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" + } +} diff --git a/packages/modules/translation/src/index.ts b/packages/modules/translation/src/index.ts new file mode 100644 index 0000000000..98838ed94c --- /dev/null +++ b/packages/modules/translation/src/index.ts @@ -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], +}) diff --git a/packages/modules/translation/src/loaders/defaults.ts b/packages/modules/translation/src/loaders/defaults.ts new file mode 100644 index 0000000000..ab05f516f6 --- /dev/null +++ b/packages/modules/translation/src/loaders/defaults.ts @@ -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 => { + const logger = + container.resolve(ContainerRegistrationKeys.LOGGER) ?? console + const localeService_: ModulesSdkTypes.IMedusaInternalService = + 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}` + ) + } +} diff --git a/packages/modules/translation/src/migrations/.snapshot-medusa-translation.json b/packages/modules/translation/src/migrations/.snapshot-medusa-translation.json new file mode 100644 index 0000000000..1aed639d90 --- /dev/null +++ b/packages/modules/translation/src/migrations/.snapshot-medusa-translation.json @@ -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": {} +} diff --git a/packages/modules/translation/src/migrations/Migration20251208124155.ts b/packages/modules/translation/src/migrations/Migration20251208124155.ts new file mode 100644 index 0000000000..7909acfe4a --- /dev/null +++ b/packages/modules/translation/src/migrations/Migration20251208124155.ts @@ -0,0 +1,49 @@ +import { Migration } from "@medusajs/framework/mikro-orm/migrations" + +export class Migration20251208124155 extends Migration { + override async up(): Promise { + 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 { + this.addSql(`drop table if exists "locale" cascade;`) + + this.addSql(`drop table if exists "translation" cascade;`) + } +} diff --git a/packages/modules/translation/src/models/locale.ts b/packages/modules/translation/src/models/locale.ts new file mode 100644 index 0000000000..c148c84f1d --- /dev/null +++ b/packages/modules/translation/src/models/locale.ts @@ -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 diff --git a/packages/modules/translation/src/models/translation.ts b/packages/modules/translation/src/models/translation.ts new file mode 100644 index 0000000000..600349e271 --- /dev/null +++ b/packages/modules/translation/src/models/translation.ts @@ -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 diff --git a/packages/modules/translation/src/services/translation-module.ts b/packages/modules/translation/src/services/translation-module.ts new file mode 100644 index 0000000000..01c5dc6141 --- /dev/null +++ b/packages/modules/translation/src/services/translation-module.ts @@ -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 + localeService: ModulesSdkTypes.IMedusaInternalService +} + +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 = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise { + 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 = {}, + @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( + results + ), + count, + ] + } + + // @ts-expect-error + createLocales( + data: TranslationTypes.CreateLocaleDTO[], + sharedContext?: Context + ): Promise + // @ts-expect-error + createLocales( + data: TranslationTypes.CreateLocaleDTO, + sharedContext?: Context + ): Promise + + @InjectManager() + @EmitEvents() + // @ts-expect-error + async createLocales( + data: TranslationTypes.CreateLocaleDTO | TranslationTypes.CreateLocaleDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + 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( + createdLocales + ) + return Array.isArray(data) ? serialized : serialized[0] + } + + // @ts-expect-error + createTranslations( + data: CreateTranslationDTO, + sharedContext?: Context + ): Promise + + // @ts-expect-error + createTranslations( + data: CreateTranslationDTO[], + sharedContext?: Context + ): Promise + + @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] + } +} diff --git a/packages/modules/translation/tsconfig.json b/packages/modules/translation/tsconfig.json new file mode 100644 index 0000000000..f208cf7f44 --- /dev/null +++ b/packages/modules/translation/tsconfig.json @@ -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/*"] + } + } +} diff --git a/yarn.lock b/yarn.lock index 865118e3c5..665c81da10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3799,6 +3799,7 @@ __metadata: "@medusajs/store": 2.12.1 "@medusajs/tax": 2.12.1 "@medusajs/telemetry": 2.12.1 + "@medusajs/translation": 2.12.1 "@medusajs/user": 2.12.1 "@medusajs/workflow-engine-inmemory": 2.12.1 "@medusajs/workflow-engine-redis": 2.12.1 @@ -4050,6 +4051,7 @@ __metadata: version: 0.0.0-use.local resolution: "@medusajs/test-utils@workspace:packages/medusa-test-utils" dependencies: + "@medusajs/core-flows": 2.12.1 "@medusajs/framework": 2.12.1 "@types/express": ^4.17.21 axios: ^1.13.1 @@ -4057,9 +4059,12 @@ __metadata: get-port: ^5.1.1 ulid: ^2.3.0 peerDependencies: + "@medusajs/core-flows": 2.12.1 "@medusajs/framework": 2.12.1 "@medusajs/medusa": 2.12.1 peerDependenciesMeta: + "@medusajs/core-flows": + optional: true "@medusajs/medusa": optional: true languageName: unknown @@ -4085,6 +4090,17 @@ __metadata: languageName: unknown linkType: soft +"@medusajs/translation@2.12.1, @medusajs/translation@workspace:*, @medusajs/translation@workspace:packages/modules/translation": + version: 0.0.0-use.local + resolution: "@medusajs/translation@workspace:packages/modules/translation" + dependencies: + "@medusajs/framework": 2.12.1 + "@medusajs/test-utils": 2.12.1 + peerDependencies: + "@medusajs/framework": 2.12.1 + languageName: unknown + linkType: soft + "@medusajs/types@2.12.1, @medusajs/types@workspace:^, @medusajs/types@workspace:packages/core/types": version: 0.0.0-use.local resolution: "@medusajs/types@workspace:packages/core/types" @@ -18372,6 +18388,7 @@ __metadata: "@medusajs/store": "workspace:^" "@medusajs/tax": "workspace:^" "@medusajs/test-utils": "workspace:*" + "@medusajs/translation": "workspace:*" "@medusajs/types": "workspace:^" "@medusajs/user": "workspace:^" "@medusajs/utils": "workspace:^"