Skip to content

Commit 7a9a43b

Browse files
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 <[email protected]>
1 parent c87ccfa commit 7a9a43b

File tree

52 files changed

+866
-318
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+866
-318
lines changed

packages/twenty-front/src/generated-metadata/graphql.ts

+1
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,7 @@ export enum FieldMetadataType {
360360
Date = 'DATE',
361361
DateTime = 'DATE_TIME',
362362
Email = 'EMAIL',
363+
Emails = 'EMAILS',
363364
FullName = 'FULL_NAME',
364365
Link = 'LINK',
365366
Links = 'LINKS',

packages/twenty-front/src/generated/graphql.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ export enum FieldMetadataType {
265265
Date = 'DATE',
266266
DateTime = 'DATE_TIME',
267267
Email = 'EMAIL',
268+
Emails = 'EMAILS',
268269
FullName = 'FULL_NAME',
269270
Link = 'LINK',
270271
Links = 'LINKS',

packages/twenty-front/src/modules/object-metadata/constants/SortableFieldMetadataTypes.ts

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const SORTABLE_FIELD_METADATA_TYPES = [
99
FieldMetadataType.Select,
1010
FieldMetadataType.Phone,
1111
FieldMetadataType.Email,
12+
FieldMetadataType.Emails,
1213
FieldMetadataType.FullName,
1314
FieldMetadataType.Rating,
1415
FieldMetadataType.Currency,

packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts

+3
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export const formatFieldMetadataItemsAsFilterDefinitions = ({
2626
FieldMetadataType.DateTime,
2727
FieldMetadataType.Text,
2828
FieldMetadataType.Email,
29+
FieldMetadataType.Emails,
2930
FieldMetadataType.Number,
3031
FieldMetadataType.Link,
3132
FieldMetadataType.Links,
@@ -77,6 +78,8 @@ export const getFilterTypeFromFieldType = (fieldType: FieldMetadataType) => {
7778
return 'CURRENCY';
7879
case FieldMetadataType.Email:
7980
return 'EMAIL';
81+
case FieldMetadataType.Emails:
82+
return 'EMAILS';
8083
case FieldMetadataType.Phone:
8184
return 'PHONE';
8285
case FieldMetadataType.Relation:

packages/twenty-front/src/modules/object-metadata/utils/getOrderByForFieldMetadataType.ts

+12-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
22

33
import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy';
4-
import { FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata';
4+
import {
5+
FieldEmailsValue,
6+
FieldLinksValue,
7+
} from '@/object-record/record-field/types/FieldMetadata';
58
import { OrderBy } from '@/types/OrderBy';
69
import { FieldMetadataType } from '~/generated-metadata/graphql';
710

@@ -43,6 +46,14 @@ export const getOrderByForFieldMetadataType = (
4346
} satisfies { [key in keyof FieldLinksValue]?: OrderBy },
4447
},
4548
];
49+
case FieldMetadataType.Emails:
50+
return [
51+
{
52+
[field.name]: {
53+
primaryEmail: direction ?? 'AscNullsLast',
54+
} satisfies { [key in keyof FieldEmailsValue]?: OrderBy },
55+
},
56+
];
4657
default:
4758
return [
4859
{

packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts

+8
Original file line numberDiff line numberDiff line change
@@ -156,5 +156,13 @@ ${mapObjectMetadataToGraphQLQuery({
156156
}`;
157157
}
158158

159+
if (fieldType === FieldMetadataType.Emails) {
160+
return `${field.name}
161+
{
162+
primaryEmail
163+
additionalEmails
164+
}`;
165+
}
166+
159167
return '';
160168
};

packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts

+4
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ export type ActorFilter = {
9494
name?: StringFilter;
9595
};
9696

97+
export type EmailsFilter = {
98+
primaryEmail?: StringFilter;
99+
};
100+
97101
export type LeafFilter =
98102
| UUIDFilter
99103
| StringFilter

packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export const MultipleFiltersDropdownContent = ({
6060
{[
6161
'TEXT',
6262
'EMAIL',
63+
'EMAILS',
6364
'PHONE',
6465
'FULL_NAME',
6566
'LINK',

packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterType.ts

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export type FilterType =
22
| 'TEXT'
33
| 'PHONE'
44
| 'EMAIL'
5+
| 'EMAILS'
56
| 'DATE_TIME'
67
| 'DATE'
78
| 'NUMBER'

packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export const getOperandsForFilterType = (
1515
switch (filterType) {
1616
case 'TEXT':
1717
case 'EMAIL':
18+
case 'EMAILS':
1819
case 'FULL_NAME':
1920
case 'ADDRESS':
2021
case 'PHONE':

packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx

+4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useContext } from 'react';
22

33
import { ActorFieldDisplay } from '@/object-record/record-field/meta-types/display/components/ActorFieldDisplay';
44
import { BooleanFieldDisplay } from '@/object-record/record-field/meta-types/display/components/BooleanFieldDisplay';
5+
import { EmailsFieldDisplay } from '@/object-record/record-field/meta-types/display/components/EmailsFieldDisplay';
56
import { LinksFieldDisplay } from '@/object-record/record-field/meta-types/display/components/LinksFieldDisplay';
67
import { RatingFieldDisplay } from '@/object-record/record-field/meta-types/display/components/RatingFieldDisplay';
78
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
1011
import { isFieldActor } from '@/object-record/record-field/types/guards/isFieldActor';
1112
import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean';
1213
import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone';
14+
import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails';
1315
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
1416
import { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating';
1517
import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects';
@@ -100,5 +102,7 @@ export const FieldDisplay = () => {
100102
<RichTextFieldDisplay />
101103
) : isFieldActor(fieldDefinition) ? (
102104
<ActorFieldDisplay />
105+
) : isFieldEmails(fieldDefinition) ? (
106+
<EmailsFieldDisplay />
103107
) : null;
104108
};

packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx

+4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useContext } from 'react';
22

33
import { AddressFieldInput } from '@/object-record/record-field/meta-types/input/components/AddressFieldInput';
44
import { DateFieldInput } from '@/object-record/record-field/meta-types/input/components/DateFieldInput';
5+
import { EmailsFieldInput } from '@/object-record/record-field/meta-types/input/components/EmailsFieldInput';
56
import { FullNameFieldInput } from '@/object-record/record-field/meta-types/input/components/FullNameFieldInput';
67
import { LinksFieldInput } from '@/object-record/record-field/meta-types/input/components/LinksFieldInput';
78
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/
1112
import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope';
1213
import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
1314
import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone';
15+
import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails';
1416
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
1517
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
1618
import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect';
@@ -103,6 +105,8 @@ export const FieldInput = ({
103105
onTab={onTab}
104106
onShiftTab={onShiftTab}
105107
/>
108+
) : isFieldEmails(fieldDefinition) ? (
109+
<EmailsFieldInput onCancel={onCancel} />
106110
) : isFieldFullName(fieldDefinition) ? (
107111
<FullNameFieldInput
108112
onEnter={onEnter}

packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistField.ts

+6
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { isFieldAddress } from '@/object-record/record-field/types/guards/isFiel
77
import { isFieldAddressValue } from '@/object-record/record-field/types/guards/isFieldAddressValue';
88
import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate';
99
import { isFieldDateValue } from '@/object-record/record-field/types/guards/isFieldDateValue';
10+
import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails';
11+
import { isFieldEmailsValue } from '@/object-record/record-field/types/guards/isFieldEmailsValue';
1012
import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName';
1113
import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue';
1214
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
@@ -65,6 +67,9 @@ export const usePersistField = () => {
6567
const fieldIsEmail =
6668
isFieldEmail(fieldDefinition) && isFieldEmailValue(valueToPersist);
6769

70+
const fieldIsEmails =
71+
isFieldEmails(fieldDefinition) && isFieldEmailsValue(valueToPersist);
72+
6873
const fieldIsDateTime =
6974
isFieldDateTime(fieldDefinition) &&
7075
isFieldDateTimeValue(valueToPersist);
@@ -119,6 +124,7 @@ export const usePersistField = () => {
119124
fieldIsText ||
120125
fieldIsBoolean ||
121126
fieldIsEmail ||
127+
fieldIsEmails ||
122128
fieldIsRating ||
123129
fieldIsNumber ||
124130
fieldIsDateTime ||
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { useEmailsField } from '@/object-record/record-field/meta-types/hooks/useEmailsField';
2+
import { EmailsDisplay } from '@/ui/field/display/components/EmailsDisplay';
3+
4+
export const EmailsFieldDisplay = () => {
5+
const { fieldValue } = useEmailsField();
6+
7+
return <EmailsDisplay value={fieldValue} />;
8+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { useContext } from 'react';
2+
import { useRecoilState, useRecoilValue } from 'recoil';
3+
4+
import { usePersistField } from '@/object-record/record-field/hooks/usePersistField';
5+
import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput';
6+
import { FieldEmailsValue } from '@/object-record/record-field/types/FieldMetadata';
7+
import { isFieldEmails } from '@/object-record/record-field/types/guards/isFieldEmails';
8+
import { emailsSchema } from '@/object-record/record-field/types/guards/isFieldEmailsValue';
9+
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
10+
import { FieldMetadataType } from '~/generated-metadata/graphql';
11+
12+
import { FieldContext } from '../../contexts/FieldContext';
13+
import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata';
14+
15+
export const useEmailsField = () => {
16+
const { recordId, fieldDefinition, hotkeyScope } = useContext(FieldContext);
17+
18+
assertFieldMetadata(FieldMetadataType.Emails, isFieldEmails, fieldDefinition);
19+
20+
const fieldName = fieldDefinition.metadata.fieldName;
21+
22+
const [fieldValue, setFieldValue] = useRecoilState<FieldEmailsValue>(
23+
recordStoreFamilySelector({
24+
recordId,
25+
fieldName: fieldName,
26+
}),
27+
);
28+
29+
const { setDraftValue, getDraftValueSelector } =
30+
useRecordFieldInput<FieldEmailsValue>(`${recordId}-${fieldName}`);
31+
32+
const draftValue = useRecoilValue(getDraftValueSelector());
33+
34+
const persistField = usePersistField();
35+
36+
const persistEmailsField = (nextValue: FieldEmailsValue) => {
37+
try {
38+
persistField(emailsSchema.parse(nextValue));
39+
} catch {
40+
return;
41+
}
42+
};
43+
44+
return {
45+
fieldDefinition,
46+
fieldValue,
47+
draftValue,
48+
setDraftValue,
49+
setFieldValue,
50+
hotkeyScope,
51+
persistEmailsField,
52+
};
53+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { useContext } from 'react';
2+
3+
import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext';
4+
5+
import { FieldEmailsValue } from '@/object-record/record-field/types/FieldMetadata';
6+
import { FieldContext } from '../../contexts/FieldContext';
7+
8+
export const useEmailsFieldDisplay = () => {
9+
const { recordId, fieldDefinition, hotkeyScope } = useContext(FieldContext);
10+
11+
const fieldName = fieldDefinition.metadata.fieldName;
12+
13+
const fieldValue = useRecordFieldValue<FieldEmailsValue | undefined>(
14+
recordId,
15+
fieldName,
16+
);
17+
18+
return {
19+
fieldDefinition,
20+
fieldValue,
21+
hotkeyScope,
22+
};
23+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { useEmailsField } from '@/object-record/record-field/meta-types/hooks/useEmailsField';
2+
import { EmailsFieldMenuItem } from '@/object-record/record-field/meta-types/input/components/EmailsFieldMenuItem';
3+
import { useMemo } from 'react';
4+
import { isDefined } from 'twenty-ui';
5+
import { MultiItemFieldInput } from './MultiItemFieldInput';
6+
7+
type EmailsFieldInputProps = {
8+
onCancel?: () => void;
9+
};
10+
11+
export const EmailsFieldInput = ({ onCancel }: EmailsFieldInputProps) => {
12+
const { persistEmailsField, hotkeyScope, fieldValue } = useEmailsField();
13+
14+
const emails = useMemo<string[]>(
15+
() =>
16+
[
17+
fieldValue?.primaryEmail ? fieldValue?.primaryEmail : null,
18+
...(fieldValue?.additionalEmails ?? []),
19+
].filter(isDefined),
20+
[fieldValue?.primaryEmail, fieldValue?.additionalEmails],
21+
);
22+
23+
const handlePersistEmails = (updatedEmails: string[]) => {
24+
const [nextPrimaryEmail, ...nextAdditionalEmails] = updatedEmails;
25+
persistEmailsField({
26+
primaryEmail: nextPrimaryEmail ?? '',
27+
additionalEmails: nextAdditionalEmails,
28+
});
29+
};
30+
31+
return (
32+
<MultiItemFieldInput
33+
items={emails}
34+
onPersist={handlePersistEmails}
35+
onCancel={onCancel}
36+
placeholder="Email"
37+
renderItem={({
38+
value: email,
39+
index,
40+
handleEdit,
41+
handleSetPrimary,
42+
handleDelete,
43+
}) => (
44+
<EmailsFieldMenuItem
45+
key={index}
46+
dropdownId={`${hotkeyScope}-emails-${index}`}
47+
isPrimary={index === 0}
48+
email={email}
49+
onEdit={handleEdit}
50+
onSetAsPrimary={handleSetPrimary}
51+
onDelete={handleDelete}
52+
/>
53+
)}
54+
hotkeyScope={hotkeyScope}
55+
/>
56+
);
57+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { EmailDisplay } from '@/ui/field/display/components/EmailDisplay';
2+
import { MultiItemFieldMenuItem } from './MultiItemFieldMenuItem';
3+
4+
type EmailsFieldMenuItemProps = {
5+
dropdownId: string;
6+
isPrimary?: boolean;
7+
onEdit?: () => void;
8+
onSetAsPrimary?: () => void;
9+
onDelete?: () => void;
10+
email: string;
11+
};
12+
13+
export const EmailsFieldMenuItem = ({
14+
dropdownId,
15+
isPrimary,
16+
onEdit,
17+
onSetAsPrimary,
18+
onDelete,
19+
email,
20+
}: EmailsFieldMenuItemProps) => {
21+
return (
22+
<MultiItemFieldMenuItem
23+
dropdownId={dropdownId}
24+
isPrimary={isPrimary}
25+
value={email}
26+
onEdit={onEdit}
27+
onSetAsPrimary={onSetAsPrimary}
28+
onDelete={onDelete}
29+
DisplayComponent={EmailDisplay}
30+
/>
31+
);
32+
};

0 commit comments

Comments
 (0)