Translate page layout tab title (#17975)

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

## When using the front-end mocks


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

## When loading page layout information from database


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

## When editing dashboard


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

Closes
https://github.com/twentyhq/twenty/issues/17950#issuecomment-3902782952
This commit is contained in:
Baptiste Devessier
2026-02-17 16:26:28 +01:00
committed by GitHub
parent e3fcff00b0
commit 963f2de864
34 changed files with 332 additions and 20 deletions
File diff suppressed because one or more lines are too long
@@ -5,6 +5,7 @@ export const PAGE_LAYOUT_TAB_FRAGMENT = gql`
${PAGE_LAYOUT_WIDGET_FRAGMENT}
fragment PageLayoutTabFragment on PageLayoutTab {
id
applicationId
title
icon
position
@@ -7,6 +7,7 @@ import {
type OnDragUpdateResponder,
type ResponderProvided,
} from '@hello-pangea/dnd';
import { useLingui } from '@lingui/react/macro';
import { useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { IconPlus, useIcons } from 'twenty-ui/display';
@@ -32,6 +33,8 @@ import { PAGE_LAYOUT_TAB_LIST_DROPPABLE_IDS } from '@/page-layout/components/Pag
import { PageLayoutTabListReorderableOverflowDropdown } from '@/page-layout/components/PageLayoutTabListReorderableOverflowDropdown';
import { PageLayoutTabListStaticOverflowDropdown } from '@/page-layout/components/PageLayoutTabListStaticOverflowDropdown';
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 { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState';
import { pageLayoutTabListCurrentDragDroppableIdComponentState } from '@/page-layout/states/pageLayoutTabListCurrentDragDroppableIdComponentState';
@@ -94,10 +97,19 @@ export const PageLayoutTabList = ({
pageLayoutType,
}: PageLayoutTabListProps) => {
const { getIcon } = useIcons();
const { t } = useLingui();
const { isCustom } = useIsCurrentObjectCustom();
const shouldTranslateTabTitles = !isCustom;
const tabsWithIcons: SingleTabProps[] = tabs.map((tab) => ({
id: tab.id,
title: tab.title,
// TODO: drop once the configuration of all record page layouts has been migrated to the backend.
title:
shouldTranslateTabTitles &&
STANDARD_PAGE_LAYOUT_TAB_TITLE_TRANSLATIONS[tab.title]
? t(STANDARD_PAGE_LAYOUT_TAB_TITLE_TRANSLATIONS[tab.title])
: tab.title,
Icon: tab.icon ? getIcon(tab.icon) : undefined,
}));
@@ -22,6 +22,7 @@ const StyledContainer = styled.div`
const createInitialTabs = (): PageLayoutTab[] => [
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'overview',
title: 'Overview',
position: 0,
@@ -34,6 +35,7 @@ const createInitialTabs = (): PageLayoutTab[] => [
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'revenue',
title: 'Revenue',
position: 1,
@@ -45,6 +47,7 @@ const createInitialTabs = (): PageLayoutTab[] => [
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'forecasts',
title: 'Forecasts',
position: 2,
@@ -73,6 +76,7 @@ const PageLayoutTabListPlayground = ({
...prev,
{
__typename: 'PageLayoutTab',
applicationId: '',
id: `new-tab-${nextIndex}`,
title: `New Tab ${nextIndex}`,
position: nextIndex,
@@ -19,6 +19,7 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
tabs: [
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'company-tab-fields',
title: 'Home',
icon: 'IconHome',
@@ -56,6 +57,7 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'company-tab-timeline',
title: 'Timeline',
icon: 'IconTimelineEvent',
@@ -93,6 +95,7 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'company-tab-tasks',
title: 'Tasks',
icon: 'IconCheckbox',
@@ -130,6 +133,7 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'company-tab-notes',
title: 'Notes',
icon: 'IconNotes',
@@ -167,6 +171,7 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'company-tab-files',
title: 'Files',
icon: 'IconPaperclip',
@@ -204,6 +209,7 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'company-tab-emails',
title: 'Emails',
icon: 'IconMail',
@@ -241,6 +247,7 @@ export const DEFAULT_COMPANY_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'company-tab-calendar',
title: 'Calendar',
icon: 'IconCalendarEvent',
@@ -18,6 +18,7 @@ export const DEFAULT_NOTE_RECORD_PAGE_LAYOUT: PageLayout = {
tabs: [
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'note-tab-fields',
title: 'Home',
icon: 'IconHome',
@@ -88,6 +89,7 @@ export const DEFAULT_NOTE_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'note-tab-note',
title: 'Note',
icon: 'IconNotes',
@@ -132,6 +134,7 @@ export const DEFAULT_NOTE_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'note-tab-timeline',
title: 'Timeline',
icon: 'IconTimelineEvent',
@@ -169,6 +172,7 @@ export const DEFAULT_NOTE_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'note-tab-files',
title: 'Files',
icon: 'IconPaperclip',
@@ -19,6 +19,7 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
tabs: [
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'opportunity-tab-fields',
title: 'Home',
icon: 'IconHome',
@@ -56,6 +57,7 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'opportunity-tab-timeline',
title: 'Timeline',
icon: 'IconTimelineEvent',
@@ -93,6 +95,7 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'opportunity-tab-tasks',
title: 'Tasks',
icon: 'IconCheckbox',
@@ -130,6 +133,7 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'opportunity-tab-notes',
title: 'Notes',
icon: 'IconNotes',
@@ -167,6 +171,7 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'opportunity-tab-files',
title: 'Files',
icon: 'IconPaperclip',
@@ -204,6 +209,7 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'opportunity-tab-emails',
title: 'Emails',
icon: 'IconMail',
@@ -241,6 +247,7 @@ export const DEFAULT_OPPORTUNITY_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'opportunity-tab-calendar',
title: 'Calendar',
icon: 'IconCalendarEvent',
@@ -18,6 +18,7 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
tabs: [
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'person-tab-fields',
title: 'Home',
icon: 'IconHome',
@@ -55,6 +56,7 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'person-tab-timeline',
title: 'Timeline',
icon: 'IconTimelineEvent',
@@ -92,6 +94,7 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'person-tab-tasks',
title: 'Tasks',
icon: 'IconCheckbox',
@@ -129,6 +132,7 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'person-tab-notes',
title: 'Notes',
icon: 'IconNotes',
@@ -166,6 +170,7 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'person-tab-files',
title: 'Files',
icon: 'IconPaperclip',
@@ -203,6 +208,7 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'person-tab-emails',
title: 'Emails',
icon: 'IconMail',
@@ -240,6 +246,7 @@ export const DEFAULT_PERSON_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'person-tab-calendar',
title: 'Calendar',
icon: 'IconCalendarEvent',
@@ -18,6 +18,7 @@ export const DEFAULT_RECORD_PAGE_LAYOUT: PageLayout = {
tabs: [
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'default-tab-fields',
title: 'Home',
icon: 'IconHome',
@@ -55,6 +56,7 @@ export const DEFAULT_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'default-tab-timeline',
title: 'Timeline',
icon: 'IconTimelineEvent',
@@ -92,6 +94,7 @@ export const DEFAULT_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'default-tab-tasks',
title: 'Tasks',
icon: 'IconCheckbox',
@@ -129,6 +132,7 @@ export const DEFAULT_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'default-tab-notes',
title: 'Notes',
icon: 'IconNotes',
@@ -166,6 +170,7 @@ export const DEFAULT_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'default-tab-files',
title: 'Files',
icon: 'IconPaperclip',
@@ -18,6 +18,7 @@ export const DEFAULT_TASK_RECORD_PAGE_LAYOUT: PageLayout = {
tabs: [
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'task-tab-fields',
title: 'Home',
icon: 'IconHome',
@@ -85,6 +86,7 @@ export const DEFAULT_TASK_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'task-tab-note',
title: 'Note',
icon: 'IconNotes',
@@ -129,6 +131,7 @@ export const DEFAULT_TASK_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'task-tab-timeline',
title: 'Timeline',
icon: 'IconTimelineEvent',
@@ -166,6 +169,7 @@ export const DEFAULT_TASK_RECORD_PAGE_LAYOUT: PageLayout = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'task-tab-files',
title: 'Files',
icon: 'IconPaperclip',
@@ -23,6 +23,7 @@ export const DEFAULT_WORKFLOW_PAGE_LAYOUT: PageLayout = {
// Flow tab (position 100)
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'workflow-tab-flow',
title: 'Flow',
position: 100,
@@ -24,6 +24,7 @@ export const DEFAULT_WORKFLOW_RUN_PAGE_LAYOUT: PageLayout = {
// Fields tab (position 100)
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'workflow-run-tab-fields',
title: 'Home',
position: 100,
@@ -62,6 +63,7 @@ export const DEFAULT_WORKFLOW_RUN_PAGE_LAYOUT: PageLayout = {
// Flow tab (position 200)
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'workflow-run-tab-flow',
title: 'Flow',
position: 200,
@@ -24,6 +24,7 @@ export const DEFAULT_WORKFLOW_VERSION_PAGE_LAYOUT: PageLayout = {
// Fields tab (position 100)
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'workflow-version-tab-fields',
title: 'Home',
icon: 'IconHome',
@@ -62,6 +63,7 @@ export const DEFAULT_WORKFLOW_VERSION_PAGE_LAYOUT: PageLayout = {
// Flow tab (position 200)
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'workflow-version-tab-flow',
title: 'Flow',
icon: 'IconSettings',
@@ -0,0 +1,22 @@
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`,
};
@@ -18,6 +18,7 @@ export const UPDATE_PAGE_LAYOUT_WITH_TABS_AND_WIDGETS = gql`
deletedAt
tabs {
id
applicationId
title
position
pageLayoutId
@@ -9,9 +9,9 @@ import { act, renderHook } from '@testing-library/react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import {
WidgetType,
PageLayoutType,
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
import {
PAGE_LAYOUT_TEST_INSTANCE_ID,
@@ -73,6 +73,7 @@ describe('useCreatePageLayoutGraphWidget', () => {
tabs: [
{
id: 'tab-1',
applicationId: '',
title: 'Tab 1',
position: 0,
pageLayoutId: '',
@@ -152,6 +153,7 @@ describe('useCreatePageLayoutGraphWidget', () => {
tabs: [
{
id: 'tab-1',
applicationId: '',
title: 'Tab 1',
position: 0,
pageLayoutId: '',
@@ -1,3 +1,4 @@
import { useCreatePageLayoutTab } from '@/page-layout/hooks/useCreatePageLayoutTab';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
@@ -6,7 +7,6 @@ import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state
import { act, renderHook } from '@testing-library/react';
import { useSetRecoilState } from 'recoil';
import { PageLayoutType } from '~/generated-metadata/graphql';
import { useCreatePageLayoutTab } from '@/page-layout/hooks/useCreatePageLayoutTab';
import {
PAGE_LAYOUT_TEST_INSTANCE_ID,
PageLayoutTestWrapper,
@@ -228,6 +228,7 @@ describe('useCreatePageLayoutTab', () => {
tabs: [
{
id: 'existing-tab',
applicationId: '',
title: 'Existing Tab',
position: 0,
pageLayoutId: 'test-layout',
@@ -1,12 +1,12 @@
import { usePageLayoutDraftState } from '@/page-layout/hooks/usePageLayoutDraftState';
import { act, renderHook } from '@testing-library/react';
import {
GraphOrderBy,
WidgetType,
AggregateOperations,
BarChartLayout,
GraphOrderBy,
PageLayoutType,
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
import {
PAGE_LAYOUT_TEST_INSTANCE_ID,
@@ -88,6 +88,7 @@ describe('usePageLayoutDraftState', () => {
tabs: [
{
id: 'tab-1',
applicationId: '',
title: 'Tab 1',
position: 0,
pageLayoutId: '',
@@ -32,6 +32,7 @@ describe('usePageLayoutWithRelationWidgets', () => {
tabs: [
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'tab-1',
title: 'Fields',
icon: 'IconList',
@@ -1,4 +1,8 @@
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { getEmptyTabLayout } from '@/page-layout/utils/getEmptyTabLayout';
import { getTabListInstanceIdFromPageLayoutId } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutId';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
@@ -6,10 +10,6 @@ import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useRecoilCallback } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { getEmptyTabLayout } from '@/page-layout/utils/getEmptyTabLayout';
export const useCreatePageLayoutTab = (pageLayoutIdFromProps?: string) => {
const pageLayoutId = useAvailableComponentInstanceIdOrThrow(
@@ -48,6 +48,7 @@ export const useCreatePageLayoutTab = (pageLayoutIdFromProps?: string) => {
: -1;
const newTab: PageLayoutTab = {
id: newTabId,
applicationId: '',
title: title || `Tab ${tabsLength + 1}`,
position: maxPosition + 1,
pageLayoutId: pageLayoutId,
@@ -0,0 +1,25 @@
import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const useIsCurrentObjectCustom = () => {
const { targetRecordIdentifier } = useLayoutRenderingContext();
const objectMetadataItem = useRecoilValue(
objectMetadataItemFamilySelector({
objectName: targetRecordIdentifier?.targetObjectNameSingular ?? '',
objectNameType: 'singular',
}),
);
if (!isDefined(targetRecordIdentifier)) {
return { isCustom: true };
}
if (!isDefined(objectMetadataItem)) {
return { isCustom: true };
}
return { isCustom: objectMetadataItem.isCustom };
};
@@ -30,6 +30,7 @@ describe('addWidgetToTab', () => {
const mockTabs: PageLayoutTab[] = [
{
id: 'tab-1',
applicationId: '',
title: 'Tab 1',
position: 0,
pageLayoutId: 'layout-1',
@@ -40,6 +41,7 @@ describe('addWidgetToTab', () => {
},
{
id: 'tab-2',
applicationId: '',
title: 'Tab 2',
position: 1,
pageLayoutId: 'layout-1',
@@ -19,6 +19,7 @@ describe('convertPageLayoutToTabLayouts', () => {
tabs: [
{
id: 'tab-1',
applicationId: '',
title: 'Tab 1',
position: 0,
pageLayoutId: 'page-layout-1',
@@ -97,6 +98,7 @@ describe('convertPageLayoutToTabLayouts', () => {
tabs: [
{
id: 'tab-1',
applicationId: '',
title: 'Tab 1',
position: 0,
pageLayoutId: 'page-layout-1',
@@ -4,6 +4,7 @@ import { getPageLayoutTabListInitialActiveTabId } from '@/page-layout/utils/getP
describe('getPageLayoutTabListInitialActiveTabId', () => {
const createMockTab = (id: string): PageLayoutTab => ({
id,
applicationId: '',
pageLayoutId: 'page-layout-1',
title: `Tab ${id}`,
position: 0,
@@ -9,6 +9,7 @@ import {
describe('getTabsByDisplayMode', () => {
const createMockTab = (id: string): PageLayoutTab => ({
id,
applicationId: '',
pageLayoutId: 'page-layout-1',
title: `Tab ${id}`,
position: 0,
@@ -36,6 +36,7 @@ describe('getTabsWithVisibleWidgets', () => {
widgets: PageLayoutTab['widgets'],
): PageLayoutTab => ({
__typename: 'PageLayoutTab',
applicationId: '',
id,
pageLayoutId: 'page-layout-1',
title: `Tab ${id}`,
@@ -1,16 +1,17 @@
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { removeWidgetFromTab } from '@/page-layout/utils/removeWidgetFromTab';
import {
AggregateOperations,
GraphOrderBy,
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { removeWidgetFromTab } from '@/page-layout/utils/removeWidgetFromTab';
describe('removeWidgetFromTab', () => {
const mockTabs: PageLayoutTab[] = [
{
id: 'tab-1',
applicationId: '',
title: 'Tab 1',
position: 0,
pageLayoutId: 'layout-1',
@@ -60,6 +61,7 @@ describe('removeWidgetFromTab', () => {
},
{
id: 'tab-2',
applicationId: '',
title: 'Tab 2',
position: 1,
pageLayoutId: 'layout-1',
@@ -145,6 +145,7 @@ const createPageLayoutWithWidget = (
tabs: [
{
__typename: 'PageLayoutTab',
applicationId: '',
id: TAB_ID_OVERVIEW,
title: 'Overview',
position: 0,
@@ -1699,6 +1700,7 @@ export const Catalog: CatalogStory<Story, typeof WidgetRenderer> = {
? [
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'pinned-tab',
title: 'Pinned Tab',
position: 0,
@@ -1710,6 +1712,7 @@ export const Catalog: CatalogStory<Story, typeof WidgetRenderer> = {
},
{
__typename: 'PageLayoutTab',
applicationId: '',
id: 'other-tab',
title: 'Other Tab',
position: 1,
@@ -1723,6 +1726,7 @@ export const Catalog: CatalogStory<Story, typeof WidgetRenderer> = {
: [
{
__typename: 'PageLayoutTab',
applicationId: '',
id: TAB_ID_OVERVIEW,
title: 'Overview',
position: 0,
@@ -22,6 +22,7 @@ import {
} from '@/page-layout/hooks/__tests__/PageLayoutTestWrapper';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
import { type PageLayout } from '@/page-layout/types/PageLayout';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { FieldWidget } from '@/page-layout/widgets/field/components/FieldWidget';
import { WidgetComponentInstanceContext } from '@/page-layout/widgets/states/contexts/WidgetComponentInstanceContext';
@@ -291,7 +292,7 @@ const TAB_ID_OVERVIEW = 'tab-overview';
const createPageLayoutWithWidget = (
widget: PageLayoutWidget,
objectMetadataId: string,
) => ({
): PageLayout => ({
id: PAGE_LAYOUT_TEST_INSTANCE_ID,
name: 'Mock Page Layout',
type: PageLayoutType.RECORD_PAGE,
@@ -299,6 +300,7 @@ const createPageLayoutWithWidget = (
tabs: [
{
__typename: 'PageLayoutTab' as const,
applicationId: '',
id: TAB_ID_OVERVIEW,
title: 'Overview',
position: 0,
@@ -0,0 +1,17 @@
import { t } from '@lingui/core/macro';
// This file exists solely for Lingui string extraction.
// The strings defined here correspond to standard page layout tab titles
// so they appear in the .po catalogs and can be translated at resolve time
// via generateMessageId hash lookups.
export const getStandardPageLayoutTabTitles = () => [
t`Home`,
t`Timeline`,
t`Tasks`,
t`Notes`,
t`Files`,
t`Emails`,
t`Calendar`,
t`Note`,
t`Flow`,
];
@@ -15,6 +15,9 @@ export class PageLayoutTabDTO {
@IDField(() => UUIDScalarType)
id: string;
@Field(() => UUIDScalarType, { nullable: false })
applicationId: string;
@Field({ nullable: false })
title: string;
@@ -4,14 +4,23 @@ import {
UseInterceptors,
UsePipes,
} from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import {
Args,
Context,
Mutation,
Parent,
Query,
ResolveField,
} from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { type I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.type';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@@ -19,6 +28,7 @@ import { CreatePageLayoutTabInput } from 'src/engine/metadata-modules/page-layou
import { UpdatePageLayoutTabInput } from 'src/engine/metadata-modules/page-layout-tab/dtos/inputs/update-page-layout-tab.input';
import { PageLayoutTabDTO } from 'src/engine/metadata-modules/page-layout-tab/dtos/page-layout-tab.dto';
import { PageLayoutTabService } from 'src/engine/metadata-modules/page-layout-tab/services/page-layout-tab.service';
import { resolvePageLayoutTabTitle } from 'src/engine/metadata-modules/page-layout-tab/utils/resolve-page-layout-tab-title.util';
import { PageLayoutGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/page-layout/utils/page-layout-graphql-api-exception.filter';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
@@ -28,7 +38,24 @@ import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/wor
@UseGuards(WorkspaceAuthGuard)
@UsePipes(ResolverValidationPipe)
export class PageLayoutTabResolver {
constructor(private readonly pageLayoutTabService: PageLayoutTabService) {}
constructor(
private readonly pageLayoutTabService: PageLayoutTabService,
private readonly i18nService: I18nService,
) {}
@ResolveField(() => String)
async title(
@Parent() tab: PageLayoutTabDTO,
@Context() context: I18nContext,
): Promise<string> {
const i18n = this.i18nService.getI18nInstance(context.req.locale);
return resolvePageLayoutTabTitle({
title: tab.title,
applicationId: tab.applicationId,
i18nInstance: i18n,
});
}
@Query(() => [PageLayoutTabDTO])
@UseGuards(NoPermissionGuard)
@@ -0,0 +1,110 @@
import { type I18n } from '@lingui/core';
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
import { resolvePageLayoutTabTitle } from 'src/engine/metadata-modules/page-layout-tab/utils/resolve-page-layout-tab-title.util';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
jest.mock('src/engine/core-modules/i18n/utils/generateMessageId');
const mockGenerateMessageId = generateMessageId as jest.MockedFunction<
typeof generateMessageId
>;
describe('resolvePageLayoutTabTitle', () => {
let mockI18n: jest.Mocked<I18n>;
beforeEach(() => {
jest.clearAllMocks();
mockI18n = {
_: jest.fn(),
} as unknown as jest.Mocked<I18n>;
});
it('should return translated title when catalog has a match', () => {
mockGenerateMessageId.mockReturnValue('abc123');
mockI18n._.mockReturnValue('Accueil');
const result = resolvePageLayoutTabTitle({
title: 'Home',
applicationId: TWENTY_STANDARD_APPLICATION.universalIdentifier,
i18nInstance: mockI18n,
});
expect(mockGenerateMessageId).toHaveBeenCalledWith('Home');
expect(mockI18n._).toHaveBeenCalledWith('abc123');
expect(result).toBe('Accueil');
});
it('should return original title when catalog returns the hash (no translation found)', () => {
mockGenerateMessageId.mockReturnValue('xyz789');
mockI18n._.mockReturnValue('xyz789');
const result = resolvePageLayoutTabTitle({
title: 'My Custom Tab',
applicationId: TWENTY_STANDARD_APPLICATION.universalIdentifier,
i18nInstance: mockI18n,
});
expect(mockGenerateMessageId).toHaveBeenCalledWith('My Custom Tab');
expect(mockI18n._).toHaveBeenCalledWith('xyz789');
expect(result).toBe('My Custom Tab');
});
it('should return original title for empty string', () => {
mockGenerateMessageId.mockReturnValue('empty-hash');
mockI18n._.mockReturnValue('empty-hash');
const result = resolvePageLayoutTabTitle({
title: '',
applicationId: TWENTY_STANDARD_APPLICATION.universalIdentifier,
i18nInstance: mockI18n,
});
expect(result).toBe('');
});
it('should translate standard tab titles', () => {
const standardTabs = [
{ source: 'Home', translated: 'Accueil' },
{ source: 'Timeline', translated: 'Chronologie' },
{ source: 'Tasks', translated: 'Tâches' },
{ source: 'Notes', translated: 'Notes' },
{ source: 'Files', translated: 'Fichiers' },
{ source: 'Emails', translated: 'E-mails' },
{ source: 'Calendar', translated: 'Calendrier' },
{ source: 'Note', translated: 'Note' },
{ source: 'Flow', translated: 'Flux' },
];
standardTabs.forEach(({ source, translated }) => {
jest.clearAllMocks();
mockGenerateMessageId.mockReturnValue(`hash-${source}`);
mockI18n._.mockReturnValue(translated);
const result = resolvePageLayoutTabTitle({
title: source,
applicationId: TWENTY_STANDARD_APPLICATION.universalIdentifier,
i18nInstance: mockI18n,
});
expect(result).toBe(translated);
});
});
it('should not translate title when applicationId is not from standard app', () => {
mockGenerateMessageId.mockReturnValue('abc123');
mockI18n._.mockReturnValue('Accueil');
const customAppId = '11111111-1111-1111-1111-111111111111';
const result = resolvePageLayoutTabTitle({
title: 'Home',
applicationId: customAppId,
i18nInstance: mockI18n,
});
expect(mockGenerateMessageId).not.toHaveBeenCalled();
expect(mockI18n._).not.toHaveBeenCalled();
expect(result).toBe('Home');
});
});
@@ -0,0 +1,27 @@
import { type I18n } from '@lingui/core';
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
import { TWENTY_STANDARD_APPLICATION } from 'src/engine/workspace-manager/twenty-standard-application/constants/twenty-standard-applications';
export const resolvePageLayoutTabTitle = ({
title,
applicationId,
i18nInstance,
}: {
title: string;
applicationId: string;
i18nInstance: I18n;
}): string => {
if (applicationId !== TWENTY_STANDARD_APPLICATION.universalIdentifier) {
return title;
}
const messageId = generateMessageId(title);
const translatedMessage = i18nInstance._(messageId);
if (translatedMessage === messageId) {
return title;
}
return translatedMessage;
};