mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
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
This commit is contained in:
parent
9a95cd02ed
commit
6d3ff4c9ce
19 changed files with 577 additions and 67 deletions
|
|
@ -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,
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<DropdownContent>
|
||||
<DropdownMenuHeader>{t`New tab`}</DropdownMenuHeader>
|
||||
|
|
@ -106,7 +91,7 @@ export const PageLayoutTabListNewTabDropdownContent = ({
|
|||
<MenuItem
|
||||
key={tab.id}
|
||||
LeftIcon={isDefined(tab.icon) ? getIcon(tab.icon) : undefined}
|
||||
text={getTabTitle(tab.title)}
|
||||
text={tab.title}
|
||||
onClick={() => handleReactivateTab(tab.id)}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
};
|
||||
|
|
@ -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`,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<I18n>;
|
||||
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export const fromFlatPageLayoutTabToPageLayoutTabDto = (
|
|||
return {
|
||||
...rest,
|
||||
...(overrides ?? {}),
|
||||
overrides,
|
||||
isOverridden: false,
|
||||
createdAt: new Date(createdAt),
|
||||
updatedAt: new Date(updatedAt),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
];
|
||||
|
|
@ -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<string> {
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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<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('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');
|
||||
});
|
||||
});
|
||||
|
|
@ -16,6 +16,7 @@ export const fromFlatPageLayoutWidgetToPageLayoutWidgetDto = (
|
|||
return {
|
||||
...rest,
|
||||
...(overrides ?? {}),
|
||||
overrides,
|
||||
isOverridden: false,
|
||||
objectMetadataId: objectMetadataId ?? undefined,
|
||||
createdAt: new Date(createdAt),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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`,
|
||||
];
|
||||
|
|
@ -26,6 +26,9 @@ export class ViewFieldGroupDTO {
|
|||
@Field(() => UUIDScalarType, { nullable: false })
|
||||
workspaceId: string;
|
||||
|
||||
@HideField()
|
||||
applicationId: string;
|
||||
|
||||
@Field()
|
||||
createdAt: Date;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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<I18n>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockI18n = {
|
||||
_: jest.fn(),
|
||||
} as unknown as jest.Mocked<I18n>;
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
|
@ -10,6 +10,7 @@ export const fromFlatViewFieldGroupToViewFieldGroupDto = (
|
|||
return {
|
||||
...rest,
|
||||
...(overrides ?? {}),
|
||||
overrides,
|
||||
isOverridden: false,
|
||||
createdAt: new Date(createdAt),
|
||||
updatedAt: new Date(updatedAt),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
Loading…
Reference in a new issue