Feat/multivalue limit (#14961)

## Summary
1. This change introduces the ability to limit how many values a
multi-value field can contain (for example: maximum number of emails,
phone numbers, links, or array items).
2. It centralizes the limit as a shared constant and type, validates
settings on the server, updates front-end types and components to
consume the setting, and adds a small settings UI so workspace admins
can change the limit per field.
3. Default behavior is preserved: if no max is configured, the existing
default (10) is used.


## **Fixes Issue:** [#14740
](https://github.com/twentyhq/twenty/issues/14740)

## Manual Test Screenshot
<img width="383" height="451" alt="Screenshot 2025-10-08 002413"
src="https://github.com/user-attachments/assets/a7704af6-10ef-4d10-b8c9-a9eeca03bfd9"
/>


### Values in fields


https://github.com/user-attachments/assets/7f59c4f7-3aca-4f83-8c04-3d44988316e3


Let me know if any changes are needed

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Ashwin Temkar
2025-10-08 12:09:38 +00:00
committed by GitHub
co-authored by Félix Malfait Félix Malfait
parent fbd7e65111
commit 1d09cb949a
21 changed files with 330 additions and 60 deletions
@@ -1,7 +1,10 @@
import { type FieldMetadataItemRelation } from '@/object-metadata/types/FieldMetadataItemRelation';
import { type FieldDateMetadataSettings } from '@/object-record/record-field/ui/types/FieldMetadata';
import { type PartialFieldMetadataItemOption } from 'twenty-shared/types';
import {
type FieldMetadataMultiItemSettings,
type PartialFieldMetadataItemOption,
} from 'twenty-shared/types';
import { type ThemeColor } from 'twenty-ui/theme';
import { type Field } from '~/generated-metadata/graphql';
@@ -18,6 +21,6 @@ export type FieldMetadataItem = Omit<
options?: FieldMetadataItemOption[] | null;
relation?: FieldMetadataItemRelation | null;
morphRelations?: FieldMetadataItemRelation[] | null;
settings?: FieldDateMetadataSettings;
settings?: FieldDateMetadataSettings | FieldMetadataMultiItemSettings | null;
isLabelSyncedWithName?: boolean | null;
};
@@ -126,5 +126,6 @@ export const linksFieldDefinition: FieldDefinition<FieldLinksMetadata> = {
metadata: {
fieldName: 'links',
objectMetadataNameSingular: 'company',
settings: null,
},
};
@@ -4,6 +4,7 @@ import { ArrayFieldMenuItem } from '@/object-record/record-field/ui/meta-types/i
import { MultiItemFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/MultiItemFieldInput';
import { arraySchema } from '@/object-record/record-field/ui/types/guards/isFieldArrayValue';
import { useContext, useMemo } from 'react';
import { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
@@ -38,6 +39,10 @@ export const ArrayFieldInput = () => {
onEscape?.({ newValue: draftValue });
};
const maxNumberOfValues =
fieldDefinition.metadata.settings?.maxNumberOfValues ??
MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES;
return (
<MultiItemFieldInput
newItemLabel="Add Item"
@@ -56,6 +61,7 @@ export const ArrayFieldInput = () => {
onDelete={handleDelete}
/>
)}
maxItemCount={maxNumberOfValues}
></MultiItemFieldInput>
);
};
@@ -7,13 +7,14 @@ import { emailSchema } from '@/object-record/record-field/ui/validation-schemas/
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useLingui } from '@lingui/react/macro';
import { useCallback, useContext, useMemo } from 'react';
import { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { MultiItemFieldInput } from './MultiItemFieldInput';
export const EmailsFieldInput = () => {
const { setDraftValue, draftValue } = useEmailsField();
const { setDraftValue, draftValue, fieldDefinition } = useEmailsField();
const { copyToClipboard } = useCopyToClipboard();
const { t } = useLingui();
@@ -78,6 +79,10 @@ export const EmailsFieldInput = () => {
onEscape?.({ newValue: draftValue });
};
const maxNumberOfValues =
fieldDefinition.metadata.settings?.maxNumberOfValues ??
MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES;
return (
<MultiItemFieldInput
items={emails}
@@ -108,6 +113,7 @@ export const EmailsFieldInput = () => {
/>
)}
onError={handleError}
maxItemCount={maxNumberOfValues}
/>
);
};
@@ -6,6 +6,7 @@ import { recordFieldInputIsFieldInErrorComponentState } from '@/object-record/re
import { linksSchema } from '@/object-record/record-field/ui/types/guards/isFieldLinksValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useContext, useMemo } from 'react';
import { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from 'twenty-shared/constants';
import { absoluteUrlSchema } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { MultiItemFieldInput } from './MultiItemFieldInput';
@@ -61,6 +62,10 @@ export const LinksFieldInput = () => {
onEscape?.({ newValue: draftValue });
};
const maxNumberOfValues =
fieldDefinition.metadata.settings?.maxNumberOfValues ??
MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES;
return (
<MultiItemFieldInput
items={links}
@@ -94,6 +99,7 @@ export const LinksFieldInput = () => {
url={link.url}
/>
)}
maxItemCount={maxNumberOfValues}
/>
);
};
@@ -41,6 +41,7 @@ type MultiItemFieldInputProps<T> = {
renderInput?: MultiItemBaseInputProps['renderInput'];
onClickOutside?: (newItemsValue: T[], event: MouseEvent | TouchEvent) => void;
onError?: (hasError: boolean, values: any[]) => void;
maxItemCount?: number;
};
// Todo: the API of this component does not look healthy: we have renderInput, renderItem, formatInput, ...
@@ -58,6 +59,7 @@ export const MultiItemFieldInput = <T,>({
renderInput,
onClickOutside,
onError,
maxItemCount,
}: MultiItemFieldInputProps<T>) => {
const containerRef = useRef<HTMLDivElement>(null);
@@ -91,14 +93,47 @@ export const MultiItemFieldInput = <T,>({
dependencies: [handleEscape],
});
const [isInputDisplayed, setIsInputDisplayed] = useState(false);
const [inputValue, setInputValue] = useState('');
const [itemToEditIndex, setItemToEditIndex] = useState(-1);
const getItemValueAsString = (index: number): string => {
let item;
switch (fieldMetadataType) {
case FieldMetadataType.LINKS:
item = items[index] as { label: string; url: string };
return item.url || '';
case FieldMetadataType.PHONES:
item = items[index] as PhoneRecord;
return item.callingCode + item.number;
case FieldMetadataType.EMAILS:
item = items[index] as string;
return item;
case FieldMetadataType.ARRAY:
item = items[index] as string;
return item;
default:
throw new CustomError(
`Unsupported field type: ${fieldMetadataType}`,
'UNSUPPORTED_FIELD_TYPE',
);
}
};
const shouldAutoEditSingleItem = items.length === 1 && maxItemCount === 1;
const [isInputDisplayed, setIsInputDisplayed] = useState(
shouldAutoEditSingleItem,
);
const [inputValue, setInputValue] = useState(() =>
shouldAutoEditSingleItem ? getItemValueAsString(0) : '',
);
const [itemToEditIndex, setItemToEditIndex] = useState(
shouldAutoEditSingleItem ? 0 : -1,
);
const [errorData, setErrorData] = useState({
isValid: true,
errorMessage: '',
});
const isAddingNewItem = itemToEditIndex === -1;
const isLimitReached =
typeof maxItemCount === 'number' && items.length >= maxItemCount;
const handleInputChange = (value: string) => {
setInputValue(value);
@@ -113,50 +148,22 @@ export const MultiItemFieldInput = <T,>({
};
const handleAddButtonClick = () => {
if (isLimitReached) {
return;
}
setItemToEditIndex(-1);
setIsInputDisplayed(true);
};
const handleEditButtonClick = (index: number) => {
let item;
switch (fieldMetadataType) {
case FieldMetadataType.LINKS:
item = items[index] as { label: string; url: string };
setInputValue(item.url || '');
break;
case FieldMetadataType.PHONES:
item = items[index] as PhoneRecord;
setInputValue(item.callingCode + item.number);
break;
case FieldMetadataType.EMAILS:
item = items[index] as string;
setInputValue(item);
break;
case FieldMetadataType.ARRAY:
item = items[index] as string;
setInputValue(item);
break;
default:
throw new CustomError(
`Unsupported field type: ${fieldMetadataType}`,
'UNSUPPORTED_FIELD_TYPE',
);
}
setInputValue(getItemValueAsString(index));
setItemToEditIndex(index);
setIsInputDisplayed(true);
};
const handleSubmitInput = () => {
const sanitizedInput = inputValue.trim();
if (validateInput !== undefined) {
const validationData = validateInput(sanitizedInput) ?? { isValid: true };
if (!validationData.isValid) {
onError?.(true, items);
setErrorData(validationData);
return;
}
}
if (sanitizedInput === '' && isAddingNewItem) {
return;
@@ -167,6 +174,15 @@ export const MultiItemFieldInput = <T,>({
return;
}
if (validateInput !== undefined) {
const validationData = validateInput(sanitizedInput) ?? { isValid: true };
if (!validationData.isValid) {
onError?.(true, items);
setErrorData(validationData);
return;
}
}
const newItem = formatInput
? formatInput(sanitizedInput)
: (sanitizedInput as unknown as T);
@@ -194,11 +210,14 @@ export const MultiItemFieldInput = <T,>({
const handleDeleteItem = (index: number) => {
const updatedItems = toSpliced(items, index, 1);
onChange(updatedItems);
setIsInputDisplayed(false);
setInputValue('');
setItemToEditIndex(-1);
};
return (
<DropdownContent ref={containerRef}>
{!!items.length && (
{!!items.length && !shouldAutoEditSingleItem && (
<>
<DropdownMenuItemsContainer hasMaxHeight>
{items.map((item, index) =>
@@ -211,7 +230,9 @@ export const MultiItemFieldInput = <T,>({
}),
)}
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
{isInputDisplayed || !isLimitReached ? (
<DropdownMenuSeparator />
) : null}
</>
)}
{isInputDisplayed || !items.length ? (
@@ -239,7 +260,7 @@ export const MultiItemFieldInput = <T,>({
) : null
}
/>
) : (
) : !isLimitReached ? (
<DropdownMenuItemsContainer>
<MenuItem
onClick={handleAddButtonClick}
@@ -247,7 +268,7 @@ export const MultiItemFieldInput = <T,>({
text={newItemLabel || `Add ${placeholder}`}
/>
</DropdownMenuItemsContainer>
)}
) : null}
</DropdownContent>
);
};
@@ -16,6 +16,7 @@ import { phonesSchema } from '@/object-record/record-field/ui/types/guards/isFie
import { PhoneCountryPickerDropdownButton } from '@/ui/input/components/internal/phone/components/PhoneCountryPickerDropdownButton';
import { css } from '@emotion/react';
import { useContext } from 'react';
import { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from 'twenty-shared/constants';
import { TEXT_INPUT_STYLE } from 'twenty-ui/theme';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
@@ -82,6 +83,10 @@ export const PhonesFieldInput = () => {
fieldDefinition?.defaultValue?.primaryPhoneCountryCode,
);
const maxNumberOfValues =
fieldDefinition.metadata.settings?.maxNumberOfValues ??
MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES;
const handlePhonesChange = (
updatedPhones: {
number: string;
@@ -195,6 +200,7 @@ export const PhonesFieldInput = () => {
);
}}
onError={handleError}
maxItemCount={maxNumberOfValues}
/>
);
};
@@ -5,6 +5,7 @@ import { type CurrencyCode } from 'twenty-shared/constants';
import {
ConnectedAccountProvider,
type AllowedAddressSubField,
type FieldMetadataMultiItemSettings,
} from 'twenty-shared/types';
import { type ThemeColor } from 'twenty-ui/theme';
import { z } from 'zod';
@@ -82,7 +83,7 @@ export type FieldLinkMetadata = BaseFieldMetadata & {
};
export type FieldLinksMetadata = BaseFieldMetadata & {
settings?: null;
settings?: FieldMetadataMultiItemSettings | null;
};
export type FieldCurrencyMetadata = BaseFieldMetadata & {
@@ -104,7 +105,7 @@ export type FieldEmailMetadata = BaseFieldMetadata & {
};
export type FieldEmailsMetadata = BaseFieldMetadata & {
settings?: null;
settings?: FieldMetadataMultiItemSettings | null;
};
export type FieldPhoneMetadata = BaseFieldMetadata & {
@@ -176,11 +177,11 @@ export type FieldActorMetadata = BaseFieldMetadata & {
export type FieldArrayMetadata = BaseFieldMetadata & {
values: { label: string; value: string }[];
settings?: null;
settings?: FieldMetadataMultiItemSettings | null;
};
export type FieldPhonesMetadata = BaseFieldMetadata & {
settings?: null;
settings?: FieldMetadataMultiItemSettings | null;
};
export type FieldTsVectorMetadata = BaseFieldMetadata & {
@@ -193,14 +194,16 @@ export type FieldMetadata =
| FieldDateTimeMetadata
| FieldDateMetadata
| FieldEmailMetadata
| FieldEmailsMetadata
| FieldFullNameMetadata
| FieldLinkMetadata
| FieldLinksMetadata
| FieldNumberMetadata
| FieldPhoneMetadata
| FieldPhonesMetadata
| FieldRatingMetadata
| FieldRelationMetadata
| FieldMorphRelationMetadata
| FieldRichTextMetadata
| FieldSelectMetadata
| FieldMultiSelectMetadata
| FieldTextMetadata
@@ -0,0 +1,100 @@
import { Controller, useFormContext } from 'react-hook-form';
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { SettingsOptionCardContentCounter } from '@/settings/components/SettingsOptions/SettingsOptionCardContentCounter';
import { type SettingsDataModelFieldMaxValuesFormValues } from '@/settings/data-model/fields/forms/utils/settingsDataModelFieldMaxValuesSchema';
import { useLingui } from '@lingui/react/macro';
import {
MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES,
MULTI_ITEM_FIELD_MIN_MAX_VALUES,
} from 'twenty-shared/constants';
import {
FieldMetadataType,
type FieldMetadataMultiItemSettings,
} from 'twenty-shared/types';
import { IconNumber } from 'twenty-ui/display';
type SettingsDataModelFieldMaxValuesFormProps = {
disabled?: boolean;
existingFieldMetadataId: string;
fieldType: FieldMetadataType;
};
export const SettingsDataModelFieldMaxValuesForm = ({
disabled,
existingFieldMetadataId,
fieldType,
}: SettingsDataModelFieldMaxValuesFormProps) => {
const { t } = useLingui();
const { control } =
useFormContext<SettingsDataModelFieldMaxValuesFormValues>();
const { fieldMetadataItem } = useFieldMetadataItemById(
existingFieldMetadataId,
);
let title: string | undefined;
let description: string | undefined;
switch (fieldType) {
case FieldMetadataType.PHONES:
title = t`Maximum phone numbers`;
description = t`Ability to add more than one phone number`;
break;
case FieldMetadataType.EMAILS:
title = t`Maximum email addresses`;
description = t`Ability to add more than one email address`;
break;
case FieldMetadataType.LINKS:
title = t`Maximum URLs`;
description = t`Ability to add more than one URL`;
break;
case FieldMetadataType.ARRAY:
title = t`Maximum values`;
description = t`Limit how many values can be added to this field`;
break;
default:
return null;
}
const existingSettings =
(fieldMetadataItem?.settings as FieldMetadataMultiItemSettings) ?? {};
return (
<Controller
name="settings"
control={control}
defaultValue={{
...existingSettings,
maxNumberOfValues:
existingSettings.maxNumberOfValues ??
MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES,
}}
render={({ field: { value, onChange } }) => {
const currentSettings =
(value as FieldMetadataMultiItemSettings | undefined) ?? {};
const maxNumberOfValues =
currentSettings.maxNumberOfValues ??
MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES;
return (
<SettingsOptionCardContentCounter
Icon={IconNumber}
title={title}
description={description}
disabled={disabled}
minValue={MULTI_ITEM_FIELD_MIN_MAX_VALUES}
value={maxNumberOfValues}
onChange={(newValue) =>
onChange({
...currentSettings,
maxNumberOfValues: newValue,
})
}
/>
);
}}
/>
);
};
@@ -8,6 +8,7 @@ import { SettingsDataModelFieldAddressSettingsFormCard } from '@/settings/data-m
import { settingsDataModelFieldBooleanFormSchema } from '@/settings/data-model/fields/forms/boolean/components/SettingsDataModelFieldBooleanForm';
import { SettingsDataModelFieldBooleanSettingsFormCard } from '@/settings/data-model/fields/forms/boolean/components/SettingsDataModelFieldBooleanSettingsFormCard';
import { SettingsDataModelFieldIsUniqueForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldIsUniqueForm';
import { SettingsDataModelFieldMaxValuesForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldMaxValuesForm';
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';
@@ -28,7 +29,9 @@ import {
settingsDataModelFieldSelectFormSchema,
} from '@/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectForm';
import { SettingsDataModelFieldSelectSettingsFormCard } from '@/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectSettingsFormCard';
import { settingsDataModelFieldMaxValuesSchema } from '@/settings/data-model/fields/forms/utils/settingsDataModelFieldMaxValuesSchema';
import { SettingsDataModelFieldPreviewWidget } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewWidget';
import { Separator } from '@/settings/components/Separator';
import { useFormContext } from 'react-hook-form';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { type SettingsDataModelFieldEditFormValues } from '~/pages/settings/data-model/SettingsObjectFieldEdit';
@@ -90,6 +93,21 @@ const phonesFieldFormSchema = z
.extend(settingsDataModelFieldPhonesFormSchema.shape)
.extend(isUniqueFieldFormSchema.shape);
const emailsFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.EMAILS) })
.extend(settingsDataModelFieldMaxValuesSchema.shape)
.extend(isUniqueFieldFormSchema.shape);
const linksFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.LINKS) })
.extend(settingsDataModelFieldMaxValuesSchema.shape)
.extend(isUniqueFieldFormSchema.shape);
const arrayFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.ARRAY) })
.extend(settingsDataModelFieldMaxValuesSchema.shape)
.extend(isUniqueFieldFormSchema.shape);
const otherFieldsFormSchema = z
.object({
type: z.enum(
@@ -107,6 +125,9 @@ const otherFieldsFormSchema = z
FieldMetadataType.ADDRESS,
FieldMetadataType.PHONES,
FieldMetadataType.TEXT,
FieldMetadataType.EMAILS,
FieldMetadataType.LINKS,
FieldMetadataType.ARRAY,
]),
) as [FieldMetadataType, ...FieldMetadataType[]],
),
@@ -128,6 +149,9 @@ export const settingsDataModelFieldSettingsFormSchema = z.discriminatedUnion(
textFieldFormSchema,
addressFieldFormSchema,
phonesFieldFormSchema,
emailsFieldFormSchema,
linksFieldFormSchema,
arrayFieldFormSchema,
otherFieldsFormSchema,
],
);
@@ -284,11 +308,26 @@ export const SettingsDataModelFieldSettingsFormCard = ({
/>
}
form={
<SettingsDataModelFieldIsUniqueForm
fieldType={fieldType}
existingFieldMetadataId={existingFieldMetadataId}
objectNameSingular={objectNameSingular}
/>
<>
{[
FieldMetadataType.EMAILS,
FieldMetadataType.LINKS,
FieldMetadataType.ARRAY,
].includes(fieldType) && (
<>
<SettingsDataModelFieldMaxValuesForm
existingFieldMetadataId={existingFieldMetadataId}
fieldType={fieldType}
/>
<Separator />
</>
)}
<SettingsDataModelFieldIsUniqueForm
fieldType={fieldType}
existingFieldMetadataId={existingFieldMetadataId}
objectNameSingular={objectNameSingular}
/>
</>
}
/>
);
@@ -4,6 +4,7 @@ import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetada
import { phonesSchema as phonesFieldDefaultValueSchema } from '@/object-record/record-field/ui/types/guards/isFieldPhonesValue';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
import { countryCodeToCallingCode } from '@/settings/data-model/fields/preview/utils/getPhonesFieldPreviewValue';
import { settingsDataModelFieldMaxValuesSchema } from '@/settings/data-model/fields/forms/utils/settingsDataModelFieldMaxValuesSchema';
import { Select } from '@/ui/input/components/Select';
import { useCountries } from '@/ui/input/components/internal/hooks/useCountries';
import { useLingui } from '@lingui/react/macro';
@@ -23,9 +24,11 @@ type SettingsDataModelFieldPhonesFormProps = {
existingFieldMetadataId: string;
};
export const settingsDataModelFieldPhonesFormSchema = z.object({
defaultValue: phonesFieldDefaultValueSchema,
});
export const settingsDataModelFieldPhonesFormSchema = z
.object({
defaultValue: phonesFieldDefaultValueSchema,
})
.merge(settingsDataModelFieldMaxValuesSchema);
export type SettingsDataModelFieldPhonesFormValues = z.infer<
typeof settingsDataModelFieldPhonesFormSchema
@@ -1,11 +1,13 @@
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import { SettingsDataModelFieldIsUniqueForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldIsUniqueForm';
import { SettingsDataModelFieldMaxValuesForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldMaxValuesForm';
import {
SettingsDataModelFieldPhonesForm,
type SettingsDataModelFieldPhonesFormValues,
} from '@/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesForm';
import { SettingsDataModelFieldPreviewWidget } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewWidget';
import { Separator } from '@/settings/components/Separator';
import { useFormContext } from 'react-hook-form';
import { FieldMetadataType } from 'twenty-shared/types';
import { type SettingsDataModelFieldEditFormValues } from '~/pages/settings/data-model/SettingsObjectFieldEdit';
@@ -34,6 +36,7 @@ export const SettingsDataModelFieldPhonesSettingsFormCard = ({
type: FieldMetadataType.PHONES,
label: watch('label'),
icon: watch('icon'),
settings: watch('settings') ?? null,
}}
objectNameSingular={objectNameSingular}
/>
@@ -44,6 +47,13 @@ export const SettingsDataModelFieldPhonesSettingsFormCard = ({
disabled={disabled}
existingFieldMetadataId={existingFieldMetadataId}
/>
<Separator />
<SettingsDataModelFieldMaxValuesForm
disabled={disabled}
existingFieldMetadataId={existingFieldMetadataId}
fieldType={FieldMetadataType.PHONES}
/>
<Separator />
<SettingsDataModelFieldIsUniqueForm
fieldType={FieldMetadataType.PHONES}
existingFieldMetadataId={existingFieldMetadataId}
@@ -0,0 +1,12 @@
import { MULTI_ITEM_FIELD_MIN_MAX_VALUES } from 'twenty-shared/constants';
import { z } from 'zod';
export const settingsDataModelFieldMaxValuesSchema = z.object({
settings: z.object({
maxNumberOfValues: z.number().int().min(MULTI_ITEM_FIELD_MIN_MAX_VALUES),
}),
});
export type SettingsDataModelFieldMaxValuesFormValues = z.infer<
typeof settingsDataModelFieldMaxValuesSchema
>;
@@ -1,5 +1,6 @@
import {
type AllowedAddressSubField,
type FieldMetadataMultiItemSettings,
type FieldMetadataType,
type IsExactly,
} from 'twenty-shared/types';
@@ -62,6 +63,10 @@ type FieldMetadataSettingsMapping = {
[FieldMetadataType.ADDRESS]: FieldMetadataAddressSettings | null;
[FieldMetadataType.MORPH_RELATION]: FieldMetadataRelationSettings;
[FieldMetadataType.TS_VECTOR]: FieldMetadataTsVectorSettings | null;
[FieldMetadataType.PHONES]: FieldMetadataMultiItemSettings | null;
[FieldMetadataType.EMAILS]: FieldMetadataMultiItemSettings | null;
[FieldMetadataType.LINKS]: FieldMetadataMultiItemSettings | null;
[FieldMetadataType.ARRAY]: FieldMetadataMultiItemSettings | null;
};
export type AllFieldMetadataSettings =
@@ -12,6 +12,7 @@ import {
type ValidationError,
validateOrReject,
} from 'class-validator';
import { MULTI_ITEM_FIELD_MIN_MAX_VALUES } from 'twenty-shared/constants';
import {
ALLOWED_ADDRESS_SUBFIELDS,
type AllowedAddressSubField,
@@ -72,6 +73,13 @@ class AddressSettingsValidation {
@IsEnum(ALLOWED_ADDRESS_SUBFIELDS, { each: true })
subFields?: AllowedAddressSubField[];
}
class MultipleValuesSettingsValidation {
@IsOptional()
@IsInt()
@Min(MULTI_ITEM_FIELD_MIN_MAX_VALUES)
maxNumberOfValues?: number;
}
@Injectable()
export class FieldMetadataValidationService {
constructor(
@@ -107,6 +115,16 @@ export class FieldMetadataValidationService {
settings,
});
break;
case FieldMetadataType.PHONES:
case FieldMetadataType.EMAILS:
case FieldMetadataType.LINKS:
case FieldMetadataType.ARRAY:
await this.validateSettings({
type: fieldType,
validator: MultipleValuesSettingsValidation,
settings,
});
break;
default:
break;
}
@@ -1,5 +1,6 @@
import { type Expect, type HasAllProperties } from 'twenty-shared/testing';
import {
type FieldMetadataMultiItemSettings,
type FieldMetadataType,
type NullablePartial,
} from 'twenty-shared/types';
@@ -133,10 +134,6 @@ type SettingsAssertions = [
Expect<HasAllProperties<RawJsonFieldMetadata, NotDefinedSettings>>,
Expect<HasAllProperties<RichTextFieldMetadata, NotDefinedSettings>>,
Expect<HasAllProperties<ActorFieldMetadata, NotDefinedSettings>>,
Expect<HasAllProperties<ArrayFieldMetadata, NotDefinedSettings>>,
Expect<HasAllProperties<PhonesFieldMetadata, NotDefinedSettings>>,
Expect<HasAllProperties<EmailsFieldMetadata, NotDefinedSettings>>,
Expect<HasAllProperties<LinksFieldMetadata, NotDefinedSettings>>,
Expect<HasAllProperties<UUIDFieldMetadata, NotDefinedSettings>>,
Expect<HasAllProperties<BooleanFieldMetadata, NotDefinedSettings>>,
@@ -164,6 +161,30 @@ type SettingsAssertions = [
{ settings: FieldMetadataDateTimeSettings | null }
>
>,
Expect<
HasAllProperties<
ArrayFieldMetadata,
{ settings: FieldMetadataMultiItemSettings | null }
>
>,
Expect<
HasAllProperties<
PhonesFieldMetadata,
{ settings: FieldMetadataMultiItemSettings | null }
>
>,
Expect<
HasAllProperties<
EmailsFieldMetadata,
{ settings: FieldMetadataMultiItemSettings | null }
>
>,
Expect<
HasAllProperties<
LinksFieldMetadata,
{ settings: FieldMetadataMultiItemSettings | null }
>
>,
Expect<
HasAllProperties<
@@ -0,0 +1,2 @@
export const MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES = 10;
@@ -0,0 +1,2 @@
export const MULTI_ITEM_FIELD_MIN_MAX_VALUES = 1;
@@ -14,6 +14,8 @@ export { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from './FieldForTotalCountA
export { MAX_OPTIONS_TO_DISPLAY } from './FieldMetadataMaxOptionsToDisplay';
export { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from './FieldRestrictedAdditionalPermissionsRequired';
export { LABEL_IDENTIFIER_FIELD_METADATA_TYPES } from './LabelIdentifierFieldMetadataTypes';
export { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from './MultiItemFieldDefaultMaxValues';
export { MULTI_ITEM_FIELD_MIN_MAX_VALUES } from './MultiItemFieldMinMaxValues';
export { MUTATION_MAX_MERGE_RECORDS } from './MutationMaxMergeRecords';
export { PermissionsOnAllObjectRecords } from './PermissionsOnAllObjectRecords';
export { QUERY_DEFAULT_LIMIT_RECORDS } from './QueryDefaultLimitRecords';
@@ -0,0 +1,3 @@
export type FieldMetadataMultiItemSettings = {
maxNumberOfValues?: number;
};
@@ -16,6 +16,7 @@ export type { ConfigVariableValue } from './ConfigVariableValue';
export { ConnectedAccountProvider } from './ConnectedAccountProvider';
export type { EnumFieldMetadataType } from './EnumFieldMetadataType';
export type { ExcludeFunctions } from './ExcludeFunctions';
export type { FieldMetadataMultiItemSettings } from './FieldMetadataMultiItemSettings';
export { FieldMetadataType } from './FieldMetadataType';
export type { FieldRatingValue } from './FieldRatingValue';
export type {