mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-24 12:34:10 +03:00
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:
parent
c87ccfa3c7
commit
7a9a43b85c
@ -360,6 +360,7 @@ export enum FieldMetadataType {
|
||||
Date = 'DATE',
|
||||
DateTime = 'DATE_TIME',
|
||||
Email = 'EMAIL',
|
||||
Emails = 'EMAILS',
|
||||
FullName = 'FULL_NAME',
|
||||
Link = 'LINK',
|
||||
Links = 'LINKS',
|
||||
|
@ -265,6 +265,7 @@ export enum FieldMetadataType {
|
||||
Date = 'DATE',
|
||||
DateTime = 'DATE_TIME',
|
||||
Email = 'EMAIL',
|
||||
Emails = 'EMAILS',
|
||||
FullName = 'FULL_NAME',
|
||||
Link = 'LINK',
|
||||
Links = 'LINKS',
|
||||
|
@ -9,6 +9,7 @@ export const SORTABLE_FIELD_METADATA_TYPES = [
|
||||
FieldMetadataType.Select,
|
||||
FieldMetadataType.Phone,
|
||||
FieldMetadataType.Email,
|
||||
FieldMetadataType.Emails,
|
||||
FieldMetadataType.FullName,
|
||||
FieldMetadataType.Rating,
|
||||
FieldMetadataType.Currency,
|
||||
|
@ -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:
|
||||
|
@ -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 [
|
||||
{
|
||||
|
@ -156,5 +156,13 @@ ${mapObjectMetadataToGraphQLQuery({
|
||||
}`;
|
||||
}
|
||||
|
||||
if (fieldType === FieldMetadataType.Emails) {
|
||||
return `${field.name}
|
||||
{
|
||||
primaryEmail
|
||||
additionalEmails
|
||||
}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
@ -94,6 +94,10 @@ export type ActorFilter = {
|
||||
name?: StringFilter;
|
||||
};
|
||||
|
||||
export type EmailsFilter = {
|
||||
primaryEmail?: StringFilter;
|
||||
};
|
||||
|
||||
export type LeafFilter =
|
||||
| UUIDFilter
|
||||
| StringFilter
|
||||
|
@ -60,6 +60,7 @@ export const MultipleFiltersDropdownContent = ({
|
||||
{[
|
||||
'TEXT',
|
||||
'EMAIL',
|
||||
'EMAILS',
|
||||
'PHONE',
|
||||
'FULL_NAME',
|
||||
'LINK',
|
||||
|
@ -2,6 +2,7 @@ export type FilterType =
|
||||
| 'TEXT'
|
||||
| 'PHONE'
|
||||
| 'EMAIL'
|
||||
| 'EMAILS'
|
||||
| 'DATE_TIME'
|
||||
| 'DATE'
|
||||
| 'NUMBER'
|
||||
|
@ -15,6 +15,7 @@ export const getOperandsForFilterType = (
|
||||
switch (filterType) {
|
||||
case 'TEXT':
|
||||
case 'EMAIL':
|
||||
case 'EMAILS':
|
||||
case 'FULL_NAME':
|
||||
case 'ADDRESS':
|
||||
case 'PHONE':
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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}
|
||||
|
@ -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 ||
|
||||
|
@ -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} />;
|
||||
};
|
@ -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,
|
||||
};
|
||||
};
|
@ -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,
|
||||
};
|
||||
};
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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) => (
|
||||
<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={label}
|
||||
onEdit={() => handleEditButtonClick(index)}
|
||||
onSetAsPrimary={() => handleSetPrimaryLink(index)}
|
||||
onDelete={() => handleDeleteLink(index)}
|
||||
url={url}
|
||||
label={link.label}
|
||||
onEdit={handleEdit}
|
||||
onSetAsPrimary={handleSetPrimary}
|
||||
onDelete={handleDelete}
|
||||
url={link.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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<DropdownMenuItemsContainer>
|
||||
<MenuItem
|
||||
onClick={handleAddButtonClick}
|
||||
LeftIcon={IconPlus}
|
||||
text="Add link"
|
||||
/>
|
||||
</DropdownMenuItemsContainer>
|
||||
)}
|
||||
</StyledDropdownMenu>
|
||||
);
|
||||
};
|
||||
|
@ -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
|
||||
<MultiItemFieldMenuItem
|
||||
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,
|
||||
},
|
||||
]}
|
||||
isPrimary={isPrimary}
|
||||
value={{ label, url }}
|
||||
onEdit={onEdit}
|
||||
onSetAsPrimary={onSetAsPrimary}
|
||||
onDelete={onDelete}
|
||||
DisplayComponent={LinkDisplay}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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,6 +77,8 @@ export type FieldInputDraftValue<FieldValue> = FieldValue extends FieldTextValue
|
||||
? FieldPhoneDraftValue
|
||||
: FieldValue extends FieldEmailValue
|
||||
? FieldEmailDraftValue
|
||||
: FieldValue extends FieldEmailsValue
|
||||
? FieldEmailsDraftValue
|
||||
: FieldValue extends FieldLinkValue
|
||||
? FieldLinkDraftValue
|
||||
: FieldValue extends FieldLinksValue
|
||||
|
@ -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;
|
||||
|
@ -9,6 +9,7 @@ import {
|
||||
FieldDateMetadata,
|
||||
FieldDateTimeMetadata,
|
||||
FieldEmailMetadata,
|
||||
FieldEmailsMetadata,
|
||||
FieldFullNameMetadata,
|
||||
FieldLinkMetadata,
|
||||
FieldLinksMetadata,
|
||||
@ -38,6 +39,8 @@ type AssertFieldMetadataFunction = <
|
||||
? FieldDateMetadata
|
||||
: E extends 'EMAIL'
|
||||
? FieldEmailMetadata
|
||||
: E extends 'EMAILS'
|
||||
? FieldEmailsMetadata
|
||||
: E extends 'SELECT'
|
||||
? FieldSelectMetadata
|
||||
: E extends 'MULTI_SELECT'
|
||||
|
@ -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;
|
@ -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;
|
@ -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;
|
||||
}
|
||||
|
@ -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}}`,
|
||||
);
|
||||
|
@ -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`,
|
||||
|
@ -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');
|
||||
}
|
||||
|
@ -12,6 +12,9 @@ export const generateEmptyFieldValue = (
|
||||
case FieldMetadataType.Text: {
|
||||
return '';
|
||||
}
|
||||
case FieldMetadataType.Emails: {
|
||||
return { primaryEmail: '', additionalEmails: null };
|
||||
}
|
||||
case FieldMetadataType.Link: {
|
||||
return {
|
||||
label: '',
|
||||
|
@ -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,
|
||||
|
@ -85,6 +85,7 @@ const previewableTypes = [
|
||||
FieldMetadataType.Currency,
|
||||
FieldMetadataType.Date,
|
||||
FieldMetadataType.DateTime,
|
||||
FieldMetadataType.Emails,
|
||||
FieldMetadataType.FullName,
|
||||
FieldMetadataType.Link,
|
||||
FieldMetadataType.Links,
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -216,7 +216,10 @@ export const SettingsObjectFieldEdit = () => {
|
||||
<StyledSettingsObjectFieldTypeSelect
|
||||
disabled
|
||||
fieldMetadataItem={activeMetadataField}
|
||||
excludedFieldTypes={[FieldMetadataType.Link]}
|
||||
excludedFieldTypes={[
|
||||
FieldMetadataType.Link,
|
||||
FieldMetadataType.Email,
|
||||
]}
|
||||
/>
|
||||
<SettingsDataModelFieldSettingsFormCard
|
||||
disableCurrencyForm
|
||||
|
@ -167,6 +167,7 @@ export const SettingsObjectNewFieldStep2 = () => {
|
||||
FieldMetadataType.Numeric,
|
||||
FieldMetadataType.RichText,
|
||||
FieldMetadataType.Actor,
|
||||
FieldMetadataType.Email,
|
||||
] as const
|
||||
).filter(isDefined);
|
||||
|
||||
|
@ -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,
|
||||
|
@ -144,5 +144,13 @@ export const mapFieldMetadataToGraphqlQuery = (
|
||||
name
|
||||
}
|
||||
`;
|
||||
} else if (fieldType === FieldMetadataType.EMAILS) {
|
||||
return `
|
||||
${field.name}
|
||||
{
|
||||
primaryEmail
|
||||
additionalEmails
|
||||
}
|
||||
`;
|
||||
}
|
||||
};
|
||||
|
@ -147,6 +147,17 @@ describe('computeSchemaComponents', () => {
|
||||
},
|
||||
type: 'object',
|
||||
},
|
||||
fieldEmails: {
|
||||
properties: {
|
||||
primaryEmail: {
|
||||
type: 'string',
|
||||
},
|
||||
additionalEmails: {
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
},
|
||||
'ObjectName with Relations': {
|
||||
|
@ -72,6 +72,7 @@ const getSchemaComponentsProperties = (
|
||||
case FieldMetadataType.FULL_NAME:
|
||||
case FieldMetadataType.ADDRESS:
|
||||
case FieldMetadataType.ACTOR:
|
||||
case FieldMetadataType.EMAILS:
|
||||
itemProperty = {
|
||||
type: 'object',
|
||||
properties: compositeTypeDefinitions
|
||||
|
@ -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;
|
||||
};
|
@ -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],
|
||||
]);
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -26,6 +26,7 @@ export enum FieldMetadataType {
|
||||
TEXT = 'TEXT',
|
||||
PHONE = 'PHONE',
|
||||
EMAIL = 'EMAIL',
|
||||
EMAILS = 'EMAILS',
|
||||
DATE_TIME = 'DATE_TIME',
|
||||
DATE = 'DATE',
|
||||
BOOLEAN = 'BOOLEAN',
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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: "''",
|
||||
|
@ -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);
|
||||
};
|
||||
|
@ -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 = {
|
||||
|
@ -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> {
|
||||
|
@ -97,6 +97,10 @@ export class WorkspaceMigrationFactory {
|
||||
],
|
||||
[FieldMetadataType.LINKS, { factory: this.compositeColumnActionFactory }],
|
||||
[FieldMetadataType.ACTOR, { factory: this.compositeColumnActionFactory }],
|
||||
[
|
||||
FieldMetadataType.EMAILS,
|
||||
{ factory: this.compositeColumnActionFactory },
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user