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 a94f5eb9357..0f96733c0f7 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 f9cbb8f9f24..402a61bd9a2 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 0bd87c6dd2a..00000000000 --- 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 b6943caedbe..048c5688d4f 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 c7381058ccc..d91ce2310f2 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 7406a3f4d95..9dd1b243b3d 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 deaa500e794..b3206bd7c67 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 61f2ed2d2f2..3cb698dcc31 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 00000000000..6c84043211d --- /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 b7ef7421bce..b7c5a9e6c63 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 00000000000..18a781b437f --- /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 c4ab26116d8..5f17a853fdf 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 00000000000..01148028c28 --- /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 00000000000..598a2f84867 --- /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 d737a7b1553..8a0a35a066b 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 7b185c08b9f..df3f47f2626 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 00000000000..661d8c2bf37 --- /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 d28996ef4b0..434c3656a6b 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 00000000000..6d28a610c02 --- /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; +};