Add translations on core views (#13895)

closes https://github.com/twentyhq/twenty/issues/11999
This commit is contained in:
nitin
2025-08-19 23:05:53 +05:30
committed by GitHub
parent c582666aeb
commit 025eaff9ec
90 changed files with 1266 additions and 69 deletions
@@ -36,6 +36,9 @@ export class ViewDTO {
@Field({ nullable: false, defaultValue: false })
isCompact: boolean;
@Field({ nullable: false, defaultValue: false })
isCustom: boolean;
@Field(() => ViewOpenRecordIn, {
nullable: false,
defaultValue: ViewOpenRecordIn.SIDE_PANEL,
@@ -60,6 +60,9 @@ export class View {
@Column({ nullable: false, default: false, type: 'boolean' })
isCompact: boolean;
@Column({ nullable: false, default: false, type: 'boolean' })
isCustom: boolean;
@Column({
type: 'enum',
enum: Object.values(ViewOpenRecordIn),
@@ -1,8 +1,19 @@
import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import {
Args,
Context,
Mutation,
Parent,
Query,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { i18n } from '@lingui/core';
import { isDefined } from 'twenty-shared/utils';
import { type I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.type';
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
import { CreateViewInput } from 'src/engine/core-modules/view/dtos/inputs/create-view.input';
import { UpdateViewInput } from 'src/engine/core-modules/view/dtos/inputs/update-view.input';
import { ViewDTO } from 'src/engine/core-modules/view/dtos/view.dto';
@@ -11,6 +22,7 @@ import { ViewGraphqlApiExceptionFilter } from 'src/engine/core-modules/view/util
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { resolveObjectMetadataStandardOverride } from 'src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util';
@Resolver(() => ViewDTO)
@UseFilters(ViewGraphqlApiExceptionFilter)
@@ -18,6 +30,55 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
export class ViewResolver {
constructor(private readonly viewService: ViewService) {}
@ResolveField(() => String)
async name(
@Parent() view: ViewDTO,
@Context() context: I18nContext,
@AuthWorkspace() workspace: Workspace,
): Promise<string> {
if (view.name.includes('{objectLabelPlural}')) {
const objectMetadata = await this.viewService.getObjectMetadataByViewId(
view.id,
workspace.id,
);
if (objectMetadata) {
const translatedObjectLabel = resolveObjectMetadataStandardOverride(
{
labelPlural: objectMetadata.labelPlural,
labelSingular: objectMetadata.labelSingular,
description: objectMetadata.description ?? undefined,
icon: objectMetadata.icon ?? undefined,
isCustom: objectMetadata.isCustom,
standardOverrides: objectMetadata.standardOverrides ?? undefined,
},
'labelPlural',
context.req.locale,
);
const messageId = generateMessageId(view.name);
const translatedTemplate = i18n._(messageId, {
objectLabelPlural: translatedObjectLabel,
});
if (translatedTemplate !== messageId) {
return translatedTemplate;
}
return view.name.replace('{objectLabelPlural}', translatedObjectLabel);
}
}
if (view.isCustom) {
return view.name;
}
const messageId = generateMessageId(view.name);
const translatedMessage = i18n._(messageId);
return translatedMessage !== messageId ? translatedMessage : view.name;
}
@Query(() => [ViewDTO])
async getCoreViews(
@AuthWorkspace() workspace: Workspace,
@@ -14,10 +14,12 @@ import {
generateViewUserFriendlyExceptionMessage,
} from 'src/engine/core-modules/view/exceptions/view.exception';
import { ViewService } from 'src/engine/core-modules/view/services/view.service';
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
describe('ViewService', () => {
let viewService: ViewService;
let viewRepository: Repository<View>;
let workspaceMetadataCacheService: WorkspaceMetadataCacheService;
const mockView = {
id: 'view-id',
@@ -28,6 +30,7 @@ describe('ViewService', () => {
icon: 'test-icon',
position: 0,
isCompact: false,
isCustom: true,
key: 'INDEX',
openRecordIn: ViewOpenRecordIn.SIDE_PANEL,
kanbanAggregateOperation: null,
@@ -53,6 +56,12 @@ describe('ViewService', () => {
delete: jest.fn(),
},
},
{
provide: WorkspaceMetadataCacheService,
useValue: {
getExistingOrRecomputeMetadataMaps: jest.fn(),
},
},
],
}).compile();
@@ -60,6 +69,9 @@ describe('ViewService', () => {
viewRepository = module.get<Repository<View>>(
getRepositoryToken(View, 'core'),
);
workspaceMetadataCacheService = module.get<WorkspaceMetadataCacheService>(
WorkspaceMetadataCacheService,
);
});
it('should be defined', () => {
@@ -181,7 +193,10 @@ describe('ViewService', () => {
const result = await viewService.create(validViewData);
expect(viewRepository.create).toHaveBeenCalledWith(validViewData);
expect(viewRepository.create).toHaveBeenCalledWith({
...validViewData,
isCustom: true,
});
expect(viewRepository.save).toHaveBeenCalledWith(mockView);
expect(result).toEqual(mockView);
});
@@ -312,4 +327,99 @@ describe('ViewService', () => {
expect(result).toEqual(true);
});
});
describe('getObjectMetadataByViewId', () => {
it('should return object metadata for a view', async () => {
const viewId = 'view-id';
const workspaceId = 'workspace-id';
const objectMetadataId = 'object-id';
const mockObjectMetadata = {
id: objectMetadataId,
nameSingular: 'TestObject',
namePlural: 'TestObjects',
labelSingular: 'Test Object',
labelPlural: 'Test Objects',
};
jest.spyOn(viewRepository, 'findOne').mockResolvedValue({
objectMetadataId,
} as View);
jest
.spyOn(
workspaceMetadataCacheService,
'getExistingOrRecomputeMetadataMaps',
)
.mockResolvedValue({
objectMetadataMaps: {
byId: {
[objectMetadataId]: mockObjectMetadata,
},
idByNameSingular: {},
},
metadataVersion: 1,
} as any);
const result = await viewService.getObjectMetadataByViewId(
viewId,
workspaceId,
);
expect(viewRepository.findOne).toHaveBeenCalledWith({
where: { id: viewId },
select: ['objectMetadataId'],
});
expect(
workspaceMetadataCacheService.getExistingOrRecomputeMetadataMaps,
).toHaveBeenCalledWith({ workspaceId });
expect(result).toEqual(mockObjectMetadata);
});
it('should return null when view is not found', async () => {
const viewId = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(viewRepository, 'findOne').mockResolvedValue(null);
const result = await viewService.getObjectMetadataByViewId(
viewId,
workspaceId,
);
expect(result).toBeNull();
expect(
workspaceMetadataCacheService.getExistingOrRecomputeMetadataMaps,
).not.toHaveBeenCalled();
});
it('should return null when object metadata is not in cache', async () => {
const viewId = 'view-id';
const workspaceId = 'workspace-id';
const objectMetadataId = 'object-id';
jest.spyOn(viewRepository, 'findOne').mockResolvedValue({
objectMetadataId,
} as View);
jest
.spyOn(
workspaceMetadataCacheService,
'getExistingOrRecomputeMetadataMaps',
)
.mockResolvedValue({
objectMetadataMaps: {
byId: {},
idByNameSingular: {},
},
metadataVersion: 1,
} as any);
const result = await viewService.getObjectMetadataByViewId(
viewId,
workspaceId,
);
expect(result).toBeNull();
});
});
});
@@ -5,6 +5,8 @@ import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { View } from 'src/engine/core-modules/view/entities/view.entity';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
import {
ViewException,
ViewExceptionCode,
@@ -18,6 +20,7 @@ export class ViewService {
constructor(
@InjectRepository(View, 'core')
private readonly viewRepository: Repository<View>,
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
) {}
async findByWorkspaceId(workspaceId: string): Promise<View[]> {
@@ -109,7 +112,10 @@ export class ViewService {
);
}
const view = this.viewRepository.create(viewData);
const view = this.viewRepository.create({
...viewData,
isCustom: true,
});
return this.viewRepository.save(view);
}
@@ -174,4 +180,25 @@ export class ViewService {
return true;
}
async getObjectMetadataByViewId(
viewId: string,
workspaceId: string,
): Promise<ObjectMetadataItemWithFieldMaps | null> {
const view = await this.viewRepository.findOne({
where: { id: viewId },
select: ['objectMetadataId'],
});
if (!view) {
return null;
}
const { objectMetadataMaps } =
await this.workspaceMetadataCacheService.getExistingOrRecomputeMetadataMaps(
{ workspaceId },
);
return objectMetadataMaps.byId[view.objectMetadataId] || null;
}
}
@@ -26,6 +26,7 @@ import { ViewFilterService } from 'src/engine/core-modules/view/services/view-fi
import { ViewGroupService } from 'src/engine/core-modules/view/services/view-group.service';
import { ViewSortService } from 'src/engine/core-modules/view/services/view-sort.service';
import { ViewService } from 'src/engine/core-modules/view/services/view.service';
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@Module({
@@ -36,6 +37,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
),
AuthModule,
WorkspaceCacheStorageModule,
WorkspaceMetadataCacheModule,
],
controllers: [
ViewController,