Skip to content

Commit ba6ed8d

Browse files
authored
feat(): Translation statistics (medusajs#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
1 parent 0f1566c commit ba6ed8d

File tree

20 files changed

+1196
-2
lines changed

20 files changed

+1196
-2
lines changed

.changeset/spicy-games-unite.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@medusajs/medusa": patch
3+
"@medusajs/translation": patch
4+
"@medusajs/types": patch
5+
---
6+
7+
chore(): Translation statistics

integration-tests/http/__tests__/translation/admin/translation.spec.ts

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,249 @@ medusaIntegrationTestRunner({
674674
})
675675
})
676676
})
677+
678+
describe("GET /admin/translations/statistics", () => {
679+
it("should return statistics for entity types with no translations", async () => {
680+
const productModule = appContainer.resolve(Modules.PRODUCT)
681+
await productModule.createProducts([
682+
{ title: "Product 1" },
683+
{ title: "Product 2" },
684+
])
685+
686+
const response = await api.get(
687+
"/admin/translations/statistics?locales=en-US&locales=fr-FR&entity_types=product",
688+
adminHeaders
689+
)
690+
691+
expect(response.status).toEqual(200)
692+
expect(response.data.statistics).toBeDefined()
693+
expect(response.data.statistics.product).toEqual({
694+
// 2 products × 5 translatable fields × 2 locales = 20 expected
695+
expected: 20,
696+
translated: 0,
697+
missing: 20,
698+
by_locale: {
699+
"en-US": { expected: 10, translated: 0, missing: 10 },
700+
"fr-FR": { expected: 10, translated: 0, missing: 10 },
701+
},
702+
})
703+
})
704+
705+
it("should return statistics with partial translations", async () => {
706+
const productModule = appContainer.resolve(Modules.PRODUCT)
707+
const [product1, product2] = await productModule.createProducts([
708+
{ title: "Product 1" },
709+
{ title: "Product 2" },
710+
])
711+
712+
// Create translations for product1 with partial fields
713+
await api.post(
714+
"/admin/translations/batch",
715+
{
716+
create: [
717+
{
718+
reference_id: product1.id,
719+
reference: "product",
720+
locale_code: "fr-FR",
721+
translations: {
722+
title: "Produit 1",
723+
description: "Description du produit 1",
724+
},
725+
},
726+
{
727+
reference_id: product2.id,
728+
reference: "product",
729+
locale_code: "fr-FR",
730+
translations: {
731+
title: "Produit 2",
732+
},
733+
},
734+
],
735+
},
736+
adminHeaders
737+
)
738+
739+
const response = await api.get(
740+
"/admin/translations/statistics?locales=fr-FR&entity_types=product",
741+
adminHeaders
742+
)
743+
744+
expect(response.status).toEqual(200)
745+
// 2 products × 5 fields × 1 locale = 10 expected
746+
// product1 has 2 fields, product2 has 1 field = 3 translated
747+
expect(response.data.statistics.product).toEqual({
748+
expected: 10,
749+
translated: 3,
750+
missing: 7,
751+
by_locale: {
752+
"fr-FR": { expected: 10, translated: 3, missing: 7 },
753+
},
754+
})
755+
})
756+
757+
it("should return statistics for multiple entity types", async () => {
758+
const productModule = appContainer.resolve(Modules.PRODUCT)
759+
const [product] = await productModule.createProducts([
760+
{
761+
title: "Product with variant",
762+
variants: [{ title: "Variant 1" }, { title: "Variant 2" }],
763+
},
764+
])
765+
766+
await api.post(
767+
"/admin/translations/batch",
768+
{
769+
create: [
770+
{
771+
reference_id: product.id,
772+
reference: "product",
773+
locale_code: "fr-FR",
774+
translations: {
775+
title: "Produit",
776+
description: "Description",
777+
subtitle: "Sous-titre",
778+
status: "Actif",
779+
material: "Matériau",
780+
},
781+
},
782+
{
783+
reference_id: product.variants[0].id,
784+
reference: "product_variant",
785+
locale_code: "fr-FR",
786+
translations: {
787+
title: "Variante 1",
788+
},
789+
},
790+
],
791+
},
792+
adminHeaders
793+
)
794+
795+
const response = await api.get(
796+
"/admin/translations/statistics?locales=fr-FR&entity_types=product&entity_types=product_variant",
797+
adminHeaders
798+
)
799+
800+
expect(response.status).toEqual(200)
801+
802+
// Product: 1 × 5 fields × 1 locale = 5, all translated
803+
expect(response.data.statistics.product).toEqual({
804+
expected: 5,
805+
translated: 5,
806+
missing: 0,
807+
by_locale: {
808+
"fr-FR": { expected: 5, translated: 5, missing: 0 },
809+
},
810+
})
811+
812+
// Variant: 2 × 2 fields × 1 locale = 4, 1 translated
813+
expect(response.data.statistics.product_variant).toEqual({
814+
expected: 4,
815+
translated: 1,
816+
missing: 3,
817+
by_locale: {
818+
"fr-FR": { expected: 4, translated: 1, missing: 3 },
819+
},
820+
})
821+
})
822+
823+
it("should return statistics for multiple locales", async () => {
824+
const productModule = appContainer.resolve(Modules.PRODUCT)
825+
const [product] = await productModule.createProducts([
826+
{ title: "Product" },
827+
])
828+
829+
await api.post(
830+
"/admin/translations/batch",
831+
{
832+
create: [
833+
{
834+
reference_id: product.id,
835+
reference: "product",
836+
locale_code: "fr-FR",
837+
translations: {
838+
title: "Produit",
839+
description: "Description",
840+
},
841+
},
842+
{
843+
reference_id: product.id,
844+
reference: "product",
845+
locale_code: "de-DE",
846+
translations: { title: "Produkt" },
847+
},
848+
],
849+
},
850+
adminHeaders
851+
)
852+
853+
const response = await api.get(
854+
"/admin/translations/statistics?locales=fr-FR&locales=de-DE&entity_types=product",
855+
adminHeaders
856+
)
857+
858+
expect(response.status).toEqual(200)
859+
// 1 product × 5 fields × 2 locales = 10 expected
860+
// fr-FR: 2 translated, de-DE: 1 translated = 3 total
861+
expect(response.data.statistics.product.expected).toEqual(10)
862+
expect(response.data.statistics.product.translated).toEqual(3)
863+
expect(response.data.statistics.product.missing).toEqual(7)
864+
865+
expect(response.data.statistics.product.by_locale["fr-FR"]).toEqual({
866+
expected: 5,
867+
translated: 2,
868+
missing: 3,
869+
})
870+
expect(response.data.statistics.product.by_locale["de-DE"]).toEqual({
871+
expected: 5,
872+
translated: 1,
873+
missing: 4,
874+
})
875+
})
876+
877+
it("should return zeros for unknown entity types", async () => {
878+
const response = await api.get(
879+
"/admin/translations/statistics?locales=fr-FR&entity_types=unknown_entity",
880+
adminHeaders
881+
)
882+
883+
expect(response.status).toEqual(200)
884+
expect(response.data.statistics.unknown_entity).toEqual({
885+
expected: 0,
886+
translated: 0,
887+
missing: 0,
888+
by_locale: {
889+
"fr-FR": { expected: 0, translated: 0, missing: 0 },
890+
},
891+
})
892+
})
893+
894+
it("should validate required fields", async () => {
895+
// Missing locales
896+
const response1 = await api
897+
.get(
898+
"/admin/translations/statistics?entity_types=product",
899+
adminHeaders
900+
)
901+
.catch((e) => e.response)
902+
903+
expect(response1.status).toEqual(400)
904+
905+
// Missing entity_types
906+
const response2 = await api
907+
.get("/admin/translations/statistics?locales=fr-FR", adminHeaders)
908+
.catch((e) => e.response)
909+
910+
expect(response2.status).toEqual(400)
911+
912+
// Both missing
913+
const response3 = await api
914+
.get("/admin/translations/statistics", adminHeaders)
915+
.catch((e) => e.response)
916+
917+
expect(response3.status).toEqual(400)
918+
})
919+
})
677920
})
678921
},
679922
})

