Unique fields - add unicity toggle on data model settings (#13592)

To review after https://github.com/twentyhq/twenty/pull/13539

closes https://github.com/twentyhq/core-team-issues/issues/1224

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Etienne
2025-08-08 19:15:49 +02:00
committed by GitHub
co-authored by Charles Bochet
parent 6a492b0732
commit e06fc34cbe
18 changed files with 222 additions and 94 deletions
@@ -34,6 +34,7 @@ export const CREATE_ONE_FIELD_METADATA_ITEM = gql`
icon
isCustom
isActive
isUnique
isNullable
createdAt
updatedAt
@@ -59,6 +60,7 @@ export const UPDATE_ONE_FIELD_METADATA_ITEM = gql`
icon
isCustom
isActive
isUnique
isNullable
createdAt
updatedAt
@@ -128,6 +130,7 @@ export const DELETE_ONE_FIELD_METADATA_ITEM = gql`
icon
isCustom
isActive
isUnique
isNullable
createdAt
updatedAt
@@ -17,6 +17,7 @@ export const queries = {
icon
isCustom
isActive
isUnique
isNullable
createdAt
updatedAt
@@ -99,6 +100,7 @@ export const queries = {
icon
isCustom
isActive
isUnique
isNullable
createdAt
updatedAt
@@ -131,6 +133,7 @@ export const variables = {
options: undefined,
settings: undefined,
isLabelSyncedWithName: true,
isUnique: undefined,
objectMetadataId,
type: 'TEXT',
},
@@ -13,6 +13,7 @@ export const formatFieldMetadataItemInput = (
| 'options'
| 'settings'
| 'isLabelSyncedWithName'
| 'isUnique'
>
>,
) => {
@@ -25,5 +26,6 @@ export const formatFieldMetadataItemInput = (
options: input.options,
settings: input.settings,
isLabelSyncedWithName: input.isLabelSyncedWithName,
isUnique: input.isUnique,
};
};
@@ -18,8 +18,6 @@ import { TEXT_INPUT_STYLE } from 'twenty-ui/theme';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
export const DEFAULT_PHONE_CALLING_CODE = '1';
const StyledCustomPhoneInputContainer = styled.div<{
hasItem: boolean;
hasError?: boolean;
@@ -18,7 +18,6 @@ type SettingsOptionCardContentSelectProps = {
const StyledSelectContainer = styled.div`
margin-left: auto;
width: 120px;
`;
export const SettingsOptionCardContentSelect = ({
@@ -0,0 +1,57 @@
import { Controller, useFormContext } from 'react-hook-form';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { Separator } from '@/settings/components/Separator';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
import { canBeUnique } from '@/settings/data-model/fields/forms/utils/canBeUnique.util';
import { t } from '@lingui/core/macro';
import { IconKey } from 'twenty-ui/display';
import { Toggle } from 'twenty-ui/input';
type SettingsDataModelFieldIsUniqueFormValues = {
isUnique: boolean;
};
type SettingsDataModelFieldIsUniqueFormProps = {
fieldMetadataItem: Pick<FieldMetadataItem, 'isUnique' | 'type' | 'isCustom'>;
};
export const SettingsDataModelFieldIsUniqueForm = ({
fieldMetadataItem,
}: SettingsDataModelFieldIsUniqueFormProps) => {
const { control } =
useFormContext<SettingsDataModelFieldIsUniqueFormValues>();
if (!canBeUnique(fieldMetadataItem)) {
return null;
}
return (
<Controller
name="isUnique"
defaultValue={fieldMetadataItem?.isUnique || false}
control={control}
render={({ field: { onChange, value } }) => {
const isUnique = value ?? false;
return (
<>
<Separator />
<SettingsOptionCardContentSelect
Icon={IconKey}
title={t`Unique`}
description={t`Prevent users from assigining the same value to different records`}
>
<Toggle
toggleSize="small"
value={isUnique}
onChange={(value) => onChange(value)}
/>
</SettingsOptionCardContentSelect>
</>
);
}}
/>
);
};
@@ -9,7 +9,8 @@ import { settingsDataModelFieldAddressFormSchema } from '@/settings/data-model/f
import { SettingsDataModelFieldAddressSettingsFormCard } from '@/settings/data-model/fields/forms/address/components/SettingsDataModelFieldAddressSettingsFormCard';
import { settingsDataModelFieldBooleanFormSchema } from '@/settings/data-model/fields/forms/boolean/components/SettingsDataModelFieldBooleanForm';
import { SettingsDataModelFieldBooleanSettingsFormCard } from '@/settings/data-model/fields/forms/boolean/components/SettingsDataModelFieldBooleanSettingsFormCard';
import { settingsDataModelFieldtextFormSchema } from '@/settings/data-model/fields/forms/components/text/SettingsDataModelFieldTextForm';
import { SettingsDataModelFieldIsUniqueForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldIsUniqueForm';
import { settingsDataModelFieldTextFormSchema } from '@/settings/data-model/fields/forms/components/text/SettingsDataModelFieldTextForm';
import { SettingsDataModelFieldTextSettingsFormCard } from '@/settings/data-model/fields/forms/components/text/SettingsDataModelFieldTextSettingsFormCard';
import { settingsDataModelFieldCurrencyFormSchema } from '@/settings/data-model/fields/forms/currency/components/SettingsDataModelFieldCurrencyForm';
import { SettingsDataModelFieldCurrencySettingsFormCard } from '@/settings/data-model/fields/forms/currency/components/SettingsDataModelFieldCurrencySettingsFormCard';
@@ -32,6 +33,10 @@ import {
} from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewCard';
import { FieldMetadataType } from '~/generated-metadata/graphql';
const isUniqueFieldFormSchema = z.object({
isUnique: z.boolean().nullable().optional(),
});
const booleanFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.BOOLEAN) })
.merge(settingsDataModelFieldBooleanFormSchema);
@@ -42,11 +47,13 @@ const currencyFieldFormSchema = z
const dateFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.DATE) })
.merge(settingsDataModelFieldDateFormSchema);
.merge(settingsDataModelFieldDateFormSchema)
.merge(isUniqueFieldFormSchema);
const dateTimeFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.DATE_TIME) })
.merge(settingsDataModelFieldDateFormSchema);
.merge(settingsDataModelFieldDateFormSchema)
.merge(isUniqueFieldFormSchema);
const relationFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.RELATION) })
@@ -62,11 +69,13 @@ const multiSelectFieldFormSchema = z
const numberFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.NUMBER) })
.merge(settingsDataModelFieldNumberFormSchema);
.merge(settingsDataModelFieldNumberFormSchema)
.merge(isUniqueFieldFormSchema);
const textFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.TEXT) })
.merge(settingsDataModelFieldtextFormSchema);
.merge(settingsDataModelFieldTextFormSchema)
.merge(isUniqueFieldFormSchema);
const addressFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.ADDRESS) })
@@ -74,27 +83,30 @@ const addressFieldFormSchema = z
const phonesFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.PHONES) })
.merge(settingsDataModelFieldPhonesFormSchema);
.merge(settingsDataModelFieldPhonesFormSchema)
.merge(isUniqueFieldFormSchema);
const otherFieldsFormSchema = z.object({
type: z.enum(
Object.keys(
omit(SETTINGS_FIELD_TYPE_CONFIGS, [
FieldMetadataType.BOOLEAN,
FieldMetadataType.CURRENCY,
FieldMetadataType.RELATION,
FieldMetadataType.SELECT,
FieldMetadataType.MULTI_SELECT,
FieldMetadataType.DATE,
FieldMetadataType.DATE_TIME,
FieldMetadataType.NUMBER,
FieldMetadataType.ADDRESS,
FieldMetadataType.PHONES,
FieldMetadataType.TEXT,
]),
) as [FieldMetadataType, ...FieldMetadataType[]],
),
});
const otherFieldsFormSchema = z
.object({
type: z.enum(
Object.keys(
omit(SETTINGS_FIELD_TYPE_CONFIGS, [
FieldMetadataType.BOOLEAN,
FieldMetadataType.CURRENCY,
FieldMetadataType.RELATION,
FieldMetadataType.SELECT,
FieldMetadataType.MULTI_SELECT,
FieldMetadataType.DATE,
FieldMetadataType.DATE_TIME,
FieldMetadataType.NUMBER,
FieldMetadataType.ADDRESS,
FieldMetadataType.PHONES,
FieldMetadataType.TEXT,
]),
) as [FieldMetadataType, ...FieldMetadataType[]],
),
})
.merge(isUniqueFieldFormSchema);
export const settingsDataModelFieldSettingsFormSchema = z.discriminatedUnion(
'type',
@@ -144,6 +156,7 @@ const previewableTypes = [
FieldMetadataType.RELATION,
FieldMetadataType.SELECT,
FieldMetadataType.TEXT,
FieldMetadataType.UUID,
];
export const SettingsDataModelFieldSettingsFormCard = ({
@@ -249,6 +262,11 @@ export const SettingsDataModelFieldSettingsFormCard = ({
objectMetadataItem={objectMetadataItem}
/>
}
form={
<SettingsDataModelFieldIsUniqueForm
fieldMetadataItem={fieldMetadataItem}
/>
}
/>
);
};
@@ -12,20 +12,26 @@ type SettingsDataModelFieldTextFormProps = {
disabled?: boolean;
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'defaultValue' | 'settings'
| 'icon'
| 'label'
| 'type'
| 'defaultValue'
| 'settings'
| 'isUnique'
| 'isCustom'
>;
};
export const textFieldDefaultValueSchema = z.object({
const textFieldDefaultValueSchema = z.object({
displayedMaxRows: z.number().nullable(),
});
export const settingsDataModelFieldtextFormSchema = z.object({
export const settingsDataModelFieldTextFormSchema = z.object({
settings: textFieldDefaultValueSchema,
});
export type SettingsDataModelFieldTextFormValues = z.infer<
typeof settingsDataModelFieldtextFormSchema
typeof settingsDataModelFieldTextFormSchema
>;
export const SettingsDataModelFieldTextForm = ({
@@ -46,25 +52,23 @@ export const SettingsDataModelFieldTextForm = ({
const displayedMaxRows = value?.displayedMaxRows ?? 0;
return (
<>
<SettingsOptionCardContentSelect
Icon={IconTextWrap}
title={t`Wrap on record pages`}
description={t`Display text on multiple lines`}
>
<Select<number>
dropdownId="text-wrap"
value={displayedMaxRows}
onChange={(value) => onChange({ displayedMaxRows: value })}
disabled={disabled}
options={TEXT_DATA_MODEL_SELECT_OPTIONS.map((option) => ({
...option,
label: t(option.label),
}))}
selectSizeVariant="small"
/>
</SettingsOptionCardContentSelect>
</>
<SettingsOptionCardContentSelect
Icon={IconTextWrap}
title={t`Wrap on record pages`}
description={t`Display text on multiple lines`}
>
<Select<number>
dropdownId="text-wrap"
value={displayedMaxRows}
onChange={(value) => onChange({ displayedMaxRows: value })}
disabled={disabled}
options={TEXT_DATA_MODEL_SELECT_OPTIONS.map((option) => ({
...option,
label: t(option.label),
}))}
selectSizeVariant="small"
/>
</SettingsOptionCardContentSelect>
);
}}
/>
@@ -3,6 +3,7 @@ import styled from '@emotion/styled';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import { SettingsDataModelFieldIsUniqueForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldIsUniqueForm';
import { SettingsDataModelFieldTextForm } from '@/settings/data-model/fields/forms/components/text/SettingsDataModelFieldTextForm';
import {
SettingsDataModelFieldPreviewCard,
@@ -14,7 +15,7 @@ type SettingsDataModelFieldTextSettingsFormCardProps = {
disabled?: boolean;
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'defaultValue'
'icon' | 'label' | 'type' | 'defaultValue' | 'isUnique' | 'isCustom'
>;
} & Pick<SettingsDataModelFieldPreviewCardProps, 'objectMetadataItem'>;
@@ -41,10 +42,15 @@ export const SettingsDataModelFieldTextSettingsFormCard = ({
/>
}
form={
<SettingsDataModelFieldTextForm
disabled={disabled}
fieldMetadataItem={fieldMetadataItem}
/>
<>
<SettingsDataModelFieldTextForm
disabled={disabled}
fieldMetadataItem={fieldMetadataItem}
/>
<SettingsDataModelFieldIsUniqueForm
fieldMetadataItem={fieldMetadataItem}
/>
</>
}
/>
);
@@ -3,6 +3,7 @@ import { useFormContext } from 'react-hook-form';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import { SettingsDataModelFieldIsUniqueForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldIsUniqueForm';
import {
SettingsDataModelFieldDateForm,
SettingsDataModelFieldDateFormValues,
@@ -17,7 +18,7 @@ type SettingsDataModelFieldDateSettingsFormCardProps = {
disabled?: boolean;
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'settings'
'icon' | 'label' | 'type' | 'isUnique' | 'isCustom' | 'settings'
>;
} & Pick<SettingsDataModelFieldPreviewCardProps, 'objectMetadataItem'>;
@@ -60,10 +61,15 @@ export const SettingsDataModelFieldDateSettingsFormCard = ({
/>
}
form={
<SettingsDataModelFieldDateForm
disabled={disabled}
fieldMetadataItem={fieldMetadataItem}
/>
<>
<SettingsDataModelFieldDateForm
disabled={disabled}
fieldMetadataItem={fieldMetadataItem}
/>
<SettingsDataModelFieldIsUniqueForm
fieldMetadataItem={fieldMetadataItem}
/>
</>
}
/>
);
@@ -2,6 +2,7 @@ import styled from '@emotion/styled';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import { SettingsDataModelFieldIsUniqueForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldIsUniqueForm';
import { SettingsDataModelFieldNumberForm } from '@/settings/data-model/fields/forms/number/components/SettingsDataModelFieldNumberForm';
import {
SettingsDataModelFieldPreviewCard,
@@ -13,7 +14,7 @@ type SettingsDataModelFieldNumberSettingsFormCardProps = {
disabled?: boolean;
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'defaultValue' | 'settings'
'icon' | 'label' | 'type' | 'defaultValue' | 'isUnique' | 'isCustom'
>;
} & Pick<SettingsDataModelFieldPreviewCardProps, 'objectMetadataItem'>;
@@ -43,10 +44,16 @@ export const SettingsDataModelFieldNumberSettingsFormCard = ({
/>
}
form={
<SettingsDataModelFieldNumberForm
disabled={disabled}
fieldMetadataItem={fieldMetadataItem}
/>
<>
<SettingsDataModelFieldNumberForm
disabled={disabled}
fieldMetadataItem={fieldMetadataItem}
/>
<SettingsDataModelFieldIsUniqueForm
fieldMetadataItem={fieldMetadataItem}
/>
</>
}
/>
);
@@ -4,6 +4,7 @@ import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import { SettingsDataModelFieldIsUniqueForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldIsUniqueForm';
import { SettingsDataModelFieldPhonesForm } from '@/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesForm';
import {
SettingsDataModelFieldPreviewCard,
@@ -14,7 +15,7 @@ type SettingsDataModelFieldPhonesSettingsFormCardProps = {
disabled?: boolean;
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'defaultValue'
'icon' | 'label' | 'type' | 'defaultValue' | 'isUnique' | 'isCustom'
>;
} & Pick<SettingsDataModelFieldPreviewCardProps, 'objectMetadataItem'>;
@@ -36,10 +37,15 @@ export const SettingsDataModelFieldPhonesSettingsFormCard = ({
/>
}
form={
<SettingsDataModelFieldPhonesForm
disabled={disabled}
fieldMetadataItem={fieldMetadataItem}
/>
<>
<SettingsDataModelFieldPhonesForm
disabled={disabled}
fieldMetadataItem={fieldMetadataItem}
/>
<SettingsDataModelFieldIsUniqueForm
fieldMetadataItem={fieldMetadataItem}
/>
</>
}
/>
);
@@ -4,6 +4,7 @@ import { z } from 'zod';
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import { SettingsDataModelFieldIsUniqueForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldIsUniqueForm';
import {
settingsDataModelFieldMultiSelectFormSchema,
SettingsDataModelFieldSelectForm,
@@ -23,7 +24,13 @@ type SettingsDataModelFieldSelectOrMultiSelectFormValues = z.infer<
type SettingsDataModelFieldSelectSettingsFormCardProps = {
fieldMetadataItem: Pick<
FieldMetadataItem,
'icon' | 'label' | 'type' | 'defaultValue' | 'options'
| 'icon'
| 'label'
| 'type'
| 'defaultValue'
| 'options'
| 'isUnique'
| 'isCustom'
>;
} & Pick<SettingsDataModelFieldPreviewCardProps, 'objectMetadataItem'>;
@@ -57,9 +64,14 @@ export const SettingsDataModelFieldSelectSettingsFormCard = ({
/>
}
form={
<SettingsDataModelFieldSelectForm
fieldMetadataItem={fieldMetadataItem}
/>
<>
<SettingsDataModelFieldSelectForm
fieldMetadataItem={fieldMetadataItem}
/>
<SettingsDataModelFieldIsUniqueForm
fieldMetadataItem={fieldMetadataItem}
/>
</>
}
/>
);
@@ -0,0 +1,22 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs';
import { FieldMetadataType } from 'twenty-shared/types';
export const canBeUnique = (
field: Pick<FieldMetadataItem, 'type' | 'isCustom'>,
) => {
if (
[FieldMetadataType.MORPH_RELATION, FieldMetadataType.RELATION].includes(
field.type,
) ||
(isCompositeFieldType(field.type) &&
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[field.type].subFields.every(
(subField) => !subField.isIncludedInUniqueConstraint,
))
) {
return false;
}
return true;
};
@@ -1,5 +1,4 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { DEFAULT_PHONE_CALLING_CODE } from '@/object-record/record-field/meta-types/input/components/PhonesFieldInput';
import { FieldPhonesValue } from '@/object-record/record-field/types/FieldMetadata';
import { getSettingsFieldTypeConfig } from '@/settings/data-model/utils/getSettingsFieldTypeConfig';
import {
@@ -16,12 +15,12 @@ const isStrCountryCodeGuard = (str: string): str is CountryCode => {
export const countryCodeToCallingCode = (countryCode: string): string => {
if (!countryCode || !isStrCountryCodeGuard(countryCode)) {
return `+${DEFAULT_PHONE_CALLING_CODE}`;
return '';
}
const callingCode = getCountryCallingCode(countryCode);
return callingCode ? `+${callingCode}` : `+${DEFAULT_PHONE_CALLING_CODE}`;
return callingCode ? `+${callingCode}` : '';
};
export const getPhonesFieldPreviewValue = ({
@@ -3,7 +3,6 @@ import React, { useMemo } from 'react';
import { FieldPhonesValue } from '@/object-record/record-field/types/FieldMetadata';
import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
import { DEFAULT_PHONE_CALLING_CODE } from '@/object-record/record-field/meta-types/input/components/PhonesFieldInput';
import { styled } from '@linaria/react';
import { parsePhoneNumber } from 'libphonenumber-js';
import { isDefined } from 'twenty-shared/utils';
@@ -49,7 +48,7 @@ export const PhonesDisplay = ({
callingCode:
value.primaryPhoneCallingCode ||
value.primaryPhoneCountryCode ||
`+${DEFAULT_PHONE_CALLING_CODE}`,
'',
}
: null,
...parseAdditionalPhones(value?.additionalPhones),
@@ -8,7 +8,6 @@ describe('validateCanCreateUniqueIndex', () => {
const field = {
name: 'testField',
type: FieldMetadataType.MORPH_RELATION,
isCustom: true,
};
expect(() => validateCanCreateUniqueIndex(field)).toThrow(
@@ -23,7 +22,6 @@ describe('validateCanCreateUniqueIndex', () => {
const field = {
name: 'testField',
type: FieldMetadataType.RELATION,
isCustom: true,
};
expect(() => validateCanCreateUniqueIndex(field)).toThrow(
@@ -38,7 +36,6 @@ describe('validateCanCreateUniqueIndex', () => {
const field = {
name: 'testField',
type: FieldMetadataType.FULL_NAME,
isCustom: true,
};
expect(() => validateCanCreateUniqueIndex(field)).toThrow(
@@ -53,7 +50,6 @@ describe('validateCanCreateUniqueIndex', () => {
const field = {
name: 'testField',
type: FieldMetadataType.ADDRESS,
isCustom: true,
};
expect(() => validateCanCreateUniqueIndex(field)).toThrow(
@@ -10,17 +10,8 @@ import {
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
export const validateCanCreateUniqueIndex = (
field: Pick<FieldMetadataEntity, 'type' | 'name' | 'isCustom'>,
field: Pick<FieldMetadataEntity, 'type' | 'name'>,
) => {
if (field.isCustom === false)
throw new FieldMetadataException(
`Unique index cannot be created on standard field`,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
{
userFriendlyMessage: t`Standard fields cannot be unique.`,
},
);
const isCompositeFieldWithNonIncludedUniqueConstraint =
isCompositeFieldMetadataType(field.type) &&
!compositeTypeDefinitions