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
@@ -0,0 +1,100 @@
import { Controller, useFormContext } from 'react-hook-form';
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { SettingsOptionCardContentCounter } from '@/settings/components/SettingsOptions/SettingsOptionCardContentCounter';
import { type SettingsDataModelFieldMaxValuesFormValues } from '@/settings/data-model/fields/forms/utils/settingsDataModelFieldMaxValuesSchema';
import { useLingui } from '@lingui/react/macro';
import {
MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES,
MULTI_ITEM_FIELD_MIN_MAX_VALUES,
} from 'twenty-shared/constants';
import {
FieldMetadataType,
type FieldMetadataMultiItemSettings,
} from 'twenty-shared/types';
import { IconNumber } from 'twenty-ui/display';
type SettingsDataModelFieldMaxValuesFormProps = {
disabled?: boolean;
existingFieldMetadataId: string;
fieldType: FieldMetadataType;
};
export const SettingsDataModelFieldMaxValuesForm = ({
disabled,
existingFieldMetadataId,
fieldType,
}: SettingsDataModelFieldMaxValuesFormProps) => {
const { t } = useLingui();
const { control } =
useFormContext<SettingsDataModelFieldMaxValuesFormValues>();
const { fieldMetadataItem } = useFieldMetadataItemById(
existingFieldMetadataId,
);
let title: string | undefined;
let description: string | undefined;
switch (fieldType) {
case FieldMetadataType.PHONES:
title = t`Maximum phone numbers`;
description = t`Ability to add more than one phone number`;
break;
case FieldMetadataType.EMAILS:
title = t`Maximum email addresses`;
description = t`Ability to add more than one email address`;
break;
case FieldMetadataType.LINKS:
title = t`Maximum URLs`;
description = t`Ability to add more than one URL`;
break;
case FieldMetadataType.ARRAY:
title = t`Maximum values`;
description = t`Limit how many values can be added to this field`;
break;
default:
return null;
}
const existingSettings =
(fieldMetadataItem?.settings as FieldMetadataMultiItemSettings) ?? {};
return (
<Controller
name="settings"
control={control}
defaultValue={{
...existingSettings,
maxNumberOfValues:
existingSettings.maxNumberOfValues ??
MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES,
}}
render={({ field: { value, onChange } }) => {
const currentSettings =
(value as FieldMetadataMultiItemSettings | undefined) ?? {};
const maxNumberOfValues =
currentSettings.maxNumberOfValues ??
MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES;
return (
<SettingsOptionCardContentCounter
Icon={IconNumber}
title={title}
description={description}
disabled={disabled}
minValue={MULTI_ITEM_FIELD_MIN_MAX_VALUES}
value={maxNumberOfValues}
onChange={(newValue) =>
onChange({
...currentSettings,
maxNumberOfValues: newValue,
})
}
/>
);
}}
/>
);
};
@@ -8,6 +8,7 @@ import { SettingsDataModelFieldAddressSettingsFormCard } from '@/settings/data-m
import { settingsDataModelFieldBooleanFormSchema } from '@/settings/data-model/fields/forms/boolean/components/SettingsDataModelFieldBooleanForm';
import { SettingsDataModelFieldBooleanSettingsFormCard } from '@/settings/data-model/fields/forms/boolean/components/SettingsDataModelFieldBooleanSettingsFormCard';
import { SettingsDataModelFieldIsUniqueForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldIsUniqueForm';
import { SettingsDataModelFieldMaxValuesForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldMaxValuesForm';
import { settingsDataModelFieldTextFormSchema } from '@/settings/data-model/fields/forms/components/text/SettingsDataModelFieldTextForm';
import { SettingsDataModelFieldTextSettingsFormCard } from '@/settings/data-model/fields/forms/components/text/SettingsDataModelFieldTextSettingsFormCard';
import { settingsDataModelFieldCurrencyFormSchema } from '@/settings/data-model/fields/forms/currency/components/SettingsDataModelFieldCurrencyForm';
@@ -28,7 +29,9 @@ import {
settingsDataModelFieldSelectFormSchema,
} from '@/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectForm';
import { SettingsDataModelFieldSelectSettingsFormCard } from '@/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectSettingsFormCard';
import { settingsDataModelFieldMaxValuesSchema } from '@/settings/data-model/fields/forms/utils/settingsDataModelFieldMaxValuesSchema';
import { SettingsDataModelFieldPreviewWidget } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewWidget';
import { Separator } from '@/settings/components/Separator';
import { useFormContext } from 'react-hook-form';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { type SettingsDataModelFieldEditFormValues } from '~/pages/settings/data-model/SettingsObjectFieldEdit';
@@ -90,6 +93,21 @@ const phonesFieldFormSchema = z
.extend(settingsDataModelFieldPhonesFormSchema.shape)
.extend(isUniqueFieldFormSchema.shape);
const emailsFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.EMAILS) })
.extend(settingsDataModelFieldMaxValuesSchema.shape)
.extend(isUniqueFieldFormSchema.shape);
const linksFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.LINKS) })
.extend(settingsDataModelFieldMaxValuesSchema.shape)
.extend(isUniqueFieldFormSchema.shape);
const arrayFieldFormSchema = z
.object({ type: z.literal(FieldMetadataType.ARRAY) })
.extend(settingsDataModelFieldMaxValuesSchema.shape)
.extend(isUniqueFieldFormSchema.shape);
const otherFieldsFormSchema = z
.object({
type: z.enum(
@@ -107,6 +125,9 @@ const otherFieldsFormSchema = z
FieldMetadataType.ADDRESS,
FieldMetadataType.PHONES,
FieldMetadataType.TEXT,
FieldMetadataType.EMAILS,
FieldMetadataType.LINKS,
FieldMetadataType.ARRAY,
]),
) as [FieldMetadataType, ...FieldMetadataType[]],
),
@@ -128,6 +149,9 @@ export const settingsDataModelFieldSettingsFormSchema = z.discriminatedUnion(
textFieldFormSchema,
addressFieldFormSchema,
phonesFieldFormSchema,
emailsFieldFormSchema,
linksFieldFormSchema,
arrayFieldFormSchema,
otherFieldsFormSchema,
],
);
@@ -284,11 +308,26 @@ export const SettingsDataModelFieldSettingsFormCard = ({
/>
}
form={
<SettingsDataModelFieldIsUniqueForm
fieldType={fieldType}
existingFieldMetadataId={existingFieldMetadataId}
objectNameSingular={objectNameSingular}
/>
<>
{[
FieldMetadataType.EMAILS,
FieldMetadataType.LINKS,
FieldMetadataType.ARRAY,
].includes(fieldType) && (
<>
<SettingsDataModelFieldMaxValuesForm
existingFieldMetadataId={existingFieldMetadataId}
fieldType={fieldType}
/>
<Separator />
</>
)}
<SettingsDataModelFieldIsUniqueForm
fieldType={fieldType}
existingFieldMetadataId={existingFieldMetadataId}
objectNameSingular={objectNameSingular}
/>
</>
}
/>
);
@@ -4,6 +4,7 @@ import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetada
import { phonesSchema as phonesFieldDefaultValueSchema } from '@/object-record/record-field/ui/types/guards/isFieldPhonesValue';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
import { countryCodeToCallingCode } from '@/settings/data-model/fields/preview/utils/getPhonesFieldPreviewValue';
import { settingsDataModelFieldMaxValuesSchema } from '@/settings/data-model/fields/forms/utils/settingsDataModelFieldMaxValuesSchema';
import { Select } from '@/ui/input/components/Select';
import { useCountries } from '@/ui/input/components/internal/hooks/useCountries';
import { useLingui } from '@lingui/react/macro';
@@ -23,9 +24,11 @@ type SettingsDataModelFieldPhonesFormProps = {
existingFieldMetadataId: string;
};
export const settingsDataModelFieldPhonesFormSchema = z.object({
defaultValue: phonesFieldDefaultValueSchema,
});
export const settingsDataModelFieldPhonesFormSchema = z
.object({
defaultValue: phonesFieldDefaultValueSchema,
})
.merge(settingsDataModelFieldMaxValuesSchema);
export type SettingsDataModelFieldPhonesFormValues = z.infer<
typeof settingsDataModelFieldPhonesFormSchema
@@ -1,11 +1,13 @@
import { SettingsDataModelPreviewFormCard } from '@/settings/data-model/components/SettingsDataModelPreviewFormCard';
import { SettingsDataModelFieldIsUniqueForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldIsUniqueForm';
import { SettingsDataModelFieldMaxValuesForm } from '@/settings/data-model/fields/forms/components/SettingsDataModelFieldMaxValuesForm';
import {
SettingsDataModelFieldPhonesForm,
type SettingsDataModelFieldPhonesFormValues,
} from '@/settings/data-model/fields/forms/phones/components/SettingsDataModelFieldPhonesForm';
import { SettingsDataModelFieldPreviewWidget } from '@/settings/data-model/fields/preview/components/SettingsDataModelFieldPreviewWidget';
import { Separator } from '@/settings/components/Separator';
import { useFormContext } from 'react-hook-form';
import { FieldMetadataType } from 'twenty-shared/types';
import { type SettingsDataModelFieldEditFormValues } from '~/pages/settings/data-model/SettingsObjectFieldEdit';
@@ -34,6 +36,7 @@ export const SettingsDataModelFieldPhonesSettingsFormCard = ({
type: FieldMetadataType.PHONES,
label: watch('label'),
icon: watch('icon'),
settings: watch('settings') ?? null,
}}
objectNameSingular={objectNameSingular}
/>
@@ -44,6 +47,13 @@ export const SettingsDataModelFieldPhonesSettingsFormCard = ({
disabled={disabled}
existingFieldMetadataId={existingFieldMetadataId}
/>
<Separator />
<SettingsDataModelFieldMaxValuesForm
disabled={disabled}
existingFieldMetadataId={existingFieldMetadataId}
fieldType={FieldMetadataType.PHONES}
/>
<Separator />
<SettingsDataModelFieldIsUniqueForm
fieldType={FieldMetadataType.PHONES}
existingFieldMetadataId={existingFieldMetadataId}
@@ -0,0 +1,12 @@
import { MULTI_ITEM_FIELD_MIN_MAX_VALUES } from 'twenty-shared/constants';
import { z } from 'zod';
export const settingsDataModelFieldMaxValuesSchema = z.object({
settings: z.object({
maxNumberOfValues: z.number().int().min(MULTI_ITEM_FIELD_MIN_MAX_VALUES),
}),
});
export type SettingsDataModelFieldMaxValuesFormValues = z.infer<
typeof settingsDataModelFieldMaxValuesSchema
>;