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; +};