packages/core/types/src/http/translations/admin/queries.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,18 @@ export interface AdminTranslationsListParams
2121
*/
2222
locale_code?: string | string[]
2323
}
24+
25+
/**
26+
* Request body for translation statistics endpoint.
27+
*/
28+
export interface AdminTranslationStatisticsParams {
29+
/**
30+
* The locales to check translations for (e.g., ["en-US", "fr-FR"]).
31+
*/
32+
locales: string[]
33+
34+
/**
35+
* The entity types to get statistics for (e.g., ["product", "product_variant"]).
36+
*/
37+
entity_types: string[]
38+
}

packages/core/types/src/http/translations/admin/responses.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,58 @@ export interface AdminTranslationsBatchResponse {
3333
deleted: boolean
3434
}
3535
}
36+
37+
/**
38+
* Statistics for a specific locale.
39+
*/
40+
export interface AdminTranslationLocaleStatistics {
41+
/**
42+
* Expected number of translated fields.
43+
*/
44+
expected: number
45+
/**
46+
* Actual number of translated fields.
47+
*/
48+
translated: number
49+
/**
50+
* Number of missing translations.
51+
*/
52+
missing: number
53+
}
54+
55+
/**
56+
* Statistics for an entity type.
57+
*/
58+
export interface AdminTranslationEntityStatistics
59+
extends AdminTranslationLocaleStatistics {
60+
/**
61+
* Breakdown of statistics by locale.
62+
*/
63+
by_locale: Record<string, AdminTranslationLocaleStatistics>
64+
}
65+
66+
/**
67+
* Response for translation statistics endpoint.
68+
*/
69+
export interface AdminTranslationStatisticsResponse {
70+
/**
71+
* Statistics by entity type.
72+
*/
73+
statistics: Record<string, AdminTranslationEntityStatistics>
74+
}
75+
76+
/**
77+
* Response for translation settings endpoint.
78+
*/
79+
export interface AdminTranslationSettingsResponse {
80+
/**
81+
* A mapping of entity types to their translatable field names.
82+
*
83+
* @example
84+
* {
85+
* "product": ["title", "description", "subtitle", "status"],
86+
* "product_variant": ["title", "material"]
87+
* }
88+
*/
89+
translatable_fields: Record<string, string[]>
90+
}

