From 6d3ff4c9ce1c12669cc4e81f8ea14efef6ed9e22 Mon Sep 17 00:00:00 2001 From: Weiko Date: Mon, 20 Apr 2026 19:29:33 +0200 Subject: [PATCH] Translate standard page layouts (#19890) ## Context Standard page layout tabs, page layout widgets, and view field group titles were hardcoded English in the backend. This PR brings them under the same translation pipeline as views. Notes: Once a standard widget/tab/section title is overriden, the backend returns its value without translation --- .../components/PageLayoutTabList.tsx | 12 +- ...PageLayoutTabListNewTabDropdownContent.tsx | 17 +- .../StandardPageLayoutTabTitleTranslations.ts | 22 --- .../standard-page-layout-tab-titles.ts | 21 +-- .../resolvers/page-layout-tab.resolver.ts | 10 ++ ...resolve-page-layout-tab-title.util.spec.ts | 50 +++++- ...-layout-tab-to-page-layout-tab-dto.util.ts | 1 + .../resolve-page-layout-tab-title.util.ts | 14 +- .../standard-page-layout-widget-titles.ts | 33 ++++ .../resolvers/page-layout-widget.resolver.ts | 37 +++- ...olve-page-layout-widget-title.util.spec.ts | 159 ++++++++++++++++++ ...t-widget-to-page-layout-widget-dto.util.ts | 1 + .../resolve-page-layout-widget-title.util.ts | 37 ++++ .../standard-view-field-group-names.ts | 12 ++ .../dtos/view-field-group.dto.ts | 3 + .../resolvers/view-field-group.resolver.ts | 28 +++ ...resolve-view-field-group-name.util.spec.ts | 149 ++++++++++++++++ ...ield-group-to-view-field-group-dto.util.ts | 1 + .../resolve-view-field-group-name.util.ts | 37 ++++ 19 files changed, 577 insertions(+), 67 deletions(-) delete mode 100644 packages/twenty-front/src/modules/page-layout/constants/StandardPageLayoutTabTitleTranslations.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/page-layout-widget/constants/standard-page-layout-widget-titles.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/__tests__/resolve-page-layout-widget-title.util.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/resolve-page-layout-widget-title.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/view-field-group/constants/standard-view-field-group-names.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/view-field-group/utils/__tests__/resolve-view-field-group-name.util.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/view-field-group/utils/resolve-view-field-group-name.util.ts diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabList.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabList.tsx index a94f5eb935..0f96733c0f 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabList.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabList.tsx @@ -32,8 +32,6 @@ import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/Pag import { PageLayoutTabListNewTabDropdownContent } from '@/page-layout/components/PageLayoutTabListNewTabDropdownContent'; import { PageLayoutTabListReorderableOverflowDropdown } from '@/page-layout/components/PageLayoutTabListReorderableOverflowDropdown'; import { PageLayoutTabListVisibleTabs } from '@/page-layout/components/PageLayoutTabListVisibleTabs'; -import { STANDARD_PAGE_LAYOUT_TAB_TITLE_TRANSLATIONS } from '@/page-layout/constants/StandardPageLayoutTabTitleTranslations'; -import { useIsCurrentObjectCustom } from '@/page-layout/hooks/useIsCurrentObjectCustom'; import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode'; import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; import { pageLayoutTabListCurrentDragDroppableIdComponentState } from '@/page-layout/states/pageLayoutTabListCurrentDragDroppableIdComponentState'; @@ -113,9 +111,6 @@ export const PageLayoutTabList = ({ }: PageLayoutTabListProps) => { const { getIcon } = useIcons(); const { t } = useLingui(); - const { isCustom } = useIsCurrentObjectCustom(); - - const shouldTranslateTabTitles = !isCustom; const isRecordPageGlobalEditionEnabled = useIsFeatureEnabled( FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED, @@ -123,12 +118,7 @@ export const PageLayoutTabList = ({ const tabsWithIcons: SingleTabProps[] = tabs.map((tab) => ({ id: tab.id, - // TODO: drop once the configuration of all record page layouts has been migrated to the backend. - title: - shouldTranslateTabTitles && - isDefined(STANDARD_PAGE_LAYOUT_TAB_TITLE_TRANSLATIONS[tab.title]) - ? t(STANDARD_PAGE_LAYOUT_TAB_TITLE_TRANSLATIONS[tab.title]) - : tab.title, + title: tab.title, Icon: isDefined(tab.icon) ? getIcon(tab.icon) : undefined, })); diff --git a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListNewTabDropdownContent.tsx b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListNewTabDropdownContent.tsx index f9cbb8f9f2..402a61bd9a 100644 --- a/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListNewTabDropdownContent.tsx +++ b/packages/twenty-front/src/modules/page-layout/components/PageLayoutTabListNewTabDropdownContent.tsx @@ -1,6 +1,4 @@ -import { STANDARD_PAGE_LAYOUT_TAB_TITLE_TRANSLATIONS } from '@/page-layout/constants/StandardPageLayoutTabTitleTranslations'; import { useCurrentPageLayoutOrThrow } from '@/page-layout/hooks/useCurrentPageLayoutOrThrow'; -import { useIsCurrentObjectCustom } from '@/page-layout/hooks/useIsCurrentObjectCustom'; import { useUpdatePageLayoutTab } from '@/page-layout/hooks/useUpdatePageLayoutTab'; import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState'; import { isReactivatableTab } from '@/page-layout/utils/isReactivatableTab'; @@ -33,9 +31,6 @@ export const PageLayoutTabListNewTabDropdownContent = ({ const { t } = useLingui(); const { getIcon } = useIcons(); const { closeDropdown } = useCloseDropdown(); - const { isCustom } = useIsCurrentObjectCustom(); - - const shouldTranslateTabTitles = !isCustom; const { currentPageLayout } = useCurrentPageLayoutOrThrow(); const { updatePageLayoutTab } = useUpdatePageLayoutTab(); @@ -77,16 +72,6 @@ export const PageLayoutTabListNewTabDropdownContent = ({ ], ); - const getTabTitle = (title: string) => { - if ( - shouldTranslateTabTitles && - isDefined(STANDARD_PAGE_LAYOUT_TAB_TITLE_TRANSLATIONS[title]) - ) { - return t(STANDARD_PAGE_LAYOUT_TAB_TITLE_TRANSLATIONS[title]); - } - return title; - }; - return ( {t`New tab`} @@ -106,7 +91,7 @@ export const PageLayoutTabListNewTabDropdownContent = ({ handleReactivateTab(tab.id)} /> ))} diff --git a/packages/twenty-front/src/modules/page-layout/constants/StandardPageLayoutTabTitleTranslations.ts b/packages/twenty-front/src/modules/page-layout/constants/StandardPageLayoutTabTitleTranslations.ts deleted file mode 100644 index 0bd87c6dd2..0000000000 --- a/packages/twenty-front/src/modules/page-layout/constants/StandardPageLayoutTabTitleTranslations.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { msg, type MacroMessageDescriptor } from '@lingui/core/macro'; - -// Translation map for standard page layout tab titles. -// When the backend hasn't translated the tab title (e.g. when using -// frontend fallback layouts), this map is used to translate known -// standard tab titles at the rendering point. -// Custom/user-created tab titles that are not in this map will be -// displayed as-is. -export const STANDARD_PAGE_LAYOUT_TAB_TITLE_TRANSLATIONS: Record< - string, - MacroMessageDescriptor -> = { - Home: msg`Home`, - Timeline: msg`Timeline`, - Tasks: msg`Tasks`, - Notes: msg`Notes`, - Files: msg`Files`, - Emails: msg`Emails`, - Calendar: msg`Calendar`, - Note: msg`Note`, - Flow: msg`Flow`, -}; diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/constants/standard-page-layout-tab-titles.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/constants/standard-page-layout-tab-titles.ts index b6943caedb..048c5688d4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/constants/standard-page-layout-tab-titles.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/constants/standard-page-layout-tab-titles.ts @@ -1,17 +1,18 @@ -import { t } from '@lingui/core/macro'; +import { msg } 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`, + msg`Home`, + msg`Timeline`, + msg`Tasks`, + msg`Notes`, + msg`Files`, + msg`Emails`, + msg`Calendar`, + msg`Note`, + msg`Flow`, + msg`Tab 1`, ]; diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/resolvers/page-layout-tab.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/resolvers/page-layout-tab.resolver.ts index c7381058cc..d91ce2310f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/resolvers/page-layout-tab.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/resolvers/page-layout-tab.resolver.ts @@ -16,6 +16,7 @@ import { import { PermissionFlagType } from 'twenty-shared/constants'; import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; 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'; @@ -41,18 +42,27 @@ export class PageLayoutTabResolver { constructor( private readonly pageLayoutTabService: PageLayoutTabService, private readonly i18nService: I18nService, + private readonly applicationService: ApplicationService, ) {} @ResolveField(() => String) async title( @Parent() tab: PageLayoutTabDTO, @Context() context: I18nContext, + @AuthWorkspace() workspace: WorkspaceEntity, ): Promise { const i18n = this.i18nService.getI18nInstance(context.req.locale); + const { twentyStandardFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspace }, + ); + return resolvePageLayoutTabTitle({ title: tab.title, applicationId: tab.applicationId, + twentyStandardApplicationId: twentyStandardFlatApplication.id, + overrides: tab.overrides, i18nInstance: i18n, }); } diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/utils/__tests__/resolve-page-layout-tab-title.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/utils/__tests__/resolve-page-layout-tab-title.util.spec.ts index 7406a3f4d9..9dd1b243b3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/utils/__tests__/resolve-page-layout-tab-title.util.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/utils/__tests__/resolve-page-layout-tab-title.util.spec.ts @@ -2,7 +2,6 @@ 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'); @@ -10,6 +9,8 @@ const mockGenerateMessageId = generateMessageId as jest.MockedFunction< typeof generateMessageId >; +const STANDARD_APPLICATION_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + describe('resolvePageLayoutTabTitle', () => { let mockI18n: jest.Mocked; @@ -26,7 +27,8 @@ describe('resolvePageLayoutTabTitle', () => { const result = resolvePageLayoutTabTitle({ title: 'Home', - applicationId: TWENTY_STANDARD_APPLICATION.universalIdentifier, + applicationId: STANDARD_APPLICATION_ID, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, i18nInstance: mockI18n, }); @@ -41,7 +43,8 @@ describe('resolvePageLayoutTabTitle', () => { const result = resolvePageLayoutTabTitle({ title: 'My Custom Tab', - applicationId: TWENTY_STANDARD_APPLICATION.universalIdentifier, + applicationId: STANDARD_APPLICATION_ID, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, i18nInstance: mockI18n, }); @@ -56,7 +59,8 @@ describe('resolvePageLayoutTabTitle', () => { const result = resolvePageLayoutTabTitle({ title: '', - applicationId: TWENTY_STANDARD_APPLICATION.universalIdentifier, + applicationId: STANDARD_APPLICATION_ID, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, i18nInstance: mockI18n, }); @@ -83,7 +87,8 @@ describe('resolvePageLayoutTabTitle', () => { const result = resolvePageLayoutTabTitle({ title: source, - applicationId: TWENTY_STANDARD_APPLICATION.universalIdentifier, + applicationId: STANDARD_APPLICATION_ID, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, i18nInstance: mockI18n, }); @@ -100,6 +105,7 @@ describe('resolvePageLayoutTabTitle', () => { const result = resolvePageLayoutTabTitle({ title: 'Home', applicationId: customAppId, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, i18nInstance: mockI18n, }); @@ -107,4 +113,38 @@ describe('resolvePageLayoutTabTitle', () => { expect(mockI18n._).not.toHaveBeenCalled(); expect(result).toBe('Home'); }); + + it('should not translate title when overrides.title is defined', () => { + mockGenerateMessageId.mockReturnValue('abc123'); + mockI18n._.mockReturnValue('Accueil'); + + const result = resolvePageLayoutTabTitle({ + title: 'Home', + applicationId: STANDARD_APPLICATION_ID, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, + overrides: { title: 'Home' }, + i18nInstance: mockI18n, + }); + + expect(mockGenerateMessageId).not.toHaveBeenCalled(); + expect(mockI18n._).not.toHaveBeenCalled(); + expect(result).toBe('Home'); + }); + + it('should translate title when overrides is defined but overrides.title is not', () => { + mockGenerateMessageId.mockReturnValue('abc123'); + mockI18n._.mockReturnValue('Accueil'); + + const result = resolvePageLayoutTabTitle({ + title: 'Home', + applicationId: STANDARD_APPLICATION_ID, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, + overrides: { icon: 'IconCustom' }, + i18nInstance: mockI18n, + }); + + expect(mockGenerateMessageId).toHaveBeenCalledWith('Home'); + expect(mockI18n._).toHaveBeenCalledWith('abc123'); + expect(result).toBe('Accueil'); + }); }); diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/utils/from-flat-page-layout-tab-to-page-layout-tab-dto.util.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/utils/from-flat-page-layout-tab-to-page-layout-tab-dto.util.ts index deaa500e79..b3206bd7c6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/utils/from-flat-page-layout-tab-to-page-layout-tab-dto.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/utils/from-flat-page-layout-tab-to-page-layout-tab-dto.util.ts @@ -16,6 +16,7 @@ export const fromFlatPageLayoutTabToPageLayoutTabDto = ( return { ...rest, ...(overrides ?? {}), + overrides, isOverridden: false, createdAt: new Date(createdAt), updatedAt: new Date(updatedAt), diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/utils/resolve-page-layout-tab-title.util.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/utils/resolve-page-layout-tab-title.util.ts index 61f2ed2d2f..3cb698dcc3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/utils/resolve-page-layout-tab-title.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/utils/resolve-page-layout-tab-title.util.ts @@ -1,18 +1,28 @@ import { type I18n } from '@lingui/core'; +import { isDefined } from 'twenty-shared/utils'; + 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'; +import { type PageLayoutTabOverrides } from 'src/engine/metadata-modules/page-layout-tab/entities/page-layout-tab.entity'; export const resolvePageLayoutTabTitle = ({ title, applicationId, + twentyStandardApplicationId, + overrides, i18nInstance, }: { title: string; applicationId: string; + twentyStandardApplicationId: string; + overrides?: PageLayoutTabOverrides | null; i18nInstance: I18n; }): string => { - if (applicationId !== TWENTY_STANDARD_APPLICATION.universalIdentifier) { + if (applicationId !== twentyStandardApplicationId) { + return title; + } + + if (isDefined(overrides?.title)) { return title; } diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/constants/standard-page-layout-widget-titles.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/constants/standard-page-layout-widget-titles.ts new file mode 100644 index 0000000000..6c84043211 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/constants/standard-page-layout-widget-titles.ts @@ -0,0 +1,33 @@ +import { msg } from '@lingui/core/macro'; + +// This file exists solely for Lingui string extraction. +// The strings defined here correspond to standard page layout widget titles +// so they appear in the .po catalogs and can be translated at resolve time +// via generateMessageId hash lookups. +export const getStandardPageLayoutWidgetTitles = () => [ + msg`Fields`, + msg`Timeline`, + msg`Tasks`, + msg`Notes`, + msg`Files`, + msg`Emails`, + msg`Calendar`, + msg`Note`, + msg`Task`, + msg`Flow`, + msg`Thread`, + msg`People`, + msg`Opportunities`, + msg`Company`, + msg`Point of Contact`, + msg`Owner`, + msg`Workflow`, + msg`Untitled Rich Text`, + msg`Deals by Company`, + msg`Pipeline Value by Stage`, + msg`Revenue Timeline`, + msg`Opportunities by Owner`, + msg`Stock market (Iframe)`, + msg`Deals created this month`, + msg`Deal value created this month`, +]; diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/resolvers/page-layout-widget.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/resolvers/page-layout-widget.resolver.ts index b7ef7421bc..b7c5a9e6c6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/resolvers/page-layout-widget.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/resolvers/page-layout-widget.resolver.ts @@ -4,11 +4,21 @@ import { UseInterceptors, UsePipes, } from '@nestjs/common'; -import { Args, Mutation, Parent, Query, ResolveField } from '@nestjs/graphql'; +import { + Args, + Context, + Mutation, + Parent, + Query, + ResolveField, +} from '@nestjs/graphql'; import { PermissionFlagType } from 'twenty-shared/constants'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; 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'; @@ -20,6 +30,7 @@ import { UpdatePageLayoutWidgetInput } from 'src/engine/metadata-modules/page-la import { PageLayoutWidgetDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/page-layout-widget.dto'; import { WidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/dtos/widget-configuration.interface'; import { PageLayoutWidgetService } from 'src/engine/metadata-modules/page-layout-widget/services/page-layout-widget.service'; +import { resolvePageLayoutWidgetTitle } from 'src/engine/metadata-modules/page-layout-widget/utils/resolve-page-layout-widget-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'; @@ -31,8 +42,32 @@ import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/wor export class PageLayoutWidgetResolver { constructor( private readonly pageLayoutWidgetService: PageLayoutWidgetService, + private readonly i18nService: I18nService, + private readonly applicationService: ApplicationService, ) {} + @ResolveField(() => String) + async title( + @Parent() widget: PageLayoutWidgetDTO, + @Context() context: I18nContext, + @AuthWorkspace() workspace: WorkspaceEntity, + ): Promise { + const i18n = this.i18nService.getI18nInstance(context.req.locale); + + const { twentyStandardFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspace }, + ); + + return resolvePageLayoutWidgetTitle({ + title: widget.title, + applicationId: widget.applicationId, + twentyStandardApplicationId: twentyStandardFlatApplication.id, + overrides: widget.overrides, + i18nInstance: i18n, + }); + } + @Query(() => [PageLayoutWidgetDTO]) @UseGuards(NoPermissionGuard) async getPageLayoutWidgets( diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/__tests__/resolve-page-layout-widget-title.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/__tests__/resolve-page-layout-widget-title.util.spec.ts new file mode 100644 index 0000000000..18a781b437 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/__tests__/resolve-page-layout-widget-title.util.spec.ts @@ -0,0 +1,159 @@ +import { type I18n } from '@lingui/core'; + +import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId'; +import { resolvePageLayoutWidgetTitle } from 'src/engine/metadata-modules/page-layout-widget/utils/resolve-page-layout-widget-title.util'; + +jest.mock('src/engine/core-modules/i18n/utils/generateMessageId'); + +const mockGenerateMessageId = generateMessageId as jest.MockedFunction< + typeof generateMessageId +>; + +const STANDARD_APPLICATION_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + +describe('resolvePageLayoutWidgetTitle', () => { + let mockI18n: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + mockI18n = { + _: jest.fn(), + } as unknown as jest.Mocked; + }); + + it('should return translated title when catalog has a match', () => { + mockGenerateMessageId.mockReturnValue('abc123'); + mockI18n._.mockReturnValue('Champs'); + + const result = resolvePageLayoutWidgetTitle({ + title: 'Fields', + applicationId: STANDARD_APPLICATION_ID, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, + i18nInstance: mockI18n, + }); + + expect(mockGenerateMessageId).toHaveBeenCalledWith('Fields'); + expect(mockI18n._).toHaveBeenCalledWith('abc123'); + expect(result).toBe('Champs'); + }); + + it('should return original title when catalog returns the hash (no translation found)', () => { + mockGenerateMessageId.mockReturnValue('xyz789'); + mockI18n._.mockReturnValue('xyz789'); + + const result = resolvePageLayoutWidgetTitle({ + title: 'My Custom Widget', + applicationId: STANDARD_APPLICATION_ID, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, + i18nInstance: mockI18n, + }); + + expect(mockGenerateMessageId).toHaveBeenCalledWith('My Custom Widget'); + expect(mockI18n._).toHaveBeenCalledWith('xyz789'); + expect(result).toBe('My Custom Widget'); + }); + + it('should return original title for empty string', () => { + mockGenerateMessageId.mockReturnValue('empty-hash'); + mockI18n._.mockReturnValue('empty-hash'); + + const result = resolvePageLayoutWidgetTitle({ + title: '', + applicationId: STANDARD_APPLICATION_ID, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, + i18nInstance: mockI18n, + }); + + expect(result).toBe(''); + }); + + it('should translate standard widget titles', () => { + const standardWidgets = [ + { source: 'Fields', translated: 'Champs' }, + { 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: 'Task', translated: 'Tâche' }, + { source: 'Flow', translated: 'Flux' }, + { source: 'Thread', translated: 'Fil' }, + { source: 'People', translated: 'Personnes' }, + { source: 'Opportunities', translated: 'Opportunités' }, + { source: 'Company', translated: 'Entreprise' }, + { source: 'Point of Contact', translated: 'Point de contact' }, + { source: 'Owner', translated: 'Propriétaire' }, + { source: 'Workflow', translated: 'Flux de travail' }, + { source: 'Deals by Company', translated: 'Affaires par entreprise' }, + ]; + + standardWidgets.forEach(({ source, translated }) => { + jest.clearAllMocks(); + mockGenerateMessageId.mockReturnValue(`hash-${source}`); + mockI18n._.mockReturnValue(translated); + + const result = resolvePageLayoutWidgetTitle({ + title: source, + applicationId: STANDARD_APPLICATION_ID, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, + i18nInstance: mockI18n, + }); + + expect(result).toBe(translated); + }); + }); + + it('should not translate title when applicationId is not from standard app', () => { + mockGenerateMessageId.mockReturnValue('abc123'); + mockI18n._.mockReturnValue('Champs'); + + const customAppId = '11111111-1111-1111-1111-111111111111'; + + const result = resolvePageLayoutWidgetTitle({ + title: 'Fields', + applicationId: customAppId, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, + i18nInstance: mockI18n, + }); + + expect(mockGenerateMessageId).not.toHaveBeenCalled(); + expect(mockI18n._).not.toHaveBeenCalled(); + expect(result).toBe('Fields'); + }); + + it('should not translate title when overrides.title is defined', () => { + mockGenerateMessageId.mockReturnValue('abc123'); + mockI18n._.mockReturnValue('Champs'); + + const result = resolvePageLayoutWidgetTitle({ + title: 'Fields', + applicationId: STANDARD_APPLICATION_ID, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, + overrides: { title: 'Fields' }, + i18nInstance: mockI18n, + }); + + expect(mockGenerateMessageId).not.toHaveBeenCalled(); + expect(mockI18n._).not.toHaveBeenCalled(); + expect(result).toBe('Fields'); + }); + + it('should translate title when overrides is defined but overrides.title is not', () => { + mockGenerateMessageId.mockReturnValue('abc123'); + mockI18n._.mockReturnValue('Champs'); + + const result = resolvePageLayoutWidgetTitle({ + title: 'Fields', + applicationId: STANDARD_APPLICATION_ID, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, + overrides: { conditionalAvailabilityExpression: 'device == "MOBILE"' }, + i18nInstance: mockI18n, + }); + + expect(mockGenerateMessageId).toHaveBeenCalledWith('Fields'); + expect(mockI18n._).toHaveBeenCalledWith('abc123'); + expect(result).toBe('Champs'); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/from-flat-page-layout-widget-to-page-layout-widget-dto.util.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/from-flat-page-layout-widget-to-page-layout-widget-dto.util.ts index c4ab26116d..5f17a853fd 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/from-flat-page-layout-widget-to-page-layout-widget-dto.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/from-flat-page-layout-widget-to-page-layout-widget-dto.util.ts @@ -16,6 +16,7 @@ export const fromFlatPageLayoutWidgetToPageLayoutWidgetDto = ( return { ...rest, ...(overrides ?? {}), + overrides, isOverridden: false, objectMetadataId: objectMetadataId ?? undefined, createdAt: new Date(createdAt), diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/resolve-page-layout-widget-title.util.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/resolve-page-layout-widget-title.util.ts new file mode 100644 index 0000000000..01148028c2 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/resolve-page-layout-widget-title.util.ts @@ -0,0 +1,37 @@ +import { type I18n } from '@lingui/core'; + +import { isDefined } from 'twenty-shared/utils'; + +import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId'; +import { type PageLayoutWidgetOverrides } from 'src/engine/metadata-modules/page-layout-widget/entities/page-layout-widget.entity'; + +export const resolvePageLayoutWidgetTitle = ({ + title, + applicationId, + twentyStandardApplicationId, + overrides, + i18nInstance, +}: { + title: string; + applicationId: string; + twentyStandardApplicationId: string; + overrides?: PageLayoutWidgetOverrides | null; + i18nInstance: I18n; +}): string => { + if (applicationId !== twentyStandardApplicationId) { + return title; + } + + if (isDefined(overrides?.title)) { + return title; + } + + const messageId = generateMessageId(title); + const translatedMessage = i18nInstance._(messageId); + + if (translatedMessage === messageId) { + return title; + } + + return translatedMessage; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field-group/constants/standard-view-field-group-names.ts b/packages/twenty-server/src/engine/metadata-modules/view-field-group/constants/standard-view-field-group-names.ts new file mode 100644 index 0000000000..598a2f8486 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/view-field-group/constants/standard-view-field-group-names.ts @@ -0,0 +1,12 @@ +import { msg } from '@lingui/core/macro'; + +export const getStandardViewFieldGroupNames = () => [ + msg`General`, + msg`System`, + msg`Work`, + msg`Social`, + msg`Deal`, + msg`Relations`, + msg`Business`, + msg`Contact`, +]; diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/view-field-group.dto.ts b/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/view-field-group.dto.ts index d737a7b155..8a0a35a066 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/view-field-group.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/view-field-group.dto.ts @@ -26,6 +26,9 @@ export class ViewFieldGroupDTO { @Field(() => UUIDScalarType, { nullable: false }) workspaceId: string; + @HideField() + applicationId: string; + @Field() createdAt: Date; diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field-group/resolvers/view-field-group.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/view-field-group/resolvers/view-field-group.resolver.ts index 7b185c08b9..df3f47f262 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-field-group/resolvers/view-field-group.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-field-group/resolvers/view-field-group.resolver.ts @@ -11,7 +11,10 @@ import { import { isArray } from '@sniptt/guards'; import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; 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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { type IDataloaders } from 'src/engine/dataloaders/dataloader.interface'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; @@ -25,6 +28,7 @@ import { UpsertFieldsWidgetInput } from 'src/engine/metadata-modules/view-field- import { ViewFieldGroupDTO } from 'src/engine/metadata-modules/view-field-group/dtos/view-field-group.dto'; import { FieldsWidgetUpsertService } from 'src/engine/metadata-modules/view-field-group/services/fields-widget-upsert.service'; import { ViewFieldGroupService } from 'src/engine/metadata-modules/view-field-group/services/view-field-group.service'; +import { resolveViewFieldGroupName } from 'src/engine/metadata-modules/view-field-group/utils/resolve-view-field-group-name.util'; import { ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto'; import { ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto'; import { type ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity'; @@ -37,8 +41,32 @@ export class ViewFieldGroupResolver { constructor( private readonly viewFieldGroupService: ViewFieldGroupService, private readonly fieldsWidgetUpsertService: FieldsWidgetUpsertService, + private readonly i18nService: I18nService, + private readonly applicationService: ApplicationService, ) {} + @ResolveField(() => String) + async name( + @Parent() viewFieldGroup: ViewFieldGroupDTO, + @Context() context: I18nContext, + @AuthWorkspace() workspace: WorkspaceEntity, + ): Promise { + const i18n = this.i18nService.getI18nInstance(context.req.locale); + + const { twentyStandardFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspace }, + ); + + return resolveViewFieldGroupName({ + name: viewFieldGroup.name, + applicationId: viewFieldGroup.applicationId, + twentyStandardApplicationId: twentyStandardFlatApplication.id, + overrides: viewFieldGroup.overrides, + i18nInstance: i18n, + }); + } + @Query(() => [ViewFieldGroupDTO]) @UseGuards(NoPermissionGuard) async getViewFieldGroups( diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field-group/utils/__tests__/resolve-view-field-group-name.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/view-field-group/utils/__tests__/resolve-view-field-group-name.util.spec.ts new file mode 100644 index 0000000000..661d8c2bf3 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/view-field-group/utils/__tests__/resolve-view-field-group-name.util.spec.ts @@ -0,0 +1,149 @@ +import { type I18n } from '@lingui/core'; + +import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId'; +import { resolveViewFieldGroupName } from 'src/engine/metadata-modules/view-field-group/utils/resolve-view-field-group-name.util'; + +jest.mock('src/engine/core-modules/i18n/utils/generateMessageId'); + +const mockGenerateMessageId = generateMessageId as jest.MockedFunction< + typeof generateMessageId +>; + +const STANDARD_APPLICATION_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; + +describe('resolveViewFieldGroupName', () => { + let mockI18n: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + mockI18n = { + _: jest.fn(), + } as unknown as jest.Mocked; + }); + + it('should return translated name when catalog has a match', () => { + mockGenerateMessageId.mockReturnValue('abc123'); + mockI18n._.mockReturnValue('Général'); + + const result = resolveViewFieldGroupName({ + name: 'General', + applicationId: STANDARD_APPLICATION_ID, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, + i18nInstance: mockI18n, + }); + + expect(mockGenerateMessageId).toHaveBeenCalledWith('General'); + expect(mockI18n._).toHaveBeenCalledWith('abc123'); + expect(result).toBe('Général'); + }); + + it('should return original name when catalog returns the hash (no translation found)', () => { + mockGenerateMessageId.mockReturnValue('xyz789'); + mockI18n._.mockReturnValue('xyz789'); + + const result = resolveViewFieldGroupName({ + name: 'My Custom Group', + applicationId: STANDARD_APPLICATION_ID, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, + i18nInstance: mockI18n, + }); + + expect(mockGenerateMessageId).toHaveBeenCalledWith('My Custom Group'); + expect(mockI18n._).toHaveBeenCalledWith('xyz789'); + expect(result).toBe('My Custom Group'); + }); + + it('should return original name for empty string', () => { + mockGenerateMessageId.mockReturnValue('empty-hash'); + mockI18n._.mockReturnValue('empty-hash'); + + const result = resolveViewFieldGroupName({ + name: '', + applicationId: STANDARD_APPLICATION_ID, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, + i18nInstance: mockI18n, + }); + + expect(result).toBe(''); + }); + + it('should translate standard view field group names', () => { + const standardGroups = [ + { source: 'General', translated: 'Général' }, + { source: 'System', translated: 'Système' }, + { source: 'Work', translated: 'Travail' }, + { source: 'Social', translated: 'Social' }, + { source: 'Deal', translated: 'Affaire' }, + { source: 'Relations', translated: 'Relations' }, + { source: 'Business', translated: 'Entreprise' }, + { source: 'Contact', translated: 'Contact' }, + ]; + + standardGroups.forEach(({ source, translated }) => { + jest.clearAllMocks(); + mockGenerateMessageId.mockReturnValue(`hash-${source}`); + mockI18n._.mockReturnValue(translated); + + const result = resolveViewFieldGroupName({ + name: source, + applicationId: STANDARD_APPLICATION_ID, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, + i18nInstance: mockI18n, + }); + + expect(result).toBe(translated); + }); + }); + + it('should not translate name when applicationId is not from standard app', () => { + mockGenerateMessageId.mockReturnValue('abc123'); + mockI18n._.mockReturnValue('Général'); + + const customAppId = '11111111-1111-1111-1111-111111111111'; + + const result = resolveViewFieldGroupName({ + name: 'General', + applicationId: customAppId, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, + i18nInstance: mockI18n, + }); + + expect(mockGenerateMessageId).not.toHaveBeenCalled(); + expect(mockI18n._).not.toHaveBeenCalled(); + expect(result).toBe('General'); + }); + + it('should not translate name when overrides.name is defined', () => { + mockGenerateMessageId.mockReturnValue('abc123'); + mockI18n._.mockReturnValue('Général'); + + const result = resolveViewFieldGroupName({ + name: 'General', + applicationId: STANDARD_APPLICATION_ID, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, + overrides: { name: 'General' }, + i18nInstance: mockI18n, + }); + + expect(mockGenerateMessageId).not.toHaveBeenCalled(); + expect(mockI18n._).not.toHaveBeenCalled(); + expect(result).toBe('General'); + }); + + it('should translate name when overrides is defined but overrides.name is not', () => { + mockGenerateMessageId.mockReturnValue('abc123'); + mockI18n._.mockReturnValue('Général'); + + const result = resolveViewFieldGroupName({ + name: 'General', + applicationId: STANDARD_APPLICATION_ID, + twentyStandardApplicationId: STANDARD_APPLICATION_ID, + overrides: { position: 3 }, + i18nInstance: mockI18n, + }); + + expect(mockGenerateMessageId).toHaveBeenCalledWith('General'); + expect(mockI18n._).toHaveBeenCalledWith('abc123'); + expect(result).toBe('Général'); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field-group/utils/from-flat-view-field-group-to-view-field-group-dto.util.ts b/packages/twenty-server/src/engine/metadata-modules/view-field-group/utils/from-flat-view-field-group-to-view-field-group-dto.util.ts index d28996ef4b..434c3656a6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-field-group/utils/from-flat-view-field-group-to-view-field-group-dto.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-field-group/utils/from-flat-view-field-group-to-view-field-group-dto.util.ts @@ -10,6 +10,7 @@ export const fromFlatViewFieldGroupToViewFieldGroupDto = ( return { ...rest, ...(overrides ?? {}), + overrides, isOverridden: false, createdAt: new Date(createdAt), updatedAt: new Date(updatedAt), diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field-group/utils/resolve-view-field-group-name.util.ts b/packages/twenty-server/src/engine/metadata-modules/view-field-group/utils/resolve-view-field-group-name.util.ts new file mode 100644 index 0000000000..6d28a610c0 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/view-field-group/utils/resolve-view-field-group-name.util.ts @@ -0,0 +1,37 @@ +import { type I18n } from '@lingui/core'; + +import { isDefined } from 'twenty-shared/utils'; + +import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId'; +import { type ViewFieldGroupOverrides } from 'src/engine/metadata-modules/view-field-group/entities/view-field-group.entity'; + +export const resolveViewFieldGroupName = ({ + name, + applicationId, + twentyStandardApplicationId, + overrides, + i18nInstance, +}: { + name: string; + applicationId: string; + twentyStandardApplicationId: string; + overrides?: ViewFieldGroupOverrides | null; + i18nInstance: I18n; +}): string => { + if (applicationId !== twentyStandardApplicationId) { + return name; + } + + if (isDefined(overrides?.name)) { + return name; + } + + const messageId = generateMessageId(name); + const translatedMessage = i18nInstance._(messageId); + + if (translatedMessage === messageId) { + return name; + } + + return translatedMessage; +};