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:
+1
@@ -126,5 +126,6 @@ export const linksFieldDefinition: FieldDefinition<FieldLinksMetadata> = {
|
||||
metadata: {
|
||||
fieldName: 'links',
|
||||
objectMetadataNameSingular: 'company',
|
||||
settings: null,
|
||||
},
|
||||
};
|
||||
|
||||
+6
@@ -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
-1
@@ -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 +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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+61
-40
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+6
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+8
-5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user