Add configuration types for widgets (#14717)

closes https://github.com/twentyhq/core-team-issues/issues/1532
This commit is contained in:
nitin
2025-09-29 17:11:26 +05:30
committed by GitHub
parent 2685f4a5b9
commit ea144c434b
88 changed files with 2829 additions and 1487 deletions
@@ -0,0 +1,98 @@
import { Field, ObjectType } from '@nestjs/graphql';
import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsNumber,
IsObject,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
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 { GraphType } from 'src/engine/core-modules/page-layout/enums/graph-type.enum';
import { GraphOrderBy } from 'src/engine/core-modules/page-layout/enums/graph-order-by.enum';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('BarChartConfiguration')
export class BarChartConfigurationDTO {
@Field(() => GraphType)
@IsEnum(GraphType)
@IsNotEmpty()
graphType: GraphType.BAR;
@Field(() => UUIDScalarType)
@IsUUID()
@IsNotEmpty()
aggregateFieldMetadataId: string;
@Field(() => AggregateOperations)
@IsEnum(AggregateOperations)
@IsNotEmpty()
aggregateOperation: AggregateOperations;
@Field(() => UUIDScalarType)
@IsUUID()
@IsNotEmpty()
groupByFieldMetadataIdX: string;
@Field(() => GraphOrderBy)
@IsEnum(GraphOrderBy)
@IsNotEmpty()
orderByX: GraphOrderBy;
@Field(() => UUIDScalarType, { nullable: true })
@IsUUID()
@IsOptional()
groupByFieldMetadataIdY?: string;
@Field(() => GraphOrderBy, { nullable: true })
@IsEnum(GraphOrderBy)
@IsOptional()
orderByY?: GraphOrderBy;
@Field(() => Boolean, { nullable: true })
@IsBoolean()
@IsOptional()
omitNullValues?: boolean;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
xAxisName?: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
yAxisName?: string;
@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;
}
@@ -0,0 +1,55 @@
import { Field, ObjectType } from '@nestjs/graphql';
import {
IsEnum,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
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 { GraphType } from 'src/engine/core-modules/page-layout/enums/graph-type.enum';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@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(() => AggregateOperations)
@IsEnum(AggregateOperations)
@IsNotEmpty()
aggregateOperationTotal: AggregateOperations;
@Field(() => UUIDScalarType)
@IsUUID()
@IsNotEmpty()
aggregateFieldMetadataIdTotal: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
description?: string;
@Field(() => GraphQLJSON, { nullable: true })
@IsObject()
@IsOptional()
filter?: ObjectRecordFilter;
}
@@ -0,0 +1,12 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsNotEmpty, IsString, IsUrl } from 'class-validator';
@ObjectType('IframeConfiguration')
export class IframeConfigurationDTO {
@Field(() => String)
@IsString()
@IsNotEmpty()
@IsUrl()
url: string;
}
@@ -49,7 +49,7 @@ export class UpdatePageLayoutWidgetWithIdInput {
@IsNotEmpty()
gridPosition: GridPositionInput;
@Field(() => GraphQLJSON)
@Field(() => GraphQLJSON, { nullable: true })
@IsObject()
@IsOptional()
configuration: Record<string, unknown> | null;
@@ -0,0 +1,98 @@
import { Field, ObjectType } from '@nestjs/graphql';
import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsNumber,
IsObject,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
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 { 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()
groupByFieldMetadataIdX: string;
@Field(() => GraphOrderBy)
@IsEnum(GraphOrderBy)
@IsNotEmpty()
orderByX: GraphOrderBy;
@Field(() => UUIDScalarType, { nullable: true })
@IsUUID()
@IsOptional()
groupByFieldMetadataIdY?: string;
@Field(() => GraphOrderBy, { nullable: true })
@IsEnum(GraphOrderBy)
@IsOptional()
orderByY?: GraphOrderBy;
@Field(() => Boolean, { nullable: true })
@IsBoolean()
@IsOptional()
omitNullValues?: boolean;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
xAxisName?: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
yAxisName?: string;
@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;
}
@@ -0,0 +1,60 @@
import { Field, ObjectType } from '@nestjs/graphql';
import {
IsEnum,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
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 { GraphType } from 'src/engine/core-modules/page-layout/enums/graph-type.enum';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('NumberChartConfiguration')
export class NumberChartConfigurationDTO {
@Field(() => GraphType)
@IsEnum(GraphType)
@IsNotEmpty()
graphType: GraphType.NUMBER;
@Field(() => UUIDScalarType)
@IsUUID()
@IsNotEmpty()
aggregateFieldMetadataId: string;
@Field(() => AggregateOperations)
@IsEnum(AggregateOperations)
@IsNotEmpty()
aggregateOperation: AggregateOperations;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
label?: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
format?: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
description?: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
color?: string;
@Field(() => GraphQLJSON, { nullable: true })
@IsObject()
@IsOptional()
filter?: ObjectRecordFilter;
}
@@ -1,9 +1,12 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { GraphQLJSON } from 'graphql-type-json';
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' });
@@ -43,8 +46,8 @@ export class PageLayoutWidgetDTO {
@Field(() => GridPositionDTO, { nullable: false })
gridPosition: GridPositionDTO;
@Field(() => GraphQLJSON, { nullable: true })
configuration: Record<string, unknown> | null;
@Field(() => WidgetConfiguration, { nullable: true })
configuration: WidgetConfigurationInterface | null;
@Field()
createdAt: Date;
@@ -0,0 +1,61 @@
import { Field, ObjectType } from '@nestjs/graphql';
import {
IsEnum,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
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 { GraphType } from 'src/engine/core-modules/page-layout/enums/graph-type.enum';
import { GraphOrderBy } from 'src/engine/core-modules/page-layout/enums/graph-order-by.enum';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@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(() => GraphOrderBy)
@IsEnum(GraphOrderBy)
@IsNotEmpty()
orderBy: GraphOrderBy;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
description?: string;
@Field(() => String, { nullable: true })
@IsString()
@IsOptional()
color?: string;
@Field(() => GraphQLJSON, { nullable: true })
@IsObject()
@IsOptional()
filter?: ObjectRecordFilter;
}
@@ -0,0 +1,55 @@
import { createUnionType } from '@nestjs/graphql';
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 { NumberChartConfigurationDTO } from 'src/engine/core-modules/page-layout/dtos/number-chart-configuration.dto';
import { PieChartConfigurationDTO } from 'src/engine/core-modules/page-layout/dtos/pie-chart-configuration.dto';
import { WidgetConfigurationType } from 'src/engine/core-modules/page-layout/enums/widget-configuration-type.enum';
export const WidgetConfiguration = createUnionType({
name: 'WidgetConfiguration',
types: () => [
BarChartConfigurationDTO,
LineChartConfigurationDTO,
PieChartConfigurationDTO,
NumberChartConfigurationDTO,
GaugeChartConfigurationDTO,
IframeConfigurationDTO,
],
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.',
);
}
switch (configuration.configurationType) {
case WidgetConfigurationType.IFRAME_CONFIG:
return IframeConfigurationDTO;
case WidgetConfigurationType.BAR_CHART_CONFIG:
return BarChartConfigurationDTO;
case WidgetConfigurationType.LINE_CHART_CONFIG:
return LineChartConfigurationDTO;
case WidgetConfigurationType.PIE_CHART_CONFIG:
return PieChartConfigurationDTO;
case WidgetConfigurationType.NUMBER_CHART_CONFIG:
return NumberChartConfigurationDTO;
case WidgetConfigurationType.GAUGE_CHART_CONFIG:
return GaugeChartConfigurationDTO;
default:
throw new Error(
`Unknown widget configuration type: ${configuration.configurationType}`,
);
}
},
});
export type WidgetConfigurationInterface =
| BarChartConfigurationDTO
| LineChartConfigurationDTO
| PieChartConfigurationDTO
| NumberChartConfigurationDTO
| GaugeChartConfigurationDTO
| IframeConfigurationDTO;
@@ -11,6 +11,7 @@ import {
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';
@@ -72,7 +73,7 @@ export class PageLayoutWidgetEntity
gridPosition: GridPosition;
@Column({ type: 'jsonb', nullable: true })
configuration: Record<string, unknown> | null;
configuration: WidgetConfigurationInterface | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@@ -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,14 @@
import { registerEnumType } from '@nestjs/graphql';
export enum GraphType {
NUMBER = 'NUMBER',
GAUGE = 'GAUGE',
PIE = 'PIE',
BAR = 'BAR',
LINE = 'LINE',
}
registerEnumType(GraphType, {
name: 'GraphType',
description: 'Type of graph widget',
});
@@ -0,0 +1,8 @@
export enum WidgetConfigurationType {
BAR_CHART_CONFIG = 'BAR_CHART_CONFIG',
LINE_CHART_CONFIG = 'LINE_CHART_CONFIG',
PIE_CHART_CONFIG = 'PIE_CHART_CONFIG',
NUMBER_CHART_CONFIG = 'NUMBER_CHART_CONFIG',
GAUGE_CHART_CONFIG = 'GAUGE_CHART_CONFIG',
IFRAME_CONFIG = 'IFRAME_CONFIG',
}
@@ -14,17 +14,20 @@ export enum PageLayoutWidgetExceptionMessageKey {
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_CONFIGURATION = 'INVALID_WIDGET_CONFIGURATION',
}
export class PageLayoutWidgetException extends CustomException<PageLayoutWidgetExceptionCode> {}
export const generatePageLayoutWidgetExceptionMessage = (
key: PageLayoutWidgetExceptionMessageKey,
value?: string,
widgetTitle?: string,
widgetType?: string,
detailedError?: string,
): string => {
switch (key) {
case PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_WIDGET_NOT_FOUND:
return `Page layout widget with ID "${value}" 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:
@@ -35,6 +38,18 @@ export const generatePageLayoutWidgetExceptionMessage = (
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_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);
}
@@ -147,7 +147,7 @@ describe('PageLayoutUpdateService', () => {
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
pageLayoutTabId: 'tab-1',
objectMetadataId: null,
configuration: {},
configuration: null,
},
],
},
@@ -210,8 +210,10 @@ describe('PageLayoutUpdateService', () => {
const id = 'non-existent-id';
const workspaceId = 'workspace-id';
const input = {
...mockPageLayout,
name: 'Updated Page Layout',
type: PageLayoutType.DASHBOARD,
objectMetadataId: 'object-metadata-id',
tabs: [],
};
jest
@@ -435,7 +437,7 @@ describe('PageLayoutUpdateService', () => {
id: 'new-widget-id',
pageLayoutTabId: tabId,
objectMetadataId: null,
configuration: {},
configuration: null,
title: 'New Widget',
type: WidgetType.VIEW,
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
@@ -451,7 +453,7 @@ describe('PageLayoutUpdateService', () => {
title: 'New Widget',
pageLayoutTabId: tabId,
objectMetadataId: null,
configuration: {},
configuration: null,
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
});
@@ -467,7 +469,7 @@ describe('PageLayoutUpdateService', () => {
id: 'new-widget-id',
pageLayoutTabId: tabId,
objectMetadataId: null,
configuration: {},
configuration: null,
title: 'New Widget',
type: WidgetType.VIEW,
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
@@ -484,7 +486,7 @@ describe('PageLayoutUpdateService', () => {
{
pageLayoutTabId: tabId,
objectMetadataId: null,
configuration: {},
configuration: null,
id: 'widget-1',
title: 'Updated Widget',
type: WidgetType.VIEW,
@@ -517,7 +519,7 @@ describe('PageLayoutUpdateService', () => {
type: WidgetType.VIEW,
pageLayoutTabId: tabId,
objectMetadataId: null,
configuration: {},
configuration: null,
gridPosition: { row: 1, column: 1, rowSpan: 2, columnSpan: 2 },
},
mockTransactionManager,
@@ -559,7 +561,7 @@ describe('PageLayoutUpdateService', () => {
id: 'widget-1',
pageLayoutTabId: tabId,
objectMetadataId: null,
configuration: {},
configuration: null,
title: 'Updated Widget',
type: WidgetType.VIEW,
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
@@ -568,7 +570,7 @@ describe('PageLayoutUpdateService', () => {
id: 'new-widget-id',
pageLayoutTabId: tabId,
objectMetadataId: null,
configuration: {},
configuration: null,
title: 'New Widget',
type: WidgetType.FIELDS,
gridPosition: { row: 0, column: 4, rowSpan: 2, columnSpan: 2 },
@@ -624,7 +626,7 @@ describe('PageLayoutUpdateService', () => {
type: WidgetType.VIEW,
pageLayoutTabId: tabId,
objectMetadataId: null,
configuration: {},
configuration: null,
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
},
mockTransactionManager,
@@ -636,7 +638,7 @@ describe('PageLayoutUpdateService', () => {
type: WidgetType.FIELDS,
pageLayoutTabId: tabId,
objectMetadataId: null,
configuration: {},
configuration: null,
gridPosition: { row: 0, column: 4, rowSpan: 2, columnSpan: 2 },
},
workspaceId,
@@ -666,7 +668,7 @@ describe('PageLayoutUpdateService', () => {
gridPosition: { row: 0, column: 0, rowSpan: 4, columnSpan: 4 },
pageLayoutTabId: 'tab-1',
objectMetadataId: null,
configuration: {},
configuration: null,
},
{
id: 'widget-2',
@@ -675,7 +677,7 @@ describe('PageLayoutUpdateService', () => {
gridPosition: { row: 0, column: 4, rowSpan: 2, columnSpan: 2 },
pageLayoutTabId: 'tab-1',
objectMetadataId: null,
configuration: {},
configuration: null,
},
],
},
@@ -3,8 +3,10 @@ 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 { UpdatePageLayoutWidgetInput } from 'src/engine/core-modules/page-layout/dtos/inputs/update-page-layout-widget.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';
@@ -229,14 +231,14 @@ export class PageLayoutUpdateService {
await this.pageLayoutWidgetService.update(
widgetUpdate.id,
workspaceId,
widgetUpdate,
widgetUpdate as UpdatePageLayoutWidgetInput,
transactionManager,
);
}
for (const widgetToCreate of entitiesToCreate) {
await this.pageLayoutWidgetService.create(
widgetToCreate,
widgetToCreate as CreatePageLayoutWidgetInput,
workspaceId,
transactionManager,
);
@@ -7,6 +7,7 @@ import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity
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,
@@ -19,6 +20,7 @@ import {
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';
@Injectable()
export class PageLayoutWidgetService {
@@ -120,11 +122,44 @@ export class PageLayoutWidgetService {
transactionManager,
);
let validatedConfig: WidgetConfigurationInterface | null = null;
if (pageLayoutWidgetData.configuration && pageLayoutWidgetData.type) {
try {
validatedConfig = validateAndTransformWidgetConfiguration(
pageLayoutWidgetData.type,
pageLayoutWidgetData.configuration,
);
} 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(
@@ -174,10 +209,47 @@ export class PageLayoutWidgetService {
);
}
await repository.update(
{ id },
updateData as QueryDeepPartialEntity<PageLayoutWidgetEntity>,
);
let validatedConfig: WidgetConfigurationInterface | null = null;
if (updateData.configuration) {
const typeForValidation = updateData.type ?? existingWidget.type;
const titleForError = updateData.title ?? existingWidget.title;
if (typeForValidation) {
try {
validatedConfig = validateAndTransformWidgetConfiguration(
typeForValidation,
updateData.configuration,
);
} 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);
}
@@ -0,0 +1,43 @@
import {
INVALID_IFRAME_CONFIG_BAD_URL,
TEST_IFRAME_CONFIG,
TEST_NUMBER_CHART_CONFIG,
} from 'test/integration/constants/widget-configuration-test-data.constants';
import { WidgetType } from 'src/engine/core-modules/page-layout/enums/widget-type.enum';
import { isWidgetConfigurationValid } from 'src/engine/core-modules/page-layout/utils/is-widget-configuration-valid.util';
describe('isWidgetConfigurationValid', () => {
it('should return true for valid configuration', () => {
const result = isWidgetConfigurationValid(
WidgetType.IFRAME,
TEST_IFRAME_CONFIG,
);
expect(result).toBe(true);
});
it('should return false for invalid configuration', () => {
const result = isWidgetConfigurationValid(
WidgetType.IFRAME,
INVALID_IFRAME_CONFIG_BAD_URL,
);
expect(result).toBe(false);
});
it('should return false for null configuration', () => {
const result = isWidgetConfigurationValid(WidgetType.IFRAME, null);
expect(result).toBe(false);
});
it('should return false for unsupported widget type', () => {
const result = isWidgetConfigurationValid(
'UNSUPPORTED' as WidgetType,
TEST_NUMBER_CHART_CONFIG,
);
expect(result).toBe(false);
});
});
@@ -0,0 +1,294 @@
import {
INVALID_BAR_CHART_CONFIG_MISSING_GROUP_BY,
INVALID_IFRAME_CONFIG_BAD_URL,
INVALID_IFRAME_CONFIG_EMPTY_URL,
INVALID_IFRAME_CONFIG_MISSING_URL,
INVALID_NUMBER_CHART_CONFIG_BAD_UUID,
INVALID_NUMBER_CHART_CONFIG_MISSING_FIELDS,
TEST_BAR_CHART_CONFIG,
TEST_BAR_CHART_CONFIG_MINIMAL,
TEST_GAUGE_CHART_CONFIG,
TEST_GAUGE_CHART_CONFIG_MINIMAL,
TEST_IFRAME_CONFIG,
TEST_LINE_CHART_CONFIG,
TEST_LINE_CHART_CONFIG_MINIMAL,
TEST_NUMBER_CHART_CONFIG,
TEST_NUMBER_CHART_CONFIG_MINIMAL,
TEST_PIE_CHART_CONFIG,
TEST_PIE_CHART_CONFIG_MINIMAL,
} from 'test/integration/constants/widget-configuration-test-data.constants';
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';
import { validateAndTransformWidgetConfiguration } from 'src/engine/core-modules/page-layout/utils/validate-and-transform-widget-configuration.util';
describe('validateAndTransformWidgetConfiguration', () => {
describe('IFRAME widget', () => {
it('should validate and transform valid iframe configuration', () => {
const result = validateAndTransformWidgetConfiguration(
WidgetType.IFRAME,
TEST_IFRAME_CONFIG,
);
expect(result).toMatchObject(TEST_IFRAME_CONFIG);
expect((result as any)?.configurationType).toBe(
WidgetConfigurationType.IFRAME_CONFIG,
);
});
it('should throw error for invalid URL', () => {
expect(() =>
validateAndTransformWidgetConfiguration(
WidgetType.IFRAME,
INVALID_IFRAME_CONFIG_BAD_URL,
),
).toThrow(/url must be a URL address/);
});
it('should throw error for missing URL', () => {
expect(() =>
validateAndTransformWidgetConfiguration(
WidgetType.IFRAME,
INVALID_IFRAME_CONFIG_MISSING_URL,
),
).toThrow(/url must be a URL address/);
});
it('should throw error for empty URL', () => {
expect(() =>
validateAndTransformWidgetConfiguration(
WidgetType.IFRAME,
INVALID_IFRAME_CONFIG_EMPTY_URL,
),
).toThrow(/url must be a URL address/);
});
});
describe('GRAPH widget', () => {
describe('NUMBER graph', () => {
it('should validate full number graph configuration', () => {
const result = validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
TEST_NUMBER_CHART_CONFIG,
);
expect(result).toMatchObject(TEST_NUMBER_CHART_CONFIG);
expect((result as any)?.configurationType).toBe(
WidgetConfigurationType.NUMBER_CHART_CONFIG,
);
});
it('should validate minimal number graph configuration', () => {
const result = validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
TEST_NUMBER_CHART_CONFIG_MINIMAL,
);
expect(result).toMatchObject(TEST_NUMBER_CHART_CONFIG_MINIMAL);
expect((result as any)?.configurationType).toBe(
WidgetConfigurationType.NUMBER_CHART_CONFIG,
);
});
it('should throw error for missing required fields', () => {
expect(() =>
validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
INVALID_NUMBER_CHART_CONFIG_MISSING_FIELDS,
),
).toThrow(/aggregateFieldMetadataId should not be empty/);
});
it('should throw error for invalid UUID', () => {
expect(() =>
validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
INVALID_NUMBER_CHART_CONFIG_BAD_UUID,
),
).toThrow(/aggregateFieldMetadataId must be a UUID/);
});
});
describe('BAR graph', () => {
it('should validate full bar graph configuration', () => {
const result = validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
TEST_BAR_CHART_CONFIG,
);
expect(result).toMatchObject(TEST_BAR_CHART_CONFIG);
expect((result as any)?.configurationType).toBe(
WidgetConfigurationType.BAR_CHART_CONFIG,
);
});
it('should validate minimal bar graph configuration', () => {
const result = validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
TEST_BAR_CHART_CONFIG_MINIMAL,
);
expect(result).toMatchObject(TEST_BAR_CHART_CONFIG_MINIMAL);
expect((result as any)?.configurationType).toBe(
WidgetConfigurationType.BAR_CHART_CONFIG,
);
});
it('should throw error for missing group by field', () => {
expect(() =>
validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
INVALID_BAR_CHART_CONFIG_MISSING_GROUP_BY,
),
).toThrow(/groupByFieldMetadataIdX should not be empty/);
});
});
describe('LINE graph', () => {
it('should validate full line graph configuration', () => {
const result = validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
TEST_LINE_CHART_CONFIG,
);
expect(result).toMatchObject(TEST_LINE_CHART_CONFIG);
expect((result as any)?.configurationType).toBe(
WidgetConfigurationType.LINE_CHART_CONFIG,
);
});
it('should validate minimal line graph configuration', () => {
const result = validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
TEST_LINE_CHART_CONFIG_MINIMAL,
);
expect(result).toMatchObject(TEST_LINE_CHART_CONFIG_MINIMAL);
expect((result as any)?.configurationType).toBe(
WidgetConfigurationType.LINE_CHART_CONFIG,
);
});
});
describe('PIE graph', () => {
it('should validate full pie graph configuration', () => {
const result = validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
TEST_PIE_CHART_CONFIG,
);
expect(result).toMatchObject(TEST_PIE_CHART_CONFIG);
expect((result as any)?.configurationType).toBe(
WidgetConfigurationType.PIE_CHART_CONFIG,
);
});
it('should validate minimal pie graph configuration', () => {
const result = validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
TEST_PIE_CHART_CONFIG_MINIMAL,
);
expect(result).toMatchObject(TEST_PIE_CHART_CONFIG_MINIMAL);
expect((result as any)?.configurationType).toBe(
WidgetConfigurationType.PIE_CHART_CONFIG,
);
});
});
describe('GAUGE graph', () => {
it('should validate full gauge graph configuration', () => {
const result = validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
TEST_GAUGE_CHART_CONFIG,
);
expect(result).toMatchObject(TEST_GAUGE_CHART_CONFIG);
expect((result as any)?.configurationType).toBe(
WidgetConfigurationType.GAUGE_CHART_CONFIG,
);
});
it('should validate minimal gauge graph configuration', () => {
const result = validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
TEST_GAUGE_CHART_CONFIG_MINIMAL,
);
expect(result).toMatchObject(TEST_GAUGE_CHART_CONFIG_MINIMAL);
expect((result as any)?.configurationType).toBe(
WidgetConfigurationType.GAUGE_CHART_CONFIG,
);
});
});
it('should return null for unsupported graph type', () => {
const configuration = {
graphType: 'UNSUPPORTED',
viewId: '550e8400-e29b-41d4-a716-446655440000',
};
const result = validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
configuration,
);
expect(result).toBeNull();
});
it('should return null for missing graph type', () => {
const configuration = {
viewId: '550e8400-e29b-41d4-a716-446655440000',
};
const result = validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
configuration,
);
expect(result).toBeNull();
});
});
describe('Edge cases', () => {
it('should throw error for null configuration', () => {
expect(() =>
validateAndTransformWidgetConfiguration(WidgetType.IFRAME, null),
).toThrow('Invalid configuration: not an object');
});
it('should throw error for undefined configuration', () => {
expect(() =>
validateAndTransformWidgetConfiguration(WidgetType.IFRAME, undefined),
).toThrow('Invalid configuration: not an object');
});
it('should throw error for non-object configuration', () => {
expect(() =>
validateAndTransformWidgetConfiguration(WidgetType.IFRAME, 'string'),
).toThrow('Invalid configuration: not an object');
});
it('should return null for unsupported widget type', () => {
const configuration = { someField: 'value' };
const result = validateAndTransformWidgetConfiguration(
'UNSUPPORTED' as WidgetType,
configuration,
);
expect(result).toBeNull();
});
});
describe('Error messages', () => {
it('should include validation details in error message', () => {
expect(() =>
validateAndTransformWidgetConfiguration(
WidgetType.GRAPH,
INVALID_NUMBER_CHART_CONFIG_BAD_UUID,
),
).toThrow(/aggregateFieldMetadataId must be a UUID/);
});
});
});
@@ -0,0 +1,18 @@
import { type 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';
export const isWidgetConfigurationValid = (
type: WidgetType,
configuration: unknown,
): boolean => {
try {
const validatedConfig = validateAndTransformWidgetConfiguration(
type,
configuration,
);
return validatedConfig !== null;
} catch {
return false;
}
};
@@ -0,0 +1,165 @@
import { plainToInstance } from 'class-transformer';
import { validateSync, type ValidationError } from 'class-validator';
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 { NumberChartConfigurationDTO } from 'src/engine/core-modules/page-layout/dtos/number-chart-configuration.dto';
import { PieChartConfigurationDTO } from 'src/engine/core-modules/page-layout/dtos/pie-chart-configuration.dto';
import { type WidgetConfigurationInterface } from 'src/engine/core-modules/page-layout/dtos/widget-configuration.interface';
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';
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: Record<string, unknown>,
): WidgetConfigurationInterface | null => {
const graphType = configuration.graphType as GraphType;
if (!graphType || !Object.values(GraphType).includes(graphType)) {
return null;
}
switch (graphType) {
case GraphType.BAR: {
const instance = plainToInstance(BarChartConfigurationDTO, configuration);
const errors = validateSync(instance);
if (errors.length > 0) {
throw errors;
}
return {
...instance,
configurationType: WidgetConfigurationType.BAR_CHART_CONFIG,
} as WidgetConfigurationInterface;
}
case GraphType.LINE: {
const instance = plainToInstance(
LineChartConfigurationDTO,
configuration,
);
const errors = validateSync(instance);
if (errors.length > 0) {
throw errors;
}
return {
...instance,
configurationType: WidgetConfigurationType.LINE_CHART_CONFIG,
} as WidgetConfigurationInterface;
}
case GraphType.PIE: {
const instance = plainToInstance(PieChartConfigurationDTO, configuration);
const errors = validateSync(instance);
if (errors.length > 0) {
throw errors;
}
return {
...instance,
configurationType: WidgetConfigurationType.PIE_CHART_CONFIG,
} as WidgetConfigurationInterface;
}
case GraphType.NUMBER: {
const instance = plainToInstance(
NumberChartConfigurationDTO,
configuration,
);
const errors = validateSync(instance);
if (errors.length > 0) {
throw errors;
}
return {
...instance,
configurationType: WidgetConfigurationType.NUMBER_CHART_CONFIG,
} as WidgetConfigurationInterface;
}
case GraphType.GAUGE: {
const instance = plainToInstance(
GaugeChartConfigurationDTO,
configuration,
);
const errors = validateSync(instance);
if (errors.length > 0) {
throw errors;
}
return {
...instance,
configurationType: WidgetConfigurationType.GAUGE_CHART_CONFIG,
} as WidgetConfigurationInterface;
}
default:
return null;
}
};
const validateIframeConfiguration = (
configuration: unknown,
): WidgetConfigurationInterface | null => {
const instance = plainToInstance(IframeConfigurationDTO, configuration);
const errors = validateSync(instance);
if (errors.length > 0) {
throw errors;
}
return {
...instance,
configurationType: WidgetConfigurationType.IFRAME_CONFIG,
} as WidgetConfigurationInterface;
};
export const validateAndTransformWidgetConfiguration = (
type: WidgetType,
configuration: unknown,
): WidgetConfigurationInterface | null => {
if (!configuration || typeof configuration !== 'object') {
throw new Error('Invalid configuration: not an object');
}
try {
switch (type) {
case WidgetType.GRAPH:
return validateGraphConfiguration(
configuration as Record<string, unknown>,
);
case WidgetType.IFRAME:
return validateIframeConfiguration(configuration);
default:
return null;
}
} catch (error) {
if (Array.isArray(error)) {
const errorMessage = formatValidationErrors(error);
throw new Error(errorMessage);
}
throw error;
}
};