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:
co-authored by
Félix Malfait
Félix Malfait
parent
fbd7e65111
commit
1d09cb949a
@@ -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;
|
||||
};
|
||||
|
||||
+1
@@ -126,5 +126,6 @@ export const linksFieldDefinition: FieldDefinition<FieldLinksMetadata> = {
|
||||
metadata: {
|
||||
fieldName: 'links',
|
||||
objectMetadataNameSingular: 'company',
|
||||
settings: null,
|
||||
},
|
||||
};
|
||||
|
||||
+6
@@ -4,6 +4,7 @@ import { ArrayFieldMenuItem } from '@/object-record/record-field/ui/meta-types/i
|
||||
import { MultiItemFieldInput } from '@/object-record/record-field/ui/meta-types/input/components/MultiItemFieldInput';
|
||||
import { arraySchema } from '@/object-record/record-field/ui/types/guards/isFieldArrayValue';
|
||||
import { useContext, useMemo } from 'react';
|
||||
import { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from 'twenty-shared/constants';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
@@ -38,6 +39,10 @@ export const ArrayFieldInput = () => {
|
||||
onEscape?.({ newValue: draftValue });
|
||||
};
|
||||
|
||||
const maxNumberOfValues =
|
||||
fieldDefinition.metadata.settings?.maxNumberOfValues ??
|
||||
MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES;
|
||||
|
||||
return (
|
||||
<MultiItemFieldInput
|
||||
newItemLabel="Add Item"
|
||||
@@ -56,6 +61,7 @@ export const ArrayFieldInput = () => {
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
maxItemCount={maxNumberOfValues}
|
||||
></MultiItemFieldInput>
|
||||
);
|
||||
};
|
||||
|
||||
+7
-1
@@ -7,13 +7,14 @@ import { emailSchema } from '@/object-record/record-field/ui/validation-schemas/
|
||||
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useCallback, useContext, useMemo } from 'react';
|
||||
import { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from 'twenty-shared/constants';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
|
||||
import { MultiItemFieldInput } from './MultiItemFieldInput';
|
||||
|
||||
export const EmailsFieldInput = () => {
|
||||
const { setDraftValue, draftValue } = useEmailsField();
|
||||
const { setDraftValue, draftValue, fieldDefinition } = useEmailsField();
|
||||
const { copyToClipboard } = useCopyToClipboard();
|
||||
const { t } = useLingui();
|
||||
|
||||
@@ -78,6 +79,10 @@ export const EmailsFieldInput = () => {
|
||||
onEscape?.({ newValue: draftValue });
|
||||
};
|
||||
|
||||
const maxNumberOfValues =
|
||||
fieldDefinition.metadata.settings?.maxNumberOfValues ??
|
||||
MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES;
|
||||
|
||||
return (
|
||||
<MultiItemFieldInput
|
||||
items={emails}
|
||||
@@ -108,6 +113,7 @@ export const EmailsFieldInput = () => {
|
||||
/>
|
||||
)}
|
||||
onError={handleError}
|
||||
maxItemCount={maxNumberOfValues}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+6
@@ -6,6 +6,7 @@ import { recordFieldInputIsFieldInErrorComponentState } from '@/object-record/re
|
||||
import { linksSchema } from '@/object-record/record-field/ui/types/guards/isFieldLinksValue';
|
||||
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
|
||||
import { useContext, useMemo } from 'react';
|
||||
import { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from 'twenty-shared/constants';
|
||||
import { absoluteUrlSchema } from 'twenty-shared/utils';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
import { MultiItemFieldInput } from './MultiItemFieldInput';
|
||||
@@ -61,6 +62,10 @@ export const LinksFieldInput = () => {
|
||||
onEscape?.({ newValue: draftValue });
|
||||
};
|
||||
|
||||
const maxNumberOfValues =
|
||||
fieldDefinition.metadata.settings?.maxNumberOfValues ??
|
||||
MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES;
|
||||
|
||||
return (
|
||||
<MultiItemFieldInput
|
||||
items={links}
|
||||
@@ -94,6 +99,7 @@ export const LinksFieldInput = () => {
|
||||
url={link.url}
|
||||
/>
|
||||
)}
|
||||
maxItemCount={maxNumberOfValues}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+61
-40
@@ -41,6 +41,7 @@ type MultiItemFieldInputProps<T> = {
|
||||
renderInput?: MultiItemBaseInputProps['renderInput'];
|
||||
onClickOutside?: (newItemsValue: T[], event: MouseEvent | TouchEvent) => void;
|
||||
onError?: (hasError: boolean, values: any[]) => void;
|
||||
maxItemCount?: number;
|
||||
};
|
||||
|
||||
// Todo: the API of this component does not look healthy: we have renderInput, renderItem, formatInput, ...
|
||||
@@ -58,6 +59,7 @@ export const MultiItemFieldInput = <T,>({
|
||||
renderInput,
|
||||
onClickOutside,
|
||||
onError,
|
||||
maxItemCount,
|
||||
}: MultiItemFieldInputProps<T>) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -91,14 +93,47 @@ export const MultiItemFieldInput = <T,>({
|
||||
dependencies: [handleEscape],
|
||||
});
|
||||
|
||||
const [isInputDisplayed, setIsInputDisplayed] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [itemToEditIndex, setItemToEditIndex] = useState(-1);
|
||||
const getItemValueAsString = (index: number): string => {
|
||||
let item;
|
||||
switch (fieldMetadataType) {
|
||||
case FieldMetadataType.LINKS:
|
||||
item = items[index] as { label: string; url: string };
|
||||
return item.url || '';
|
||||
case FieldMetadataType.PHONES:
|
||||
item = items[index] as PhoneRecord;
|
||||
return item.callingCode + item.number;
|
||||
case FieldMetadataType.EMAILS:
|
||||
item = items[index] as string;
|
||||
return item;
|
||||
case FieldMetadataType.ARRAY:
|
||||
item = items[index] as string;
|
||||
return item;
|
||||
default:
|
||||
throw new CustomError(
|
||||
`Unsupported field type: ${fieldMetadataType}`,
|
||||
'UNSUPPORTED_FIELD_TYPE',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldAutoEditSingleItem = items.length === 1 && maxItemCount === 1;
|
||||
|
||||
const [isInputDisplayed, setIsInputDisplayed] = useState(
|
||||
shouldAutoEditSingleItem,
|
||||
);
|
||||
const [inputValue, setInputValue] = useState(() =>
|
||||
shouldAutoEditSingleItem ? getItemValueAsString(0) : '',
|
||||
);
|
||||
const [itemToEditIndex, setItemToEditIndex] = useState(
|
||||
shouldAutoEditSingleItem ? 0 : -1,
|
||||
);
|
||||
const [errorData, setErrorData] = useState({
|
||||
isValid: true,
|
||||
errorMessage: '',
|
||||
});
|
||||
const isAddingNewItem = itemToEditIndex === -1;
|
||||
const isLimitReached =
|
||||
typeof maxItemCount === 'number' && items.length >= maxItemCount;
|
||||
|
||||
const handleInputChange = (value: string) => {
|
||||
setInputValue(value);
|
||||
@@ -113,50 +148,22 @@ export const MultiItemFieldInput = <T,>({
|
||||
};
|
||||
|
||||
const handleAddButtonClick = () => {
|
||||
if (isLimitReached) {
|
||||
return;
|
||||
}
|
||||
|
||||
setItemToEditIndex(-1);
|
||||
setIsInputDisplayed(true);
|
||||
};
|
||||
|
||||
const handleEditButtonClick = (index: number) => {
|
||||
let item;
|
||||
switch (fieldMetadataType) {
|
||||
case FieldMetadataType.LINKS:
|
||||
item = items[index] as { label: string; url: string };
|
||||
setInputValue(item.url || '');
|
||||
break;
|
||||
case FieldMetadataType.PHONES:
|
||||
item = items[index] as PhoneRecord;
|
||||
setInputValue(item.callingCode + item.number);
|
||||
break;
|
||||
case FieldMetadataType.EMAILS:
|
||||
item = items[index] as string;
|
||||
setInputValue(item);
|
||||
break;
|
||||
case FieldMetadataType.ARRAY:
|
||||
item = items[index] as string;
|
||||
setInputValue(item);
|
||||
break;
|
||||
default:
|
||||
throw new CustomError(
|
||||
`Unsupported field type: ${fieldMetadataType}`,
|
||||
'UNSUPPORTED_FIELD_TYPE',
|
||||
);
|
||||
}
|
||||
|
||||
setInputValue(getItemValueAsString(index));
|
||||
setItemToEditIndex(index);
|
||||
setIsInputDisplayed(true);
|
||||
};
|
||||
|
||||
const handleSubmitInput = () => {
|
||||
const sanitizedInput = inputValue.trim();
|
||||
if (validateInput !== undefined) {
|
||||
const validationData = validateInput(sanitizedInput) ?? { isValid: true };
|
||||
if (!validationData.isValid) {
|
||||
onError?.(true, items);
|
||||
setErrorData(validationData);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (sanitizedInput === '' && isAddingNewItem) {
|
||||
return;
|
||||
@@ -167,6 +174,15 @@ export const MultiItemFieldInput = <T,>({
|
||||
return;
|
||||
}
|
||||
|
||||
if (validateInput !== undefined) {
|
||||
const validationData = validateInput(sanitizedInput) ?? { isValid: true };
|
||||
if (!validationData.isValid) {
|
||||
onError?.(true, items);
|
||||
setErrorData(validationData);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const newItem = formatInput
|
||||
? formatInput(sanitizedInput)
|
||||
: (sanitizedInput as unknown as T);
|
||||
@@ -194,11 +210,14 @@ export const MultiItemFieldInput = <T,>({
|
||||
const handleDeleteItem = (index: number) => {
|
||||
const updatedItems = toSpliced(items, index, 1);
|
||||
onChange(updatedItems);
|
||||
setIsInputDisplayed(false);
|
||||
setInputValue('');
|
||||
setItemToEditIndex(-1);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownContent ref={containerRef}>
|
||||
{!!items.length && (
|
||||
{!!items.length && !shouldAutoEditSingleItem && (
|
||||
<>
|
||||
<DropdownMenuItemsContainer hasMaxHeight>
|
||||
{items.map((item, index) =>
|
||||
@@ -211,7 +230,9 @@ export const MultiItemFieldInput = <T,>({
|
||||
}),
|
||||
)}
|
||||
</DropdownMenuItemsContainer>
|
||||
<DropdownMenuSeparator />
|
||||
{isInputDisplayed || !isLimitReached ? (
|
||||
<DropdownMenuSeparator />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{isInputDisplayed || !items.length ? (
|
||||
@@ -239,7 +260,7 @@ export const MultiItemFieldInput = <T,>({
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
) : !isLimitReached ? (
|
||||
<DropdownMenuItemsContainer>
|
||||
<MenuItem
|
||||
onClick={handleAddButtonClick}
|
||||
@@ -247,7 +268,7 @@ export const MultiItemFieldInput = <T,>({
|
||||
text={newItemLabel || `Add ${placeholder}`}
|
||||
/>
|
||||
</DropdownMenuItemsContainer>
|
||||
)}
|
||||
) : null}
|
||||
</DropdownContent>
|
||||
);
|
||||
};
|
||||
|
||||
+6
@@ -16,6 +16,7 @@ import { phonesSchema } from '@/object-record/record-field/ui/types/guards/isFie
|
||||
import { PhoneCountryPickerDropdownButton } from '@/ui/input/components/internal/phone/components/PhoneCountryPickerDropdownButton';
|
||||
import { css } from '@emotion/react';
|
||||
import { useContext } from 'react';
|
||||
import { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from 'twenty-shared/constants';
|
||||
import { TEXT_INPUT_STYLE } from 'twenty-ui/theme';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
import { stripSimpleQuotesFromString } from '~/utils/string/stripSimpleQuotesFromString';
|
||||
@@ -82,6 +83,10 @@ export const PhonesFieldInput = () => {
|
||||
fieldDefinition?.defaultValue?.primaryPhoneCountryCode,
|
||||
);
|
||||
|
||||
const maxNumberOfValues =
|
||||
fieldDefinition.metadata.settings?.maxNumberOfValues ??
|
||||
MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES;
|
||||
|
||||
const handlePhonesChange = (
|
||||
updatedPhones: {
|
||||
number: string;
|
||||
@@ -195,6 +200,7 @@ export const PhonesFieldInput = () => {
|
||||
);
|
||||
}}
|
||||
onError={handleError}
|
||||
maxItemCount={maxNumberOfValues}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+8
-5
@@ -5,6 +5,7 @@ import { type CurrencyCode } from 'twenty-shared/constants';
|
||||
import {
|
||||
ConnectedAccountProvider,
|
||||
type AllowedAddressSubField,
|
||||
type FieldMetadataMultiItemSettings,
|
||||
} from 'twenty-shared/types';
|
||||
import { type ThemeColor } from 'twenty-ui/theme';
|
||||
import { z } from 'zod';
|
||||
@@ -82,7 +83,7 @@ export type FieldLinkMetadata = BaseFieldMetadata & {
|
||||
};
|
||||
|
||||
export type FieldLinksMetadata = BaseFieldMetadata & {
|
||||
settings?: null;
|
||||
settings?: FieldMetadataMultiItemSettings | null;
|
||||
};
|
||||
|
||||
export type FieldCurrencyMetadata = BaseFieldMetadata & {
|
||||
@@ -104,7 +105,7 @@ export type FieldEmailMetadata = BaseFieldMetadata & {
|
||||
};
|
||||
|
||||
export type FieldEmailsMetadata = BaseFieldMetadata & {
|
||||
settings?: null;
|
||||
settings?: FieldMetadataMultiItemSettings | null;
|
||||
};
|
||||
|
||||
export type FieldPhoneMetadata = BaseFieldMetadata & {
|
||||
@@ -176,11 +177,11 @@ export type FieldActorMetadata = BaseFieldMetadata & {
|
||||
|
||||
export type FieldArrayMetadata = BaseFieldMetadata & {
|
||||
values: { label: string; value: string }[];
|
||||
settings?: null;
|
||||
settings?: FieldMetadataMultiItemSettings | null;
|
||||
};
|
||||
|
||||
export type FieldPhonesMetadata = BaseFieldMetadata & {
|
||||
settings?: null;
|
||||
settings?: FieldMetadataMultiItemSettings | null;
|
||||
};
|
||||
|
||||
export type FieldTsVectorMetadata = BaseFieldMetadata & {
|
||||
@@ -193,14 +194,16 @@ export type FieldMetadata =
|
||||
| FieldDateTimeMetadata
|
||||
| FieldDateMetadata
|
||||
| FieldEmailMetadata
|
||||
| FieldEmailsMetadata
|
||||
| FieldFullNameMetadata
|
||||
| FieldLinkMetadata
|
||||
| FieldLinksMetadata
|
||||
| FieldNumberMetadata
|
||||
| FieldPhoneMetadata
|
||||
| FieldPhonesMetadata
|
||||
| FieldRatingMetadata
|
||||
| FieldRelationMetadata
|
||||
| FieldMorphRelationMetadata
|
||||
| FieldRichTextMetadata
|
||||
| FieldSelectMetadata
|
||||
| FieldMultiSelectMetadata
|
||||
| FieldTextMetadata
|
||||
|
||||
+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
|
||||
>;
|
||||
+5
@@ -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 =
|
||||
|
||||
+18
@@ -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;
|
||||
}
|
||||
|
||||
+25
-4
@@ -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<HasAllProperties<RawJsonFieldMetadata, NotDefinedSettings>>,
|
||||
Expect<HasAllProperties<RichTextFieldMetadata, NotDefinedSettings>>,
|
||||
Expect<HasAllProperties<ActorFieldMetadata, NotDefinedSettings>>,
|
||||
Expect<HasAllProperties<ArrayFieldMetadata, NotDefinedSettings>>,
|
||||
Expect<HasAllProperties<PhonesFieldMetadata, NotDefinedSettings>>,
|
||||
Expect<HasAllProperties<EmailsFieldMetadata, NotDefinedSettings>>,
|
||||
Expect<HasAllProperties<LinksFieldMetadata, NotDefinedSettings>>,
|
||||
Expect<HasAllProperties<UUIDFieldMetadata, NotDefinedSettings>>,
|
||||
Expect<HasAllProperties<BooleanFieldMetadata, NotDefinedSettings>>,
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES = 10;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const MULTI_ITEM_FIELD_MIN_MAX_VALUES = 1;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export type FieldMetadataMultiItemSettings = {
|
||||
maxNumberOfValues?: number;
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user