From 1d09cb949a619741ecebcc61a2b4644eb4c92fd7 Mon Sep 17 00:00:00 2001
From: Ashwin Temkar <83207402+ashwintemkar@users.noreply.github.com>
Date: Wed, 8 Oct 2025 17:39:38 +0530
Subject: [PATCH] Feat/multivalue limit (#14961)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## 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
### 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
Co-authored-by: Félix Malfait
---
.../types/FieldMetadataItem.ts | 7 +-
.../ui/__mocks__/fieldDefinitions.ts | 1 +
.../input/components/ArrayFieldInput.tsx | 6 ++
.../input/components/EmailsFieldInput.tsx | 8 +-
.../input/components/LinksFieldInput.tsx | 6 ++
.../input/components/MultiItemFieldInput.tsx | 101 +++++++++++-------
.../input/components/PhonesFieldInput.tsx | 6 ++
.../record-field/ui/types/FieldMetadata.ts | 13 ++-
.../SettingsDataModelFieldMaxValuesForm.tsx | 100 +++++++++++++++++
...SettingsDataModelFieldSettingsFormCard.tsx | 49 ++++++++-
.../SettingsDataModelFieldPhonesForm.tsx | 9 +-
...gsDataModelFieldPhonesSettingsFormCard.tsx | 10 ++
.../settingsDataModelFieldMaxValuesSchema.ts | 12 +++
.../field-metadata-settings.interface.ts | 5 +
.../field-metadata-validation.service.ts | 18 ++++
.../types/field-metadata-entity-test.type.ts | 29 ++++-
.../MultiItemFieldDefaultMaxValues.ts | 2 +
.../constants/MultiItemFieldMinMaxValues.ts | 2 +
packages/twenty-shared/src/constants/index.ts | 2 +
.../types/FieldMetadataMultiItemSettings.ts | 3 +
packages/twenty-shared/src/types/index.ts | 1 +
21 files changed, 330 insertions(+), 60 deletions(-)
create mode 100644 packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldMaxValuesForm.tsx
create mode 100644 packages/twenty-front/src/modules/settings/data-model/fields/forms/utils/settingsDataModelFieldMaxValuesSchema.ts
create mode 100644 packages/twenty-shared/src/constants/MultiItemFieldDefaultMaxValues.ts
create mode 100644 packages/twenty-shared/src/constants/MultiItemFieldMinMaxValues.ts
create mode 100644 packages/twenty-shared/src/types/FieldMetadataMultiItemSettings.ts
diff --git a/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataItem.ts
index b6e1e42a40d..52c7fa5a8e8 100644
--- a/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataItem.ts
+++ b/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataItem.ts
@@ -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;
};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/__mocks__/fieldDefinitions.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/__mocks__/fieldDefinitions.ts
index 1158df143e3..c4f33907feb 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/__mocks__/fieldDefinitions.ts
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/__mocks__/fieldDefinitions.ts
@@ -126,5 +126,6 @@ export const linksFieldDefinition: FieldDefinition = {
metadata: {
fieldName: 'links',
objectMetadataNameSingular: 'company',
+ settings: null,
},
};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/ArrayFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/ArrayFieldInput.tsx
index 2247a784123..d3f28a0639b 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/ArrayFieldInput.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/ArrayFieldInput.tsx
@@ -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 (
{
onDelete={handleDelete}
/>
)}
+ maxItemCount={maxNumberOfValues}
>
);
};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/EmailsFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/EmailsFieldInput.tsx
index b958753f68f..8456c0d79a0 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/EmailsFieldInput.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/EmailsFieldInput.tsx
@@ -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 (
{
/>
)}
onError={handleError}
+ maxItemCount={maxNumberOfValues}
/>
);
};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/LinksFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/LinksFieldInput.tsx
index 20078e7d9fa..ebeff23e3b2 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/LinksFieldInput.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/LinksFieldInput.tsx
@@ -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 (
{
url={link.url}
/>
)}
+ maxItemCount={maxNumberOfValues}
/>
);
};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MultiItemFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MultiItemFieldInput.tsx
index cd0b98e03fd..96da27492cd 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MultiItemFieldInput.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/MultiItemFieldInput.tsx
@@ -41,6 +41,7 @@ type MultiItemFieldInputProps = {
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 = ({
renderInput,
onClickOutside,
onError,
+ maxItemCount,
}: MultiItemFieldInputProps) => {
const containerRef = useRef(null);
@@ -91,14 +93,47 @@ export const MultiItemFieldInput = ({
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 = ({
};
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 = ({
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 = ({
const handleDeleteItem = (index: number) => {
const updatedItems = toSpliced(items, index, 1);
onChange(updatedItems);
+ setIsInputDisplayed(false);
+ setInputValue('');
+ setItemToEditIndex(-1);
};
return (
- {!!items.length && (
+ {!!items.length && !shouldAutoEditSingleItem && (
<>
{items.map((item, index) =>
@@ -211,7 +230,9 @@ export const MultiItemFieldInput = ({
}),
)}
-
+ {isInputDisplayed || !isLimitReached ? (
+
+ ) : null}
>
)}
{isInputDisplayed || !items.length ? (
@@ -239,7 +260,7 @@ export const MultiItemFieldInput = ({
) : null
}
/>
- ) : (
+ ) : !isLimitReached ? (
- )}
+ ) : null}
);
};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/PhonesFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/PhonesFieldInput.tsx
index 5d6168e5a66..69ae037f61b 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/PhonesFieldInput.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/PhonesFieldInput.tsx
@@ -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}
/>
);
};
diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts
index 45b73840d00..5489a306fd6 100644
--- a/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts
@@ -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
diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldMaxValuesForm.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldMaxValuesForm.tsx
new file mode 100644
index 00000000000..fcdce3117ae
--- /dev/null
+++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldMaxValuesForm.tsx
@@ -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();
+
+ 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 (
+ {
+ const currentSettings =
+ (value as FieldMetadataMultiItemSettings | undefined) ?? {};
+
+ const maxNumberOfValues =
+ currentSettings.maxNumberOfValues ??
+ MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES;
+
+ return (
+
+ onChange({
+ ...currentSettings,
+ maxNumberOfValues: newValue,
+ })
+ }
+ />
+ );
+ }}
+ />
+ );
+};
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 a118f7aab6e..77a1aa9c006 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
@@ -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={
-
+ <>
+ {[
+ FieldMetadataType.EMAILS,
+ FieldMetadataType.LINKS,
+ FieldMetadataType.ARRAY,
+ ].includes(fieldType) && (
+ <>
+
+
+ >
+ )}
+
+ >
}
/>
);
diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesForm.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesForm.tsx
index 7a73528f665..59d11a7f88f 100644
--- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesForm.tsx
+++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesForm.tsx
@@ -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
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 20e4dc695d4..d3a1d83c200 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
@@ -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}
/>
+
+
+
;
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface.ts
index e842664dc7d..8ca25b6ffb4 100644
--- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface.ts
@@ -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 =
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service.ts
index 9635b6281f6..5dc17a09cfd 100644
--- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service.ts
@@ -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;
}
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/types/field-metadata-entity-test.type.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/types/field-metadata-entity-test.type.ts
index 9901fa85fa5..42cd3a23c2a 100644
--- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/types/field-metadata-entity-test.type.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/types/field-metadata-entity-test.type.ts
@@ -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>,
Expect>,
Expect>,
- Expect>,
- Expect>,
- Expect>,
- Expect>,
Expect>,
Expect>,
@@ -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<
diff --git a/packages/twenty-shared/src/constants/MultiItemFieldDefaultMaxValues.ts b/packages/twenty-shared/src/constants/MultiItemFieldDefaultMaxValues.ts
new file mode 100644
index 00000000000..b0c9f54ebce
--- /dev/null
+++ b/packages/twenty-shared/src/constants/MultiItemFieldDefaultMaxValues.ts
@@ -0,0 +1,2 @@
+export const MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES = 10;
+
diff --git a/packages/twenty-shared/src/constants/MultiItemFieldMinMaxValues.ts b/packages/twenty-shared/src/constants/MultiItemFieldMinMaxValues.ts
new file mode 100644
index 00000000000..4257e54b014
--- /dev/null
+++ b/packages/twenty-shared/src/constants/MultiItemFieldMinMaxValues.ts
@@ -0,0 +1,2 @@
+export const MULTI_ITEM_FIELD_MIN_MAX_VALUES = 1;
+
diff --git a/packages/twenty-shared/src/constants/index.ts b/packages/twenty-shared/src/constants/index.ts
index 94f83e229da..6323ff420be 100644
--- a/packages/twenty-shared/src/constants/index.ts
+++ b/packages/twenty-shared/src/constants/index.ts
@@ -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';
diff --git a/packages/twenty-shared/src/types/FieldMetadataMultiItemSettings.ts b/packages/twenty-shared/src/types/FieldMetadataMultiItemSettings.ts
new file mode 100644
index 00000000000..9c84e8379f2
--- /dev/null
+++ b/packages/twenty-shared/src/types/FieldMetadataMultiItemSettings.ts
@@ -0,0 +1,3 @@
+export type FieldMetadataMultiItemSettings = {
+ maxNumberOfValues?: number;
+};
diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts
index 2516e89fd37..2863d238f30 100644
--- a/packages/twenty-shared/src/types/index.ts
+++ b/packages/twenty-shared/src/types/index.ts
@@ -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 {