From 7cb294169dc8eefbb0901608707b427a281eebe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Tue, 23 Sep 2025 11:00:36 +0200 Subject: [PATCH] Add a new graphql mutation in the pageLayout resolver to handle page layout update with tabs and widgets (#14612) Add a new graphql mutation in the pageLayout resolver to handle page layout update with tabs and widgets. Later we will add validation inside the service to check if the page layout tab structure is correct (for instance check widgets don't overlap or aren't out of the grid, or to check that they have a correct configuration ...). This mutation will be plugged to the save action of the dashboards in the frontend. --- .../src/generated-metadata/graphql.ts | 31 + .../twenty-front/src/generated/graphql.ts | 31 + ...date-page-layout-tab-with-widgets.input.ts | 38 + ...update-page-layout-widget-with-id.input.ts | 56 ++ .../update-page-layout-with-tabs.input.ts | 40 + .../page-layout/page-layout.module.ts | 2 + .../resolvers/page-layout-tab.resolver.ts | 4 +- .../resolvers/page-layout-widget.resolver.ts | 4 +- .../resolvers/page-layout.resolver.ts | 24 +- .../page-layout-tab.service.spec.ts | 0 .../page-layout-update.service.spec.ts | 755 ++++++++++++++++++ .../page-layout-widget.service.spec.ts | 0 .../page-layout.service.spec.ts | 0 .../services/page-layout-update.service.ts | 245 ++++++ .../page-layout-resolver.integration-spec.ts | 8 +- ...-with-tabs-and-widgets.integration-spec.ts | 422 ++++++++++ ...layout-with-tabs-operation-factory.util.ts | 48 ++ .../compute-diff-between-objects.test.ts | 77 ++ .../src/utils/compute-diff-between-objects.ts | 80 ++ packages/twenty-shared/src/utils/index.ts | 1 + 20 files changed, 1858 insertions(+), 8 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-tab-with-widgets.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-widget-with-id.input.ts create mode 100644 packages/twenty-server/src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-with-tabs.input.ts rename packages/twenty-server/src/engine/core-modules/page-layout/services/{tests => __tests__}/page-layout-tab.service.spec.ts (100%) create mode 100644 packages/twenty-server/src/engine/core-modules/page-layout/services/__tests__/page-layout-update.service.spec.ts rename packages/twenty-server/src/engine/core-modules/page-layout/services/{tests => __tests__}/page-layout-widget.service.spec.ts (100%) rename packages/twenty-server/src/engine/core-modules/page-layout/services/{tests => __tests__}/page-layout.service.spec.ts (100%) create mode 100644 packages/twenty-server/src/engine/core-modules/page-layout/services/page-layout-update.service.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/page-layout/page-layout-update-with-tabs-and-widgets.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/update-page-layout-with-tabs-operation-factory.util.ts create mode 100644 packages/twenty-shared/src/utils/__tests__/compute-diff-between-objects.test.ts create mode 100644 packages/twenty-shared/src/utils/compute-diff-between-objects.ts 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';