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',
DateTime = 'DATE_TIME',
Email = 'EMAIL',
Emails = 'EMAILS',
FullName = 'FULL_NAME',
Link = 'LINK',
Links = 'LINKS',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,6 +15,7 @@ export const getOperandsForFilterType = (
switch (filterType) {
case 'TEXT':
case 'EMAIL':
case 'EMAILS':
case 'FULL_NAME':
case 'ADDRESS':
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 { 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 { RatingFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RatingFieldDisplay';
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 { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean';
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 { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating';
import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects';
@ -100,5 +102,7 @@ export const FieldDisplay = () => {
<RichTextFieldDisplay />
) : isFieldActor(fieldDefinition) ? (
<ActorFieldDisplay />
) : isFieldEmails(fieldDefinition) ? (
<EmailsFieldDisplay />
) : null;
};

View File

@ -2,6 +2,7 @@ import { useContext } from 'react';
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 { EmailsFieldInput } from '@/object-record/record-field/meta-types/input/components/EmailsFieldInput';
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';
@ -11,6 +12,7 @@ import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/
import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope';
import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
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 { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
@ -103,6 +105,8 @@ export const FieldInput = ({
onTab={onTab}
onShiftTab={onShiftTab}
/>
) : isFieldEmails(fieldDefinition) ? (
<EmailsFieldInput onCancel={onCancel} />
) : isFieldFullName(fieldDefinition) ? (
<FullNameFieldInput
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 { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
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 { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
@ -65,6 +67,9 @@ export const usePersistField = () => {
const fieldIsEmail =
isFieldEmail(fieldDefinition) && isFieldEmailValue(valueToPersist);
const fieldIsEmails =
isFieldEmails(fieldDefinition) && isFieldEmailsValue(valueToPersist);
const fieldIsDateTime =
isFieldDateTime(fieldDefinition) &&
isFieldDateTimeValue(valueToPersist);
@ -119,6 +124,7 @@ export const usePersistField = () => {
fieldIsText ||
fieldIsBoolean ||
fieldIsEmail ||
fieldIsEmails ||
fieldIsRating ||
fieldIsNumber ||
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 { LinksFieldMenuItem } from '@/object-record/record-field/meta-types/input/components/LinksFieldMenuItem';
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';
import { isDefined } from '~/utils/isDefined';
import { useMemo } from 'react';
import { isDefined } from 'twenty-ui';
import { absoluteUrlSchema } from '~/utils/validation-schemas/absoluteUrlSchema';
const StyledDropdownMenu = styled(DropdownMenu)`
left: -1px;
position: absolute;
top: -1px;
`;
import { MultiItemFieldInput } from './MultiItemFieldInput';
type LinksFieldInputProps = {
onCancel?: () => void;
@ -31,8 +12,6 @@ type LinksFieldInputProps = {
export const LinksFieldInput = ({ onCancel }: LinksFieldInputProps) => {
const { persistLinksField, hotkeyScope, fieldValue } = useLinksField();
const containerRef = useRef<HTMLDivElement>(null);
const links = useMemo<{ url: string; label: string }[]>(
() =>
[
@ -51,158 +30,44 @@ export const LinksFieldInput = ({ onCancel }: LinksFieldInputProps) => {
],
);
const handleDropdownClose = () => {
onCancel?.();
};
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;
const handlePersistLinks = (
updatedLinks: { url: string; label: string }[],
) => {
const [nextPrimaryLink, ...nextSecondaryLinks] = updatedLinks;
persistLinksField({
primaryLinkUrl: nextPrimaryLink.url ?? '',
primaryLinkLabel: nextPrimaryLink.label ?? '',
primaryLinkUrl: nextPrimaryLink?.url ?? '',
primaryLinkLabel: nextPrimaryLink?.label ?? '',
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 (
<StyledDropdownMenu ref={containerRef} width={200}>
{!!links.length && (
<>
<DropdownMenuItemsContainer>
{links.map(({ label, url }, index) => (
<LinksFieldMenuItem
key={index}
dropdownId={`${hotkeyScope}-links-${index}`}
isPrimary={index === 0}
label={label}
onEdit={() => handleEditButtonClick(index)}
onSetAsPrimary={() => handleSetPrimaryLink(index)}
onDelete={() => handleDeleteLink(index)}
url={url}
/>
))}
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
</>
)}
{isInputDisplayed || !links.length ? (
<DropdownMenuInput
autoFocus
placeholder="URL"
value={inputValue}
hotkeyScope={hotkeyScope}
onChange={(event) => setInputValue(event.target.value)}
onEnter={handleSubmitInput}
rightComponent={
<LightIconButton
Icon={isAddingNewLink ? IconPlus : IconCheck}
disabled={!urlInputValidation?.success}
onClick={handleSubmitInput}
/>
}
<MultiItemFieldInput
items={links}
onPersist={handlePersistLinks}
onCancel={onCancel}
placeholder="URL"
validateInput={(input) => absoluteUrlSchema.safeParse(input).success}
formatInput={(input) => ({ url: input, label: '' })}
renderItem={({
value: link,
index,
handleEdit,
handleSetPrimary,
handleDelete,
}) => (
<LinksFieldMenuItem
key={index}
dropdownId={`${hotkeyScope}-links-${index}`}
isPrimary={index === 0}
label={link.label}
onEdit={handleEdit}
onSetAsPrimary={handleSetPrimary}
onDelete={handleDelete}
url={link.url}
/>
) : (
<DropdownMenuItemsContainer>
<MenuItem
onClick={handleAddButtonClick}
LeftIcon={IconPlus}
text="Add link"
/>
</DropdownMenuItemsContainer>
)}
</StyledDropdownMenu>
hotkeyScope={hotkeyScope}
/>
);
};

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 { 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 { MultiItemFieldMenuItem } from './MultiItemFieldMenuItem';
type LinksFieldMenuItemProps = {
dropdownId: string;
@ -25,12 +11,6 @@ type LinksFieldMenuItemProps = {
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 = ({
dropdownId,
isPrimary,
@ -40,76 +20,15 @@ export const LinksFieldMenuItem = ({
onDelete,
url,
}: 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 (
<MenuItem
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
text={<LinkDisplay value={{ label, url }} />}
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,
},
]}
<MultiItemFieldMenuItem
dropdownId={dropdownId}
isPrimary={isPrimary}
value={{ label, url }}
onEdit={onEdit}
onSetAsPrimary={onSetAsPrimary}
onDelete={onDelete}
DisplayComponent={LinkDisplay}
/>
);
};

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,
FieldCurrencyValue,
FieldDateTimeValue,
FieldEmailsValue,
FieldEmailValue,
FieldFullNameValue,
FieldJsonValue,
@ -26,6 +27,10 @@ export type FieldNumberDraftValue = string;
export type FieldDateTimeDraftValue = string;
export type FieldPhoneDraftValue = string;
export type FieldEmailDraftValue = string;
export type FieldEmailsDraftValue = {
primaryEmail: string;
additionalEmails: string[] | null;
};
export type FieldSelectDraftValue = string;
export type FieldMultiSelectDraftValue = string[];
export type FieldRelationDraftValue = string;
@ -72,28 +77,30 @@ export type FieldInputDraftValue<FieldValue> = FieldValue extends FieldTextValue
? FieldPhoneDraftValue
: FieldValue extends FieldEmailValue
? FieldEmailDraftValue
: 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 FieldEmailsValue
? FieldEmailsDraftValue
: FieldValue extends FieldLinkValue
? FieldLinkDraftValue
: FieldValue extends FieldLinksValue
? FieldLinksDraftValue
: FieldValue extends FieldCurrencyValue
? FieldCurrencyDraftValue
: FieldValue extends FieldFullNameValue
? FieldFullNameDraftValue
: FieldValue extends FieldRatingValue
? FieldRatingValue
: FieldValue extends FieldSelectValue
? FieldSelectDraftValue
: FieldValue extends FieldMultiSelectValue
? FieldMultiSelectDraftValue
: FieldValue extends FieldRelationToOneValue
? FieldRelationDraftValue
: FieldValue extends FieldRelationFromManyValue
? FieldRelationManyDraftValue
: FieldValue extends FieldAddressValue
? FieldAddressDraftValue
: FieldValue extends FieldJsonValue
? FieldJsonDraftValue
: FieldValue extends FieldActorValue
? FieldActorDraftValue
: never;

View File

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

View File

@ -9,6 +9,7 @@ import {
FieldDateMetadata,
FieldDateTimeMetadata,
FieldEmailMetadata,
FieldEmailsMetadata,
FieldFullNameMetadata,
FieldLinkMetadata,
FieldLinksMetadata,
@ -38,35 +39,37 @@ type AssertFieldMetadataFunction = <
? FieldDateMetadata
: E extends 'EMAIL'
? FieldEmailMetadata
: E extends 'SELECT'
? FieldSelectMetadata
: E extends 'MULTI_SELECT'
? FieldMultiSelectMetadata
: E extends 'RATING'
? FieldRatingMetadata
: E extends 'LINK'
? FieldLinkMetadata
: E extends 'LINKS'
? FieldLinksMetadata
: E extends 'NUMBER'
? 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 'EMAILS'
? FieldEmailsMetadata
: E extends 'SELECT'
? FieldSelectMetadata
: E extends 'MULTI_SELECT'
? FieldMultiSelectMetadata
: E extends 'RATING'
? FieldRatingMetadata
: E extends 'LINK'
? FieldLinkMetadata
: E extends 'LINKS'
? FieldLinksMetadata
: E extends 'NUMBER'
? 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,
>(
fieldType: E,
fieldTypeGuard: (

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 { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
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 { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation';
@ -29,7 +30,8 @@ export const getFieldButtonIcon = (
(isFieldRelation(fieldDefinition) &&
fieldDefinition.metadata.relationObjectMetadataNameSingular !==
'workspaceMember') ||
isFieldLinks(fieldDefinition)
isFieldLinks(fieldDefinition) ||
isFieldEmails(fieldDefinition)
) {
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 { isFieldDateTime } from '@/object-record/record-field/types/guards/isFieldDateTime';
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 { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue';
import { isFieldLink } from '@/object-record/record-field/types/guards/isFieldLink';
@ -120,6 +122,12 @@ export const isFieldValueEmpty = ({
return !isFieldActorValue(fieldValue) || isValueEmpty(fieldValue.name);
}
if (isFieldEmails(fieldDefinition)) {
return (
!isFieldEmailsValue(fieldValue) || isValueEmpty(fieldValue.primaryEmail)
);
}
throw new Error(
`Entity field type not supported in isFieldValueEmpty : ${fieldDefinition.type}}`,
);

View File

@ -8,6 +8,7 @@ import {
BooleanFilter,
CurrencyFilter,
DateFilter,
EmailsFilter,
FloatFilter,
FullNameFilter,
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: {
throw new Error(
`Not implemented yet, use UUID filter instead on the corredponding "${filterKey}Id" field`,

View File

@ -5,6 +5,7 @@ import {
AddressFilter,
CurrencyFilter,
DateFilter,
EmailsFilter,
FloatFilter,
RecordGqlOperationFilter,
RelationFilter,
@ -229,6 +230,22 @@ const applyEmptyFilters = (
],
};
break;
case 'EMAILS':
emptyRecordFilter = {
or: [
{
[correspondingField.name]: {
primaryEmail: { ilike: '' },
} as EmailsFilter,
},
{
[correspondingField.name]: {
primaryEmail: { is: 'NULL' },
} as EmailsFilter,
},
],
};
break;
default:
throw new Error(`Unsupported empty filter type ${filterType}`);
}
@ -806,6 +823,51 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
);
}
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:
throw new Error('Unknown filter type');
}

View File

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

View File

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

View File

@ -85,6 +85,7 @@ const previewableTypes = [
FieldMetadataType.Currency,
FieldMetadataType.Date,
FieldMetadataType.DateTime,
FieldMetadataType.Emails,
FieldMetadataType.FullName,
FieldMetadataType.Link,
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
disabled
fieldMetadataItem={activeMetadataField}
excludedFieldTypes={[FieldMetadataType.Link]}
excludedFieldTypes={[
FieldMetadataType.Link,
FieldMetadataType.Email,
]}
/>
<SettingsDataModelFieldSettingsFormCard
disableCurrencyForm

View File

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

View File

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

View File

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

View File

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

View File

@ -72,6 +72,7 @@ const getSchemaComponentsProperties = (
case FieldMetadataType.FULL_NAME:
case FieldMetadataType.ADDRESS:
case FieldMetadataType.ACTOR:
case FieldMetadataType.EMAILS:
itemProperty = {
type: 'object',
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 { 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 { 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 { 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';
@ -23,4 +24,5 @@ export const compositeTypeDefinitions = new Map<
[FieldMetadataType.FULL_NAME, fullNameCompositeType],
[FieldMetadataType.ADDRESS, addressCompositeType],
[FieldMetadataType.ACTOR, actorCompositeType],
[FieldMetadataType.EMAILS, emailsCompositeType],
]);

View File

@ -175,3 +175,13 @@ export class FieldMetadataDefaultActor {
@IsString()
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',
PHONE = 'PHONE',
EMAIL = 'EMAIL',
EMAILS = 'EMAILS',
DATE_TIME = 'DATE_TIME',
DATE = 'DATE',
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>(
fieldMetadataInput,
objectMetadata,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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