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 17:39:38 +05:30
committed by GitHub
parent fbd7e65111
commit 1d09cb949a
21 changed files with 330 additions and 60 deletions
@@ -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