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.
This commit is contained in:
@@ -1593,6 +1593,7 @@ export type Mutation = {
|
||||
updatePageLayout: PageLayout;
|
||||
updatePageLayoutTab: PageLayoutTab;
|
||||
updatePageLayoutWidget: PageLayoutWidget;
|
||||
updatePageLayoutWithTabsAndWidgets: PageLayout;
|
||||
updatePasswordViaResetToken: InvalidatePassword;
|
||||
updateWebhook?: Maybe<Webhook>;
|
||||
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<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type UpdatePageLayoutTabWithWidgetsInput = {
|
||||
id: Scalars['UUID'];
|
||||
position: Scalars['Float'];
|
||||
title: Scalars['String'];
|
||||
widgets: Array<UpdatePageLayoutWidgetWithIdInput>;
|
||||
};
|
||||
|
||||
export type UpdatePageLayoutWidgetInput = {
|
||||
configuration?: InputMaybe<Scalars['JSON']>;
|
||||
gridPosition?: InputMaybe<GridPositionInput>;
|
||||
@@ -3610,6 +3624,23 @@ export type UpdatePageLayoutWidgetInput = {
|
||||
type?: InputMaybe<WidgetType>;
|
||||
};
|
||||
|
||||
export type UpdatePageLayoutWidgetWithIdInput = {
|
||||
configuration: Scalars['JSON'];
|
||||
gridPosition: GridPositionInput;
|
||||
id: Scalars['UUID'];
|
||||
objectMetadataId?: InputMaybe<Scalars['UUID']>;
|
||||
pageLayoutTabId: Scalars['UUID'];
|
||||
title: Scalars['String'];
|
||||
type: WidgetType;
|
||||
};
|
||||
|
||||
export type UpdatePageLayoutWithTabsInput = {
|
||||
name: Scalars['String'];
|
||||
objectMetadataId: Scalars['UUID'];
|
||||
tabs: Array<UpdatePageLayoutTabWithWidgetsInput>;
|
||||
type: PageLayoutType;
|
||||
};
|
||||
|
||||
export type UpdateRemoteServerInput = {
|
||||
foreignDataWrapperOptions?: InputMaybe<Scalars['JSON']>;
|
||||
id: Scalars['UUID'];
|
||||
|
||||
@@ -1544,6 +1544,7 @@ export type Mutation = {
|
||||
updatePageLayout: PageLayout;
|
||||
updatePageLayoutTab: PageLayoutTab;
|
||||
updatePageLayoutWidget: PageLayoutWidget;
|
||||
updatePageLayoutWithTabsAndWidgets: PageLayout;
|
||||
updatePasswordViaResetToken: InvalidatePassword;
|
||||
updateWebhook?: Maybe<Webhook>;
|
||||
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<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type UpdatePageLayoutTabWithWidgetsInput = {
|
||||
id: Scalars['UUID'];
|
||||
position: Scalars['Float'];
|
||||
title: Scalars['String'];
|
||||
widgets: Array<UpdatePageLayoutWidgetWithIdInput>;
|
||||
};
|
||||
|
||||
export type UpdatePageLayoutWidgetInput = {
|
||||
configuration?: InputMaybe<Scalars['JSON']>;
|
||||
gridPosition?: InputMaybe<GridPositionInput>;
|
||||
@@ -3456,6 +3470,23 @@ export type UpdatePageLayoutWidgetInput = {
|
||||
type?: InputMaybe<WidgetType>;
|
||||
};
|
||||
|
||||
export type UpdatePageLayoutWidgetWithIdInput = {
|
||||
configuration: Scalars['JSON'];
|
||||
gridPosition: GridPositionInput;
|
||||
id: Scalars['UUID'];
|
||||
objectMetadataId?: InputMaybe<Scalars['UUID']>;
|
||||
pageLayoutTabId: Scalars['UUID'];
|
||||
title: Scalars['String'];
|
||||
type: WidgetType;
|
||||
};
|
||||
|
||||
export type UpdatePageLayoutWithTabsInput = {
|
||||
name: Scalars['String'];
|
||||
objectMetadataId: Scalars['UUID'];
|
||||
tabs: Array<UpdatePageLayoutTabWithWidgetsInput>;
|
||||
type: PageLayoutType;
|
||||
};
|
||||
|
||||
export type UpdateRoleInput = {
|
||||
/** The id of the role to update */
|
||||
id: Scalars['UUID'];
|
||||
|
||||
+38
@@ -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[];
|
||||
}
|
||||
+56
@@ -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<string, unknown> | null;
|
||||
}
|
||||
+40
@@ -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[];
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
+3
-1
@@ -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) {}
|
||||
|
||||
|
||||
+3
-1
@@ -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,
|
||||
|
||||
+22
-2
@@ -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<PageLayoutDTO> {
|
||||
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<PageLayoutDTO> {
|
||||
return this.pageLayoutUpdateService.updatePageLayoutWithTabs({
|
||||
id,
|
||||
workspaceId: workspace.id,
|
||||
input,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+755
@@ -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>(
|
||||
PageLayoutUpdateService,
|
||||
);
|
||||
pageLayoutService = module.get<PageLayoutService>(PageLayoutService);
|
||||
pageLayoutTabService =
|
||||
module.get<PageLayoutTabService>(PageLayoutTabService);
|
||||
pageLayoutWidgetService = module.get<PageLayoutWidgetService>(
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+245
@@ -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<PageLayoutEntity> {
|
||||
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<PageLayoutEntity> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+422
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+48
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { isDefined } from '@/utils/validation';
|
||||
import deepEqual from 'deep-equal';
|
||||
|
||||
type Diff<T extends { id: string }> = {
|
||||
toCreate: T[];
|
||||
toUpdate: T[];
|
||||
idsToDelete: string[];
|
||||
};
|
||||
|
||||
const extractProperties = <T extends { id: string }>(
|
||||
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<T, K>): Diff<K> => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user