Add translations on core views (#13895)
closes https://github.com/twentyhq/twenty/issues/11999
This commit is contained in:
@@ -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,
|
||||
|
||||
+111
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user