Support for multiple values in the Phone field (#6882)

### Description

- This is the first PR on Phones field;


- We are introducing new field type(Phones)


- We are Forbidding creation of Phone field


- We Added support for filtering and sorting on Phones field


- We are using the same display mode as used on the Links field type
(chips), check the Domain field of the Company object


- We are also using the same logic of the link when editing the field

**How to Test**

1. Checkout to TWNTY-6260 branch
2. Reset database using "npx nx database:reset twenty-server" command
3. Add custom field of type Phones in settings/data-model

**Loom Video:**\

<https://www.loom.com/share/3c981260be254dcf851256d020a20ab0?sid=58507361-3a3b-452c-9de8-b5b1abda70ac>

### Refs

#6260

Co-authored-by: gitstart-twenty <gitstart-twenty@users.noreply.github.com>
This commit is contained in:
gitstart-app[bot]
2024-09-11 11:15:04 +02:00
committed by GitHub
parent 91187dcf82
commit 846953b0f4
52 changed files with 793 additions and 64 deletions

View File

@ -2527,6 +2527,7 @@ export enum FieldMetadataType {
Number = 'NUMBER',
Numeric = 'NUMERIC',
Phone = 'PHONE',
Phones = 'PHONES',
Position = 'POSITION',
Rating = 'RATING',
RawJson = 'RAW_JSON',

View File

@ -368,6 +368,7 @@ export enum FieldMetadataType {
Number = 'NUMBER',
Numeric = 'NUMERIC',
Phone = 'PHONE',
Phones = 'PHONES',
Position = 'POSITION',
Rating = 'RATING',
RawJson = 'RAW_JSON',

View File

@ -1,5 +1,5 @@
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
import { gql } from '@apollo/client';
export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
@ -273,6 +273,7 @@ export enum FieldMetadataType {
Number = 'NUMBER',
Numeric = 'NUMERIC',
Phone = 'PHONE',
Phones = 'PHONES',
Position = 'POSITION',
Rating = 'RATING',
RawJson = 'RAW_JSON',

View File

@ -15,4 +15,5 @@ export const SORTABLE_FIELD_METADATA_TYPES = [
FieldMetadataType.Currency,
FieldMetadataType.Actor,
FieldMetadataType.Links,
FieldMetadataType.Phones,
];

View File

@ -38,6 +38,7 @@ export const formatFieldMetadataItemsAsFilterDefinitions = ({
FieldMetadataType.Currency,
FieldMetadataType.Rating,
FieldMetadataType.Actor,
FieldMetadataType.Phones,
].includes(field.type)
) {
return acc;
@ -83,6 +84,8 @@ export const getFilterTypeFromFieldType = (fieldType: FieldMetadataType) => {
return 'EMAILS';
case FieldMetadataType.Phone:
return 'PHONE';
case FieldMetadataType.Phones:
return 'PHONES';
case FieldMetadataType.Relation:
return 'RELATION';
case FieldMetadataType.Select:

View File

@ -4,6 +4,7 @@ import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordG
import {
FieldEmailsValue,
FieldLinksValue,
FieldPhonesValue,
} from '@/object-record/record-field/types/FieldMetadata';
import { OrderBy } from '@/types/OrderBy';
import { FieldMetadataType } from '~/generated-metadata/graphql';
@ -54,6 +55,14 @@ export const getOrderByForFieldMetadataType = (
} satisfies { [key in keyof FieldEmailsValue]?: OrderBy },
},
];
case FieldMetadataType.Phones:
return [
{
[field.name]: {
primaryPhoneNumber: direction ?? 'AscNullsLast',
} satisfies { [key in keyof FieldPhonesValue]?: OrderBy },
},
];
default:
return [
{

View File

@ -164,5 +164,14 @@ ${mapObjectMetadataToGraphQLQuery({
}`;
}
if (fieldType === FieldMetadataType.Phones) {
return `${field.name}
{
primaryPhoneNumber
primaryPhoneCountryCode
additionalPhones
}`;
}
return '';
};

View File

@ -98,6 +98,11 @@ export type EmailsFilter = {
primaryEmail?: StringFilter;
};
export type PhonesFilter = {
primaryPhoneNumber?: StringFilter;
primaryPhoneCountryCode?: StringFilter;
};
export type LeafFilter =
| UUIDFilter
| StringFilter
@ -110,6 +115,7 @@ export type LeafFilter =
| AddressFilter
| LinksFilter
| ActorFilter
| PhonesFilter
| undefined;
export type AndObjectRecordFilter = {

View File

@ -67,6 +67,7 @@ export const MultipleFiltersDropdownContent = ({
'LINKS',
'ADDRESS',
'ACTOR',
'PHONES',
].includes(filterDefinitionUsedInDropdown.type) && (
<ObjectFilterDropdownTextSearchInput />
)}

View File

@ -1,6 +1,7 @@
export type FilterType =
| 'TEXT'
| 'PHONE'
| 'PHONES'
| 'EMAIL'
| 'EMAILS'
| 'DATE_TIME'

View File

@ -22,6 +22,7 @@ export const getOperandsForFilterType = (
case 'LINK':
case 'LINKS':
case 'ACTOR':
case 'PHONES':
return [
ViewFilterOperand.Contains,
ViewFilterOperand.DoesNotContain,

View File

@ -4,6 +4,7 @@ import { ActorFieldDisplay } from '@/object-record/record-field/meta-types/displ
import { BooleanFieldDisplay } from '@/object-record/record-field/meta-types/display/components/BooleanFieldDisplay';
import { EmailsFieldDisplay } from '@/object-record/record-field/meta-types/display/components/EmailsFieldDisplay';
import { LinksFieldDisplay } from '@/object-record/record-field/meta-types/display/components/LinksFieldDisplay';
import { PhonesFieldDisplay } from '@/object-record/record-field/meta-types/display/components/PhonesFieldDisplay';
import { RatingFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RatingFieldDisplay';
import { RelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay';
import { RichTextFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RichTextFieldDisplay';
@ -13,6 +14,7 @@ import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFiel
import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone';
import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { isFieldPhones } from '@/object-record/record-field/types/guards/isFieldPhones';
import { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating';
import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects';
import { isFieldRelationToOneObject } from '@/object-record/record-field/types/guards/isFieldRelationToOneObject';
@ -104,5 +106,7 @@ export const FieldDisplay = () => {
<ActorFieldDisplay />
) : isFieldEmails(fieldDefinition) ? (
<EmailsFieldDisplay />
) : isFieldPhones(fieldDefinition) ? (
<PhonesFieldDisplay />
) : null;
};

View File

@ -6,6 +6,7 @@ import { EmailsFieldInput } from '@/object-record/record-field/meta-types/input/
import { FullNameFieldInput } from '@/object-record/record-field/meta-types/input/components/FullNameFieldInput';
import { LinksFieldInput } from '@/object-record/record-field/meta-types/input/components/LinksFieldInput';
import { MultiSelectFieldInput } from '@/object-record/record-field/meta-types/input/components/MultiSelectFieldInput';
import { PhonesFieldInput } from '@/object-record/record-field/meta-types/input/components/PhonesFieldInput';
import { RawJsonFieldInput } from '@/object-record/record-field/meta-types/input/components/RawJsonFieldInput';
import { RelationFromManyFieldInput } from '@/object-record/record-field/meta-types/input/components/RelationFromManyFieldInput';
import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/components/SelectFieldInput';
@ -16,6 +17,7 @@ import { isFieldEmails } from '@/object-record/record-field/types/guards/isField
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
import { isFieldPhones } from '@/object-record/record-field/types/guards/isFieldPhones';
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects';
import { isFieldRelationToOneObject } from '@/object-record/record-field/types/guards/isFieldRelationToOneObject';
@ -89,6 +91,8 @@ export const FieldInput = ({
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldPhones(fieldDefinition) ? (
<PhonesFieldInput onCancel={onCancel} />
) : isFieldText(fieldDefinition) ? (
<TextFieldInput
onEnter={onEnter}

View File

@ -15,6 +15,8 @@ import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldL
import { isFieldLinksValue } from '@/object-record/record-field/types/guards/isFieldLinksValue';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
import { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue';
import { isFieldPhones } from '@/object-record/record-field/types/guards/isFieldPhones';
import { isFieldPhonesValue } from '@/object-record/record-field/types/guards/isFieldPhonesValue';
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
import { isFieldRawJsonValue } from '@/object-record/record-field/types/guards/isFieldRawJsonValue';
import { isFieldRelationToOneObject } from '@/object-record/record-field/types/guards/isFieldRelationToOneObject';
@ -104,6 +106,9 @@ export const usePersistField = () => {
const fieldIsPhone =
isFieldPhone(fieldDefinition) && isFieldPhoneValue(valueToPersist);
const fieldIsPhones =
isFieldPhones(fieldDefinition) && isFieldPhonesValue(valueToPersist);
const fieldIsSelect =
isFieldSelect(fieldDefinition) && isFieldSelectValue(valueToPersist);
@ -130,6 +135,7 @@ export const usePersistField = () => {
fieldIsDateTime ||
fieldIsDate ||
fieldIsPhone ||
fieldIsPhones ||
fieldIsLink ||
fieldIsLinks ||
fieldIsCurrency ||

View File

@ -0,0 +1,11 @@
import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus';
import { usePhonesFieldDisplay } from '@/object-record/record-field/meta-types/hooks/usePhonesFieldDisplay';
import { PhonesDisplay } from '@/ui/field/display/components/PhonesDisplay';
export const PhonesFieldDisplay = () => {
const { fieldValue } = usePhonesFieldDisplay();
const { isFocused } = useFieldFocus();
return <PhonesDisplay value={fieldValue} isFocused={isFocused} />;
};

View File

@ -0,0 +1,53 @@
import { useContext } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { usePersistField } from '@/object-record/record-field/hooks/usePersistField';
import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput';
import { FieldPhonesValue } from '@/object-record/record-field/types/FieldMetadata';
import { isFieldPhones } from '@/object-record/record-field/types/guards/isFieldPhones';
import { phonesSchema } from '@/object-record/record-field/types/guards/isFieldPhonesValue';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FieldContext } from '../../contexts/FieldContext';
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';
export const usePhonesField = () => {
const { recordId, fieldDefinition, hotkeyScope } = useContext(FieldContext);
assertFieldMetadata(FieldMetadataType.Phones, isFieldPhones, fieldDefinition);
const fieldName = fieldDefinition.metadata.fieldName;
const [fieldValue, setFieldValue] = useRecoilState<FieldPhonesValue>(
recordStoreFamilySelector({
recordId,
fieldName: fieldName,
}),
);
const { setDraftValue, getDraftValueSelector } =
useRecordFieldInput<FieldPhonesValue>(`${recordId}-${fieldName}`);
const draftValue = useRecoilValue(getDraftValueSelector());
const persistField = usePersistField();
const persistPhonesField = (nextValue: FieldPhonesValue) => {
try {
persistField(phonesSchema.parse(nextValue));
} catch {
return;
}
};
return {
fieldDefinition,
fieldValue,
draftValue,
setDraftValue,
setFieldValue,
hotkeyScope,
persistPhonesField,
};
};

View File

@ -0,0 +1,22 @@
import { useContext } from 'react';
import { FieldPhonesValue } from '@/object-record/record-field/types/FieldMetadata';
import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
import { FieldContext } from '../../contexts/FieldContext';
export const usePhonesFieldDisplay = () => {
const { recordId, fieldDefinition } = useContext(FieldContext);
const fieldName = fieldDefinition.metadata.fieldName;
const fieldValue = useRecordFieldValue<FieldPhonesValue | undefined>(
recordId,
fieldName,
);
return {
fieldDefinition,
fieldValue,
};
};

View File

@ -2,6 +2,7 @@ import { useEmailsField } from '@/object-record/record-field/meta-types/hooks/us
import { EmailsFieldMenuItem } from '@/object-record/record-field/meta-types/input/components/EmailsFieldMenuItem';
import { useMemo } from 'react';
import { isDefined } from 'twenty-ui';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { MultiItemFieldInput } from './MultiItemFieldInput';
type EmailsFieldInputProps = {
@ -34,6 +35,7 @@ export const EmailsFieldInput = ({ onCancel }: EmailsFieldInputProps) => {
onPersist={handlePersistEmails}
onCancel={onCancel}
placeholder="Email"
fieldMetadataType={FieldMetadataType.Emails}
renderItem={({
value: email,
index,

View File

@ -2,6 +2,7 @@ import { useLinksField } from '@/object-record/record-field/meta-types/hooks/use
import { LinksFieldMenuItem } from '@/object-record/record-field/meta-types/input/components/LinksFieldMenuItem';
import { useMemo } from 'react';
import { isDefined } from 'twenty-ui';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { absoluteUrlSchema } from '~/utils/validation-schemas/absoluteUrlSchema';
import { MultiItemFieldInput } from './MultiItemFieldInput';
@ -47,6 +48,7 @@ export const LinksFieldInput = ({ onCancel }: LinksFieldInputProps) => {
onPersist={handlePersistLinks}
onCancel={onCancel}
placeholder="URL"
fieldMetadataType={FieldMetadataType.Links}
validateInput={(input) => absoluteUrlSchema.safeParse(input).success}
formatInput={(input) => ({ url: input, label: '' })}
renderItem={({

View File

@ -1,16 +1,21 @@
import styled from '@emotion/styled';
import { useRef, useState } from 'react';
import React, { useRef, useState } from 'react';
import { Key } from 'ts-key-enum';
import { IconCheck, IconPlus } from 'twenty-ui';
import { PhoneRecord } from '@/object-record/record-field/types/FieldMetadata';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuInput } from '@/ui/layout/dropdown/components/DropdownMenuInput';
import {
DropdownMenuInput,
DropdownMenuInputProps,
} from '@/ui/layout/dropdown/components/DropdownMenuInput';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { moveArrayItem } from '~/utils/array/moveArrayItem';
import { toSpliced } from '~/utils/array/toSpliced';
@ -35,6 +40,8 @@ type MultiItemFieldInputProps<T> = {
handleDelete: () => void;
}) => React.ReactNode;
hotkeyScope: string;
fieldMetadataType: FieldMetadataType;
renderInput?: DropdownMenuInputProps['renderInput'];
};
export const MultiItemFieldInput = <T,>({
@ -46,6 +53,8 @@ export const MultiItemFieldInput = <T,>({
formatInput,
renderItem,
hotkeyScope,
fieldMetadataType,
renderInput,
}: MultiItemFieldInputProps<T>) => {
const containerRef = useRef<HTMLDivElement>(null);
const handleDropdownClose = () => {
@ -70,9 +79,25 @@ export const MultiItemFieldInput = <T,>({
};
const handleEditButtonClick = (index: number) => {
const item = items[index] as { label: string; url: string };
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.countryCode + item.number);
break;
case FieldMetadataType.Emails:
item = items[index] as string;
setInputValue(item);
break;
default:
throw new Error(`Unsupported field type: ${fieldMetadataType}`);
}
setItemToEditIndex(index);
setInputValue(item.url || '');
setIsInputDisplayed(true);
};
@ -132,6 +157,16 @@ export const MultiItemFieldInput = <T,>({
placeholder={placeholder}
value={inputValue}
hotkeyScope={hotkeyScope}
renderInput={
renderInput
? (props) =>
renderInput({
...props,
onChange: (newValue) =>
setInputValue(newValue as unknown as string),
})
: undefined
}
onChange={(event) => setInputValue(event.target.value)}
onEnter={handleSubmitInput}
rightComponent={

View File

@ -0,0 +1,134 @@
import { usePhonesField } from '@/object-record/record-field/meta-types/hooks/usePhonesField';
import { PhonesFieldMenuItem } from '@/object-record/record-field/meta-types/input/components/PhonesFieldMenuItem';
import styled from '@emotion/styled';
import { E164Number, parsePhoneNumber } from 'libphonenumber-js';
import { useMemo } from 'react';
import ReactPhoneNumberInput from 'react-phone-number-input';
import 'react-phone-number-input/style.css';
import { isDefined, TEXT_INPUT_STYLE } from 'twenty-ui';
import { MultiItemFieldInput } from './MultiItemFieldInput';
import { PhoneCountryPickerDropdownButton } from '@/ui/input/components/internal/phone/components/PhoneCountryPickerDropdownButton';
import { FieldMetadataType } from '~/generated-metadata/graphql';
const StyledCustomPhoneInput = styled(ReactPhoneNumberInput)`
font-family: ${({ theme }) => theme.font.family};
height: 32px;
${TEXT_INPUT_STYLE}
padding: 0;
.PhoneInputInput {
background: none;
border: none;
color: ${({ theme }) => theme.font.color.primary};
&::placeholder,
&::-webkit-input-placeholder {
color: ${({ theme }) => theme.font.color.light};
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.medium};
}
:focus {
outline: none;
}
}
& svg {
border-radius: ${({ theme }) => theme.border.radius.xs};
height: 12px;
}
width: calc(100% - ${({ theme }) => theme.spacing(8)});
`;
type PhonesFieldInputProps = {
onCancel?: () => void;
};
export const PhonesFieldInput = ({ onCancel }: PhonesFieldInputProps) => {
const { persistPhonesField, hotkeyScope, fieldValue } = usePhonesField();
const phones = useMemo<{ number: string; countryCode: string }[]>(
() =>
[
fieldValue.primaryPhoneNumber
? {
number: fieldValue.primaryPhoneNumber,
countryCode: fieldValue.primaryPhoneCountryCode,
}
: null,
...(fieldValue.additionalPhones ?? []),
].filter(isDefined),
[
fieldValue.primaryPhoneNumber,
fieldValue.primaryPhoneCountryCode,
fieldValue.additionalPhones,
],
);
const handlePersistPhones = (
updatedPhones: { number: string; countryCode: string }[],
) => {
const [nextPrimaryPhone, ...nextAdditionalPhones] = updatedPhones;
persistPhonesField({
primaryPhoneNumber: nextPrimaryPhone?.number ?? '',
primaryPhoneCountryCode: nextPrimaryPhone?.countryCode ?? '',
additionalPhones: nextAdditionalPhones,
});
};
return (
<MultiItemFieldInput
items={phones}
onPersist={handlePersistPhones}
onCancel={onCancel}
placeholder="Phone"
fieldMetadataType={FieldMetadataType.Phones}
formatInput={(input) => {
const phone = parsePhoneNumber(input);
if (phone !== undefined) {
return {
number: phone.nationalNumber,
countryCode: `+${phone.countryCallingCode}`,
};
}
return {
number: '',
countryCode: '',
};
}}
renderItem={({
value: phone,
index,
handleEdit,
handleSetPrimary,
handleDelete,
}) => (
<PhonesFieldMenuItem
key={index}
dropdownId={`${hotkeyScope}-phones-${index}`}
isPrimary={index === 0}
phone={phone}
onEdit={handleEdit}
onSetAsPrimary={handleSetPrimary}
onDelete={handleDelete}
/>
)}
renderInput={({ value, onChange, autoFocus, placeholder }) => {
return (
<StyledCustomPhoneInput
autoFocus={autoFocus}
placeholder={placeholder}
value={value as E164Number}
onChange={onChange as unknown as (newValue: E164Number) => void}
international={true}
withCountryCallingCode={true}
countrySelectComponent={PhoneCountryPickerDropdownButton}
/>
);
}}
hotkeyScope={hotkeyScope}
/>
);
};

View File

@ -0,0 +1,32 @@
import { PhoneDisplay } from '@/ui/field/display/components/PhoneDisplay';
import { MultiItemFieldMenuItem } from './MultiItemFieldMenuItem';
type PhonesFieldMenuItemProps = {
dropdownId: string;
isPrimary?: boolean;
onEdit?: () => void;
onSetAsPrimary?: () => void;
onDelete?: () => void;
phone: { number: string; countryCode: string };
};
export const PhonesFieldMenuItem = ({
dropdownId,
isPrimary,
onEdit,
onSetAsPrimary,
onDelete,
phone,
}: PhonesFieldMenuItemProps) => {
return (
<MultiItemFieldMenuItem
dropdownId={dropdownId}
isPrimary={isPrimary}
value={phone.countryCode + phone.number}
onEdit={onEdit}
onSetAsPrimary={onSetAsPrimary}
onDelete={onDelete}
DisplayComponent={PhoneDisplay}
/>
);
};

View File

@ -13,6 +13,7 @@ import {
FieldLinkValue,
FieldMultiSelectValue,
FieldNumberValue,
FieldPhonesValue,
FieldPhoneValue,
FieldRatingValue,
FieldRelationFromManyValue,
@ -20,12 +21,18 @@ import {
FieldSelectValue,
FieldTextValue,
FieldUUidValue,
PhoneRecord,
} from '@/object-record/record-field/types/FieldMetadata';
export type FieldTextDraftValue = string;
export type FieldNumberDraftValue = string;
export type FieldDateTimeDraftValue = string;
export type FieldPhoneDraftValue = string;
export type FieldPhonesDraftValue = {
primaryPhoneNumber: string;
primaryPhoneCountryCode: string;
additionalPhones?: PhoneRecord[] | null;
};
export type FieldEmailDraftValue = string;
export type FieldEmailsDraftValue = {
primaryEmail: string;
@ -75,32 +82,34 @@ export type FieldInputDraftValue<FieldValue> = FieldValue extends FieldTextValue
? FieldBooleanValue
: FieldValue extends FieldPhoneValue
? FieldPhoneDraftValue
: FieldValue extends FieldEmailValue
? FieldEmailDraftValue
: FieldValue extends FieldEmailsValue
? FieldEmailsDraftValue
: FieldValue extends FieldLinkValue
? FieldLinkDraftValue
: FieldValue extends FieldLinksValue
? FieldLinksDraftValue
: FieldValue extends FieldCurrencyValue
? FieldCurrencyDraftValue
: FieldValue extends FieldFullNameValue
? FieldFullNameDraftValue
: FieldValue extends FieldRatingValue
? FieldRatingValue
: FieldValue extends FieldSelectValue
? FieldSelectDraftValue
: FieldValue extends FieldMultiSelectValue
? FieldMultiSelectDraftValue
: FieldValue extends FieldRelationToOneValue
? FieldRelationDraftValue
: FieldValue extends FieldRelationFromManyValue
? FieldRelationManyDraftValue
: FieldValue extends FieldAddressValue
? FieldAddressDraftValue
: FieldValue extends FieldJsonValue
? FieldJsonDraftValue
: FieldValue extends FieldActorValue
? FieldActorDraftValue
: never;
: FieldValue extends FieldPhonesValue
? FieldPhonesDraftValue
: FieldValue extends FieldEmailValue
? FieldEmailDraftValue
: FieldValue extends FieldEmailsValue
? FieldEmailsDraftValue
: FieldValue extends FieldLinkValue
? FieldLinkDraftValue
: FieldValue extends FieldLinksValue
? FieldLinksDraftValue
: FieldValue extends FieldCurrencyValue
? FieldCurrencyDraftValue
: FieldValue extends FieldFullNameValue
? FieldFullNameDraftValue
: FieldValue extends FieldRatingValue
? FieldRatingValue
: FieldValue extends FieldSelectValue
? FieldSelectDraftValue
: FieldValue extends FieldMultiSelectValue
? FieldMultiSelectDraftValue
: FieldValue extends FieldRelationToOneValue
? FieldRelationDraftValue
: FieldValue extends FieldRelationFromManyValue
? FieldRelationManyDraftValue
: FieldValue extends FieldAddressValue
? FieldAddressDraftValue
: FieldValue extends FieldJsonValue
? FieldJsonDraftValue
: FieldValue extends FieldActorValue
? FieldActorDraftValue
: never;

View File

@ -157,6 +157,11 @@ export type FieldActorMetadata = {
fieldName: string;
};
export type FieldPhonesMetadata = {
objectMetadataNameSingular?: string;
fieldName: string;
};
export type FieldMetadata =
| FieldBooleanMetadata
| FieldCurrencyMetadata
@ -230,3 +235,11 @@ export type FieldActorValue = {
workspaceMemberId?: string;
name: string;
};
export type PhoneRecord = { number: string; countryCode: string };
export type FieldPhonesValue = {
primaryPhoneNumber: string;
primaryPhoneCountryCode: string;
additionalPhones?: PhoneRecord[] | null;
};

View File

@ -17,6 +17,7 @@ import {
FieldMultiSelectMetadata,
FieldNumberMetadata,
FieldPhoneMetadata,
FieldPhonesMetadata,
FieldRatingMetadata,
FieldRawJsonMetadata,
FieldRelationMetadata,
@ -55,21 +56,23 @@ type AssertFieldMetadataFunction = <
? FieldNumberMetadata
: E extends 'PHONE'
? FieldPhoneMetadata
: E extends 'RELATION'
? FieldRelationMetadata
: E extends 'TEXT'
? FieldTextMetadata
: E extends 'UUID'
? FieldUuidMetadata
: E extends 'ADDRESS'
? FieldAddressMetadata
: E extends 'RAW_JSON'
? FieldRawJsonMetadata
: E extends 'RICH_TEXT'
? FieldTextMetadata
: E extends 'ACTOR'
? FieldActorMetadata
: never,
: E extends 'PHONES'
? FieldPhonesMetadata
: E extends 'RELATION'
? FieldRelationMetadata
: E extends 'TEXT'
? FieldTextMetadata
: E extends 'UUID'
? FieldUuidMetadata
: E extends 'ADDRESS'
? FieldAddressMetadata
: E extends 'RAW_JSON'
? FieldRawJsonMetadata
: E extends 'RICH_TEXT'
? FieldTextMetadata
: E extends 'ACTOR'
? FieldActorMetadata
: never,
>(
fieldType: E,
fieldTypeGuard: (

View File

@ -0,0 +1,9 @@
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { FieldDefinition } from '../FieldDefinition';
import { FieldMetadata, FieldPhonesMetadata } from '../FieldMetadata';
export const isFieldPhones = (
field: Pick<FieldDefinition<FieldMetadata>, 'type'>,
): field is FieldDefinition<FieldPhonesMetadata> =>
field.type === FieldMetadataType.Phones;

View File

@ -0,0 +1,15 @@
import { z } from 'zod';
import { FieldPhonesValue } from '../FieldMetadata';
export const phonesSchema = z.object({
primaryPhoneNumber: z.string(),
primaryPhoneCountryCode: z.string(),
additionalPhones: z
.array(z.object({ number: z.string(), countryCode: z.string() }))
.nullable(),
}) satisfies z.ZodType<FieldPhonesValue>;
export const isFieldPhonesValue = (
fieldValue: unknown,
): fieldValue is FieldPhonesValue => phonesSchema.safeParse(fieldValue).success;

View File

@ -6,6 +6,7 @@ import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guar
import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
import { isFieldPhones } from '@/object-record/record-field/types/guards/isFieldPhones';
import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
@ -31,7 +32,8 @@ export const getFieldButtonIcon = (
fieldDefinition.metadata.relationObjectMetadataNameSingular !==
'workspaceMember') ||
isFieldLinks(fieldDefinition) ||
isFieldEmails(fieldDefinition)
isFieldEmails(fieldDefinition) ||
isFieldPhones(fieldDefinition)
) {
return IconPencil;
}

View File

@ -24,6 +24,8 @@ import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/is
import { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue';
import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
import { isFieldPhone } from '@/object-record/record-field/types/guards/isFieldPhone';
import { isFieldPhones } from '@/object-record/record-field/types/guards/isFieldPhones';
import { isFieldPhonesValue } from '@/object-record/record-field/types/guards/isFieldPhonesValue';
import { isFieldPosition } from '@/object-record/record-field/types/guards/isFieldPosition';
import { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating';
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
@ -128,6 +130,13 @@ export const isFieldValueEmpty = ({
);
}
if (isFieldPhones(fieldDefinition)) {
return (
!isFieldPhonesValue(fieldValue) ||
isValueEmpty(fieldValue.primaryPhoneNumber)
);
}
throw new Error(
`Entity field type not supported in isFieldValueEmpty : ${fieldDefinition.type}}`,
);

View File

@ -14,6 +14,7 @@ import {
LinksFilter,
NotObjectRecordFilter,
OrObjectRecordFilter,
PhonesFilter,
RecordGqlOperationFilter,
StringFilter,
URLFilter,
@ -282,6 +283,26 @@ export const isRecordMatchingFilter = ({
value: record[filterKey].primaryEmail,
});
}
case FieldMetadataType.Phones: {
const phonesFilter = filterValue as PhonesFilter;
const keys: (keyof PhonesFilter)[] = [
'primaryPhoneNumber',
'primaryPhoneCountryCode',
];
return keys.some((key) => {
const value = phonesFilter[key];
if (value === undefined) {
return false;
}
return isMatchingStringFilter({
stringFilter: value,
value: record[filterKey][key],
});
});
}
case FieldMetadataType.Relation: {
throw new Error(
`Not implemented yet, use UUID filter instead on the corredponding "${filterKey}Id" field`,

View File

@ -52,6 +52,19 @@ const applyEmptyFilters = (
],
};
break;
case 'PHONES': {
const phonesFilter = generateILikeFiltersForCompositeFields(
'',
correspondingField.name,
['primaryPhoneNumber', 'primaryPhoneCountryCode'],
true,
);
emptyRecordFilter = {
and: phonesFilter,
};
break;
}
case 'CURRENCY':
emptyRecordFilter = {
or: [
@ -870,6 +883,43 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
);
}
break;
case 'PHONES': {
const phonesFilters = generateILikeFiltersForCompositeFields(
rawUIFilter.value,
correspondingField.name,
['primaryPhoneNumber', 'primaryPhoneCountryCode'],
);
switch (rawUIFilter.operand) {
case ViewFilterOperand.Contains:
objectRecordFilters.push({
or: phonesFilters,
});
break;
case ViewFilterOperand.DoesNotContain:
objectRecordFilters.push({
and: phonesFilters.map((filter) => {
return {
not: filter,
};
}),
});
break;
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition.type,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
);
}
break;
}
default:
throw new Error('Unknown filter type');
}

View File

@ -97,6 +97,13 @@ export const generateEmptyFieldValue = (
name: '',
};
}
case FieldMetadataType.Phones: {
return {
primaryPhoneNumber: '',
primaryPhoneCountryCode: '',
additionalPhones: null,
};
}
default: {
throw new Error('Unhandled FieldMetadataType');
}

View File

@ -130,6 +130,15 @@ export const SETTINGS_FIELD_TYPE_CONFIGS = {
exampleValue: '+1234-567-890',
category: 'Basic',
},
[FieldMetadataType.Phones]: {
label: 'Phones',
Icon: IconPhone,
exampleValue: {
primaryPhoneNumber: '234-567-890',
primaryPhoneCountryCode: '+1',
},
category: 'Basic',
},
[FieldMetadataType.Rating]: {
label: 'Rating',
Icon: IconTwentyStar,

View File

@ -92,6 +92,7 @@ const previewableTypes = [
FieldMetadataType.MultiSelect,
FieldMetadataType.Number,
FieldMetadataType.Phone,
FieldMetadataType.Phones,
FieldMetadataType.Rating,
FieldMetadataType.RawJson,
FieldMetadataType.Relation,

View File

@ -24,7 +24,7 @@ export const PhoneDisplay = ({ value }: PhoneDisplayProps) => {
}
const URI = parsedPhoneNumber.getURI();
const formattedNational = parsedPhoneNumber?.formatNational();
const formatedPhoneNumber = parsedPhoneNumber.formatInternational();
return (
<ContactLink
@ -33,7 +33,7 @@ export const PhoneDisplay = ({ value }: PhoneDisplayProps) => {
event.stopPropagation();
}}
>
{formattedNational || value}
{formatedPhoneNumber || value}
</ContactLink>
);
};

View File

@ -0,0 +1,87 @@
import styled from '@emotion/styled';
import { useMemo } from 'react';
import { THEME_COMMON } from 'twenty-ui';
import { FieldPhonesValue } from '@/object-record/record-field/types/FieldMetadata';
import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
import { RoundedLink } from '@/ui/navigation/link/components/RoundedLink';
import { parsePhoneNumber } from 'libphonenumber-js';
import { isDefined } from '~/utils/isDefined';
type PhonesDisplayProps = {
value?: FieldPhonesValue;
isFocused?: boolean;
};
const themeSpacing = THEME_COMMON.spacingMultiplicator;
const StyledContainer = styled.div`
align-items: center;
display: flex;
gap: ${themeSpacing * 1}px;
justify-content: flex-start;
max-width: 100%;
overflow: hidden;
width: 100%;
`;
export const PhonesDisplay = ({ value, isFocused }: PhonesDisplayProps) => {
const phones = useMemo(
() =>
[
value?.primaryPhoneNumber
? {
number: value.primaryPhoneNumber,
countryCode: value.primaryPhoneCountryCode,
}
: null,
...(value?.additionalPhones ?? []),
]
.filter(isDefined)
.map(({ number, countryCode }) => {
return {
number,
countryCode,
};
}),
[
value?.primaryPhoneNumber,
value?.primaryPhoneCountryCode,
value?.additionalPhones,
],
);
return isFocused ? (
<ExpandableList isChipCountDisplayed>
{phones.map(({ number, countryCode }, index) => {
const parsedPhone = parsePhoneNumber(countryCode + number);
const URI = parsedPhone.getURI();
return (
<RoundedLink
key={index}
href={URI}
label={parsedPhone.formatInternational()}
/>
);
})}
</ExpandableList>
) : (
<StyledContainer>
{phones.map(({ number, countryCode }, index) => {
const parsedPhone = parsePhoneNumber(countryCode + number);
const URI = parsedPhone.getURI();
return (
<RoundedLink
key={index}
href={URI}
label={parsedPhone.formatInternational()}
/>
);
})}
</StyledContainer>
);
};

View File

@ -1,6 +1,7 @@
import { forwardRef, InputHTMLAttributes, ReactNode, useRef } from 'react';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { forwardRef, InputHTMLAttributes, ReactNode, useRef } from 'react';
import 'react-phone-number-input/style.css';
import { RGBA, TEXT_INPUT_STYLE } from 'twenty-ui';
import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents';
@ -43,7 +44,9 @@ const StyledRightContainer = styled.div`
transform: translateY(-50%);
`;
type DropdownMenuInputProps = InputHTMLAttributes<HTMLInputElement> & {
type HTMLInputProps = InputHTMLAttributes<HTMLInputElement>;
export type DropdownMenuInputProps = HTMLInputProps & {
hotkeyScope?: string;
onClickOutside?: () => void;
onEnter?: () => void;
@ -51,6 +54,12 @@ type DropdownMenuInputProps = InputHTMLAttributes<HTMLInputElement> & {
onShiftTab?: () => void;
onTab?: () => void;
rightComponent?: ReactNode;
renderInput?: (props: {
value: HTMLInputProps['value'];
onChange: HTMLInputProps['onChange'];
autoFocus: HTMLInputProps['autoFocus'];
placeholder: HTMLInputProps['placeholder'];
}) => React.ReactNode;
};
export const DropdownMenuInput = forwardRef<
@ -71,6 +80,7 @@ export const DropdownMenuInput = forwardRef<
onShiftTab,
onTab,
rightComponent,
renderInput,
},
ref,
) => {
@ -90,14 +100,23 @@ export const DropdownMenuInput = forwardRef<
return (
<StyledInputContainer className={className}>
<StyledInput
autoFocus={autoFocus}
value={value}
placeholder={placeholder}
onChange={onChange}
ref={combinedRef}
withRightComponent={!!rightComponent}
/>
{renderInput ? (
renderInput({
value,
onChange,
autoFocus,
placeholder,
})
) : (
<StyledInput
autoFocus={autoFocus}
value={value}
placeholder={placeholder}
onChange={onChange}
ref={combinedRef}
withRightComponent={!!rightComponent}
/>
)}
{!!rightComponent && (
<StyledRightContainer>{rightComponent}</StyledRightContainer>
)}

View File

@ -166,6 +166,7 @@ export const SettingsObjectNewFieldStep2 = () => {
FieldMetadataType.RichText,
FieldMetadataType.Actor,
FieldMetadataType.Email,
FieldMetadataType.Phone,
] as const
).filter(isDefined);

View File

@ -7,6 +7,7 @@ export const FIELD_CURRENCY_MOCK_NAME = 'fieldCurrency';
export const FIELD_ADDRESS_MOCK_NAME = 'fieldAddress';
export const FIELD_ACTOR_MOCK_NAME = 'fieldActor';
export const FIELD_FULL_NAME_MOCK_NAME = 'fieldFullName';
export const FIELD_PHONES_MOCK_NAME = 'fieldPhones';
export const fieldNumberMock = {
name: 'fieldNumber',
@ -221,6 +222,7 @@ const fieldActorMock = {
name: '',
},
};
const fieldEmailsMock = {
name: 'fieldEmails',
type: FieldMetadataType.EMAILS,
@ -228,10 +230,24 @@ const fieldEmailsMock = {
defaultValue: [{ primaryEmail: '', additionalEmails: {} }],
};
const fieldPhonesMock = {
name: FIELD_PHONES_MOCK_NAME,
type: FieldMetadataType.PHONES,
isNullable: false,
defaultValue: [
{
primaryPhoneNumber: '',
primaryPhoneCountryCode: '',
additionalPhones: {},
},
],
};
export const fields = [
fieldUuidMock,
fieldTextMock,
fieldPhoneMock,
fieldPhonesMock,
fieldEmailMock,
fieldEmailsMock,
fieldDateTimeMock,

View File

@ -152,5 +152,14 @@ export const mapFieldMetadataToGraphqlQuery = (
additionalEmails
}
`;
} else if (fieldType === FieldMetadataType.PHONES) {
return `
${field.name}
{
primaryPhoneNumber
primaryPhoneCountryCode
additionalPhones
}
`;
}
};

View File

@ -33,6 +33,20 @@ describe('computeSchemaComponents', () => {
fieldPhone: {
type: 'string',
},
fieldPhones: {
properties: {
additionalPhones: {
type: 'object',
},
primaryPhoneCountryCode: {
type: 'string',
},
primaryPhoneNumber: {
type: 'string',
},
},
type: 'object',
},
fieldEmail: {
type: 'string',
format: 'email',
@ -195,6 +209,20 @@ describe('computeSchemaComponents', () => {
fieldPhone: {
type: 'string',
},
fieldPhones: {
properties: {
additionalPhones: {
type: 'object',
},
primaryPhoneCountryCode: {
type: 'string',
},
primaryPhoneNumber: {
type: 'string',
},
},
type: 'object',
},
fieldEmail: {
type: 'string',
format: 'email',
@ -356,6 +384,20 @@ describe('computeSchemaComponents', () => {
fieldPhone: {
type: 'string',
},
fieldPhones: {
properties: {
additionalPhones: {
type: 'object',
},
primaryPhoneCountryCode: {
type: 'string',
},
primaryPhoneNumber: {
type: 'string',
},
},
type: 'object',
},
fieldEmail: {
type: 'string',
format: 'email',

View File

@ -137,6 +137,7 @@ const getSchemaComponentsProperties = ({
case FieldMetadataType.ADDRESS:
case FieldMetadataType.ACTOR:
case FieldMetadataType.EMAILS:
case FieldMetadataType.PHONES:
itemProperty = {
type: 'object',
properties: compositeTypeDefinitions

View File

@ -7,6 +7,7 @@ import { emailsCompositeType } from 'src/engine/metadata-modules/field-metadata/
import { fullNameCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/full-name.composite-type';
import { linkCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/link.composite-type';
import { linksCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type';
import { phonesCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
export const compositeTypeDefinitions = new Map<
@ -20,4 +21,5 @@ export const compositeTypeDefinitions = new Map<
[FieldMetadataType.ADDRESS, addressCompositeType],
[FieldMetadataType.ACTOR, actorCompositeType],
[FieldMetadataType.EMAILS, emailsCompositeType],
[FieldMetadataType.PHONES, phonesCompositeType],
]);

View File

@ -0,0 +1,33 @@
import { CompositeType } from 'src/engine/metadata-modules/field-metadata/interfaces/composite-type.interface';
import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
export const phonesCompositeType: CompositeType = {
type: FieldMetadataType.PHONES,
properties: [
{
name: 'primaryPhoneNumber',
type: FieldMetadataType.TEXT,
hidden: false,
isRequired: false,
},
{
name: 'primaryPhoneCountryCode',
type: FieldMetadataType.TEXT,
hidden: false,
isRequired: false,
},
{
name: 'additionalPhones',
type: FieldMetadataType.RAW_JSON,
hidden: false,
isRequired: false,
},
],
};
export type PhonesMetadata = {
primaryPhoneNumber: string;
primaryPhoneCountryCode: string;
additionalPhones: object | null;
};

View File

@ -185,3 +185,17 @@ export class FieldMetadataDefaultValueEmails {
@IsObject()
additionalEmails: string[] | null;
}
export class FieldMetadataDefaultValuePhones {
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
primaryPhoneNumber: string | null;
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
primaryPhoneCountryCode: string | null;
@ValidateIf((_object, value) => value !== null)
@IsObject()
additionalPhones: object | null;
}

View File

@ -25,6 +25,7 @@ export enum FieldMetadataType {
UUID = 'UUID',
TEXT = 'TEXT',
PHONE = 'PHONE',
PHONES = 'PHONES',
EMAIL = 'EMAIL',
EMAILS = 'EMAILS',
DATE_TIME = 'DATE_TIME',

View File

@ -10,6 +10,7 @@ import {
FieldMetadataDefaultValueLinks,
FieldMetadataDefaultValueNowFunction,
FieldMetadataDefaultValueNumber,
FieldMetadataDefaultValuePhones,
FieldMetadataDefaultValueRawJson,
FieldMetadataDefaultValueRichText,
FieldMetadataDefaultValueString,
@ -27,6 +28,7 @@ type FieldMetadataDefaultValueMapping = {
| FieldMetadataDefaultValueUuidFunction;
[FieldMetadataType.TEXT]: FieldMetadataDefaultValueString;
[FieldMetadataType.PHONE]: FieldMetadataDefaultValueString;
[FieldMetadataType.PHONES]: FieldMetadataDefaultValuePhones;
[FieldMetadataType.EMAIL]: FieldMetadataDefaultValueString;
[FieldMetadataType.EMAILS]: FieldMetadataDefaultValueEmails;
[FieldMetadataType.DATE_TIME]:

View File

@ -47,6 +47,12 @@ export function generateDefaultValue(
primaryLinkUrl: "''",
secondaryLinks: null,
};
case FieldMetadataType.PHONES:
return {
primaryPhoneNumber: "''",
primaryPhoneCountryCode: "''",
additionalPhones: null,
};
default:
return null;
}

View File

@ -9,7 +9,8 @@ export const isCompositeFieldMetadataType = (
| FieldMetadataType.ADDRESS
| FieldMetadataType.LINKS
| FieldMetadataType.ACTOR
| FieldMetadataType.EMAILS => {
| FieldMetadataType.EMAILS
| FieldMetadataType.PHONES => {
return [
FieldMetadataType.LINK,
FieldMetadataType.CURRENCY,
@ -18,5 +19,6 @@ export const isCompositeFieldMetadataType = (
FieldMetadataType.LINKS,
FieldMetadataType.ACTOR,
FieldMetadataType.EMAILS,
FieldMetadataType.PHONES,
].includes(type);
};

View File

@ -19,6 +19,7 @@ import {
FieldMetadataDefaultValueLinks,
FieldMetadataDefaultValueNowFunction,
FieldMetadataDefaultValueNumber,
FieldMetadataDefaultValuePhones,
FieldMetadataDefaultValueRawJson,
FieldMetadataDefaultValueString,
FieldMetadataDefaultValueStringArray,
@ -55,6 +56,7 @@ export const defaultValueValidatorsMap = {
[FieldMetadataType.LINKS]: [FieldMetadataDefaultValueLinks],
[FieldMetadataType.ACTOR]: [FieldMetadataDefaultActor],
[FieldMetadataType.EMAILS]: [FieldMetadataDefaultValueEmails],
[FieldMetadataType.PHONES]: [FieldMetadataDefaultValuePhones],
};
type ValidationResult = {

View File

@ -24,7 +24,8 @@ export type CompositeFieldMetadataType =
| FieldMetadataType.FULL_NAME
| FieldMetadataType.LINK
| FieldMetadataType.LINKS
| FieldMetadataType.EMAILS;
| FieldMetadataType.EMAILS
| FieldMetadataType.PHONES;
@Injectable()
export class CompositeColumnActionFactory extends ColumnActionAbstractFactory<CompositeFieldMetadataType> {

View File

@ -101,6 +101,10 @@ export class WorkspaceMigrationFactory {
FieldMetadataType.EMAILS,
{ factory: this.compositeColumnActionFactory },
],
[
FieldMetadataType.PHONES,
{ factory: this.compositeColumnActionFactory },
],
]);
}