diff --git a/.changeset/gentle-bees-grow.md b/.changeset/gentle-bees-grow.md new file mode 100644 index 0000000000..18dcb71858 --- /dev/null +++ b/.changeset/gentle-bees-grow.md @@ -0,0 +1,9 @@ +--- +"@medusajs/medusa": patch +"@medusajs/translation": patch +"@medusajs/types": patch +"@medusajs/js-sdk": patch +"@medusajs/dashboard": patch +--- + +feat(): Translation settings + user configuration + js/admin diff --git a/integration-tests/http/__tests__/translation/admin/translation.spec.ts b/integration-tests/http/__tests__/translation/admin/translation.spec.ts index 5ec7e62f2c..db3a199f56 100644 --- a/integration-tests/http/__tests__/translation/admin/translation.spec.ts +++ b/integration-tests/http/__tests__/translation/admin/translation.spec.ts @@ -891,7 +891,6 @@ medusaIntegrationTestRunner({ }) it("should validate required fields", async () => { - // Missing locales const response1 = await api .get( "/admin/translations/statistics?entity_types=product", @@ -901,14 +900,12 @@ medusaIntegrationTestRunner({ expect(response1.status).toEqual(400) - // Missing entity_types const response2 = await api .get("/admin/translations/statistics?locales=fr-FR", adminHeaders) .catch((e) => e.response) expect(response2.status).toEqual(400) - // Both missing const response3 = await api .get("/admin/translations/statistics", adminHeaders) .catch((e) => e.response) @@ -916,6 +913,123 @@ medusaIntegrationTestRunner({ expect(response3.status).toEqual(400) }) }) + + describe("GET /admin/translations/entities", () => { + it("should return entities with only translatable fields", async () => { + const productModule = appContainer.resolve(Modules.PRODUCT) + await productModule.createProducts([ + { + title: "Product 1", + description: "Description 1", + handle: "product-1", + status: "published", + }, + { + title: "Product 2", + description: "Description 2", + handle: "product-2", + status: "draft", + }, + ]) + + const response = await api.get( + "/admin/translations/entities?type=product", + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.data).toHaveLength(2) + expect(response.data.count).toEqual(2) + expect(response.data.offset).toEqual(0) + expect(response.data.limit).toEqual(20) + + response.data.data.forEach((entity: Record) => { + expect(entity).toHaveProperty("id") + expect(entity.title).toBeDefined() + expect(entity.description).toBeDefined() + expect(entity.material).toBeDefined() + expect(entity.status).not.toBeDefined() + }) + }) + + it("should return empty array for unknown entity type", async () => { + const response = await api.get( + "/admin/translations/entities?type=unknown_entity", + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.data).toEqual([]) + expect(response.data.count).toEqual(0) + }) + + it("should support pagination", async () => { + const productModule = appContainer.resolve(Modules.PRODUCT) + await productModule.createProducts([ + { title: "Product 1" }, + { title: "Product 2" }, + { title: "Product 3" }, + { title: "Product 4" }, + { title: "Product 5" }, + ]) + + const response = await api.get( + "/admin/translations/entities?type=product&limit=2&offset=0", + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.data).toHaveLength(2) + expect(response.data.count).toEqual(5) + expect(response.data.limit).toEqual(2) + expect(response.data.offset).toEqual(0) + + const response2 = await api.get( + "/admin/translations/entities?type=product&limit=2&offset=2", + adminHeaders + ) + + expect(response2.status).toEqual(200) + expect(response2.data.data).toHaveLength(2) + expect(response2.data.offset).toEqual(2) + }) + + it("should return product variants with their translatable fields", async () => { + const productModule = appContainer.resolve(Modules.PRODUCT) + await productModule.createProducts([ + { + title: "Product with variants", + variants: [ + { title: "Variant 1", manage_inventory: false }, + { title: "Variant 2", manage_inventory: false }, + ], + }, + ]) + + const response = await api.get( + "/admin/translations/entities?type=product_variant", + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.data).toHaveLength(2) + expect(response.data.count).toEqual(2) + + response.data.data.forEach((variant: Record) => { + expect(variant).toHaveProperty("id") + expect(variant.title).toBeDefined() + expect(variant.manage_inventory).not.toBeDefined() + }) + }) + + it("should validate required type parameter", async () => { + const response = await api + .get("/admin/translations/entities", adminHeaders) + .catch((e) => e.response) + + expect(response.status).toEqual(400) + }) + }) }) }, }) diff --git a/packages/admin/dashboard/src/hooks/api/translations.tsx b/packages/admin/dashboard/src/hooks/api/translations.tsx index e2b5d65d9b..a7ca25c181 100644 --- a/packages/admin/dashboard/src/hooks/api/translations.tsx +++ b/packages/admin/dashboard/src/hooks/api/translations.tsx @@ -2,25 +2,16 @@ import { FetchError } from "@medusajs/js-sdk" import { HttpTypes } from "@medusajs/types" import { QueryKey, + useInfiniteQuery, UseInfiniteQueryOptions, - UseInfiniteQueryResult, useMutation, UseMutationOptions, useQuery, UseQueryOptions, } from "@tanstack/react-query" import { sdk } from "../../lib/client" -import { queryKeysFactory } from "../../lib/query-key-factory" import { queryClient } from "../../lib/query-client" -import { productsQueryKeys, useInfiniteProducts } from "./products" -import { - productVariantQueryKeys, - useInfiniteVariants, -} from "./product-variants" -import { categoriesQueryKeys, useInfiniteCategories } from "./categories" -import { collectionsQueryKeys, useInfiniteCollections } from "./collections" -import { productTagsQueryKeys, useInfiniteProductTags } from "./tags" -import { productTypesQueryKeys, useInfiniteProductTypes } from "./product-types" +import { queryKeysFactory } from "../../lib/query-key-factory" const TRANSLATIONS_QUERY_KEY = "translations" as const export const translationsQueryKeys = queryKeysFactory(TRANSLATIONS_QUERY_KEY) @@ -35,177 +26,77 @@ export const translationStatisticsQueryKeys = queryKeysFactory( TRANSLATION_STATISTICS_QUERY_KEY ) +const TRANSLATION_ENTITIES_QUERY_KEY = "translation_entities" as const +export const translationEntitiesQueryKeys = queryKeysFactory( + TRANSLATION_ENTITIES_QUERY_KEY +) + +const DEFAULT_PAGE_SIZE = 20 + +/** + * Hook to fetch entities with their translatable fields and all translations. + * Uses the /admin/translations/entities endpoint which returns entities + * with all their translations for all locales. + * + * @param reference - The entity type (e.g., "product", "product_variant") + * @param referenceId - Optional ID(s) to filter specific entities + * @param options - React Query options + */ export const useReferenceTranslations = ( reference: string, - translatableFields: string[], referenceId?: string | string[], options?: Omit< - UseInfiniteQueryOptions, + UseInfiniteQueryOptions< + HttpTypes.AdminTranslationEntitiesResponse, + FetchError, + { + pages: HttpTypes.AdminTranslationEntitiesResponse[] + pageParams: number[] + }, + HttpTypes.AdminTranslationEntitiesResponse, + QueryKey, + number + >, "queryFn" | "queryKey" | "initialPageParam" | "getNextPageParam" > ) => { - const referenceHookMap = new Map< - string, - () => Omit, "data"> & { - data: { - translations: HttpTypes.AdminTranslation[] - references: (Record & { id: string })[] - count: number - } - } - >([ - [ - "product", - () => { - const fields = translatableFields.concat(["translations.*"]).join(",") + const { data, ...rest } = useInfiniteQuery({ + queryKey: translationEntitiesQueryKeys.list({ + type: reference, + id: referenceId, + }), + queryFn: async ({ pageParam = 0 }) => { + return sdk.admin.translation.entities({ + type: reference, + id: referenceId, + limit: DEFAULT_PAGE_SIZE, + offset: pageParam, + }) + }, + initialPageParam: 0, + getNextPageParam: (lastPage) => { + const nextOffset = lastPage.offset + lastPage.limit + return nextOffset < lastPage.count ? nextOffset : undefined + }, + ...options, + }) - const { data, ...rest } = useInfiniteProducts( - { fields, id: referenceId ?? [] }, - options - ) - const products = data?.pages.flatMap((page) => page.products) ?? [] + const entitiesWithTranslations = + data?.pages.flatMap((page) => page.data) ?? [] + const translations = entitiesWithTranslations.flatMap( + (entity) => entity.translations ?? [] + ) + const references = entitiesWithTranslations.map( + ({ translations: _, ...entity }) => entity + ) + const count = data?.pages[0]?.count ?? 0 - return { - ...rest, - data: { - translations: - products?.flatMap((product) => product.translations ?? []) ?? [], - references: products ?? [], - count: data?.pages[0]?.count ?? 0, - }, - } - }, - ], - [ - "product_variant", - () => { - const fields = translatableFields.concat(["translations.*"]).join(",") - - const { data, ...rest } = useInfiniteVariants( - { id: referenceId ?? [], fields }, - options - ) - const variants = data?.pages.flatMap((page) => page.variants) ?? [] - - return { - ...rest, - data: { - translations: - variants?.flatMap((variant) => variant.translations ?? []) ?? [], - references: variants ?? [], - translatableFields, - count: data?.pages[0]?.count ?? 0, - }, - } - }, - ], - [ - "product_category", - () => { - const fields = translatableFields.concat(["translations.*"]).join(",") - - const { data, ...rest } = useInfiniteCategories( - { id: referenceId ?? [], fields }, - options - ) - const categories = - data?.pages.flatMap((page) => page.product_categories) ?? [] - - return { - ...rest, - data: { - translations: - categories?.flatMap((category) => category.translations ?? []) ?? - [], - references: categories ?? [], - translatableFields, - count: data?.pages[0]?.count ?? 0, - }, - } - }, - ], - [ - "product_collection", - () => { - const fields = translatableFields.concat(["translations.*"]).join(",") - - const { data, ...rest } = useInfiniteCollections( - { id: referenceId ?? [], fields }, - options - ) - const collections = - data?.pages.flatMap((page) => page.collections) ?? [] - - return { - ...rest, - data: { - translations: - collections?.flatMap( - (collection) => collection.translations ?? [] - ) ?? [], - references: collections ?? [], - translatableFields, - count: data?.pages[0]?.count ?? 0, - }, - } - }, - ], - [ - "product_type", - () => { - const fields = translatableFields.concat(["translations.*"]).join(",") - - const { data, ...rest } = useInfiniteProductTypes( - { id: referenceId ?? [], fields }, - options - ) - const product_types = - data?.pages.flatMap((page) => page.product_types) ?? [] - - return { - ...rest, - data: { - translations: - product_types?.flatMap((type) => type.translations ?? []) ?? [], - references: product_types ?? [], - count: data?.pages[0]?.count ?? 0, - translatableFields, - }, - } - }, - ], - [ - "product_tag", - () => { - const fields = translatableFields.concat(["translations.*"]).join(",") - - const { data, ...rest } = useInfiniteProductTags( - { id: referenceId ?? [], fields }, - options - ) - const product_tags = - data?.pages.flatMap((page) => page.product_tags) ?? [] - - return { - ...rest, - data: { - translations: - product_tags?.flatMap((tag) => tag.translations ?? []) ?? [], - references: product_tags ?? [], - translatableFields, - count: data?.pages[0]?.count ?? 0, - }, - } - }, - ], - // TODO: product option and option values - ]) - const referenceHook = referenceHookMap.get(reference) - if (!referenceHook) { - throw new Error(`No hook found for reference type: ${reference}`) + return { + references, + translations, + count, + ...rest, } - const { data, ...rest } = referenceHook() - return { ...data, ...rest } } export const useTranslations = ( @@ -229,15 +120,6 @@ export const useTranslations = ( return { ...data, ...rest } } -const referenceInvalidationKeysMap = new Map([ - ["product", productsQueryKeys.lists()], - ["product_variant", productVariantQueryKeys.lists()], - ["product_category", categoriesQueryKeys.lists()], - ["product_collection", collectionsQueryKeys.lists()], - ["product_type", productTypesQueryKeys.lists()], - ["product_tag", productTagsQueryKeys.lists()], -]) - export const useBatchTranslations = ( reference: string, options?: UseMutationOptions< @@ -258,7 +140,7 @@ export const useBatchTranslations = ( const invalidateQueries = async () => { await Promise.all([ queryClient.invalidateQueries({ - queryKey: referenceInvalidationKeysMap.get(reference), + queryKey: translationEntitiesQueryKeys.list({ type: reference }), }), queryClient.invalidateQueries({ queryKey: translationStatisticsQueryKeys.lists(), @@ -293,6 +175,27 @@ export const useTranslationSettings = ( return { ...data, ...rest } } +export const useTranslationEntities = ( + query: HttpTypes.AdminTranslationEntitiesParams, + options?: Omit< + UseQueryOptions< + HttpTypes.AdminTranslationEntitiesResponse, + FetchError, + HttpTypes.AdminTranslationEntitiesResponse, + QueryKey + >, + "queryFn" | "queryKey" + > +) => { + const { data, ...rest } = useQuery({ + queryKey: translationEntitiesQueryKeys.list(query), + queryFn: () => sdk.admin.translation.entities(query), + ...options, + }) + + return { ...data, ...rest } +} + export const useTranslationStatistics = ( query?: HttpTypes.AdminTranslationStatisticsParams, options?: Omit< diff --git a/packages/admin/dashboard/src/routes/translations/translations-edit/translations-edit.tsx b/packages/admin/dashboard/src/routes/translations/translations-edit/translations-edit.tsx index 93c1f2a48e..3808fdb371 100644 --- a/packages/admin/dashboard/src/routes/translations/translations-edit/translations-edit.tsx +++ b/packages/admin/dashboard/src/routes/translations/translations-edit/translations-edit.tsx @@ -1,14 +1,14 @@ +import { keepPreviousData } from "@tanstack/react-query" +import { useEffect } from "react" import { useNavigate, useSearchParams } from "react-router-dom" +import { RouteFocusModal } from "../../../components/modals" import { useReferenceTranslations, useStore, useTranslationSettings, } from "../../../hooks/api" -import { TranslationsEditForm } from "./components/translations-edit-form" -import { useEffect } from "react" -import { RouteFocusModal } from "../../../components/modals" import { useFeatureFlag } from "../../../providers/feature-flag-provider" -import { keepPreviousData } from "@tanstack/react-query" +import { TranslationsEditForm } from "./components/translations-edit-form" export const TranslationsEdit = () => { const isTranslationsEnabled = useFeatureFlag("translation") @@ -41,15 +41,10 @@ export const TranslationsEdit = () => { isPending, isError, error, - } = useReferenceTranslations( - reference!, - translatable_fields?.[reference!] ?? [], - referenceIdParam, - { - enabled: !!translatable_fields && !!reference, - placeholderData: keepPreviousData, - } - ) + } = useReferenceTranslations(reference!, referenceIdParam, { + enabled: !!reference, + placeholderData: keepPreviousData, + }) const { store, isPending: isStorePending, diff --git a/packages/core/js-sdk/src/admin/translation.ts b/packages/core/js-sdk/src/admin/translation.ts index a2a797591d..ce1b85c2c6 100644 --- a/packages/core/js-sdk/src/admin/translation.ts +++ b/packages/core/js-sdk/src/admin/translation.ts @@ -161,6 +161,56 @@ export class Translation { ) } + /** + * This method retrieves a paginated list of entities for a given entity type with only their + * translatable fields. + * It sends a request to the + * Get Translation Entities API route. + * + * @param query - The query parameters including the entity type and pagination configurations. + * @param headers - Headers to pass in the request. + * @returns The paginated list of entities with their translatable fields. + * + * @example + * To retrieve the entities for a given entity type: + * + * ```ts + * sdk.admin.translation.entities({ + * type: "product" + * }) + * .then(({ data, count, offset, limit }) => { + * console.log(data) + * }) + * ``` + * + * 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.translation.entities({ + * type: "product", + * limit: 10, + * offset: 10 + * }) + * .then(({ data, count, offset, limit }) => { + * console.log(data) + * }) + * ``` + */ + async entities( + query: HttpTypes.AdminTranslationEntitiesParams, + headers?: ClientHeaders + ) { + return await this.client.fetch( + `/admin/translations/entities`, + { + headers, + query, + } + ) + } + /** * This method retrieves the statistics for the translations for a given entity type or all entity types if no entity type is provided. * It sends a request to the diff --git a/packages/core/types/src/common/config-module.ts b/packages/core/types/src/common/config-module.ts index 7b499c2e4c..77667ab6b8 100644 --- a/packages/core/types/src/common/config-module.ts +++ b/packages/core/types/src/common/config-module.ts @@ -5,11 +5,30 @@ import { } from "../modules-sdk" import type { RedisOptions } from "ioredis" + import { ConnectionOptions } from "node:tls" // @ts-ignore import type { InlineConfig } from "vite" import type { Logger } from "../logger" +/** + * Registry for module options types. Modules can augment this interface + * using declaration merging to provide typed options in defineConfig. + * + * @example + * ```ts + * // In @medusajs/translation module: + * declare module "@medusajs/types" { + * interface ModuleOptions { + * "@medusajs/translation": { + * entities?: { type: string; fields: string[] }[] + * } + * } + * } + * ``` + */ +export interface ModuleOptions {} + /** * @interface * @@ -1093,17 +1112,6 @@ export type ConfigModule = { logger?: Logger } -type InternalModuleDeclarationOverride = InternalModuleDeclaration & { - /** - * Optional key to be used to identify the module, if not provided, it will be inferred from the module joiner config service name. - */ - key?: string - /** - * By default, modules are enabled, if provided as true, this will disable the module entirely. - */ - disable?: boolean -} - type ExternalModuleDeclarationOverride = ExternalModuleDeclaration & { /** * key to be used to identify the module, if not provided, it will be inferred from the module joiner config service name. @@ -1116,11 +1124,41 @@ type ExternalModuleDeclarationOverride = ExternalModuleDeclaration & { } /** - * Modules accepted by the defineConfig function + * Generates a union of typed module configs for all known modules in the ModuleOptions registry. + * This enables automatic type inference when using registered module resolve strings. */ -export type InputConfigModules = Partial< - InternalModuleDeclarationOverride | ExternalModuleDeclarationOverride ->[] +type KnownModuleConfigs = { + [K in keyof ModuleOptions]: Partial< + Omit & { + key?: string + disable?: boolean + resolve: K + options?: ModuleOptions[K] + } + > +}[keyof ModuleOptions] + +/** + * Generic module config for modules not registered in ModuleOptions. + */ +type GenericModuleConfig = Partial< + Omit & { + key?: string + disable?: boolean + resolve?: string + options?: Record + } +> + +/** + * Modules accepted by the defineConfig function. + * Automatically infers options type for known modules registered in ModuleOptions. + */ +export type InputConfigModules = ( + | KnownModuleConfigs + | GenericModuleConfig + | ExternalModuleDeclarationOverride +)[] /** * The configuration accepted by the "defineConfig" helper diff --git a/packages/core/types/src/http/translations/admin/queries.ts b/packages/core/types/src/http/translations/admin/queries.ts index dc789fe8ad..39747fcb59 100644 --- a/packages/core/types/src/http/translations/admin/queries.ts +++ b/packages/core/types/src/http/translations/admin/queries.ts @@ -61,3 +61,28 @@ export interface AdminTranslationSettingsParams { */ entity_type?: string } + +/** + * Query parameters for translation entities endpoint. + */ +export interface AdminTranslationEntitiesParams extends FindParams { + /** + * The entity type to retrieve (e.g., "product", "product_variant"). + * This determines which table to query and which translatable fields to return. + * + * @example + * "product" + */ + type: string + + /** + * Filter by entity ID(s). Can be a single ID or an array of IDs. + * + * @example + * "prod_123" + * + * @example + * ["prod_123", "prod_456"] + */ + id?: string | string[] +} diff --git a/packages/core/types/src/http/translations/admin/responses.ts b/packages/core/types/src/http/translations/admin/responses.ts index 6d808d9daa..6466aed215 100644 --- a/packages/core/types/src/http/translations/admin/responses.ts +++ b/packages/core/types/src/http/translations/admin/responses.ts @@ -99,3 +99,35 @@ export interface AdminTranslationSettingsResponse { */ translatable_fields: Record } + +/** + * Response for translation entities endpoint. + * Returns paginated entities with only their translatable fields and all their translations. + */ +export interface AdminTranslationEntitiesResponse { + /** + * The list of entities with their translatable fields. + * Each entity contains only the fields configured as translatable + * for that entity type in the translation settings, plus all + * translations for all locales. + */ + data: (Record & { + id: string + translations: AdminTranslation[] + })[] + + /** + * The total count of entities. + */ + count: number + + /** + * The offset of the current page. + */ + offset: number + + /** + * The limit of items per page. + */ + limit: number +} diff --git a/packages/core/types/src/translation/common.ts b/packages/core/types/src/translation/common.ts index 0ff6041663..aabb00306e 100644 --- a/packages/core/types/src/translation/common.ts +++ b/packages/core/types/src/translation/common.ts @@ -80,6 +80,41 @@ export interface TranslationDTO { deleted_at: Date | string | null } +/** + * The translation settings details. + */ +export interface TranslationSettingsDTO { + /** + * The ID of the settings record. + */ + id: string + + /** + * The entity type these settings apply to (e.g., "product", "product_variant"). + */ + entity_type: string + + /** + * The translatable fields for this entity type. + */ + fields: string[] + + /** + * The date and time the settings were created. + */ + created_at: Date | string + + /** + * The date and time the settings were last updated. + */ + updated_at: Date | string + + /** + * The date and time the settings were deleted. + */ + deleted_at: Date | string | null +} + /** * The filters to apply on the retrieved locales. */ diff --git a/packages/core/types/src/translation/service.ts b/packages/core/types/src/translation/service.ts index c6cc4c3937..acd19e9ab7 100644 --- a/packages/core/types/src/translation/service.ts +++ b/packages/core/types/src/translation/service.ts @@ -704,27 +704,31 @@ export interface ITranslationModuleService extends IModuleService { ): Promise /** - * This method retrieves the translatable fields of a resource. For example, - * product entities have translatable fields such as `title` and `description`. + * This method retrieves the translatable fields of a resource from the database. + * For example, product entities have translatable fields such as `title` and `description`. * * @param {string} entityType - Name of the resource's table to get translatable fields for. * If not provided, returns all translatable fields for all entity types. For example, `product` or `product_variant`. - * @returns {Record} A mapping of resource names to their translatable fields. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise>} A mapping of resource names to their translatable fields. * * @example * To get translatable fields for all resources: - * + * * ```ts - * const allFields = translationModuleService.getTranslatableFields() + * const allFields = await translationModuleService.getTranslatableFields() * // Returns: { product: ["title", "description", ...], product_variant: ["title", ...] } * ``` - * + * * To get translatable fields for a specific resource: * * ```ts - * const productFields = translationModuleService.getTranslatableFields("product") + * const productFields = await translationModuleService.getTranslatableFields("product") * // Returns: { product: ["title", "description", "subtitle", "status"] } * ``` */ - getTranslatableFields(entityType?: string): Record + getTranslatableFields( + entityType?: string, + sharedContext?: Context + ): Promise> } diff --git a/packages/core/utils/src/common/define-config.ts b/packages/core/utils/src/common/define-config.ts index 031014f924..c49a26ca55 100644 --- a/packages/core/utils/src/common/define-config.ts +++ b/packages/core/utils/src/common/define-config.ts @@ -349,11 +349,11 @@ function resolveModules( ...(isObject(moduleConfig) ? moduleConfig : { disable: !moduleConfig }), - }) + } as InputConfigModules[number]) }) } else if (Array.isArray(configModules)) { const modules_ = (configModules ?? []) as InternalModuleDeclaration[] - modules.push(...modules_) + modules.push(...(modules_ as InputConfigModules)) } else { throw new Error( "Invalid modules configuration. Should be an array or object." diff --git a/packages/medusa/src/api/admin/translations/entities/route.ts b/packages/medusa/src/api/admin/translations/entities/route.ts new file mode 100644 index 0000000000..e74045f4c9 --- /dev/null +++ b/packages/medusa/src/api/admin/translations/entities/route.ts @@ -0,0 +1,90 @@ +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import { ContainerRegistrationKeys } from "@medusajs/framework/utils" +import { HttpTypes } from "@medusajs/types" + +export const GET = async ( + req: AuthenticatedMedusaRequest< + undefined, + HttpTypes.AdminTranslationEntitiesParams + >, + res: MedusaResponse +) => { + const query = req.scope.resolve(ContainerRegistrationKeys.QUERY) + const { type, id } = req.validatedQuery + + const { + data: [translationSettings], + } = await query.graph( + { + entity: "translation_settings", + fields: ["*"], + filters: { + entity_type: type, + }, + }, + { + cache: { enable: true }, + } + ) + + const translatableFields = translationSettings?.fields ?? [] + + const filters: Record = {} + if (id) { + filters.id = id + } + + const { data: entities = [], metadata } = await query + .graph( + { + entity: type, + fields: ["id", ...translatableFields], + filters, + pagination: req.queryConfig.pagination, + }, + { + cache: { enable: true }, + } + ) + .catch((e) => { + const normalizedMessage = e.message.toLowerCase() + if ( + normalizedMessage.includes("service with alias") && + normalizedMessage.includes("was not found") + ) { + return { data: [], metadata: { count: 0, skip: 0, take: 0 } } + } + throw e + }) + + let aggregatedData = + entities as HttpTypes.AdminTranslationEntitiesResponse["data"] + + if (aggregatedData.length) { + const { data: translations } = await query.graph({ + entity: "translations", + fields: ["*"], + filters: { + reference_id: aggregatedData.map((entity) => entity.id), + }, + }) + + // aggregate data - include all translations for all locales + aggregatedData = aggregatedData.map((entity) => { + entity.translations = translations.filter( + (translation) => translation.reference_id === entity.id + ) + return entity + }) + } + + return res.json({ + data: aggregatedData, + count: metadata?.count ?? 0, + offset: metadata?.skip ?? 0, + limit: metadata?.take ?? 0, + }) +} diff --git a/packages/medusa/src/api/admin/translations/middlewares.ts b/packages/medusa/src/api/admin/translations/middlewares.ts index 04bdcff6f0..42fdf22556 100644 --- a/packages/medusa/src/api/admin/translations/middlewares.ts +++ b/packages/medusa/src/api/admin/translations/middlewares.ts @@ -6,6 +6,7 @@ import { import { AdminBatchTranslations, AdminGetTranslationsParams, + AdminTranslationEntitiesParams, AdminTranslationSettingsParams, AdminTranslationStatistics, } from "./validators" @@ -43,4 +44,14 @@ export const adminTranslationsRoutesMiddlewares: MiddlewareRoute[] = [ validateAndTransformQuery(AdminTranslationSettingsParams, {}), ], }, + { + method: ["GET"], + matcher: "/admin/translations/entities", + middlewares: [ + validateAndTransformQuery( + AdminTranslationEntitiesParams, + QueryConfig.listTransformQueryConfig + ), + ], + }, ] diff --git a/packages/medusa/src/api/admin/translations/settings/route.ts b/packages/medusa/src/api/admin/translations/settings/route.ts index cfb752c761..7064cfd0e4 100644 --- a/packages/medusa/src/api/admin/translations/settings/route.ts +++ b/packages/medusa/src/api/admin/translations/settings/route.ts @@ -25,12 +25,12 @@ export const GET = async ( const translationService = req.scope.resolve( Modules.TRANSLATION ) - const translatable_fields = translationService.getTranslatableFields( + const translatableFields = await translationService.getTranslatableFields( req.validatedQuery.entity_type ) res.json({ - translatable_fields, + translatable_fields: translatableFields, }) } diff --git a/packages/medusa/src/api/admin/translations/validators.ts b/packages/medusa/src/api/admin/translations/validators.ts index afcb1c5ac5..389835c1da 100644 --- a/packages/medusa/src/api/admin/translations/validators.ts +++ b/packages/medusa/src/api/admin/translations/validators.ts @@ -71,3 +71,16 @@ export type AdminTranslationSettingsParamsType = z.infer< export const AdminTranslationSettingsParams = z.object({ entity_type: z.string().optional(), }) + +export type AdminTranslationEntitiesParamsType = z.infer< + typeof AdminTranslationEntitiesParams +> +export const AdminTranslationEntitiesParams = createFindParams({ + limit: 20, + offset: 0, +}).merge( + z.object({ + type: z.string(), + id: z.union([z.string(), z.array(z.string())]).optional(), + }) +) 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 index bb7609fe7c..bcb54628e3 100644 --- a/packages/modules/translation/integration-tests/__tests__/translation-module-service.spec.ts +++ b/packages/modules/translation/integration-tests/__tests__/translation-module-service.spec.ts @@ -15,7 +15,11 @@ moduleIntegrationTestRunner({ service: TranslationModuleService, }).linkable - expect(Object.keys(linkable)).toEqual(["locale", "translation"]) + expect(Object.keys(linkable)).toEqual([ + "locale", + "translation", + "translationSettings", + ]) Object.keys(linkable).forEach((key) => { delete linkable[key].toJSON @@ -40,6 +44,15 @@ moduleIntegrationTestRunner({ field: "translation", }, }, + translationSettings: { + id: { + linkable: "translation_settings_id", + entity: "TranslationSettings", + primaryKey: "id", + serviceName: "translation", + field: "translationSettings", + }, + }, }) }) @@ -647,11 +660,83 @@ moduleIntegrationTestRunner({ }) }) + describe("Settings", () => { + describe("getTranslatableFields", () => { + it("should return all translatable fields from database", async () => { + const fields = await service.getTranslatableFields() + + expect(fields).toHaveProperty("product") + expect(fields).toHaveProperty("product_variant") + expect(fields.product).toEqual( + expect.arrayContaining(["title", "description"]) + ) + }) + + it("should return translatable fields for a specific entity type", async () => { + const fields = await service.getTranslatableFields("product") + + expect(Object.keys(fields)).toEqual(["product"]) + expect(fields.product).toEqual( + expect.arrayContaining(["title", "description"]) + ) + }) + + it("should return empty object for unknown entity type", async () => { + const fields = await service.getTranslatableFields("unknown_entity") + + expect(fields).toEqual({}) + }) + }) + + describe("listing translations filters by configured fields", () => { + it("should only return configured fields in translations", async () => { + await service.createTranslations({ + reference_id: "prod_filter_1", + reference: "product", + locale_code: "en-US", + translations: { + title: "Product Title", + description: "Product Description", + unconfigured_field: "Should be filtered out", + }, + }) + + const translations = await service.listTranslations({ + reference_id: "prod_filter_1", + }) + + expect(translations).toHaveLength(1) + expect(translations[0].translations).toHaveProperty("title") + expect(translations[0].translations).toHaveProperty("description") + expect(translations[0].translations).not.toHaveProperty( + "unconfigured_field" + ) + }) + + it("should return empty translations for unconfigured entity types", async () => { + await service.createTranslations({ + reference_id: "unconfigured_1", + reference: "unconfigured_entity", + locale_code: "en-US", + translations: { + field1: "Value 1", + field2: "Value 2", + }, + }) + + const translations = await service.listTranslations({ + reference_id: "unconfigured_1", + }) + + expect(translations).toHaveLength(1) + expect(translations[0].translations).toEqual({}) + }) + }) + }) + describe("Statistics", () => { describe("getStatistics", () => { it("should return statistics for a single entity type and locale", async () => { - // Create translations for 2 products with some fields filled - // Product has 4 translatable fields: title, description, material, subtitle await service.createTranslations([ { reference_id: "prod_stat_1", diff --git a/packages/modules/translation/mikro-orm.config.dev.ts b/packages/modules/translation/mikro-orm.config.dev.ts index d3ba9b7d88..c80abd2107 100644 --- a/packages/modules/translation/mikro-orm.config.dev.ts +++ b/packages/modules/translation/mikro-orm.config.dev.ts @@ -1,7 +1,8 @@ import { defineMikroOrmCliConfig } from "@medusajs/framework/utils" import Locale from "./src/models/locale" import Translation from "./src/models/translation" +import Settings from "./src/models/settings" export default defineMikroOrmCliConfig("translation", { - entities: [Locale, Translation], + entities: [Locale, Translation, Settings], }) diff --git a/packages/modules/translation/src/index.ts b/packages/modules/translation/src/index.ts index 1ddb1b2843..b99064384e 100644 --- a/packages/modules/translation/src/index.ts +++ b/packages/modules/translation/src/index.ts @@ -1,7 +1,8 @@ -import TranslationModuleService from "@services/translation-module" -import loadDefaults from "./loaders/defaults" -import loadConfig from "./loaders/config" +import "./types" import { Module } from "@medusajs/framework/utils" +import TranslationModuleService from "@services/translation-module" +import loadConfig from "./loaders/config" +import loadDefaults from "./loaders/defaults" export const TRANSLATION_MODULE = "translation" diff --git a/packages/modules/translation/src/loaders/config.ts b/packages/modules/translation/src/loaders/config.ts index 8003d0c5a5..d76f91aee4 100644 --- a/packages/modules/translation/src/loaders/config.ts +++ b/packages/modules/translation/src/loaders/config.ts @@ -1,11 +1,60 @@ -import { LoaderOptions } from "@medusajs/framework/types" +import { + LoaderOptions, + Logger, + ModulesSdkTypes, +} from "@medusajs/framework/types" +import { ContainerRegistrationKeys } from "@medusajs/framework/utils" import { TRANSLATABLE_FIELDS_CONFIG_KEY } from "@utils/constants" import { asValue } from "awilix" import { translatableFieldsConfig } from "../utils/translatable-fields" +import Settings from "@models/settings" +import type { TranslationModuleOptions } from "../types" -export default async ({ container }: LoaderOptions): Promise => { - container.register( - TRANSLATABLE_FIELDS_CONFIG_KEY, - asValue(translatableFieldsConfig) - ) +export default async ({ + container, + options, +}: LoaderOptions): Promise => { + const logger = + container.resolve(ContainerRegistrationKeys.LOGGER) ?? console + const settingsService: ModulesSdkTypes.IMedusaInternalService< + typeof Settings + > = container.resolve("translationSettingsService") + + const mergedConfig: Record = translatableFieldsConfig + + const userProvidedFields = options?.entities ?? [] + for (const field of userProvidedFields) { + mergedConfig[field.type] ??= [] + mergedConfig[field.type] = Array.from( + new Set([...(mergedConfig[field.type] ?? []), ...field.fields]) + ) + } + + try { + const existingSettings = await settingsService.list( + {}, + { select: ["id", "entity_type"] } + ) + const existingByEntityType = new Map( + existingSettings.map((s) => [s.entity_type, s.id]) + ) + + const settingsToUpsert = Object.entries(mergedConfig).map( + ([entityType, fields]) => { + const existingId = existingByEntityType.get(entityType) + return existingId + ? { id: existingId, entity_type: entityType, fields } + : { entity_type: entityType, fields } + } + ) + + const resp = await settingsService.upsert(settingsToUpsert) + logger.debug(`Loaded ${resp.length} translation settings`) + } catch (error) { + logger.warn( + `Failed to load translation settings, skipping loader. Original error: ${error.message}` + ) + } + + container.register(TRANSLATABLE_FIELDS_CONFIG_KEY, asValue(mergedConfig)) } diff --git a/packages/modules/translation/src/migrations/.snapshot-medusa-translation.json b/packages/modules/translation/src/migrations/.snapshot-medusa-translation.json index 36722eeeee..9fb74f51b3 100644 --- a/packages/modules/translation/src/migrations/.snapshot-medusa-translation.json +++ b/packages/modules/translation/src/migrations/.snapshot-medusa-translation.json @@ -263,6 +263,104 @@ "checks": [], "foreignKeys": {}, "nativeEnums": {} + }, + { + "columns": { + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "fields": { + "name": "fields", + "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_settings", + "schema": "public", + "indexes": [ + { + "keyName": "IDX_translation_settings_deleted_at", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE INDEX IF NOT EXISTS \"IDX_translation_settings_deleted_at\" ON \"translation_settings\" (\"deleted_at\") WHERE deleted_at IS NULL" + }, + { + "keyName": "IDX_translation_settings_entity_type_unique", + "columnNames": [], + "composite": false, + "constraint": false, + "primary": false, + "unique": false, + "expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_translation_settings_entity_type_unique\" ON \"translation_settings\" (\"entity_type\") WHERE deleted_at IS NULL" + }, + { + "keyName": "translation_settings_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {}, + "nativeEnums": {} } ], "nativeEnums": {} diff --git a/packages/modules/translation/src/migrations/Migration20251218140235.ts b/packages/modules/translation/src/migrations/Migration20251218140235.ts new file mode 100644 index 0000000000..9470e95b44 --- /dev/null +++ b/packages/modules/translation/src/migrations/Migration20251218140235.ts @@ -0,0 +1,22 @@ +import { Migration } from "@medusajs/framework/mikro-orm/migrations" + +export class Migration20251218140235 extends Migration { + override async up(): Promise { + this.addSql( + `alter table if exists "translation_settings" drop constraint if exists "translation_settings_entity_type_unique";` + ) + this.addSql( + `create table if not exists "translation_settings" ("id" text not null, "entity_type" text not null, "fields" jsonb not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "translation_settings_pkey" primary key ("id"));` + ) + this.addSql( + `CREATE INDEX IF NOT EXISTS "IDX_translation_settings_deleted_at" ON "translation_settings" ("deleted_at") WHERE deleted_at IS NULL;` + ) + this.addSql( + `CREATE UNIQUE INDEX IF NOT EXISTS "IDX_translation_settings_entity_type_unique" ON "translation_settings" ("entity_type") WHERE deleted_at IS NULL;` + ) + } + + override async down(): Promise { + this.addSql(`drop table if exists "translation_settings" cascade;`) + } +} diff --git a/packages/modules/translation/src/models/index.ts b/packages/modules/translation/src/models/index.ts index 7f91fb680e..265e8f59fc 100644 --- a/packages/modules/translation/src/models/index.ts +++ b/packages/modules/translation/src/models/index.ts @@ -1,2 +1,3 @@ export { default as Translation } from "./translation" -export { default as Locale } from "./locale" \ No newline at end of file +export { default as Locale } from "./locale" +export { default as Settings } from "./settings" \ No newline at end of file diff --git a/packages/modules/translation/src/models/settings.ts b/packages/modules/translation/src/models/settings.ts new file mode 100644 index 0000000000..2dd2394e5d --- /dev/null +++ b/packages/modules/translation/src/models/settings.ts @@ -0,0 +1,26 @@ +import { model } from "@medusajs/framework/utils" + +const Settings = model + .define("translation_settings", { + id: model.id({ prefix: "trset" }).primaryKey(), + /** + * The entity type that these settings apply to (e.g., "product", "product_variant"). + */ + entity_type: model.text().searchable(), + /** + * The translatable fields for this entity type. + * Array of field names that can be translated. + * + * @example + * ["title", "description", "material"] + */ + fields: model.json(), + }) + .indexes([ + { + on: ["entity_type"], + unique: true, + }, + ]) + +export default Settings diff --git a/packages/modules/translation/src/services/translation-module.ts b/packages/modules/translation/src/services/translation-module.ts index be31045555..fb7a4afa6d 100644 --- a/packages/modules/translation/src/services/translation-module.ts +++ b/packages/modules/translation/src/services/translation-module.ts @@ -21,13 +21,18 @@ import { } from "@medusajs/framework/utils" import Locale from "@models/locale" import Translation from "@models/translation" +import Settings from "@models/settings" import { computeTranslatedFieldCount } from "@utils/compute-translated-field-count" import { TRANSLATABLE_FIELDS_CONFIG_KEY } from "@utils/constants" +import { filterTranslationFields } from "@utils/filter-translation-fields" type InjectedDependencies = { baseRepository: DAL.RepositoryService translationService: ModulesSdkTypes.IMedusaInternalService localeService: ModulesSdkTypes.IMedusaInternalService + translationSettingsService: ModulesSdkTypes.IMedusaInternalService< + typeof Settings + > [TRANSLATABLE_FIELDS_CONFIG_KEY]: Record } @@ -39,9 +44,13 @@ export default class TranslationModuleService Translation: { dto: TranslationTypes.TranslationDTO } + TranslationSettings: { + dto: TranslationTypes.TranslationSettingsDTO + } }>({ Locale, Translation, + TranslationSettings: Settings, }) implements ITranslationModuleService { @@ -52,20 +61,38 @@ export default class TranslationModuleService protected localeService_: ModulesSdkTypes.IMedusaInternalService< typeof Locale > - - private readonly translatableFieldsConfig_: Record + protected settingsService_: ModulesSdkTypes.IMedusaInternalService< + typeof Settings + > constructor({ baseRepository, translationService, localeService, - translatableFieldsConfig, + translationSettingsService, }: InjectedDependencies) { super(...arguments) this.baseRepository_ = baseRepository this.translationService_ = translationService this.localeService_ = localeService - this.translatableFieldsConfig_ = translatableFieldsConfig + this.settingsService_ = translationSettingsService + } + + @InjectManager() + async getTranslatableFields( + entityType?: string, + @MedusaContext() sharedContext: Context = {} + ): Promise> { + const filters = entityType ? { entity_type: entityType } : {} + const settings = await this.settingsService_.list( + filters, + {}, + sharedContext + ) + return settings.reduce((acc, setting) => { + acc[setting.entity_type] = setting.fields as unknown as string[] + return acc + }, {} as Record) } static prepareFilters( @@ -83,6 +110,54 @@ export default class TranslationModuleService return restFilters } + @InjectManager() + // @ts-expect-error + async retrieveTranslation( + id: string, + config: FindConfig = {}, + @MedusaContext() sharedContext: Context = {} + ): Promise { + const configWithReference = + TranslationModuleService.ensureReferenceFieldInConfig(config) + + const result = await this.translationService_.retrieve( + id, + configWithReference, + sharedContext + ) + + const serialized = + await this.baseRepository_.serialize( + result + ) + + const translatableFieldsConfig = await this.getTranslatableFields( + undefined, + sharedContext + ) + + return filterTranslationFields([serialized], translatableFieldsConfig)[0] + } + + /** + * Ensures the 'reference' field is included in the select config. + * This is needed for filtering translations by translatable fields. + */ + static ensureReferenceFieldInConfig( + config: FindConfig + ): FindConfig { + if (!config?.select?.length) { + return config + } + + const select = config.select as string[] + if (!select.includes("reference")) { + return { ...config, select: [...select, "reference"] } + } + + return config + } + @InjectManager() // @ts-expect-error async listTranslations( @@ -91,16 +166,25 @@ export default class TranslationModuleService @MedusaContext() sharedContext: Context = {} ): Promise { const preparedFilters = TranslationModuleService.prepareFilters(filters) + const configWithReference = + TranslationModuleService.ensureReferenceFieldInConfig(config) const results = await this.translationService_.list( preparedFilters, - config, + configWithReference, sharedContext ) - return await this.baseRepository_.serialize< + const serialized = await this.baseRepository_.serialize< TranslationTypes.TranslationDTO[] >(results) + + const translatableFieldsConfig = await this.getTranslatableFields( + undefined, + sharedContext + ) + + return filterTranslationFields(serialized, translatableFieldsConfig) } @InjectManager() @@ -111,17 +195,26 @@ export default class TranslationModuleService @MedusaContext() sharedContext: Context = {} ): Promise<[TranslationTypes.TranslationDTO[], number]> { const preparedFilters = TranslationModuleService.prepareFilters(filters) + const configWithReference = + TranslationModuleService.ensureReferenceFieldInConfig(config) const [results, count] = await this.translationService_.listAndCount( preparedFilters, - config, + configWithReference, + sharedContext + ) + + const serialized = await this.baseRepository_.serialize< + TranslationTypes.TranslationDTO[] + >(results) + + const translatableFieldsConfig = await this.getTranslatableFields( + undefined, sharedContext ) return [ - await this.baseRepository_.serialize( - results - ), + filterTranslationFields(serialized, translatableFieldsConfig), count, ] } @@ -183,12 +276,16 @@ export default class TranslationModuleService TranslationTypes.TranslationDTO | TranslationTypes.TranslationDTO[] > { const dataArray = Array.isArray(data) ? data : [data] + const translatableFieldsConfig = await this.getTranslatableFields( + undefined, + sharedContext + ) const normalizedData = dataArray.map((translation) => ({ ...translation, locale_code: normalizeLocale(translation.locale_code), translated_field_count: computeTranslatedFieldCount( translation.translations as Record, - this.translatableFieldsConfig_[translation.reference] + translatableFieldsConfig[translation.reference] ), })) @@ -248,6 +345,11 @@ export default class TranslationModuleService ) } + const translatableFieldsConfig = await this.getTranslatableFields( + undefined, + sharedContext + ) + for (const update of dataArray) { if (update.translations) { const reference = update.reference || referenceMap[update.id] @@ -257,7 +359,7 @@ export default class TranslationModuleService } ).translated_field_count = computeTranslatedFieldCount( update.translations as Record, - this.translatableFieldsConfig_[reference] || [] + translatableFieldsConfig[reference] || [] ) } } @@ -275,13 +377,6 @@ export default class TranslationModuleService return Array.isArray(data) ? serialized : serialized[0] } - getTranslatableFields(entityType?: string): Record { - if (entityType) { - return { [entityType]: this.translatableFieldsConfig_[entityType] } - } - return this.translatableFieldsConfig_ - } - @InjectManager() async getStatistics( input: TranslationTypes.TranslationStatisticsInput, @@ -309,11 +404,16 @@ export default class TranslationModuleService sharedContext.manager) as SqlEntityManager const knex = manager.getKnex() + const translatableFieldsConfig = await this.getTranslatableFields( + undefined, + sharedContext + ) + const result: TranslationTypes.TranslationStatisticsOutput = {} const entityTypes: string[] = [] for (const entityType of Object.keys(entities)) { - const translatableFields = this.translatableFieldsConfig_[entityType] + const translatableFields = translatableFieldsConfig[entityType] if (!translatableFields || translatableFields.length === 0) { result[entityType] = { @@ -352,7 +452,7 @@ export default class TranslationModuleService ) for (const entityType of entityTypes) { - const translatableFields = this.translatableFieldsConfig_[entityType] + const translatableFields = translatableFieldsConfig[entityType] const fieldsPerEntity = translatableFields.length const entityCount = entities[entityType].count const expectedPerLocale = entityCount * fieldsPerEntity diff --git a/packages/modules/translation/src/types/index.ts b/packages/modules/translation/src/types/index.ts new file mode 100644 index 0000000000..bc620f40ca --- /dev/null +++ b/packages/modules/translation/src/types/index.ts @@ -0,0 +1,50 @@ +import { RemoteQueryEntryPoints } from "@medusajs/framework/types" + +/** + * Extracts only the keys of T where the value is a string (or nullable string), the key + * is not __typename or id. + * This filters out relations and other non-string fields. + */ +type StringValuedKeys = { + [K in keyof T]: K extends `${string}_id` + ? never + : "__typename" extends K + ? never + : "id" extends keyof K + ? never + : NonNullable extends string + ? K + : never +}[keyof T] + +/** + * A discriminated union of all possible entity configurations. + * When you specify a `type`, TypeScript will narrow `fields` to only + * the string-valued keys of that specific entity type. + */ +export type TranslatableEntityConfig = + | { + [K in keyof RemoteQueryEntryPoints]: { + type: K + fields: StringValuedKeys[] + } + }[keyof RemoteQueryEntryPoints] + | { + type: string + fields: string[] + } + +/** + * Options for configuring the translation module. + */ +export type TranslationModuleOptions = { + entities?: TranslatableEntityConfig[] +} + +// Augment the global ModuleOptions registry +declare module "@medusajs/types" { + interface ModuleOptions { + "@medusajs/translation": TranslationModuleOptions + "@medusajs/medusa/translation": TranslationModuleOptions + } +} diff --git a/packages/modules/translation/src/utils/filter-translation-fields.ts b/packages/modules/translation/src/utils/filter-translation-fields.ts new file mode 100644 index 0000000000..d18d78f0be --- /dev/null +++ b/packages/modules/translation/src/utils/filter-translation-fields.ts @@ -0,0 +1,29 @@ +import { TranslationTypes } from "@medusajs/framework/types" + +export function filterTranslationFields( + translations: TranslationTypes.TranslationDTO[], + translatableFieldsConfig: Record +): TranslationTypes.TranslationDTO[] { + return translations.map((translation) => { + const allowedFields = translatableFieldsConfig[translation.reference] + if (!allowedFields?.length) { + translation.translations = {} + return translation + } + + const filteredTranslations: Record = {} + for (const field of allowedFields) { + if ( + translation.translations && + field in (translation.translations as Record) + ) { + filteredTranslations[field] = ( + translation.translations as Record + )[field] + } + } + + translation.translations = filteredTranslations + return translation + }) +}