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 Screenshot 2025-10-08 002413 ### 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 ? ( ({ text={newItemLabel || `Add ${placeholder}`} /> - )} + ) : 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 {