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:
+12
@@ -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`,
|
||||
];
|
||||
+3
@@ -26,6 +26,9 @@ export class ViewFieldGroupDTO {
|
||||
@Field(() => UUIDScalarType, { nullable: false })
|
||||
workspaceId: string;
|
||||
|
||||
@HideField()
|
||||
applicationId: string;
|
||||
|
||||
@Field()
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
+28
@@ -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(
|
||||
|
||||
+149
@@ -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');
|
||||
});
|
||||
});
|
||||
+1
@@ -10,6 +10,7 @@ export const fromFlatViewFieldGroupToViewFieldGroupDto = (
|
||||
return {
|
||||
...rest,
|
||||
...(overrides ?? {}),
|
||||
overrides,
|
||||
isOverridden: false,
|
||||
createdAt: new Date(createdAt),
|
||||
updatedAt: new Date(updatedAt),
|
||||
|
||||
+37
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user