feat(): Translation settings + user configuration + admin hook and js sdk + dashboard (#14355)

* feat(): Translation settings + user configuration

* feat(): Translation settings + user configuration

* Create gentle-bees-grow.md

* add entities end point

* add entities end point

* add admin hook and js sdk method

* update changeset

* fix tests

* fix tests

* rm unnecessary copy

* update dashboard to use the new resources

* update dashboard to use the new resources

* update dashboard to use the new resources

* allow type inference through interface augmentation in the defineConfig of medusa-config

* allow type inference through interface augmentation in the defineConfig of medusa-config

* exclude id and _id props

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Adrien de Peretti
2025-12-19 15:29:48 +01:00
committed by GitHub
parent 797878af26
commit b21a599d11
26 changed files with 1041 additions and 260 deletions

View File

@@ -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

View File

@@ -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<string, unknown>) => {
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<string, unknown>) => {
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)
})
})
})
},
})

View File

@@ -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<any, FetchError, any, any, QueryKey, number>,
UseInfiniteQueryOptions<
HttpTypes.AdminTranslationEntitiesResponse,
FetchError,
{
pages: HttpTypes.AdminTranslationEntitiesResponse[]
pageParams: number[]
},
HttpTypes.AdminTranslationEntitiesResponse,
QueryKey,
number
>,
"queryFn" | "queryKey" | "initialPageParam" | "getNextPageParam"
>
) => {
const referenceHookMap = new Map<
string,
() => Omit<UseInfiniteQueryResult<any, FetchError>, "data"> & {
data: {
translations: HttpTypes.AdminTranslation[]
references: (Record<string, any> & { 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<string, QueryKey>([
["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<

View File

@@ -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,

View File

@@ -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<HttpTypes.AdminTranslationEntitiesResponse>(
`/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

View File

@@ -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<InternalModuleDeclaration, "options"> & {
key?: string
disable?: boolean
resolve: K
options?: ModuleOptions[K]
}
>
}[keyof ModuleOptions]
/**
* Generic module config for modules not registered in ModuleOptions.
*/
type GenericModuleConfig = Partial<
Omit<InternalModuleDeclaration, "options"> & {
key?: string
disable?: boolean
resolve?: string
options?: Record<string, unknown>
}
>
/**
* 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

View File

@@ -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[]
}

View File

@@ -99,3 +99,35 @@ export interface AdminTranslationSettingsResponse {
*/
translatable_fields: Record<string, string[]>
}
/**
* 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<string, unknown> & {
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
}

View File

@@ -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.
*/

View File

@@ -704,27 +704,31 @@ export interface ITranslationModuleService extends IModuleService {
): Promise<TranslationStatisticsOutput>
/**
* 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<string, string[]>} 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<Record<string, string[]>>} 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<string, string[]>
getTranslatableFields(
entityType?: string,
sharedContext?: Context
): Promise<Record<string, string[]>>
}

View File

@@ -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."

View File

@@ -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<HttpTypes.AdminTranslationEntitiesResponse>
) => {
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<string, unknown> = {}
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,
})
}

View File

@@ -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
),
],
},
]

View File

@@ -25,12 +25,12 @@ export const GET = async (
const translationService = req.scope.resolve<ITranslationModuleService>(
Modules.TRANSLATION
)
const translatable_fields = translationService.getTranslatableFields(
const translatableFields = await translationService.getTranslatableFields(
req.validatedQuery.entity_type
)
res.json({
translatable_fields,
translatable_fields: translatableFields,
})
}

View File

@@ -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(),
})
)

View File

@@ -15,7 +15,11 @@ moduleIntegrationTestRunner<ITranslationModuleService>({
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<ITranslationModuleService>({
field: "translation",
},
},
translationSettings: {
id: {
linkable: "translation_settings_id",
entity: "TranslationSettings",
primaryKey: "id",
serviceName: "translation",
field: "translationSettings",
},
},
})
})
@@ -647,11 +660,83 @@ moduleIntegrationTestRunner<ITranslationModuleService>({
})
})
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",

View File

@@ -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],
})

View File

@@ -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"

View File

@@ -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<void> => {
container.register(
TRANSLATABLE_FIELDS_CONFIG_KEY,
asValue(translatableFieldsConfig)
)
export default async ({
container,
options,
}: LoaderOptions<TranslationModuleOptions>): Promise<void> => {
const logger =
container.resolve<Logger>(ContainerRegistrationKeys.LOGGER) ?? console
const settingsService: ModulesSdkTypes.IMedusaInternalService<
typeof Settings
> = container.resolve("translationSettingsService")
const mergedConfig: Record<string, string[]> = 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))
}

View File

@@ -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": {}

View File

@@ -0,0 +1,22 @@
import { Migration } from "@medusajs/framework/mikro-orm/migrations"
export class Migration20251218140235 extends Migration {
override async up(): Promise<void> {
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<void> {
this.addSql(`drop table if exists "translation_settings" cascade;`)
}
}

View File

@@ -1,2 +1,3 @@
export { default as Translation } from "./translation"
export { default as Locale } from "./locale"
export { default as Locale } from "./locale"
export { default as Settings } from "./settings"

View File

@@ -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

View File

@@ -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<typeof Translation>
localeService: ModulesSdkTypes.IMedusaInternalService<typeof Locale>
translationSettingsService: ModulesSdkTypes.IMedusaInternalService<
typeof Settings
>
[TRANSLATABLE_FIELDS_CONFIG_KEY]: Record<string, string[]>
}
@@ -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<string, string[]>
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<Record<string, string[]>> {
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<string, string[]>)
}
static prepareFilters(
@@ -83,6 +110,54 @@ export default class TranslationModuleService
return restFilters
}
@InjectManager()
// @ts-expect-error
async retrieveTranslation(
id: string,
config: FindConfig<TranslationTypes.TranslationDTO> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<TranslationTypes.TranslationDTO> {
const configWithReference =
TranslationModuleService.ensureReferenceFieldInConfig(config)
const result = await this.translationService_.retrieve(
id,
configWithReference,
sharedContext
)
const serialized =
await this.baseRepository_.serialize<TranslationTypes.TranslationDTO>(
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<TranslationTypes.TranslationDTO>
): FindConfig<TranslationTypes.TranslationDTO> {
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<TranslationTypes.TranslationDTO[]> {
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<TranslationTypes.TranslationDTO[]>(
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<string, unknown>,
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<string, unknown>,
this.translatableFieldsConfig_[reference] || []
translatableFieldsConfig[reference] || []
)
}
}
@@ -275,13 +377,6 @@ export default class TranslationModuleService
return Array.isArray(data) ? serialized : serialized[0]
}
getTranslatableFields(entityType?: string): Record<string, string[]> {
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

View File

@@ -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<T> = {
[K in keyof T]: K extends `${string}_id`
? never
: "__typename" extends K
? never
: "id" extends keyof K
? never
: NonNullable<T[K]> 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<RemoteQueryEntryPoints[K]>[]
}
}[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
}
}

View File

@@ -0,0 +1,29 @@
import { TranslationTypes } from "@medusajs/framework/types"
export function filterTranslationFields(
translations: TranslationTypes.TranslationDTO[],
translatableFieldsConfig: Record<string, string[]>
): TranslationTypes.TranslationDTO[] {
return translations.map((translation) => {
const allowedFields = translatableFieldsConfig[translation.reference]
if (!allowedFields?.length) {
translation.translations = {}
return translation
}
const filteredTranslations: Record<string, unknown> = {}
for (const field of allowedFields) {
if (
translation.translations &&
field in (translation.translations as Record<string, unknown>)
) {
filteredTranslations[field] = (
translation.translations as Record<string, unknown>
)[field]
}
}
translation.translations = filteredTranslations
return translation
})
}