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:
@@ -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,
|
||||
),
|
||||
}),
|
||||
{},
|
||||
|
||||
+25
-1
@@ -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');
|
||||
|
||||
+4
-2
@@ -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] ?? '';
|
||||
|
||||
Reference in New Issue
Block a user