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:
1 parent
797878af26
commit
b21a599d11
26 files changed
+1033
-252
No files matched your search
@@ -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 () => {
|
it("should validate required fields", async () => {
|
||||||
// Missing locales
|
|
||||||
const response1 = await api
|
const response1 = await api
|
||||||
.get(
|
.get(
|
||||||
"/admin/translations/statistics?entity_types=product",
|
"/admin/translations/statistics?entity_types=product",
|
||||||
@@ -901,14 +900,12 @@ medusaIntegrationTestRunner({
|
|||||||
|
|
||||||
expect(response1.status).toEqual(400)
|
expect(response1.status).toEqual(400)
|
||||||
|
|
||||||
// Missing entity_types
|
|
||||||
const response2 = await api
|
const response2 = await api
|
||||||
.get("/admin/translations/statistics?locales=fr-FR", adminHeaders)
|
.get("/admin/translations/statistics?locales=fr-FR", adminHeaders)
|
||||||
.catch((e) => e.response)
|
.catch((e) => e.response)
|
||||||
|
|
||||||
expect(response2.status).toEqual(400)
|
expect(response2.status).toEqual(400)
|
||||||
|
|
||||||
// Both missing
|
|
||||||
const response3 = await api
|
const response3 = await api
|
||||||
.get("/admin/translations/statistics", adminHeaders)
|
.get("/admin/translations/statistics", adminHeaders)
|
||||||
.catch((e) => e.response)
|
.catch((e) => e.response)
|
||||||
@@ -916,6 +913,123 @@ medusaIntegrationTestRunner({
|
|||||||
expect(response3.status).toEqual(400)
|
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 { HttpTypes } from "@medusajs/types"
|
||||||
import {
|
import {
|
||||||
QueryKey,
|
QueryKey,
|
||||||
|
useInfiniteQuery,
|
||||||
UseInfiniteQueryOptions,
|
UseInfiniteQueryOptions,
|
||||||
UseInfiniteQueryResult,
|
|
||||||
useMutation,
|
useMutation,
|
||||||
UseMutationOptions,
|
UseMutationOptions,
|
||||||
useQuery,
|
useQuery,
|
||||||
UseQueryOptions,
|
UseQueryOptions,
|
||||||
} from "@tanstack/react-query"
|
} from "@tanstack/react-query"
|
||||||
import { sdk } from "../../lib/client"
|
import { sdk } from "../../lib/client"
|
||||||
import { queryKeysFactory } from "../../lib/query-key-factory"
|
|
||||||
import { queryClient } from "../../lib/query-client"
|
import { queryClient } from "../../lib/query-client"
|
||||||
import { productsQueryKeys, useInfiniteProducts } from "./products"
|
import { queryKeysFactory } from "../../lib/query-key-factory"
|
||||||
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"
|
|
||||||
|
|
||||||
const TRANSLATIONS_QUERY_KEY = "translations" as const
|
const TRANSLATIONS_QUERY_KEY = "translations" as const
|
||||||
export const translationsQueryKeys = queryKeysFactory(TRANSLATIONS_QUERY_KEY)
|
export const translationsQueryKeys = queryKeysFactory(TRANSLATIONS_QUERY_KEY)
|
||||||
@@ -35,177 +26,77 @@ export const translationStatisticsQueryKeys = queryKeysFactory(
|
|||||||
TRANSLATION_STATISTICS_QUERY_KEY
|
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 = (
|
export const useReferenceTranslations = (
|
||||||
reference: string,
|
reference: string,
|
||||||
translatableFields: string[],
|
|
||||||
referenceId?: string | string[],
|
referenceId?: string | string[],
|
||||||
options?: Omit<
|
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"
|
"queryFn" | "queryKey" | "initialPageParam" | "getNextPageParam"
|
||||||
>
|
>
|
||||||
) => {
|
) => {
|
||||||
const referenceHookMap = new Map<
|
const { data, ...rest } = useInfiniteQuery({
|
||||||
string,
|
queryKey: translationEntitiesQueryKeys.list({
|
||||||
() => Omit<UseInfiniteQueryResult<any, FetchError>, "data"> & {
|
type: reference,
|
||||||
data: {
|
id: referenceId,
|
||||||
translations: HttpTypes.AdminTranslation[]
|
}),
|
||||||
references: (Record<string, any> & { id: string })[]
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
count: number
|
return sdk.admin.translation.entities({
|
||||||
}
|
type: reference,
|
||||||
}
|
id: referenceId,
|
||||||
>([
|
limit: DEFAULT_PAGE_SIZE,
|
||||||
[
|
offset: pageParam,
|
||||||
"product",
|
})
|
||||||
() => {
|
},
|
||||||
const fields = translatableFields.concat(["translations.*"]).join(",")
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (lastPage) => {
|
||||||
|
const nextOffset = lastPage.offset + lastPage.limit
|
||||||
|
return nextOffset < lastPage.count ? nextOffset : undefined
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
|
||||||
const { data, ...rest } = useInfiniteProducts(
|
const entitiesWithTranslations =
|
||||||
{ fields, id: referenceId ?? [] },
|
data?.pages.flatMap((page) => page.data) ?? []
|
||||||
options
|
const translations = entitiesWithTranslations.flatMap(
|
||||||
|
(entity) => entity.translations ?? []
|
||||||
)
|
)
|
||||||
const products = data?.pages.flatMap((page) => page.products) ?? []
|
const references = entitiesWithTranslations.map(
|
||||||
|
({ translations: _, ...entity }) => entity
|
||||||
|
)
|
||||||
|
const count = data?.pages[0]?.count ?? 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
references,
|
||||||
|
translations,
|
||||||
|
count,
|
||||||
...rest,
|
...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}`)
|
|
||||||
}
|
|
||||||
const { data, ...rest } = referenceHook()
|
|
||||||
return { ...data, ...rest }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTranslations = (
|
export const useTranslations = (
|
||||||
@@ -229,15 +120,6 @@ export const useTranslations = (
|
|||||||
return { ...data, ...rest }
|
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 = (
|
export const useBatchTranslations = (
|
||||||
reference: string,
|
reference: string,
|
||||||
options?: UseMutationOptions<
|
options?: UseMutationOptions<
|
||||||
@@ -258,7 +140,7 @@ export const useBatchTranslations = (
|
|||||||
const invalidateQueries = async () => {
|
const invalidateQueries = async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: referenceInvalidationKeysMap.get(reference),
|
queryKey: translationEntitiesQueryKeys.list({ type: reference }),
|
||||||
}),
|
}),
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: translationStatisticsQueryKeys.lists(),
|
queryKey: translationStatisticsQueryKeys.lists(),
|
||||||
@@ -293,6 +175,27 @@ export const useTranslationSettings = (
|
|||||||
return { ...data, ...rest }
|
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 = (
|
export const useTranslationStatistics = (
|
||||||
query?: HttpTypes.AdminTranslationStatisticsParams,
|
query?: HttpTypes.AdminTranslationStatisticsParams,
|
||||||
options?: Omit<
|
options?: Omit<
|
||||||
|
|||||||
+7
-12
@@ -1,14 +1,14 @@
|
|||||||
|
import { keepPreviousData } from "@tanstack/react-query"
|
||||||
|
import { useEffect } from "react"
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom"
|
import { useNavigate, useSearchParams } from "react-router-dom"
|
||||||
|
import { RouteFocusModal } from "../../../components/modals"
|
||||||
import {
|
import {
|
||||||
useReferenceTranslations,
|
useReferenceTranslations,
|
||||||
useStore,
|
useStore,
|
||||||
useTranslationSettings,
|
useTranslationSettings,
|
||||||
} from "../../../hooks/api"
|
} 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 { useFeatureFlag } from "../../../providers/feature-flag-provider"
|
||||||
import { keepPreviousData } from "@tanstack/react-query"
|
import { TranslationsEditForm } from "./components/translations-edit-form"
|
||||||
|
|
||||||
export const TranslationsEdit = () => {
|
export const TranslationsEdit = () => {
|
||||||
const isTranslationsEnabled = useFeatureFlag("translation")
|
const isTranslationsEnabled = useFeatureFlag("translation")
|
||||||
@@ -41,15 +41,10 @@ export const TranslationsEdit = () => {
|
|||||||
isPending,
|
isPending,
|
||||||
isError,
|
isError,
|
||||||
error,
|
error,
|
||||||
} = useReferenceTranslations(
|
} = useReferenceTranslations(reference!, referenceIdParam, {
|
||||||
reference!,
|
enabled: !!reference,
|
||||||
translatable_fields?.[reference!] ?? [],
|
|
||||||
referenceIdParam,
|
|
||||||
{
|
|
||||||
enabled: !!translatable_fields && !!reference,
|
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
}
|
})
|
||||||
)
|
|
||||||
const {
|
const {
|
||||||
store,
|
store,
|
||||||
isPending: isStorePending,
|
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.
|
* 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
|
* It sends a request to the
|
||||||
|
|||||||
@@ -5,11 +5,30 @@ import {
|
|||||||
} from "../modules-sdk"
|
} from "../modules-sdk"
|
||||||
|
|
||||||
import type { RedisOptions } from "ioredis"
|
import type { RedisOptions } from "ioredis"
|
||||||
|
|
||||||
import { ConnectionOptions } from "node:tls"
|
import { ConnectionOptions } from "node:tls"
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import type { InlineConfig } from "vite"
|
import type { InlineConfig } from "vite"
|
||||||
import type { Logger } from "../logger"
|
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
|
* @interface
|
||||||
*
|
*
|
||||||
@@ -1093,17 +1112,6 @@ export type ConfigModule = {
|
|||||||
logger?: Logger
|
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 & {
|
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.
|
* 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<
|
type KnownModuleConfigs = {
|
||||||
InternalModuleDeclarationOverride | ExternalModuleDeclarationOverride
|
[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
|
* The configuration accepted by the "defineConfig" helper
|
||||||
|
|||||||
@@ -61,3 +61,28 @@ export interface AdminTranslationSettingsParams {
|
|||||||
*/
|
*/
|
||||||
entity_type?: string
|
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[]>
|
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
|
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.
|
* The filters to apply on the retrieved locales.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -704,27 +704,31 @@ export interface ITranslationModuleService extends IModuleService {
|
|||||||
): Promise<TranslationStatisticsOutput>
|
): Promise<TranslationStatisticsOutput>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This method retrieves the translatable fields of a resource. For example,
|
* This method retrieves the translatable fields of a resource from the database.
|
||||||
* product entities have translatable fields such as `title` and `description`.
|
* 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.
|
* @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`.
|
* 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
|
* @example
|
||||||
* To get translatable fields for all resources:
|
* To get translatable fields for all resources:
|
||||||
*
|
*
|
||||||
* ```ts
|
* ```ts
|
||||||
* const allFields = translationModuleService.getTranslatableFields()
|
* const allFields = await translationModuleService.getTranslatableFields()
|
||||||
* // Returns: { product: ["title", "description", ...], product_variant: ["title", ...] }
|
* // Returns: { product: ["title", "description", ...], product_variant: ["title", ...] }
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* To get translatable fields for a specific resource:
|
* To get translatable fields for a specific resource:
|
||||||
*
|
*
|
||||||
* ```ts
|
* ```ts
|
||||||
* const productFields = translationModuleService.getTranslatableFields("product")
|
* const productFields = await translationModuleService.getTranslatableFields("product")
|
||||||
* // Returns: { product: ["title", "description", "subtitle", "status"] }
|
* // 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)
|
...(isObject(moduleConfig)
|
||||||
? moduleConfig
|
? moduleConfig
|
||||||
: { disable: !moduleConfig }),
|
: { disable: !moduleConfig }),
|
||||||
})
|
} as InputConfigModules[number])
|
||||||
})
|
})
|
||||||
} else if (Array.isArray(configModules)) {
|
} else if (Array.isArray(configModules)) {
|
||||||
const modules_ = (configModules ?? []) as InternalModuleDeclaration[]
|
const modules_ = (configModules ?? []) as InternalModuleDeclaration[]
|
||||||
modules.push(...modules_)
|
modules.push(...(modules_ as InputConfigModules))
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Invalid modules configuration. Should be an array or object."
|
"Invalid modules configuration. Should be an array or object."
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
AdminBatchTranslations,
|
AdminBatchTranslations,
|
||||||
AdminGetTranslationsParams,
|
AdminGetTranslationsParams,
|
||||||
|
AdminTranslationEntitiesParams,
|
||||||
AdminTranslationSettingsParams,
|
AdminTranslationSettingsParams,
|
||||||
AdminTranslationStatistics,
|
AdminTranslationStatistics,
|
||||||
} from "./validators"
|
} from "./validators"
|
||||||
@@ -43,4 +44,14 @@ export const adminTranslationsRoutesMiddlewares: MiddlewareRoute[] = [
|
|||||||
validateAndTransformQuery(AdminTranslationSettingsParams, {}),
|
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>(
|
const translationService = req.scope.resolve<ITranslationModuleService>(
|
||||||
Modules.TRANSLATION
|
Modules.TRANSLATION
|
||||||
)
|
)
|
||||||
const translatable_fields = translationService.getTranslatableFields(
|
const translatableFields = await translationService.getTranslatableFields(
|
||||||
req.validatedQuery.entity_type
|
req.validatedQuery.entity_type
|
||||||
)
|
)
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
translatable_fields,
|
translatable_fields: translatableFields,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,3 +71,16 @@ export type AdminTranslationSettingsParamsType = z.infer<
|
|||||||
export const AdminTranslationSettingsParams = z.object({
|
export const AdminTranslationSettingsParams = z.object({
|
||||||
entity_type: z.string().optional(),
|
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(),
|
||||||
|
})
|
||||||
|
)
|
||||||
+88
-3
@@ -15,7 +15,11 @@ moduleIntegrationTestRunner<ITranslationModuleService>({
|
|||||||
service: TranslationModuleService,
|
service: TranslationModuleService,
|
||||||
}).linkable
|
}).linkable
|
||||||
|
|
||||||
expect(Object.keys(linkable)).toEqual(["locale", "translation"])
|
expect(Object.keys(linkable)).toEqual([
|
||||||
|
"locale",
|
||||||
|
"translation",
|
||||||
|
"translationSettings",
|
||||||
|
])
|
||||||
|
|
||||||
Object.keys(linkable).forEach((key) => {
|
Object.keys(linkable).forEach((key) => {
|
||||||
delete linkable[key].toJSON
|
delete linkable[key].toJSON
|
||||||
@@ -40,6 +44,15 @@ moduleIntegrationTestRunner<ITranslationModuleService>({
|
|||||||
field: "translation",
|
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("Statistics", () => {
|
||||||
describe("getStatistics", () => {
|
describe("getStatistics", () => {
|
||||||
it("should return statistics for a single entity type and locale", async () => {
|
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([
|
await service.createTranslations([
|
||||||
{
|
{
|
||||||
reference_id: "prod_stat_1",
|
reference_id: "prod_stat_1",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { defineMikroOrmCliConfig } from "@medusajs/framework/utils"
|
import { defineMikroOrmCliConfig } from "@medusajs/framework/utils"
|
||||||
import Locale from "./src/models/locale"
|
import Locale from "./src/models/locale"
|
||||||
import Translation from "./src/models/translation"
|
import Translation from "./src/models/translation"
|
||||||
|
import Settings from "./src/models/settings"
|
||||||
|
|
||||||
export default defineMikroOrmCliConfig("translation", {
|
export default defineMikroOrmCliConfig("translation", {
|
||||||
entities: [Locale, Translation],
|
entities: [Locale, Translation, Settings],
|
||||||
})
|
})
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import TranslationModuleService from "@services/translation-module"
|
import "./types"
|
||||||
import loadDefaults from "./loaders/defaults"
|
|
||||||
import loadConfig from "./loaders/config"
|
|
||||||
import { Module } from "@medusajs/framework/utils"
|
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"
|
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 { TRANSLATABLE_FIELDS_CONFIG_KEY } from "@utils/constants"
|
||||||
import { asValue } from "awilix"
|
import { asValue } from "awilix"
|
||||||
import { translatableFieldsConfig } from "../utils/translatable-fields"
|
import { translatableFieldsConfig } from "../utils/translatable-fields"
|
||||||
|
import Settings from "@models/settings"
|
||||||
|
import type { TranslationModuleOptions } from "../types"
|
||||||
|
|
||||||
export default async ({ container }: LoaderOptions): Promise<void> => {
|
export default async ({
|
||||||
container.register(
|
container,
|
||||||
TRANSLATABLE_FIELDS_CONFIG_KEY,
|
options,
|
||||||
asValue(translatableFieldsConfig)
|
}: 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": [],
|
"checks": [],
|
||||||
"foreignKeys": {},
|
"foreignKeys": {},
|
||||||
"nativeEnums": {}
|
"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": {}
|
"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 Translation } from "./translation"
|
||||||
export { default as Locale } from "./locale"
|
export { default as Locale } from "./locale"
|
||||||
|
export { default as Settings } from "./settings"
|
||||||
@@ -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"
|
} from "@medusajs/framework/utils"
|
||||||
import Locale from "@models/locale"
|
import Locale from "@models/locale"
|
||||||
import Translation from "@models/translation"
|
import Translation from "@models/translation"
|
||||||
|
import Settings from "@models/settings"
|
||||||
import { computeTranslatedFieldCount } from "@utils/compute-translated-field-count"
|
import { computeTranslatedFieldCount } from "@utils/compute-translated-field-count"
|
||||||
import { TRANSLATABLE_FIELDS_CONFIG_KEY } from "@utils/constants"
|
import { TRANSLATABLE_FIELDS_CONFIG_KEY } from "@utils/constants"
|
||||||
|
import { filterTranslationFields } from "@utils/filter-translation-fields"
|
||||||
|
|
||||||
type InjectedDependencies = {
|
type InjectedDependencies = {
|
||||||
baseRepository: DAL.RepositoryService
|
baseRepository: DAL.RepositoryService
|
||||||
translationService: ModulesSdkTypes.IMedusaInternalService<typeof Translation>
|
translationService: ModulesSdkTypes.IMedusaInternalService<typeof Translation>
|
||||||
localeService: ModulesSdkTypes.IMedusaInternalService<typeof Locale>
|
localeService: ModulesSdkTypes.IMedusaInternalService<typeof Locale>
|
||||||
|
translationSettingsService: ModulesSdkTypes.IMedusaInternalService<
|
||||||
|
typeof Settings
|
||||||
|
>
|
||||||
[TRANSLATABLE_FIELDS_CONFIG_KEY]: Record<string, string[]>
|
[TRANSLATABLE_FIELDS_CONFIG_KEY]: Record<string, string[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,9 +44,13 @@ export default class TranslationModuleService
|
|||||||
Translation: {
|
Translation: {
|
||||||
dto: TranslationTypes.TranslationDTO
|
dto: TranslationTypes.TranslationDTO
|
||||||
}
|
}
|
||||||
|
TranslationSettings: {
|
||||||
|
dto: TranslationTypes.TranslationSettingsDTO
|
||||||
|
}
|
||||||
}>({
|
}>({
|
||||||
Locale,
|
Locale,
|
||||||
Translation,
|
Translation,
|
||||||
|
TranslationSettings: Settings,
|
||||||
})
|
})
|
||||||
implements ITranslationModuleService
|
implements ITranslationModuleService
|
||||||
{
|
{
|
||||||
@@ -52,20 +61,38 @@ export default class TranslationModuleService
|
|||||||
protected localeService_: ModulesSdkTypes.IMedusaInternalService<
|
protected localeService_: ModulesSdkTypes.IMedusaInternalService<
|
||||||
typeof Locale
|
typeof Locale
|
||||||
>
|
>
|
||||||
|
protected settingsService_: ModulesSdkTypes.IMedusaInternalService<
|
||||||
private readonly translatableFieldsConfig_: Record<string, string[]>
|
typeof Settings
|
||||||
|
>
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
baseRepository,
|
baseRepository,
|
||||||
translationService,
|
translationService,
|
||||||
localeService,
|
localeService,
|
||||||
translatableFieldsConfig,
|
translationSettingsService,
|
||||||
}: InjectedDependencies) {
|
}: InjectedDependencies) {
|
||||||
super(...arguments)
|
super(...arguments)
|
||||||
this.baseRepository_ = baseRepository
|
this.baseRepository_ = baseRepository
|
||||||
this.translationService_ = translationService
|
this.translationService_ = translationService
|
||||||
this.localeService_ = localeService
|
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(
|
static prepareFilters(
|
||||||
@@ -83,6 +110,54 @@ export default class TranslationModuleService
|
|||||||
return restFilters
|
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()
|
@InjectManager()
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
async listTranslations(
|
async listTranslations(
|
||||||
@@ -91,16 +166,25 @@ export default class TranslationModuleService
|
|||||||
@MedusaContext() sharedContext: Context = {}
|
@MedusaContext() sharedContext: Context = {}
|
||||||
): Promise<TranslationTypes.TranslationDTO[]> {
|
): Promise<TranslationTypes.TranslationDTO[]> {
|
||||||
const preparedFilters = TranslationModuleService.prepareFilters(filters)
|
const preparedFilters = TranslationModuleService.prepareFilters(filters)
|
||||||
|
const configWithReference =
|
||||||
|
TranslationModuleService.ensureReferenceFieldInConfig(config)
|
||||||
|
|
||||||
const results = await this.translationService_.list(
|
const results = await this.translationService_.list(
|
||||||
preparedFilters,
|
preparedFilters,
|
||||||
config,
|
configWithReference,
|
||||||
sharedContext
|
sharedContext
|
||||||
)
|
)
|
||||||
|
|
||||||
return await this.baseRepository_.serialize<
|
const serialized = await this.baseRepository_.serialize<
|
||||||
TranslationTypes.TranslationDTO[]
|
TranslationTypes.TranslationDTO[]
|
||||||
>(results)
|
>(results)
|
||||||
|
|
||||||
|
const translatableFieldsConfig = await this.getTranslatableFields(
|
||||||
|
undefined,
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
|
||||||
|
return filterTranslationFields(serialized, translatableFieldsConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
@InjectManager()
|
@InjectManager()
|
||||||
@@ -111,17 +195,26 @@ export default class TranslationModuleService
|
|||||||
@MedusaContext() sharedContext: Context = {}
|
@MedusaContext() sharedContext: Context = {}
|
||||||
): Promise<[TranslationTypes.TranslationDTO[], number]> {
|
): Promise<[TranslationTypes.TranslationDTO[], number]> {
|
||||||
const preparedFilters = TranslationModuleService.prepareFilters(filters)
|
const preparedFilters = TranslationModuleService.prepareFilters(filters)
|
||||||
|
const configWithReference =
|
||||||
|
TranslationModuleService.ensureReferenceFieldInConfig(config)
|
||||||
|
|
||||||
const [results, count] = await this.translationService_.listAndCount(
|
const [results, count] = await this.translationService_.listAndCount(
|
||||||
preparedFilters,
|
preparedFilters,
|
||||||
config,
|
configWithReference,
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
|
||||||
|
const serialized = await this.baseRepository_.serialize<
|
||||||
|
TranslationTypes.TranslationDTO[]
|
||||||
|
>(results)
|
||||||
|
|
||||||
|
const translatableFieldsConfig = await this.getTranslatableFields(
|
||||||
|
undefined,
|
||||||
sharedContext
|
sharedContext
|
||||||
)
|
)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
await this.baseRepository_.serialize<TranslationTypes.TranslationDTO[]>(
|
filterTranslationFields(serialized, translatableFieldsConfig),
|
||||||
results
|
|
||||||
),
|
|
||||||
count,
|
count,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -183,12 +276,16 @@ export default class TranslationModuleService
|
|||||||
TranslationTypes.TranslationDTO | TranslationTypes.TranslationDTO[]
|
TranslationTypes.TranslationDTO | TranslationTypes.TranslationDTO[]
|
||||||
> {
|
> {
|
||||||
const dataArray = Array.isArray(data) ? data : [data]
|
const dataArray = Array.isArray(data) ? data : [data]
|
||||||
|
const translatableFieldsConfig = await this.getTranslatableFields(
|
||||||
|
undefined,
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
const normalizedData = dataArray.map((translation) => ({
|
const normalizedData = dataArray.map((translation) => ({
|
||||||
...translation,
|
...translation,
|
||||||
locale_code: normalizeLocale(translation.locale_code),
|
locale_code: normalizeLocale(translation.locale_code),
|
||||||
translated_field_count: computeTranslatedFieldCount(
|
translated_field_count: computeTranslatedFieldCount(
|
||||||
translation.translations as Record<string, unknown>,
|
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) {
|
for (const update of dataArray) {
|
||||||
if (update.translations) {
|
if (update.translations) {
|
||||||
const reference = update.reference || referenceMap[update.id]
|
const reference = update.reference || referenceMap[update.id]
|
||||||
@@ -257,7 +359,7 @@ export default class TranslationModuleService
|
|||||||
}
|
}
|
||||||
).translated_field_count = computeTranslatedFieldCount(
|
).translated_field_count = computeTranslatedFieldCount(
|
||||||
update.translations as Record<string, unknown>,
|
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]
|
return Array.isArray(data) ? serialized : serialized[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
getTranslatableFields(entityType?: string): Record<string, string[]> {
|
|
||||||
if (entityType) {
|
|
||||||
return { [entityType]: this.translatableFieldsConfig_[entityType] }
|
|
||||||
}
|
|
||||||
return this.translatableFieldsConfig_
|
|
||||||
}
|
|
||||||
|
|
||||||
@InjectManager()
|
@InjectManager()
|
||||||
async getStatistics(
|
async getStatistics(
|
||||||
input: TranslationTypes.TranslationStatisticsInput,
|
input: TranslationTypes.TranslationStatisticsInput,
|
||||||
@@ -309,11 +404,16 @@ export default class TranslationModuleService
|
|||||||
sharedContext.manager) as SqlEntityManager
|
sharedContext.manager) as SqlEntityManager
|
||||||
const knex = manager.getKnex()
|
const knex = manager.getKnex()
|
||||||
|
|
||||||
|
const translatableFieldsConfig = await this.getTranslatableFields(
|
||||||
|
undefined,
|
||||||
|
sharedContext
|
||||||
|
)
|
||||||
|
|
||||||
const result: TranslationTypes.TranslationStatisticsOutput = {}
|
const result: TranslationTypes.TranslationStatisticsOutput = {}
|
||||||
const entityTypes: string[] = []
|
const entityTypes: string[] = []
|
||||||
|
|
||||||
for (const entityType of Object.keys(entities)) {
|
for (const entityType of Object.keys(entities)) {
|
||||||
const translatableFields = this.translatableFieldsConfig_[entityType]
|
const translatableFields = translatableFieldsConfig[entityType]
|
||||||
|
|
||||||
if (!translatableFields || translatableFields.length === 0) {
|
if (!translatableFields || translatableFields.length === 0) {
|
||||||
result[entityType] = {
|
result[entityType] = {
|
||||||
@@ -352,7 +452,7 @@ export default class TranslationModuleService
|
|||||||
)
|
)
|
||||||
|
|
||||||
for (const entityType of entityTypes) {
|
for (const entityType of entityTypes) {
|
||||||
const translatableFields = this.translatableFieldsConfig_[entityType]
|
const translatableFields = translatableFieldsConfig[entityType]
|
||||||
const fieldsPerEntity = translatableFields.length
|
const fieldsPerEntity = translatableFields.length
|
||||||
const entityCount = entities[entityType].count
|
const entityCount = entities[entityType].count
|
||||||
const expectedPerLocale = entityCount * fieldsPerEntity
|
const expectedPerLocale = entityCount * fieldsPerEntity
|
||||||
|
|||||||
@@ -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