mirror of
https://github.com/twentyhq/twenty.git
synced 2024-12-24 20:42:05 +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',
|
Date = 'DATE',
|
||||||
DateTime = 'DATE_TIME',
|
DateTime = 'DATE_TIME',
|
||||||
Email = 'EMAIL',
|
Email = 'EMAIL',
|
||||||
|
Emails = 'EMAILS',
|
||||||
FullName = 'FULL_NAME',
|
FullName = 'FULL_NAME',
|
||||||
Link = 'LINK',
|
Link = 'LINK',
|
||||||
Links = 'LINKS',
|
Links = 'LINKS',
|
||||||
|
@ -265,6 +265,7 @@ export enum FieldMetadataType {
|
|||||||
Date = 'DATE',
|
Date = 'DATE',
|
||||||
DateTime = 'DATE_TIME',
|
DateTime = 'DATE_TIME',
|
||||||
Email = 'EMAIL',
|
Email = 'EMAIL',
|
||||||
|
Emails = 'EMAILS',
|
||||||
FullName = 'FULL_NAME',
|
FullName = 'FULL_NAME',
|
||||||
Link = 'LINK',
|
Link = 'LINK',
|
||||||
Links = 'LINKS',
|
Links = 'LINKS',
|
||||||
|
@ -9,6 +9,7 @@ export const SORTABLE_FIELD_METADATA_TYPES = [
|
|||||||
FieldMetadataType.Select,
|
FieldMetadataType.Select,
|
||||||
FieldMetadataType.Phone,
|
FieldMetadataType.Phone,
|
||||||
FieldMetadataType.Email,
|
FieldMetadataType.Email,
|
||||||
|
FieldMetadataType.Emails,
|
||||||
FieldMetadataType.FullName,
|
FieldMetadataType.FullName,
|
||||||
FieldMetadataType.Rating,
|
FieldMetadataType.Rating,
|
||||||
FieldMetadataType.Currency,
|
FieldMetadataType.Currency,
|
||||||
|
@ -26,6 +26,7 @@ export const formatFieldMetadataItemsAsFilterDefinitions = ({
|
|||||||
FieldMetadataType.DateTime,
|
FieldMetadataType.DateTime,
|
||||||
FieldMetadataType.Text,
|
FieldMetadataType.Text,
|
||||||
FieldMetadataType.Email,
|
FieldMetadataType.Email,
|
||||||
|
FieldMetadataType.Emails,
|
||||||
FieldMetadataType.Number,
|
FieldMetadataType.Number,
|
||||||
FieldMetadataType.Link,
|
FieldMetadataType.Link,
|
||||||
FieldMetadataType.Links,
|
FieldMetadataType.Links,
|
||||||
@ -77,6 +78,8 @@ export const getFilterTypeFromFieldType = (fieldType: FieldMetadataType) => {
|
|||||||
return 'CURRENCY';
|
return 'CURRENCY';
|
||||||
case FieldMetadataType.Email:
|
case FieldMetadataType.Email:
|
||||||
return 'EMAIL';
|
return 'EMAIL';
|
||||||
|
case FieldMetadataType.Emails:
|
||||||
|
return 'EMAILS';
|
||||||
case FieldMetadataType.Phone:
|
case FieldMetadataType.Phone:
|
||||||
return 'PHONE';
|
return 'PHONE';
|
||||||
case FieldMetadataType.Relation:
|
case FieldMetadataType.Relation:
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||||
|
|
||||||
import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy';
|
import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy';
|
||||||
import { FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata';
|
import {
|
||||||
|
FieldEmailsValue,
|
||||||
|
FieldLinksValue,
|
||||||
|
} from '@/object-record/record-field/types/FieldMetadata';
|
||||||
import { OrderBy } from '@/types/OrderBy';
|
import { OrderBy } from '@/types/OrderBy';
|
||||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||||
|
|
||||||
@ -43,6 +46,14 @@ export const getOrderByForFieldMetadataType = (
|
|||||||
} satisfies { [key in keyof FieldLinksValue]?: OrderBy },
|
} satisfies { [key in keyof FieldLinksValue]?: OrderBy },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
case FieldMetadataType.Emails:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
[field.name]: {
|
||||||
|
primaryEmail: direction ?? 'AscNullsLast',
|
||||||
|
} satisfies { [key in keyof FieldEmailsValue]?: OrderBy },
|
||||||
|
},
|
||||||
|
];
|
||||||
default:
|
default:
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
@ -156,5 +156,13 @@ ${mapObjectMetadataToGraphQLQuery({
|
|||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fieldType === FieldMetadataType.Emails) {
|
||||||
|
return `${field.name}
|
||||||
|
{
|
||||||
|
primaryEmail
|
||||||
|
additionalEmails
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
@ -94,6 +94,10 @@ export type ActorFilter = {
|
|||||||
name?: StringFilter;
|
name?: StringFilter;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type EmailsFilter = {
|
||||||
|
primaryEmail?: StringFilter;
|
||||||
|
};
|
||||||
|
|
||||||
export type LeafFilter =
|
export type LeafFilter =
|
||||||
| UUIDFilter
|
| UUIDFilter
|
||||||
| StringFilter
|
| StringFilter
|
||||||
|
@ -60,6 +60,7 @@ export const MultipleFiltersDropdownContent = ({
|
|||||||
{[
|
{[
|
||||||
'TEXT',
|
'TEXT',
|
||||||
'EMAIL',
|
'EMAIL',
|
||||||
|
'EMAILS',
|
||||||
'PHONE',
|
'PHONE',
|
||||||
'FULL_NAME',
|
'FULL_NAME',
|
||||||
'LINK',
|
'LINK',
|
||||||
|
@ -2,6 +2,7 @@ export type FilterType =
|
|||||||
| 'TEXT'
|
| 'TEXT'
|
||||||
| 'PHONE'
|
| 'PHONE'
|
||||||
| 'EMAIL'
|
| 'EMAIL'
|
||||||
|
| 'EMAILS'
|
||||||
| 'DATE_TIME'
|
| 'DATE_TIME'
|
||||||
| 'DATE'
|
| 'DATE'
|
||||||
| 'NUMBER'
|
| 'NUMBER'
|
||||||
|
@ -15,6 +15,7 @@ export const getOperandsForFilterType = (
|
|||||||
switch (filterType) {
|
switch (filterType) {
|
||||||
case 'TEXT':
|
case 'TEXT':
|
||||||
case 'EMAIL':
|
case 'EMAIL':
|
||||||
|
case 'EMAILS':
|
||||||
case 'FULL_NAME':
|
case 'FULL_NAME':
|
||||||
case 'ADDRESS':
|
case 'ADDRESS':
|
||||||
case 'PHONE':
|
case 'PHONE':
|
||||||
|
@ -2,6 +2,7 @@ import { useContext } from 'react';
|
|||||||
|
|
||||||
import { ActorFieldDisplay } from '@/object-record/record-field/meta-types/display/components/ActorFieldDisplay';
|
import { ActorFieldDisplay } from '@/object-record/record-field/meta-types/display/components/ActorFieldDisplay';
|
||||||
import { BooleanFieldDisplay } from '@/object-record/record-field/meta-types/display/components/BooleanFieldDisplay';
|
import { BooleanFieldDisplay } from '@/object-record/record-field/meta-types/display/components/BooleanFieldDisplay';
|
||||||
|
import { EmailsFieldDisplay } from '@/object-record/record-field/meta-types/display/components/EmailsFieldDisplay';
|
||||||
import { LinksFieldDisplay } from '@/object-record/record-field/meta-types/display/components/LinksFieldDisplay';
|
import { LinksFieldDisplay } from '@/object-record/record-field/meta-types/display/components/LinksFieldDisplay';
|
||||||
import { RatingFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RatingFieldDisplay';
|
import { RatingFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RatingFieldDisplay';
|
||||||
import { RelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay';
|
import { RelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay';
|
||||||
@ -10,6 +11,7 @@ import { isFieldIdentifierDisplay } from '@/object-record/record-field/meta-type
|
|||||||
import { isFieldActor } from '@/object-record/record-field/types/guards/isFieldActor';
|
import { isFieldActor } from '@/object-record/record-field/types/guards/isFieldActor';
|
||||||
import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean';
|
import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean';
|
||||||
import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone';
|
import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone';
|
||||||
|
import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails';
|
||||||
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
|
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
|
||||||
import { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating';
|
import { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating';
|
||||||
import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects';
|
import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects';
|
||||||
@ -100,5 +102,7 @@ export const FieldDisplay = () => {
|
|||||||
<RichTextFieldDisplay />
|
<RichTextFieldDisplay />
|
||||||
) : isFieldActor(fieldDefinition) ? (
|
) : isFieldActor(fieldDefinition) ? (
|
||||||
<ActorFieldDisplay />
|
<ActorFieldDisplay />
|
||||||
|
) : isFieldEmails(fieldDefinition) ? (
|
||||||
|
<EmailsFieldDisplay />
|
||||||
) : null;
|
) : null;
|
||||||
};
|
};
|
||||||
|
@ -2,6 +2,7 @@ import { useContext } from 'react';
|
|||||||
|
|
||||||
import { AddressFieldInput } from '@/object-record/record-field/meta-types/input/components/AddressFieldInput';
|
import { AddressFieldInput } from '@/object-record/record-field/meta-types/input/components/AddressFieldInput';
|
||||||
import { DateFieldInput } from '@/object-record/record-field/meta-types/input/components/DateFieldInput';
|
import { DateFieldInput } from '@/object-record/record-field/meta-types/input/components/DateFieldInput';
|
||||||
|
import { EmailsFieldInput } from '@/object-record/record-field/meta-types/input/components/EmailsFieldInput';
|
||||||
import { FullNameFieldInput } from '@/object-record/record-field/meta-types/input/components/FullNameFieldInput';
|
import { FullNameFieldInput } from '@/object-record/record-field/meta-types/input/components/FullNameFieldInput';
|
||||||
import { LinksFieldInput } from '@/object-record/record-field/meta-types/input/components/LinksFieldInput';
|
import { LinksFieldInput } from '@/object-record/record-field/meta-types/input/components/LinksFieldInput';
|
||||||
import { MultiSelectFieldInput } from '@/object-record/record-field/meta-types/input/components/MultiSelectFieldInput';
|
import { MultiSelectFieldInput } from '@/object-record/record-field/meta-types/input/components/MultiSelectFieldInput';
|
||||||
@ -11,6 +12,7 @@ import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/
|
|||||||
import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope';
|
import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope';
|
||||||
import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
|
import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
|
||||||
import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone';
|
import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone';
|
||||||
|
import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails';
|
||||||
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
|
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
|
||||||
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
|
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
|
||||||
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
|
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
|
||||||
@ -103,6 +105,8 @@ export const FieldInput = ({
|
|||||||
onTab={onTab}
|
onTab={onTab}
|
||||||
onShiftTab={onShiftTab}
|
onShiftTab={onShiftTab}
|
||||||
/>
|
/>
|
||||||
|
) : isFieldEmails(fieldDefinition) ? (
|
||||||
|
<EmailsFieldInput onCancel={onCancel} />
|
||||||
) : isFieldFullName(fieldDefinition) ? (
|
) : isFieldFullName(fieldDefinition) ? (
|
||||||
<FullNameFieldInput
|
<FullNameFieldInput
|
||||||
onEnter={onEnter}
|
onEnter={onEnter}
|
||||||
|
@ -7,6 +7,8 @@ import { isFieldAddress } from '@/object-record/record-field/types/guards/isFiel
|
|||||||
import { isFieldAddressValue } from '@/object-record/record-field/types/guards/isFieldAddressValue';
|
import { isFieldAddressValue } from '@/object-record/record-field/types/guards/isFieldAddressValue';
|
||||||
import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
|
import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
|
||||||
import { isFieldDateValue } from '@/object-record/record-field/types/guards/isFieldDateValue';
|
import { isFieldDateValue } from '@/object-record/record-field/types/guards/isFieldDateValue';
|
||||||
|
import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails';
|
||||||
|
import { isFieldEmailsValue } from '@/object-record/record-field/types/guards/isFieldEmailsValue';
|
||||||
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
|
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
|
||||||
import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue';
|
import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue';
|
||||||
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
|
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
|
||||||
@ -65,6 +67,9 @@ export const usePersistField = () => {
|
|||||||
const fieldIsEmail =
|
const fieldIsEmail =
|
||||||
isFieldEmail(fieldDefinition) && isFieldEmailValue(valueToPersist);
|
isFieldEmail(fieldDefinition) && isFieldEmailValue(valueToPersist);
|
||||||
|
|
||||||
|
const fieldIsEmails =
|
||||||
|
isFieldEmails(fieldDefinition) && isFieldEmailsValue(valueToPersist);
|
||||||
|
|
||||||
const fieldIsDateTime =
|
const fieldIsDateTime =
|
||||||
isFieldDateTime(fieldDefinition) &&
|
isFieldDateTime(fieldDefinition) &&
|
||||||
isFieldDateTimeValue(valueToPersist);
|
isFieldDateTimeValue(valueToPersist);
|
||||||
@ -119,6 +124,7 @@ export const usePersistField = () => {
|
|||||||
fieldIsText ||
|
fieldIsText ||
|
||||||
fieldIsBoolean ||
|
fieldIsBoolean ||
|
||||||
fieldIsEmail ||
|
fieldIsEmail ||
|
||||||
|
fieldIsEmails ||
|
||||||
fieldIsRating ||
|
fieldIsRating ||
|
||||||
fieldIsNumber ||
|
fieldIsNumber ||
|
||||||
fieldIsDateTime ||
|
fieldIsDateTime ||
|
||||||
|
@ -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 { useLinksField } from '@/object-record/record-field/meta-types/hooks/useLinksField';
|
||||||
import { LinksFieldMenuItem } from '@/object-record/record-field/meta-types/input/components/LinksFieldMenuItem';
|
import { LinksFieldMenuItem } from '@/object-record/record-field/meta-types/input/components/LinksFieldMenuItem';
|
||||||
import { LightIconButton } from '@/ui/input/button/components/LightIconButton';
|
import { useMemo } from 'react';
|
||||||
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
|
import { isDefined } from 'twenty-ui';
|
||||||
import { DropdownMenuInput } from '@/ui/layout/dropdown/components/DropdownMenuInput';
|
|
||||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
|
||||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
|
||||||
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
|
|
||||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
|
||||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
|
||||||
import { moveArrayItem } from '~/utils/array/moveArrayItem';
|
|
||||||
import { toSpliced } from '~/utils/array/toSpliced';
|
|
||||||
import { isDefined } from '~/utils/isDefined';
|
|
||||||
import { absoluteUrlSchema } from '~/utils/validation-schemas/absoluteUrlSchema';
|
import { absoluteUrlSchema } from '~/utils/validation-schemas/absoluteUrlSchema';
|
||||||
|
import { MultiItemFieldInput } from './MultiItemFieldInput';
|
||||||
const StyledDropdownMenu = styled(DropdownMenu)`
|
|
||||||
left: -1px;
|
|
||||||
position: absolute;
|
|
||||||
top: -1px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
type LinksFieldInputProps = {
|
type LinksFieldInputProps = {
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
@ -31,8 +12,6 @@ type LinksFieldInputProps = {
|
|||||||
export const LinksFieldInput = ({ onCancel }: LinksFieldInputProps) => {
|
export const LinksFieldInput = ({ onCancel }: LinksFieldInputProps) => {
|
||||||
const { persistLinksField, hotkeyScope, fieldValue } = useLinksField();
|
const { persistLinksField, hotkeyScope, fieldValue } = useLinksField();
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const links = useMemo<{ url: string; label: string }[]>(
|
const links = useMemo<{ url: string; label: string }[]>(
|
||||||
() =>
|
() =>
|
||||||
[
|
[
|
||||||
@ -51,158 +30,44 @@ export const LinksFieldInput = ({ onCancel }: LinksFieldInputProps) => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDropdownClose = () => {
|
const handlePersistLinks = (
|
||||||
onCancel?.();
|
updatedLinks: { url: string; label: string }[],
|
||||||
};
|
) => {
|
||||||
|
const [nextPrimaryLink, ...nextSecondaryLinks] = updatedLinks;
|
||||||
useListenClickOutside({
|
|
||||||
refs: [containerRef],
|
|
||||||
callback: handleDropdownClose,
|
|
||||||
});
|
|
||||||
|
|
||||||
useScopedHotkeys(Key.Escape, handleDropdownClose, hotkeyScope);
|
|
||||||
|
|
||||||
const [isInputDisplayed, setIsInputDisplayed] = useState(false);
|
|
||||||
const [inputValue, setInputValue] = useState('');
|
|
||||||
const [linkToEditIndex, setLinkToEditIndex] = useState(-1);
|
|
||||||
const isAddingNewLink = linkToEditIndex === -1;
|
|
||||||
|
|
||||||
const handleAddButtonClick = () => {
|
|
||||||
setLinkToEditIndex(-1);
|
|
||||||
setIsInputDisplayed(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditButtonClick = (index: number) => {
|
|
||||||
setLinkToEditIndex(index);
|
|
||||||
setInputValue(links[index].url);
|
|
||||||
setIsInputDisplayed(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const urlInputValidation = inputValue
|
|
||||||
? absoluteUrlSchema.safeParse(inputValue)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const handleSubmitInput = () => {
|
|
||||||
if (!urlInputValidation?.success) return;
|
|
||||||
|
|
||||||
const validatedInputValue = urlInputValidation.data;
|
|
||||||
|
|
||||||
// Don't persist if value hasn't changed.
|
|
||||||
if (
|
|
||||||
!isAddingNewLink &&
|
|
||||||
validatedInputValue === links[linkToEditIndex].url
|
|
||||||
) {
|
|
||||||
setIsInputDisplayed(false);
|
|
||||||
setInputValue('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const linkValue = { label: '', url: validatedInputValue };
|
|
||||||
const nextLinks = isAddingNewLink
|
|
||||||
? [...links, linkValue]
|
|
||||||
: toSpliced(links, linkToEditIndex, 1, linkValue);
|
|
||||||
const [nextPrimaryLink, ...nextSecondaryLinks] = nextLinks;
|
|
||||||
|
|
||||||
persistLinksField({
|
persistLinksField({
|
||||||
primaryLinkUrl: nextPrimaryLink.url ?? '',
|
primaryLinkUrl: nextPrimaryLink?.url ?? '',
|
||||||
primaryLinkLabel: nextPrimaryLink.label ?? '',
|
primaryLinkLabel: nextPrimaryLink?.label ?? '',
|
||||||
secondaryLinks: nextSecondaryLinks,
|
secondaryLinks: nextSecondaryLinks,
|
||||||
});
|
});
|
||||||
setIsInputDisplayed(false);
|
|
||||||
setInputValue('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSetPrimaryLink = (index: number) => {
|
|
||||||
const nextLinks = moveArrayItem(links, { fromIndex: index, toIndex: 0 });
|
|
||||||
const [nextPrimaryLink, ...nextSecondaryLinks] = nextLinks;
|
|
||||||
|
|
||||||
persistLinksField({
|
|
||||||
primaryLinkUrl: nextPrimaryLink.url ?? '',
|
|
||||||
primaryLinkLabel: nextPrimaryLink.label ?? '',
|
|
||||||
secondaryLinks: nextSecondaryLinks,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteLink = (index: number) => {
|
|
||||||
const hasOnlyOneLastLink = links.length === 1;
|
|
||||||
|
|
||||||
if (hasOnlyOneLastLink) {
|
|
||||||
persistLinksField({
|
|
||||||
primaryLinkUrl: '',
|
|
||||||
primaryLinkLabel: '',
|
|
||||||
secondaryLinks: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
handleDropdownClose();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isRemovingPrimary = index === 0;
|
|
||||||
if (isRemovingPrimary) {
|
|
||||||
const [, nextPrimaryLink, ...nextSecondaryLinks] = links;
|
|
||||||
|
|
||||||
persistLinksField({
|
|
||||||
primaryLinkUrl: nextPrimaryLink.url ?? '',
|
|
||||||
primaryLinkLabel: nextPrimaryLink.label ?? '',
|
|
||||||
secondaryLinks: nextSecondaryLinks,
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
persistLinksField({
|
|
||||||
...fieldValue,
|
|
||||||
secondaryLinks: toSpliced(fieldValue.secondaryLinks ?? [], index - 1, 1),
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledDropdownMenu ref={containerRef} width={200}>
|
<MultiItemFieldInput
|
||||||
{!!links.length && (
|
items={links}
|
||||||
<>
|
onPersist={handlePersistLinks}
|
||||||
<DropdownMenuItemsContainer>
|
onCancel={onCancel}
|
||||||
{links.map(({ label, url }, index) => (
|
placeholder="URL"
|
||||||
|
validateInput={(input) => absoluteUrlSchema.safeParse(input).success}
|
||||||
|
formatInput={(input) => ({ url: input, label: '' })}
|
||||||
|
renderItem={({
|
||||||
|
value: link,
|
||||||
|
index,
|
||||||
|
handleEdit,
|
||||||
|
handleSetPrimary,
|
||||||
|
handleDelete,
|
||||||
|
}) => (
|
||||||
<LinksFieldMenuItem
|
<LinksFieldMenuItem
|
||||||
key={index}
|
key={index}
|
||||||
dropdownId={`${hotkeyScope}-links-${index}`}
|
dropdownId={`${hotkeyScope}-links-${index}`}
|
||||||
isPrimary={index === 0}
|
isPrimary={index === 0}
|
||||||
label={label}
|
label={link.label}
|
||||||
onEdit={() => handleEditButtonClick(index)}
|
onEdit={handleEdit}
|
||||||
onSetAsPrimary={() => handleSetPrimaryLink(index)}
|
onSetAsPrimary={handleSetPrimary}
|
||||||
onDelete={() => handleDeleteLink(index)}
|
onDelete={handleDelete}
|
||||||
url={url}
|
url={link.url}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</DropdownMenuItemsContainer>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{isInputDisplayed || !links.length ? (
|
|
||||||
<DropdownMenuInput
|
|
||||||
autoFocus
|
|
||||||
placeholder="URL"
|
|
||||||
value={inputValue}
|
|
||||||
hotkeyScope={hotkeyScope}
|
hotkeyScope={hotkeyScope}
|
||||||
onChange={(event) => setInputValue(event.target.value)}
|
|
||||||
onEnter={handleSubmitInput}
|
|
||||||
rightComponent={
|
|
||||||
<LightIconButton
|
|
||||||
Icon={isAddingNewLink ? IconPlus : IconCheck}
|
|
||||||
disabled={!urlInputValidation?.success}
|
|
||||||
onClick={handleSubmitInput}
|
|
||||||
/>
|
/>
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DropdownMenuItemsContainer>
|
|
||||||
<MenuItem
|
|
||||||
onClick={handleAddButtonClick}
|
|
||||||
LeftIcon={IconPlus}
|
|
||||||
text="Add link"
|
|
||||||
/>
|
|
||||||
</DropdownMenuItemsContainer>
|
|
||||||
)}
|
|
||||||
</StyledDropdownMenu>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,19 +1,5 @@
|
|||||||
import styled from '@emotion/styled';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import {
|
|
||||||
IconBookmark,
|
|
||||||
IconBookmarkPlus,
|
|
||||||
IconComponent,
|
|
||||||
IconDotsVertical,
|
|
||||||
IconPencil,
|
|
||||||
IconTrash,
|
|
||||||
} from 'twenty-ui';
|
|
||||||
|
|
||||||
import { LinkDisplay } from '@/ui/field/display/components/LinkDisplay';
|
import { LinkDisplay } from '@/ui/field/display/components/LinkDisplay';
|
||||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
import { MultiItemFieldMenuItem } from './MultiItemFieldMenuItem';
|
||||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
|
||||||
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
|
|
||||||
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
|
|
||||||
|
|
||||||
type LinksFieldMenuItemProps = {
|
type LinksFieldMenuItemProps = {
|
||||||
dropdownId: string;
|
dropdownId: string;
|
||||||
@ -25,12 +11,6 @@ type LinksFieldMenuItemProps = {
|
|||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const StyledIconBookmark = styled(IconBookmark)`
|
|
||||||
color: ${({ theme }) => theme.font.color.light};
|
|
||||||
height: ${({ theme }) => theme.icon.size.sm}px;
|
|
||||||
width: ${({ theme }) => theme.icon.size.sm}px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const LinksFieldMenuItem = ({
|
export const LinksFieldMenuItem = ({
|
||||||
dropdownId,
|
dropdownId,
|
||||||
isPrimary,
|
isPrimary,
|
||||||
@ -40,76 +20,15 @@ export const LinksFieldMenuItem = ({
|
|||||||
onDelete,
|
onDelete,
|
||||||
url,
|
url,
|
||||||
}: LinksFieldMenuItemProps) => {
|
}: LinksFieldMenuItemProps) => {
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
|
||||||
const { isDropdownOpen, closeDropdown } = useDropdown(dropdownId);
|
|
||||||
|
|
||||||
const handleMouseEnter = () => setIsHovered(true);
|
|
||||||
const handleMouseLeave = () => setIsHovered(false);
|
|
||||||
|
|
||||||
const handleDeleteClick = () => {
|
|
||||||
setIsHovered(false);
|
|
||||||
onDelete?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Make sure dropdown closes on unmount.
|
|
||||||
useEffect(() => {
|
|
||||||
if (isDropdownOpen) {
|
|
||||||
return () => closeDropdown();
|
|
||||||
}
|
|
||||||
}, [closeDropdown, isDropdownOpen]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MultiItemFieldMenuItem
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
text={<LinkDisplay value={{ label, url }} />}
|
|
||||||
isIconDisplayedOnHoverOnly={!isPrimary && !isDropdownOpen}
|
|
||||||
iconButtons={[
|
|
||||||
{
|
|
||||||
Wrapper: isHovered
|
|
||||||
? ({ iconButton }) => (
|
|
||||||
<Dropdown
|
|
||||||
dropdownId={dropdownId}
|
dropdownId={dropdownId}
|
||||||
dropdownHotkeyScope={{
|
isPrimary={isPrimary}
|
||||||
scope: dropdownId,
|
value={{ label, url }}
|
||||||
}}
|
onEdit={onEdit}
|
||||||
dropdownPlacement="right-start"
|
onSetAsPrimary={onSetAsPrimary}
|
||||||
dropdownStrategy="fixed"
|
onDelete={onDelete}
|
||||||
disableBlur
|
DisplayComponent={LinkDisplay}
|
||||||
clickableComponent={iconButton}
|
|
||||||
dropdownComponents={
|
|
||||||
<DropdownMenuItemsContainer>
|
|
||||||
{!isPrimary && (
|
|
||||||
<MenuItem
|
|
||||||
LeftIcon={IconBookmarkPlus}
|
|
||||||
text="Set as Primary"
|
|
||||||
onClick={onSetAsPrimary}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<MenuItem
|
|
||||||
LeftIcon={IconPencil}
|
|
||||||
text="Edit"
|
|
||||||
onClick={onEdit}
|
|
||||||
/>
|
|
||||||
<MenuItem
|
|
||||||
accent="danger"
|
|
||||||
LeftIcon={IconTrash}
|
|
||||||
text="Delete"
|
|
||||||
onClick={handleDeleteClick}
|
|
||||||
/>
|
|
||||||
</DropdownMenuItemsContainer>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
: undefined,
|
|
||||||
Icon:
|
|
||||||
isPrimary && !isHovered
|
|
||||||
? (StyledIconBookmark as IconComponent)
|
|
||||||
: IconDotsVertical,
|
|
||||||
accent: 'tertiary',
|
|
||||||
onClick: isHovered ? () => {} : undefined,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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,
|
FieldBooleanValue,
|
||||||
FieldCurrencyValue,
|
FieldCurrencyValue,
|
||||||
FieldDateTimeValue,
|
FieldDateTimeValue,
|
||||||
|
FieldEmailsValue,
|
||||||
FieldEmailValue,
|
FieldEmailValue,
|
||||||
FieldFullNameValue,
|
FieldFullNameValue,
|
||||||
FieldJsonValue,
|
FieldJsonValue,
|
||||||
@ -26,6 +27,10 @@ export type FieldNumberDraftValue = string;
|
|||||||
export type FieldDateTimeDraftValue = string;
|
export type FieldDateTimeDraftValue = string;
|
||||||
export type FieldPhoneDraftValue = string;
|
export type FieldPhoneDraftValue = string;
|
||||||
export type FieldEmailDraftValue = string;
|
export type FieldEmailDraftValue = string;
|
||||||
|
export type FieldEmailsDraftValue = {
|
||||||
|
primaryEmail: string;
|
||||||
|
additionalEmails: string[] | null;
|
||||||
|
};
|
||||||
export type FieldSelectDraftValue = string;
|
export type FieldSelectDraftValue = string;
|
||||||
export type FieldMultiSelectDraftValue = string[];
|
export type FieldMultiSelectDraftValue = string[];
|
||||||
export type FieldRelationDraftValue = string;
|
export type FieldRelationDraftValue = string;
|
||||||
@ -72,6 +77,8 @@ export type FieldInputDraftValue<FieldValue> = FieldValue extends FieldTextValue
|
|||||||
? FieldPhoneDraftValue
|
? FieldPhoneDraftValue
|
||||||
: FieldValue extends FieldEmailValue
|
: FieldValue extends FieldEmailValue
|
||||||
? FieldEmailDraftValue
|
? FieldEmailDraftValue
|
||||||
|
: FieldValue extends FieldEmailsValue
|
||||||
|
? FieldEmailsDraftValue
|
||||||
: FieldValue extends FieldLinkValue
|
: FieldValue extends FieldLinkValue
|
||||||
? FieldLinkDraftValue
|
? FieldLinkDraftValue
|
||||||
: FieldValue extends FieldLinksValue
|
: FieldValue extends FieldLinksValue
|
||||||
|
@ -72,6 +72,11 @@ export type FieldEmailMetadata = {
|
|||||||
fieldName: string;
|
fieldName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FieldEmailsMetadata = {
|
||||||
|
objectMetadataNameSingular?: string;
|
||||||
|
fieldName: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type FieldPhoneMetadata = {
|
export type FieldPhoneMetadata = {
|
||||||
objectMetadataNameSingular?: string;
|
objectMetadataNameSingular?: string;
|
||||||
placeHolder: string;
|
placeHolder: string;
|
||||||
@ -180,6 +185,10 @@ export type FieldBooleanValue = boolean;
|
|||||||
|
|
||||||
export type FieldPhoneValue = string;
|
export type FieldPhoneValue = string;
|
||||||
export type FieldEmailValue = string;
|
export type FieldEmailValue = string;
|
||||||
|
export type FieldEmailsValue = {
|
||||||
|
primaryEmail: string;
|
||||||
|
additionalEmails: string[] | null;
|
||||||
|
};
|
||||||
export type FieldLinkValue = { url: string; label: string };
|
export type FieldLinkValue = { url: string; label: string };
|
||||||
export type FieldLinksValue = {
|
export type FieldLinksValue = {
|
||||||
primaryLinkLabel: string;
|
primaryLinkLabel: string;
|
||||||
|
@ -9,6 +9,7 @@ import {
|
|||||||
FieldDateMetadata,
|
FieldDateMetadata,
|
||||||
FieldDateTimeMetadata,
|
FieldDateTimeMetadata,
|
||||||
FieldEmailMetadata,
|
FieldEmailMetadata,
|
||||||
|
FieldEmailsMetadata,
|
||||||
FieldFullNameMetadata,
|
FieldFullNameMetadata,
|
||||||
FieldLinkMetadata,
|
FieldLinkMetadata,
|
||||||
FieldLinksMetadata,
|
FieldLinksMetadata,
|
||||||
@ -38,6 +39,8 @@ type AssertFieldMetadataFunction = <
|
|||||||
? FieldDateMetadata
|
? FieldDateMetadata
|
||||||
: E extends 'EMAIL'
|
: E extends 'EMAIL'
|
||||||
? FieldEmailMetadata
|
? FieldEmailMetadata
|
||||||
|
: E extends 'EMAILS'
|
||||||
|
? FieldEmailsMetadata
|
||||||
: E extends 'SELECT'
|
: E extends 'SELECT'
|
||||||
? FieldSelectMetadata
|
? FieldSelectMetadata
|
||||||
: E extends 'MULTI_SELECT'
|
: E extends 'MULTI_SELECT'
|
||||||
|
@ -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 { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition';
|
||||||
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
|
import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata';
|
||||||
import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone';
|
import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone';
|
||||||
|
import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails';
|
||||||
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
|
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
|
||||||
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
|
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
|
||||||
import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation';
|
import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation';
|
||||||
@ -29,7 +30,8 @@ export const getFieldButtonIcon = (
|
|||||||
(isFieldRelation(fieldDefinition) &&
|
(isFieldRelation(fieldDefinition) &&
|
||||||
fieldDefinition.metadata.relationObjectMetadataNameSingular !==
|
fieldDefinition.metadata.relationObjectMetadataNameSingular !==
|
||||||
'workspaceMember') ||
|
'workspaceMember') ||
|
||||||
isFieldLinks(fieldDefinition)
|
isFieldLinks(fieldDefinition) ||
|
||||||
|
isFieldEmails(fieldDefinition)
|
||||||
) {
|
) {
|
||||||
return IconPencil;
|
return IconPencil;
|
||||||
}
|
}
|
||||||
|
@ -12,6 +12,8 @@ import { isFieldCurrencyValue } from '@/object-record/record-field/types/guards/
|
|||||||
import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
|
import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
|
||||||
import { isFieldDateTime } from '@/object-record/record-field/types/guards/isFieldDateTime';
|
import { isFieldDateTime } from '@/object-record/record-field/types/guards/isFieldDateTime';
|
||||||
import { isFieldEmail } from '@/object-record/record-field/types/guards/isFieldEmail';
|
import { isFieldEmail } from '@/object-record/record-field/types/guards/isFieldEmail';
|
||||||
|
import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails';
|
||||||
|
import { isFieldEmailsValue } from '@/object-record/record-field/types/guards/isFieldEmailsValue';
|
||||||
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
|
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
|
||||||
import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue';
|
import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue';
|
||||||
import { isFieldLink } from '@/object-record/record-field/types/guards/isFieldLink';
|
import { isFieldLink } from '@/object-record/record-field/types/guards/isFieldLink';
|
||||||
@ -120,6 +122,12 @@ export const isFieldValueEmpty = ({
|
|||||||
return !isFieldActorValue(fieldValue) || isValueEmpty(fieldValue.name);
|
return !isFieldActorValue(fieldValue) || isValueEmpty(fieldValue.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isFieldEmails(fieldDefinition)) {
|
||||||
|
return (
|
||||||
|
!isFieldEmailsValue(fieldValue) || isValueEmpty(fieldValue.primaryEmail)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Entity field type not supported in isFieldValueEmpty : ${fieldDefinition.type}}`,
|
`Entity field type not supported in isFieldValueEmpty : ${fieldDefinition.type}}`,
|
||||||
);
|
);
|
||||||
|
@ -8,6 +8,7 @@ import {
|
|||||||
BooleanFilter,
|
BooleanFilter,
|
||||||
CurrencyFilter,
|
CurrencyFilter,
|
||||||
DateFilter,
|
DateFilter,
|
||||||
|
EmailsFilter,
|
||||||
FloatFilter,
|
FloatFilter,
|
||||||
FullNameFilter,
|
FullNameFilter,
|
||||||
LinksFilter,
|
LinksFilter,
|
||||||
@ -268,6 +269,18 @@ export const isRecordMatchingFilter = ({
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
case FieldMetadataType.Emails: {
|
||||||
|
const emailsFilter = filterValue as EmailsFilter;
|
||||||
|
|
||||||
|
if (emailsFilter.primaryEmail === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isMatchingStringFilter({
|
||||||
|
stringFilter: emailsFilter.primaryEmail,
|
||||||
|
value: record[filterKey].primaryEmail,
|
||||||
|
});
|
||||||
|
}
|
||||||
case FieldMetadataType.Relation: {
|
case FieldMetadataType.Relation: {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Not implemented yet, use UUID filter instead on the corredponding "${filterKey}Id" field`,
|
`Not implemented yet, use UUID filter instead on the corredponding "${filterKey}Id" field`,
|
||||||
|
@ -5,6 +5,7 @@ import {
|
|||||||
AddressFilter,
|
AddressFilter,
|
||||||
CurrencyFilter,
|
CurrencyFilter,
|
||||||
DateFilter,
|
DateFilter,
|
||||||
|
EmailsFilter,
|
||||||
FloatFilter,
|
FloatFilter,
|
||||||
RecordGqlOperationFilter,
|
RecordGqlOperationFilter,
|
||||||
RelationFilter,
|
RelationFilter,
|
||||||
@ -229,6 +230,22 @@ const applyEmptyFilters = (
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
|
case 'EMAILS':
|
||||||
|
emptyRecordFilter = {
|
||||||
|
or: [
|
||||||
|
{
|
||||||
|
[correspondingField.name]: {
|
||||||
|
primaryEmail: { ilike: '' },
|
||||||
|
} as EmailsFilter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
[correspondingField.name]: {
|
||||||
|
primaryEmail: { is: 'NULL' },
|
||||||
|
} as EmailsFilter,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported empty filter type ${filterType}`);
|
throw new Error(`Unsupported empty filter type ${filterType}`);
|
||||||
}
|
}
|
||||||
@ -806,6 +823,51 @@ export const turnObjectDropdownFilterIntoQueryFilter = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'EMAILS':
|
||||||
|
switch (rawUIFilter.operand) {
|
||||||
|
case ViewFilterOperand.Contains:
|
||||||
|
objectRecordFilters.push({
|
||||||
|
or: [
|
||||||
|
{
|
||||||
|
[correspondingField.name]: {
|
||||||
|
primaryEmail: {
|
||||||
|
ilike: `%${rawUIFilter.value}%`,
|
||||||
|
},
|
||||||
|
} as EmailsFilter,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case ViewFilterOperand.DoesNotContain:
|
||||||
|
objectRecordFilters.push({
|
||||||
|
and: [
|
||||||
|
{
|
||||||
|
not: {
|
||||||
|
[correspondingField.name]: {
|
||||||
|
primaryEmail: {
|
||||||
|
ilike: `%${rawUIFilter.value}%`,
|
||||||
|
},
|
||||||
|
} as EmailsFilter,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case ViewFilterOperand.IsEmpty:
|
||||||
|
case ViewFilterOperand.IsNotEmpty:
|
||||||
|
applyEmptyFilters(
|
||||||
|
rawUIFilter.operand,
|
||||||
|
correspondingField,
|
||||||
|
objectRecordFilters,
|
||||||
|
rawUIFilter.definition.type,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(
|
||||||
|
`Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error('Unknown filter type');
|
throw new Error('Unknown filter type');
|
||||||
}
|
}
|
||||||
|
@ -12,6 +12,9 @@ export const generateEmptyFieldValue = (
|
|||||||
case FieldMetadataType.Text: {
|
case FieldMetadataType.Text: {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
case FieldMetadataType.Emails: {
|
||||||
|
return { primaryEmail: '', additionalEmails: null };
|
||||||
|
}
|
||||||
case FieldMetadataType.Link: {
|
case FieldMetadataType.Link: {
|
||||||
return {
|
return {
|
||||||
label: '',
|
label: '',
|
||||||
|
@ -99,6 +99,11 @@ export const SETTINGS_FIELD_TYPE_CONFIGS = {
|
|||||||
Icon: IconRelationManyToMany,
|
Icon: IconRelationManyToMany,
|
||||||
},
|
},
|
||||||
[FieldMetadataType.Email]: { label: 'Email', Icon: IconMail },
|
[FieldMetadataType.Email]: { label: 'Email', Icon: IconMail },
|
||||||
|
[FieldMetadataType.Emails]: {
|
||||||
|
label: 'Emails',
|
||||||
|
Icon: IconMail,
|
||||||
|
exampleValue: { primaryEmail: 'john@twenty.com' },
|
||||||
|
},
|
||||||
[FieldMetadataType.Phone]: {
|
[FieldMetadataType.Phone]: {
|
||||||
label: 'Phone',
|
label: 'Phone',
|
||||||
Icon: IconPhone,
|
Icon: IconPhone,
|
||||||
|
@ -85,6 +85,7 @@ const previewableTypes = [
|
|||||||
FieldMetadataType.Currency,
|
FieldMetadataType.Currency,
|
||||||
FieldMetadataType.Date,
|
FieldMetadataType.Date,
|
||||||
FieldMetadataType.DateTime,
|
FieldMetadataType.DateTime,
|
||||||
|
FieldMetadataType.Emails,
|
||||||
FieldMetadataType.FullName,
|
FieldMetadataType.FullName,
|
||||||
FieldMetadataType.Link,
|
FieldMetadataType.Link,
|
||||||
FieldMetadataType.Links,
|
FieldMetadataType.Links,
|
||||||
|
@ -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
|
<StyledSettingsObjectFieldTypeSelect
|
||||||
disabled
|
disabled
|
||||||
fieldMetadataItem={activeMetadataField}
|
fieldMetadataItem={activeMetadataField}
|
||||||
excludedFieldTypes={[FieldMetadataType.Link]}
|
excludedFieldTypes={[
|
||||||
|
FieldMetadataType.Link,
|
||||||
|
FieldMetadataType.Email,
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
<SettingsDataModelFieldSettingsFormCard
|
<SettingsDataModelFieldSettingsFormCard
|
||||||
disableCurrencyForm
|
disableCurrencyForm
|
||||||
|
@ -167,6 +167,7 @@ export const SettingsObjectNewFieldStep2 = () => {
|
|||||||
FieldMetadataType.Numeric,
|
FieldMetadataType.Numeric,
|
||||||
FieldMetadataType.RichText,
|
FieldMetadataType.RichText,
|
||||||
FieldMetadataType.Actor,
|
FieldMetadataType.Actor,
|
||||||
|
FieldMetadataType.Email,
|
||||||
] as const
|
] as const
|
||||||
).filter(isDefined);
|
).filter(isDefined);
|
||||||
|
|
||||||
|
@ -205,12 +205,19 @@ const fieldActorMock = {
|
|||||||
name: '',
|
name: '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const fieldEmailsMock = {
|
||||||
|
name: 'fieldEmails',
|
||||||
|
type: FieldMetadataType.EMAILS,
|
||||||
|
isNullable: false,
|
||||||
|
defaultValue: [{ primaryEmail: '', additionalEmails: {} }],
|
||||||
|
};
|
||||||
|
|
||||||
export const fields = [
|
export const fields = [
|
||||||
fieldUuidMock,
|
fieldUuidMock,
|
||||||
fieldTextMock,
|
fieldTextMock,
|
||||||
fieldPhoneMock,
|
fieldPhoneMock,
|
||||||
fieldEmailMock,
|
fieldEmailMock,
|
||||||
|
fieldEmailsMock,
|
||||||
fieldDateTimeMock,
|
fieldDateTimeMock,
|
||||||
fieldDateMock,
|
fieldDateMock,
|
||||||
fieldBooleanMock,
|
fieldBooleanMock,
|
||||||
|
@ -144,5 +144,13 @@ export const mapFieldMetadataToGraphqlQuery = (
|
|||||||
name
|
name
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
} else if (fieldType === FieldMetadataType.EMAILS) {
|
||||||
|
return `
|
||||||
|
${field.name}
|
||||||
|
{
|
||||||
|
primaryEmail
|
||||||
|
additionalEmails
|
||||||
|
}
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -147,6 +147,17 @@ describe('computeSchemaComponents', () => {
|
|||||||
},
|
},
|
||||||
type: 'object',
|
type: 'object',
|
||||||
},
|
},
|
||||||
|
fieldEmails: {
|
||||||
|
properties: {
|
||||||
|
primaryEmail: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
additionalEmails: {
|
||||||
|
type: 'object',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'ObjectName with Relations': {
|
'ObjectName with Relations': {
|
||||||
|
@ -72,6 +72,7 @@ const getSchemaComponentsProperties = (
|
|||||||
case FieldMetadataType.FULL_NAME:
|
case FieldMetadataType.FULL_NAME:
|
||||||
case FieldMetadataType.ADDRESS:
|
case FieldMetadataType.ADDRESS:
|
||||||
case FieldMetadataType.ACTOR:
|
case FieldMetadataType.ACTOR:
|
||||||
|
case FieldMetadataType.EMAILS:
|
||||||
itemProperty = {
|
itemProperty = {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: compositeTypeDefinitions
|
properties: compositeTypeDefinitions
|
||||||
|
@ -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 { actorCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
|
||||||
import { addressCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/address.composite-type';
|
import { addressCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/address.composite-type';
|
||||||
import { currencyCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/currency.composite-type';
|
import { currencyCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/currency.composite-type';
|
||||||
|
import { emailsCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/emails.composite-type';
|
||||||
import { fullNameCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/full-name.composite-type';
|
import { fullNameCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/full-name.composite-type';
|
||||||
import { linkCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/link.composite-type';
|
import { linkCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/link.composite-type';
|
||||||
import { linksCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type';
|
import { linksCompositeType } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type';
|
||||||
@ -23,4 +24,5 @@ export const compositeTypeDefinitions = new Map<
|
|||||||
[FieldMetadataType.FULL_NAME, fullNameCompositeType],
|
[FieldMetadataType.FULL_NAME, fullNameCompositeType],
|
||||||
[FieldMetadataType.ADDRESS, addressCompositeType],
|
[FieldMetadataType.ADDRESS, addressCompositeType],
|
||||||
[FieldMetadataType.ACTOR, actorCompositeType],
|
[FieldMetadataType.ACTOR, actorCompositeType],
|
||||||
|
[FieldMetadataType.EMAILS, emailsCompositeType],
|
||||||
]);
|
]);
|
||||||
|
@ -175,3 +175,13 @@ export class FieldMetadataDefaultActor {
|
|||||||
@IsString()
|
@IsString()
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class FieldMetadataDefaultValueEmails {
|
||||||
|
@ValidateIf((_object, value) => value !== null)
|
||||||
|
@IsQuotedString()
|
||||||
|
primaryEmail: string | null;
|
||||||
|
|
||||||
|
@ValidateIf((_object, value) => value !== null)
|
||||||
|
@IsObject()
|
||||||
|
additionalEmails: string[] | null;
|
||||||
|
}
|
||||||
|
@ -26,6 +26,7 @@ export enum FieldMetadataType {
|
|||||||
TEXT = 'TEXT',
|
TEXT = 'TEXT',
|
||||||
PHONE = 'PHONE',
|
PHONE = 'PHONE',
|
||||||
EMAIL = 'EMAIL',
|
EMAIL = 'EMAIL',
|
||||||
|
EMAILS = 'EMAILS',
|
||||||
DATE_TIME = 'DATE_TIME',
|
DATE_TIME = 'DATE_TIME',
|
||||||
DATE = 'DATE',
|
DATE = 'DATE',
|
||||||
BOOLEAN = 'BOOLEAN',
|
BOOLEAN = 'BOOLEAN',
|
||||||
|
@ -143,6 +143,13 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fieldMetadataInput.type === FieldMetadataType.EMAIL) {
|
||||||
|
throw new FieldMetadataException(
|
||||||
|
'"Email" field types are being deprecated, please use Emails type instead',
|
||||||
|
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.validateFieldMetadataInput<CreateFieldInput>(
|
this.validateFieldMetadataInput<CreateFieldInput>(
|
||||||
fieldMetadataInput,
|
fieldMetadataInput,
|
||||||
objectMetadata,
|
objectMetadata,
|
||||||
|
@ -4,6 +4,7 @@ import {
|
|||||||
FieldMetadataDefaultValueBoolean,
|
FieldMetadataDefaultValueBoolean,
|
||||||
FieldMetadataDefaultValueCurrency,
|
FieldMetadataDefaultValueCurrency,
|
||||||
FieldMetadataDefaultValueDateTime,
|
FieldMetadataDefaultValueDateTime,
|
||||||
|
FieldMetadataDefaultValueEmails,
|
||||||
FieldMetadataDefaultValueFullName,
|
FieldMetadataDefaultValueFullName,
|
||||||
FieldMetadataDefaultValueLink,
|
FieldMetadataDefaultValueLink,
|
||||||
FieldMetadataDefaultValueLinks,
|
FieldMetadataDefaultValueLinks,
|
||||||
@ -27,6 +28,7 @@ type FieldMetadataDefaultValueMapping = {
|
|||||||
[FieldMetadataType.TEXT]: FieldMetadataDefaultValueString;
|
[FieldMetadataType.TEXT]: FieldMetadataDefaultValueString;
|
||||||
[FieldMetadataType.PHONE]: FieldMetadataDefaultValueString;
|
[FieldMetadataType.PHONE]: FieldMetadataDefaultValueString;
|
||||||
[FieldMetadataType.EMAIL]: FieldMetadataDefaultValueString;
|
[FieldMetadataType.EMAIL]: FieldMetadataDefaultValueString;
|
||||||
|
[FieldMetadataType.EMAILS]: FieldMetadataDefaultValueEmails;
|
||||||
[FieldMetadataType.DATE_TIME]:
|
[FieldMetadataType.DATE_TIME]:
|
||||||
| FieldMetadataDefaultValueDateTime
|
| FieldMetadataDefaultValueDateTime
|
||||||
| FieldMetadataDefaultValueNowFunction;
|
| FieldMetadataDefaultValueNowFunction;
|
||||||
|
@ -10,6 +10,11 @@ export function generateDefaultValue(
|
|||||||
case FieldMetadataType.PHONE:
|
case FieldMetadataType.PHONE:
|
||||||
case FieldMetadataType.EMAIL:
|
case FieldMetadataType.EMAIL:
|
||||||
return "''";
|
return "''";
|
||||||
|
case FieldMetadataType.EMAILS:
|
||||||
|
return {
|
||||||
|
primaryEmail: "''",
|
||||||
|
additionalEmails: null,
|
||||||
|
};
|
||||||
case FieldMetadataType.FULL_NAME:
|
case FieldMetadataType.FULL_NAME:
|
||||||
return {
|
return {
|
||||||
firstName: "''",
|
firstName: "''",
|
||||||
|
@ -8,7 +8,8 @@ export const isCompositeFieldMetadataType = (
|
|||||||
| FieldMetadataType.FULL_NAME
|
| FieldMetadataType.FULL_NAME
|
||||||
| FieldMetadataType.ADDRESS
|
| FieldMetadataType.ADDRESS
|
||||||
| FieldMetadataType.LINKS
|
| FieldMetadataType.LINKS
|
||||||
| FieldMetadataType.ACTOR => {
|
| FieldMetadataType.ACTOR
|
||||||
|
| FieldMetadataType.EMAILS => {
|
||||||
return [
|
return [
|
||||||
FieldMetadataType.LINK,
|
FieldMetadataType.LINK,
|
||||||
FieldMetadataType.CURRENCY,
|
FieldMetadataType.CURRENCY,
|
||||||
@ -16,5 +17,6 @@ export const isCompositeFieldMetadataType = (
|
|||||||
FieldMetadataType.ADDRESS,
|
FieldMetadataType.ADDRESS,
|
||||||
FieldMetadataType.LINKS,
|
FieldMetadataType.LINKS,
|
||||||
FieldMetadataType.ACTOR,
|
FieldMetadataType.ACTOR,
|
||||||
|
FieldMetadataType.EMAILS,
|
||||||
].includes(type);
|
].includes(type);
|
||||||
};
|
};
|
||||||
|
@ -13,6 +13,7 @@ import {
|
|||||||
FieldMetadataDefaultValueCurrency,
|
FieldMetadataDefaultValueCurrency,
|
||||||
FieldMetadataDefaultValueDate,
|
FieldMetadataDefaultValueDate,
|
||||||
FieldMetadataDefaultValueDateTime,
|
FieldMetadataDefaultValueDateTime,
|
||||||
|
FieldMetadataDefaultValueEmails,
|
||||||
FieldMetadataDefaultValueFullName,
|
FieldMetadataDefaultValueFullName,
|
||||||
FieldMetadataDefaultValueLink,
|
FieldMetadataDefaultValueLink,
|
||||||
FieldMetadataDefaultValueLinks,
|
FieldMetadataDefaultValueLinks,
|
||||||
@ -53,6 +54,7 @@ export const defaultValueValidatorsMap = {
|
|||||||
[FieldMetadataType.RAW_JSON]: [FieldMetadataDefaultValueRawJson],
|
[FieldMetadataType.RAW_JSON]: [FieldMetadataDefaultValueRawJson],
|
||||||
[FieldMetadataType.LINKS]: [FieldMetadataDefaultValueLinks],
|
[FieldMetadataType.LINKS]: [FieldMetadataDefaultValueLinks],
|
||||||
[FieldMetadataType.ACTOR]: [FieldMetadataDefaultActor],
|
[FieldMetadataType.ACTOR]: [FieldMetadataDefaultActor],
|
||||||
|
[FieldMetadataType.EMAILS]: [FieldMetadataDefaultValueEmails],
|
||||||
};
|
};
|
||||||
|
|
||||||
type ValidationResult = {
|
type ValidationResult = {
|
||||||
|
@ -23,7 +23,8 @@ export type CompositeFieldMetadataType =
|
|||||||
| FieldMetadataType.CURRENCY
|
| FieldMetadataType.CURRENCY
|
||||||
| FieldMetadataType.FULL_NAME
|
| FieldMetadataType.FULL_NAME
|
||||||
| FieldMetadataType.LINK
|
| FieldMetadataType.LINK
|
||||||
| FieldMetadataType.LINKS;
|
| FieldMetadataType.LINKS
|
||||||
|
| FieldMetadataType.EMAILS;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CompositeColumnActionFactory extends ColumnActionAbstractFactory<CompositeFieldMetadataType> {
|
export class CompositeColumnActionFactory extends ColumnActionAbstractFactory<CompositeFieldMetadataType> {
|
||||||
|
@ -97,6 +97,10 @@ export class WorkspaceMigrationFactory {
|
|||||||
],
|
],
|
||||||
[FieldMetadataType.LINKS, { factory: this.compositeColumnActionFactory }],
|
[FieldMetadataType.LINKS, { factory: this.compositeColumnActionFactory }],
|
||||||
[FieldMetadataType.ACTOR, { factory: this.compositeColumnActionFactory }],
|
[FieldMetadataType.ACTOR, { factory: this.compositeColumnActionFactory }],
|
||||||
|
[
|
||||||
|
FieldMetadataType.EMAILS,
|
||||||
|
{ factory: this.compositeColumnActionFactory },
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user