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",
|
"@tiptap/react": "3.4.2",
|
||||||
"@types/marked": "^6.0.0",
|
"@types/marked": "^6.0.0",
|
||||||
"@xyflow/react": "^12.4.2",
|
"@xyflow/react": "^12.4.2",
|
||||||
|
"addressparser": "1.0.1",
|
||||||
"ai": "6.0.97",
|
"ai": "6.0.97",
|
||||||
"apollo-link-rest": "^0.10.0-rc.2",
|
"apollo-link-rest": "^0.10.0-rc.2",
|
||||||
"apollo-upload-client": "^19.0.0",
|
"apollo-upload-client": "^19.0.0",
|
||||||
@@ -176,6 +177,7 @@
|
|||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@tiptap/suggestion": "3.4.2",
|
"@tiptap/suggestion": "3.4.2",
|
||||||
|
"@types/addressparser": "^1.0.3",
|
||||||
"@types/deep-equal": "^1.0.1",
|
"@types/deep-equal": "^1.0.1",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
|
|||||||
+39
-10
@@ -2,9 +2,11 @@ import { useQuery } from '@apollo/client/react';
|
|||||||
import { styled } from '@linaria/react';
|
import { styled } from '@linaria/react';
|
||||||
|
|
||||||
import { EmailAttachmentsField } from '@/activities/emails/components/EmailAttachmentsField';
|
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 { type EmailComposerState } from '@/activities/emails/types/EmailComposerState';
|
||||||
import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput';
|
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 { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
|
||||||
import { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts';
|
import { GET_MY_CONNECTED_ACCOUNTS } from '@/settings/accounts/graphql/queries/getMyConnectedAccounts';
|
||||||
import { Select } from '@/ui/input/components/Select';
|
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 = {
|
type EmailComposerFieldsProps = {
|
||||||
composerState: EmailComposerState;
|
composerState: EmailComposerState;
|
||||||
|
contextRecord?: EmailComposerContextRecord | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EmailComposerFields = ({
|
export const EmailComposerFields = ({
|
||||||
composerState,
|
composerState,
|
||||||
|
contextRecord,
|
||||||
}: EmailComposerFieldsProps) => {
|
}: EmailComposerFieldsProps) => {
|
||||||
const { data: accountsData } = useQuery<{
|
const { data: accountsData } = useQuery<{
|
||||||
myConnectedAccounts: { id: string; handle: string }[];
|
myConnectedAccounts: { id: string; handle: string }[];
|
||||||
@@ -58,6 +67,12 @@ export const EmailComposerFields = ({
|
|||||||
|
|
||||||
const hasMultipleAccounts = accountOptions.length > 1;
|
const hasMultipleAccounts = accountOptions.length > 1;
|
||||||
|
|
||||||
|
const allRecipientKeys = [
|
||||||
|
...composerState.to,
|
||||||
|
...composerState.cc,
|
||||||
|
...composerState.bcc,
|
||||||
|
].map((recipient) => getEmailRecipientKey(recipient.address));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledFieldsContainer>
|
<StyledFieldsContainer>
|
||||||
{hasMultipleAccounts && (
|
{hasMultipleAccounts && (
|
||||||
@@ -71,11 +86,14 @@ export const EmailComposerFields = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<StyledToRow>
|
<StyledToRow>
|
||||||
<FormMultiTextFieldInput
|
<EmailRecipientsFieldInput
|
||||||
label={t`To`}
|
label={t`To`}
|
||||||
defaultValue={composerState.initialTo}
|
|
||||||
onChange={composerState.setTo}
|
|
||||||
placeholder={t`Recipients`}
|
placeholder={t`Recipients`}
|
||||||
|
recipients={composerState.to}
|
||||||
|
onChange={composerState.setTo}
|
||||||
|
onSubmit={composerState.handleSend}
|
||||||
|
excludedSuggestionKeys={allRecipientKeys}
|
||||||
|
contextRecord={contextRecord}
|
||||||
/>
|
/>
|
||||||
{!composerState.showCcBcc && (
|
{!composerState.showCcBcc && (
|
||||||
<StyledCcBccToggle onClick={() => composerState.setShowCcBcc(true)}>
|
<StyledCcBccToggle onClick={() => composerState.setShowCcBcc(true)}>
|
||||||
@@ -85,20 +103,31 @@ export const EmailComposerFields = ({
|
|||||||
</StyledToRow>
|
</StyledToRow>
|
||||||
{composerState.showCcBcc && (
|
{composerState.showCcBcc && (
|
||||||
<>
|
<>
|
||||||
<FormMultiTextFieldInput
|
<EmailRecipientsFieldInput
|
||||||
label={t`Cc`}
|
label={t`Cc`}
|
||||||
defaultValue={composerState.initialCc}
|
|
||||||
onChange={composerState.setCc}
|
|
||||||
placeholder={t`Cc`}
|
placeholder={t`Cc`}
|
||||||
|
recipients={composerState.cc}
|
||||||
|
onChange={composerState.setCc}
|
||||||
|
onSubmit={composerState.handleSend}
|
||||||
|
excludedSuggestionKeys={allRecipientKeys}
|
||||||
|
contextRecord={contextRecord}
|
||||||
/>
|
/>
|
||||||
<FormMultiTextFieldInput
|
<EmailRecipientsFieldInput
|
||||||
label={t`Bcc`}
|
label={t`Bcc`}
|
||||||
defaultValue={composerState.initialBcc}
|
|
||||||
onChange={composerState.setBcc}
|
|
||||||
placeholder={t`Bcc`}
|
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
|
<FormTextFieldInput
|
||||||
label={t`Subject`}
|
label={t`Subject`}
|
||||||
defaultValue={composerState.initialSubject}
|
defaultValue={composerState.initialSubject}
|
||||||
|
|||||||
+4
@@ -29,6 +29,10 @@ export const useComposeEmailForTargetRecord = () => {
|
|||||||
openComposeEmailInSidePanel({
|
openComposeEmailInSidePanel({
|
||||||
connectedAccountId,
|
connectedAccountId,
|
||||||
defaultTo,
|
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 { type EmailAttachment } from 'twenty-shared/types';
|
||||||
|
|
||||||
import { useSendEmail } from '@/activities/emails/hooks/useSendEmail';
|
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';
|
import { type EmailDraftPrefill } from '@/activities/emails/types/EmailDraftPrefill';
|
||||||
|
|
||||||
type UseEmailComposerStateArgs = {
|
type UseEmailComposerStateArgs = {
|
||||||
@@ -14,11 +18,10 @@ type UseEmailComposerStateArgs = {
|
|||||||
onSent?: (messageThreadId: string | null) => void;
|
onSent?: (messageThreadId: string | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const countRecipients = (csv: string): number =>
|
const hasInvalidRecipient = (recipients: EmailRecipient[]): boolean =>
|
||||||
csv
|
recipients.some(
|
||||||
.split(',')
|
(recipient) => !isValidEmailRecipientAddress(recipient.address),
|
||||||
.map((entry) => entry.trim())
|
);
|
||||||
.filter((entry) => entry.length > 0).length;
|
|
||||||
|
|
||||||
export const useEmailComposerState = ({
|
export const useEmailComposerState = ({
|
||||||
connectedAccountId: initialConnectedAccountId,
|
connectedAccountId: initialConnectedAccountId,
|
||||||
@@ -37,9 +40,15 @@ export const useEmailComposerState = ({
|
|||||||
const [connectedAccountId, setConnectedAccountId] = useState(
|
const [connectedAccountId, setConnectedAccountId] = useState(
|
||||||
initialConnectedAccountId,
|
initialConnectedAccountId,
|
||||||
);
|
);
|
||||||
const [to, setTo] = useState(initialTo);
|
const [to, setTo] = useState<EmailRecipient[]>(() =>
|
||||||
const [cc, setCc] = useState(initialCc);
|
parseEmailRecipients(initialTo),
|
||||||
const [bcc, setBcc] = useState(initialBcc);
|
);
|
||||||
|
const [cc, setCc] = useState<EmailRecipient[]>(() =>
|
||||||
|
parseEmailRecipients(initialCc),
|
||||||
|
);
|
||||||
|
const [bcc, setBcc] = useState<EmailRecipient[]>(() =>
|
||||||
|
parseEmailRecipients(initialBcc),
|
||||||
|
);
|
||||||
const [subject, setSubject] = useState(initialSubject);
|
const [subject, setSubject] = useState(initialSubject);
|
||||||
const [body, setBody] = useState(initialBody);
|
const [body, setBody] = useState(initialBody);
|
||||||
const [showCcBcc, setShowCcBcc] = useState(
|
const [showCcBcc, setShowCcBcc] = useState(
|
||||||
@@ -49,33 +58,38 @@ export const useEmailComposerState = ({
|
|||||||
|
|
||||||
const { sendEmail, loading } = useSendEmail();
|
const { sendEmail, loading } = useSendEmail();
|
||||||
|
|
||||||
const recipientCount = useMemo(
|
const recipientCount = to.length + cc.length + bcc.length;
|
||||||
() => countRecipients(to) + countRecipients(cc) + countRecipients(bcc),
|
|
||||||
[to, cc, bcc],
|
|
||||||
);
|
|
||||||
|
|
||||||
const exceedsRecipientLimit = recipientCount > MAX_EMAIL_RECIPIENTS;
|
const exceedsRecipientLimit = recipientCount > MAX_EMAIL_RECIPIENTS;
|
||||||
|
|
||||||
|
const hasInvalidRecipients = useMemo(
|
||||||
|
() =>
|
||||||
|
hasInvalidRecipient(to) ||
|
||||||
|
hasInvalidRecipient(cc) ||
|
||||||
|
hasInvalidRecipient(bcc),
|
||||||
|
[to, cc, bcc],
|
||||||
|
);
|
||||||
|
|
||||||
const canSend =
|
const canSend =
|
||||||
to.trim().length > 0 &&
|
to.length > 0 &&
|
||||||
connectedAccountId.length > 0 &&
|
connectedAccountId.length > 0 &&
|
||||||
!loading &&
|
!loading &&
|
||||||
!exceedsRecipientLimit;
|
!exceedsRecipientLimit &&
|
||||||
|
!hasInvalidRecipients;
|
||||||
|
|
||||||
const handleSend = useCallback(async () => {
|
const handleSend = useCallback(async () => {
|
||||||
if (!to.trim() || !connectedAccountId || exceedsRecipientLimit) {
|
if (!canSend) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmedTo = to.trim();
|
const serializedCc = serializeEmailRecipients(cc);
|
||||||
const trimmedCc = cc.trim();
|
const serializedBcc = serializeEmailRecipients(bcc);
|
||||||
const trimmedBcc = bcc.trim();
|
|
||||||
|
|
||||||
const { success, messageThreadId } = await sendEmail({
|
const { success, messageThreadId } = await sendEmail({
|
||||||
connectedAccountId,
|
connectedAccountId,
|
||||||
to: trimmedTo,
|
to: serializeEmailRecipients(to),
|
||||||
cc: trimmedCc || undefined,
|
cc: serializedCc || undefined,
|
||||||
bcc: trimmedBcc || undefined,
|
bcc: serializedBcc || undefined,
|
||||||
subject,
|
subject,
|
||||||
body,
|
body,
|
||||||
inReplyTo: defaultInReplyTo,
|
inReplyTo: defaultInReplyTo,
|
||||||
@@ -87,6 +101,7 @@ export const useEmailComposerState = ({
|
|||||||
onSent?.(messageThreadId);
|
onSent?.(messageThreadId);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
canSend,
|
||||||
connectedAccountId,
|
connectedAccountId,
|
||||||
to,
|
to,
|
||||||
cc,
|
cc,
|
||||||
@@ -98,7 +113,6 @@ export const useEmailComposerState = ({
|
|||||||
files,
|
files,
|
||||||
sendEmail,
|
sendEmail,
|
||||||
onSent,
|
onSent,
|
||||||
exceedsRecipientLimit,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -121,9 +135,6 @@ export const useEmailComposerState = ({
|
|||||||
handleSend,
|
handleSend,
|
||||||
loading,
|
loading,
|
||||||
canSend,
|
canSend,
|
||||||
initialTo,
|
|
||||||
initialCc,
|
|
||||||
initialBcc,
|
|
||||||
initialSubject,
|
initialSubject,
|
||||||
initialBody,
|
initialBody,
|
||||||
recipientCount,
|
recipientCount,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { isNonEmptyString } from '@sniptt/guards';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { useEmailThread } from '@/activities/emails/hooks/useEmailThread';
|
import { useEmailThread } from '@/activities/emails/hooks/useEmailThread';
|
||||||
|
import { formatEmailRecipient } from '@/activities/emails/recipients/utils/formatEmailRecipient';
|
||||||
import {
|
import {
|
||||||
type ReplyContext,
|
type ReplyContext,
|
||||||
type ReplyContextReady,
|
type ReplyContextReady,
|
||||||
@@ -51,6 +53,12 @@ export const useReplyContext = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const senderHandle = lastSentMessage.sender?.handle ?? '';
|
const senderHandle = lastSentMessage.sender?.handle ?? '';
|
||||||
|
const replyTo = isNonEmptyString(senderHandle)
|
||||||
|
? formatEmailRecipient({
|
||||||
|
address: senderHandle,
|
||||||
|
displayName: lastSentMessage.sender?.displayName,
|
||||||
|
})
|
||||||
|
: '';
|
||||||
|
|
||||||
const rawSubject = lastSentMessage.subject ?? '';
|
const rawSubject = lastSentMessage.subject ?? '';
|
||||||
const subject = rawSubject.startsWith('Re: ')
|
const subject = rawSubject.startsWith('Re: ')
|
||||||
@@ -59,7 +67,7 @@ export const useReplyContext = (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
loading: false,
|
loading: false,
|
||||||
to: senderHandle,
|
to: replyTo,
|
||||||
subject,
|
subject,
|
||||||
inReplyTo: lastSentMessage.headerMessageId ?? '',
|
inReplyTo: lastSentMessage.headerMessageId ?? '',
|
||||||
connectedAccountId,
|
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 EmailDraftPrefill } from '@/activities/emails/types/EmailDraftPrefill';
|
||||||
import { type EmailThreadMessageWithSender } from '@/activities/emails/types/EmailThreadMessageWithSender';
|
import { type EmailThreadMessageWithSender } from '@/activities/emails/types/EmailThreadMessageWithSender';
|
||||||
import { MessageParticipantRole } from 'twenty-shared/types';
|
import { MessageParticipantRole } from 'twenty-shared/types';
|
||||||
@@ -5,17 +8,23 @@ import { MessageParticipantRole } from 'twenty-shared/types';
|
|||||||
export const getEmailDraftPrefillFromMessage = (
|
export const getEmailDraftPrefillFromMessage = (
|
||||||
message: EmailThreadMessageWithSender,
|
message: EmailThreadMessageWithSender,
|
||||||
): EmailDraftPrefill => {
|
): EmailDraftPrefill => {
|
||||||
const joinHandlesByRole = (role: MessageParticipantRole) =>
|
const joinRecipientsByRole = (role: MessageParticipantRole) =>
|
||||||
message.messageParticipants
|
message.messageParticipants
|
||||||
.filter((participant) => participant.role === role)
|
.filter((participant) => participant.role === role)
|
||||||
.map((participant) => participant.handle)
|
.filter((participant) => isNonEmptyString(participant.handle))
|
||||||
|
.map((participant) =>
|
||||||
|
formatEmailRecipient({
|
||||||
|
address: participant.handle,
|
||||||
|
displayName: participant.displayName,
|
||||||
|
}),
|
||||||
|
)
|
||||||
.join(', ');
|
.join(', ');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
to: joinHandlesByRole(MessageParticipantRole.TO),
|
to: joinRecipientsByRole(MessageParticipantRole.TO),
|
||||||
cc: joinHandlesByRole(MessageParticipantRole.CC),
|
cc: joinRecipientsByRole(MessageParticipantRole.CC),
|
||||||
bcc: joinHandlesByRole(MessageParticipantRole.BCC),
|
bcc: joinRecipientsByRole(MessageParticipantRole.BCC),
|
||||||
subject: message.subject,
|
subject: message.subject,
|
||||||
body: message.text,
|
body: message.text,
|
||||||
};
|
};
|
||||||
|
|||||||
+7
@@ -66,6 +66,13 @@ export const ComposeEmailCommand = () => {
|
|||||||
openComposeEmailInSidePanel({
|
openComposeEmailInSidePanel({
|
||||||
connectedAccountId,
|
connectedAccountId,
|
||||||
defaultTo,
|
defaultTo,
|
||||||
|
contextRecord:
|
||||||
|
isDefined(objectNameSingular) && isDefined(singleSelectedRecordId)
|
||||||
|
? {
|
||||||
|
objectNameSingular,
|
||||||
|
recordId: singleSelectedRecordId,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+70
-15
@@ -1,18 +1,40 @@
|
|||||||
import { styled } from '@linaria/react';
|
import { styled } from '@linaria/react';
|
||||||
import { useContext } from 'react';
|
import { type MouseEvent, type ReactNode, useContext } from 'react';
|
||||||
import { IconX } from 'twenty-ui/icon';
|
import { IconX } from 'twenty-ui/icon';
|
||||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
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;
|
align-items: center;
|
||||||
background-color: ${({ danger }) =>
|
background-color: ${({ danger, selected }) =>
|
||||||
danger ? themeCssVariables.color.red3 : themeCssVariables.color.blue3};
|
selected
|
||||||
border-color: ${({ danger }) =>
|
? danger
|
||||||
danger ? themeCssVariables.color.red5 : themeCssVariables.color.blue5};
|
? 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-radius: ${themeCssVariables.border.radius.smRound};
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
color: ${({ danger, selected }) =>
|
||||||
|
selected
|
||||||
|
? themeCssVariables.font.color.inverted
|
||||||
|
: danger
|
||||||
|
? themeCssVariables.color.red
|
||||||
|
: themeCssVariables.color.blue};
|
||||||
column-gap: ${themeCssVariables.spacing[1]};
|
column-gap: ${themeCssVariables.spacing[1]};
|
||||||
corner-shape: round;
|
corner-shape: round;
|
||||||
cursor: ${({ deletable }) => (deletable ? 'pointer' : 'default')};
|
cursor: ${({ deletable }) => (deletable ? 'pointer' : 'default')};
|
||||||
@@ -20,18 +42,35 @@ const StyledChip = styled.div<{ deletable: boolean; danger: boolean }>`
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
max-width: 100%;
|
||||||
padding-left: ${themeCssVariables.spacing[1]};
|
padding-left: ${themeCssVariables.spacing[1]};
|
||||||
padding-right: ${({ deletable }) =>
|
padding-right: ${({ deletable }) =>
|
||||||
deletable ? '0' : themeCssVariables.spacing[1]};
|
deletable ? '0' : themeCssVariables.spacing[1]};
|
||||||
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
white-space: nowrap;
|
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 }>`
|
const StyledLabel = styled.span<{ maxWidth?: number }>`
|
||||||
color: ${({ danger }) =>
|
|
||||||
danger ? themeCssVariables.color.red : themeCssVariables.color.blue};
|
|
||||||
line-height: 140%;
|
line-height: 140%;
|
||||||
|
max-width: ${({ maxWidth }) =>
|
||||||
|
maxWidth === undefined ? 'none' : `${maxWidth}px`};
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledDelete = styled.button<{ danger: boolean }>`
|
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-bottom-right-radius: ${themeCssVariables.border.radius.smRound};
|
||||||
border-top-right-radius: ${themeCssVariables.border.radius.smRound};
|
border-top-right-radius: ${themeCssVariables.border.radius.smRound};
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
color: ${({ danger }) =>
|
color: inherit;
|
||||||
danger ? themeCssVariables.color.red : themeCssVariables.color.blue};
|
|
||||||
corner-shape: round;
|
corner-shape: round;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -61,29 +99,46 @@ const StyledDelete = styled.button<{ danger: boolean }>`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
type BaseChipProps = {
|
type BaseChipProps = {
|
||||||
|
chipId?: string;
|
||||||
label: string;
|
label: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
onRemove?: () => void;
|
onRemove?: (event: MouseEvent) => void;
|
||||||
removeAriaLabel?: string;
|
removeAriaLabel?: string;
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
leftIcon?: React.ReactNode;
|
selected?: boolean;
|
||||||
|
isFlashing?: boolean;
|
||||||
|
onDoubleClick?: () => void;
|
||||||
|
maxLabelWidth?: number;
|
||||||
|
leftIcon?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BaseChip = ({
|
export const BaseChip = ({
|
||||||
|
chipId,
|
||||||
label,
|
label,
|
||||||
title,
|
title,
|
||||||
onRemove,
|
onRemove,
|
||||||
removeAriaLabel = 'Remove',
|
removeAriaLabel = 'Remove',
|
||||||
danger = false,
|
danger = false,
|
||||||
|
selected = false,
|
||||||
|
isFlashing = false,
|
||||||
|
onDoubleClick,
|
||||||
|
maxLabelWidth,
|
||||||
leftIcon,
|
leftIcon,
|
||||||
}: BaseChipProps) => {
|
}: BaseChipProps) => {
|
||||||
const { theme } = useContext(ThemeContext);
|
const { theme } = useContext(ThemeContext);
|
||||||
const isDeletable = onRemove !== undefined;
|
const isDeletable = onRemove !== undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledChip deletable={isDeletable} danger={danger}>
|
<StyledChip
|
||||||
|
id={chipId}
|
||||||
|
deletable={isDeletable}
|
||||||
|
danger={danger}
|
||||||
|
selected={selected}
|
||||||
|
data-flashing={isFlashing}
|
||||||
|
onDoubleClick={onDoubleClick}
|
||||||
|
>
|
||||||
{leftIcon}
|
{leftIcon}
|
||||||
<StyledLabel title={title ?? label} danger={danger}>
|
<StyledLabel title={title ?? label} maxWidth={maxLabelWidth}>
|
||||||
{label}
|
{label}
|
||||||
</StyledLabel>
|
</StyledLabel>
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { SidePanelPages } from 'twenty-shared/types';
|
|||||||
import { type IconComponent, IconArrowBackUp, IconMail } from 'twenty-ui/icon';
|
import { type IconComponent, IconArrowBackUp, IconMail } from 'twenty-ui/icon';
|
||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
|
|
||||||
|
import { type EmailComposerContextRecord } from '@/activities/emails/recipients/types/EmailComposerContextRecord';
|
||||||
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
|
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
|
||||||
import { composeEmailConnectedAccountIdComponentState } from '@/side-panel/pages/compose-email/states/composeEmailConnectedAccountIdComponentState';
|
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 { composeEmailDefaultInReplyToComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultInReplyToComponentState';
|
||||||
import { composeEmailDefaultSubjectComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultSubjectComponentState';
|
import { composeEmailDefaultSubjectComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultSubjectComponentState';
|
||||||
import { composeEmailDefaultToComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultToComponentState';
|
import { composeEmailDefaultToComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultToComponentState';
|
||||||
@@ -18,6 +20,7 @@ type OpenComposeEmailParams = {
|
|||||||
defaultTo?: string;
|
defaultTo?: string;
|
||||||
defaultSubject?: string;
|
defaultSubject?: string;
|
||||||
defaultInReplyTo?: string;
|
defaultInReplyTo?: string;
|
||||||
|
contextRecord?: EmailComposerContextRecord;
|
||||||
pageTitle?: string;
|
pageTitle?: string;
|
||||||
pageIcon?: IconComponent;
|
pageIcon?: IconComponent;
|
||||||
};
|
};
|
||||||
@@ -60,6 +63,13 @@ export const useOpenComposeEmailInSidePanel = () => {
|
|||||||
params.defaultInReplyTo ?? '',
|
params.defaultInReplyTo ?? '',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
store.set(
|
||||||
|
composeEmailContextRecordComponentState.atomFamily({
|
||||||
|
instanceId: pageId,
|
||||||
|
}),
|
||||||
|
params.contextRecord ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
navigateSidePanelMenu({
|
navigateSidePanelMenu({
|
||||||
page: SidePanelPages.ComposeEmail,
|
page: SidePanelPages.ComposeEmail,
|
||||||
pageTitle: params.pageTitle ?? (isReply ? t`Reply` : t`New Email`),
|
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 { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
|
||||||
import { useSidePanelHistory } from '@/side-panel/hooks/useSidePanelHistory';
|
import { useSidePanelHistory } from '@/side-panel/hooks/useSidePanelHistory';
|
||||||
import { composeEmailConnectedAccountIdComponentState } from '@/side-panel/pages/compose-email/states/composeEmailConnectedAccountIdComponentState';
|
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 { composeEmailDefaultInReplyToComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultInReplyToComponentState';
|
||||||
import { composeEmailDefaultSubjectComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultSubjectComponentState';
|
import { composeEmailDefaultSubjectComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultSubjectComponentState';
|
||||||
import { composeEmailDefaultToComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultToComponentState';
|
import { composeEmailDefaultToComponentState } from '@/side-panel/pages/compose-email/states/composeEmailDefaultToComponentState';
|
||||||
@@ -43,6 +44,9 @@ export const SidePanelComposeEmailPage = () => {
|
|||||||
const composeEmailDefaultInReplyTo = useAtomComponentStateValue(
|
const composeEmailDefaultInReplyTo = useAtomComponentStateValue(
|
||||||
composeEmailDefaultInReplyToComponentState,
|
composeEmailDefaultInReplyToComponentState,
|
||||||
);
|
);
|
||||||
|
const composeEmailContextRecord = useAtomComponentStateValue(
|
||||||
|
composeEmailContextRecordComponentState,
|
||||||
|
);
|
||||||
|
|
||||||
const { goBackFromSidePanel } = useSidePanelHistory();
|
const { goBackFromSidePanel } = useSidePanelHistory();
|
||||||
|
|
||||||
@@ -74,7 +78,10 @@ export const SidePanelComposeEmailPage = () => {
|
|||||||
return (
|
return (
|
||||||
<StyledContainer>
|
<StyledContainer>
|
||||||
<StyledContent>
|
<StyledContent>
|
||||||
<EmailComposerFields composerState={composerState} />
|
<EmailComposerFields
|
||||||
|
composerState={composerState}
|
||||||
|
contextRecord={composeEmailContextRecord}
|
||||||
|
/>
|
||||||
</StyledContent>
|
</StyledContent>
|
||||||
<SidePanelFooter
|
<SidePanelFooter
|
||||||
actions={[
|
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,
|
FileFolder,
|
||||||
ObjectRecord,
|
ObjectRecord,
|
||||||
} from 'twenty-shared/types';
|
} 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 { Brackets, type ObjectLiteral } from 'typeorm';
|
||||||
|
|
||||||
import { type ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
import { type ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||||
@@ -33,7 +37,6 @@ import {
|
|||||||
SearchExceptionCode,
|
SearchExceptionCode,
|
||||||
} from 'src/engine/core-modules/search/exceptions/search.exception';
|
} 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 { 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 { formatSearchTerms } from 'src/engine/core-modules/search/utils/format-search-terms';
|
||||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
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';
|
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ export type LeafFilter =
|
|||||||
| AddressFilter
|
| AddressFilter
|
||||||
| LinksFilter
|
| LinksFilter
|
||||||
| ActorFilter
|
| ActorFilter
|
||||||
|
| EmailsFilter
|
||||||
| PhonesFilter
|
| PhonesFilter
|
||||||
| ArrayFilter
|
| ArrayFilter
|
||||||
| RawJsonFilter
|
| RawJsonFilter
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ export { safeDecodeURIComponent } from './url/safeDecodeURIComponent';
|
|||||||
export { uuidToBase36 } from './uuidToBase36';
|
export { uuidToBase36 } from './uuidToBase36';
|
||||||
export { assertIsDefinedOrThrow } from './validation/assertIsDefinedOrThrow';
|
export { assertIsDefinedOrThrow } from './validation/assertIsDefinedOrThrow';
|
||||||
export { emailSchema } from './validation/emailSchema';
|
export { emailSchema } from './validation/emailSchema';
|
||||||
|
export { escapeForIlike } from './validation/escapeForIlike';
|
||||||
export { isDefined } from './validation/isDefined';
|
export { isDefined } from './validation/isDefined';
|
||||||
export { isEmptyObject } from './validation/isEmptyObject';
|
export { isEmptyObject } from './validation/isEmptyObject';
|
||||||
export { isLabelIdentifierFieldMetadataTypes } from './validation/isLabelIdentifierFieldMetadataTypes';
|
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', () => {
|
describe('escapeForIlike', () => {
|
||||||
it('should escape percent signs', () => {
|
it('should escape percent signs', () => {
|
||||||
@@ -52928,6 +52928,7 @@ __metadata:
|
|||||||
"@tiptap/extensions": "npm:3.4.2"
|
"@tiptap/extensions": "npm:3.4.2"
|
||||||
"@tiptap/react": "npm:3.4.2"
|
"@tiptap/react": "npm:3.4.2"
|
||||||
"@tiptap/suggestion": "npm:3.4.2"
|
"@tiptap/suggestion": "npm:3.4.2"
|
||||||
|
"@types/addressparser": "npm:^1.0.3"
|
||||||
"@types/deep-equal": "npm:^1.0.1"
|
"@types/deep-equal": "npm:^1.0.1"
|
||||||
"@types/file-saver": "npm:^2.0.7"
|
"@types/file-saver": "npm:^2.0.7"
|
||||||
"@types/jest": "npm:^30.0.0"
|
"@types/jest": "npm:^30.0.0"
|
||||||
@@ -52940,6 +52941,7 @@ __metadata:
|
|||||||
"@vitest/coverage-istanbul": "npm:^4.1.0"
|
"@vitest/coverage-istanbul": "npm:^4.1.0"
|
||||||
"@wyw-in-js/vite": "npm:^1.1.0"
|
"@wyw-in-js/vite": "npm:^1.1.0"
|
||||||
"@xyflow/react": "npm:^12.4.2"
|
"@xyflow/react": "npm:^12.4.2"
|
||||||
|
addressparser: "npm:1.0.1"
|
||||||
ai: "npm:6.0.97"
|
ai: "npm:6.0.97"
|
||||||
apollo-link-rest: "npm:^0.10.0-rc.2"
|
apollo-link-rest: "npm:^0.10.0-rc.2"
|
||||||
apollo-upload-client: "npm:^19.0.0"
|
apollo-upload-client: "npm:^19.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user