feat(translation,fulfillment,customer,product,region,tax,core-flows,medusa,types): Implement dynamic translation settings management (#14536)

* Add is_active field to translation_settings model

* Types

* Workflows

* Api layer

* Tests

* Add changeset

* Add comment

* Hook to create or deactivate translatable entities on startup

* Cleanup old code

* Configure translatable option for core entities

* Validation step and snake case correction

* Cleanup

* Tests

* Comment in PR

* Update changeset

* Mock DmlEntity.getTranslatableEntities

* Move validation to module service layer

* Remove validation from remaining workflow

* Return object directly

* Type improvements

* Remove .only from tests

* Apply snakeCase

* Fix tests

* Fix tests

* Remove unnecessary map and use set instead

* Fix tests

* Comments

* Include translatable product properties

* Avoid race condition in translations tests

* Update test
This commit is contained in:
Nicolas Gorga
2026-01-14 07:09:49 -03:00
committed by GitHub
parent 42235825ee
commit d60ea7268a
50 changed files with 1397 additions and 199 deletions

View File

@@ -0,0 +1,13 @@
---
"@medusajs/fulfillment": patch
"@medusajs/translation": patch
"@medusajs/customer": patch
"@medusajs/core-flows": patch
"@medusajs/product": patch
"@medusajs/region": patch
"@medusajs/tax": patch
"@medusajs/types": patch
"@medusajs/medusa": patch
---
feat(translation,fulfillment,customer,product,region,tax,core-flows,medusa,types): Implement dynamic translation settings management

View File

@@ -44,6 +44,10 @@ medusaIntegrationTestRunner({
beforeEach(async () => {
await createAdminUser(dbConnection, adminHeaders, appContainer)
const translationModule = appContainer.resolve(Modules.TRANSLATION)
await translationModule.__hooks?.onApplicationStart?.().catch(() => {})
const publishableKey = await generatePublishableKey(appContainer)
storeHeaders = generateStoreHeaders({ publishableKey })

View File

@@ -41,6 +41,8 @@ medusaIntegrationTestRunner({
beforeEach(async () => {
appContainer = getContainer()
const translationModule = appContainer.resolve(Modules.TRANSLATION)
await translationModule.__hooks?.onApplicationStart?.().catch(() => {})
await createAdminUser(dbConnection, adminHeaders, appContainer)
const taxStructure = await setupTaxStructure(

View File

@@ -33,6 +33,9 @@ medusaIntegrationTestRunner({
)
await createAdminUser(dbConnection, adminHeaders, appContainer)
const translationModule = appContainer.resolve(Modules.TRANSLATION)
await translationModule.__hooks?.onApplicationStart?.().catch(() => {})
salesChannel = (
await api.post(
"/admin/sales-channels",

View File

@@ -47,6 +47,10 @@ medusaIntegrationTestRunner({
appContainer.resolve(Modules.TAX)
)
await createAdminUser(dbConnection, adminHeaders, appContainer)
const translationModule = appContainer.resolve(Modules.TRANSLATION)
await translationModule.__hooks?.onApplicationStart?.().catch(() => {})
const publishableKey = await generatePublishableKey(appContainer)
storeHeaders = generateStoreHeaders({ publishableKey })

View File

@@ -46,6 +46,9 @@ medusaIntegrationTestRunner({
appContainer.resolve(Modules.TAX)
)
await createAdminUser(dbConnection, adminHeaders, appContainer)
const translationModule = appContainer.resolve(Modules.TRANSLATION)
await translationModule.__hooks?.onApplicationStart?.().catch(() => {})
const publishableKey = await generatePublishableKey(appContainer)
storeHeaders = generateStoreHeaders({ publishableKey })

View File

@@ -47,6 +47,10 @@ medusaIntegrationTestRunner({
appContainer.resolve(Modules.TAX)
)
await createAdminUser(dbConnection, adminHeaders, appContainer)
const translationModule = appContainer.resolve(Modules.TRANSLATION)
await translationModule.__hooks?.onApplicationStart?.().catch(() => {})
const publishableKey = await generatePublishableKey(appContainer)
storeHeaders = generateStoreHeaders({ publishableKey })

View File

@@ -24,6 +24,9 @@ medusaIntegrationTestRunner({
const container = getContainer()
await createAdminUser(dbConnection, adminHeaders, container)
const translationModule = container.resolve(Modules.TRANSLATION)
await translationModule.__hooks?.onApplicationStart?.().catch(() => {})
// Set up supported locales in the store
const storeModule = container.resolve(Modules.STORE)
const [defaultStore] = await storeModule.listStores(

View File

@@ -41,6 +41,9 @@ medusaIntegrationTestRunner({
await createAdminUser(dbConnection, adminHeaders, appContainer)
const translationModule = appContainer.resolve(Modules.TRANSLATION)
await translationModule.__hooks?.onApplicationStart?.().catch(() => {})
// Set up store locales
const storeModule = appContainer.resolve(Modules.STORE)
const [defaultStore] = await storeModule.listStores(

View File

@@ -1,4 +1,5 @@
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { Modules } from "@medusajs/utils"
import {
adminHeaders,
createAdminUser,
@@ -13,6 +14,10 @@ medusaIntegrationTestRunner({
describe("Admin Locale API", () => {
beforeEach(async () => {
await createAdminUser(dbConnection, adminHeaders, getContainer())
const appContainer = getContainer()
const translationModule = appContainer.resolve(Modules.TRANSLATION)
await translationModule.__hooks?.onApplicationStart?.().catch(() => {})
})
afterAll(async () => {

View File

@@ -0,0 +1,528 @@
import { medusaIntegrationTestRunner } from "@medusajs/test-utils"
import { DmlEntity, Modules } from "@medusajs/utils"
import {
adminHeaders,
createAdminUser,
} from "../../../../helpers/create-admin-user"
jest.setTimeout(100000)
process.env.MEDUSA_FF_TRANSLATION = "true"
medusaIntegrationTestRunner({
testSuite: ({ dbConnection, getContainer, api }) => {
describe("Admin Translation Settings Batch API", () => {
let mockGetTranslatableEntities: jest.SpyInstance
beforeEach(async () => {
mockGetTranslatableEntities = jest.spyOn(
DmlEntity,
"getTranslatableEntities"
)
mockGetTranslatableEntities.mockReturnValue([
{ entity: "ProductVariant", fields: ["title", "material"] },
{ entity: "ProductCategory", fields: ["name", "description"] },
{ entity: "ProductCollection", fields: ["title"] },
])
await createAdminUser(dbConnection, adminHeaders, getContainer())
const appContainer = getContainer()
const translationModuleService = appContainer.resolve(
Modules.TRANSLATION
)
await translationModuleService.__hooks
?.onApplicationStart?.()
.catch(() => {})
// Delete all translation settings to be able to test the create operation
const settings =
await translationModuleService.listTranslationSettings()
await translationModuleService.deleteTranslationSettings(
settings.map((s) => s.id)
)
})
afterAll(async () => {
delete process.env.MEDUSA_FF_TRANSLATION
mockGetTranslatableEntities.mockRestore()
})
describe("POST /admin/translations/settings/batch", () => {
describe("create", () => {
it("should create a single translation setting", async () => {
const response = await api.post(
"/admin/translations/settings/batch",
{
create: [
{
entity_type: "product_variant",
fields: ["title", "material"],
is_active: true,
},
],
},
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.created).toHaveLength(1)
expect(response.data.created[0]).toEqual(
expect.objectContaining({
id: expect.any(String),
entity_type: "product_variant",
fields: ["title", "material"],
is_active: true,
created_at: expect.any(String),
updated_at: expect.any(String),
})
)
})
it("should create multiple translation settings", async () => {
const response = await api.post(
"/admin/translations/settings/batch",
{
create: [
{
entity_type: "product_variant",
fields: ["title", "material"],
is_active: true,
},
{
entity_type: "product_category",
fields: ["name", "description"],
is_active: true,
},
{
entity_type: "product_collection",
fields: ["title"],
is_active: false,
},
],
},
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.created).toHaveLength(3)
expect(response.data.created).toEqual(
expect.arrayContaining([
expect.objectContaining({
entity_type: "product_variant",
fields: ["title", "material"],
is_active: true,
}),
expect.objectContaining({
entity_type: "product_category",
fields: ["name", "description"],
is_active: true,
}),
expect.objectContaining({
entity_type: "product_collection",
fields: ["title"],
is_active: false,
}),
])
)
})
})
describe("update", () => {
it("should update an existing translation setting", async () => {
const createResponse = await api.post(
"/admin/translations/settings/batch",
{
create: [
{
entity_type: "product_variant",
fields: ["title"],
is_active: true,
},
],
},
adminHeaders
)
const settingId = createResponse.data.created[0].id
const updateResponse = await api.post(
"/admin/translations/settings/batch",
{
update: [
{
id: settingId,
entity_type: "product_variant",
fields: ["title", "material"],
},
],
},
adminHeaders
)
expect(updateResponse.status).toEqual(200)
expect(updateResponse.data.updated).toHaveLength(1)
expect(updateResponse.data.updated[0]).toEqual(
expect.objectContaining({
id: settingId,
entity_type: "product_variant",
fields: ["title", "material"],
is_active: true,
})
)
})
it("should update multiple translation settings", async () => {
const createResponse = await api.post(
"/admin/translations/settings/batch",
{
create: [
{
entity_type: "product_variant",
fields: ["title"],
is_active: true,
},
{
entity_type: "product_category",
fields: ["name"],
is_active: true,
},
],
},
adminHeaders
)
const [settingId1, settingId2] = createResponse.data.created.map(
(s) => s.id
)
const updateResponse = await api.post(
"/admin/translations/settings/batch",
{
update: [
{
id: settingId1,
entity_type: "product_variant",
fields: ["title", "material"],
},
{
id: settingId2,
entity_type: "product_category",
is_active: false,
},
],
},
adminHeaders
)
expect(updateResponse.status).toEqual(200)
expect(updateResponse.data.updated).toHaveLength(2)
expect(updateResponse.data.updated).toEqual(
expect.arrayContaining([
expect.objectContaining({
entity_type: "product_variant",
fields: ["title", "material"],
}),
expect.objectContaining({
entity_type: "product_category",
is_active: false,
}),
])
)
})
})
describe("delete", () => {
it("should delete a translation setting", async () => {
const createResponse = await api.post(
"/admin/translations/settings/batch",
{
create: [
{
entity_type: "product_variant",
fields: ["title"],
is_active: true,
},
],
},
adminHeaders
)
const settingId = createResponse.data.created[0].id
const deleteResponse = await api.post(
"/admin/translations/settings/batch",
{
delete: [settingId],
},
adminHeaders
)
expect(deleteResponse.status).toEqual(200)
expect(deleteResponse.data.deleted).toEqual({
ids: [settingId],
object: "translation_settings",
deleted: true,
})
})
it("should delete multiple translation settings", async () => {
const createResponse = await api.post(
"/admin/translations/settings/batch",
{
create: [
{
entity_type: "product_variant",
fields: ["title"],
is_active: true,
},
{
entity_type: "product_category",
fields: ["name"],
is_active: true,
},
{
entity_type: "product_collection",
fields: ["title"],
is_active: true,
},
],
},
adminHeaders
)
const ids = createResponse.data.created.map((s) => s.id)
const deleteResponse = await api.post(
"/admin/translations/settings/batch",
{
delete: ids,
},
adminHeaders
)
expect(deleteResponse.status).toEqual(200)
expect(deleteResponse.data.deleted).toEqual({
ids: expect.arrayContaining(ids),
object: "translation_settings",
deleted: true,
})
expect(deleteResponse.data.deleted.ids).toHaveLength(3)
})
it("should handle deleting with empty array", async () => {
const response = await api.post(
"/admin/translations/settings/batch",
{
delete: [],
},
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.deleted).toEqual({
ids: [],
object: "translation_settings",
deleted: true,
})
})
})
describe("combined operations", () => {
it("should handle create, update, and delete in a single batch", async () => {
const setupResponse = await api.post(
"/admin/translations/settings/batch",
{
create: [
{
entity_type: "product_variant",
fields: ["title"],
is_active: true,
},
{
entity_type: "product_category",
fields: ["name"],
is_active: true,
},
],
},
adminHeaders
)
const [settingId1, settingId2] = setupResponse.data.created.map(
(s) => s.id
)
const batchResponse = await api.post(
"/admin/translations/settings/batch",
{
create: [
{
entity_type: "product_collection",
fields: ["title"],
is_active: true,
},
],
update: [
{
id: settingId1,
entity_type: "product_variant",
fields: ["title", "material"],
is_active: false,
},
],
delete: [settingId2],
},
adminHeaders
)
expect(batchResponse.status).toEqual(200)
expect(batchResponse.data.created).toHaveLength(1)
expect(batchResponse.data.updated).toHaveLength(1)
expect(batchResponse.data.deleted.ids).toContain(settingId2)
expect(batchResponse.data.created[0]).toEqual(
expect.objectContaining({
entity_type: "product_collection",
fields: ["title"],
is_active: true,
})
)
expect(batchResponse.data.updated[0]).toEqual(
expect.objectContaining({
id: settingId1,
fields: ["title", "material"],
is_active: false,
})
)
})
it("should handle empty batch request", async () => {
const response = await api.post(
"/admin/translations/settings/batch",
{
create: [],
update: [],
delete: [],
},
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.created).toEqual([])
expect(response.data.updated).toEqual([])
expect(response.data.deleted.ids).toEqual([])
})
})
describe("validation", () => {
it("should reject non-translatable entity types", async () => {
const error = await api
.post(
"/admin/translations/settings/batch",
{
create: [
{
entity_type: "NonExistentEntity",
fields: ["title"],
is_active: true,
},
],
},
adminHeaders
)
.catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data.message).toContain(
"NonExistentEntity is not a translatable entity"
)
})
it("should reject invalid fields for translatable entities", async () => {
const error = await api
.post(
"/admin/translations/settings/batch",
{
create: [
{
entity_type: "product_variant",
fields: ["title", "invalid_field", "another_invalid"],
is_active: true,
},
],
},
adminHeaders
)
.catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data.message).toContain("product_variant")
expect(error.response.data.message).toContain("invalid_field")
expect(error.response.data.message).toContain("another_invalid")
})
it("should reject multiple invalid settings in a single batch", async () => {
const error = await api
.post(
"/admin/translations/settings/batch",
{
create: [
{
entity_type: "NonExistentEntity",
fields: ["title"],
is_active: true,
},
{
entity_type: "product_variant",
fields: ["title", "invalid_field"],
is_active: true,
},
],
},
adminHeaders
)
.catch((e) => e)
expect(error.response.status).toEqual(400)
expect(error.response.data.message).toContain(
"NonExistentEntity is not a translatable entity"
)
expect(error.response.data.message).toContain("product_variant")
expect(error.response.data.message).toContain("invalid_field")
})
it("should accept valid fields for translatable entities", async () => {
const response = await api.post(
"/admin/translations/settings/batch",
{
create: [
{
entity_type: "product_variant",
fields: ["title", "material"],
is_active: true,
},
{
entity_type: "product_category",
fields: ["name", "description"],
is_active: true,
},
{
entity_type: "product_collection",
fields: ["title"],
is_active: true,
},
],
},
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data.created).toHaveLength(3)
})
})
})
})
},
})

View File

@@ -22,6 +22,9 @@ medusaIntegrationTestRunner({
beforeEach(async () => {
await createAdminUser(dbConnection, adminHeaders, getContainer())
const translationModule = appContainer.resolve(Modules.TRANSLATION)
await translationModule.__hooks?.onApplicationStart?.().catch(() => {})
const storeModule = appContainer.resolve(Modules.STORE)
const [defaultStore] = await storeModule.listStores(
{},

View File

@@ -0,0 +1,34 @@
import { Modules } from "@medusajs/framework/utils"
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
import { CreateTranslationSettingsDTO } from "@medusajs/types"
export const createTranslationSettingsStepId = "create-translation-settings"
export type CreateTranslationSettingsStepInput =
| CreateTranslationSettingsDTO
| CreateTranslationSettingsDTO[]
export const createTranslationSettingsStep = createStep(
createTranslationSettingsStepId,
async (data: CreateTranslationSettingsStepInput, { container }) => {
const service = container.resolve(Modules.TRANSLATION)
const normalizedInput = Array.isArray(data) ? data : [data]
const created = await service.createTranslationSettings(normalizedInput)
return new StepResponse(
created,
created.map((translationSettings) => translationSettings.id)
)
},
async (createdIds, { container }) => {
if (!createdIds?.length) {
return
}
const service = container.resolve(Modules.TRANSLATION)
await service.deleteTranslationSettings(createdIds)
}
)

View File

@@ -0,0 +1,28 @@
import { Modules } from "@medusajs/framework/utils"
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
export const deleteTranslationSettingsStepId = "delete-translation-settings"
export const deleteTranslationSettingsStep = createStep(
deleteTranslationSettingsStepId,
async (data: string[], { container }) => {
const service = container.resolve(Modules.TRANSLATION)
const previous = await service.listTranslationSettings({
id: data,
})
await service.deleteTranslationSettings(data)
return new StepResponse(void 0, previous)
},
async (previous, { container }) => {
if (!previous?.length) {
return
}
const service = container.resolve(Modules.TRANSLATION)
await service.createTranslationSettings(previous)
}
)

View File

@@ -2,3 +2,6 @@ export * from "./create-translations"
export * from "./delete-translations"
export * from "./update-translations"
export * from "./validate-translations"
export * from "./create-translation-settings"
export * from "./update-translation-settings"
export * from "./delete-translation-settings"

View File

@@ -0,0 +1,35 @@
import { Modules } from "@medusajs/framework/utils"
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
import { UpdateTranslationSettingsDTO } from "@medusajs/types"
export const updateTranslationSettingsStepId = "update-translation-settings"
export type UpdateTranslationSettingsStepInput =
| UpdateTranslationSettingsDTO
| UpdateTranslationSettingsDTO[]
export const updateTranslationSettingsStep = createStep(
updateTranslationSettingsStepId,
async (data: UpdateTranslationSettingsStepInput, { container }) => {
const service = container.resolve(Modules.TRANSLATION)
const normalizedInput = Array.isArray(data) ? data : [data]
const previous = await service.listTranslationSettings({
id: normalizedInput.map((d) => d.id),
})
const updated = await service.updateTranslationSettings(normalizedInput)
return new StepResponse(updated, previous)
},
async (previous, { container }) => {
if (!previous?.length) {
return
}
const service = container.resolve(Modules.TRANSLATION)
await service.updateTranslationSettings(previous)
}
)

View File

@@ -0,0 +1,35 @@
import {
createWorkflow,
parallelize,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import {
UpdateTranslationSettingsDTO,
CreateTranslationSettingsDTO,
} from "@medusajs/types"
import {
createTranslationSettingsStep,
deleteTranslationSettingsStep,
updateTranslationSettingsStep,
} from "../steps"
export const batchTranslationSettingsWorkflowId = "batch-translation-settings"
export interface BatchTranslationSettingsWorkflowInput {
create: CreateTranslationSettingsDTO[]
update: UpdateTranslationSettingsDTO[]
delete: string[]
}
export const batchTranslationSettingsWorkflow = createWorkflow(
batchTranslationSettingsWorkflowId,
(input: BatchTranslationSettingsWorkflowInput) => {
const [created, updated, deleted] = parallelize(
createTranslationSettingsStep(input.create),
updateTranslationSettingsStep(input.update),
deleteTranslationSettingsStep(input.delete)
)
return new WorkflowResponse({ created, updated, deleted })
}
)

View File

@@ -7,7 +7,6 @@ import {
} from "@medusajs/framework/workflows-sdk"
import { emitEventStep } from "../../common/steps/emit-event"
import { createTranslationsStep } from "../steps"
import { validateTranslationsStep } from "../steps"
import { TranslationWorkflowEvents } from "@medusajs/framework/utils"
/**
@@ -27,7 +26,7 @@ export const createTranslationsWorkflowId = "create-translations"
*
* You can use this workflow within your own customizations or custom workflows, allowing you
* to create translations in your custom flows.
*
*
* @since 2.12.3
* @featureFlag translation
*
@@ -55,7 +54,6 @@ export const createTranslationsWorkflow = createWorkflow(
(
input: WorkflowData<CreateTranslationsWorkflowInput>
): WorkflowResponse<TranslationDTO[]> => {
validateTranslationsStep(input.translations)
const translations = createTranslationsStep(input.translations)
const translationIdEvents = transform(

View File

@@ -2,3 +2,4 @@ export * from "./create-translations"
export * from "./delete-translations"
export * from "./update-translations"
export * from "./batch-translations"
export * from "./batch-translation-settings"

View File

@@ -7,7 +7,6 @@ import {
} from "@medusajs/framework/workflows-sdk"
import { emitEventStep } from "../../common/steps/emit-event"
import { updateTranslationsStep, UpdateTranslationsStepInput } from "../steps"
import { validateTranslationsStep } from "../steps"
import { TranslationWorkflowEvents } from "@medusajs/framework/utils"
/**
@@ -22,13 +21,13 @@ export const updateTranslationsWorkflowId = "update-translations"
*
* You can use this workflow within your own customizations or custom workflows, allowing you
* to update translations in your custom flows.
*
*
* @since 2.12.3
* @featureFlag translation
*
* @example
* To update translations by their IDs:
*
*
* ```ts
* const { result } = await updateTranslationsWorkflow(container)
* .run({
@@ -61,11 +60,6 @@ export const updateTranslationsWorkflow = createWorkflow(
(
input: WorkflowData<UpdateTranslationsWorkflowInput>
): WorkflowResponse<TranslationDTO[]> => {
const validateInput = transform(input, (input) => {
return "translations" in input ? input.translations : [input.update]
})
validateTranslationsStep(validateInput)
const translations = updateTranslationsStep(input)
const translationIdEvents = transform(

View File

@@ -6,7 +6,7 @@ export interface AdminTranslation {
/**
* The ID of the entity being translated.
*
*
* @example
* "prod_123"
*/
@@ -14,7 +14,7 @@ export interface AdminTranslation {
/**
* The name of the table that the translation belongs to.
*
*
* @example
* "product"
*/
@@ -22,7 +22,7 @@ export interface AdminTranslation {
/**
* The BCP 47 language tag code for this translation.
*
*
* @example
* "en-US"
*/
@@ -31,7 +31,7 @@ export interface AdminTranslation {
/**
* The translations of the resource.
* The object's keys are the field names of the data model, and its value is the translated value.
*
*
* @example
* {
* "title": "Product Title",
@@ -55,3 +55,34 @@ export interface AdminTranslation {
*/
deleted_at: Date | string | null
}
export interface AdminTranslationSettings {
/**
* The ID of the settings.
*/
id: 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 entity type.
*/
entity_type: string
/**
* The translatable fields.
*/
fields: string[]
/**
* Whether the entity translatable status is enabled.
*/
is_active: boolean
}

View File

@@ -1,5 +1,5 @@
import { PaginatedResponse } from "../../common"
import { AdminTranslation } from "./entities"
import { AdminTranslation, AdminTranslationSettings } from "./entities"
export interface AdminTranslationsResponse {
/**
@@ -100,6 +100,25 @@ export interface AdminTranslationSettingsResponse {
translatable_fields: Record<string, string[]>
}
export interface AdminBatchTranslationSettingsResponse {
/**
* The created settings.
*/
created: AdminTranslationSettings[]
/**
* The updated settings.
*/
updated: AdminTranslationSettings[]
/**
* The deleted settings.
*/
deleted: {
ids: string[]
object: "translation_settings"
deleted: boolean
}
}
/**
* Response for translation entities endpoint.
* Returns paginated entities with only their translatable fields and all their translations.

View File

@@ -99,6 +99,11 @@ export interface TranslationSettingsDTO {
*/
fields: string[]
/**
* Whether the entity translatable status is enabled.
*/
is_active: boolean
/**
* The date and time the settings were created.
*/
@@ -168,13 +173,30 @@ export interface FilterableTranslationProps
locale_code?: string | string[] | OperatorMap<string>
}
export interface FilterableTranslationSettingsProps
extends BaseFilterable<FilterableTranslationSettingsProps> {
/**
* The IDs to filter the translation settings by.
*/
id?: string[] | string | OperatorMap<string | string[]>
/**
* Filter translation settings by entity type.
*/
entity_type?: string | string[] | OperatorMap<string | string[]>
/**
* Filter translation settings by active status.
*/
is_active?: boolean | OperatorMap<boolean>
}
/**
* Input for getStatistics method.
*/
export interface TranslationStatisticsInput {
/**
* Locales to check translations for.
*
*
* @example
* ["en-US", "fr-FR"]
*/
@@ -183,15 +205,18 @@ export interface TranslationStatisticsInput {
/**
* Key-value pairs of entity types and their configurations.
*/
entities: Record<string, {
/**
* Total number of records for the entity type.
* For example, total number of products.
*
* This is necessary to compute expected translation counts.
*/
count: number
}>
entities: Record<
string,
{
/**
* Total number of records for the entity type.
* For example, total number of products.
*
* This is necessary to compute expected translation counts.
*/
count: number
}
>
}
/**

View File

@@ -9,7 +9,7 @@ export interface CreateLocaleDTO {
/**
* The BCP 47 language tag code of the locale.
*
*
* @example
* "en-US"
*/
@@ -17,7 +17,7 @@ export interface CreateLocaleDTO {
/**
* The human-readable name of the locale.
*
*
* @example
* "English (United States)"
*/
@@ -30,7 +30,7 @@ export interface CreateLocaleDTO {
export interface UpdateLocaleDataDTO {
/**
* The BCP 47 language tag code of the locale.
*
*
* @example
* "en-US"
*/
@@ -38,7 +38,7 @@ export interface UpdateLocaleDataDTO {
/**
* The human-readable name of the locale.
*
*
* @example
* "English (United States)"
*/
@@ -66,7 +66,7 @@ export interface UpsertLocaleDTO {
/**
* The BCP 47 language tag code of the locale.
*
*
* @example
* "en-US"
*/
@@ -74,7 +74,7 @@ export interface UpsertLocaleDTO {
/**
* The human-readable name of the locale.
*
*
* @example
* "English (United States)"
*/
@@ -87,7 +87,7 @@ export interface UpsertLocaleDTO {
export interface CreateTranslationDTO {
/**
* The ID of the data model being translated.
*
*
* @example
* "prod_123"
*/
@@ -95,7 +95,7 @@ export interface CreateTranslationDTO {
/**
* The name of the table that the translation belongs to.
*
*
* @example
* "product"
*/
@@ -103,7 +103,7 @@ export interface CreateTranslationDTO {
/**
* The BCP 47 language tag code for this translation.
*
*
* @example
* "en-US"
*/
@@ -111,7 +111,7 @@ export interface CreateTranslationDTO {
/**
* The translated fields as key-value pairs.
*
*
* @example
* {
* "title": "Product Title",
@@ -127,7 +127,7 @@ export interface CreateTranslationDTO {
export interface UpdateTranslationDataDTO {
/**
* The ID of the data model being translated.
*
*
* @example
* "prod_123"
*/
@@ -135,7 +135,7 @@ export interface UpdateTranslationDataDTO {
/**
* The name of the table that the translation belongs to.
*
*
* @example
* "product"
*/
@@ -143,7 +143,7 @@ export interface UpdateTranslationDataDTO {
/**
* The BCP 47 language tag code for this translation.
*
*
* @example
* "en-US"
*/
@@ -151,7 +151,7 @@ export interface UpdateTranslationDataDTO {
/**
* The translated fields as key-value pairs.
*
*
* @example
* {
* "title": "Product Title",
@@ -182,7 +182,7 @@ export interface UpsertTranslationDTO {
/**
* The ID of the data model being translated.
*
*
* @example
* "prod_123"
*/
@@ -190,7 +190,7 @@ export interface UpsertTranslationDTO {
/**
* The name of the table that the translation belongs to.
*
*
* @example
* "product"
*/
@@ -198,7 +198,7 @@ export interface UpsertTranslationDTO {
/**
* The BCP 47 language tag code for this translation.
*
*
* @example
* "en-US"
*/
@@ -206,7 +206,7 @@ export interface UpsertTranslationDTO {
/**
* The translated fields as key-value pairs.
*
*
* @example
* {
* "title": "Product Title",
@@ -215,3 +215,52 @@ export interface UpsertTranslationDTO {
*/
translations?: Record<string, unknown>
}
export interface CreateTranslationSettingsDTO {
/**
* The entity type.
*
* @example
* "product"
*/
entity_type: string
/**
* The translatable fields.
*
* @example
* ["title", "description", "material"]
*/
fields: string[]
/**
* Whether the entity translatable status is enabled.
*/
is_active?: boolean
}
/**
* The translation settings to be created or updated.
*/
export interface UpdateTranslationSettingsDTO {
/**
* The ID of the translation settings to update.
*/
id: string
/**
* The entity type.
*
* @example
* "product"
*/
entity_type?: string
/**
* The translatable fields.
*
* @example
* ["title", "description", "material"]
*/
fields?: string[]
/**
* Whether the entity translatable status is enabled.
*/
is_active?: boolean
}

View File

@@ -5,23 +5,27 @@ import { Context } from "../shared-context"
import {
FilterableLocaleProps,
FilterableTranslationProps,
FilterableTranslationSettingsProps,
LocaleDTO,
TranslationDTO,
TranslationSettingsDTO,
TranslationStatisticsInput,
TranslationStatisticsOutput,
} from "./common"
import {
CreateLocaleDTO,
CreateTranslationDTO,
CreateTranslationSettingsDTO,
UpdateLocaleDTO,
UpdateLocaleDataDTO,
UpdateTranslationDTO,
UpdateTranslationDataDTO,
UpdateTranslationSettingsDTO,
} from "./mutations"
/**
* The main service interface for the Translation Module.
*
*
* @privateRemarks
* Method signatures match what MedusaService generates.
*/
@@ -43,12 +47,12 @@ export interface ITranslationModuleService extends IModuleService {
* ```
*
* To specify relations that should be retrieved:
*
*
* :::note
*
*
* You can only retrieve data models defined in the same module. To retrieve linked data models
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
*
*
* :::
*
* ```ts
@@ -82,12 +86,12 @@ export interface ITranslationModuleService extends IModuleService {
* ```
*
* To specify relations that should be retrieved within the locales:
*
*
* :::note
*
*
* You can only retrieve data models defined in the same module. To retrieve linked data models
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
*
*
* :::
*
* ```ts
@@ -141,12 +145,12 @@ export interface ITranslationModuleService extends IModuleService {
* ```
*
* To specify relations that should be retrieved within the locales:
*
*
* :::note
*
*
* You can only retrieve data models defined in the same module. To retrieve linked data models
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
*
*
* :::
*
* ```ts
@@ -250,7 +254,7 @@ export interface ITranslationModuleService extends IModuleService {
*
* @example
* To update locales by their IDs:
*
*
* ```ts
* const locales = await translationModuleService.updateLocales([
* {
@@ -265,7 +269,7 @@ export interface ITranslationModuleService extends IModuleService {
* ```
*
* To update locales by a selector:
*
*
* ```ts
* const locales = await translationModuleService.updateLocales({
* selector: {
@@ -299,7 +303,7 @@ export interface ITranslationModuleService extends IModuleService {
* @param {string | object | string[] | object[]} primaryKeyValues - The IDs or objects with IDs identifying the locales to delete.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<void>} Resolves when the locales are deleted.
*
*
* @example
* await translationModuleService.deleteLocales(["loc_123", "loc_321"])
*/
@@ -336,7 +340,7 @@ export interface ITranslationModuleService extends IModuleService {
* @returns {Promise<Record<string, string[]> | void>} An object that includes the IDs of related records that were restored.
*
* If there are no related records restored, the promise resolves to `void`.
*
*
* @example
* await translationModuleService.restoreLocales(["loc_123", "loc_321"])
*/
@@ -362,12 +366,12 @@ export interface ITranslationModuleService extends IModuleService {
* ```
*
* To specify relations that should be retrieved:
*
*
* :::note
*
*
* You can only retrieve data models defined in the same module. To retrieve linked data models
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
*
*
* :::
*
* ```ts
@@ -401,12 +405,12 @@ export interface ITranslationModuleService extends IModuleService {
* ```
*
* To specify relations that should be retrieved within the translations:
*
*
* :::note
*
*
* You can only retrieve data models defined in the same module. To retrieve linked data models
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
*
*
* :::
*
* ```ts
@@ -419,7 +423,7 @@ export interface ITranslationModuleService extends IModuleService {
* }
* )
* ```
*
*
* By default, only the first `15` records are retrieved. You can control pagination by specifying the `skip` and `take` properties of the `config` parameter:
*
* ```ts
@@ -460,12 +464,12 @@ export interface ITranslationModuleService extends IModuleService {
* ```
*
* To specify relations that should be retrieved within the translations:
*
*
* :::note
*
*
* You can only retrieve data models defined in the same module. To retrieve linked data models
* from other modules, use [Query](https://docs.medusajs.com/learn/fundamentals/module-links/query) instead.
*
*
* :::
*
* ```ts
@@ -478,7 +482,7 @@ export interface ITranslationModuleService extends IModuleService {
* }
* )
* ```
*
*
* By default, only the first `15` records are retrieved. You can control pagination by specifying the `skip` and `take` properties of the `config` parameter:
*
* ```ts
@@ -618,7 +622,7 @@ export interface ITranslationModuleService extends IModuleService {
* @param {string | object | string[] | object[]} primaryKeyValues - The IDs or objects with IDs identifying the translations to delete.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<void>} Resolves when the translations are deleted.
*
*
* @example
* await translationModuleService.deleteTranslations("tra_123")
*/
@@ -635,7 +639,7 @@ export interface ITranslationModuleService extends IModuleService {
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<Record<string, string[]> | void>} An object that includes the IDs of related records that were also soft deleted.
* If there are no related records, the promise resolves to `void`.
*
*
* @example
* await translationModuleService.softDeleteTranslations(["tra_123", "tra_321"])
*/
@@ -667,7 +671,7 @@ export interface ITranslationModuleService extends IModuleService {
/**
* This method retrieves translation statistics for the specified entities and locales.
* It's useful to understand the translation coverage of different entities across various locales.
*
*
* You can use this method to get insights into how many fields are translated, missing translations,
* and the expected number of translations based on the entities and locales provided.
*
@@ -731,4 +735,189 @@ export interface ITranslationModuleService extends IModuleService {
entityType?: string,
sharedContext?: Context
): Promise<Record<string, string[]>>
/**
* This method creates a translation setting.
*
* @param {CreateTranslationSettingsDTO} data - The translation setting to be created.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<TranslationSettingsDTO>} The created translation setting.
*
* @example
* const translationSetting = await translationModuleService.createTranslationSettings({
* entity_type: "product",
* fields: ["title", "description"],
* is_active: true,
* })
*/
createTranslationSettings(
data: CreateTranslationSettingsDTO,
sharedContext?: Context
): Promise<TranslationSettingsDTO>
/**
*
* @param data - The translation settings to be created.
* @param sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<TranslationSettingsDTO[]>} The created translation settings.
*
* @example
* const translationSettings = await translationModuleService.createTranslationSettings([
* {
* entity_type: "product",
* fields: ["title", "description"],
* is_active: true,
* },
* ])
*/
createTranslationSettings(
data: CreateTranslationSettingsDTO[],
sharedContext?: Context
): Promise<TranslationSettingsDTO[]>
/**
* This method updates an existent translation setting. The ID should be included in the data object.
}
* @param {UpdateTranslationSettingsDTO} data - The attributes to update in the translation setting (including id).
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<TranslationSettingsDTO>} The updated translation setting.
*
* @example
* const translationSettings = await translationModuleService.updateTranslationSettings([
* {
* id: "ts_123",
* entity_type: "product_collection",
* fields: ["title"],
* is_active: true,
* },
* ])
*/
updateTranslationSettings(
data: UpdateTranslationSettingsDTO,
sharedContext?: Context
): Promise<TranslationSettingsDTO>
/**
* This method updates one or more existent translation settings.
* @param {UpdateTranslationSettingsDTO[]} data - The translation settings to update.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<TranslationSettingsDTO[]>} The updated translation settings.
*
* @example
* const translationSettings = await translationModuleService.updateTranslationSettings([
* {
* id: "ts_123",
* entity_type: "product_collection",
* fields: ["title"],
* is_active: true,
* },
* ])
*/
updateTranslationSettings(
data: UpdateTranslationSettingsDTO[],
sharedContext?: Context
): Promise<TranslationSettingsDTO[]>
/**
* This method deletes one or more translation settings.
*
* @param {string[]} input - The IDs of the translation settings to delete.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<void>} Resolves when the translation settings are deleted.
*
* @example
* await translationModuleService.deleteTranslationSettings([
* "ts_123",
* "ts_321",
* ])
*/
deleteTranslationSettings(
input: string[],
sharedContext?: Context
): Promise<void>
/**
* This method retrieves a paginated list of translation settings based on optional filters and configuration.
*
* @param {FilterableTranslationSettingsProps} filters - The filters to apply on the retrieved translation settings.
* @param {FindConfig<TranslationSettingsDTO>} config - The configurations determining how the translation settings are retrieved. Its properties, such as `select` or `relations`, accept the
* attributes or relations associated with a translation settings.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<TranslationSettingsDTO[]>} The list of translation settings.
*
* @example
* const translationSettings = await translationModuleService.listTranslationSettings({
* entity_type: "product",
* is_active: true,
* })
* // Returns: [
* // {
* // id: "ts_123",
* // entity_type: "product",
* // fields: ["title", "description"],
* // is_active: true,
* // },
* // ]
*/
listTranslationSettings(
filters?: FilterableTranslationSettingsProps,
config?: FindConfig<TranslationSettingsDTO>,
sharedContext?: Context
): Promise<TranslationSettingsDTO[]>
/**
* This method retrieves a paginated list of translation settings based on optional filters and configuration, along with the total count.
*
* @param {FilterableTranslationSettingsProps} filters - The filters to apply on the retrieved translation settings.
* @param {FindConfig<TranslationSettingsDTO>} config - The configurations determining how the translation settings are retrieved. Its properties, such as `select` or `relations`, accept the
* attributes or relations associated with a translation settings.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<[TranslationSettingsDTO[], number]>} The list of translation settings along with their total count.
*
* @example
* const [translationSettings, count] = await translationModuleService.listAndCountTranslationSettings({
* entity_type: "product",
* is_active: true,
* })
* // Returns: [
* // [
* // {
* // id: "ts_123",
* // entity_type: "product",
* // fields: ["title", "description"],
* // is_active: true,
* // },
* // ],
* // 1,
* // ]
*/
listAndCountTranslationSettings(
filters?: FilterableTranslationSettingsProps,
config?: FindConfig<TranslationSettingsDTO>,
sharedContext?: Context
): Promise<[TranslationSettingsDTO[], number]>
/**
* This method retrieves a translation setting by its ID.
*
* @param {string} id - The ID of the translation setting to retrieve.
* @param {FindConfig<TranslationSettingsDTO>} config - The configurations determining how the translation setting is retrieved. Its properties, such as `select` or `relations`, accept the
* attributes or relations associated with a translation settings.
* @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
* @returns {Promise<TranslationSettingsDTO>} The retrieved translation setting.
*
* @example
* const translationSetting = await translationModuleService.retrieveTranslationSettings("ts_123")
* // Returns: {
* // id: "ts_123",
* // entity_type: "product",
* // fields: ["title", "description"],
* // is_active: true,
* // }
*/
retrieveTranslationSettings(
id: string,
config?: FindConfig<TranslationSettingsDTO>,
sharedContext?: Context
): Promise<TranslationSettingsDTO>
}

View File

@@ -5,6 +5,7 @@ import {
} from "@medusajs/framework"
import {
AdminBatchTranslations,
AdminBatchTranslationSettings,
AdminGetTranslationsParams,
AdminTranslationEntitiesParams,
AdminTranslationSettingsParams,
@@ -44,6 +45,11 @@ export const adminTranslationsRoutesMiddlewares: MiddlewareRoute[] = [
validateAndTransformQuery(AdminTranslationSettingsParams, {}),
],
},
{
method: ["POST"],
matcher: "/admin/translations/settings/batch",
middlewares: [validateAndTransformBody(AdminBatchTranslationSettings)],
},
{
method: ["GET"],
matcher: "/admin/translations/entities",

View File

@@ -0,0 +1,39 @@
import { batchTranslationSettingsWorkflow } from "@medusajs/core-flows"
import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework"
import { defineFileConfig, FeatureFlag } from "@medusajs/framework/utils"
import { HttpTypes } from "@medusajs/types"
import TranslationFeatureFlag from "../../../../../feature-flags/translation"
import { AdminBatchTranslationSettingsType } from "../../validators"
/**
* @since 2.12.5
* @featureFlag translation
*/
export const POST = async (
req: AuthenticatedMedusaRequest<AdminBatchTranslationSettingsType>,
res: MedusaResponse<HttpTypes.AdminBatchTranslationSettingsResponse>
) => {
const { create = [], update = [], delete: deleteIds = [] } = req.validatedBody
const { result } = await batchTranslationSettingsWorkflow(req.scope).run({
input: {
create,
update,
delete: deleteIds,
},
})
return res.status(200).json({
created: result.created,
updated: result.updated,
deleted: {
ids: deleteIds,
object: "translation_settings",
deleted: true,
},
})
}
defineFileConfig({
isDisabled: () => !FeatureFlag.isFeatureEnabled(TranslationFeatureFlag.key),
})

View File

@@ -72,6 +72,27 @@ export const AdminTranslationSettingsParams = z.object({
entity_type: z.string().optional(),
})
const AdminUpdateTranslationSettings = z.object({
id: z.string(),
entity_type: z.string().optional(),
fields: z.array(z.string()).optional(),
is_active: z.boolean().optional(),
})
const AdminCreateTranslationSettings = z.object({
entity_type: z.string(),
fields: z.array(z.string()),
is_active: z.boolean().optional(),
})
export type AdminBatchTranslationSettingsType = z.infer<
typeof AdminBatchTranslationSettings
>
export const AdminBatchTranslationSettings = createBatchBody(
AdminCreateTranslationSettings,
AdminUpdateTranslationSettings
)
export type AdminTranslationEntitiesParamsType = z.infer<
typeof AdminTranslationEntitiesParams
>

View File

@@ -5,7 +5,7 @@ import { CustomerGroupCustomer } from "@models"
const CustomerGroup = model
.define("CustomerGroup", {
id: model.id({ prefix: "cusgroup" }).primaryKey(),
name: model.text().searchable(),
name: model.text().searchable().translatable(),
metadata: model.json().nullable(),
created_by: model.text().nullable(),
customers: model.manyToMany(() => Customer, {

View File

@@ -4,8 +4,8 @@ import { ShippingOption } from "./shipping-option"
export const ShippingOptionType = model.define("shipping_option_type", {
id: model.id({ prefix: "sotype" }).primaryKey(),
label: model.text().searchable(),
description: model.text().searchable().nullable(),
label: model.text().searchable().translatable(),
description: model.text().searchable().translatable().nullable(),
code: model.text().searchable(),
shipping_options: model.hasMany(() => ShippingOption, {
mappedBy: "type",

View File

@@ -10,7 +10,7 @@ import { ShippingProfile } from "./shipping-profile"
export const ShippingOption = model
.define("shipping_option", {
id: model.id({ prefix: "so" }).primaryKey(),
name: model.text().searchable(),
name: model.text().searchable().translatable(),
price_type: model
.enum(ShippingOptionPriceType)
.default(ShippingOptionPriceType.FLAT),

View File

@@ -4,8 +4,8 @@ import Product from "./product"
const ProductCategory = model
.define("ProductCategory", {
id: model.id({ prefix: "pcat" }).primaryKey(),
name: model.text().searchable(),
description: model.text().searchable().default(""),
name: model.text().searchable().translatable(),
description: model.text().searchable().translatable().default(""),
handle: model.text().searchable(),
mpath: model.text(),
is_active: model.boolean().default(false),

View File

@@ -4,7 +4,7 @@ import Product from "./product"
const ProductCollection = model
.define("ProductCollection", {
id: model.id({ prefix: "pcol" }).primaryKey(),
title: model.text().searchable(),
title: model.text().searchable().translatable(),
handle: model.text(),
metadata: model.json().nullable(),
products: model.hasMany(() => Product, {

View File

@@ -4,7 +4,7 @@ import { ProductOption, ProductVariant } from "./index"
const ProductOptionValue = model
.define("ProductOptionValue", {
id: model.id({ prefix: "optval" }).primaryKey(),
value: model.text(),
value: model.text().translatable(),
metadata: model.json().nullable(),
option: model
.belongsTo(() => ProductOption, {

View File

@@ -5,7 +5,7 @@ import ProductOptionValue from "./product-option-value"
const ProductOption = model
.define("ProductOption", {
id: model.id({ prefix: "opt" }).primaryKey(),
title: model.text().searchable(),
title: model.text().searchable().translatable(),
metadata: model.json().nullable(),
product: model.belongsTo(() => Product, {
mappedBy: "options",

View File

@@ -6,7 +6,7 @@ const ProductTag = model
{ tableName: "product_tag", name: "ProductTag" },
{
id: model.id({ prefix: "ptag" }).primaryKey(),
value: model.text().searchable(),
value: model.text().searchable().translatable(),
metadata: model.json().nullable(),
products: model.manyToMany(() => Product, {
mappedBy: "tags",

View File

@@ -4,7 +4,7 @@ import { Product } from "@models"
const ProductType = model
.define("ProductType", {
id: model.id({ prefix: "ptyp" }).primaryKey(),
value: model.text().searchable(),
value: model.text().searchable().translatable(),
metadata: model.json().nullable(),
products: model.hasMany(() => Product, {
mappedBy: "type",

View File

@@ -5,7 +5,7 @@ import ProductVariantProductImage from "./product-variant-product-image"
const ProductVariant = model
.define("ProductVariant", {
id: model.id({ prefix: "variant" }).primaryKey(),
title: model.text().searchable(),
title: model.text().searchable().translatable(),
sku: model.text().searchable().nullable(),
barcode: model.text().searchable().nullable(),
ean: model.text().searchable().nullable(),
@@ -15,7 +15,7 @@ const ProductVariant = model
hs_code: model.text().nullable(),
origin_country: model.text().nullable(),
mid_code: model.text().nullable(),
material: model.text().nullable(),
material: model.text().translatable().nullable(),
weight: model.number().nullable(),
length: model.number().nullable(),
height: model.number().nullable(),

View File

@@ -11,10 +11,10 @@ import ProductVariant from "./product-variant"
const Product = model
.define("Product", {
id: model.id({ prefix: "prod" }).primaryKey(),
title: model.text().searchable(),
title: model.text().searchable().translatable(),
handle: model.text(),
subtitle: model.text().searchable().nullable(),
description: model.text().searchable().nullable(),
subtitle: model.text().searchable().translatable().nullable(),
description: model.text().searchable().translatable().nullable(),
is_giftcard: model.boolean().default(false),
status: model
.enum(ProductUtils.ProductStatus)
@@ -27,7 +27,7 @@ const Product = model
origin_country: model.text().nullable(),
hs_code: model.text().nullable(),
mid_code: model.text().nullable(),
material: model.text().nullable(),
material: model.text().translatable().nullable(),
discountable: model.boolean().default(true),
external_id: model.text().nullable(),
metadata: model.json().nullable(),

View File

@@ -3,7 +3,7 @@ import RegionCountry from "./country"
export default model.define("region", {
id: model.id({ prefix: "reg" }).primaryKey(),
name: model.text().searchable(),
name: model.text().searchable().translatable(),
currency_code: model.text().searchable(),
automatic_taxes: model.boolean().default(true),
countries: model.hasMany(() => RegionCountry),

View File

@@ -7,7 +7,7 @@ const TaxRate = model
id: model.id({ prefix: "txr" }).primaryKey(),
rate: model.float().nullable(),
code: model.text().searchable(),
name: model.text().searchable(),
name: model.text().searchable().translatable(),
is_default: model.boolean().default(false),
is_combinable: model.boolean().default(false),
tax_region: model.belongsTo(() => TaxRegion, {

View File

@@ -1,15 +1,42 @@
import { ITranslationModuleService } from "@medusajs/framework/types"
import { Module, Modules } from "@medusajs/framework/utils"
import { DmlEntity, Module, Modules } from "@medusajs/framework/utils"
import { moduleIntegrationTestRunner } from "@medusajs/test-utils"
import TranslationModuleService from "@services/translation-module"
import { createLocaleFixture, createTranslationFixture } from "../__fixtures__"
jest.setTimeout(100000)
// Set up the mock before module initialization
let mockGetTranslatableEntities: jest.SpyInstance
moduleIntegrationTestRunner<ITranslationModuleService>({
moduleName: Modules.TRANSLATION,
hooks: {
beforeModuleInit: async () => {
mockGetTranslatableEntities = jest.spyOn(
DmlEntity,
"getTranslatableEntities"
)
mockGetTranslatableEntities.mockReturnValue([
{
entity: "Product",
fields: ["title", "description", "subtitle", "material"],
},
{ entity: "ProductVariant", fields: ["title", "material"] },
{ entity: "ProductCategory", fields: ["name"] },
])
},
},
testSuite: ({ service }) => {
describe("Translation Module Service", () => {
beforeEach(async () => {
await service.__hooks?.onApplicationStart?.().catch(() => {})
})
afterAll(() => {
// Restore the mock after all tests complete
mockGetTranslatableEntities.mockRestore()
})
it(`should export the appropriate linkable configuration`, () => {
const linkable = Module(Modules.TRANSLATION, {
service: TranslationModuleService,

View File

@@ -1,12 +1,11 @@
import "./types"
import { Module } from "@medusajs/framework/utils"
import TranslationModuleService from "@services/translation-module"
import loadConfig from "./loaders/config"
import loadDefaults from "./loaders/defaults"
export const TRANSLATION_MODULE = "translation"
export default Module(TRANSLATION_MODULE, {
service: TranslationModuleService,
loaders: [loadDefaults, loadConfig],
loaders: [loadDefaults],
})

View File

@@ -1,60 +0,0 @@
import {
LoaderOptions,
Logger,
ModulesSdkTypes,
} from "@medusajs/framework/types"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
import { TRANSLATABLE_FIELDS_CONFIG_KEY } from "@utils/constants"
import { asValue } from "awilix"
import { translatableFieldsConfig } from "../utils/translatable-fields"
import Settings from "@models/settings"
import type { TranslationModuleOptions } from "../types"
export default async ({
container,
options,
}: LoaderOptions<TranslationModuleOptions>): Promise<void> => {
const logger =
container.resolve<Logger>(ContainerRegistrationKeys.LOGGER) ?? console
const settingsService: ModulesSdkTypes.IMedusaInternalService<
typeof Settings
> = container.resolve("translationSettingsService")
const mergedConfig: Record<string, string[]> = translatableFieldsConfig
const userProvidedFields = options?.entities ?? []
for (const field of userProvidedFields) {
mergedConfig[field.type] ??= []
mergedConfig[field.type] = Array.from(
new Set([...(mergedConfig[field.type] ?? []), ...field.fields])
)
}
try {
const existingSettings = await settingsService.list(
{},
{ select: ["id", "entity_type"] }
)
const existingByEntityType = new Map(
existingSettings.map((s) => [s.entity_type, s.id])
)
const settingsToUpsert = Object.entries(mergedConfig).map(
([entityType, fields]) => {
const existingId = existingByEntityType.get(entityType)
return existingId
? { id: existingId, entity_type: entityType, fields }
: { entity_type: entityType, fields }
}
)
const resp = await settingsService.upsert(settingsToUpsert)
logger.debug(`Loaded ${resp.length} translation settings`)
} catch (error) {
logger.warn(
`Failed to load translation settings, skipping loader. Original error: ${error.message}`
)
}
container.register(TRANSLATABLE_FIELDS_CONFIG_KEY, asValue(mergedConfig))
}

View File

@@ -293,6 +293,16 @@
"nullable": false,
"mappedType": "json"
},
"is_active": {
"name": "is_active",
"type": "boolean",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"default": "true",
"mappedType": "boolean"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",

View File

@@ -0,0 +1,13 @@
import { Migration } from "@medusajs/framework/mikro-orm/migrations";
export class Migration20260108122757 extends Migration {
override async up(): Promise<void> {
this.addSql(`alter table if exists "translation_settings" add column if not exists "is_active" boolean not null default true;`);
}
override async down(): Promise<void> {
this.addSql(`alter table if exists "translation_settings" drop column if exists "is_active";`);
}
}

View File

@@ -18,6 +18,10 @@ const Settings = model
* ["title", "description", "material"]
*/
fields: model.json(),
/**
* Wether the entity translatable status is enabled.
*/
is_active: model.boolean().default(true),
})
.indexes([
{

View File

@@ -2,6 +2,7 @@ import { raw } from "@medusajs/framework/mikro-orm/core"
import {
Context,
CreateTranslationDTO,
CreateTranslationSettingsDTO,
DAL,
FilterableTranslationProps,
FindConfig,
@@ -9,21 +10,25 @@ import {
LocaleDTO,
ModulesSdkTypes,
TranslationTypes,
UpdateTranslationSettingsDTO,
} from "@medusajs/framework/types"
import { SqlEntityManager } from "@medusajs/framework/mikro-orm/postgresql"
import {
arrayDifference,
DmlEntity,
EmitEvents,
InjectManager,
MedusaContext,
MedusaError,
MedusaErrorTypes,
MedusaService,
normalizeLocale,
toSnakeCase,
} from "@medusajs/framework/utils"
import Locale from "@models/locale"
import Translation from "@models/translation"
import Settings from "@models/settings"
import { computeTranslatedFieldCount } from "@utils/compute-translated-field-count"
import { TRANSLATABLE_FIELDS_CONFIG_KEY } from "@utils/constants"
import { filterTranslationFields } from "@utils/filter-translation-fields"
type InjectedDependencies = {
@@ -33,7 +38,6 @@ type InjectedDependencies = {
translationSettingsService: ModulesSdkTypes.IMedusaInternalService<
typeof Settings
>
[TRANSLATABLE_FIELDS_CONFIG_KEY]: Record<string, string[]>
}
export default class TranslationModuleService
@@ -78,6 +82,55 @@ export default class TranslationModuleService
this.settingsService_ = translationSettingsService
}
__hooks = {
onApplicationStart: async () => {
return this.onApplicationStart_()
},
}
protected async onApplicationStart_() {
const translatableEntities = DmlEntity.getTranslatableEntities()
const translatableEntitiesSet = new Set(
translatableEntities.map((entity) => toSnakeCase(entity.entity))
)
const currentTranslationSettings = await this.settingsService_.list()
const currentTranslationSettingsSet = new Set(
currentTranslationSettings.map((setting) => setting.entity_type)
)
const settingsToUpsert: (
| CreateTranslationSettingsDTO
| UpdateTranslationSettingsDTO
)[] = []
for (const setting of currentTranslationSettings) {
if (
!translatableEntitiesSet.has(setting.entity_type) &&
setting.is_active
) {
settingsToUpsert.push({
id: setting.id,
is_active: false,
})
}
}
for (const entity of translatableEntities) {
const snakeCaseEntityType = toSnakeCase(entity.entity)
const hasCurrentSettings =
currentTranslationSettingsSet.has(snakeCaseEntityType)
if (!hasCurrentSettings) {
settingsToUpsert.push({
entity_type: snakeCaseEntityType,
fields: entity.fields,
})
}
}
await this.settingsService_.upsert(settingsToUpsert)
}
@InjectManager()
async getTranslatableFields(
entityType?: string,
@@ -90,7 +143,8 @@ export default class TranslationModuleService
sharedContext
)
return settings.reduce((acc, setting) => {
acc[setting.entity_type] = setting.fields as unknown as string[]
acc[toSnakeCase(setting.entity_type)] =
setting.fields as unknown as string[]
return acc
}, {} as Record<string, string[]>)
}
@@ -377,6 +431,42 @@ export default class TranslationModuleService
return Array.isArray(data) ? serialized : serialized[0]
}
@InjectManager()
@EmitEvents()
// @ts-expect-error
async createTranslationSettings(
data: CreateTranslationSettingsDTO[] | CreateTranslationSettingsDTO,
@MedusaContext() sharedContext: Context = {}
): Promise<
| TranslationTypes.TranslationSettingsDTO
| TranslationTypes.TranslationSettingsDTO[]
> {
const dataArray = Array.isArray(data) ? data : [data]
await this.validateSettings_(dataArray, sharedContext)
// @ts-expect-error TS can't match union type to overloads
return await super.createTranslationSettings(data, sharedContext)
}
@InjectManager()
@EmitEvents()
// @ts-expect-error
async updateTranslationSettings(
data: UpdateTranslationSettingsDTO | UpdateTranslationSettingsDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<
| TranslationTypes.TranslationSettingsDTO[]
| TranslationTypes.TranslationSettingsDTO
> {
const dataArray = Array.isArray(data) ? data : [data]
await this.validateSettings_(dataArray, sharedContext)
// @ts-expect-error TS can't match union type to overloads
return await super.updateTranslationSettings(data, sharedContext)
}
@InjectManager()
async getStatistics(
input: TranslationTypes.TranslationStatisticsInput,
@@ -492,4 +582,79 @@ export default class TranslationModuleService
return result
}
/**
* Validates the translation settings to create or update against the translatable entities and their translatable fields configuration.
* @param dataToValidate - The data to validate.
* @param sharedContext - A context used to share resources, such as transaction manager, between the application and the module.
*/
@InjectManager()
protected async validateSettings_(
dataToValidate: (
| CreateTranslationSettingsDTO
| UpdateTranslationSettingsDTO
)[],
@MedusaContext() sharedContext: Context = {}
) {
const translatableEntities = DmlEntity.getTranslatableEntities()
const translatableEntitiesMap = new Map(
translatableEntities.map((entity) => [toSnakeCase(entity.entity), entity])
)
const invalidSettings: {
entity_type: string
is_invalid_entity: boolean
invalidFields?: string[]
}[] = []
for (const item of dataToValidate) {
let itemEntityType = item.entity_type
if (!itemEntityType) {
const translationSetting = await this.retrieveTranslationSettings(
//@ts-expect-error - if no entity_type, we are on an update
item.id,
{ select: ["entity_type"] },
sharedContext
)
itemEntityType = translationSetting.entity_type
}
const entity = translatableEntitiesMap.get(itemEntityType)
if (!entity) {
invalidSettings.push({
entity_type: itemEntityType,
is_invalid_entity: true,
})
} else {
const invalidFields = arrayDifference(item.fields ?? [], entity.fields)
if (invalidFields.length) {
invalidSettings.push({
entity_type: itemEntityType,
is_invalid_entity: false,
invalidFields,
})
}
}
}
if (invalidSettings.length) {
throw new MedusaError(
MedusaErrorTypes.INVALID_DATA,
"Invalid translation settings:\n" +
invalidSettings
.map(
(setting) =>
`- ${setting.entity_type} ${
setting.is_invalid_entity
? "is not a translatable entity"
: `doesn't have the following fields set as translatable: ${setting.invalidFields?.join(
", "
)}`
}`
)
.join("\n")
)
}
}
}

View File

@@ -1 +0,0 @@
export const TRANSLATABLE_FIELDS_CONFIG_KEY = "translatableFieldsConfig"

View File

@@ -1,41 +0,0 @@
export const PRODUCT_TRANSLATABLE_FIELDS = [
"title",
"description",
"material",
"subtitle",
]
export const PRODUCT_VARIANT_TRANSLATABLE_FIELDS = ["title", "material"]
export const PRODUCT_TYPE_TRANSLATABLE_FIELDS = ["value"]
export const PRODUCT_COLLECTION_TRANSLATABLE_FIELDS = ["title"]
export const PRODUCT_CATEGORY_TRANSLATABLE_FIELDS = ["name", "description"]
export const PRODUCT_TAG_TRANSLATABLE_FIELDS = ["value"]
export const PRODUCT_OPTION_TRANSLATABLE_FIELDS = ["title"]
export const PRODUCT_OPTION_VALUE_TRANSLATABLE_FIELDS = ["value"]
export const REGION_TRANSLATABLE_FIELDS = ["name"]
export const CUSTOMER_GROUP_TRANSLATABLE_FIELDS = ["name"]
export const SHIPPING_OPTION_TRANSLATABLE_FIELDS = ["name"]
export const SHIPPING_OPTION_TYPE_TRANSLATABLE_FIELDS = ["label", "description"]
export const TAX_RATE_TRANSLATABLE_FIELDS = ["name"]
// export const RETURN_REASON_TRANSLATABLE_FIELDS = [
// "value",
// "label",
// "description",
// ]
export const translatableFieldsConfig = {
product: PRODUCT_TRANSLATABLE_FIELDS,
product_variant: PRODUCT_VARIANT_TRANSLATABLE_FIELDS,
product_type: PRODUCT_TYPE_TRANSLATABLE_FIELDS,
product_collection: PRODUCT_COLLECTION_TRANSLATABLE_FIELDS,
product_category: PRODUCT_CATEGORY_TRANSLATABLE_FIELDS,
product_tag: PRODUCT_TAG_TRANSLATABLE_FIELDS,
product_option: PRODUCT_OPTION_TRANSLATABLE_FIELDS,
product_option_value: PRODUCT_OPTION_VALUE_TRANSLATABLE_FIELDS,
region: REGION_TRANSLATABLE_FIELDS,
customer_group: CUSTOMER_GROUP_TRANSLATABLE_FIELDS,
shipping_option: SHIPPING_OPTION_TRANSLATABLE_FIELDS,
shipping_option_type: SHIPPING_OPTION_TYPE_TRANSLATABLE_FIELDS,
tax_rate: TAX_RATE_TRANSLATABLE_FIELDS,
// return_reason: RETURN_REASON_TRANSLATABLE_FIELDS,
}