diff --git a/packages/twenty-chrome-extension/src/generated/graphql.tsx b/packages/twenty-chrome-extension/src/generated/graphql.tsx index 27d954014743..8b388aebc971 100644 --- a/packages/twenty-chrome-extension/src/generated/graphql.tsx +++ b/packages/twenty-chrome-extension/src/generated/graphql.tsx @@ -2527,6 +2527,7 @@ export enum FieldMetadataType { Number = 'NUMBER', Numeric = 'NUMERIC', Phone = 'PHONE', + Phones = 'PHONES', Position = 'POSITION', Rating = 'RATING', RawJson = 'RAW_JSON', diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 830272b9ae20..4af948bc1783 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -368,6 +368,7 @@ export enum FieldMetadataType { Number = 'NUMBER', Numeric = 'NUMERIC', Phone = 'PHONE', + Phones = 'PHONES', Position = 'POSITION', Rating = 'RATING', RawJson = 'RAW_JSON', diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 4fd5f53ec6ae..a70718a34ee5 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -1,5 +1,5 @@ -import { gql } from '@apollo/client'; import * as Apollo from '@apollo/client'; +import { gql } from '@apollo/client'; export type Maybe = T | null; export type InputMaybe = Maybe; export type Exact = { [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', diff --git a/packages/twenty-front/src/modules/object-metadata/constants/SortableFieldMetadataTypes.ts b/packages/twenty-front/src/modules/object-metadata/constants/SortableFieldMetadataTypes.ts index 2e7713d96fc0..282710253650 100644 --- a/packages/twenty-front/src/modules/object-metadata/constants/SortableFieldMetadataTypes.ts +++ b/packages/twenty-front/src/modules/object-metadata/constants/SortableFieldMetadataTypes.ts @@ -15,4 +15,5 @@ export const SORTABLE_FIELD_METADATA_TYPES = [ FieldMetadataType.Currency, FieldMetadataType.Actor, FieldMetadataType.Links, + FieldMetadataType.Phones, ]; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts index 48932dd7bb09..ea5adb09ce4f 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts @@ -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: diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getOrderByForFieldMetadataType.ts b/packages/twenty-front/src/modules/object-metadata/utils/getOrderByForFieldMetadataType.ts index 1803bc9c5db8..4bb814db1571 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/getOrderByForFieldMetadataType.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/getOrderByForFieldMetadataType.ts @@ -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 [ { diff --git a/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts b/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts index 9984d62ffdcf..671964f85913 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts @@ -164,5 +164,14 @@ ${mapObjectMetadataToGraphQLQuery({ }`; } + if (fieldType === FieldMetadataType.Phones) { + return `${field.name} + { + primaryPhoneNumber + primaryPhoneCountryCode + additionalPhones + }`; + } + return ''; }; diff --git a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts index 38038ee0562e..72573ea2133e 100644 --- a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts +++ b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts @@ -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 = { diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent.tsx index b11b7ef980fa..a735dc7fe69d 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent.tsx @@ -67,6 +67,7 @@ export const MultipleFiltersDropdownContent = ({ 'LINKS', 'ADDRESS', 'ACTOR', + 'PHONES', ].includes(filterDefinitionUsedInDropdown.type) && ( )} diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterType.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterType.ts index 875148acf06b..bc1c0489d7b7 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterType.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterType.ts @@ -1,6 +1,7 @@ export type FilterType = | 'TEXT' | 'PHONE' + | 'PHONES' | 'EMAIL' | 'EMAILS' | 'DATE_TIME' diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts index 8265e54a9fe3..5b10825b4e97 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts @@ -22,6 +22,7 @@ export const getOperandsForFilterType = ( case 'LINK': case 'LINKS': case 'ACTOR': + case 'PHONES': return [ ViewFilterOperand.Contains, ViewFilterOperand.DoesNotContain, diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx index 613eeb72f071..3f9297833654 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx @@ -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 = () => { ) : isFieldEmails(fieldDefinition) ? ( + ) : isFieldPhones(fieldDefinition) ? ( + ) : null; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx index 16555c0d1cf8..4e1537106c1a 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx @@ -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) ? ( + ) : isFieldText(fieldDefinition) ? ( { 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 || diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/PhonesFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/PhonesFieldDisplay.tsx new file mode 100644 index 000000000000..5a7925c3238d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/PhonesFieldDisplay.tsx @@ -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 ; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/usePhonesField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/usePhonesField.ts new file mode 100644 index 000000000000..109f1e5fb41b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/usePhonesField.ts @@ -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( + recordStoreFamilySelector({ + recordId, + fieldName: fieldName, + }), + ); + + const { setDraftValue, getDraftValueSelector } = + useRecordFieldInput(`${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, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/usePhonesFieldDisplay.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/usePhonesFieldDisplay.ts new file mode 100644 index 000000000000..e1b33157854e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/usePhonesFieldDisplay.ts @@ -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( + recordId, + fieldName, + ); + + return { + fieldDefinition, + fieldValue, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/EmailsFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/EmailsFieldInput.tsx index c3d90e7b1bbb..02ce963192b0 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/EmailsFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/EmailsFieldInput.tsx @@ -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, diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx index 66ed0055d8f0..c205039450f0 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx @@ -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={({ diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInput.tsx index b2a94f117abc..4a5bcb5c7b3d 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInput.tsx @@ -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 = { handleDelete: () => void; }) => React.ReactNode; hotkeyScope: string; + fieldMetadataType: FieldMetadataType; + renderInput?: DropdownMenuInputProps['renderInput']; }; export const MultiItemFieldInput = ({ @@ -46,6 +53,8 @@ export const MultiItemFieldInput = ({ formatInput, renderItem, hotkeyScope, + fieldMetadataType, + renderInput, }: MultiItemFieldInputProps) => { const containerRef = useRef(null); const handleDropdownClose = () => { @@ -70,9 +79,25 @@ export const MultiItemFieldInput = ({ }; 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 = ({ 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={ diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/PhonesFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/PhonesFieldInput.tsx new file mode 100644 index 000000000000..6667acf2031f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/PhonesFieldInput.tsx @@ -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 ( + { + const phone = parsePhoneNumber(input); + if (phone !== undefined) { + return { + number: phone.nationalNumber, + countryCode: `+${phone.countryCallingCode}`, + }; + } + return { + number: '', + countryCode: '', + }; + }} + renderItem={({ + value: phone, + index, + handleEdit, + handleSetPrimary, + handleDelete, + }) => ( + + )} + renderInput={({ value, onChange, autoFocus, placeholder }) => { + return ( + void} + international={true} + withCountryCallingCode={true} + countrySelectComponent={PhoneCountryPickerDropdownButton} + /> + ); + }} + hotkeyScope={hotkeyScope} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/PhonesFieldMenuItem.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/PhonesFieldMenuItem.tsx new file mode 100644 index 000000000000..3f793d38fcd5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/PhonesFieldMenuItem.tsx @@ -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 ( + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts index b3196f1c702f..2e50c1b6849c 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts @@ -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 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; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts index 367f14680570..aaf659355717 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts index 9c94d9b666a7..1d9e944db621 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts @@ -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: ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldPhones.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldPhones.ts new file mode 100644 index 000000000000..156adfe6340c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldPhones.ts @@ -0,0 +1,9 @@ +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +import { FieldDefinition } from '../FieldDefinition'; +import { FieldMetadata, FieldPhonesMetadata } from '../FieldMetadata'; + +export const isFieldPhones = ( + field: Pick, 'type'>, +): field is FieldDefinition => + field.type === FieldMetadataType.Phones; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldPhonesValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldPhonesValue.ts new file mode 100644 index 000000000000..fa60d1980b9b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldPhonesValue.ts @@ -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; + +export const isFieldPhonesValue = ( + fieldValue: unknown, +): fieldValue is FieldPhonesValue => phonesSchema.safeParse(fieldValue).success; diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/getFieldButtonIcon.tsx b/packages/twenty-front/src/modules/object-record/record-field/utils/getFieldButtonIcon.tsx index b100872b5470..6a02b6ae122a 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/utils/getFieldButtonIcon.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/utils/getFieldButtonIcon.tsx @@ -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; } diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts b/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts index 905afb646a25..0c366d1efb41 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts @@ -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}}`, ); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts index e9fb8bfa2e73..f83c82d0e865 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts @@ -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`, diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter.ts index ae16274f9453..3b0e9f8c2c72 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter.ts @@ -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'); } diff --git a/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts b/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts index f9d6be3208c0..a418792bc3de 100644 --- a/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts +++ b/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts @@ -97,6 +97,13 @@ export const generateEmptyFieldValue = ( name: '', }; } + case FieldMetadataType.Phones: { + return { + primaryPhoneNumber: '', + primaryPhoneCountryCode: '', + additionalPhones: null, + }; + } default: { throw new Error('Unhandled FieldMetadataType'); } diff --git a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsFieldTypeConfigs.ts b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsFieldTypeConfigs.ts index 0e6cf0cbf085..ae770b4a3f39 100644 --- a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsFieldTypeConfigs.ts +++ b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsFieldTypeConfigs.ts @@ -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, diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx index 65f2505bc47c..a39e2f603a4a 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx @@ -92,6 +92,7 @@ const previewableTypes = [ FieldMetadataType.MultiSelect, FieldMetadataType.Number, FieldMetadataType.Phone, + FieldMetadataType.Phones, FieldMetadataType.Rating, FieldMetadataType.RawJson, FieldMetadataType.Relation, diff --git a/packages/twenty-front/src/modules/ui/field/display/components/PhoneDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/PhoneDisplay.tsx index d18ac35109aa..7c79ac85e7f6 100644 --- a/packages/twenty-front/src/modules/ui/field/display/components/PhoneDisplay.tsx +++ b/packages/twenty-front/src/modules/ui/field/display/components/PhoneDisplay.tsx @@ -24,7 +24,7 @@ export const PhoneDisplay = ({ value }: PhoneDisplayProps) => { } const URI = parsedPhoneNumber.getURI(); - const formattedNational = parsedPhoneNumber?.formatNational(); + const formatedPhoneNumber = parsedPhoneNumber.formatInternational(); return ( { event.stopPropagation(); }} > - {formattedNational || value} + {formatedPhoneNumber || value} ); }; diff --git a/packages/twenty-front/src/modules/ui/field/display/components/PhonesDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/PhonesDisplay.tsx new file mode 100644 index 000000000000..04423ff32aa1 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/field/display/components/PhonesDisplay.tsx @@ -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 ? ( + + {phones.map(({ number, countryCode }, index) => { + const parsedPhone = parsePhoneNumber(countryCode + number); + const URI = parsedPhone.getURI(); + return ( + + ); + })} + + ) : ( + + {phones.map(({ number, countryCode }, index) => { + const parsedPhone = parsePhoneNumber(countryCode + number); + const URI = parsedPhone.getURI(); + return ( + + ); + })} + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuInput.tsx b/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuInput.tsx index 5022ffecb6dc..5a123105c153 100644 --- a/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuInput.tsx +++ b/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuInput.tsx @@ -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 & { +type HTMLInputProps = InputHTMLAttributes; + +export type DropdownMenuInputProps = HTMLInputProps & { hotkeyScope?: string; onClickOutside?: () => void; onEnter?: () => void; @@ -51,6 +54,12 @@ type DropdownMenuInputProps = InputHTMLAttributes & { 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 ( - + {renderInput ? ( + renderInput({ + value, + onChange, + autoFocus, + placeholder, + }) + ) : ( + + )} {!!rightComponent && ( {rightComponent} )} diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx index d5a4e57e0a4e..449aa8aa9d95 100644 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectNewField/SettingsObjectNewFieldStep2.tsx @@ -166,6 +166,7 @@ export const SettingsObjectNewFieldStep2 = () => { FieldMetadataType.RichText, FieldMetadataType.Actor, FieldMetadataType.Email, + FieldMetadataType.Phone, ] as const ).filter(isDefined); diff --git a/packages/twenty-server/src/engine/api/__mocks__/object-metadata-item.mock.ts b/packages/twenty-server/src/engine/api/__mocks__/object-metadata-item.mock.ts index 399f09615597..0a33b2e26fe5 100644 --- a/packages/twenty-server/src/engine/api/__mocks__/object-metadata-item.mock.ts +++ b/packages/twenty-server/src/engine/api/__mocks__/object-metadata-item.mock.ts @@ -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, diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils.ts index c96998bed59f..0a7b879c62c6 100644 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils.ts +++ b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils.ts @@ -152,5 +152,14 @@ export const mapFieldMetadataToGraphqlQuery = ( additionalEmails } `; + } else if (fieldType === FieldMetadataType.PHONES) { + return ` + ${field.name} + { + primaryPhoneNumber + primaryPhoneCountryCode + additionalPhones + } + `; } }; diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/components.utils.spec.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/components.utils.spec.ts index acabfb71fedd..7a9f939bb157 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/components.utils.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/components.utils.spec.ts @@ -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', diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts index 4b9d7fc49696..6841475c3e0b 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts @@ -137,6 +137,7 @@ const getSchemaComponentsProperties = ({ case FieldMetadataType.ADDRESS: case FieldMetadataType.ACTOR: case FieldMetadataType.EMAILS: + case FieldMetadataType.PHONES: itemProperty = { type: 'object', properties: compositeTypeDefinitions diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/index.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/index.ts index f8a05964733e..de361576a7bb 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/index.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/index.ts @@ -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], ]); diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type.ts new file mode 100644 index 000000000000..a53661779b80 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type.ts @@ -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; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/default-value.input.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/default-value.input.ts index 8a03f6b54bcf..45de1dfceaf7 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/default-value.input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/default-value.input.ts @@ -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; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts index 263790c7ca7d..a35b98815b01 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts @@ -25,6 +25,7 @@ export enum FieldMetadataType { UUID = 'UUID', TEXT = 'TEXT', PHONE = 'PHONE', + PHONES = 'PHONES', EMAIL = 'EMAIL', EMAILS = 'EMAILS', DATE_TIME = 'DATE_TIME', diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface.ts index d6e9e700215c..07f18bd4a2c6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface.ts @@ -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]: diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/generate-default-value.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/generate-default-value.ts index b031e4884675..958ff0e3212b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/generate-default-value.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/generate-default-value.ts @@ -47,6 +47,12 @@ export function generateDefaultValue( primaryLinkUrl: "''", secondaryLinks: null, }; + case FieldMetadataType.PHONES: + return { + primaryPhoneNumber: "''", + primaryPhoneCountryCode: "''", + additionalPhones: null, + }; default: return null; } diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util.ts index 437310003d2c..110673bd1cf6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util.ts @@ -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); }; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/validate-default-value-for-type.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/validate-default-value-for-type.util.ts index ce254ffd7d7d..06303046259d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/validate-default-value-for-type.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/validate-default-value-for-type.util.ts @@ -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 = { diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory.ts index dc913233c3ca..9af3e89d4b04 100644 --- a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory.ts +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory.ts @@ -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 { diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.factory.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.factory.ts index d495051d703d..0ba0ee710dbe 100644 --- a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.factory.ts +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.factory.ts @@ -101,6 +101,10 @@ export class WorkspaceMigrationFactory { FieldMetadataType.EMAILS, { factory: this.compositeColumnActionFactory }, ], + [ + FieldMetadataType.PHONES, + { factory: this.compositeColumnActionFactory }, + ], ]); }