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:
+100
@@ -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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+44
-5
@@ -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}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
+6
-3
@@ -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
|
||||
|
||||
+10
@@ -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}
|
||||
|
||||
+12
@@ -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
|
||||
>;
|
||||
Reference in New Issue
Block a user