Translate page layout tab title (#17975)

> [!NOTE]
> The improvement will only fully work once all tabs get translated. 

## When using the front-end mocks


https://github.com/user-attachments/assets/cf7dc0fb-9438-4e18-841e-47558ff71474

## When loading page layout information from database


https://github.com/user-attachments/assets/d2c9d98b-97e2-4629-aa6c-53f3f3713733

## When editing dashboard


https://github.com/user-attachments/assets/c6ae3a7a-f05f-48ea-8b33-8f689e3f71f7

Closes
https://github.com/twentyhq/twenty/issues/17950#issuecomment-3902782952
This commit is contained in:
Baptiste Devessier
2026-02-17 16:26:28 +01:00
committed by GitHub
parent e3fcff00b0
commit 963f2de864
34 changed files with 332 additions and 20 deletions
@@ -0,0 +1,17 @@
import { t } from '@lingui/core/macro';
// This file exists solely for Lingui string extraction.
// The strings defined here correspond to standard page layout tab titles
// so they appear in the .po catalogs and can be translated at resolve time
// via generateMessageId hash lookups.
export const getStandardPageLayoutTabTitles = () => [
t`Home`,
t`Timeline`,
t`Tasks`,
t`Notes`,
t`Files`,
t`Emails`,
t`Calendar`,
t`Note`,
t`Flow`,
];
@@ -15,6 +15,9 @@ export class PageLayoutTabDTO {
@IDField(() => UUIDScalarType)
id: string;
@Field(() => UUIDScalarType, { nullable: false })
applicationId: string;
@Field({ nullable: false })
title: string;
@@ -4,14 +4,23 @@ import {
UseInterceptors,
UsePipes,
} from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import {
Args,
Context,
Mutation,
Parent,
Query,
ResolveField,
} from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { type I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.type';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@@ -19,6 +28,7 @@ import { CreatePageLayoutTabInput } from 'src/engine/metadata-modules/page-layou
import { UpdatePageLayoutTabInput } from 'src/engine/metadata-modules/page-layout-tab/dtos/inputs/update-page-layout-tab.input';
import { PageLayoutTabDTO } from 'src/engine/metadata-modules/page-layout-tab/dtos/page-layout-tab.dto';
import { PageLayoutTabService } from 'src/engine/metadata-modules/page-layout-tab/services/page-layout-tab.service';
import { resolvePageLayoutTabTitle } from 'src/engine/metadata-modules/page-layout-tab/utils/resolve-page-layout-tab-title.util';
import { PageLayoutGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/page-layout/utils/page-layout-graphql-api-exception.filter';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
@@ -28,7 +38,24 @@ import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/wor
@UseGuards(WorkspaceAuthGuard)
@UsePipes(ResolverValidationPipe)
export class PageLayoutTabResolver {
constructor(private readonly pageLayoutTabService: PageLayoutTabService) {}
constructor(
private readonly pageLayoutTabService: PageLayoutTabService,
private readonly i18nService: I18nService,
) {}
@ResolveField(() => String)
async title(
@Parent() tab: PageLayoutTabDTO,
@Context() context: I18nContext,
): Promise<string> {
const i18n = this.i18nService.getI18nInstance(context.req.locale);
return resolvePageLayoutTabTitle({
title: tab.title,
applicationId: tab.applicationId,
i18nInstance: i18n,
});
}
@Query(() => [PageLayoutTabDTO])
@UseGuards(NoPermissionGuard)
@@ -0,0 +1,110 @@
import { type I18n } from '@lingui/core';
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
import { resolvePageLayoutTabTitle } from 'src/engine/metadata-modules/page-layout-tab/utils/resolve-page-layout-tab-title.util';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
jest.mock('src/engine/core-modules/i18n/utils/generateMessageId');
const mockGenerateMessageId = generateMessageId as jest.MockedFunction<
typeof generateMessageId
>;
describe('resolvePageLayoutTabTitle', () => {
let mockI18n: jest.Mocked<I18n>;
beforeEach(() => {
jest.clearAllMocks();
mockI18n = {
_: jest.fn(),
} as unknown as jest.Mocked<I18n>;
});
it('should return translated title when catalog has a match', () => {
mockGenerateMessageId.mockReturnValue('abc123');
mockI18n._.mockReturnValue('Accueil');
const result = resolvePageLayoutTabTitle({
title: 'Home',
applicationId: TWENTY_STANDARD_APPLICATION.universalIdentifier,
i18nInstance: mockI18n,
});
expect(mockGenerateMessageId).toHaveBeenCalledWith('Home');
expect(mockI18n._).toHaveBeenCalledWith('abc123');
expect(result).toBe('Accueil');
});
it('should return original title when catalog returns the hash (no translation found)', () => {
mockGenerateMessageId.mockReturnValue('xyz789');
mockI18n._.mockReturnValue('xyz789');
const result = resolvePageLayoutTabTitle({
title: 'My Custom Tab',
applicationId: TWENTY_STANDARD_APPLICATION.universalIdentifier,
i18nInstance: mockI18n,
});
expect(mockGenerateMessageId).toHaveBeenCalledWith('My Custom Tab');
expect(mockI18n._).toHaveBeenCalledWith('xyz789');
expect(result).toBe('My Custom Tab');
});
it('should return original title for empty string', () => {
mockGenerateMessageId.mockReturnValue('empty-hash');
mockI18n._.mockReturnValue('empty-hash');
const result = resolvePageLayoutTabTitle({
title: '',
applicationId: TWENTY_STANDARD_APPLICATION.universalIdentifier,
i18nInstance: mockI18n,
});
expect(result).toBe('');
});
it('should translate standard tab titles', () => {
const standardTabs = [
{ source: 'Home', translated: 'Accueil' },
{ source: 'Timeline', translated: 'Chronologie' },
{ source: 'Tasks', translated: 'Tâches' },
{ source: 'Notes', translated: 'Notes' },
{ source: 'Files', translated: 'Fichiers' },
{ source: 'Emails', translated: 'E-mails' },
{ source: 'Calendar', translated: 'Calendrier' },
{ source: 'Note', translated: 'Note' },
{ source: 'Flow', translated: 'Flux' },
];
standardTabs.forEach(({ source, translated }) => {
jest.clearAllMocks();
mockGenerateMessageId.mockReturnValue(`hash-${source}`);
mockI18n._.mockReturnValue(translated);
const result = resolvePageLayoutTabTitle({
title: source,
applicationId: TWENTY_STANDARD_APPLICATION.universalIdentifier,
i18nInstance: mockI18n,
});
expect(result).toBe(translated);
});
});
it('should not translate title when applicationId is not from standard app', () => {
mockGenerateMessageId.mockReturnValue('abc123');
mockI18n._.mockReturnValue('Accueil');
const customAppId = '11111111-1111-1111-1111-111111111111';
const result = resolvePageLayoutTabTitle({
title: 'Home',
applicationId: customAppId,
i18nInstance: mockI18n,
});
expect(mockGenerateMessageId).not.toHaveBeenCalled();
expect(mockI18n._).not.toHaveBeenCalled();
expect(result).toBe('Home');
});
});
@@ -0,0 +1,27 @@
import { type I18n } from '@lingui/core';
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
export const resolvePageLayoutTabTitle = ({
title,
applicationId,
i18nInstance,
}: {
title: string;
applicationId: string;
i18nInstance: I18n;
}): string => {
if (applicationId !== TWENTY_STANDARD_APPLICATION.universalIdentifier) {
return title;
}
const messageId = generateMessageId(title);
const translatedMessage = i18nInstance._(messageId);
if (translatedMessage === messageId) {
return title;
}
return translatedMessage;
};