Fix i18n issues on datamodel translation (#13710)

As per title, I believe calling i18n is bad because it's a singleton.

This singleton is being set back and force by users in
i18n.middleware.ts

Strategy:
we need to load the translations dynamically! Should be straight
forward:
- singleton (i18nService) containing all translationMessages
- singleton also containing a map of i18n instances loaded for each
locale => I've checked it's not heavy on RAM at all
- some methods to get the translation for a messageId x locale or the
whole i18n instance

==> use it everywhere, this PR only takes care of data model translation
This commit is contained in:
Charles Bochet
2025-08-07 01:28:40 +02:00
committed by GitHub
parent 58d25ebd83
commit fecf8ca913
5 changed files with 79 additions and 14 deletions
@@ -4,6 +4,7 @@ import { i18n } from '@lingui/core';
import { NextFunction, Request, Response } from 'express';
import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
// TODO: this should be deprecated as singleton pattern won't work: user will keep changing locales for eachothers
@Injectable()
export class I18nMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
@@ -1,6 +1,6 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { i18n } from '@lingui/core';
import { I18n, MessageOptions, Messages, i18n, setupI18n } from '@lingui/core';
import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
import { messages as afMessages } from 'src/engine/core-modules/i18n/locales/generated/af-ZA';
@@ -37,9 +37,11 @@ import { messages as zhHantMessages } from 'src/engine/core-modules/i18n/locales
@Injectable()
export class I18nService implements OnModuleInit {
private i18nInstancesMap: Record<keyof typeof APP_LOCALES, I18n> =
{} as Record<keyof typeof APP_LOCALES, I18n>;
async loadTranslations() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const messages: Record<keyof typeof APP_LOCALES, any> = {
const messagesByLocale: Record<keyof typeof APP_LOCALES, Messages> = {
en: enMessages,
'pseudo-en': pseudoEnMessages,
'af-ZA': afMessages,
@@ -73,16 +75,45 @@ export class I18nService implements OnModuleInit {
'zh-TW': zhHantMessages,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(Object.entries(messages) as [keyof typeof APP_LOCALES, any][]).forEach(
([locale, message]) => {
i18n.load(locale, message);
},
);
(
Object.entries(messagesByLocale) as [keyof typeof APP_LOCALES, Messages][]
).forEach(([locale, messages]) => {
const localeI18n = setupI18n();
localeI18n.load(locale, messages);
localeI18n.activate(locale);
this.i18nInstancesMap[locale] = localeI18n;
// TODO: deprecate this line which is legacy as soon as we only use the i18nInstancesMap
// Also deprecate i18n.middleware.ts
i18n.load(locale, messages);
});
// TODO: deprecate this line which is legacy as soon as we only use the i18nInstancesMap
i18n.activate(SOURCE_LOCALE);
}
getI18nInstance(locale: keyof typeof APP_LOCALES) {
return this.i18nInstancesMap[locale];
}
translateMessage({
messageId,
values,
locale = SOURCE_LOCALE,
options,
}: {
messageId: string;
values?: Record<string, string>;
locale?: keyof typeof APP_LOCALES;
options?: MessageOptions;
}) {
const i18n = this.getI18nInstance(locale);
return i18n._(messageId, values, options);
}
async onModuleInit() {
this.loadTranslations();
}
@@ -1,12 +1,12 @@
import { Injectable } from '@nestjs/common';
import DataLoader from 'dataloader';
import { APP_LOCALES } from 'twenty-shared/translations';
import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import { IndexMetadataInterface } from 'src/engine/metadata-modules/index-metadata/interfaces/index-metadata.interface';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
import { filterMorphRelationDuplicateFieldsDTO } from 'src/engine/dataloaders/utils/filter-morph-relation-duplicate-fields.util';
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
@@ -17,6 +17,7 @@ import { fromFieldMetadataEntityToFieldMetadataDto } from 'src/engine/metadata-m
import { resolveFieldMetadataStandardOverride } from 'src/engine/metadata-modules/field-metadata/utils/resolve-field-metadata-standard-override.util';
import { IndexFieldMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-field-metadata.dto';
import { IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
export type RelationMetadataLoaderPayload = {
@@ -69,6 +70,7 @@ export type IndexFieldMetadataLoaderPayload = {
@Injectable()
export class DataloaderService {
constructor(
private readonly i18nService: I18nService,
private readonly fieldMetadataRelationService: FieldMetadataRelationService,
private readonly fieldMetadataMorphRelationService: FieldMetadataMorphRelationService,
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
@@ -190,6 +192,10 @@ export class DataloaderService {
private createFieldMetadataLoader() {
return new DataLoader<FieldMetadataLoaderPayload, FieldMetadataDTO[]>(
async (dataLoaderParams: FieldMetadataLoaderPayload[]) => {
const locale = dataLoaderParams[0].locale;
const i18nInstance = this.i18nService.getI18nInstance(
locale ?? SOURCE_LOCALE,
);
const workspaceId = dataLoaderParams[0].workspaceId;
const objectMetadataIds = dataLoaderParams.map(
(dataLoaderParam) => dataLoaderParam.objectMetadata.id,
@@ -232,6 +238,7 @@ export class DataloaderService {
},
field,
dataLoaderParams[0].locale,
i18nInstance,
),
}),
{},
@@ -31,6 +31,7 @@ describe('resolveFieldMetadataStandardOverride', () => {
fieldMetadata,
'label',
'fr-FR',
mockI18n,
);
expect(result).toBe('Custom Label');
@@ -49,6 +50,7 @@ describe('resolveFieldMetadataStandardOverride', () => {
fieldMetadata,
'description',
undefined,
mockI18n,
);
expect(result).toBe('Custom Description');
@@ -67,6 +69,7 @@ describe('resolveFieldMetadataStandardOverride', () => {
fieldMetadata,
'icon',
SOURCE_LOCALE,
mockI18n,
);
expect(result).toBe('custom-icon');
@@ -89,6 +92,7 @@ describe('resolveFieldMetadataStandardOverride', () => {
fieldMetadata,
'icon',
'fr-FR',
mockI18n,
);
expect(result).toBe('override-icon');
@@ -113,13 +117,19 @@ describe('resolveFieldMetadataStandardOverride', () => {
};
expect(
resolveFieldMetadataStandardOverride(fieldMetadata, 'label', 'fr-FR'),
resolveFieldMetadataStandardOverride(
fieldMetadata,
'label',
'fr-FR',
mockI18n,
),
).toBe('Libellé traduit');
expect(
resolveFieldMetadataStandardOverride(
fieldMetadata,
'description',
'fr-FR',
mockI18n,
),
).toBe('Description traduite');
});
@@ -146,6 +156,7 @@ describe('resolveFieldMetadataStandardOverride', () => {
fieldMetadata,
'label',
'fr-FR',
mockI18n,
);
expect(result).toBe('Standard Label');
@@ -173,6 +184,7 @@ describe('resolveFieldMetadataStandardOverride', () => {
fieldMetadata,
'description',
'fr-FR',
mockI18n,
);
expect(result).toBe('Standard Description');
@@ -200,6 +212,7 @@ describe('resolveFieldMetadataStandardOverride', () => {
fieldMetadata,
'label',
undefined,
mockI18n,
);
expect(result).toBe('Standard Label');
@@ -225,6 +238,7 @@ describe('resolveFieldMetadataStandardOverride', () => {
fieldMetadata,
'label',
SOURCE_LOCALE,
mockI18n,
),
).toBe('Overridden Label');
expect(
@@ -232,6 +246,7 @@ describe('resolveFieldMetadataStandardOverride', () => {
fieldMetadata,
'description',
SOURCE_LOCALE,
mockI18n,
),
).toBe('Overridden Description');
expect(
@@ -239,6 +254,7 @@ describe('resolveFieldMetadataStandardOverride', () => {
fieldMetadata,
'icon',
SOURCE_LOCALE,
mockI18n,
),
).toBe('overridden-icon');
});
@@ -261,6 +277,7 @@ describe('resolveFieldMetadataStandardOverride', () => {
fieldMetadata,
'label',
'fr-FR',
mockI18n,
);
expect(result).toBe('Standard Label');
@@ -284,6 +301,7 @@ describe('resolveFieldMetadataStandardOverride', () => {
fieldMetadata,
'label',
SOURCE_LOCALE,
mockI18n,
);
expect(result).toBe('Standard Label');
@@ -307,6 +325,7 @@ describe('resolveFieldMetadataStandardOverride', () => {
fieldMetadata,
'label',
SOURCE_LOCALE,
mockI18n,
);
expect(result).toBe('Standard Label');
@@ -330,6 +349,7 @@ describe('resolveFieldMetadataStandardOverride', () => {
fieldMetadata,
'label',
'fr-FR',
mockI18n,
);
expect(mockGenerateMessageId).toHaveBeenCalledWith('Standard Label');
@@ -355,6 +375,7 @@ describe('resolveFieldMetadataStandardOverride', () => {
fieldMetadata,
'label',
'fr-FR',
mockI18n,
);
expect(result).toBe('Standard Label');
@@ -382,6 +403,7 @@ describe('resolveFieldMetadataStandardOverride', () => {
fieldMetadata,
'label',
'fr-FR',
mockI18n,
);
expect(result).toBe('Translation Override');
@@ -404,6 +426,7 @@ describe('resolveFieldMetadataStandardOverride', () => {
fieldMetadata,
'label',
SOURCE_LOCALE,
mockI18n,
);
expect(result).toBe('Source Override');
@@ -427,6 +450,7 @@ describe('resolveFieldMetadataStandardOverride', () => {
fieldMetadata,
'label',
'de-DE',
mockI18n,
);
expect(result).toBe('Auto Translated Label');
@@ -1,4 +1,4 @@
import { i18n } from '@lingui/core';
import { I18n } from '@lingui/core';
import { isNonEmptyString } from '@sniptt/guards';
import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
@@ -13,6 +13,7 @@ export const resolveFieldMetadataStandardOverride = (
>,
labelKey: 'label' | 'description' | 'icon',
locale: keyof typeof APP_LOCALES | undefined,
i18nInstance: I18n,
): string => {
if (fieldMetadata.isCustom) {
return fieldMetadata[labelKey] ?? '';
@@ -43,7 +44,8 @@ export const resolveFieldMetadataStandardOverride = (
}
const messageId = generateMessageId(fieldMetadata[labelKey] ?? '');
const translatedMessage = i18n._(messageId);
const translatedMessage = i18nInstance._(messageId);
if (translatedMessage === messageId) {
return fieldMetadata[labelKey] ?? '';