diff --git a/packages/twenty-server/src/engine/core-modules/i18n/i18n.middleware.ts b/packages/twenty-server/src/engine/core-modules/i18n/i18n.middleware.ts index 2fd7b54fa35..03e3c174b45 100644 --- a/packages/twenty-server/src/engine/core-modules/i18n/i18n.middleware.ts +++ b/packages/twenty-server/src/engine/core-modules/i18n/i18n.middleware.ts @@ -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) { diff --git a/packages/twenty-server/src/engine/core-modules/i18n/i18n.service.ts b/packages/twenty-server/src/engine/core-modules/i18n/i18n.service.ts index c4e2188820e..0b129a4b12a 100644 --- a/packages/twenty-server/src/engine/core-modules/i18n/i18n.service.ts +++ b/packages/twenty-server/src/engine/core-modules/i18n/i18n.service.ts @@ -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 = + {} as Record; + async loadTranslations() { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const messages: Record = { + const messagesByLocale: Record = { 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; + locale?: keyof typeof APP_LOCALES; + options?: MessageOptions; + }) { + const i18n = this.getI18nInstance(locale); + + return i18n._(messageId, values, options); + } + async onModuleInit() { this.loadTranslations(); } diff --git a/packages/twenty-server/src/engine/dataloaders/dataloader.service.ts b/packages/twenty-server/src/engine/dataloaders/dataloader.service.ts index d63a9f721c0..46d55910fb9 100644 --- a/packages/twenty-server/src/engine/dataloaders/dataloader.service.ts +++ b/packages/twenty-server/src/engine/dataloaders/dataloader.service.ts @@ -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( 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, ), }), {}, diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/resolve-field-metadata-standard-override.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/resolve-field-metadata-standard-override.util.spec.ts index b8540d2d497..0857da96ed4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/resolve-field-metadata-standard-override.util.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/resolve-field-metadata-standard-override.util.spec.ts @@ -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'); diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/resolve-field-metadata-standard-override.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/resolve-field-metadata-standard-override.util.ts index 2878c27aa47..404d750d47b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/resolve-field-metadata-standard-override.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/resolve-field-metadata-standard-override.util.ts @@ -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] ?? '';