feat(): Introduce translation module and preliminary application of them (#14189)
* feat(): Translation first steps * feat(): locale middleware * feat(): readonly links * feat(): feature flag * feat(): modules sdk * feat(): translation module re export * start adding workflows * update typings * update typings * test(): Add integration tests * test(): centralize filters preparation * test(): centralize filters preparation * remove unnecessary importy * fix workflows * Define StoreLocale inside Store Module * Link definition to extend Store with supported_locales * store_locale migration * Add supported_locales handling in Store Module * Tests * Accept supported_locales in Store endpoints * Add locales to js-sdk * Include locale list and default locale in Store Detail section * Initialize local namespace in js-sdk * Add locales route * Make code primary key of locale table to facilitate upserts * Add locales routes * Show locale code as is * Add list translations api route * Batch endpoint * Types * New batchTranslationsWorkflow and various updates to existent ones * Edit default locale UI * WIP * Apply translation agnostically * middleware * Apply translation agnostically * fix Apply translation agnostically * apply translations to product list * Add feature flag * fetch translations by batches of 250 max * fix apply * improve and test util * apply to product list * dont manage translations if no locale * normalize locale * potential todo * Protect translations routes with feature flag * Extract normalize locale util to core/utils * Normalize locale on write * Normalize locale for read * Use feature flag to guard translations UI across the board * Avoid throwing incorrectly when locale_code not present in partial updates * move applyTranslations util * remove old tests * fix util tests * fix(): product end points * cleanup * update lock * remove unused var * cleanup * fix apply locale * missing new dep for test utils * Change entity_type, entity_id to reference, reference_id * Remove comment * Avoid registering translations route if ff not enabled * Prevent registering express handler for disabled route via defineFileConfig * Add tests * Add changeset * Update test * fix integration tests, module and internals * Add locale id plus fixed * Allow to pass array of reference_id * fix unit tests * fix link loading * fix store route * fix sales channel test * fix tests --------- Co-authored-by: Nicolas Gorga <nicogorga11@gmail.com> Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
fea3d4ec49
commit
6dc0b8bed8
@@ -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",
|
||||
|
||||
28
packages/medusa/src/api/admin/locales/[code]/route.ts
Normal file
28
packages/medusa/src/api/admin/locales/[code]/route.ts
Normal file
@@ -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<HttpTypes.AdminLocaleParams>,
|
||||
res: MedusaResponse<HttpTypes.AdminLocaleResponse>
|
||||
) => {
|
||||
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 })
|
||||
}
|
||||
27
packages/medusa/src/api/admin/locales/middlewares.ts
Normal file
27
packages/medusa/src/api/admin/locales/middlewares.ts
Normal file
@@ -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
|
||||
),
|
||||
],
|
||||
},
|
||||
]
|
||||
12
packages/medusa/src/api/admin/locales/query-config.ts
Normal file
12
packages/medusa/src/api/admin/locales/query-config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const defaultAdminLocaleFields = ["code", "name"]
|
||||
|
||||
export const retrieveTransformQueryConfig = {
|
||||
defaults: defaultAdminLocaleFields,
|
||||
isList: false,
|
||||
}
|
||||
|
||||
export const listTransformQueryConfig = {
|
||||
...retrieveTransformQueryConfig,
|
||||
defaultLimit: 200,
|
||||
isList: true,
|
||||
}
|
||||
29
packages/medusa/src/api/admin/locales/route.ts
Normal file
29
packages/medusa/src/api/admin/locales/route.ts
Normal file
@@ -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<HttpTypes.AdminLocaleListParams>,
|
||||
res: MedusaResponse<HttpTypes.AdminLocaleListResponse>
|
||||
) => {
|
||||
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,
|
||||
})
|
||||
}
|
||||
18
packages/medusa/src/api/admin/locales/validators.ts
Normal file
18
packages/medusa/src/api/admin/locales/validators.ts
Normal file
@@ -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<typeof AdminGetLocalesParams>
|
||||
export const AdminGetLocalesParams = createFindParams({
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
})
|
||||
.merge(AdminGetLocalesParamsFields)
|
||||
.merge(applyAndAndOrOperators(AdminGetLocalesParamsFields))
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
68
packages/medusa/src/api/admin/translations/batch/route.ts
Normal file
68
packages/medusa/src/api/admin/translations/batch/route.ts
Normal file
@@ -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<AdminCreateTranslationType, AdminUpdateTranslationType>
|
||||
>,
|
||||
res: MedusaResponse<HttpTypes.AdminTranslationsBatchResponse>
|
||||
) => {
|
||||
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),
|
||||
})
|
||||
32
packages/medusa/src/api/admin/translations/middlewares.ts
Normal file
32
packages/medusa/src/api/admin/translations/middlewares.ts
Normal file
@@ -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)],
|
||||
},
|
||||
]
|
||||
17
packages/medusa/src/api/admin/translations/query-config.ts
Normal file
17
packages/medusa/src/api/admin/translations/query-config.ts
Normal file
@@ -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,
|
||||
}
|
||||
38
packages/medusa/src/api/admin/translations/route.ts
Normal file
38
packages/medusa/src/api/admin/translations/route.ts
Normal file
@@ -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<HttpTypes.AdminTranslationsListParams>,
|
||||
res: MedusaResponse<HttpTypes.AdminTranslationsListResponse>
|
||||
) => {
|
||||
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),
|
||||
})
|
||||
50
packages/medusa/src/api/admin/translations/validators.ts
Normal file
50
packages/medusa/src/api/admin/translations/validators.ts
Normal file
@@ -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<typeof AdminCreateTranslation>
|
||||
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<typeof AdminUpdateTranslation>
|
||||
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<typeof AdminBatchTranslations>
|
||||
export const AdminBatchTranslations = createBatchBody(
|
||||
AdminCreateTranslation,
|
||||
AdminUpdateTranslation
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
10
packages/medusa/src/feature-flags/translation.ts
Normal file
10
packages/medusa/src/feature-flags/translation.ts
Normal file
@@ -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
|
||||
6
packages/medusa/src/modules/translation.ts
Normal file
6
packages/medusa/src/modules/translation.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import TranslationModule from "@medusajs/translation"
|
||||
|
||||
export * from "@medusajs/translation"
|
||||
|
||||
export default TranslationModule
|
||||
export const discoveryPath = require.resolve("@medusajs/translation")
|
||||
Reference in New Issue
Block a user