From 45473218d36c5c29a3586e21b92e3f2d5b377e30 Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Tue, 21 Oct 2025 16:12:03 +0200 Subject: [PATCH] Field deactivation side effect `views` `calendar` `kanban` `viewFields` (#15180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Introduction Handling both: - field deactivation side effect on view fields, view filters and views - field deactivation side effect on view that targets it as `kanbanAggregateFieldMetadataId` - field deactivation side effect on view that targets it as `calendarFieldMetadataId` ## Coverage added coverage ```ts PASS test/integration/metadata/suites/field-metadata/kanban-aggregate-field-deactivation-deletes-views.integration-spec.ts (13.132 s) kanban-aggregate-field-deactivation-nullifies-kanban-properties ✓ should nullify kanban properties when field used as kanbanAggregateOperationFieldMetadataId is deactivated (3923 ms) ✓ should not modify views when field not used as kanbanAggregateOperationFieldMetadataId is deactivated (2958 ms) ✓ should nullify kanban properties on multiple views when they all use the same field as kanbanAggregateOperationFieldMetadataId (2542 ms) ✓ should nullify kanban properties when views have different aggregate operations on same field (3380 ms) Test Suites: 1 passed, 1 total Tests: 4 passed, 4 total Snapshots: 0 total Time: 13.154 s ``` ```ts PASS test/integration/metadata/suites/field-metadata/view-group-field-deactivation-deletes-views.integration-spec.ts (12.639 s) view-group-field-deactivation-deletes-views ✓ should delete view when field used in view group is deactivated (3469 ms) ✓ should not delete view when field not used in view group is deactivated (3109 ms) ✓ should delete multiple views when they all use the same field in view groups (2741 ms) ✓ should handle deactivation when view has multiple view groups with different fields (3008 ms) Test Suites: 1 passed, 1 total Tests: 4 passed, 4 total Snapshots: 0 total Time: 12.664 s ``` ```ts PASS test/integration/metadata/suites/field-metadata/calendar-field-deactivation-deletes-views.integration-spec.ts (14.579 s) calendar-field-deactivation-deletes-views ✓ should delete view when field used as calendarFieldMetadataId is deactivated (3388 ms) ✓ should not delete view when field not used as calendarFieldMetadataId is deactivated (2438 ms) ✓ should delete multiple views when they all use the same field as calendarFieldMetadataId (2635 ms) ✓ should handle deactivation when views have different calendar layouts on same field (3195 ms) ✓ should delete calendar view but not other view types when calendar field is deactivated (2682 ms) Test Suites: 1 passed, 1 total Tests: 5 passed, 5 total Snapshots: 0 total Time: 14.601 s, estimated 15 s ``` ## View soft deletion We decided to remove the soft deletion grain on all the views, in this PR context we've only removed soft deleted validation requirement on any view entities ## Conclusion close https://github.com/twentyhq/core-team-issues/issues/1754 --- ...67836-kanbanFieldMetadataIdentifierView.ts | 19 + ...052489394-calendarFieldMetadataRelation.ts | 19 + .../field-metadata/field-metadata.entity.ts | 10 + .../services/field-metadata.service-v2.ts | 20 + .../services/field-metadata.service.ts | 15 - ...metadata-many-to-one-relations.constant.ts | 50 +-- ...quired-metadata-for-validation.constant.ts | 1 + ...many-to-one-related-metadata-names.type.ts | 4 +- ...y-and-related-entity-maps-or-throw.util.ts | 5 +- .../__mocks__/get-flat-field-metadata.mock.ts | 2 + ...elation-target-flat-field-metadata-mock.ts | 2 + ...lat-field-metadatas-to-create.spec.ts.snap | 8 + ...e-flat-field-metadata-map-cache.service.ts | 14 +- .../types/flat-field-metadata.type.ts | 4 + ...data-entity-to-flat-field-metadata.util.ts | 3 + ...field-input-to-flat-field-metadata.util.ts | 34 +- ...d-metadata-from-create-field-input.util.ts | 2 + ...field-metadata-update-side-effects.util.ts | 10 +- ...metadata-deactivation-side-effects.util.ts | 90 ++++ ...-field-metadata-update-side-effect.util.ts | 110 ++++- .../flat-view/types/flat-view.type.ts | 2 + ...-field-metadatas-for-custom-object.util.ts | 16 + ...-field-metadatas-for-custom-object.util.ts | 4 + .../services/view-field-v2.service.ts | 22 +- .../view/entities/view.entity.ts | 23 + .../view/services/view-v2.service.ts | 52 ++- ...ce-migration-build-orchestrator.service.ts | 2 + .../find-flat-entity-property-update.util.ts | 23 + .../is-property-update.util.ts} | 0 ...gration-v2-view-actions-builder.service.ts | 97 +++++ .../flat-field-metadata-validator.service.ts | 17 + .../flat-view-field-validator.service.ts | 43 +- .../flat-view-filter-validator.service.ts | 25 -- .../flat-view-group-validator.service.ts | 22 - .../services/flat-view-validator.service.ts | 74 +++- .../update-field-action-handler.service.ts | 2 +- .../update-object-action-handler.service.ts | 9 +- .../get-field-metadata-entity.mock.ts | 2 + ...ect-on-view-field.integration-spec.ts.snap | 4 +- ...tivation-deletes-views.integration-spec.ts | 297 +++++++++++++ ...ete-one-field-metadata.integration-spec.ts | 2 +- ...iews-kanban-properties.integration-spec.ts | 399 ++++++++++++++++++ ...tivation-deletes-views.integration-spec.ts | 307 ++++++++++++++ ....ts => ExtractPropertiesThatEndsWithId.ts} | 0 packages/twenty-shared/src/types/index.ts | 2 +- 45 files changed, 1674 insertions(+), 194 deletions(-) create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1760965667836-kanbanFieldMetadataIdentifierView.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1761052489394-calendarFieldMetadataRelation.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-field-metadata-deactivation-side-effects.util.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/utils/find-flat-entity-property-update.util.ts rename packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/{types/is-property-update.type.ts => utils/is-property-update.util.ts} (100%) create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-metadata/calendar-field-deactivation-deletes-views.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-metadata/kanban-aggregate-field-deactivation-nullifies-views-kanban-properties.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-metadata/view-group-field-deactivation-deletes-views.integration-spec.ts rename packages/twenty-shared/src/types/{extract-properties-that-ends-with-id.ts => ExtractPropertiesThatEndsWithId.ts} (100%) diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1760965667836-kanbanFieldMetadataIdentifierView.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1760965667836-kanbanFieldMetadataIdentifierView.ts new file mode 100644 index 0000000000..c13d555a48 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1760965667836-kanbanFieldMetadataIdentifierView.ts @@ -0,0 +1,19 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class KanbanFieldMetadataIdentifierView1760965667836 + implements MigrationInterface +{ + name = 'KanbanFieldMetadataIdentifierView1760965667836'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."view" ADD CONSTRAINT "FK_b3cc95732479f7a1337350c398f" FOREIGN KEY ("kanbanAggregateOperationFieldMetadataId") REFERENCES "core"."fieldMetadata"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."view" DROP CONSTRAINT "FK_b3cc95732479f7a1337350c398f"`, + ); + } +} diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1761052489394-calendarFieldMetadataRelation.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1761052489394-calendarFieldMetadataRelation.ts new file mode 100644 index 0000000000..17c648baba --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1761052489394-calendarFieldMetadataRelation.ts @@ -0,0 +1,19 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class CalendarFieldMetadataRelation1761052489394 + implements MigrationInterface +{ + name = 'CalendarFieldMetadataRelation1761052489394'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."view" ADD CONSTRAINT "FK_5c0d21d6b8d5544a24ab9787114" FOREIGN KEY ("calendarFieldMetadataId") REFERENCES "core"."fieldMetadata"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."view" DROP CONSTRAINT "FK_5c0d21d6b8d5544a24ab9787114"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts index eff155956a..d7e2cf9478 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts @@ -29,6 +29,7 @@ import { FieldPermissionEntity } from 'src/engine/metadata-modules/object-permis import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity'; import { ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entities/view-filter.entity'; import { ViewGroupEntity } from 'src/engine/metadata-modules/view-group/entities/view-group.entity'; +import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity'; @Entity('fieldMetadata') @Check( @@ -200,4 +201,13 @@ export class FieldMetadataEntity< @OneToMany(() => ViewGroupEntity, (viewGroup) => viewGroup.fieldMetadata) viewGroups: Relation; + + @OneToMany( + () => ViewEntity, + (view) => view.kanbanAggregateOperationFieldMetadata, + ) + kanbanAggregateOperationViews: Relation; + + @OneToMany(() => ViewEntity, (view) => view.calendarFieldMetadata) + calendarViews: Relation; } diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service-v2.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service-v2.ts index fdaa187af3..f71e819abf 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service-v2.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service-v2.ts @@ -144,6 +144,7 @@ export class FieldMetadataServiceV2 { flatViewFilterMaps: existingFlatViewFilterMaps, flatViewGroupMaps: existingFlatViewGroupMaps, flatViewMaps: existingFlatViewMaps, + flatViewFieldMaps: existingFlatViewFieldMaps, } = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( { workspaceId, @@ -154,6 +155,7 @@ export class FieldMetadataServiceV2 { 'flatViewFilterMaps', 'flatViewGroupMaps', 'flatViewMaps', + 'flatViewFieldMaps', ], }, ); @@ -165,6 +167,8 @@ export class FieldMetadataServiceV2 { updateFieldInput, flatViewFilterMaps: existingFlatViewFilterMaps, flatViewGroupMaps: existingFlatViewGroupMaps, + flatViewMaps: existingFlatViewMaps, + flatViewFieldMaps: existingFlatViewFieldMaps, }); if (inputTranspilationResult.status === 'fail') { @@ -181,6 +185,9 @@ export class FieldMetadataServiceV2 { flatViewGroupsToUpdate, flatViewFiltersToDelete, flatViewFiltersToUpdate, + flatViewFieldsToDelete, + flatViewsToUpdate, + flatViewsToDelete, } = inputTranspilationResult.result; const validateAndBuildResult = @@ -215,6 +222,18 @@ export class FieldMetadataServiceV2 { flatEntityToDelete: flatViewGroupsToDelete, flatEntityToUpdate: flatViewGroupsToUpdate, }), + flatViewMaps: computeFlatEntityMapsFromTo({ + flatEntityMaps: existingFlatViewMaps, + flatEntityToCreate: [], + flatEntityToDelete: flatViewsToDelete, + flatEntityToUpdate: flatViewsToUpdate, + }), + flatViewFieldMaps: computeFlatEntityMapsFromTo({ + flatEntityMaps: existingFlatViewFieldMaps, + flatEntityToCreate: [], + flatEntityToDelete: flatViewFieldsToDelete, + flatEntityToUpdate: [], + }), }, buildOptions: { isSystemBuild: false, @@ -222,6 +241,7 @@ export class FieldMetadataServiceV2 { index: true, viewGroup: true, viewFilter: true, + view: true, }, }, workspaceId, diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts index cb93c7398d..d011ca1bac 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts @@ -365,21 +365,6 @@ export class FieldMetadataService extends TypeOrmQueryService = T extends `${infer Name}Id` - ? Name - : never; - type ExtractEntityRelations> = { - [K in ExtractPropertiesThatEndsWithId< - TEntity, - 'id' | 'workspaceId' - > as PropertyNameToRelationName]: K; + [K in ExtractPropertiesThatEndsWithId]: K; }; -type MetadataRelatedMetadataNames = Extract< - keyof ExtractEntityRelations>, - AllMetadataName ->; +type MetadataRelatedMetadataNames = + keyof ExtractEntityRelations>; type MetadataNameAndRelations = { [T in AllMetadataName]: MetadataRelatedMetadataNames extends never ? Record - : Record, string> & { - [K in Exclude< - AllMetadataName, - MetadataRelatedMetadataNames - >]?: string; + : { + [P in MetadataRelatedMetadataNames]?: AllMetadataName; }; }; -export const ALL_METADATA_NAME_MANY_TO_ONE_RELATIONS = { +export const ALL_METADATA_RELATED_METADATA_BY_FOREIGN_KEY = { fieldMetadata: { - objectMetadata: 'objectMetadataId', + objectMetadataId: 'objectMetadata', }, objectMetadata: {}, view: { - objectMetadata: 'objectMetadataId', + kanbanAggregateOperationFieldMetadataId: 'fieldMetadata', + calendarFieldMetadataId: 'fieldMetadata', + objectMetadataId: 'objectMetadata', }, viewField: { - view: 'viewId', - fieldMetadata: 'fieldMetadataId', + viewId: 'view', + fieldMetadataId: 'fieldMetadata', }, viewGroup: { - view: 'viewId', - fieldMetadata: 'fieldMetadataId', + viewId: 'view', + fieldMetadataId: 'fieldMetadata', }, index: { - objectMetadata: 'objectMetadataId', + objectMetadataId: 'objectMetadata', }, serverlessFunction: {}, cronTrigger: { - serverlessFunction: 'serverlessFunctionId', + serverlessFunctionId: 'serverlessFunction', }, databaseEventTrigger: { - serverlessFunction: 'serverlessFunctionId', + serverlessFunctionId: 'serverlessFunction', }, routeTrigger: { - serverlessFunction: 'serverlessFunctionId', + serverlessFunctionId: 'serverlessFunction', }, viewFilter: { - view: 'viewId', - fieldMetadata: 'fieldMetadataId', + viewId: 'view', + fieldMetadataId: 'fieldMetadata', }, } as const satisfies MetadataNameAndRelations; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts index 7fe2d91e48..5193daa8df 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts @@ -18,6 +18,7 @@ export const ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION = { fieldMetadata: true, }, view: { + fieldMetadata: true, objectMetadata: true, }, viewField: { diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/metadata-many-to-one-related-metadata-names.type.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/metadata-many-to-one-related-metadata-names.type.ts index b642db503a..8c504d8800 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/metadata-many-to-one-related-metadata-names.type.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/metadata-many-to-one-related-metadata-names.type.ts @@ -1,8 +1,8 @@ -import { type ALL_METADATA_NAME_MANY_TO_ONE_RELATIONS } from 'src/engine/metadata-modules/flat-entity/constant/all-metadata-many-to-one-relations.constant'; +import { type ALL_METADATA_RELATED_METADATA_BY_FOREIGN_KEY } from 'src/engine/metadata-modules/flat-entity/constant/all-metadata-many-to-one-relations.constant'; import { type AllMetadataName } from 'src/engine/metadata-modules/flat-entity/types/all-metadata-name.type'; export type MetadataManyToOneRelatedMetadataNames = Extract< - keyof (typeof ALL_METADATA_NAME_MANY_TO_ONE_RELATIONS)[T], + (typeof ALL_METADATA_RELATED_METADATA_BY_FOREIGN_KEY)[T][keyof (typeof ALL_METADATA_RELATED_METADATA_BY_FOREIGN_KEY)[T]], AllMetadataName >; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/add-flat-entity-to-flat-entity-and-related-entity-maps-or-throw.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/add-flat-entity-to-flat-entity-and-related-entity-maps-or-throw.util.ts index 0c3350354c..0834d25211 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/add-flat-entity-to-flat-entity-and-related-entity-maps-or-throw.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/add-flat-entity-to-flat-entity-and-related-entity-maps-or-throw.util.ts @@ -1,4 +1,4 @@ -import { ALL_METADATA_NAME_MANY_TO_ONE_RELATIONS } from 'src/engine/metadata-modules/flat-entity/constant/all-metadata-many-to-one-relations.constant'; +import { ALL_METADATA_RELATED_METADATA_BY_FOREIGN_KEY } from 'src/engine/metadata-modules/flat-entity/constant/all-metadata-many-to-one-relations.constant'; import { FlatEntityMapsException, FlatEntityMapsExceptionCode, @@ -32,8 +32,9 @@ export const addFlatEntityToFlatEntityAndRelatedEntityMapsOrThrow = < flatEntityMaps: initialFlatEntityAndRelatedMaps[flatEntityMapsKey], }); + // TODO refactor const manyToOneRelatedMetadataName = Object.entries( - ALL_METADATA_NAME_MANY_TO_ONE_RELATIONS[metadataName], + ALL_METADATA_RELATED_METADATA_BY_FOREIGN_KEY[metadataName], ); return manyToOneRelatedMetadataName.reduce( diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock.ts index cf32f3bff8..8f858d13a1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock.ts @@ -19,8 +19,10 @@ export const getFlatFieldMetadataMock = ( const createdAt = '2024-01-01T00:00:00.000Z' as unknown as Date; return { + calendarViewIds: [], viewFilterIds: [], viewGroupIds: [], + kanbanAggregateOperationViewIds: [], viewFieldIds: [], createdAt, updatedAt: createdAt, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__mocks__/get-morph-or-relation-target-flat-field-metadata-mock.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__mocks__/get-morph-or-relation-target-flat-field-metadata-mock.ts index 0d6e89d595..738898487c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__mocks__/get-morph-or-relation-target-flat-field-metadata-mock.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__mocks__/get-morph-or-relation-target-flat-field-metadata-mock.ts @@ -32,9 +32,11 @@ export const getRelationTargetFlatFieldMetadataMock = ({ const createdAt = '2024-01-01T00:00:00.000Z' as unknown as Date; return { + calendarViewIds: [], viewFilterIds: [], viewGroupIds: [], viewFieldIds: [], + kanbanAggregateOperationViewIds: [], createdAt, updatedAt: createdAt, description: 'default flat field metadata description', diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__tests__/__snapshots__/morph-relation-from-create-field-input-to-flat-field-metadatas-to-create.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__tests__/__snapshots__/morph-relation-from-create-field-input-to-flat-field-metadatas-to-create.spec.ts.snap index 47b715525b..4edc1bd634 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__tests__/__snapshots__/morph-relation-from-create-field-input-to-flat-field-metadatas-to-create.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__tests__/__snapshots__/morph-relation-from-create-field-input-to-flat-field-metadatas-to-create.spec.ts.snap @@ -105,6 +105,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su "flatFieldMetadatas": [ { "applicationId": null, + "calendarViewIds": [], "createdAt": Any, "defaultValue": null, "description": "new field description", @@ -117,6 +118,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su "isSystem": false, "isUIReadOnly": false, "isUnique": null, + "kanbanAggregateOperationViewIds": [], "label": "newFieldLabel", "morphId": Any, "name": "newFieldPets", @@ -139,6 +141,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su }, { "applicationId": null, + "calendarViewIds": [], "createdAt": Any, "defaultValue": null, "description": null, @@ -151,6 +154,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su "isSystem": false, "isUIReadOnly": false, "isUnique": null, + "kanbanAggregateOperationViewIds": [], "label": "Pet", "morphId": null, "name": "pet", @@ -175,6 +179,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su }, { "applicationId": null, + "calendarViewIds": [], "createdAt": Any, "defaultValue": null, "description": "new field description", @@ -187,6 +192,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su "isSystem": false, "isUIReadOnly": false, "isUnique": null, + "kanbanAggregateOperationViewIds": [], "label": "newFieldLabel", "morphId": Any, "name": "newFieldCompanies", @@ -209,6 +215,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su }, { "applicationId": null, + "calendarViewIds": [], "createdAt": Any, "defaultValue": null, "description": null, @@ -221,6 +228,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su "isSystem": false, "isUIReadOnly": false, "isUnique": null, + "kanbanAggregateOperationViewIds": [], "label": "Company", "morphId": null, "name": "company", diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/services/workspace-flat-field-metadata-map-cache.service.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/services/workspace-flat-field-metadata-map-cache.service.ts index fca743b8e0..40c592fa37 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/services/workspace-flat-field-metadata-map-cache.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/services/workspace-flat-field-metadata-map-cache.service.ts @@ -48,9 +48,21 @@ export class WorkspaceFlatFieldMetadataMapCacheService extends WorkspaceFlatMapC viewGroups: { id: true, }, + kanbanAggregateOperationViews: { + id: true, + }, + calendarViews: { + id: true, + }, }, withDeleted: true, - relations: ['viewFields', 'viewFilters', 'viewGroups'], + relations: [ + 'viewFields', + 'viewFilters', + 'viewGroups', + 'kanbanAggregateOperationViews', + 'calendarViews', + ], }); return fieldMetadatas.reduce( diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type.ts index 47962b2d2f..57c8916be4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type.ts @@ -12,6 +12,8 @@ export const FIELD_METADATA_RELATION_PROPERTIES = [ 'application', 'viewFilters', 'viewGroups', + 'kanbanAggregateOperationViews', + 'calendarViews', ] as const satisfies (keyof FieldMetadataEntity)[]; export type FieldMetadataEntityRelationProperties = @@ -23,4 +25,6 @@ export type FlatFieldMetadata = viewFieldIds: string[]; viewFilterIds: string[]; viewGroupIds: string[]; + kanbanAggregateOperationViewIds: string[]; + calendarViewIds: string[]; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-field-metadata-entity-to-flat-field-metadata.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-field-metadata-entity-to-flat-field-metadata.util.ts index 0a66e6bd79..4e599b7660 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-field-metadata-entity-to-flat-field-metadata.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-field-metadata-entity-to-flat-field-metadata.util.ts @@ -20,6 +20,9 @@ export const fromFieldMetadataEntityToFlatFieldMetadata = < return { ...fieldMetadataWithoutRelations, + kanbanAggregateOperationViewIds: + fieldMetadataEntity.kanbanAggregateOperationViews.map(({ id }) => id), + calendarViewIds: fieldMetadataEntity.calendarViews.map(({ id }) => id), viewGroupIds: fieldMetadataEntity.viewGroups.map(({ id }) => id), viewFieldIds: fieldMetadataEntity.viewFields.map(({ id }) => id), viewFilterIds: fieldMetadataEntity.viewFilters.map(({ id }) => id), diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-update-field-input-to-flat-field-metadata.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-update-field-input-to-flat-field-metadata.util.ts index fdf2a833cb..330b63f2a1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-update-field-input-to-flat-field-metadata.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-update-field-input-to-flat-field-metadata.util.ts @@ -19,6 +19,7 @@ import { type FieldInputTranspilationResult } from 'src/engine/metadata-modules/ import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { computeFlatFieldMetadataRelatedFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/compute-flat-field-metadata-related-flat-field-metadata.util'; import { + FLAT_FIELD_METADATA_UPDATE_EMPTY_SIDE_EFFECTS, type FlatFieldMetadataUpdateSideEffects, handleFlatFieldMetadataUpdateSideEffect, } from 'src/engine/metadata-modules/flat-field-metadata/utils/handle-flat-field-metadata-update-side-effect.util'; @@ -34,6 +35,8 @@ type FromUpdateFieldInputToFlatFieldMetadataArgs = { | 'flatFieldMetadataMaps' | 'flatViewFilterMaps' | 'flatViewGroupMaps' + | 'flatViewMaps' + | 'flatViewFieldMaps' >; type FlatFieldMetadataAndIndexToUpdate = { @@ -46,6 +49,8 @@ export const fromUpdateFieldInputToFlatFieldMetadata = ({ updateFieldInput: rawUpdateFieldInput, flatViewFilterMaps, flatViewGroupMaps, + flatViewMaps, + flatViewFieldMaps, }: FromUpdateFieldInputToFlatFieldMetadataArgs): FieldInputTranspilationResult => { const updateFieldInputInformalProperties = extractAndSanitizeObjectStringFields(rawUpdateFieldInput, [ @@ -114,6 +119,8 @@ export const fromUpdateFieldInputToFlatFieldMetadata = ({ return { status: 'success', result: { + flatViewsToUpdate: [], + flatViewsToDelete: [], flatViewGroupsToCreate: [], flatViewGroupsToDelete: [], flatViewGroupsToUpdate: [], @@ -123,6 +130,7 @@ export const fromUpdateFieldInputToFlatFieldMetadata = ({ flatIndexMetadatasToCreate: [], flatViewFiltersToDelete: [], flatViewFiltersToUpdate: [], + flatViewFieldsToDelete: [], }, }; } @@ -152,15 +160,8 @@ export const fromUpdateFieldInputToFlatFieldMetadata = ({ ]; const initialAccumulator: FlatFieldMetadataAndIndexToUpdate = { + ...structuredClone(FLAT_FIELD_METADATA_UPDATE_EMPTY_SIDE_EFFECTS), flatFieldMetadatasToUpdate: [], - flatIndexMetadatasToUpdate: [], - flatViewFiltersToDelete: [], - flatViewFiltersToUpdate: [], - flatViewGroupsToCreate: [], - flatViewGroupsToDelete: [], - flatIndexMetadatasToCreate: [], - flatIndexMetadatasToDelete: [], - flatViewGroupsToUpdate: [], }; updatedEditableFieldProperties.options = !isDefined( @@ -190,6 +191,9 @@ export const fromUpdateFieldInputToFlatFieldMetadata = ({ flatViewFiltersToUpdate, flatIndexMetadatasToCreate, flatIndexMetadatasToDelete, + flatViewsToDelete, + flatViewFieldsToDelete, + flatViewsToUpdate, } = handleFlatFieldMetadataUpdateSideEffect({ flatViewFilterMaps, flatViewGroupMaps, @@ -198,6 +202,8 @@ export const fromUpdateFieldInputToFlatFieldMetadata = ({ flatFieldMetadataMaps, flatIndexMaps, toFlatFieldMetadata, + flatViewMaps, + flatViewFieldMaps, }); return { @@ -237,6 +243,18 @@ export const fromUpdateFieldInputToFlatFieldMetadata = ({ ...accumulator.flatIndexMetadatasToCreate, ...flatIndexMetadatasToCreate, ], + flatViewsToDelete: [ + ...accumulator.flatViewsToDelete, + ...flatViewsToDelete, + ], + flatViewFieldsToDelete: [ + ...accumulator.flatViewFieldsToDelete, + ...flatViewFieldsToDelete, + ], + flatViewsToUpdate: [ + ...accumulator.flatViewsToUpdate, + ...flatViewsToUpdate, + ], }; }, initialAccumulator, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util.ts index dde7f06855..50130dc0bb 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util.ts @@ -23,6 +23,7 @@ export const getDefaultFlatFieldMetadata = ({ const createdAt = new Date(); return { + calendarViewIds: [], viewFieldIds: [], description: createFieldInput.description ?? null, id: fieldMetadataId, @@ -57,5 +58,6 @@ export const getDefaultFlatFieldMetadata = ({ applicationId: null, viewFilterIds: [], viewGroupIds: [], + kanbanAggregateOperationViewIds: [], } as const satisfies FlatFieldMetadata; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-enum-flat-field-metadata-update-side-effects.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-enum-flat-field-metadata-update-side-effects.util.ts index f78412aa9d..0c5a43935f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-enum-flat-field-metadata-update-side-effects.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-enum-flat-field-metadata-update-side-effects.util.ts @@ -2,7 +2,6 @@ import { type EnumFieldMetadataType, type FromTo } from 'twenty-shared/types'; import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; -import { isEnumFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-enum-flat-field-metadata.util'; import { type FlatViewFiltersToDeleteAndUpdate, recomputeViewFiltersOnFlatFieldMetadataOptionsUpdate, @@ -15,7 +14,7 @@ import { import { type PropertyUpdate } from 'src/engine/workspace-manager/workspace-migration-v2/types/property-update.type'; type HandleEnumFlatFieldMetadataOptionsUpdateSideEffectsArgs = FromTo< - FlatFieldMetadata, + FlatFieldMetadata, 'flatFieldMetadata' > & Pick; @@ -42,13 +41,6 @@ export const handleEnumFlatFieldMetadataUpdateSideEffects = ({ EMPTY_ENUM_FIELD_METADATA_SIDE_EFFECT_RESULT, ); - if ( - !isEnumFlatFieldMetadata(toFlatFieldMetadata) || - !isEnumFlatFieldMetadata(fromFlatFieldMetadata) - ) { - return sideEffectResult; - } - if ( JSON.stringify(fromFlatFieldMetadata.options) !== JSON.stringify(toFlatFieldMetadata.options) diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-field-metadata-deactivation-side-effects.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-field-metadata-deactivation-side-effects.util.ts new file mode 100644 index 0000000000..0f3a320637 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-field-metadata-deactivation-side-effects.util.ts @@ -0,0 +1,90 @@ +import { type FromTo } from 'twenty-shared/types'; + +import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; +import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type'; +import { type FlatViewFilter } from 'src/engine/metadata-modules/flat-view-filter/types/flat-view-filter.type'; +import { reduceFlatViewGroupsByViewId } from 'src/engine/metadata-modules/flat-view-group/utils/reduce-flat-view-groups-by-view-id.util'; +import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type'; + +type HandleFlatFieldMetadataDeactivationSideEffectsArgs = FromTo< + FlatFieldMetadata, + 'flatFieldMetadata' +> & + Pick< + AllFlatEntityMaps, + | 'flatViewMaps' + | 'flatViewFilterMaps' + | 'flatViewFieldMaps' + | 'flatViewGroupMaps' + >; + +export type FieldMetadataDeactivationSideEffect = { + flatViewsToDelete: FlatView[]; + flatViewsToUpdate: FlatView[]; + flatViewFieldsToDelete: FlatViewField[]; + flatViewFiltersToDelete: FlatViewFilter[]; +}; + +export const handleFieldMetadataDeactivationSideEffects = ({ + flatViewMaps, + fromFlatFieldMetadata, + flatViewFieldMaps, + flatViewFilterMaps, + flatViewGroupMaps, +}: HandleFlatFieldMetadataDeactivationSideEffectsArgs): FieldMetadataDeactivationSideEffect => { + const flatViewFiltersToDelete = findManyFlatEntityByIdInFlatEntityMapsOrThrow( + { + flatEntityIds: fromFlatFieldMetadata.viewFilterIds, + flatEntityMaps: flatViewFilterMaps, + }, + ); + + const flatViewFieldsToDelete = findManyFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityIds: fromFlatFieldMetadata.viewFieldIds, + flatEntityMaps: flatViewFieldMaps, + }); + + const flatViewGroups = findManyFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityIds: fromFlatFieldMetadata.viewGroupIds, + flatEntityMaps: flatViewGroupMaps, + }); + + const { flatViewGroupRecordByViewId } = reduceFlatViewGroupsByViewId({ + flatViewGroups, + }); + + // Note: We assume a view only has view groups related to one field + const viewIdsToDelete = [ + ...new Set([ + ...Object.keys(flatViewGroupRecordByViewId), + ...fromFlatFieldMetadata.calendarViewIds, + ]), + ]; + + const flatViewsToDelete = findManyFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityIds: viewIdsToDelete, + flatEntityMaps: flatViewMaps, + }); + + const viewIdsToUpdate = + fromFlatFieldMetadata.kanbanAggregateOperationViewIds.filter( + (viewId) => !viewIdsToDelete.includes(viewId), + ); + const flatViewsToUpdate = findManyFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityIds: viewIdsToUpdate, + flatEntityMaps: flatViewMaps, + }).map((flatView) => ({ + ...flatView, + kanbanAggregateOperation: null, + kanbanAggregateOperationFieldMetadataId: null, + })); + + return { + flatViewsToUpdate, + flatViewsToDelete, + flatViewFieldsToDelete, + flatViewFiltersToDelete, + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-flat-field-metadata-update-side-effect.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-flat-field-metadata-update-side-effect.util.ts index 15e4b0b74f..8366bae4d2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-flat-field-metadata-update-side-effect.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/handle-flat-field-metadata-update-side-effect.util.ts @@ -3,17 +3,23 @@ import { type FromTo } from 'twenty-shared/types'; import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { handleEnumFlatFieldMetadataUpdateSideEffects } from 'src/engine/metadata-modules/flat-field-metadata/utils/handle-enum-flat-field-metadata-update-side-effects.util'; +import { + type FieldMetadataDeactivationSideEffect, + handleFieldMetadataDeactivationSideEffects, +} from 'src/engine/metadata-modules/flat-field-metadata/utils/handle-field-metadata-deactivation-side-effects.util'; import { type FieldMetadataUpdateIndexSideEffect, handleIndexChangesDuringFieldUpdate, } from 'src/engine/metadata-modules/flat-field-metadata/utils/handle-index-changes-during-field-update.util'; +import { isEnumFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-enum-flat-field-metadata.util'; import { type FlatViewFiltersToDeleteAndUpdate } from 'src/engine/metadata-modules/flat-field-metadata/utils/recompute-view-filters-on-flat-field-metadata-options-update.util'; import { type FlatViewGroupsToDeleteUpdateAndCreate } from 'src/engine/metadata-modules/flat-field-metadata/utils/recompute-view-groups-on-flat-field-metadata-options-update.util'; export type FlatFieldMetadataUpdateSideEffects = FlatViewFiltersToDeleteAndUpdate & FlatViewGroupsToDeleteUpdateAndCreate & - FieldMetadataUpdateIndexSideEffect; + FieldMetadataUpdateIndexSideEffect & + FieldMetadataDeactivationSideEffect; type HandleFlatFieldMetadataUpdateSideEffectArgs = FromTo< FlatFieldMetadata, @@ -26,8 +32,25 @@ type HandleFlatFieldMetadataUpdateSideEffectArgs = FromTo< | 'flatFieldMetadataMaps' | 'flatViewFilterMaps' | 'flatViewGroupMaps' + | 'flatViewMaps' + | 'flatViewFieldMaps' >; +export const FLAT_FIELD_METADATA_UPDATE_EMPTY_SIDE_EFFECTS: FlatFieldMetadataUpdateSideEffects = + { + flatIndexMetadatasToUpdate: [], + flatViewFiltersToDelete: [], + flatViewFiltersToUpdate: [], + flatViewGroupsToCreate: [], + flatViewGroupsToDelete: [], + flatIndexMetadatasToDelete: [], + flatIndexMetadatasToCreate: [], + flatViewGroupsToUpdate: [], + flatViewsToDelete: [], + flatViewFieldsToDelete: [], + flatViewsToUpdate: [], + }; + export const handleFlatFieldMetadataUpdateSideEffect = ({ fromFlatFieldMetadata, toFlatFieldMetadata, @@ -36,19 +59,59 @@ export const handleFlatFieldMetadataUpdateSideEffect = ({ flatFieldMetadataMaps, flatViewFilterMaps, flatViewGroupMaps, + flatViewMaps, + flatViewFieldMaps, }: HandleFlatFieldMetadataUpdateSideEffectArgs): FlatFieldMetadataUpdateSideEffects => { - const { - flatViewFiltersToDelete, - flatViewFiltersToUpdate, - flatViewGroupsToCreate, - flatViewGroupsToDelete, - flatViewGroupsToUpdate, - } = handleEnumFlatFieldMetadataUpdateSideEffects({ - flatViewFilterMaps, - flatViewGroupMaps, - fromFlatFieldMetadata, - toFlatFieldMetadata, - }); + const sideEffectResult = structuredClone( + FLAT_FIELD_METADATA_UPDATE_EMPTY_SIDE_EFFECTS, + ); + + const isDeactivation = + fromFlatFieldMetadata.isActive === true && + toFlatFieldMetadata.isActive === false; + + if (isDeactivation) { + const { + flatViewsToDelete, + flatViewFieldsToDelete, + flatViewFiltersToDelete, + flatViewsToUpdate, + } = handleFieldMetadataDeactivationSideEffects({ + flatViewMaps, + fromFlatFieldMetadata, + toFlatFieldMetadata, + flatViewFieldMaps, + flatViewFilterMaps, + flatViewGroupMaps, + }); + + sideEffectResult.flatViewsToUpdate.push(...flatViewsToUpdate); + sideEffectResult.flatViewsToDelete.push(...flatViewsToDelete); + sideEffectResult.flatViewFieldsToDelete.push(...flatViewFieldsToDelete); + sideEffectResult.flatViewFiltersToDelete.push(...flatViewFiltersToDelete); + } else if ( + isEnumFlatFieldMetadata(toFlatFieldMetadata) && + isEnumFlatFieldMetadata(fromFlatFieldMetadata) + ) { + const { + flatViewFiltersToDelete, + flatViewFiltersToUpdate, + flatViewGroupsToCreate, + flatViewGroupsToDelete, + flatViewGroupsToUpdate, + } = handleEnumFlatFieldMetadataUpdateSideEffects({ + flatViewFilterMaps, + flatViewGroupMaps, + fromFlatFieldMetadata, + toFlatFieldMetadata, + }); + + sideEffectResult.flatViewFiltersToUpdate.push(...flatViewFiltersToUpdate); + sideEffectResult.flatViewGroupsToCreate.push(...flatViewGroupsToCreate); + sideEffectResult.flatViewGroupsToDelete.push(...flatViewGroupsToDelete); + sideEffectResult.flatViewGroupsToUpdate.push(...flatViewGroupsToUpdate); + sideEffectResult.flatViewFiltersToDelete.push(...flatViewFiltersToDelete); + } const { flatIndexMetadatasToUpdate, @@ -62,14 +125,15 @@ export const handleFlatFieldMetadataUpdateSideEffect = ({ flatFieldMetadataMaps, }); - return { - flatIndexMetadatasToUpdate, - flatViewFiltersToDelete, - flatViewFiltersToUpdate, - flatViewGroupsToCreate, - flatViewGroupsToDelete, - flatIndexMetadatasToDelete, - flatIndexMetadatasToCreate, - flatViewGroupsToUpdate, - }; + sideEffectResult.flatIndexMetadatasToUpdate.push( + ...flatIndexMetadatasToUpdate, + ); + sideEffectResult.flatIndexMetadatasToCreate.push( + ...flatIndexMetadatasToCreate, + ); + sideEffectResult.flatIndexMetadatasToDelete.push( + ...flatIndexMetadatasToDelete, + ); + + return sideEffectResult; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-view/types/flat-view.type.ts b/packages/twenty-server/src/engine/metadata-modules/flat-view/types/flat-view.type.ts index 1d76324a1b..2af95d90ff 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-view/types/flat-view.type.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-view/types/flat-view.type.ts @@ -1,4 +1,5 @@ import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/types/flat-entity.type'; import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { type ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity'; @@ -19,6 +20,7 @@ export type ViewEntityRelationProperties = | ViewGroupEntity | ViewFilterGroupEntity | Workspace + | FieldMetadataEntity >; export type FlatView = FlatEntityFrom< diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-flat-field-metadatas-for-custom-object.util.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-flat-field-metadatas-for-custom-object.util.ts index d79e38db37..831cc31b23 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-flat-field-metadatas-for-custom-object.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-flat-field-metadatas-for-custom-object.util.ts @@ -29,6 +29,8 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({ id: v4(), viewFieldIds: [], viewGroupIds: [], + kanbanAggregateOperationViewIds: [], + calendarViewIds: [], isLabelSyncedWithName: false, isUnique: true, objectMetadataId, @@ -66,6 +68,8 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({ id: v4(), viewFieldIds: [], viewGroupIds: [], + kanbanAggregateOperationViewIds: [], + calendarViewIds: [], isLabelSyncedWithName: false, isUnique: false, objectMetadataId, @@ -103,6 +107,8 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({ id: v4(), viewFieldIds: [], viewGroupIds: [], + kanbanAggregateOperationViewIds: [], + calendarViewIds: [], isLabelSyncedWithName: false, isUnique: false, objectMetadataId, @@ -140,6 +146,8 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({ id: v4(), viewFieldIds: [], viewGroupIds: [], + kanbanAggregateOperationViewIds: [], + calendarViewIds: [], isLabelSyncedWithName: false, isUnique: false, objectMetadataId, @@ -177,6 +185,8 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({ id: v4(), viewFieldIds: [], viewGroupIds: [], + kanbanAggregateOperationViewIds: [], + calendarViewIds: [], isLabelSyncedWithName: false, isUnique: false, objectMetadataId, @@ -214,6 +224,8 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({ id: v4(), viewFieldIds: [], viewGroupIds: [], + kanbanAggregateOperationViewIds: [], + calendarViewIds: [], isLabelSyncedWithName: false, isUnique: false, objectMetadataId, @@ -250,6 +262,8 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({ id: v4(), viewFieldIds: [], viewGroupIds: [], + kanbanAggregateOperationViewIds: [], + calendarViewIds: [], isLabelSyncedWithName: false, isUnique: false, objectMetadataId, @@ -286,6 +300,8 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({ type: FieldMetadataType.TS_VECTOR, viewFieldIds: [], viewGroupIds: [], + kanbanAggregateOperationViewIds: [], + calendarViewIds: [], id: v4(), isLabelSyncedWithName: false, isUnique: false, diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-relation-flat-field-metadatas-for-custom-object.util.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-relation-flat-field-metadatas-for-custom-object.util.ts index 2d6f1eb51f..47af3acf7c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-relation-flat-field-metadatas-for-custom-object.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-relation-flat-field-metadatas-for-custom-object.util.ts @@ -63,6 +63,8 @@ const generateSourceFlatFieldMetadata = ({ } return { + calendarViewIds: [], + kanbanAggregateOperationViewIds: [], viewFilterIds: [], viewFieldIds: [], viewGroupIds: [], @@ -131,7 +133,9 @@ const generateTargetFlatFieldMetadata = ({ return { morphId: null, + calendarViewIds: [], viewFieldIds: [], + kanbanAggregateOperationViewIds: [], viewFilterIds: [], viewGroupIds: [], id: sourceFlatFieldMetadata.relationTargetFieldMetadataId, diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field/services/view-field-v2.service.ts b/packages/twenty-server/src/engine/metadata-modules/view-field/services/view-field-v2.service.ts index e3551def33..5596aa7019 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-field/services/view-field-v2.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-field/services/view-field-v2.service.ts @@ -250,13 +250,19 @@ export class ViewFieldV2Service { const { flatViewFieldMaps: existingFlatViewFieldMaps, flatViewMaps: existingFlatViewMaps, - } = - await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( - { - workspaceId, - flatMapsKeys: ['flatViewFieldMaps', 'flatViewMaps'], - }, - ); + flatFieldMetadataMaps, + flatObjectMetadataMaps, + } = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: [ + 'flatViewFieldMaps', + 'flatViewMaps', + 'flatFieldMetadataMaps', + 'flatObjectMetadataMaps', + ], + }, + ); const existingViewFieldToDelete = fromDestroyViewFieldInputToFlatViewFieldOrThrow({ @@ -277,6 +283,8 @@ export class ViewFieldV2Service { }, dependencyAllFlatEntityMaps: { flatViewMaps: existingFlatViewMaps, + flatFieldMetadataMaps, + flatObjectMetadataMaps, }, buildOptions: { isSystemBuild: false, diff --git a/packages/twenty-server/src/engine/metadata-modules/view/entities/view.entity.ts b/packages/twenty-server/src/engine/metadata-modules/view/entities/view.entity.ts index 6f165c3ee3..4aa01b253f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/entities/view.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/entities/view.entity.ts @@ -17,6 +17,7 @@ import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/inte import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity'; import { ViewFilterGroupEntity } from 'src/engine/metadata-modules/view-filter-group/entities/view-filter-group.entity'; @@ -101,6 +102,17 @@ export class ViewEntity extends SyncableEntity implements Required { @Column({ nullable: true, type: 'uuid' }) kanbanAggregateOperationFieldMetadataId: string | null; + @ManyToOne( + () => FieldMetadataEntity, + (FieldMetadataEntity) => FieldMetadataEntity.kanbanAggregateOperationViews, + { + onDelete: 'CASCADE', + nullable: true, + }, + ) + @JoinColumn({ name: 'kanbanAggregateOperationFieldMetadataId' }) + kanbanAggregateOperationFieldMetadata: Relation; + @Column({ type: 'enum', enum: Object.values(ViewCalendarLayout), @@ -112,6 +124,17 @@ export class ViewEntity extends SyncableEntity implements Required { @Column({ nullable: true, type: 'uuid' }) calendarFieldMetadataId: string | null; + @ManyToOne( + () => FieldMetadataEntity, + (fieldMetadata) => fieldMetadata.calendarViews, + { + onDelete: 'CASCADE', + nullable: true, + }, + ) + @JoinColumn({ name: 'calendarFieldMetadataId' }) + calendarFieldMetadata: Relation; + @Column({ nullable: false, type: 'uuid' }) workspaceId: string; diff --git a/packages/twenty-server/src/engine/metadata-modules/view/services/view-v2.service.ts b/packages/twenty-server/src/engine/metadata-modules/view/services/view-v2.service.ts index 0abaeef554..a6f15197c3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/services/view-v2.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/services/view-v2.service.ts @@ -31,13 +31,20 @@ export class ViewV2Service { createViewInput: CreateViewInput; workspaceId: string; }): Promise { - const { flatObjectMetadataMaps, flatViewMaps: existingFlatViewMaps } = - await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( - { - workspaceId, - flatMapsKeys: ['flatObjectMetadataMaps', 'flatViewMaps'], - }, - ); + const { + flatObjectMetadataMaps, + flatViewMaps: existingFlatViewMaps, + flatFieldMetadataMaps: existingFlatFieldMetadataMaps, + } = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: [ + 'flatObjectMetadataMaps', + 'flatViewMaps', + 'flatFieldMetadataMaps', + ], + }, + ); const flatViewFromCreateInput = fromCreateViewInputToFlatViewToCreate({ createViewInput, @@ -57,6 +64,7 @@ export class ViewV2Service { }, dependencyAllFlatEntityMaps: { flatObjectMetadataMaps: flatObjectMetadataMaps, + flatFieldMetadataMaps: existingFlatFieldMetadataMaps, }, buildOptions: { isSystemBuild: false, @@ -93,11 +101,14 @@ export class ViewV2Service { updateViewInput: UpdateViewInput; workspaceId: string; }): Promise { - const { flatViewMaps: existingFlatViewMaps } = + const { + flatViewMaps: existingFlatViewMaps, + flatFieldMetadataMaps: existingFlatFieldMetadataMaps, + } = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( { workspaceId, - flatMapsKeys: ['flatViewMaps'], + flatMapsKeys: ['flatViewMaps', 'flatFieldMetadataMaps'], }, ); @@ -118,6 +129,9 @@ export class ViewV2Service { flatEntityToUpdate: [flatViewFromUpdateInput], }), }, + dependencyAllFlatEntityMaps: { + flatFieldMetadataMaps: existingFlatFieldMetadataMaps, + }, buildOptions: { isSystemBuild: false, }, @@ -153,11 +167,14 @@ export class ViewV2Service { deleteViewInput: DeleteViewInput; workspaceId: string; }): Promise { - const { flatViewMaps: existingFlatViewMaps } = + const { + flatViewMaps: existingFlatViewMaps, + flatFieldMetadataMaps: existingFlatFieldMetadataMaps, + } = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( { workspaceId, - flatMapsKeys: ['flatViewMaps'], + flatMapsKeys: ['flatViewMaps', 'flatFieldMetadataMaps'], }, ); @@ -178,6 +195,9 @@ export class ViewV2Service { flatEntityToUpdate: [optimisticallyUpdatedFlatViewWithDeletedAt], }), }, + dependencyAllFlatEntityMaps: { + flatFieldMetadataMaps: existingFlatFieldMetadataMaps, + }, buildOptions: { isSystemBuild: false, }, @@ -213,11 +233,14 @@ export class ViewV2Service { destroyViewInput: DestroyViewInput; workspaceId: string; }): Promise { - const { flatViewMaps: existingFlatViewMaps } = + const { + flatViewMaps: existingFlatViewMaps, + flatFieldMetadataMaps: existingFlatFieldMetadataMaps, + } = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( { workspaceId, - flatMapsKeys: ['flatViewMaps'], + flatMapsKeys: ['flatViewMaps', 'flatFieldMetadataMaps'], }, ); @@ -243,6 +266,9 @@ export class ViewV2Service { view: true, }, }, + dependencyAllFlatEntityMaps: { + flatFieldMetadataMaps: existingFlatFieldMetadataMaps, + }, workspaceId, }, ); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-build-orchestrator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-build-orchestrator.service.ts index 9b7545678f..01abd98e7e 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-build-orchestrator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-build-orchestrator.service.ts @@ -221,6 +221,8 @@ export class WorkspaceMigrationBuildOrchestratorService { dependencyOptimisticFlatEntityMaps: { flatObjectMetadataMaps: optimisticAllFlatEntityMaps.flatObjectMetadataMaps, + flatFieldMetadataMaps: + optimisticAllFlatEntityMaps.flatFieldMetadataMaps, }, from: fromFlatViewMaps, to: toFlatViewMaps, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/utils/find-flat-entity-property-update.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/utils/find-flat-entity-property-update.util.ts new file mode 100644 index 0000000000..e08c0e01fb --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/utils/find-flat-entity-property-update.util.ts @@ -0,0 +1,23 @@ +import { type AllMetadataName } from 'src/engine/metadata-modules/flat-entity/types/all-metadata-name.type'; +import { type FlatEntityPropertiesToCompare } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-properties-to-compare.type'; +import { type FlatEntityPropertiesUpdates } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-properties-updates.type'; +import { type FlatEntityPropertyUpdate } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-property-update.type'; +import { type MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity.type'; +import { isPropertyUpdate } from 'src/engine/workspace-manager/workspace-migration-v2/utils/is-property-update.util'; + +export const findFlatEntityPropertyUpdate = < + T extends AllMetadataName, + P extends Extract< + FlatEntityPropertiesToCompare, + keyof MetadataFlatEntity + >, +>({ + property, + flatEntityUpdates, +}: { + flatEntityUpdates: FlatEntityPropertiesUpdates; + property: P; +}) => + flatEntityUpdates.find((update): update is FlatEntityPropertyUpdate => + isPropertyUpdate(update, property), + ); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/types/is-property-update.type.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/utils/is-property-update.util.ts similarity index 100% rename from packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/types/is-property-update.type.ts rename to packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/utils/is-property-update.util.ts diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/view/workspace-migration-v2-view-actions-builder.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/view/workspace-migration-v2-view-actions-builder.service.ts index ab5c0f2205..e4bb79c920 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/view/workspace-migration-v2-view-actions-builder.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/view/workspace-migration-v2-view-actions-builder.service.ts @@ -57,6 +57,53 @@ export class WorkspaceMigrationV2ViewActionsBuilderService extends WorkspaceEnti dependencyOptimisticFlatEntityMaps.flatObjectMetadataMaps, }); + const kanbanFieldMetadata = isDefined( + flatViewToValidate.kanbanAggregateOperationFieldMetadataId, + ) + ? findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: + flatViewToValidate.kanbanAggregateOperationFieldMetadataId, + flatEntityMaps: + dependencyOptimisticFlatEntityMaps.flatFieldMetadataMaps, + }) + : undefined; + const updatedFlatFieldMetadataMapsWithKanban = isDefined( + kanbanFieldMetadata, + ) + ? replaceFlatEntityInFlatEntityMapsOrThrow({ + flatEntity: { + ...kanbanFieldMetadata, + kanbanAggregateOperationViewIds: [ + ...kanbanFieldMetadata.kanbanAggregateOperationViewIds, + flatViewToValidate.id, + ], + }, + flatEntityMaps: + dependencyOptimisticFlatEntityMaps.flatFieldMetadataMaps, + }) + : dependencyOptimisticFlatEntityMaps.flatFieldMetadataMaps; + + const calendarFieldMetadata = isDefined( + flatViewToValidate.calendarFieldMetadataId, + ) + ? findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: flatViewToValidate.calendarFieldMetadataId, + flatEntityMaps: updatedFlatFieldMetadataMapsWithKanban, + }) + : undefined; + const updatedFlatFieldMetadataMaps = isDefined(calendarFieldMetadata) + ? replaceFlatEntityInFlatEntityMapsOrThrow({ + flatEntity: { + ...calendarFieldMetadata, + calendarViewIds: [ + ...calendarFieldMetadata.calendarViewIds, + flatViewToValidate.id, + ], + }, + flatEntityMaps: updatedFlatFieldMetadataMapsWithKanban, + }) + : updatedFlatFieldMetadataMapsWithKanban; + return { status: 'success', action: { @@ -64,6 +111,7 @@ export class WorkspaceMigrationV2ViewActionsBuilderService extends WorkspaceEnti view: flatViewToValidate, }, dependencyOptimisticFlatEntityMaps: { + flatFieldMetadataMaps: updatedFlatFieldMetadataMaps, flatObjectMetadataMaps: updatedFlatObjectMetadataMaps, }, }; @@ -107,6 +155,54 @@ export class WorkspaceMigrationV2ViewActionsBuilderService extends WorkspaceEnti }) : dependencyOptimisticFlatEntityMaps.flatObjectMetadataMaps; + const kanbanFieldMetadata = isDefined( + flatViewToValidate.kanbanAggregateOperationFieldMetadataId, + ) + ? findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: + flatViewToValidate.kanbanAggregateOperationFieldMetadataId, + flatEntityMaps: + dependencyOptimisticFlatEntityMaps.flatFieldMetadataMaps, + }) + : undefined; + + const updatedFlatFieldMetadataMapsWithKanban = isDefined( + kanbanFieldMetadata, + ) + ? replaceFlatEntityInFlatEntityMapsOrThrow({ + flatEntity: { + ...kanbanFieldMetadata, + kanbanAggregateOperationViewIds: + kanbanFieldMetadata.kanbanAggregateOperationViewIds.filter( + (id) => id !== flatViewToValidate.id, + ), + }, + flatEntityMaps: + dependencyOptimisticFlatEntityMaps.flatFieldMetadataMaps, + }) + : dependencyOptimisticFlatEntityMaps.flatFieldMetadataMaps; + + const calendarFieldMetadata = isDefined( + flatViewToValidate.calendarFieldMetadataId, + ) + ? findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: flatViewToValidate.calendarFieldMetadataId, + flatEntityMaps: updatedFlatFieldMetadataMapsWithKanban, + }) + : undefined; + + const updatedFlatFieldMetadataMaps = isDefined(calendarFieldMetadata) + ? replaceFlatEntityInFlatEntityMapsOrThrow({ + flatEntity: { + ...calendarFieldMetadata, + calendarViewIds: calendarFieldMetadata.calendarViewIds.filter( + (id) => id !== flatViewToValidate.id, + ), + }, + flatEntityMaps: updatedFlatFieldMetadataMapsWithKanban, + }) + : updatedFlatFieldMetadataMapsWithKanban; + return { status: 'success', action: { @@ -114,6 +210,7 @@ export class WorkspaceMigrationV2ViewActionsBuilderService extends WorkspaceEnti viewId: flatViewToValidate.id, }, dependencyOptimisticFlatEntityMaps: { + flatFieldMetadataMaps: updatedFlatFieldMetadataMaps, flatObjectMetadataMaps: updatedFlatObjectMetadataMaps, }, }; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-field-metadata-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-field-metadata-validator.service.ts index 6313e997f4..64a21f731e 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-field-metadata-validator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-field-metadata-validator.service.ts @@ -15,6 +15,7 @@ import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/ import { validateFlatFieldMetadataNameAvailability } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-flat-field-metadata-name-availability.util'; import { validateFlatFieldMetadataName } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-flat-field-metadata-name.util'; import { isStandardMetadata } from 'src/engine/metadata-modules/utils/is-standard-metadata.util'; +import { findFlatEntityPropertyUpdate } from 'src/engine/workspace-manager/workspace-migration-v2/utils/find-flat-entity-property-update.util'; import { FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/types/failed-flat-entity-validation.type'; 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'; @@ -89,6 +90,22 @@ export class FlatFieldMetadataValidatorService { message: 'Label identifier field metadata id does not exist', userFriendlyMessage: msg`Object related to updated field does not have a label identifier`, }); + } else if ( + flatObjectMetadata.labelIdentifierFieldMetadataId === + flatFieldMetadataToValidate.id && + isDefined( + findFlatEntityPropertyUpdate({ + flatEntityUpdates: updates, + property: 'isActive', + }), + ) && + flatFieldMetadataToValidate.isActive === false + ) { + validationResult.errors.push({ + code: FieldMetadataExceptionCode.LABEL_IDENTIFIER_FIELD_METADATA_ID_NOT_FOUND, + message: 'Label identifier field metadata cannot be deactivated', + userFriendlyMessage: msg`Label identifier field cannot be deactivated`, + }); } if (isMorphOrRelationFlatFieldMetadata(flatFieldMetadataToValidate)) { diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-view-field-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-view-field-validator.service.ts index 2d520c8470..88f2c18b26 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-view-field-validator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-view-field-validator.service.ts @@ -118,6 +118,10 @@ export class FlatViewFieldValidatorService { public validateFlatViewFieldDeletion({ flatEntityToValidate: { id: viewFieldIdToDelete }, optimisticFlatEntityMaps: optimisticFlatViewFieldMaps, + dependencyOptimisticFlatEntityMaps: { + flatFieldMetadataMaps, + flatObjectMetadataMaps, + }, }: FlatEntityValidationArgs< typeof ALL_METADATA_NAME.viewField >): FailedFlatEntityValidation { @@ -138,14 +142,37 @@ export class FlatViewFieldValidatorService { message: t`View field to delete not found`, userFriendlyMessage: msg`View field to delete not found`, }); - } else { - if (!isDefined(existingFlatViewField.deletedAt)) { - validationResult.errors.push({ - code: ViewExceptionCode.INVALID_VIEW_DATA, - message: t`View field to delete has not been soft deleted`, - userFriendlyMessage: msg`View field to delete has not been soft deleted`, - }); - } + + return validationResult; + } + + const flatFieldMetadata = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: existingFlatViewField.fieldMetadataId, + flatEntityMaps: flatFieldMetadataMaps, + }); + + if (!isDefined(flatFieldMetadata)) { + return validationResult; + } + + const flatObjectMetadata = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: flatFieldMetadata.objectMetadataId, + flatEntityMaps: flatObjectMetadataMaps, + }); + + if (!isDefined(flatObjectMetadata)) { + return validationResult; + } + + if ( + flatObjectMetadata.labelIdentifierFieldMetadataId === + existingFlatViewField.fieldMetadataId + ) { + 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/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-view-filter-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-view-filter-validator.service.ts index bc7ef8fad9..2783a2cdea 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-view-filter-validator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-view-filter-validator.service.ts @@ -5,7 +5,6 @@ import { isDefined } from 'twenty-shared/utils'; import { ALL_METADATA_NAME } from 'src/engine/metadata-modules/flat-entity/constant/all-metadata-name.constant'; import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; -import { isEnumFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-enum-flat-field-metadata.util'; import { type FlatViewFilter } from 'src/engine/metadata-modules/flat-view-filter/types/flat-view-filter.type'; import { ViewFilterExceptionCode } from 'src/engine/metadata-modules/view-filter/exceptions/view-filter.exception'; import { FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/types/failed-flat-entity-validation.type'; @@ -77,7 +76,6 @@ export class FlatViewFilterValidatorService { validateFlatViewFilterDeletion({ flatEntityToValidate: flatViewFilterToValidate, optimisticFlatEntityMaps: optimisticFlatViewFilterMaps, - dependencyOptimisticFlatEntityMaps, }: FlatEntityValidationArgs< typeof ALL_METADATA_NAME.viewFilter >): FailedFlatEntityValidation { @@ -104,29 +102,6 @@ export class FlatViewFilterValidatorService { return validationResult; } - const flatFieldMetadata = findFlatEntityByIdInFlatEntityMaps({ - flatEntityId: existingViewFilter.fieldMetadataId, - flatEntityMaps: dependencyOptimisticFlatEntityMaps.flatFieldMetadataMaps, - }); - - if (!isDefined(flatFieldMetadata)) { - return validationResult; - } - - const shouldValidateDeletion = isEnumFlatFieldMetadata(flatFieldMetadata) - ? flatFieldMetadata.options.some( - (option) => option.value === existingViewFilter.value, - ) - : true; - - if (shouldValidateDeletion && !isDefined(existingViewFilter.deletedAt)) { - validationResult.errors.push({ - code: ViewFilterExceptionCode.INVALID_VIEW_FILTER_DATA, - message: t`View filter has to be soft deleted first`, - userFriendlyMessage: msg`View filter has to be soft deleted first`, - }); - } - return validationResult; } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-view-group-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-view-group-validator.service.ts index 93778e58ab..40a09c6ca4 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-view-group-validator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-view-group-validator.service.ts @@ -5,7 +5,6 @@ import { isDefined } from 'twenty-shared/utils'; import { ALL_METADATA_NAME } from 'src/engine/metadata-modules/flat-entity/constant/all-metadata-name.constant'; import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; -import { isEnumFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-enum-flat-field-metadata.util'; import { FlatViewGroup } from 'src/engine/metadata-modules/flat-view-group/types/flat-view-group.type'; import { ViewExceptionCode } from 'src/engine/metadata-modules/view/exceptions/view.exception'; import { FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/types/failed-flat-entity-validation.type'; @@ -89,7 +88,6 @@ export class FlatViewGroupValidatorService { public validateFlatViewGroupDeletion({ flatEntityToValidate: { id: viewGroupIdToDelete }, optimisticFlatEntityMaps: optimisticFlatViewGroupMaps, - dependencyOptimisticFlatEntityMaps: { flatFieldMetadataMaps }, }: FlatEntityValidationArgs< typeof ALL_METADATA_NAME.viewGroup >): FailedFlatEntityValidation { @@ -114,26 +112,6 @@ export class FlatViewGroupValidatorService { return validationResult; } - const relatedFlatFieldMetadata = findFlatEntityByIdInFlatEntityMaps({ - flatEntityId: existingFlatViewGroup.fieldMetadataId, - flatEntityMaps: flatFieldMetadataMaps, - }); - - if ( - isDefined(relatedFlatFieldMetadata) && - isEnumFlatFieldMetadata(relatedFlatFieldMetadata) && - relatedFlatFieldMetadata.options.some( - (option) => option.value === existingFlatViewGroup.fieldValue, - ) && - !isDefined(existingFlatViewGroup.deletedAt) - ) { - validationResult.errors.push({ - code: ViewExceptionCode.INVALID_VIEW_DATA, - message: t`View group to delete has not been soft deleted`, - userFriendlyMessage: msg`View group to delete has not been soft deleted`, - }); - } - return validationResult; } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-view-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-view-validator.service.ts index 7b86f78628..d1a7d133d2 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-view-validator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-view-validator.service.ts @@ -1,22 +1,23 @@ -import { Injectable } from '@nestjs/common'; - import { msg, t } from '@lingui/core/macro'; import { isDefined } from 'twenty-shared/utils'; -import { ALL_METADATA_NAME } from 'src/engine/metadata-modules/flat-entity/constant/all-metadata-name.constant'; -import { FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type'; +import { type ALL_METADATA_NAME } from 'src/engine/metadata-modules/flat-entity/constant/all-metadata-name.constant'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type'; import { ViewExceptionCode } from 'src/engine/metadata-modules/view/exceptions/view.exception'; -import { FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/types/failed-flat-entity-validation.type'; -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'; +import { findFlatEntityPropertyUpdate } from 'src/engine/workspace-manager/workspace-migration-v2/utils/find-flat-entity-property-update.util'; +import { type FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/types/failed-flat-entity-validation.type'; +import { type FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/flat-entity-update-validation-args.type'; +import { type FlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/flat-entity-validation-args.type'; -@Injectable() export class FlatViewValidatorService { constructor() {} public validateFlatViewUpdate({ flatEntityId, + flatEntityUpdates, optimisticFlatEntityMaps: optimisticFlatViewMaps, + dependencyOptimisticFlatEntityMaps: { flatFieldMetadataMaps }, }: FlatEntityUpdateValidationArgs< typeof ALL_METADATA_NAME.view >): FailedFlatEntityValidation { @@ -38,6 +39,29 @@ export class FlatViewValidatorService { }); } + const kanbanAggregateOperationFieldMetadataIdUpdate = + findFlatEntityPropertyUpdate({ + property: 'kanbanAggregateOperationFieldMetadataId', + flatEntityUpdates, + }); + + if ( + isDefined(kanbanAggregateOperationFieldMetadataIdUpdate) && + kanbanAggregateOperationFieldMetadataIdUpdate.to !== null && + !isDefined( + findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: kanbanAggregateOperationFieldMetadataIdUpdate.to, + flatEntityMaps: flatFieldMetadataMaps, + }), + ) + ) { + validationResult.errors.push({ + code: ViewExceptionCode.INVALID_VIEW_DATA, + message: t`View kanban aggregate field metadata not found`, + userFriendlyMessage: msg`View kanban aggregate field metadata not found`, + }); + } + return validationResult; } @@ -63,14 +87,6 @@ export class FlatViewValidatorService { message: t`View not found`, userFriendlyMessage: msg`View not found`, }); - } else { - if (!isDefined(existingFlatView.deletedAt)) { - validationResult.errors.push({ - code: ViewExceptionCode.INVALID_VIEW_DATA, - message: t`View to delete has not been soft deleted`, - userFriendlyMessage: msg`View to delete has not been soft deleted`, - }); - } } return validationResult; @@ -79,7 +95,10 @@ export class FlatViewValidatorService { public async validateFlatViewCreation({ flatEntityToValidate: flatViewToValidate, optimisticFlatEntityMaps: optimisticFlatViewMaps, - dependencyOptimisticFlatEntityMaps, + dependencyOptimisticFlatEntityMaps: { + flatFieldMetadataMaps, + flatObjectMetadataMaps, + }, }: FlatEntityValidationArgs): Promise< FailedFlatEntityValidation > { @@ -92,9 +111,7 @@ export class FlatViewValidatorService { }; const optimisticFlatObjectMetadata = - dependencyOptimisticFlatEntityMaps.flatObjectMetadataMaps.byId[ - flatViewToValidate.objectMetadataId - ]; + flatObjectMetadataMaps.byId[flatViewToValidate.objectMetadataId]; if (!isDefined(optimisticFlatObjectMetadata)) { validationResult.errors.push({ @@ -112,6 +129,23 @@ export class FlatViewValidatorService { }); } + if ( + isDefined(flatViewToValidate.kanbanAggregateOperationFieldMetadataId) && + !isDefined( + findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: + flatViewToValidate.kanbanAggregateOperationFieldMetadataId, + flatEntityMaps: flatFieldMetadataMaps, + }), + ) + ) { + validationResult.errors.push({ + code: ViewExceptionCode.INVALID_VIEW_DATA, + message: t`View kanban aggregate field metadata not found`, + userFriendlyMessage: msg`View kanban aggregate field metadata not found`, + }); + } + return validationResult; } } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/field/services/update-field-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/field/services/update-field-action-handler.service.ts index e0ba53b9e9..4c43229c89 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/field/services/update-field-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/field/services/update-field-action-handler.service.ts @@ -26,8 +26,8 @@ import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-meta import { fieldMetadataTypeToColumnType } from 'src/engine/metadata-modules/workspace-migration/utils/field-metadata-type-to-column-type.util'; import { WorkspaceSchemaManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service'; import { isMorphOrRelationFieldMetadataType } from 'src/engine/utils/is-morph-or-relation-field-metadata-type.util'; -import { isPropertyUpdate } from 'src/engine/workspace-manager/workspace-migration-v2/types/is-property-update.type'; import { PropertyUpdate } from 'src/engine/workspace-manager/workspace-migration-v2/types/property-update.type'; +import { isPropertyUpdate } from 'src/engine/workspace-manager/workspace-migration-v2/utils/is-property-update.util'; import { type UpdateFieldAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/field/types/workspace-migration-field-action-v2'; import { serializeDefaultValueV2 } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/utils/serialize-default-value-v2.util'; import { diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/object/services/update-object-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/object/services/update-object-action-handler.service.ts index bfadda0788..96856a2848 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/object/services/update-object-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/object/services/update-object-action-handler.service.ts @@ -17,7 +17,7 @@ import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-meta import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { WorkspaceSchemaManagerService } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.service'; import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util'; -import { isPropertyUpdate } from 'src/engine/workspace-manager/workspace-migration-v2/types/is-property-update.type'; +import { findFlatEntityPropertyUpdate } from 'src/engine/workspace-manager/workspace-migration-v2/utils/find-flat-entity-property-update.util'; import { type UpdateObjectAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/object/types/workspace-migration-object-action-v2'; import { type WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/types/workspace-migration-action-runner-args.type'; import { fromFlatEntityPropertiesUpdatesToPartialFlatEntity } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/utils/from-flat-entity-properties-updates-to-partial-flat-entity'; @@ -105,9 +105,10 @@ export class UpdateObjectActionHandlerService extends WorkspaceMigrationRunnerAc flatObjectMetadata: flatObjectMetadata, }); - const nameSingularUpdate = updates.find((update) => - isPropertyUpdate(update, 'nameSingular'), - ); + const nameSingularUpdate = findFlatEntityPropertyUpdate({ + flatEntityUpdates: updates, + property: 'nameSingular', + }); if (isDefined(nameSingularUpdate)) { const updatedObjectMetadata: FlatObjectMetadata = { diff --git a/packages/twenty-server/src/utils/__test__/get-field-metadata-entity.mock.ts b/packages/twenty-server/src/utils/__test__/get-field-metadata-entity.mock.ts index 6f0b667616..c2e84ebbd4 100644 --- a/packages/twenty-server/src/utils/__test__/get-field-metadata-entity.mock.ts +++ b/packages/twenty-server/src/utils/__test__/get-field-metadata-entity.mock.ts @@ -20,9 +20,11 @@ export const getMockFieldMetadataEntity = < overrides: GetMockFieldMetadataEntityOverride, ): FieldMetadataEntity => { return { + calendarViews: [], viewFilters: [], viewFields: [], viewGroups: [], + kanbanAggregateOperationViews: [], morphId: null, fieldPermissions: [], icon: null, 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 8c7d1d3b5b..7bad3ed92d 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 @@ -130,8 +130,8 @@ exports[`View Field Resolver - Successful object metadata identifier update side "errors": [ { "code": "INVALID_VIEW_DATA", - "message": "View field to delete has not been soft deleted", - "userFriendlyMessage": "View field to delete has not been soft deleted", + "message": "Label identifier view field cannot be deleted", + "userFriendlyMessage": "Label identifier view field cannot be deleted", }, ], "flatEntityMinimalInformation": { diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/calendar-field-deactivation-deletes-views.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/calendar-field-deactivation-deletes-views.integration-spec.ts new file mode 100644 index 0000000000..13c80f1f08 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/calendar-field-deactivation-deletes-views.integration-spec.ts @@ -0,0 +1,297 @@ +import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util'; +import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; +import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util'; +import { createOneCoreView } from 'test/integration/metadata/suites/view/utils/create-one-core-view.util'; +import { findOneCoreView } from 'test/integration/metadata/suites/view/utils/find-one-core-view.util'; +import { generateRecordName } from 'test/integration/utils/generate-record-name'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { ViewCalendarLayout } from 'src/engine/metadata-modules/view/enums/view-calendar-layout.enum'; +import { ViewType } from 'src/engine/metadata-modules/view/enums/view-type.enum'; + +const VIEW_WITH_CALENDAR_FIELDS = ` + id + name + objectMetadataId + type + icon + position + isCompact + calendarLayout + calendarFieldMetadataId +`; + +type TestSetup = { + objectMetadataId: string; + calendarFieldMetadataId: string; + nonCalendarFieldMetadataId: string; + viewWithCalendarId: string; + viewWithoutCalendarId: string; +}; + +describe('calendar-field-deactivation-deletes-views', () => { + let testSetup: TestSetup; + + const verifyViewExists = async (viewId: string, shouldExist: boolean) => { + const { + data: { getCoreView }, + } = await findOneCoreView({ + viewId, + gqlFields: VIEW_WITH_CALENDAR_FIELDS, + expectToFail: false, + }); + + if (shouldExist) { + expect(isDefined(getCoreView)).toBe(true); + } else { + expect(getCoreView).toBeNull(); + } + + return getCoreView; + }; + + const deactivateFieldAndVerify = async (fieldId: string) => { + const { data, errors } = await updateOneFieldMetadata({ + expectToFail: false, + input: { + idToUpdate: fieldId, + updatePayload: { isActive: false }, + }, + gqlFields: ` + id + isActive + `, + }); + + expect(errors).toBeUndefined(); + expect(data.updateOneField.id).toBe(fieldId); + expect(data.updateOneField.isActive).toBe(false); + }; + + beforeEach(async () => { + const { + data: { + createOneObject: { id: objectMetadataId }, + }, + } = await createOneObjectMetadata({ + expectToFail: false, + input: { + nameSingular: 'calendarViewDeletionTestObject', + namePlural: 'calendarViewDeletionTestObjects', + labelSingular: 'Calendar View Deletion Test Object', + labelPlural: 'Calendar View Deletion Test Objects', + icon: 'IconTestTube', + }, + }); + + const { + data: { + createOneField: { id: calendarFieldMetadataId }, + }, + } = await createOneFieldMetadata({ + expectToFail: false, + input: { + name: 'calendarDateField', + type: FieldMetadataType.DATE_TIME, + label: 'Calendar Date Field', + objectMetadataId, + }, + gqlFields: 'id', + }); + + const { + data: { + createOneField: { id: nonCalendarFieldMetadataId }, + }, + } = await createOneFieldMetadata({ + expectToFail: false, + input: { + name: 'normalField', + type: FieldMetadataType.TEXT, + label: 'Normal Field', + objectMetadataId, + }, + gqlFields: 'id', + }); + + const { + data: { createCoreView: viewWithCalendar }, + } = await createOneCoreView({ + input: { + name: generateRecordName('Calendar View With Date Field'), + objectMetadataId, + type: ViewType.CALENDAR, + calendarFieldMetadataId, + calendarLayout: ViewCalendarLayout.WEEK, + icon: 'IconCalendar', + }, + gqlFields: VIEW_WITH_CALENDAR_FIELDS, + expectToFail: false, + }); + + const { + data: { createCoreView: viewWithoutCalendar }, + } = await createOneCoreView({ + input: { + name: generateRecordName('Table View Without Calendar'), + objectMetadataId, + type: ViewType.TABLE, + icon: 'IconTable', + }, + gqlFields: VIEW_WITH_CALENDAR_FIELDS, + expectToFail: false, + }); + + testSetup = { + objectMetadataId, + calendarFieldMetadataId, + nonCalendarFieldMetadataId, + viewWithCalendarId: viewWithCalendar.id, + viewWithoutCalendarId: viewWithoutCalendar.id, + }; + }); + + afterEach(async () => { + await updateOneObjectMetadata({ + expectToFail: false, + input: { + idToUpdate: testSetup.objectMetadataId, + updatePayload: { + isActive: false, + }, + }, + }); + await deleteOneObjectMetadata({ + expectToFail: false, + input: { idToDelete: testSetup.objectMetadataId }, + }); + }); + + it('should delete view when field used as calendarFieldMetadataId is deactivated', async () => { + const initialViewWithCalendar = await verifyViewExists( + testSetup.viewWithCalendarId, + true, + ); + + await verifyViewExists(testSetup.viewWithoutCalendarId, true); + + expect(initialViewWithCalendar.calendarFieldMetadataId).toBe( + testSetup.calendarFieldMetadataId, + ); + expect(initialViewWithCalendar.calendarLayout).toBe('WEEK'); + + await deactivateFieldAndVerify(testSetup.calendarFieldMetadataId); + + await verifyViewExists(testSetup.viewWithCalendarId, false); + await verifyViewExists(testSetup.viewWithoutCalendarId, true); + }); + + it('should not delete view when field not used as calendarFieldMetadataId is deactivated', async () => { + await verifyViewExists(testSetup.viewWithCalendarId, true); + await verifyViewExists(testSetup.viewWithoutCalendarId, true); + + await deactivateFieldAndVerify(testSetup.nonCalendarFieldMetadataId); + + await verifyViewExists(testSetup.viewWithCalendarId, true); + await verifyViewExists(testSetup.viewWithoutCalendarId, true); + }); + + it('should delete multiple views when they all use the same field as calendarFieldMetadataId', async () => { + const { + data: { createCoreView: secondViewWithCalendar }, + } = await createOneCoreView({ + input: { + name: generateRecordName('Second Calendar View'), + objectMetadataId: testSetup.objectMetadataId, + type: ViewType.CALENDAR, + calendarFieldMetadataId: testSetup.calendarFieldMetadataId, + calendarLayout: ViewCalendarLayout.MONTH, + icon: 'IconCalendar', + }, + gqlFields: VIEW_WITH_CALENDAR_FIELDS, + expectToFail: false, + }); + + await verifyViewExists(testSetup.viewWithCalendarId, true); + await verifyViewExists(secondViewWithCalendar.id, true); + await verifyViewExists(testSetup.viewWithoutCalendarId, true); + + await deactivateFieldAndVerify(testSetup.calendarFieldMetadataId); + + await verifyViewExists(testSetup.viewWithCalendarId, false); + await verifyViewExists(secondViewWithCalendar.id, false); + await verifyViewExists(testSetup.viewWithoutCalendarId, true); + }); + + it('should handle deactivation when views have different calendar layouts on same field', async () => { + const { + data: { createCoreView: viewWithDayLayout }, + } = await createOneCoreView({ + input: { + name: generateRecordName('Calendar View With Day Layout'), + objectMetadataId: testSetup.objectMetadataId, + type: ViewType.CALENDAR, + calendarFieldMetadataId: testSetup.calendarFieldMetadataId, + calendarLayout: ViewCalendarLayout.DAY, + icon: 'IconCalendar', + }, + gqlFields: VIEW_WITH_CALENDAR_FIELDS, + expectToFail: false, + }); + + const { + data: { createCoreView: viewWithMonthLayout }, + } = await createOneCoreView({ + input: { + name: generateRecordName('Calendar View With Month Layout'), + objectMetadataId: testSetup.objectMetadataId, + type: ViewType.CALENDAR, + calendarFieldMetadataId: testSetup.calendarFieldMetadataId, + calendarLayout: ViewCalendarLayout.MONTH, + icon: 'IconCalendar', + }, + gqlFields: VIEW_WITH_CALENDAR_FIELDS, + expectToFail: false, + }); + + await verifyViewExists(testSetup.viewWithCalendarId, true); + await verifyViewExists(viewWithDayLayout.id, true); + await verifyViewExists(viewWithMonthLayout.id, true); + await verifyViewExists(testSetup.viewWithoutCalendarId, true); + + await deactivateFieldAndVerify(testSetup.calendarFieldMetadataId); + + await verifyViewExists(testSetup.viewWithCalendarId, false); + await verifyViewExists(viewWithDayLayout.id, false); + await verifyViewExists(viewWithMonthLayout.id, false); + await verifyViewExists(testSetup.viewWithoutCalendarId, true); + }); + + it('should delete calendar view but not other view types when calendar field is deactivated', async () => { + const { + data: { createCoreView: kanbanViewWithSameObject }, + } = await createOneCoreView({ + input: { + name: generateRecordName('Kanban View'), + objectMetadataId: testSetup.objectMetadataId, + type: ViewType.KANBAN, + icon: 'IconLayoutKanban', + }, + gqlFields: VIEW_WITH_CALENDAR_FIELDS, + expectToFail: false, + }); + + await verifyViewExists(testSetup.viewWithCalendarId, true); + await verifyViewExists(testSetup.viewWithoutCalendarId, true); + await verifyViewExists(kanbanViewWithSameObject.id, true); + + await deactivateFieldAndVerify(testSetup.calendarFieldMetadataId); + + await verifyViewExists(testSetup.viewWithCalendarId, false); + await verifyViewExists(testSetup.viewWithoutCalendarId, true); + await verifyViewExists(kanbanViewWithSameObject.id, true); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/delete-one-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/delete-one-field-metadata.integration-spec.ts index 7ece0cc02e..d6bde7e9e4 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/delete-one-field-metadata.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/delete-one-field-metadata.integration-spec.ts @@ -90,7 +90,7 @@ describe('deleteOne', () => { }); }); - it('should reset kanban aggregate operation when deleting a field used as kanbanAggregateOperationFieldMetadataId', async () => { + it('should delete kanban aggregate operation when deleting a field used as kanbanAggregateOperationFieldMetadataId', async () => { const viewThatShouldBeUpdated = await findViewByIdWithRestApi(viewId); if (!isDefined(viewThatShouldBeUpdated)) { diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/kanban-aggregate-field-deactivation-nullifies-views-kanban-properties.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/kanban-aggregate-field-deactivation-nullifies-views-kanban-properties.integration-spec.ts new file mode 100644 index 0000000000..3cb63f223f --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/kanban-aggregate-field-deactivation-nullifies-views-kanban-properties.integration-spec.ts @@ -0,0 +1,399 @@ +import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util'; +import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; +import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util'; +import { createOneCoreView } from 'test/integration/metadata/suites/view/utils/create-one-core-view.util'; +import { findOneCoreView } from 'test/integration/metadata/suites/view/utils/find-one-core-view.util'; +import { generateRecordName } from 'test/integration/utils/generate-record-name'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant'; +import { ViewType } from 'src/engine/metadata-modules/view/enums/view-type.enum'; + +const VIEW_WITH_KANBAN_FIELDS = ` + id + name + objectMetadataId + type + icon + position + isCompact + kanbanAggregateOperation + kanbanAggregateOperationFieldMetadataId +`; + +type TestSetup = { + objectMetadataId: string; + aggregateFieldMetadataId: string; + nonAggregateFieldMetadataId: string; + viewWithAggregateId: string; + viewWithoutAggregateId: string; +}; + +describe('kanban-aggregate-field-deactivation-nullifies-kanban-properties', () => { + let testSetup: TestSetup; + + const verifyKanbanPropertiesAreNull = async (viewId: string) => { + const { + data: { getCoreView: view }, + } = await findOneCoreView({ + viewId, + gqlFields: VIEW_WITH_KANBAN_FIELDS, + expectToFail: false, + }); + + expect(isDefined(view)).toBe(true); + expect(view.kanbanAggregateOperation).toBeNull(); + expect(view.kanbanAggregateOperationFieldMetadataId).toBeNull(); + + return view; + }; + + const deactivateFieldAndVerify = async (fieldId: string) => { + const { data, errors } = await updateOneFieldMetadata({ + expectToFail: false, + input: { + idToUpdate: fieldId, + updatePayload: { isActive: false }, + }, + gqlFields: ` + id + isActive + `, + }); + + expect(errors).toBeUndefined(); + expect(data.updateOneField.id).toBe(fieldId); + expect(data.updateOneField.isActive).toBe(false); + }; + + beforeEach(async () => { + const { + data: { + createOneObject: { id: objectMetadataId }, + }, + } = await createOneObjectMetadata({ + expectToFail: false, + input: { + nameSingular: 'viewDeletionTestObject', + namePlural: 'viewDeletionTestObjects', + labelSingular: 'View Deletion Test Object', + labelPlural: 'View Deletion Test Objects', + icon: 'IconTestTube', + }, + }); + + const { + data: { + createOneField: { id: aggregateFieldMetadataId }, + }, + } = await createOneFieldMetadata({ + expectToFail: false, + input: { + name: 'aggregateField', + type: FieldMetadataType.NUMBER, + label: 'Aggregate Field', + objectMetadataId, + }, + gqlFields: 'id', + }); + + const { + data: { + createOneField: { id: nonAggregateFieldMetadataId }, + }, + } = await createOneFieldMetadata({ + expectToFail: false, + input: { + name: 'normalField', + type: FieldMetadataType.TEXT, + label: 'Normal Field', + objectMetadataId, + }, + gqlFields: 'id', + }); + + const { + data: { createCoreView: viewWithAggregate }, + } = await createOneCoreView({ + input: { + name: generateRecordName('Kanban View With Aggregate'), + objectMetadataId, + type: ViewType.KANBAN, + kanbanAggregateOperationFieldMetadataId: aggregateFieldMetadataId, + kanbanAggregateOperation: AggregateOperations.SUM, + icon: 'IconLayoutKanban', + }, + gqlFields: VIEW_WITH_KANBAN_FIELDS, + expectToFail: false, + }); + + const { + data: { createCoreView: viewWithoutAggregate }, + } = await createOneCoreView({ + input: { + name: generateRecordName('Kanban View Without Aggregate'), + objectMetadataId, + type: ViewType.KANBAN, + icon: 'IconLayoutKanban', + }, + gqlFields: VIEW_WITH_KANBAN_FIELDS, + expectToFail: false, + }); + + testSetup = { + objectMetadataId, + aggregateFieldMetadataId, + nonAggregateFieldMetadataId, + viewWithAggregateId: viewWithAggregate.id, + viewWithoutAggregateId: viewWithoutAggregate.id, + }; + }); + + afterEach(async () => { + await updateOneObjectMetadata({ + expectToFail: false, + input: { + idToUpdate: testSetup.objectMetadataId, + updatePayload: { + isActive: false, + }, + }, + }); + await deleteOneObjectMetadata({ + expectToFail: false, + input: { idToDelete: testSetup.objectMetadataId }, + }); + }); + + it('should nullify kanban properties when field used as kanbanAggregateOperationFieldMetadataId is deactivated', async () => { + const { + data: { getCoreView: initialViewWithAggregate }, + } = await findOneCoreView({ + viewId: testSetup.viewWithAggregateId, + gqlFields: VIEW_WITH_KANBAN_FIELDS, + expectToFail: false, + }); + + const { + data: { getCoreView: initialViewWithoutAggregate }, + } = await findOneCoreView({ + viewId: testSetup.viewWithoutAggregateId, + gqlFields: VIEW_WITH_KANBAN_FIELDS, + expectToFail: false, + }); + + expect( + initialViewWithAggregate.kanbanAggregateOperationFieldMetadataId, + ).toBe(testSetup.aggregateFieldMetadataId); + expect(initialViewWithAggregate.kanbanAggregateOperation).toBe('SUM'); + + await deactivateFieldAndVerify(testSetup.aggregateFieldMetadataId); + + await verifyKanbanPropertiesAreNull(testSetup.viewWithAggregateId); + + // View without aggregate should remain unchanged + const { + data: { getCoreView: updatedViewWithoutAggregate }, + } = await findOneCoreView({ + viewId: testSetup.viewWithoutAggregateId, + gqlFields: VIEW_WITH_KANBAN_FIELDS, + expectToFail: false, + }); + + expect(updatedViewWithoutAggregate).toEqual(initialViewWithoutAggregate); + }); + + it('should not modify views when field not used as kanbanAggregateOperationFieldMetadataId is deactivated', async () => { + const { + data: { getCoreView: initialViewWithAggregate }, + } = await findOneCoreView({ + viewId: testSetup.viewWithAggregateId, + gqlFields: VIEW_WITH_KANBAN_FIELDS, + expectToFail: false, + }); + + const { + data: { getCoreView: initialViewWithoutAggregate }, + } = await findOneCoreView({ + viewId: testSetup.viewWithoutAggregateId, + gqlFields: VIEW_WITH_KANBAN_FIELDS, + expectToFail: false, + }); + + await deactivateFieldAndVerify(testSetup.nonAggregateFieldMetadataId); + + const { + data: { getCoreView: updatedViewWithAggregate }, + } = await findOneCoreView({ + viewId: testSetup.viewWithAggregateId, + gqlFields: VIEW_WITH_KANBAN_FIELDS, + expectToFail: false, + }); + + const { + data: { getCoreView: updatedViewWithoutAggregate }, + } = await findOneCoreView({ + viewId: testSetup.viewWithoutAggregateId, + gqlFields: VIEW_WITH_KANBAN_FIELDS, + expectToFail: false, + }); + + expect(updatedViewWithAggregate).toEqual(initialViewWithAggregate); + expect(updatedViewWithoutAggregate).toEqual(initialViewWithoutAggregate); + }); + + it('should nullify kanban properties on multiple views when they all use the same field as kanbanAggregateOperationFieldMetadataId', async () => { + const { + data: { createCoreView: secondViewWithAggregate }, + } = await createOneCoreView({ + input: { + name: generateRecordName('Second Kanban View With Aggregate'), + objectMetadataId: testSetup.objectMetadataId, + type: ViewType.KANBAN, + kanbanAggregateOperationFieldMetadataId: + testSetup.aggregateFieldMetadataId, + kanbanAggregateOperation: AggregateOperations.MAX, + icon: 'IconLayoutKanban', + }, + gqlFields: VIEW_WITH_KANBAN_FIELDS, + expectToFail: false, + }); + + const { + data: { getCoreView: initialViewWithAggregate }, + } = await findOneCoreView({ + viewId: testSetup.viewWithAggregateId, + gqlFields: VIEW_WITH_KANBAN_FIELDS, + expectToFail: false, + }); + + const { + data: { getCoreView: initialSecondViewWithAggregate }, + } = await findOneCoreView({ + viewId: secondViewWithAggregate.id, + gqlFields: VIEW_WITH_KANBAN_FIELDS, + expectToFail: false, + }); + + const { + data: { getCoreView: initialViewWithoutAggregate }, + } = await findOneCoreView({ + viewId: testSetup.viewWithoutAggregateId, + gqlFields: VIEW_WITH_KANBAN_FIELDS, + expectToFail: false, + }); + + expect( + initialViewWithAggregate.kanbanAggregateOperationFieldMetadataId, + ).toBe(testSetup.aggregateFieldMetadataId); + expect( + initialSecondViewWithAggregate.kanbanAggregateOperationFieldMetadataId, + ).toBe(testSetup.aggregateFieldMetadataId); + + await deactivateFieldAndVerify(testSetup.aggregateFieldMetadataId); + + await verifyKanbanPropertiesAreNull(testSetup.viewWithAggregateId); + await verifyKanbanPropertiesAreNull(secondViewWithAggregate.id); + + // View without aggregate should remain unchanged + const { + data: { getCoreView: updatedViewWithoutAggregate }, + } = await findOneCoreView({ + viewId: testSetup.viewWithoutAggregateId, + gqlFields: VIEW_WITH_KANBAN_FIELDS, + expectToFail: false, + }); + + expect(updatedViewWithoutAggregate).toEqual(initialViewWithoutAggregate); + }); + + it('should nullify kanban properties when views have different aggregate operations on same field', async () => { + const { + data: { createCoreView: viewWithMin }, + } = await createOneCoreView({ + input: { + name: generateRecordName('Kanban View With MIN'), + objectMetadataId: testSetup.objectMetadataId, + type: ViewType.KANBAN, + kanbanAggregateOperationFieldMetadataId: + testSetup.aggregateFieldMetadataId, + kanbanAggregateOperation: AggregateOperations.MIN, + icon: 'IconLayoutKanban', + }, + gqlFields: VIEW_WITH_KANBAN_FIELDS, + expectToFail: false, + }); + + const { + data: { createCoreView: viewWithAvg }, + } = await createOneCoreView({ + input: { + name: generateRecordName('Kanban View With AVG'), + objectMetadataId: testSetup.objectMetadataId, + type: ViewType.KANBAN, + kanbanAggregateOperationFieldMetadataId: + testSetup.aggregateFieldMetadataId, + kanbanAggregateOperation: AggregateOperations.AVG, + icon: 'IconLayoutKanban', + }, + gqlFields: VIEW_WITH_KANBAN_FIELDS, + expectToFail: false, + }); + + const { + data: { getCoreView: initialViewWithAggregate }, + } = await findOneCoreView({ + viewId: testSetup.viewWithAggregateId, + gqlFields: VIEW_WITH_KANBAN_FIELDS, + expectToFail: false, + }); + + const { + data: { getCoreView: initialViewWithMin }, + } = await findOneCoreView({ + viewId: viewWithMin.id, + gqlFields: VIEW_WITH_KANBAN_FIELDS, + expectToFail: false, + }); + + const { + data: { getCoreView: initialViewWithAvg }, + } = await findOneCoreView({ + viewId: viewWithAvg.id, + gqlFields: VIEW_WITH_KANBAN_FIELDS, + expectToFail: false, + }); + + const { + data: { getCoreView: initialViewWithoutAggregate }, + } = await findOneCoreView({ + viewId: testSetup.viewWithoutAggregateId, + gqlFields: VIEW_WITH_KANBAN_FIELDS, + expectToFail: false, + }); + + expect(initialViewWithAggregate.kanbanAggregateOperation).toBe('SUM'); + expect(initialViewWithMin.kanbanAggregateOperation).toBe('MIN'); + expect(initialViewWithAvg.kanbanAggregateOperation).toBe('AVG'); + + await deactivateFieldAndVerify(testSetup.aggregateFieldMetadataId); + + await verifyKanbanPropertiesAreNull(testSetup.viewWithAggregateId); + await verifyKanbanPropertiesAreNull(viewWithMin.id); + await verifyKanbanPropertiesAreNull(viewWithAvg.id); + + // View without aggregate should remain unchanged + const { + data: { getCoreView: updatedViewWithoutAggregate }, + } = await findOneCoreView({ + viewId: testSetup.viewWithoutAggregateId, + gqlFields: VIEW_WITH_KANBAN_FIELDS, + expectToFail: false, + }); + + expect(updatedViewWithoutAggregate).toEqual(initialViewWithoutAggregate); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/view-group-field-deactivation-deletes-views.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/view-group-field-deactivation-deletes-views.integration-spec.ts new file mode 100644 index 0000000000..f12a1c28a2 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/view-group-field-deactivation-deletes-views.integration-spec.ts @@ -0,0 +1,307 @@ +import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util'; +import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; +import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util'; +import { createOneCoreViewGroup } from 'test/integration/metadata/suites/view-group/utils/create-one-core-view-group.util'; +import { createOneCoreView } from 'test/integration/metadata/suites/view/utils/create-one-core-view.util'; +import { findOneCoreView } from 'test/integration/metadata/suites/view/utils/find-one-core-view.util'; +import { generateRecordName } from 'test/integration/utils/generate-record-name'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { ViewType } from 'src/engine/metadata-modules/view/enums/view-type.enum'; + +const VIEW_FIELDS = ` + id + name + objectMetadataId + type + icon + position +`; + +type TestSetup = { + objectMetadataId: string; + groupByFieldMetadataId: string; + nonGroupByFieldMetadataId: string; + viewWithGroupId: string; + viewWithoutGroupId: string; +}; + +describe('view-group-field-deactivation-deletes-views', () => { + let testSetup: TestSetup; + + const verifyViewExists = async (viewId: string, shouldExist: boolean) => { + const { + data: { getCoreView }, + } = await findOneCoreView({ + viewId, + gqlFields: VIEW_FIELDS, + expectToFail: false, + }); + + if (shouldExist) { + expect(isDefined(getCoreView)).toBe(true); + } else { + expect(getCoreView).toBeNull(); + } + + return getCoreView; + }; + + const deactivateFieldAndVerify = async (fieldId: string) => { + const { data, errors } = await updateOneFieldMetadata({ + expectToFail: false, + input: { + idToUpdate: fieldId, + updatePayload: { isActive: false }, + }, + gqlFields: ` + id + isActive + `, + }); + + expect(errors).toBeUndefined(); + expect(data.updateOneField.id).toBe(fieldId); + expect(data.updateOneField.isActive).toBe(false); + }; + + beforeEach(async () => { + const { + data: { + createOneObject: { id: objectMetadataId }, + }, + } = await createOneObjectMetadata({ + expectToFail: false, + input: { + nameSingular: 'viewGroupDeletionTestObject', + namePlural: 'viewGroupDeletionTestObjects', + labelSingular: 'View Group Deletion Test Object', + labelPlural: 'View Group Deletion Test Objects', + icon: 'IconTestTube', + }, + }); + + const { + data: { + createOneField: { id: groupByFieldMetadataId }, + }, + } = await createOneFieldMetadata({ + expectToFail: false, + input: { + name: 'groupByField', + type: FieldMetadataType.SELECT, + label: 'Group By Field', + objectMetadataId, + options: [ + { label: 'Option 1', value: 'OPTION_1', color: 'blue', position: 0 }, + { label: 'Option 2', value: 'OPTION_2', color: 'red', position: 1 }, + { label: 'Option 3', value: 'OPTION_3', color: 'green', position: 2 }, + ], + }, + gqlFields: 'id', + }); + + const { + data: { + createOneField: { id: nonGroupByFieldMetadataId }, + }, + } = await createOneFieldMetadata({ + expectToFail: false, + input: { + name: 'normalField', + type: FieldMetadataType.TEXT, + label: 'Normal Field', + objectMetadataId, + }, + gqlFields: 'id', + }); + + const { + data: { createCoreView: viewWithGroup }, + } = await createOneCoreView({ + input: { + name: generateRecordName('View With Group'), + objectMetadataId, + type: ViewType.TABLE, + icon: 'IconTable', + }, + gqlFields: VIEW_FIELDS, + expectToFail: false, + }); + + await createOneCoreViewGroup({ + input: { + fieldMetadataId: groupByFieldMetadataId, + fieldValue: 'OPTION_1', + viewId: viewWithGroup.id, + }, + gqlFields: 'id', + expectToFail: false, + }); + + const { + data: { createCoreView: viewWithoutGroup }, + } = await createOneCoreView({ + input: { + name: generateRecordName('View Without Group'), + objectMetadataId, + type: ViewType.TABLE, + icon: 'IconTable', + }, + gqlFields: VIEW_FIELDS, + expectToFail: false, + }); + + testSetup = { + objectMetadataId, + groupByFieldMetadataId, + nonGroupByFieldMetadataId, + viewWithGroupId: viewWithGroup.id, + viewWithoutGroupId: viewWithoutGroup.id, + }; + }); + + afterEach(async () => { + await updateOneObjectMetadata({ + expectToFail: false, + input: { + idToUpdate: testSetup.objectMetadataId, + updatePayload: { + isActive: false, + }, + }, + }); + await deleteOneObjectMetadata({ + expectToFail: false, + input: { idToDelete: testSetup.objectMetadataId }, + }); + }); + + it('should delete view when field used in view group is deactivated', async () => { + await verifyViewExists(testSetup.viewWithGroupId, true); + await verifyViewExists(testSetup.viewWithoutGroupId, true); + + await deactivateFieldAndVerify(testSetup.groupByFieldMetadataId); + + await verifyViewExists(testSetup.viewWithGroupId, false); + await verifyViewExists(testSetup.viewWithoutGroupId, true); + }); + + it('should not delete view when field not used in view group is deactivated', async () => { + await verifyViewExists(testSetup.viewWithGroupId, true); + await verifyViewExists(testSetup.viewWithoutGroupId, true); + + await deactivateFieldAndVerify(testSetup.nonGroupByFieldMetadataId); + + await verifyViewExists(testSetup.viewWithGroupId, true); + await verifyViewExists(testSetup.viewWithoutGroupId, true); + }); + + it('should delete multiple views when they all use the same field in view groups', async () => { + const { + data: { createCoreView: secondViewWithGroup }, + } = await createOneCoreView({ + input: { + name: generateRecordName('Second View With Group'), + objectMetadataId: testSetup.objectMetadataId, + type: ViewType.TABLE, + icon: 'IconTable', + }, + gqlFields: VIEW_FIELDS, + expectToFail: false, + }); + + await createOneCoreViewGroup({ + input: { + fieldMetadataId: testSetup.groupByFieldMetadataId, + fieldValue: 'OPTION_2', + viewId: secondViewWithGroup.id, + }, + gqlFields: 'id', + expectToFail: false, + }); + + await verifyViewExists(testSetup.viewWithGroupId, true); + await verifyViewExists(secondViewWithGroup.id, true); + await verifyViewExists(testSetup.viewWithoutGroupId, true); + + await deactivateFieldAndVerify(testSetup.groupByFieldMetadataId); + + await verifyViewExists(testSetup.viewWithGroupId, false); + await verifyViewExists(secondViewWithGroup.id, false); + await verifyViewExists(testSetup.viewWithoutGroupId, true); + }); + + it('should handle deactivation when view has multiple view groups with different fields', async () => { + const { + data: { + createOneField: { id: anotherGroupByFieldMetadataId }, + }, + } = await createOneFieldMetadata({ + expectToFail: false, + input: { + name: 'anotherGroupByField', + type: FieldMetadataType.SELECT, + label: 'Another Group By Field', + objectMetadataId: testSetup.objectMetadataId, + options: [ + { + label: 'Status A', + value: 'STATUS_A', + color: 'purple', + position: 0, + }, + { + label: 'Status B', + value: 'STATUS_B', + color: 'orange', + position: 1, + }, + ], + }, + gqlFields: 'id', + }); + + const { + data: { createCoreView: viewWithMultipleGroups }, + } = await createOneCoreView({ + input: { + name: generateRecordName('View With Multiple Groups'), + objectMetadataId: testSetup.objectMetadataId, + type: ViewType.TABLE, + icon: 'IconTable', + }, + gqlFields: VIEW_FIELDS, + expectToFail: false, + }); + + await createOneCoreViewGroup({ + input: { + fieldMetadataId: testSetup.groupByFieldMetadataId, + fieldValue: 'OPTION_1', + viewId: viewWithMultipleGroups.id, + }, + gqlFields: 'id', + expectToFail: false, + }); + + await createOneCoreViewGroup({ + input: { + fieldMetadataId: anotherGroupByFieldMetadataId, + fieldValue: 'STATUS_A', + viewId: viewWithMultipleGroups.id, + }, + gqlFields: 'id', + expectToFail: false, + }); + + await verifyViewExists(viewWithMultipleGroups.id, true); + + await deactivateFieldAndVerify(testSetup.groupByFieldMetadataId); + + await verifyViewExists(viewWithMultipleGroups.id, false); + }); +}); diff --git a/packages/twenty-shared/src/types/extract-properties-that-ends-with-id.ts b/packages/twenty-shared/src/types/ExtractPropertiesThatEndsWithId.ts similarity index 100% rename from packages/twenty-shared/src/types/extract-properties-that-ends-with-id.ts rename to packages/twenty-shared/src/types/ExtractPropertiesThatEndsWithId.ts diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts index 349a494a1f..71e35b38f4 100644 --- a/packages/twenty-shared/src/types/index.ts +++ b/packages/twenty-shared/src/types/index.ts @@ -17,7 +17,7 @@ export type { ConfigVariableValue } from './ConfigVariableValue'; export { ConnectedAccountProvider } from './ConnectedAccountProvider'; export type { EnumFieldMetadataType } from './EnumFieldMetadataType'; export type { ExcludeFunctions } from './ExcludeFunctions'; -export type { ExtractPropertiesThatEndsWithId } from './extract-properties-that-ends-with-id'; +export type { ExtractPropertiesThatEndsWithId } from './ExtractPropertiesThatEndsWithId'; export type { FieldMetadataMultiItemSettings } from './FieldMetadataMultiItemSettings'; export { FieldMetadataType } from './FieldMetadataType'; export type { FieldRatingValue } from './FieldRatingValue';