Migrate page layout widget to v2 of the API (#16323)

This commit is contained in:
Raphaël Bosi
2025-12-05 14:04:06 +01:00
committed by GitHub
parent 56e66315cd
commit 077be7644c
159 changed files with 1760 additions and 308 deletions
@@ -37,7 +37,7 @@ import { MessageQueueModule } from 'src/engine/core-modules/message-queue/messag
import { messageQueueModuleFactory } from 'src/engine/core-modules/message-queue/message-queue.module-factory';
import { TimelineMessagingModule } from 'src/engine/core-modules/messaging/timeline-messaging.module';
import { OpenApiModule } from 'src/engine/core-modules/open-api/open-api.module';
import { PageLayoutModule } from 'src/engine/core-modules/page-layout/page-layout.module';
import { PageLayoutModule } from 'src/engine/metadata-modules/page-layout/page-layout.module';
import { PostgresCredentialsModule } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.module';
import { PublicDomainModule } from 'src/engine/core-modules/public-domain/public-domain.module';
import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-client.module';
@@ -1 +0,0 @@
export const WIDGET_GRID_MAX_COLUMNS = 12;
@@ -1 +0,0 @@
export const WIDGET_GRID_MAX_ROWS = 100;
@@ -1,97 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
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 { type PageLayoutTabDTO } from 'src/engine/core-modules/page-layout/dtos/page-layout-tab.dto';
import {
generatePageLayoutTabExceptionMessage,
PageLayoutTabException,
PageLayoutTabExceptionCode,
PageLayoutTabExceptionMessageKey,
} from 'src/engine/core-modules/page-layout/exceptions/page-layout-tab.exception';
import { PageLayoutTabRestApiExceptionFilter } from 'src/engine/core-modules/page-layout/filters/page-layout-tab-rest-api-exception.filter';
import { PageLayoutTabService } from 'src/engine/core-modules/page-layout/services/page-layout-tab.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
@Controller('rest/metadata/pageLayoutTabs')
@UseGuards(WorkspaceAuthGuard)
@UseFilters(PageLayoutTabRestApiExceptionFilter)
export class PageLayoutTabController {
constructor(private readonly pageLayoutTabService: PageLayoutTabService) {}
@Get()
@UseGuards(NoPermissionGuard)
async findMany(
@AuthWorkspace() workspace: WorkspaceEntity,
@Query('pageLayoutId') pageLayoutId: string,
): Promise<PageLayoutTabDTO[]> {
if (!isDefined(pageLayoutId)) {
throw new PageLayoutTabException(
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_ID_REQUIRED,
),
PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA,
);
}
return this.pageLayoutTabService.findByPageLayoutId(
workspace.id,
pageLayoutId,
);
}
@Get(':id')
@UseGuards(NoPermissionGuard)
async findOne(
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutTabDTO | null> {
return this.pageLayoutTabService.findByIdOrThrow(id, workspace.id);
}
@Post()
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async create(
@Body() input: CreatePageLayoutTabInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutTabDTO> {
return this.pageLayoutTabService.create(input, workspace.id);
}
@Patch(':id')
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async update(
@Param('id') id: string,
@Body() input: UpdatePageLayoutTabInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutTabDTO> {
return this.pageLayoutTabService.update(id, workspace.id, input);
}
@Delete(':id')
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async delete(
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutTabDTO> {
return this.pageLayoutTabService.delete(id, workspace.id);
}
}
@@ -1,99 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { isDefined } from 'class-validator';
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 { type PageLayoutWidgetDTO } from 'src/engine/core-modules/page-layout/dtos/page-layout-widget.dto';
import {
generatePageLayoutWidgetExceptionMessage,
PageLayoutWidgetException,
PageLayoutWidgetExceptionCode,
PageLayoutWidgetExceptionMessageKey,
} from 'src/engine/core-modules/page-layout/exceptions/page-layout-widget.exception';
import { PageLayoutWidgetRestApiExceptionFilter } from 'src/engine/core-modules/page-layout/filters/page-layout-widget-rest-api-exception.filter';
import { PageLayoutWidgetService } from 'src/engine/core-modules/page-layout/services/page-layout-widget.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
@Controller('rest/metadata/pageLayoutWidgets')
@UseGuards(WorkspaceAuthGuard)
@UseFilters(PageLayoutWidgetRestApiExceptionFilter)
export class PageLayoutWidgetController {
constructor(
private readonly pageLayoutWidgetService: PageLayoutWidgetService,
) {}
@Get()
@UseGuards(NoPermissionGuard)
async findMany(
@AuthWorkspace() workspace: WorkspaceEntity,
@Query('pageLayoutTabId') pageLayoutTabId: string,
): Promise<PageLayoutWidgetDTO[]> {
if (!isDefined(pageLayoutTabId)) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_TAB_ID_REQUIRED,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
return this.pageLayoutWidgetService.findByPageLayoutTabId(
workspace.id,
pageLayoutTabId,
);
}
@Get(':id')
@UseGuards(NoPermissionGuard)
async findOne(
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutWidgetDTO | null> {
return this.pageLayoutWidgetService.findByIdOrThrow(id, workspace.id);
}
@Post()
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async create(
@Body() input: CreatePageLayoutWidgetInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutWidgetDTO> {
return this.pageLayoutWidgetService.create(input, workspace.id);
}
@Patch(':id')
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async update(
@Param('id') id: string,
@Body() input: UpdatePageLayoutWidgetInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutWidgetDTO> {
return this.pageLayoutWidgetService.update(id, workspace.id, input);
}
@Delete(':id')
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async delete(
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutWidgetDTO> {
return this.pageLayoutWidgetService.delete(id, workspace.id);
}
}
@@ -1,98 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { CreatePageLayoutInput } from 'src/engine/core-modules/page-layout/dtos/inputs/create-page-layout.input';
import { UpdatePageLayoutInput } from 'src/engine/core-modules/page-layout/dtos/inputs/update-page-layout.input';
import { type PageLayoutDTO } from 'src/engine/core-modules/page-layout/dtos/page-layout.dto';
import { PageLayoutEntity } from 'src/engine/core-modules/page-layout/entities/page-layout.entity';
import { PageLayoutRestApiExceptionFilter } from 'src/engine/core-modules/page-layout/filters/page-layout-rest-api-exception.filter';
import { PageLayoutService } from 'src/engine/core-modules/page-layout/services/page-layout.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
@Controller('rest/metadata/pageLayouts')
@UseGuards(WorkspaceAuthGuard)
@UseFilters(PageLayoutRestApiExceptionFilter)
export class PageLayoutController {
constructor(private readonly pageLayoutService: PageLayoutService) {}
@Get()
@UseGuards(NoPermissionGuard)
async findMany(
@AuthWorkspace() workspace: WorkspaceEntity,
@Query('objectMetadataId') objectMetadataId?: string,
): Promise<PageLayoutDTO[]> {
if (isDefined(objectMetadataId)) {
return this.pageLayoutService.findByObjectMetadataId(
workspace.id,
objectMetadataId,
);
}
return this.pageLayoutService.findByWorkspaceId(workspace.id);
}
@Get(':id')
@UseGuards(NoPermissionGuard)
async findOne(
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutDTO | null> {
return this.pageLayoutService.findByIdOrThrow(id, workspace.id);
}
@Post()
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async create(
@Body() input: CreatePageLayoutInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutDTO> {
return this.pageLayoutService.create(input, workspace.id);
}
@Patch(':id')
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async update(
@Param('id') id: string,
@Body() input: UpdatePageLayoutInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutDTO> {
const updatedPageLayout = await this.pageLayoutService.update(
id,
workspace.id,
input,
);
return updatedPageLayout;
}
@Delete(':id')
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async delete(
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutEntity> {
const deletedPageLayout = await this.pageLayoutService.delete(
id,
workspace.id,
);
return deletedPageLayout;
}
}
@@ -1,86 +0,0 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsTimeZone,
IsUUID,
Max,
Min,
} from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
import { CalendarStartDay } from 'twenty-shared/constants';
import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { GraphType } from 'src/engine/core-modules/page-layout/enums/graph-type.enum';
@ObjectType('AggregateChartConfiguration')
export class AggregateChartConfigurationDTO {
@Field(() => GraphType)
@IsEnum(GraphType)
@IsNotEmpty()
graphType: GraphType.AGGREGATE;
@Field(() => UUIDScalarType)
@IsUUID()
@IsNotEmpty()
aggregateFieldMetadataId: string;
@Field(() => AggregateOperations)
@IsEnum(AggregateOperations)
@IsNotEmpty()
aggregateOperation: AggregateOperations;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
label?: string;
@Field(() => Boolean, { nullable: true, defaultValue: false })
@IsBoolean()
@IsOptional()
displayDataLabel?: boolean;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
format?: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
description?: string;
@Field(() => GraphQLJSON, { nullable: true })
@IsObject()
@IsOptional()
filter?: ObjectRecordFilter;
@Field(() => String, { nullable: true, defaultValue: 'UTC' })
@IsTimeZone()
@IsOptional()
timezone?: string;
@Field(() => Int, { nullable: true, defaultValue: CalendarStartDay.MONDAY })
@IsOptional()
@Min(0)
@Max(7)
firstDayOfTheWeek?: number;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
prefix?: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
suffix?: string;
}
@@ -1,165 +0,0 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import {
IsBoolean,
IsEnum,
IsIn,
IsNotEmpty,
IsNumber,
IsObject,
IsOptional,
IsString,
IsTimeZone,
IsUUID,
Max,
Min,
} from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
import { CalendarStartDay } from 'twenty-shared/constants';
import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { AxisNameDisplay } from 'src/engine/core-modules/page-layout/enums/axis-name-display.enum';
import { BarChartGroupMode } from 'src/engine/core-modules/page-layout/enums/bar-chart-group-mode.enum';
import { ObjectRecordGroupByDateGranularity } from 'src/engine/core-modules/page-layout/enums/date-granularity.enum';
import { GraphOrderBy } from 'src/engine/core-modules/page-layout/enums/graph-order-by.enum';
import { GraphType } from 'src/engine/core-modules/page-layout/enums/graph-type.enum';
@ObjectType('BarChartConfiguration')
export class BarChartConfigurationDTO {
@Field(() => GraphType)
@IsIn([GraphType.VERTICAL_BAR, GraphType.HORIZONTAL_BAR])
@IsNotEmpty()
graphType: GraphType.VERTICAL_BAR | GraphType.HORIZONTAL_BAR;
@Field(() => UUIDScalarType)
@IsUUID()
@IsNotEmpty()
aggregateFieldMetadataId: string;
@Field(() => AggregateOperations)
@IsEnum(AggregateOperations)
@IsNotEmpty()
aggregateOperation: AggregateOperations;
@Field(() => UUIDScalarType)
@IsUUID()
@IsNotEmpty()
primaryAxisGroupByFieldMetadataId: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
primaryAxisGroupBySubFieldName?: string;
@Field(() => ObjectRecordGroupByDateGranularity, {
nullable: true,
defaultValue: ObjectRecordGroupByDateGranularity.DAY,
})
@IsEnum(ObjectRecordGroupByDateGranularity)
@IsOptional()
primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity;
@Field(() => GraphOrderBy, { nullable: true })
@IsEnum(GraphOrderBy)
@IsOptional()
primaryAxisOrderBy?: GraphOrderBy;
@Field(() => UUIDScalarType, { nullable: true })
@IsUUID()
@IsOptional()
secondaryAxisGroupByFieldMetadataId?: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
secondaryAxisGroupBySubFieldName?: string;
@Field(() => ObjectRecordGroupByDateGranularity, {
nullable: true,
defaultValue: ObjectRecordGroupByDateGranularity.DAY,
})
@IsEnum(ObjectRecordGroupByDateGranularity)
@IsOptional()
secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity;
@Field(() => GraphOrderBy, { nullable: true })
@IsEnum(GraphOrderBy)
@IsOptional()
secondaryAxisOrderBy?: GraphOrderBy;
@Field(() => Boolean, { nullable: true })
@IsBoolean()
@IsOptional()
omitNullValues?: boolean;
@Field(() => AxisNameDisplay, {
nullable: true,
defaultValue: AxisNameDisplay.NONE,
})
@IsEnum(AxisNameDisplay)
@IsOptional()
axisNameDisplay?: AxisNameDisplay;
@Field(() => Boolean, { nullable: true, defaultValue: false })
@IsBoolean()
@IsOptional()
displayDataLabel?: boolean;
@Field(() => Boolean, { nullable: true, defaultValue: true })
@IsBoolean()
@IsOptional()
displayLegend?: boolean;
@Field(() => Number, { nullable: true })
@IsNumber()
@IsOptional()
rangeMin?: number;
@Field(() => Number, { nullable: true })
@IsNumber()
@IsOptional()
rangeMax?: number;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
description?: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
color?: string;
@Field(() => GraphQLJSON, { nullable: true })
@IsObject()
@IsOptional()
filter?: ObjectRecordFilter;
@Field(() => BarChartGroupMode, {
nullable: true,
})
@IsEnum(BarChartGroupMode)
@IsOptional()
groupMode?: BarChartGroupMode;
@Field(() => Boolean, {
nullable: true,
})
@IsBoolean()
@IsOptional()
isCumulative?: boolean;
@Field(() => String, { nullable: true, defaultValue: 'UTC' })
@IsTimeZone()
@IsOptional()
timezone?: string;
@Field(() => Int, { nullable: true, defaultValue: CalendarStartDay.MONDAY })
@IsOptional()
@Min(0)
@Max(7)
firstDayOfTheWeek?: number;
}
@@ -1,71 +0,0 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsTimeZone,
IsUUID,
Max,
Min,
} from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
import { CalendarStartDay } from 'twenty-shared/constants';
import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { GraphType } from 'src/engine/core-modules/page-layout/enums/graph-type.enum';
@ObjectType('GaugeChartConfiguration')
export class GaugeChartConfigurationDTO {
@Field(() => GraphType)
@IsEnum(GraphType)
@IsNotEmpty()
graphType: GraphType.GAUGE;
@Field(() => UUIDScalarType)
@IsUUID()
@IsNotEmpty()
aggregateFieldMetadataId: string;
@Field(() => AggregateOperations)
@IsEnum(AggregateOperations)
@IsNotEmpty()
aggregateOperation: AggregateOperations;
@Field(() => Boolean, { nullable: true, defaultValue: false })
@IsBoolean()
@IsOptional()
displayDataLabel?: boolean;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
color?: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
description?: string;
@Field(() => GraphQLJSON, { nullable: true })
@IsObject()
@IsOptional()
filter?: ObjectRecordFilter;
@Field(() => String, { nullable: true, defaultValue: 'UTC' })
@IsTimeZone()
@IsOptional()
timezone?: string;
@Field(() => Int, { nullable: true, defaultValue: CalendarStartDay.MONDAY })
@IsOptional()
@Min(0)
@Max(7)
firstDayOfTheWeek?: number;
}
@@ -1,12 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsOptional, IsString, IsUrl } from 'class-validator';
@ObjectType('IframeConfiguration')
export class IframeConfigurationDTO {
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
@IsUrl()
url?: string;
}
@@ -1,29 +0,0 @@
import { Field, Float, InputType } from '@nestjs/graphql';
import {
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class CreatePageLayoutTabInput {
@Field({ nullable: false })
@IsString()
@IsNotEmpty()
title: string;
@Field(() => Float, { nullable: true })
@IsNumber()
@IsOptional()
position?: number;
@Field(() => UUIDScalarType, { nullable: false })
@IsUUID()
@IsNotEmpty()
pageLayoutId: string;
}
@@ -1,50 +0,0 @@
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 CreatePageLayoutWidgetInput {
@Field(() => UUIDScalarType, { nullable: false })
@IsUUID()
@IsNotEmpty()
pageLayoutTabId: string;
@Field({ nullable: false })
@IsString()
@IsNotEmpty()
title: string;
@Field(() => WidgetType, { nullable: true, defaultValue: WidgetType.VIEW })
@IsEnum(WidgetType)
@IsOptional()
type?: WidgetType;
@Field(() => UUIDScalarType, { nullable: true })
@IsUUID()
@IsOptional()
objectMetadataId?: string | null;
@Field(() => GridPositionInput, { nullable: false })
@ValidateNested()
@Type(() => GridPositionInput)
gridPosition: GridPositionInput;
@Field(() => GraphQLJSON, { nullable: true })
@IsObject()
@IsOptional()
configuration?: Record<string, unknown> | null;
}
@@ -1,33 +0,0 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum';
@InputType()
export class CreatePageLayoutInput {
@Field({ nullable: false })
@IsString()
@IsNotEmpty()
name: string;
@Field(() => PageLayoutType, {
nullable: true,
defaultValue: PageLayoutType.RECORD_PAGE,
})
@IsEnum(PageLayoutType)
@IsOptional()
type?: PageLayoutType;
@Field(() => UUIDScalarType, { nullable: true })
@IsUUID()
@IsOptional()
objectMetadataId?: string | null;
}
@@ -1,32 +0,0 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsInt, IsNotEmpty, Min } from 'class-validator';
import { GridPosition } from 'src/engine/core-modules/page-layout/types/grid-position.type';
@InputType('GridPositionInput')
export class GridPositionInput implements GridPosition {
@Field()
@IsInt()
@Min(0)
@IsNotEmpty()
row: number;
@Field()
@IsInt()
@Min(0)
@IsNotEmpty()
column: number;
@Field()
@IsInt()
@Min(1)
@IsNotEmpty()
rowSpan: number;
@Field()
@IsInt()
@Min(1)
@IsNotEmpty()
columnSpan: number;
}
@@ -1,38 +0,0 @@
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[];
}
@@ -1,16 +0,0 @@
import { Field, Float, InputType } from '@nestjs/graphql';
import { IsNumber, IsOptional, IsString } from 'class-validator';
@InputType()
export class UpdatePageLayoutTabInput {
@Field({ nullable: true })
@IsString()
@IsOptional()
title?: string;
@Field(() => Float, { nullable: true })
@IsNumber()
@IsOptional()
position?: number;
}
@@ -1,56 +0,0 @@
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, { nullable: true })
@IsObject()
@IsOptional()
configuration: Record<string, unknown> | null;
}
@@ -1,45 +0,0 @@
import { Field, InputType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsEnum,
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 UpdatePageLayoutWidgetInput {
@Field({ nullable: true })
@IsString()
@IsOptional()
title?: string;
@Field(() => WidgetType, { nullable: true })
@IsEnum(WidgetType)
@IsOptional()
type?: WidgetType;
@Field(() => UUIDScalarType, { nullable: true })
@IsUUID()
@IsOptional()
objectMetadataId?: string | null;
@Field(() => GridPositionInput, { nullable: true })
@ValidateNested()
@Type(() => GridPositionInput)
@IsOptional()
gridPosition?: GridPositionInput;
@Field(() => GraphQLJSON, { nullable: true })
@IsObject()
@IsOptional()
configuration?: Record<string, unknown> | null;
}
@@ -1,42 +0,0 @@
import { Field, InputType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsEnum,
IsNotEmpty,
IsOptional,
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, { nullable: true })
@IsUUID()
@IsOptional()
objectMetadataId: string | null;
@Field(() => [UpdatePageLayoutTabWithWidgetsInput])
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => UpdatePageLayoutTabWithWidgetsInput)
tabs: UpdatePageLayoutTabWithWidgetsInput[];
}
@@ -1,24 +0,0 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum';
@InputType()
export class UpdatePageLayoutInput {
@Field({ nullable: true })
@IsString()
@IsOptional()
name?: string;
@Field(() => PageLayoutType, { nullable: true })
@IsEnum(PageLayoutType)
@IsOptional()
type?: PageLayoutType;
@Field(() => UUIDScalarType, { nullable: true })
@IsUUID()
@IsOptional()
objectMetadataId?: string | null;
}
@@ -1,166 +0,0 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsNumber,
IsObject,
IsOptional,
IsString,
IsTimeZone,
IsUUID,
Max,
Min,
} from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
import { CalendarStartDay } from 'twenty-shared/constants';
import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { AxisNameDisplay } from 'src/engine/core-modules/page-layout/enums/axis-name-display.enum';
import { ObjectRecordGroupByDateGranularity } from 'src/engine/core-modules/page-layout/enums/date-granularity.enum';
import { GraphOrderBy } from 'src/engine/core-modules/page-layout/enums/graph-order-by.enum';
import { GraphType } from 'src/engine/core-modules/page-layout/enums/graph-type.enum';
@ObjectType('LineChartConfiguration')
export class LineChartConfigurationDTO {
@Field(() => GraphType)
@IsEnum(GraphType)
@IsNotEmpty()
graphType: GraphType.LINE;
@Field(() => UUIDScalarType)
@IsUUID()
@IsNotEmpty()
aggregateFieldMetadataId: string;
@Field(() => AggregateOperations)
@IsEnum(AggregateOperations)
@IsNotEmpty()
aggregateOperation: AggregateOperations;
@Field(() => UUIDScalarType)
@IsUUID()
@IsNotEmpty()
primaryAxisGroupByFieldMetadataId: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
primaryAxisGroupBySubFieldName?: string;
@Field(() => ObjectRecordGroupByDateGranularity, {
nullable: true,
defaultValue: ObjectRecordGroupByDateGranularity.DAY,
})
@IsEnum(ObjectRecordGroupByDateGranularity)
@IsOptional()
primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity;
@Field(() => GraphOrderBy, {
nullable: true,
defaultValue: GraphOrderBy.FIELD_ASC,
})
@IsEnum(GraphOrderBy)
@IsOptional()
primaryAxisOrderBy?: GraphOrderBy;
@Field(() => UUIDScalarType, { nullable: true })
@IsUUID()
@IsOptional()
secondaryAxisGroupByFieldMetadataId?: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
secondaryAxisGroupBySubFieldName?: string;
@Field(() => ObjectRecordGroupByDateGranularity, {
nullable: true,
defaultValue: ObjectRecordGroupByDateGranularity.DAY,
})
@IsEnum(ObjectRecordGroupByDateGranularity)
@IsOptional()
secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity;
@Field(() => GraphOrderBy, { nullable: true })
@IsEnum(GraphOrderBy)
@IsOptional()
secondaryAxisOrderBy?: GraphOrderBy;
@Field(() => Boolean, { nullable: true })
@IsBoolean()
@IsOptional()
omitNullValues?: boolean;
@Field(() => AxisNameDisplay, {
nullable: true,
defaultValue: AxisNameDisplay.NONE,
})
@IsEnum(AxisNameDisplay)
@IsOptional()
axisNameDisplay?: AxisNameDisplay;
@Field(() => Boolean, { nullable: true, defaultValue: false })
@IsBoolean()
@IsOptional()
displayDataLabel?: boolean;
@Field(() => Boolean, { nullable: true, defaultValue: true })
@IsBoolean()
@IsOptional()
displayLegend?: boolean;
@Field(() => Number, { nullable: true })
@IsNumber()
@IsOptional()
rangeMin?: number;
@Field(() => Number, { nullable: true })
@IsNumber()
@IsOptional()
rangeMax?: number;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
description?: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
color?: string;
@Field(() => GraphQLJSON, { nullable: true })
@IsObject()
@IsOptional()
filter?: ObjectRecordFilter;
@Field(() => Boolean, {
nullable: true,
})
@IsBoolean()
@IsOptional()
isStacked?: boolean;
@Field(() => Boolean, {
nullable: true,
})
@IsBoolean()
@IsOptional()
isCumulative?: boolean;
@Field(() => String, { nullable: true, defaultValue: 'UTC' })
@IsTimeZone()
@IsOptional()
timezone?: string;
@Field(() => Int, { nullable: true, defaultValue: CalendarStartDay.MONDAY })
@IsOptional()
@Min(0)
@Max(7)
firstDayOfTheWeek?: number;
}
@@ -1,33 +0,0 @@
import { Field, Float, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { PageLayoutWidgetDTO } from 'src/engine/core-modules/page-layout/dtos/page-layout-widget.dto';
@ObjectType('PageLayoutTab')
export class PageLayoutTabDTO {
@IDField(() => UUIDScalarType)
id: string;
@Field({ nullable: false })
title: string;
@Field(() => Float, { nullable: false, defaultValue: 0 })
position: number;
@Field(() => UUIDScalarType, { nullable: false })
pageLayoutId: string;
@Field(() => [PageLayoutWidgetDTO], { nullable: true })
widgets?: PageLayoutWidgetDTO[] | null;
@Field()
createdAt: Date;
@Field()
updatedAt: Date;
@Field(() => Date, { nullable: true })
deletedAt?: Date | null;
}
@@ -1,60 +0,0 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import {
WidgetConfiguration,
WidgetConfigurationInterface,
} from 'src/engine/core-modules/page-layout/dtos/widget-configuration.interface';
import { WidgetType } from 'src/engine/core-modules/page-layout/enums/widget-type.enum';
registerEnumType(WidgetType, { name: 'WidgetType' });
@ObjectType('GridPosition')
export class GridPositionDTO {
@Field()
row: number;
@Field()
column: number;
@Field()
rowSpan: number;
@Field()
columnSpan: number;
}
@ObjectType('PageLayoutWidget')
export class PageLayoutWidgetDTO {
@IDField(() => UUIDScalarType)
id: string;
@Field(() => UUIDScalarType, { nullable: false })
pageLayoutTabId: string;
@Field({ nullable: false })
title: string;
@Field(() => WidgetType, { nullable: false })
type: WidgetType;
@Field(() => UUIDScalarType, { nullable: true })
objectMetadataId: string | null;
@Field(() => GridPositionDTO, { nullable: false })
gridPosition: GridPositionDTO;
@Field(() => WidgetConfiguration, { nullable: true })
configuration: WidgetConfigurationInterface | null;
@Field()
createdAt: Date;
@Field()
updatedAt: Date;
@Field(() => Date, { nullable: true })
deletedAt?: Date | null;
}
@@ -1,39 +0,0 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { PageLayoutTabDTO } from 'src/engine/core-modules/page-layout/dtos/page-layout-tab.dto';
import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum';
registerEnumType(PageLayoutType, { name: 'PageLayoutType' });
@ObjectType('PageLayout')
export class PageLayoutDTO {
@IDField(() => UUIDScalarType)
id: string;
@Field({ nullable: false })
name: string;
@Field(() => PageLayoutType, {
nullable: false,
defaultValue: PageLayoutType.RECORD_PAGE,
})
type: PageLayoutType;
@Field(() => UUIDScalarType, { nullable: true })
objectMetadataId?: string | null;
@Field(() => [PageLayoutTabDTO], { nullable: true })
tabs?: PageLayoutTabDTO[] | null;
@Field()
createdAt: Date;
@Field()
updatedAt: Date;
@Field(() => Date, { nullable: true })
deletedAt?: Date | null;
}
@@ -1,109 +0,0 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsTimeZone,
IsUUID,
Max,
Min,
} from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
import { CalendarStartDay } from 'twenty-shared/constants';
import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ObjectRecordGroupByDateGranularity } from 'src/engine/core-modules/page-layout/enums/date-granularity.enum';
import { GraphOrderBy } from 'src/engine/core-modules/page-layout/enums/graph-order-by.enum';
import { GraphType } from 'src/engine/core-modules/page-layout/enums/graph-type.enum';
@ObjectType('PieChartConfiguration')
export class PieChartConfigurationDTO {
@Field(() => GraphType)
@IsEnum(GraphType)
@IsNotEmpty()
graphType: GraphType.PIE;
@Field(() => UUIDScalarType)
@IsUUID()
@IsNotEmpty()
aggregateFieldMetadataId: string;
@Field(() => AggregateOperations)
@IsEnum(AggregateOperations)
@IsNotEmpty()
aggregateOperation: AggregateOperations;
@Field(() => UUIDScalarType)
@IsUUID()
@IsNotEmpty()
groupByFieldMetadataId: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
groupBySubFieldName?: string;
@Field(() => ObjectRecordGroupByDateGranularity, {
nullable: true,
defaultValue: ObjectRecordGroupByDateGranularity.DAY,
})
@IsEnum(ObjectRecordGroupByDateGranularity)
@IsOptional()
dateGranularity?: ObjectRecordGroupByDateGranularity;
@Field(() => GraphOrderBy, {
nullable: true,
defaultValue: GraphOrderBy.VALUE_DESC,
})
@IsEnum(GraphOrderBy)
@IsOptional()
orderBy?: GraphOrderBy;
@Field(() => Boolean, { nullable: true, defaultValue: false })
@IsBoolean()
@IsOptional()
displayDataLabel?: boolean;
@Field(() => Boolean, { nullable: true, defaultValue: true })
@IsBoolean()
@IsOptional()
showCenterMetric?: boolean;
@Field(() => Boolean, { nullable: true, defaultValue: true })
@IsBoolean()
@IsOptional()
displayLegend?: boolean;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
description?: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
color?: string;
@Field(() => GraphQLJSON, { nullable: true })
@IsObject()
@IsOptional()
filter?: ObjectRecordFilter;
@Field(() => String, { nullable: true, defaultValue: 'UTC' })
@IsTimeZone()
@IsOptional()
timezone?: string;
@Field(() => Int, { nullable: true, defaultValue: CalendarStartDay.MONDAY })
@IsOptional()
@Min(0)
@Max(7)
firstDayOfTheWeek?: number;
}
@@ -1,17 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsOptional, IsString } from 'class-validator';
import { type RichTextV2Metadata } from 'twenty-shared/types';
@ObjectType('RichTextV2Body')
export class RichTextV2BodyDTO implements RichTextV2Metadata {
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
blocknote?: string | null;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
markdown: string | null;
}
@@ -1,15 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import { IsNotEmpty, ValidateNested } from 'class-validator';
import { RichTextV2BodyDTO } from 'src/engine/core-modules/page-layout/dtos/rich-text-v2-body.dto';
@ObjectType('StandaloneRichTextConfiguration')
export class StandaloneRichTextConfigurationDTO {
@Field(() => RichTextV2BodyDTO)
@ValidateNested()
@Type(() => RichTextV2BodyDTO)
@IsNotEmpty()
body: RichTextV2BodyDTO;
}
@@ -1,77 +0,0 @@
import { createUnionType } from '@nestjs/graphql';
import { AggregateChartConfigurationDTO } from 'src/engine/core-modules/page-layout/dtos/aggregate-chart-configuration.dto';
import { BarChartConfigurationDTO } from 'src/engine/core-modules/page-layout/dtos/bar-chart-configuration.dto';
import { GaugeChartConfigurationDTO } from 'src/engine/core-modules/page-layout/dtos/gauge-chart-configuration.dto';
import { IframeConfigurationDTO } from 'src/engine/core-modules/page-layout/dtos/iframe-configuration.dto';
import { LineChartConfigurationDTO } from 'src/engine/core-modules/page-layout/dtos/line-chart-configuration.dto';
import { PieChartConfigurationDTO } from 'src/engine/core-modules/page-layout/dtos/pie-chart-configuration.dto';
import { StandaloneRichTextConfigurationDTO } from 'src/engine/core-modules/page-layout/dtos/standalone-rich-text-configuration.dto';
import { GraphType } from 'src/engine/core-modules/page-layout/enums/graph-type.enum';
import { WidgetConfigurationType } from 'src/engine/core-modules/page-layout/enums/widget-configuration-type.enum';
export const WidgetConfiguration = createUnionType({
name: 'WidgetConfiguration',
types: () => [
BarChartConfigurationDTO,
LineChartConfigurationDTO,
PieChartConfigurationDTO,
AggregateChartConfigurationDTO,
GaugeChartConfigurationDTO,
IframeConfigurationDTO,
StandaloneRichTextConfigurationDTO,
],
resolveType(configuration: Record<string, unknown>) {
if (!('configurationType' in configuration)) {
throw new Error(
'Widget configuration missing configurationType discriminator. This indicates a validation bug or data corruption.',
);
}
if (
configuration.configurationType === WidgetConfigurationType.CHART_CONFIG
) {
switch (configuration.graphType) {
case GraphType.VERTICAL_BAR:
case GraphType.HORIZONTAL_BAR:
return BarChartConfigurationDTO;
case GraphType.LINE:
return LineChartConfigurationDTO;
case GraphType.PIE:
return PieChartConfigurationDTO;
case GraphType.AGGREGATE:
return AggregateChartConfigurationDTO;
case GraphType.GAUGE:
return GaugeChartConfigurationDTO;
default:
throw new Error(`Unknown graph type: ${configuration.graphType}`);
}
}
if (
configuration.configurationType === WidgetConfigurationType.IFRAME_CONFIG
) {
return IframeConfigurationDTO;
}
if (
configuration.configurationType ===
WidgetConfigurationType.STANDALONE_RICH_TEXT_CONFIG
) {
return StandaloneRichTextConfigurationDTO;
}
throw new Error(
`Unknown widget configuration type: ${configuration.configurationType}`,
);
},
});
export type WidgetConfigurationInterface =
| BarChartConfigurationDTO
| LineChartConfigurationDTO
| PieChartConfigurationDTO
| AggregateChartConfigurationDTO
| GaugeChartConfigurationDTO
| IframeConfigurationDTO
| StandaloneRichTextConfigurationDTO;
@@ -1,74 +0,0 @@
import { ObjectType } from '@nestjs/graphql';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
Relation,
UpdateDateColumn,
} from 'typeorm';
import { StrictSyncableEntity } from 'src/engine/workspace-manager/workspace-sync/interfaces/strict-syncable-entity.interface';
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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@Entity({ name: 'pageLayoutTab', schema: 'core' })
@ObjectType('PageLayoutTab')
@Index(
'IDX_PAGE_LAYOUT_TAB_WORKSPACE_ID_PAGE_LAYOUT_ID',
['workspaceId', 'pageLayoutId'],
{ where: '"deletedAt" IS NULL' },
)
export class PageLayoutTabEntity
extends StrictSyncableEntity
implements Required<PageLayoutTabEntity>
{
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false })
title: string;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@ManyToOne(() => WorkspaceEntity, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<WorkspaceEntity>;
@Column({ nullable: false, type: 'float', default: 0 })
position: number;
@Column({ nullable: false, type: 'uuid' })
pageLayoutId: string;
@ManyToOne(() => PageLayoutEntity, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'pageLayoutId' })
pageLayout: Relation<PageLayoutEntity>;
@OneToMany(() => PageLayoutWidgetEntity, (widget) => widget.pageLayoutTab, {
cascade: true,
})
widgets: Relation<PageLayoutWidgetEntity[]>;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt: Date | null;
}
@@ -1,89 +0,0 @@
import { ObjectType } from '@nestjs/graphql';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Relation,
UpdateDateColumn,
} from 'typeorm';
import { WidgetConfigurationInterface } from 'src/engine/core-modules/page-layout/dtos/widget-configuration.interface';
import { PageLayoutTabEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-tab.entity';
import { WidgetType } from 'src/engine/core-modules/page-layout/enums/widget-type.enum';
import { GridPosition } from 'src/engine/core-modules/page-layout/types/grid-position.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@Entity({ name: 'pageLayoutWidget', schema: 'core' })
@ObjectType('PageLayoutWidget')
@Index(
'IDX_PAGE_LAYOUT_WIDGET_WORKSPACE_ID_PAGE_LAYOUT_TAB_ID',
['workspaceId', 'pageLayoutTabId'],
{ where: '"deletedAt" IS NULL' },
)
export class PageLayoutWidgetEntity
implements Required<PageLayoutWidgetEntity>
{
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false, type: 'uuid' })
pageLayoutTabId: string;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@ManyToOne(() => WorkspaceEntity, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<WorkspaceEntity>;
@ManyToOne(() => PageLayoutTabEntity, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'pageLayoutTabId' })
pageLayoutTab: Relation<PageLayoutTabEntity>;
@Column({ nullable: false })
title: string;
@Column({
type: 'enum',
enum: Object.values(WidgetType),
nullable: false,
default: WidgetType.VIEW,
})
type: WidgetType;
@Column({ nullable: true, type: 'uuid' })
objectMetadataId: string | null;
@ManyToOne(() => ObjectMetadataEntity, {
onDelete: 'CASCADE',
nullable: true,
})
@JoinColumn({ name: 'objectMetadataId' })
objectMetadata: Relation<ObjectMetadataEntity> | null;
@Column({ type: 'jsonb', nullable: false })
gridPosition: GridPosition;
@Column({ type: 'jsonb', nullable: true })
configuration: WidgetConfigurationInterface | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt: Date | null;
}
@@ -1,76 +0,0 @@
import { ObjectType } from '@nestjs/graphql';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
Relation,
UpdateDateColumn,
} from 'typeorm';
import { PageLayoutTabEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-tab.entity';
import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@Entity({ name: 'pageLayout', schema: 'core' })
@ObjectType('PageLayout')
@Index(
'IDX_PAGE_LAYOUT_WORKSPACE_ID_OBJECT_METADATA_ID',
['workspaceId', 'objectMetadataId'],
{ where: '"deletedAt" IS NULL' },
)
export class PageLayoutEntity implements Required<PageLayoutEntity> {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false })
name: string;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@ManyToOne(() => WorkspaceEntity, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<WorkspaceEntity>;
@Column({
type: 'enum',
enum: Object.values(PageLayoutType),
nullable: false,
default: PageLayoutType.RECORD_PAGE,
})
type: PageLayoutType;
@Column({ nullable: true, type: 'uuid' })
objectMetadataId: string | null;
@ManyToOne(() => ObjectMetadataEntity, {
onDelete: 'CASCADE',
nullable: true,
})
@JoinColumn({ name: 'objectMetadataId' })
objectMetadata: Relation<ObjectMetadataEntity> | null;
@OneToMany(() => PageLayoutTabEntity, (tab) => tab.pageLayout, {
cascade: true,
})
tabs: Relation<PageLayoutTabEntity[]>;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt: Date | null;
}
@@ -1,13 +0,0 @@
import { registerEnumType } from '@nestjs/graphql';
export enum AxisNameDisplay {
NONE = 'NONE',
X = 'X',
Y = 'Y',
BOTH = 'BOTH',
}
registerEnumType(AxisNameDisplay, {
name: 'AxisNameDisplay',
description: 'Which axes should display labels',
});
@@ -1,11 +0,0 @@
import { registerEnumType } from '@nestjs/graphql';
export enum BarChartGroupMode {
STACKED = 'STACKED',
GROUPED = 'GROUPED',
}
registerEnumType(BarChartGroupMode, {
name: 'BarChartGroupMode',
description: 'Display mode for bar charts with secondary grouping',
});
@@ -1,11 +0,0 @@
import { registerEnumType } from '@nestjs/graphql';
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
registerEnumType(ObjectRecordGroupByDateGranularity, {
name: 'ObjectRecordGroupByDateGranularity',
description:
'Date granularity options (e.g. DAY, MONTH, QUARTER, YEAR, WEEK, DAY_OF_THE_WEEK, MONTH_OF_THE_YEAR, QUARTER_OF_THE_YEAR)',
});
export { ObjectRecordGroupByDateGranularity };
@@ -1,13 +0,0 @@
import { registerEnumType } from '@nestjs/graphql';
export enum GraphOrderBy {
FIELD_ASC = 'FIELD_ASC',
FIELD_DESC = 'FIELD_DESC',
VALUE_ASC = 'VALUE_ASC',
VALUE_DESC = 'VALUE_DESC',
}
registerEnumType(GraphOrderBy, {
name: 'GraphOrderBy',
description: 'Order by options for graph widgets',
});
@@ -1,15 +0,0 @@
import { registerEnumType } from '@nestjs/graphql';
export enum GraphType {
AGGREGATE = 'AGGREGATE',
GAUGE = 'GAUGE',
PIE = 'PIE',
VERTICAL_BAR = 'VERTICAL_BAR',
HORIZONTAL_BAR = 'HORIZONTAL_BAR',
LINE = 'LINE',
}
registerEnumType(GraphType, {
name: 'GraphType',
description: 'Type of graph widget',
});
@@ -1,5 +0,0 @@
export enum PageLayoutType {
RECORD_INDEX = 'RECORD_INDEX',
RECORD_PAGE = 'RECORD_PAGE',
DASHBOARD = 'DASHBOARD',
}
@@ -1,5 +0,0 @@
export enum WidgetConfigurationType {
CHART_CONFIG = 'CHART_CONFIG',
IFRAME_CONFIG = 'IFRAME_CONFIG',
STANDALONE_RICH_TEXT_CONFIG = 'STANDALONE_RICH_TEXT_CONFIG',
}
@@ -1,17 +0,0 @@
export enum WidgetType {
VIEW = 'VIEW',
IFRAME = 'IFRAME',
FIELDS = 'FIELDS',
GRAPH = 'GRAPH',
STANDALONE_RICH_TEXT = 'STANDALONE_RICH_TEXT',
TIMELINE = 'TIMELINE',
TASKS = 'TASKS',
NOTES = 'NOTES',
FILES = 'FILES',
EMAILS = 'EMAILS',
CALENDAR = 'CALENDAR',
FIELD_RICH_TEXT = 'FIELD_RICH_TEXT',
WORKFLOW = 'WORKFLOW',
WORKFLOW_VERSION = 'WORKFLOW_VERSION',
WORKFLOW_RUN = 'WORKFLOW_RUN',
}
@@ -1,38 +0,0 @@
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum PageLayoutTabExceptionCode {
PAGE_LAYOUT_TAB_NOT_FOUND = 'PAGE_LAYOUT_TAB_NOT_FOUND',
INVALID_PAGE_LAYOUT_TAB_DATA = 'INVALID_PAGE_LAYOUT_TAB_DATA',
}
export enum PageLayoutTabExceptionMessageKey {
PAGE_LAYOUT_TAB_NOT_FOUND = 'PAGE_LAYOUT_TAB_NOT_FOUND',
TITLE_REQUIRED = 'TITLE_REQUIRED',
PAGE_LAYOUT_ID_REQUIRED = 'PAGE_LAYOUT_ID_REQUIRED',
PAGE_LAYOUT_NOT_FOUND = 'PAGE_LAYOUT_NOT_FOUND',
PAGE_LAYOUT_TAB_NOT_DELETED = 'PAGE_LAYOUT_TAB_NOT_DELETED',
}
export class PageLayoutTabException extends CustomException<PageLayoutTabExceptionCode> {}
export const generatePageLayoutTabExceptionMessage = (
key: PageLayoutTabExceptionMessageKey,
value?: string,
): string => {
switch (key) {
case PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND:
return `Page layout tab with ID "${value}" not found`;
case PageLayoutTabExceptionMessageKey.TITLE_REQUIRED:
return 'Page layout tab title is required';
case PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_ID_REQUIRED:
return 'Page layout ID is required';
case PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND:
return 'Page layout not found';
case PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_DELETED:
return 'Page layout tab is not deleted and cannot be restored';
default:
assertUnreachable(key);
}
};
@@ -1,66 +0,0 @@
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum PageLayoutWidgetExceptionCode {
PAGE_LAYOUT_WIDGET_NOT_FOUND = 'PAGE_LAYOUT_WIDGET_NOT_FOUND',
INVALID_PAGE_LAYOUT_WIDGET_DATA = 'INVALID_PAGE_LAYOUT_WIDGET_DATA',
}
export enum PageLayoutWidgetExceptionMessageKey {
PAGE_LAYOUT_WIDGET_NOT_FOUND = 'PAGE_LAYOUT_WIDGET_NOT_FOUND',
TITLE_REQUIRED = 'TITLE_REQUIRED',
PAGE_LAYOUT_TAB_ID_REQUIRED = 'PAGE_LAYOUT_TAB_ID_REQUIRED',
PAGE_LAYOUT_TAB_NOT_FOUND = 'PAGE_LAYOUT_TAB_NOT_FOUND',
PAGE_LAYOUT_WIDGET_NOT_DELETED = 'PAGE_LAYOUT_WIDGET_NOT_DELETED',
GRID_POSITION_REQUIRED = 'GRID_POSITION_REQUIRED',
INVALID_WIDGET_GRID_POSITION = 'INVALID_WIDGET_GRID_POSITION',
INVALID_WIDGET_CONFIGURATION = 'INVALID_WIDGET_CONFIGURATION',
}
export class PageLayoutWidgetException extends CustomException<PageLayoutWidgetExceptionCode> {}
export const generatePageLayoutWidgetExceptionMessage = (
key: PageLayoutWidgetExceptionMessageKey,
widgetTitle?: string,
widgetType?: string,
detailedError?: string,
): string => {
switch (key) {
case PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_WIDGET_NOT_FOUND:
return `Page layout widget with ID "${widgetTitle}" not found`;
case PageLayoutWidgetExceptionMessageKey.TITLE_REQUIRED:
return 'Page layout widget title is required';
case PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_TAB_ID_REQUIRED:
return 'Page layout tab ID is required';
case PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND:
return 'Page layout tab not found';
case PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_WIDGET_NOT_DELETED:
return 'Page layout widget is not deleted and cannot be restored';
case PageLayoutWidgetExceptionMessageKey.GRID_POSITION_REQUIRED:
return 'Grid position is required';
case PageLayoutWidgetExceptionMessageKey.INVALID_WIDGET_GRID_POSITION:
if (widgetTitle && detailedError) {
return `Invalid grid position for widget "${widgetTitle}": ${detailedError}`;
}
if (detailedError) {
return `Invalid grid position: ${detailedError}`;
}
return 'Invalid widget grid position';
case PageLayoutWidgetExceptionMessageKey.INVALID_WIDGET_CONFIGURATION:
if (widgetTitle && widgetType && detailedError) {
return `Invalid configuration for widget "${widgetTitle}" of type ${widgetType}: ${detailedError}`;
}
if (widgetTitle && widgetType) {
return `Invalid configuration for widget "${widgetTitle}" of type ${widgetType}`;
}
if (widgetType) {
return `Invalid configuration for widget type ${widgetType}`;
}
return 'Invalid widget configuration';
default:
assertUnreachable(key);
}
};
@@ -1,29 +0,0 @@
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum PageLayoutExceptionCode {
PAGE_LAYOUT_NOT_FOUND = 'PAGE_LAYOUT_NOT_FOUND',
INVALID_PAGE_LAYOUT_DATA = 'INVALID_PAGE_LAYOUT_DATA',
}
export enum PageLayoutExceptionMessageKey {
PAGE_LAYOUT_NOT_FOUND = 'PAGE_LAYOUT_NOT_FOUND',
NAME_REQUIRED = 'NAME_REQUIRED',
}
export class PageLayoutException extends CustomException<PageLayoutExceptionCode> {}
export const generatePageLayoutExceptionMessage = (
key: PageLayoutExceptionMessageKey,
value?: string,
): string => {
switch (key) {
case PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND:
return `Page layout with ID "${value}" not found`;
case PageLayoutExceptionMessageKey.NAME_REQUIRED:
return 'Page layout name is required';
default:
assertUnreachable(key);
}
};
@@ -1,47 +0,0 @@
import {
type ArgumentsHost,
Catch,
type ExceptionFilter,
} from '@nestjs/common';
import { type Response } from 'express';
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import {
PageLayoutException,
PageLayoutExceptionCode,
} from 'src/engine/core-modules/page-layout/exceptions/page-layout.exception';
@Catch(PageLayoutException)
export class PageLayoutRestApiExceptionFilter implements ExceptionFilter {
constructor(
private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
) {}
catch(exception: PageLayoutException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
switch (exception.code) {
case PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND:
return this.httpExceptionHandlerService.handleError(
exception,
response,
404,
);
case PageLayoutExceptionCode.INVALID_PAGE_LAYOUT_DATA:
return this.httpExceptionHandlerService.handleError(
exception,
response,
400,
);
default:
// TODO: change to 500 when we have input validation
return this.httpExceptionHandlerService.handleError(
exception,
response,
400,
);
}
}
}
@@ -1,47 +0,0 @@
import {
type ArgumentsHost,
Catch,
type ExceptionFilter,
} from '@nestjs/common';
import { type Response } from 'express';
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import {
PageLayoutTabException,
PageLayoutTabExceptionCode,
} from 'src/engine/core-modules/page-layout/exceptions/page-layout-tab.exception';
@Catch(PageLayoutTabException)
export class PageLayoutTabRestApiExceptionFilter implements ExceptionFilter {
constructor(
private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
) {}
catch(exception: PageLayoutTabException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
switch (exception.code) {
case PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND:
return this.httpExceptionHandlerService.handleError(
exception,
response,
404,
);
case PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA:
return this.httpExceptionHandlerService.handleError(
exception,
response,
400,
);
default:
// TODO: change to 500 when we have input validation
return this.httpExceptionHandlerService.handleError(
exception,
response,
400,
);
}
}
}
@@ -1,47 +0,0 @@
import {
type ArgumentsHost,
Catch,
type ExceptionFilter,
} from '@nestjs/common';
import { type Response } from 'express';
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import {
PageLayoutWidgetException,
PageLayoutWidgetExceptionCode,
} from 'src/engine/core-modules/page-layout/exceptions/page-layout-widget.exception';
@Catch(PageLayoutWidgetException)
export class PageLayoutWidgetRestApiExceptionFilter implements ExceptionFilter {
constructor(
private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
) {}
catch(exception: PageLayoutWidgetException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
switch (exception.code) {
case PageLayoutWidgetExceptionCode.PAGE_LAYOUT_WIDGET_NOT_FOUND:
return this.httpExceptionHandlerService.handleError(
exception,
response,
404,
);
case PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA:
return this.httpExceptionHandlerService.handleError(
exception,
response,
400,
);
default:
// TODO: change to 500 when we have input validation
return this.httpExceptionHandlerService.handleError(
exception,
response,
400,
);
}
}
}
@@ -1,50 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { PageLayoutTabController } from 'src/engine/core-modules/page-layout/controllers/page-layout-tab.controller';
import { PageLayoutWidgetController } from 'src/engine/core-modules/page-layout/controllers/page-layout-widget.controller';
import { PageLayoutController } from 'src/engine/core-modules/page-layout/controllers/page-layout.controller';
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 { PageLayoutTabResolver } from 'src/engine/core-modules/page-layout/resolvers/page-layout-tab.resolver';
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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
@Module({
imports: [
TypeOrmModule.forFeature([
PageLayoutEntity,
PageLayoutTabEntity,
PageLayoutWidgetEntity,
WorkspaceEntity,
]),
TwentyORMModule,
PermissionsModule,
FeatureFlagModule,
],
controllers: [
PageLayoutController,
PageLayoutTabController,
PageLayoutWidgetController,
],
providers: [
PageLayoutService,
PageLayoutTabService,
PageLayoutWidgetService,
PageLayoutResolver,
PageLayoutTabResolver,
PageLayoutWidgetResolver,
PageLayoutUpdateService,
],
exports: [PageLayoutService, PageLayoutTabService, PageLayoutWidgetService],
})
export class PageLayoutModule {}
@@ -1,97 +0,0 @@
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';
import { PageLayoutTabService } from 'src/engine/core-modules/page-layout/services/page-layout-tab.service';
import { PageLayoutGraphqlApiExceptionFilter } from 'src/engine/core-modules/page-layout/utils/page-layout-graphql-api-exception.filter';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
@Resolver(() => PageLayoutTabDTO)
@UseFilters(PageLayoutGraphqlApiExceptionFilter)
@UseGuards(WorkspaceAuthGuard)
@UsePipes(ResolverValidationPipe)
export class PageLayoutTabResolver {
constructor(private readonly pageLayoutTabService: PageLayoutTabService) {}
@Query(() => [PageLayoutTabDTO])
@UseGuards(NoPermissionGuard)
async getPageLayoutTabs(
@AuthWorkspace() workspace: WorkspaceEntity,
@Args('pageLayoutId', { type: () => String }) pageLayoutId: string,
): Promise<PageLayoutTabDTO[]> {
return this.pageLayoutTabService.findByPageLayoutId(
workspace.id,
pageLayoutId,
);
}
@Query(() => PageLayoutTabDTO)
@UseGuards(NoPermissionGuard)
async getPageLayoutTab(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutTabDTO> {
return this.pageLayoutTabService.findByIdOrThrow(id, workspace.id);
}
@Mutation(() => PageLayoutTabDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async createPageLayoutTab(
@Args('input') input: CreatePageLayoutTabInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutTabDTO> {
return this.pageLayoutTabService.create(input, workspace.id);
}
@Mutation(() => PageLayoutTabDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async updatePageLayoutTab(
@Args('id', { type: () => String }) id: string,
@Args('input') input: UpdatePageLayoutTabInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutTabDTO> {
return this.pageLayoutTabService.update(id, workspace.id, input);
}
@Mutation(() => Boolean)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async deletePageLayoutTab(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<boolean> {
const deletedPageLayoutTab = await this.pageLayoutTabService.delete(
id,
workspace.id,
);
return isDefined(deletedPageLayoutTab);
}
@Mutation(() => Boolean)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async destroyPageLayoutTab(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<boolean> {
return this.pageLayoutTabService.destroy(id, workspace.id);
}
@Mutation(() => PageLayoutTabDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async restorePageLayoutTab(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutTabDTO> {
return this.pageLayoutTabService.restore(id, workspace.id);
}
}
@@ -1,109 +0,0 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import {
Args,
Mutation,
Parent,
Query,
ResolveField,
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';
import { WidgetConfiguration } from 'src/engine/core-modules/page-layout/dtos/widget-configuration.interface';
import { PageLayoutWidgetService } from 'src/engine/core-modules/page-layout/services/page-layout-widget.service';
import { injectWidgetConfigurationDiscriminator } from 'src/engine/core-modules/page-layout/utils/inject-widget-configuration-discriminator.util';
import { PageLayoutGraphqlApiExceptionFilter } from 'src/engine/core-modules/page-layout/utils/page-layout-graphql-api-exception.filter';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
@Resolver(() => PageLayoutWidgetDTO)
@UseFilters(PageLayoutGraphqlApiExceptionFilter)
@UseGuards(WorkspaceAuthGuard)
@UsePipes(ResolverValidationPipe)
export class PageLayoutWidgetResolver {
constructor(
private readonly pageLayoutWidgetService: PageLayoutWidgetService,
) {}
@Query(() => [PageLayoutWidgetDTO])
@UseGuards(NoPermissionGuard)
async getPageLayoutWidgets(
@AuthWorkspace() workspace: WorkspaceEntity,
@Args('pageLayoutTabId', { type: () => String }) pageLayoutTabId: string,
): Promise<PageLayoutWidgetDTO[]> {
return this.pageLayoutWidgetService.findByPageLayoutTabId(
workspace.id,
pageLayoutTabId,
);
}
@Query(() => PageLayoutWidgetDTO)
@UseGuards(NoPermissionGuard)
async getPageLayoutWidget(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutWidgetDTO> {
return this.pageLayoutWidgetService.findByIdOrThrow(id, workspace.id);
}
@Mutation(() => PageLayoutWidgetDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async createPageLayoutWidget(
@Args('input') input: CreatePageLayoutWidgetInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutWidgetDTO> {
return this.pageLayoutWidgetService.create(input, workspace.id);
}
@Mutation(() => PageLayoutWidgetDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async updatePageLayoutWidget(
@Args('id', { type: () => String }) id: string,
@Args('input') input: UpdatePageLayoutWidgetInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutWidgetDTO> {
return this.pageLayoutWidgetService.update(id, workspace.id, input);
}
@Mutation(() => PageLayoutWidgetDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async deletePageLayoutWidget(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutWidgetDTO> {
return this.pageLayoutWidgetService.delete(id, workspace.id);
}
@Mutation(() => Boolean)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async destroyPageLayoutWidget(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<boolean> {
return this.pageLayoutWidgetService.destroy(id, workspace.id);
}
@Mutation(() => PageLayoutWidgetDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async restorePageLayoutWidget(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutWidgetDTO> {
return this.pageLayoutWidgetService.restore(id, workspace.id);
}
@ResolveField(() => WidgetConfiguration, { nullable: true })
configuration(@Parent() widget: PageLayoutWidgetDTO) {
return injectWidgetConfigurationDiscriminator(
widget.type,
widget.configuration,
);
}
}
@@ -1,126 +0,0 @@
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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
@Resolver(() => PageLayoutDTO)
@UseFilters(PageLayoutGraphqlApiExceptionFilter)
@UseGuards(WorkspaceAuthGuard)
@UsePipes(ResolverValidationPipe)
export class PageLayoutResolver {
constructor(
private readonly pageLayoutService: PageLayoutService,
private readonly pageLayoutUpdateService: PageLayoutUpdateService,
) {}
@Query(() => [PageLayoutDTO])
@UseGuards(NoPermissionGuard)
async getPageLayouts(
@AuthWorkspace() workspace: WorkspaceEntity,
@Args('objectMetadataId', { type: () => String, nullable: true })
objectMetadataId?: string,
): Promise<PageLayoutDTO[]> {
if (objectMetadataId) {
return this.pageLayoutService.findByObjectMetadataId(
workspace.id,
objectMetadataId,
);
}
return this.pageLayoutService.findByWorkspaceId(workspace.id);
}
@Query(() => PageLayoutDTO, { nullable: true })
@UseGuards(NoPermissionGuard)
async getPageLayout(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutDTO | null> {
return this.pageLayoutService.findByIdOrThrow(id, workspace.id);
}
@Mutation(() => PageLayoutDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async createPageLayout(
@Args('input') input: CreatePageLayoutInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutDTO> {
return this.pageLayoutService.create(input, workspace.id);
}
@Mutation(() => PageLayoutDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async updatePageLayout(
@Args('id', { type: () => String }) id: string,
@Args('input') input: UpdatePageLayoutInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutDTO> {
return this.pageLayoutService.update(id, workspace.id, input);
}
@Mutation(() => PageLayoutDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async deletePageLayout(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutDTO> {
const deletedPageLayout = await this.pageLayoutService.delete(
id,
workspace.id,
);
return deletedPageLayout;
}
@Mutation(() => Boolean)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async destroyPageLayout(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<boolean> {
const deletedPageLayout = await this.pageLayoutService.destroy(
id,
workspace.id,
);
return isDefined(deletedPageLayout);
}
@Mutation(() => PageLayoutDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async restorePageLayout(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutDTO> {
return this.pageLayoutService.restore(id, workspace.id);
}
@Mutation(() => PageLayoutDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async updatePageLayoutWithTabsAndWidgets(
@Args('id', { type: () => String }) id: string,
@Args('input') input: UpdatePageLayoutWithTabsInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutDTO> {
return this.pageLayoutUpdateService.updatePageLayoutWithTabs({
id,
workspaceId: workspace.id,
input,
});
}
}
@@ -1,606 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type Repository } from 'typeorm';
import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { 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 { WidgetType } from 'src/engine/core-modules/page-layout/enums/widget-type.enum';
import {
generatePageLayoutTabExceptionMessage,
PageLayoutTabException,
PageLayoutTabExceptionCode,
PageLayoutTabExceptionMessageKey,
} from 'src/engine/core-modules/page-layout/exceptions/page-layout-tab.exception';
import {
PageLayoutException,
PageLayoutExceptionCode,
} from 'src/engine/core-modules/page-layout/exceptions/page-layout.exception';
import { PageLayoutTabService } from 'src/engine/core-modules/page-layout/services/page-layout-tab.service';
import { PageLayoutService } from 'src/engine/core-modules/page-layout/services/page-layout.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
describe('PageLayoutTabService', () => {
let pageLayoutTabService: PageLayoutTabService;
let pageLayoutTabRepository: Repository<PageLayoutTabEntity>;
let pageLayoutService: PageLayoutService;
const mockPageLayoutTab = {
id: 'page-layout-tab-id',
title: 'Test Tab',
position: 0,
pageLayoutId: 'page-layout-id',
pageLayout: {} as any,
workspaceId: 'workspace-id',
workspace: {} as any,
widgets: [],
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
application: {} as ApplicationEntity,
applicationId: 'application-id',
universalIdentifier: 'universal-identifier',
} as PageLayoutTabEntity;
const mockWidget = {
id: 'widget-1',
title: 'Test Widget',
type: WidgetType.VIEW,
pageLayoutTabId: 'page-layout-tab-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();
const module: TestingModule = await Test.createTestingModule({
providers: [
PageLayoutTabService,
{
provide: getRepositoryToken(PageLayoutTabEntity),
useValue: {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
delete: jest.fn(),
restore: jest.fn(),
insert: jest.fn(),
},
},
{
provide: getRepositoryToken(WorkspaceEntity),
useValue: {
findOneOrFail: jest.fn().mockResolvedValue({
workspaceCustomApplicationId: 'application-id',
}),
},
},
{
provide: PageLayoutService,
useValue: {
findByIdOrThrow: jest.fn(),
},
},
],
}).compile();
pageLayoutTabService =
module.get<PageLayoutTabService>(PageLayoutTabService);
pageLayoutTabRepository = module.get<Repository<PageLayoutTabEntity>>(
getRepositoryToken(PageLayoutTabEntity),
);
pageLayoutService = module.get<PageLayoutService>(PageLayoutService);
});
it('should be defined', () => {
expect(pageLayoutTabService).toBeDefined();
});
describe('findByPageLayoutId', () => {
it('should return page layout tabs for a page layout id', async () => {
const workspaceId = 'workspace-id';
const pageLayoutId = 'page-layout-id';
const expectedTabs = [mockPageLayoutTab];
jest
.spyOn(pageLayoutTabRepository, 'find')
.mockResolvedValue(expectedTabs);
const result = await pageLayoutTabService.findByPageLayoutId(
workspaceId,
pageLayoutId,
);
expect(pageLayoutTabRepository.find).toHaveBeenCalledWith({
where: {
pageLayoutId,
pageLayout: { workspaceId },
},
order: { position: 'ASC' },
relations: ['widgets'],
withDeleted: false,
});
expect(result).toEqual(expectedTabs);
});
it('should return empty array when no tabs are found', async () => {
const workspaceId = 'workspace-id';
const pageLayoutId = 'page-layout-id';
jest.spyOn(pageLayoutTabRepository, 'find').mockResolvedValue([]);
const result = await pageLayoutTabService.findByPageLayoutId(
workspaceId,
pageLayoutId,
);
expect(result).toEqual([]);
});
it('should order tabs by position in ascending order', async () => {
const workspaceId = 'workspace-id';
const pageLayoutId = 'page-layout-id';
const tab1 = { ...mockPageLayoutTab, id: 'tab-1', position: 2 };
const tab2 = { ...mockPageLayoutTab, id: 'tab-2', position: 0 };
const tab3 = { ...mockPageLayoutTab, id: 'tab-3', position: 1 };
const expectedTabs = [tab2, tab3, tab1];
jest
.spyOn(pageLayoutTabRepository, 'find')
.mockResolvedValue(expectedTabs);
const result = await pageLayoutTabService.findByPageLayoutId(
workspaceId,
pageLayoutId,
);
expect(pageLayoutTabRepository.find).toHaveBeenCalledWith({
where: {
pageLayoutId,
pageLayout: { workspaceId },
},
order: { position: 'ASC' },
relations: ['widgets'],
withDeleted: false,
});
expect(result).toEqual(expectedTabs);
});
it('should include widgets relation', async () => {
const workspaceId = 'workspace-id';
const pageLayoutId = 'page-layout-id';
const widget1 = { ...mockWidget, id: 'widget-1', type: WidgetType.VIEW };
const widget2 = {
...mockWidget,
id: 'widget-2',
type: WidgetType.FIELDS,
};
const tabWithWidgets = {
...mockPageLayoutTab,
widgets: [widget1, widget2],
};
jest
.spyOn(pageLayoutTabRepository, 'find')
.mockResolvedValue([tabWithWidgets]);
const result = await pageLayoutTabService.findByPageLayoutId(
workspaceId,
pageLayoutId,
);
expect(result[0].widgets).toHaveLength(2);
expect(result[0].widgets[0].id).toEqual('widget-1');
expect(result[0].widgets[1].id).toEqual('widget-2');
});
});
describe('findByIdOrThrow', () => {
it('should return page layout tab when found', async () => {
const id = 'page-layout-tab-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutTabRepository, 'findOne')
.mockResolvedValue(mockPageLayoutTab);
const result = await pageLayoutTabService.findByIdOrThrow(
id,
workspaceId,
);
expect(pageLayoutTabRepository.findOne).toHaveBeenCalledWith({
where: {
id,
workspaceId,
deletedAt: expect.anything(),
},
relations: ['widgets'],
});
expect(result).toEqual(mockPageLayoutTab);
});
it('should throw exception when page layout tab is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(pageLayoutTabRepository, 'findOne').mockResolvedValue(null);
await expect(
pageLayoutTabService.findByIdOrThrow(id, workspaceId),
).rejects.toThrow(PageLayoutTabException);
});
});
describe('create', () => {
it('should create a new page layout tab successfully', async () => {
const workspaceId = 'workspace-id';
const pageLayoutTabData = {
id: 'page-layout-tab-id',
title: 'New Tab',
pageLayoutId: 'page-layout-id',
position: 1,
};
jest
.spyOn(pageLayoutTabService, 'findByIdOrThrow')
.mockResolvedValue(mockPageLayoutTab as any);
jest.spyOn(pageLayoutTabRepository, 'insert').mockResolvedValue({
identifiers: [{ id: 'page-layout-tab-id' }],
generatedMaps: [],
raw: [],
});
const result = await pageLayoutTabService.create(
pageLayoutTabData,
workspaceId,
);
expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith(
pageLayoutTabData.pageLayoutId,
workspaceId,
undefined,
);
expect(pageLayoutTabRepository.insert).toHaveBeenCalledWith({
...pageLayoutTabData,
workspaceId,
applicationId: 'application-id',
universalIdentifier: expect.any(String),
});
expect(result).toEqual(mockPageLayoutTab);
});
it('should throw an exception when title is not provided', async () => {
const workspaceId = 'workspace-id';
const pageLayoutTabData = {
pageLayoutId: 'page-layout-id',
};
await expect(
// @ts-expect-error - we are testing the exception
pageLayoutTabService.create(pageLayoutTabData, workspaceId),
).rejects.toThrow(PageLayoutTabException);
await expect(
// @ts-expect-error - we are testing the exception
pageLayoutTabService.create(pageLayoutTabData, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA,
);
});
it('should throw an exception when page layout does not exist', async () => {
const workspaceId = 'workspace-id';
const pageLayoutTabData = {
title: 'New Tab',
pageLayoutId: 'non-existent-page-layout-id',
};
jest.spyOn(pageLayoutTabRepository, 'insert').mockResolvedValue({
identifiers: [{ id: 'page-layout-tab-id' }],
generatedMaps: [],
raw: [],
});
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockRejectedValue(
new PageLayoutException(
'Page layout not found',
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
await expect(
pageLayoutTabService.create(pageLayoutTabData, workspaceId),
).rejects.toThrow(PageLayoutTabException);
await expect(
pageLayoutTabService.create(pageLayoutTabData, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA,
);
});
it('should throw an exception when page layout is not found', async () => {
const workspaceId = 'workspace-id';
const pageLayoutTabData = {
title: 'New Tab',
pageLayoutId: 'non-existent-page-layout-id',
};
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockRejectedValue(new Error('Page layout not found'));
await expect(
pageLayoutTabService.create(pageLayoutTabData, workspaceId),
).rejects.toThrow();
});
});
describe('update', () => {
it('should update a page layout tab successfully', async () => {
const id = 'page-layout-tab-id';
const workspaceId = 'workspace-id';
const updateData = { title: 'Updated Tab' };
const updatedTab = { ...mockPageLayoutTab, title: 'Updated Tab' };
jest
.spyOn(pageLayoutTabRepository, 'findOne')
.mockResolvedValue(mockPageLayoutTab);
jest.spyOn(pageLayoutTabRepository, 'update').mockResolvedValue({
affected: 1,
generatedMaps: [],
raw: {},
});
jest
.spyOn(pageLayoutTabService, 'findByIdOrThrow')
.mockResolvedValue(updatedTab);
const result = await pageLayoutTabService.update(
id,
workspaceId,
updateData,
);
expect(pageLayoutTabRepository.update).toHaveBeenCalledWith(
{ id },
updateData,
);
expect(result).toEqual(updatedTab);
});
it('should throw an exception when tab to update is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
const updateData = { title: 'Updated Tab' };
jest.spyOn(pageLayoutTabRepository, 'update').mockResolvedValue({
affected: 1,
generatedMaps: [],
raw: {},
});
jest
.spyOn(pageLayoutTabService, 'findByIdOrThrow')
.mockRejectedValue(
new PageLayoutTabException(
'Page layout tab not found',
PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
),
);
await expect(
pageLayoutTabService.update(id, workspaceId, updateData),
).rejects.toThrow(PageLayoutTabException);
});
});
describe('delete', () => {
it('should soft delete a page layout tab successfully', async () => {
const id = 'page-layout-tab-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutTabService, 'findByIdOrThrow')
.mockResolvedValue(mockPageLayoutTab);
jest
.spyOn(pageLayoutTabRepository, 'softDelete')
.mockResolvedValue({ affected: 1, generatedMaps: [], raw: {} });
const result = await pageLayoutTabService.delete(id, workspaceId);
expect(pageLayoutTabRepository.softDelete).toHaveBeenCalledWith(id);
expect(result).toEqual(mockPageLayoutTab);
});
it('should throw an exception when tab to delete is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutTabService, 'findByIdOrThrow')
.mockRejectedValue(
new PageLayoutTabException(
'Page layout tab not found',
PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
),
);
await expect(
pageLayoutTabService.delete(id, workspaceId),
).rejects.toThrow(PageLayoutTabException);
});
});
describe('destroy', () => {
it('should permanently delete a page layout tab successfully', async () => {
const id = 'page-layout-tab-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutTabRepository, 'findOne')
.mockResolvedValue(mockPageLayoutTab);
jest
.spyOn(pageLayoutTabRepository, 'delete')
.mockResolvedValue({ affected: 1, generatedMaps: [], raw: {} });
const result = await pageLayoutTabService.destroy(id, workspaceId);
expect(pageLayoutTabRepository.findOne).toHaveBeenCalledWith({
where: {
id,
workspaceId,
},
withDeleted: true,
});
expect(pageLayoutTabRepository.delete).toHaveBeenCalledWith(id);
expect(result).toBe(true);
});
it('should throw an exception when tab to destroy is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(pageLayoutTabRepository, 'findOne').mockResolvedValue(null);
await expect(
pageLayoutTabService.destroy(id, workspaceId),
).rejects.toThrow(PageLayoutTabException);
await expect(
pageLayoutTabService.destroy(id, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
);
});
});
describe('restore', () => {
it('should restore a deleted page layout tab successfully', async () => {
const id = 'page-layout-tab-id';
const workspaceId = 'workspace-id';
const deletedTab = { ...mockPageLayoutTab, deletedAt: new Date() };
jest
.spyOn(pageLayoutTabRepository, 'findOne')
.mockResolvedValue(deletedTab);
jest
.spyOn(pageLayoutTabRepository, 'restore')
.mockResolvedValue({ affected: 1, generatedMaps: [], raw: {} });
jest
.spyOn(pageLayoutTabService, 'findByIdOrThrow')
.mockResolvedValue(mockPageLayoutTab);
const result = await pageLayoutTabService.restore(id, workspaceId);
expect(pageLayoutTabRepository.findOne).toHaveBeenCalledWith({
select: {
id: true,
deletedAt: true,
pageLayoutId: true,
},
where: {
id,
workspaceId,
},
withDeleted: true,
});
expect(pageLayoutTabRepository.restore).toHaveBeenCalledWith(id);
expect(result).toEqual(mockPageLayoutTab);
});
it('should throw an exception when tab to restore is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(pageLayoutTabRepository, 'findOne').mockResolvedValue(null);
await expect(
pageLayoutTabService.restore(id, workspaceId),
).rejects.toThrow(PageLayoutTabException);
await expect(
pageLayoutTabService.restore(id, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
);
});
it('should throw an exception when tab is not deleted', async () => {
const id = 'page-layout-tab-id';
const workspaceId = 'workspace-id';
const notDeletedTab = { ...mockPageLayoutTab, deletedAt: null };
jest
.spyOn(pageLayoutTabRepository, 'findOne')
.mockResolvedValue(notDeletedTab);
await expect(
pageLayoutTabService.restore(id, workspaceId),
).rejects.toThrow(PageLayoutTabException);
await expect(
pageLayoutTabService.restore(id, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA,
);
});
it('should throw an exception when parent page layout is not accessible', async () => {
const id = 'page-layout-tab-id';
const workspaceId = 'workspace-id';
const deletedTab = {
...mockPageLayoutTab,
deletedAt: new Date(),
pageLayoutId: 'deleted-page-layout-id',
};
jest
.spyOn(pageLayoutTabRepository, 'findOne')
.mockResolvedValue(deletedTab);
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockRejectedValue(
new PageLayoutException(
'Page layout not found',
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
await expect(
pageLayoutTabService.restore(id, workspaceId),
).rejects.toThrow(PageLayoutTabException);
await expect(
pageLayoutTabService.restore(id, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA,
);
await expect(
pageLayoutTabService.restore(id, workspaceId),
).rejects.toHaveProperty(
'message',
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
),
);
expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith(
'deleted-page-layout-id',
workspaceId,
undefined,
);
});
});
});
@@ -1,761 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { DataSource, type EntityManager } from 'typeorm';
import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
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 WorkspaceEntity } 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 WorkspaceEntity,
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 WorkspaceEntity,
pageLayout: {} as PageLayoutEntity,
widgets: [],
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
application: {} as ApplicationEntity,
applicationId: 'application-id',
universalIdentifier: 'universal-identifier',
} 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: null,
},
],
},
],
};
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 = {
name: 'Updated Page Layout',
type: PageLayoutType.DASHBOARD,
objectMetadataId: 'object-metadata-id',
tabs: [],
};
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: null,
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: null,
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: null,
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: null,
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: null,
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: null,
title: 'Updated Widget',
type: WidgetType.VIEW,
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
},
{
id: 'new-widget-id',
pageLayoutTabId: tabId,
objectMetadataId: null,
configuration: null,
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: null,
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: null,
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: null,
},
{
id: 'widget-2',
title: 'Widget 2',
type: WidgetType.FIELDS,
gridPosition: { row: 0, column: 4, rowSpan: 2, columnSpan: 2 },
pageLayoutTabId: 'tab-1',
objectMetadataId: null,
configuration: null,
},
],
},
{
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);
});
});
});
@@ -1,580 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { IsNull, type Repository } from 'typeorm';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { PageLayoutWidgetEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-widget.entity';
import { WidgetType } from 'src/engine/core-modules/page-layout/enums/widget-type.enum';
import {
PageLayoutTabException,
PageLayoutTabExceptionCode,
} from 'src/engine/core-modules/page-layout/exceptions/page-layout-tab.exception';
import {
generatePageLayoutWidgetExceptionMessage,
PageLayoutWidgetException,
PageLayoutWidgetExceptionCode,
PageLayoutWidgetExceptionMessageKey,
} from 'src/engine/core-modules/page-layout/exceptions/page-layout-widget.exception';
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';
describe('PageLayoutWidgetService', () => {
let pageLayoutWidgetService: PageLayoutWidgetService;
let pageLayoutWidgetRepository: Repository<PageLayoutWidgetEntity>;
let pageLayoutTabService: PageLayoutTabService;
const mockPageLayoutWidget = {
id: 'page-layout-widget-id',
title: 'Test Widget',
type: WidgetType.VIEW,
pageLayoutTabId: 'page-layout-tab-id',
pageLayoutTab: {} as any,
objectMetadataId: 'object-metadata-id',
objectMetadata: null,
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();
const module: TestingModule = await Test.createTestingModule({
providers: [
PageLayoutWidgetService,
{
provide: getRepositoryToken(PageLayoutWidgetEntity),
useValue: {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
insert: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
delete: jest.fn(),
restore: jest.fn(),
},
},
{
provide: PageLayoutTabService,
useValue: {
findByIdOrThrow: jest.fn(),
},
},
{
provide: FeatureFlagService,
useValue: {
isFeatureEnabled: jest.fn(),
},
},
],
}).compile();
pageLayoutWidgetService = module.get<PageLayoutWidgetService>(
PageLayoutWidgetService,
);
pageLayoutWidgetRepository = module.get<Repository<PageLayoutWidgetEntity>>(
getRepositoryToken(PageLayoutWidgetEntity),
);
pageLayoutTabService =
module.get<PageLayoutTabService>(PageLayoutTabService);
});
it('should be defined', () => {
expect(pageLayoutWidgetService).toBeDefined();
});
describe('findByPageLayoutTabId', () => {
it('should return page layout widgets for a page layout tab id', async () => {
const workspaceId = 'workspace-id';
const pageLayoutTabId = 'page-layout-tab-id';
const expectedWidgets = [mockPageLayoutWidget];
jest
.spyOn(pageLayoutWidgetRepository, 'find')
.mockResolvedValue(expectedWidgets);
const result = await pageLayoutWidgetService.findByPageLayoutTabId(
workspaceId,
pageLayoutTabId,
);
expect(pageLayoutWidgetRepository.find).toHaveBeenCalledWith({
where: {
pageLayoutTabId,
workspaceId,
},
order: { createdAt: 'ASC' },
withDeleted: false,
});
expect(result).toEqual(expectedWidgets);
});
it('should return empty array when no widgets are found', async () => {
const workspaceId = 'workspace-id';
const pageLayoutTabId = 'page-layout-tab-id';
jest.spyOn(pageLayoutWidgetRepository, 'find').mockResolvedValue([]);
const result = await pageLayoutWidgetService.findByPageLayoutTabId(
workspaceId,
pageLayoutTabId,
);
expect(result).toEqual([]);
});
});
describe('findByIdOrThrow', () => {
it('should return page layout widget when found', async () => {
const id = 'page-layout-widget-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutWidgetRepository, 'findOne')
.mockResolvedValue(mockPageLayoutWidget);
const result = await pageLayoutWidgetService.findByIdOrThrow(
id,
workspaceId,
);
expect(pageLayoutWidgetRepository.findOne).toHaveBeenCalledWith({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
});
expect(result).toEqual(mockPageLayoutWidget);
});
it('should throw exception when page layout widget is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(pageLayoutWidgetRepository, 'findOne').mockResolvedValue(null);
await expect(
pageLayoutWidgetService.findByIdOrThrow(id, workspaceId),
).rejects.toThrow(PageLayoutWidgetException);
await expect(
pageLayoutWidgetService.findByIdOrThrow(id, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutWidgetExceptionCode.PAGE_LAYOUT_WIDGET_NOT_FOUND,
);
});
});
describe('create', () => {
const validPageLayoutWidgetData = {
id: 'page-layout-widget-id',
title: 'New Widget',
pageLayoutTabId: 'page-layout-tab-id',
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
type: WidgetType.VIEW,
};
it('should create a new page layout widget successfully', async () => {
const workspaceId = 'workspace-id';
jest.spyOn(pageLayoutWidgetRepository, 'insert').mockResolvedValue({
identifiers: [{ id: 'page-layout-widget-id' }],
generatedMaps: [],
raw: [],
});
jest
.spyOn(pageLayoutWidgetService, 'findByIdOrThrow')
.mockResolvedValue(mockPageLayoutWidget);
const result = await pageLayoutWidgetService.create(
validPageLayoutWidgetData,
workspaceId,
);
expect(pageLayoutWidgetRepository.insert).toHaveBeenCalledWith({
...validPageLayoutWidgetData,
workspaceId,
});
expect(result).toEqual(mockPageLayoutWidget);
});
it('should throw an exception when title is not provided', async () => {
const workspaceId = 'workspace-id';
const pageLayoutWidgetData = {
...validPageLayoutWidgetData,
title: undefined,
};
await expect(
// @ts-expect-error - we are testing the exception
pageLayoutWidgetService.create(pageLayoutWidgetData, workspaceId),
).rejects.toThrow(PageLayoutWidgetException);
await expect(
// @ts-expect-error - we are testing the exception
pageLayoutWidgetService.create(pageLayoutWidgetData, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
});
it('should throw an exception when pageLayoutTabId is not provided', async () => {
const workspaceId = 'workspace-id';
const pageLayoutWidgetData = {
...validPageLayoutWidgetData,
pageLayoutTabId: undefined,
};
await expect(
// @ts-expect-error - we are testing the exception
pageLayoutWidgetService.create(pageLayoutWidgetData, workspaceId),
).rejects.toThrow(PageLayoutWidgetException);
await expect(
// @ts-expect-error - we are testing the exception
pageLayoutWidgetService.create(pageLayoutWidgetData, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
});
it('should throw an exception when gridPosition is not provided', async () => {
const workspaceId = 'workspace-id';
const pageLayoutWidgetData = {
...validPageLayoutWidgetData,
gridPosition: undefined,
};
await expect(
// @ts-expect-error - we are testing the exception
pageLayoutWidgetService.create(pageLayoutWidgetData, workspaceId),
).rejects.toThrow(PageLayoutWidgetException);
await expect(
// @ts-expect-error - we are testing the exception
pageLayoutWidgetService.create(pageLayoutWidgetData, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
});
it('should throw an exception when page layout tab does not exist', async () => {
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutTabService, 'findByIdOrThrow')
.mockRejectedValue(
new PageLayoutTabException(
'Page layout tab not found',
PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
),
);
await expect(
pageLayoutWidgetService.create(validPageLayoutWidgetData, workspaceId),
).rejects.toThrow(PageLayoutWidgetException);
await expect(
pageLayoutWidgetService.create(validPageLayoutWidgetData, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
});
it('should rethrow other errors', async () => {
const workspaceId = 'workspace-id';
const unexpectedError = new Error('Unexpected error');
jest
.spyOn(pageLayoutTabService, 'findByIdOrThrow')
.mockRejectedValue(unexpectedError);
await expect(
pageLayoutWidgetService.create(validPageLayoutWidgetData, workspaceId),
).rejects.toThrow(unexpectedError);
});
});
describe('update', () => {
it('should update a page layout widget successfully', async () => {
const id = 'page-layout-widget-id';
const workspaceId = 'workspace-id';
const updateData = { title: 'Updated Widget' };
const updatedWidget = {
...mockPageLayoutWidget,
title: 'Updated Widget',
};
jest
.spyOn(pageLayoutWidgetRepository, 'findOne')
.mockResolvedValueOnce(mockPageLayoutWidget)
.mockResolvedValueOnce(updatedWidget);
jest.spyOn(pageLayoutWidgetRepository, 'update').mockResolvedValue({
affected: 1,
generatedMaps: [],
raw: {},
});
const result = await pageLayoutWidgetService.update(
id,
workspaceId,
updateData,
);
expect(pageLayoutWidgetRepository.findOne).toHaveBeenCalledTimes(2);
expect(pageLayoutWidgetRepository.findOne).toHaveBeenNthCalledWith(1, {
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
});
expect(pageLayoutWidgetRepository.findOne).toHaveBeenNthCalledWith(2, {
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
});
expect(pageLayoutWidgetRepository.update).toHaveBeenCalledWith(
{ id },
updateData,
);
expect(result).toEqual(updatedWidget);
});
it('should throw an exception when widget to update is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
const updateData = { title: 'Updated Widget' };
jest.spyOn(pageLayoutWidgetRepository, 'findOne').mockResolvedValue(null);
await expect(
pageLayoutWidgetService.update(id, workspaceId, updateData),
).rejects.toThrow(PageLayoutWidgetException);
await expect(
pageLayoutWidgetService.update(id, workspaceId, updateData),
).rejects.toHaveProperty(
'code',
PageLayoutWidgetExceptionCode.PAGE_LAYOUT_WIDGET_NOT_FOUND,
);
});
});
describe('delete', () => {
it('should soft delete a page layout widget successfully', async () => {
const id = 'page-layout-widget-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutWidgetRepository, 'findOne')
.mockResolvedValue(mockPageLayoutWidget);
jest
.spyOn(pageLayoutWidgetRepository, 'softDelete')
.mockResolvedValue({ affected: 1, generatedMaps: [], raw: {} });
const result = await pageLayoutWidgetService.delete(id, workspaceId);
expect(pageLayoutWidgetRepository.findOne).toHaveBeenCalledWith({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
});
expect(pageLayoutWidgetRepository.softDelete).toHaveBeenCalledWith(id);
expect(result).toEqual(mockPageLayoutWidget);
});
it('should throw an exception when widget to delete is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(pageLayoutWidgetRepository, 'findOne').mockResolvedValue(null);
await expect(
pageLayoutWidgetService.delete(id, workspaceId),
).rejects.toThrow(PageLayoutWidgetException);
await expect(
pageLayoutWidgetService.delete(id, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutWidgetExceptionCode.PAGE_LAYOUT_WIDGET_NOT_FOUND,
);
});
});
describe('destroy', () => {
it('should permanently delete a page layout widget successfully', async () => {
const id = 'page-layout-widget-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutWidgetRepository, 'findOne')
.mockResolvedValue(mockPageLayoutWidget);
jest
.spyOn(pageLayoutWidgetRepository, 'delete')
.mockResolvedValue({ affected: 1, generatedMaps: [], raw: {} });
const result = await pageLayoutWidgetService.destroy(id, workspaceId);
expect(pageLayoutWidgetRepository.findOne).toHaveBeenCalledWith({
where: {
id,
workspaceId,
},
withDeleted: true,
});
expect(pageLayoutWidgetRepository.delete).toHaveBeenCalledWith(id);
expect(result).toBe(true);
});
it('should throw an exception when widget to destroy is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(pageLayoutWidgetRepository, 'findOne').mockResolvedValue(null);
await expect(
pageLayoutWidgetService.destroy(id, workspaceId),
).rejects.toThrow(PageLayoutWidgetException);
await expect(
pageLayoutWidgetService.destroy(id, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutWidgetExceptionCode.PAGE_LAYOUT_WIDGET_NOT_FOUND,
);
});
});
describe('restore', () => {
it('should restore a deleted page layout widget successfully', async () => {
const id = 'page-layout-widget-id';
const workspaceId = 'workspace-id';
const deletedWidget = { ...mockPageLayoutWidget, deletedAt: new Date() };
jest
.spyOn(pageLayoutWidgetRepository, 'findOne')
.mockResolvedValueOnce(deletedWidget) // First call in restore method to check if deleted
.mockResolvedValueOnce(mockPageLayoutWidget); // Second call in findByIdOrThrow
jest
.spyOn(pageLayoutWidgetRepository, 'restore')
.mockResolvedValue({ affected: 1, generatedMaps: [], raw: {} });
const result = await pageLayoutWidgetService.restore(id, workspaceId);
expect(pageLayoutWidgetRepository.findOne).toHaveBeenCalledTimes(2);
expect(pageLayoutWidgetRepository.findOne).toHaveBeenNthCalledWith(1, {
select: {
id: true,
deletedAt: true,
pageLayoutTabId: true,
},
where: {
id,
workspaceId,
},
withDeleted: true,
});
expect(pageLayoutWidgetRepository.findOne).toHaveBeenNthCalledWith(2, {
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
});
expect(pageLayoutWidgetRepository.restore).toHaveBeenCalledWith(id);
expect(result).toEqual(mockPageLayoutWidget);
});
it('should throw an exception when widget to restore is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(pageLayoutWidgetRepository, 'findOne').mockResolvedValue(null);
await expect(
pageLayoutWidgetService.restore(id, workspaceId),
).rejects.toThrow(PageLayoutWidgetException);
await expect(
pageLayoutWidgetService.restore(id, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutWidgetExceptionCode.PAGE_LAYOUT_WIDGET_NOT_FOUND,
);
});
it('should throw an exception when widget is not deleted', async () => {
const id = 'page-layout-widget-id';
const workspaceId = 'workspace-id';
const notDeletedWidget = { ...mockPageLayoutWidget, deletedAt: null };
jest
.spyOn(pageLayoutWidgetRepository, 'findOne')
.mockResolvedValue(notDeletedWidget);
await expect(
pageLayoutWidgetService.restore(id, workspaceId),
).rejects.toThrow(PageLayoutWidgetException);
await expect(
pageLayoutWidgetService.restore(id, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
});
it('should throw an exception when parent tab is not accessible', async () => {
const id = 'page-layout-widget-id';
const workspaceId = 'workspace-id';
const deletedWidget = {
...mockPageLayoutWidget,
deletedAt: new Date(),
pageLayoutTabId: 'deleted-tab-id',
};
jest
.spyOn(pageLayoutWidgetRepository, 'findOne')
.mockResolvedValue(deletedWidget);
jest
.spyOn(pageLayoutTabService, 'findByIdOrThrow')
.mockRejectedValue(
new PageLayoutTabException(
'Page layout tab not found',
PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
),
);
await expect(
pageLayoutWidgetService.restore(id, workspaceId),
).rejects.toThrow(PageLayoutWidgetException);
await expect(
pageLayoutWidgetService.restore(id, workspaceId),
).rejects.toHaveProperty(
'code',
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
await expect(
pageLayoutWidgetService.restore(id, workspaceId),
).rejects.toHaveProperty(
'message',
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND,
),
);
expect(pageLayoutTabService.findByIdOrThrow).toHaveBeenCalledWith(
'deleted-tab-id',
workspaceId,
undefined,
);
});
});
});
@@ -1,474 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { IsNull, type Repository } from 'typeorm';
import { type CreatePageLayoutInput } from 'src/engine/core-modules/page-layout/dtos/inputs/create-page-layout.input';
import { PageLayoutEntity } from 'src/engine/core-modules/page-layout/entities/page-layout.entity';
import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum';
import {
PageLayoutException,
PageLayoutExceptionCode,
PageLayoutExceptionMessageKey,
generatePageLayoutExceptionMessage,
} from 'src/engine/core-modules/page-layout/exceptions/page-layout.exception';
import { PageLayoutService } from 'src/engine/core-modules/page-layout/services/page-layout.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
describe('PageLayoutService', () => {
let pageLayoutService: PageLayoutService;
let pageLayoutRepository: Repository<PageLayoutEntity>;
let twentyORMGlobalManager: TwentyORMGlobalManager;
const mockPageLayout = {
id: 'page-layout-id',
name: 'Test Page Layout',
workspaceId: 'workspace-id',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: 'object-metadata-id',
tabs: [],
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
} as unknown as PageLayoutEntity;
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
PageLayoutService,
{
provide: getRepositoryToken(PageLayoutEntity),
useValue: {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
delete: jest.fn(),
restore: jest.fn(),
insert: jest.fn(),
},
},
{
provide: TwentyORMGlobalManager,
useValue: {
getRepositoryForWorkspace: jest.fn(),
},
},
],
}).compile();
pageLayoutService = module.get<PageLayoutService>(PageLayoutService);
pageLayoutRepository = module.get<Repository<PageLayoutEntity>>(
getRepositoryToken(PageLayoutEntity),
);
twentyORMGlobalManager = module.get<TwentyORMGlobalManager>(
TwentyORMGlobalManager,
);
});
describe('findByWorkspaceId', () => {
it('should return page layouts for a workspace', async () => {
const workspaceId = 'workspace-id';
const expectedPageLayouts = [mockPageLayout];
jest
.spyOn(pageLayoutRepository, 'find')
.mockResolvedValue(expectedPageLayouts);
const result = await pageLayoutService.findByWorkspaceId(workspaceId);
expect(pageLayoutRepository.find).toHaveBeenCalledWith({
where: {
workspaceId,
deletedAt: IsNull(),
},
relations: ['tabs', 'tabs.widgets'],
});
expect(result).toEqual(expectedPageLayouts);
});
});
describe('findByObjectMetadataId', () => {
it('should return page layouts for an object metadata id', async () => {
const workspaceId = 'workspace-id';
const objectMetadataId = 'object-metadata-id';
const expectedPageLayouts = [mockPageLayout];
jest
.spyOn(pageLayoutRepository, 'find')
.mockResolvedValue(expectedPageLayouts);
const result = await pageLayoutService.findByObjectMetadataId(
workspaceId,
objectMetadataId,
);
expect(pageLayoutRepository.find).toHaveBeenCalledWith({
where: {
workspaceId,
objectMetadataId,
deletedAt: IsNull(),
},
relations: ['tabs', 'tabs.widgets'],
});
expect(result).toEqual(expectedPageLayouts);
});
});
describe('findByIdOrThrow', () => {
it('should return a page layout by id', async () => {
const id = 'page-layout-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutRepository, 'findOne')
.mockResolvedValue(mockPageLayout);
const result = await pageLayoutService.findByIdOrThrow(id, workspaceId);
expect(pageLayoutRepository.findOne).toHaveBeenCalledWith({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
relations: ['tabs', 'tabs.widgets'],
});
expect(result).toEqual(mockPageLayout);
});
it('should throw exception when page layout is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(pageLayoutRepository, 'findOne').mockResolvedValue(null);
await expect(
pageLayoutService.findByIdOrThrow(id, workspaceId),
).rejects.toThrow(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
});
});
describe('create', () => {
const validPageLayoutData = {
id: 'page-layout-id',
name: 'Test Page Layout',
type: PageLayoutType.RECORD_PAGE,
objectMetadataId: 'object-metadata-id',
};
it('should create a page layout successfully', async () => {
jest.spyOn(pageLayoutRepository, 'insert').mockResolvedValue({
identifiers: [{ id: 'page-layout-id' }],
generatedMaps: [],
raw: [],
});
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockResolvedValue(mockPageLayout);
const result = await pageLayoutService.create(
validPageLayoutData,
'workspace-id',
);
expect(pageLayoutRepository.insert).toHaveBeenCalledWith({
...validPageLayoutData,
workspaceId: 'workspace-id',
});
expect(result).toEqual(mockPageLayout);
});
it('should throw exception when name is missing', async () => {
const invalidData = { ...validPageLayoutData, name: undefined };
const workspaceId = 'workspace-id';
await expect(
pageLayoutService.create(
invalidData as unknown as CreatePageLayoutInput,
workspaceId,
),
).rejects.toThrow(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.NAME_REQUIRED,
),
PageLayoutExceptionCode.INVALID_PAGE_LAYOUT_DATA,
),
);
});
});
describe('update', () => {
it('should update a page layout successfully', async () => {
const id = 'page-layout-id';
const workspaceId = 'workspace-id';
const updateData = { name: 'Updated Page Layout' };
const updatedPageLayout = { ...mockPageLayout, ...updateData };
jest.spyOn(pageLayoutRepository, 'update').mockResolvedValue({} as any);
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockResolvedValue(updatedPageLayout);
const result = await pageLayoutService.update(
id,
workspaceId,
updateData,
);
expect(pageLayoutRepository.update).toHaveBeenCalledWith(
{ id, workspaceId },
updateData,
);
expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith(
id,
workspaceId,
undefined,
);
expect(result).toEqual(updatedPageLayout);
});
it('should throw exception when page layout is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
const updateData = { name: 'Updated Page Layout' };
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockRejectedValue(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
await expect(
pageLayoutService.update(id, workspaceId, updateData),
).rejects.toThrow(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
});
});
describe('delete', () => {
it('should delete a page layout successfully', async () => {
const id = 'page-layout-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockResolvedValue(mockPageLayout);
jest
.spyOn(pageLayoutRepository, 'softDelete')
.mockResolvedValue({} as any);
const result = await pageLayoutService.delete(id, workspaceId);
expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith(
id,
workspaceId,
undefined,
);
expect(pageLayoutRepository.softDelete).toHaveBeenCalledWith(id);
expect(result).toEqual(mockPageLayout);
});
it('should throw exception when page layout is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockRejectedValue(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
await expect(pageLayoutService.delete(id, workspaceId)).rejects.toThrow(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
});
});
describe('destroy', () => {
it('should destroy a page layout successfully', async () => {
const id = 'page-layout-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutRepository, 'findOne')
.mockResolvedValue(mockPageLayout);
jest.spyOn(pageLayoutRepository, 'delete').mockResolvedValue({} as any);
const result = await pageLayoutService.destroy(id, workspaceId);
expect(pageLayoutRepository.findOne).toHaveBeenCalledWith({
where: {
id,
workspaceId,
},
withDeleted: true,
});
expect(pageLayoutRepository.delete).toHaveBeenCalledWith(id);
expect(result).toEqual(mockPageLayout);
});
it('should throw exception when page layout is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest
.spyOn(pageLayoutRepository, 'findOne')
.mockRejectedValue(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
await expect(pageLayoutService.destroy(id, workspaceId)).rejects.toThrow(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
});
it('should destroy associated dashboards when page layout is a dashboard', async () => {
const id = 'page-layout-id';
const workspaceId = 'workspace-id';
const mockDashboardRepository = {
find: jest.fn(),
delete: jest.fn(),
};
const mockDashboards = [{ id: 'dashboard', pageLayoutId: id }];
jest.spyOn(pageLayoutRepository, 'findOne').mockResolvedValue({
...mockPageLayout,
type: PageLayoutType.DASHBOARD,
});
jest
.spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace')
.mockResolvedValue(mockDashboardRepository as any);
jest
.spyOn(mockDashboardRepository, 'find')
.mockResolvedValue(mockDashboards);
jest
.spyOn(mockDashboardRepository, 'delete')
.mockResolvedValue({} as any);
jest.spyOn(pageLayoutRepository, 'delete').mockResolvedValue({} as any);
const result = await pageLayoutService.destroy(id, workspaceId);
expect(
twentyORMGlobalManager.getRepositoryForWorkspace,
).toHaveBeenCalledWith(workspaceId, 'dashboard', {
shouldBypassPermissionChecks: true,
});
expect(mockDashboardRepository.find).toHaveBeenCalledWith({
where: {
pageLayoutId: id,
},
});
expect(mockDashboardRepository.delete).toHaveBeenCalledWith('dashboard');
expect(pageLayoutRepository.delete).toHaveBeenCalledWith(id);
expect(result).toEqual({
...mockPageLayout,
type: PageLayoutType.DASHBOARD,
});
});
});
describe('restore', () => {
it('should restore a page layout successfully', async () => {
const id = 'page-layout-id';
const workspaceId = 'workspace-id';
const deletedPageLayout = { ...mockPageLayout, deletedAt: new Date() };
jest
.spyOn(pageLayoutRepository, 'findOne')
.mockResolvedValue(deletedPageLayout);
jest.spyOn(pageLayoutRepository, 'restore').mockResolvedValue({} as any);
jest
.spyOn(pageLayoutService, 'findByIdOrThrow')
.mockResolvedValue(mockPageLayout);
const result = await pageLayoutService.restore(id, workspaceId);
expect(pageLayoutRepository.findOne).toHaveBeenCalledWith({
select: {
id: true,
deletedAt: true,
},
where: {
id,
workspaceId,
},
withDeleted: true,
});
expect(pageLayoutRepository.restore).toHaveBeenCalledWith(id);
expect(pageLayoutService.findByIdOrThrow).toHaveBeenCalledWith(
id,
workspaceId,
);
expect(result).toEqual(mockPageLayout);
});
it('should throw exception when page layout is not found', async () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
jest.spyOn(pageLayoutRepository, 'findOne').mockResolvedValue(null);
await expect(pageLayoutService.restore(id, workspaceId)).rejects.toThrow(
new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
),
);
});
});
});
@@ -1,304 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { EntityManager, IsNull, Repository } from 'typeorm';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { v4 } from 'uuid';
import { CreatePageLayoutTabInput } from 'src/engine/core-modules/page-layout/dtos/inputs/create-page-layout-tab.input';
import { PageLayoutTabEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-tab.entity';
import {
PageLayoutTabException,
PageLayoutTabExceptionCode,
PageLayoutTabExceptionMessageKey,
generatePageLayoutTabExceptionMessage,
} from 'src/engine/core-modules/page-layout/exceptions/page-layout-tab.exception';
import {
PageLayoutException,
PageLayoutExceptionCode,
} from 'src/engine/core-modules/page-layout/exceptions/page-layout.exception';
import { PageLayoutService } from 'src/engine/core-modules/page-layout/services/page-layout.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
export class PageLayoutTabService {
constructor(
@InjectRepository(PageLayoutTabEntity)
private readonly pageLayoutTabRepository: Repository<PageLayoutTabEntity>,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
private readonly pageLayoutService: PageLayoutService,
) {}
private getPageLayoutTabRepository(
transactionManager?: EntityManager,
): Repository<PageLayoutTabEntity> {
return transactionManager
? transactionManager.getRepository(PageLayoutTabEntity)
: this.pageLayoutTabRepository;
}
async findByPageLayoutId(
workspaceId: string,
pageLayoutId: string,
transactionManager?: EntityManager,
withDeleted = false,
): Promise<PageLayoutTabEntity[]> {
const repository = this.getPageLayoutTabRepository(transactionManager);
return repository.find({
where: {
pageLayoutId,
pageLayout: { workspaceId },
},
order: { position: 'ASC' },
relations: ['widgets'],
withDeleted,
});
}
async findByIdOrThrow(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutTabEntity> {
const repository = this.getPageLayoutTabRepository(transactionManager);
const pageLayoutTab = await repository.findOne({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
relations: ['widgets'],
});
if (!isDefined(pageLayoutTab)) {
throw new PageLayoutTabException(
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND,
id,
),
PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
);
}
return pageLayoutTab;
}
async create(
pageLayoutTabData: CreatePageLayoutTabInput,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutTabEntity> {
if (!isDefined(pageLayoutTabData.title)) {
throw new PageLayoutTabException(
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.TITLE_REQUIRED,
),
PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA,
);
}
if (!isDefined(pageLayoutTabData.pageLayoutId)) {
throw new PageLayoutTabException(
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_ID_REQUIRED,
),
PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA,
);
}
try {
await this.pageLayoutService.findByIdOrThrow(
pageLayoutTabData.pageLayoutId,
workspaceId,
transactionManager,
);
const workspace = await this.workspaceRepository.findOneOrFail({
where: { id: workspaceId },
select: ['workspaceCustomApplicationId'],
});
const repository = this.getPageLayoutTabRepository(transactionManager);
const insertResult = await repository.insert({
...pageLayoutTabData,
workspaceId,
universalIdentifier: v4(),
applicationId: workspace.workspaceCustomApplicationId,
});
return this.findByIdOrThrow(
insertResult.identifiers[0].id,
workspaceId,
transactionManager,
);
} catch (error) {
if (
error instanceof PageLayoutException &&
error.code === PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND
) {
throw new PageLayoutTabException(
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
),
PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA,
);
}
throw error;
}
}
async update(
id: string,
workspaceId: string,
updateData: QueryDeepPartialEntity<PageLayoutTabEntity>,
transactionManager?: EntityManager,
): Promise<PageLayoutTabEntity> {
const repository = this.getPageLayoutTabRepository(transactionManager);
const existingTab = await repository.findOne({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
});
if (!isDefined(existingTab)) {
throw new PageLayoutTabException(
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND,
id,
),
PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
);
}
await repository.update({ id }, updateData);
return this.findByIdOrThrow(id, workspaceId, transactionManager);
}
async delete(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutTabEntity> {
const pageLayoutTab = await this.findByIdOrThrow(
id,
workspaceId,
transactionManager,
);
const repository = this.getPageLayoutTabRepository(transactionManager);
await repository.softDelete(id);
return pageLayoutTab;
}
async destroy(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<boolean> {
const repository = this.getPageLayoutTabRepository(transactionManager);
const pageLayoutTab = await repository.findOne({
where: {
id,
workspaceId,
},
withDeleted: true,
});
if (!isDefined(pageLayoutTab)) {
throw new PageLayoutTabException(
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND,
id,
),
PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
);
}
await repository.delete(id);
return true;
}
async restore(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutTabEntity> {
const repository = this.getPageLayoutTabRepository(transactionManager);
const pageLayoutTab = await repository.findOne({
select: {
id: true,
deletedAt: true,
pageLayoutId: true,
},
where: {
id,
workspaceId,
},
withDeleted: true,
});
if (!isDefined(pageLayoutTab)) {
throw new PageLayoutTabException(
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND,
id,
),
PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND,
);
}
if (!isDefined(pageLayoutTab.deletedAt)) {
throw new PageLayoutTabException(
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_DELETED,
),
PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA,
);
}
try {
await this.pageLayoutService.findByIdOrThrow(
pageLayoutTab.pageLayoutId,
workspaceId,
transactionManager,
);
} catch (error) {
if (
error instanceof PageLayoutException &&
error.code === PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND
) {
throw new PageLayoutTabException(
generatePageLayoutTabExceptionMessage(
PageLayoutTabExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
),
PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA,
);
}
throw error;
}
await repository.restore(id);
const restoredPageLayoutTab = await this.findByIdOrThrow(
id,
workspaceId,
transactionManager,
);
return restoredPageLayoutTab;
}
}
@@ -1,282 +0,0 @@
import { Injectable } from '@nestjs/common';
import { computeDiffBetweenObjects, isDefined } from 'twenty-shared/utils';
import { DataSource, EntityManager } from 'typeorm';
import { CreatePageLayoutWidgetInput } from 'src/engine/core-modules/page-layout/dtos/inputs/create-page-layout-widget.input';
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,
true,
);
const {
toCreate: entitiesToCreate,
toUpdate: entitiesToUpdate,
toRestoreAndUpdate: entitiesToRestoreAndUpdate,
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 tabToRestoreAndUpdate of entitiesToRestoreAndUpdate) {
await this.pageLayoutTabService.restore(
tabToRestoreAndUpdate.id,
workspaceId,
transactionManager,
);
const { widgets: _widgets, ...updateData } = tabToRestoreAndUpdate;
await this.pageLayoutTabService.update(
tabToRestoreAndUpdate.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,
true,
);
const {
toCreate: entitiesToCreate,
toUpdate: entitiesToUpdate,
toRestoreAndUpdate: entitiesToRestoreAndUpdate,
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 widgetToRestoreAndUpdate of entitiesToRestoreAndUpdate) {
await this.pageLayoutWidgetService.restore(
widgetToRestoreAndUpdate.id,
workspaceId,
transactionManager,
);
await this.pageLayoutWidgetService.update(
widgetToRestoreAndUpdate.id,
workspaceId,
widgetToRestoreAndUpdate,
transactionManager,
);
}
for (const widgetToCreate of entitiesToCreate) {
await this.pageLayoutWidgetService.create(
widgetToCreate as CreatePageLayoutWidgetInput,
workspaceId,
transactionManager,
);
}
}
}
@@ -1,405 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { EntityManager, IsNull, Repository } from 'typeorm';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
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 { WidgetConfigurationInterface } from 'src/engine/core-modules/page-layout/dtos/widget-configuration.interface';
import { PageLayoutWidgetEntity } from 'src/engine/core-modules/page-layout/entities/page-layout-widget.entity';
import {
PageLayoutTabException,
PageLayoutTabExceptionCode,
} from 'src/engine/core-modules/page-layout/exceptions/page-layout-tab.exception';
import {
PageLayoutWidgetException,
PageLayoutWidgetExceptionCode,
PageLayoutWidgetExceptionMessageKey,
generatePageLayoutWidgetExceptionMessage,
} from 'src/engine/core-modules/page-layout/exceptions/page-layout-widget.exception';
import { PageLayoutTabService } from 'src/engine/core-modules/page-layout/services/page-layout-tab.service';
import { validateAndTransformWidgetConfiguration } from 'src/engine/core-modules/page-layout/utils/validate-and-transform-widget-configuration.util';
import { validateWidgetGridPosition } from 'src/engine/core-modules/page-layout/utils/validate-widget-grid-position.util';
@Injectable()
export class PageLayoutWidgetService {
constructor(
@InjectRepository(PageLayoutWidgetEntity)
private readonly pageLayoutWidgetRepository: Repository<PageLayoutWidgetEntity>,
private readonly pageLayoutTabService: PageLayoutTabService,
private readonly featureFlagService: FeatureFlagService,
) {}
private getPageLayoutWidgetRepository(
transactionManager?: EntityManager,
): Repository<PageLayoutWidgetEntity> {
return transactionManager
? transactionManager.getRepository(PageLayoutWidgetEntity)
: this.pageLayoutWidgetRepository;
}
async findByPageLayoutTabId(
workspaceId: string,
pageLayoutTabId: string,
transactionManager?: EntityManager,
withDeleted = false,
): Promise<PageLayoutWidgetEntity[]> {
const repository = this.getPageLayoutWidgetRepository(transactionManager);
return repository.find({
where: {
pageLayoutTabId,
workspaceId,
},
order: { createdAt: 'ASC' },
withDeleted,
});
}
async findByIdOrThrow(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutWidgetEntity> {
const repository = this.getPageLayoutWidgetRepository(transactionManager);
const pageLayoutWidget = await repository.findOne({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
});
if (!isDefined(pageLayoutWidget)) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_WIDGET_NOT_FOUND,
id,
),
PageLayoutWidgetExceptionCode.PAGE_LAYOUT_WIDGET_NOT_FOUND,
);
}
return pageLayoutWidget;
}
async create(
pageLayoutWidgetData: CreatePageLayoutWidgetInput,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutWidgetEntity> {
if (!isDefined(pageLayoutWidgetData.title)) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.TITLE_REQUIRED,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
if (!isDefined(pageLayoutWidgetData.pageLayoutTabId)) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_TAB_ID_REQUIRED,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
if (!isDefined(pageLayoutWidgetData.gridPosition)) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.GRID_POSITION_REQUIRED,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
validateWidgetGridPosition(
pageLayoutWidgetData.gridPosition,
pageLayoutWidgetData.title,
);
try {
await this.pageLayoutTabService.findByIdOrThrow(
pageLayoutWidgetData.pageLayoutTabId,
workspaceId,
transactionManager,
);
let validatedConfig: WidgetConfigurationInterface | null = null;
if (pageLayoutWidgetData.configuration && pageLayoutWidgetData.type) {
const isDashboardV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_DASHBOARD_V2_ENABLED,
workspaceId,
);
try {
validatedConfig = await validateAndTransformWidgetConfiguration({
type: pageLayoutWidgetData.type,
configuration: pageLayoutWidgetData.configuration,
isDashboardV2Enabled,
});
} catch (error) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.INVALID_WIDGET_CONFIGURATION,
pageLayoutWidgetData.title,
pageLayoutWidgetData.type,
error instanceof Error ? error.message : String(error),
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
if (!validatedConfig) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.INVALID_WIDGET_CONFIGURATION,
pageLayoutWidgetData.title,
pageLayoutWidgetData.type,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
}
const repository = this.getPageLayoutWidgetRepository(transactionManager);
const insertResult = await repository.insert({
...pageLayoutWidgetData,
workspaceId,
...(validatedConfig && { configuration: validatedConfig }),
} as QueryDeepPartialEntity<PageLayoutWidgetEntity>);
return this.findByIdOrThrow(
insertResult.identifiers[0].id,
workspaceId,
transactionManager,
);
} catch (error) {
if (
error instanceof PageLayoutTabException &&
error.code === PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND
) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
throw error;
}
}
async update(
id: string,
workspaceId: string,
updateData: UpdatePageLayoutWidgetInput,
transactionManager?: EntityManager,
): Promise<PageLayoutWidgetEntity> {
const repository = this.getPageLayoutWidgetRepository(transactionManager);
const existingWidget = await repository.findOne({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
});
if (!isDefined(existingWidget)) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_WIDGET_NOT_FOUND,
id,
),
PageLayoutWidgetExceptionCode.PAGE_LAYOUT_WIDGET_NOT_FOUND,
);
}
if (updateData.gridPosition) {
const titleForValidation = updateData.title ?? existingWidget.title;
validateWidgetGridPosition(updateData.gridPosition, titleForValidation);
}
let validatedConfig: WidgetConfigurationInterface | null = null;
if (updateData.configuration) {
const typeForValidation = updateData.type ?? existingWidget.type;
const titleForError = updateData.title ?? existingWidget.title;
if (typeForValidation) {
const isDashboardV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_DASHBOARD_V2_ENABLED,
workspaceId,
);
try {
validatedConfig = await validateAndTransformWidgetConfiguration({
type: typeForValidation,
configuration: updateData.configuration,
isDashboardV2Enabled,
});
} catch (error) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.INVALID_WIDGET_CONFIGURATION,
titleForError,
typeForValidation,
error instanceof Error ? error.message : String(error),
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
if (!validatedConfig) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.INVALID_WIDGET_CONFIGURATION,
titleForError,
typeForValidation,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
}
}
await repository.update({ id }, {
...updateData,
...(validatedConfig && { configuration: validatedConfig }),
} as QueryDeepPartialEntity<PageLayoutWidgetEntity>);
return this.findByIdOrThrow(id, workspaceId, transactionManager);
}
async delete(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutWidgetEntity> {
const pageLayoutWidget = await this.findByIdOrThrow(
id,
workspaceId,
transactionManager,
);
const repository = this.getPageLayoutWidgetRepository(transactionManager);
await repository.softDelete(id);
return pageLayoutWidget;
}
async destroy(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<boolean> {
const repository = this.getPageLayoutWidgetRepository(transactionManager);
const pageLayoutWidget = await repository.findOne({
where: {
id,
workspaceId,
},
withDeleted: true,
});
if (!isDefined(pageLayoutWidget)) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_WIDGET_NOT_FOUND,
id,
),
PageLayoutWidgetExceptionCode.PAGE_LAYOUT_WIDGET_NOT_FOUND,
);
}
await repository.delete(id);
return true;
}
async restore(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutWidgetEntity> {
const repository = this.getPageLayoutWidgetRepository(transactionManager);
const pageLayoutWidget = await repository.findOne({
select: {
id: true,
deletedAt: true,
pageLayoutTabId: true,
},
where: {
id,
workspaceId,
},
withDeleted: true,
});
if (!isDefined(pageLayoutWidget)) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_WIDGET_NOT_FOUND,
id,
),
PageLayoutWidgetExceptionCode.PAGE_LAYOUT_WIDGET_NOT_FOUND,
);
}
if (!isDefined(pageLayoutWidget.deletedAt)) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_WIDGET_NOT_DELETED,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
try {
await this.pageLayoutTabService.findByIdOrThrow(
pageLayoutWidget.pageLayoutTabId,
workspaceId,
transactionManager,
);
} catch (error) {
if (
error instanceof PageLayoutTabException &&
error.code === PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND
) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_TAB_NOT_FOUND,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
throw error;
}
await repository.restore(id);
const restoredPageLayoutWidget = await this.findByIdOrThrow(
id,
workspaceId,
transactionManager,
);
return restoredPageLayoutWidget;
}
}
@@ -1,261 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { EntityManager, IsNull, Repository } from 'typeorm';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { CreatePageLayoutInput } from 'src/engine/core-modules/page-layout/dtos/inputs/create-page-layout.input';
import { PageLayoutEntity } from 'src/engine/core-modules/page-layout/entities/page-layout.entity';
import { PageLayoutType } from 'src/engine/core-modules/page-layout/enums/page-layout-type.enum';
import {
PageLayoutException,
PageLayoutExceptionCode,
PageLayoutExceptionMessageKey,
generatePageLayoutExceptionMessage,
} from 'src/engine/core-modules/page-layout/exceptions/page-layout.exception';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@Injectable()
export class PageLayoutService {
private readonly logger = new Logger(PageLayoutService.name);
constructor(
@InjectRepository(PageLayoutEntity)
private readonly pageLayoutRepository: Repository<PageLayoutEntity>,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
private getPageLayoutRepository(
transactionManager?: EntityManager,
): Repository<PageLayoutEntity> {
return transactionManager
? transactionManager.getRepository(PageLayoutEntity)
: this.pageLayoutRepository;
}
async findByWorkspaceId(
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutEntity[]> {
const repository = this.getPageLayoutRepository(transactionManager);
return repository.find({
where: {
workspaceId,
deletedAt: IsNull(),
},
relations: ['tabs', 'tabs.widgets'],
});
}
async findByObjectMetadataId(
workspaceId: string,
objectMetadataId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutEntity[]> {
const repository = this.getPageLayoutRepository(transactionManager);
return repository.find({
where: {
workspaceId,
objectMetadataId,
deletedAt: IsNull(),
},
relations: ['tabs', 'tabs.widgets'],
});
}
async findByIdOrThrow(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutEntity> {
const repository = this.getPageLayoutRepository(transactionManager);
const pageLayout = await repository.findOne({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
relations: ['tabs', 'tabs.widgets'],
});
if (!isDefined(pageLayout)) {
throw new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
);
}
return pageLayout;
}
async create(
pageLayoutData: CreatePageLayoutInput,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutEntity> {
if (!isDefined(pageLayoutData.name)) {
throw new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.NAME_REQUIRED,
),
PageLayoutExceptionCode.INVALID_PAGE_LAYOUT_DATA,
);
}
const repository = this.getPageLayoutRepository(transactionManager);
const insertResult = await repository.insert({
...pageLayoutData,
workspaceId,
});
return this.findByIdOrThrow(
insertResult.identifiers[0].id,
workspaceId,
transactionManager,
);
}
async update(
id: string,
workspaceId: string,
updateData: QueryDeepPartialEntity<PageLayoutEntity>,
transactionManager?: EntityManager,
): Promise<PageLayoutEntity> {
const repository = this.getPageLayoutRepository(transactionManager);
await repository.update({ id, workspaceId }, updateData);
const updatedPageLayout = await this.findByIdOrThrow(
id,
workspaceId,
transactionManager,
);
return updatedPageLayout;
}
async delete(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutEntity> {
const pageLayout = await this.findByIdOrThrow(
id,
workspaceId,
transactionManager,
);
const repository = this.getPageLayoutRepository(transactionManager);
await repository.softDelete(id);
return pageLayout;
}
async destroy(
id: string,
workspaceId: string,
transactionManager?: EntityManager,
): Promise<PageLayoutEntity> {
const repository = this.getPageLayoutRepository(transactionManager);
const pageLayout = await repository.findOne({
where: {
id,
workspaceId,
},
withDeleted: true,
});
if (!isDefined(pageLayout)) {
throw new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
);
}
if (pageLayout.type === PageLayoutType.DASHBOARD) {
await this.destroyAssociatedDashboards(id, workspaceId);
}
await repository.delete(id);
return pageLayout;
}
private async destroyAssociatedDashboards(
pageLayoutId: string,
workspaceId: string,
): Promise<void> {
try {
const dashboardRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'dashboard',
{ shouldBypassPermissionChecks: true },
);
const dashboards = await dashboardRepository.find({
where: {
pageLayoutId,
},
});
for (const dashboard of dashboards) {
await dashboardRepository.delete(dashboard.id);
}
} catch (error) {
this.logger.error(
`Failed to destroy associated dashboards for page layout ${pageLayoutId}: ${error}`,
);
}
}
async restore(id: string, workspaceId: string): Promise<PageLayoutEntity> {
const pageLayout = await this.pageLayoutRepository.findOne({
select: {
id: true,
deletedAt: true,
},
where: {
id,
workspaceId,
},
withDeleted: true,
});
if (!isDefined(pageLayout)) {
throw new PageLayoutException(
generatePageLayoutExceptionMessage(
PageLayoutExceptionMessageKey.PAGE_LAYOUT_NOT_FOUND,
id,
),
PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND,
);
}
if (!isDefined(pageLayout.deletedAt)) {
throw new PageLayoutException(
'Page layout is not deleted and cannot be restored',
PageLayoutExceptionCode.INVALID_PAGE_LAYOUT_DATA,
);
}
await this.pageLayoutRepository.restore(id);
const restoredPageLayout = await this.findByIdOrThrow(id, workspaceId);
return restoredPageLayout;
}
}
@@ -1,6 +0,0 @@
export type GridPosition = {
row: number;
column: number;
rowSpan: number;
columnSpan: number;
};
@@ -1,378 +0,0 @@
import {
INVALID_HORIZONTAL_BAR_CHART_CONFIG_MISSING_GROUP_BY,
INVALID_IFRAME_CONFIG_BAD_URL,
INVALID_IFRAME_CONFIG_EMPTY_URL,
INVALID_NUMBER_CHART_CONFIG_BAD_UUID,
INVALID_NUMBER_CHART_CONFIG_MISSING_FIELDS,
INVALID_STANDALONE_RICH_TEXT_CONFIG_BODY_WRONG_TYPE,
INVALID_STANDALONE_RICH_TEXT_CONFIG_INVALID_SUBFIELDS,
INVALID_STANDALONE_RICH_TEXT_CONFIG_MISSING_BODY,
INVALID_VERTICAL_BAR_CHART_CONFIG_MISSING_GROUP_BY,
TEST_GAUGE_CHART_CONFIG,
TEST_HORIZONTAL_BAR_CHART_CONFIG,
TEST_HORIZONTAL_BAR_CHART_CONFIG_MINIMAL,
TEST_IFRAME_CONFIG,
TEST_LINE_CHART_CONFIG,
TEST_NUMBER_CHART_CONFIG,
TEST_NUMBER_CHART_CONFIG_MINIMAL,
TEST_PIE_CHART_CONFIG,
TEST_STANDALONE_RICH_TEXT_CONFIG,
TEST_STANDALONE_RICH_TEXT_CONFIG_MINIMAL,
TEST_VERTICAL_BAR_CHART_CONFIG,
TEST_VERTICAL_BAR_CHART_CONFIG_MINIMAL,
} from 'test/integration/constants/widget-configuration-test-data.constants';
import { WidgetType } from 'src/engine/core-modules/page-layout/enums/widget-type.enum';
import { validateAndTransformWidgetConfiguration } from 'src/engine/core-modules/page-layout/utils/validate-and-transform-widget-configuration.util';
jest.mock(
'src/engine/core-modules/record-transformer/utils/transform-rich-text-v2.util',
() => ({
transformRichTextV2Value: jest.fn((value) =>
Promise.resolve({
blocknote: value.blocknote ?? null,
markdown: value.markdown ?? null,
}),
),
}),
);
describe('validateAndTransformWidgetConfiguration', () => {
describe('IFRAME widget', () => {
it('should validate and transform valid iframe configuration', async () => {
const result = await validateAndTransformWidgetConfiguration({
type: WidgetType.IFRAME,
configuration: TEST_IFRAME_CONFIG,
isDashboardV2Enabled: false,
});
expect(result).toMatchObject(TEST_IFRAME_CONFIG);
});
it('should throw error for invalid URL', async () => {
await expect(
validateAndTransformWidgetConfiguration({
type: WidgetType.IFRAME,
configuration: INVALID_IFRAME_CONFIG_BAD_URL,
isDashboardV2Enabled: false,
}),
).rejects.toThrow(/url must be a URL address/);
});
it('should throw error for empty URL', async () => {
await expect(
validateAndTransformWidgetConfiguration({
type: WidgetType.IFRAME,
configuration: INVALID_IFRAME_CONFIG_EMPTY_URL,
isDashboardV2Enabled: false,
}),
).rejects.toThrow(/url must be a URL address/);
});
});
describe('STANDALONE_RICH_TEXT widget', () => {
it('should validate and transform valid standalone rich text configuration', async () => {
const result = await validateAndTransformWidgetConfiguration({
type: WidgetType.STANDALONE_RICH_TEXT,
configuration: TEST_STANDALONE_RICH_TEXT_CONFIG,
isDashboardV2Enabled: false,
});
expect(result).toMatchObject(TEST_STANDALONE_RICH_TEXT_CONFIG);
});
it('should validate minimal standalone rich text configuration', async () => {
const result = await validateAndTransformWidgetConfiguration({
type: WidgetType.STANDALONE_RICH_TEXT,
configuration: TEST_STANDALONE_RICH_TEXT_CONFIG_MINIMAL,
isDashboardV2Enabled: false,
});
expect(result).toMatchObject(TEST_STANDALONE_RICH_TEXT_CONFIG_MINIMAL);
});
it('should throw error for missing body', async () => {
await expect(
validateAndTransformWidgetConfiguration({
type: WidgetType.STANDALONE_RICH_TEXT,
configuration: INVALID_STANDALONE_RICH_TEXT_CONFIG_MISSING_BODY,
isDashboardV2Enabled: false,
}),
).rejects.toThrow(/body/);
});
it('should throw error when body is wrong type', async () => {
await expect(
validateAndTransformWidgetConfiguration({
type: WidgetType.STANDALONE_RICH_TEXT,
configuration: INVALID_STANDALONE_RICH_TEXT_CONFIG_BODY_WRONG_TYPE,
isDashboardV2Enabled: false,
}),
).rejects.toThrow();
});
it('should strip invalid subfields from body', async () => {
const result = await validateAndTransformWidgetConfiguration({
type: WidgetType.STANDALONE_RICH_TEXT,
configuration: INVALID_STANDALONE_RICH_TEXT_CONFIG_INVALID_SUBFIELDS,
isDashboardV2Enabled: false,
});
expect(result).toBeDefined();
expect((result as any).body.blocknote).toBeDefined();
expect((result as any).body.markdown).toBe('valid');
expect((result as any).body.invalidField).toBeUndefined();
});
});
describe('GRAPH widget', () => {
describe('NUMBER graph', () => {
it('should validate full number graph configuration', async () => {
const result = await validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: TEST_NUMBER_CHART_CONFIG,
isDashboardV2Enabled: false,
});
expect(result).toMatchObject(TEST_NUMBER_CHART_CONFIG);
});
it('should validate minimal number graph configuration', async () => {
const result = await validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: TEST_NUMBER_CHART_CONFIG_MINIMAL,
isDashboardV2Enabled: false,
});
expect(result).toMatchObject(TEST_NUMBER_CHART_CONFIG_MINIMAL);
});
it('should throw error for partial number graph configuration with missing required fields', async () => {
await expect(
validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: INVALID_NUMBER_CHART_CONFIG_MISSING_FIELDS,
isDashboardV2Enabled: false,
}),
).rejects.toThrow(/aggregateFieldMetadataId.*aggregateOperation/);
});
it('should throw error for invalid UUID', async () => {
await expect(
validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: INVALID_NUMBER_CHART_CONFIG_BAD_UUID,
isDashboardV2Enabled: false,
}),
).rejects.toThrow(/aggregateFieldMetadataId must be a UUID/);
});
});
describe('VERTICAL_BAR graph', () => {
it('should validate full vertical bar graph configuration', async () => {
const result = await validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: TEST_VERTICAL_BAR_CHART_CONFIG,
isDashboardV2Enabled: false,
});
expect(result).toMatchObject(TEST_VERTICAL_BAR_CHART_CONFIG);
});
it('should validate minimal vertical bar graph configuration', async () => {
const result = await validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: TEST_VERTICAL_BAR_CHART_CONFIG_MINIMAL,
isDashboardV2Enabled: false,
});
expect(result).toMatchObject(TEST_VERTICAL_BAR_CHART_CONFIG_MINIMAL);
});
it('should throw error for partial vertical bar graph configuration with missing required fields', async () => {
await expect(
validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: INVALID_VERTICAL_BAR_CHART_CONFIG_MISSING_GROUP_BY,
isDashboardV2Enabled: false,
}),
).rejects.toThrow(/primaryAxisGroupByFieldMetadataId/);
});
});
describe('HORIZONTAL_BAR graph', () => {
it('should validate full horizontal bar graph configuration', async () => {
const result = await validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: TEST_HORIZONTAL_BAR_CHART_CONFIG,
isDashboardV2Enabled: false,
});
expect(result).toMatchObject(TEST_HORIZONTAL_BAR_CHART_CONFIG);
});
it('should validate minimal horizontal bar graph configuration', async () => {
const result = await validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: TEST_HORIZONTAL_BAR_CHART_CONFIG_MINIMAL,
isDashboardV2Enabled: false,
});
expect(result).toMatchObject(TEST_HORIZONTAL_BAR_CHART_CONFIG_MINIMAL);
});
it('should throw error for partial horizontal bar graph configuration with missing required fields', async () => {
await expect(
validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: INVALID_HORIZONTAL_BAR_CHART_CONFIG_MISSING_GROUP_BY,
isDashboardV2Enabled: false,
}),
).rejects.toThrow(/primaryAxisGroupByFieldMetadataId/);
});
});
it('should return null for unsupported graph type', async () => {
const configuration = {
graphType: 'UNSUPPORTED',
viewId: '550e8400-e29b-41d4-a716-446655440000',
};
const result = await validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: configuration,
isDashboardV2Enabled: false,
});
expect(result).toBeNull();
});
it('should return null for missing graph type', async () => {
const configuration = {
viewId: '550e8400-e29b-41d4-a716-446655440000',
};
const result = await validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: configuration,
isDashboardV2Enabled: false,
});
expect(result).toBeNull();
});
});
describe('Edge cases', () => {
it('should throw error for null configuration', async () => {
await expect(
validateAndTransformWidgetConfiguration({
type: WidgetType.IFRAME,
configuration: null,
isDashboardV2Enabled: false,
}),
).rejects.toThrow('Invalid configuration: not an object');
});
it('should throw error for undefined configuration', async () => {
await expect(
validateAndTransformWidgetConfiguration({
type: WidgetType.IFRAME,
configuration: undefined,
isDashboardV2Enabled: false,
}),
).rejects.toThrow('Invalid configuration: not an object');
});
it('should throw error for non-object configuration', async () => {
await expect(
validateAndTransformWidgetConfiguration({
type: WidgetType.IFRAME,
configuration: 'string',
isDashboardV2Enabled: false,
}),
).rejects.toThrow('Invalid configuration: not an object');
});
it('should return null for unsupported widget type', async () => {
const configuration = { someField: 'value' };
const result = await validateAndTransformWidgetConfiguration({
type: 'UNSUPPORTED' as WidgetType,
configuration: configuration,
isDashboardV2Enabled: false,
});
expect(result).toBeNull();
});
});
describe('Error messages', () => {
it('should include validation details in error message', async () => {
await expect(
validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: INVALID_NUMBER_CHART_CONFIG_BAD_UUID,
isDashboardV2Enabled: false,
}),
).rejects.toThrow(/aggregateFieldMetadataId must be a UUID/);
});
});
describe('Feature flags', () => {
it('should throw error for GAUGE chart type when IS_DASHBOARD_V2_ENABLED is false', async () => {
await expect(
validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: TEST_GAUGE_CHART_CONFIG,
isDashboardV2Enabled: false,
}),
).rejects.toThrow(/IS_DASHBOARD_V2_ENABLED feature flag/);
});
it('should not throw error for GAUGE chart type when IS_DASHBOARD_V2_ENABLED is true', async () => {
await expect(
validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: TEST_GAUGE_CHART_CONFIG,
isDashboardV2Enabled: true,
}),
).resolves.not.toThrow();
});
it('should not throw error for PIE chart type regardless of IS_DASHBOARD_V2_ENABLED', async () => {
await expect(
validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: TEST_PIE_CHART_CONFIG,
isDashboardV2Enabled: false,
}),
).resolves.not.toThrow();
await expect(
validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: TEST_PIE_CHART_CONFIG,
isDashboardV2Enabled: true,
}),
).resolves.not.toThrow();
});
it('should not throw error for LINE chart type regardless of IS_DASHBOARD_V2_ENABLED', async () => {
await expect(
validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: TEST_LINE_CHART_CONFIG,
isDashboardV2Enabled: false,
}),
).resolves.not.toThrow();
await expect(
validateAndTransformWidgetConfiguration({
type: WidgetType.GRAPH,
configuration: TEST_LINE_CHART_CONFIG,
isDashboardV2Enabled: true,
}),
).resolves.not.toThrow();
});
});
});
@@ -1,187 +0,0 @@
import { WIDGET_GRID_MAX_COLUMNS } from 'src/engine/core-modules/page-layout/constants/widget-grid-max-columns.constant';
import { WIDGET_GRID_MAX_ROWS } from 'src/engine/core-modules/page-layout/constants/widget-grid-max-rows.constant';
import { PageLayoutWidgetException } from 'src/engine/core-modules/page-layout/exceptions/page-layout-widget.exception';
import { validateWidgetGridPosition } from 'src/engine/core-modules/page-layout/utils/validate-widget-grid-position.util';
describe('validateWidgetGridPosition', () => {
const validGridPosition = {
row: 0,
column: 0,
rowSpan: 2,
columnSpan: 3,
};
describe('Valid grid positions', () => {
it('should not throw for valid grid position', () => {
expect(() =>
validateWidgetGridPosition(validGridPosition, 'Test Widget'),
).not.toThrow();
});
it('should not throw for widget at max column boundary', () => {
expect(() =>
validateWidgetGridPosition(
{
row: 0,
column: WIDGET_GRID_MAX_COLUMNS - 1,
rowSpan: 1,
columnSpan: 1,
},
'Test Widget',
),
).not.toThrow();
});
it('should not throw for widget at max row boundary', () => {
expect(() =>
validateWidgetGridPosition(
{
row: WIDGET_GRID_MAX_ROWS - 1,
column: 0,
rowSpan: 1,
columnSpan: 1,
},
'Test Widget',
),
).not.toThrow();
});
it('should not throw for widget spanning to column grid edge', () => {
expect(() =>
validateWidgetGridPosition(
{
row: 0,
column: 8,
rowSpan: 1,
columnSpan: 4,
},
'Test Widget',
),
).not.toThrow();
});
it('should not throw for widget spanning to row grid edge', () => {
expect(() =>
validateWidgetGridPosition(
{
row: WIDGET_GRID_MAX_ROWS - 5,
column: 0,
rowSpan: 5,
columnSpan: 6,
},
'Test Widget',
),
).not.toThrow();
});
});
describe('Invalid row positions', () => {
it('should throw for row exceeding max rows', () => {
expect(() =>
validateWidgetGridPosition(
{ ...validGridPosition, row: WIDGET_GRID_MAX_ROWS },
'Test Widget',
),
).toThrow(PageLayoutWidgetException);
});
it('should throw when widget extends beyond grid height', () => {
expect(() =>
validateWidgetGridPosition(
{
row: WIDGET_GRID_MAX_ROWS - 2,
column: 0,
rowSpan: 5,
columnSpan: 6,
},
'Test Widget',
),
).toThrow(/extends beyond grid height/);
});
});
describe('Invalid column positions', () => {
it('should throw for column exceeding max columns', () => {
expect(() =>
validateWidgetGridPosition(
{ ...validGridPosition, column: WIDGET_GRID_MAX_COLUMNS },
'Test Widget',
),
).toThrow(PageLayoutWidgetException);
});
});
describe('Widget extending beyond grid', () => {
it('should throw when widget extends beyond grid width', () => {
expect(() =>
validateWidgetGridPosition(
{
row: 0,
column: 10,
rowSpan: 1,
columnSpan: 3,
},
'Test Widget',
),
).toThrow(/extends beyond grid width/);
});
});
describe('Error messages', () => {
it('should include max columns value in error', () => {
expect(() =>
validateWidgetGridPosition(
{
row: 0,
column: 10,
rowSpan: 1,
columnSpan: 5,
},
'Test Widget',
),
).toThrow(new RegExp(WIDGET_GRID_MAX_COLUMNS.toString()));
});
it('should include max rows value in error for row start', () => {
expect(() =>
validateWidgetGridPosition(
{
row: WIDGET_GRID_MAX_ROWS + 10,
column: 0,
rowSpan: 1,
columnSpan: 1,
},
'Test Widget',
),
).toThrow(new RegExp(WIDGET_GRID_MAX_ROWS.toString()));
});
it('should include max rows value in error for row extension', () => {
expect(() =>
validateWidgetGridPosition(
{
row: 95,
column: 0,
rowSpan: 10,
columnSpan: 6,
},
'Test Widget',
),
).toThrow(new RegExp(WIDGET_GRID_MAX_ROWS.toString()));
});
it('should include widget title in error message', () => {
expect(() =>
validateWidgetGridPosition(
{
row: WIDGET_GRID_MAX_ROWS,
column: 0,
rowSpan: 1,
columnSpan: 1,
},
'My Custom Widget',
),
).toThrow(/My Custom Widget/);
});
});
});
@@ -1,39 +0,0 @@
import { type WidgetConfigurationInterface } from 'src/engine/core-modules/page-layout/dtos/widget-configuration.interface';
import { WidgetConfigurationType } from 'src/engine/core-modules/page-layout/enums/widget-configuration-type.enum';
import { WidgetType } from 'src/engine/core-modules/page-layout/enums/widget-type.enum';
type ConfigurationWithDiscriminator = WidgetConfigurationInterface & {
configurationType: WidgetConfigurationType;
};
export const injectWidgetConfigurationDiscriminator = (
widgetType: WidgetType,
configuration: WidgetConfigurationInterface | null,
): ConfigurationWithDiscriminator | null => {
if (!configuration) {
return null;
}
if (widgetType === WidgetType.IFRAME) {
return {
...configuration,
configurationType: WidgetConfigurationType.IFRAME_CONFIG,
} satisfies ConfigurationWithDiscriminator;
}
if (widgetType === WidgetType.GRAPH && 'graphType' in configuration) {
return {
...configuration,
configurationType: WidgetConfigurationType.CHART_CONFIG,
} satisfies ConfigurationWithDiscriminator;
}
if (widgetType === WidgetType.STANDALONE_RICH_TEXT) {
return {
...configuration,
configurationType: WidgetConfigurationType.STANDALONE_RICH_TEXT_CONFIG,
} satisfies ConfigurationWithDiscriminator;
}
return configuration as ConfigurationWithDiscriminator;
};
@@ -1,64 +0,0 @@
import { assertUnreachable } from 'twenty-shared/utils';
import {
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
PageLayoutTabException,
PageLayoutTabExceptionCode,
} from 'src/engine/core-modules/page-layout/exceptions/page-layout-tab.exception';
import {
PageLayoutWidgetException,
PageLayoutWidgetExceptionCode,
} from 'src/engine/core-modules/page-layout/exceptions/page-layout-widget.exception';
import {
PageLayoutException,
PageLayoutExceptionCode,
} from 'src/engine/core-modules/page-layout/exceptions/page-layout.exception';
export const pageLayoutGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof PageLayoutException) {
switch (error.code) {
case PageLayoutExceptionCode.PAGE_LAYOUT_NOT_FOUND:
throw new NotFoundError(error.message);
case PageLayoutExceptionCode.INVALID_PAGE_LAYOUT_DATA:
throw new UserInputError(error.message, {
userFriendlyMessage: error.userFriendlyMessage,
});
default: {
return assertUnreachable(error.code);
}
}
}
if (error instanceof PageLayoutTabException) {
switch (error.code) {
case PageLayoutTabExceptionCode.PAGE_LAYOUT_TAB_NOT_FOUND:
throw new NotFoundError(error.message);
case PageLayoutTabExceptionCode.INVALID_PAGE_LAYOUT_TAB_DATA:
throw new UserInputError(error.message, {
userFriendlyMessage: error.userFriendlyMessage,
});
default: {
return assertUnreachable(error.code);
}
}
}
if (error instanceof PageLayoutWidgetException) {
switch (error.code) {
case PageLayoutWidgetExceptionCode.PAGE_LAYOUT_WIDGET_NOT_FOUND:
throw new NotFoundError(error.message);
case PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA:
throw new UserInputError(error.message, {
userFriendlyMessage: error.userFriendlyMessage,
});
default: {
return assertUnreachable(error.code);
}
}
}
throw error;
};
@@ -1,20 +0,0 @@
import { ArgumentsHost, Catch } from '@nestjs/common';
import { GqlExceptionFilter } from '@nestjs/graphql';
import { PageLayoutTabException } from 'src/engine/core-modules/page-layout/exceptions/page-layout-tab.exception';
import { PageLayoutWidgetException } from 'src/engine/core-modules/page-layout/exceptions/page-layout-widget.exception';
import { PageLayoutException } from 'src/engine/core-modules/page-layout/exceptions/page-layout.exception';
import { pageLayoutGraphqlApiExceptionHandler } from 'src/engine/core-modules/page-layout/utils/page-layout-graphql-api-exception-handler.util';
@Catch(PageLayoutException, PageLayoutTabException, PageLayoutWidgetException)
export class PageLayoutGraphqlApiExceptionFilter implements GqlExceptionFilter {
catch(
exception:
| PageLayoutException
| PageLayoutTabException
| PageLayoutWidgetException,
_host: ArgumentsHost,
) {
return pageLayoutGraphqlApiExceptionHandler(exception);
}
}
@@ -1,225 +0,0 @@
import { plainToInstance } from 'class-transformer';
import { validateSync, type ValidationError } from 'class-validator';
import { isDefined } from 'twenty-shared/utils';
import { AggregateChartConfigurationDTO } from 'src/engine/core-modules/page-layout/dtos/aggregate-chart-configuration.dto';
import { transformRichTextV2Value } from 'src/engine/core-modules/record-transformer/utils/transform-rich-text-v2.util';
import { BarChartConfigurationDTO } from 'src/engine/core-modules/page-layout/dtos/bar-chart-configuration.dto';
import { GaugeChartConfigurationDTO } from 'src/engine/core-modules/page-layout/dtos/gauge-chart-configuration.dto';
import { IframeConfigurationDTO } from 'src/engine/core-modules/page-layout/dtos/iframe-configuration.dto';
import { LineChartConfigurationDTO } from 'src/engine/core-modules/page-layout/dtos/line-chart-configuration.dto';
import { PieChartConfigurationDTO } from 'src/engine/core-modules/page-layout/dtos/pie-chart-configuration.dto';
import { StandaloneRichTextConfigurationDTO } from 'src/engine/core-modules/page-layout/dtos/standalone-rich-text-configuration.dto';
import { type WidgetConfigurationInterface } from 'src/engine/core-modules/page-layout/dtos/widget-configuration.interface';
import { BarChartGroupMode } from 'src/engine/core-modules/page-layout/enums/bar-chart-group-mode.enum';
import { GraphType } from 'src/engine/core-modules/page-layout/enums/graph-type.enum';
import { WidgetType } from 'src/engine/core-modules/page-layout/enums/widget-type.enum';
const formatValidationErrors = (errors: ValidationError[]): string => {
return errors
.map((err) => {
const constraints = err.constraints
? Object.values(err.constraints).join(', ')
: 'Unknown error';
return `${err.property}: ${constraints}`;
})
.join('; ');
};
const validateGraphConfiguration = ({
configuration,
isDashboardV2Enabled,
}: {
configuration: Record<string, unknown>;
isDashboardV2Enabled: boolean;
}): WidgetConfigurationInterface | null => {
const graphType = configuration.graphType as GraphType;
if (!graphType || !Object.values(GraphType).includes(graphType)) {
return null;
}
if (graphType === GraphType.GAUGE && !isDashboardV2Enabled) {
throw new Error(
`Chart type ${graphType} requires IS_DASHBOARD_V2_ENABLED feature flag`,
);
}
switch (graphType) {
case GraphType.VERTICAL_BAR:
case GraphType.HORIZONTAL_BAR: {
const instance = plainToInstance(BarChartConfigurationDTO, configuration);
const errors = validateSync(instance, {
whitelist: true,
forbidUnknownValues: true,
});
if (errors.length > 0) {
throw errors;
}
if (
isDefined(instance.secondaryAxisGroupByFieldMetadataId) &&
!isDefined(instance.groupMode)
) {
instance.groupMode = BarChartGroupMode.STACKED;
}
return instance;
}
case GraphType.LINE: {
const instance = plainToInstance(
LineChartConfigurationDTO,
configuration,
);
const errors = validateSync(instance, {
whitelist: true,
forbidUnknownValues: true,
});
if (errors.length > 0) {
throw errors;
}
if (
isDefined(instance.secondaryAxisGroupByFieldMetadataId) &&
!isDefined(instance.isStacked)
) {
instance.isStacked = true;
}
return instance;
}
case GraphType.PIE: {
const instance = plainToInstance(PieChartConfigurationDTO, configuration);
const errors = validateSync(instance, {
whitelist: true,
forbidUnknownValues: true,
});
if (errors.length > 0) {
throw errors;
}
return instance;
}
case GraphType.AGGREGATE: {
const instance = plainToInstance(
AggregateChartConfigurationDTO,
configuration,
);
const errors = validateSync(instance, {
whitelist: true,
forbidUnknownValues: true,
});
if (errors.length > 0) {
throw errors;
}
return instance;
}
case GraphType.GAUGE: {
const instance = plainToInstance(
GaugeChartConfigurationDTO,
configuration,
);
const errors = validateSync(instance, {
whitelist: true,
forbidUnknownValues: true,
});
if (errors.length > 0) {
throw errors;
}
return instance;
}
default:
return null;
}
};
const validateIframeConfiguration = (
configuration: unknown,
): WidgetConfigurationInterface | null => {
const instance = plainToInstance(IframeConfigurationDTO, configuration);
const errors = validateSync(instance, {
whitelist: true,
forbidUnknownValues: true,
});
if (errors.length > 0) {
throw errors;
}
return instance;
};
const validateStandaloneRichTextConfiguration = async (
configuration: unknown,
): Promise<WidgetConfigurationInterface | null> => {
const instance = plainToInstance(
StandaloneRichTextConfigurationDTO,
configuration,
);
const errors = validateSync(instance, {
whitelist: true,
forbidUnknownValues: true,
});
if (errors.length > 0) {
throw errors;
}
if (instance.body) {
instance.body = await transformRichTextV2Value(instance.body);
}
return instance;
};
export const validateAndTransformWidgetConfiguration = async ({
type,
configuration,
isDashboardV2Enabled,
}: {
type: WidgetType;
configuration: unknown;
isDashboardV2Enabled: boolean;
}): Promise<WidgetConfigurationInterface | null> => {
if (!configuration || typeof configuration !== 'object') {
throw new Error('Invalid configuration: not an object');
}
try {
switch (type) {
case WidgetType.GRAPH:
return validateGraphConfiguration({
configuration: configuration as Record<string, unknown>,
isDashboardV2Enabled,
});
case WidgetType.IFRAME:
return validateIframeConfiguration(configuration);
case WidgetType.STANDALONE_RICH_TEXT:
return await validateStandaloneRichTextConfiguration(configuration);
default:
return null;
}
} catch (error) {
if (Array.isArray(error)) {
const errorMessage = formatValidationErrors(error);
throw new Error(errorMessage);
}
throw error;
}
};
@@ -1,70 +0,0 @@
import { WIDGET_GRID_MAX_COLUMNS } from 'src/engine/core-modules/page-layout/constants/widget-grid-max-columns.constant';
import { WIDGET_GRID_MAX_ROWS } from 'src/engine/core-modules/page-layout/constants/widget-grid-max-rows.constant';
import {
PageLayoutWidgetException,
PageLayoutWidgetExceptionCode,
PageLayoutWidgetExceptionMessageKey,
generatePageLayoutWidgetExceptionMessage,
} from 'src/engine/core-modules/page-layout/exceptions/page-layout-widget.exception';
type GridPosition = {
row: number;
column: number;
rowSpan: number;
columnSpan: number;
};
export const validateWidgetGridPosition = (
gridPosition: GridPosition,
widgetTitle: string,
): void => {
const { row, column, rowSpan, columnSpan } = gridPosition;
if (column >= WIDGET_GRID_MAX_COLUMNS) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.INVALID_WIDGET_GRID_POSITION,
widgetTitle,
undefined,
`column ${column} exceeds grid width (max column is ${WIDGET_GRID_MAX_COLUMNS - 1})`,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
if (column + columnSpan > WIDGET_GRID_MAX_COLUMNS) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.INVALID_WIDGET_GRID_POSITION,
widgetTitle,
undefined,
`widget extends beyond grid width (column ${column} + columnSpan ${columnSpan} > ${WIDGET_GRID_MAX_COLUMNS})`,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
if (row >= WIDGET_GRID_MAX_ROWS) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.INVALID_WIDGET_GRID_POSITION,
widgetTitle,
undefined,
`row ${row} exceeds maximum allowed rows (${WIDGET_GRID_MAX_ROWS})`,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
if (row + rowSpan > WIDGET_GRID_MAX_ROWS) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.INVALID_WIDGET_GRID_POSITION,
widgetTitle,
undefined,
`widget extends beyond grid height (row ${row} + rowSpan ${rowSpan} > ${WIDGET_GRID_MAX_ROWS})`,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
};