diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 08d4154542..b4cb1217e3 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -4227,7 +4227,7 @@ export type DeleteOneFieldMetadataItemMutation = { __typename?: 'Mutation', dele export type ObjectMetadataItemsQueryVariables = Exact<{ [key: string]: never; }>; -export type ObjectMetadataItemsQuery = { __typename?: 'Query', objects: { __typename?: 'ObjectConnection', edges: Array<{ __typename?: 'ObjectEdge', node: { __typename?: 'Object', id: string, dataSourceId: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage?: boolean | null, hasPreviousPage?: boolean | null, startCursor?: any | null, endCursor?: any | null } } }; +export type ObjectMetadataItemsQuery = { __typename?: 'Query', objects: { __typename?: 'ObjectConnection', edges: Array<{ __typename?: 'ObjectEdge', node: { __typename?: 'Object', id: string, dataSourceId: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage?: boolean | null, hasPreviousPage?: boolean | null, startCursor?: any | null, endCursor?: any | null } } }; export type SkipBookOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>; @@ -7622,6 +7622,7 @@ export const ObjectMetadataItemsDocument = gql` indexWhereClause indexType isUnique + isCustom indexFieldMetadataList { id fieldMetadataId diff --git a/packages/twenty-front/src/modules/object-metadata/graphql/queries.ts b/packages/twenty-front/src/modules/object-metadata/graphql/queries.ts index 16b5be0b8a..66f55a29af 100644 --- a/packages/twenty-front/src/modules/object-metadata/graphql/queries.ts +++ b/packages/twenty-front/src/modules/object-metadata/graphql/queries.ts @@ -33,6 +33,7 @@ export const FIND_MANY_OBJECT_METADATA_ITEMS = gql` indexWhereClause indexType isUnique + isCustom indexFieldMetadataList { id fieldMetadataId diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldIsUniqueForm.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldIsUniqueForm.tsx index 5451523693..9526f3de47 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldIsUniqueForm.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldIsUniqueForm.tsx @@ -2,6 +2,7 @@ import { Controller, useFormContext } from 'react-hook-form'; import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { Separator } from '@/settings/components/Separator'; import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect'; import { canBeUnique } from '@/settings/data-model/fields/forms/utils/canBeUnique.util'; @@ -14,15 +15,30 @@ type SettingsDataModelFieldIsUniqueFormValues = { }; type SettingsDataModelFieldIsUniqueFormProps = { - fieldMetadataItem: Pick; + fieldMetadataItem: Pick< + FieldMetadataItem, + 'icon' | 'label' | 'type' | 'isCustom' | 'settings' | 'isUnique' + > & + Partial<{ id: string }>; + objectMetadataItem: Pick; }; export const SettingsDataModelFieldIsUniqueForm = ({ fieldMetadataItem, + objectMetadataItem, }: SettingsDataModelFieldIsUniqueFormProps) => { const { control } = useFormContext(); + const hasStandardUniqueIndex = objectMetadataItem.indexMetadatas.some( + (index) => + index.isUnique && + !index.isCustom && + index.indexFieldMetadatas?.some( + (field) => field.fieldMetadataId === fieldMetadataItem.id, + ), + ); + if (!canBeUnique(fieldMetadataItem)) { return null; } @@ -41,12 +57,13 @@ export const SettingsDataModelFieldIsUniqueForm = ({ onChange(value)} + disabled={hasStandardUniqueIndex} /> diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx index 0ba0c79b06..1baf906419 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx @@ -265,6 +265,7 @@ export const SettingsDataModelFieldSettingsFormCard = ({ form={ } /> diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/text/SettingsDataModelFieldTextSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/text/SettingsDataModelFieldTextSettingsFormCard.tsx index e99d268879..bc2faf3c6a 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/text/SettingsDataModelFieldTextSettingsFormCard.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/text/SettingsDataModelFieldTextSettingsFormCard.tsx @@ -15,8 +15,9 @@ type SettingsDataModelFieldTextSettingsFormCardProps = { disabled?: boolean; fieldMetadataItem: Pick< FieldMetadataItem, - 'icon' | 'label' | 'type' | 'defaultValue' | 'isUnique' | 'isCustom' - >; + 'icon' | 'label' | 'type' | 'isCustom' | 'settings' + > & + Partial<{ id: string }>; } & Pick; const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)` @@ -49,6 +50,7 @@ export const SettingsDataModelFieldTextSettingsFormCard = ({ /> } diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/date/components/SettingsDataModelFieldDateSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/date/components/SettingsDataModelFieldDateSettingsFormCard.tsx index 53d0cd910a..c02d4109b6 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/date/components/SettingsDataModelFieldDateSettingsFormCard.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/date/components/SettingsDataModelFieldDateSettingsFormCard.tsx @@ -18,8 +18,9 @@ type SettingsDataModelFieldDateSettingsFormCardProps = { disabled?: boolean; fieldMetadataItem: Pick< FieldMetadataItem, - 'icon' | 'label' | 'type' | 'isUnique' | 'isCustom' | 'settings' - >; + 'icon' | 'label' | 'type' | 'isCustom' | 'settings' | 'isUnique' + > & + Partial<{ id: string }>; } & Pick; const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)` @@ -68,6 +69,7 @@ export const SettingsDataModelFieldDateSettingsFormCard = ({ /> } diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberSettingsFormCard.tsx index 3f5fdb9b35..ea15422011 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberSettingsFormCard.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberSettingsFormCard.tsx @@ -14,8 +14,9 @@ type SettingsDataModelFieldNumberSettingsFormCardProps = { disabled?: boolean; fieldMetadataItem: Pick< FieldMetadataItem, - 'icon' | 'label' | 'type' | 'defaultValue' | 'isUnique' | 'isCustom' - >; + 'icon' | 'label' | 'type' | 'isCustom' | 'settings' | 'isUnique' + > & + Partial<{ id: string }>; } & Pick; const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)` @@ -52,6 +53,7 @@ export const SettingsDataModelFieldNumberSettingsFormCard = ({ } diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesSettingsFormCard.tsx index f64ffbad6f..6f4853152a 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesSettingsFormCard.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesSettingsFormCard.tsx @@ -15,8 +15,9 @@ type SettingsDataModelFieldPhonesSettingsFormCardProps = { disabled?: boolean; fieldMetadataItem: Pick< FieldMetadataItem, - 'icon' | 'label' | 'type' | 'defaultValue' | 'isUnique' | 'isCustom' - >; + 'icon' | 'label' | 'type' | 'isCustom' | 'settings' + > & + Partial<{ id: string }>; } & Pick; const StyledFieldPreviewCard = styled(SettingsDataModelFieldPreviewCard)` @@ -44,6 +45,7 @@ export const SettingsDataModelFieldPhonesSettingsFormCard = ({ /> } diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/utils/canBeUnique.util.ts b/packages/twenty-front/src/modules/settings/data-model/fields/forms/utils/canBeUnique.util.ts index 25432d3bae..06e8d531c7 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/utils/canBeUnique.util.ts +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/utils/canBeUnique.util.ts @@ -6,10 +6,6 @@ import { FieldMetadataType } from 'twenty-shared/types'; export const canBeUnique = ( field: Pick, ) => { - if (field.isCustom === false) { - return false; - } - if ( [FieldMetadataType.MORPH_RELATION, FieldMetadataType.RELATION].includes( field.type, diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/hooks/__tests__/before-update-one-field.hook.spec.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/hooks/__tests__/before-update-one-field.hook.spec.ts index e5329c5707..03bc21e492 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/hooks/__tests__/before-update-one-field.hook.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/hooks/__tests__/before-update-one-field.hook.spec.ts @@ -12,6 +12,9 @@ import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadat import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { BeforeUpdateOneField } from 'src/engine/metadata-modules/field-metadata/hooks/before-update-one-field.hook'; import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service'; +import { type IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; import { getMockFieldMetadataEntity } from 'src/utils/__test__/get-field-metadata-entity.mock'; jest.mock('@lingui/core', () => ({ @@ -26,6 +29,7 @@ type UpdateFieldInputForTest = Omit; describe('BeforeUpdateOneField', () => { let hook: BeforeUpdateOneField; let fieldMetadataService: FieldMetadataService; + let objectMetadataService: ObjectMetadataService; const mockWorkspaceId = 'workspace-id'; const mockFieldId = 'field-id'; @@ -40,6 +44,12 @@ describe('BeforeUpdateOneField', () => { findOneWithinWorkspace: jest.fn(), }, }, + { + provide: ObjectMetadataService, + useValue: { + findOneWithinWorkspace: jest.fn(), + }, + }, ], }).compile(); @@ -47,6 +57,9 @@ describe('BeforeUpdateOneField', () => { module.get>(BeforeUpdateOneField); fieldMetadataService = module.get(FieldMetadataService); + objectMetadataService = module.get( + ObjectMetadataService, + ); }); afterEach(() => { @@ -571,4 +584,101 @@ describe('BeforeUpdateOneField', () => { expect(result).toEqual(expectedResult); }); + + it('should throw ValidationError if isUnique is updated for a standard field with a standard unique index', async () => { + const mockField: Partial = { + id: mockFieldId, + isCustom: false, + isUnique: true, + }; + + jest + .spyOn(fieldMetadataService, 'findOneWithinWorkspace') + .mockResolvedValue(mockField as FieldMetadataEntity); + + const mockObjectMetadata: Partial = { + id: mockWorkspaceId, + indexMetadatas: [ + { + isUnique: true, + isCustom: false, + indexFieldMetadatas: [{ fieldMetadataId: mockFieldId }], + } as IndexMetadataEntity, + ], + }; + + jest + .spyOn(objectMetadataService, 'findOneWithinWorkspace') + .mockResolvedValue(mockObjectMetadata as ObjectMetadataEntity); + + const instance: UpdateOneInputType = { + id: mockFieldId, + update: { + isUnique: false, + }, + }; + + await expect( + hook.run(instance as UpdateOneInputType, { + workspaceId: mockWorkspaceId, + locale: undefined, + }), + ).rejects.toThrow(ValidationError); + }); + + it('should not throw ValidationError if isUnique is updated for a standard field without a standard unique index', async () => { + const mockField: Partial = { + id: mockFieldId, + isCustom: false, + isUnique: true, + }; + + jest + .spyOn(fieldMetadataService, 'findOneWithinWorkspace') + .mockResolvedValue(mockField as FieldMetadataEntity); + + const mockObjectMetadata: Partial = { + id: mockWorkspaceId, + indexMetadatas: [ + { + isUnique: false, + isCustom: false, + indexFieldMetadatas: [{ fieldMetadataId: mockFieldId }], + } as IndexMetadataEntity, + ], + }; + + jest + .spyOn(objectMetadataService, 'findOneWithinWorkspace') + .mockResolvedValue(mockObjectMetadata as ObjectMetadataEntity); + + const instance: UpdateOneInputType = { + id: mockFieldId, + update: { + isUnique: false, + }, + }; + + jest + .spyOn(fieldMetadataService, 'findOneWithinWorkspace') + .mockResolvedValue(mockField as FieldMetadataEntity); + + const result = await hook.run( + instance as UpdateOneInputType, + { + workspaceId: mockWorkspaceId, + locale: undefined, + }, + ); + + const expectedResult = { + id: mockFieldId, + update: { + isUnique: false, + standardOverrides: {}, + }, + }; + + expect(result).toEqual(expectedResult); + }); }); diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/hooks/before-update-one-field.hook.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/hooks/before-update-one-field.hook.ts index 4aa0c19f2f..cd6f217124 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/hooks/before-update-one-field.hook.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/hooks/before-update-one-field.hook.ts @@ -17,6 +17,7 @@ import { type FieldStandardOverridesDTO } from 'src/engine/metadata-modules/fiel import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input'; import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service'; +import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; interface StandardFieldUpdate extends Partial { standardOverrides?: FieldStandardOverridesDTO; @@ -26,7 +27,10 @@ interface StandardFieldUpdate extends Partial { export class BeforeUpdateOneField implements BeforeUpdateOneHook { - constructor(readonly fieldMetadataService: FieldMetadataService) {} + constructor( + readonly fieldMetadataService: FieldMetadataService, + readonly objectMetadataService: ObjectMetadataService, + ) {} async run( instance: UpdateOneInputType, @@ -45,7 +49,11 @@ export class BeforeUpdateOneField const fieldMetadata = await this.getFieldMetadata(instance, workspaceId); if (!fieldMetadata.isCustom) { - return this.handleStandardFieldUpdate(instance, fieldMetadata, locale); + return await this.handleStandardFieldUpdate( + instance, + fieldMetadata, + locale, + ); } return instance; @@ -69,11 +77,11 @@ export class BeforeUpdateOneField return fieldMetadata; } - private handleStandardFieldUpdate( + private async handleStandardFieldUpdate( instance: UpdateOneInputType, fieldMetadata: FieldMetadataEntity, locale?: keyof typeof APP_LOCALES, - ): UpdateOneInputType { + ): Promise> { const update: StandardFieldUpdate = {}; const updatableFields = [ 'isActive', @@ -81,6 +89,7 @@ export class BeforeUpdateOneField 'options', 'settings', 'defaultValue', + 'isUnique', ]; const overridableFields = ['label', 'icon', 'description']; @@ -91,7 +100,7 @@ export class BeforeUpdateOneField if (nonUpdatableFields.length > 0) { throw new ValidationError( - `Only isActive, isLabelSyncedWithName, label, icon, description and defaultValue fields can be updated for standard fields. Invalid fields: ${nonUpdatableFields.join(', ')}`, + `Only isActive, isLabelSyncedWithName, label, icon, description, isUnique and defaultValue fields can be updated for standard fields. Invalid fields: ${nonUpdatableFields.join(', ')}`, ); } @@ -106,6 +115,7 @@ export class BeforeUpdateOneField this.handleOptionsField(instance, update); this.handleSettingsField(instance, update); this.handleDefaultValueField(instance, update); + await this.handleIsUniqueField(instance, fieldMetadata, update); return { id: instance.id, @@ -113,6 +123,43 @@ export class BeforeUpdateOneField }; } + private async handleIsUniqueField( + instance: UpdateOneInputType, + fieldMetadata: FieldMetadataEntity, + update: StandardFieldUpdate, + ) { + if (!isDefined(instance.update.isUnique)) { + return; + } + + const objectMetadata = + await this.objectMetadataService.findOneWithinWorkspace( + fieldMetadata.workspaceId, + { + where: { + id: fieldMetadata.objectMetadataId, + }, + }, + ); + + const hasStandardUniqueIndex = objectMetadata?.indexMetadatas.some( + (index) => + index.isUnique && + !index.isCustom && + index.indexFieldMetadatas?.some( + (field) => field.fieldMetadataId === fieldMetadata.id, + ), + ); + + if (hasStandardUniqueIndex && instance.update.isUnique === false) { + throw new ValidationError( + 'Unique standard field cannot be updated to non-unique.', + ); + } + + update.isUnique = instance.update.isUnique; + } + private handleDefaultValueField( instance: UpdateOneInputType, update: StandardFieldUpdate, diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/build-updatable-standard-field-input.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/build-updatable-standard-field-input.util.ts index 68288cef14..8a849e6c81 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/build-updatable-standard-field-input.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/build-updatable-standard-field-input.util.ts @@ -20,6 +20,7 @@ export const buildUpdatableStandardFieldInput = ( defaultValue: fieldMetadataInput.defaultValue, settings: fieldMetadataInput.settings, isLabelSyncedWithName: fieldMetadataInput.isLabelSyncedWithName, + isUnique: fieldMetadataInput.isUnique, }; if ('standardOverrides' in fieldMetadataInput) { diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-valid-unique-input.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-valid-unique-input.util.ts index e5b4d3e0b4..f3e876813c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-valid-unique-input.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-valid-unique-input.util.ts @@ -1,10 +1,10 @@ -import { isDeepStrictEqual } from 'util'; - import { type FieldMetadataType } from 'twenty-shared/types'; import { type FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface'; +import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types'; import { generateDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/generate-default-value'; +import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; export const isValidUniqueFieldDefaultValueCombination = ({ defaultValue, @@ -15,7 +15,22 @@ export const isValidUniqueFieldDefaultValueCombination = ({ isUnique: boolean; type: FieldMetadataType; }) => { + if (!isUnique) return true; + const defaultDefaultValue = generateDefaultValue(type); - return !isUnique || isDeepStrictEqual(defaultValue, defaultDefaultValue); + if (!isCompositeFieldMetadataType(type)) + return defaultValue === defaultDefaultValue; + + const doUniquePropertiesHaveDefaultValues = + compositeTypeDefinitions + .get(type) + ?.properties.filter((property) => property.isIncludedInUniqueConstraint) + .every( + ({ name }) => + (defaultValue as Record)?.[name] === + (defaultDefaultValue as Record)?.[name], + ) ?? false; + + return doUniquePropertiesHaveDefaultValues; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts index d1ec2a558b..bbd6616be3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts @@ -560,7 +560,11 @@ export class ObjectMetadataService extends TypeOrmQueryService, ): Promise { return this.objectMetadataRepository.findOne({ - relations: ['fields'], + relations: [ + 'fields', + 'indexMetadatas', + 'indexMetadatas.indexFieldMetadatas', + ], ...options, where: { ...options.where, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service.ts index f4d3258f60..40fd165f43 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service.ts @@ -257,7 +257,7 @@ export class WorkspaceMigrationRunnerService { `Unique index creation failed because of unique constraint violation`, IndexMetadataExceptionCode.INDEX_CREATION_FAILED, { - userFriendlyMessage: t`Cannot enable uniqueness due to existing duplicate values. Please review and fix your data first.`, + userFriendlyMessage: t`Cannot enable uniqueness due to existing duplicate values. Please review and fix your data first (including soft deleted records).`, }, ); } diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/unique-field/update-one-unique-field-metadata.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/unique-field/update-one-unique-field-metadata.integration-spec.ts index e51b6c2354..182d54963e 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/unique-field/update-one-unique-field-metadata.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/unique-field/update-one-unique-field-metadata.integration-spec.ts @@ -288,4 +288,64 @@ describe('update one unique field metadata', () => { 'Unique index cannot be created for field fullNameField of type FULL_NAME', ); }); + + it('should update a standard field to unique field metadata if it has not standard unique index', async () => { + const { data: createdField } = await createOneFieldMetadata({ + input: { + name: 'testField', + label: 'Test Field', + type: FieldMetadataType.TEXT, + objectMetadataId: createdObjectId, + isUnique: false, + isCustom: false, + }, + gqlFields: ` + id + name + label + type + isUnique + `, + }); + + const { data: addUniqueData, errors: addUniqueErrors } = + await updateOneFieldMetadata({ + input: { + idToUpdate: createdField.createOneField.id, + updatePayload: { isUnique: true }, + }, + gqlFields: ` + id + name + label + type + isUnique + `, + }); + + expect(addUniqueErrors).toBeUndefined(); + expect(addUniqueData).not.toBeNull(); + expect(addUniqueData.updateOneField).toBeDefined(); + expect(addUniqueData.updateOneField.isUnique).toBe(true); + + const { data: removeUniqueData, errors: removeUniqueErrors } = + await updateOneFieldMetadata({ + input: { + idToUpdate: createdField.createOneField.id, + updatePayload: { isUnique: false }, + }, + gqlFields: ` + id + name + label + type + isUnique + `, + }); + + expect(removeUniqueErrors).toBeUndefined(); + expect(removeUniqueData).not.toBeNull(); + expect(removeUniqueData.updateOneField).toBeDefined(); + expect(removeUniqueData.updateOneField.isUnique).toBe(false); + }); }); diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/create-one-index-metadata-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/create-one-index-metadata-query-factory.util.ts new file mode 100644 index 0000000000..e9a7d8139b --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/utils/create-one-index-metadata-query-factory.util.ts @@ -0,0 +1,32 @@ +import gql from 'graphql-tag'; +import { type UpdateOneFieldFactoryInput } from 'test/integration/metadata/suites/field-metadata/utils/update-one-field-metadata-query-factory.util'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; + +type CreateIndexMetadataInput = { + objectMetadataId: string; + isUnique: boolean; + indexFieldMetadatas: { + fieldMetadataId: string; + }[]; +}; + +export type CreateOneIndexMetadataFactoryInput = { + input: CreateIndexMetadataInput; +}; + +export const createOneIndexMetadataQueryFactory = ({ + gqlFields = 'id', + input, +}: PerformMetadataQueryParams) => ({ + query: gql` + mutation UpdateOneFieldMetadataItem($idToUpdate: UUID!, $updatePayload: UpdateFieldInput!) { + updateOneField(input: {id: $idToUpdate, update: $updatePayload}) { + ${gqlFields} + } + } + `, + variables: { + idToUpdate: input.idToUpdate, + updatePayload: input.updatePayload, + }, +}); diff --git a/packages/twenty-website/src/content/user-guide/objects/fields.mdx b/packages/twenty-website/src/content/user-guide/objects/fields.mdx index 01673d1fa1..5e1b0620ea 100644 --- a/packages/twenty-website/src/content/user-guide/objects/fields.mdx +++ b/packages/twenty-website/src/content/user-guide/objects/fields.mdx @@ -86,4 +86,8 @@ And, voilĂ ! You've deactivated a field. But what does this imply for your CRM o You can reactivate Standard and Custom Fields or have the option to permanently delete them. +## Other field options + +You can set a field as unique to prevent assigning the same value to different records, which helps maintain data integrity and simplifies relations import. In case of error at uniqueness creation, check all duplicates in your data (even soft deleted records). + \ No newline at end of file