Add composite Emails field and forbid creation of Email field type (#6689)

### Description

1. 
   - We are introducing new field type(Emails)


   - We are Forbiding creation of Email field


   - We Added support for filtering and sorting on Emails 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\
   Follow the below steps for testing locally:\
   1. Checkout to TWENTY-6261\
2. Reset database using "npx nx database:reset twenty-server" command\
   3. Run both the backend and frontend app\
4. Go to Settings/Data model and choose one of the standard objects like
people\
   5. Click on Add Field button and choose Emails as the field type

   \
   ### Refs

   #6261\
   \
   ### Demo

    \

<https://www.loom.com/share/22979acac8134ed390fef93cc56fe07c?sid=adafba94-840d-4f01-872c-dc9ec256d987>

Co-authored-by: gitstart-twenty <gitstart-twenty@users.noreply.github.com>
This commit is contained in:
gitstart-app[bot] 2024-08-29 11:42:24 +02:00 committed by GitHub
parent c87ccfa3c7
commit 7a9a43b85c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 866 additions and 318 deletions

View File

@ -360,6 +360,7 @@ export enum FieldMetadataType {
Date = 'DATE', Date = 'DATE',
DateTime = 'DATE_TIME', DateTime = 'DATE_TIME',
Email = 'EMAIL', Email = 'EMAIL',
Emails = 'EMAILS',
FullName = 'FULL_NAME', FullName = 'FULL_NAME',
Link = 'LINK', Link = 'LINK',
Links = 'LINKS', Links = 'LINKS',

View File

@ -265,6 +265,7 @@ export enum FieldMetadataType {
Date = 'DATE', Date = 'DATE',
DateTime = 'DATE_TIME', DateTime = 'DATE_TIME',
Email = 'EMAIL', Email = 'EMAIL',
Emails = 'EMAILS',
FullName = 'FULL_NAME', FullName = 'FULL_NAME',
Link = 'LINK', Link = 'LINK',
Links = 'LINKS', Links = 'LINKS',

View File

@ -9,6 +9,7 @@ export const SORTABLE_FIELD_METADATA_TYPES = [
FieldMetadataType.Select, FieldMetadataType.Select,
FieldMetadataType.Phone, FieldMetadataType.Phone,
FieldMetadataType.Email, FieldMetadataType.Email,
FieldMetadataType.Emails,
FieldMetadataType.FullName, FieldMetadataType.FullName,
FieldMetadataType.Rating, FieldMetadataType.Rating,
FieldMetadataType.Currency, FieldMetadataType.Currency,

View File

@ -26,6 +26,7 @@ export const formatFieldMetadataItemsAsFilterDefinitions = ({
FieldMetadataType.DateTime, FieldMetadataType.DateTime,
FieldMetadataType.Text, FieldMetadataType.Text,
FieldMetadataType.Email, FieldMetadataType.Email,
FieldMetadataType.Emails,
FieldMetadataType.Number, FieldMetadataType.Number,
FieldMetadataType.Link, FieldMetadataType.Link,
FieldMetadataType.Links, FieldMetadataType.Links,
@ -77,6 +78,8 @@ export const getFilterTypeFromFieldType = (fieldType: FieldMetadataType) => {
return 'CURRENCY'; return 'CURRENCY';
case FieldMetadataType.Email: case FieldMetadataType.Email:
return 'EMAIL'; return 'EMAIL';
case FieldMetadataType.Emails:
return 'EMAILS';
case FieldMetadataType.Phone: case FieldMetadataType.Phone:
return 'PHONE'; return 'PHONE';
case FieldMetadataType.Relation: case FieldMetadataType.Relation:

View File

@ -1,7 +1,10 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy'; import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy';
import { FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata'; import {
FieldEmailsValue,
FieldLinksValue,
} from '@/object-record/record-field/types/FieldMetadata';
import { OrderBy } from '@/types/OrderBy'; import { OrderBy } from '@/types/OrderBy';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
@ -43,6 +46,14 @@ export const getOrderByForFieldMetadataType = (
} satisfies { [key in keyof FieldLinksValue]?: OrderBy }, } satisfies { [key in keyof FieldLinksValue]?: OrderBy },
}, },
]; ];
case FieldMetadataType.Emails:
return [
{
[field.name]: {
primaryEmail: direction ?? 'AscNullsLast',
} satisfies { [key in keyof FieldEmailsValue]?: OrderBy },
},
];
default: default:
return [ return [
{ {

View File

@ -156,5 +156,13 @@ ${mapObjectMetadataToGraphQLQuery({
}`; }`;
} }
if (fieldType === FieldMetadataType.Emails) {
return `${field.name}
{
primaryEmail
additionalEmails
}`;
}
return ''; return '';
}; };

View File

@ -94,6 +94,10 @@ export type ActorFilter = {
name?: StringFilter; name?: StringFilter;
}; };
export type EmailsFilter = {
primaryEmail?: StringFilter;
};
export type LeafFilter = export type LeafFilter =
| UUIDFilter | UUIDFilter
| StringFilter | StringFilter

View File

@ -60,6 +60,7 @@ export const MultipleFiltersDropdownContent = ({
{[ {[
'TEXT', 'TEXT',
'EMAIL', 'EMAIL',
'EMAILS',
'PHONE', 'PHONE',
'FULL_NAME', 'FULL_NAME',
'LINK', 'LINK',

View File

@ -2,6 +2,7 @@ export type FilterType =
| 'TEXT' | 'TEXT'
| 'PHONE' | 'PHONE'
| 'EMAIL' | 'EMAIL'
| 'EMAILS'
| 'DATE_TIME' | 'DATE_TIME'
| 'DATE' | 'DATE'
| 'NUMBER' | 'NUMBER'

View File

@ -15,6 +15,7 @@ export const getOperandsForFilterType = (
switch (filterType) { switch (filterType) {
case 'TEXT': case 'TEXT':
case 'EMAIL': case 'EMAIL':
case 'EMAILS':
case 'FULL_NAME': case 'FULL_NAME':
case 'ADDRESS': case 'ADDRESS':
case 'PHONE': case 'PHONE':

View File

@ -2,6 +2,7 @@ import { useContext } from 'react';
import { ActorFieldDisplay } from '@/object-record/record-field/meta-types/display/components/ActorFieldDisplay'; import { ActorFieldDisplay } from '@/object-record/record-field/meta-types/display/components/ActorFieldDisplay';
import { BooleanFieldDisplay } from '@/object-record/record-field/meta-types/display/components/BooleanFieldDisplay'; import { BooleanFieldDisplay } from '@/object-record/record-field/meta-types/display/components/BooleanFieldDisplay';
import { EmailsFieldDisplay } from '@/object-record/record-field/meta-types/display/components/EmailsFieldDisplay';
import { LinksFieldDisplay } from '@/object-record/record-field/meta-types/display/components/LinksFieldDisplay'; import { LinksFieldDisplay } from '@/object-record/record-field/meta-types/display/components/LinksFieldDisplay';
import { RatingFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RatingFieldDisplay'; import { RatingFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RatingFieldDisplay';
import { RelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay'; import { RelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay';
@ -10,6 +11,7 @@ import { isFieldIdentifierDisplay } from '@/object-record/record-field/meta-type
import { isFieldActor } from '@/object-record/record-field/types/guards/isFieldActor'; import { isFieldActor } from '@/object-record/record-field/types/guards/isFieldActor';
import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean'; import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean';
import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone'; import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone';
import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks'; import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating'; import { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating';
import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects'; import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects';
@ -100,5 +102,7 @@ export const FieldDisplay = () => {
<RichTextFieldDisplay /> <RichTextFieldDisplay />
) : isFieldActor(fieldDefinition) ? ( ) : isFieldActor(fieldDefinition) ? (
<ActorFieldDisplay /> <ActorFieldDisplay />
) : isFieldEmails(fieldDefinition) ? (
<EmailsFieldDisplay />
) : null; ) : null;
}; };

View File

@ -2,6 +2,7 @@ import { useContext } from 'react';
import { AddressFieldInput } from '@/object-record/record-field/meta-types/input/components/AddressFieldInput'; import { AddressFieldInput } from '@/object-record/record-field/meta-types/input/components/AddressFieldInput';
import { DateFieldInput } from '@/object-record/record-field/meta-types/input/components/DateFieldInput'; import { DateFieldInput } from '@/object-record/record-field/meta-types/input/components/DateFieldInput';
import { EmailsFieldInput } from '@/object-record/record-field/meta-types/input/components/EmailsFieldInput';
import { FullNameFieldInput } from '@/object-record/record-field/meta-types/input/components/FullNameFieldInput'; import { FullNameFieldInput } from '@/object-record/record-field/meta-types/input/components/FullNameFieldInput';
import { LinksFieldInput } from '@/object-record/record-field/meta-types/input/components/LinksFieldInput'; import { LinksFieldInput } from '@/object-record/record-field/meta-types/input/components/LinksFieldInput';
import { MultiSelectFieldInput } from '@/object-record/record-field/meta-types/input/components/MultiSelectFieldInput'; import { MultiSelectFieldInput } from '@/object-record/record-field/meta-types/input/components/MultiSelectFieldInput';
@ -11,6 +12,7 @@ import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/
import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope'; import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope';
import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate'; import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone'; import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone';
import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails';
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName'; import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks'; import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect'; import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
@ -103,6 +105,8 @@ export const FieldInput = ({
onTab={onTab} onTab={onTab}
onShiftTab={onShiftTab} onShiftTab={onShiftTab}
/> />
) : isFieldEmails(fieldDefinition) ? (
<EmailsFieldInput onCancel={onCancel} />
) : isFieldFullName(fieldDefinition) ? ( ) : isFieldFullName(fieldDefinition) ? (
<FullNameFieldInput <FullNameFieldInput
onEnter={onEnter} onEnter={onEnter}

View File

@ -7,6 +7,8 @@ import { isFieldAddress } from '@/object-record/record-field/types/guards/isFiel
import { isFieldAddressValue } from '@/object-record/record-field/types/guards/isFieldAddressValue'; import { isFieldAddressValue } from '@/object-record/record-field/types/guards/isFieldAddressValue';
import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate'; import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
import { isFieldDateValue } from '@/object-record/record-field/types/guards/isFieldDateValue'; import { isFieldDateValue } from '@/object-record/record-field/types/guards/isFieldDateValue';
import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails';
import { isFieldEmailsValue } from '@/object-record/record-field/types/guards/isFieldEmailsValue';
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName'; import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue'; import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks'; import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
@ -65,6 +67,9 @@ export const usePersistField = () => {
const fieldIsEmail = const fieldIsEmail =
isFieldEmail(fieldDefinition) && isFieldEmailValue(valueToPersist); isFieldEmail(fieldDefinition) && isFieldEmailValue(valueToPersist);
const fieldIsEmails =
isFieldEmails(fieldDefinition) && isFieldEmailsValue(valueToPersist);
const fieldIsDateTime = const fieldIsDateTime =
isFieldDateTime(fieldDefinition) && isFieldDateTime(fieldDefinition) &&
isFieldDateTimeValue(valueToPersist); isFieldDateTimeValue(valueToPersist);
@ -119,6 +124,7 @@ export const usePersistField = () => {
fieldIsText || fieldIsText ||
fieldIsBoolean || fieldIsBoolean ||
fieldIsEmail || fieldIsEmail ||
fieldIsEmails ||
fieldIsRating || fieldIsRating ||
fieldIsNumber || fieldIsNumber ||
fieldIsDateTime || fieldIsDateTime ||

View File

@ -0,0 +1,8 @@
import { useEmailsField } from '@/object-record/record-field/meta-types/hooks/useEmailsField';
import { EmailsDisplay } from '@/ui/field/display/components/EmailsDisplay';
export const EmailsFieldDisplay = () => {
const { fieldValue } = useEmailsField();
return <EmailsDisplay value={fieldValue} />;
};

View File

@ -0,0 +1,53 @@
import { useContext } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { usePersistField } from '@/object-record/record-field/hooks/usePersistField';
import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput';
import { FieldEmailsValue } from '@/object-record/record-field/types/FieldMetadata';
import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails';
import { emailsSchema } from '@/object-record/record-field/types/guards/isFieldEmailsValue';
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 useEmailsField = () => {
const { recordId, fieldDefinition, hotkeyScope } = useContext(FieldContext);
assertFieldMetadata(FieldMetadataType.Emails, isFieldEmails, fieldDefinition);
const fieldName = fieldDefinition.metadata.fieldName;
const [fieldValue, setFieldValue] = useRecoilState<FieldEmailsValue>(
recordStoreFamilySelector({
recordId,
fieldName: fieldName,
}),
);
const { setDraftValue, getDraftValueSelector } =
useRecordFieldInput<FieldEmailsValue>(`${recordId}-${fieldName}`);
const draftValue = useRecoilValue(getDraftValueSelector());
const persistField = usePersistField();
const persistEmailsField = (nextValue: FieldEmailsValue) => {
try {
persistField(emailsSchema.parse(nextValue));
} catch {
return;
}
};
return {
fieldDefinition,
fieldValue,
draftValue,
setDraftValue,
setFieldValue,
hotkeyScope,
persistEmailsField,
};
};

View File

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

View File

@ -0,0 +1,57 @@
import { useEmailsField } from '@/object-record/record-field/meta-types/hooks/useEmailsField';
import { EmailsFieldMenuItem } from '@/object-record/record-field/meta-types/input/components/EmailsFieldMenuItem';
import { useMemo } from 'react';
import { isDefined } from 'twenty-ui';
import { MultiItemFieldInput } from './MultiItemFieldInput';
type EmailsFieldInputProps = {
onCancel?: () => void;
};
export const EmailsFieldInput = ({ onCancel }: EmailsFieldInputProps) => {
const { persistEmailsField, hotkeyScope, fieldValue } = useEmailsField();
const emails = useMemo<string[]>(
() =>
[
fieldValue?.primaryEmail ? fieldValue?.primaryEmail : null,
...(fieldValue?.additionalEmails ?? []),
].filter(isDefined),
[fieldValue?.primaryEmail, fieldValue?.additionalEmails],
);
const handlePersistEmails = (updatedEmails: string[]) => {
const [nextPrimaryEmail, ...nextAdditionalEmails] = updatedEmails;
persistEmailsField({
primaryEmail: nextPrimaryEmail ?? '',
additionalEmails: nextAdditionalEmails,
});
};
return (
<MultiItemFieldInput
items={emails}
onPersist={handlePersistEmails}
onCancel={onCancel}
placeholder="Email"
renderItem={({
value: email,
index,
handleEdit,
handleSetPrimary,
handleDelete,
}) => (
<EmailsFieldMenuItem
key={index}
dropdownId={`${hotkeyScope}-emails-${index}`}
isPrimary={index === 0}
email={email}
onEdit={handleEdit}
onSetAsPrimary={handleSetPrimary}
onDelete={handleDelete}
/>
)}
hotkeyScope={hotkeyScope}
/>
);
};

View File

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

View File

@ -1,28 +1,9 @@
import styled from '@emotion/styled';
import { useMemo, useRef, useState } from 'react';
import { Key } from 'ts-key-enum';
import { IconCheck, IconPlus } from 'twenty-ui';
import { useLinksField } from '@/object-record/record-field/meta-types/hooks/useLinksField'; import { useLinksField } from '@/object-record/record-field/meta-types/hooks/useLinksField';
import { LinksFieldMenuItem } from '@/object-record/record-field/meta-types/input/components/LinksFieldMenuItem'; import { LinksFieldMenuItem } from '@/object-record/record-field/meta-types/input/components/LinksFieldMenuItem';
import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; import { useMemo } from 'react';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; import { isDefined } from 'twenty-ui';
import { DropdownMenuInput } 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 { moveArrayItem } from '~/utils/array/moveArrayItem';
import { toSpliced } from '~/utils/array/toSpliced';
import { isDefined } from '~/utils/isDefined';
import { absoluteUrlSchema } from '~/utils/validation-schemas/absoluteUrlSchema'; import { absoluteUrlSchema } from '~/utils/validation-schemas/absoluteUrlSchema';
import { MultiItemFieldInput } from './MultiItemFieldInput';
const StyledDropdownMenu = styled(DropdownMenu)`
left: -1px;
position: absolute;
top: -1px;
`;
type LinksFieldInputProps = { type LinksFieldInputProps = {
onCancel?: () => void; onCancel?: () => void;
@ -31,8 +12,6 @@ type LinksFieldInputProps = {
export const LinksFieldInput = ({ onCancel }: LinksFieldInputProps) => { export const LinksFieldInput = ({ onCancel }: LinksFieldInputProps) => {
const { persistLinksField, hotkeyScope, fieldValue } = useLinksField(); const { persistLinksField, hotkeyScope, fieldValue } = useLinksField();
const containerRef = useRef<HTMLDivElement>(null);
const links = useMemo<{ url: string; label: string }[]>( const links = useMemo<{ url: string; label: string }[]>(
() => () =>
[ [
@ -51,158 +30,44 @@ export const LinksFieldInput = ({ onCancel }: LinksFieldInputProps) => {
], ],
); );
const handleDropdownClose = () => { const handlePersistLinks = (
onCancel?.(); updatedLinks: { url: string; label: string }[],
}; ) => {
const [nextPrimaryLink, ...nextSecondaryLinks] = updatedLinks;
useListenClickOutside({
refs: [containerRef],
callback: handleDropdownClose,
});
useScopedHotkeys(Key.Escape, handleDropdownClose, hotkeyScope);
const [isInputDisplayed, setIsInputDisplayed] = useState(false);
const [inputValue, setInputValue] = useState('');
const [linkToEditIndex, setLinkToEditIndex] = useState(-1);
const isAddingNewLink = linkToEditIndex === -1;
const handleAddButtonClick = () => {
setLinkToEditIndex(-1);
setIsInputDisplayed(true);
};
const handleEditButtonClick = (index: number) => {
setLinkToEditIndex(index);
setInputValue(links[index].url);
setIsInputDisplayed(true);
};
const urlInputValidation = inputValue
? absoluteUrlSchema.safeParse(inputValue)
: null;
const handleSubmitInput = () => {
if (!urlInputValidation?.success) return;
const validatedInputValue = urlInputValidation.data;
// Don't persist if value hasn't changed.
if (
!isAddingNewLink &&
validatedInputValue === links[linkToEditIndex].url
) {
setIsInputDisplayed(false);
setInputValue('');
return;
}
const linkValue = { label: '', url: validatedInputValue };
const nextLinks = isAddingNewLink
? [...links, linkValue]
: toSpliced(links, linkToEditIndex, 1, linkValue);
const [nextPrimaryLink, ...nextSecondaryLinks] = nextLinks;
persistLinksField({ persistLinksField({
primaryLinkUrl: nextPrimaryLink.url ?? '', primaryLinkUrl: nextPrimaryLink?.url ?? '',
primaryLinkLabel: nextPrimaryLink.label ?? '', primaryLinkLabel: nextPrimaryLink?.label ?? '',
secondaryLinks: nextSecondaryLinks, secondaryLinks: nextSecondaryLinks,
}); });
setIsInputDisplayed(false);
setInputValue('');
};
const handleSetPrimaryLink = (index: number) => {
const nextLinks = moveArrayItem(links, { fromIndex: index, toIndex: 0 });
const [nextPrimaryLink, ...nextSecondaryLinks] = nextLinks;
persistLinksField({
primaryLinkUrl: nextPrimaryLink.url ?? '',
primaryLinkLabel: nextPrimaryLink.label ?? '',
secondaryLinks: nextSecondaryLinks,
});
};
const handleDeleteLink = (index: number) => {
const hasOnlyOneLastLink = links.length === 1;
if (hasOnlyOneLastLink) {
persistLinksField({
primaryLinkUrl: '',
primaryLinkLabel: '',
secondaryLinks: null,
});
handleDropdownClose();
return;
}
const isRemovingPrimary = index === 0;
if (isRemovingPrimary) {
const [, nextPrimaryLink, ...nextSecondaryLinks] = links;
persistLinksField({
primaryLinkUrl: nextPrimaryLink.url ?? '',
primaryLinkLabel: nextPrimaryLink.label ?? '',
secondaryLinks: nextSecondaryLinks,
});
return;
}
persistLinksField({
...fieldValue,
secondaryLinks: toSpliced(fieldValue.secondaryLinks ?? [], index - 1, 1),
});
}; };
return ( return (
<StyledDropdownMenu ref={containerRef} width={200}> <MultiItemFieldInput
{!!links.length && ( items={links}
<> onPersist={handlePersistLinks}
<DropdownMenuItemsContainer> onCancel={onCancel}
{links.map(({ label, url }, index) => ( placeholder="URL"
validateInput={(input) => absoluteUrlSchema.safeParse(input).success}
formatInput={(input) => ({ url: input, label: '' })}
renderItem={({
value: link,
index,
handleEdit,
handleSetPrimary,
handleDelete,
}) => (
<LinksFieldMenuItem <LinksFieldMenuItem
key={index} key={index}
dropdownId={`${hotkeyScope}-links-${index}`} dropdownId={`${hotkeyScope}-links-${index}`}
isPrimary={index === 0} isPrimary={index === 0}
label={label} label={link.label}
onEdit={() => handleEditButtonClick(index)} onEdit={handleEdit}
onSetAsPrimary={() => handleSetPrimaryLink(index)} onSetAsPrimary={handleSetPrimary}
onDelete={() => handleDeleteLink(index)} onDelete={handleDelete}
url={url} url={link.url}
/> />
))}
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
</>
)} )}
{isInputDisplayed || !links.length ? (
<DropdownMenuInput
autoFocus
placeholder="URL"
value={inputValue}
hotkeyScope={hotkeyScope} hotkeyScope={hotkeyScope}
onChange={(event) => setInputValue(event.target.value)}
onEnter={handleSubmitInput}
rightComponent={
<LightIconButton
Icon={isAddingNewLink ? IconPlus : IconCheck}
disabled={!urlInputValidation?.success}
onClick={handleSubmitInput}
/> />
}
/>
) : (
<DropdownMenuItemsContainer>
<MenuItem
onClick={handleAddButtonClick}
LeftIcon={IconPlus}
text="Add link"
/>
</DropdownMenuItemsContainer>
)}
</StyledDropdownMenu>
); );
}; };

View File

@ -1,19 +1,5 @@
import styled from '@emotion/styled';
import { useEffect, useState } from 'react';
import {
IconBookmark,
IconBookmarkPlus,
IconComponent,
IconDotsVertical,
IconPencil,
IconTrash,
} from 'twenty-ui';
import { LinkDisplay } from '@/ui/field/display/components/LinkDisplay'; import { LinkDisplay } from '@/ui/field/display/components/LinkDisplay';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { MultiItemFieldMenuItem } from './MultiItemFieldMenuItem';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
type LinksFieldMenuItemProps = { type LinksFieldMenuItemProps = {
dropdownId: string; dropdownId: string;
@ -25,12 +11,6 @@ type LinksFieldMenuItemProps = {
url: string; url: string;
}; };
const StyledIconBookmark = styled(IconBookmark)`
color: ${({ theme }) => theme.font.color.light};
height: ${({ theme }) => theme.icon.size.sm}px;
width: ${({ theme }) => theme.icon.size.sm}px;
`;
export const LinksFieldMenuItem = ({ export const LinksFieldMenuItem = ({
dropdownId, dropdownId,
isPrimary, isPrimary,
@ -40,76 +20,15 @@ export const LinksFieldMenuItem = ({
onDelete, onDelete,
url, url,
}: LinksFieldMenuItemProps) => { }: LinksFieldMenuItemProps) => {
const [isHovered, setIsHovered] = useState(false);
const { isDropdownOpen, closeDropdown } = useDropdown(dropdownId);
const handleMouseEnter = () => setIsHovered(true);
const handleMouseLeave = () => setIsHovered(false);
const handleDeleteClick = () => {
setIsHovered(false);
onDelete?.();
};
// Make sure dropdown closes on unmount.
useEffect(() => {
if (isDropdownOpen) {
return () => closeDropdown();
}
}, [closeDropdown, isDropdownOpen]);
return ( return (
<MenuItem <MultiItemFieldMenuItem
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
text={<LinkDisplay value={{ label, url }} />}
isIconDisplayedOnHoverOnly={!isPrimary && !isDropdownOpen}
iconButtons={[
{
Wrapper: isHovered
? ({ iconButton }) => (
<Dropdown
dropdownId={dropdownId} dropdownId={dropdownId}
dropdownHotkeyScope={{ isPrimary={isPrimary}
scope: dropdownId, value={{ label, url }}
}} onEdit={onEdit}
dropdownPlacement="right-start" onSetAsPrimary={onSetAsPrimary}
dropdownStrategy="fixed" onDelete={onDelete}
disableBlur DisplayComponent={LinkDisplay}
clickableComponent={iconButton}
dropdownComponents={
<DropdownMenuItemsContainer>
{!isPrimary && (
<MenuItem
LeftIcon={IconBookmarkPlus}
text="Set as Primary"
onClick={onSetAsPrimary}
/>
)}
<MenuItem
LeftIcon={IconPencil}
text="Edit"
onClick={onEdit}
/>
<MenuItem
accent="danger"
LeftIcon={IconTrash}
text="Delete"
onClick={handleDeleteClick}
/>
</DropdownMenuItemsContainer>
}
/>
)
: undefined,
Icon:
isPrimary && !isHovered
? (StyledIconBookmark as IconComponent)
: IconDotsVertical,
accent: 'tertiary',
onClick: isHovered ? () => {} : undefined,
},
]}
/> />
); );
}; };

View File

@ -0,0 +1,155 @@
import styled from '@emotion/styled';
import { useRef, useState } from 'react';
import { Key } from 'ts-key-enum';
import { IconCheck, IconPlus } from 'twenty-ui';
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 { 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 { moveArrayItem } from '~/utils/array/moveArrayItem';
import { toSpliced } from '~/utils/array/toSpliced';
const StyledDropdownMenu = styled(DropdownMenu)`
left: -1px;
position: absolute;
top: -1px;
`;
type MultiItemFieldInputProps<T> = {
items: T[];
onPersist: (updatedItems: T[]) => void;
onCancel?: () => void;
placeholder: string;
validateInput?: (input: string) => boolean;
formatInput?: (input: string) => T;
renderItem: (props: {
value: T;
index: number;
handleEdit: () => void;
handleSetPrimary: () => void;
handleDelete: () => void;
}) => React.ReactNode;
hotkeyScope: string;
};
export const MultiItemFieldInput = <T,>({
items,
onPersist,
onCancel,
placeholder,
validateInput,
formatInput,
renderItem,
hotkeyScope,
}: MultiItemFieldInputProps<T>) => {
const containerRef = useRef<HTMLDivElement>(null);
const handleDropdownClose = () => {
onCancel?.();
};
useListenClickOutside({
refs: [containerRef],
callback: handleDropdownClose,
});
useScopedHotkeys(Key.Escape, handleDropdownClose, hotkeyScope);
const [isInputDisplayed, setIsInputDisplayed] = useState(false);
const [inputValue, setInputValue] = useState('');
const [itemToEditIndex, setItemToEditIndex] = useState(-1);
const isAddingNewItem = itemToEditIndex === -1;
const handleAddButtonClick = () => {
setItemToEditIndex(-1);
setIsInputDisplayed(true);
};
const handleEditButtonClick = (index: number) => {
setItemToEditIndex(index);
setInputValue((items[index] as unknown as string) || '');
setIsInputDisplayed(true);
};
const handleSubmitInput = () => {
if (validateInput !== undefined && !validateInput(inputValue)) return;
const newItem = formatInput
? formatInput(inputValue)
: (inputValue as unknown as T);
if (!isAddingNewItem && newItem === items[itemToEditIndex]) {
setIsInputDisplayed(false);
setInputValue('');
return;
}
const updatedItems = isAddingNewItem
? [...items, newItem]
: toSpliced(items, itemToEditIndex, 1, newItem);
onPersist(updatedItems);
setIsInputDisplayed(false);
setInputValue('');
};
const handleSetPrimaryItem = (index: number) => {
const updatedItems = moveArrayItem(items, { fromIndex: index, toIndex: 0 });
onPersist(updatedItems);
};
const handleDeleteItem = (index: number) => {
const updatedItems = toSpliced(items, index, 1);
onPersist(updatedItems);
};
return (
<StyledDropdownMenu ref={containerRef} width={200}>
{!!items.length && (
<>
<DropdownMenuItemsContainer>
{items.map((item, index) =>
renderItem({
value: item,
index,
handleEdit: () => handleEditButtonClick(index),
handleSetPrimary: () => handleSetPrimaryItem(index),
handleDelete: () => handleDeleteItem(index),
}),
)}
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
</>
)}
{isInputDisplayed || !items.length ? (
<DropdownMenuInput
autoFocus
placeholder={placeholder}
value={inputValue}
hotkeyScope={hotkeyScope}
onChange={(event) => setInputValue(event.target.value)}
onEnter={handleSubmitInput}
rightComponent={
<LightIconButton
Icon={isAddingNewItem ? IconPlus : IconCheck}
onClick={handleSubmitInput}
/>
}
/>
) : (
<DropdownMenuItemsContainer>
<MenuItem
onClick={handleAddButtonClick}
LeftIcon={IconPlus}
text={`Add ${placeholder}`}
/>
</DropdownMenuItemsContainer>
)}
</StyledDropdownMenu>
);
};

View File

@ -0,0 +1,110 @@
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import styled from '@emotion/styled';
import { useEffect, useState } from 'react';
import {
IconBookmark,
IconBookmarkPlus,
IconComponent,
IconDotsVertical,
IconPencil,
IconTrash,
} from 'twenty-ui';
type MultiItemFieldMenuItemProps<T> = {
dropdownId: string;
isPrimary?: boolean;
value: T;
onEdit?: () => void;
onSetAsPrimary?: () => void;
onDelete?: () => void;
DisplayComponent: React.ComponentType<{ value: T }>;
};
const StyledIconBookmark = styled(IconBookmark)`
color: ${({ theme }) => theme.font.color.light};
height: ${({ theme }) => theme.icon.size.sm}px;
width: ${({ theme }) => theme.icon.size.sm}px;
`;
export const MultiItemFieldMenuItem = <T,>({
dropdownId,
isPrimary,
value,
onEdit,
onSetAsPrimary,
onDelete,
DisplayComponent,
}: MultiItemFieldMenuItemProps<T>) => {
const [isHovered, setIsHovered] = useState(false);
const { isDropdownOpen, closeDropdown } = useDropdown(dropdownId);
const handleMouseEnter = () => setIsHovered(true);
const handleMouseLeave = () => setIsHovered(false);
const handleDeleteClick = () => {
setIsHovered(false);
onDelete?.();
};
useEffect(() => {
if (isDropdownOpen) {
return () => closeDropdown();
}
}, [closeDropdown, isDropdownOpen]);
return (
<MenuItem
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
text={<DisplayComponent value={value} />}
isIconDisplayedOnHoverOnly={!isPrimary && !isDropdownOpen}
iconButtons={[
{
Wrapper: isHovered
? ({ iconButton }) => (
<Dropdown
dropdownId={dropdownId}
dropdownHotkeyScope={{ scope: dropdownId }}
dropdownPlacement="right-start"
dropdownStrategy="fixed"
disableBlur
clickableComponent={iconButton}
dropdownComponents={
<DropdownMenuItemsContainer>
{!isPrimary && (
<MenuItem
LeftIcon={IconBookmarkPlus}
text="Set as Primary"
onClick={onSetAsPrimary}
/>
)}
<MenuItem
LeftIcon={IconPencil}
text="Edit"
onClick={onEdit}
/>
<MenuItem
accent="danger"
LeftIcon={IconTrash}
text="Delete"
onClick={handleDeleteClick}
/>
</DropdownMenuItemsContainer>
}
/>
)
: undefined,
Icon:
isPrimary && !isHovered
? (StyledIconBookmark as IconComponent)
: IconDotsVertical,
accent: 'tertiary',
onClick: isHovered ? () => {} : undefined,
},
]}
/>
);
};

View File

@ -5,6 +5,7 @@ import {
FieldBooleanValue, FieldBooleanValue,
FieldCurrencyValue, FieldCurrencyValue,
FieldDateTimeValue, FieldDateTimeValue,
FieldEmailsValue,
FieldEmailValue, FieldEmailValue,
FieldFullNameValue, FieldFullNameValue,
FieldJsonValue, FieldJsonValue,
@ -26,6 +27,10 @@ export type FieldNumberDraftValue = string;
export type FieldDateTimeDraftValue = string; export type FieldDateTimeDraftValue = string;
export type FieldPhoneDraftValue = string; export type FieldPhoneDraftValue = string;
export type FieldEmailDraftValue = string; export type FieldEmailDraftValue = string;
export type FieldEmailsDraftValue = {
primaryEmail: string;
additionalEmails: string[] | null;
};
export type FieldSelectDraftValue = string; export type FieldSelectDraftValue = string;
export type FieldMultiSelectDraftValue = string[]; export type FieldMultiSelectDraftValue = string[];
export type FieldRelationDraftValue = string; export type FieldRelationDraftValue = string;
@ -72,6 +77,8 @@ export type FieldInputDraftValue<FieldValue> = FieldValue extends FieldTextValue
? FieldPhoneDraftValue ? FieldPhoneDraftValue
: FieldValue extends FieldEmailValue : FieldValue extends FieldEmailValue
? FieldEmailDraftValue ? FieldEmailDraftValue
: FieldValue extends FieldEmailsValue
? FieldEmailsDraftValue
: FieldValue extends FieldLinkValue : FieldValue extends FieldLinkValue
? FieldLinkDraftValue ? FieldLinkDraftValue
: FieldValue extends FieldLinksValue : FieldValue extends FieldLinksValue

View File

@ -72,6 +72,11 @@ export type FieldEmailMetadata = {
fieldName: string; fieldName: string;
}; };
export type FieldEmailsMetadata = {
objectMetadataNameSingular?: string;
fieldName: string;
};
export type FieldPhoneMetadata = { export type FieldPhoneMetadata = {
objectMetadataNameSingular?: string; objectMetadataNameSingular?: string;
placeHolder: string; placeHolder: string;
@ -180,6 +185,10 @@ export type FieldBooleanValue = boolean;
export type FieldPhoneValue = string; export type FieldPhoneValue = string;
export type FieldEmailValue = string; export type FieldEmailValue = string;
export type FieldEmailsValue = {
primaryEmail: string;
additionalEmails: string[] | null;
};
export type FieldLinkValue = { url: string; label: string }; export type FieldLinkValue = { url: string; label: string };
export type FieldLinksValue = { export type FieldLinksValue = {
primaryLinkLabel: string; primaryLinkLabel: string;

View File

@ -9,6 +9,7 @@ import {
FieldDateMetadata, FieldDateMetadata,
FieldDateTimeMetadata, FieldDateTimeMetadata,
FieldEmailMetadata, FieldEmailMetadata,
FieldEmailsMetadata,
FieldFullNameMetadata, FieldFullNameMetadata,
FieldLinkMetadata, FieldLinkMetadata,
FieldLinksMetadata, FieldLinksMetadata,
@ -38,6 +39,8 @@ type AssertFieldMetadataFunction = <
? FieldDateMetadata ? FieldDateMetadata
: E extends 'EMAIL' : E extends 'EMAIL'
? FieldEmailMetadata ? FieldEmailMetadata
: E extends 'EMAILS'
? FieldEmailsMetadata
: E extends 'SELECT' : E extends 'SELECT'
? FieldSelectMetadata ? FieldSelectMetadata
: E extends 'MULTI_SELECT' : E extends 'MULTI_SELECT'

View File

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

View File

@ -0,0 +1,12 @@
import { z } from 'zod';
import { FieldEmailsValue } from '@/object-record/record-field/types/FieldMetadata';
export const emailsSchema = z.object({
primaryEmail: z.string(),
additionalEmails: z.array(z.string()).nullable(),
}) satisfies z.ZodType<FieldEmailsValue>;
export const isFieldEmailsValue = (
fieldValue: unknown,
): fieldValue is FieldEmailsValue => emailsSchema.safeParse(fieldValue).success;

View File

@ -3,6 +3,7 @@ import { IconComponent, IconPencil } from 'twenty-ui';
import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone'; import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone';
import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks'; import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect'; import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation'; import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation';
@ -29,7 +30,8 @@ export const getFieldButtonIcon = (
(isFieldRelation(fieldDefinition) && (isFieldRelation(fieldDefinition) &&
fieldDefinition.metadata.relationObjectMetadataNameSingular !== fieldDefinition.metadata.relationObjectMetadataNameSingular !==
'workspaceMember') || 'workspaceMember') ||
isFieldLinks(fieldDefinition) isFieldLinks(fieldDefinition) ||
isFieldEmails(fieldDefinition)
) { ) {
return IconPencil; return IconPencil;
} }

View File

@ -12,6 +12,8 @@ import { isFieldCurrencyValue } from '@/object-record/record-field/types/guards/
import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate'; import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
import { isFieldDateTime } from '@/object-record/record-field/types/guards/isFieldDateTime'; import { isFieldDateTime } from '@/object-record/record-field/types/guards/isFieldDateTime';
import { isFieldEmail } from '@/object-record/record-field/types/guards/isFieldEmail'; import { isFieldEmail } from '@/object-record/record-field/types/guards/isFieldEmail';
import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails';
import { isFieldEmailsValue } from '@/object-record/record-field/types/guards/isFieldEmailsValue';
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName'; import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue'; import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue';
import { isFieldLink } from '@/object-record/record-field/types/guards/isFieldLink'; import { isFieldLink } from '@/object-record/record-field/types/guards/isFieldLink';
@ -120,6 +122,12 @@ export const isFieldValueEmpty = ({
return !isFieldActorValue(fieldValue) || isValueEmpty(fieldValue.name); return !isFieldActorValue(fieldValue) || isValueEmpty(fieldValue.name);
} }
if (isFieldEmails(fieldDefinition)) {
return (
!isFieldEmailsValue(fieldValue) || isValueEmpty(fieldValue.primaryEmail)
);
}
throw new Error( throw new Error(
`Entity field type not supported in isFieldValueEmpty : ${fieldDefinition.type}}`, `Entity field type not supported in isFieldValueEmpty : ${fieldDefinition.type}}`,
); );

View File

@ -8,6 +8,7 @@ import {
BooleanFilter, BooleanFilter,
CurrencyFilter, CurrencyFilter,
DateFilter, DateFilter,
EmailsFilter,
FloatFilter, FloatFilter,
FullNameFilter, FullNameFilter,
LinksFilter, LinksFilter,
@ -268,6 +269,18 @@ export const isRecordMatchingFilter = ({
}) })
); );
} }
case FieldMetadataType.Emails: {
const emailsFilter = filterValue as EmailsFilter;
if (emailsFilter.primaryEmail === undefined) {
return false;
}
return isMatchingStringFilter({
stringFilter: emailsFilter.primaryEmail,
value: record[filterKey].primaryEmail,
});
}
case FieldMetadataType.Relation: { case FieldMetadataType.Relation: {
throw new Error( throw new Error(
`Not implemented yet, use UUID filter instead on the corredponding "${filterKey}Id" field`, `Not implemented yet, use UUID filter instead on the corredponding "${filterKey}Id" field`,

View File

@ -5,6 +5,7 @@ import {
AddressFilter, AddressFilter,
CurrencyFilter, CurrencyFilter,
DateFilter, DateFilter,
EmailsFilter,
FloatFilter, FloatFilter,
RecordGqlOperationFilter, RecordGqlOperationFilter,
RelationFilter, RelationFilter,
@ -229,6 +230,22 @@ const applyEmptyFilters = (
], ],
}; };
break; break;
case 'EMAILS':
emptyRecordFilter = {
or: [
{
[correspondingField.name]: {
primaryEmail: { ilike: '' },
} as EmailsFilter,
},
{
[correspondingField.name]: {
primaryEmail: { is: 'NULL' },
} as EmailsFilter,
},
],
};
break;
default: default:
throw new Error(`Unsupported empty filter type ${filterType}`); throw new Error(`Unsupported empty filter type ${filterType}`);
} }
@ -806,6 +823,51 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
); );
} }
break; break;
case 'EMAILS':
switch (rawUIFilter.operand) {
case ViewFilterOperand.Contains:
objectRecordFilters.push({
or: [
{
[correspondingField.name]: {
primaryEmail: {
ilike: `%${rawUIFilter.value}%`,
},
} as EmailsFilter,
},
],
});
break;
case ViewFilterOperand.DoesNotContain:
objectRecordFilters.push({
and: [
{
not: {
[correspondingField.name]: {
primaryEmail: {
ilike: `%${rawUIFilter.value}%`,
},
} as EmailsFilter,
},
},
],
});
break;
case ViewFilterOperand.IsEmpty:
case ViewFilterOperand.IsNotEmpty:
applyEmptyFilters(
rawUIFilter.operand,
correspondingField,
objectRecordFilters,
rawUIFilter.definition.type,
);
break;
default:
throw new Error(
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
);
}
break;
default: default:
throw new Error('Unknown filter type'); throw new Error('Unknown filter type');
} }

View File

@ -12,6 +12,9 @@ export const generateEmptyFieldValue = (
case FieldMetadataType.Text: { case FieldMetadataType.Text: {
return ''; return '';
} }
case FieldMetadataType.Emails: {
return { primaryEmail: '', additionalEmails: null };
}
case FieldMetadataType.Link: { case FieldMetadataType.Link: {
return { return {
label: '', label: '',

View File

@ -99,6 +99,11 @@ export const SETTINGS_FIELD_TYPE_CONFIGS = {
Icon: IconRelationManyToMany, Icon: IconRelationManyToMany,
}, },
[FieldMetadataType.Email]: { label: 'Email', Icon: IconMail }, [FieldMetadataType.Email]: { label: 'Email', Icon: IconMail },
[FieldMetadataType.Emails]: {
label: 'Emails',
Icon: IconMail,
exampleValue: { primaryEmail: 'john@twenty.com' },
},
[FieldMetadataType.Phone]: { [FieldMetadataType.Phone]: {
label: 'Phone', label: 'Phone',
Icon: IconPhone, Icon: IconPhone,

View File

@ -85,6 +85,7 @@ const previewableTypes = [
FieldMetadataType.Currency, FieldMetadataType.Currency,
FieldMetadataType.Date, FieldMetadataType.Date,
FieldMetadataType.DateTime, FieldMetadataType.DateTime,
FieldMetadataType.Emails,
FieldMetadataType.FullName, FieldMetadataType.FullName,
FieldMetadataType.Link, FieldMetadataType.Link,
FieldMetadataType.Links, FieldMetadataType.Links,

View File

@ -0,0 +1,53 @@
import { useMemo } from 'react';
import { THEME_COMMON } from 'twenty-ui';
import { FieldEmailsValue } 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 styled from '@emotion/styled';
import { isDefined } from '~/utils/isDefined';
type EmailsDisplayProps = {
value?: FieldEmailsValue;
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 EmailsDisplay = ({ value, isFocused }: EmailsDisplayProps) => {
const emails = useMemo(
() =>
[
value?.primaryEmail ? value.primaryEmail : null,
...(value?.additionalEmails ?? []),
].filter(isDefined),
[value?.primaryEmail, value?.additionalEmails],
);
return isFocused ? (
<ExpandableList isChipCountDisplayed>
{emails.map((email, index) => (
<RoundedLink key={index} label={email} href={`mailto:${email}`} />
))}
</ExpandableList>
) : (
<StyledContainer>
{emails.map((email, index) => (
<RoundedLink key={index} label={email} href={`mailto:${email}`} />
))}
</StyledContainer>
);
};

View File

@ -216,7 +216,10 @@ export const SettingsObjectFieldEdit = () => {
<StyledSettingsObjectFieldTypeSelect <StyledSettingsObjectFieldTypeSelect
disabled disabled
fieldMetadataItem={activeMetadataField} fieldMetadataItem={activeMetadataField}
excludedFieldTypes={[FieldMetadataType.Link]} excludedFieldTypes={[
FieldMetadataType.Link,
FieldMetadataType.Email,
]}
/> />
<SettingsDataModelFieldSettingsFormCard <SettingsDataModelFieldSettingsFormCard
disableCurrencyForm disableCurrencyForm

View File

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

View File

@ -205,12 +205,19 @@ const fieldActorMock = {
name: '', name: '',
}, },
}; };
const fieldEmailsMock = {
name: 'fieldEmails',
type: FieldMetadataType.EMAILS,
isNullable: false,
defaultValue: [{ primaryEmail: '', additionalEmails: {} }],
};
export const fields = [ export const fields = [
fieldUuidMock, fieldUuidMock,
fieldTextMock, fieldTextMock,
fieldPhoneMock, fieldPhoneMock,
fieldEmailMock, fieldEmailMock,
fieldEmailsMock,
fieldDateTimeMock, fieldDateTimeMock,
fieldDateMock, fieldDateMock,
fieldBooleanMock, fieldBooleanMock,

View File

@ -144,5 +144,13 @@ export const mapFieldMetadataToGraphqlQuery = (
name name
} }
`; `;
} else if (fieldType === FieldMetadataType.EMAILS) {
return `
${field.name}
{
primaryEmail
additionalEmails
}
`;
} }
}; };

View File

@ -147,6 +147,17 @@ describe('computeSchemaComponents', () => {
}, },
type: 'object', type: 'object',
}, },
fieldEmails: {
properties: {
primaryEmail: {
type: 'string',
},
additionalEmails: {
type: 'object',
},
},
type: 'object',
},
}, },
}, },
'ObjectName with Relations': { 'ObjectName with Relations': {

View File

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

View File

@ -0,0 +1,26 @@
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 emailsCompositeType: CompositeType = {
type: FieldMetadataType.EMAILS,
properties: [
{
name: 'primaryEmail',
type: FieldMetadataType.TEXT,
hidden: false,
isRequired: false,
},
{
name: 'additionalEmails',
type: FieldMetadataType.RAW_JSON,
hidden: false,
isRequired: false,
},
],
};
export type EmailsMetadata = {
primaryEmail: string;
additionalEmails: string[] | null;
};

View File

@ -4,6 +4,7 @@ import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metada
import { actorCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; import { actorCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { addressCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/address.composite-type'; import { addressCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/address.composite-type';
import { currencyCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/currency.composite-type'; import { currencyCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/currency.composite-type';
import { emailsCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/emails.composite-type';
import { fullNameCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/full-name.composite-type'; import { fullNameCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/full-name.composite-type';
import { linkCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/link.composite-type'; import { linkCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/link.composite-type';
import { linksCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type'; import { linksCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type';
@ -23,4 +24,5 @@ export const compositeTypeDefinitions = new Map<
[FieldMetadataType.FULL_NAME, fullNameCompositeType], [FieldMetadataType.FULL_NAME, fullNameCompositeType],
[FieldMetadataType.ADDRESS, addressCompositeType], [FieldMetadataType.ADDRESS, addressCompositeType],
[FieldMetadataType.ACTOR, actorCompositeType], [FieldMetadataType.ACTOR, actorCompositeType],
[FieldMetadataType.EMAILS, emailsCompositeType],
]); ]);

View File

@ -175,3 +175,13 @@ export class FieldMetadataDefaultActor {
@IsString() @IsString()
name: string; name: string;
} }
export class FieldMetadataDefaultValueEmails {
@ValidateIf((_object, value) => value !== null)
@IsQuotedString()
primaryEmail: string | null;
@ValidateIf((_object, value) => value !== null)
@IsObject()
additionalEmails: string[] | null;
}

View File

@ -26,6 +26,7 @@ export enum FieldMetadataType {
TEXT = 'TEXT', TEXT = 'TEXT',
PHONE = 'PHONE', PHONE = 'PHONE',
EMAIL = 'EMAIL', EMAIL = 'EMAIL',
EMAILS = 'EMAILS',
DATE_TIME = 'DATE_TIME', DATE_TIME = 'DATE_TIME',
DATE = 'DATE', DATE = 'DATE',
BOOLEAN = 'BOOLEAN', BOOLEAN = 'BOOLEAN',

View File

@ -143,6 +143,13 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
); );
} }
if (fieldMetadataInput.type === FieldMetadataType.EMAIL) {
throw new FieldMetadataException(
'"Email" field types are being deprecated, please use Emails type instead',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
this.validateFieldMetadataInput<CreateFieldInput>( this.validateFieldMetadataInput<CreateFieldInput>(
fieldMetadataInput, fieldMetadataInput,
objectMetadata, objectMetadata,

View File

@ -4,6 +4,7 @@ import {
FieldMetadataDefaultValueBoolean, FieldMetadataDefaultValueBoolean,
FieldMetadataDefaultValueCurrency, FieldMetadataDefaultValueCurrency,
FieldMetadataDefaultValueDateTime, FieldMetadataDefaultValueDateTime,
FieldMetadataDefaultValueEmails,
FieldMetadataDefaultValueFullName, FieldMetadataDefaultValueFullName,
FieldMetadataDefaultValueLink, FieldMetadataDefaultValueLink,
FieldMetadataDefaultValueLinks, FieldMetadataDefaultValueLinks,
@ -27,6 +28,7 @@ type FieldMetadataDefaultValueMapping = {
[FieldMetadataType.TEXT]: FieldMetadataDefaultValueString; [FieldMetadataType.TEXT]: FieldMetadataDefaultValueString;
[FieldMetadataType.PHONE]: FieldMetadataDefaultValueString; [FieldMetadataType.PHONE]: FieldMetadataDefaultValueString;
[FieldMetadataType.EMAIL]: FieldMetadataDefaultValueString; [FieldMetadataType.EMAIL]: FieldMetadataDefaultValueString;
[FieldMetadataType.EMAILS]: FieldMetadataDefaultValueEmails;
[FieldMetadataType.DATE_TIME]: [FieldMetadataType.DATE_TIME]:
| FieldMetadataDefaultValueDateTime | FieldMetadataDefaultValueDateTime
| FieldMetadataDefaultValueNowFunction; | FieldMetadataDefaultValueNowFunction;

View File

@ -10,6 +10,11 @@ export function generateDefaultValue(
case FieldMetadataType.PHONE: case FieldMetadataType.PHONE:
case FieldMetadataType.EMAIL: case FieldMetadataType.EMAIL:
return "''"; return "''";
case FieldMetadataType.EMAILS:
return {
primaryEmail: "''",
additionalEmails: null,
};
case FieldMetadataType.FULL_NAME: case FieldMetadataType.FULL_NAME:
return { return {
firstName: "''", firstName: "''",

View File

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

View File

@ -13,6 +13,7 @@ import {
FieldMetadataDefaultValueCurrency, FieldMetadataDefaultValueCurrency,
FieldMetadataDefaultValueDate, FieldMetadataDefaultValueDate,
FieldMetadataDefaultValueDateTime, FieldMetadataDefaultValueDateTime,
FieldMetadataDefaultValueEmails,
FieldMetadataDefaultValueFullName, FieldMetadataDefaultValueFullName,
FieldMetadataDefaultValueLink, FieldMetadataDefaultValueLink,
FieldMetadataDefaultValueLinks, FieldMetadataDefaultValueLinks,
@ -53,6 +54,7 @@ export const defaultValueValidatorsMap = {
[FieldMetadataType.RAW_JSON]: [FieldMetadataDefaultValueRawJson], [FieldMetadataType.RAW_JSON]: [FieldMetadataDefaultValueRawJson],
[FieldMetadataType.LINKS]: [FieldMetadataDefaultValueLinks], [FieldMetadataType.LINKS]: [FieldMetadataDefaultValueLinks],
[FieldMetadataType.ACTOR]: [FieldMetadataDefaultActor], [FieldMetadataType.ACTOR]: [FieldMetadataDefaultActor],
[FieldMetadataType.EMAILS]: [FieldMetadataDefaultValueEmails],
}; };
type ValidationResult = { type ValidationResult = {

View File

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

View File

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