[PAGE LAYOUTS] Add widgets validation (#16635)

- Add widget validation
- Remove 'None' option for primary axis group by
- Fix error message parsing by passing the operation type in
`useMetadataErrorHandler`
This commit is contained in:
Raphaël Bosi
2026-01-07 16:00:13 +01:00
committed by GitHub
parent 701a713042
commit 4faed25624
71 changed files with 4004 additions and 1220 deletions
@@ -1,8 +1,9 @@
import { isDefined } from 'class-validator';
import { type DataSource } from 'typeorm';
import { v4 } from 'uuid';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { validateAndTransformWidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-and-transform-widget-configuration.util';
import { validateWidgetConfigurationInput } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-widget-configuration-input.util';
import { getPageLayoutWidgetDataSeeds } from 'src/engine/workspace-manager/dev-seeder/core/utils/get-page-layout-widget-data-seeds.util';
export const seedPageLayoutWidgets = async ({
@@ -26,26 +27,22 @@ export const seedPageLayoutWidgets = async ({
isDashboardV2Enabled,
);
const pageLayoutWidgets = await Promise.all(
widgetSeeds.map(async (widget) => {
const validatedConfiguration = widget.configuration
? await validateAndTransformWidgetConfiguration({
type: widget.type,
configuration: widget.configuration,
isDashboardV2Enabled,
})
: null;
const pageLayoutWidgets = widgetSeeds.map((widget) => {
if (isDefined(widget.configuration)) {
validateWidgetConfigurationInput({
configuration: widget.configuration,
});
}
return {
...widget,
workspaceId,
gridPosition: widget.gridPosition,
configuration: validatedConfiguration,
universalIdentifier: v4(),
applicationId: workspaceCustomApplicationId,
};
}),
);
return {
...widget,
workspaceId,
gridPosition: widget.gridPosition,
configuration: widget.configuration,
universalIdentifier: v4(),
applicationId: workspaceCustomApplicationId,
};
});
if (pageLayoutWidgets.length > 0) {
await dataSource
@@ -81,6 +81,7 @@ export class WorkspaceMigrationV2IndexActionsBuilderService extends WorkspaceEnt
flatEntityUpdates,
buildOptions,
workspaceId,
additionalCacheDataMaps,
}: FlatEntityUpdateValidationArgs<
typeof ALL_METADATA_NAME.index
>): FlatEntityValidationReturnType<typeof ALL_METADATA_NAME.index, 'update'> {
@@ -113,6 +114,7 @@ export class WorkspaceMigrationV2IndexActionsBuilderService extends WorkspaceEnt
workspaceId,
flatEntityToValidate: flatEntity,
remainingFlatEntityMapsToValidate: createEmptyFlatEntityMaps(),
additionalCacheDataMaps,
});
if (deletionValidationResult.errors.length > 0) {
@@ -152,6 +154,7 @@ export class WorkspaceMigrationV2IndexActionsBuilderService extends WorkspaceEnt
flatIndexMaps: tempOptimisticFlatIndexMaps,
},
remainingFlatEntityMapsToValidate: createEmptyFlatEntityMaps(),
additionalCacheDataMaps,
});
if (creationValidationResult.errors.length > 0) {
@@ -19,14 +19,16 @@ export class WorkspaceMigrationV2PageLayoutWidgetActionsBuilderService extends W
super(ALL_METADATA_NAME.pageLayoutWidget);
}
protected validateFlatEntityCreation(
protected async validateFlatEntityCreation(
args: FlatEntityValidationArgs<typeof ALL_METADATA_NAME.pageLayoutWidget>,
): FlatEntityValidationReturnType<
typeof ALL_METADATA_NAME.pageLayoutWidget,
'create'
): Promise<
FlatEntityValidationReturnType<
typeof ALL_METADATA_NAME.pageLayoutWidget,
'create'
>
> {
const validationResult =
this.flatPageLayoutWidgetValidatorService.validateFlatPageLayoutWidgetCreation(
await this.flatPageLayoutWidgetValidatorService.validateFlatPageLayoutWidgetCreation(
args,
);
@@ -77,16 +79,18 @@ export class WorkspaceMigrationV2PageLayoutWidgetActionsBuilderService extends W
};
}
protected validateFlatEntityUpdate(
protected async validateFlatEntityUpdate(
args: FlatEntityUpdateValidationArgs<
typeof ALL_METADATA_NAME.pageLayoutWidget
>,
): FlatEntityValidationReturnType<
typeof ALL_METADATA_NAME.pageLayoutWidget,
'update'
): Promise<
FlatEntityValidationReturnType<
typeof ALL_METADATA_NAME.pageLayoutWidget,
'update'
>
> {
const validationResult =
this.flatPageLayoutWidgetValidatorService.validateFlatPageLayoutWidgetUpdate(
await this.flatPageLayoutWidgetValidatorService.validateFlatPageLayoutWidgetUpdate(
args,
);
@@ -57,6 +57,7 @@ export abstract class WorkspaceEntityMigrationBuilderV2Service<
dependencyOptimisticFlatEntityMaps: inputDependencyOptimisticFlatEntityMaps,
from: fromFlatEntityMaps,
to: toFlatEntityMaps,
additionalCacheDataMaps,
workspaceId,
}: ValidateAndBuildArgs<T>): ValidateAndBuildReturnType<T> {
this.logger.time(`EntityBuilder ${this.metadataName}`, 'validateAndBuild');
@@ -125,6 +126,7 @@ export abstract class WorkspaceEntityMigrationBuilderV2Service<
});
const validationResult = await this.validateFlatEntityCreation({
additionalCacheDataMaps,
flatEntityToValidate: flatEntityToCreate,
workspaceId,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps,
@@ -193,6 +195,7 @@ export abstract class WorkspaceEntityMigrationBuilderV2Service<
remainingFlatEntityMapsToValidate: remainingFlatEntityMapsToDelete,
buildOptions,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps,
additionalCacheDataMaps,
});
if (validationResult.status === 'fail') {
@@ -237,6 +240,7 @@ export abstract class WorkspaceEntityMigrationBuilderV2Service<
optimisticFlatEntityMapsAndRelatedFlatEntityMaps,
workspaceId,
buildOptions,
additionalCacheDataMaps,
});
if (validationResult.status === 'fail') {
@@ -3,9 +3,11 @@ import { type AllMetadataName } from 'twenty-shared/metadata';
import { type MetadataFlatEntityAndRelatedFlatEntityMapsForValidation } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity-and-related-flat-entity-maps-for-validation.type';
import { type MetadataFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity-maps.type';
import { type MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity.type';
import { type WorkspaceMigrationBuilderAdditionalCacheDataMaps } from 'src/engine/workspace-manager/workspace-migration-v2/types/workspace-migration-builder-additional-cache-data-maps.type';
import { type WorkspaceMigrationBuilderOptions } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-builder-options.type';
export type FlatEntityValidationArgs<T extends AllMetadataName> = {
additionalCacheDataMaps: WorkspaceMigrationBuilderAdditionalCacheDataMaps;
flatEntityToValidate: MetadataFlatEntity<T>;
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: MetadataFlatEntityAndRelatedFlatEntityMapsForValidation<T>;
workspaceId: string;
@@ -36,6 +36,7 @@ export class FlatFieldMetadataValidatorService {
},
workspaceId,
buildOptions,
additionalCacheDataMaps,
}: FlatEntityUpdateValidationArgs<
typeof ALL_METADATA_NAME.fieldMetadata
>): FailedFlatEntityValidation<'fieldMetadata', 'update'> {
@@ -184,6 +185,7 @@ export class FlatFieldMetadataValidatorService {
buildOptions,
remainingFlatEntityMapsToValidate: createEmptyFlatEntityMaps(),
workspaceId,
additionalCacheDataMaps,
},
);
@@ -285,6 +287,7 @@ export class FlatFieldMetadataValidatorService {
workspaceId,
buildOptions,
remainingFlatEntityMapsToValidate,
additionalCacheDataMaps,
}: FlatEntityValidationArgs<
typeof ALL_METADATA_NAME.fieldMetadata
>): FailedFlatEntityValidation<'fieldMetadata', 'create'> {
@@ -372,6 +375,7 @@ export class FlatFieldMetadataValidatorService {
buildOptions,
workspaceId,
remainingFlatEntityMapsToValidate,
additionalCacheDataMaps,
},
),
);
@@ -4,10 +4,20 @@ import { msg, t } from '@lingui/core/macro';
import { ALL_METADATA_NAME } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { FlatPageLayoutWidgetTypeValidatorService } from 'src/engine/metadata-modules/flat-page-layout-widget/services/flat-page-layout-widget-type-validator.service';
import { PageLayoutTabExceptionCode } from 'src/engine/metadata-modules/page-layout-tab/exceptions/page-layout-tab.exception';
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';
import { PageLayoutWidgetExceptionCode } from 'src/engine/metadata-modules/page-layout-widget/exceptions/page-layout-widget.exception';
import { FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/types/failed-flat-entity-validation.type';
import { AllPageLayoutWidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/types/all-page-layout-widget-configuration.type';
import { GridPosition } from 'src/engine/metadata-modules/page-layout-widget/types/grid-position.type';
import { validateWidgetGridPosition } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-widget-grid-position.util';
import {
FailedFlatEntityValidation,
FlatEntityValidationError,
} from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/types/failed-flat-entity-validation.type';
import { getEmptyFlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/utils/get-flat-entity-validation-error.util';
import { FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/flat-entity-update-validation-args.type';
import { FlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/flat-entity-validation-args.type';
@@ -15,19 +25,26 @@ import { fromFlatEntityPropertiesUpdatesToPartialFlatEntity } from 'src/engine/w
@Injectable()
export class FlatPageLayoutWidgetValidatorService {
constructor() {}
constructor(
private readonly flatPageLayoutWidgetTypeValidatorService: FlatPageLayoutWidgetTypeValidatorService,
) {}
public validateFlatPageLayoutWidgetUpdate({
public async validateFlatPageLayoutWidgetUpdate({
flatEntityId,
flatEntityUpdates,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
flatPageLayoutWidgetMaps: optimisticFlatPageLayoutWidgetMaps,
},
optimisticFlatEntityMapsAndRelatedFlatEntityMaps,
additionalCacheDataMaps: { featureFlagsMap },
workspaceId,
buildOptions,
}: FlatEntityUpdateValidationArgs<
typeof ALL_METADATA_NAME.pageLayoutWidget
>): FailedFlatEntityValidation<'pageLayoutWidget', 'update'> {
>): Promise<FailedFlatEntityValidation<'pageLayoutWidget', 'update'>> {
const isDashboardV2Enabled =
featureFlagsMap[FeatureFlagKey.IS_DASHBOARD_V2_ENABLED] ?? false;
const existingFlatPageLayoutWidget =
optimisticFlatPageLayoutWidgetMaps.byId[flatEntityId];
optimisticFlatEntityMapsAndRelatedFlatEntityMaps.flatPageLayoutWidgetMaps
.byId[flatEntityId];
const validationResult = getEmptyFlatEntityValidationError({
flatEntityMinimalInformation: {
@@ -61,6 +78,38 @@ export class FlatPageLayoutWidgetValidatorService {
pageLayoutTabId: updatedFlatPageLayoutWidget.pageLayoutTabId,
};
const gridPositionErrors = this.validateGridPosition({
gridPosition: updatedFlatPageLayoutWidget.gridPosition,
widgetTitle: updatedFlatPageLayoutWidget.title,
});
validationResult.errors.push(...gridPositionErrors);
const featureFlagErrors = this.validateFeatureFlags({
type: updatedFlatPageLayoutWidget.type,
configuration: updatedFlatPageLayoutWidget.configuration,
widgetTitle: updatedFlatPageLayoutWidget.title,
isDashboardV2Enabled,
});
validationResult.errors.push(...featureFlagErrors);
const typeSpecificityErrors =
this.flatPageLayoutWidgetTypeValidatorService.validateFlatPageLayoutWidgetTypeSpecificitiesForUpdate(
{
flatEntityToValidate: updatedFlatPageLayoutWidget,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps,
updates: flatEntityUpdates,
additionalCacheDataMaps: { featureFlagsMap },
workspaceId,
buildOptions,
remainingFlatEntityMapsToValidate:
optimisticFlatEntityMapsAndRelatedFlatEntityMaps.flatPageLayoutWidgetMaps,
},
);
validationResult.errors.push(...typeSpecificityErrors);
return validationResult;
}
@@ -100,15 +149,19 @@ export class FlatPageLayoutWidgetValidatorService {
return validationResult;
}
public validateFlatPageLayoutWidgetCreation({
public async validateFlatPageLayoutWidgetCreation({
flatEntityToValidate: flatPageLayoutWidgetToValidate,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
flatPageLayoutTabMaps,
flatPageLayoutWidgetMaps: optimisticFlatPageLayoutWidgetMaps,
},
additionalCacheDataMaps: { featureFlagsMap },
optimisticFlatEntityMapsAndRelatedFlatEntityMaps,
workspaceId,
buildOptions,
remainingFlatEntityMapsToValidate,
}: FlatEntityValidationArgs<
typeof ALL_METADATA_NAME.pageLayoutWidget
>): FailedFlatEntityValidation<'pageLayoutWidget', 'create'> {
>): Promise<FailedFlatEntityValidation<'pageLayoutWidget', 'create'>> {
const isDashboardV2Enabled =
featureFlagsMap[FeatureFlagKey.IS_DASHBOARD_V2_ENABLED] ?? false;
const validationResult = getEmptyFlatEntityValidationError({
flatEntityMinimalInformation: {
id: flatPageLayoutWidgetToValidate.id,
@@ -120,9 +173,8 @@ export class FlatPageLayoutWidgetValidatorService {
});
const existingFlatPageLayoutWidget =
optimisticFlatPageLayoutWidgetMaps.byId[
flatPageLayoutWidgetToValidate.id
];
optimisticFlatEntityMapsAndRelatedFlatEntityMaps.flatPageLayoutWidgetMaps
.byId[flatPageLayoutWidgetToValidate.id];
if (isDefined(existingFlatPageLayoutWidget)) {
const flatPageLayoutWidgetId = flatPageLayoutWidgetToValidate.id;
@@ -136,7 +188,8 @@ export class FlatPageLayoutWidgetValidatorService {
const referencedPageLayoutTab = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: flatPageLayoutWidgetToValidate.pageLayoutTabId,
flatEntityMaps: flatPageLayoutTabMaps,
flatEntityMaps:
optimisticFlatEntityMapsAndRelatedFlatEntityMaps.flatPageLayoutTabMaps,
});
if (!isDefined(referencedPageLayoutTab)) {
@@ -147,6 +200,97 @@ export class FlatPageLayoutWidgetValidatorService {
});
}
const gridPositionErrors = this.validateGridPosition({
gridPosition: flatPageLayoutWidgetToValidate.gridPosition,
widgetTitle: flatPageLayoutWidgetToValidate.title,
});
validationResult.errors.push(...gridPositionErrors);
const featureFlagErrors = this.validateFeatureFlags({
type: flatPageLayoutWidgetToValidate.type,
configuration: flatPageLayoutWidgetToValidate.configuration,
widgetTitle: flatPageLayoutWidgetToValidate.title,
isDashboardV2Enabled,
});
validationResult.errors.push(...featureFlagErrors);
const typeSpecificityErrors =
this.flatPageLayoutWidgetTypeValidatorService.validateFlatPageLayoutWidgetTypeSpecificitiesForCreation(
{
flatEntityToValidate: flatPageLayoutWidgetToValidate,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps,
additionalCacheDataMaps: { featureFlagsMap },
workspaceId,
buildOptions,
remainingFlatEntityMapsToValidate,
},
);
validationResult.errors.push(...typeSpecificityErrors);
return validationResult;
}
private validateGridPosition({
gridPosition,
widgetTitle,
}: {
gridPosition: GridPosition | undefined;
widgetTitle: string;
}): FlatEntityValidationError[] {
if (!isDefined(gridPosition)) {
return [
{
code: PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
message: t`Grid position is required`,
userFriendlyMessage: msg`Grid position is required`,
},
];
}
return validateWidgetGridPosition(gridPosition, widgetTitle);
}
private validateFeatureFlags({
type,
configuration,
widgetTitle,
isDashboardV2Enabled,
}: {
type: WidgetType | undefined;
configuration: AllPageLayoutWidgetConfiguration | null | undefined;
widgetTitle: string;
isDashboardV2Enabled: boolean;
}): FlatEntityValidationError[] {
if (!isDefined(type) || !isDefined(configuration)) {
return [];
}
if (type !== WidgetType.GRAPH) {
return [];
}
const graphConfiguration = configuration as unknown as {
configurationType?: GraphType;
};
if (
graphConfiguration.configurationType === GraphType.GAUGE_CHART &&
!isDashboardV2Enabled
) {
const chartType = graphConfiguration.configurationType;
return [
{
code: PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
message: t`Invalid configuration for widget "${widgetTitle}": Chart type ${chartType} requires IS_DASHBOARD_V2_ENABLED feature flag`,
userFriendlyMessage: msg`This chart type requires the Dashboard V2 feature to be enabled`,
},
];
}
return [];
}
}
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FlatFieldMetadataTypeValidatorService } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service';
import { FlatPageLayoutWidgetTypeValidatorService } from 'src/engine/metadata-modules/flat-page-layout-widget/services/flat-page-layout-widget-type-validator.service';
import { FlatAgentValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-agent-validator.service';
import { FlatCronTriggerValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-cron-trigger-validator.service';
import { FlatDatabaseEventTriggerValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-database-event-trigger-validator.service';
@@ -39,6 +40,7 @@ import { FlatViewValidatorService } from 'src/engine/workspace-manager/workspace
FlatDatabaseEventTriggerValidatorService,
FlatCronTriggerValidatorService,
FlatFieldMetadataTypeValidatorService,
FlatPageLayoutWidgetTypeValidatorService,
FlatRouteTriggerValidatorService,
FlatRoleValidatorService,
FlatRoleTargetValidatorService,