diff --git a/packages/twenty-server/src/engine/core-modules/view/controllers/view.controller.ts b/packages/twenty-server/src/engine/core-modules/view/controllers/view.controller.ts index 06d33430e4..ec7159d00a 100644 --- a/packages/twenty-server/src/engine/core-modules/view/controllers/view.controller.ts +++ b/packages/twenty-server/src/engine/core-modules/view/controllers/view.controller.ts @@ -11,6 +11,7 @@ import { UseGuards, } from '@nestjs/common'; +import { type APP_LOCALES } from 'twenty-shared/translations'; import { isDefined } from 'twenty-shared/utils'; import { CreateViewInput } from 'src/engine/core-modules/view/dtos/inputs/create-view.input'; @@ -27,32 +28,40 @@ import { ViewRestApiExceptionFilter } from 'src/engine/core-modules/view/filters import { ViewService } from 'src/engine/core-modules/view/services/view.service'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { RequestLocale } from 'src/engine/decorators/locale/request-locale.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'; +import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service'; @Controller('rest/metadata/views') @UseGuards(WorkspaceAuthGuard) @UseFilters(ViewRestApiExceptionFilter) export class ViewController { - constructor(private readonly viewService: ViewService) {} + constructor( + private readonly viewService: ViewService, + private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService, + ) {} @Get() async findMany( + @RequestLocale() locale: keyof typeof APP_LOCALES | undefined, @AuthWorkspace() workspace: Workspace, @Query('objectMetadataId') objectMetadataId?: string, ): Promise { - if (objectMetadataId) { - return this.viewService.findByObjectMetadataId( - workspace.id, - objectMetadataId, - ); - } + const views = objectMetadataId + ? await this.viewService.findByObjectMetadataId( + workspace.id, + objectMetadataId, + ) + : await this.viewService.findByWorkspaceId(workspace.id); - return this.viewService.findByWorkspaceId(workspace.id); + return this.processViewsWithTemplates(views, workspace.id, locale); } @Get(':id') async findOne( @Param('id') id: string, + @RequestLocale() locale: keyof typeof APP_LOCALES | undefined, @AuthWorkspace() workspace: Workspace, ): Promise { const view = await this.viewService.findById(id, workspace.id); @@ -72,29 +81,51 @@ export class ViewController { ); } - return view; + const processedViews = await this.processViewsWithTemplates( + [view], + workspace.id, + locale, + ); + + return processedViews[0]; } @Post() async create( @Body() input: CreateViewInput, @AuthWorkspace() workspace: Workspace, + @RequestLocale() locale?: keyof typeof APP_LOCALES, ): Promise { - return this.viewService.create({ + const view = await this.viewService.create({ ...input, workspaceId: workspace.id, }); + + const processedViews = await this.processViewsWithTemplates( + [view], + workspace.id, + locale, + ); + + return processedViews[0]; } @Patch(':id') async update( @Param('id') id: string, @Body() input: UpdateViewInput, + @RequestLocale() locale: keyof typeof APP_LOCALES | undefined, @AuthWorkspace() workspace: Workspace, ): Promise { const updatedView = await this.viewService.update(id, workspace.id, input); - return updatedView; + const processedViews = await this.processViewsWithTemplates( + [updatedView], + workspace.id, + locale, + ); + + return processedViews[0]; } @Delete(':id') @@ -107,5 +138,66 @@ export class ViewController { return { success: isDefined(deletedView) }; } + private async processViewsWithTemplates( + views: ViewDTO[], + workspaceId: string, + locale?: keyof typeof APP_LOCALES, + ): Promise { + const hasTemplates = views.some((view) => + view.name.includes('{objectLabelPlural}'), + ); + + if (!hasTemplates && views.every((view) => view.isCustom)) { + return views; + } + + const { objectMetadataMaps } = + await this.workspaceMetadataCacheService.getExistingOrRecomputeMetadataMaps( + { workspaceId }, + ); + + return views.map((view) => { + let processedName = view.name; + + if (view.name.includes('{objectLabelPlural}')) { + const objectMetadata = objectMetadataMaps.byId[view.objectMetadataId]; + + 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', + locale, + ); + + processedName = this.viewService.processViewNameWithTemplate( + view.name, + view.isCustom, + translatedObjectLabel, + locale, + ); + } + } else { + processedName = this.viewService.processViewNameWithTemplate( + view.name, + view.isCustom, + undefined, + locale, + ); + } + + return { + ...view, + name: processedName, + }; + }); + } + // TODO: the destroy endpoint will be implemented when we settle on a strategy } diff --git a/packages/twenty-server/src/engine/core-modules/view/resolvers/view.resolver.ts b/packages/twenty-server/src/engine/core-modules/view/resolvers/view.resolver.ts index e47df63091..94573eeae9 100644 --- a/packages/twenty-server/src/engine/core-modules/view/resolvers/view.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/view/resolvers/view.resolver.ts @@ -12,9 +12,7 @@ import { import { isArray } from '@sniptt/guards'; import { isDefined } from 'twenty-shared/utils'; -import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; 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 { ViewFieldDTO } from 'src/engine/core-modules/view/dtos/view-field.dto'; @@ -47,7 +45,6 @@ export class ViewResolver { private readonly viewFilterGroupService: ViewFilterGroupService, private readonly viewSortService: ViewSortService, private readonly viewGroupService: ViewGroupService, - private readonly i18nService: I18nService, ) {} @ResolveField(() => String) @@ -76,34 +73,21 @@ export class ViewResolver { context.req.locale, ); - const messageId = generateMessageId(view.name); - const translatedTemplate = this.i18nService.translateMessage({ - messageId, - values: { - objectLabelPlural: translatedObjectLabel, - }, - locale: context.req.locale, - }); - - if (translatedTemplate !== messageId) { - return translatedTemplate; - } - - return view.name.replace('{objectLabelPlural}', translatedObjectLabel); + return this.viewService.processViewNameWithTemplate( + view.name, + view.isCustom, + translatedObjectLabel, + context.req.locale, + ); } } - if (view.isCustom) { - return view.name; - } - - const messageId = generateMessageId(view.name); - const translatedMessage = this.i18nService.translateMessage({ - messageId, - locale: context.req.locale, - }); - - return translatedMessage !== messageId ? translatedMessage : view.name; + return this.viewService.processViewNameWithTemplate( + view.name, + view.isCustom, + undefined, + context.req.locale, + ); } @Query(() => [ViewDTO]) diff --git a/packages/twenty-server/src/engine/core-modules/view/services/tests/view.service.spec.ts b/packages/twenty-server/src/engine/core-modules/view/services/tests/view.service.spec.ts index 4c3ebbc31a..e2114c88e2 100644 --- a/packages/twenty-server/src/engine/core-modules/view/services/tests/view.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/view/services/tests/view.service.spec.ts @@ -3,6 +3,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { type Repository } from 'typeorm'; +import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; import { ViewEntity } from 'src/engine/core-modules/view/entities/view.entity'; import { ViewOpenRecordIn } from 'src/engine/core-modules/view/enums/view-open-record-in'; import { ViewType } from 'src/engine/core-modules/view/enums/view-type.enum'; @@ -18,6 +19,7 @@ import { ViewService } from 'src/engine/core-modules/view/services/view.service' describe('ViewService', () => { let viewService: ViewService; let viewRepository: Repository; + let i18nService: I18nService; const mockView = { id: 'view-id', @@ -54,6 +56,12 @@ describe('ViewService', () => { delete: jest.fn(), }, }, + { + provide: I18nService, + useValue: { + translateMessage: jest.fn(), + }, + }, ], }).compile(); @@ -61,6 +69,7 @@ describe('ViewService', () => { viewRepository = module.get>( getRepositoryToken(ViewEntity), ); + i18nService = module.get(I18nService); }); it('should be defined', () => { @@ -316,4 +325,74 @@ describe('ViewService', () => { expect(result).toEqual(true); }); }); + + describe('processViewNameWithTemplate', () => { + it('should replace template with objectLabelPlural', () => { + const viewName = 'All {objectLabelPlural}'; + const objectLabelPlural = 'Companies'; + + jest.spyOn(i18nService, 'translateMessage').mockImplementation((args) => { + return args.messageId; + }); + + const result = viewService.processViewNameWithTemplate( + viewName, + false, + objectLabelPlural, + 'en', + ); + + expect(result).toBe('All Companies'); + }); + + it('should return translated value when translation exists', () => { + const viewName = 'All {objectLabelPlural}'; + const objectLabelPlural = 'Companies'; + const translatedTemplate = 'Toutes les Companies'; + + jest + .spyOn(i18nService, 'translateMessage') + .mockReturnValue(translatedTemplate); + + const result = viewService.processViewNameWithTemplate( + viewName, + false, + objectLabelPlural, + 'fr-FR', + ); + + expect(result).toBe(translatedTemplate); + }); + + it('should not translate custom views', () => { + const viewName = 'My Custom View'; + + const result = viewService.processViewNameWithTemplate( + viewName, + true, + undefined, + 'en', + ); + + expect(i18nService.translateMessage).not.toHaveBeenCalled(); + expect(result).toBe(viewName); + }); + + it('should return original name when no objectLabelPlural provided for template', () => { + const viewName = 'All {objectLabelPlural}'; + + jest.spyOn(i18nService, 'translateMessage').mockImplementation((args) => { + return args.messageId; + }); + + const result = viewService.processViewNameWithTemplate( + viewName, + false, + undefined, + 'en', + ); + + expect(result).toBe(viewName); + }); + }); }); diff --git a/packages/twenty-server/src/engine/core-modules/view/services/view.service.ts b/packages/twenty-server/src/engine/core-modules/view/services/view.service.ts index 913a32f2ad..35cb861391 100644 --- a/packages/twenty-server/src/engine/core-modules/view/services/view.service.ts +++ b/packages/twenty-server/src/engine/core-modules/view/services/view.service.ts @@ -1,9 +1,12 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations'; import { isDefined } from 'twenty-shared/utils'; import { IsNull, Repository } from 'typeorm'; +import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; +import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId'; import { ViewEntity } from 'src/engine/core-modules/view/entities/view.entity'; import { ViewException, @@ -12,13 +15,13 @@ import { generateViewExceptionMessage, generateViewUserFriendlyExceptionMessage, } from 'src/engine/core-modules/view/exceptions/view.exception'; -import { ViewDTO } from 'src/engine/core-modules/view/dtos/view.dto'; @Injectable() export class ViewService { constructor( @InjectRepository(ViewEntity) private readonly viewRepository: Repository, + private readonly i18nService: I18nService, ) {} async findByWorkspaceId(workspaceId: string): Promise { @@ -81,7 +84,7 @@ export class ViewService { return view || null; } - async create(viewData: Partial): Promise { + async create(viewData: Partial): Promise { if (!isDefined(viewData.workspaceId)) { throw new ViewException( generateViewExceptionMessage( @@ -178,4 +181,42 @@ export class ViewService { return true; } + + processViewNameWithTemplate( + viewName: string, + isCustom: boolean, + objectLabelPlural?: string, + locale?: keyof typeof APP_LOCALES, + ): string { + if (viewName.includes('{objectLabelPlural}') && objectLabelPlural) { + const messageId = generateMessageId(viewName); + const translatedTemplate = this.i18nService.translateMessage({ + messageId, + values: { + objectLabelPlural, + }, + locale: locale ?? SOURCE_LOCALE, + }); + + if (translatedTemplate !== messageId) { + return translatedTemplate; + } + + return viewName.replace('{objectLabelPlural}', objectLabelPlural); + } + + if (!isCustom) { + const messageId = generateMessageId(viewName); + const translatedMessage = this.i18nService.translateMessage({ + messageId, + locale: locale ?? SOURCE_LOCALE, + }); + + if (translatedMessage !== messageId) { + return translatedMessage; + } + } + + return viewName; + } } diff --git a/packages/twenty-server/src/engine/decorators/locale/request-locale.decorator.ts b/packages/twenty-server/src/engine/decorators/locale/request-locale.decorator.ts new file mode 100644 index 0000000000..3600da36a1 --- /dev/null +++ b/packages/twenty-server/src/engine/decorators/locale/request-locale.decorator.ts @@ -0,0 +1,16 @@ +import { type ExecutionContext, createParamDecorator } from '@nestjs/common'; + +import { type APP_LOCALES } from 'twenty-shared/translations'; + +import { getRequest } from 'src/utils/extract-request'; + +export const RequestLocale = createParamDecorator( + ( + _data: unknown, + ctx: ExecutionContext, + ): keyof typeof APP_LOCALES | undefined => { + const request = getRequest(ctx); + + return request.locale; + }, +);