feat(): Translation statistics (#14299)
* chore(): Translation statistics * chore(): improve statistics performances * add end point to get statistics * add tests * Create spicy-games-unite.md * feat(): add material and fix tests * feat(): add translatable api * feat(): add translatable api * fix tests * fix tests * fix tests * feedback
This commit is contained in:
committed by
GitHub
parent
0f1566c644
commit
ba6ed8d9dd
7
.changeset/spicy-games-unite.md
Normal file
7
.changeset/spicy-games-unite.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@medusajs/medusa": patch
|
||||
"@medusajs/translation": patch
|
||||
"@medusajs/types": patch
|
||||
---
|
||||
|
||||
chore(): Translation statistics
|
||||
@@ -674,6 +674,249 @@ medusaIntegrationTestRunner({
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("GET /admin/translations/statistics", () => {
|
||||
it("should return statistics for entity types with no translations", async () => {
|
||||
const productModule = appContainer.resolve(Modules.PRODUCT)
|
||||
await productModule.createProducts([
|
||||
{ title: "Product 1" },
|
||||
{ title: "Product 2" },
|
||||
])
|
||||
|
||||
const response = await api.get(
|
||||
"/admin/translations/statistics?locales=en-US&locales=fr-FR&entity_types=product",
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(response.data.statistics).toBeDefined()
|
||||
expect(response.data.statistics.product).toEqual({
|
||||
// 2 products × 5 translatable fields × 2 locales = 20 expected
|
||||
expected: 20,
|
||||
translated: 0,
|
||||
missing: 20,
|
||||
by_locale: {
|
||||
"en-US": { expected: 10, translated: 0, missing: 10 },
|
||||
"fr-FR": { expected: 10, translated: 0, missing: 10 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should return statistics with partial translations", async () => {
|
||||
const productModule = appContainer.resolve(Modules.PRODUCT)
|
||||
const [product1, product2] = await productModule.createProducts([
|
||||
{ title: "Product 1" },
|
||||
{ title: "Product 2" },
|
||||
])
|
||||
|
||||
// Create translations for product1 with partial fields
|
||||
await api.post(
|
||||
"/admin/translations/batch",
|
||||
{
|
||||
create: [
|
||||
{
|
||||
reference_id: product1.id,
|
||||
reference: "product",
|
||||
locale_code: "fr-FR",
|
||||
translations: {
|
||||
title: "Produit 1",
|
||||
description: "Description du produit 1",
|
||||
},
|
||||
},
|
||||
{
|
||||
reference_id: product2.id,
|
||||
reference: "product",
|
||||
locale_code: "fr-FR",
|
||||
translations: {
|
||||
title: "Produit 2",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const response = await api.get(
|
||||
"/admin/translations/statistics?locales=fr-FR&entity_types=product",
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
// 2 products × 5 fields × 1 locale = 10 expected
|
||||
// product1 has 2 fields, product2 has 1 field = 3 translated
|
||||
expect(response.data.statistics.product).toEqual({
|
||||
expected: 10,
|
||||
translated: 3,
|
||||
missing: 7,
|
||||
by_locale: {
|
||||
"fr-FR": { expected: 10, translated: 3, missing: 7 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should return statistics for multiple entity types", async () => {
|
||||
const productModule = appContainer.resolve(Modules.PRODUCT)
|
||||
const [product] = await productModule.createProducts([
|
||||
{
|
||||
title: "Product with variant",
|
||||
variants: [{ title: "Variant 1" }, { title: "Variant 2" }],
|
||||
},
|
||||
])
|
||||
|
||||
await api.post(
|
||||
"/admin/translations/batch",
|
||||
{
|
||||
create: [
|
||||
{
|
||||
reference_id: product.id,
|
||||
reference: "product",
|
||||
locale_code: "fr-FR",
|
||||
translations: {
|
||||
title: "Produit",
|
||||
description: "Description",
|
||||
subtitle: "Sous-titre",
|
||||
status: "Actif",
|
||||
material: "Matériau",
|
||||
},
|
||||
},
|
||||
{
|
||||
reference_id: product.variants[0].id,
|
||||
reference: "product_variant",
|
||||
locale_code: "fr-FR",
|
||||
translations: {
|
||||
title: "Variante 1",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const response = await api.get(
|
||||
"/admin/translations/statistics?locales=fr-FR&entity_types=product&entity_types=product_variant",
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
|
||||
// Product: 1 × 5 fields × 1 locale = 5, all translated
|
||||
expect(response.data.statistics.product).toEqual({
|
||||
expected: 5,
|
||||
translated: 5,
|
||||
missing: 0,
|
||||
by_locale: {
|
||||
"fr-FR": { expected: 5, translated: 5, missing: 0 },
|
||||
},
|
||||
})
|
||||
|
||||
// Variant: 2 × 2 fields × 1 locale = 4, 1 translated
|
||||
expect(response.data.statistics.product_variant).toEqual({
|
||||
expected: 4,
|
||||
translated: 1,
|
||||
missing: 3,
|
||||
by_locale: {
|
||||
"fr-FR": { expected: 4, translated: 1, missing: 3 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should return statistics for multiple locales", async () => {
|
||||
const productModule = appContainer.resolve(Modules.PRODUCT)
|
||||
const [product] = await productModule.createProducts([
|
||||
{ title: "Product" },
|
||||
])
|
||||
|
||||
await api.post(
|
||||
"/admin/translations/batch",
|
||||
{
|
||||
create: [
|
||||
{
|
||||
reference_id: product.id,
|
||||
reference: "product",
|
||||
locale_code: "fr-FR",
|
||||
translations: {
|
||||
title: "Produit",
|
||||
description: "Description",
|
||||
},
|
||||
},
|
||||
{
|
||||
reference_id: product.id,
|
||||
reference: "product",
|
||||
locale_code: "de-DE",
|
||||
translations: { title: "Produkt" },
|
||||
},
|
||||
],
|
||||
},
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const response = await api.get(
|
||||
"/admin/translations/statistics?locales=fr-FR&locales=de-DE&entity_types=product",
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
// 1 product × 5 fields × 2 locales = 10 expected
|
||||
// fr-FR: 2 translated, de-DE: 1 translated = 3 total
|
||||
expect(response.data.statistics.product.expected).toEqual(10)
|
||||
expect(response.data.statistics.product.translated).toEqual(3)
|
||||
expect(response.data.statistics.product.missing).toEqual(7)
|
||||
|
||||
expect(response.data.statistics.product.by_locale["fr-FR"]).toEqual({
|
||||
expected: 5,
|
||||
translated: 2,
|
||||
missing: 3,
|
||||
})
|
||||
expect(response.data.statistics.product.by_locale["de-DE"]).toEqual({
|
||||
expected: 5,
|
||||
translated: 1,
|
||||
missing: 4,
|
||||
})
|
||||
})
|
||||
|
||||
it("should return zeros for unknown entity types", async () => {
|
||||
const response = await api.get(
|
||||
"/admin/translations/statistics?locales=fr-FR&entity_types=unknown_entity",
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(response.data.statistics.unknown_entity).toEqual({
|
||||
expected: 0,
|
||||
translated: 0,
|
||||
missing: 0,
|
||||
by_locale: {
|
||||
"fr-FR": { expected: 0, translated: 0, missing: 0 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should validate required fields", async () => {
|
||||
// Missing locales
|
||||
const response1 = await api
|
||||
.get(
|
||||
"/admin/translations/statistics?entity_types=product",
|
||||
adminHeaders
|
||||
)
|
||||
.catch((e) => e.response)
|
||||
|
||||
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)
|
||||
|
||||
expect(response3.status).toEqual(400)
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -21,3 +21,18 @@ export interface AdminTranslationsListParams
|
||||
*/
|
||||
locale_code?: string | string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for translation statistics endpoint.
|
||||
*/
|
||||
export interface AdminTranslationStatisticsParams {
|
||||
/**
|
||||
* The locales to check translations for (e.g., ["en-US", "fr-FR"]).
|
||||
*/
|
||||
locales: string[]
|
||||
|
||||
/**
|
||||
* The entity types to get statistics for (e.g., ["product", "product_variant"]).
|
||||
*/
|
||||
entity_types: string[]
|
||||
}
|
||||
|
||||
@@ -33,3 +33,58 @@ export interface AdminTranslationsBatchResponse {
|
||||
deleted: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistics for a specific locale.
|
||||
*/
|
||||
export interface AdminTranslationLocaleStatistics {
|
||||
/**
|
||||
* Expected number of translated fields.
|
||||
*/
|
||||
expected: number
|
||||
/**
|
||||
* Actual number of translated fields.
|
||||
*/
|
||||
translated: number
|
||||
/**
|
||||
* Number of missing translations.
|
||||
*/
|
||||
missing: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistics for an entity type.
|
||||
*/
|
||||
export interface AdminTranslationEntityStatistics
|
||||
extends AdminTranslationLocaleStatistics {
|
||||
/**
|
||||
* Breakdown of statistics by locale.
|
||||
*/
|
||||
by_locale: Record<string, AdminTranslationLocaleStatistics>
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for translation statistics endpoint.
|
||||
*/
|
||||
export interface AdminTranslationStatisticsResponse {
|
||||
/**
|
||||
* Statistics by entity type.
|
||||
*/
|
||||
statistics: Record<string, AdminTranslationEntityStatistics>
|
||||
}
|
||||
|
||||
/**
|
||||
* Response for translation settings endpoint.
|
||||
*/
|
||||
export interface AdminTranslationSettingsResponse {
|
||||
/**
|
||||
* A mapping of entity types to their translatable field names.
|
||||
*
|
||||
* @example
|
||||
* {
|
||||
* "product": ["title", "description", "subtitle", "status"],
|
||||
* "product_variant": ["title", "material"]
|
||||
* }
|
||||
*/
|
||||
translatable_fields: Record<string, string[]>
|
||||
}
|
||||
|
||||
@@ -132,3 +132,55 @@ export interface FilterableTranslationProps
|
||||
*/
|
||||
locale_code?: string | string[] | OperatorMap<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for getStatistics method.
|
||||
*/
|
||||
export interface TranslationStatisticsInput {
|
||||
/**
|
||||
* Locales to check translations for (e.g., ["en-US", "fr-FR"]).
|
||||
*/
|
||||
locales: string[]
|
||||
|
||||
/**
|
||||
* Entity types with their total counts.
|
||||
* Key is the entity type (e.g., "product"), value contains the count of entities.
|
||||
*/
|
||||
entities: Record<string, { count: number }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistics for a specific locale.
|
||||
*/
|
||||
export interface LocaleStatistics {
|
||||
/**
|
||||
* Expected number of translated fields.
|
||||
*/
|
||||
expected: number
|
||||
|
||||
/**
|
||||
* Actual number of translated fields (non-null, non-empty).
|
||||
*/
|
||||
translated: number
|
||||
|
||||
/**
|
||||
* Number of missing translations (expected - translated).
|
||||
*/
|
||||
missing: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistics for an entity type.
|
||||
*/
|
||||
export interface EntityTypeStatistics extends LocaleStatistics {
|
||||
/**
|
||||
* Breakdown of statistics by locale.
|
||||
*/
|
||||
by_locale: Record<string, LocaleStatistics>
|
||||
}
|
||||
|
||||
/**
|
||||
* Output of getStatistics method.
|
||||
* Maps entity types to their translation statistics.
|
||||
*/
|
||||
export type TranslationStatisticsOutput = Record<string, EntityTypeStatistics>
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
FilterableTranslationProps,
|
||||
LocaleDTO,
|
||||
TranslationDTO,
|
||||
TranslationStatisticsInput,
|
||||
TranslationStatisticsOutput,
|
||||
} from "./common"
|
||||
import {
|
||||
CreateLocaleDTO,
|
||||
@@ -291,4 +293,57 @@ export interface ITranslationModuleService extends IModuleService {
|
||||
config?: RestoreReturn<TReturnableLinkableKeys>,
|
||||
sharedContext?: Context
|
||||
): Promise<Record<string, string[]> | void>
|
||||
|
||||
/**
|
||||
* This method retrieves translation statistics for the given entities and locales.
|
||||
* It counts translated fields at a granular level, providing both aggregated
|
||||
* and per-locale breakdowns.
|
||||
*
|
||||
* @param {TranslationStatisticsInput} input - The entities and locales to check.
|
||||
* @param {Context} sharedContext
|
||||
* @returns {Promise<TranslationStatisticsOutput>} Statistics by entity type.
|
||||
*
|
||||
* @example
|
||||
* const stats = await translationService.getStatistics({
|
||||
* locales: ["en-US", "fr-FR"],
|
||||
* entities: {
|
||||
* product: { count: 2 },
|
||||
* product_variant: { count: 5 }
|
||||
* }
|
||||
* })
|
||||
* // Returns:
|
||||
* // {
|
||||
* // product: {
|
||||
* // expected: 20, // 2 products × 5 fields × 2 locales
|
||||
* // translated: 15,
|
||||
* // missing: 5,
|
||||
* // by_locale: {
|
||||
* // "en-US": { expected: 10, translated: 8, missing: 2 },
|
||||
* // "fr-FR": { expected: 10, translated: 7, missing: 3 }
|
||||
* // }
|
||||
* // }
|
||||
* // }
|
||||
*/
|
||||
getStatistics(
|
||||
input: TranslationStatisticsInput,
|
||||
sharedContext?: Context
|
||||
): Promise<TranslationStatisticsOutput>
|
||||
|
||||
/**
|
||||
* This method retrieves the translatable fields configuration.
|
||||
* Returns a mapping of entity types to their translatable field names.
|
||||
*
|
||||
* @param {string} entityType - Optional entity type to filter by. If not provided, returns all.
|
||||
* @returns {Record<string, string[]>} A mapping of entity types to their translatable fields.
|
||||
*
|
||||
* @example
|
||||
* // Get all translatable fields
|
||||
* const allFields = translationService.getTranslatableFields()
|
||||
* // Returns: { product: ["title", "description", ...], product_variant: ["title", ...] }
|
||||
*
|
||||
* // Get fields for a specific entity type
|
||||
* const productFields = translationService.getTranslatableFields("product")
|
||||
* // Returns: { product: ["title", "description", "subtitle", "status"] }
|
||||
*/
|
||||
getTranslatableFields(entityType?: string): Record<string, string[]>
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import {
|
||||
AdminBatchTranslations,
|
||||
AdminGetTranslationsParams,
|
||||
AdminTranslationStatistics,
|
||||
} from "./validators"
|
||||
import * as QueryConfig from "./query-config"
|
||||
import { DEFAULT_BATCH_ENDPOINTS_SIZE_LIMIT } from "../../../utils"
|
||||
@@ -29,4 +30,14 @@ export const adminTranslationsRoutesMiddlewares: MiddlewareRoute[] = [
|
||||
},
|
||||
middlewares: [validateAndTransformBody(AdminBatchTranslations)],
|
||||
},
|
||||
{
|
||||
method: ["GET"],
|
||||
matcher: "/admin/translations/statistics",
|
||||
middlewares: [validateAndTransformQuery(AdminTranslationStatistics, {})],
|
||||
},
|
||||
{
|
||||
method: ["GET"],
|
||||
matcher: "/admin/translations/settings",
|
||||
middlewares: [],
|
||||
},
|
||||
]
|
||||
|
||||
29
packages/medusa/src/api/admin/translations/settings/route.ts
Normal file
29
packages/medusa/src/api/admin/translations/settings/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
AuthenticatedMedusaRequest,
|
||||
MedusaResponse,
|
||||
} from "@medusajs/framework/http"
|
||||
import { HttpTypes, ITranslationModuleService } from "@medusajs/framework/types"
|
||||
import {
|
||||
defineFileConfig,
|
||||
FeatureFlag,
|
||||
Modules,
|
||||
} from "@medusajs/framework/utils"
|
||||
import TranslationFeatureFlag from "../../../../feature-flags/translation"
|
||||
|
||||
export const GET = async (
|
||||
req: AuthenticatedMedusaRequest,
|
||||
res: MedusaResponse<HttpTypes.AdminTranslationSettingsResponse>
|
||||
) => {
|
||||
const translationService = req.scope.resolve<ITranslationModuleService>(
|
||||
Modules.TRANSLATION
|
||||
)
|
||||
const translatable_fields = translationService.getTranslatableFields()
|
||||
|
||||
res.json({
|
||||
translatable_fields,
|
||||
})
|
||||
}
|
||||
|
||||
defineFileConfig({
|
||||
isDisabled: () => !FeatureFlag.isFeatureEnabled(TranslationFeatureFlag.key),
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
AuthenticatedMedusaRequest,
|
||||
MedusaResponse,
|
||||
} from "@medusajs/framework/http"
|
||||
import { HttpTypes, ITranslationModuleService } from "@medusajs/framework/types"
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
defineFileConfig,
|
||||
FeatureFlag,
|
||||
Modules,
|
||||
promiseAll,
|
||||
} from "@medusajs/framework/utils"
|
||||
import TranslationFeatureFlag from "../../../../feature-flags/translation"
|
||||
|
||||
export const GET = async (
|
||||
req: AuthenticatedMedusaRequest<
|
||||
unknown,
|
||||
HttpTypes.AdminTranslationStatisticsParams
|
||||
>,
|
||||
res: MedusaResponse<HttpTypes.AdminTranslationStatisticsResponse>
|
||||
) => {
|
||||
const translationService = req.scope.resolve<ITranslationModuleService>(
|
||||
Modules.TRANSLATION
|
||||
)
|
||||
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
|
||||
|
||||
const { locales, entity_types } = req.validatedQuery
|
||||
|
||||
// Fetch counts for each entity type in parallel
|
||||
const entityCounts = await promiseAll(
|
||||
entity_types.map(async (entityType) => {
|
||||
const { metadata } = await query
|
||||
.graph(
|
||||
{
|
||||
entity: entityType,
|
||||
fields: ["id"],
|
||||
pagination: { take: 1, skip: 0 },
|
||||
},
|
||||
{
|
||||
throwIfKeyNotFound: false,
|
||||
cache: { enable: true },
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
const normalizedMessage = e.message.toLowerCase()
|
||||
if (
|
||||
normalizedMessage.includes("service with alias") &&
|
||||
normalizedMessage.includes("was not found")
|
||||
) {
|
||||
return { metadata: { count: 0 } }
|
||||
}
|
||||
throw e
|
||||
})
|
||||
return { entityType, count: metadata?.count ?? 0 }
|
||||
})
|
||||
)
|
||||
|
||||
const entities: Record<string, { count: number }> = {}
|
||||
for (const { entityType, count } of entityCounts) {
|
||||
entities[entityType] = { count }
|
||||
}
|
||||
|
||||
const statistics = await translationService.getStatistics({
|
||||
locales,
|
||||
entities,
|
||||
})
|
||||
|
||||
return res.json({
|
||||
statistics,
|
||||
})
|
||||
}
|
||||
|
||||
defineFileConfig({
|
||||
isDisabled: () => !FeatureFlag.isFeatureEnabled(TranslationFeatureFlag.key),
|
||||
})
|
||||
@@ -48,3 +48,19 @@ export const AdminBatchTranslations = createBatchBody(
|
||||
AdminCreateTranslation,
|
||||
AdminUpdateTranslation
|
||||
)
|
||||
|
||||
export type AdminTranslationStatisticsType = z.infer<
|
||||
typeof AdminTranslationStatistics
|
||||
>
|
||||
export const AdminTranslationStatistics = z
|
||||
.object({
|
||||
locales: z.union([z.string(), z.array(z.string())]),
|
||||
entity_types: z.union([z.string(), z.array(z.string())]),
|
||||
})
|
||||
.transform((data) => ({
|
||||
// Normalize to arrays for consistent handling
|
||||
locales: Array.isArray(data.locales) ? data.locales : [data.locales],
|
||||
entity_types: Array.isArray(data.entity_types)
|
||||
? data.entity_types
|
||||
: [data.entity_types],
|
||||
}))
|
||||
|
||||
@@ -646,6 +646,330 @@ moduleIntegrationTestRunner<ITranslationModuleService>({
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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, subtitle, status
|
||||
await service.createTranslations([
|
||||
{
|
||||
reference_id: "prod_stat_1",
|
||||
reference: "product",
|
||||
locale_code: "en-US",
|
||||
translations: {
|
||||
title: "Product 1",
|
||||
description: "Description 1",
|
||||
// subtitle and status are missing
|
||||
},
|
||||
},
|
||||
{
|
||||
reference_id: "prod_stat_2",
|
||||
reference: "product",
|
||||
locale_code: "en-US",
|
||||
translations: {
|
||||
title: "Product 2",
|
||||
description: "Description 2",
|
||||
subtitle: "Subtitle 2",
|
||||
status: "Active",
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const stats = await service.getStatistics({
|
||||
locales: ["en-US"],
|
||||
entities: {
|
||||
product: { count: 2 },
|
||||
},
|
||||
})
|
||||
|
||||
// Expected: 2 products × 5 fields × 1 locale = 10
|
||||
// Translated: prod_1 has 2, prod_2 has 4 = 6
|
||||
expect(stats.product).toEqual({
|
||||
expected: 10,
|
||||
translated: 6,
|
||||
missing: 4,
|
||||
by_locale: {
|
||||
"en-US": {
|
||||
expected: 10,
|
||||
translated: 6,
|
||||
missing: 4,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should return statistics for multiple locales", async () => {
|
||||
await service.createTranslations([
|
||||
{
|
||||
reference_id: "prod_multi_1",
|
||||
reference: "product",
|
||||
locale_code: "en-US",
|
||||
translations: {
|
||||
title: "Product 1 EN",
|
||||
description: "Description EN",
|
||||
},
|
||||
},
|
||||
{
|
||||
reference_id: "prod_multi_1",
|
||||
reference: "product",
|
||||
locale_code: "fr-FR",
|
||||
translations: {
|
||||
title: "Produit 1 FR",
|
||||
// only title translated for French
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const stats = await service.getStatistics({
|
||||
locales: ["en-US", "fr-FR"],
|
||||
entities: {
|
||||
product: { count: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
// Expected per locale: 1 product × 5 fields = 5
|
||||
// Total expected: 5 × 2 locales = 10
|
||||
expect(stats.product.expected).toEqual(10)
|
||||
expect(stats.product.translated).toEqual(3) // 2 EN + 1 FR
|
||||
expect(stats.product.missing).toEqual(7)
|
||||
|
||||
expect(stats.product.by_locale["en-US"]).toEqual({
|
||||
expected: 5,
|
||||
translated: 2,
|
||||
missing: 3,
|
||||
})
|
||||
|
||||
expect(stats.product.by_locale["fr-FR"]).toEqual({
|
||||
expected: 5,
|
||||
translated: 1,
|
||||
missing: 4,
|
||||
})
|
||||
})
|
||||
|
||||
it("should return statistics for multiple entity types", async () => {
|
||||
await service.createTranslations([
|
||||
{
|
||||
reference_id: "prod_type_1",
|
||||
reference: "product",
|
||||
locale_code: "en-US",
|
||||
translations: {
|
||||
title: "Product Title",
|
||||
description: "Product Description",
|
||||
subtitle: "Product Subtitle",
|
||||
status: "Active",
|
||||
material: "Product Material",
|
||||
},
|
||||
},
|
||||
{
|
||||
reference_id: "var_type_1",
|
||||
reference: "product_variant",
|
||||
locale_code: "en-US",
|
||||
translations: {
|
||||
title: "Variant Title",
|
||||
// material missing
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const stats = await service.getStatistics({
|
||||
locales: ["en-US"],
|
||||
entities: {
|
||||
product: { count: 1 },
|
||||
product_variant: { count: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
// Product: 1 × 5 fields = 5 expected, 5 translated
|
||||
expect(stats.product).toEqual({
|
||||
expected: 5,
|
||||
translated: 5,
|
||||
missing: 0,
|
||||
by_locale: {
|
||||
"en-US": { expected: 5, translated: 5, missing: 0 },
|
||||
},
|
||||
})
|
||||
|
||||
// Variant: 1 × 2 fields = 2 expected, 1 translated
|
||||
expect(stats.product_variant).toEqual({
|
||||
expected: 2,
|
||||
translated: 1,
|
||||
missing: 1,
|
||||
by_locale: {
|
||||
"en-US": { expected: 2, translated: 1, missing: 1 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should return zeros for entity types not in config", async () => {
|
||||
const stats = await service.getStatistics({
|
||||
locales: ["en-US"],
|
||||
entities: {
|
||||
unknown_entity: { count: 10 },
|
||||
},
|
||||
})
|
||||
|
||||
expect(stats.unknown_entity).toEqual({
|
||||
expected: 0,
|
||||
translated: 0,
|
||||
missing: 0,
|
||||
by_locale: {
|
||||
"en-US": { expected: 0, translated: 0, missing: 0 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should return all missing when no translations exist", async () => {
|
||||
const stats = await service.getStatistics({
|
||||
locales: ["en-US", "fr-FR"],
|
||||
entities: {
|
||||
product: { count: 5 },
|
||||
},
|
||||
})
|
||||
|
||||
// 5 products × 5 fields × 2 locales = 50 expected, 0 translated
|
||||
expect(stats.product).toEqual({
|
||||
expected: 50,
|
||||
translated: 0,
|
||||
missing: 50,
|
||||
by_locale: {
|
||||
"en-US": { expected: 25, translated: 0, missing: 25 },
|
||||
"fr-FR": { expected: 25, translated: 0, missing: 25 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should ignore empty string and null values in translations", async () => {
|
||||
await service.createTranslations([
|
||||
{
|
||||
reference_id: "prod_empty_1",
|
||||
reference: "product",
|
||||
locale_code: "en-US",
|
||||
translations: {
|
||||
title: "Valid Title",
|
||||
description: "", // empty string should not count
|
||||
subtitle: "Valid Subtitle",
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const stats = await service.getStatistics({
|
||||
locales: ["en-US"],
|
||||
entities: {
|
||||
product: { count: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
// Only title, subtitle and material should count (3), not empty description
|
||||
expect(stats.product.translated).toEqual(2)
|
||||
expect(stats.product.missing).toEqual(3)
|
||||
})
|
||||
|
||||
it("should normalize locale codes", async () => {
|
||||
await service.createTranslations([
|
||||
{
|
||||
reference_id: "prod_norm_1",
|
||||
reference: "product",
|
||||
locale_code: "en-us",
|
||||
translations: {
|
||||
title: "Product Title",
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const stats = await service.getStatistics({
|
||||
locales: ["EN-US"],
|
||||
entities: {
|
||||
product: { count: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
expect(stats.product.translated).toEqual(1)
|
||||
})
|
||||
|
||||
it("should throw error when no locales provided", async () => {
|
||||
const error = await service
|
||||
.getStatistics({
|
||||
locales: [],
|
||||
entities: { product: { count: 1 } },
|
||||
})
|
||||
.catch((e) => e)
|
||||
|
||||
expect(error.message).toContain(
|
||||
"At least one locale must be provided"
|
||||
)
|
||||
})
|
||||
|
||||
it("should throw error when no entities provided", async () => {
|
||||
const error = await service
|
||||
.getStatistics({
|
||||
locales: ["en-US"],
|
||||
entities: {},
|
||||
})
|
||||
.catch((e) => e)
|
||||
|
||||
expect(error.message).toContain(
|
||||
"At least one entity type must be provided"
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle large entity counts correctly", async () => {
|
||||
// This tests that the expected calculation works with large numbers
|
||||
// without actually creating that many translations
|
||||
const stats = await service.getStatistics({
|
||||
locales: ["en-US", "fr-FR", "de-DE"],
|
||||
entities: {
|
||||
product: { count: 10000 },
|
||||
product_variant: { count: 50000 },
|
||||
},
|
||||
})
|
||||
|
||||
// Product: 10000 × 5 fields × 3 locales = 150000
|
||||
expect(stats.product.expected).toEqual(150000)
|
||||
expect(stats.product.translated).toEqual(0)
|
||||
expect(stats.product.missing).toEqual(150000)
|
||||
|
||||
// Variant: 50000 × 2 fields × 3 locales = 300000
|
||||
expect(stats.product_variant.expected).toEqual(300000)
|
||||
expect(stats.product_variant.translated).toEqual(0)
|
||||
expect(stats.product_variant.missing).toEqual(300000)
|
||||
})
|
||||
|
||||
it("should update statistics after translation is updated", async () => {
|
||||
const created = await service.createTranslations({
|
||||
reference_id: "prod_update_stat_1",
|
||||
reference: "product",
|
||||
locale_code: "en-US",
|
||||
translations: {
|
||||
title: "Product Title",
|
||||
// only 1 of 5 fields
|
||||
},
|
||||
})
|
||||
|
||||
let stats = await service.getStatistics({
|
||||
locales: ["en-US"],
|
||||
entities: { product: { count: 1 } },
|
||||
})
|
||||
expect(stats.product.translated).toEqual(1)
|
||||
|
||||
await service.updateTranslations({
|
||||
id: created.id,
|
||||
translations: {
|
||||
title: "Product Title",
|
||||
description: "Product Description",
|
||||
subtitle: "Product Subtitle",
|
||||
},
|
||||
})
|
||||
|
||||
stats = await service.getStatistics({
|
||||
locales: ["en-US"],
|
||||
entities: { product: { count: 1 } },
|
||||
})
|
||||
expect(stats.product.translated).toEqual(3)
|
||||
expect(stats.product.missing).toEqual(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import TranslationModuleService from "@services/translation-module"
|
||||
import loadDefaults from "./loaders/defaults"
|
||||
import loadConfig from "./loaders/config"
|
||||
import { Module } from "@medusajs/framework/utils"
|
||||
|
||||
export const TRANSLATION_MODULE = "translation"
|
||||
|
||||
export default Module(TRANSLATION_MODULE, {
|
||||
service: TranslationModuleService,
|
||||
loaders: [loadDefaults],
|
||||
loaders: [loadDefaults, loadConfig],
|
||||
})
|
||||
|
||||
52
packages/modules/translation/src/loaders/config.ts
Normal file
52
packages/modules/translation/src/loaders/config.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { LoaderOptions } from "@medusajs/framework/types"
|
||||
import {
|
||||
PRODUCT_TRANSLATABLE_FIELDS,
|
||||
PRODUCT_VARIANT_TRANSLATABLE_FIELDS,
|
||||
} from "../utils/translatable-fields"
|
||||
import { asValue } from "awilix"
|
||||
import { TRANSLATABLE_FIELDS_CONFIG_KEY } from "@utils/constants"
|
||||
|
||||
export default async ({
|
||||
container,
|
||||
options,
|
||||
}: LoaderOptions<{
|
||||
expandedTranslatableFields: { [key: string]: string[] }
|
||||
}>): Promise<void> => {
|
||||
const { expandedTranslatableFields } = options ?? {}
|
||||
|
||||
const { product, productVariant, ...others } =
|
||||
expandedTranslatableFields ?? {}
|
||||
|
||||
const translatableFieldsConfig: Record<string, string[]> = {
|
||||
product: PRODUCT_TRANSLATABLE_FIELDS,
|
||||
product_variant: PRODUCT_VARIANT_TRANSLATABLE_FIELDS,
|
||||
}
|
||||
|
||||
if (product) {
|
||||
const translatableFields = new Set([
|
||||
...PRODUCT_TRANSLATABLE_FIELDS,
|
||||
...product,
|
||||
])
|
||||
translatableFieldsConfig.product = Array.from(translatableFields)
|
||||
}
|
||||
|
||||
if (productVariant) {
|
||||
const translatableFields = new Set([
|
||||
...PRODUCT_VARIANT_TRANSLATABLE_FIELDS,
|
||||
...productVariant,
|
||||
])
|
||||
translatableFieldsConfig.product_variant = Array.from(translatableFields)
|
||||
}
|
||||
|
||||
if (others) {
|
||||
Object.entries(others).forEach(([key, value]) => {
|
||||
const translatableFields = new Set([...value])
|
||||
translatableFieldsConfig[key] = Array.from(translatableFields)
|
||||
})
|
||||
}
|
||||
|
||||
container.register(
|
||||
TRANSLATABLE_FIELDS_CONFIG_KEY,
|
||||
asValue(translatableFieldsConfig)
|
||||
)
|
||||
}
|
||||
@@ -149,6 +149,16 @@
|
||||
"nullable": false,
|
||||
"mappedType": "json"
|
||||
},
|
||||
"translated_field_count": {
|
||||
"name": "translated_field_count",
|
||||
"type": "integer",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"default": "0",
|
||||
"mappedType": "integer"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamptz",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
export class Migration20251215083927 extends Migration {
|
||||
|
||||
override async up(): Promise<void> {
|
||||
this.addSql(`alter table if exists "translation" add column if not exists "translated_field_count" integer not null default 0;`);
|
||||
}
|
||||
|
||||
override async down(): Promise<void> {
|
||||
this.addSql(`alter table if exists "translation" drop column if exists "translated_field_count";`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,7 +6,8 @@ const Translation = model
|
||||
reference_id: model.text().searchable(),
|
||||
reference: model.text().searchable(), // e.g., "product", "product_variant", "product_category"
|
||||
locale_code: model.text().searchable(), // BCP 47 language tag, e.g., "en-US", "da-DK"
|
||||
translations: model.json(), // JSON object containing translated fields, e.g., { "title": "...", "description": "..." }
|
||||
translations: model.json(),
|
||||
translated_field_count: model.number().default(0), // Precomputed count of translated fields for performance
|
||||
})
|
||||
.indexes([
|
||||
{
|
||||
|
||||
@@ -10,20 +10,25 @@ import {
|
||||
ModulesSdkTypes,
|
||||
TranslationTypes,
|
||||
} from "@medusajs/framework/types"
|
||||
import { SqlEntityManager } from "@medusajs/framework/mikro-orm/postgresql"
|
||||
import {
|
||||
EmitEvents,
|
||||
InjectManager,
|
||||
MedusaContext,
|
||||
MedusaError,
|
||||
MedusaService,
|
||||
normalizeLocale,
|
||||
} from "@medusajs/framework/utils"
|
||||
import Locale from "@models/locale"
|
||||
import Translation from "@models/translation"
|
||||
import { computeTranslatedFieldCount } from "@utils/compute-translated-field-count"
|
||||
import { TRANSLATABLE_FIELDS_CONFIG_KEY } from "@utils/constants"
|
||||
|
||||
type InjectedDependencies = {
|
||||
baseRepository: DAL.RepositoryService
|
||||
translationService: ModulesSdkTypes.IMedusaInternalService<typeof Translation>
|
||||
localeService: ModulesSdkTypes.IMedusaInternalService<typeof Locale>
|
||||
[TRANSLATABLE_FIELDS_CONFIG_KEY]: Record<string, string[]>
|
||||
}
|
||||
|
||||
export default class TranslationModuleService
|
||||
@@ -48,15 +53,19 @@ export default class TranslationModuleService
|
||||
typeof Locale
|
||||
>
|
||||
|
||||
private readonly translatableFieldsConfig_: Record<string, string[]>
|
||||
|
||||
constructor({
|
||||
baseRepository,
|
||||
translationService,
|
||||
localeService,
|
||||
translatableFieldsConfig,
|
||||
}: InjectedDependencies) {
|
||||
super(...arguments)
|
||||
this.baseRepository_ = baseRepository
|
||||
this.translationService_ = translationService
|
||||
this.localeService_ = localeService
|
||||
this.translatableFieldsConfig_ = translatableFieldsConfig
|
||||
}
|
||||
|
||||
static prepareFilters(
|
||||
@@ -177,6 +186,10 @@ export default class TranslationModuleService
|
||||
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]
|
||||
),
|
||||
}))
|
||||
|
||||
const createdTranslations = await this.translationService_.create(
|
||||
@@ -190,4 +203,193 @@ export default class TranslationModuleService
|
||||
|
||||
return Array.isArray(data) ? serialized : serialized[0]
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
updateTranslations(
|
||||
data: TranslationTypes.UpdateTranslationDTO,
|
||||
sharedContext?: Context
|
||||
): Promise<TranslationTypes.TranslationDTO>
|
||||
// @ts-expect-error
|
||||
updateTranslations(
|
||||
data: TranslationTypes.UpdateTranslationDTO[],
|
||||
sharedContext?: Context
|
||||
): Promise<TranslationTypes.TranslationDTO[]>
|
||||
|
||||
@InjectManager()
|
||||
@EmitEvents()
|
||||
// @ts-expect-error
|
||||
async updateTranslations(
|
||||
data:
|
||||
| TranslationTypes.UpdateTranslationDTO
|
||||
| TranslationTypes.UpdateTranslationDTO[],
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<
|
||||
TranslationTypes.TranslationDTO | TranslationTypes.TranslationDTO[]
|
||||
> {
|
||||
const dataArray = Array.isArray(data) ? data : [data]
|
||||
|
||||
const updatesWithTranslations = dataArray.filter((d) => d.translations)
|
||||
|
||||
if (updatesWithTranslations.length) {
|
||||
const idsNeedingReference = updatesWithTranslations
|
||||
.filter((d) => !d.reference)
|
||||
.map((d) => d.id)
|
||||
|
||||
let referenceMap: Record<string, string> = {}
|
||||
|
||||
if (idsNeedingReference.length) {
|
||||
const existingTranslations = await this.translationService_.list(
|
||||
{ id: idsNeedingReference },
|
||||
{ select: ["id", "reference"] },
|
||||
sharedContext
|
||||
)
|
||||
referenceMap = Object.fromEntries(
|
||||
existingTranslations.map((t) => [t.id, t.reference])
|
||||
)
|
||||
}
|
||||
|
||||
for (const update of dataArray) {
|
||||
if (update.translations) {
|
||||
const reference = update.reference || referenceMap[update.id]
|
||||
;(
|
||||
update as TranslationTypes.UpdateTranslationDTO & {
|
||||
translated_field_count: number
|
||||
}
|
||||
).translated_field_count = computeTranslatedFieldCount(
|
||||
update.translations as Record<string, unknown>,
|
||||
this.translatableFieldsConfig_[reference] || []
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updatedTranslations = await this.translationService_.update(
|
||||
dataArray,
|
||||
sharedContext
|
||||
)
|
||||
|
||||
const serialized = await this.baseRepository_.serialize<
|
||||
TranslationTypes.TranslationDTO[]
|
||||
>(updatedTranslations)
|
||||
|
||||
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,
|
||||
@MedusaContext() sharedContext: Context = {}
|
||||
): Promise<TranslationTypes.TranslationStatisticsOutput> {
|
||||
const { locales, entities } = input
|
||||
|
||||
if (!locales || !locales.length) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"At least one locale must be provided"
|
||||
)
|
||||
}
|
||||
|
||||
if (!entities || !Object.keys(entities).length) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"At least one entity type must be provided"
|
||||
)
|
||||
}
|
||||
|
||||
const normalizedLocales = locales.map(normalizeLocale)
|
||||
|
||||
const manager = (sharedContext.transactionManager ??
|
||||
sharedContext.manager) as SqlEntityManager
|
||||
const knex = manager.getKnex()
|
||||
|
||||
const result: TranslationTypes.TranslationStatisticsOutput = {}
|
||||
const entityTypes: string[] = []
|
||||
|
||||
for (const entityType of Object.keys(entities)) {
|
||||
const translatableFields = this.translatableFieldsConfig_[entityType]
|
||||
|
||||
if (!translatableFields || translatableFields.length === 0) {
|
||||
result[entityType] = {
|
||||
expected: 0,
|
||||
translated: 0,
|
||||
missing: 0,
|
||||
by_locale: Object.fromEntries(
|
||||
normalizedLocales.map((locale) => [
|
||||
locale,
|
||||
{ expected: 0, translated: 0, missing: 0 },
|
||||
])
|
||||
),
|
||||
}
|
||||
} else {
|
||||
entityTypes.push(entityType)
|
||||
}
|
||||
}
|
||||
|
||||
if (!entityTypes.length) {
|
||||
return result
|
||||
}
|
||||
|
||||
const { rows } = await knex.raw(
|
||||
`
|
||||
SELECT
|
||||
reference,
|
||||
locale_code,
|
||||
COALESCE(SUM(translated_field_count), 0)::int AS translated_field_count
|
||||
FROM translation
|
||||
WHERE reference = ANY(?)
|
||||
AND locale_code = ANY(?)
|
||||
AND deleted_at IS NULL
|
||||
GROUP BY reference, locale_code
|
||||
`,
|
||||
[entityTypes, normalizedLocales]
|
||||
)
|
||||
|
||||
for (const entityType of entityTypes) {
|
||||
const translatableFields = this.translatableFieldsConfig_[entityType]
|
||||
const fieldsPerEntity = translatableFields.length
|
||||
const entityCount = entities[entityType].count
|
||||
const expectedPerLocale = entityCount * fieldsPerEntity
|
||||
|
||||
result[entityType] = {
|
||||
expected: expectedPerLocale * normalizedLocales.length,
|
||||
translated: 0,
|
||||
missing: expectedPerLocale * normalizedLocales.length,
|
||||
by_locale: Object.fromEntries(
|
||||
normalizedLocales.map((locale) => [
|
||||
locale,
|
||||
{
|
||||
expected: expectedPerLocale,
|
||||
translated: 0,
|
||||
missing: expectedPerLocale,
|
||||
},
|
||||
])
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const entityType = row.reference
|
||||
const localeCode = row.locale_code
|
||||
const translatedCount = parseInt(row.translated_field_count, 10) || 0
|
||||
|
||||
result[entityType].by_locale[localeCode].translated = translatedCount
|
||||
result[entityType].by_locale[localeCode].missing =
|
||||
result[entityType].by_locale[localeCode].expected - translatedCount
|
||||
result[entityType].translated += translatedCount
|
||||
}
|
||||
|
||||
for (const entityType of entityTypes) {
|
||||
result[entityType].missing =
|
||||
result[entityType].expected - result[entityType].translated
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Computes the count of translated fields based on the translatable fields configuration.
|
||||
* Only counts fields that are:
|
||||
* 1. In the translatableFields array for the entity type
|
||||
* 2. Have a non-null, non-empty value in the translations object
|
||||
*
|
||||
* @param translations - The translations JSON object from the translation record
|
||||
* @param translatableFields - Array of field names that are translatable for this entity type
|
||||
* @returns The count of translated fields
|
||||
*/
|
||||
export function computeTranslatedFieldCount(
|
||||
translations: Record<string, unknown> | undefined | null,
|
||||
translatableFields: string[] | undefined | null
|
||||
): number {
|
||||
if (!translations || !translatableFields?.length) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return translatableFields.filter((field) => {
|
||||
const value = translations[field]
|
||||
return value != null && value !== "" && value !== "null"
|
||||
}).length
|
||||
}
|
||||
1
packages/modules/translation/src/utils/constants.ts
Normal file
1
packages/modules/translation/src/utils/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const TRANSLATABLE_FIELDS_CONFIG_KEY = "translatableFieldsConfig"
|
||||
@@ -0,0 +1,9 @@
|
||||
export const PRODUCT_TRANSLATABLE_FIELDS = [
|
||||
"title",
|
||||
"description",
|
||||
"material",
|
||||
"subtitle",
|
||||
"status",
|
||||
]
|
||||
|
||||
export const PRODUCT_VARIANT_TRANSLATABLE_FIELDS = ["title", "material"]
|
||||
Reference in New Issue
Block a user