diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 9cb88b14039..52f21253b68 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1593,6 +1593,7 @@ export type Mutation = { updatePageLayout: PageLayout; updatePageLayoutTab: PageLayoutTab; updatePageLayoutWidget: PageLayoutWidget; + updatePageLayoutWithTabsAndWidgets: PageLayout; updatePasswordViaResetToken: InvalidatePassword; updateWebhook?: Maybe; updateWorkflowRunStep: WorkflowAction; @@ -2309,6 +2310,12 @@ export type MutationUpdatePageLayoutWidgetArgs = { }; +export type MutationUpdatePageLayoutWithTabsAndWidgetsArgs = { + id: Scalars['String']; + input: UpdatePageLayoutWithTabsInput; +}; + + export type MutationUpdatePasswordViaResetTokenArgs = { newPassword: Scalars['String']; passwordResetToken: Scalars['String']; @@ -3602,6 +3609,13 @@ export type UpdatePageLayoutTabInput = { title?: InputMaybe; }; +export type UpdatePageLayoutTabWithWidgetsInput = { + id: Scalars['UUID']; + position: Scalars['Float']; + title: Scalars['String']; + widgets: Array; +}; + export type UpdatePageLayoutWidgetInput = { configuration?: InputMaybe; gridPosition?: InputMaybe; @@ -3610,6 +3624,23 @@ export type UpdatePageLayoutWidgetInput = { type?: InputMaybe; }; +export type UpdatePageLayoutWidgetWithIdInput = { + configuration: Scalars['JSON']; + gridPosition: GridPositionInput; + id: Scalars['UUID']; + objectMetadataId?: InputMaybe; + pageLayoutTabId: Scalars['UUID']; + title: Scalars['String']; + type: WidgetType; +}; + +export type UpdatePageLayoutWithTabsInput = { + name: Scalars['String']; + objectMetadataId: Scalars['UUID']; + tabs: Array; + type: PageLayoutType; +}; + export type UpdateRemoteServerInput = { foreignDataWrapperOptions?: InputMaybe; id: Scalars['UUID']; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index b34b7942b30..db87d866a17 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -1544,6 +1544,7 @@ export type Mutation = { updatePageLayout: PageLayout; updatePageLayoutTab: PageLayoutTab; updatePageLayoutWidget: PageLayoutWidget; + updatePageLayoutWithTabsAndWidgets: PageLayout; updatePasswordViaResetToken: InvalidatePassword; updateWebhook?: Maybe; updateWorkflowRunStep: WorkflowAction; @@ -2220,6 +2221,12 @@ export type MutationUpdatePageLayoutWidgetArgs = { }; +export type MutationUpdatePageLayoutWithTabsAndWidgetsArgs = { + id: Scalars['String']; + input: UpdatePageLayoutWithTabsInput; +}; + + export type MutationUpdatePasswordViaResetTokenArgs = { newPassword: Scalars['String']; passwordResetToken: Scalars['String']; @@ -3448,6 +3455,13 @@ export type UpdatePageLayoutTabInput = { title?: InputMaybe; }; +export type UpdatePageLayoutTabWithWidgetsInput = { + id: Scalars['UUID']; + position: Scalars['Float']; + title: Scalars['String']; + widgets: Array; +}; + export type UpdatePageLayoutWidgetInput = { configuration?: InputMaybe; gridPosition?: InputMaybe; @@ -3456,6 +3470,23 @@ export type UpdatePageLayoutWidgetInput = { type?: InputMaybe; }; +export type UpdatePageLayoutWidgetWithIdInput = { + configuration: Scalars['JSON']; + gridPosition: GridPositionInput; + id: Scalars['UUID']; + objectMetadataId?: InputMaybe; + pageLayoutTabId: Scalars['UUID']; + title: Scalars['String']; + type: WidgetType; +}; + +export type UpdatePageLayoutWithTabsInput = { + name: Scalars['String']; + objectMetadataId: Scalars['UUID']; + tabs: Array; + type: PageLayoutType; +}; + export type UpdateRoleInput = { /** The id of the role to update */ id: Scalars['UUID']; diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-tab-with-widgets.input.ts b/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-tab-with-widgets.input.ts new file mode 100644 index 00000000000..cae11286444 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-tab-with-widgets.input.ts @@ -0,0 +1,38 @@ +import { Field, Float, InputType } from '@nestjs/graphql'; + +import { Type } from 'class-transformer'; +import { + IsArray, + IsNotEmpty, + IsNumber, + IsString, + IsUUID, + ValidateNested, +} from 'class-validator'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { UpdatePageLayoutWidgetWithIdInput } from 'src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-widget-with-id.input'; + +@InputType() +export class UpdatePageLayoutTabWithWidgetsInput { + @Field(() => UUIDScalarType) + @IsUUID() + id: string; + + @Field() + @IsString() + @IsNotEmpty() + title: string; + + @Field(() => Float) + @IsNumber() + @IsNotEmpty() + position: number; + + @Field(() => [UpdatePageLayoutWidgetWithIdInput]) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => UpdatePageLayoutWidgetWithIdInput) + @IsNotEmpty() + widgets: UpdatePageLayoutWidgetWithIdInput[]; +} diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-widget-with-id.input.ts b/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-widget-with-id.input.ts new file mode 100644 index 00000000000..acb93dfaa0d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-widget-with-id.input.ts @@ -0,0 +1,56 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { Type } from 'class-transformer'; +import { + IsEnum, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + IsUUID, + ValidateNested, +} from 'class-validator'; +import { GraphQLJSON } from 'graphql-type-json'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { GridPositionInput } from 'src/engine/core-modules/page-layout/dtos/inputs/grid-position.input'; +import { WidgetType } from 'src/engine/core-modules/page-layout/enums/widget-type.enum'; + +@InputType() +export class UpdatePageLayoutWidgetWithIdInput { + @Field(() => UUIDScalarType) + @IsUUID() + @IsNotEmpty() + id: string; + + @Field(() => UUIDScalarType) + @IsUUID() + @IsNotEmpty() + pageLayoutTabId: string; + + @Field() + @IsString() + @IsNotEmpty() + title: string; + + @Field(() => WidgetType) + @IsEnum(WidgetType) + @IsNotEmpty() + type: WidgetType; + + @Field(() => UUIDScalarType, { nullable: true }) + @IsUUID() + @IsOptional() + objectMetadataId: string | null; + + @Field(() => GridPositionInput) + @ValidateNested() + @Type(() => GridPositionInput) + @IsNotEmpty() + gridPosition: GridPositionInput; + + @Field(() => GraphQLJSON) + @IsObject() + @IsOptional() + configuration: Record | null; +} diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-with-tabs.input.ts b/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-with-tabs.input.ts new file mode 100644 index 00000000000..b72744f054e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-with-tabs.input.ts @@ -0,0 +1,40 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsEnum, + IsNotEmpty, + IsString, + IsUUID, + ValidateNested, +} from 'class-validator'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { UpdatePageLayoutTabWithWidgetsInput } from 'src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-tab-with-widgets.input'; +import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum'; + +@InputType() +export class UpdatePageLayoutWithTabsInput { + @Field() + @IsString() + @IsNotEmpty() + name: string; + + @Field(() => PageLayoutType) + @IsEnum(PageLayoutType) + @IsNotEmpty() + type: PageLayoutType; + + @Field(() => UUIDScalarType) + @IsUUID() + objectMetadataId: string | null; + + @Field(() => [UpdatePageLayoutTabWithWidgetsInput]) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => UpdatePageLayoutTabWithWidgetsInput) + tabs: UpdatePageLayoutTabWithWidgetsInput[]; +} 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 a965a9dff03..e4c24236ac0 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 @@ -11,6 +11,7 @@ import { PageLayoutTabResolver } from 'src/engine/core-modules/page-layout/resol import { PageLayoutWidgetResolver } from 'src/engine/core-modules/page-layout/resolvers/page-layout-widget.resolver'; import { PageLayoutResolver } from 'src/engine/core-modules/page-layout/resolvers/page-layout.resolver'; import { PageLayoutTabService } from 'src/engine/core-modules/page-layout/services/page-layout-tab.service'; +import { PageLayoutUpdateService } from 'src/engine/core-modules/page-layout/services/page-layout-update.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'; @@ -36,6 +37,7 @@ import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; PageLayoutResolver, PageLayoutTabResolver, PageLayoutWidgetResolver, + PageLayoutUpdateService, ], exports: [PageLayoutService, PageLayoutTabService, PageLayoutWidgetService], }) diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout-tab.resolver.ts b/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout-tab.resolver.ts index 1375e4a838f..dac589db3b9 100644 --- a/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout-tab.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout-tab.resolver.ts @@ -1,8 +1,9 @@ -import { UseFilters, UseGuards } from '@nestjs/common'; +import { UseFilters, UseGuards, UsePipes } from '@nestjs/common'; import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; import { isDefined } from 'twenty-shared/utils'; +import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; import { CreatePageLayoutTabInput } from 'src/engine/core-modules/page-layout/dtos/inputs/create-page-layout-tab.input'; import { UpdatePageLayoutTabInput } from 'src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-tab.input'; import { PageLayoutTabDTO } from 'src/engine/core-modules/page-layout/dtos/page-layout-tab.dto'; @@ -15,6 +16,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; @Resolver(() => PageLayoutTabDTO) @UseFilters(PageLayoutGraphqlApiExceptionFilter) @UseGuards(WorkspaceAuthGuard) +@UsePipes(ResolverValidationPipe) export class PageLayoutTabResolver { constructor(private readonly pageLayoutTabService: PageLayoutTabService) {} diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout-widget.resolver.ts b/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout-widget.resolver.ts index 2ac958327e1..feb1cb9c62c 100644 --- a/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout-widget.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout-widget.resolver.ts @@ -1,6 +1,7 @@ -import { UseFilters, UseGuards } from '@nestjs/common'; +import { UseFilters, UseGuards, UsePipes } from '@nestjs/common'; import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; +import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; 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 { PageLayoutWidgetDTO } from 'src/engine/core-modules/page-layout/dtos/page-layout-widget.dto'; @@ -13,6 +14,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; @Resolver(() => PageLayoutWidgetDTO) @UseFilters(PageLayoutGraphqlApiExceptionFilter) @UseGuards(WorkspaceAuthGuard) +@UsePipes(ResolverValidationPipe) export class PageLayoutWidgetResolver { constructor( private readonly pageLayoutWidgetService: PageLayoutWidgetService, diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout.resolver.ts b/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout.resolver.ts index 7e2e5fb2de1..62ef3bdf08a 100644 --- a/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/page-layout/resolvers/page-layout.resolver.ts @@ -1,11 +1,14 @@ -import { UseFilters, UseGuards } from '@nestjs/common'; +import { UseFilters, UseGuards, UsePipes } from '@nestjs/common'; import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; import { isDefined } from 'twenty-shared/utils'; +import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; import { CreatePageLayoutInput } from 'src/engine/core-modules/page-layout/dtos/inputs/create-page-layout.input'; +import { UpdatePageLayoutWithTabsInput } from 'src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-with-tabs.input'; import { UpdatePageLayoutInput } from 'src/engine/core-modules/page-layout/dtos/inputs/update-page-layout.input'; import { PageLayoutDTO } from 'src/engine/core-modules/page-layout/dtos/page-layout.dto'; +import { PageLayoutUpdateService } from 'src/engine/core-modules/page-layout/services/page-layout-update.service'; import { PageLayoutService } from 'src/engine/core-modules/page-layout/services/page-layout.service'; import { PageLayoutGraphqlApiExceptionFilter } from 'src/engine/core-modules/page-layout/utils/page-layout-graphql-api-exception.filter'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -15,8 +18,12 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; @Resolver(() => PageLayoutDTO) @UseFilters(PageLayoutGraphqlApiExceptionFilter) @UseGuards(WorkspaceAuthGuard) +@UsePipes(ResolverValidationPipe) export class PageLayoutResolver { - constructor(private readonly pageLayoutService: PageLayoutService) {} + constructor( + private readonly pageLayoutService: PageLayoutService, + private readonly pageLayoutUpdateService: PageLayoutUpdateService, + ) {} @Query(() => [PageLayoutDTO]) async getPageLayouts( @@ -92,4 +99,17 @@ export class PageLayoutResolver { ): Promise { return this.pageLayoutService.restore(id, workspace.id); } + + @Mutation(() => PageLayoutDTO) + async updatePageLayoutWithTabsAndWidgets( + @Args('id', { type: () => String }) id: string, + @Args('input') input: UpdatePageLayoutWithTabsInput, + @AuthWorkspace() workspace: Workspace, + ): Promise { + return this.pageLayoutUpdateService.updatePageLayoutWithTabs({ + id, + workspaceId: workspace.id, + input, + }); + } } 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 similarity index 100% rename from packages/twenty-server/src/engine/core-modules/page-layout/services/tests/page-layout-tab.service.spec.ts rename to packages/twenty-server/src/engine/core-modules/page-layout/services/__tests__/page-layout-tab.service.spec.ts diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/services/__tests__/page-layout-update.service.spec.ts b/packages/twenty-server/src/engine/core-modules/page-layout/services/__tests__/page-layout-update.service.spec.ts new file mode 100644 index 00000000000..7504c595457 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/page-layout/services/__tests__/page-layout-update.service.spec.ts @@ -0,0 +1,755 @@ +import { Test, type TestingModule } from '@nestjs/testing'; + +import { DataSource, type EntityManager } from 'typeorm'; + +import { type UpdatePageLayoutTabWithWidgetsInput } from 'src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-tab-with-widgets.input'; +import { type UpdatePageLayoutWidgetWithIdInput } from 'src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-widget-with-id.input'; +import { type PageLayoutTabEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-tab.entity'; +import { type PageLayoutWidgetEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-widget.entity'; +import { type 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 { WidgetType } from 'src/engine/core-modules/page-layout/enums/widget-type.enum'; +import { PageLayoutTabService } from 'src/engine/core-modules/page-layout/services/page-layout-tab.service'; +import { PageLayoutUpdateService } from 'src/engine/core-modules/page-layout/services/page-layout-update.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 { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +describe('PageLayoutUpdateService', () => { + let pageLayoutUpdateService: PageLayoutUpdateService; + let pageLayoutService: PageLayoutService; + let pageLayoutTabService: PageLayoutTabService; + let pageLayoutWidgetService: PageLayoutWidgetService; + let mockTransactionManager: EntityManager; + let mockDataSource: DataSource; + + const mockPageLayout = { + id: 'page-layout-id', + name: 'Test Page Layout', + workspaceId: 'workspace-id', + type: PageLayoutType.DASHBOARD, + objectMetadataId: 'object-metadata-id', + tabs: [], + workspace: {} as Workspace, + objectMetadata: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + } as PageLayoutEntity; + + const mockTab = { + id: 'tab-1', + title: 'Test Tab', + position: 0, + pageLayoutId: 'page-layout-id', + workspaceId: 'workspace-id', + workspace: {} as Workspace, + pageLayout: {} as PageLayoutEntity, + widgets: [], + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + } as PageLayoutTabEntity; + + const mockWidget = { + id: 'widget-1', + title: 'Test Widget', + type: WidgetType.VIEW, + pageLayoutTabId: 'tab-1', + workspaceId: 'workspace-id', + objectMetadataId: 'object-metadata-id', + gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 }, + configuration: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + } as PageLayoutWidgetEntity; + + beforeEach(async () => { + jest.clearAllMocks(); + + mockTransactionManager = {} as EntityManager; + mockDataSource = { + createQueryRunner: jest.fn().mockReturnValue({ + connect: jest.fn(), + startTransaction: jest.fn(), + commitTransaction: jest.fn(), + rollbackTransaction: jest.fn(), + release: jest.fn(), + manager: mockTransactionManager, + }), + } as unknown as DataSource; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PageLayoutUpdateService, + { + provide: PageLayoutService, + useValue: { + findByIdOrThrow: jest.fn(), + update: jest.fn(), + }, + }, + { + provide: PageLayoutTabService, + useValue: { + findByPageLayoutId: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + }, + { + provide: PageLayoutWidgetService, + useValue: { + findByPageLayoutTabId: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + }, + { + provide: DataSource, + useValue: mockDataSource, + }, + ], + }).compile(); + + pageLayoutUpdateService = module.get( + PageLayoutUpdateService, + ); + pageLayoutService = module.get(PageLayoutService); + pageLayoutTabService = + module.get(PageLayoutTabService); + pageLayoutWidgetService = module.get( + PageLayoutWidgetService, + ); + }); + + describe('updatePageLayoutWithTabs', () => { + it('should update page layout and handle tabs', async () => { + const id = 'page-layout-id'; + const workspaceId = 'workspace-id'; + const input = { + name: 'Updated Page Layout', + type: PageLayoutType.DASHBOARD, + objectMetadataId: 'object-metadata-id', + tabs: [ + { + id: 'tab-1', + title: 'Updated Tab', + position: 0, + widgets: [ + { + id: 'widget-1', + title: 'Updated Widget', + type: WidgetType.VIEW, + gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 }, + pageLayoutTabId: 'tab-1', + objectMetadataId: null, + configuration: {}, + }, + ], + }, + ], + }; + + const updatedPageLayout = { + ...mockPageLayout, + name: 'Updated Page Layout', + }; + + jest + .spyOn(pageLayoutService, 'findByIdOrThrow') + .mockResolvedValueOnce(mockPageLayout) + .mockResolvedValueOnce(updatedPageLayout); + jest + .spyOn(pageLayoutService, 'update') + .mockResolvedValue(updatedPageLayout); + jest + .spyOn(pageLayoutTabService, 'findByPageLayoutId') + .mockResolvedValue([mockTab]); + jest.spyOn(pageLayoutTabService, 'update').mockResolvedValue({ + ...mockTab, + title: 'Updated Tab', + }); + jest + .spyOn(pageLayoutWidgetService, 'findByPageLayoutTabId') + .mockResolvedValue([mockWidget]); + jest.spyOn(pageLayoutWidgetService, 'update').mockResolvedValue({ + ...mockWidget, + title: 'Updated Widget', + }); + + const result = await pageLayoutUpdateService.updatePageLayoutWithTabs({ + id, + workspaceId, + input, + transactionManager: mockTransactionManager, + }); + + expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith( + id, + workspaceId, + mockTransactionManager, + ); + expect(pageLayoutService.update).toHaveBeenCalledWith( + id, + workspaceId, + { + name: 'Updated Page Layout', + type: PageLayoutType.DASHBOARD, + objectMetadataId: 'object-metadata-id', + }, + mockTransactionManager, + ); + expect(result).toEqual(updatedPageLayout); + }); + + it('should throw error when page layout is not found', async () => { + const id = 'non-existent-id'; + const workspaceId = 'workspace-id'; + const input = { + ...mockPageLayout, + name: 'Updated Page Layout', + }; + + jest + .spyOn(pageLayoutService, 'findByIdOrThrow') + .mockRejectedValue(new Error('Page layout not found')); + + await expect( + pageLayoutUpdateService.updatePageLayoutWithTabs({ + id, + workspaceId, + input, + transactionManager: mockTransactionManager, + }), + ).rejects.toThrow('Page layout not found'); + }); + }); + + describe('updatePageLayoutTabs', () => { + it('should create new tabs', async () => { + const pageLayoutId = 'page-layout-id'; + const workspaceId = 'workspace-id'; + const tabs = [ + { + id: 'new-tab-id', + title: 'New Tab', + position: 0, + widgets: [], + }, + ]; + + jest + .spyOn(pageLayoutTabService, 'findByPageLayoutId') + .mockResolvedValue([]); + jest.spyOn(pageLayoutTabService, 'create').mockResolvedValue({ + ...mockTab, + id: 'new-tab-id', + title: 'New Tab', + position: 0, + }); + jest + .spyOn(pageLayoutWidgetService, 'findByPageLayoutTabId') + .mockResolvedValue([]); + + await pageLayoutUpdateService['updatePageLayoutTabs']({ + pageLayoutId, + workspaceId, + tabs, + transactionManager: mockTransactionManager, + }); + + expect(pageLayoutTabService.create).toHaveBeenCalledWith( + { + id: 'new-tab-id', + title: 'New Tab', + position: 0, + pageLayoutId, + widgets: [], + }, + workspaceId, + mockTransactionManager, + ); + }); + + it('should update existing tabs', async () => { + const pageLayoutId = 'page-layout-id'; + const workspaceId = 'workspace-id'; + const tabs = [ + { + id: 'tab-1', + title: 'Updated Tab', + position: 0, + widgets: [], + }, + ]; + + jest + .spyOn(pageLayoutTabService, 'findByPageLayoutId') + .mockResolvedValue([mockTab]); + jest.spyOn(pageLayoutTabService, 'update').mockResolvedValue({ + ...mockTab, + title: 'Updated Tab', + }); + jest + .spyOn(pageLayoutWidgetService, 'findByPageLayoutTabId') + .mockResolvedValue([]); + + await pageLayoutUpdateService['updatePageLayoutTabs']({ + pageLayoutId, + workspaceId, + tabs, + transactionManager: mockTransactionManager, + }); + + expect(pageLayoutTabService.update).toHaveBeenCalledWith( + 'tab-1', + workspaceId, + { + id: 'tab-1', + title: 'Updated Tab', + position: 0, + }, + mockTransactionManager, + ); + }); + + it('should delete removed tabs', async () => { + const pageLayoutId = 'page-layout-id'; + const workspaceId = 'workspace-id'; + const tabs: UpdatePageLayoutTabWithWidgetsInput[] = []; + const existingTabs = [mockTab]; + + jest + .spyOn(pageLayoutTabService, 'findByPageLayoutId') + .mockResolvedValue(existingTabs); + jest.spyOn(pageLayoutTabService, 'delete').mockResolvedValue(mockTab); + + await pageLayoutUpdateService['updatePageLayoutTabs']({ + pageLayoutId, + workspaceId, + tabs, + transactionManager: mockTransactionManager, + }); + + expect(pageLayoutTabService.delete).toHaveBeenCalledWith( + 'tab-1', + workspaceId, + mockTransactionManager, + ); + }); + + it('should handle tabs with mixed operations (create, update, delete)', async () => { + const pageLayoutId = 'page-layout-id'; + const workspaceId = 'workspace-id'; + const tabs = [ + { + id: 'tab-1', + title: 'Updated Tab', + position: 0, + widgets: [], + }, + { + id: 'new-tab-id', + title: 'New Tab', + position: 1, + widgets: [], + }, + ]; + const existingTabs = [ + mockTab, + { + ...mockTab, + id: 'tab-to-delete', + title: 'Tab to Delete', + }, + ] as PageLayoutTabEntity[]; + + jest + .spyOn(pageLayoutTabService, 'findByPageLayoutId') + .mockResolvedValue(existingTabs); + jest.spyOn(pageLayoutTabService, 'update').mockResolvedValue({ + ...mockTab, + title: 'Updated Tab', + }); + jest.spyOn(pageLayoutTabService, 'create').mockResolvedValue({ + ...mockTab, + id: 'new-tab-id', + title: 'New Tab', + position: 1, + }); + jest.spyOn(pageLayoutTabService, 'delete').mockResolvedValue({ + ...mockTab, + id: 'tab-to-delete', + title: 'Tab to Delete', + }); + jest + .spyOn(pageLayoutWidgetService, 'findByPageLayoutTabId') + .mockResolvedValue([]); + + await pageLayoutUpdateService['updatePageLayoutTabs']({ + pageLayoutId, + workspaceId, + tabs, + transactionManager: mockTransactionManager, + }); + + expect(pageLayoutTabService.delete).toHaveBeenCalledWith( + 'tab-to-delete', + workspaceId, + mockTransactionManager, + ); + expect(pageLayoutTabService.update).toHaveBeenCalledWith( + 'tab-1', + workspaceId, + { + id: 'tab-1', + title: 'Updated Tab', + position: 0, + }, + mockTransactionManager, + ); + expect(pageLayoutTabService.create).toHaveBeenCalledWith( + { + id: 'new-tab-id', + title: 'New Tab', + position: 1, + pageLayoutId, + widgets: [], + }, + workspaceId, + mockTransactionManager, + ); + }); + }); + + describe('updateWidgetsForTab', () => { + it('should create new widgets', async () => { + const tabId = 'tab-1'; + const workspaceId = 'workspace-id'; + const widgets = [ + { + id: 'new-widget-id', + pageLayoutTabId: tabId, + objectMetadataId: null, + configuration: {}, + title: 'New Widget', + type: WidgetType.VIEW, + gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 }, + }, + ]; + + jest + .spyOn(pageLayoutWidgetService, 'findByPageLayoutTabId') + .mockResolvedValue([]); + jest.spyOn(pageLayoutWidgetService, 'create').mockResolvedValue({ + ...mockWidget, + id: 'new-widget-id', + title: 'New Widget', + pageLayoutTabId: tabId, + objectMetadataId: null, + configuration: {}, + gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 }, + }); + + await pageLayoutUpdateService['updateWidgetsForTab']({ + tabId, + widgets, + workspaceId, + transactionManager: mockTransactionManager, + }); + + expect(pageLayoutWidgetService.create).toHaveBeenCalledWith( + { + id: 'new-widget-id', + pageLayoutTabId: tabId, + objectMetadataId: null, + configuration: {}, + title: 'New Widget', + type: WidgetType.VIEW, + gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 }, + }, + workspaceId, + mockTransactionManager, + ); + }); + + it('should update existing widgets', async () => { + const tabId = 'tab-1'; + const workspaceId = 'workspace-id'; + const widgets = [ + { + pageLayoutTabId: tabId, + objectMetadataId: null, + configuration: {}, + id: 'widget-1', + title: 'Updated Widget', + type: WidgetType.VIEW, + gridPosition: { row: 1, column: 1, rowSpan: 2, columnSpan: 2 }, + }, + ]; + + jest + .spyOn(pageLayoutWidgetService, 'findByPageLayoutTabId') + .mockResolvedValue([mockWidget]); + jest.spyOn(pageLayoutWidgetService, 'update').mockResolvedValue({ + ...mockWidget, + title: 'Updated Widget', + gridPosition: { row: 1, column: 1, rowSpan: 2, columnSpan: 2 }, + }); + + await pageLayoutUpdateService['updateWidgetsForTab']({ + tabId, + widgets, + workspaceId, + transactionManager: mockTransactionManager, + }); + + expect(pageLayoutWidgetService.update).toHaveBeenCalledWith( + 'widget-1', + workspaceId, + { + id: 'widget-1', + title: 'Updated Widget', + type: WidgetType.VIEW, + pageLayoutTabId: tabId, + objectMetadataId: null, + configuration: {}, + gridPosition: { row: 1, column: 1, rowSpan: 2, columnSpan: 2 }, + }, + mockTransactionManager, + ); + }); + + it('should delete removed widgets', async () => { + const tabId = 'tab-1'; + const workspaceId = 'workspace-id'; + const widgets: UpdatePageLayoutWidgetWithIdInput[] = []; + const existingWidgets = [mockWidget]; + + jest + .spyOn(pageLayoutWidgetService, 'findByPageLayoutTabId') + .mockResolvedValue(existingWidgets); + jest + .spyOn(pageLayoutWidgetService, 'delete') + .mockResolvedValue(mockWidget); + + await pageLayoutUpdateService['updateWidgetsForTab']({ + tabId, + widgets, + workspaceId, + transactionManager: mockTransactionManager, + }); + + expect(pageLayoutWidgetService.delete).toHaveBeenCalledWith( + 'widget-1', + workspaceId, + mockTransactionManager, + ); + }); + + it('should handle widgets with mixed operations (create, update, delete)', async () => { + const tabId = 'tab-1'; + const workspaceId = 'workspace-id'; + const widgets = [ + { + id: 'widget-1', + pageLayoutTabId: tabId, + objectMetadataId: null, + configuration: {}, + title: 'Updated Widget', + type: WidgetType.VIEW, + gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 }, + }, + { + id: 'new-widget-id', + pageLayoutTabId: tabId, + objectMetadataId: null, + configuration: {}, + title: 'New Widget', + type: WidgetType.FIELDS, + gridPosition: { row: 0, column: 4, rowSpan: 2, columnSpan: 2 }, + }, + ]; + const existingWidgets = [ + mockWidget, + { + ...mockWidget, + id: 'widget-to-delete', + title: 'Widget to Delete', + }, + ] as PageLayoutWidgetEntity[]; + + jest + .spyOn(pageLayoutWidgetService, 'findByPageLayoutTabId') + .mockResolvedValue(existingWidgets); + jest.spyOn(pageLayoutWidgetService, 'update').mockResolvedValue({ + ...mockWidget, + title: 'Updated Widget', + }); + jest.spyOn(pageLayoutWidgetService, 'create').mockResolvedValue({ + ...mockWidget, + id: 'new-widget-id', + title: 'New Widget', + type: WidgetType.FIELDS, + gridPosition: { row: 0, column: 4, rowSpan: 2, columnSpan: 2 }, + }); + jest.spyOn(pageLayoutWidgetService, 'delete').mockResolvedValue({ + ...mockWidget, + id: 'widget-to-delete', + title: 'Widget to Delete', + }); + + await pageLayoutUpdateService['updateWidgetsForTab']({ + tabId, + widgets, + workspaceId, + transactionManager: mockTransactionManager, + }); + + expect(pageLayoutWidgetService.delete).toHaveBeenCalledWith( + 'widget-to-delete', + workspaceId, + mockTransactionManager, + ); + expect(pageLayoutWidgetService.update).toHaveBeenCalledWith( + 'widget-1', + workspaceId, + { + id: 'widget-1', + title: 'Updated Widget', + type: WidgetType.VIEW, + pageLayoutTabId: tabId, + objectMetadataId: null, + configuration: {}, + gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 }, + }, + mockTransactionManager, + ); + expect(pageLayoutWidgetService.create).toHaveBeenCalledWith( + { + id: 'new-widget-id', + title: 'New Widget', + type: WidgetType.FIELDS, + pageLayoutTabId: tabId, + objectMetadataId: null, + configuration: {}, + gridPosition: { row: 0, column: 4, rowSpan: 2, columnSpan: 2 }, + }, + workspaceId, + mockTransactionManager, + ); + }); + }); + + describe('integration scenarios', () => { + it('should handle complete page layout update with nested tabs and widgets', async () => { + const id = 'page-layout-id'; + const workspaceId = 'workspace-id'; + const input = { + name: 'Complete Layout', + type: PageLayoutType.DASHBOARD, + objectMetadataId: null, + tabs: [ + { + id: 'tab-1', + title: 'Tab 1', + position: 0, + widgets: [ + { + id: 'widget-1', + title: 'Widget 1', + type: WidgetType.VIEW, + gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 }, + pageLayoutTabId: 'tab-1', + objectMetadataId: null, + configuration: {}, + }, + { + id: 'widget-2', + title: 'Widget 2', + type: WidgetType.FIELDS, + gridPosition: { row: 0, column: 4, rowSpan: 2, columnSpan: 2 }, + pageLayoutTabId: 'tab-1', + objectMetadataId: null, + configuration: {}, + }, + ], + }, + { + id: 'tab-2', + title: 'Tab 2', + position: 1, + widgets: [], + }, + ], + }; + + const existingTabs = [mockTab]; + const existingWidgets = [mockWidget]; + const completeLayout = { ...mockPageLayout, name: 'Complete Layout' }; + + jest + .spyOn(pageLayoutService, 'findByIdOrThrow') + .mockResolvedValueOnce(mockPageLayout) + .mockResolvedValueOnce(completeLayout); + jest.spyOn(pageLayoutService, 'update').mockResolvedValue(completeLayout); + jest + .spyOn(pageLayoutTabService, 'findByPageLayoutId') + .mockResolvedValue(existingTabs); + jest.spyOn(pageLayoutTabService, 'update').mockResolvedValue({ + ...mockTab, + id: 'tab-1', + title: 'Tab 1', + position: 0, + }); + jest.spyOn(pageLayoutTabService, 'create').mockResolvedValue({ + ...mockTab, + id: 'tab-2', + title: 'Tab 2', + position: 1, + }); + jest + .spyOn(pageLayoutWidgetService, 'findByPageLayoutTabId') + .mockResolvedValue(existingWidgets); + jest.spyOn(pageLayoutWidgetService, 'update').mockResolvedValue({ + ...mockWidget, + id: 'widget-1', + title: 'Widget 1', + }); + jest.spyOn(pageLayoutWidgetService, 'create').mockResolvedValue({ + ...mockWidget, + id: 'widget-2', + title: 'Widget 2', + type: WidgetType.FIELDS, + gridPosition: { row: 0, column: 4, rowSpan: 2, columnSpan: 2 }, + }); + + const result = await pageLayoutUpdateService.updatePageLayoutWithTabs({ + id, + workspaceId, + input, + transactionManager: mockTransactionManager, + }); + + expect(pageLayoutService.update).toHaveBeenCalledWith( + id, + workspaceId, + { + name: 'Complete Layout', + type: PageLayoutType.DASHBOARD, + objectMetadataId: null, + }, + mockTransactionManager, + ); + expect(pageLayoutTabService.update).toHaveBeenCalled(); + expect(pageLayoutTabService.create).toHaveBeenCalled(); + expect(pageLayoutWidgetService.update).toHaveBeenCalled(); + expect(pageLayoutWidgetService.create).toHaveBeenCalled(); + expect(result).toEqual(completeLayout); + }); + }); +}); 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 similarity index 100% rename from packages/twenty-server/src/engine/core-modules/page-layout/services/tests/page-layout-widget.service.spec.ts rename to packages/twenty-server/src/engine/core-modules/page-layout/services/__tests__/page-layout-widget.service.spec.ts 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 similarity index 100% rename from packages/twenty-server/src/engine/core-modules/page-layout/services/tests/page-layout.service.spec.ts rename to packages/twenty-server/src/engine/core-modules/page-layout/services/__tests__/page-layout.service.spec.ts diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/services/page-layout-update.service.ts b/packages/twenty-server/src/engine/core-modules/page-layout/services/page-layout-update.service.ts new file mode 100644 index 00000000000..e79dda4d574 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/page-layout/services/page-layout-update.service.ts @@ -0,0 +1,245 @@ +import { Injectable } from '@nestjs/common'; + +import { computeDiffBetweenObjects, isDefined } from 'twenty-shared/utils'; +import { DataSource, EntityManager } from 'typeorm'; + +import { UpdatePageLayoutTabWithWidgetsInput } from 'src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-tab-with-widgets.input'; +import { UpdatePageLayoutWidgetWithIdInput } from 'src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-widget-with-id.input'; +import { UpdatePageLayoutWithTabsInput } from 'src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-with-tabs.input'; +import { PageLayoutTabEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-tab.entity'; +import { PageLayoutWidgetEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-widget.entity'; +import { PageLayoutEntity } from 'src/engine/core-modules/page-layout/entities/page-layout.entity'; +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'; + +type UpdatePageLayoutWithTabsParams = { + id: string; + workspaceId: string; + input: UpdatePageLayoutWithTabsInput; + transactionManager?: EntityManager; +}; + +type UpdatePageLayoutTabsParams = { + pageLayoutId: string; + workspaceId: string; + tabs: UpdatePageLayoutTabWithWidgetsInput[]; + transactionManager: EntityManager; +}; + +type UpdateWidgetsForTabParams = { + tabId: string; + widgets: UpdatePageLayoutWidgetWithIdInput[]; + workspaceId: string; + transactionManager: EntityManager; +}; + +@Injectable() +export class PageLayoutUpdateService { + constructor( + private readonly pageLayoutService: PageLayoutService, + private readonly pageLayoutTabService: PageLayoutTabService, + private readonly pageLayoutWidgetService: PageLayoutWidgetService, + private readonly dataSource: DataSource, + ) {} + + async updatePageLayoutWithTabs({ + id, + workspaceId, + input, + transactionManager, + }: UpdatePageLayoutWithTabsParams): Promise { + if (!isDefined(transactionManager)) { + const queryRunner = this.dataSource.createQueryRunner(); + + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const result = await this.updatePageLayoutWithTabsWithinTransaction({ + id, + workspaceId, + input, + transactionManager: queryRunner.manager, + }); + + await queryRunner.commitTransaction(); + + return result; + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } + } + + return this.updatePageLayoutWithTabsWithinTransaction({ + id, + workspaceId, + input, + transactionManager, + }); + } + + private async updatePageLayoutWithTabsWithinTransaction({ + id, + workspaceId, + input, + transactionManager, + }: UpdatePageLayoutWithTabsParams & { + transactionManager: EntityManager; + }): Promise { + await this.pageLayoutService.findByIdOrThrow( + id, + workspaceId, + transactionManager, + ); + + const { tabs, ...updateData } = input; + + await this.pageLayoutService.update( + id, + workspaceId, + updateData, + transactionManager, + ); + + await this.updatePageLayoutTabs({ + pageLayoutId: id, + workspaceId, + tabs, + transactionManager, + }); + + return this.pageLayoutService.findByIdOrThrow( + id, + workspaceId, + transactionManager, + ); + } + + private async updatePageLayoutTabs({ + pageLayoutId, + workspaceId, + tabs, + transactionManager, + }: UpdatePageLayoutTabsParams): Promise { + const existingTabs = await this.pageLayoutTabService.findByPageLayoutId( + workspaceId, + pageLayoutId, + transactionManager, + ); + + const { + toCreate: entitiesToCreate, + toUpdate: entitiesToUpdate, + idsToDelete, + } = computeDiffBetweenObjects< + PageLayoutTabEntity, + UpdatePageLayoutTabWithWidgetsInput + >({ + existingObjects: existingTabs, + receivedObjects: tabs, + propertiesToCompare: ['title', 'position'], + }); + + for (const tabId of idsToDelete) { + await this.pageLayoutTabService.delete( + tabId, + workspaceId, + transactionManager, + ); + } + + for (const tabToUpdate of entitiesToUpdate) { + const { widgets: _widgets, ...updateData } = tabToUpdate; + + await this.pageLayoutTabService.update( + tabToUpdate.id, + workspaceId, + updateData, + transactionManager, + ); + } + + for (const tabToCreate of entitiesToCreate) { + await this.pageLayoutTabService.create( + { + ...tabToCreate, + pageLayoutId, + }, + workspaceId, + transactionManager, + ); + } + + for (const tabInput of tabs) { + await this.updateWidgetsForTab({ + tabId: tabInput.id, + widgets: tabInput.widgets, + workspaceId, + transactionManager, + }); + } + } + + private async updateWidgetsForTab({ + tabId, + widgets, + workspaceId, + transactionManager, + }: UpdateWidgetsForTabParams): Promise { + const existingWidgets = + await this.pageLayoutWidgetService.findByPageLayoutTabId( + workspaceId, + tabId, + transactionManager, + ); + + const { + toCreate: entitiesToCreate, + toUpdate: entitiesToUpdate, + idsToDelete, + } = computeDiffBetweenObjects< + PageLayoutWidgetEntity, + UpdatePageLayoutWidgetWithIdInput + >({ + existingObjects: existingWidgets, + receivedObjects: widgets, + propertiesToCompare: [ + 'pageLayoutTabId', + 'objectMetadataId', + 'title', + 'type', + 'gridPosition', + 'configuration', + ], + }); + + for (const widgetId of idsToDelete) { + await this.pageLayoutWidgetService.delete( + widgetId, + workspaceId, + transactionManager, + ); + } + + for (const widgetUpdate of entitiesToUpdate) { + await this.pageLayoutWidgetService.update( + widgetUpdate.id, + workspaceId, + widgetUpdate, + transactionManager, + ); + } + + for (const widgetToCreate of entitiesToCreate) { + await this.pageLayoutWidgetService.create( + widgetToCreate, + workspaceId, + transactionManager, + ); + } + } +} 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 193aa175094..6e42181afdf 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 @@ -422,10 +422,10 @@ describe('Page Layout Resolver', () => { 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); + assertGraphQLErrorResponse( + findOneDashboardResponseAfterDestroy, + ErrorCode.NOT_FOUND, + ); }); }); diff --git a/packages/twenty-server/test/integration/graphql/suites/page-layout/page-layout-update-with-tabs-and-widgets.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/page-layout/page-layout-update-with-tabs-and-widgets.integration-spec.ts new file mode 100644 index 00000000000..746cde5d01d --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/page-layout/page-layout-update-with-tabs-and-widgets.integration-spec.ts @@ -0,0 +1,422 @@ +import { findPageLayoutOperationFactory } from 'test/integration/graphql/utils/find-page-layout-operation-factory.util'; +import { + assertGraphQLErrorResponse, + assertGraphQLSuccessfulResponse, +} from 'test/integration/graphql/utils/graphql-test-assertions.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { createTestPageLayoutWithGraphQL } from 'test/integration/graphql/utils/page-layout-graphql.util'; +import { updatePageLayoutWithTabsOperationFactory } from 'test/integration/graphql/utils/update-page-layout-with-tabs-operation-factory.util'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; +import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { cleanupPageLayoutTabRecords } from 'test/integration/utils/page-layout-tab-test.util'; +import { + assertPageLayoutStructure, + cleanupPageLayoutRecords, +} from 'test/integration/utils/page-layout-test.util'; +import { cleanupPageLayoutWidgetRecords } from 'test/integration/utils/page-layout-widget-test.util'; + +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { type 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 { WidgetType } from 'src/engine/core-modules/page-layout/enums/widget-type.enum'; + +const existingTabId = '20202020-e02c-4292-9994-42695c4e41e8'; +const tabToUpdateId = '20202020-974a-4480-b814-1fca24937132'; +const tabToDeleteId = '20202020-a510-49e6-a9d9-0ff3e5c6a1ae'; +const newTabId = '20202020-1db1-4c18-8804-6c700a8106a6'; + +const existingWidgetId = '20202020-afbe-46a3-a759-9a10ea7f5888'; +const widgetToUpdateId = '20202020-c312-4342-93c0-f02c92c4a609'; +const widgetToDeleteId = '20202020-8a0b-4ec2-8784-af4adb27c18a'; +const newWidgetId = '20202020-da21-4321-b313-699eeff00798'; +const anotherNewWidgetId = '20202020-3937-4617-bc88-8a811f769c90'; + +describe('Page Layout Update With Tabs And Widgets Integration', () => { + let testObjectMetadataId: string; + + beforeAll(async () => { + const { + data: { + createOneObject: { id: objectMetadataId }, + }, + } = await createOneObjectMetadata({ + input: { + nameSingular: 'myTestUpdatePageLayoutObject', + namePlural: 'myTestUpdatePageLayoutObjects', + labelSingular: 'My Test Update Page Layout Object', + labelPlural: 'My Test Update Page Layout Objects', + icon: 'IconUpdate', + }, + }); + + testObjectMetadataId = objectMetadataId; + }); + + afterAll(async () => { + await deleteOneObjectMetadata({ + input: { idToDelete: testObjectMetadataId }, + }); + }); + + afterEach(async () => { + await cleanupPageLayoutRecords(); + await cleanupPageLayoutTabRecords(); + await cleanupPageLayoutWidgetRecords(); + }); + + describe('updatePageLayoutWithTabsAndWidgets', () => { + it('should handle complex page layout update with tab and widget CRUD operations', async () => { + const pageLayout = await createTestPageLayoutWithGraphQL({ + name: 'Initial Page Layout', + type: PageLayoutType.RECORD_PAGE, + objectMetadataId: testObjectMetadataId, + }); + + const initialUpdateInput = { + name: 'Updated Page Layout', + type: PageLayoutType.DASHBOARD, + objectMetadataId: testObjectMetadataId, + tabs: [ + { + id: existingTabId, + title: 'Existing Tab', + position: 1, + widgets: [ + { + id: existingWidgetId, + pageLayoutTabId: existingTabId, + title: 'Existing Widget', + type: WidgetType.VIEW, + objectMetadataId: testObjectMetadataId, + gridPosition: { + row: 0, + column: 0, + rowSpan: 2, + columnSpan: 2, + }, + configuration: { viewId: 'test-view-1' }, + }, + { + id: widgetToUpdateId, + pageLayoutTabId: existingTabId, + title: 'Widget To Update', + type: WidgetType.FIELDS, + objectMetadataId: testObjectMetadataId, + gridPosition: { + row: 0, + column: 2, + rowSpan: 1, + columnSpan: 1, + }, + configuration: { fields: ['id', 'name'] }, + }, + { + id: widgetToDeleteId, + pageLayoutTabId: existingTabId, + title: 'Widget To Delete', + type: WidgetType.IFRAME, + objectMetadataId: null, + gridPosition: { + row: 1, + column: 2, + rowSpan: 1, + columnSpan: 1, + }, + configuration: { url: 'https://example.com' }, + }, + ], + }, + { + id: tabToUpdateId, + title: 'Tab To Update', + position: 2, + widgets: [], + }, + { + id: tabToDeleteId, + title: 'Tab To Delete', + position: 3, + widgets: [], + }, + ], + }; + + const initialOperation = updatePageLayoutWithTabsOperationFactory({ + pageLayoutId: pageLayout.id, + data: initialUpdateInput, + }); + + const initialResponse = await makeGraphqlAPIRequest(initialOperation); + + assertGraphQLSuccessfulResponse(initialResponse); + + const initialResult = + initialResponse.body.data.updatePageLayoutWithTabsAndWidgets; + + assertPageLayoutStructure(initialResult, { + id: pageLayout.id, + name: 'Updated Page Layout', + type: PageLayoutType.DASHBOARD, + objectMetadataId: testObjectMetadataId, + }); + + expect(initialResult.tabs).toHaveLength(3); + expect(initialResult.tabs[0].widgets).toHaveLength(3); + + const complexUpdateInput = { + name: 'Final Page Layout', + type: PageLayoutType.RECORD_PAGE, + objectMetadataId: testObjectMetadataId, + tabs: [ + { + id: existingTabId, + title: 'Updated Existing Tab', + position: 1, + widgets: [ + { + id: existingWidgetId, + pageLayoutTabId: existingTabId, + title: 'Existing Widget', + type: WidgetType.VIEW, + objectMetadataId: testObjectMetadataId, + gridPosition: { + row: 0, + column: 0, + rowSpan: 2, + columnSpan: 2, + }, + configuration: { viewId: 'test-view-1' }, + }, + { + id: widgetToUpdateId, + pageLayoutTabId: existingTabId, + title: 'Updated Widget Title', + type: WidgetType.GRAPH, + objectMetadataId: testObjectMetadataId, + gridPosition: { + row: 0, + column: 2, + rowSpan: 2, + columnSpan: 3, + }, + configuration: { chartType: 'bar', metrics: ['count'] }, + }, + { + id: newWidgetId, + pageLayoutTabId: existingTabId, + title: 'New Widget', + type: WidgetType.FIELDS, + objectMetadataId: testObjectMetadataId, + gridPosition: { + row: 2, + column: 0, + rowSpan: 1, + columnSpan: 2, + }, + configuration: { fields: ['id', 'name', 'createdAt'] }, + }, + ], + }, + { + id: tabToUpdateId, + title: 'Completely Updated Tab', + position: 3, + widgets: [ + { + id: anotherNewWidgetId, + pageLayoutTabId: tabToUpdateId, + title: 'Another New Widget', + type: WidgetType.IFRAME, + objectMetadataId: null, + gridPosition: { + row: 0, + column: 0, + rowSpan: 1, + columnSpan: 1, + }, + configuration: { url: 'https://updated-example.com' }, + }, + ], + }, + { + id: newTabId, + title: 'Brand New Tab', + position: 2, + widgets: [], + }, + ], + }; + + const complexOperation = updatePageLayoutWithTabsOperationFactory({ + pageLayoutId: pageLayout.id, + data: complexUpdateInput, + }); + + const complexResponse = await makeGraphqlAPIRequest(complexOperation); + + assertGraphQLSuccessfulResponse(complexResponse); + + const finalResult: PageLayoutEntity = + complexResponse.body.data.updatePageLayoutWithTabsAndWidgets; + + assertPageLayoutStructure(finalResult, { + id: pageLayout.id, + name: 'Final Page Layout', + type: PageLayoutType.RECORD_PAGE, + objectMetadataId: testObjectMetadataId, + }); + + expect(finalResult.tabs).toHaveLength(3); + + const updatedExistingTab = finalResult.tabs.find( + (tab) => tab.id === existingTabId, + ); + const updatedTab = finalResult.tabs.find( + (tab) => tab.id === tabToUpdateId, + ); + const newTab = finalResult.tabs.find((tab) => tab.id === newTabId); + const deletedTab = finalResult.tabs.find( + (tab) => tab.id === tabToDeleteId, + ); + + expect(updatedExistingTab).toBeDefined(); + expect(updatedExistingTab?.title).toBe('Updated Existing Tab'); + expect(updatedExistingTab?.position).toBe(1); + + expect(updatedTab).toBeDefined(); + expect(updatedTab?.title).toBe('Completely Updated Tab'); + expect(updatedTab?.position).toBe(3); + + expect(newTab).toBeDefined(); + expect(newTab?.title).toBe('Brand New Tab'); + expect(newTab?.position).toBe(2); + + expect(deletedTab).toBeUndefined(); + + const firstTabWidgets = updatedExistingTab?.widgets; + + expect(firstTabWidgets).toHaveLength(3); + + const existingWidget = firstTabWidgets?.find( + (widget) => widget.id === existingWidgetId, + ); + const updatedWidget = firstTabWidgets?.find( + (widget) => widget.id === widgetToUpdateId, + ); + const newWidget = firstTabWidgets?.find( + (widget) => widget.id === newWidgetId, + ); + const deletedWidget = firstTabWidgets?.find( + (widget) => widget.id === widgetToDeleteId, + ); + + expect(existingWidget).toBeDefined(); + expect(existingWidget?.title).toBe('Existing Widget'); + expect(existingWidget?.type).toBe(WidgetType.VIEW); + + expect(updatedWidget).toBeDefined(); + expect(updatedWidget?.title).toBe('Updated Widget Title'); + expect(updatedWidget?.type).toBe(WidgetType.GRAPH); + expect(updatedWidget?.gridPosition.rowSpan).toBe(2); + expect(updatedWidget?.gridPosition.columnSpan).toBe(3); + expect(updatedWidget?.configuration?.chartType).toBe('bar'); + + expect(newWidget).toBeDefined(); + expect(newWidget?.title).toBe('New Widget'); + expect(newWidget?.type).toBe(WidgetType.FIELDS); + expect(newWidget?.configuration?.fields).toEqual([ + 'id', + 'name', + 'createdAt', + ]); + + expect(deletedWidget).toBeUndefined(); + + const secondTabWidgets = updatedTab?.widgets; + + expect(secondTabWidgets).toHaveLength(1); + + const anotherNewWidget = secondTabWidgets?.find( + (widget) => widget.id === anotherNewWidgetId, + ); + + expect(anotherNewWidget).toBeDefined(); + expect(anotherNewWidget?.title).toBe('Another New Widget'); + expect(anotherNewWidget?.type).toBe(WidgetType.IFRAME); + expect(anotherNewWidget?.configuration?.url).toBe( + 'https://updated-example.com', + ); + + expect(newTab?.widgets).toHaveLength(0); + }); + + it('should handle transaction rollback on error during complex update', async () => { + const pageLayout = await createTestPageLayoutWithGraphQL({ + name: 'Test Transaction Rollback', + type: PageLayoutType.RECORD_PAGE, + }); + + const invalidUpdateInput = { + name: '', + type: PageLayoutType.DASHBOARD, + objectMetadataId: 'invalid-uuid', + tabs: [ + { + id: '20202020-9a4c-4f97-bd63-a5f5843ee610', + title: 'Test Tab', + position: 1, + widgets: [], + }, + ], + }; + + const operation = updatePageLayoutWithTabsOperationFactory({ + pageLayoutId: pageLayout.id, + data: invalidUpdateInput, + }); + + const response = await makeGraphqlAPIRequest(operation); + + expect(response.body.errors).toBeDefined(); + expect(response.body.errors.length).toBeGreaterThan(0); + + const finalPageLayout = findPageLayoutOperationFactory({ + pageLayoutId: pageLayout.id, + }); + const finalResponse = await makeGraphqlAPIRequest(finalPageLayout); + + assertGraphQLSuccessfulResponse(finalResponse); + const finalResult = finalResponse.body.data.getPageLayout; + + assertPageLayoutStructure(finalResult, { + id: pageLayout.id, + name: 'Test Transaction Rollback', + type: PageLayoutType.RECORD_PAGE, + }); + }); + + it('should throw when empty tabs array is provided', async () => { + const pageLayout = await createTestPageLayoutWithGraphQL({ + name: 'Test Empty Tabs', + type: PageLayoutType.RECORD_PAGE, + objectMetadataId: testObjectMetadataId, + }); + + const updateInput = { + name: 'Updated Layout With No Tabs', + type: PageLayoutType.DASHBOARD, + objectMetadataId: testObjectMetadataId, + tabs: [], + }; + + const operation = updatePageLayoutWithTabsOperationFactory({ + pageLayoutId: pageLayout.id, + data: updateInput, + }); + + const response = await makeGraphqlAPIRequest(operation); + + assertGraphQLErrorResponse(response, ErrorCode.BAD_USER_INPUT); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/update-page-layout-with-tabs-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/update-page-layout-with-tabs-operation-factory.util.ts new file mode 100644 index 00000000000..f3bf6378fda --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/utils/update-page-layout-with-tabs-operation-factory.util.ts @@ -0,0 +1,48 @@ +import gql from 'graphql-tag'; +import { PAGE_LAYOUT_GQL_FIELDS } from 'test/integration/constants/page-layout-gql-fields.constants'; + +import { type UpdatePageLayoutWithTabsInput } from 'src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-with-tabs.input'; + +type UpdatePageLayoutWithTabsOperationFactoryParams = { + gqlFields?: string; + pageLayoutId: string; + data: UpdatePageLayoutWithTabsInput; +}; + +export const updatePageLayoutWithTabsOperationFactory = ({ + gqlFields = PAGE_LAYOUT_GQL_FIELDS, + pageLayoutId, + data, +}: UpdatePageLayoutWithTabsOperationFactoryParams) => ({ + query: gql` + mutation UpdatePageLayoutWithTabsAndWidgets($id: String!, $input: UpdatePageLayoutWithTabsInput!) { + updatePageLayoutWithTabsAndWidgets(id: $id, input: $input) { + ${gqlFields} + tabs { + id + title + position + pageLayoutId + widgets { + id + title + type + pageLayoutTabId + objectMetadataId + gridPosition { + row + column + rowSpan + columnSpan + } + configuration + } + } + } + } + `, + variables: { + id: pageLayoutId, + input: data, + }, +}); diff --git a/packages/twenty-shared/src/utils/__tests__/compute-diff-between-objects.test.ts b/packages/twenty-shared/src/utils/__tests__/compute-diff-between-objects.test.ts new file mode 100644 index 00000000000..663c4a27b06 --- /dev/null +++ b/packages/twenty-shared/src/utils/__tests__/compute-diff-between-objects.test.ts @@ -0,0 +1,77 @@ +import { computeDiffBetweenObjects } from '../compute-diff-between-objects'; + +describe('computeDiffBetweenObjects', () => { + it('should return the correct diff', () => { + const existingObjects = [ + { id: '1', name: 'Object 1' }, + { id: '2', name: 'Object 2' }, + ]; + const receivedObjects = [ + { id: '1', name: 'Object 1' }, + { id: '3', name: 'Object 3' }, + ]; + + const diff = computeDiffBetweenObjects({ + existingObjects, + receivedObjects, + propertiesToCompare: ['name'], + }); + + expect(diff).toEqual({ + toCreate: [{ id: '3', name: 'Object 3' }], + toUpdate: [], + idsToDelete: ['2'], + }); + }); + + it('should return the correct diff when the properties to compare are empty', () => { + const existingObjects = [{ id: '1', name: 'Object 1' }]; + const receivedObjects = [{ id: '1', name: 'Object 1' }]; + + const diff = computeDiffBetweenObjects({ + existingObjects, + receivedObjects, + propertiesToCompare: [], + }); + + expect(diff).toEqual({ + toCreate: [], + toUpdate: [], + idsToDelete: [], + }); + }); + + it('should return the correct diff when the existing objects are empty', () => { + const existingObjects: { id: string; name: string }[] = []; + const receivedObjects = [{ id: '1', name: 'Object 1' }]; + + const diff = computeDiffBetweenObjects({ + existingObjects, + receivedObjects, + propertiesToCompare: ['name'], + }); + + expect(diff).toEqual({ + toCreate: [{ id: '1', name: 'Object 1' }], + toUpdate: [], + idsToDelete: [], + }); + }); + + it('should return the correct diff when the received objects are empty', () => { + const existingObjects = [{ id: '1', name: 'Object 1' }]; + const receivedObjects: { id: string; name: string }[] = []; + + const diff = computeDiffBetweenObjects({ + existingObjects, + receivedObjects, + propertiesToCompare: ['name'], + }); + + expect(diff).toEqual({ + toCreate: [], + toUpdate: [], + idsToDelete: ['1'], + }); + }); +}); diff --git a/packages/twenty-shared/src/utils/compute-diff-between-objects.ts b/packages/twenty-shared/src/utils/compute-diff-between-objects.ts new file mode 100644 index 00000000000..3572ce0e9d0 --- /dev/null +++ b/packages/twenty-shared/src/utils/compute-diff-between-objects.ts @@ -0,0 +1,80 @@ +import { isDefined } from '@/utils/validation'; +import deepEqual from 'deep-equal'; + +type Diff = { + toCreate: T[]; + toUpdate: T[]; + idsToDelete: string[]; +}; + +const extractProperties = ( + object: T, + properties: (keyof T)[], +) => { + return properties.reduce((acc, property) => { + return { + ...acc, + [property]: object[property], + }; + }, {}); +}; + +type ComputeDiffBetweenObjectsParams< + T extends { id: string }, + K extends { id: string }, +> = { + existingObjects: T[]; + receivedObjects: K[]; + propertiesToCompare: (keyof K & keyof T)[]; +}; + +export const computeDiffBetweenObjects = < + T extends { id: string }, + K extends { id: string }, +>({ + existingObjects, + receivedObjects, + propertiesToCompare, +}: ComputeDiffBetweenObjectsParams): Diff => { + const toCreate: K[] = []; + const toUpdate: K[] = []; + + const existingEntitiesMap = new Map( + existingObjects.map((entity) => [entity.id, entity]), + ); + const receivedEntitiesMap = new Map( + receivedObjects.map((entity) => [entity.id, entity]), + ); + + for (const receivedObject of receivedObjects) { + const existingEntity = existingEntitiesMap.get(receivedObject.id); + + if (isDefined(existingEntity)) { + const comparableExistingEntity = extractProperties( + existingEntity, + propertiesToCompare, + ); + + const comparableReceivedEntity = extractProperties( + receivedObject, + propertiesToCompare, + ); + + if (!deepEqual(comparableExistingEntity, comparableReceivedEntity)) { + toUpdate.push(receivedObject); + } + } else { + toCreate.push(receivedObject); + } + } + + const idsToDelete = existingObjects + .filter((existingEntity) => !receivedEntitiesMap.has(existingEntity.id)) + .map((entity) => entity.id); + + return { + toCreate, + toUpdate, + idsToDelete, + }; +}; diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 7166914c350..03886b52497 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -13,6 +13,7 @@ export { findByProperty } from './array/findByProperty'; export { findOrThrow } from './array/findOrThrow'; export { sumByProperty } from './array/sumByProperty'; export { assertUnreachable } from './assertUnreachable'; +export { computeDiffBetweenObjects } from './compute-diff-between-objects'; export { deepMerge } from './deepMerge'; export { extractAndSanitizeObjectStringFields } from './extractAndSanitizeObjectStringFields'; export { isFieldMetadataDateKind } from './fieldMetadata/isFieldMetadataDateKind';