From 2e84c11eaed3e911514de817506db2992d88718b Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Thu, 23 Oct 2025 11:02:18 +0200 Subject: [PATCH] `[v2_FIX]` Update standard `object/field` (#15233) # Introduction Refactoring the standard overrides dispatcher to only pass over fields to has to be dispatched in the standardOverrides entry and let the other side effects resulting from out of standard overrides mutation trigger Related to https://github.com/twentyhq/core-team-issues/issues/1753 ## This allows - standard field settings, options etc updates and so on ## Remark - Determine what we should do on object deactivation ( right now in production we can still access deactivated object relation properties and so on e.g deactivate opportunities still accessible from a view field on company ( still have to re-create it as it has been deleted ) => decided to leave as it is right now, `isActive` could be considered as uiDeactivated in the end - We should also add forbidden standard field mutations validation inside the builder itself ( here we want to early return in the api input transpiler too as we don't want to spread invalid side effects ) => or in the end we could just centralize both but it will generate several errors ## Coverage ```ts PASS test/integration/metadata/suites/object-metadata/successful-update-one-standard-object-metadata.integration-spec.ts PASS test/integration/metadata/suites/field-metadata/successful-update-one-standard-field-metadata.integration-spec.ts PASS test/integration/metadata/suites/object-metadata/failing-update-one-standard-object-metadata.integration-spec.ts PASS test/integration/metadata/suites/field-metadata/failing-update-one-standard-field-metadata.integration-spec.ts Test Suites: 4 passed, 4 total Tests: 18 passed, 18 total Snapshots: 16 passed, 16 total Time: 8.721 s, estimated 10 s ``` ## Update post review Faced a behavior where updating back the company label to its original value would result in storing this value in the standard overrides Refactored both field and object transpilation behavior to rather remove the standard override value instead and let fallback on original value Yes it's quite duplicated will factorize once we move this inside the builder --- .../services/field-metadata.service-v2.ts | 9 +- ...rties-to-compare-and-stringify.constant.ts | 13 +- ...d-metadata-editable-properties.constant.ts | 35 +- ...d-metadata-editable-properties.constant.ts | 2 +- ...field-input-to-flat-field-metadata.util.ts | 85 +---- .../utils/sanitize-raw-update-field-input.ts | 106 ++++++ ...t-metadata-editable-properties.constant.ts | 19 + ...metadata-and-related-flat-entities.util.ts | 224 +++--------- ...object-metadata-update-side-effect.util.ts | 83 +++++ ...fter-flat-object-identifier-update.util.ts | 12 +- .../utils/sanitize-raw-update-object-input.ts | 108 ++++++ .../object-metadata-v2.service.ts | 24 +- .../flat-view-group-validator.service.ts | 2 - ...rd-field-metadata.integration-spec.ts.snap | 37 ++ ...rd-field-metadata.integration-spec.ts.snap | 326 ++++++++++++++++++ ...tandard-field-metadata.integration-spec.ts | 105 ++++++ ...tandard-field-metadata.integration-spec.ts | 179 ++++++++++ ...d-object-metadata.integration-spec.ts.snap | 49 +++ ...d-object-metadata.integration-spec.ts.snap | 71 ++++ ...andard-object-metadata.integration-spec.ts | 115 ++++++ ...andard-object-metadata.integration-spec.ts | 150 ++++++++ .../utils/update-one-object-metadata.util.ts | 7 +- 22 files changed, 1472 insertions(+), 289 deletions(-) create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/sanitize-raw-update-field-input.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/constants/flat-object-metadata-editable-properties.constant.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/handle-flat-object-metadata-update-side-effect.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/sanitize-raw-update-object-input.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/failing-update-one-standard-field-metadata.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/successful-update-one-standard-field-metadata.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-metadata/failing-update-one-standard-field-metadata.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-metadata/successful-update-one-standard-field-metadata.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-update-one-standard-object-metadata.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/successful-update-one-standard-object-metadata.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/metadata/suites/object-metadata/failing-update-one-standard-object-metadata.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/object-metadata/successful-update-one-standard-object-metadata.integration-spec.ts 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 f71e819abf9..d352068cdcf 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 @@ -172,7 +172,14 @@ export class FieldMetadataServiceV2 { }); if (inputTranspilationResult.status === 'fail') { - throw inputTranspilationResult.error; + throw new FieldMetadataException( + inputTranspilationResult.error.message, + inputTranspilationResult.error.code, + { + userFriendlyMessage: + inputTranspilationResult.error.userFriendlyMessage, + }, + ); } const { diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-flat-entity-properties-to-compare-and-stringify.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-flat-entity-properties-to-compare-and-stringify.constant.ts index d93e17c4b89..44cdc9e2b3a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-flat-entity-properties-to-compare-and-stringify.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-flat-entity-properties-to-compare-and-stringify.constant.ts @@ -3,6 +3,7 @@ import { FLAT_DATABASE_EVENT_TRIGGER_EDITABLE_PROPERTIES } from 'src/engine/meta import { type AllMetadataName } from 'src/engine/metadata-modules/flat-entity/types/all-metadata-name.type'; import { type MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity.type'; import { FLAT_FIELD_METADATA_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-field-metadata/constants/flat-field-metadata-editable-properties.constant'; +import { FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-object-metadata/constants/flat-object-metadata-editable-properties.constant'; import { FLAT_VIEW_FIELD_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-field/constants/flat-view-field-editable-properties.constant'; import { FLAT_VIEW_FILTER_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-filter/constants/flat-view-filter-editable-properties.constant'; import { FLAT_VIEW_GROUP_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-group/constants/flat-view-group-editable-properties.constant'; @@ -17,7 +18,7 @@ type OneFlatEntityConfiguration = { export const ALL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY = { fieldMetadata: { propertiesToCompare: [ - ...FLAT_FIELD_METADATA_EDITABLE_PROPERTIES, + ...FLAT_FIELD_METADATA_EDITABLE_PROPERTIES.custom, 'standardOverrides', ], propertiesToStringify: [ @@ -29,16 +30,8 @@ export const ALL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY = { }, objectMetadata: { propertiesToCompare: [ - 'description', - 'icon', - 'isActive', - 'isLabelSyncedWithName', - 'labelPlural', - 'labelSingular', - 'namePlural', - 'nameSingular', + ...FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES.custom, 'standardOverrides', - 'labelIdentifierFieldMetadataId', ], propertiesToStringify: ['standardOverrides'], }, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/constants/flat-field-metadata-editable-properties.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/constants/flat-field-metadata-editable-properties.constant.ts index 37b27ee48ee..9f8807e0f2e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/constants/flat-field-metadata-editable-properties.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/constants/flat-field-metadata-editable-properties.constant.ts @@ -1,14 +1,25 @@ import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; -export const FLAT_FIELD_METADATA_EDITABLE_PROPERTIES = [ - 'defaultValue', - 'description', - 'icon', - 'isActive', - 'isLabelSyncedWithName', - 'isUnique', - 'label', - 'name', - 'options', - 'settings', -] as const satisfies (keyof FlatFieldMetadata)[]; +export const FLAT_FIELD_METADATA_EDITABLE_PROPERTIES = { + custom: [ + 'defaultValue', + 'description', + 'icon', + 'isActive', + 'isLabelSyncedWithName', + 'isUnique', + 'label', + 'name', + 'options', + 'settings', + ], + standard: [ + 'defaultValue', + 'description', + 'icon', + 'isActive', + 'label', + 'options', + 'settings', + ], +} as const satisfies Record<'standard' | 'custom', (keyof FlatFieldMetadata)[]>; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-editable-properties.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-editable-properties.constant.ts index 097444c01f7..09588b37540 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-editable-properties.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-editable-properties.constant.ts @@ -1,4 +1,4 @@ import { type FLAT_FIELD_METADATA_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-field-metadata/constants/flat-field-metadata-editable-properties.constant'; export type FlatFieldMetadataEditableProperties = - (typeof FLAT_FIELD_METADATA_EDITABLE_PROPERTIES)[number]; + (typeof FLAT_FIELD_METADATA_EDITABLE_PROPERTIES.custom)[number]; 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 330b63f2a1b..9b676d4f07d 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 @@ -5,13 +5,11 @@ import { } from 'twenty-shared/utils'; import { v4 } from 'uuid'; -import { FIELD_METADATA_STANDARD_OVERRIDES_PROPERTIES } from 'src/engine/metadata-modules/field-metadata/constants/field-metadata-standard-overrides-properties.constant'; import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input'; import { FieldMetadataException, FieldMetadataExceptionCode, } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; -import { type FieldMetadataStandardOverridesProperties } from 'src/engine/metadata-modules/field-metadata/types/field-metadata-standard-overrides-properties.type'; import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { FLAT_FIELD_METADATA_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-field-metadata/constants/flat-field-metadata-editable-properties.constant'; @@ -23,6 +21,7 @@ import { type FlatFieldMetadataUpdateSideEffects, handleFlatFieldMetadataUpdateSideEffect, } from 'src/engine/metadata-modules/flat-field-metadata/utils/handle-flat-field-metadata-update-side-effect.util'; +import { sanitizeRawUpdateFieldInput } from 'src/engine/metadata-modules/flat-field-metadata/utils/sanitize-raw-update-field-input'; import { isStandardMetadata } from 'src/engine/metadata-modules/utils/is-standard-metadata.util'; import { mergeUpdateInExistingRecord } from 'src/utils/merge-update-in-existing-record.util'; @@ -57,10 +56,6 @@ export const fromUpdateFieldInputToFlatFieldMetadata = ({ 'objectMetadataId', 'id', ]); - const updatedEditableFieldProperties = extractAndSanitizeObjectStringFields( - rawUpdateFieldInput, - FLAT_FIELD_METADATA_EDITABLE_PROPERTIES, - ); const existingFlatFieldMetadataToUpdate = findFlatEntityByIdInFlatEntityMaps({ flatEntityId: updateFieldInputInformalProperties.id, @@ -78,62 +73,12 @@ export const fromUpdateFieldInputToFlatFieldMetadata = ({ }; } - if (isStandardMetadata(existingFlatFieldMetadataToUpdate)) { - const invalidUpdatedProperties = Object.keys( - updatedEditableFieldProperties, - ).filter((property) => - FIELD_METADATA_STANDARD_OVERRIDES_PROPERTIES.includes( - property as FieldMetadataStandardOverridesProperties, - ), - ); - - if (invalidUpdatedProperties.length > 0) { - const invalidProperties = invalidUpdatedProperties.join(', '); - - return { - status: 'fail', - error: { - code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT, - message: `Cannot update standard field metadata properties: ${invalidProperties}`, - userFriendlyMessage: msg`Cannot update standard field properties: ${invalidProperties}`, - }, - }; - } - - const updatedStandardFlatFieldMetadata = - FIELD_METADATA_STANDARD_OVERRIDES_PROPERTIES.reduce((acc, property) => { - const isPropertyUpdated = - updatedEditableFieldProperties[property] !== undefined; - - return { - ...acc, - standardOverrides: { - ...acc.standardOverrides, - ...(isPropertyUpdated - ? { [property]: updatedEditableFieldProperties[property] } - : {}), - }, - }; - }, existingFlatFieldMetadataToUpdate); - - return { - status: 'success', - result: { - flatViewsToUpdate: [], - flatViewsToDelete: [], - flatViewGroupsToCreate: [], - flatViewGroupsToDelete: [], - flatViewGroupsToUpdate: [], - flatFieldMetadatasToUpdate: [updatedStandardFlatFieldMetadata], - flatIndexMetadatasToUpdate: [], - flatIndexMetadatasToDelete: [], - flatIndexMetadatasToCreate: [], - flatViewFiltersToDelete: [], - flatViewFiltersToUpdate: [], - flatViewFieldsToDelete: [], - }, - }; - } + const isStandardField = isStandardMetadata(existingFlatFieldMetadataToUpdate); + const { standardOverrides, updatedEditableFieldProperties } = + sanitizeRawUpdateFieldInput({ + existingFlatFieldMetadata: existingFlatFieldMetadataToUpdate, + rawUpdateFieldInput, + }); const flatObjectMetadata = findFlatEntityByIdInFlatEntityMaps({ flatEntityId: existingFlatFieldMetadataToUpdate.objectMetadataId, @@ -176,11 +121,17 @@ export const fromUpdateFieldInputToFlatFieldMetadata = ({ const optimisticiallyUpdatedFlatFieldMetadatas = flatFieldMetadatasToUpdate.reduce( (accumulator, fromFlatFieldMetadata) => { - const toFlatFieldMetadata = mergeUpdateInExistingRecord({ - existing: fromFlatFieldMetadata, - properties: FLAT_FIELD_METADATA_EDITABLE_PROPERTIES, - update: updatedEditableFieldProperties, - }); + const toFlatFieldMetadata = { + ...mergeUpdateInExistingRecord({ + existing: fromFlatFieldMetadata, + properties: + FLAT_FIELD_METADATA_EDITABLE_PROPERTIES[ + isStandardField ? 'standard' : 'custom' + ], + update: updatedEditableFieldProperties, + }), + standardOverrides, + }; const { flatViewGroupsToCreate, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/sanitize-raw-update-field-input.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/sanitize-raw-update-field-input.ts new file mode 100644 index 00000000000..b628f18fb47 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/sanitize-raw-update-field-input.ts @@ -0,0 +1,106 @@ +import { + extractAndSanitizeObjectStringFields, + isDefined, +} from 'twenty-shared/utils'; + +import { FIELD_METADATA_STANDARD_OVERRIDES_PROPERTIES } from 'src/engine/metadata-modules/field-metadata/constants/field-metadata-standard-overrides-properties.constant'; +import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input'; +import { + FieldMetadataException, + FieldMetadataExceptionCode, +} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; +import { FLAT_FIELD_METADATA_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-field-metadata/constants/flat-field-metadata-editable-properties.constant'; +import { type FlatFieldMetadataEditableProperties } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-editable-properties.constant'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { isStandardMetadata } from 'src/engine/metadata-modules/utils/is-standard-metadata.util'; + +type SanitizeRawUpdateFieldInputArgs = { + rawUpdateFieldInput: UpdateFieldInput; + existingFlatFieldMetadata: FlatFieldMetadata; +}; +export const sanitizeRawUpdateFieldInput = ({ + existingFlatFieldMetadata, + rawUpdateFieldInput, +}: SanitizeRawUpdateFieldInputArgs) => { + const isStandardField = isStandardMetadata(existingFlatFieldMetadata); + const updatedEditableFieldProperties = extractAndSanitizeObjectStringFields( + rawUpdateFieldInput, + [ + ...new Set([ + ...FLAT_FIELD_METADATA_EDITABLE_PROPERTIES.standard, + ...FLAT_FIELD_METADATA_EDITABLE_PROPERTIES.custom, + ]), + ], + ); + + if (!isStandardField) { + return { + updatedEditableFieldProperties, + standardOverrides: null, + }; + } + + const invalidUpdatedProperties = Object.keys( + updatedEditableFieldProperties, + ).filter( + (property: FlatFieldMetadataEditableProperties) => + !FLAT_FIELD_METADATA_EDITABLE_PROPERTIES.standard.includes( + property as (typeof FLAT_FIELD_METADATA_EDITABLE_PROPERTIES.standard)[number], + ), + ); + + if (invalidUpdatedProperties.length > 0) { + throw new FieldMetadataException( + `Cannot edit standard field metadata properties: ${invalidUpdatedProperties.join(', ')}`, + FieldMetadataExceptionCode.FIELD_MUTATION_NOT_ALLOWED, + ); + } + + const standardOverrides = FIELD_METADATA_STANDARD_OVERRIDES_PROPERTIES.reduce( + (standardOverrides, property) => { + const propertyValue = updatedEditableFieldProperties[property]; + + const isPropertyUpdated = + updatedEditableFieldProperties[property] !== undefined; + + if (!isPropertyUpdated) { + return standardOverrides; + } + delete updatedEditableFieldProperties[property]; + + if (propertyValue === existingFlatFieldMetadata[property]) { + if ( + isDefined(standardOverrides) && + Object.prototype.hasOwnProperty.call(standardOverrides, property) + ) { + const { [property]: _, ...restOverrides } = standardOverrides; + + return restOverrides; + } + + return standardOverrides; + } + + return { + ...standardOverrides, + [property]: propertyValue, + }; + }, + existingFlatFieldMetadata.standardOverrides, + ); + + if ( + isDefined(standardOverrides) && + Object.keys(standardOverrides).length === 0 + ) { + return { + standardOverrides: null, + updatedEditableFieldProperties, + }; + } + + return { + standardOverrides, + updatedEditableFieldProperties, + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/constants/flat-object-metadata-editable-properties.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/constants/flat-object-metadata-editable-properties.constant.ts new file mode 100644 index 00000000000..2cac35dcc71 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/constants/flat-object-metadata-editable-properties.constant.ts @@ -0,0 +1,19 @@ +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; + +export const FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES = { + custom: [ + 'description', + 'icon', + 'isActive', + 'isLabelSyncedWithName', + 'labelPlural', + 'labelSingular', + 'namePlural', + 'nameSingular', + 'labelIdentifierFieldMetadataId', + ], + standard: ['description', 'icon', 'isActive', 'labelPlural', 'labelSingular'], +} as const satisfies Record< + 'standard' | 'custom', + (keyof FlatObjectMetadata)[] +>; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-update-object-input-to-flat-object-metadata-and-related-flat-entities.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-update-object-input-to-flat-object-metadata-and-related-flat-entities.util.ts index 33a67e022b0..b9d829ab61a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-update-object-input-to-flat-object-metadata-and-related-flat-entities.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-update-object-input-to-flat-object-metadata-and-related-flat-entities.util.ts @@ -1,28 +1,24 @@ import { - extractAndSanitizeObjectStringFields, isDefined, trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties, } from 'twenty-shared/utils'; -import { ALL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY } from 'src/engine/metadata-modules/flat-entity/constant/all-flat-entity-properties-to-compare-and-stringify.constant'; import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; -import { type FlatEntityPropertiesToCompare } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-properties-to-compare.type'; import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; -import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; -import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; +import { FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-object-metadata/constants/flat-object-metadata-editable-properties.constant'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; -import { recomputeIndexAfterFlatObjectMetadataSingularNameUpdate } from 'src/engine/metadata-modules/flat-object-metadata/utils/recompute-index-after-flat-object-metadata-singular-name-update.util'; -import { recomputeViewFieldIdentifierAfterFlatObjectIdentifierUpdate } from 'src/engine/metadata-modules/flat-object-metadata/utils/recompute-view-field-identifier-after-flat-object-identifier-update.util'; -import { renameRelatedMorphFieldOnObjectNamesUpdate } from 'src/engine/metadata-modules/flat-object-metadata/utils/rename-related-morph-field-on-object-names-update.util'; -import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type'; -import { OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES } from 'src/engine/metadata-modules/object-metadata/constants/object-metadata-standard-overrides-properties.constant'; +import { + type FlatObjectMetadataUpdateSideEffects, + handleFlatObjectMetadataUpdateSideEffect, +} from 'src/engine/metadata-modules/flat-object-metadata/utils/handle-flat-object-metadata-update-side-effect.util'; +import { sanitizeRawUpdateObjectInput } from 'src/engine/metadata-modules/flat-object-metadata/utils/sanitize-raw-update-object-input'; import { type UpdateOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input'; import { ObjectMetadataException, ObjectMetadataExceptionCode, } from 'src/engine/metadata-modules/object-metadata/object-metadata.exception'; -import { type ObjectMetadataStandardOverridesProperties } from 'src/engine/metadata-modules/object-metadata/types/object-metadata-standard-overrides-properties.types'; import { isStandardMetadata } from 'src/engine/metadata-modules/utils/is-standard-metadata.util'; +import { mergeUpdateInExistingRecord } from 'src/utils/merge-update-in-existing-record.util'; type FromUpdateObjectInputToFlatObjectMetadataArgs = { updateObjectInput: UpdateOneObjectInput; @@ -35,24 +31,6 @@ type FromUpdateObjectInputToFlatObjectMetadataArgs = { | 'flatViewMaps' >; -const objectMetadataEditableProperties = - ALL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY.objectMetadata.propertiesToCompare.filter( - ( - property, - ): property is Exclude< - FlatEntityPropertiesToCompare<'objectMetadata'>, - 'standardOverrides' - > => property !== 'standardOverrides', - ); - -type UpdatedFlatObjectAndRelatedFlatEntities = { - flatObjectMetadata: FlatObjectMetadata; - otherObjectFlatFieldMetadataToUpdate: FlatFieldMetadata[]; - flatIndexMetadataToUpdate: FlatIndexMetadata[]; - flatViewFieldToUpdate: FlatViewField[]; - flatViewFieldToCreate: FlatViewField[]; -}; - export const fromUpdateObjectInputToFlatObjectMetadataAndRelatedFlatEntities = ({ flatObjectMetadataMaps: existingFlatObjectMetadataMaps, @@ -61,171 +39,65 @@ export const fromUpdateObjectInputToFlatObjectMetadataAndRelatedFlatEntities = flatFieldMetadataMaps, flatViewFieldMaps, flatViewMaps, - }: FromUpdateObjectInputToFlatObjectMetadataArgs): UpdatedFlatObjectAndRelatedFlatEntities => { + }: FromUpdateObjectInputToFlatObjectMetadataArgs): FlatObjectMetadataUpdateSideEffects & { + flatObjectMetadataToUpdate: FlatObjectMetadata; + } => { const { id: objectMetadataIdToUpdate } = trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties( rawUpdateObjectInput, ['id'], ); - const updatedEditableObjectProperties = - extractAndSanitizeObjectStringFields( - rawUpdateObjectInput.update, - objectMetadataEditableProperties, - ); - const flatObjectMetadataToUpdate = findFlatEntityByIdInFlatEntityMaps({ + const existingFlatObjectMetadata = findFlatEntityByIdInFlatEntityMaps({ flatEntityMaps: existingFlatObjectMetadataMaps, flatEntityId: objectMetadataIdToUpdate, }); - if (!isDefined(flatObjectMetadataToUpdate)) { + if (!isDefined(existingFlatObjectMetadata)) { throw new ObjectMetadataException( 'Object to update not found', ObjectMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND, ); } - if (isStandardMetadata(flatObjectMetadataToUpdate)) { - const invalidUpdatedProperties = Object.keys( - updatedEditableObjectProperties, - ).filter( - (property) => - !OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES.includes( - property as ObjectMetadataStandardOverridesProperties, - ), - ); + const isStandardObject = isStandardMetadata(existingFlatObjectMetadata); + const { standardOverrides, updatedEditableObjectProperties } = + sanitizeRawUpdateObjectInput({ + existingFlatObjectMetadata, + rawUpdateObjectInput, + }); - if (invalidUpdatedProperties.length > 0) { - throw new ObjectMetadataException( - `Cannot edit standard object metadata properties: ${invalidUpdatedProperties.join(', ')}`, - ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT, - ); - } - - const updatedStandardFlatObjectMetadata = - OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES.reduce( - (acc, property) => { - const isPropertyUpdated = - updatedEditableObjectProperties[property] !== undefined; - - return { - ...acc, - standardOverrides: { - ...acc.standardOverrides, - ...(isPropertyUpdated - ? { [property]: updatedEditableObjectProperties[property] } - : {}), - }, - }; - }, - flatObjectMetadataToUpdate, - ); - - return { - flatObjectMetadata: updatedStandardFlatObjectMetadata, - otherObjectFlatFieldMetadataToUpdate: [], - flatIndexMetadataToUpdate: [], - flatViewFieldToUpdate: [], - flatViewFieldToCreate: [], - }; - } - - const initialAccumulator: UpdatedFlatObjectAndRelatedFlatEntities = { - flatObjectMetadata: flatObjectMetadataToUpdate, - otherObjectFlatFieldMetadataToUpdate: [], - flatIndexMetadataToUpdate: [], - flatViewFieldToUpdate: [], - flatViewFieldToCreate: [], + const toFlatObjectMetadata = { + ...mergeUpdateInExistingRecord({ + existing: existingFlatObjectMetadata, + properties: + FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES[ + isStandardObject ? 'standard' : 'custom' + ], + update: updatedEditableObjectProperties, + }), + standardOverrides, }; - return objectMetadataEditableProperties.reduce( - ( - { - flatObjectMetadata, - otherObjectFlatFieldMetadataToUpdate, - flatIndexMetadataToUpdate, - flatViewFieldToUpdate, - flatViewFieldToCreate, - }, - property, - ) => { - const updatedPropertyValue = updatedEditableObjectProperties[property]; - const isPropertyUpdated = - updatedPropertyValue !== undefined && - flatObjectMetadata[property] !== updatedPropertyValue; + const { + flatIndexMetadatasToUpdate, + flatViewFieldsToCreate, + flatViewFieldsToUpdate, + otherObjectFlatFieldMetadatasToUpdate, + } = handleFlatObjectMetadataUpdateSideEffect({ + fromFlatObjectMetadata: existingFlatObjectMetadata, + toFlatObjectMetadata, + flatFieldMetadataMaps, + flatIndexMaps, + flatViewFieldMaps, + flatViewMaps, + }); - if (!isPropertyUpdated) { - return { - flatObjectMetadata, - otherObjectFlatFieldMetadataToUpdate, - flatIndexMetadataToUpdate, - flatViewFieldToUpdate, - flatViewFieldToCreate, - }; - } - - const updatedFlatObjectMetadata = { - ...flatObjectMetadata, - [property]: updatedPropertyValue, - }; - - const newUpdatedOtherObjectFlatFieldMetadatas = - property === 'nameSingular' || property === 'namePlural' - ? renameRelatedMorphFieldOnObjectNamesUpdate({ - flatFieldMetadataMaps, - fromFlatObjectMetadata: updatedFlatObjectMetadata, - toFlatObjectMetadata: updatedFlatObjectMetadata, - }) - : []; - - const newUpdatedFlatIndexMetadatas = - property === 'nameSingular' - ? recomputeIndexAfterFlatObjectMetadataSingularNameUpdate({ - flatFieldMetadataMaps, - existingFlatObjectMetadata: flatObjectMetadataToUpdate, - flatIndexMaps, - updatedSingularName: updatedFlatObjectMetadata.nameSingular, - }) - : []; - - const { - flatViewFieldToCreate: newFlatViewFieldToCreate, - flatViewFieldToUpdate: newFlatViewFieldToUpdate, - } = - property === 'labelIdentifierFieldMetadataId' && - isDefined(updatedFlatObjectMetadata.labelIdentifierFieldMetadataId) - ? recomputeViewFieldIdentifierAfterFlatObjectIdentifierUpdate({ - existingFlatObjectMetadata: flatObjectMetadataToUpdate, - flatViewFieldMaps, - flatViewMaps, - updatedLabelIdentifierFieldMetadataId: - updatedFlatObjectMetadata.labelIdentifierFieldMetadataId, - }) - : { - flatViewFieldToCreate: [], - flatViewFieldToUpdate: [], - }; - - return { - flatObjectMetadata: updatedFlatObjectMetadata, - otherObjectFlatFieldMetadataToUpdate: [ - ...otherObjectFlatFieldMetadataToUpdate, - ...newUpdatedOtherObjectFlatFieldMetadatas, - ], - flatViewFieldToUpdate: [ - ...flatViewFieldToUpdate, - ...newFlatViewFieldToUpdate, - ], - flatViewFieldToCreate: [ - ...flatViewFieldToCreate, - ...newFlatViewFieldToCreate, - ], - flatIndexMetadataToUpdate: [ - ...flatIndexMetadataToUpdate, - ...newUpdatedFlatIndexMetadatas, - ], - }; - }, - initialAccumulator, - ); + return { + flatIndexMetadatasToUpdate, + flatObjectMetadataToUpdate: toFlatObjectMetadata, + flatViewFieldsToCreate, + flatViewFieldsToUpdate, + otherObjectFlatFieldMetadatasToUpdate, + }; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/handle-flat-object-metadata-update-side-effect.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/handle-flat-object-metadata-update-side-effect.util.ts new file mode 100644 index 00000000000..2e011519fa8 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/handle-flat-object-metadata-update-side-effect.util.ts @@ -0,0 +1,83 @@ +import { type FromTo } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +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 { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { recomputeIndexAfterFlatObjectMetadataSingularNameUpdate } from 'src/engine/metadata-modules/flat-object-metadata/utils/recompute-index-after-flat-object-metadata-singular-name-update.util'; +import { recomputeViewFieldIdentifierAfterFlatObjectIdentifierUpdate } from 'src/engine/metadata-modules/flat-object-metadata/utils/recompute-view-field-identifier-after-flat-object-identifier-update.util'; +import { renameRelatedMorphFieldOnObjectNamesUpdate } from 'src/engine/metadata-modules/flat-object-metadata/utils/rename-related-morph-field-on-object-names-update.util'; +import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type'; + +export type FlatObjectMetadataUpdateSideEffects = { + otherObjectFlatFieldMetadatasToUpdate: FlatFieldMetadata[]; + flatViewFieldsToUpdate: FlatViewField[]; + flatViewFieldsToCreate: FlatViewField[]; + flatIndexMetadatasToUpdate: FlatIndexMetadata[]; +}; + +type HandleFlatObjectMetadataUpdateSideEffectArgs = FromTo< + FlatObjectMetadata, + 'flatObjectMetadata' +> & + Pick< + AllFlatEntityMaps, + | 'flatFieldMetadataMaps' + | 'flatViewFieldMaps' + | 'flatIndexMaps' + | 'flatViewMaps' + >; + +export const handleFlatObjectMetadataUpdateSideEffect = ({ + flatIndexMaps, + flatFieldMetadataMaps, + flatViewFieldMaps, + flatViewMaps, + fromFlatObjectMetadata, + toFlatObjectMetadata, +}: HandleFlatObjectMetadataUpdateSideEffectArgs): FlatObjectMetadataUpdateSideEffects => { + const otherObjectFlatFieldMetadatasToUpdate = + fromFlatObjectMetadata.nameSingular !== toFlatObjectMetadata.nameSingular || + fromFlatObjectMetadata.namePlural !== toFlatObjectMetadata.namePlural + ? renameRelatedMorphFieldOnObjectNamesUpdate({ + flatFieldMetadataMaps, + fromFlatObjectMetadata, + toFlatObjectMetadata, + }) + : []; + + const flatIndexMetadatasToUpdate = + fromFlatObjectMetadata.nameSingular !== toFlatObjectMetadata.nameSingular + ? recomputeIndexAfterFlatObjectMetadataSingularNameUpdate({ + flatFieldMetadataMaps, + existingFlatObjectMetadata: fromFlatObjectMetadata, + flatIndexMaps, + updatedSingularName: toFlatObjectMetadata.nameSingular, + }) + : []; + + const { flatViewFieldsToCreate, flatViewFieldsToUpdate } = + fromFlatObjectMetadata.labelIdentifierFieldMetadataId !== + toFlatObjectMetadata.labelIdentifierFieldMetadataId && + isDefined(toFlatObjectMetadata.labelIdentifierFieldMetadataId) && + isDefined(fromFlatObjectMetadata.labelIdentifierFieldMetadataId) + ? recomputeViewFieldIdentifierAfterFlatObjectIdentifierUpdate({ + existingFlatObjectMetadata: fromFlatObjectMetadata, + flatViewFieldMaps, + flatViewMaps, + updatedLabelIdentifierFieldMetadataId: + toFlatObjectMetadata.labelIdentifierFieldMetadataId, + }) + : { + flatViewFieldsToCreate: [], + flatViewFieldsToUpdate: [], + }; + + return { + flatIndexMetadatasToUpdate, + flatViewFieldsToCreate, + flatViewFieldsToUpdate, + otherObjectFlatFieldMetadatasToUpdate, + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/recompute-view-field-identifier-after-flat-object-identifier-update.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/recompute-view-field-identifier-after-flat-object-identifier-update.util.ts index 9f2544881a0..f3eb3d2b142 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/recompute-view-field-identifier-after-flat-object-identifier-update.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/recompute-view-field-identifier-after-flat-object-identifier-update.util.ts @@ -13,8 +13,8 @@ type RecomputeViewFieldIdentifierAfterFlatObjectIdentifierUpdateArgs = { } & Pick; type FlatViewFieldToCreateAndUpdate = { - flatViewFieldToCreate: FlatViewField[]; - flatViewFieldToUpdate: FlatViewField[]; + flatViewFieldsToCreate: FlatViewField[]; + flatViewFieldsToUpdate: FlatViewField[]; }; export const recomputeViewFieldIdentifierAfterFlatObjectIdentifierUpdate = ({ existingFlatObjectMetadata, @@ -28,8 +28,8 @@ export const recomputeViewFieldIdentifierAfterFlatObjectIdentifierUpdate = ({ }); const accumulator: FlatViewFieldToCreateAndUpdate = { - flatViewFieldToCreate: [], - flatViewFieldToUpdate: [], + flatViewFieldsToCreate: [], + flatViewFieldsToUpdate: [], }; for (const flatView of flatViews) { @@ -69,7 +69,7 @@ export const recomputeViewFieldIdentifierAfterFlatObjectIdentifierUpdate = ({ applicationId: existingFlatObjectMetadata.applicationId, }; - accumulator.flatViewFieldToCreate.push(flatViewFieldToCreate); + accumulator.flatViewFieldsToCreate.push(flatViewFieldToCreate); } else if ( labelMetadataIdentifierViewField.position > lowestViewFieldPosition ) { @@ -78,7 +78,7 @@ export const recomputeViewFieldIdentifierAfterFlatObjectIdentifierUpdate = ({ position: lowestViewFieldPosition - 1, }; - accumulator.flatViewFieldToUpdate.push(updatedFlatViewField); + accumulator.flatViewFieldsToUpdate.push(updatedFlatViewField); } } diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/sanitize-raw-update-object-input.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/sanitize-raw-update-object-input.ts new file mode 100644 index 00000000000..2166214451d --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/sanitize-raw-update-object-input.ts @@ -0,0 +1,108 @@ +import { + extractAndSanitizeObjectStringFields, + isDefined, +} from 'twenty-shared/utils'; + +import { FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-object-metadata/constants/flat-object-metadata-editable-properties.constant'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES } from 'src/engine/metadata-modules/object-metadata/constants/object-metadata-standard-overrides-properties.constant'; +import { type UpdateOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input'; +import { + ObjectMetadataException, + ObjectMetadataExceptionCode, +} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception'; +import { type ObjectMetadataStandardOverridesProperties } from 'src/engine/metadata-modules/object-metadata/types/object-metadata-standard-overrides-properties.types'; +import { isStandardMetadata } from 'src/engine/metadata-modules/utils/is-standard-metadata.util'; + +type SanitizeRawUpdateObjectInputArgs = { + rawUpdateObjectInput: UpdateOneObjectInput; + existingFlatObjectMetadata: FlatObjectMetadata; +}; + +export const sanitizeRawUpdateObjectInput = ({ + existingFlatObjectMetadata, + rawUpdateObjectInput, +}: SanitizeRawUpdateObjectInputArgs) => { + const isStandardObject = isStandardMetadata(existingFlatObjectMetadata); + const updatedEditableObjectProperties = extractAndSanitizeObjectStringFields( + rawUpdateObjectInput.update, + [ + ...new Set([ + ...FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES.standard, + ...FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES.custom, + ]), + ], + ); + + if (!isStandardObject) { + return { + updatedEditableObjectProperties, + standardOverrides: null, + }; + } + + const invalidUpdatedProperties = Object.keys( + updatedEditableObjectProperties, + ).filter( + (property) => + !FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES.standard.includes( + property as ObjectMetadataStandardOverridesProperties, + ), + ); + + if (invalidUpdatedProperties.length > 0) { + throw new ObjectMetadataException( + `Cannot edit standard object metadata properties: ${invalidUpdatedProperties.join(', ')}`, + ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT, + ); + } + + const standardOverrides = + OBJECT_METADATA_STANDARD_OVERRIDES_PROPERTIES.reduce( + (standardOverrides, property) => { + const propertyValue = updatedEditableObjectProperties[property]; + + const isPropertyUpdated = + updatedEditableObjectProperties[property] !== undefined; + + if (!isPropertyUpdated) { + return standardOverrides; + } + delete updatedEditableObjectProperties[property]; + + if (propertyValue === existingFlatObjectMetadata[property]) { + if ( + isDefined(standardOverrides) && + Object.prototype.hasOwnProperty.call(standardOverrides, property) + ) { + const { [property]: _, ...restOverrides } = standardOverrides; + + return restOverrides; + } + + return standardOverrides; + } + + return { + ...standardOverrides, + [property]: propertyValue, + }; + }, + existingFlatObjectMetadata.standardOverrides, + ); + + if ( + isDefined(standardOverrides) && + Object.keys(standardOverrides).length === 0 + ) { + return { + standardOverrides: null, + updatedEditableObjectProperties, + }; + } + + return { + standardOverrides, + updatedEditableObjectProperties, + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata-v2.service.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata-v2.service.ts index 03f679b6f39..b774a47b2f4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata-v2.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata-v2.service.ts @@ -67,11 +67,11 @@ export class ObjectMetadataServiceV2 { ); const { - flatObjectMetadata: optimisticallyUpdatedFlatObjectMetadata, - otherObjectFlatFieldMetadataToUpdate: otherObjectFlatFieldMetadatas, - flatIndexMetadataToUpdate, - flatViewFieldToUpdate, - flatViewFieldToCreate, + otherObjectFlatFieldMetadatasToUpdate, + flatObjectMetadataToUpdate, + flatIndexMetadatasToUpdate, + flatViewFieldsToCreate, + flatViewFieldsToUpdate, } = fromUpdateObjectInputToFlatObjectMetadataAndRelatedFlatEntities({ flatFieldMetadataMaps: existingFlatFieldMetadataMaps, flatObjectMetadataMaps: existingFlatObjectMetadataMaps, @@ -89,25 +89,25 @@ export class ObjectMetadataServiceV2 { flatEntityMaps: existingFlatObjectMetadataMaps, flatEntityToCreate: [], flatEntityToDelete: [], - flatEntityToUpdate: [optimisticallyUpdatedFlatObjectMetadata], + flatEntityToUpdate: [flatObjectMetadataToUpdate], }), flatIndexMaps: computeFlatEntityMapsFromTo({ flatEntityMaps: existingFlatIndexMaps, flatEntityToCreate: [], flatEntityToDelete: [], - flatEntityToUpdate: flatIndexMetadataToUpdate, + flatEntityToUpdate: flatIndexMetadatasToUpdate, }), flatFieldMetadataMaps: computeFlatEntityMapsFromTo({ flatEntityMaps: existingFlatFieldMetadataMaps, flatEntityToCreate: [], flatEntityToDelete: [], - flatEntityToUpdate: otherObjectFlatFieldMetadatas, + flatEntityToUpdate: otherObjectFlatFieldMetadatasToUpdate, }), flatViewFieldMaps: computeFlatEntityMapsFromTo({ flatEntityMaps: existingFlatViewFieldMaps, - flatEntityToCreate: flatViewFieldToCreate, + flatEntityToCreate: flatViewFieldsToCreate, flatEntityToDelete: [], - flatEntityToUpdate: flatViewFieldToUpdate, + flatEntityToUpdate: flatViewFieldsToUpdate, }), }, dependencyAllFlatEntityMaps: { @@ -136,9 +136,7 @@ export class ObjectMetadataServiceV2 { ); const updatedFlatObjectMetadata = - recomputedFlatObjectMetadataMaps.byId[ - optimisticallyUpdatedFlatObjectMetadata.id - ]; + recomputedFlatObjectMetadataMaps.byId[flatObjectMetadataToUpdate.id]; if (!isDefined(updatedFlatObjectMetadata)) { throw new ObjectMetadataException( 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 40a09c6ca40..ce5295cf348 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 @@ -108,8 +108,6 @@ export class FlatViewGroupValidatorService { message: t`View group to delete not found`, userFriendlyMessage: msg`View group to delete not found`, }); - - return validationResult; } return validationResult; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/failing-update-one-standard-field-metadata.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/failing-update-one-standard-field-metadata.integration-spec.ts.snap new file mode 100644 index 00000000000..2ef96b9dd6a --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/failing-update-one-standard-field-metadata.integration-spec.ts.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Standard field metadata update should be ignored when trying to update isLabelSyncedWithName on standard field 1`] = ` +{ + "extensions": { + "code": "FORBIDDEN", + "subCode": "FIELD_MUTATION_NOT_ALLOWED", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Cannot edit standard field metadata properties: isLabelSyncedWithName", + "name": "ForbiddenError", +} +`; + +exports[`Standard field metadata update should be ignored when trying to update name on standard field 1`] = ` +{ + "extensions": { + "code": "FORBIDDEN", + "subCode": "FIELD_MUTATION_NOT_ALLOWED", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Cannot edit standard field metadata properties: name", + "name": "ForbiddenError", +} +`; + +exports[`Standard field metadata update should be ignored when trying to update several forbidden properties 1`] = ` +{ + "extensions": { + "code": "FORBIDDEN", + "subCode": "FIELD_MUTATION_NOT_ALLOWED", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Cannot edit standard field metadata properties: isLabelSyncedWithName, name", + "name": "ForbiddenError", +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/successful-update-one-standard-field-metadata.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/successful-update-one-standard-field-metadata.integration-spec.ts.snap new file mode 100644 index 00000000000..b2cdef322ed --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/successful-update-one-standard-field-metadata.integration-spec.ts.snap @@ -0,0 +1,326 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Standard field metadata update should succeed when setting isActive to false 1`] = ` +{ + "defaultValue": "'NEW'", + "description": "Opportunity stage", + "icon": "IconProgressCheck", + "id": Any, + "isActive": false, + "isCustom": false, + "label": "Stage", + "name": "stage", + "options": [ + { + "color": "red", + "id": Any, + "label": "New", + "position": 0, + "value": "NEW", + }, + { + "color": "purple", + "id": Any, + "label": "Screening", + "position": 1, + "value": "SCREENING", + }, + { + "color": "sky", + "id": Any, + "label": "Meeting", + "position": 2, + "value": "MEETING", + }, + { + "color": "turquoise", + "id": Any, + "label": "Proposal", + "position": 3, + "value": "PROPOSAL", + }, + { + "color": "yellow", + "id": Any, + "label": "Customer", + "position": 4, + "value": "CUSTOMER", + }, + ], + "standardOverrides": null, +} +`; + +exports[`Standard field metadata update should succeed when updating defaultValue 1`] = ` +{ + "defaultValue": "'SCREENING'", + "description": "Opportunity stage", + "icon": "IconProgressCheck", + "id": Any, + "isActive": true, + "isCustom": false, + "label": "Stage", + "name": "stage", + "options": [ + { + "color": "red", + "id": Any, + "label": "New", + "position": 0, + "value": "NEW", + }, + { + "color": "purple", + "id": Any, + "label": "Screening", + "position": 1, + "value": "SCREENING", + }, + { + "color": "sky", + "id": Any, + "label": "Meeting", + "position": 2, + "value": "MEETING", + }, + { + "color": "turquoise", + "id": Any, + "label": "Proposal", + "position": 3, + "value": "PROPOSAL", + }, + { + "color": "yellow", + "id": Any, + "label": "Customer", + "position": 4, + "value": "CUSTOMER", + }, + ], + "standardOverrides": null, +} +`; + +exports[`Standard field metadata update should succeed when updating description 1`] = ` +{ + "defaultValue": "'NEW'", + "description": "Opportunity stage", + "icon": "IconProgressCheck", + "id": Any, + "isActive": true, + "isCustom": false, + "label": "Stage", + "name": "stage", + "options": [ + { + "color": "red", + "id": Any, + "label": "New", + "position": 0, + "value": "NEW", + }, + { + "color": "purple", + "id": Any, + "label": "Screening", + "position": 1, + "value": "SCREENING", + }, + { + "color": "sky", + "id": Any, + "label": "Meeting", + "position": 2, + "value": "MEETING", + }, + { + "color": "turquoise", + "id": Any, + "label": "Proposal", + "position": 3, + "value": "PROPOSAL", + }, + { + "color": "yellow", + "id": Any, + "label": "Customer", + "position": 4, + "value": "CUSTOMER", + }, + ], + "standardOverrides": { + "description": "Updated test description for company name field", + "icon": null, + "label": null, + }, +} +`; + +exports[`Standard field metadata update should succeed when updating icon 1`] = ` +{ + "defaultValue": "'NEW'", + "description": "Opportunity stage", + "icon": "IconProgressCheck", + "id": Any, + "isActive": true, + "isCustom": false, + "label": "Stage", + "name": "stage", + "options": [ + { + "color": "red", + "id": Any, + "label": "New", + "position": 0, + "value": "NEW", + }, + { + "color": "purple", + "id": Any, + "label": "Screening", + "position": 1, + "value": "SCREENING", + }, + { + "color": "sky", + "id": Any, + "label": "Meeting", + "position": 2, + "value": "MEETING", + }, + { + "color": "turquoise", + "id": Any, + "label": "Proposal", + "position": 3, + "value": "PROPOSAL", + }, + { + "color": "yellow", + "id": Any, + "label": "Customer", + "position": 4, + "value": "CUSTOMER", + }, + ], + "standardOverrides": { + "description": null, + "icon": "IconBuildingFactory", + "label": null, + }, +} +`; + +exports[`Standard field metadata update should succeed when updating label 1`] = ` +{ + "defaultValue": "'NEW'", + "description": "Opportunity stage", + "icon": "IconProgressCheck", + "id": Any, + "isActive": true, + "isCustom": false, + "label": "Stage", + "name": "stage", + "options": [ + { + "color": "red", + "id": Any, + "label": "New", + "position": 0, + "value": "NEW", + }, + { + "color": "purple", + "id": Any, + "label": "Screening", + "position": 1, + "value": "SCREENING", + }, + { + "color": "sky", + "id": Any, + "label": "Meeting", + "position": 2, + "value": "MEETING", + }, + { + "color": "turquoise", + "id": Any, + "label": "Proposal", + "position": 3, + "value": "PROPOSAL", + }, + { + "color": "yellow", + "id": Any, + "label": "Customer", + "position": 4, + "value": "CUSTOMER", + }, + ], + "standardOverrides": { + "description": null, + "icon": null, + "label": "Business Name", + }, +} +`; + +exports[`Standard field metadata update should succeed when updating options 1`] = ` +{ + "defaultValue": "'NEW'", + "description": "Opportunity stage", + "icon": "IconProgressCheck", + "id": Any, + "isActive": true, + "isCustom": false, + "label": "Stage", + "name": "stage", + "options": [ + { + "color": "red", + "id": Any, + "label": "New Lead", + "position": 0, + "value": "NEW", + }, + { + "color": "purple", + "id": Any, + "label": "Under Review", + "position": 1, + "value": "SCREENING", + }, + { + "color": "sky", + "id": Any, + "label": "Meeting", + "position": 2, + "value": "MEETING", + }, + { + "color": "turquoise", + "id": Any, + "label": "Proposal Sent", + "position": 3, + "value": "PROPOSAL", + }, + { + "color": "yellow", + "id": Any, + "label": "Customer", + "position": 4, + "value": "CUSTOMER", + }, + { + "color": "green", + "id": Any, + "label": "Closed", + "position": 5, + "value": "CLOSED", + }, + ], + "standardOverrides": null, +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/failing-update-one-standard-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/failing-update-one-standard-field-metadata.integration-spec.ts new file mode 100644 index 00000000000..f69407fb6cd --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/failing-update-one-standard-field-metadata.integration-spec.ts @@ -0,0 +1,105 @@ +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util'; +import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util'; +import { jestExpectToBeDefined } from 'test/utils/expect-to-be-defined.util.test'; +import { + eachTestingContextFilter, + type EachTestingContext, +} from 'twenty-shared/testing'; + +import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input'; + +type UpdateOneStandardFieldMetadataTestingContext = EachTestingContext< + Partial +>[]; + +const failingUpdateTestsUseCase: UpdateOneStandardFieldMetadataTestingContext = + [ + { + title: 'when trying to update name on standard field', + context: { + name: 'newName', + }, + }, + { + title: 'when trying to update isLabelSyncedWithName on standard field', + context: { + isLabelSyncedWithName: false, + }, + }, + { + title: 'when trying to update several forbidden properties', + context: { + name: 'newName', + isLabelSyncedWithName: true, + }, + }, + ]; + +const allTestsUseCases = [...failingUpdateTestsUseCase]; + +describe('Standard field metadata update should be ignored', () => { + let companyNameFieldMetadataId: string; + + beforeAll(async () => { + const { objects } = await findManyObjectMetadata({ + expectToFail: false, + input: { + filter: {}, + paging: { first: 100 }, + }, + gqlFields: ` + id + nameSingular + fieldsList { + id + name + label + description + icon + isActive + isCustom + isLabelSyncedWithName + } + `, + }); + + const companyObject = objects.find((o) => o.nameSingular === 'company'); + + jestExpectToBeDefined(companyObject); + + const companyNameField = companyObject.fieldsList?.find( + (field) => field.name === 'name' && !field.isCustom, + ); + + jestExpectToBeDefined(companyNameField); + companyNameFieldMetadataId = companyNameField.id; + }); + + it.each(eachTestingContextFilter(allTestsUseCases))( + '$title', + async ({ context }) => { + const { errors } = await updateOneFieldMetadata({ + input: { + idToUpdate: companyNameFieldMetadataId, + updatePayload: context, + }, + expectToFail: true, + gqlFields: ` + id + name + label + description + icon + isActive + isCustom + isLabelSyncedWithName + `, + }); + + expectOneNotInternalServerErrorSnapshot({ + errors, + }); + }, + ); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/successful-update-one-standard-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/successful-update-one-standard-field-metadata.integration-spec.ts new file mode 100644 index 00000000000..e037c8b3964 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/successful-update-one-standard-field-metadata.integration-spec.ts @@ -0,0 +1,179 @@ +import { updateOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata.util'; +import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util'; +import { jestExpectToBeDefined } from 'test/utils/expect-to-be-defined.util.test'; +import { extractRecordIdsAndDatesAsExpectAny } from 'test/utils/extract-record-ids-and-dates-as-expect-any'; +import { + eachTestingContextFilter, + type EachTestingContext, +} from 'twenty-shared/testing'; + +import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; +import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input'; + +type UpdateOneStandardFieldMetadataTestingContext = EachTestingContext< + Partial +>[]; + +const successfulUpdateTestsUseCase: UpdateOneStandardFieldMetadataTestingContext = + [ + { + title: 'when updating description', + context: { + description: 'Updated test description for company name field', + }, + }, + { + title: 'when updating icon', + context: { + icon: 'IconBuildingFactory', + }, + }, + { + title: 'when setting isActive to false', + context: { + isActive: false, + }, + }, + { + title: 'when updating label', + context: { + label: 'Business Name', + }, + }, + { + title: 'when updating options', + context: { + options: [ + { value: 'NEW', label: 'New Lead', position: 0, color: 'red' }, + { + value: 'SCREENING', + label: 'Under Review', + position: 1, + color: 'purple', + }, + { value: 'MEETING', label: 'Meeting', position: 2, color: 'sky' }, + { + value: 'PROPOSAL', + label: 'Proposal Sent', + position: 3, + color: 'turquoise', + }, + { + value: 'CUSTOMER', + label: 'Customer', + position: 4, + color: 'yellow', + }, + { value: 'CLOSED', label: 'Closed', position: 5, color: 'green' }, + ], + }, + }, + { + title: 'when updating defaultValue', + context: { + defaultValue: "'SCREENING'", + }, + }, + ]; + +describe('Standard field metadata update should succeed', () => { + let originalStageFieldMetadata: FieldMetadataDTO; + + beforeAll(async () => { + const { objects } = await findManyObjectMetadata({ + expectToFail: false, + input: { + filter: {}, + paging: { first: 100 }, + }, + gqlFields: ` + id + nameSingular + fieldsList { + id + name + label + description + icon + isActive + isCustom + type + options + settings + defaultValue + standardOverrides { + label + description + icon + } + } + `, + }); + + const opportunityObject = objects.find( + (o) => o.nameSingular === 'opportunity', + ); + + jestExpectToBeDefined(opportunityObject); + + const opportunityStageField = opportunityObject.fieldsList?.find( + (field) => field.name === 'stage' && !field.isCustom, + ); + + jestExpectToBeDefined(opportunityStageField); + originalStageFieldMetadata = opportunityStageField; + }); + + afterEach(async () => { + await updateOneFieldMetadata({ + expectToFail: false, + input: { + idToUpdate: originalStageFieldMetadata.id, + updatePayload: { + label: originalStageFieldMetadata.label, + description: originalStageFieldMetadata.description, + icon: originalStageFieldMetadata.icon, + isActive: originalStageFieldMetadata.isActive, + options: originalStageFieldMetadata.options, + defaultValue: originalStageFieldMetadata.defaultValue, + }, + }, + }); + }); + + it.each(eachTestingContextFilter(successfulUpdateTestsUseCase))( + '$title', + async ({ context }) => { + const updatePayload = context; + + const { data } = await updateOneFieldMetadata({ + input: { + idToUpdate: originalStageFieldMetadata.id, + updatePayload, + }, + expectToFail: false, + gqlFields: ` + id + name + label + description + icon + isActive + isCustom + options + defaultValue + standardOverrides { + label + description + icon + } + `, + }); + + expect(data.updateOneField.id).toBe(originalStageFieldMetadata.id); + expect(data.updateOneField).toMatchSnapshot( + extractRecordIdsAndDatesAsExpectAny({ ...data.updateOneField }), + ); + }, + ); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-update-one-standard-object-metadata.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-update-one-standard-object-metadata.integration-spec.ts.snap new file mode 100644 index 00000000000..fb6ce3ceac5 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-update-one-standard-object-metadata.integration-spec.ts.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Standard object metadata update should fail when trying to update labelIdentifierFieldMetadataId on standard object 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "INVALID_OBJECT_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Cannot edit standard object metadata properties: labelIdentifierFieldMetadataId", + "name": "UserInputError", +} +`; + +exports[`Standard object metadata update should fail when trying to update namePlural on standard object 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "INVALID_OBJECT_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Cannot edit standard object metadata properties: namePlural", + "name": "UserInputError", +} +`; + +exports[`Standard object metadata update should fail when trying to update nameSingular on standard object 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "INVALID_OBJECT_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Cannot edit standard object metadata properties: nameSingular", + "name": "UserInputError", +} +`; + +exports[`Standard object metadata update should fail when trying to update several forbidden properties 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "INVALID_OBJECT_INPUT", + "userFriendlyMessage": "An error occurred.", + }, + "message": "Cannot edit standard object metadata properties: isLabelSyncedWithName, namePlural", + "name": "UserInputError", +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/successful-update-one-standard-object-metadata.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/successful-update-one-standard-object-metadata.integration-spec.ts.snap new file mode 100644 index 00000000000..7247356eeba --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/successful-update-one-standard-object-metadata.integration-spec.ts.snap @@ -0,0 +1,71 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Standard object metadata update should succeed when setting isActive to false 1`] = ` +{ + "description": "A company", + "icon": "IconBuildingSkyscraper", + "id": Any, + "isActive": false, + "labelPlural": "Companies", + "labelSingular": "Company", + "namePlural": "companies", + "nameSingular": "company", + "shortcut": "C", + "standardOverrides": null, +} +`; + +exports[`Standard object metadata update should succeed when updating description 1`] = ` +{ + "description": "Updated test description for company", + "icon": "IconBuildingSkyscraper", + "id": Any, + "isActive": true, + "labelPlural": "Companies", + "labelSingular": "Company", + "namePlural": "companies", + "nameSingular": "company", + "shortcut": "C", + "standardOverrides": { + "description": "Updated test description for company", + "icon": null, + "labelPlural": null, + "labelSingular": null, + }, +} +`; + +exports[`Standard object metadata update should succeed when updating icon 1`] = ` +{ + "description": "A company", + "icon": "IconBuildingSkyscraper", + "id": Any, + "isActive": true, + "labelPlural": "Companies", + "labelSingular": "Company", + "namePlural": "companies", + "nameSingular": "company", + "shortcut": "C", + "standardOverrides": null, +} +`; + +exports[`Standard object metadata update should succeed when updating labelSingular and labelPlural 1`] = ` +{ + "description": "A company", + "icon": "IconBuildingSkyscraper", + "id": Any, + "isActive": true, + "labelPlural": "Businesses", + "labelSingular": "Business", + "namePlural": "companies", + "nameSingular": "company", + "shortcut": "C", + "standardOverrides": { + "description": null, + "icon": null, + "labelPlural": "Businesses", + "labelSingular": "Business", + }, +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/failing-update-one-standard-object-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/object-metadata/failing-update-one-standard-object-metadata.integration-spec.ts new file mode 100644 index 00000000000..471b7cc789a --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/failing-update-one-standard-object-metadata.integration-spec.ts @@ -0,0 +1,115 @@ +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util'; +import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util'; +import { jestExpectToBeDefined } from 'test/utils/expect-to-be-defined.util.test'; +import { + eachTestingContextFilter, + type EachTestingContext, +} from 'twenty-shared/testing'; + +import { type UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input'; + +type TestingRuntimeContext = { + objectMetadataId: string; + textFieldMetadataId: string; +}; + +type UpdateOneStandardObjectMetadataTestingContext = EachTestingContext< + | ((args: TestingRuntimeContext) => Partial) + | Partial +>[]; + +const failingUpdateTestsUseCase: UpdateOneStandardObjectMetadataTestingContext = + [ + { + title: 'when trying to update nameSingular on standard object', + context: { + nameSingular: 'newCompany', + }, + }, + { + title: 'when trying to update namePlural on standard object', + context: { + namePlural: 'newCompanies', + }, + }, + { + title: + 'when trying to update labelIdentifierFieldMetadataId on standard object', + context: ({ textFieldMetadataId }) => ({ + labelIdentifierFieldMetadataId: textFieldMetadataId, + }), + }, + { + title: 'when trying to update several forbidden properties', + context: { + namePlural: 'newCompanies', + isActive: false, + isLabelSyncedWithName: false, + shortcut: 'whatever', + }, + }, + ]; + +describe('Standard object metadata update should fail', () => { + let companyObjectMetadataId: string; + let companyNameFieldMetadataId: string; + + beforeAll(async () => { + const { objects } = await findManyObjectMetadata({ + expectToFail: false, + input: { + filter: {}, + paging: { first: 100 }, + }, + gqlFields: ` + id + nameSingular + namePlural + labelSingular + labelPlural + isLabelSyncedWithName + fieldsList { + id + name + type + } + `, + }); + + const companyObject = objects.find((o) => o.nameSingular === 'company'); + + jestExpectToBeDefined(companyObject); + companyObjectMetadataId = companyObject.id; + + const nameField = companyObject.fieldsList?.find( + (field: { name: string }) => field.name === 'name', + ); + + jestExpectToBeDefined(nameField); + companyNameFieldMetadataId = nameField!.id; + }); + + it.each(eachTestingContextFilter(failingUpdateTestsUseCase))( + '$title', + async ({ context }) => { + const updatePayload = + typeof context === 'function' + ? context({ + objectMetadataId: companyObjectMetadataId, + textFieldMetadataId: companyNameFieldMetadataId, + }) + : context; + + const { errors } = await updateOneObjectMetadata({ + input: { + idToUpdate: companyObjectMetadataId, + updatePayload, + }, + expectToFail: true, + }); + + expectOneNotInternalServerErrorSnapshot({ errors }); + }, + ); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/successful-update-one-standard-object-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/object-metadata/successful-update-one-standard-object-metadata.integration-spec.ts new file mode 100644 index 00000000000..b0de7b75af7 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/successful-update-one-standard-object-metadata.integration-spec.ts @@ -0,0 +1,150 @@ +import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util'; +import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util'; +import { jestExpectToBeDefined } from 'test/utils/expect-to-be-defined.util.test'; +import { extractRecordIdsAndDatesAsExpectAny } from 'test/utils/extract-record-ids-and-dates-as-expect-any'; +import { + eachTestingContextFilter, + type EachTestingContext, +} from 'twenty-shared/testing'; + +import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto'; +import { type UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input'; + +type TestingRuntimeContext = { + objectMetadataId: string; +}; + +type UpdateOneStandardObjectMetadataTestingContext = EachTestingContext< + | ((args: TestingRuntimeContext) => Partial) + | Partial +>[]; + +const successfulUpdateTestsUseCase: UpdateOneStandardObjectMetadataTestingContext = + [ + { + title: 'when updating description', + context: { + description: 'Updated test description for company', + }, + }, + { + title: 'when updating icon', + context: { + icon: 'IconBuildingSkyscraper', + }, + }, + { + title: 'when setting isActive to false', + context: { + isActive: false, + }, + }, + { + title: 'when updating labelSingular and labelPlural', + context: { + labelSingular: 'Business', + labelPlural: 'Businesses', + }, + }, + ]; + +const allTestsUseCases = [...successfulUpdateTestsUseCase]; + +describe('Standard object metadata update should succeed', () => { + let companyObjectMetadataId: string; + let originalCompanyMetadata: ObjectMetadataDTO; + + beforeAll(async () => { + const { objects } = await findManyObjectMetadata({ + expectToFail: false, + input: { + filter: {}, + paging: { first: 100 }, + }, + gqlFields: ` + id + nameSingular + namePlural + labelSingular + labelPlural + description + icon + isActive + shortcut + `, + }); + + const companyObject = objects.find((o) => o.nameSingular === 'company'); + + jestExpectToBeDefined(companyObject); + companyObjectMetadataId = companyObject.id; + originalCompanyMetadata = companyObject; + }); + + afterEach(async () => { + await updateOneObjectMetadata({ + expectToFail: false, + input: { + idToUpdate: companyObjectMetadataId, + updatePayload: { + labelSingular: originalCompanyMetadata.labelSingular, + labelPlural: originalCompanyMetadata.labelPlural, + description: originalCompanyMetadata.description, + icon: originalCompanyMetadata.icon, + isActive: originalCompanyMetadata.isActive, + shortcut: originalCompanyMetadata.shortcut, + }, + }, + }); + }); + + it.each(eachTestingContextFilter(allTestsUseCases))( + '$title', + async ({ context }) => { + const updatePayload = + typeof context === 'function' + ? context({ objectMetadataId: companyObjectMetadataId }) + : context; + + const { + data: { updateOneObject }, + errors, + } = await updateOneObjectMetadata({ + input: { + idToUpdate: companyObjectMetadataId, + updatePayload, + }, + expectToFail: false, + gqlFields: ` + id + nameSingular + namePlural + labelSingular + labelPlural + description + icon + isActive + shortcut + standardOverrides { + labelSingular + labelPlural + description + icon + } + `, + }); + + expect(errors).toBeUndefined(); + expect(updateOneObject).toBeDefined(); + expect(updateOneObject.id).toBe(companyObjectMetadataId); + + expect(updateOneObject).toMatchObject({ + ...updatePayload, + }); + + expect(updateOneObject).toMatchSnapshot( + extractRecordIdsAndDatesAsExpectAny({ ...updateOneObject }), + ); + }, + ); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util.ts b/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util.ts index 4e5339cd7f9..08dae47bbf4 100644 --- a/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util.ts +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util.ts @@ -3,15 +3,20 @@ import { type UpdateOneObjectFactoryInput, updateOneObjectMetadataQueryFactory, } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata-query-factory.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; +import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto'; + export const updateOneObjectMetadata = async ({ input, gqlFields, expectToFail, -}: PerformMetadataQueryParams) => { +}: PerformMetadataQueryParams): CommonResponseBody<{ + updateOneObject: ObjectMetadataDTO; +}> => { const graphqlOperation = updateOneObjectMetadataQueryFactory({ input, gqlFields,