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', Number = 'NUMBER',
Numeric = 'NUMERIC', Numeric = 'NUMERIC',
Phone = 'PHONE', Phone = 'PHONE',
Phones = 'PHONES',
Position = 'POSITION', Position = 'POSITION',
Rating = 'RATING', Rating = 'RATING',
RawJson = 'RAW_JSON', RawJson = 'RAW_JSON',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -22,6 +22,7 @@ export const getOperandsForFilterType = (
case 'LINK': case 'LINK':
case 'LINKS': case 'LINKS':
case 'ACTOR': case 'ACTOR':
case 'PHONES':
return [ return [
ViewFilterOperand.Contains, ViewFilterOperand.Contains,
ViewFilterOperand.DoesNotContain, 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 { BooleanFieldDisplay } from '@/object-record/record-field/meta-types/display/components/BooleanFieldDisplay';
import { EmailsFieldDisplay } from '@/object-record/record-field/meta-types/display/components/EmailsFieldDisplay'; 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 { 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 { RatingFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RatingFieldDisplay';
import { RelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay'; import { RelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay';
import { RichTextFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RichTextFieldDisplay'; 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 { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone';
import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails'; import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks'; 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 { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating';
import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects'; import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects';
import { isFieldRelationToOneObject } from '@/object-record/record-field/types/guards/isFieldRelationToOneObject'; import { isFieldRelationToOneObject } from '@/object-record/record-field/types/guards/isFieldRelationToOneObject';
@ -104,5 +106,7 @@ export const FieldDisplay = () => {
<ActorFieldDisplay /> <ActorFieldDisplay />
) : isFieldEmails(fieldDefinition) ? ( ) : isFieldEmails(fieldDefinition) ? (
<EmailsFieldDisplay /> <EmailsFieldDisplay />
) : isFieldPhones(fieldDefinition) ? (
<PhonesFieldDisplay />
) : null; ) : 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 { FullNameFieldInput } from '@/object-record/record-field/meta-types/input/components/FullNameFieldInput';
import { LinksFieldInput } from '@/object-record/record-field/meta-types/input/components/LinksFieldInput'; 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 { 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 { RawJsonFieldInput } from '@/object-record/record-field/meta-types/input/components/RawJsonFieldInput';
import { RelationFromManyFieldInput } from '@/object-record/record-field/meta-types/input/components/RelationFromManyFieldInput'; import { RelationFromManyFieldInput } from '@/object-record/record-field/meta-types/input/components/RelationFromManyFieldInput';
import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/components/SelectFieldInput'; 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 { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks'; import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect'; 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 { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects'; import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects';
import { isFieldRelationToOneObject } from '@/object-record/record-field/types/guards/isFieldRelationToOneObject'; import { isFieldRelationToOneObject } from '@/object-record/record-field/types/guards/isFieldRelationToOneObject';
@ -89,6 +91,8 @@ export const FieldInput = ({
onTab={onTab} onTab={onTab}
onShiftTab={onShiftTab} onShiftTab={onShiftTab}
/> />
) : isFieldPhones(fieldDefinition) ? (
<PhonesFieldInput onCancel={onCancel} />
) : isFieldText(fieldDefinition) ? ( ) : isFieldText(fieldDefinition) ? (
<TextFieldInput <TextFieldInput
onEnter={onEnter} 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 { isFieldLinksValue } from '@/object-record/record-field/types/guards/isFieldLinksValue';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect'; import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
import { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue'; 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 { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
import { isFieldRawJsonValue } from '@/object-record/record-field/types/guards/isFieldRawJsonValue'; import { isFieldRawJsonValue } from '@/object-record/record-field/types/guards/isFieldRawJsonValue';
import { isFieldRelationToOneObject } from '@/object-record/record-field/types/guards/isFieldRelationToOneObject'; import { isFieldRelationToOneObject } from '@/object-record/record-field/types/guards/isFieldRelationToOneObject';
@ -104,6 +106,9 @@ export const usePersistField = () => {
const fieldIsPhone = const fieldIsPhone =
isFieldPhone(fieldDefinition) && isFieldPhoneValue(valueToPersist); isFieldPhone(fieldDefinition) && isFieldPhoneValue(valueToPersist);
const fieldIsPhones =
isFieldPhones(fieldDefinition) && isFieldPhonesValue(valueToPersist);
const fieldIsSelect = const fieldIsSelect =
isFieldSelect(fieldDefinition) && isFieldSelectValue(valueToPersist); isFieldSelect(fieldDefinition) && isFieldSelectValue(valueToPersist);
@ -130,6 +135,7 @@ export const usePersistField = () => {
fieldIsDateTime || fieldIsDateTime ||
fieldIsDate || fieldIsDate ||
fieldIsPhone || fieldIsPhone ||
fieldIsPhones ||
fieldIsLink || fieldIsLink ||
fieldIsLinks || fieldIsLinks ||
fieldIsCurrency || 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 { EmailsFieldMenuItem } from '@/object-record/record-field/meta-types/input/components/EmailsFieldMenuItem';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { isDefined } from 'twenty-ui'; import { isDefined } from 'twenty-ui';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { MultiItemFieldInput } from './MultiItemFieldInput'; import { MultiItemFieldInput } from './MultiItemFieldInput';
type EmailsFieldInputProps = { type EmailsFieldInputProps = {
@ -34,6 +35,7 @@ export const EmailsFieldInput = ({ onCancel }: EmailsFieldInputProps) => {
onPersist={handlePersistEmails} onPersist={handlePersistEmails}
onCancel={onCancel} onCancel={onCancel}
placeholder="Email" placeholder="Email"
fieldMetadataType={FieldMetadataType.Emails}
renderItem={({ renderItem={({
value: email, value: email,
index, 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 { LinksFieldMenuItem } from '@/object-record/record-field/meta-types/input/components/LinksFieldMenuItem';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { isDefined } from 'twenty-ui'; import { isDefined } from 'twenty-ui';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { absoluteUrlSchema } from '~/utils/validation-schemas/absoluteUrlSchema'; import { absoluteUrlSchema } from '~/utils/validation-schemas/absoluteUrlSchema';
import { MultiItemFieldInput } from './MultiItemFieldInput'; import { MultiItemFieldInput } from './MultiItemFieldInput';
@ -47,6 +48,7 @@ export const LinksFieldInput = ({ onCancel }: LinksFieldInputProps) => {
onPersist={handlePersistLinks} onPersist={handlePersistLinks}
onCancel={onCancel} onCancel={onCancel}
placeholder="URL" placeholder="URL"
fieldMetadataType={FieldMetadataType.Links}
validateInput={(input) => absoluteUrlSchema.safeParse(input).success} validateInput={(input) => absoluteUrlSchema.safeParse(input).success}
formatInput={(input) => ({ url: input, label: '' })} formatInput={(input) => ({ url: input, label: '' })}
renderItem={({ renderItem={({

View File

@ -1,16 +1,21 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { Key } from 'ts-key-enum'; import { Key } from 'ts-key-enum';
import { IconCheck, IconPlus } from 'twenty-ui'; import { IconCheck, IconPlus } from 'twenty-ui';
import { PhoneRecord } from '@/object-record/record-field/types/FieldMetadata';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; 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 { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { moveArrayItem } from '~/utils/array/moveArrayItem'; import { moveArrayItem } from '~/utils/array/moveArrayItem';
import { toSpliced } from '~/utils/array/toSpliced'; import { toSpliced } from '~/utils/array/toSpliced';
@ -35,6 +40,8 @@ type MultiItemFieldInputProps<T> = {
handleDelete: () => void; handleDelete: () => void;
}) => React.ReactNode; }) => React.ReactNode;
hotkeyScope: string; hotkeyScope: string;
fieldMetadataType: FieldMetadataType;
renderInput?: DropdownMenuInputProps['renderInput'];
}; };
export const MultiItemFieldInput = <T,>({ export const MultiItemFieldInput = <T,>({
@ -46,6 +53,8 @@ export const MultiItemFieldInput = <T,>({
formatInput, formatInput,
renderItem, renderItem,
hotkeyScope, hotkeyScope,
fieldMetadataType,
renderInput,
}: MultiItemFieldInputProps<T>) => { }: MultiItemFieldInputProps<T>) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const handleDropdownClose = () => { const handleDropdownClose = () => {
@ -70,9 +79,25 @@ export const MultiItemFieldInput = <T,>({
}; };
const handleEditButtonClick = (index: number) => { 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); setItemToEditIndex(index);
setInputValue(item.url || '');
setIsInputDisplayed(true); setIsInputDisplayed(true);
}; };
@ -132,6 +157,16 @@ export const MultiItemFieldInput = <T,>({
placeholder={placeholder} placeholder={placeholder}
value={inputValue} value={inputValue}
hotkeyScope={hotkeyScope} hotkeyScope={hotkeyScope}
renderInput={
renderInput
? (props) =>
renderInput({
...props,
onChange: (newValue) =>
setInputValue(newValue as unknown as string),
})
: undefined
}
onChange={(event) => setInputValue(event.target.value)} onChange={(event) => setInputValue(event.target.value)}
onEnter={handleSubmitInput} onEnter={handleSubmitInput}
rightComponent={ 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, FieldLinkValue,
FieldMultiSelectValue, FieldMultiSelectValue,
FieldNumberValue, FieldNumberValue,
FieldPhonesValue,
FieldPhoneValue, FieldPhoneValue,
FieldRatingValue, FieldRatingValue,
FieldRelationFromManyValue, FieldRelationFromManyValue,
@ -20,12 +21,18 @@ import {
FieldSelectValue, FieldSelectValue,
FieldTextValue, FieldTextValue,
FieldUUidValue, FieldUUidValue,
PhoneRecord,
} from '@/object-record/record-field/types/FieldMetadata'; } from '@/object-record/record-field/types/FieldMetadata';
export type FieldTextDraftValue = string; export type FieldTextDraftValue = string;
export type FieldNumberDraftValue = string; export type FieldNumberDraftValue = string;
export type FieldDateTimeDraftValue = string; export type FieldDateTimeDraftValue = string;
export type FieldPhoneDraftValue = string; export type FieldPhoneDraftValue = string;
export type FieldPhonesDraftValue = {
primaryPhoneNumber: string;
primaryPhoneCountryCode: string;
additionalPhones?: PhoneRecord[] | null;
};
export type FieldEmailDraftValue = string; export type FieldEmailDraftValue = string;
export type FieldEmailsDraftValue = { export type FieldEmailsDraftValue = {
primaryEmail: string; primaryEmail: string;
@ -75,32 +82,34 @@ export type FieldInputDraftValue<FieldValue> = FieldValue extends FieldTextValue
? FieldBooleanValue ? FieldBooleanValue
: FieldValue extends FieldPhoneValue : FieldValue extends FieldPhoneValue
? FieldPhoneDraftValue ? FieldPhoneDraftValue
: FieldValue extends FieldEmailValue : FieldValue extends FieldPhonesValue
? FieldEmailDraftValue ? FieldPhonesDraftValue
: FieldValue extends FieldEmailsValue : FieldValue extends FieldEmailValue
? FieldEmailsDraftValue ? FieldEmailDraftValue
: FieldValue extends FieldLinkValue : FieldValue extends FieldEmailsValue
? FieldLinkDraftValue ? FieldEmailsDraftValue
: FieldValue extends FieldLinksValue : FieldValue extends FieldLinkValue
? FieldLinksDraftValue ? FieldLinkDraftValue
: FieldValue extends FieldCurrencyValue : FieldValue extends FieldLinksValue
? FieldCurrencyDraftValue ? FieldLinksDraftValue
: FieldValue extends FieldFullNameValue : FieldValue extends FieldCurrencyValue
? FieldFullNameDraftValue ? FieldCurrencyDraftValue
: FieldValue extends FieldRatingValue : FieldValue extends FieldFullNameValue
? FieldRatingValue ? FieldFullNameDraftValue
: FieldValue extends FieldSelectValue : FieldValue extends FieldRatingValue
? FieldSelectDraftValue ? FieldRatingValue
: FieldValue extends FieldMultiSelectValue : FieldValue extends FieldSelectValue
? FieldMultiSelectDraftValue ? FieldSelectDraftValue
: FieldValue extends FieldRelationToOneValue : FieldValue extends FieldMultiSelectValue
? FieldRelationDraftValue ? FieldMultiSelectDraftValue
: FieldValue extends FieldRelationFromManyValue : FieldValue extends FieldRelationToOneValue
? FieldRelationManyDraftValue ? FieldRelationDraftValue
: FieldValue extends FieldAddressValue : FieldValue extends FieldRelationFromManyValue
? FieldAddressDraftValue ? FieldRelationManyDraftValue
: FieldValue extends FieldJsonValue : FieldValue extends FieldAddressValue
? FieldJsonDraftValue ? FieldAddressDraftValue
: FieldValue extends FieldActorValue : FieldValue extends FieldJsonValue
? FieldActorDraftValue ? FieldJsonDraftValue
: never; : FieldValue extends FieldActorValue
? FieldActorDraftValue
: never;

View File

@ -157,6 +157,11 @@ export type FieldActorMetadata = {
fieldName: string; fieldName: string;
}; };
export type FieldPhonesMetadata = {
objectMetadataNameSingular?: string;
fieldName: string;
};
export type FieldMetadata = export type FieldMetadata =
| FieldBooleanMetadata | FieldBooleanMetadata
| FieldCurrencyMetadata | FieldCurrencyMetadata
@ -230,3 +235,11 @@ export type FieldActorValue = {
workspaceMemberId?: string; workspaceMemberId?: string;
name: 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, FieldMultiSelectMetadata,
FieldNumberMetadata, FieldNumberMetadata,
FieldPhoneMetadata, FieldPhoneMetadata,
FieldPhonesMetadata,
FieldRatingMetadata, FieldRatingMetadata,
FieldRawJsonMetadata, FieldRawJsonMetadata,
FieldRelationMetadata, FieldRelationMetadata,
@ -55,21 +56,23 @@ type AssertFieldMetadataFunction = <
? FieldNumberMetadata ? FieldNumberMetadata
: E extends 'PHONE' : E extends 'PHONE'
? FieldPhoneMetadata ? FieldPhoneMetadata
: E extends 'RELATION' : E extends 'PHONES'
? FieldRelationMetadata ? FieldPhonesMetadata
: E extends 'TEXT' : E extends 'RELATION'
? FieldTextMetadata ? FieldRelationMetadata
: E extends 'UUID' : E extends 'TEXT'
? FieldUuidMetadata ? FieldTextMetadata
: E extends 'ADDRESS' : E extends 'UUID'
? FieldAddressMetadata ? FieldUuidMetadata
: E extends 'RAW_JSON' : E extends 'ADDRESS'
? FieldRawJsonMetadata ? FieldAddressMetadata
: E extends 'RICH_TEXT' : E extends 'RAW_JSON'
? FieldTextMetadata ? FieldRawJsonMetadata
: E extends 'ACTOR' : E extends 'RICH_TEXT'
? FieldActorMetadata ? FieldTextMetadata
: never, : E extends 'ACTOR'
? FieldActorMetadata
: never,
>( >(
fieldType: E, fieldType: E,
fieldTypeGuard: ( 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 { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks'; import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect'; 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 { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
@ -31,7 +32,8 @@ export const getFieldButtonIcon = (
fieldDefinition.metadata.relationObjectMetadataNameSingular !== fieldDefinition.metadata.relationObjectMetadataNameSingular !==
'workspaceMember') || 'workspaceMember') ||
isFieldLinks(fieldDefinition) || isFieldLinks(fieldDefinition) ||
isFieldEmails(fieldDefinition) isFieldEmails(fieldDefinition) ||
isFieldPhones(fieldDefinition)
) { ) {
return IconPencil; 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 { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue';
import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber'; import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber';
import { isFieldPhone } from '@/object-record/record-field/types/guards/isFieldPhone'; 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 { isFieldPosition } from '@/object-record/record-field/types/guards/isFieldPosition';
import { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating'; import { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating';
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson'; 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( throw new Error(
`Entity field type not supported in isFieldValueEmpty : ${fieldDefinition.type}}`, `Entity field type not supported in isFieldValueEmpty : ${fieldDefinition.type}}`,
); );

View File

@ -14,6 +14,7 @@ import {
LinksFilter, LinksFilter,
NotObjectRecordFilter, NotObjectRecordFilter,
OrObjectRecordFilter, OrObjectRecordFilter,
PhonesFilter,
RecordGqlOperationFilter, RecordGqlOperationFilter,
StringFilter, StringFilter,
URLFilter, URLFilter,
@ -282,6 +283,26 @@ export const isRecordMatchingFilter = ({
value: record[filterKey].primaryEmail, 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: { case FieldMetadataType.Relation: {
throw new Error( throw new Error(
`Not implemented yet, use UUID filter instead on the corredponding "${filterKey}Id" field`, `Not implemented yet, use UUID filter instead on the corredponding "${filterKey}Id" field`,

View File

@ -52,6 +52,19 @@ const applyEmptyFilters = (
], ],
}; };
break; break;
case 'PHONES': {
const phonesFilter = generateILikeFiltersForCompositeFields(
'',
correspondingField.name,
['primaryPhoneNumber', 'primaryPhoneCountryCode'],
true,
);
emptyRecordFilter = {
and: phonesFilter,
};
break;
}
case 'CURRENCY': case 'CURRENCY':
emptyRecordFilter = { emptyRecordFilter = {
or: [ or: [
@ -870,6 +883,43 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
); );
} }
break; 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: default:
throw new Error('Unknown filter type'); throw new Error('Unknown filter type');
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -166,6 +166,7 @@ export const SettingsObjectNewFieldStep2 = () => {
FieldMetadataType.RichText, FieldMetadataType.RichText,
FieldMetadataType.Actor, FieldMetadataType.Actor,
FieldMetadataType.Email, FieldMetadataType.Email,
FieldMetadataType.Phone,
] as const ] as const
).filter(isDefined); ).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_ADDRESS_MOCK_NAME = 'fieldAddress';
export const FIELD_ACTOR_MOCK_NAME = 'fieldActor'; export const FIELD_ACTOR_MOCK_NAME = 'fieldActor';
export const FIELD_FULL_NAME_MOCK_NAME = 'fieldFullName'; export const FIELD_FULL_NAME_MOCK_NAME = 'fieldFullName';
export const FIELD_PHONES_MOCK_NAME = 'fieldPhones';
export const fieldNumberMock = { export const fieldNumberMock = {
name: 'fieldNumber', name: 'fieldNumber',
@ -221,6 +222,7 @@ const fieldActorMock = {
name: '', name: '',
}, },
}; };
const fieldEmailsMock = { const fieldEmailsMock = {
name: 'fieldEmails', name: 'fieldEmails',
type: FieldMetadataType.EMAILS, type: FieldMetadataType.EMAILS,
@ -228,10 +230,24 @@ const fieldEmailsMock = {
defaultValue: [{ primaryEmail: '', additionalEmails: {} }], defaultValue: [{ primaryEmail: '', additionalEmails: {} }],
}; };
const fieldPhonesMock = {
name: FIELD_PHONES_MOCK_NAME,
type: FieldMetadataType.PHONES,
isNullable: false,
defaultValue: [
{
primaryPhoneNumber: '',
primaryPhoneCountryCode: '',
additionalPhones: {},
},
],
};
export const fields = [ export const fields = [
fieldUuidMock, fieldUuidMock,
fieldTextMock, fieldTextMock,
fieldPhoneMock, fieldPhoneMock,
fieldPhonesMock,
fieldEmailMock, fieldEmailMock,
fieldEmailsMock, fieldEmailsMock,
fieldDateTimeMock, fieldDateTimeMock,

View File

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

View File

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

View File

@ -137,6 +137,7 @@ const getSchemaComponentsProperties = ({
case FieldMetadataType.ADDRESS: case FieldMetadataType.ADDRESS:
case FieldMetadataType.ACTOR: case FieldMetadataType.ACTOR:
case FieldMetadataType.EMAILS: case FieldMetadataType.EMAILS:
case FieldMetadataType.PHONES:
itemProperty = { itemProperty = {
type: 'object', type: 'object',
properties: compositeTypeDefinitions 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 { 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 { 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 { 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'; import { FieldMetadataType } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
export const compositeTypeDefinitions = new Map< export const compositeTypeDefinitions = new Map<
@ -20,4 +21,5 @@ export const compositeTypeDefinitions = new Map<
[FieldMetadataType.ADDRESS, addressCompositeType], [FieldMetadataType.ADDRESS, addressCompositeType],
[FieldMetadataType.ACTOR, actorCompositeType], [FieldMetadataType.ACTOR, actorCompositeType],
[FieldMetadataType.EMAILS, emailsCompositeType], [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() @IsObject()
additionalEmails: string[] | null; 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', UUID = 'UUID',
TEXT = 'TEXT', TEXT = 'TEXT',
PHONE = 'PHONE', PHONE = 'PHONE',
PHONES = 'PHONES',
EMAIL = 'EMAIL', EMAIL = 'EMAIL',
EMAILS = 'EMAILS', EMAILS = 'EMAILS',
DATE_TIME = 'DATE_TIME', DATE_TIME = 'DATE_TIME',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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