feat(): Introduce translation module and preliminary application of them (#14189)

* feat(): Translation first steps

* feat(): locale middleware

* feat(): readonly links

* feat(): feature flag

* feat(): modules sdk

* feat(): translation module re export

* start adding workflows

* update typings

* update typings

* test(): Add integration tests

* test(): centralize filters preparation

* test(): centralize filters preparation

* remove unnecessary importy

* fix workflows

* Define StoreLocale inside Store Module

* Link definition to extend Store with supported_locales

* store_locale migration

* Add supported_locales handling in Store Module

* Tests

* Accept supported_locales in Store endpoints

* Add locales to js-sdk

* Include locale list and default locale in Store Detail section

* Initialize local namespace in js-sdk

* Add locales route

* Make code primary key of locale table to facilitate upserts

* Add locales routes

* Show locale code as is

* Add list translations api route

* Batch endpoint

* Types

* New batchTranslationsWorkflow and various updates to existent ones

* Edit default locale UI

* WIP

* Apply translation agnostically

* middleware

* Apply translation agnostically

* fix Apply translation agnostically

* apply translations to product list

* Add feature flag

* fetch translations by batches of 250 max

* fix apply

* improve and test util

* apply to product list

* dont manage translations if no locale

* normalize locale

* potential todo

* Protect translations routes with feature flag

* Extract normalize locale util to core/utils

* Normalize locale on write

* Normalize locale for read

* Use feature flag to guard translations UI across the board

* Avoid throwing incorrectly when locale_code not present in partial updates

* move applyTranslations util

* remove old tests

* fix util tests

* fix(): product end points

* cleanup

* update lock

* remove unused var

* cleanup

* fix apply locale

* missing new dep for test utils

* Change entity_type, entity_id to reference, reference_id

* Remove comment

* Avoid registering translations route if ff not enabled

* Prevent registering express handler for disabled route via defineFileConfig

* Add tests

* Add changeset

* Update test

* fix integration tests, module and internals

* Add locale id plus fixed

* Allow to pass array of reference_id

* fix unit tests

* fix link loading

* fix store route

* fix sales channel test

* fix tests

---------

Co-authored-by: Nicolas Gorga <nicogorga11@gmail.com>
Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Adrien de Peretti
2025-12-08 19:33:08 +01:00
committed by GitHub
parent fea3d4ec49
commit 6dc0b8bed8
130 changed files with 5649 additions and 112 deletions

View File

@@ -31,4 +31,5 @@ export * from "./shipping-profile"
export * from "./stock-location"
export * from "./store"
export * from "./tax"
export * from "./translation"
export * from "./user"

View File

@@ -0,0 +1,2 @@
export * from "./steps"
export * from "./workflows"

View File

@@ -0,0 +1,47 @@
import {
CreateTranslationDTO,
ITranslationModuleService,
} from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
export const createTranslationsStepId = "create-translations"
/**
* This step creates one or more translations.
*
* @example
* const data = createTranslationsStep([
* {
* reference_id: "prod_123",
* reference: "product",
* locale_code: "fr-FR",
* translations: { title: "Produit", description: "Description du produit" }
* }
* ])
*/
export const createTranslationsStep = createStep(
createTranslationsStepId,
async (data: CreateTranslationDTO[], { container }) => {
const service = container.resolve<ITranslationModuleService>(
Modules.TRANSLATION
)
const created = await service.createTranslations(data)
return new StepResponse(
created,
created.map((translation) => translation.id)
)
},
async (createdIds, { container }) => {
if (!createdIds?.length) {
return
}
const service = container.resolve<ITranslationModuleService>(
Modules.TRANSLATION
)
await service.deleteTranslations(createdIds)
}
)

View File

@@ -0,0 +1,36 @@
import { ITranslationModuleService } from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
/**
* The IDs of the translations to delete.
*/
export type DeleteTranslationsStepInput = string[]
export const deleteTranslationsStepId = "delete-translations"
/**
* This step deletes one or more translations.
*/
export const deleteTranslationsStep = createStep(
deleteTranslationsStepId,
async (ids: DeleteTranslationsStepInput, { container }) => {
const service = container.resolve<ITranslationModuleService>(
Modules.TRANSLATION
)
await service.softDeleteTranslations(ids)
return new StepResponse(void 0, ids)
},
async (prevIds, { container }) => {
if (!prevIds?.length) {
return
}
const service = container.resolve<ITranslationModuleService>(
Modules.TRANSLATION
)
await service.restoreTranslations(prevIds)
}
)

View File

@@ -0,0 +1,4 @@
export * from "./create-translations"
export * from "./delete-translations"
export * from "./update-translations"
export * from "./validate-translations"

View File

@@ -0,0 +1,113 @@
import {
FilterableTranslationProps,
ITranslationModuleService,
UpdateTranslationDTO,
} from "@medusajs/framework/types"
import {
MedusaError,
MedusaErrorTypes,
Modules,
} from "@medusajs/framework/utils"
import { StepResponse, createStep } from "@medusajs/framework/workflows-sdk"
/**
* The data to update translations.
*/
export type UpdateTranslationsStepInput =
| {
/**
* The filters to select the translations to update.
*/
selector: FilterableTranslationProps
/**
* The data to update in the translations.
*/
update: UpdateTranslationDTO
}
| {
translations: UpdateTranslationDTO[]
}
export const updateTranslationsStepId = "update-translations"
/**
* This step updates translations matching the specified filters.
*
* @example
* const data = updateTranslationsStep({
* selector: {
* reference_id: "prod_123",
* locale_code: "fr-FR"
* },
* update: {
* translations: { title: "Nouveau titre" }
* }
* })
*/
export const updateTranslationsStep = createStep(
updateTranslationsStepId,
async (data: UpdateTranslationsStepInput, { container }) => {
const service = container.resolve<ITranslationModuleService>(
Modules.TRANSLATION
)
if ("translations" in data) {
if (data.translations.some((t) => !t.id)) {
throw new MedusaError(
MedusaErrorTypes.INVALID_DATA,
"Translation ID is required when doing a batch update of translations"
)
}
if (!data.translations.length) {
return new StepResponse([], [])
}
const prevData = await service.listTranslations({
id: data.translations.map((t) => t.id) as string[],
})
const translations = await service.updateTranslations(data.translations)
return new StepResponse(translations, prevData)
}
const prevData = await service.listTranslations(data.selector, {
select: [
"id",
"reference_id",
"reference",
"locale_code",
"translations",
],
})
if (Object.keys(data.update).length === 0) {
return new StepResponse(prevData, [])
}
const translations = await service.updateTranslations({
selector: data.selector,
data: data.update,
})
return new StepResponse(translations, prevData)
},
async (prevData, { container }) => {
if (!prevData?.length) {
return
}
const service = container.resolve<ITranslationModuleService>(
Modules.TRANSLATION
)
await service.updateTranslations(
prevData.map((t) => ({
id: t.id,
reference_id: t.reference_id,
reference: t.reference,
locale_code: t.locale_code,
translations: t.translations,
}))
)
}
)

View File

@@ -0,0 +1,57 @@
import {
ContainerRegistrationKeys,
MedusaError,
MedusaErrorTypes,
} from "@medusajs/framework/utils"
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
import { CreateTranslationDTO, UpdateTranslationDTO } from "@medusajs/types"
export const validateTranslationsStepId = "validate-translations"
export type ValidateTranslationsStepInput =
| CreateTranslationDTO[]
| CreateTranslationDTO
| UpdateTranslationDTO[]
| UpdateTranslationDTO
// TODO: Do we want to validate anything else here?
export const validateTranslationsStep = createStep(
validateTranslationsStepId,
async (data: ValidateTranslationsStepInput, { container }) => {
const query = container.resolve(ContainerRegistrationKeys.QUERY)
const {
data: [store],
} = await query.graph(
{
entity: "store",
fields: ["supported_locales.*"],
pagination: {
take: 1,
},
},
{
cache: { enable: true },
}
)
const enabledLocales = (store.supported_locales ?? []).map(
(locale) => locale.locale_code
)
const normalizedInput = Array.isArray(data) ? data : [data]
const unsupportedLocales = normalizedInput
.filter((translation) => Boolean(translation.locale_code))
.map((translation) => translation.locale_code)
.filter((locale) => !enabledLocales.includes(locale ?? ""))
if (unsupportedLocales.length) {
throw new MedusaError(
MedusaErrorTypes.INVALID_DATA,
`The following locales are not supported in the store: ${unsupportedLocales.join(
", "
)}`
)
}
return new StepResponse(void 0)
}
)

View File

@@ -0,0 +1,44 @@
import {
createWorkflow,
parallelize,
transform,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import { CreateTranslationDTO, UpdateTranslationDTO } from "@medusajs/types"
import { createTranslationsWorkflow } from "./create-translations"
import { deleteTranslationsWorkflow } from "./delete-translations"
import { updateTranslationsWorkflow } from "./update-translations"
export const batchTranslationsWorkflowId = "batch-translations"
export type BatchTranslationsWorkflowInput = {
create: CreateTranslationDTO[]
update: UpdateTranslationDTO[]
delete: string[]
}
export const batchTranslationsWorkflow = createWorkflow(
batchTranslationsWorkflowId,
(input: BatchTranslationsWorkflowInput) => {
const [created, updated, deleted] = parallelize(
createTranslationsWorkflow.runAsStep({
input: {
translations: input.create,
},
}),
updateTranslationsWorkflow.runAsStep({
input: {
translations: input.update,
},
}),
deleteTranslationsWorkflow.runAsStep({
input: {
ids: input.delete,
},
})
)
return new WorkflowResponse(
transform({ created, updated, deleted }, (result) => result)
)
}
)

View File

@@ -0,0 +1,66 @@
import { CreateTranslationDTO, TranslationDTO } from "@medusajs/framework/types"
import {
WorkflowData,
WorkflowResponse,
createWorkflow,
transform,
} from "@medusajs/framework/workflows-sdk"
import { emitEventStep } from "../../common/steps/emit-event"
import { createTranslationsStep } from "../steps"
import { validateTranslationsStep } from "../steps"
export type CreateTranslationsWorkflowInput = {
translations: CreateTranslationDTO[]
}
export const createTranslationsWorkflowId = "create-translations"
/**
* This workflow creates one or more translations.
*
* You can use this workflow within your own customizations or custom workflows, allowing you
* to create translations in your custom flows.
*
* @example
* const { result } = await createTranslationsWorkflow(container)
* .run({
* input: {
* translations: [
* {
* reference_id: "prod_123",
* reference: "product",
* locale_code: "fr-FR",
* translations: { title: "Produit", description: "Description du produit" }
* }
* ]
* }
* })
*
* @summary
*
* Create one or more translations.
*/
export const createTranslationsWorkflow = createWorkflow(
createTranslationsWorkflowId,
(
input: WorkflowData<CreateTranslationsWorkflowInput>
): WorkflowResponse<TranslationDTO[]> => {
validateTranslationsStep(input.translations)
const translations = createTranslationsStep(input.translations)
const translationIdEvents = transform(
{ translations },
({ translations }) => {
return translations.map((t) => {
return { id: t.id }
})
}
)
emitEventStep({
eventName: "translation.created",
data: translationIdEvents,
})
return new WorkflowResponse(translations)
}
)

View File

@@ -0,0 +1,48 @@
import {
WorkflowData,
createWorkflow,
transform,
} from "@medusajs/framework/workflows-sdk"
import { emitEventStep } from "../../common/steps/emit-event"
import { deleteTranslationsStep } from "../steps"
export type DeleteTranslationsWorkflowInput = { ids: string[] }
export const deleteTranslationsWorkflowId = "delete-translations"
/**
* This workflow deletes one or more translations.
*
* You can use this workflow within your own customizations or custom workflows, allowing you
* to delete translations in your custom flows.
*
* @example
* const { result } = await deleteTranslationsWorkflow(container)
* .run({
* input: {
* ids: ["trans_123"]
* }
* })
*
* @summary
*
* Delete one or more translations.
*/
export const deleteTranslationsWorkflow = createWorkflow(
deleteTranslationsWorkflowId,
(
input: WorkflowData<DeleteTranslationsWorkflowInput>
): WorkflowData<void> => {
deleteTranslationsStep(input.ids)
const translationIdEvents = transform({ input }, ({ input }) => {
return input.ids?.map((id) => {
return { id }
})
})
emitEventStep({
eventName: "translation.deleted",
data: translationIdEvents,
})
}
)

View File

@@ -0,0 +1,4 @@
export * from "./create-translations"
export * from "./delete-translations"
export * from "./update-translations"
export * from "./batch-translations"

View File

@@ -0,0 +1,67 @@
import { TranslationDTO } from "@medusajs/framework/types"
import {
createWorkflow,
transform,
WorkflowData,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk"
import { emitEventStep } from "../../common/steps/emit-event"
import { updateTranslationsStep, UpdateTranslationsStepInput } from "../steps"
import { validateTranslationsStep } from "../steps"
export type UpdateTranslationsWorkflowInput = UpdateTranslationsStepInput
export const updateTranslationsWorkflowId = "update-translations"
/**
* This workflow updates translations matching the specified filters.
*
* You can use this workflow within your own customizations or custom workflows, allowing you
* to update translations in your custom flows.
*
* @example
* const { result } = await updateTranslationsWorkflow(container)
* .run({
* input: {
* selector: {
* reference_id: "prod_123",
* locale_code: "fr-FR"
* },
* update: {
* translations: { title: "Nouveau titre" }
* }
* }
* })
*
* @summary
*
* Update translations.
*/
export const updateTranslationsWorkflow = createWorkflow(
updateTranslationsWorkflowId,
(
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(
{ translations },
({ translations }) => {
return translations?.map((t) => {
return { id: t.id }
})
}
)
emitEventStep({
eventName: "translation.updated",
data: translationIdEvents,
})
return new WorkflowResponse(translations)
}
)