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:
committed by
GitHub
parent
e3fcff00b0
commit
963f2de864
+17
@@ -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`,
|
||||
];
|
||||
+3
@@ -15,6 +15,9 @@ export class PageLayoutTabDTO {
|
||||
@IDField(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@Field(() => UUIDScalarType, { nullable: false })
|
||||
applicationId: string;
|
||||
|
||||
@Field({ nullable: false })
|
||||
title: string;
|
||||
|
||||
|
||||
+30
-3
@@ -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)
|
||||
|
||||
+110
@@ -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');
|
||||
});
|
||||
});
|
||||
+27
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user