From 5b4f791ddc20b72bba2557367ce3219fbce660b5 Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Mon, 13 Apr 2026 16:38:27 +0200 Subject: [PATCH] Move view field label identifier deletion validation into the cross entity validation (#19642) ## Introduction In the same validate build and run we should be able to delete a view field targetting a label identifier and at the same create one that repoints to it again without failing any validation Leading for this valdiation rule to be moved in the cross entity validation steps --- ...ield-label-identifier-cross-entity.util.ts | 123 ++++++++++++++++++ ...ce-migration-build-orchestrator.service.ts | 8 +- ...ross-entity-transversal-validation.util.ts | 15 ++- .../flat-view-field-validator.service.ts | 40 ------ ...ect-on-view-field.integration-spec.ts.snap | 3 +- 5 files changed, 145 insertions(+), 44 deletions(-) create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-view-field/validators/utils/validate-view-field-label-identifier-cross-entity.util.ts diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-view-field/validators/utils/validate-view-field-label-identifier-cross-entity.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-view-field/validators/utils/validate-view-field-label-identifier-cross-entity.util.ts new file mode 100644 index 00000000000..e148229f8a5 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-view-field/validators/utils/validate-view-field-label-identifier-cross-entity.util.ts @@ -0,0 +1,123 @@ +import { msg, t } from '@lingui/core/macro'; +import { ViewType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; +import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util'; +import { ViewExceptionCode } from 'src/engine/metadata-modules/view/exceptions/view.exception'; +import { type OrchestratorFailureReport } from 'src/engine/workspace-manager/workspace-migration/types/workspace-migration-orchestrator.type'; +import { type AllUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/all-universal-flat-entity-maps.type'; +import { type UniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-maps.type'; +import { type UniversalFlatViewField } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field.type'; +import { type UniversalDeleteViewFieldAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view-field/types/workspace-migration-view-field-action.type'; +import { getEmptyFlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/utils/get-flat-entity-validation-error.util'; + +export const validateViewFieldLabelIdentifierCrossEntity = ({ + optimisticUniversalFlatMaps, + deletedViewFieldActions, + preDeletionFlatViewFieldMaps, +}: { + optimisticUniversalFlatMaps: Pick< + AllUniversalFlatEntityMaps, + 'flatObjectMetadataMaps' | 'flatViewMaps' | 'flatViewFieldMaps' + >; + deletedViewFieldActions: UniversalDeleteViewFieldAction[]; + preDeletionFlatViewFieldMaps: UniversalFlatEntityMaps; +}): Pick => { + const validationErrors: Pick = { + viewField: [], + }; + + if (deletedViewFieldActions.length === 0) { + return validationErrors; + } + + const alreadyCheckedViewUniversalIdentifiers = new Set(); + + for (const deleteAction of deletedViewFieldActions) { + const deletedViewField = findFlatEntityByUniversalIdentifierOrThrow({ + universalIdentifier: deleteAction.universalIdentifier, + flatEntityMaps: preDeletionFlatViewFieldMaps, + }); + + const { viewUniversalIdentifier, fieldMetadataUniversalIdentifier } = + deletedViewField; + + if (alreadyCheckedViewUniversalIdentifiers.has(viewUniversalIdentifier)) { + continue; + } + + const view = findFlatEntityByUniversalIdentifier({ + universalIdentifier: viewUniversalIdentifier, + flatEntityMaps: optimisticUniversalFlatMaps.flatViewMaps, + }); + + if (!isDefined(view) || view.type === ViewType.FIELDS_WIDGET) { + continue; + } + + const objectMetadata = findFlatEntityByUniversalIdentifier({ + universalIdentifier: view.objectMetadataUniversalIdentifier, + flatEntityMaps: optimisticUniversalFlatMaps.flatObjectMetadataMaps, + }); + + if (!isDefined(objectMetadata)) { + continue; + } + + const { labelIdentifierFieldMetadataUniversalIdentifier } = objectMetadata; + + if (!isDefined(labelIdentifierFieldMetadataUniversalIdentifier)) { + continue; + } + + if ( + fieldMetadataUniversalIdentifier !== + labelIdentifierFieldMetadataUniversalIdentifier + ) { + continue; + } + + alreadyCheckedViewUniversalIdentifiers.add(viewUniversalIdentifier); + + const viewFieldUniversalIdentifiers = + view.viewFieldUniversalIdentifiers ?? []; + + const hasLabelIdentifierViewField = viewFieldUniversalIdentifiers.some( + (viewFieldUniversalIdentifier) => { + const viewField = findFlatEntityByUniversalIdentifier({ + universalIdentifier: viewFieldUniversalIdentifier, + flatEntityMaps: optimisticUniversalFlatMaps.flatViewFieldMaps, + }); + + return ( + isDefined(viewField) && + viewField.fieldMetadataUniversalIdentifier === + labelIdentifierFieldMetadataUniversalIdentifier + ); + }, + ); + + if (!hasLabelIdentifierViewField) { + const failedValidation = getEmptyFlatEntityValidationError({ + flatEntityMinimalInformation: { + universalIdentifier: deleteAction.universalIdentifier, + viewUniversalIdentifier, + fieldMetadataUniversalIdentifier, + }, + metadataName: 'viewField', + type: 'delete', + }); + + failedValidation.errors.push({ + code: ViewExceptionCode.INVALID_VIEW_DATA, + message: t`Label identifier view field cannot be deleted`, + userFriendlyMessage: msg`Label identifier view field cannot be deleted`, + }); + + validationErrors.viewField.push(failedValidation); + } + } + + return validationErrors; +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts index 01b97a4a997..579cabc4cae 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts @@ -131,6 +131,10 @@ export class WorkspaceMigrationBuildOrchestratorService { dependencyAllFlatEntityMaps, }); + const preDeletionFlatViewFieldMaps = structuredClone( + optimisticAllFlatEntityMaps.flatViewFieldMaps, + ); + const { flatObjectMetadataMaps, flatViewFieldMaps, @@ -818,12 +822,14 @@ export class WorkspaceMigrationBuildOrchestratorService { } } - const { objectMetadata } = crossEntityTransversalValidation({ + const { objectMetadata, viewField } = crossEntityTransversalValidation({ optimisticUniversalFlatMaps: optimisticAllFlatEntityMaps, orchestratorActionsReport, + preDeletionFlatViewFieldMaps, }); orchestratorFailureReport.objectMetadata.push(...objectMetadata); + orchestratorFailureReport.viewField.push(...viewField); const allErrors = Object.values(orchestratorFailureReport); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/cross-entity-transversal-validation.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/cross-entity-transversal-validation.util.ts index 3899200f233..37b7d32015e 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/cross-entity-transversal-validation.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/cross-entity-transversal-validation.util.ts @@ -1,21 +1,32 @@ import { validateObjectMetadataCrossEntity } from 'src/engine/metadata-modules/flat-object-metadata/validators/utils/validate-object-metadata-cross-entity.util'; +import { validateViewFieldLabelIdentifierCrossEntity } from 'src/engine/metadata-modules/flat-view-field/validators/utils/validate-view-field-label-identifier-cross-entity.util'; import { type OrchestratorActionsReport, type OrchestratorFailureReport, } from 'src/engine/workspace-manager/workspace-migration/types/workspace-migration-orchestrator.type'; import { type AllUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/all-universal-flat-entity-maps.type'; +import { type UniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-maps.type'; +import { type UniversalFlatViewField } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field.type'; export const crossEntityTransversalValidation = ({ optimisticUniversalFlatMaps, orchestratorActionsReport, + preDeletionFlatViewFieldMaps, }: { optimisticUniversalFlatMaps: AllUniversalFlatEntityMaps; orchestratorActionsReport: OrchestratorActionsReport; -}): Pick => { + preDeletionFlatViewFieldMaps: UniversalFlatEntityMaps; +}): Pick => { const { objectMetadata } = validateObjectMetadataCrossEntity({ optimisticUniversalFlatMaps, orchestratorActionsReport, }); - return { objectMetadata }; + const { viewField } = validateViewFieldLabelIdentifierCrossEntity({ + optimisticUniversalFlatMaps, + deletedViewFieldActions: orchestratorActionsReport.viewField.delete, + preDeletionFlatViewFieldMaps, + }); + + return { objectMetadata, viewField }; }; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-field-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-field-validator.service.ts index 34b1a21807b..02e9a0ca27d 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-field-validator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-field-validator.service.ts @@ -127,9 +127,6 @@ export class FlatViewFieldValidatorService { flatEntityToValidate: { universalIdentifier }, optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { flatViewFieldMaps: optimisticFlatViewFieldMaps, - flatFieldMetadataMaps, - flatObjectMetadataMaps, - flatViewMaps, }, }: UniversalFlatEntityValidationArgs< typeof ALL_METADATA_NAME.viewField @@ -157,43 +154,6 @@ export class FlatViewFieldValidatorService { return validationResult; } - const flatFieldMetadata = findFlatEntityByUniversalIdentifier({ - universalIdentifier: - existingFlatViewField.fieldMetadataUniversalIdentifier, - flatEntityMaps: flatFieldMetadataMaps, - }); - - if (!isDefined(flatFieldMetadata)) { - return validationResult; - } - - const flatObjectMetadata = findFlatEntityByUniversalIdentifier({ - universalIdentifier: flatFieldMetadata.objectMetadataUniversalIdentifier, - flatEntityMaps: flatObjectMetadataMaps, - }); - - if (!isDefined(flatObjectMetadata)) { - return validationResult; - } - - if ( - flatObjectMetadata.labelIdentifierFieldMetadataUniversalIdentifier === - existingFlatViewField.fieldMetadataUniversalIdentifier - ) { - const flatView = findFlatEntityByUniversalIdentifier({ - universalIdentifier: existingFlatViewField.viewUniversalIdentifier, - flatEntityMaps: flatViewMaps, - }); - - if (!isDefined(flatView) || flatView.type !== ViewType.FIELDS_WIDGET) { - validationResult.errors.push({ - code: ViewExceptionCode.INVALID_VIEW_DATA, - message: t`Label identifier view field cannot be deleted`, - userFriendlyMessage: msg`Label identifier view field cannot be deleted`, - }); - } - } - return validationResult; } diff --git a/packages/twenty-server/test/integration/graphql/suites/view/view-field/__snapshots__/object-identifier-update-side-effect-on-view-field.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/view/view-field/__snapshots__/object-identifier-update-side-effect-on-view-field.integration-spec.ts.snap index 18dc91499f3..8c13edaf4af 100644 --- a/packages/twenty-server/test/integration/graphql/suites/view/view-field/__snapshots__/object-identifier-update-side-effect-on-view-field.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/graphql/suites/view/view-field/__snapshots__/object-identifier-update-side-effect-on-view-field.integration-spec.ts.snap @@ -89,10 +89,11 @@ exports[`View Field Resolver - Successful object metadata identifier update side }, ], "flatEntityMinimalInformation": { + "fieldMetadataUniversalIdentifier": Any, "universalIdentifier": Any, + "viewUniversalIdentifier": Any, }, "metadataName": "viewField", - "status": "fail", "type": "delete", }, ],