Metadata modules for PageLayoutTab PageLayoutWidget (#16662)

# Introduction
Creating dedicated folders and module for both `page-layout-tab` and
`page-layout-widget`

The addition diff with deletion is due to the module being added
This commit is contained in:
Paul Rastoin
2025-12-18 12:15:29 +01:00
committed by GitHub
parent db960cf509
commit 47a8a15598
130 changed files with 301 additions and 238 deletions
@@ -0,0 +1 @@
export const WIDGET_GRID_MAX_COLUMNS = 12;
@@ -0,0 +1 @@
export const WIDGET_GRID_MAX_ROWS = 100;
@@ -0,0 +1,99 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { isDefined } from 'class-validator';
import { PermissionFlagType } from 'twenty-shared/constants';
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 { CreatePageLayoutWidgetInput } from 'src/engine/metadata-modules/page-layout-widget/dtos/inputs/create-page-layout-widget.input';
import { UpdatePageLayoutWidgetInput } from 'src/engine/metadata-modules/page-layout-widget/dtos/inputs/update-page-layout-widget.input';
import { type PageLayoutWidgetDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/page-layout-widget.dto';
import {
generatePageLayoutWidgetExceptionMessage,
PageLayoutWidgetException,
PageLayoutWidgetExceptionCode,
PageLayoutWidgetExceptionMessageKey,
} from 'src/engine/metadata-modules/page-layout-widget/exceptions/page-layout-widget.exception';
import { PageLayoutWidgetRestApiExceptionFilter } from 'src/engine/metadata-modules/page-layout-widget/filters/page-layout-widget-rest-api-exception.filter';
import { PageLayoutWidgetService } from 'src/engine/metadata-modules/page-layout-widget/services/page-layout-widget.service';
@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);
}
}
@@ -0,0 +1,95 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsTimeZone,
IsUUID,
Max,
Min,
ValidateNested,
} 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 { RatioAggregateConfigDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/ratio-aggregate-config.dto';
import { GraphType } from 'src/engine/metadata-modules/page-layout-widget/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;
@Field(() => RatioAggregateConfigDTO, { nullable: true })
@ValidateNested()
@Type(() => RatioAggregateConfigDTO)
@IsOptional()
ratioAggregateConfig?: RatioAggregateConfigDTO;
}
@@ -0,0 +1,165 @@
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/metadata-modules/page-layout-widget/enums/axis-name-display.enum';
import { BarChartGroupMode } from 'src/engine/metadata-modules/page-layout-widget/enums/bar-chart-group-mode.enum';
import { ObjectRecordGroupByDateGranularity } from 'src/engine/metadata-modules/page-layout-widget/enums/date-granularity.enum';
import { GraphOrderBy } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum';
import { GraphType } from 'src/engine/metadata-modules/page-layout-widget/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;
}
@@ -0,0 +1,71 @@
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/metadata-modules/page-layout-widget/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;
}
@@ -0,0 +1,12 @@
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;
}
@@ -0,0 +1,50 @@
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/metadata-modules/page-layout-widget/dtos/inputs/grid-position.input';
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/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;
}
@@ -0,0 +1,13 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsUUID } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class DeletePageLayoutWidgetInput {
@Field(() => UUIDScalarType)
@IsUUID()
@IsNotEmpty()
id: string;
}
@@ -0,0 +1,13 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsUUID } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class DestroyPageLayoutWidgetInput {
@Field(() => UUIDScalarType)
@IsUUID()
@IsNotEmpty()
id: string;
}
@@ -0,0 +1,32 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsInt, IsNotEmpty, Min } from 'class-validator';
import { GridPosition } from 'src/engine/metadata-modules/page-layout-widget/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;
}
@@ -0,0 +1,56 @@
import { Field, InputType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsEnum,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUUID,
ValidateNested,
} from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { GridPositionInput } from 'src/engine/metadata-modules/page-layout-widget/dtos/inputs/grid-position.input';
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/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;
}
@@ -0,0 +1,45 @@
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/metadata-modules/page-layout-widget/dtos/inputs/grid-position.input';
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/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;
}
@@ -0,0 +1,166 @@
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/metadata-modules/page-layout-widget/enums/axis-name-display.enum';
import { ObjectRecordGroupByDateGranularity } from 'src/engine/metadata-modules/page-layout-widget/enums/date-granularity.enum';
import { GraphOrderBy } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum';
import { GraphType } from 'src/engine/metadata-modules/page-layout-widget/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;
}
@@ -0,0 +1,60 @@
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/metadata-modules/page-layout-widget/dtos/widget-configuration.interface';
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/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;
}
@@ -0,0 +1,114 @@
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/metadata-modules/page-layout-widget/enums/date-granularity.enum';
import { GraphOrderBy } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum';
import { GraphType } from 'src/engine/metadata-modules/page-layout-widget/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(() => Boolean, { nullable: true, defaultValue: false })
@IsBoolean()
@IsOptional()
hideEmptyCategory?: 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;
}
@@ -0,0 +1,19 @@
import { Field, InputType, ObjectType } from '@nestjs/graphql';
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('RatioAggregateConfig')
@InputType('RatioAggregateConfigInput')
export class RatioAggregateConfigDTO {
@Field(() => UUIDScalarType)
@IsUUID()
@IsNotEmpty()
fieldMetadataId: string;
@Field(() => String)
@IsString()
@IsNotEmpty()
optionValue: string;
}
@@ -0,0 +1,17 @@
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;
}
@@ -0,0 +1,15 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import { IsNotEmpty, ValidateNested } from 'class-validator';
import { RichTextV2BodyDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/rich-text-v2-body.dto';
@ObjectType('StandaloneRichTextConfiguration')
export class StandaloneRichTextConfigurationDTO {
@Field(() => RichTextV2BodyDTO)
@ValidateNested()
@Type(() => RichTextV2BodyDTO)
@IsNotEmpty()
body: RichTextV2BodyDTO;
}
@@ -0,0 +1,77 @@
import { createUnionType } from '@nestjs/graphql';
import { AggregateChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/aggregate-chart-configuration.dto';
import { BarChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/bar-chart-configuration.dto';
import { GaugeChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/gauge-chart-configuration.dto';
import { IframeConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/iframe-configuration.dto';
import { LineChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/line-chart-configuration.dto';
import { PieChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/pie-chart-configuration.dto';
import { StandaloneRichTextConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/standalone-rich-text-configuration.dto';
import { GraphType } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-type.enum';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/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;
@@ -0,0 +1,92 @@
import { ObjectType } from '@nestjs/graphql';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Relation,
UpdateDateColumn,
} from 'typeorm';
import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/interfaces/syncable-entity.interface';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PageLayoutTabEntity } from 'src/engine/metadata-modules/page-layout-tab/entities/page-layout-tab.entity';
import { WidgetConfigurationInterface } from 'src/engine/metadata-modules/page-layout-widget/dtos/widget-configuration.interface';
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
import { GridPosition } from 'src/engine/metadata-modules/page-layout-widget/types/grid-position.type';
@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
extends SyncableEntity
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;
}
@@ -0,0 +1,13 @@
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',
});
@@ -0,0 +1,11 @@
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',
});
@@ -0,0 +1,11 @@
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 };
@@ -0,0 +1,13 @@
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',
});
@@ -0,0 +1,15 @@
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',
});
@@ -0,0 +1,5 @@
export enum WidgetConfigurationType {
CHART_CONFIG = 'CHART_CONFIG',
IFRAME_CONFIG = 'IFRAME_CONFIG',
STANDALONE_RICH_TEXT_CONFIG = 'STANDALONE_RICH_TEXT_CONFIG',
}
@@ -0,0 +1,17 @@
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',
}
@@ -0,0 +1,88 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
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',
}
const pageLayoutWidgetExceptionUserFriendlyMessages: Record<
PageLayoutWidgetExceptionCode,
MessageDescriptor
> = {
[PageLayoutWidgetExceptionCode.PAGE_LAYOUT_WIDGET_NOT_FOUND]: msg`Page layout widget not found.`,
[PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA]: msg`Invalid page layout widget data.`,
};
export class PageLayoutWidgetException extends CustomException<PageLayoutWidgetExceptionCode> {
constructor(
message: string,
code: PageLayoutWidgetExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
pageLayoutWidgetExceptionUserFriendlyMessages[code],
});
}
}
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);
}
};
@@ -0,0 +1,47 @@
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/metadata-modules/page-layout-widget/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,
);
}
}
}
@@ -0,0 +1,39 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { FlatPageLayoutWidgetModule } from 'src/engine/metadata-modules/flat-page-layout-widget/flat-page-layout-widget.module';
import { PageLayoutWidgetController } from 'src/engine/metadata-modules/page-layout-widget/controllers/page-layout-widget.controller';
import { PageLayoutWidgetEntity } from 'src/engine/metadata-modules/page-layout-widget/entities/page-layout-widget.entity';
import { PageLayoutWidgetResolver } from 'src/engine/metadata-modules/page-layout-widget/resolvers/page-layout-widget.resolver';
import { PageLayoutWidgetService } from 'src/engine/metadata-modules/page-layout-widget/services/page-layout-widget.service';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration-v2/interceptors/workspace-migration-builder-graphql-api-exception.interceptor';
import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module';
@Module({
imports: [
TypeOrmModule.forFeature([PageLayoutWidgetEntity, WorkspaceEntity]),
TwentyORMModule,
PermissionsModule,
FeatureFlagModule,
WorkspaceCacheStorageModule,
WorkspaceMigrationV2Module,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
FlatPageLayoutWidgetModule,
ApplicationModule,
],
controllers: [PageLayoutWidgetController],
providers: [
PageLayoutWidgetService,
PageLayoutWidgetResolver,
WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor,
],
exports: [PageLayoutWidgetService],
})
export class PageLayoutWidgetModule {}
@@ -0,0 +1,117 @@
import {
UseFilters,
UseGuards,
UseInterceptors,
UsePipes,
} from '@nestjs/common';
import {
Args,
Mutation,
Parent,
Query,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
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 { CreatePageLayoutWidgetInput } from 'src/engine/metadata-modules/page-layout-widget/dtos/inputs/create-page-layout-widget.input';
import { UpdatePageLayoutWidgetInput } from 'src/engine/metadata-modules/page-layout-widget/dtos/inputs/update-page-layout-widget.input';
import { PageLayoutWidgetDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/page-layout-widget.dto';
import { WidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/dtos/widget-configuration.interface';
import { PageLayoutWidgetService } from 'src/engine/metadata-modules/page-layout-widget/services/page-layout-widget.service';
import { injectWidgetConfigurationDiscriminator } from 'src/engine/metadata-modules/page-layout-widget/utils/inject-widget-configuration-discriminator.util';
import { PageLayoutGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/page-layout/utils/page-layout-graphql-api-exception.filter';
import { WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration-v2/interceptors/workspace-migration-builder-graphql-api-exception.interceptor';
@Resolver(() => PageLayoutWidgetDTO)
@UseInterceptors(WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor)
@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,
);
}
}
@@ -0,0 +1,478 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
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 { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { FlatPageLayoutWidgetMaps } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget-maps.type';
import { FlatPageLayoutWidget } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget.type';
import { fromCreatePageLayoutWidgetInputToFlatPageLayoutWidgetToCreate } from 'src/engine/metadata-modules/flat-page-layout-widget/utils/from-create-page-layout-widget-input-to-flat-page-layout-widget-to-create.util';
import { fromDeletePageLayoutWidgetInputToFlatPageLayoutWidgetOrThrow } from 'src/engine/metadata-modules/flat-page-layout-widget/utils/from-delete-page-layout-widget-input-to-flat-page-layout-widget-or-throw.util';
import { fromDestroyPageLayoutWidgetInputToFlatPageLayoutWidgetOrThrow } from 'src/engine/metadata-modules/flat-page-layout-widget/utils/from-destroy-page-layout-widget-input-to-flat-page-layout-widget-or-throw.util';
import { fromRestorePageLayoutWidgetInputToFlatPageLayoutWidgetOrThrow } from 'src/engine/metadata-modules/flat-page-layout-widget/utils/from-restore-page-layout-widget-input-to-flat-page-layout-widget-or-throw.util';
import {
fromUpdatePageLayoutWidgetInputToFlatPageLayoutWidgetToUpdateOrThrow,
type UpdatePageLayoutWidgetInputWithId,
} from 'src/engine/metadata-modules/flat-page-layout-widget/utils/from-update-page-layout-widget-input-to-flat-page-layout-widget-to-update-or-throw.util';
import { CreatePageLayoutWidgetInput } from 'src/engine/metadata-modules/page-layout-widget/dtos/inputs/create-page-layout-widget.input';
import { UpdatePageLayoutWidgetInput } from 'src/engine/metadata-modules/page-layout-widget/dtos/inputs/update-page-layout-widget.input';
import { type PageLayoutWidgetDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/page-layout-widget.dto';
import { WidgetConfigurationInterface } from 'src/engine/metadata-modules/page-layout-widget/dtos/widget-configuration.interface';
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
import {
PageLayoutWidgetException,
PageLayoutWidgetExceptionCode,
PageLayoutWidgetExceptionMessageKey,
generatePageLayoutWidgetExceptionMessage,
} from 'src/engine/metadata-modules/page-layout-widget/exceptions/page-layout-widget.exception';
import { fromFlatPageLayoutWidgetToPageLayoutWidgetDto } from 'src/engine/metadata-modules/page-layout-widget/utils/from-flat-page-layout-widget-to-page-layout-widget-dto.util';
import { validateAndTransformWidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-and-transform-widget-configuration.util';
import { validateWidgetGridPosition } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-widget-grid-position.util';
import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-validate-build-and-run-service';
type WidgetMigrationOperations = {
flatEntityToCreate: FlatPageLayoutWidget[];
flatEntityToUpdate: FlatPageLayoutWidget[];
flatEntityToDelete: FlatPageLayoutWidget[];
};
@Injectable()
export class PageLayoutWidgetService {
constructor(
private readonly featureFlagService: FeatureFlagService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly applicationService: ApplicationService,
) {}
private async getFlatPageLayoutWidgetMaps(
workspaceId: string,
): Promise<FlatPageLayoutWidgetMaps> {
const { flatPageLayoutWidgetMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatPageLayoutWidgetMaps'],
},
);
return flatPageLayoutWidgetMaps;
}
private async validateWidgetConfigurationOrThrow({
type,
configuration,
workspaceId,
titleForError,
}: {
type: WidgetType;
configuration: Record<string, unknown>;
workspaceId: string;
titleForError: string;
}): Promise<WidgetConfigurationInterface> {
const isDashboardV2Enabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_DASHBOARD_V2_ENABLED,
workspaceId,
);
let validatedConfig: WidgetConfigurationInterface | null = null;
try {
validatedConfig = await validateAndTransformWidgetConfiguration({
type,
configuration,
isDashboardV2Enabled,
});
} catch (error) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.INVALID_WIDGET_CONFIGURATION,
titleForError,
type,
error instanceof Error ? error.message : String(error),
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
if (!isDefined(validatedConfig)) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.INVALID_WIDGET_CONFIGURATION,
titleForError,
type,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
return validatedConfig;
}
private async validateAndRunWidgetMigration({
workspaceId,
operations,
errorMessage,
}: {
workspaceId: string;
operations: WidgetMigrationOperations;
errorMessage: string;
}): Promise<void> {
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayoutWidget: operations,
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
errorMessage,
);
}
}
async findByPageLayoutTabId(
workspaceId: string,
pageLayoutTabId: string,
): Promise<PageLayoutWidgetDTO[]> {
const flatPageLayoutWidgetMaps =
await this.getFlatPageLayoutWidgetMaps(workspaceId);
return Object.values(flatPageLayoutWidgetMaps.byId)
.filter(isDefined)
.filter(
(widget) =>
widget.pageLayoutTabId === pageLayoutTabId &&
!isDefined(widget.deletedAt),
)
.sort(
(widgetA, widgetB) =>
new Date(widgetA.createdAt).getTime() -
new Date(widgetB.createdAt).getTime(),
)
.map(fromFlatPageLayoutWidgetToPageLayoutWidgetDto);
}
async findByIdOrThrow(
id: string,
workspaceId: string,
): Promise<PageLayoutWidgetDTO> {
const flatPageLayoutWidgetMaps =
await this.getFlatPageLayoutWidgetMaps(workspaceId);
const flatWidget = flatPageLayoutWidgetMaps.byId[id];
if (!isDefined(flatWidget) || isDefined(flatWidget.deletedAt)) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_WIDGET_NOT_FOUND,
id,
),
PageLayoutWidgetExceptionCode.PAGE_LAYOUT_WIDGET_NOT_FOUND,
);
}
return fromFlatPageLayoutWidgetToPageLayoutWidgetDto(flatWidget);
}
async create(
createPageLayoutWidgetInput: CreatePageLayoutWidgetInput,
workspaceId: string,
): Promise<PageLayoutWidgetDTO> {
this.validateCreateInput(createPageLayoutWidgetInput);
validateWidgetGridPosition(
createPageLayoutWidgetInput.gridPosition,
createPageLayoutWidgetInput.title,
);
const validatedConfig = await this.getValidatedConfigurationForCreate(
createPageLayoutWidgetInput,
workspaceId,
);
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const flatPageLayoutWidgetToCreate =
fromCreatePageLayoutWidgetInputToFlatPageLayoutWidgetToCreate({
createPageLayoutWidgetInput: {
...createPageLayoutWidgetInput,
...(validatedConfig && {
configuration: validatedConfig as Record<string, unknown>,
}),
},
workspaceId,
workspaceCustomApplicationId: workspaceCustomFlatApplication.id,
});
await this.validateAndRunWidgetMigration({
workspaceId,
operations: {
flatEntityToCreate: [flatPageLayoutWidgetToCreate],
flatEntityToUpdate: [],
flatEntityToDelete: [],
},
errorMessage:
'Multiple validation errors occurred while creating page layout widget',
});
const recomputedMaps = await this.getFlatPageLayoutWidgetMaps(workspaceId);
return fromFlatPageLayoutWidgetToPageLayoutWidgetDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: flatPageLayoutWidgetToCreate.id,
flatEntityMaps: recomputedMaps,
}),
);
}
private validateCreateInput(input: CreatePageLayoutWidgetInput): void {
if (!isDefined(input.title)) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.TITLE_REQUIRED,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
if (!isDefined(input.pageLayoutTabId)) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_TAB_ID_REQUIRED,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
if (!isDefined(input.gridPosition)) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.GRID_POSITION_REQUIRED,
),
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
}
private async getValidatedConfigurationForCreate(
input: CreatePageLayoutWidgetInput,
workspaceId: string,
): Promise<WidgetConfigurationInterface | null> {
if (!input.configuration || !input.type) {
return null;
}
return this.validateWidgetConfigurationOrThrow({
type: input.type,
configuration: input.configuration,
workspaceId,
titleForError: input.title,
});
}
async update(
id: string,
workspaceId: string,
updateData: UpdatePageLayoutWidgetInput,
): Promise<PageLayoutWidgetDTO> {
const existingFlatPageLayoutWidgetMaps =
await this.getFlatPageLayoutWidgetMaps(workspaceId);
const existingWidget = this.getExistingWidgetOrThrow(
id,
existingFlatPageLayoutWidgetMaps,
);
if (updateData.gridPosition) {
const titleForValidation = updateData.title ?? existingWidget.title;
validateWidgetGridPosition(updateData.gridPosition, titleForValidation);
}
const validatedConfig = await this.getValidatedConfigurationForUpdate(
updateData,
existingWidget,
workspaceId,
);
const updatePageLayoutWidgetInput: UpdatePageLayoutWidgetInputWithId = {
id,
update: {
...updateData,
...(validatedConfig && {
configuration: validatedConfig as Record<string, unknown>,
}),
},
};
const flatPageLayoutWidgetToUpdate =
fromUpdatePageLayoutWidgetInputToFlatPageLayoutWidgetToUpdateOrThrow({
updatePageLayoutWidgetInput,
flatPageLayoutWidgetMaps: existingFlatPageLayoutWidgetMaps,
});
await this.validateAndRunWidgetMigration({
workspaceId,
operations: {
flatEntityToCreate: [],
flatEntityToUpdate: [flatPageLayoutWidgetToUpdate],
flatEntityToDelete: [],
},
errorMessage:
'Multiple validation errors occurred while updating page layout widget',
});
const recomputedMaps = await this.getFlatPageLayoutWidgetMaps(workspaceId);
return fromFlatPageLayoutWidgetToPageLayoutWidgetDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedMaps,
}),
);
}
private getExistingWidgetOrThrow(
id: string,
flatPageLayoutWidgetMaps: FlatPageLayoutWidgetMaps,
): FlatPageLayoutWidget {
const existingWidget = flatPageLayoutWidgetMaps.byId[id];
if (!isDefined(existingWidget) || isDefined(existingWidget.deletedAt)) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_WIDGET_NOT_FOUND,
id,
),
PageLayoutWidgetExceptionCode.PAGE_LAYOUT_WIDGET_NOT_FOUND,
);
}
return existingWidget;
}
private async getValidatedConfigurationForUpdate(
updateData: UpdatePageLayoutWidgetInput,
existingWidget: FlatPageLayoutWidget,
workspaceId: string,
): Promise<WidgetConfigurationInterface | null> {
if (!updateData.configuration) {
return null;
}
const typeForValidation = updateData.type ?? existingWidget.type;
if (!typeForValidation) {
return null;
}
const titleForError = updateData.title ?? existingWidget.title;
return this.validateWidgetConfigurationOrThrow({
type: typeForValidation,
configuration: updateData.configuration,
workspaceId,
titleForError,
});
}
async delete(id: string, workspaceId: string): Promise<PageLayoutWidgetDTO> {
const existingFlatPageLayoutWidgetMaps =
await this.getFlatPageLayoutWidgetMaps(workspaceId);
const flatPageLayoutWidgetToDelete =
fromDeletePageLayoutWidgetInputToFlatPageLayoutWidgetOrThrow({
deletePageLayoutWidgetInput: { id },
flatPageLayoutWidgetMaps: existingFlatPageLayoutWidgetMaps,
});
await this.validateAndRunWidgetMigration({
workspaceId,
operations: {
flatEntityToCreate: [],
flatEntityToUpdate: [flatPageLayoutWidgetToDelete],
flatEntityToDelete: [],
},
errorMessage:
'Multiple validation errors occurred while deleting page layout widget',
});
const recomputedMaps = await this.getFlatPageLayoutWidgetMaps(workspaceId);
return fromFlatPageLayoutWidgetToPageLayoutWidgetDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedMaps,
}),
);
}
async destroy(id: string, workspaceId: string): Promise<boolean> {
const existingFlatPageLayoutWidgetMaps =
await this.getFlatPageLayoutWidgetMaps(workspaceId);
const flatPageLayoutWidgetToDestroy =
fromDestroyPageLayoutWidgetInputToFlatPageLayoutWidgetOrThrow({
destroyPageLayoutWidgetInput: { id },
flatPageLayoutWidgetMaps: existingFlatPageLayoutWidgetMaps,
});
await this.validateAndRunWidgetMigration({
workspaceId,
operations: {
flatEntityToCreate: [],
flatEntityToUpdate: [],
flatEntityToDelete: [flatPageLayoutWidgetToDestroy],
},
errorMessage:
'Multiple validation errors occurred while destroying page layout widget',
});
return true;
}
async restore(id: string, workspaceId: string): Promise<PageLayoutWidgetDTO> {
const existingFlatPageLayoutWidgetMaps =
await this.getFlatPageLayoutWidgetMaps(workspaceId);
const flatPageLayoutWidgetToRestore =
fromRestorePageLayoutWidgetInputToFlatPageLayoutWidgetOrThrow({
restorePageLayoutWidgetInput: { id },
flatPageLayoutWidgetMaps: existingFlatPageLayoutWidgetMaps,
});
await this.validateAndRunWidgetMigration({
workspaceId,
operations: {
flatEntityToCreate: [],
flatEntityToUpdate: [flatPageLayoutWidgetToRestore],
flatEntityToDelete: [],
},
errorMessage:
'Multiple validation errors occurred while restoring page layout widget',
});
const recomputedMaps = await this.getFlatPageLayoutWidgetMaps(workspaceId);
return fromFlatPageLayoutWidgetToPageLayoutWidgetDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedMaps,
}),
);
}
}
@@ -0,0 +1,6 @@
export type GridPosition = {
row: number;
column: number;
rowSpan: number;
columnSpan: number;
};
@@ -0,0 +1,378 @@
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/metadata-modules/page-layout-widget/enums/widget-type.enum';
import { validateAndTransformWidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/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();
});
});
});
@@ -0,0 +1,187 @@
import { WIDGET_GRID_MAX_COLUMNS } from 'src/engine/metadata-modules/page-layout-widget/constants/widget-grid-max-columns.constant';
import { WIDGET_GRID_MAX_ROWS } from 'src/engine/metadata-modules/page-layout-widget/constants/widget-grid-max-rows.constant';
import { PageLayoutWidgetException } from 'src/engine/metadata-modules/page-layout-widget/exceptions/page-layout-widget.exception';
import { validateWidgetGridPosition } from 'src/engine/metadata-modules/page-layout-widget/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/);
});
});
});
@@ -0,0 +1,15 @@
import { type FlatPageLayoutWidget } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget.type';
import { type PageLayoutWidgetDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/page-layout-widget.dto';
export const fromFlatPageLayoutWidgetToPageLayoutWidgetDto = (
flatPageLayoutWidget: FlatPageLayoutWidget,
): PageLayoutWidgetDTO => {
const { createdAt, updatedAt, deletedAt, ...rest } = flatPageLayoutWidget;
return {
...rest,
createdAt: new Date(createdAt),
updatedAt: new Date(updatedAt),
deletedAt: deletedAt ? new Date(deletedAt) : null,
};
};
@@ -0,0 +1,39 @@
import { type WidgetConfigurationInterface } from 'src/engine/metadata-modules/page-layout-widget/dtos/widget-configuration.interface';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.enum';
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/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;
};
@@ -0,0 +1,225 @@
import { plainToInstance } from 'class-transformer';
import { validateSync, type ValidationError } from 'class-validator';
import { isDefined } from 'twenty-shared/utils';
import { transformRichTextV2Value } from 'src/engine/core-modules/record-transformer/utils/transform-rich-text-v2.util';
import { AggregateChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/aggregate-chart-configuration.dto';
import { BarChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/bar-chart-configuration.dto';
import { GaugeChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/gauge-chart-configuration.dto';
import { IframeConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/iframe-configuration.dto';
import { LineChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/line-chart-configuration.dto';
import { PieChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/pie-chart-configuration.dto';
import { StandaloneRichTextConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/standalone-rich-text-configuration.dto';
import { type WidgetConfigurationInterface } from 'src/engine/metadata-modules/page-layout-widget/dtos/widget-configuration.interface';
import { BarChartGroupMode } from 'src/engine/metadata-modules/page-layout-widget/enums/bar-chart-group-mode.enum';
import { GraphType } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-type.enum';
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/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;
}
};
@@ -0,0 +1,70 @@
import { WIDGET_GRID_MAX_COLUMNS } from 'src/engine/metadata-modules/page-layout-widget/constants/widget-grid-max-columns.constant';
import { WIDGET_GRID_MAX_ROWS } from 'src/engine/metadata-modules/page-layout-widget/constants/widget-grid-max-rows.constant';
import {
PageLayoutWidgetException,
PageLayoutWidgetExceptionCode,
PageLayoutWidgetExceptionMessageKey,
generatePageLayoutWidgetExceptionMessage,
} from 'src/engine/metadata-modules/page-layout-widget/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,
);
}
};