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:
@@ -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",
|
||||
|
||||
+39
-10
@@ -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}
|
||||
|
||||
+4
@@ -29,6 +29,10 @@ export const useComposeEmailForTargetRecord = () => {
|
||||
openComposeEmailInSidePanel({
|
||||
connectedAccountId,
|
||||
defaultTo,
|
||||
contextRecord: {
|
||||
objectNameSingular: targetRecord.targetObjectNameSingular,
|
||||
recordId: targetRecord.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+36
-25
@@ -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,
|
||||
|
||||
+158
@@ -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>
|
||||
);
|
||||
};
|
||||
+47
@@ -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>
|
||||
);
|
||||
};
|
||||
+53
@@ -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>
|
||||
);
|
||||
};
|
||||
+107
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+558
@@ -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>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const EMAIL_RECIPIENT_MEMBER_SUGGESTIONS_LIMIT = 3;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const EMAIL_RECIPIENT_PEOPLE_SUGGESTIONS_LIMIT = 8;
|
||||
+171
@@ -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('');
|
||||
});
|
||||
});
|
||||
+249
@@ -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,
|
||||
),
|
||||
],
|
||||
};
|
||||
};
|
||||
+163
@@ -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,
|
||||
};
|
||||
};
|
||||
+78
@@ -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 };
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type EmailComposerContextRecord = {
|
||||
objectNameSingular: string;
|
||||
recordId: string;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export type EmailRecipient = {
|
||||
address: string;
|
||||
displayName?: string;
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export type EmailRecipientPerson = {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
avatarUrl: string | null;
|
||||
primaryEmail: string;
|
||||
};
|
||||
+39
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
+92
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
+75
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
+16
@@ -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}>`;
|
||||
};
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export const getEmailRecipientKey = (address: string): string =>
|
||||
address.trim().toLowerCase();
|
||||
+12
@@ -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 ?? '',
|
||||
});
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import { emailSchema } from 'twenty-shared/utils';
|
||||
|
||||
export const isValidEmailRecipientAddress = (address: string): boolean =>
|
||||
emailSchema.safeParse(address).success;
|
||||
+79
@@ -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,
|
||||
};
|
||||
};
|
||||
+26
@@ -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 [];
|
||||
}
|
||||
};
|
||||
+5
@@ -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(', ');
|
||||
+14
-5
@@ -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,
|
||||
};
|
||||
|
||||
+7
@@ -66,6 +66,13 @@ export const ComposeEmailCommand = () => {
|
||||
openComposeEmailInSidePanel({
|
||||
connectedAccountId,
|
||||
defaultTo,
|
||||
contextRecord:
|
||||
isDefined(objectNameSingular) && isDefined(singleSelectedRecordId)
|
||||
? {
|
||||
objectNameSingular,
|
||||
recordId: singleSelectedRecordId,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+70
-15
@@ -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`),
|
||||
|
||||
+8
-1
@@ -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={[
|
||||
|
||||
+10
@@ -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
-1
@@ -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', () => {
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user