Workflow Send Email Node Multiple Recipients Support (#17458)
This PR adds support sending emails to multiple recipients Figma Reference: https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=88868-88963&t=Ya0csmNlN4xxczvV-11 Demo: https://github.com/user-attachments/assets/ecaeaaec-fe42-4fb5-96d3-a91d08b30148
This commit is contained in:
+128
-15
@@ -8,11 +8,16 @@ import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||
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 { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
|
||||
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
|
||||
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
|
||||
@@ -26,8 +31,10 @@ import { useEffect, useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { ConnectedAccountProvider, SettingsPath } from 'twenty-shared/types';
|
||||
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
|
||||
import { type EmailRecipients } from 'twenty-shared/workflow';
|
||||
import { IconPlus } from 'twenty-ui/display';
|
||||
import { type SelectOption } from 'twenty-ui/input';
|
||||
import { Button, type SelectOption } from 'twenty-ui/input';
|
||||
import { MenuItem } from 'twenty-ui/navigation';
|
||||
import { type JsonValue } from 'type-fest';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
@@ -58,7 +65,7 @@ type WorkflowFile = {
|
||||
|
||||
type SendEmailFormData = {
|
||||
connectedAccountId: string;
|
||||
email: string;
|
||||
recipients: Required<EmailRecipients>;
|
||||
subject: string;
|
||||
body: string;
|
||||
files: WorkflowFile[];
|
||||
@@ -82,14 +89,41 @@ export const WorkflowEditActionSendEmail = ({
|
||||
|
||||
const redirectUrl = `/object/workflow/${workflowVisualizerWorkflowId}`;
|
||||
|
||||
const [formData, setFormData] = useState<SendEmailFormData>({
|
||||
connectedAccountId: action.settings.input.connectedAccountId,
|
||||
email: action.settings.input.email,
|
||||
subject: action.settings.input.subject ?? '',
|
||||
body: action.settings.input.body ?? '',
|
||||
files: action.settings.input.files ?? [],
|
||||
const [formData, setFormData] = useState<SendEmailFormData>(() => {
|
||||
const inputRecipients = action.settings.input.recipients;
|
||||
|
||||
return {
|
||||
connectedAccountId: action.settings.input.connectedAccountId,
|
||||
recipients: {
|
||||
to: inputRecipients?.to ?? '',
|
||||
cc: inputRecipients?.cc ?? '',
|
||||
bcc: inputRecipients?.bcc ?? '',
|
||||
},
|
||||
subject: action.settings.input.subject ?? '',
|
||||
body: action.settings.input.body ?? '',
|
||||
files: action.settings.input.files ?? [],
|
||||
};
|
||||
});
|
||||
|
||||
const [visibleAdvancedFields, setVisibleAdvancedFields] = useState<{
|
||||
cc: boolean;
|
||||
bcc: boolean;
|
||||
}>(() => {
|
||||
const inputRecipients = action.settings.input.recipients;
|
||||
|
||||
return {
|
||||
cc: Boolean(inputRecipients?.cc),
|
||||
bcc: Boolean(inputRecipients?.bcc),
|
||||
};
|
||||
});
|
||||
|
||||
const { closeDropdown } = useCloseDropdown();
|
||||
|
||||
const advancedOptionsDropdownId = 'send-email-advanced-options';
|
||||
|
||||
const hasAvailableAdvancedOptions =
|
||||
!visibleAdvancedFields.cc || !visibleAdvancedFields.bcc;
|
||||
|
||||
const checkConnectedAccountScopes = async (
|
||||
connectedAccountId: string | null,
|
||||
) => {
|
||||
@@ -143,7 +177,7 @@ export const WorkflowEditActionSendEmail = ({
|
||||
...action.settings,
|
||||
input: {
|
||||
connectedAccountId: formData.connectedAccountId,
|
||||
email: formData.email,
|
||||
recipients: formData.recipients,
|
||||
subject: formData.subject,
|
||||
body: formData.body,
|
||||
files: formData.files,
|
||||
@@ -286,16 +320,95 @@ export const WorkflowEditActionSendEmail = ({
|
||||
dropdownOffset={{ y: parseInt(theme.spacing(1), 10) }}
|
||||
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
|
||||
/>
|
||||
<FormTextFieldInput
|
||||
label={t`Email`}
|
||||
placeholder={t`Enter receiver email`}
|
||||
<FormMultiTextFieldInput
|
||||
label={t`To`}
|
||||
placeholder={t`Enter emails, comma-separated`}
|
||||
readonly={actionOptions.readonly}
|
||||
defaultValue={formData.email}
|
||||
onChange={(email) => {
|
||||
handleFieldChange('email', email);
|
||||
defaultValue={formData.recipients.to}
|
||||
onChange={(value) => {
|
||||
handleFieldChange('recipients', {
|
||||
...formData.recipients,
|
||||
to: value,
|
||||
});
|
||||
}}
|
||||
VariablePicker={WorkflowVariablePicker}
|
||||
/>
|
||||
{visibleAdvancedFields.cc && (
|
||||
<FormMultiTextFieldInput
|
||||
label={t`CC`}
|
||||
placeholder={t`Enter CC emails, comma-separated`}
|
||||
readonly={actionOptions.readonly}
|
||||
defaultValue={formData.recipients.cc}
|
||||
onChange={(value) => {
|
||||
handleFieldChange('recipients', {
|
||||
...formData.recipients,
|
||||
cc: value,
|
||||
});
|
||||
}}
|
||||
VariablePicker={WorkflowVariablePicker}
|
||||
/>
|
||||
)}
|
||||
{visibleAdvancedFields.bcc && (
|
||||
<FormMultiTextFieldInput
|
||||
label={t`BCC`}
|
||||
placeholder={t`Enter BCC emails, comma-separated`}
|
||||
readonly={actionOptions.readonly}
|
||||
defaultValue={formData.recipients.bcc}
|
||||
onChange={(value) => {
|
||||
handleFieldChange('recipients', {
|
||||
...formData.recipients,
|
||||
bcc: value,
|
||||
});
|
||||
}}
|
||||
VariablePicker={WorkflowVariablePicker}
|
||||
/>
|
||||
)}
|
||||
{!actionOptions.readonly && hasAvailableAdvancedOptions && (
|
||||
<Dropdown
|
||||
dropdownId={advancedOptionsDropdownId}
|
||||
dropdownPlacement="bottom-start"
|
||||
clickableComponent={
|
||||
<Button
|
||||
title={t`Advanced options`}
|
||||
variant="secondary"
|
||||
accent="default"
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
dropdownComponents={
|
||||
<DropdownContent
|
||||
widthInPixels={GenericDropdownContentWidth.Medium}
|
||||
>
|
||||
<DropdownMenuItemsContainer>
|
||||
{!visibleAdvancedFields.cc && (
|
||||
<MenuItem
|
||||
text={t`Add CC`}
|
||||
onClick={() => {
|
||||
setVisibleAdvancedFields((prev) => ({
|
||||
...prev,
|
||||
cc: true,
|
||||
}));
|
||||
closeDropdown(advancedOptionsDropdownId);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!visibleAdvancedFields.bcc && (
|
||||
<MenuItem
|
||||
text={t`Add BCC`}
|
||||
onClick={() => {
|
||||
setVisibleAdvancedFields((prev) => ({
|
||||
...prev,
|
||||
bcc: true,
|
||||
}));
|
||||
closeDropdown(advancedOptionsDropdownId);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownContent>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<FormTextFieldInput
|
||||
label={t`Subject`}
|
||||
placeholder={t`Enter email subject`}
|
||||
|
||||
+13
-4
@@ -24,7 +24,11 @@ const DEFAULT_ACTION: WorkflowSendEmailAction = {
|
||||
settings: {
|
||||
input: {
|
||||
connectedAccountId: '',
|
||||
email: '',
|
||||
recipients: {
|
||||
to: '',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
},
|
||||
subject: '',
|
||||
body: '',
|
||||
files: [],
|
||||
@@ -49,7 +53,11 @@ const CONFIGURED_ACTION: WorkflowSendEmailAction = {
|
||||
settings: {
|
||||
input: {
|
||||
connectedAccountId: mockedConnectedAccounts[0].accountOwnerId,
|
||||
email: 'test@twenty.com',
|
||||
recipients: {
|
||||
to: 'test@twenty.com',
|
||||
cc: '',
|
||||
bcc: '',
|
||||
},
|
||||
subject: 'Welcome to Twenty!',
|
||||
body: 'Dear Tim,\n\nWelcome to Twenty! We are excited to have you on board.\n\nBest regards,\nThe Team',
|
||||
files: [],
|
||||
@@ -111,8 +119,10 @@ export const Default: Story = {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(await canvas.findByText('Account')).toBeVisible();
|
||||
expect(await canvas.findByText('To')).toBeVisible();
|
||||
expect(await canvas.findByText('Subject')).toBeVisible();
|
||||
expect(await canvas.findByText('Body')).toBeVisible();
|
||||
expect(await canvas.findByText('Advanced options')).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -127,8 +137,7 @@ export const Configured: Story = {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(await canvas.findByText('Account')).toBeVisible();
|
||||
expect(await canvas.findByText('Subject')).toBeVisible();
|
||||
expect(await canvas.findByText('Body')).toBeVisible();
|
||||
expect(await canvas.findByText('To')).toBeVisible();
|
||||
|
||||
const emailInput = await canvas.findByText('tim@twenty.com');
|
||||
expect(emailInput).toBeVisible();
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { BaseChip } from '@/object-record/record-field/ui/form-types/components/BaseChip';
|
||||
import styled from '@emotion/styled';
|
||||
import { NodeViewWrapper, type NodeViewProps } from '@tiptap/react';
|
||||
|
||||
const StyledWrapper = styled.span`
|
||||
display: inline-block;
|
||||
padding-inline: ${({ theme }) => theme.spacing(0.5)};
|
||||
`;
|
||||
|
||||
type WorkflowTextEditorTextChipProps = NodeViewProps;
|
||||
|
||||
export const WorkflowTextEditorTextChip = ({
|
||||
deleteNode,
|
||||
node,
|
||||
editor,
|
||||
}: WorkflowTextEditorTextChipProps) => {
|
||||
const text = node.attrs.text as string;
|
||||
|
||||
return (
|
||||
<NodeViewWrapper as={StyledWrapper} style={{ whiteSpace: 'nowrap' }}>
|
||||
<BaseChip
|
||||
label={text}
|
||||
onRemove={editor.isEditable ? deleteNode : undefined}
|
||||
/>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
};
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
import { getMultiItemFieldEditorContent } from '@/workflow/workflow-variables/utils/getMultiItemFieldEditorContent';
|
||||
import { parseMultiItemEditorContent } from '@/workflow/workflow-variables/utils/parseMultiItemEditorContent';
|
||||
|
||||
describe('getMultiItemFieldEditorContent', () => {
|
||||
it('should parse plain emails as textTags', () => {
|
||||
const result = getMultiItemFieldEditorContent(
|
||||
'first@example.com, second@example.com',
|
||||
);
|
||||
|
||||
expect(result.content?.[0]?.content).toEqual([
|
||||
{ type: 'textTag', attrs: { text: 'first@example.com' } },
|
||||
{ type: 'textTag', attrs: { text: 'second@example.com' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should parse standalone variables as variableTags', () => {
|
||||
const result = getMultiItemFieldEditorContent(
|
||||
'{{user.email}}, {{trigger.record.email}}',
|
||||
);
|
||||
|
||||
expect(result.content?.[0]?.content).toEqual([
|
||||
{ type: 'variableTag', attrs: { variable: '{{user.email}}' } },
|
||||
{ type: 'variableTag', attrs: { variable: '{{trigger.record.email}}' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle mixed emails and variables', () => {
|
||||
const result = getMultiItemFieldEditorContent(
|
||||
'{{trigger.record.email}}, sales@company.com, {{user.managerEmail}}',
|
||||
);
|
||||
|
||||
expect(result.content?.[0]?.content).toEqual([
|
||||
{ type: 'variableTag', attrs: { variable: '{{trigger.record.email}}' } },
|
||||
{ type: 'textTag', attrs: { text: 'sales@company.com' } },
|
||||
{ type: 'variableTag', attrs: { variable: '{{user.managerEmail}}' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should treat embedded variables as plain text (not standalone)', () => {
|
||||
const result = getMultiItemFieldEditorContent('hello {{user.email}}');
|
||||
|
||||
expect(result.content?.[0]?.content).toEqual([
|
||||
{ type: 'textTag', attrs: { text: 'hello {{user.email}}' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should skip empty entries from extra commas or whitespace', () => {
|
||||
const result = getMultiItemFieldEditorContent(
|
||||
'first@example.com, , , second@example.com, ',
|
||||
);
|
||||
|
||||
expect(result.content?.[0]?.content).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should roundtrip correctly with parseMultiItemEditorContent', () => {
|
||||
const original =
|
||||
'{{trigger.record.email}}, static@example.com, {{user.email}}';
|
||||
const content = getMultiItemFieldEditorContent(original);
|
||||
const serialized = parseMultiItemEditorContent(content);
|
||||
|
||||
expect(serialized).toBe(original);
|
||||
});
|
||||
});
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import type { JSONContent } from '@tiptap/react';
|
||||
|
||||
import { parseMultiItemEditorContent } from '@/workflow/workflow-variables/utils/parseMultiItemEditorContent';
|
||||
|
||||
describe('parseMultiItemEditorContent', () => {
|
||||
it('should serialize textTags and variableTags as comma-separated string', () => {
|
||||
const input: JSONContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'textTag', attrs: { text: 'static@example.com' } },
|
||||
{ type: 'variableTag', attrs: { variable: '{{user.email}}' } },
|
||||
{ type: 'textTag', attrs: { text: 'another@example.com' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(parseMultiItemEditorContent(input)).toBe(
|
||||
'static@example.com, {{user.email}}, another@example.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip empty tags and ignore non-tag nodes', () => {
|
||||
const input: JSONContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'textTag', attrs: { text: 'valid@example.com' } },
|
||||
{ type: 'textTag', attrs: { text: '' } },
|
||||
{ type: 'variableTag', attrs: {} },
|
||||
{ type: 'text', text: 'ignored plain text' },
|
||||
{ type: 'textTag', attrs: { text: 'another@example.com' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(parseMultiItemEditorContent(input)).toBe(
|
||||
'valid@example.com, another@example.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty string for empty or missing content', () => {
|
||||
expect(parseMultiItemEditorContent({ type: 'doc' })).toBe('');
|
||||
expect(parseMultiItemEditorContent({ type: 'doc', content: [] })).toBe('');
|
||||
});
|
||||
});
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
|
||||
import type { JSONContent } from '@tiptap/react';
|
||||
|
||||
export const getMultiItemFieldEditorContent = (
|
||||
rawContent: string,
|
||||
): JSONContent => {
|
||||
const paragraphContent: JSONContent[] = [];
|
||||
|
||||
const parts = rawContent.split(/,\s*/);
|
||||
|
||||
parts.forEach((part) => {
|
||||
const trimmedPart = part.trim();
|
||||
|
||||
if (trimmedPart.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isStandaloneVariableString(trimmedPart)) {
|
||||
paragraphContent.push({
|
||||
type: 'variableTag',
|
||||
attrs: { variable: trimmedPart },
|
||||
});
|
||||
} else {
|
||||
paragraphContent.push({
|
||||
type: 'textTag',
|
||||
attrs: { text: trimmedPart },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: paragraphContent,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
+4
@@ -23,6 +23,10 @@ export const parseEditorContent = (json: JSONContent): string => {
|
||||
return node.attrs?.variable || '';
|
||||
}
|
||||
|
||||
if (node.type === 'textTag') {
|
||||
return node.attrs?.text || '';
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import type { JSONContent } from '@tiptap/react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const isTagNode = (node: JSONContent): boolean => {
|
||||
return node.type === 'textTag' || node.type === 'variableTag';
|
||||
};
|
||||
|
||||
const getTagValue = (node: JSONContent): string => {
|
||||
if (node.type === 'textTag') {
|
||||
return node.attrs?.text || '';
|
||||
}
|
||||
if (node.type === 'variableTag') {
|
||||
return node.attrs?.variable || '';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
export const parseMultiItemEditorContent = (json: JSONContent): string => {
|
||||
const collectTags = (nodes: JSONContent[]): string[] => {
|
||||
const results: string[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
if (isTagNode(node)) {
|
||||
const value = getTagValue(node);
|
||||
if (value.length > 0) {
|
||||
results.push(value);
|
||||
}
|
||||
} else if (
|
||||
(node.type === 'paragraph' || node.type === 'doc') &&
|
||||
isDefined(node.content)
|
||||
) {
|
||||
results.push(...collectTags(node.content));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
if (isDefined(json.content)) {
|
||||
return collectTags(json.content).join(', ');
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { WorkflowTextEditorTextChip } from '@/workflow/workflow-variables/components/WorkflowTextEditorTextChip';
|
||||
import { Node } from '@tiptap/core';
|
||||
import { mergeAttributes, ReactNodeViewRenderer } from '@tiptap/react';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
textTag: {
|
||||
insertTextTag: (text: string) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const TextTag = Node.create({
|
||||
name: 'textTag',
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
atom: true,
|
||||
|
||||
addAttributes: () => ({
|
||||
text: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-text'),
|
||||
renderHTML: (attributes) => {
|
||||
return {
|
||||
'data-text': attributes.text,
|
||||
};
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
renderHTML: ({ node, HTMLAttributes }) => {
|
||||
const text = node.attrs.text as string;
|
||||
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-type': 'textTag',
|
||||
class: 'text-tag',
|
||||
}),
|
||||
text,
|
||||
];
|
||||
},
|
||||
|
||||
addNodeView: () => {
|
||||
return ReactNodeViewRenderer(WorkflowTextEditorTextChip);
|
||||
},
|
||||
|
||||
renderText: ({ node }) => {
|
||||
return node.attrs.text;
|
||||
},
|
||||
|
||||
addCommands: () => ({
|
||||
insertTextTag:
|
||||
(text: string) =>
|
||||
({ commands }) => {
|
||||
commands.insertContent({
|
||||
type: 'textTag',
|
||||
attrs: { text },
|
||||
});
|
||||
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user