fix: handle dashboard filters referencing deleted fields (#18512)
closes https://github.com/twentyhq/twenty/issues/18174 --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
+6
@@ -0,0 +1,6 @@
|
||||
export class PageLayoutWidgetFieldValidationException extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'PageLayoutWidgetFieldValidationException';
|
||||
}
|
||||
}
|
||||
+17
-34
@@ -19,7 +19,6 @@ import { CreatePageLayoutWidgetInput } from 'src/engine/metadata-modules/page-la
|
||||
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 { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
|
||||
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
|
||||
import {
|
||||
PageLayoutWidgetException,
|
||||
PageLayoutWidgetExceptionCode,
|
||||
@@ -28,8 +27,7 @@ import {
|
||||
} from 'src/engine/metadata-modules/page-layout-widget/exceptions/page-layout-widget.exception';
|
||||
import { type AllPageLayoutWidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/types/all-page-layout-widget-configuration.type';
|
||||
import { fromFlatPageLayoutWidgetToPageLayoutWidgetDto } from 'src/engine/metadata-modules/page-layout-widget/utils/from-flat-page-layout-widget-to-page-layout-widget-dto.util';
|
||||
import { isChartFieldsForValidation } from 'src/engine/metadata-modules/page-layout-widget/utils/is-chart-fields-for-validation.util';
|
||||
import { validateChartConfigurationFieldReferences } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-chart-configuration-field-references.util';
|
||||
import { validateChartConfigurationFieldReferencesOrThrow } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-chart-configuration-field-references.util';
|
||||
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
|
||||
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
|
||||
import { DashboardSyncService } from 'src/modules/dashboard-sync/services/dashboard-sync.service';
|
||||
@@ -122,25 +120,17 @@ export class PageLayoutWidgetService {
|
||||
}
|
||||
}
|
||||
|
||||
private async validateChartFieldReferencesIfApplicable({
|
||||
private async validateChartFieldReferences({
|
||||
configuration,
|
||||
objectMetadataId,
|
||||
widgetType,
|
||||
widgetTitle,
|
||||
workspaceId,
|
||||
}: {
|
||||
configuration: AllPageLayoutWidgetConfiguration;
|
||||
objectMetadataId?: string | null;
|
||||
widgetType?: WidgetType | null;
|
||||
widgetTitle?: string | null;
|
||||
workspaceId: string;
|
||||
}): Promise<void> {
|
||||
const needsChartValidation =
|
||||
isChartFieldsForValidation(configuration) ||
|
||||
widgetType === WidgetType.GRAPH;
|
||||
|
||||
if (!needsChartValidation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { flatFieldMetadataMaps, flatObjectMetadataMaps } =
|
||||
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
@@ -149,20 +139,13 @@ export class PageLayoutWidgetService {
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
validateChartConfigurationFieldReferences({
|
||||
configuration,
|
||||
objectMetadataId,
|
||||
widgetType,
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadataMaps,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new PageLayoutWidgetException(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
|
||||
);
|
||||
}
|
||||
validateChartConfigurationFieldReferencesOrThrow({
|
||||
widgetConfiguration: configuration,
|
||||
widgetObjectMetadataId: objectMetadataId,
|
||||
widgetTitle,
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadataMaps,
|
||||
});
|
||||
}
|
||||
|
||||
async findByPageLayoutTabId({
|
||||
@@ -275,10 +258,10 @@ export class PageLayoutWidgetService {
|
||||
});
|
||||
|
||||
if (isDefined(createInput.configuration)) {
|
||||
await this.validateChartFieldReferencesIfApplicable({
|
||||
await this.validateChartFieldReferences({
|
||||
configuration: createInput.configuration,
|
||||
objectMetadataId: createInput.objectMetadataId ?? null,
|
||||
widgetType: createInput.type,
|
||||
widgetTitle: createInput.title,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
@@ -405,14 +388,14 @@ export class PageLayoutWidgetService {
|
||||
const effectiveObjectMetadataId = isObjectMetadataIdBeingUpdated
|
||||
? processedUpdateData.objectMetadataId
|
||||
: existingWidget.objectMetadataId;
|
||||
const effectiveWidgetType =
|
||||
processedUpdateData.type ?? existingWidget.type;
|
||||
const effectiveWidgetTitle =
|
||||
processedUpdateData.title ?? existingWidget.title;
|
||||
|
||||
if (isDefined(effectiveConfiguration)) {
|
||||
await this.validateChartFieldReferencesIfApplicable({
|
||||
await this.validateChartFieldReferences({
|
||||
configuration: effectiveConfiguration,
|
||||
objectMetadataId: effectiveObjectMetadataId,
|
||||
widgetType: effectiveWidgetType,
|
||||
widgetTitle: effectiveWidgetTitle,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { type GaugeChartConfigurationDTO } from 'src/engine/metadata-modules/pag
|
||||
import { type LineChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/line-chart-configuration.dto';
|
||||
import { type PieChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/pie-chart-configuration.dto';
|
||||
|
||||
export type ChartFieldsForValidation =
|
||||
export type ChartReferencingFieldInConfiguration =
|
||||
| AggregateChartConfigurationDTO
|
||||
| BarChartConfigurationDTO
|
||||
| GaugeChartConfigurationDTO
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
import { GRAPH_CONFIGURATION_TYPES } from 'src/engine/metadata-modules/page-layout-widget/constants/graph-configuration-types.constant';
|
||||
import { type AllPageLayoutWidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/types/all-page-layout-widget-configuration.type';
|
||||
import { type ChartFieldsForValidation } from 'src/engine/metadata-modules/page-layout-widget/types/chart-fields-for-validation.type';
|
||||
import { type ChartReferencingFieldInConfiguration } from 'src/engine/metadata-modules/page-layout-widget/types/chart-referencing-field-in-configuration.type';
|
||||
|
||||
export const isChartFieldsForValidation = (
|
||||
export const isChartReferencingFieldInConfiguration = (
|
||||
configuration: AllPageLayoutWidgetConfiguration,
|
||||
): configuration is ChartFieldsForValidation =>
|
||||
): configuration is ChartReferencingFieldInConfiguration =>
|
||||
GRAPH_CONFIGURATION_TYPES.has(configuration.configurationType);
|
||||
+147
-69
@@ -1,3 +1,4 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
@@ -5,54 +6,85 @@ import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
|
||||
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
|
||||
import {
|
||||
PageLayoutWidgetException,
|
||||
PageLayoutWidgetExceptionCode,
|
||||
} from 'src/engine/metadata-modules/page-layout-widget/exceptions/page-layout-widget.exception';
|
||||
import { PageLayoutWidgetFieldValidationException } from 'src/engine/metadata-modules/page-layout-widget/exceptions/page-layout-widget-field-validation.exception';
|
||||
import { type AllPageLayoutWidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/types/all-page-layout-widget-configuration.type';
|
||||
import { findActiveFlatFieldMetadataById } from 'src/engine/metadata-modules/page-layout-widget/utils/find-active-flat-field-metadata-by-id.util';
|
||||
import { isChartFieldsForValidation } from 'src/engine/metadata-modules/page-layout-widget/utils/is-chart-fields-for-validation.util';
|
||||
import { validateGroupByField } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-group-by-field.util';
|
||||
import { isChartReferencingFieldInConfiguration } from 'src/engine/metadata-modules/page-layout-widget/utils/is-chart-referencing-field-in-configuration.util';
|
||||
import { validateGroupByFieldOrThrow } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-group-by-field.util';
|
||||
|
||||
export const validateChartConfigurationFieldReferences = ({
|
||||
configuration,
|
||||
objectMetadataId,
|
||||
widgetType,
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
}: {
|
||||
configuration?: AllPageLayoutWidgetConfiguration | null;
|
||||
objectMetadataId?: string | null;
|
||||
widgetType?: WidgetType | null;
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
}): void => {
|
||||
if (!isDefined(configuration)) return;
|
||||
const buildChartFieldValidationException = (
|
||||
message: string,
|
||||
widgetTitle?: string | null,
|
||||
): PageLayoutWidgetException => {
|
||||
const prefix = isDefined(widgetTitle) ? `Chart "${widgetTitle}": ` : '';
|
||||
const fullMessage = prefix + message;
|
||||
|
||||
if (!isChartFieldsForValidation(configuration)) {
|
||||
if (widgetType === WidgetType.GRAPH) {
|
||||
throw new Error(
|
||||
'GRAPH widgets require configurationType AGGREGATE_CHART, BAR_CHART, GAUGE_CHART, LINE_CHART, or PIE_CHART.',
|
||||
);
|
||||
return new PageLayoutWidgetException(
|
||||
fullMessage,
|
||||
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
|
||||
{
|
||||
userFriendlyMessage: msg`${fullMessage}`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const validateGroupByFieldAsChartFieldOrThrow = (
|
||||
params: Parameters<typeof validateGroupByFieldOrThrow>[0],
|
||||
widgetTitle?: string | null,
|
||||
): void => {
|
||||
try {
|
||||
validateGroupByFieldOrThrow(params);
|
||||
} catch (error) {
|
||||
if (!(error instanceof PageLayoutWidgetFieldValidationException)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw buildChartFieldValidationException(error.message, widgetTitle);
|
||||
}
|
||||
};
|
||||
|
||||
export const validateChartConfigurationFieldReferencesOrThrow = ({
|
||||
widgetConfiguration,
|
||||
widgetObjectMetadataId,
|
||||
widgetTitle,
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadataMaps,
|
||||
}: {
|
||||
widgetConfiguration?: AllPageLayoutWidgetConfiguration | null;
|
||||
widgetObjectMetadataId?: string | null;
|
||||
widgetTitle?: string | null;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
|
||||
}): void => {
|
||||
if (!isDefined(widgetConfiguration)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (widgetType && widgetType !== WidgetType.GRAPH) {
|
||||
throw new Error(
|
||||
`Graph configuration is only valid for widgets of type GRAPH.`,
|
||||
if (!isChartReferencingFieldInConfiguration(widgetConfiguration)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDefined(widgetObjectMetadataId)) {
|
||||
throw buildChartFieldValidationException(
|
||||
'objectMetadataId is required for graph widgets.',
|
||||
widgetTitle,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDefined(objectMetadataId)) {
|
||||
throw new Error('objectMetadataId is required for graph widgets.');
|
||||
}
|
||||
|
||||
const objectMetadata = findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityId: objectMetadataId,
|
||||
flatEntityId: widgetObjectMetadataId,
|
||||
flatEntityMaps: flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
if (!isDefined(objectMetadata) || !objectMetadata.isActive) {
|
||||
throw new Error(`objectMetadataId "${objectMetadataId}" not found.`);
|
||||
throw buildChartFieldValidationException(
|
||||
`objectMetadataId "${widgetObjectMetadataId}" not found.`,
|
||||
widgetTitle,
|
||||
);
|
||||
}
|
||||
|
||||
const allFields = Object.values(flatFieldMetadataMaps.byUniversalIdentifier)
|
||||
@@ -69,70 +101,116 @@ export const validateChartConfigurationFieldReferences = ({
|
||||
});
|
||||
|
||||
const aggregateField = findActiveFlatFieldMetadataById(
|
||||
configuration.aggregateFieldMetadataId,
|
||||
widgetConfiguration.aggregateFieldMetadataId,
|
||||
flatFieldMetadataMaps,
|
||||
);
|
||||
|
||||
if (!isDefined(aggregateField)) {
|
||||
throw new Error(
|
||||
`aggregateFieldMetadataId "${configuration.aggregateFieldMetadataId}" not found.`,
|
||||
throw buildChartFieldValidationException(
|
||||
`aggregateFieldMetadataId "${widgetConfiguration.aggregateFieldMetadataId}" not found.`,
|
||||
widgetTitle,
|
||||
);
|
||||
}
|
||||
|
||||
if (aggregateField.objectMetadataId !== objectMetadataId) {
|
||||
throw new Error(
|
||||
`aggregateFieldMetadataId must belong to objectMetadataId "${objectMetadataId}".`,
|
||||
if (aggregateField.objectMetadataId !== widgetObjectMetadataId) {
|
||||
throw buildChartFieldValidationException(
|
||||
`aggregateFieldMetadataId must belong to objectMetadataId "${widgetObjectMetadataId}".`,
|
||||
widgetTitle,
|
||||
);
|
||||
}
|
||||
|
||||
switch (configuration.configurationType) {
|
||||
switch (widgetConfiguration.configurationType) {
|
||||
case WidgetConfigurationType.BAR_CHART:
|
||||
case WidgetConfigurationType.LINE_CHART: {
|
||||
validateGroupByField({
|
||||
fieldId: configuration.primaryAxisGroupByFieldMetadataId,
|
||||
subFieldName: configuration.primaryAxisGroupBySubFieldName,
|
||||
paramName: 'primaryAxisGroupByFieldMetadataId',
|
||||
objectMetadataId,
|
||||
flatFieldMetadataMaps,
|
||||
allFields,
|
||||
fieldsByObjectId,
|
||||
});
|
||||
validateGroupByFieldAsChartFieldOrThrow(
|
||||
{
|
||||
fieldId: widgetConfiguration.primaryAxisGroupByFieldMetadataId,
|
||||
subFieldName: widgetConfiguration.primaryAxisGroupBySubFieldName,
|
||||
paramName: 'primaryAxisGroupByFieldMetadataId',
|
||||
objectMetadataId: widgetObjectMetadataId,
|
||||
flatFieldMetadataMaps,
|
||||
allFields,
|
||||
fieldsByObjectId,
|
||||
},
|
||||
widgetTitle,
|
||||
);
|
||||
|
||||
if (isDefined(configuration.secondaryAxisGroupBySubFieldName)) {
|
||||
if (!isDefined(configuration.secondaryAxisGroupByFieldMetadataId)) {
|
||||
throw new Error(
|
||||
if (isDefined(widgetConfiguration.secondaryAxisGroupBySubFieldName)) {
|
||||
if (
|
||||
!isDefined(widgetConfiguration.secondaryAxisGroupByFieldMetadataId)
|
||||
) {
|
||||
throw buildChartFieldValidationException(
|
||||
'secondaryAxisGroupByFieldMetadataId is required when secondaryAxisGroupBySubFieldName is provided.',
|
||||
widgetTitle,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isDefined(configuration.secondaryAxisGroupByFieldMetadataId)) {
|
||||
validateGroupByField({
|
||||
fieldId: configuration.secondaryAxisGroupByFieldMetadataId,
|
||||
subFieldName: configuration.secondaryAxisGroupBySubFieldName,
|
||||
paramName: 'secondaryAxisGroupByFieldMetadataId',
|
||||
objectMetadataId,
|
||||
flatFieldMetadataMaps,
|
||||
allFields,
|
||||
fieldsByObjectId,
|
||||
});
|
||||
if (isDefined(widgetConfiguration.secondaryAxisGroupByFieldMetadataId)) {
|
||||
validateGroupByFieldAsChartFieldOrThrow(
|
||||
{
|
||||
fieldId: widgetConfiguration.secondaryAxisGroupByFieldMetadataId,
|
||||
subFieldName: widgetConfiguration.secondaryAxisGroupBySubFieldName,
|
||||
paramName: 'secondaryAxisGroupByFieldMetadataId',
|
||||
objectMetadataId: widgetObjectMetadataId,
|
||||
flatFieldMetadataMaps,
|
||||
allFields,
|
||||
fieldsByObjectId,
|
||||
},
|
||||
widgetTitle,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case WidgetConfigurationType.PIE_CHART: {
|
||||
validateGroupByField({
|
||||
fieldId: configuration.groupByFieldMetadataId,
|
||||
subFieldName: configuration.groupBySubFieldName,
|
||||
paramName: 'groupByFieldMetadataId',
|
||||
objectMetadataId,
|
||||
flatFieldMetadataMaps,
|
||||
allFields,
|
||||
fieldsByObjectId,
|
||||
});
|
||||
validateGroupByFieldAsChartFieldOrThrow(
|
||||
{
|
||||
fieldId: widgetConfiguration.groupByFieldMetadataId,
|
||||
subFieldName: widgetConfiguration.groupBySubFieldName,
|
||||
paramName: 'groupByFieldMetadataId',
|
||||
objectMetadataId: widgetObjectMetadataId,
|
||||
flatFieldMetadataMaps,
|
||||
allFields,
|
||||
fieldsByObjectId,
|
||||
},
|
||||
widgetTitle,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case WidgetConfigurationType.AGGREGATE_CHART:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (isDefined(widgetConfiguration.filter?.recordFilters)) {
|
||||
for (const recordFilter of widgetConfiguration.filter.recordFilters) {
|
||||
const filterField = findActiveFlatFieldMetadataById(
|
||||
recordFilter.fieldMetadataId,
|
||||
flatFieldMetadataMaps,
|
||||
);
|
||||
|
||||
if (!isDefined(filterField)) {
|
||||
const inactiveOrMissingField = findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityId: recordFilter.fieldMetadataId,
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
const fieldLabel = inactiveOrMissingField
|
||||
? `"${inactiveOrMissingField.label}"`
|
||||
: `field id "${recordFilter.fieldMetadataId}"`;
|
||||
|
||||
throw buildChartFieldValidationException(
|
||||
`One of the chart filters uses ${fieldLabel}, but it was deleted. Please remove or replace this filter rule.`,
|
||||
widgetTitle,
|
||||
);
|
||||
}
|
||||
|
||||
if (filterField.objectMetadataId !== widgetObjectMetadataId) {
|
||||
throw buildChartFieldValidationException(
|
||||
`Filter field "${recordFilter.fieldMetadataId}" must belong to objectMetadataId "${widgetObjectMetadataId}".`,
|
||||
widgetTitle,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+44
-17
@@ -4,11 +4,24 @@ import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
|
||||
import { PageLayoutWidgetFieldValidationException } from 'src/engine/metadata-modules/page-layout-widget/exceptions/page-layout-widget-field-validation.exception';
|
||||
import { findActiveFlatFieldMetadataById } from 'src/engine/metadata-modules/page-layout-widget/utils/find-active-flat-field-metadata-by-id.util';
|
||||
import { validateCompositeSubfield } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-composite-subfield.util';
|
||||
import { validateRelationSubfield } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-relation-subfield.util';
|
||||
|
||||
export const validateGroupByField = ({
|
||||
const toGroupByFieldValidationException = (
|
||||
error: unknown,
|
||||
): PageLayoutWidgetFieldValidationException => {
|
||||
if (error instanceof PageLayoutWidgetFieldValidationException) {
|
||||
return error;
|
||||
}
|
||||
|
||||
return new PageLayoutWidgetFieldValidationException(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
};
|
||||
|
||||
export const validateGroupByFieldOrThrow = ({
|
||||
fieldId,
|
||||
subFieldName,
|
||||
paramName,
|
||||
@@ -26,44 +39,58 @@ export const validateGroupByField = ({
|
||||
fieldsByObjectId: Map<string, FlatFieldMetadata[]>;
|
||||
}): void => {
|
||||
if (!isDefined(fieldId)) {
|
||||
throw new Error(`${paramName} is required.`);
|
||||
throw new PageLayoutWidgetFieldValidationException(
|
||||
`${paramName} is required.`,
|
||||
);
|
||||
}
|
||||
|
||||
const field = findActiveFlatFieldMetadataById(fieldId, flatFieldMetadataMaps);
|
||||
|
||||
if (!isDefined(field)) {
|
||||
throw new Error(`${paramName} "${fieldId}" not found.`);
|
||||
throw new PageLayoutWidgetFieldValidationException(
|
||||
`${paramName} "${fieldId}" not found.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (field.objectMetadataId !== objectMetadataId) {
|
||||
throw new Error(
|
||||
throw new PageLayoutWidgetFieldValidationException(
|
||||
`${paramName} must belong to objectMetadataId "${objectMetadataId}".`,
|
||||
);
|
||||
}
|
||||
|
||||
if (isCompositeFieldMetadataType(field.type)) {
|
||||
validateCompositeSubfield({
|
||||
field,
|
||||
subFieldName,
|
||||
paramName: field.name,
|
||||
});
|
||||
try {
|
||||
validateCompositeSubfield({
|
||||
field,
|
||||
subFieldName,
|
||||
paramName: field.name,
|
||||
});
|
||||
} catch (error) {
|
||||
throw toGroupByFieldValidationException(error);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMorphOrRelationFlatFieldMetadata(field)) {
|
||||
validateRelationSubfield({
|
||||
field,
|
||||
subFieldName,
|
||||
paramName: field.name,
|
||||
allFields,
|
||||
fieldsByObjectId,
|
||||
});
|
||||
try {
|
||||
validateRelationSubfield({
|
||||
field,
|
||||
subFieldName,
|
||||
paramName: field.name,
|
||||
allFields,
|
||||
fieldsByObjectId,
|
||||
});
|
||||
} catch (error) {
|
||||
throw toGroupByFieldValidationException(error);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDefined(subFieldName)) {
|
||||
throw new Error(`Field "${field.name}" does not support subfields.`);
|
||||
throw new PageLayoutWidgetFieldValidationException(
|
||||
`Field "${field.name}" does not support subfields.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
+31
-3
@@ -22,6 +22,7 @@ import { reconstructFlatPageLayoutWithTabsAndWidgets } from 'src/engine/metadata
|
||||
import { UpdatePageLayoutTabWithWidgetsInput } from 'src/engine/metadata-modules/page-layout-tab/dtos/inputs/update-page-layout-tab-with-widgets.input';
|
||||
import { UpdatePageLayoutWidgetWithIdInput } from 'src/engine/metadata-modules/page-layout-widget/dtos/inputs/update-page-layout-widget-with-id.input';
|
||||
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
|
||||
import { validateChartConfigurationFieldReferencesOrThrow } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-chart-configuration-field-references.util';
|
||||
import { UpdatePageLayoutWithTabsInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout-with-tabs.input';
|
||||
import { PageLayoutDTO } from 'src/engine/metadata-modules/page-layout/dtos/page-layout.dto';
|
||||
import {
|
||||
@@ -474,6 +475,14 @@ export class PageLayoutUpdateService {
|
||||
widgetsToCreate: FlatPageLayoutWidget[];
|
||||
widgetsToUpdate: FlatPageLayoutWidget[];
|
||||
} {
|
||||
for (const widgetInput of widgets) {
|
||||
this.validateChartFieldReferences({
|
||||
widgetInput,
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadataMaps,
|
||||
});
|
||||
}
|
||||
|
||||
const existingWidgets = Object.values(
|
||||
flatPageLayoutWidgetMaps.byUniversalIdentifier,
|
||||
)
|
||||
@@ -634,6 +643,28 @@ export class PageLayoutUpdateService {
|
||||
};
|
||||
}
|
||||
|
||||
private validateChartFieldReferences({
|
||||
widgetInput,
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadataMaps,
|
||||
}: {
|
||||
widgetInput: UpdatePageLayoutWidgetWithIdInput;
|
||||
flatFieldMetadataMaps: AllFlatEntityMaps['flatFieldMetadataMaps'];
|
||||
flatObjectMetadataMaps: AllFlatEntityMaps['flatObjectMetadataMaps'];
|
||||
}): void {
|
||||
if (!isDefined(widgetInput.configuration)) {
|
||||
return;
|
||||
}
|
||||
|
||||
validateChartConfigurationFieldReferencesOrThrow({
|
||||
widgetConfiguration: widgetInput.configuration,
|
||||
widgetObjectMetadataId: widgetInput.objectMetadataId,
|
||||
widgetTitle: widgetInput.title,
|
||||
flatFieldMetadataMaps,
|
||||
flatObjectMetadataMaps,
|
||||
});
|
||||
}
|
||||
|
||||
private collectOrphanedViewIdsFromDeletedWidgets({
|
||||
widgetsToUpdate,
|
||||
tabsToUpdate,
|
||||
@@ -649,7 +680,6 @@ export class PageLayoutUpdateService {
|
||||
const viewIdsToDelete = new Set<string>();
|
||||
const directlyDeletedWidgetIds = new Set<string>();
|
||||
|
||||
// Collect viewIds from directly deleted FIELDS widgets
|
||||
for (const widget of widgetsToUpdate) {
|
||||
if (isDefined(widget.deletedAt)) {
|
||||
directlyDeletedWidgetIds.add(widget.id);
|
||||
@@ -661,7 +691,6 @@ export class PageLayoutUpdateService {
|
||||
}
|
||||
}
|
||||
|
||||
// Collect viewIds from FIELDS widgets in deleted tabs
|
||||
const deletedTabIds = new Set(
|
||||
tabsToUpdate
|
||||
.filter((tab) => isDefined(tab.deletedAt))
|
||||
@@ -685,7 +714,6 @@ export class PageLayoutUpdateService {
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out viewIds still referenced by surviving widgets
|
||||
for (const widget of allExistingWidgets) {
|
||||
if (
|
||||
!isDefined(widget.deletedAt) &&
|
||||
|
||||
@@ -5,7 +5,7 @@ import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-m
|
||||
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
|
||||
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
|
||||
import { findActiveFlatFieldMetadataById } from 'src/engine/metadata-modules/page-layout-widget/utils/find-active-flat-field-metadata-by-id.util';
|
||||
import { isChartFieldsForValidation } from 'src/engine/metadata-modules/page-layout-widget/utils/is-chart-fields-for-validation.util';
|
||||
import { isChartReferencingFieldInConfiguration } from 'src/engine/metadata-modules/page-layout-widget/utils/is-chart-referencing-field-in-configuration.util';
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
import {
|
||||
type DashboardToolContext,
|
||||
@@ -114,7 +114,7 @@ export const createGetDashboardTool = (
|
||||
tab.widgets?.map((w) => {
|
||||
if (
|
||||
w.type !== WidgetType.GRAPH ||
|
||||
!isChartFieldsForValidation(w.configuration)
|
||||
!isChartReferencingFieldInConfiguration(w.configuration)
|
||||
) {
|
||||
return {
|
||||
id: w.id,
|
||||
|
||||
+212
@@ -1,8 +1,23 @@
|
||||
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
|
||||
import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util';
|
||||
import { deleteOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/delete-one-field-metadata.util';
|
||||
import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util';
|
||||
import { createOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/create-one-page-layout-tab.util';
|
||||
import { destroyOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/destroy-one-page-layout-tab.util';
|
||||
import {
|
||||
fetchTestFieldMetadataIds,
|
||||
type TestFieldMetadataIds,
|
||||
} from 'test/integration/metadata/suites/page-layout-widget/utils/fetch-test-field-metadata-ids.util';
|
||||
import { createOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/create-one-page-layout.util';
|
||||
import { destroyOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/destroy-one-page-layout.util';
|
||||
import { updateOnePageLayoutWithTabsAndWidgets } from 'test/integration/metadata/suites/page-layout/utils/update-one-page-layout-with-tabs-and-widgets.util';
|
||||
import { AggregateOperations, FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
|
||||
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
|
||||
import { type AllPageLayoutWidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/types/all-page-layout-widget-configuration.type';
|
||||
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
|
||||
|
||||
describe('Page layout with tabs update should fail', () => {
|
||||
@@ -42,4 +57,201 @@ describe('Page layout with tabs update should fail', () => {
|
||||
|
||||
expectOneNotInternalServerErrorSnapshot({ errors });
|
||||
});
|
||||
|
||||
describe('chart filter validation failures', () => {
|
||||
let testFieldMetadataIds: TestFieldMetadataIds;
|
||||
let testPageLayoutId: string | undefined;
|
||||
let testPageLayoutTabId: string | undefined;
|
||||
let testFilterFieldMetadataId: string | undefined;
|
||||
let testFilterFieldDeleted = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
testFieldMetadataIds = await fetchTestFieldMetadataIds();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const filterFieldName = `deletedFilterField${Date.now()}`;
|
||||
|
||||
const { data: createdFieldData } = await createOneFieldMetadata({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
objectMetadataId: testFieldMetadataIds.objectMetadataId,
|
||||
type: FieldMetadataType.TEXT,
|
||||
label: 'Deleted Filter Field',
|
||||
name: filterFieldName,
|
||||
isLabelSyncedWithName: false,
|
||||
},
|
||||
});
|
||||
|
||||
testFilterFieldMetadataId = createdFieldData.createOneField.id;
|
||||
|
||||
const { data: layoutData } = await createOnePageLayout({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
name: 'Deleted Filter Field Layout',
|
||||
type: PageLayoutType.RECORD_PAGE,
|
||||
},
|
||||
});
|
||||
|
||||
testPageLayoutId = layoutData.createPageLayout.id;
|
||||
|
||||
const { data: tabData } = await createOnePageLayoutTab({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
title: 'Deleted Filter Field Tab',
|
||||
pageLayoutId: testPageLayoutId,
|
||||
},
|
||||
});
|
||||
|
||||
testPageLayoutTabId = tabData.createPageLayoutTab.id;
|
||||
testFilterFieldDeleted = false;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (isDefined(testFilterFieldMetadataId) && !testFilterFieldDeleted) {
|
||||
await updateOneFieldMetadata({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
idToUpdate: testFilterFieldMetadataId,
|
||||
updatePayload: {
|
||||
isActive: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
await deleteOneFieldMetadata({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
idToDelete: testFilterFieldMetadataId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (isDefined(testPageLayoutTabId)) {
|
||||
await destroyOnePageLayoutTab({
|
||||
expectToFail: false,
|
||||
input: { id: testPageLayoutTabId },
|
||||
});
|
||||
}
|
||||
|
||||
if (isDefined(testPageLayoutId)) {
|
||||
await destroyOnePageLayout({
|
||||
expectToFail: false,
|
||||
input: { id: testPageLayoutId },
|
||||
});
|
||||
}
|
||||
|
||||
testFilterFieldMetadataId = undefined;
|
||||
testPageLayoutTabId = undefined;
|
||||
testPageLayoutId = undefined;
|
||||
testFilterFieldDeleted = false;
|
||||
});
|
||||
|
||||
it('when saving layout tabs and widgets with a deleted chart filter field', async () => {
|
||||
if (
|
||||
!isDefined(testPageLayoutId) ||
|
||||
!isDefined(testPageLayoutTabId) ||
|
||||
!isDefined(testFilterFieldMetadataId)
|
||||
) {
|
||||
throw new Error('Test setup incomplete');
|
||||
}
|
||||
|
||||
const chartWidgetId = v4();
|
||||
const chartTitle = 'Opportunities by Name';
|
||||
const chartConfiguration = {
|
||||
configurationType: WidgetConfigurationType.PIE_CHART,
|
||||
aggregateFieldMetadataId: testFieldMetadataIds.fieldMetadataId1,
|
||||
aggregateOperation: AggregateOperations.COUNT,
|
||||
groupByFieldMetadataId: testFieldMetadataIds.fieldMetadataId2,
|
||||
filter: {
|
||||
recordFilters: [
|
||||
{
|
||||
fieldMetadataId: testFilterFieldMetadataId,
|
||||
operand: 'contains',
|
||||
value: 'acme',
|
||||
},
|
||||
],
|
||||
recordFilterGroups: [],
|
||||
},
|
||||
} satisfies AllPageLayoutWidgetConfiguration;
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: testPageLayoutTabId,
|
||||
title: 'Deleted Filter Field Tab',
|
||||
position: 0,
|
||||
widgets: [
|
||||
{
|
||||
id: chartWidgetId,
|
||||
pageLayoutTabId: testPageLayoutTabId,
|
||||
title: chartTitle,
|
||||
type: WidgetType.GRAPH,
|
||||
objectMetadataId: testFieldMetadataIds.objectMetadataId,
|
||||
gridPosition: {
|
||||
row: 0,
|
||||
column: 0,
|
||||
rowSpan: 1,
|
||||
columnSpan: 1,
|
||||
},
|
||||
configuration: chartConfiguration,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
await updateOnePageLayoutWithTabsAndWidgets({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
id: testPageLayoutId,
|
||||
name: 'Deleted Filter Field Layout',
|
||||
type: PageLayoutType.RECORD_PAGE,
|
||||
objectMetadataId: null,
|
||||
tabs,
|
||||
},
|
||||
});
|
||||
|
||||
await updateOneFieldMetadata({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
idToUpdate: testFilterFieldMetadataId,
|
||||
updatePayload: {
|
||||
isActive: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await deleteOneFieldMetadata({
|
||||
expectToFail: false,
|
||||
input: {
|
||||
idToDelete: testFilterFieldMetadataId,
|
||||
},
|
||||
});
|
||||
|
||||
testFilterFieldDeleted = true;
|
||||
|
||||
const { errors } = await updateOnePageLayoutWithTabsAndWidgets({
|
||||
expectToFail: true,
|
||||
input: {
|
||||
id: testPageLayoutId,
|
||||
name: 'Deleted Filter Field Layout',
|
||||
type: PageLayoutType.RECORD_PAGE,
|
||||
objectMetadataId: null,
|
||||
tabs,
|
||||
},
|
||||
});
|
||||
|
||||
expect(errors).toBeDefined();
|
||||
expect(errors).toHaveLength(1);
|
||||
|
||||
const [firstError] = errors!;
|
||||
|
||||
expect(firstError.extensions.code).toBe('BAD_USER_INPUT');
|
||||
expect(firstError.message).toContain(`Chart "${chartTitle}":`);
|
||||
expect(firstError.message).toContain(
|
||||
'Please remove or replace this filter rule.',
|
||||
);
|
||||
expect(
|
||||
String(firstError.extensions.userFriendlyMessage),
|
||||
).toContain(`Chart "${chartTitle}":`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user