diff --git a/packages/twenty-server/src/engine/metadata-modules/minimal-metadata/minimal-metadata.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/minimal-metadata/minimal-metadata.resolver.ts index 5f679fdbd6d..dd679d336f2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/minimal-metadata/minimal-metadata.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/minimal-metadata/minimal-metadata.resolver.ts @@ -1,7 +1,8 @@ import { UseGuards } from '@nestjs/common'; -import { Query } from '@nestjs/graphql'; +import { Context, Query } from '@nestjs/graphql'; import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; +import { type I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.type'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; @@ -22,10 +23,12 @@ export class MinimalMetadataResolver { @AuthWorkspace() workspace: WorkspaceEntity, @AuthUserWorkspaceId({ allowUndefined: true }) userWorkspaceId: string | undefined, + @Context() context: I18nContext, ): Promise { return this.minimalMetadataService.getMinimalMetadata( workspace.id, userWorkspaceId, + context.req.locale, ); } } diff --git a/packages/twenty-server/src/engine/metadata-modules/minimal-metadata/minimal-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/minimal-metadata/minimal-metadata.service.ts index f0ba5e43d46..f1079ad6938 100644 --- a/packages/twenty-server/src/engine/metadata-modules/minimal-metadata/minimal-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/minimal-metadata/minimal-metadata.service.ts @@ -4,15 +4,18 @@ import { ALL_METADATA_NAME, type AllMetadataName, } from 'twenty-shared/metadata'; +import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations'; import { ViewVisibility } from 'twenty-shared/types'; import { isDefined, uncapitalize } from 'twenty-shared/utils'; +import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; import { ALL_FLAT_ENTITY_MAPS_PROPERTIES } from 'src/engine/metadata-modules/flat-entity/constant/all-flat-entity-maps-properties.constant'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { type CollectionHashDTO } from 'src/engine/metadata-modules/minimal-metadata/dtos/collection-hash.dto'; import { MinimalMetadataDTO } from 'src/engine/metadata-modules/minimal-metadata/dtos/minimal-metadata.dto'; import { MinimalObjectMetadataDTO } from 'src/engine/metadata-modules/minimal-metadata/dtos/minimal-object-metadata.dto'; import { MinimalViewDTO } from 'src/engine/metadata-modules/minimal-metadata/dtos/minimal-view.dto'; +import { resolveObjectMetadataStandardOverride } from 'src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; import { type WorkspaceCacheKeyName } from 'src/engine/workspace-cache/types/workspace-cache-key.type'; @@ -33,11 +36,13 @@ export class MinimalMetadataService { constructor( private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, private readonly workspaceCacheService: WorkspaceCacheService, + private readonly i18nService: I18nService, ) {} async getMinimalMetadata( workspaceId: string, userWorkspaceId?: string, + locale?: string, ): Promise { const [{ flatObjectMetadataMaps, flatViewMaps }, cacheHashes] = await Promise.all([ @@ -63,23 +68,48 @@ export class MinimalMetadataService { }) .filter(isDefined); + const safeLocale = (locale as keyof typeof APP_LOCALES) ?? SOURCE_LOCALE; + const i18nInstance = this.i18nService.getI18nInstance(safeLocale); + const objectMetadataItems: MinimalObjectMetadataDTO[] = Object.values( flatObjectMetadataMaps.byUniversalIdentifier, ) .filter(isDefined) .filter((flatObjectMetadata) => flatObjectMetadata.isActive === true) - .map((flatObjectMetadata) => ({ - id: flatObjectMetadata.id, - nameSingular: flatObjectMetadata.nameSingular, - namePlural: flatObjectMetadata.namePlural, - labelSingular: flatObjectMetadata.labelSingular, - labelPlural: flatObjectMetadata.labelPlural, - icon: flatObjectMetadata.icon ?? undefined, - isCustom: flatObjectMetadata.isCustom, - isActive: flatObjectMetadata.isActive, - isSystem: flatObjectMetadata.isSystem, - isRemote: flatObjectMetadata.isRemote, - })); + .map((flatObjectMetadata) => { + const objectMetadataForOverride = { + labelPlural: flatObjectMetadata.labelPlural, + labelSingular: flatObjectMetadata.labelSingular, + description: flatObjectMetadata.description ?? undefined, + icon: flatObjectMetadata.icon ?? undefined, + color: flatObjectMetadata.color ?? undefined, + isCustom: flatObjectMetadata.isCustom, + standardOverrides: flatObjectMetadata.standardOverrides ?? undefined, + }; + + return { + id: flatObjectMetadata.id, + nameSingular: flatObjectMetadata.nameSingular, + namePlural: flatObjectMetadata.namePlural, + labelSingular: resolveObjectMetadataStandardOverride( + objectMetadataForOverride, + 'labelSingular', + safeLocale, + i18nInstance, + ), + labelPlural: resolveObjectMetadataStandardOverride( + objectMetadataForOverride, + 'labelPlural', + safeLocale, + i18nInstance, + ), + icon: flatObjectMetadata.icon ?? undefined, + isCustom: flatObjectMetadata.isCustom, + isActive: flatObjectMetadata.isActive, + isSystem: flatObjectMetadata.isSystem, + isRemote: flatObjectMetadata.isRemote, + }; + }); const views: MinimalViewDTO[] = Object.values( flatViewMaps.byUniversalIdentifier, diff --git a/packages/twenty-server/test/integration/metadata/suites/minimal-metadata/minimal-metadata-i18n.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/minimal-metadata/minimal-metadata-i18n.integration-spec.ts new file mode 100644 index 00000000000..304eb69fde5 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/minimal-metadata/minimal-metadata-i18n.integration-spec.ts @@ -0,0 +1,113 @@ +import request from 'supertest'; + +import { WORKSPACE_MEMBER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant'; + +const client = request(`http://localhost:${APP_PORT}`); + +const minimalMetadataQuery = { + query: ` + query MinimalMetadataI18n { + minimalMetadata { + objectMetadataItems { + nameSingular + labelSingular + labelPlural + isCustom + } + } + } + `, +}; + +type MinimalObjectItem = { + nameSingular: string; + labelSingular: string; + labelPlural: string; + isCustom: boolean; +}; + +const updateWorkspaceMemberLocale = async (locale: string) => { + const response = await client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send({ + query: ` + mutation UpdateWorkspaceMember( + $workspaceMemberId: UUID! + $data: WorkspaceMemberUpdateInput! + ) { + updateWorkspaceMember(id: $workspaceMemberId, data: $data) { + id + locale + } + } + `, + variables: { + workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JANE, + data: { locale }, + }, + }); + + expect(response.body.errors).toBeUndefined(); + expect(response.body.data.updateWorkspaceMember.locale).toBe(locale); +}; + +const queryMinimalMetadata = () => + client + .post('/metadata') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send(minimalMetadataQuery); + +const findObjectByName = ( + items: MinimalObjectItem[], + nameSingular: string, +): MinimalObjectItem | undefined => + items.find((item) => item.nameSingular === nameSingular); + +describe('minimalMetadata i18n', () => { + afterAll(async () => { + await updateWorkspaceMemberLocale('en'); + }); + + it('should return English labels when user locale is en', async () => { + await updateWorkspaceMemberLocale('en'); + + const response = await queryMinimalMetadata(); + + expect(response.body.data).toBeDefined(); + expect(response.body.errors).toBeUndefined(); + + const { objectMetadataItems } = response.body.data.minimalMetadata; + const company = findObjectByName(objectMetadataItems, 'company'); + + expect(company).toBeDefined(); + expect(company!.labelSingular).toBe('Company'); + expect(company!.labelPlural).toBe('Companies'); + }); + + it('should return French labels when user locale is fr-FR', async () => { + await updateWorkspaceMemberLocale('fr-FR'); + + const response = await queryMinimalMetadata(); + + expect(response.body.data).toBeDefined(); + expect(response.body.errors).toBeUndefined(); + + const { objectMetadataItems } = response.body.data.minimalMetadata; + const company = findObjectByName(objectMetadataItems, 'company'); + const person = findObjectByName(objectMetadataItems, 'person'); + const opportunity = findObjectByName(objectMetadataItems, 'opportunity'); + + expect(company).toBeDefined(); + expect(company!.labelSingular).toBe('Entreprise'); + expect(company!.labelPlural).toBe('Entreprises'); + + expect(person).toBeDefined(); + expect(person!.labelSingular).toBe('Personne'); + expect(person!.labelPlural).toBe('Personnes'); + + expect(opportunity).toBeDefined(); + expect(opportunity!.labelSingular).toBe('Opportunité'); + expect(opportunity!.labelPlural).toBe('Opportunités'); + }); +});