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:
committed by
GitHub
parent
797878af26
commit
b21a599d11
9
.changeset/gentle-bees-grow.md
Normal file
9
.changeset/gentle-bees-grow.md
Normal 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
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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[]>>
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
90
packages/medusa/src/api/admin/translations/entities/route.ts
Normal file
90
packages/medusa/src/api/admin/translations/entities/route.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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": {}
|
||||
|
||||
@@ -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;`)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
26
packages/modules/translation/src/models/settings.ts
Normal file
26
packages/modules/translation/src/models/settings.ts
Normal 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
|
||||
@@ -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
|
||||
|
||||
50
packages/modules/translation/src/types/index.ts
Normal file
50
packages/modules/translation/src/types/index.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user