feat(): Translation settings + user configuration + admin hook and js sdk + dashboard (#14355)

* feat(): Translation settings + user configuration

* feat(): Translation settings + user configuration

* Create gentle-bees-grow.md

* add entities end point

* add entities end point

* add admin hook and js sdk method

* update changeset

* fix tests

* fix tests

* rm unnecessary copy

* update dashboard to use the new resources

* update dashboard to use the new resources

* update dashboard to use the new resources

* allow type inference through interface augmentation in the defineConfig of medusa-config

* allow type inference through interface augmentation in the defineConfig of medusa-config

* exclude id and _id props

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Adrien de Peretti
2025-12-19 15:29:48 +01:00
committed by GitHub
co-authored by Oli Juhl
parent 797878af26
commit b21a599d11
26 changed files with 1041 additions and 260 deletions
@@ -15,7 +15,11 @@ moduleIntegrationTestRunner<ITranslationModuleService>({
service: TranslationModuleService,
}).linkable
expect(Object.keys(linkable)).toEqual(["locale", "translation"])
expect(Object.keys(linkable)).toEqual([
"locale",
"translation",
"translationSettings",
])
Object.keys(linkable).forEach((key) => {
delete linkable[key].toJSON
@@ -40,6 +44,15 @@ moduleIntegrationTestRunner<ITranslationModuleService>({
field: "translation",
},
},
translationSettings: {
id: {
linkable: "translation_settings_id",
entity: "TranslationSettings",
primaryKey: "id",
serviceName: "translation",
field: "translationSettings",
},
},
})
})
@@ -647,11 +660,83 @@ moduleIntegrationTestRunner<ITranslationModuleService>({
})
})
describe("Settings", () => {
describe("getTranslatableFields", () => {
it("should return all translatable fields from database", async () => {
const fields = await service.getTranslatableFields()
expect(fields).toHaveProperty("product")
expect(fields).toHaveProperty("product_variant")
expect(fields.product).toEqual(
expect.arrayContaining(["title", "description"])
)
})
it("should return translatable fields for a specific entity type", async () => {
const fields = await service.getTranslatableFields("product")
expect(Object.keys(fields)).toEqual(["product"])
expect(fields.product).toEqual(
expect.arrayContaining(["title", "description"])
)
})
it("should return empty object for unknown entity type", async () => {
const fields = await service.getTranslatableFields("unknown_entity")
expect(fields).toEqual({})
})
})
describe("listing translations filters by configured fields", () => {
it("should only return configured fields in translations", async () => {
await service.createTranslations({
reference_id: "prod_filter_1",
reference: "product",
locale_code: "en-US",
translations: {
title: "Product Title",
description: "Product Description",
unconfigured_field: "Should be filtered out",
},
})
const translations = await service.listTranslations({
reference_id: "prod_filter_1",
})
expect(translations).toHaveLength(1)
expect(translations[0].translations).toHaveProperty("title")
expect(translations[0].translations).toHaveProperty("description")
expect(translations[0].translations).not.toHaveProperty(
"unconfigured_field"
)
})
it("should return empty translations for unconfigured entity types", async () => {
await service.createTranslations({
reference_id: "unconfigured_1",
reference: "unconfigured_entity",
locale_code: "en-US",
translations: {
field1: "Value 1",
field2: "Value 2",
},
})
const translations = await service.listTranslations({
reference_id: "unconfigured_1",
})
expect(translations).toHaveLength(1)
expect(translations[0].translations).toEqual({})
})
})
})
describe("Statistics", () => {
describe("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, material, subtitle
await service.createTranslations([
{
reference_id: "prod_stat_1",
@@ -1,7 +1,8 @@
import { defineMikroOrmCliConfig } from "@medusajs/framework/utils"
import Locale from "./src/models/locale"
import Translation from "./src/models/translation"
import Settings from "./src/models/settings"
export default defineMikroOrmCliConfig("translation", {
entities: [Locale, Translation],
entities: [Locale, Translation, Settings],
})
+4 -3
View File
@@ -1,7 +1,8 @@
import TranslationModuleService from "@services/translation-module"
import loadDefaults from "./loaders/defaults"
import loadConfig from "./loaders/config"
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"
@@ -1,11 +1,60 @@
import { LoaderOptions } from "@medusajs/framework/types"
import {
LoaderOptions,
Logger,
ModulesSdkTypes,
} from "@medusajs/framework/types"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
import { TRANSLATABLE_FIELDS_CONFIG_KEY } from "@utils/constants"
import { asValue } from "awilix"
import { translatableFieldsConfig } from "../utils/translatable-fields"
import Settings from "@models/settings"
import type { TranslationModuleOptions } from "../types"
export default async ({ container }: LoaderOptions): Promise<void> => {
container.register(
TRANSLATABLE_FIELDS_CONFIG_KEY,
asValue(translatableFieldsConfig)
)
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))
}
@@ -263,6 +263,104 @@
"checks": [],
"foreignKeys": {},
"nativeEnums": {}
},
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"entity_type": {
"name": "entity_type",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"fields": {
"name": "fields",
"type": "jsonb",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "json"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
}
},
"name": "translation_settings",
"schema": "public",
"indexes": [
{
"keyName": "IDX_translation_settings_deleted_at",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_translation_settings_deleted_at\" ON \"translation_settings\" (\"deleted_at\") WHERE deleted_at IS NULL"
},
{
"keyName": "IDX_translation_settings_entity_type_unique",
"columnNames": [],
"composite": false,
"constraint": false,
"primary": false,
"unique": false,
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_translation_settings_entity_type_unique\" ON \"translation_settings\" (\"entity_type\") WHERE deleted_at IS NULL"
},
{
"keyName": "translation_settings_pkey",
"columnNames": [
"id"
],
"composite": false,
"constraint": true,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {},
"nativeEnums": {}
}
],
"nativeEnums": {}
@@ -0,0 +1,22 @@
import { Migration } from "@medusajs/framework/mikro-orm/migrations"
export class Migration20251218140235 extends Migration {
override async up(): Promise<void> {
this.addSql(
`alter table if exists "translation_settings" drop constraint if exists "translation_settings_entity_type_unique";`
)
this.addSql(
`create table if not exists "translation_settings" ("id" text not null, "entity_type" text not null, "fields" jsonb not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "translation_settings_pkey" primary key ("id"));`
)
this.addSql(
`CREATE INDEX IF NOT EXISTS "IDX_translation_settings_deleted_at" ON "translation_settings" ("deleted_at") WHERE deleted_at IS NULL;`
)
this.addSql(
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_translation_settings_entity_type_unique" ON "translation_settings" ("entity_type") WHERE deleted_at IS NULL;`
)
}
override async down(): Promise<void> {
this.addSql(`drop table if exists "translation_settings" cascade;`)
}
}
@@ -1,2 +1,3 @@
export { default as Translation } from "./translation"
export { default as Locale } from "./locale"
export { default as Locale } from "./locale"
export { default as Settings } from "./settings"
@@ -0,0 +1,26 @@
import { model } from "@medusajs/framework/utils"
const Settings = model
.define("translation_settings", {
id: model.id({ prefix: "trset" }).primaryKey(),
/**
* The entity type that these settings apply to (e.g., "product", "product_variant").
*/
entity_type: model.text().searchable(),
/**
* The translatable fields for this entity type.
* Array of field names that can be translated.
*
* @example
* ["title", "description", "material"]
*/
fields: model.json(),
})
.indexes([
{
on: ["entity_type"],
unique: true,
},
])
export default Settings
@@ -21,13 +21,18 @@ import {
} from "@medusajs/framework/utils"
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 = {
baseRepository: DAL.RepositoryService
translationService: ModulesSdkTypes.IMedusaInternalService<typeof Translation>
localeService: ModulesSdkTypes.IMedusaInternalService<typeof Locale>
translationSettingsService: ModulesSdkTypes.IMedusaInternalService<
typeof Settings
>
[TRANSLATABLE_FIELDS_CONFIG_KEY]: Record<string, string[]>
}
@@ -39,9 +44,13 @@ export default class TranslationModuleService
Translation: {
dto: TranslationTypes.TranslationDTO
}
TranslationSettings: {
dto: TranslationTypes.TranslationSettingsDTO
}
}>({
Locale,
Translation,
TranslationSettings: Settings,
})
implements ITranslationModuleService
{
@@ -52,20 +61,38 @@ export default class TranslationModuleService
protected localeService_: ModulesSdkTypes.IMedusaInternalService<
typeof Locale
>
private readonly translatableFieldsConfig_: Record<string, string[]>
protected settingsService_: ModulesSdkTypes.IMedusaInternalService<
typeof Settings
>
constructor({
baseRepository,
translationService,
localeService,
translatableFieldsConfig,
translationSettingsService,
}: InjectedDependencies) {
super(...arguments)
this.baseRepository_ = baseRepository
this.translationService_ = translationService
this.localeService_ = localeService
this.translatableFieldsConfig_ = translatableFieldsConfig
this.settingsService_ = translationSettingsService
}
@InjectManager()
async getTranslatableFields(
entityType?: string,
@MedusaContext() sharedContext: Context = {}
): Promise<Record<string, string[]>> {
const filters = entityType ? { entity_type: entityType } : {}
const settings = await this.settingsService_.list(
filters,
{},
sharedContext
)
return settings.reduce((acc, setting) => {
acc[setting.entity_type] = setting.fields as unknown as string[]
return acc
}, {} as Record<string, string[]>)
}
static prepareFilters(
@@ -83,6 +110,54 @@ export default class TranslationModuleService
return restFilters
}
@InjectManager()
// @ts-expect-error
async retrieveTranslation(
id: string,
config: FindConfig<TranslationTypes.TranslationDTO> = {},
@MedusaContext() sharedContext: Context = {}
): Promise<TranslationTypes.TranslationDTO> {
const configWithReference =
TranslationModuleService.ensureReferenceFieldInConfig(config)
const result = await this.translationService_.retrieve(
id,
configWithReference,
sharedContext
)
const serialized =
await this.baseRepository_.serialize<TranslationTypes.TranslationDTO>(
result
)
const translatableFieldsConfig = await this.getTranslatableFields(
undefined,
sharedContext
)
return filterTranslationFields([serialized], translatableFieldsConfig)[0]
}
/**
* Ensures the 'reference' field is included in the select config.
* This is needed for filtering translations by translatable fields.
*/
static ensureReferenceFieldInConfig(
config: FindConfig<TranslationTypes.TranslationDTO>
): FindConfig<TranslationTypes.TranslationDTO> {
if (!config?.select?.length) {
return config
}
const select = config.select as string[]
if (!select.includes("reference")) {
return { ...config, select: [...select, "reference"] }
}
return config
}
@InjectManager()
// @ts-expect-error
async listTranslations(
@@ -91,16 +166,25 @@ export default class TranslationModuleService
@MedusaContext() sharedContext: Context = {}
): Promise<TranslationTypes.TranslationDTO[]> {
const preparedFilters = TranslationModuleService.prepareFilters(filters)
const configWithReference =
TranslationModuleService.ensureReferenceFieldInConfig(config)
const results = await this.translationService_.list(
preparedFilters,
config,
configWithReference,
sharedContext
)
return await this.baseRepository_.serialize<
const serialized = await this.baseRepository_.serialize<
TranslationTypes.TranslationDTO[]
>(results)
const translatableFieldsConfig = await this.getTranslatableFields(
undefined,
sharedContext
)
return filterTranslationFields(serialized, translatableFieldsConfig)
}
@InjectManager()
@@ -111,17 +195,26 @@ export default class TranslationModuleService
@MedusaContext() sharedContext: Context = {}
): Promise<[TranslationTypes.TranslationDTO[], number]> {
const preparedFilters = TranslationModuleService.prepareFilters(filters)
const configWithReference =
TranslationModuleService.ensureReferenceFieldInConfig(config)
const [results, count] = await this.translationService_.listAndCount(
preparedFilters,
config,
configWithReference,
sharedContext
)
const serialized = await this.baseRepository_.serialize<
TranslationTypes.TranslationDTO[]
>(results)
const translatableFieldsConfig = await this.getTranslatableFields(
undefined,
sharedContext
)
return [
await this.baseRepository_.serialize<TranslationTypes.TranslationDTO[]>(
results
),
filterTranslationFields(serialized, translatableFieldsConfig),
count,
]
}
@@ -183,12 +276,16 @@ export default class TranslationModuleService
TranslationTypes.TranslationDTO | TranslationTypes.TranslationDTO[]
> {
const dataArray = Array.isArray(data) ? data : [data]
const translatableFieldsConfig = await this.getTranslatableFields(
undefined,
sharedContext
)
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]
translatableFieldsConfig[translation.reference]
),
}))
@@ -248,6 +345,11 @@ export default class TranslationModuleService
)
}
const translatableFieldsConfig = await this.getTranslatableFields(
undefined,
sharedContext
)
for (const update of dataArray) {
if (update.translations) {
const reference = update.reference || referenceMap[update.id]
@@ -257,7 +359,7 @@ export default class TranslationModuleService
}
).translated_field_count = computeTranslatedFieldCount(
update.translations as Record<string, unknown>,
this.translatableFieldsConfig_[reference] || []
translatableFieldsConfig[reference] || []
)
}
}
@@ -275,13 +377,6 @@ export default class TranslationModuleService
return Array.isArray(data) ? serialized : serialized[0]
}
getTranslatableFields(entityType?: string): Record<string, string[]> {
if (entityType) {
return { [entityType]: this.translatableFieldsConfig_[entityType] }
}
return this.translatableFieldsConfig_
}
@InjectManager()
async getStatistics(
input: TranslationTypes.TranslationStatisticsInput,
@@ -309,11 +404,16 @@ export default class TranslationModuleService
sharedContext.manager) as SqlEntityManager
const knex = manager.getKnex()
const translatableFieldsConfig = await this.getTranslatableFields(
undefined,
sharedContext
)
const result: TranslationTypes.TranslationStatisticsOutput = {}
const entityTypes: string[] = []
for (const entityType of Object.keys(entities)) {
const translatableFields = this.translatableFieldsConfig_[entityType]
const translatableFields = translatableFieldsConfig[entityType]
if (!translatableFields || translatableFields.length === 0) {
result[entityType] = {
@@ -352,7 +452,7 @@ export default class TranslationModuleService
)
for (const entityType of entityTypes) {
const translatableFields = this.translatableFieldsConfig_[entityType]
const translatableFields = translatableFieldsConfig[entityType]
const fieldsPerEntity = translatableFields.length
const entityCount = entities[entityType].count
const expectedPerLocale = entityCount * fieldsPerEntity
@@ -0,0 +1,50 @@
import { RemoteQueryEntryPoints } from "@medusajs/framework/types"
/**
* Extracts only the keys of T where the value is a string (or nullable string), the key
* is not __typename or id.
* This filters out relations and other non-string fields.
*/
type StringValuedKeys<T> = {
[K in keyof T]: K extends `${string}_id`
? never
: "__typename" extends K
? never
: "id" extends keyof K
? never
: NonNullable<T[K]> extends string
? K
: never
}[keyof T]
/**
* A discriminated union of all possible entity configurations.
* When you specify a `type`, TypeScript will narrow `fields` to only
* the string-valued keys of that specific entity type.
*/
export type TranslatableEntityConfig =
| {
[K in keyof RemoteQueryEntryPoints]: {
type: K
fields: StringValuedKeys<RemoteQueryEntryPoints[K]>[]
}
}[keyof RemoteQueryEntryPoints]
| {
type: string
fields: string[]
}
/**
* Options for configuring the translation module.
*/
export type TranslationModuleOptions = {
entities?: TranslatableEntityConfig[]
}
// Augment the global ModuleOptions registry
declare module "@medusajs/types" {
interface ModuleOptions {
"@medusajs/translation": TranslationModuleOptions
"@medusajs/medusa/translation": TranslationModuleOptions
}
}
@@ -0,0 +1,29 @@
import { TranslationTypes } from "@medusajs/framework/types"
export function filterTranslationFields(
translations: TranslationTypes.TranslationDTO[],
translatableFieldsConfig: Record<string, string[]>
): TranslationTypes.TranslationDTO[] {
return translations.map((translation) => {
const allowedFields = translatableFieldsConfig[translation.reference]
if (!allowedFields?.length) {
translation.translations = {}
return translation
}
const filteredTranslations: Record<string, unknown> = {}
for (const field of allowedFields) {
if (
translation.translations &&
field in (translation.translations as Record<string, unknown>)
) {
filteredTranslations[field] = (
translation.translations as Record<string, unknown>
)[field]
}
}
translation.translations = filteredTranslations
return translation
})
}