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:
committed by
GitHub
parent
91187dcf82
commit
846953b0f4
@ -2527,6 +2527,7 @@ export enum FieldMetadataType {
|
||||
Number = 'NUMBER',
|
||||
Numeric = 'NUMERIC',
|
||||
Phone = 'PHONE',
|
||||
Phones = 'PHONES',
|
||||
Position = 'POSITION',
|
||||
Rating = 'RATING',
|
||||
RawJson = 'RAW_JSON',
|
||||
|
||||
@ -368,6 +368,7 @@ export enum FieldMetadataType {
|
||||
Number = 'NUMBER',
|
||||
Numeric = 'NUMERIC',
|
||||
Phone = 'PHONE',
|
||||
Phones = 'PHONES',
|
||||
Position = 'POSITION',
|
||||
Rating = 'RATING',
|
||||
RawJson = 'RAW_JSON',
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { gql } from '@apollo/client';
|
||||
import * as Apollo from '@apollo/client';
|
||||
import { gql } from '@apollo/client';
|
||||
export type Maybe<T> = T | null;
|
||||
export type InputMaybe<T> = Maybe<T>;
|
||||
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
|
||||
@ -273,6 +273,7 @@ export enum FieldMetadataType {
|
||||
Number = 'NUMBER',
|
||||
Numeric = 'NUMERIC',
|
||||
Phone = 'PHONE',
|
||||
Phones = 'PHONES',
|
||||
Position = 'POSITION',
|
||||
Rating = 'RATING',
|
||||
RawJson = 'RAW_JSON',
|
||||
|
||||
@ -15,4 +15,5 @@ export const SORTABLE_FIELD_METADATA_TYPES = [
|
||||
FieldMetadataType.Currency,
|
||||
FieldMetadataType.Actor,
|
||||
FieldMetadataType.Links,
|
||||
FieldMetadataType.Phones,
|
||||
];
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 [
|
||||
{
|
||||
|
||||
@ -164,5 +164,14 @@ ${mapObjectMetadataToGraphQLQuery({
|
||||
}`;
|
||||
}
|
||||
|
||||
if (fieldType === FieldMetadataType.Phones) {
|
||||
return `${field.name}
|
||||
{
|
||||
primaryPhoneNumber
|
||||
primaryPhoneCountryCode
|
||||
additionalPhones
|
||||
}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -67,6 +67,7 @@ export const MultipleFiltersDropdownContent = ({
|
||||
'LINKS',
|
||||
'ADDRESS',
|
||||
'ACTOR',
|
||||
'PHONES',
|
||||
].includes(filterDefinitionUsedInDropdown.type) && (
|
||||
<ObjectFilterDropdownTextSearchInput />
|
||||
)}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
export type FilterType =
|
||||
| 'TEXT'
|
||||
| 'PHONE'
|
||||
| 'PHONES'
|
||||
| 'EMAIL'
|
||||
| 'EMAILS'
|
||||
| 'DATE_TIME'
|
||||
|
||||
@ -22,6 +22,7 @@ export const getOperandsForFilterType = (
|
||||
case 'LINK':
|
||||
case 'LINKS':
|
||||
case 'ACTOR':
|
||||
case 'PHONES':
|
||||
return [
|
||||
ViewFilterOperand.Contains,
|
||||
ViewFilterOperand.DoesNotContain,
|
||||
|
||||
@ -4,6 +4,7 @@ import { ActorFieldDisplay } from '@/object-record/record-field/meta-types/displ
|
||||
import { BooleanFieldDisplay } from '@/object-record/record-field/meta-types/display/components/BooleanFieldDisplay';
|
||||
import { EmailsFieldDisplay } from '@/object-record/record-field/meta-types/display/components/EmailsFieldDisplay';
|
||||
import { LinksFieldDisplay } from '@/object-record/record-field/meta-types/display/components/LinksFieldDisplay';
|
||||
import { PhonesFieldDisplay } from '@/object-record/record-field/meta-types/display/components/PhonesFieldDisplay';
|
||||
import { RatingFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RatingFieldDisplay';
|
||||
import { RelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay';
|
||||
import { RichTextFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RichTextFieldDisplay';
|
||||
@ -13,6 +14,7 @@ import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFiel
|
||||
import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone';
|
||||
import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails';
|
||||
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
|
||||
import { isFieldPhones } from '@/object-record/record-field/types/guards/isFieldPhones';
|
||||
import { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating';
|
||||
import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects';
|
||||
import { isFieldRelationToOneObject } from '@/object-record/record-field/types/guards/isFieldRelationToOneObject';
|
||||
@ -104,5 +106,7 @@ export const FieldDisplay = () => {
|
||||
<ActorFieldDisplay />
|
||||
) : isFieldEmails(fieldDefinition) ? (
|
||||
<EmailsFieldDisplay />
|
||||
) : isFieldPhones(fieldDefinition) ? (
|
||||
<PhonesFieldDisplay />
|
||||
) : null;
|
||||
};
|
||||
|
||||
@ -6,6 +6,7 @@ import { EmailsFieldInput } from '@/object-record/record-field/meta-types/input/
|
||||
import { FullNameFieldInput } from '@/object-record/record-field/meta-types/input/components/FullNameFieldInput';
|
||||
import { LinksFieldInput } from '@/object-record/record-field/meta-types/input/components/LinksFieldInput';
|
||||
import { MultiSelectFieldInput } from '@/object-record/record-field/meta-types/input/components/MultiSelectFieldInput';
|
||||
import { PhonesFieldInput } from '@/object-record/record-field/meta-types/input/components/PhonesFieldInput';
|
||||
import { RawJsonFieldInput } from '@/object-record/record-field/meta-types/input/components/RawJsonFieldInput';
|
||||
import { RelationFromManyFieldInput } from '@/object-record/record-field/meta-types/input/components/RelationFromManyFieldInput';
|
||||
import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/components/SelectFieldInput';
|
||||
@ -16,6 +17,7 @@ import { isFieldEmails } from '@/object-record/record-field/types/guards/isField
|
||||
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
|
||||
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
|
||||
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
|
||||
import { isFieldPhones } from '@/object-record/record-field/types/guards/isFieldPhones';
|
||||
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
|
||||
import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects';
|
||||
import { isFieldRelationToOneObject } from '@/object-record/record-field/types/guards/isFieldRelationToOneObject';
|
||||
@ -89,6 +91,8 @@ export const FieldInput = ({
|
||||
onTab={onTab}
|
||||
onShiftTab={onShiftTab}
|
||||
/>
|
||||
) : isFieldPhones(fieldDefinition) ? (
|
||||
<PhonesFieldInput onCancel={onCancel} />
|
||||
) : isFieldText(fieldDefinition) ? (
|
||||
<TextFieldInput
|
||||
onEnter={onEnter}
|
||||
|
||||
@ -15,6 +15,8 @@ import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldL
|
||||
import { isFieldLinksValue } from '@/object-record/record-field/types/guards/isFieldLinksValue';
|
||||
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
|
||||
import { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue';
|
||||
import { isFieldPhones } from '@/object-record/record-field/types/guards/isFieldPhones';
|
||||
import { isFieldPhonesValue } from '@/object-record/record-field/types/guards/isFieldPhonesValue';
|
||||
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
|
||||
import { isFieldRawJsonValue } from '@/object-record/record-field/types/guards/isFieldRawJsonValue';
|
||||
import { isFieldRelationToOneObject } from '@/object-record/record-field/types/guards/isFieldRelationToOneObject';
|
||||
@ -104,6 +106,9 @@ export const usePersistField = () => {
|
||||
const fieldIsPhone =
|
||||
isFieldPhone(fieldDefinition) && isFieldPhoneValue(valueToPersist);
|
||||
|
||||
const fieldIsPhones =
|
||||
isFieldPhones(fieldDefinition) && isFieldPhonesValue(valueToPersist);
|
||||
|
||||
const fieldIsSelect =
|
||||
isFieldSelect(fieldDefinition) && isFieldSelectValue(valueToPersist);
|
||||
|
||||
@ -130,6 +135,7 @@ export const usePersistField = () => {
|
||||
fieldIsDateTime ||
|
||||
fieldIsDate ||
|
||||
fieldIsPhone ||
|
||||
fieldIsPhones ||
|
||||
fieldIsLink ||
|
||||
fieldIsLinks ||
|
||||
fieldIsCurrency ||
|
||||
|
||||
@ -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} />;
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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={({
|
||||
|
||||
@ -1,16 +1,21 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useRef, useState } from 'react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Key } from 'ts-key-enum';
|
||||
import { IconCheck, IconPlus } from 'twenty-ui';
|
||||
|
||||
import { PhoneRecord } from '@/object-record/record-field/types/FieldMetadata';
|
||||
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
|
||||
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
|
||||
import { DropdownMenuInput } from '@/ui/layout/dropdown/components/DropdownMenuInput';
|
||||
import {
|
||||
DropdownMenuInput,
|
||||
DropdownMenuInputProps,
|
||||
} from '@/ui/layout/dropdown/components/DropdownMenuInput';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
|
||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
import { moveArrayItem } from '~/utils/array/moveArrayItem';
|
||||
import { toSpliced } from '~/utils/array/toSpliced';
|
||||
|
||||
@ -35,6 +40,8 @@ type MultiItemFieldInputProps<T> = {
|
||||
handleDelete: () => void;
|
||||
}) => React.ReactNode;
|
||||
hotkeyScope: string;
|
||||
fieldMetadataType: FieldMetadataType;
|
||||
renderInput?: DropdownMenuInputProps['renderInput'];
|
||||
};
|
||||
|
||||
export const MultiItemFieldInput = <T,>({
|
||||
@ -46,6 +53,8 @@ export const MultiItemFieldInput = <T,>({
|
||||
formatInput,
|
||||
renderItem,
|
||||
hotkeyScope,
|
||||
fieldMetadataType,
|
||||
renderInput,
|
||||
}: MultiItemFieldInputProps<T>) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const handleDropdownClose = () => {
|
||||
@ -70,9 +79,25 @@ export const MultiItemFieldInput = <T,>({
|
||||
};
|
||||
|
||||
const handleEditButtonClick = (index: number) => {
|
||||
const item = items[index] as { label: string; url: string };
|
||||
let item;
|
||||
switch (fieldMetadataType) {
|
||||
case FieldMetadataType.Links:
|
||||
item = items[index] as { label: string; url: string };
|
||||
setInputValue(item.url || '');
|
||||
break;
|
||||
case FieldMetadataType.Phones:
|
||||
item = items[index] as PhoneRecord;
|
||||
setInputValue(item.countryCode + item.number);
|
||||
break;
|
||||
case FieldMetadataType.Emails:
|
||||
item = items[index] as string;
|
||||
setInputValue(item);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported field type: ${fieldMetadataType}`);
|
||||
}
|
||||
|
||||
setItemToEditIndex(index);
|
||||
setInputValue(item.url || '');
|
||||
setIsInputDisplayed(true);
|
||||
};
|
||||
|
||||
@ -132,6 +157,16 @@ export const MultiItemFieldInput = <T,>({
|
||||
placeholder={placeholder}
|
||||
value={inputValue}
|
||||
hotkeyScope={hotkeyScope}
|
||||
renderInput={
|
||||
renderInput
|
||||
? (props) =>
|
||||
renderInput({
|
||||
...props,
|
||||
onChange: (newValue) =>
|
||||
setInputValue(newValue as unknown as string),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
onChange={(event) => setInputValue(event.target.value)}
|
||||
onEnter={handleSubmitInput}
|
||||
rightComponent={
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -13,6 +13,7 @@ import {
|
||||
FieldLinkValue,
|
||||
FieldMultiSelectValue,
|
||||
FieldNumberValue,
|
||||
FieldPhonesValue,
|
||||
FieldPhoneValue,
|
||||
FieldRatingValue,
|
||||
FieldRelationFromManyValue,
|
||||
@ -20,12 +21,18 @@ import {
|
||||
FieldSelectValue,
|
||||
FieldTextValue,
|
||||
FieldUUidValue,
|
||||
PhoneRecord,
|
||||
} from '@/object-record/record-field/types/FieldMetadata';
|
||||
|
||||
export type FieldTextDraftValue = string;
|
||||
export type FieldNumberDraftValue = string;
|
||||
export type FieldDateTimeDraftValue = string;
|
||||
export type FieldPhoneDraftValue = string;
|
||||
export type FieldPhonesDraftValue = {
|
||||
primaryPhoneNumber: string;
|
||||
primaryPhoneCountryCode: string;
|
||||
additionalPhones?: PhoneRecord[] | null;
|
||||
};
|
||||
export type FieldEmailDraftValue = string;
|
||||
export type FieldEmailsDraftValue = {
|
||||
primaryEmail: string;
|
||||
@ -75,32 +82,34 @@ export type FieldInputDraftValue<FieldValue> = FieldValue extends FieldTextValue
|
||||
? FieldBooleanValue
|
||||
: FieldValue extends FieldPhoneValue
|
||||
? FieldPhoneDraftValue
|
||||
: FieldValue extends FieldEmailValue
|
||||
? FieldEmailDraftValue
|
||||
: FieldValue extends FieldEmailsValue
|
||||
? FieldEmailsDraftValue
|
||||
: FieldValue extends FieldLinkValue
|
||||
? FieldLinkDraftValue
|
||||
: FieldValue extends FieldLinksValue
|
||||
? FieldLinksDraftValue
|
||||
: FieldValue extends FieldCurrencyValue
|
||||
? FieldCurrencyDraftValue
|
||||
: FieldValue extends FieldFullNameValue
|
||||
? FieldFullNameDraftValue
|
||||
: FieldValue extends FieldRatingValue
|
||||
? FieldRatingValue
|
||||
: FieldValue extends FieldSelectValue
|
||||
? FieldSelectDraftValue
|
||||
: FieldValue extends FieldMultiSelectValue
|
||||
? FieldMultiSelectDraftValue
|
||||
: FieldValue extends FieldRelationToOneValue
|
||||
? FieldRelationDraftValue
|
||||
: FieldValue extends FieldRelationFromManyValue
|
||||
? FieldRelationManyDraftValue
|
||||
: FieldValue extends FieldAddressValue
|
||||
? FieldAddressDraftValue
|
||||
: FieldValue extends FieldJsonValue
|
||||
? FieldJsonDraftValue
|
||||
: FieldValue extends FieldActorValue
|
||||
? FieldActorDraftValue
|
||||
: never;
|
||||
: FieldValue extends FieldPhonesValue
|
||||
? FieldPhonesDraftValue
|
||||
: FieldValue extends FieldEmailValue
|
||||
? FieldEmailDraftValue
|
||||
: FieldValue extends FieldEmailsValue
|
||||
? FieldEmailsDraftValue
|
||||
: FieldValue extends FieldLinkValue
|
||||
? FieldLinkDraftValue
|
||||
: FieldValue extends FieldLinksValue
|
||||
? FieldLinksDraftValue
|
||||
: FieldValue extends FieldCurrencyValue
|
||||
? FieldCurrencyDraftValue
|
||||
: FieldValue extends FieldFullNameValue
|
||||
? FieldFullNameDraftValue
|
||||
: FieldValue extends FieldRatingValue
|
||||
? FieldRatingValue
|
||||
: FieldValue extends FieldSelectValue
|
||||
? FieldSelectDraftValue
|
||||
: FieldValue extends FieldMultiSelectValue
|
||||
? FieldMultiSelectDraftValue
|
||||
: FieldValue extends FieldRelationToOneValue
|
||||
? FieldRelationDraftValue
|
||||
: FieldValue extends FieldRelationFromManyValue
|
||||
? FieldRelationManyDraftValue
|
||||
: FieldValue extends FieldAddressValue
|
||||
? FieldAddressDraftValue
|
||||
: FieldValue extends FieldJsonValue
|
||||
? FieldJsonDraftValue
|
||||
: FieldValue extends FieldActorValue
|
||||
? FieldActorDraftValue
|
||||
: never;
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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: (
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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}}`,
|
||||
);
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -97,6 +97,13 @@ export const generateEmptyFieldValue = (
|
||||
name: '',
|
||||
};
|
||||
}
|
||||
case FieldMetadataType.Phones: {
|
||||
return {
|
||||
primaryPhoneNumber: '',
|
||||
primaryPhoneCountryCode: '',
|
||||
additionalPhones: null,
|
||||
};
|
||||
}
|
||||
default: {
|
||||
throw new Error('Unhandled FieldMetadataType');
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -92,6 +92,7 @@ const previewableTypes = [
|
||||
FieldMetadataType.MultiSelect,
|
||||
FieldMetadataType.Number,
|
||||
FieldMetadataType.Phone,
|
||||
FieldMetadataType.Phones,
|
||||
FieldMetadataType.Rating,
|
||||
FieldMetadataType.RawJson,
|
||||
FieldMetadataType.Relation,
|
||||
|
||||
@ -24,7 +24,7 @@ export const PhoneDisplay = ({ value }: PhoneDisplayProps) => {
|
||||
}
|
||||
|
||||
const URI = parsedPhoneNumber.getURI();
|
||||
const formattedNational = parsedPhoneNumber?.formatNational();
|
||||
const formatedPhoneNumber = parsedPhoneNumber.formatInternational();
|
||||
|
||||
return (
|
||||
<ContactLink
|
||||
@ -33,7 +33,7 @@ export const PhoneDisplay = ({ value }: PhoneDisplayProps) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{formattedNational || value}
|
||||
{formatedPhoneNumber || value}
|
||||
</ContactLink>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -1,6 +1,7 @@
|
||||
import { forwardRef, InputHTMLAttributes, ReactNode, useRef } from 'react';
|
||||
import { css } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { forwardRef, InputHTMLAttributes, ReactNode, useRef } from 'react';
|
||||
import 'react-phone-number-input/style.css';
|
||||
import { RGBA, TEXT_INPUT_STYLE } from 'twenty-ui';
|
||||
|
||||
import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents';
|
||||
@ -43,7 +44,9 @@ const StyledRightContainer = styled.div`
|
||||
transform: translateY(-50%);
|
||||
`;
|
||||
|
||||
type DropdownMenuInputProps = InputHTMLAttributes<HTMLInputElement> & {
|
||||
type HTMLInputProps = InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
export type DropdownMenuInputProps = HTMLInputProps & {
|
||||
hotkeyScope?: string;
|
||||
onClickOutside?: () => void;
|
||||
onEnter?: () => void;
|
||||
@ -51,6 +54,12 @@ type DropdownMenuInputProps = InputHTMLAttributes<HTMLInputElement> & {
|
||||
onShiftTab?: () => void;
|
||||
onTab?: () => void;
|
||||
rightComponent?: ReactNode;
|
||||
renderInput?: (props: {
|
||||
value: HTMLInputProps['value'];
|
||||
onChange: HTMLInputProps['onChange'];
|
||||
autoFocus: HTMLInputProps['autoFocus'];
|
||||
placeholder: HTMLInputProps['placeholder'];
|
||||
}) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const DropdownMenuInput = forwardRef<
|
||||
@ -71,6 +80,7 @@ export const DropdownMenuInput = forwardRef<
|
||||
onShiftTab,
|
||||
onTab,
|
||||
rightComponent,
|
||||
renderInput,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@ -90,14 +100,23 @@ export const DropdownMenuInput = forwardRef<
|
||||
|
||||
return (
|
||||
<StyledInputContainer className={className}>
|
||||
<StyledInput
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
ref={combinedRef}
|
||||
withRightComponent={!!rightComponent}
|
||||
/>
|
||||
{renderInput ? (
|
||||
renderInput({
|
||||
value,
|
||||
onChange,
|
||||
autoFocus,
|
||||
placeholder,
|
||||
})
|
||||
) : (
|
||||
<StyledInput
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
ref={combinedRef}
|
||||
withRightComponent={!!rightComponent}
|
||||
/>
|
||||
)}
|
||||
{!!rightComponent && (
|
||||
<StyledRightContainer>{rightComponent}</StyledRightContainer>
|
||||
)}
|
||||
|
||||
@ -166,6 +166,7 @@ export const SettingsObjectNewFieldStep2 = () => {
|
||||
FieldMetadataType.RichText,
|
||||
FieldMetadataType.Actor,
|
||||
FieldMetadataType.Email,
|
||||
FieldMetadataType.Phone,
|
||||
] as const
|
||||
).filter(isDefined);
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -152,5 +152,14 @@ export const mapFieldMetadataToGraphqlQuery = (
|
||||
additionalEmails
|
||||
}
|
||||
`;
|
||||
} else if (fieldType === FieldMetadataType.PHONES) {
|
||||
return `
|
||||
${field.name}
|
||||
{
|
||||
primaryPhoneNumber
|
||||
primaryPhoneCountryCode
|
||||
additionalPhones
|
||||
}
|
||||
`;
|
||||
}
|
||||
};
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -137,6 +137,7 @@ const getSchemaComponentsProperties = ({
|
||||
case FieldMetadataType.ADDRESS:
|
||||
case FieldMetadataType.ACTOR:
|
||||
case FieldMetadataType.EMAILS:
|
||||
case FieldMetadataType.PHONES:
|
||||
itemProperty = {
|
||||
type: 'object',
|
||||
properties: compositeTypeDefinitions
|
||||
|
||||
@ -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],
|
||||
]);
|
||||
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -25,6 +25,7 @@ export enum FieldMetadataType {
|
||||
UUID = 'UUID',
|
||||
TEXT = 'TEXT',
|
||||
PHONE = 'PHONE',
|
||||
PHONES = 'PHONES',
|
||||
EMAIL = 'EMAIL',
|
||||
EMAILS = 'EMAILS',
|
||||
DATE_TIME = 'DATE_TIME',
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -47,6 +47,12 @@ export function generateDefaultValue(
|
||||
primaryLinkUrl: "''",
|
||||
secondaryLinks: null,
|
||||
};
|
||||
case FieldMetadataType.PHONES:
|
||||
return {
|
||||
primaryPhoneNumber: "''",
|
||||
primaryPhoneCountryCode: "''",
|
||||
additionalPhones: null,
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -24,7 +24,8 @@ export type CompositeFieldMetadataType =
|
||||
| FieldMetadataType.FULL_NAME
|
||||
| FieldMetadataType.LINK
|
||||
| FieldMetadataType.LINKS
|
||||
| FieldMetadataType.EMAILS;
|
||||
| FieldMetadataType.EMAILS
|
||||
| FieldMetadataType.PHONES;
|
||||
|
||||
@Injectable()
|
||||
export class CompositeColumnActionFactory extends ColumnActionAbstractFactory<CompositeFieldMetadataType> {
|
||||
|
||||
@ -101,6 +101,10 @@ export class WorkspaceMigrationFactory {
|
||||
FieldMetadataType.EMAILS,
|
||||
{ factory: this.compositeColumnActionFactory },
|
||||
],
|
||||
[
|
||||
FieldMetadataType.PHONES,
|
||||
{ factory: this.compositeColumnActionFactory },
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user