packages/core/types/src/translation/common.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,55 @@ export interface FilterableTranslationProps
132132
*/
133133
locale_code?: string | string[] | OperatorMap<string>
134134
}
135+
136+
/**
137+
* Input for getStatistics method.
138+
*/
139+
export interface TranslationStatisticsInput {
140+
/**
141+
* Locales to check translations for (e.g., ["en-US", "fr-FR"]).
142+
*/
143+
locales: string[]
144+
145+
/**
146+
* Entity types with their total counts.
147+
* Key is the entity type (e.g., "product"), value contains the count of entities.
148+
*/
149+
entities: Record<string, { count: number }>
150+
}
151+
152+
/**
153+
* Statistics for a specific locale.
154+
*/
155+
export interface LocaleStatistics {
156+
/**
157+
* Expected number of translated fields.
158+
*/
159+
expected: number
160+
161+
/**
162+
* Actual number of translated fields (non-null, non-empty).
163+
*/
164+
translated: number
165+
166+
/**
167+
* Number of missing translations (expected - translated).
168+
*/
169+
missing: number
170+
}
171+
172+
/**
173+
* Statistics for an entity type.
174+
*/
175+
export interface EntityTypeStatistics extends LocaleStatistics {
176+
/**
177+
* Breakdown of statistics by locale.
178+
*/
179+
by_locale: Record<string, LocaleStatistics>
180+
}
181+
182+
/**
183+
* Output of getStatistics method.
184+
* Maps entity types to their translation statistics.
185+
*/
186+
export type TranslationStatisticsOutput = Record<string, EntityTypeStatistics>

0 commit comments

Comments
 (0)