From 3d2d14eb7199bc374a0301c7c5efef89a157e4fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Tue, 9 Sep 2025 14:55:22 +0200 Subject: [PATCH] Pre hook for dashboard creation + updates on the entity (#14354) Closes https://github.com/twentyhq/core-team-issues/issues/1417 - The pre-hook creates a page-layout and links it to the dashboard - Added `position`, `createdBy`, `attachments`, `searchVector`, `favorites` to the dashboard entity - Updated the view seed - Updated the page layout services to work within a transaction --- .../workspace-query-hook.module.ts | 2 + .../dtos/inputs/create-page-layout.input.ts | 2 +- .../dtos/inputs/update-page-layout.input.ts | 2 +- .../page-layout/page-layout.module.ts | 2 + .../services/page-layout-tab.service.ts | 91 ++++++++++--- .../services/page-layout-widget.service.ts | 78 ++++++++--- .../services/page-layout.service.ts | 121 +++++++++++++++--- .../tests/page-layout-tab.service.spec.ts | 41 +++--- .../tests/page-layout-widget.service.spec.ts | 43 +++---- .../tests/page-layout.service.spec.ts | 79 +++++++++++- .../views/dashboards-all.view.ts | 16 ++- .../constants/standard-field-ids.ts | 9 ++ .../attachment.workspace-entity.ts | 19 ++- .../dashboard-create-one.pre-query.hook.ts | 63 +++++++++ .../dashboard-query-hook.module.ts | 10 ++ .../dashboard.workspace-entity.ts | 91 ++++++++++++- .../favorite.workspace-entity.ts | 17 +++ .../timeline-activity.workspace-entity.ts | 19 ++- .../page-layout-resolver.integration-spec.ts | 67 ++++++++++ 19 files changed, 657 insertions(+), 115 deletions(-) create mode 100644 packages/twenty-server/src/modules/dashboard/query-hooks/dashboard-create-one.pre-query.hook.ts create mode 100644 packages/twenty-server/src/modules/dashboard/query-hooks/dashboard-query-hook.module.ts diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.module.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.module.ts index 04e48b8c9da..77bd3216ae6 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.module.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/workspace-query-hook.module.ts @@ -8,6 +8,7 @@ import { WorkspaceQueryHookService } from 'src/engine/api/graphql/workspace-quer import { BlocklistQueryHookModule } from 'src/modules/blocklist/query-hooks/blocklist-query-hook.module'; import { CalendarQueryHookModule } from 'src/modules/calendar/common/query-hooks/calendar-query-hook.module'; import { ConnectedAccountQueryHookModule } from 'src/modules/connected-account/query-hooks/connected-account-query-hook.module'; +import { DashboardQueryHookModule } from 'src/modules/dashboard/query-hooks/dashboard-query-hook.module'; import { MessagingQueryHookModule } from 'src/modules/messaging/common/query-hooks/messaging-query-hook.module'; import { WorkspaceMemberQueryHookModule } from 'src/modules/workspace-member/query-hooks/workspace-member-query-hook.module'; @@ -16,6 +17,7 @@ import { WorkspaceMemberQueryHookModule } from 'src/modules/workspace-member/que MessagingQueryHookModule, CalendarQueryHookModule, ConnectedAccountQueryHookModule, + DashboardQueryHookModule, BlocklistQueryHookModule, WorkspaceMemberQueryHookModule, DiscoveryModule, diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/create-page-layout.input.ts b/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/create-page-layout.input.ts index 4624906e955..5b5311373c4 100644 --- a/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/create-page-layout.input.ts +++ b/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/create-page-layout.input.ts @@ -29,5 +29,5 @@ export class CreatePageLayoutInput { @Field(() => UUIDScalarType, { nullable: true }) @IsUUID() @IsOptional() - objectMetadataId?: string; + objectMetadataId?: string | null; } diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/update-page-layout.input.ts b/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/update-page-layout.input.ts index dff713c97d6..ba939a3fbbf 100644 --- a/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/update-page-layout.input.ts +++ b/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/update-page-layout.input.ts @@ -20,5 +20,5 @@ export class UpdatePageLayoutInput { @Field(() => UUIDScalarType, { nullable: true }) @IsUUID() @IsOptional() - objectMetadataId?: string; + objectMetadataId?: string | null; } diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/page-layout.module.ts b/packages/twenty-server/src/engine/core-modules/page-layout/page-layout.module.ts index b53e5390124..a965a9dff03 100644 --- a/packages/twenty-server/src/engine/core-modules/page-layout/page-layout.module.ts +++ b/packages/twenty-server/src/engine/core-modules/page-layout/page-layout.module.ts @@ -13,6 +13,7 @@ import { PageLayoutResolver } from 'src/engine/core-modules/page-layout/resolver import { PageLayoutTabService } from 'src/engine/core-modules/page-layout/services/page-layout-tab.service'; import { PageLayoutWidgetService } from 'src/engine/core-modules/page-layout/services/page-layout-widget.service'; import { PageLayoutService } from 'src/engine/core-modules/page-layout/services/page-layout.service'; +import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; @Module({ imports: [ @@ -21,6 +22,7 @@ import { PageLayoutService } from 'src/engine/core-modules/page-layout/services/ PageLayoutTabEntity, PageLayoutWidgetEntity, ]), + TwentyORMModule, ], controllers: [ PageLayoutController, diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/services/page-layout-tab.service.ts b/packages/twenty-server/src/engine/core-modules/page-layout/services/page-layout-tab.service.ts index b11cf09747f..ed346597c21 100644 --- a/packages/twenty-server/src/engine/core-modules/page-layout/services/page-layout-tab.service.ts +++ b/packages/twenty-server/src/engine/core-modules/page-layout/services/page-layout-tab.service.ts @@ -2,9 +2,10 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { isDefined } from 'twenty-shared/utils'; -import { IsNull, Repository } from 'typeorm'; +import { EntityManager, IsNull, Repository } from 'typeorm'; import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; +import { CreatePageLayoutTabInput } from 'src/engine/core-modules/page-layout/dtos/inputs/create-page-layout-tab.input'; import { PageLayoutTabEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-tab.entity'; import { PageLayoutTabException, @@ -26,11 +27,22 @@ export class PageLayoutTabService { private readonly pageLayoutService: PageLayoutService, ) {} + private getPageLayoutTabRepository( + transactionManager?: EntityManager, + ): Repository { + return transactionManager + ? transactionManager.getRepository(PageLayoutTabEntity) + : this.pageLayoutTabRepository; + } + async findByPageLayoutId( workspaceId: string, pageLayoutId: string, + transactionManager?: EntityManager, ): Promise { - return this.pageLayoutTabRepository.find({ + const repository = this.getPageLayoutTabRepository(transactionManager); + + return repository.find({ where: { pageLayoutId, pageLayout: { workspaceId }, @@ -44,8 +56,11 @@ export class PageLayoutTabService { async findByIdOrThrow( id: string, workspaceId: string, + transactionManager?: EntityManager, ): Promise { - const pageLayoutTab = await this.pageLayoutTabRepository.findOne({ + const repository = this.getPageLayoutTabRepository(transactionManager); + + const pageLayoutTab = await repository.findOne({ where: { id, workspaceId, @@ -68,8 +83,9 @@ export class PageLayoutTabService { } async create( - pageLayoutTabData: Partial, + pageLayoutTabData: CreatePageLayoutTabInput, workspaceId: string, + transactionManager?: EntityManager, ): Promise { if (!isDefined(pageLayoutTabData.title)) { throw new PageLayoutTabException( @@ -93,14 +109,21 @@ export class PageLayoutTabService { await this.pageLayoutService.findByIdOrThrow( pageLayoutTabData.pageLayoutId, workspaceId, + transactionManager, ); - const pageLayoutTab = this.pageLayoutTabRepository.create({ + const repository = this.getPageLayoutTabRepository(transactionManager); + + const insertResult = await repository.insert({ ...pageLayoutTabData, workspaceId, }); - return this.pageLayoutTabRepository.save(pageLayoutTab); + return this.findByIdOrThrow( + insertResult.identifiers[0].id, + workspaceId, + transactionManager, + ); } catch (error) { if ( error instanceof PageLayoutException && @@ -121,8 +144,11 @@ export class PageLayoutTabService { id: string, workspaceId: string, updateData: QueryDeepPartialEntity, + transactionManager?: EntityManager, ): Promise { - const existingTab = await this.pageLayoutTabRepository.findOne({ + const repository = this.getPageLayoutTabRepository(transactionManager); + + const existingTab = await repository.findOne({ where: { id, workspaceId, @@ -140,21 +166,37 @@ export class PageLayoutTabService { ); } - await this.pageLayoutTabRepository.update({ id }, updateData); + await repository.update({ id }, updateData); - return this.findByIdOrThrow(id, workspaceId); + return this.findByIdOrThrow(id, workspaceId, transactionManager); } - async delete(id: string, workspaceId: string): Promise { - const pageLayoutTab = await this.findByIdOrThrow(id, workspaceId); + async delete( + id: string, + workspaceId: string, + transactionManager?: EntityManager, + ): Promise { + const pageLayoutTab = await this.findByIdOrThrow( + id, + workspaceId, + transactionManager, + ); - await this.pageLayoutTabRepository.softDelete(id); + const repository = this.getPageLayoutTabRepository(transactionManager); + + await repository.softDelete(id); return pageLayoutTab; } - async destroy(id: string, workspaceId: string): Promise { - const pageLayoutTab = await this.pageLayoutTabRepository.findOne({ + async destroy( + id: string, + workspaceId: string, + transactionManager?: EntityManager, + ): Promise { + const repository = this.getPageLayoutTabRepository(transactionManager); + + const pageLayoutTab = await repository.findOne({ where: { id, workspaceId, @@ -172,13 +214,19 @@ export class PageLayoutTabService { ); } - await this.pageLayoutTabRepository.delete(id); + await repository.delete(id); return true; } - async restore(id: string, workspaceId: string): Promise { - const pageLayoutTab = await this.pageLayoutTabRepository.findOne({ + async restore( + id: string, + workspaceId: string, + transactionManager?: EntityManager, + ): Promise { + const repository = this.getPageLayoutTabRepository(transactionManager); + + const pageLayoutTab = await repository.findOne({ select: { id: true, deletedAt: true, @@ -214,6 +262,7 @@ export class PageLayoutTabService { await this.pageLayoutService.findByIdOrThrow( pageLayoutTab.pageLayoutId, workspaceId, + transactionManager, ); } catch (error) { if ( @@ -230,9 +279,13 @@ export class PageLayoutTabService { throw error; } - await this.pageLayoutTabRepository.restore(id); + await repository.restore(id); - const restoredPageLayoutTab = await this.findByIdOrThrow(id, workspaceId); + const restoredPageLayoutTab = await this.findByIdOrThrow( + id, + workspaceId, + transactionManager, + ); return restoredPageLayoutTab; } diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/services/page-layout-widget.service.ts b/packages/twenty-server/src/engine/core-modules/page-layout/services/page-layout-widget.service.ts index 3c11b0fd32c..e91bec091be 100644 --- a/packages/twenty-server/src/engine/core-modules/page-layout/services/page-layout-widget.service.ts +++ b/packages/twenty-server/src/engine/core-modules/page-layout/services/page-layout-widget.service.ts @@ -2,9 +2,10 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { isDefined } from 'twenty-shared/utils'; -import { IsNull, Repository } from 'typeorm'; +import { EntityManager, IsNull, Repository } from 'typeorm'; import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; +import { CreatePageLayoutWidgetInput } from 'src/engine/core-modules/page-layout/dtos/inputs/create-page-layout-widget.input'; import { UpdatePageLayoutWidgetInput } from 'src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-widget.input'; import { PageLayoutWidgetEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-widget.entity'; import { @@ -27,11 +28,22 @@ export class PageLayoutWidgetService { private readonly pageLayoutTabService: PageLayoutTabService, ) {} + private getPageLayoutWidgetRepository( + transactionManager?: EntityManager, + ): Repository { + return transactionManager + ? transactionManager.getRepository(PageLayoutWidgetEntity) + : this.pageLayoutWidgetRepository; + } + async findByPageLayoutTabId( workspaceId: string, pageLayoutTabId: string, + transactionManager?: EntityManager, ): Promise { - return this.pageLayoutWidgetRepository.find({ + const repository = this.getPageLayoutWidgetRepository(transactionManager); + + return repository.find({ where: { pageLayoutTabId, workspaceId, @@ -44,8 +56,11 @@ export class PageLayoutWidgetService { async findByIdOrThrow( id: string, workspaceId: string, + transactionManager?: EntityManager, ): Promise { - const pageLayoutWidget = await this.pageLayoutWidgetRepository.findOne({ + const repository = this.getPageLayoutWidgetRepository(transactionManager); + + const pageLayoutWidget = await repository.findOne({ where: { id, workspaceId, @@ -67,8 +82,9 @@ export class PageLayoutWidgetService { } async create( - pageLayoutWidgetData: Partial, + pageLayoutWidgetData: CreatePageLayoutWidgetInput, workspaceId: string, + transactionManager?: EntityManager, ): Promise { if (!isDefined(pageLayoutWidgetData.title)) { throw new PageLayoutWidgetException( @@ -101,14 +117,21 @@ export class PageLayoutWidgetService { await this.pageLayoutTabService.findByIdOrThrow( pageLayoutWidgetData.pageLayoutTabId, workspaceId, + transactionManager, ); - const pageLayoutWidget = this.pageLayoutWidgetRepository.create({ + const repository = this.getPageLayoutWidgetRepository(transactionManager); + + const insertResult = await repository.insert({ ...pageLayoutWidgetData, workspaceId, - }); + } as QueryDeepPartialEntity); - return this.pageLayoutWidgetRepository.save(pageLayoutWidget); + return this.findByIdOrThrow( + insertResult.identifiers[0].id, + workspaceId, + transactionManager, + ); } catch (error) { if ( error instanceof PageLayoutTabException && @@ -129,8 +152,11 @@ export class PageLayoutWidgetService { id: string, workspaceId: string, updateData: UpdatePageLayoutWidgetInput, + transactionManager?: EntityManager, ): Promise { - const existingWidget = await this.pageLayoutWidgetRepository.findOne({ + const repository = this.getPageLayoutWidgetRepository(transactionManager); + + const existingWidget = await repository.findOne({ where: { id, workspaceId, @@ -148,27 +174,40 @@ export class PageLayoutWidgetService { ); } - await this.pageLayoutWidgetRepository.update( + await repository.update( { id }, updateData as QueryDeepPartialEntity, ); - return this.findByIdOrThrow(id, workspaceId); + return this.findByIdOrThrow(id, workspaceId, transactionManager); } async delete( id: string, workspaceId: string, + transactionManager?: EntityManager, ): Promise { - const pageLayoutWidget = await this.findByIdOrThrow(id, workspaceId); + const pageLayoutWidget = await this.findByIdOrThrow( + id, + workspaceId, + transactionManager, + ); - await this.pageLayoutWidgetRepository.softDelete(id); + const repository = this.getPageLayoutWidgetRepository(transactionManager); + + await repository.softDelete(id); return pageLayoutWidget; } - async destroy(id: string, workspaceId: string): Promise { - const pageLayoutWidget = await this.pageLayoutWidgetRepository.findOne({ + async destroy( + id: string, + workspaceId: string, + transactionManager?: EntityManager, + ): Promise { + const repository = this.getPageLayoutWidgetRepository(transactionManager); + + const pageLayoutWidget = await repository.findOne({ where: { id, workspaceId, @@ -186,7 +225,7 @@ export class PageLayoutWidgetService { ); } - await this.pageLayoutWidgetRepository.delete(id); + await repository.delete(id); return true; } @@ -194,8 +233,11 @@ export class PageLayoutWidgetService { async restore( id: string, workspaceId: string, + transactionManager?: EntityManager, ): Promise { - const pageLayoutWidget = await this.pageLayoutWidgetRepository.findOne({ + const repository = this.getPageLayoutWidgetRepository(transactionManager); + + const pageLayoutWidget = await repository.findOne({ select: { id: true, deletedAt: true, @@ -231,6 +273,7 @@ export class PageLayoutWidgetService { await this.pageLayoutTabService.findByIdOrThrow( pageLayoutWidget.pageLayoutTabId, workspaceId, + transactionManager, ); } catch (error) { if ( @@ -247,11 +290,12 @@ export class PageLayoutWidgetService { throw error; } - await this.pageLayoutWidgetRepository.restore(id); + await repository.restore(id); const restoredPageLayoutWidget = await this.findByIdOrThrow( id, workspaceId, + transactionManager, ); return restoredPageLayoutWidget; diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/services/page-layout.service.ts b/packages/twenty-server/src/engine/core-modules/page-layout/services/page-layout.service.ts index cf3e00e786d..cca013d15bd 100644 --- a/packages/twenty-server/src/engine/core-modules/page-layout/services/page-layout.service.ts +++ b/packages/twenty-server/src/engine/core-modules/page-layout/services/page-layout.service.ts @@ -1,27 +1,46 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { isDefined } from 'twenty-shared/utils'; -import { IsNull, Repository } from 'typeorm'; +import { EntityManager, IsNull, Repository } from 'typeorm'; import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; +import { CreatePageLayoutInput } from 'src/engine/core-modules/page-layout/dtos/inputs/create-page-layout.input'; import { PageLayoutEntity } from 'src/engine/core-modules/page-layout/entities/page-layout.entity'; +import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum'; import { PageLayoutException, PageLayoutExceptionCode, PageLayoutExceptionMessageKey, generatePageLayoutExceptionMessage, } from 'src/engine/core-modules/page-layout/exceptions/page-layout.exception'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; @Injectable() export class PageLayoutService { + private readonly logger = new Logger(PageLayoutService.name); + constructor( @InjectRepository(PageLayoutEntity) private readonly pageLayoutRepository: Repository, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, ) {} - async findByWorkspaceId(workspaceId: string): Promise { - return this.pageLayoutRepository.find({ + private getPageLayoutRepository( + transactionManager?: EntityManager, + ): Repository { + return transactionManager + ? transactionManager.getRepository(PageLayoutEntity) + : this.pageLayoutRepository; + } + + async findByWorkspaceId( + workspaceId: string, + transactionManager?: EntityManager, + ): Promise { + const repository = this.getPageLayoutRepository(transactionManager); + + return repository.find({ where: { workspaceId, deletedAt: IsNull(), @@ -33,8 +52,11 @@ export class PageLayoutService { async findByObjectMetadataId( workspaceId: string, objectMetadataId: string, + transactionManager?: EntityManager, ): Promise { - return this.pageLayoutRepository.find({ + const repository = this.getPageLayoutRepository(transactionManager); + + return repository.find({ where: { workspaceId, objectMetadataId, @@ -47,8 +69,11 @@ export class PageLayoutService { async findByIdOrThrow( id: string, workspaceId: string, + transactionManager?: EntityManager, ): Promise { - const pageLayout = await this.pageLayoutRepository.findOne({ + const repository = this.getPageLayoutRepository(transactionManager); + + const pageLayout = await repository.findOne({ where: { id, workspaceId, @@ -71,8 +96,9 @@ export class PageLayoutService { } async create( - pageLayoutData: Partial, + pageLayoutData: CreatePageLayoutInput, workspaceId: string, + transactionManager?: EntityManager, ): Promise { if (!isDefined(pageLayoutData.name)) { throw new PageLayoutException( @@ -83,36 +109,65 @@ export class PageLayoutService { ); } - const pageLayout = this.pageLayoutRepository.create({ + const repository = this.getPageLayoutRepository(transactionManager); + + const insertResult = await repository.insert({ ...pageLayoutData, workspaceId, }); - return this.pageLayoutRepository.save(pageLayout); + return this.findByIdOrThrow( + insertResult.identifiers[0].id, + workspaceId, + transactionManager, + ); } async update( id: string, workspaceId: string, updateData: QueryDeepPartialEntity, + transactionManager?: EntityManager, ): Promise { - await this.pageLayoutRepository.update({ id, workspaceId }, updateData); + const repository = this.getPageLayoutRepository(transactionManager); - const updatedPageLayout = await this.findByIdOrThrow(id, workspaceId); + await repository.update({ id, workspaceId }, updateData); + + const updatedPageLayout = await this.findByIdOrThrow( + id, + workspaceId, + transactionManager, + ); return updatedPageLayout; } - async delete(id: string, workspaceId: string): Promise { - const pageLayout = await this.findByIdOrThrow(id, workspaceId); + async delete( + id: string, + workspaceId: string, + transactionManager?: EntityManager, + ): Promise { + const pageLayout = await this.findByIdOrThrow( + id, + workspaceId, + transactionManager, + ); - await this.pageLayoutRepository.softDelete(id); + const repository = this.getPageLayoutRepository(transactionManager); + + await repository.softDelete(id); return pageLayout; } - async destroy(id: string, workspaceId: string): Promise { - const pageLayout = await this.pageLayoutRepository.findOne({ + async destroy( + id: string, + workspaceId: string, + transactionManager?: EntityManager, + ): Promise { + const repository = this.getPageLayoutRepository(transactionManager); + + const pageLayout = await repository.findOne({ where: { id, workspaceId, @@ -130,11 +185,43 @@ export class PageLayoutService { ); } - await this.pageLayoutRepository.delete(id); + if (pageLayout.type === PageLayoutType.DASHBOARD) { + await this.destroyAssociatedDashboards(id, workspaceId); + } + + await repository.delete(id); return pageLayout; } + private async destroyAssociatedDashboards( + pageLayoutId: string, + workspaceId: string, + ): Promise { + try { + const dashboardRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + 'dashboard', + { shouldBypassPermissionChecks: true }, + ); + + const dashboards = await dashboardRepository.find({ + where: { + pageLayoutId, + }, + }); + + for (const dashboard of dashboards) { + await dashboardRepository.delete(dashboard.id); + } + } catch (error) { + this.logger.error( + `Failed to destroy associated dashboards for page layout ${pageLayoutId}: ${error}`, + ); + } + } + async restore(id: string, workspaceId: string): Promise { const pageLayout = await this.pageLayoutRepository.findOne({ select: { diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/services/tests/page-layout-tab.service.spec.ts b/packages/twenty-server/src/engine/core-modules/page-layout/services/tests/page-layout-tab.service.spec.ts index c340b26667c..9b6128a0fd9 100644 --- a/packages/twenty-server/src/engine/core-modules/page-layout/services/tests/page-layout-tab.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/page-layout/services/tests/page-layout-tab.service.spec.ts @@ -51,15 +51,6 @@ describe('PageLayoutTabService', () => { deletedAt: null, } as PageLayoutWidgetEntity; - const mockPageLayout = { - id: 'page-layout-id', - workspaceId: 'workspace-id', - title: 'Test Layout', - createdAt: new Date(), - updatedAt: new Date(), - deletedAt: null, - }; - beforeEach(async () => { jest.clearAllMocks(); @@ -77,6 +68,7 @@ describe('PageLayoutTabService', () => { softDelete: jest.fn(), delete: jest.fn(), restore: jest.fn(), + insert: jest.fn(), }, }, { @@ -240,20 +232,21 @@ describe('PageLayoutTabService', () => { it('should create a new page layout tab successfully', async () => { const workspaceId = 'workspace-id'; const pageLayoutTabData = { + id: 'page-layout-tab-id', title: 'New Tab', pageLayoutId: 'page-layout-id', position: 1, }; jest - .spyOn(pageLayoutService, 'findByIdOrThrow') - .mockResolvedValue(mockPageLayout as any); - jest - .spyOn(pageLayoutTabRepository, 'create') - .mockReturnValue(mockPageLayoutTab); - jest - .spyOn(pageLayoutTabRepository, 'save') - .mockResolvedValue(mockPageLayoutTab); + .spyOn(pageLayoutTabService, 'findByIdOrThrow') + .mockResolvedValue(mockPageLayoutTab as any); + + jest.spyOn(pageLayoutTabRepository, 'insert').mockResolvedValue({ + identifiers: [{ id: 'page-layout-tab-id' }], + generatedMaps: [], + raw: [], + }); const result = await pageLayoutTabService.create( pageLayoutTabData, @@ -263,14 +256,12 @@ describe('PageLayoutTabService', () => { expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith( pageLayoutTabData.pageLayoutId, workspaceId, + undefined, ); - expect(pageLayoutTabRepository.create).toHaveBeenCalledWith({ + expect(pageLayoutTabRepository.insert).toHaveBeenCalledWith({ ...pageLayoutTabData, workspaceId, }); - expect(pageLayoutTabRepository.save).toHaveBeenCalledWith( - mockPageLayoutTab, - ); expect(result).toEqual(mockPageLayoutTab); }); @@ -281,9 +272,11 @@ describe('PageLayoutTabService', () => { }; await expect( + // @ts-expect-error - we are testing the exception pageLayoutTabService.create(pageLayoutTabData, workspaceId), ).rejects.toThrow(PageLayoutTabException); await expect( + // @ts-expect-error - we are testing the exception pageLayoutTabService.create(pageLayoutTabData, workspaceId), ).rejects.toHaveProperty( 'code', @@ -298,6 +291,11 @@ describe('PageLayoutTabService', () => { pageLayoutId: 'non-existent-page-layout-id', }; + jest.spyOn(pageLayoutTabRepository, 'insert').mockResolvedValue({ + identifiers: [{ id: 'page-layout-tab-id' }], + generatedMaps: [], + raw: [], + }); jest .spyOn(pageLayoutService, 'findByIdOrThrow') .mockRejectedValue( @@ -586,6 +584,7 @@ describe('PageLayoutTabService', () => { expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith( 'deleted-page-layout-id', workspaceId, + undefined, ); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/services/tests/page-layout-widget.service.spec.ts b/packages/twenty-server/src/engine/core-modules/page-layout/services/tests/page-layout-widget.service.spec.ts index e4d614e7319..f813e3cdec3 100644 --- a/packages/twenty-server/src/engine/core-modules/page-layout/services/tests/page-layout-widget.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/page-layout/services/tests/page-layout-widget.service.spec.ts @@ -38,18 +38,6 @@ describe('PageLayoutWidgetService', () => { deletedAt: null, } as PageLayoutWidgetEntity; - const mockPageLayoutTab = { - id: 'page-layout-tab-id', - title: 'Test Tab', - position: 0, - pageLayoutId: 'page-layout-id', - pageLayout: { workspaceId: 'workspace-id' }, - widgets: [], - createdAt: new Date(), - updatedAt: new Date(), - deletedAt: null, - }; - beforeEach(async () => { jest.clearAllMocks(); @@ -63,6 +51,7 @@ describe('PageLayoutWidgetService', () => { findOne: jest.fn(), create: jest.fn(), save: jest.fn(), + insert: jest.fn(), update: jest.fn(), softDelete: jest.fn(), delete: jest.fn(), @@ -177,6 +166,7 @@ describe('PageLayoutWidgetService', () => { describe('create', () => { const validPageLayoutWidgetData = { + id: 'page-layout-widget-id', title: 'New Widget', pageLayoutTabId: 'page-layout-tab-id', gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 }, @@ -186,14 +176,13 @@ describe('PageLayoutWidgetService', () => { it('should create a new page layout widget successfully', async () => { const workspaceId = 'workspace-id'; + jest.spyOn(pageLayoutWidgetRepository, 'insert').mockResolvedValue({ + identifiers: [{ id: 'page-layout-widget-id' }], + generatedMaps: [], + raw: [], + }); jest - .spyOn(pageLayoutTabService, 'findByIdOrThrow') - .mockResolvedValue(mockPageLayoutTab as any); - jest - .spyOn(pageLayoutWidgetRepository, 'create') - .mockReturnValue(mockPageLayoutWidget); - jest - .spyOn(pageLayoutWidgetRepository, 'save') + .spyOn(pageLayoutWidgetService, 'findByIdOrThrow') .mockResolvedValue(mockPageLayoutWidget); const result = await pageLayoutWidgetService.create( @@ -201,17 +190,10 @@ describe('PageLayoutWidgetService', () => { workspaceId, ); - expect(pageLayoutTabService.findByIdOrThrow).toHaveBeenCalledWith( - validPageLayoutWidgetData.pageLayoutTabId, - workspaceId, - ); - expect(pageLayoutWidgetRepository.create).toHaveBeenCalledWith({ + expect(pageLayoutWidgetRepository.insert).toHaveBeenCalledWith({ ...validPageLayoutWidgetData, workspaceId, }); - expect(pageLayoutWidgetRepository.save).toHaveBeenCalledWith( - mockPageLayoutWidget, - ); expect(result).toEqual(mockPageLayoutWidget); }); @@ -223,9 +205,11 @@ describe('PageLayoutWidgetService', () => { }; await expect( + // @ts-expect-error - we are testing the exception pageLayoutWidgetService.create(pageLayoutWidgetData, workspaceId), ).rejects.toThrow(PageLayoutWidgetException); await expect( + // @ts-expect-error - we are testing the exception pageLayoutWidgetService.create(pageLayoutWidgetData, workspaceId), ).rejects.toHaveProperty( 'code', @@ -241,9 +225,11 @@ describe('PageLayoutWidgetService', () => { }; await expect( + // @ts-expect-error - we are testing the exception pageLayoutWidgetService.create(pageLayoutWidgetData, workspaceId), ).rejects.toThrow(PageLayoutWidgetException); await expect( + // @ts-expect-error - we are testing the exception pageLayoutWidgetService.create(pageLayoutWidgetData, workspaceId), ).rejects.toHaveProperty( 'code', @@ -259,9 +245,11 @@ describe('PageLayoutWidgetService', () => { }; await expect( + // @ts-expect-error - we are testing the exception pageLayoutWidgetService.create(pageLayoutWidgetData, workspaceId), ).rejects.toThrow(PageLayoutWidgetException); await expect( + // @ts-expect-error - we are testing the exception pageLayoutWidgetService.create(pageLayoutWidgetData, workspaceId), ).rejects.toHaveProperty( 'code', @@ -578,6 +566,7 @@ describe('PageLayoutWidgetService', () => { expect(pageLayoutTabService.findByIdOrThrow).toHaveBeenCalledWith( 'deleted-tab-id', workspaceId, + undefined, ); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/services/tests/page-layout.service.spec.ts b/packages/twenty-server/src/engine/core-modules/page-layout/services/tests/page-layout.service.spec.ts index 394d5ac5d80..d2154283ec9 100644 --- a/packages/twenty-server/src/engine/core-modules/page-layout/services/tests/page-layout.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/page-layout/services/tests/page-layout.service.spec.ts @@ -3,6 +3,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { IsNull, type Repository } from 'typeorm'; +import { type CreatePageLayoutInput } from 'src/engine/core-modules/page-layout/dtos/inputs/create-page-layout.input'; import { PageLayoutEntity } from 'src/engine/core-modules/page-layout/entities/page-layout.entity'; import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum'; import { @@ -12,10 +13,12 @@ import { generatePageLayoutExceptionMessage, } from 'src/engine/core-modules/page-layout/exceptions/page-layout.exception'; import { PageLayoutService } from 'src/engine/core-modules/page-layout/services/page-layout.service'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; describe('PageLayoutService', () => { let pageLayoutService: PageLayoutService; let pageLayoutRepository: Repository; + let twentyORMGlobalManager: TwentyORMGlobalManager; const mockPageLayout = { id: 'page-layout-id', @@ -46,6 +49,13 @@ describe('PageLayoutService', () => { softDelete: jest.fn(), delete: jest.fn(), restore: jest.fn(), + insert: jest.fn(), + }, + }, + { + provide: TwentyORMGlobalManager, + useValue: { + getRepositoryForWorkspace: jest.fn(), }, }, ], @@ -55,6 +65,9 @@ describe('PageLayoutService', () => { pageLayoutRepository = module.get>( getRepositoryToken(PageLayoutEntity), ); + twentyORMGlobalManager = module.get( + TwentyORMGlobalManager, + ); }); describe('findByWorkspaceId', () => { @@ -142,17 +155,20 @@ describe('PageLayoutService', () => { describe('create', () => { const validPageLayoutData = { + id: 'page-layout-id', name: 'Test Page Layout', type: PageLayoutType.RECORD_PAGE, objectMetadataId: 'object-metadata-id', }; it('should create a page layout successfully', async () => { + jest.spyOn(pageLayoutRepository, 'insert').mockResolvedValue({ + identifiers: [{ id: 'page-layout-id' }], + generatedMaps: [], + raw: [], + }); jest - .spyOn(pageLayoutRepository, 'create') - .mockReturnValue(mockPageLayout); - jest - .spyOn(pageLayoutRepository, 'save') + .spyOn(pageLayoutService, 'findByIdOrThrow') .mockResolvedValue(mockPageLayout); const result = await pageLayoutService.create( @@ -160,11 +176,10 @@ describe('PageLayoutService', () => { 'workspace-id', ); - expect(pageLayoutRepository.create).toHaveBeenCalledWith({ + expect(pageLayoutRepository.insert).toHaveBeenCalledWith({ ...validPageLayoutData, workspaceId: 'workspace-id', }); - expect(pageLayoutRepository.save).toHaveBeenCalledWith(mockPageLayout); expect(result).toEqual(mockPageLayout); }); @@ -173,7 +188,10 @@ describe('PageLayoutService', () => { const workspaceId = 'workspace-id'; await expect( - pageLayoutService.create(invalidData, workspaceId), + pageLayoutService.create( + invalidData as unknown as CreatePageLayoutInput, + workspaceId, + ), ).rejects.toThrow( new PageLayoutException( generatePageLayoutExceptionMessage( @@ -210,6 +228,7 @@ describe('PageLayoutService', () => { expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith( id, workspaceId, + undefined, ); expect(result).toEqual(updatedPageLayout); }); @@ -262,6 +281,7 @@ describe('PageLayoutService', () => { expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith( id, workspaceId, + undefined, ); expect(pageLayoutRepository.softDelete).toHaveBeenCalledWith(id); expect(result).toEqual(mockPageLayout); @@ -344,6 +364,51 @@ describe('PageLayoutService', () => { ), ); }); + + it('should destroy associated dashboards when page layout is a dashboard', async () => { + const id = 'page-layout-id'; + const workspaceId = 'workspace-id'; + const mockDashboardRepository = { + find: jest.fn(), + delete: jest.fn(), + }; + const mockDashboards = [{ id: 'dashboard', pageLayoutId: id }]; + + jest.spyOn(pageLayoutRepository, 'findOne').mockResolvedValue({ + ...mockPageLayout, + type: PageLayoutType.DASHBOARD, + }); + jest + .spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace') + .mockResolvedValue(mockDashboardRepository as any); + jest + .spyOn(mockDashboardRepository, 'find') + .mockResolvedValue(mockDashboards); + jest + .spyOn(mockDashboardRepository, 'delete') + .mockResolvedValue({} as any); + jest.spyOn(pageLayoutRepository, 'delete').mockResolvedValue({} as any); + + const result = await pageLayoutService.destroy(id, workspaceId); + + expect( + twentyORMGlobalManager.getRepositoryForWorkspace, + ).toHaveBeenCalledWith(workspaceId, 'dashboard', { + shouldBypassPermissionChecks: true, + }); + expect(mockDashboardRepository.find).toHaveBeenCalledWith({ + where: { + pageLayoutId: id, + }, + }); + expect(mockDashboardRepository.delete).toHaveBeenCalledWith('dashboard'); + + expect(pageLayoutRepository.delete).toHaveBeenCalledWith(id); + expect(result).toEqual({ + ...mockPageLayout, + type: PageLayoutType.DASHBOARD, + }); + }); }); describe('restore', () => { diff --git a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/dashboards-all.view.ts b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/dashboards-all.view.ts index 0f595bf1758..72867a53371 100644 --- a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/dashboards-all.view.ts +++ b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/views/dashboards-all.view.ts @@ -6,6 +6,7 @@ import { DASHBOARD_STANDARD_FIELD_IDS, } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; +import { ViewOpenRecordInType } from 'src/modules/view/standard-objects/view.workspace-entity'; export const dashboardsAllView = ( objectMetadataItems: ObjectMetadataEntity[], @@ -27,6 +28,7 @@ export const dashboardsAllView = ( position: 0, icon: 'IconLayoutDashboard', kanbanFieldMetadataId: '', + openRecordIn: ViewOpenRecordInType.RECORD_PAGE, filters: [], fields: [ { @@ -42,7 +44,7 @@ export const dashboardsAllView = ( fieldMetadataId: dashboardObjectMetadata.fields.find( (field) => - field.standardId === BASE_OBJECT_STANDARD_FIELD_IDS.createdAt, + field.standardId === DASHBOARD_STANDARD_FIELD_IDS.createdBy, )?.id ?? '', position: 1, isVisible: true, @@ -52,12 +54,22 @@ export const dashboardsAllView = ( fieldMetadataId: dashboardObjectMetadata.fields.find( (field) => - field.standardId === BASE_OBJECT_STANDARD_FIELD_IDS.updatedAt, + field.standardId === BASE_OBJECT_STANDARD_FIELD_IDS.createdAt, )?.id ?? '', position: 2, isVisible: true, size: 150, }, + { + fieldMetadataId: + dashboardObjectMetadata.fields.find( + (field) => + field.standardId === BASE_OBJECT_STANDARD_FIELD_IDS.updatedAt, + )?.id ?? '', + position: 3, + isVisible: true, + size: 150, + }, ], }; }; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts index 356b1830ef5..d5f151b7210 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids.ts @@ -40,6 +40,7 @@ export const ATTACHMENT_STANDARD_FIELD_IDS = { person: '20202020-0158-4aa2-965c-5cdafe21ffa2', company: '20202020-ceab-4a28-b546-73b06b4c08d5', opportunity: '20202020-7374-499d-bea3-9354890755b5', + dashboard: '20202020-5324-43f3-9dbf-1a33e7de0ce6', custom: '20202020-302d-43b3-9aea-aa4f89282a9f', } as const; @@ -193,6 +194,7 @@ export const TIMELINE_ACTIVITY_STANDARD_FIELD_IDS = { workflow: '20202020-616c-4ad3-a2e9-c477c341e295', workflowVersion: '20202020-74f1-4711-a129-e14ca0ecd744', workflowRun: '20202020-96f0-401b-9186-a3a0759225ac', + dashboard: '20202020-7864-48f5-af7c-9e4b60140948', custom: '20202020-4a71-41b0-9f83-9cdcca3f8b14', linkedRecordCachedName: '20202020-cfdb-4bef-bbce-a29f41230934', linkedRecordId: '20202020-2e0e-48c0-b445-ee6c1e61687d', @@ -213,6 +215,7 @@ export const FAVORITE_STANDARD_FIELD_IDS = { view: '20202020-5a93-4fa9-acce-e73481a0bbdf', custom: '20202020-855a-4bc8-9861-79deef37011f', favoriteFolder: '20202020-f658-4d12-8b4d-248356aa4bd9', + dashboard: '20202020-6ef9-45e4-b440-cc986f687c91', } as const; export const FAVORITE_FOLDER_STANDARD_FIELD_IDS = { @@ -541,7 +544,13 @@ export const CUSTOM_OBJECT_STANDARD_FIELD_IDS = { export const DASHBOARD_STANDARD_FIELD_IDS = { title: '20202020-20ee-4091-95dc-44b57eda3a89', + position: '20202020-38af-409b-95f0-7f08aa5f420f', pageLayoutId: '20202020-bb53-4648-aa36-1d9d54e6f7f2', + createdBy: '20202020-ff32-4fa1-b7ad-407cc6aa0734', + timelineActivities: '20202020-9b0c-5d6e-7f8a-9b0c1d2e3f4a', + favorites: '20202020-f032-478f-88fa-6426ff6f1e4c', + attachments: '20202020-bf6f-4220-8c55-2764f1175870', + searchVector: '20202020-0bcc-47a4-8360-2e35a7133f7a', } as const; export const STANDARD_OBJECT_FIELD_IDS = { diff --git a/packages/twenty-server/src/modules/attachment/standard-objects/attachment.workspace-entity.ts b/packages/twenty-server/src/modules/attachment/standard-objects/attachment.workspace-entity.ts index c6d1906cfbf..681372c8001 100644 --- a/packages/twenty-server/src/modules/attachment/standard-objects/attachment.workspace-entity.ts +++ b/packages/twenty-server/src/modules/attachment/standard-objects/attachment.workspace-entity.ts @@ -1,9 +1,9 @@ import { msg } from '@lingui/core/macro'; import { FieldMetadataType } from 'twenty-shared/types'; +import { RelationOnDeleteAction } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-on-delete-action.interface'; import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; -import { RelationOnDeleteAction } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-on-delete-action.interface'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { CustomWorkspaceEntity } from 'src/engine/twenty-orm/custom.workspace-entity'; @@ -18,6 +18,7 @@ import { ATTACHMENT_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/work import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-icons'; import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity'; +import { DashboardWorkspaceEntity } from 'src/modules/dashboard/standard-objects/dashboard.workspace-entity'; import { NoteWorkspaceEntity } from 'src/modules/note/standard-objects/note.workspace-entity'; import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity'; import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity'; @@ -158,6 +159,22 @@ export class AttachmentWorkspaceEntity extends BaseWorkspaceEntity { @WorkspaceJoinColumn('opportunity') opportunityId: string | null; + @WorkspaceRelation({ + standardId: ATTACHMENT_STANDARD_FIELD_IDS.dashboard, + type: RelationType.MANY_TO_ONE, + label: msg`Dashboard`, + description: msg`Attachment dashboard`, + icon: 'IconLayout', + inverseSideTarget: () => DashboardWorkspaceEntity, + inverseSideFieldKey: 'attachments', + onDelete: RelationOnDeleteAction.CASCADE, + }) + @WorkspaceIsNullable() + dashboard: Relation | null; + + @WorkspaceJoinColumn('dashboard') + dashboardId: string | null; + @WorkspaceDynamicRelation({ type: RelationType.MANY_TO_ONE, argsFactory: (oppositeObjectMetadata) => ({ diff --git a/packages/twenty-server/src/modules/dashboard/query-hooks/dashboard-create-one.pre-query.hook.ts b/packages/twenty-server/src/modules/dashboard/query-hooks/dashboard-create-one.pre-query.hook.ts new file mode 100644 index 00000000000..bf99397ed3a --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/query-hooks/dashboard-create-one.pre-query.hook.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; + +import { DataSource } from 'typeorm'; + +import { type WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface'; +import { type CreateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; + +import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator'; +import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum'; +import { PageLayoutTabService } from 'src/engine/core-modules/page-layout/services/page-layout-tab.service'; +import { PageLayoutService } from 'src/engine/core-modules/page-layout/services/page-layout.service'; +import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate'; +import { type DashboardWorkspaceEntity } from 'src/modules/dashboard/standard-objects/dashboard.workspace-entity'; + +@Injectable() +@WorkspaceQueryHook(`dashboard.createOne`) +export class DashboardCreateOnePreQueryHook + implements WorkspacePreQueryHookInstance +{ + constructor( + private readonly pageLayoutService: PageLayoutService, + private readonly pageLayoutTabService: PageLayoutTabService, + @InjectDataSource() + private readonly coreDataSource: DataSource, + ) {} + + async execute( + authContext: AuthContext, + _objectName: string, + payload: CreateOneResolverArgs, + ): Promise> { + const workspace = authContext.workspace; + + workspaceValidator.assertIsDefinedOrThrow(workspace); + + return await this.coreDataSource.transaction(async (manager) => { + const pageLayout = await this.pageLayoutService.create( + { + type: PageLayoutType.DASHBOARD, + objectMetadataId: null, + name: 'Dashboard Layout', + }, + workspace.id, + manager, + ); + + await this.pageLayoutTabService.create( + { + title: 'Tab 1', + pageLayoutId: pageLayout.id, + }, + workspace.id, + manager, + ); + + payload.data.pageLayoutId = pageLayout.id; + + return payload; + }); + } +} diff --git a/packages/twenty-server/src/modules/dashboard/query-hooks/dashboard-query-hook.module.ts b/packages/twenty-server/src/modules/dashboard/query-hooks/dashboard-query-hook.module.ts new file mode 100644 index 00000000000..c1a9eccf7e0 --- /dev/null +++ b/packages/twenty-server/src/modules/dashboard/query-hooks/dashboard-query-hook.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; + +import { PageLayoutModule } from 'src/engine/core-modules/page-layout/page-layout.module'; +import { DashboardCreateOnePreQueryHook } from 'src/modules/dashboard/query-hooks/dashboard-create-one.pre-query.hook'; + +@Module({ + imports: [PageLayoutModule], + providers: [DashboardCreateOnePreQueryHook], +}) +export class DashboardQueryHookModule {} diff --git a/packages/twenty-server/src/modules/dashboard/standard-objects/dashboard.workspace-entity.ts b/packages/twenty-server/src/modules/dashboard/standard-objects/dashboard.workspace-entity.ts index 446622d0ef8..1daf738c726 100644 --- a/packages/twenty-server/src/modules/dashboard/standard-objects/dashboard.workspace-entity.ts +++ b/packages/twenty-server/src/modules/dashboard/standard-objects/dashboard.workspace-entity.ts @@ -1,14 +1,30 @@ import { msg } from '@lingui/core/macro'; import { FieldMetadataType } from 'twenty-shared/types'; +import { RelationOnDeleteAction } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-on-delete-action.interface'; +import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; +import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; + import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants'; +import { ActorMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; +import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator'; import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator'; import { WorkspaceGate } from 'src/engine/twenty-orm/decorators/workspace-gate.decorator'; +import { WorkspaceIsFieldUIReadOnly } from 'src/engine/twenty-orm/decorators/workspace-is-field-ui-readonly.decorator'; +import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator'; +import { WorkspaceIsSearchable } from 'src/engine/twenty-orm/decorators/workspace-is-searchable.decorator'; +import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; +import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator'; import { DASHBOARD_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-icons'; import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; +import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity'; +import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity'; +import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity'; @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.dashboard, @@ -22,6 +38,7 @@ import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync @WorkspaceGate({ featureFlag: FeatureFlagKey.IS_PAGE_LAYOUT_ENABLED, }) +@WorkspaceIsSearchable() export class DashboardWorkspaceEntity extends BaseWorkspaceEntity { @WorkspaceField({ standardId: DASHBOARD_STANDARD_FIELD_IDS.title, @@ -39,5 +56,77 @@ export class DashboardWorkspaceEntity extends BaseWorkspaceEntity { description: msg`Dashboard page layout`, icon: 'IconLayout', }) - pageLayoutId: string; + @WorkspaceIsNullable() + @WorkspaceIsFieldUIReadOnly() + pageLayoutId: string | null; + + @WorkspaceField({ + standardId: DASHBOARD_STANDARD_FIELD_IDS.position, + type: FieldMetadataType.POSITION, + label: msg`Position`, + description: msg`Dashboard record Position`, + icon: 'IconHierarchy2', + defaultValue: 0, + }) + @WorkspaceIsSystem() + position: number; + + @WorkspaceField({ + standardId: DASHBOARD_STANDARD_FIELD_IDS.createdBy, + type: FieldMetadataType.ACTOR, + label: msg`Created by`, + icon: 'IconCreativeCommonsSa', + description: msg`The creator of the record`, + }) + @WorkspaceIsFieldUIReadOnly() + createdBy: ActorMetadata; + + @WorkspaceRelation({ + standardId: DASHBOARD_STANDARD_FIELD_IDS.timelineActivities, + type: RelationType.ONE_TO_MANY, + label: msg`Timeline Activities`, + description: msg`Timeline activities linked to the dashboard`, + inverseSideTarget: () => TimelineActivityWorkspaceEntity, + onDelete: RelationOnDeleteAction.SET_NULL, + }) + @WorkspaceIsNullable() + @WorkspaceIsSystem() + timelineActivities: Relation; + + @WorkspaceRelation({ + standardId: DASHBOARD_STANDARD_FIELD_IDS.favorites, + type: RelationType.ONE_TO_MANY, + label: msg`Favorites`, + description: msg`Favorites linked to the dashboard`, + inverseSideTarget: () => FavoriteWorkspaceEntity, + onDelete: RelationOnDeleteAction.CASCADE, + }) + @WorkspaceIsSystem() + favorites: Relation; + + @WorkspaceRelation({ + standardId: DASHBOARD_STANDARD_FIELD_IDS.attachments, + type: RelationType.ONE_TO_MANY, + label: msg`Attachments`, + description: msg`Attachments linked to the dashboard`, + inverseSideTarget: () => AttachmentWorkspaceEntity, + onDelete: RelationOnDeleteAction.CASCADE, + }) + @WorkspaceIsNullable() + @WorkspaceIsSystem() + attachments: Relation; + + @WorkspaceField({ + standardId: DASHBOARD_STANDARD_FIELD_IDS.searchVector, + type: FieldMetadataType.TS_VECTOR, + label: SEARCH_VECTOR_FIELD.label, + description: SEARCH_VECTOR_FIELD.description, + icon: 'IconUser', + generatedType: 'STORED', + asExpression: `to_tsvector('english', title)`, + }) + @WorkspaceIsNullable() + @WorkspaceIsSystem() + @WorkspaceFieldIndex({ indexType: IndexType.GIN }) + searchVector: string; } diff --git a/packages/twenty-server/src/modules/favorite/standard-objects/favorite.workspace-entity.ts b/packages/twenty-server/src/modules/favorite/standard-objects/favorite.workspace-entity.ts index 36892e519e4..17b3ec393b4 100644 --- a/packages/twenty-server/src/modules/favorite/standard-objects/favorite.workspace-entity.ts +++ b/packages/twenty-server/src/modules/favorite/standard-objects/favorite.workspace-entity.ts @@ -18,6 +18,7 @@ import { FAVORITE_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/worksp import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-icons'; import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity'; +import { DashboardWorkspaceEntity } from 'src/modules/dashboard/standard-objects/dashboard.workspace-entity'; import { FavoriteFolderWorkspaceEntity } from 'src/modules/favorite-folder/standard-objects/favorite-folder.workspace-entity'; import { NoteWorkspaceEntity } from 'src/modules/note/standard-objects/note.workspace-entity'; import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity'; @@ -210,6 +211,22 @@ export class FavoriteWorkspaceEntity extends BaseWorkspaceEntity { @WorkspaceJoinColumn('note') noteId: string; + @WorkspaceRelation({ + standardId: FAVORITE_STANDARD_FIELD_IDS.dashboard, + type: RelationType.MANY_TO_ONE, + label: msg`Dashboard`, + description: msg`Favorite dashboard`, + icon: 'IconLayoutDashboard', + inverseSideTarget: () => DashboardWorkspaceEntity, + inverseSideFieldKey: 'favorites', + onDelete: RelationOnDeleteAction.CASCADE, + }) + @WorkspaceIsNullable() + dashboard: Relation | null; + + @WorkspaceJoinColumn('dashboard') + dashboardId: string; + @WorkspaceField({ standardId: FAVORITE_STANDARD_FIELD_IDS.view, type: FieldMetadataType.UUID, diff --git a/packages/twenty-server/src/modules/timeline/standard-objects/timeline-activity.workspace-entity.ts b/packages/twenty-server/src/modules/timeline/standard-objects/timeline-activity.workspace-entity.ts index d325762f4dd..880dc2021eb 100644 --- a/packages/twenty-server/src/modules/timeline/standard-objects/timeline-activity.workspace-entity.ts +++ b/packages/twenty-server/src/modules/timeline/standard-objects/timeline-activity.workspace-entity.ts @@ -1,9 +1,9 @@ import { msg } from '@lingui/core/macro'; import { FieldMetadataType } from 'twenty-shared/types'; +import { RelationOnDeleteAction } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-on-delete-action.interface'; import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; -import { RelationOnDeleteAction } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-on-delete-action.interface'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { CustomWorkspaceEntity } from 'src/engine/twenty-orm/custom.workspace-entity'; @@ -19,6 +19,7 @@ import { TIMELINE_ACTIVITY_STANDARD_FIELD_IDS } from 'src/engine/workspace-manag import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-icons'; import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; import { CompanyWorkspaceEntity } from 'src/modules/company/standard-objects/company.workspace-entity'; +import { DashboardWorkspaceEntity } from 'src/modules/dashboard/standard-objects/dashboard.workspace-entity'; import { NoteWorkspaceEntity } from 'src/modules/note/standard-objects/note.workspace-entity'; import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity'; import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity'; @@ -243,6 +244,22 @@ export class TimelineActivityWorkspaceEntity extends BaseWorkspaceEntity { @WorkspaceJoinColumn('workflowRun') workflowRunId: string | null; + @WorkspaceRelation({ + standardId: TIMELINE_ACTIVITY_STANDARD_FIELD_IDS.dashboard, + type: RelationType.MANY_TO_ONE, + label: msg`Dashboard`, + description: msg`Event dashboard`, + icon: 'IconTargetArrow', + inverseSideTarget: () => DashboardWorkspaceEntity, + inverseSideFieldKey: 'timelineActivities', + onDelete: RelationOnDeleteAction.SET_NULL, + }) + @WorkspaceIsNullable() + dashboard: Relation | null; + + @WorkspaceJoinColumn('dashboard') + dashboardId: string | null; + @WorkspaceDynamicRelation({ type: RelationType.MANY_TO_ONE, argsFactory: (oppositeObjectMetadata) => ({ diff --git a/packages/twenty-server/test/integration/graphql/suites/page-layout/page-layout-resolver.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/page-layout/page-layout-resolver.integration-spec.ts index 70e6d584c89..97c73b92b39 100644 --- a/packages/twenty-server/test/integration/graphql/suites/page-layout/page-layout-resolver.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/page-layout/page-layout-resolver.integration-spec.ts @@ -1,3 +1,4 @@ +import gql from 'graphql-tag'; import { TEST_NOT_EXISTING_PAGE_LAYOUT_ID } from 'test/integration/constants/test-page-layout-ids.constants'; import { createPageLayoutOperationFactory } from 'test/integration/graphql/utils/create-page-layout-operation-factory.util'; import { deletePageLayoutOperationFactory } from 'test/integration/graphql/utils/delete-page-layout-operation-factory.util'; @@ -367,6 +368,72 @@ describe('Page Layout Resolver', () => { ), ); }); + + it('should destroy all associated dashboards when page layout is of type dashboard', async () => { + const dashboardId = '20202020-304c-44f2-ba7b-070762ff0e8a'; + + const pageLayout = await createTestPageLayoutWithGraphQL({ + name: 'Page Layout to Destroy', + type: PageLayoutType.DASHBOARD, + }); + + const findOneDashboardOperation = { + query: gql` + query Dashboard($filter: DashboardFilterInput!) { + dashboard(filter: $filter) { + id + } + } + `, + variables: { + filter: { id: { eq: dashboardId } }, + }, + }; + + await makeGraphqlAPIRequest({ + query: gql` + mutation CreateDashboard($input: CreateDashboardInput!) { + createDashboard(input: $input) { + pageLayoutId + id + title + } + } + `, + variables: { + input: { + id: dashboardId, + name: 'Dashboard to Destroy', + pageLayoutId: pageLayout.id, + }, + }, + }); + + const findOneDashboardResponseBeforeDestroy = await makeGraphqlAPIRequest( + findOneDashboardOperation, + ); + + expect( + findOneDashboardResponseBeforeDestroy.body.data.dashboard, + ).toBeDefined(); + + const destroyOperation = destroyPageLayoutOperationFactory({ + pageLayoutId: pageLayout.id, + }); + const destroyResponse = await makeGraphqlAPIRequest(destroyOperation); + + const findOneDashboardResponseAfterDestroy = await makeGraphqlAPIRequest( + findOneDashboardOperation, + ); + + assertGraphQLSuccessfulResponse(destroyResponse); + expect(destroyResponse.body.data.destroyPageLayout).toBe(true); + + expect(findOneDashboardResponseAfterDestroy.body.errors).toBeDefined(); + expect( + findOneDashboardResponseAfterDestroy.body.errors[0].extensions.code, + ).toBe(ErrorCode.NOT_FOUND); + }); }); describe('restorePageLayout', () => {