Rebuild email composer recipient fields as a structured chip input with person resolution and autocomplete (#22668)

# Why

The To/Cc/Bcc fields reused `FormMultiTextFieldInput`, the workflow
Tiptap tag editor, with recipients stored as a comma-separated string.
That caused every reported issue: duplicates were allowed, the field was
locked to one 32px line with a hidden horizontal scrollbar, chips did
nothing on click, `First Last <email>` could not even be typed (space
committed a tag) and was rejected by the backend when pasted, chips
could not be edited, invalid addresses only failed server-side after
pressing Send, and there was no autocomplete at all.

## The model

A recipient is `{ address, displayName? }`. Person and workspace member
are never stored in composer state; they are resolved live from the
address at render time, mirroring how `MatchParticipantService` links
`messageParticipant.handle` to `personId`/`workspaceMemberId` on the
receive side. Entities appear at the edges (autocomplete in, chip
display out); state, dedupe, validation, and send operate on addresses
only. The send path is unchanged: `SendEmailInput.to/cc/bcc` stay
comma-separated bare addresses.

# What changed

New module `activities/emails/recipients/` (the workflow editor is
untouched; its other consumers are unaffected):

- **`EmailRecipientsFieldInput`**: wrapping chip rows (up to ~3 lines,
then scroll), commit on Enter/Tab/comma/semicolon/blur, space commits
only when the buffer is already a valid email, paste parses RFC 5322
lists (names, quoted commas, semicolons, newlines), case-insensitive
dedupe with a flash on the existing chip, invalid addresses become red
chips that disable Send, double-click or keyboard editing in place with
Escape revert, Backspace select-then-delete, arrow-key chip navigation,
Ctrl/Cmd+Enter commits a pending buffer or sends when the buffer is
empty.
- **Person resolution**: chips resolve against People
(`emails.primaryEmail`, case-insensitive) and workspace members,
rendering avatar + name when known and degrading to a plain address chip
otherwise.
- **Chip menu**: person/member header, Copy email, Edit, Remove, and Add
as person for unknown addresses (creates the Person; the chip upgrades
in place).
- **Autocomplete**: blends context people (company you are composing
from, or the company behind a person/opportunity), ranked people search,
workspace members with a Team member badge, and a literal "Use this
email" row ranked first when the typed buffer is a valid address.
Suggestions exclude addresses already present in any field. Enter picks
the highlighted or top row.
- **Prefill**: replies and drafts preserve participant display names
(`getEmailDraftPrefillFromMessage`, `useReplyContext`).
- `useEmailComposerState` holds `EmailRecipient[]` per field and blocks
send on invalid recipients; the recipient-limit warning is surfaced
again in the composer.
- The Send Email engine command passes the record context so context
suggestions work from the record page action.
- `EmailsFilter` was missing from the shared `LeafFilter` union, so
nothing could filter on `emails.primaryEmail`; added (additive).
- New dependency `addressparser@1.0.1` in twenty-front, the same package
and version the server already uses to parse inbound mail headers, so
both sides parse identically. Tiny, dependency-free, browser-safe.

# Decisions and tradeoffs

- Person resolution matches on `emails.primaryEmail` only,
case-insensitively via per-address `ilike` filters (no `%` wildcards,
`%_\` escaped). `additionalEmails` is a JSONB array and not cleanly
filterable through the GraphQL filter API today; the server-side matcher
checks additional emails too, so a chip may show as a plain address even
though the send still links to the person via participant matching.
- Chip flash-on-duplicate replays its CSS animation by remounting the
chip subtree (nonce in the React key), chosen over animation-restart
hacks; the remount is invisible.
- Keyboard chip selection keeps DOM focus on the input and tracks a
virtual `selectedChipIndex` (`aria-activedescendant`) instead of roving
focus across chips: one focus point, no focus juggling, standard
combobox listbox pattern.
- `flushSync` (precedent: `Dropdown.tsx`) focuses and places the caret
after entering chip-edit mode; the alternative was a useEffect on
editing state.
- Suggestion rows `preventDefault` on mousedown so picking a suggestion
never blurs the input (blur would first commit the half-typed buffer as
a junk chip).
- Cmd/Ctrl+Enter inside a recipient field: with a non-empty buffer it
commits the buffer only; with an empty buffer it sends via an `onSubmit`
prop wired to `handleSend`. Not commit+send in one stroke: `handleSend`
holds a same-render closure over composer state, so sending in the same
event would read the pre-commit recipients. E2E also showed the side
panel's own ctrl+Enter hotkey never fires while any form field is
focused (focus-stack scoping, applies to the old composer too), which is
why the field triggers the submit itself.
- Enter with suggestions open picks the highlighted (or top) suggestion,
Gmail-style. When the typed buffer is itself a valid email, the literal
row is ranked first so Enter keeps meaning "add what I typed".
- Suggestions are disabled while editing a chip (the edit buffer holds
`Name <email>` text, a poor search query).
- Dedupe blocks within a field; across fields typed duplicates are
allowed (sometimes intentional), but suggestions exclude addresses
already present in any of To/Cc/Bcc.
- Chip menu actions never navigate: navigating the side panel (or main
view) unmounts the composer and silently destroys the draft, since
composer state is component-local with no draft persistence. "Add as
person" creates the record and shows a snackbar while the chip upgrades
in place; the person header row is informational. "Open person"
navigation should come back once drafts survive navigation.
- The reply composer gets no context record: its widget target record is
the message thread, not a person/company, and replies already prefill
participants.
- If two people share a primary email, the last fetched match wins for
chip display (no ambiguity UI).
- "Add as person" splits the display name on the first space for
firstName/lastName, the same heuristic the contact-creation manager uses
server-side.

# Deferred

- Display names on the wire (`Name <email>` in outbound headers): needs
`SendEmailInput` / `EmailComposerService.validateEmails` changes
server-side.
- Drag chips between To/Cc/Bcc; collapse-on-blur to one line with a "+N
others" summary.
- Frequency/recency ranking of suggestions from `messageParticipant`
aggregates.
- "Open person" from the chip menu, pending draft persistence across
navigation.

# Verification

Unit tests cover the parser, formatter round-trip, merge/dedupe, and the
field state machine (commit, dedupe flash, edit, cancel, keyboard
selection). Typecheck, lint, and the email module suites pass, plus the
shared and side-panel suites.

Every flow was also driven end to end with Playwright against seeded
data: prefill resolution, context and typed suggestions, keyboard
navigation and picks, dedupe flash, RFC 5322 paste, invalid chips gating
Send, wrapping, in-place editing, chip menus, clipboard copy, Add as
person with live chip upgrade, Cc/Bcc exclusions, and the Ctrl+Enter
send path (the mutation reached the server; it failed only on the seeded
account's missing refresh token, expected outside a real provider
connection).

Screenshots of each verified behavior:
https://claude.ai/code/artifact/1743f05d-422e-43d0-bbea-a34a0470c180

---
_Generated by [Claude
Code](https://claude.ai/code/session_0199wDARiw48GqVTpgWzbXWw)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22668?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-07-09 13:11:09 +02:00
committed by GitHub
parent 3dd7dfde5a
commit 6897fff632
41 changed files with 2170 additions and 60 deletions
+2
View File
@@ -83,6 +83,7 @@
"@tiptap/react": "3.4.2",
"@types/marked": "^6.0.0",
"@xyflow/react": "^12.4.2",
"addressparser": "1.0.1",
"ai": "6.0.97",
"apollo-link-rest": "^0.10.0-rc.2",
"apollo-upload-client": "^19.0.0",
@@ -176,6 +177,7 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@tiptap/suggestion": "3.4.2",
"@types/addressparser": "^1.0.3",
"@types/deep-equal": "^1.0.1",
"@types/file-saver": "^2.0.7",
"@types/jest": "^30.0.0",
@@ -2,9 +2,11 @@ import { useQuery } from '@apollo/client/react';
import { styled } from '@linaria/react';
import { EmailAttachmentsField } from '@/activities/emails/components/EmailAttachmentsField';
import { EmailRecipientsFieldInput } from '@/activities/emails/recipients/components/EmailRecipientsFieldInput';
import { type EmailComposerContextRecord } from '@/activities/emails/recipients/types/EmailComposerContextRecord';
import { getEmailRecipientKey } from '@/activities/emails/recipients/utils/getEmailRecipientKey';
import { type EmailComposerState } from '@/activities/emails/types/EmailComposerState';
import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput';
import { FormMultiTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormMultiTextFieldInput';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts';
import { Select } from '@/ui/input/components/Select';
@@ -39,12 +41,19 @@ const StyledCcBccToggle = styled.button`
}
`;
const StyledRecipientLimitWarning = styled.div`
color: ${themeCssVariables.color.red};
font-size: ${themeCssVariables.font.size.xs};
`;
type EmailComposerFieldsProps = {
composerState: EmailComposerState;
contextRecord?: EmailComposerContextRecord | null;
};
export const EmailComposerFields = ({
composerState,
contextRecord,
}: EmailComposerFieldsProps) => {
const { data: accountsData } = useQuery<{
myConnectedAccounts: { id: string; handle: string }[];
@@ -58,6 +67,12 @@ export const EmailComposerFields = ({
const hasMultipleAccounts = accountOptions.length > 1;
const allRecipientKeys = [
...composerState.to,
...composerState.cc,
...composerState.bcc,
].map((recipient) => getEmailRecipientKey(recipient.address));
return (
<StyledFieldsContainer>
{hasMultipleAccounts && (
@@ -71,11 +86,14 @@ export const EmailComposerFields = ({
/>
)}
<StyledToRow>
<FormMultiTextFieldInput
<EmailRecipientsFieldInput
label={t`To`}
defaultValue={composerState.initialTo}
onChange={composerState.setTo}
placeholder={t`Recipients`}
recipients={composerState.to}
onChange={composerState.setTo}
onSubmit={composerState.handleSend}
excludedSuggestionKeys={allRecipientKeys}
contextRecord={contextRecord}
/>
{!composerState.showCcBcc && (
<StyledCcBccToggle onClick={() => composerState.setShowCcBcc(true)}>
@@ -85,20 +103,31 @@ export const EmailComposerFields = ({
</StyledToRow>
{composerState.showCcBcc && (
<>
<FormMultiTextFieldInput
<EmailRecipientsFieldInput
label={t`Cc`}
defaultValue={composerState.initialCc}
onChange={composerState.setCc}
placeholder={t`Cc`}
recipients={composerState.cc}
onChange={composerState.setCc}
onSubmit={composerState.handleSend}
excludedSuggestionKeys={allRecipientKeys}
contextRecord={contextRecord}
/>
<FormMultiTextFieldInput
<EmailRecipientsFieldInput
label={t`Bcc`}
defaultValue={composerState.initialBcc}
onChange={composerState.setBcc}
placeholder={t`Bcc`}
recipients={composerState.bcc}
onChange={composerState.setBcc}
onSubmit={composerState.handleSend}
excludedSuggestionKeys={allRecipientKeys}
contextRecord={contextRecord}
/>
</>
)}
{composerState.exceedsRecipientLimit && (
<StyledRecipientLimitWarning>
{t`Too many recipients (${composerState.recipientCount}/${composerState.maxRecipients}).`}
</StyledRecipientLimitWarning>
)}
<FormTextFieldInput
label={t`Subject`}
defaultValue={composerState.initialSubject}
@@ -29,6 +29,10 @@ export const useComposeEmailForTargetRecord = () => {
openComposeEmailInSidePanel({
connectedAccountId,
defaultTo,
contextRecord: {
objectNameSingular: targetRecord.targetObjectNameSingular,
recordId: targetRecord.id,
},
});
};
@@ -3,6 +3,10 @@ import { MAX_EMAIL_RECIPIENTS } from 'twenty-shared/constants';
import { type EmailAttachment } from 'twenty-shared/types';
import { useSendEmail } from '@/activities/emails/hooks/useSendEmail';
import { type EmailRecipient } from '@/activities/emails/recipients/types/EmailRecipient';
import { isValidEmailRecipientAddress } from '@/activities/emails/recipients/utils/isValidEmailRecipientAddress';
import { parseEmailRecipients } from '@/activities/emails/recipients/utils/parseEmailRecipients';
import { serializeEmailRecipients } from '@/activities/emails/recipients/utils/serializeEmailRecipients';
import { type EmailDraftPrefill } from '@/activities/emails/types/EmailDraftPrefill';
type UseEmailComposerStateArgs = {
@@ -14,11 +18,10 @@ type UseEmailComposerStateArgs = {
onSent?: (messageThreadId: string | null) => void;
};
const countRecipients = (csv: string): number =>
csv
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0).length;
const hasInvalidRecipient = (recipients: EmailRecipient[]): boolean =>
recipients.some(
(recipient) => !isValidEmailRecipientAddress(recipient.address),
);
export const useEmailComposerState = ({
connectedAccountId: initialConnectedAccountId,
@@ -37,9 +40,15 @@ export const useEmailComposerState = ({
const [connectedAccountId, setConnectedAccountId] = useState(
initialConnectedAccountId,
);
const [to, setTo] = useState(initialTo);
const [cc, setCc] = useState(initialCc);
const [bcc, setBcc] = useState(initialBcc);
const [to, setTo] = useState<EmailRecipient[]>(() =>
parseEmailRecipients(initialTo),
);
const [cc, setCc] = useState<EmailRecipient[]>(() =>
parseEmailRecipients(initialCc),
);
const [bcc, setBcc] = useState<EmailRecipient[]>(() =>
parseEmailRecipients(initialBcc),
);
const [subject, setSubject] = useState(initialSubject);
const [body, setBody] = useState(initialBody);
const [showCcBcc, setShowCcBcc] = useState(
@@ -49,33 +58,38 @@ export const useEmailComposerState = ({
const { sendEmail, loading } = useSendEmail();
const recipientCount = useMemo(
() => countRecipients(to) + countRecipients(cc) + countRecipients(bcc),
[to, cc, bcc],
);
const recipientCount = to.length + cc.length + bcc.length;
const exceedsRecipientLimit = recipientCount > MAX_EMAIL_RECIPIENTS;
const hasInvalidRecipients = useMemo(
() =>
hasInvalidRecipient(to) ||
hasInvalidRecipient(cc) ||
hasInvalidRecipient(bcc),
[to, cc, bcc],
);
const canSend =
to.trim().length > 0 &&
to.length > 0 &&
connectedAccountId.length > 0 &&
!loading &&
!exceedsRecipientLimit;
!exceedsRecipientLimit &&
!hasInvalidRecipients;
const handleSend = useCallback(async () => {
if (!to.trim() || !connectedAccountId || exceedsRecipientLimit) {
if (!canSend) {
return;
}
const trimmedTo = to.trim();
const trimmedCc = cc.trim();
const trimmedBcc = bcc.trim();
const serializedCc = serializeEmailRecipients(cc);
const serializedBcc = serializeEmailRecipients(bcc);
const { success, messageThreadId } = await sendEmail({
connectedAccountId,
to: trimmedTo,
cc: trimmedCc || undefined,
bcc: trimmedBcc || undefined,
to: serializeEmailRecipients(to),
cc: serializedCc || undefined,
bcc: serializedBcc || undefined,
subject,
body,
inReplyTo: defaultInReplyTo,
@@ -87,6 +101,7 @@ export const useEmailComposerState = ({
onSent?.(messageThreadId);
}
}, [
canSend,
connectedAccountId,
to,
cc,
@@ -98,7 +113,6 @@ export const useEmailComposerState = ({
files,
sendEmail,
onSent,
exceedsRecipientLimit,
]);
return {
@@ -121,9 +135,6 @@ export const useEmailComposerState = ({
handleSend,
loading,
canSend,
initialTo,
initialCc,
initialBcc,
initialSubject,
initialBody,
recipientCount,
@@ -1,6 +1,8 @@
import { isNonEmptyString } from '@sniptt/guards';
import { useMemo } from 'react';
import { useEmailThread } from '@/activities/emails/hooks/useEmailThread';
import { formatEmailRecipient } from '@/activities/emails/recipients/utils/formatEmailRecipient';
import {
type ReplyContext,
type ReplyContextReady,
@@ -51,6 +53,12 @@ export const useReplyContext = (
}
const senderHandle = lastSentMessage.sender?.handle ?? '';
const replyTo = isNonEmptyString(senderHandle)
? formatEmailRecipient({
address: senderHandle,
displayName: lastSentMessage.sender?.displayName,
})
: '';
const rawSubject = lastSentMessage.subject ?? '';
const subject = rawSubject.startsWith('Re: ')
@@ -59,7 +67,7 @@ export const useReplyContext = (
return {
loading: false,
to: senderHandle,
to: replyTo,
subject,
inReplyTo: lastSentMessage.headerMessageId ?? '',
connectedAccountId,
@@ -0,0 +1,158 @@
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconCopy, IconPencil, IconTrash, IconUserPlus } from 'twenty-ui/icon';
import { MenuItem, MenuItemAvatar } from 'twenty-ui/navigation';
import { type EmailRecipientResolution } from '@/activities/emails/recipients/hooks/useEmailRecipientsResolution';
import { type EmailRecipient } from '@/activities/emails/recipients/types/EmailRecipient';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
type EmailRecipientChipMenuContentProps = {
dropdownId: string;
recipient: EmailRecipient;
resolution: EmailRecipientResolution | undefined;
isInvalid: boolean;
onEdit: () => void;
onRemove: () => void;
};
export const EmailRecipientChipMenuContent = ({
dropdownId,
recipient,
resolution,
isInvalid,
onEdit,
onRemove,
}: EmailRecipientChipMenuContentProps) => {
const { t } = useLingui();
const { closeDropdown } = useCloseDropdown();
const { copyToClipboard } = useCopyToClipboard();
const { enqueueSuccessSnackBar } = useSnackBar();
const { createOneRecord: createPerson } = useCreateOneRecord({
objectNameSingular: CoreObjectNameSingular.Person,
});
const workspaceMember = resolution?.workspaceMember;
const person = resolution?.person;
const workspaceMemberFullName = isDefined(workspaceMember)
? `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`.trim()
: '';
const personFullName = isDefined(person)
? `${person.firstName} ${person.lastName}`.trim()
: '';
const handleAddAsPerson = async () => {
closeDropdown(dropdownId);
const [firstName = '', ...lastNameParts] = (
recipient.displayName ?? ''
).split(' ');
const createdPerson = await createPerson({
emails: { primaryEmail: recipient.address, additionalEmails: [] },
name: { firstName, lastName: lastNameParts.join(' ') },
});
if (isDefined(createdPerson)) {
enqueueSuccessSnackBar({ message: t`Person created` });
}
};
const handleCopy = () => {
closeDropdown(dropdownId);
copyToClipboard(recipient.address, t`Email copied to clipboard`);
};
const handleEdit = () => {
closeDropdown(dropdownId);
onEdit();
};
const handleRemove = () => {
closeDropdown(dropdownId);
onRemove();
};
const showAddAsPerson =
!isDefined(person) && !isDefined(workspaceMember) && !isInvalid;
return (
<DropdownContent widthInPixels={280}>
{(isDefined(person) || isDefined(workspaceMember) || showAddAsPerson) && (
<>
<DropdownMenuItemsContainer>
{isDefined(workspaceMember) ? (
<MenuItemAvatar
avatar={{
avatarUrl: getAbsoluteImageUrl(workspaceMember.avatarUrl),
placeholder: isNonEmptyString(workspaceMemberFullName)
? workspaceMemberFullName
: recipient.address,
placeholderColorSeed: workspaceMember.id,
size: 'md',
type: 'rounded',
}}
text={
isNonEmptyString(workspaceMemberFullName)
? workspaceMemberFullName
: recipient.address
}
contextualText={t`Team member`}
/>
) : isDefined(person) ? (
<MenuItemAvatar
avatar={{
avatarUrl: getAbsoluteImageUrl(person.avatarUrl),
placeholder: isNonEmptyString(personFullName)
? personFullName
: recipient.address,
placeholderColorSeed: person.id,
size: 'md',
type: 'rounded',
}}
text={
isNonEmptyString(personFullName)
? personFullName
: recipient.address
}
contextualText={recipient.address}
/>
) : (
<MenuItem
LeftIcon={IconUserPlus}
text={t`Add as person`}
onClick={handleAddAsPerson}
/>
)}
</DropdownMenuItemsContainer>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItemsContainer>
<MenuItem
LeftIcon={IconCopy}
text={t`Copy email`}
onClick={handleCopy}
/>
<MenuItem LeftIcon={IconPencil} text={t`Edit`} onClick={handleEdit} />
<MenuItem
accent="danger"
LeftIcon={IconTrash}
text={t`Remove`}
onClick={handleRemove}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
);
};
@@ -0,0 +1,47 @@
import { Avatar } from 'twenty-ui/data-display';
import { MenuItemSelectAvatar } from 'twenty-ui/navigation';
import { type EmailRecipientSuggestion } from '@/activities/emails/recipients/hooks/useEmailRecipientSuggestions';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { isSelectedItemIdComponentFamilyState } from '@/ui/layout/selectable-list/states/isSelectedItemIdComponentFamilyState';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
type EmailRecipientSuggestionMenuItemProps = {
suggestion: EmailRecipientSuggestion;
selectableListInstanceId: string;
onPick: (suggestion: EmailRecipientSuggestion) => void;
};
export const EmailRecipientSuggestionMenuItem = ({
suggestion,
selectableListInstanceId,
onPick,
}: EmailRecipientSuggestionMenuItemProps) => {
const isSelectedItemId = useAtomComponentFamilyStateValue(
isSelectedItemIdComponentFamilyState,
suggestion.suggestionId,
selectableListInstanceId,
);
return (
<SelectableListItem itemId={suggestion.suggestionId}>
<MenuItemSelectAvatar
onClick={() => onPick(suggestion)}
text={suggestion.label}
contextualText={suggestion.secondaryText}
selected={false}
focused={isSelectedItemId}
avatar={
<Avatar
avatarUrl={getAbsoluteImageUrl(suggestion.avatarUrl)}
placeholder={suggestion.label}
placeholderColorSeed={suggestion.avatarColorSeed}
size="md"
type="rounded"
/>
}
/>
</SelectableListItem>
);
};
@@ -0,0 +1,53 @@
import { useLingui } from '@lingui/react/macro';
import { MenuItem } from 'twenty-ui/navigation';
import { EmailRecipientSuggestionMenuItem } from '@/activities/emails/recipients/components/EmailRecipientSuggestionMenuItem';
import { type EmailRecipientSuggestion } from '@/activities/emails/recipients/hooks/useEmailRecipientSuggestions';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
type EmailRecipientSuggestionsDropdownContentProps = {
suggestions: EmailRecipientSuggestion[];
selectableListInstanceId: string;
focusId: string;
onPick: (suggestion: EmailRecipientSuggestion) => void;
};
export const EmailRecipientSuggestionsDropdownContent = ({
suggestions,
selectableListInstanceId,
focusId,
onPick,
}: EmailRecipientSuggestionsDropdownContentProps) => {
const { t } = useLingui();
return (
<div onMouseDown={(event) => event.preventDefault()}>
<DropdownContent widthInPixels={340}>
<DropdownMenuItemsContainer hasMaxHeight>
{suggestions.length === 0 ? (
<MenuItem text={t`No results`} />
) : (
<SelectableList
selectableListInstanceId={selectableListInstanceId}
focusId={focusId}
selectableItemIdArray={suggestions.map(
(suggestion) => suggestion.suggestionId,
)}
>
{suggestions.map((suggestion) => (
<EmailRecipientSuggestionMenuItem
key={suggestion.suggestionId}
suggestion={suggestion}
selectableListInstanceId={selectableListInstanceId}
onPick={onPick}
/>
))}
</SelectableList>
)}
</DropdownMenuItemsContainer>
</DropdownContent>
</div>
);
};
@@ -0,0 +1,107 @@
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { Avatar } from 'twenty-ui/data-display';
import { EmailRecipientChipMenuContent } from '@/activities/emails/recipients/components/EmailRecipientChipMenuContent';
import { type EmailRecipientResolution } from '@/activities/emails/recipients/hooks/useEmailRecipientsResolution';
import { type EmailRecipient } from '@/activities/emails/recipients/types/EmailRecipient';
import { formatEmailRecipient } from '@/activities/emails/recipients/utils/formatEmailRecipient';
import { BaseChip } from '@/object-record/record-field/ui/form-types/components/BaseChip';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
const CHIP_MAX_LABEL_WIDTH = 240;
type EmailRecipientsFieldChipProps = {
chipId: string;
dropdownId: string;
recipient: EmailRecipient;
resolution: EmailRecipientResolution | undefined;
isInvalid: boolean;
selected: boolean;
isFlashing: boolean;
onEdit: () => void;
onRemove: () => void;
};
export const EmailRecipientsFieldChip = ({
chipId,
dropdownId,
recipient,
resolution,
isInvalid,
selected,
isFlashing,
onEdit,
onRemove,
}: EmailRecipientsFieldChipProps) => {
const { t } = useLingui();
const workspaceMember = resolution?.workspaceMember;
const person = resolution?.person;
const workspaceMemberFullName = isDefined(workspaceMember)
? `${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`.trim()
: '';
const personFullName = isDefined(person)
? `${person.firstName} ${person.lastName}`.trim()
: '';
const resolvedLabel =
[workspaceMemberFullName, personFullName, recipient.displayName ?? ''].find(
isNonEmptyString,
) ?? recipient.address;
const avatar =
isDefined(workspaceMember) || isDefined(person) ? (
<Avatar
avatarUrl={getAbsoluteImageUrl(
workspaceMember?.avatarUrl ?? person?.avatarUrl,
)}
placeholder={resolvedLabel}
placeholderColorSeed={workspaceMember?.id ?? person?.id}
size="sm"
type="rounded"
/>
) : undefined;
return (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="bottom-start"
clickableComponent={
<BaseChip
chipId={chipId}
label={resolvedLabel}
title={
isInvalid
? t`Invalid email address`
: formatEmailRecipient(recipient)
}
leftIcon={avatar}
danger={isInvalid}
selected={selected}
isFlashing={isFlashing}
onDoubleClick={onEdit}
maxLabelWidth={CHIP_MAX_LABEL_WIDTH}
onRemove={(event) => {
event.stopPropagation();
onRemove();
}}
removeAriaLabel={t`Remove ${recipient.address}`}
/>
}
dropdownComponents={
<EmailRecipientChipMenuContent
dropdownId={dropdownId}
recipient={recipient}
resolution={resolution}
isInvalid={isInvalid}
onEdit={onEdit}
onRemove={onRemove}
/>
}
/>
);
};
@@ -0,0 +1,558 @@
import { styled } from '@linaria/react';
import { isNonEmptyString } from '@sniptt/guards';
import { useStore } from 'jotai';
import {
type ClipboardEvent,
type KeyboardEvent,
type MouseEvent,
type ReactNode,
useId,
useMemo,
useRef,
useState,
} from 'react';
import { flushSync } from 'react-dom';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useDebouncedCallback } from 'use-debounce';
import { EmailRecipientsFieldChip } from '@/activities/emails/recipients/components/EmailRecipientsFieldChip';
import { EmailRecipientSuggestionsDropdownContent } from '@/activities/emails/recipients/components/EmailRecipientSuggestionsDropdownContent';
import { useEmailRecipientsField } from '@/activities/emails/recipients/hooks/useEmailRecipientsField';
import { useEmailRecipientsResolution } from '@/activities/emails/recipients/hooks/useEmailRecipientsResolution';
import {
type EmailRecipientSuggestion,
useEmailRecipientSuggestions,
} from '@/activities/emails/recipients/hooks/useEmailRecipientSuggestions';
import { type EmailComposerContextRecord } from '@/activities/emails/recipients/types/EmailComposerContextRecord';
import { type EmailRecipient } from '@/activities/emails/recipients/types/EmailRecipient';
import { getEmailRecipientKey } from '@/activities/emails/recipients/utils/getEmailRecipientKey';
import { isValidEmailRecipientAddress } from '@/activities/emails/recipients/utils/isValidEmailRecipientAddress';
import { parseEmailRecipients } from '@/activities/emails/recipients/utils/parseEmailRecipients';
import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer';
import { FORM_FIELD_PLACEHOLDER_STYLES } from '@/object-record/record-field/ui/form-types/constants/FormFieldPlaceholderStyles';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
const SUGGESTIONS_SEARCH_DEBOUNCE_MS = 300;
const StyledRowContainer = styled.div`
align-content: flex-start;
align-items: center;
background-color: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
box-sizing: border-box;
cursor: text;
display: flex;
flex-wrap: wrap;
gap: ${themeCssVariables.spacing[1]};
max-height: 96px;
min-height: 32px;
overflow-y: auto;
padding: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[2]};
width: 100%;
`;
const StyledInput = styled.input`
background: transparent;
border: none;
color: ${themeCssVariables.font.color.primary};
flex: 1 1 60px;
font-family: inherit;
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.regular};
height: 20px;
min-width: 60px;
outline: none;
padding: 0;
&::placeholder {
${FORM_FIELD_PLACEHOLDER_STYLES}
}
`;
type EmailRecipientsFieldInputProps = {
label: string;
placeholder: string;
recipients: EmailRecipient[];
onChange: (recipients: EmailRecipient[]) => void;
onSubmit?: () => void;
excludedSuggestionKeys?: string[];
contextRecord?: EmailComposerContextRecord | null;
};
export const EmailRecipientsFieldInput = ({
label,
placeholder,
recipients,
onChange,
onSubmit,
excludedSuggestionKeys = [],
contextRecord,
}: EmailRecipientsFieldInputProps) => {
const instanceId = useId();
const focusId = `email-recipients-field-${instanceId}`;
const suggestionsDropdownId = `${focusId}-suggestions`;
const inputRef = useRef<HTMLInputElement>(null);
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const { openDropdown } = useOpenDropdown();
const { closeDropdown } = useCloseDropdown();
const { resetSelectedItem } = useSelectableList(suggestionsDropdownId);
const store = useStore();
const selectedItemIdAtom = useAtomComponentStateCallbackState(
selectedItemIdComponentState,
suggestionsDropdownId,
);
const isDropdownOpen = useAtomComponentStateValue(
isDropdownOpenComponentState,
suggestionsDropdownId,
);
const {
inputValue,
setInputValue,
editingIndex,
isEditing,
selectedChipIndex,
chipFlash,
commitInput,
addRecipient,
addRecipients,
beginEditingChip,
cancelEditing,
removeRecipientAtIndex,
removeRecipientWithKeyboard,
clearChipSelection,
moveChipSelection,
} = useEmailRecipientsField({ recipients, onChange });
const [suggestionsSearchInput, setSuggestionsSearchInput] = useState('');
const debouncedSetSuggestionsSearchInput = useDebouncedCallback(
setSuggestionsSearchInput,
SUGGESTIONS_SEARCH_DEBOUNCE_MS,
);
const resetSuggestionsSearchInput = () => {
debouncedSetSuggestionsSearchInput.cancel();
setSuggestionsSearchInput('');
};
const { resolutionByRecipientKey } = useEmailRecipientsResolution({
recipients,
});
const { suggestions } = useEmailRecipientSuggestions({
searchInput: isEditing ? '' : suggestionsSearchInput,
excludedRecipientKeys: excludedSuggestionKeys,
contextRecord,
});
const invalidRecipientKeys = useMemo(
() =>
new Set(
recipients
.filter(
(recipient) => !isValidEmailRecipientAddress(recipient.address),
)
.map((recipient) => getEmailRecipientKey(recipient.address)),
),
[recipients],
);
const openSuggestions = () => {
if (!isDropdownOpen) {
openDropdown({
dropdownComponentInstanceIdFromProps: suggestionsDropdownId,
globalHotkeysConfig: { enableGlobalHotkeysWithModifiers: true },
});
}
};
const closeSuggestions = () => {
if (isDropdownOpen) {
closeDropdown(suggestionsDropdownId);
}
};
const getChipId = (chipIndex: number) => `${focusId}-chip-${chipIndex}`;
const scrollChipIntoView = (chipIndex: number | null) => {
if (chipIndex === null) {
return;
}
document
.getElementById(getChipId(chipIndex))
?.scrollIntoView({ block: 'nearest' });
};
const focusInput = () => inputRef.current?.focus();
const handleRowMouseDown = (event: MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget) {
event.preventDefault();
focusInput();
}
};
const handleInputFocus = () => {
pushFocusItemToFocusStack({
focusId,
component: {
type: FocusComponentType.FORM_FIELD_INPUT,
instanceId: focusId,
},
globalHotkeysConfig: {
enableGlobalHotkeysConflictingWithKeyboard: false,
},
});
};
const handleInputClick = () => {
if (!isEditing && inputValue.length === 0 && suggestions.length > 0) {
openSuggestions();
}
};
const commitInputAndCloseSuggestions = () => {
commitInput();
resetSuggestionsSearchInput();
closeSuggestions();
};
const handleInputBlur = () => {
removeFocusItemFromFocusStackById({ focusId });
commitInputAndCloseSuggestions();
clearChipSelection();
};
const handleInputChange = (value: string) => {
setInputValue(value);
clearChipSelection();
resetSelectedItem();
if (isEditing) {
return;
}
debouncedSetSuggestionsSearchInput(value);
if (value.trim().length > 0) {
openSuggestions();
} else {
resetSuggestionsSearchInput();
closeSuggestions();
}
};
const handlePickSuggestion = (suggestion: EmailRecipientSuggestion) => {
addRecipient(suggestion.recipient);
resetSuggestionsSearchInput();
closeSuggestions();
focusInput();
};
const handleInputPaste = (event: ClipboardEvent<HTMLInputElement>) => {
if (isEditing) {
return;
}
const pastedText = event.clipboardData.getData('text/plain');
const parsedRecipients = parseEmailRecipients(pastedText);
const shouldCommitAsChips =
parsedRecipients.length > 1 ||
(parsedRecipients.length === 1 &&
(isNonEmptyString(parsedRecipients[0].displayName) ||
isValidEmailRecipientAddress(parsedRecipients[0].address)));
if (!shouldCommitAsChips) {
return;
}
event.preventDefault();
addRecipients(parsedRecipients, null);
};
const handleChipEdit = (chipIndex: number) => {
flushSync(() => {
beginEditingChip(chipIndex);
resetSuggestionsSearchInput();
closeSuggestions();
});
const inputElement = inputRef.current;
if (!isDefined(inputElement)) {
return;
}
inputElement.focus();
inputElement.setSelectionRange(
inputElement.value.length,
inputElement.value.length,
);
};
const handleChipRemove = (chipIndex: number) => {
removeRecipientAtIndex(chipIndex);
focusInput();
};
const handleSubmitHotkey = () => {
if (inputValue.length > 0) {
commitInputAndCloseSuggestions();
} else {
onSubmit?.();
}
};
useHotkeysOnFocusedElement({
keys: ['ctrl+Enter,meta+Enter'],
callback: handleSubmitHotkey,
focusId,
dependencies: [handleSubmitHotkey],
});
useHotkeysOnFocusedElement({
keys: ['ctrl+Enter,meta+Enter'],
callback: handleSubmitHotkey,
focusId: suggestionsDropdownId,
dependencies: [handleSubmitHotkey],
});
const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.ctrlKey || event.metaKey) {
return;
}
const inputElement = event.currentTarget;
const bufferIsEmpty = inputValue.length === 0;
const caretAtStart =
inputElement.selectionStart === 0 && inputElement.selectionEnd === 0;
switch (event.key) {
case 'Enter': {
event.preventDefault();
if (!isEditing && isDropdownOpen && suggestions.length > 0) {
const selectedItemId = store.get(selectedItemIdAtom);
const selectedSuggestion = suggestions.find(
(suggestion) => suggestion.suggestionId === selectedItemId,
);
const suggestionsMatchBuffer =
suggestionsSearchInput === inputValue.trim();
const topSuggestion = suggestionsMatchBuffer
? suggestions[0]
: undefined;
const pickedSuggestion = selectedSuggestion ?? topSuggestion;
if (isDefined(pickedSuggestion)) {
handlePickSuggestion(pickedSuggestion);
return;
}
}
if (bufferIsEmpty && selectedChipIndex !== null) {
handleChipEdit(selectedChipIndex);
return;
}
commitInputAndCloseSuggestions();
return;
}
case 'Tab': {
if (!bufferIsEmpty) {
commitInputAndCloseSuggestions();
}
return;
}
case ',':
case ';': {
event.preventDefault();
if (!bufferIsEmpty) {
commitInputAndCloseSuggestions();
}
return;
}
case ' ': {
if (isValidEmailRecipientAddress(inputValue.trim())) {
event.preventDefault();
commitInputAndCloseSuggestions();
}
return;
}
case 'Backspace': {
if (!bufferIsEmpty || isEditing) {
return;
}
event.preventDefault();
if (selectedChipIndex !== null) {
removeRecipientWithKeyboard();
return;
}
scrollChipIntoView(moveChipSelection(-1));
return;
}
case 'Delete': {
if (bufferIsEmpty && selectedChipIndex !== null) {
event.preventDefault();
removeRecipientWithKeyboard();
}
return;
}
case 'ArrowDown': {
if (!isEditing && !isDropdownOpen && suggestions.length > 0) {
event.preventDefault();
openSuggestions();
}
return;
}
case 'ArrowLeft': {
if (!caretAtStart || isEditing || recipients.length === 0) {
return;
}
event.preventDefault();
scrollChipIntoView(moveChipSelection(-1));
return;
}
case 'ArrowRight': {
if (selectedChipIndex === null) {
return;
}
event.preventDefault();
scrollChipIntoView(moveChipSelection(1));
return;
}
case 'Escape': {
if (isDropdownOpen) {
closeSuggestions();
return;
}
if (isEditing) {
event.preventDefault();
cancelEditing();
return;
}
if (selectedChipIndex !== null) {
event.preventDefault();
clearChipSelection();
}
return;
}
default:
return;
}
};
const recipientsInput = (
<StyledInput
key="email-recipients-input"
ref={inputRef}
type="text"
autoComplete="off"
spellCheck={false}
role="combobox"
aria-expanded={isDropdownOpen}
aria-label={label}
placeholder={
recipients.length === 0 && !isEditing ? placeholder : undefined
}
value={inputValue}
onChange={(event) => handleInputChange(event.target.value)}
onKeyDown={handleInputKeyDown}
onPaste={handleInputPaste}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
onClick={handleInputClick}
/>
);
const rowChildren: ReactNode[] = recipients.map((recipient, chipIndex) => {
if (chipIndex === editingIndex) {
return recipientsInput;
}
const chipKey = getEmailRecipientKey(recipient.address);
const flashNonce =
chipFlash !== null && chipFlash.chipKey === chipKey
? chipFlash.nonce
: null;
return (
<div
key={flashNonce === null ? chipKey : `${chipKey}-flash-${flashNonce}`}
onMouseDown={(event) => event.preventDefault()}
>
<EmailRecipientsFieldChip
chipId={getChipId(chipIndex)}
dropdownId={`${focusId}-chip-menu-${chipKey}`}
recipient={recipient}
resolution={resolutionByRecipientKey.get(chipKey)}
isInvalid={invalidRecipientKeys.has(chipKey)}
selected={chipIndex === selectedChipIndex}
isFlashing={flashNonce !== null}
onEdit={() => handleChipEdit(chipIndex)}
onRemove={() => handleChipRemove(chipIndex)}
/>
</div>
);
});
if (!isEditing) {
rowChildren.push(recipientsInput);
}
return (
<FormFieldInputContainer>
<InputLabel>{label}</InputLabel>
<Dropdown
dropdownId={suggestionsDropdownId}
dropdownPlacement="bottom-start"
dropdownOffset={{ y: 4 }}
disableClickForClickableComponent
clickableComponentWidth="100%"
onClose={resetSelectedItem}
clickableComponent={
<StyledRowContainer onMouseDown={handleRowMouseDown}>
{rowChildren}
</StyledRowContainer>
}
dropdownComponents={
<EmailRecipientSuggestionsDropdownContent
suggestions={suggestions}
selectableListInstanceId={suggestionsDropdownId}
focusId={suggestionsDropdownId}
onPick={handlePickSuggestion}
/>
}
/>
</FormFieldInputContainer>
);
};
@@ -0,0 +1 @@
export const EMAIL_RECIPIENT_MEMBER_SUGGESTIONS_LIMIT = 3;
@@ -0,0 +1 @@
export const EMAIL_RECIPIENT_PEOPLE_SUGGESTIONS_LIMIT = 8;
@@ -0,0 +1,171 @@
import { act, renderHook } from '@testing-library/react';
import { useEmailRecipientsField } from '@/activities/emails/recipients/hooks/useEmailRecipientsField';
import { type EmailRecipient } from '@/activities/emails/recipients/types/EmailRecipient';
const setup = (initialRecipients: EmailRecipient[] = []) => {
const onChange = jest.fn();
const view = renderHook(
({ recipients }: { recipients: EmailRecipient[] }) =>
useEmailRecipientsField({ recipients, onChange }),
{ initialProps: { recipients: initialRecipients } },
);
return { view, onChange };
};
describe('useEmailRecipientsField', () => {
it('should commit typed input as parsed recipients', () => {
const { view, onChange } = setup();
act(() => {
view.result.current.setInputValue('Jane Doe <jane@example.com>');
});
act(() => {
view.result.current.commitInput();
});
expect(onChange).toHaveBeenCalledWith([
{ address: 'jane@example.com', displayName: 'Jane Doe' },
]);
expect(view.result.current.inputValue).toBe('');
});
it('should not call onChange when committing an empty buffer', () => {
const { view, onChange } = setup();
act(() => {
view.result.current.commitInput();
});
expect(onChange).not.toHaveBeenCalled();
});
it('should flash the existing chip instead of adding a duplicate', () => {
const { view, onChange } = setup([{ address: 'jane@example.com' }]);
act(() => {
view.result.current.setInputValue('JANE@example.com');
});
act(() => {
view.result.current.commitInput();
});
expect(onChange).toHaveBeenCalledWith([{ address: 'jane@example.com' }]);
expect(view.result.current.chipFlash?.chipKey).toBe('jane@example.com');
});
it('should replace the edited chip on commit', () => {
const { view, onChange } = setup([
{ address: 'a@example.com' },
{ address: 'b@example.com' },
]);
act(() => {
view.result.current.beginEditingChip(0);
});
expect(view.result.current.inputValue).toBe('a@example.com');
act(() => {
view.result.current.setInputValue('c@example.com');
});
act(() => {
view.result.current.commitInput();
});
expect(onChange).toHaveBeenCalledWith([
{ address: 'c@example.com', displayName: undefined },
{ address: 'b@example.com' },
]);
expect(view.result.current.editingIndex).toBeNull();
});
it('should remove the edited chip when committed empty', () => {
const { view, onChange } = setup([
{ address: 'a@example.com' },
{ address: 'b@example.com' },
]);
act(() => {
view.result.current.beginEditingChip(0);
});
act(() => {
view.result.current.setInputValue(' ');
});
act(() => {
view.result.current.commitInput();
});
expect(onChange).toHaveBeenCalledWith([{ address: 'b@example.com' }]);
});
it('should restore the chip untouched when editing is cancelled', () => {
const { view, onChange } = setup([{ address: 'a@example.com' }]);
act(() => {
view.result.current.beginEditingChip(0);
});
act(() => {
view.result.current.setInputValue('changed@example.com');
});
act(() => {
view.result.current.cancelEditing();
});
expect(onChange).not.toHaveBeenCalled();
expect(view.result.current.editingIndex).toBeNull();
expect(view.result.current.inputValue).toBe('');
});
it('should select the last chip and keep selection on the previous chip after keyboard removal', () => {
const { view, onChange } = setup([
{ address: 'a@example.com' },
{ address: 'b@example.com' },
]);
act(() => {
view.result.current.moveChipSelection(-1);
});
expect(view.result.current.selectedChipIndex).toBe(1);
act(() => {
view.result.current.removeRecipientWithKeyboard();
});
expect(onChange).toHaveBeenCalledWith([{ address: 'a@example.com' }]);
expect(view.result.current.selectedChipIndex).toBe(0);
});
it('should clear the selection when moving right past the last chip', () => {
const { view } = setup([{ address: 'a@example.com' }]);
act(() => {
view.result.current.moveChipSelection(-1);
});
act(() => {
view.result.current.moveChipSelection(1);
});
expect(view.result.current.selectedChipIndex).toBeNull();
});
it('should add a picked suggestion as a recipient', () => {
const { view, onChange } = setup([{ address: 'a@example.com' }]);
act(() => {
view.result.current.addRecipient({
address: 'jane@example.com',
displayName: 'Jane Doe',
});
});
expect(onChange).toHaveBeenCalledWith([
{ address: 'a@example.com' },
{ address: 'jane@example.com', displayName: 'Jane Doe' },
]);
expect(view.result.current.inputValue).toBe('');
});
});
@@ -0,0 +1,249 @@
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { EMAIL_RECIPIENT_MEMBER_SUGGESTIONS_LIMIT } from '@/activities/emails/recipients/constants/EmailRecipientMemberSuggestionsLimit';
import { EMAIL_RECIPIENT_PEOPLE_SUGGESTIONS_LIMIT } from '@/activities/emails/recipients/constants/EmailRecipientPeopleSuggestionsLimit';
import { type EmailComposerContextRecord } from '@/activities/emails/recipients/types/EmailComposerContextRecord';
import { type EmailRecipientPerson } from '@/activities/emails/recipients/types/EmailRecipientPerson';
import { getEmailRecipientKey } from '@/activities/emails/recipients/utils/getEmailRecipientKey';
import { getEmailRecipientPersonFromRecord } from '@/activities/emails/recipients/utils/getEmailRecipientPersonFromRecord';
import { isValidEmailRecipientAddress } from '@/activities/emails/recipients/utils/isValidEmailRecipientAddress';
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersState';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useObjectRecordSearchRecords } from '@/object-record/hooks/useObjectRecordSearchRecords';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
export type EmailRecipientSuggestion = {
suggestionId: string;
recipient: { address: string; displayName?: string };
label: string;
secondaryText: string;
avatarUrl: string | null;
avatarColorSeed: string;
};
type UseEmailRecipientSuggestionsArgs = {
searchInput: string;
excludedRecipientKeys: string[];
contextRecord?: EmailComposerContextRecord | null;
};
const getSuggestion = ({
suggestionId,
fullName,
address,
secondaryText,
avatarUrl,
avatarColorSeed,
}: {
suggestionId: string;
fullName: string;
address: string;
secondaryText: string;
avatarUrl: string | null;
avatarColorSeed: string;
}): EmailRecipientSuggestion => ({
suggestionId,
recipient: {
address,
displayName: isNonEmptyString(fullName) ? fullName : undefined,
},
label: isNonEmptyString(fullName) ? fullName : address,
secondaryText,
avatarUrl,
avatarColorSeed,
});
const getPersonSuggestion = (
person: EmailRecipientPerson,
): EmailRecipientSuggestion =>
getSuggestion({
suggestionId: `person-${person.id}`,
fullName: `${person.firstName} ${person.lastName}`.trim(),
address: person.primaryEmail,
secondaryText: person.primaryEmail,
avatarUrl: person.avatarUrl,
avatarColorSeed: person.id,
});
export const useEmailRecipientSuggestions = ({
searchInput,
excludedRecipientKeys,
contextRecord,
}: UseEmailRecipientSuggestionsArgs) => {
const currentWorkspaceMembers = useAtomStateValue(
currentWorkspaceMembersState,
);
const trimmedSearchInput = searchInput.trim();
const hasSearchInput = trimmedSearchInput.length > 0;
const isCompanyContext =
contextRecord?.objectNameSingular === CoreObjectNameSingular.Company;
const isPersonContext =
contextRecord?.objectNameSingular === CoreObjectNameSingular.Person;
const isOpportunityContext =
contextRecord?.objectNameSingular === CoreObjectNameSingular.Opportunity;
const { record: contextPerson } = useFindOneRecord({
objectNameSingular: CoreObjectNameSingular.Person,
objectRecordId: contextRecord?.recordId ?? '',
recordGqlFields: { id: true, companyId: true },
skip: !isPersonContext,
});
const { record: contextOpportunity } = useFindOneRecord({
objectNameSingular: CoreObjectNameSingular.Opportunity,
objectRecordId: contextRecord?.recordId ?? '',
recordGqlFields: { id: true, companyId: true },
skip: !isOpportunityContext,
});
const contextCompanyId = isCompanyContext
? (contextRecord?.recordId ?? null)
: (contextPerson?.companyId ?? contextOpportunity?.companyId ?? null);
const { records: contextPeopleRecords } = useFindManyRecords({
objectNameSingular: CoreObjectNameSingular.Person,
filter: { companyId: { eq: contextCompanyId ?? '' } },
recordGqlFields: { id: true, name: true, avatarUrl: true, emails: true },
limit: EMAIL_RECIPIENT_PEOPLE_SUGGESTIONS_LIMIT,
skip: !isDefined(contextCompanyId),
});
const { searchRecords } = useObjectRecordSearchRecords({
objectNameSingulars: [CoreObjectNameSingular.Person],
searchInput: hasSearchInput ? trimmedSearchInput : undefined,
limit: EMAIL_RECIPIENT_PEOPLE_SUGGESTIONS_LIMIT,
});
const searchedPersonIds = searchRecords.map(
(searchRecord) => searchRecord.recordId,
);
const { records: searchedPeopleRecords } = useFindManyRecords({
objectNameSingular: CoreObjectNameSingular.Person,
filter: { id: { in: searchedPersonIds } },
recordGqlFields: { id: true, name: true, avatarUrl: true, emails: true },
limit: EMAIL_RECIPIENT_PEOPLE_SUGGESTIONS_LIMIT,
skip: !hasSearchInput || searchedPersonIds.length === 0,
});
const excludedKeySet = new Set(excludedRecipientKeys);
const isSuggestablePerson = (person: EmailRecipientPerson) =>
isNonEmptyString(person.primaryEmail) &&
!excludedKeySet.has(getEmailRecipientKey(person.primaryEmail));
const contextPeople = contextPeopleRecords.map(
getEmailRecipientPersonFromRecord,
);
const searchedPeopleById = new Map(
searchedPeopleRecords.map((personRecord) => [
personRecord.id,
getEmailRecipientPersonFromRecord(personRecord),
]),
);
const orderedSearchedPeople = searchedPersonIds
.map((personId) => searchedPeopleById.get(personId))
.filter(isDefined);
const contextPersonIds = new Set(contextPeople.map((person) => person.id));
const orderedPeople = hasSearchInput
? [
...orderedSearchedPeople.filter((person) =>
contextPersonIds.has(person.id),
),
...orderedSearchedPeople.filter(
(person) => !contextPersonIds.has(person.id),
),
]
: contextPeople;
const peopleSuggestions = orderedPeople
.filter(isSuggestablePerson)
.map(getPersonSuggestion);
const memberSuggestions: EmailRecipientSuggestion[] = hasSearchInput
? filterBySearchQuery({
items: currentWorkspaceMembers.filter(
(workspaceMember) =>
isNonEmptyString(workspaceMember.userEmail) &&
!excludedKeySet.has(
getEmailRecipientKey(workspaceMember.userEmail),
),
),
searchQuery: trimmedSearchInput,
getSearchableValues: (workspaceMember) => [
`${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`.trim(),
workspaceMember.userEmail,
],
})
.slice(0, EMAIL_RECIPIENT_MEMBER_SUGGESTIONS_LIMIT)
.map((workspaceMember) =>
getSuggestion({
suggestionId: `workspace-member-${workspaceMember.id}`,
fullName:
`${workspaceMember.name.firstName} ${workspaceMember.name.lastName}`.trim(),
address: workspaceMember.userEmail,
secondaryText: `${workspaceMember.userEmail} · ${t`Team member`}`,
avatarUrl: workspaceMember.avatarUrl ?? null,
avatarColorSeed: workspaceMember.id,
}),
)
: [];
const seenRecipientKeys = new Set<string>();
const dedupedRecordSuggestions = [
...peopleSuggestions,
...memberSuggestions,
].filter((suggestion) => {
const recipientKey = getEmailRecipientKey(suggestion.recipient.address);
if (seenRecipientKeys.has(recipientKey)) {
return false;
}
seenRecipientKeys.add(recipientKey);
return true;
});
const literalKey = getEmailRecipientKey(trimmedSearchInput);
const bufferIsAddableAddress =
hasSearchInput &&
isValidEmailRecipientAddress(trimmedSearchInput) &&
!excludedKeySet.has(literalKey);
if (!bufferIsAddableAddress) {
return { suggestions: dedupedRecordSuggestions };
}
const exactMatchSuggestion = dedupedRecordSuggestions.find(
(suggestion) =>
getEmailRecipientKey(suggestion.recipient.address) === literalKey,
);
const firstSuggestion: EmailRecipientSuggestion = exactMatchSuggestion ?? {
suggestionId: 'literal',
recipient: { address: trimmedSearchInput },
label: trimmedSearchInput,
secondaryText: t`Use this email`,
avatarUrl: null,
avatarColorSeed: literalKey,
};
return {
suggestions: [
firstSuggestion,
...dedupedRecordSuggestions.filter(
(suggestion) => suggestion !== firstSuggestion,
),
],
};
};
@@ -0,0 +1,163 @@
import { useState } from 'react';
import { type EmailRecipient } from '@/activities/emails/recipients/types/EmailRecipient';
import { formatEmailRecipient } from '@/activities/emails/recipients/utils/formatEmailRecipient';
import { mergeEmailRecipients } from '@/activities/emails/recipients/utils/mergeEmailRecipients';
import { parseEmailRecipients } from '@/activities/emails/recipients/utils/parseEmailRecipients';
import { toSpliced } from '~/utils/array/toSpliced';
type UseEmailRecipientsFieldArgs = {
recipients: EmailRecipient[];
onChange: (recipients: EmailRecipient[]) => void;
};
type ChipFlash = {
chipKey: string;
nonce: number;
};
export const useEmailRecipientsField = ({
recipients,
onChange,
}: UseEmailRecipientsFieldArgs) => {
const [inputValue, setInputValue] = useState('');
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [selectedChipIndex, setSelectedChipIndex] = useState<number | null>(
null,
);
const [chipFlash, setChipFlash] = useState<ChipFlash | null>(null);
const isEditing = editingIndex !== null;
const flashChip = (chipKey: string) => {
setChipFlash((previousFlash) => ({
chipKey,
nonce: (previousFlash?.nonce ?? 0) + 1,
}));
};
const addRecipients = (
addedRecipients: EmailRecipient[],
replacedIndex: number | null,
) => {
const baseRecipients =
replacedIndex === null
? recipients
: toSpliced(recipients, replacedIndex, 1);
const { mergedRecipients, duplicateKeys } = mergeEmailRecipients(
baseRecipients,
addedRecipients,
replacedIndex ?? baseRecipients.length,
);
onChange(mergedRecipients);
const firstDuplicateKey = duplicateKeys[0];
if (firstDuplicateKey !== undefined) {
flashChip(firstDuplicateKey);
}
};
const commitRecipients = (committedRecipients: EmailRecipient[]) => {
if (editingIndex !== null && committedRecipients.length === 0) {
onChange(toSpliced(recipients, editingIndex, 1));
} else if (committedRecipients.length > 0) {
addRecipients(committedRecipients, editingIndex);
}
setEditingIndex(null);
setInputValue('');
};
const commitInput = () => {
commitRecipients(parseEmailRecipients(inputValue));
};
const addRecipient = (recipient: EmailRecipient) => {
commitRecipients([recipient]);
};
const beginEditingChip = (chipIndex: number) => {
setEditingIndex(chipIndex);
setInputValue(formatEmailRecipient(recipients[chipIndex]));
setSelectedChipIndex(null);
};
const cancelEditing = () => {
setEditingIndex(null);
setInputValue('');
};
const removeRecipientAtIndex = (chipIndex: number) => {
onChange(toSpliced(recipients, chipIndex, 1));
setSelectedChipIndex(null);
if (editingIndex !== null && chipIndex < editingIndex) {
setEditingIndex(editingIndex - 1);
}
};
const removeRecipientWithKeyboard = () => {
if (selectedChipIndex === null) {
return;
}
const removedIndex = selectedChipIndex;
onChange(toSpliced(recipients, removedIndex, 1));
const remainingCount = recipients.length - 1;
setSelectedChipIndex(
removedIndex > 0 && remainingCount > 0 ? removedIndex - 1 : null,
);
};
const clearChipSelection = () => {
setSelectedChipIndex(null);
};
const moveChipSelection = (direction: -1 | 1): number | null => {
if (recipients.length === 0) {
return null;
}
if (selectedChipIndex === null) {
if (direction === 1) {
return null;
}
const lastIndex = recipients.length - 1;
setSelectedChipIndex(lastIndex);
return lastIndex;
}
const nextIndex = selectedChipIndex + direction;
if (nextIndex >= recipients.length) {
setSelectedChipIndex(null);
return null;
}
const boundedIndex = Math.max(nextIndex, 0);
setSelectedChipIndex(boundedIndex);
return boundedIndex;
};
return {
inputValue,
setInputValue,
editingIndex,
isEditing,
selectedChipIndex,
chipFlash,
commitInput,
addRecipient,
addRecipients,
beginEditingChip,
cancelEditing,
removeRecipientAtIndex,
removeRecipientWithKeyboard,
clearChipSelection,
moveChipSelection,
};
};
@@ -0,0 +1,78 @@
import { MAX_EMAIL_RECIPIENTS } from 'twenty-shared/constants';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { escapeForIlike, isDefined } from 'twenty-shared/utils';
import { type EmailRecipient } from '@/activities/emails/recipients/types/EmailRecipient';
import { type EmailRecipientPerson } from '@/activities/emails/recipients/types/EmailRecipientPerson';
import { getEmailRecipientKey } from '@/activities/emails/recipients/utils/getEmailRecipientKey';
import { getEmailRecipientPersonFromRecord } from '@/activities/emails/recipients/utils/getEmailRecipientPersonFromRecord';
import { isValidEmailRecipientAddress } from '@/activities/emails/recipients/utils/isValidEmailRecipientAddress';
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersState';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { type PartialWorkspaceMember } from '@/settings/roles/types/RoleWithPartialMembers';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export type EmailRecipientResolution = {
person?: EmailRecipientPerson;
workspaceMember?: PartialWorkspaceMember;
};
export const useEmailRecipientsResolution = ({
recipients,
}: {
recipients: EmailRecipient[];
}) => {
const currentWorkspaceMembers = useAtomStateValue(
currentWorkspaceMembersState,
);
const recipientKeys = [
...new Set(
recipients
.filter((recipient) => isValidEmailRecipientAddress(recipient.address))
.map((recipient) => getEmailRecipientKey(recipient.address)),
),
];
const { records: matchedPeople } = useFindManyRecords({
objectNameSingular: CoreObjectNameSingular.Person,
filter: {
or: recipientKeys.map((recipientKey) => ({
emails: { primaryEmail: { ilike: escapeForIlike(recipientKey) } },
})),
},
recordGqlFields: { id: true, name: true, avatarUrl: true, emails: true },
limit: MAX_EMAIL_RECIPIENTS,
skip: recipientKeys.length === 0,
});
const recipientKeySet = new Set(recipientKeys);
const resolutionByRecipientKey = new Map<string, EmailRecipientResolution>();
for (const workspaceMember of currentWorkspaceMembers) {
const memberKey = getEmailRecipientKey(workspaceMember.userEmail ?? '');
if (
isDefined(workspaceMember.userEmail) &&
recipientKeySet.has(memberKey)
) {
resolutionByRecipientKey.set(memberKey, { workspaceMember });
}
}
for (const personRecord of matchedPeople) {
const person = getEmailRecipientPersonFromRecord(personRecord);
const personKey = getEmailRecipientKey(person.primaryEmail);
if (!recipientKeySet.has(personKey)) {
continue;
}
resolutionByRecipientKey.set(personKey, {
...resolutionByRecipientKey.get(personKey),
person,
});
}
return { resolutionByRecipientKey };
};
@@ -0,0 +1,4 @@
export type EmailComposerContextRecord = {
objectNameSingular: string;
recordId: string;
};
@@ -0,0 +1,4 @@
export type EmailRecipient = {
address: string;
displayName?: string;
};
@@ -0,0 +1,7 @@
export type EmailRecipientPerson = {
id: string;
firstName: string;
lastName: string;
avatarUrl: string | null;
primaryEmail: string;
};
@@ -0,0 +1,39 @@
import { formatEmailRecipient } from '@/activities/emails/recipients/utils/formatEmailRecipient';
import { parseEmailRecipients } from '@/activities/emails/recipients/utils/parseEmailRecipients';
describe('formatEmailRecipient', () => {
it('should return the bare address when there is no display name', () => {
expect(formatEmailRecipient({ address: 'jane@example.com' })).toBe(
'jane@example.com',
);
});
it('should format a display name with angle brackets', () => {
expect(
formatEmailRecipient({
address: 'jane@example.com',
displayName: 'Jane Doe',
}),
).toBe('Jane Doe <jane@example.com>');
});
it('should quote display names containing special characters', () => {
expect(
formatEmailRecipient({
address: 'jane@example.com',
displayName: 'Doe, Jane',
}),
).toBe('"Doe, Jane" <jane@example.com>');
});
it('should round trip through parseEmailRecipients', () => {
const recipient = {
address: 'jane@example.com',
displayName: 'Doe, Jane "JD"',
};
expect(parseEmailRecipients(formatEmailRecipient(recipient))).toEqual([
recipient,
]);
});
});
@@ -0,0 +1,92 @@
import { mergeEmailRecipients } from '@/activities/emails/recipients/utils/mergeEmailRecipients';
describe('mergeEmailRecipients', () => {
it('should append unique recipients at the given index', () => {
const { mergedRecipients, duplicateKeys } = mergeEmailRecipients(
[{ address: 'a@example.com' }],
[{ address: 'b@example.com' }],
1,
);
expect(mergedRecipients).toEqual([
{ address: 'a@example.com' },
{ address: 'b@example.com' },
]);
expect(duplicateKeys).toEqual([]);
});
it('should insert at an arbitrary position', () => {
const { mergedRecipients } = mergeEmailRecipients(
[{ address: 'a@example.com' }, { address: 'c@example.com' }],
[{ address: 'b@example.com' }],
1,
);
expect(mergedRecipients.map((recipient) => recipient.address)).toEqual([
'a@example.com',
'b@example.com',
'c@example.com',
]);
});
it('should report duplicates case-insensitively and keep the existing entry', () => {
const { mergedRecipients, duplicateKeys } = mergeEmailRecipients(
[{ address: 'jane@example.com' }],
[{ address: 'Jane@Example.com' }],
1,
);
expect(mergedRecipients).toEqual([{ address: 'jane@example.com' }]);
expect(duplicateKeys).toEqual(['jane@example.com']);
});
it('should upgrade the display name of an existing recipient from a duplicate', () => {
const { mergedRecipients } = mergeEmailRecipients(
[{ address: 'jane@example.com' }],
[{ address: 'jane@example.com', displayName: 'Jane Doe' }],
1,
);
expect(mergedRecipients).toEqual([
{ address: 'jane@example.com', displayName: 'Jane Doe' },
]);
});
it('should not overwrite an existing display name', () => {
const { mergedRecipients } = mergeEmailRecipients(
[{ address: 'jane@example.com', displayName: 'Jane' }],
[{ address: 'jane@example.com', displayName: 'Someone Else' }],
1,
);
expect(mergedRecipients).toEqual([
{ address: 'jane@example.com', displayName: 'Jane' },
]);
});
it('should dedupe within the added batch itself', () => {
const { mergedRecipients, duplicateKeys } = mergeEmailRecipients(
[],
[{ address: 'a@example.com' }, { address: 'A@example.com' }],
0,
);
expect(mergedRecipients).toEqual([{ address: 'a@example.com' }]);
expect(duplicateKeys).toEqual(['a@example.com']);
});
it('should upgrade a display name from a later duplicate within the batch', () => {
const { mergedRecipients } = mergeEmailRecipients(
[],
[
{ address: 'a@example.com' },
{ address: 'A@example.com', displayName: 'Aline' },
],
0,
);
expect(mergedRecipients).toEqual([
{ address: 'a@example.com', displayName: 'Aline' },
]);
});
});
@@ -0,0 +1,75 @@
import { parseEmailRecipients } from '@/activities/emails/recipients/utils/parseEmailRecipients';
describe('parseEmailRecipients', () => {
it('should parse a bare email address', () => {
expect(parseEmailRecipients('jane@example.com')).toEqual([
{ address: 'jane@example.com', displayName: undefined },
]);
});
it('should parse a display name with angle brackets', () => {
expect(parseEmailRecipients('Jane Doe <jane@example.com>')).toEqual([
{ address: 'jane@example.com', displayName: 'Jane Doe' },
]);
});
it('should parse a quoted display name containing a comma', () => {
expect(
parseEmailRecipients('"Doe, Jane" <jane@example.com>, bob@example.com'),
).toEqual([
{ address: 'jane@example.com', displayName: 'Doe, Jane' },
{ address: 'bob@example.com', displayName: undefined },
]);
});
it('should parse comma separated addresses', () => {
expect(parseEmailRecipients('a@example.com, b@example.com')).toEqual([
{ address: 'a@example.com', displayName: undefined },
{ address: 'b@example.com', displayName: undefined },
]);
});
it('should parse semicolon separated addresses', () => {
expect(parseEmailRecipients('a@example.com; b@example.com')).toEqual([
{ address: 'a@example.com', displayName: undefined },
{ address: 'b@example.com', displayName: undefined },
]);
});
it('should parse newline separated addresses', () => {
expect(parseEmailRecipients('a@example.com\nb@example.com')).toEqual([
{ address: 'a@example.com', displayName: undefined },
{ address: 'b@example.com', displayName: undefined },
]);
});
it('should skip empty entries between separators', () => {
expect(parseEmailRecipients('a@example.com,, ,b@example.com')).toEqual([
{ address: 'a@example.com', displayName: undefined },
{ address: 'b@example.com', displayName: undefined },
]);
});
it('should flatten address groups', () => {
expect(
parseEmailRecipients('Friends: a@example.com, b@example.com;'),
).toEqual([
{ address: 'a@example.com', displayName: undefined },
{ address: 'b@example.com', displayName: undefined },
]);
});
it('should return an empty list for empty groups', () => {
expect(parseEmailRecipients('undisclosed-recipients:;')).toEqual([]);
});
it('should return an empty list for whitespace only input', () => {
expect(parseEmailRecipients(' ')).toEqual([]);
});
it('should keep an invalid address so validation can flag it', () => {
expect(parseEmailRecipients('not-an-email')).toEqual([
{ address: 'not-an-email', displayName: undefined },
]);
});
});
@@ -0,0 +1,16 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type EmailRecipient } from '@/activities/emails/recipients/types/EmailRecipient';
export const formatEmailRecipient = (recipient: EmailRecipient): string => {
if (!isNonEmptyString(recipient.displayName)) {
return recipient.address;
}
const requiresQuoting = /[,;<>@"]/.test(recipient.displayName);
const formattedDisplayName = requiresQuoting
? `"${recipient.displayName.replaceAll('"', '\\"')}"`
: recipient.displayName;
return `${formattedDisplayName} <${recipient.address}>`;
};
@@ -0,0 +1,2 @@
export const getEmailRecipientKey = (address: string): string =>
address.trim().toLowerCase();
@@ -0,0 +1,12 @@
import { type EmailRecipientPerson } from '@/activities/emails/recipients/types/EmailRecipientPerson';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
export const getEmailRecipientPersonFromRecord = (
personRecord: ObjectRecord,
): EmailRecipientPerson => ({
id: personRecord.id,
firstName: personRecord.name?.firstName ?? '',
lastName: personRecord.name?.lastName ?? '',
avatarUrl: personRecord.avatarUrl ?? null,
primaryEmail: personRecord.emails?.primaryEmail ?? '',
});
@@ -0,0 +1,4 @@
import { emailSchema } from 'twenty-shared/utils';
export const isValidEmailRecipientAddress = (address: string): boolean =>
emailSchema.safeParse(address).success;
@@ -0,0 +1,79 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type EmailRecipient } from '@/activities/emails/recipients/types/EmailRecipient';
import { getEmailRecipientKey } from '@/activities/emails/recipients/utils/getEmailRecipientKey';
import { toSpliced } from '~/utils/array/toSpliced';
type MergeEmailRecipientsResult = {
mergedRecipients: EmailRecipient[];
duplicateKeys: string[];
};
export const mergeEmailRecipients = (
baseRecipients: EmailRecipient[],
addedRecipients: EmailRecipient[],
insertAtIndex: number,
): MergeEmailRecipientsResult => {
const baseRecipientKeys = new Set(
baseRecipients.map((baseRecipient) =>
getEmailRecipientKey(baseRecipient.address),
),
);
const uniqueAddedRecipients: EmailRecipient[] = [];
const uniqueAddedRecipientsByKey = new Map<string, EmailRecipient>();
const duplicateKeys: string[] = [];
const displayNameUpgrades = new Map<string, string>();
for (const addedRecipient of addedRecipients) {
const recipientKey = getEmailRecipientKey(addedRecipient.address);
if (baseRecipientKeys.has(recipientKey)) {
duplicateKeys.push(recipientKey);
if (isNonEmptyString(addedRecipient.displayName)) {
displayNameUpgrades.set(recipientKey, addedRecipient.displayName);
}
continue;
}
const alreadyAddedRecipient = uniqueAddedRecipientsByKey.get(recipientKey);
if (alreadyAddedRecipient !== undefined) {
duplicateKeys.push(recipientKey);
if (
isNonEmptyString(addedRecipient.displayName) &&
!isNonEmptyString(alreadyAddedRecipient.displayName)
) {
alreadyAddedRecipient.displayName = addedRecipient.displayName;
}
continue;
}
const uniqueAddedRecipient = { ...addedRecipient };
uniqueAddedRecipientsByKey.set(recipientKey, uniqueAddedRecipient);
uniqueAddedRecipients.push(uniqueAddedRecipient);
}
const upgradedBaseRecipients = baseRecipients.map((baseRecipient) => {
const upgradedDisplayName = displayNameUpgrades.get(
getEmailRecipientKey(baseRecipient.address),
);
return upgradedDisplayName !== undefined &&
!isNonEmptyString(baseRecipient.displayName)
? { ...baseRecipient, displayName: upgradedDisplayName }
: baseRecipient;
});
return {
mergedRecipients: toSpliced(
upgradedBaseRecipients,
insertAtIndex,
0,
...uniqueAddedRecipients,
),
duplicateKeys,
};
};
@@ -0,0 +1,26 @@
import addressparser from 'addressparser';
import { isNonEmptyString } from '@sniptt/guards';
import { type EmailRecipient } from '@/activities/emails/recipients/types/EmailRecipient';
export const parseEmailRecipients = (rawText: string): EmailRecipient[] => {
const normalizedText = rawText.replace(/\r?\n/g, ',');
try {
return addressparser(normalizedText)
.flatMap((parsedAddress) => parsedAddress.group ?? [parsedAddress])
.map((parsedAddress) =>
isNonEmptyString(parsedAddress.address)
? {
address: parsedAddress.address,
displayName: isNonEmptyString(parsedAddress.name)
? parsedAddress.name
: undefined,
}
: { address: parsedAddress.name.trim(), displayName: undefined },
)
.filter((recipient) => isNonEmptyString(recipient.address));
} catch {
return [];
}
};
@@ -0,0 +1,5 @@
import { type EmailRecipient } from '@/activities/emails/recipients/types/EmailRecipient';
export const serializeEmailRecipients = (
recipients: EmailRecipient[],
): string => recipients.map((recipient) => recipient.address).join(', ');
@@ -1,3 +1,6 @@
import { isNonEmptyString } from '@sniptt/guards';
import { formatEmailRecipient } from '@/activities/emails/recipients/utils/formatEmailRecipient';
import { type EmailDraftPrefill } from '@/activities/emails/types/EmailDraftPrefill';
import { type EmailThreadMessageWithSender } from '@/activities/emails/types/EmailThreadMessageWithSender';
import { MessageParticipantRole } from 'twenty-shared/types';
@@ -5,17 +8,23 @@ import { MessageParticipantRole } from 'twenty-shared/types';
export const getEmailDraftPrefillFromMessage = (
message: EmailThreadMessageWithSender,
): EmailDraftPrefill => {
const joinHandlesByRole = (role: MessageParticipantRole) =>
const joinRecipientsByRole = (role: MessageParticipantRole) =>
message.messageParticipants
.filter((participant) => participant.role === role)
.map((participant) => participant.handle)
.filter((participant) => isNonEmptyString(participant.handle))
.map((participant) =>
formatEmailRecipient({
address: participant.handle,
displayName: participant.displayName,
}),
)
.join(', ');
return {
messageId: message.id,
to: joinHandlesByRole(MessageParticipantRole.TO),
cc: joinHandlesByRole(MessageParticipantRole.CC),
bcc: joinHandlesByRole(MessageParticipantRole.BCC),
to: joinRecipientsByRole(MessageParticipantRole.TO),
cc: joinRecipientsByRole(MessageParticipantRole.CC),
bcc: joinRecipientsByRole(MessageParticipantRole.BCC),
subject: message.subject,
body: message.text,
};
@@ -66,6 +66,13 @@ export const ComposeEmailCommand = () => {
openComposeEmailInSidePanel({
connectedAccountId,
defaultTo,
contextRecord:
isDefined(objectNameSingular) && isDefined(singleSelectedRecordId)
? {
objectNameSingular,
recordId: singleSelectedRecordId,
}
: undefined,
});
};
@@ -1,18 +1,40 @@
import { styled } from '@linaria/react';
import { useContext } from 'react';
import { type MouseEvent, type ReactNode, useContext } from 'react';
import { IconX } from 'twenty-ui/icon';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const StyledChip = styled.div<{ deletable: boolean; danger: boolean }>`
const StyledChip = styled.div<{
deletable: boolean;
danger: boolean;
selected: boolean;
}>`
align-items: center;
background-color: ${({ danger }) =>
danger ? themeCssVariables.color.red3 : themeCssVariables.color.blue3};
border-color: ${({ danger }) =>
danger ? themeCssVariables.color.red5 : themeCssVariables.color.blue5};
background-color: ${({ danger, selected }) =>
selected
? danger
? themeCssVariables.color.red
: themeCssVariables.color.blue
: danger
? themeCssVariables.color.red3
: themeCssVariables.color.blue3};
border-color: ${({ danger, selected }) =>
selected
? danger
? themeCssVariables.color.red
: themeCssVariables.color.blue
: danger
? themeCssVariables.color.red5
: themeCssVariables.color.blue5};
border-radius: ${themeCssVariables.border.radius.smRound};
border-style: solid;
border-width: 1px;
box-sizing: border-box;
color: ${({ danger, selected }) =>
selected
? themeCssVariables.font.color.inverted
: danger
? themeCssVariables.color.red
: themeCssVariables.color.blue};
column-gap: ${themeCssVariables.spacing[1]};
corner-shape: round;
cursor: ${({ deletable }) => (deletable ? 'pointer' : 'default')};
@@ -20,18 +42,35 @@ const StyledChip = styled.div<{ deletable: boolean; danger: boolean }>`
flex-direction: row;
flex-shrink: 0;
height: 20px;
max-width: 100%;
padding-left: ${themeCssVariables.spacing[1]};
padding-right: ${({ deletable }) =>
deletable ? '0' : themeCssVariables.spacing[1]};
user-select: none;
white-space: nowrap;
@keyframes base-chip-flash {
0%,
100% {
filter: none;
}
50% {
filter: brightness(0.85);
}
}
&[data-flashing='true'] {
animation: base-chip-flash 300ms ease-in-out 2;
}
`;
const StyledLabel = styled.span<{ danger: boolean }>`
color: ${({ danger }) =>
danger ? themeCssVariables.color.red : themeCssVariables.color.blue};
const StyledLabel = styled.span<{ maxWidth?: number }>`
line-height: 140%;
max-width: ${({ maxWidth }) =>
maxWidth === undefined ? 'none' : `${maxWidth}px`};
overflow: hidden;
text-overflow: ellipsis;
`;
const StyledDelete = styled.button<{ danger: boolean }>`
@@ -41,8 +80,7 @@ const StyledDelete = styled.button<{ danger: boolean }>`
border-bottom-right-radius: ${themeCssVariables.border.radius.smRound};
border-top-right-radius: ${themeCssVariables.border.radius.smRound};
box-sizing: border-box;
color: ${({ danger }) =>
danger ? themeCssVariables.color.red : themeCssVariables.color.blue};
color: inherit;
corner-shape: round;
cursor: pointer;
display: flex;
@@ -61,29 +99,46 @@ const StyledDelete = styled.button<{ danger: boolean }>`
`;
type BaseChipProps = {
chipId?: string;
label: string;
title?: string;
onRemove?: () => void;
onRemove?: (event: MouseEvent) => void;
removeAriaLabel?: string;
danger?: boolean;
leftIcon?: React.ReactNode;
selected?: boolean;
isFlashing?: boolean;
onDoubleClick?: () => void;
maxLabelWidth?: number;
leftIcon?: ReactNode;
};
export const BaseChip = ({
chipId,
label,
title,
onRemove,
removeAriaLabel = 'Remove',
danger = false,
selected = false,
isFlashing = false,
onDoubleClick,
maxLabelWidth,
leftIcon,
}: BaseChipProps) => {
const { theme } = useContext(ThemeContext);
const isDeletable = onRemove !== undefined;
return (
<StyledChip deletable={isDeletable} danger={danger}>
<StyledChip
id={chipId}
deletable={isDeletable}
danger={danger}
selected={selected}
data-flashing={isFlashing}
onDoubleClick={onDoubleClick}
>
{leftIcon}
<StyledLabel title={title ?? label} danger={danger}>
<StyledLabel title={title ?? label} maxWidth={maxLabelWidth}>
{label}
</StyledLabel>
@@ -5,8 +5,10 @@ import { SidePanelPages } from 'twenty-shared/types';
import { type IconComponent, IconArrowBackUp, IconMail } from 'twenty-ui/icon';
import { v4 } from 'uuid';
import { type EmailComposerContextRecord } from '@/activities/emails/recipients/types/EmailComposerContextRecord';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { composeEmailConnectedAccountIdComponentState } from '@/side-panel/pages/compose-email/states/composeEmailConnectedAccountIdComponentState';
import { composeEmailContextRecordComponentState } from '@/side-panel/pages/compose-email/states/composeEmailContextRecordComponentState';
import { composeEmailDefaultInReplyToComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultInReplyToComponentState';
import { composeEmailDefaultSubjectComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultSubjectComponentState';
import { composeEmailDefaultToComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultToComponentState';
@@ -18,6 +20,7 @@ type OpenComposeEmailParams = {
defaultTo?: string;
defaultSubject?: string;
defaultInReplyTo?: string;
contextRecord?: EmailComposerContextRecord;
pageTitle?: string;
pageIcon?: IconComponent;
};
@@ -60,6 +63,13 @@ export const useOpenComposeEmailInSidePanel = () => {
params.defaultInReplyTo ?? '',
);
store.set(
composeEmailContextRecordComponentState.atomFamily({
instanceId: pageId,
}),
params.contextRecord ?? null,
);
navigateSidePanelMenu({
page: SidePanelPages.ComposeEmail,
pageTitle: params.pageTitle ?? (isReply ? t`Reply` : t`New Email`),
@@ -5,6 +5,7 @@ import { useEmailComposerState } from '@/activities/emails/hooks/useEmailCompose
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
import { useSidePanelHistory } from '@/side-panel/hooks/useSidePanelHistory';
import { composeEmailConnectedAccountIdComponentState } from '@/side-panel/pages/compose-email/states/composeEmailConnectedAccountIdComponentState';
import { composeEmailContextRecordComponentState } from '@/side-panel/pages/compose-email/states/composeEmailContextRecordComponentState';
import { composeEmailDefaultInReplyToComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultInReplyToComponentState';
import { composeEmailDefaultSubjectComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultSubjectComponentState';
import { composeEmailDefaultToComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultToComponentState';
@@ -43,6 +44,9 @@ export const SidePanelComposeEmailPage = () => {
const composeEmailDefaultInReplyTo = useAtomComponentStateValue(
composeEmailDefaultInReplyToComponentState,
);
const composeEmailContextRecord = useAtomComponentStateValue(
composeEmailContextRecordComponentState,
);
const { goBackFromSidePanel } = useSidePanelHistory();
@@ -74,7 +78,10 @@ export const SidePanelComposeEmailPage = () => {
return (
<StyledContainer>
<StyledContent>
<EmailComposerFields composerState={composerState} />
<EmailComposerFields
composerState={composerState}
contextRecord={composeEmailContextRecord}
/>
</StyledContent>
<SidePanelFooter
actions={[
@@ -0,0 +1,10 @@
import { type EmailComposerContextRecord } from '@/activities/emails/recipients/types/EmailComposerContextRecord';
import { SidePanelPageComponentInstanceContext } from '@/side-panel/states/contexts/SidePanelPageComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const composeEmailContextRecordComponentState =
createAtomComponentState<EmailComposerContextRecord | null>({
key: 'side-panel/compose-email-context-record',
defaultValue: null,
componentInstanceContext: SidePanelPageComponentInstanceContext,
});
@@ -8,7 +8,11 @@ import {
FileFolder,
ObjectRecord,
} from 'twenty-shared/types';
import { getLogoUrlFromDomainName, isDefined } from 'twenty-shared/utils';
import {
escapeForIlike,
getLogoUrlFromDomainName,
isDefined,
} from 'twenty-shared/utils';
import { Brackets, type ObjectLiteral } from 'typeorm';
import { type ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
@@ -33,7 +37,6 @@ import {
SearchExceptionCode,
} from 'src/engine/core-modules/search/exceptions/search.exception';
import { type RecordsWithObjectMetadataItem } from 'src/engine/core-modules/search/types/records-with-object-metadata-item';
import { escapeForIlike } from 'src/engine/core-modules/search/utils/escape-for-ilike';
import { formatSearchTerms } from 'src/engine/core-modules/search/utils/format-search-terms';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
@@ -180,6 +180,7 @@ export type LeafFilter =
| AddressFilter
| LinksFilter
| ActorFilter
| EmailsFilter
| PhonesFilter
| ArrayFilter
| RawJsonFilter
@@ -228,6 +228,7 @@ export { safeDecodeURIComponent } from './url/safeDecodeURIComponent';
export { uuidToBase36 } from './uuidToBase36';
export { assertIsDefinedOrThrow } from './validation/assertIsDefinedOrThrow';
export { emailSchema } from './validation/emailSchema';
export { escapeForIlike } from './validation/escapeForIlike';
export { isDefined } from './validation/isDefined';
export { isEmptyObject } from './validation/isEmptyObject';
export { isLabelIdentifierFieldMetadataTypes } from './validation/isLabelIdentifierFieldMetadataTypes';
@@ -1,4 +1,4 @@
import { escapeForIlike } from 'src/engine/core-modules/search/utils/escape-for-ilike';
import { escapeForIlike } from '@/utils/validation/escapeForIlike';
describe('escapeForIlike', () => {
it('should escape percent signs', () => {
+2
View File
@@ -52928,6 +52928,7 @@ __metadata:
"@tiptap/extensions": "npm:3.4.2"
"@tiptap/react": "npm:3.4.2"
"@tiptap/suggestion": "npm:3.4.2"
"@types/addressparser": "npm:^1.0.3"
"@types/deep-equal": "npm:^1.0.1"
"@types/file-saver": "npm:^2.0.7"
"@types/jest": "npm:^30.0.0"
@@ -52940,6 +52941,7 @@ __metadata:
"@vitest/coverage-istanbul": "npm:^4.1.0"
"@wyw-in-js/vite": "npm:^1.1.0"
"@xyflow/react": "npm:^12.4.2"
addressparser: "npm:1.0.1"
ai: "npm:6.0.97"
apollo-link-rest: "npm:^0.10.0-rc.2"
apollo-upload-client: "npm:^19.0.0"