diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 7ee649b5c5ca..c02633c4c993 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -360,6 +360,7 @@ export enum FieldMetadataType { Date = 'DATE', DateTime = 'DATE_TIME', Email = 'EMAIL', + Emails = 'EMAILS', FullName = 'FULL_NAME', Link = 'LINK', Links = 'LINKS', diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index d9823a7969d9..39201910cafc 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -265,6 +265,7 @@ export enum FieldMetadataType { Date = 'DATE', DateTime = 'DATE_TIME', Email = 'EMAIL', + Emails = 'EMAILS', FullName = 'FULL_NAME', Link = 'LINK', Links = 'LINKS', diff --git a/packages/twenty-front/src/modules/object-metadata/constants/SortableFieldMetadataTypes.ts b/packages/twenty-front/src/modules/object-metadata/constants/SortableFieldMetadataTypes.ts index 9271c4d2b5e7..2e7713d96fc0 100644 --- a/packages/twenty-front/src/modules/object-metadata/constants/SortableFieldMetadataTypes.ts +++ b/packages/twenty-front/src/modules/object-metadata/constants/SortableFieldMetadataTypes.ts @@ -9,6 +9,7 @@ export const SORTABLE_FIELD_METADATA_TYPES = [ FieldMetadataType.Select, FieldMetadataType.Phone, FieldMetadataType.Email, + FieldMetadataType.Emails, FieldMetadataType.FullName, FieldMetadataType.Rating, FieldMetadataType.Currency, diff --git a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts index 7fa1fae41dc4..e8aef6f8ff11 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts @@ -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: diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getOrderByForFieldMetadataType.ts b/packages/twenty-front/src/modules/object-metadata/utils/getOrderByForFieldMetadataType.ts index 0e54e820e27d..1803bc9c5db8 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/getOrderByForFieldMetadataType.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/getOrderByForFieldMetadataType.ts @@ -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 [ { diff --git a/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts b/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts index 7f46e887d6e2..9984d62ffdcf 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts @@ -156,5 +156,13 @@ ${mapObjectMetadataToGraphQLQuery({ }`; } + if (fieldType === FieldMetadataType.Emails) { + return `${field.name} +{ + primaryEmail + additionalEmails +}`; + } + return ''; }; diff --git a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts index 29658b5cb8bc..38038ee0562e 100644 --- a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts +++ b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts @@ -94,6 +94,10 @@ export type ActorFilter = { name?: StringFilter; }; +export type EmailsFilter = { + primaryEmail?: StringFilter; +}; + export type LeafFilter = | UUIDFilter | StringFilter diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent.tsx index f795fdddbc29..91e31b5671ef 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent.tsx @@ -60,6 +60,7 @@ export const MultipleFiltersDropdownContent = ({ {[ 'TEXT', 'EMAIL', + 'EMAILS', 'PHONE', 'FULL_NAME', 'LINK', diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterType.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterType.ts index 36ba51c2096e..875148acf06b 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterType.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterType.ts @@ -2,6 +2,7 @@ export type FilterType = | 'TEXT' | 'PHONE' | 'EMAIL' + | 'EMAILS' | 'DATE_TIME' | 'DATE' | 'NUMBER' diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts index a7400625ead0..8265e54a9fe3 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts @@ -15,6 +15,7 @@ export const getOperandsForFilterType = ( switch (filterType) { case 'TEXT': case 'EMAIL': + case 'EMAILS': case 'FULL_NAME': case 'ADDRESS': case 'PHONE': diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx index 6f648f145c77..613eeb72f071 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx @@ -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 = () => { ) : isFieldActor(fieldDefinition) ? ( + ) : isFieldEmails(fieldDefinition) ? ( + ) : null; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx index bea16402ae2a..16555c0d1cf8 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx @@ -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) ? ( + ) : isFieldFullName(fieldDefinition) ? ( { 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 || diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/EmailsFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/EmailsFieldDisplay.tsx new file mode 100644 index 000000000000..b2a6fc724024 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/EmailsFieldDisplay.tsx @@ -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 ; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useEmailsField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useEmailsField.ts new file mode 100644 index 000000000000..ff723f0048ac --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useEmailsField.ts @@ -0,0 +1,53 @@ +import { useContext } from 'react'; +import { useRecoilState, useRecoilValue } from 'recoil'; + +import { usePersistField } from '@/object-record/record-field/hooks/usePersistField'; +import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; +import { 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( + recordStoreFamilySelector({ + recordId, + fieldName: fieldName, + }), + ); + + const { setDraftValue, getDraftValueSelector } = + useRecordFieldInput(`${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, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useEmailsFieldDisplay.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useEmailsFieldDisplay.ts new file mode 100644 index 000000000000..c3a66facf23f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useEmailsFieldDisplay.ts @@ -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( + recordId, + fieldName, + ); + + return { + fieldDefinition, + fieldValue, + hotkeyScope, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/EmailsFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/EmailsFieldInput.tsx new file mode 100644 index 000000000000..c3d90e7b1bbb --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/EmailsFieldInput.tsx @@ -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( + () => + [ + 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 ( + ( + + )} + hotkeyScope={hotkeyScope} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/EmailsFieldMenuItem.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/EmailsFieldMenuItem.tsx new file mode 100644 index 000000000000..f55b67854f2d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/EmailsFieldMenuItem.tsx @@ -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 ( + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx index dfb3adf03803..66ed0055d8f0 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldInput.tsx @@ -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(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 ( - - {!!links.length && ( - <> - - {links.map(({ label, url }, index) => ( - handleEditButtonClick(index)} - onSetAsPrimary={() => handleSetPrimaryLink(index)} - onDelete={() => handleDeleteLink(index)} - url={url} - /> - ))} - - - - )} - {isInputDisplayed || !links.length ? ( - setInputValue(event.target.value)} - onEnter={handleSubmitInput} - rightComponent={ - - } + absoluteUrlSchema.safeParse(input).success} + formatInput={(input) => ({ url: input, label: '' })} + renderItem={({ + value: link, + index, + handleEdit, + handleSetPrimary, + handleDelete, + }) => ( + - ) : ( - - - )} - + hotkeyScope={hotkeyScope} + /> ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldMenuItem.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldMenuItem.tsx index 9cc08ac9f294..fbc74d34fdef 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldMenuItem.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinksFieldMenuItem.tsx @@ -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 ( - } - isIconDisplayedOnHoverOnly={!isPrimary && !isDropdownOpen} - iconButtons={[ - { - Wrapper: isHovered - ? ({ iconButton }) => ( - - {!isPrimary && ( - - )} - - - - } - /> - ) - : undefined, - Icon: - isPrimary && !isHovered - ? (StyledIconBookmark as IconComponent) - : IconDotsVertical, - accent: 'tertiary', - onClick: isHovered ? () => {} : undefined, - }, - ]} + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInput.tsx new file mode 100644 index 000000000000..b223abfcff13 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInput.tsx @@ -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 = { + 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 = ({ + items, + onPersist, + onCancel, + placeholder, + validateInput, + formatInput, + renderItem, + hotkeyScope, +}: MultiItemFieldInputProps) => { + const containerRef = useRef(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 ( + + {!!items.length && ( + <> + + {items.map((item, index) => + renderItem({ + value: item, + index, + handleEdit: () => handleEditButtonClick(index), + handleSetPrimary: () => handleSetPrimaryItem(index), + handleDelete: () => handleDeleteItem(index), + }), + )} + + + + )} + {isInputDisplayed || !items.length ? ( + setInputValue(event.target.value)} + onEnter={handleSubmitInput} + rightComponent={ + + } + /> + ) : ( + + + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldMenuItem.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldMenuItem.tsx new file mode 100644 index 000000000000..95fcf4e5a943 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldMenuItem.tsx @@ -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 = { + 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 = ({ + dropdownId, + isPrimary, + value, + onEdit, + onSetAsPrimary, + onDelete, + DisplayComponent, +}: MultiItemFieldMenuItemProps) => { + 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 ( + } + isIconDisplayedOnHoverOnly={!isPrimary && !isDropdownOpen} + iconButtons={[ + { + Wrapper: isHovered + ? ({ iconButton }) => ( + + {!isPrimary && ( + + )} + + + + } + /> + ) + : undefined, + Icon: + isPrimary && !isHovered + ? (StyledIconBookmark as IconComponent) + : IconDotsVertical, + accent: 'tertiary', + onClick: isHovered ? () => {} : undefined, + }, + ]} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts index 55a25af1c4c9..b3196f1c702f 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts @@ -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 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; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts index 7777c10c091d..367f14680570 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts @@ -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; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts index 8445a468e028..9c94d9b666a7 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts @@ -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: ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldEmails.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldEmails.ts new file mode 100644 index 000000000000..434586c2bf8d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldEmails.ts @@ -0,0 +1,9 @@ +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +import { FieldDefinition } from '../FieldDefinition'; +import { FieldEmailsMetadata, FieldMetadata } from '../FieldMetadata'; + +export const isFieldEmails = ( + field: Pick, 'type'>, +): field is FieldDefinition => + field.type === FieldMetadataType.Emails; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldEmailsValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldEmailsValue.ts new file mode 100644 index 000000000000..04fa3c9a16e2 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldEmailsValue.ts @@ -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; + +export const isFieldEmailsValue = ( + fieldValue: unknown, +): fieldValue is FieldEmailsValue => emailsSchema.safeParse(fieldValue).success; diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/getFieldButtonIcon.tsx b/packages/twenty-front/src/modules/object-record/record-field/utils/getFieldButtonIcon.tsx index d8ea93496ffe..b100872b5470 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/utils/getFieldButtonIcon.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/utils/getFieldButtonIcon.tsx @@ -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; } diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts b/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts index 1cb5296c40ad..905afb646a25 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts @@ -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}}`, ); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts index bf0b4a545de0..a60d907d685d 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts @@ -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`, diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter.ts index 159a064cf471..6d93ceba9e1d 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter.ts @@ -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'); } diff --git a/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts b/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts index 95747a055586..f9d6be3208c0 100644 --- a/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts +++ b/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts @@ -12,6 +12,9 @@ export const generateEmptyFieldValue = ( case FieldMetadataType.Text: { return ''; } + case FieldMetadataType.Emails: { + return { primaryEmail: '', additionalEmails: null }; + } case FieldMetadataType.Link: { return { label: '', diff --git a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsFieldTypeConfigs.ts b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsFieldTypeConfigs.ts index 8a190997a37f..3272ad990fa1 100644 --- a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsFieldTypeConfigs.ts +++ b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsFieldTypeConfigs.ts @@ -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, diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx index d0e6604d143b..65f2505bc47c 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx @@ -85,6 +85,7 @@ const previewableTypes = [ FieldMetadataType.Currency, FieldMetadataType.Date, FieldMetadataType.DateTime, + FieldMetadataType.Emails, FieldMetadataType.FullName, FieldMetadataType.Link, FieldMetadataType.Links, diff --git a/packages/twenty-front/src/modules/ui/field/display/components/EmailsDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/EmailsDisplay.tsx new file mode 100644 index 000000000000..8b7e67780a2a --- /dev/null +++ b/packages/twenty-front/src/modules/ui/field/display/components/EmailsDisplay.tsx @@ -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 ? ( + + {emails.map((email, index) => ( + + ))} + + ) : ( + + {emails.map((email, index) => ( + + ))} + + ); +}; diff --git a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx index faedb92ce8be..a05e522a3bfc 100644 --- a/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/SettingsObjectFieldEdit.tsx @@ -216,7 +216,10 @@ export const SettingsObjectFieldEdit = () => { { FieldMetadataType.Numeric, FieldMetadataType.RichText, FieldMetadataType.Actor, + FieldMetadataType.Email, ] as const ).filter(isDefined); diff --git a/packages/twenty-server/src/engine/api/__mocks__/object-metadata-item.mock.ts b/packages/twenty-server/src/engine/api/__mocks__/object-metadata-item.mock.ts index 1a20e09c9c55..ab348dd176f8 100644 --- a/packages/twenty-server/src/engine/api/__mocks__/object-metadata-item.mock.ts +++ b/packages/twenty-server/src/engine/api/__mocks__/object-metadata-item.mock.ts @@ -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, diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils.ts index b82e5b05c6c6..c96998bed59f 100644 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils.ts +++ b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/map-field-metadata-to-graphql-query.utils.ts @@ -144,5 +144,13 @@ export const mapFieldMetadataToGraphqlQuery = ( name } `; + } else if (fieldType === FieldMetadataType.EMAILS) { + return ` + ${field.name} + { + primaryEmail + additionalEmails + } + `; } }; diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/components.utils.spec.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/components.utils.spec.ts index 3275b8392d01..e481a2343859 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/components.utils.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/__tests__/components.utils.spec.ts @@ -147,6 +147,17 @@ describe('computeSchemaComponents', () => { }, type: 'object', }, + fieldEmails: { + properties: { + primaryEmail: { + type: 'string', + }, + additionalEmails: { + type: 'object', + }, + }, + type: 'object', + }, }, }, 'ObjectName with Relations': { diff --git a/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts b/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts index bd7248cc0939..cc5c91065f35 100644 --- a/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts +++ b/packages/twenty-server/src/engine/core-modules/open-api/utils/components.utils.ts @@ -72,6 +72,7 @@ const getSchemaComponentsProperties = ( case FieldMetadataType.FULL_NAME: case FieldMetadataType.ADDRESS: case FieldMetadataType.ACTOR: + case FieldMetadataType.EMAILS: itemProperty = { type: 'object', properties: compositeTypeDefinitions diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/emails.composite-type.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/emails.composite-type.ts new file mode 100644 index 000000000000..3cee1f44127a --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/emails.composite-type.ts @@ -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; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/index.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/index.ts index 7f1a2f129b86..991618f8cdde 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/index.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/composite-types/index.ts @@ -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], ]); diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/default-value.input.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/default-value.input.ts index 15f565d73b9c..8a03f6b54bcf 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/default-value.input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/default-value.input.ts @@ -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; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts index 09f1fad3314a..263790c7ca7d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts @@ -26,6 +26,7 @@ export enum FieldMetadataType { TEXT = 'TEXT', PHONE = 'PHONE', EMAIL = 'EMAIL', + EMAILS = 'EMAILS', DATE_TIME = 'DATE_TIME', DATE = 'DATE', BOOLEAN = 'BOOLEAN', diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts index 6c428c468120..ede0e64e9f79 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.service.ts @@ -143,6 +143,13 @@ export class FieldMetadataService extends TypeOrmQueryService( fieldMetadataInput, objectMetadata, diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface.ts index 82fd946614e4..d6e9e700215c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface.ts @@ -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; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/generate-default-value.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/generate-default-value.ts index b6e0b1b4be36..b031e4884675 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/generate-default-value.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/generate-default-value.ts @@ -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: "''", diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util.ts index e795d79a5abf..437310003d2c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util.ts @@ -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); }; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/validate-default-value-for-type.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/validate-default-value-for-type.util.ts index 7087cfb5c70e..ce254ffd7d7d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/validate-default-value-for-type.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/validate-default-value-for-type.util.ts @@ -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 = { diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory.ts index 2a6ed45b0fcd..dc913233c3ca 100644 --- a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory.ts +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory.ts @@ -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 { diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.factory.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.factory.ts index 8c16ab64cb0c..d495051d703d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.factory.ts +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.factory.ts @@ -97,6 +97,10 @@ export class WorkspaceMigrationFactory { ], [FieldMetadataType.LINKS, { factory: this.compositeColumnActionFactory }], [FieldMetadataType.ACTOR, { factory: this.compositeColumnActionFactory }], + [ + FieldMetadataType.EMAILS, + { factory: this.compositeColumnActionFactory }, + ], ]); }