7417 workflows i can send emails using the email account (#7431)
- update `send-email.workflow-action.ts` so it send email via the google sdk - remove useless `workflow-action.email.ts` - add `send` authorization to google api scopes - update the front workflow email step form to provide a `connectedAccountId` from the available connected accounts - update the permissions of connected accounts: ask users to reconnect when selecting missing send permission 
This commit is contained in:
@ -0,0 +1 @@
|
||||
export const GMAIL_SEND_SCOPE = 'https://www.googleapis.com/auth/gmail.send';
|
||||
@ -13,5 +13,6 @@ export type ConnectedAccount = {
|
||||
authFailedAt: Date | null;
|
||||
messageChannels: MessageChannel[];
|
||||
calendarChannels: CalendarChannel[];
|
||||
scopes: string[] | null;
|
||||
__typename: 'ConnectedAccount';
|
||||
};
|
||||
|
||||
@ -12,11 +12,17 @@ export const useTriggerGoogleApisOAuth = () => {
|
||||
const [generateTransientToken] = useGenerateTransientTokenMutation();
|
||||
|
||||
const triggerGoogleApisOAuth = useCallback(
|
||||
async (
|
||||
redirectLocation?: AppPath,
|
||||
messageVisibility?: MessageChannelVisibility,
|
||||
calendarVisibility?: CalendarChannelVisibility,
|
||||
) => {
|
||||
async ({
|
||||
redirectLocation,
|
||||
messageVisibility,
|
||||
calendarVisibility,
|
||||
loginHint,
|
||||
}: {
|
||||
redirectLocation?: AppPath | string;
|
||||
messageVisibility?: MessageChannelVisibility;
|
||||
calendarVisibility?: CalendarChannelVisibility;
|
||||
loginHint?: string;
|
||||
} = {}) => {
|
||||
const authServerUrl = REACT_APP_SERVER_BASE_URL;
|
||||
|
||||
const transientToken = await generateTransientToken();
|
||||
@ -38,6 +44,8 @@ export const useTriggerGoogleApisOAuth = () => {
|
||||
? `&messageVisibility=${messageVisibility}`
|
||||
: '';
|
||||
|
||||
params += loginHint ? `&loginHint=${loginHint}` : '';
|
||||
|
||||
window.location.href = `${authServerUrl}/auth/google-apis?${params}`;
|
||||
},
|
||||
[generateTransientToken],
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import React, { MouseEvent, useMemo, useRef, useState } from 'react';
|
||||
import { IconChevronDown, IconComponent } from 'twenty-ui';
|
||||
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
@ -11,6 +11,7 @@ import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
|
||||
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
|
||||
|
||||
import { SelectHotkeyScope } from '../types/SelectHotkeyScope';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
export type SelectOption<Value extends string | number | null> = {
|
||||
value: Value;
|
||||
@ -18,6 +19,12 @@ export type SelectOption<Value extends string | number | null> = {
|
||||
Icon?: IconComponent;
|
||||
};
|
||||
|
||||
type CallToActionButton = {
|
||||
text: string;
|
||||
onClick: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
Icon?: IconComponent;
|
||||
};
|
||||
|
||||
export type SelectProps<Value extends string | number | null> = {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
@ -32,6 +39,7 @@ export type SelectProps<Value extends string | number | null> = {
|
||||
options: SelectOption<Value>[];
|
||||
value?: Value;
|
||||
withSearchInput?: boolean;
|
||||
callToActionButton?: CallToActionButton;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div<{ fullWidth?: boolean }>`
|
||||
@ -89,6 +97,7 @@ export const Select = <Value extends string | number | null>({
|
||||
options,
|
||||
value,
|
||||
withSearchInput,
|
||||
callToActionButton,
|
||||
}: SelectProps<Value>) => {
|
||||
const selectContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@ -97,8 +106,8 @@ export const Select = <Value extends string | number | null>({
|
||||
|
||||
const selectedOption =
|
||||
options.find(({ value: key }) => key === value) ||
|
||||
options[0] ||
|
||||
emptyOption;
|
||||
emptyOption ||
|
||||
options[0];
|
||||
const filteredOptions = useMemo(
|
||||
() =>
|
||||
searchInputValue
|
||||
@ -109,7 +118,9 @@ export const Select = <Value extends string | number | null>({
|
||||
[options, searchInputValue],
|
||||
);
|
||||
|
||||
const isDisabled = disabledFromProps || options.length <= 1;
|
||||
const isDisabled =
|
||||
disabledFromProps ||
|
||||
(options.length <= 1 && !isDefined(callToActionButton));
|
||||
|
||||
const { closeDropdown } = useDropdown(dropdownId);
|
||||
|
||||
@ -177,6 +188,18 @@ export const Select = <Value extends string | number | null>({
|
||||
))}
|
||||
</DropdownMenuItemsContainer>
|
||||
)}
|
||||
{!!callToActionButton && !!filteredOptions.length && (
|
||||
<DropdownMenuSeparator />
|
||||
)}
|
||||
{!!callToActionButton && (
|
||||
<DropdownMenuItemsContainer hasMaxHeight>
|
||||
<MenuItem
|
||||
onClick={callToActionButton.onClick}
|
||||
LeftIcon={callToActionButton.Icon}
|
||||
text={callToActionButton.text}
|
||||
/>
|
||||
</DropdownMenuItemsContainer>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
dropdownHotkeyScope={{ scope: SelectHotkeyScope.Select }}
|
||||
|
||||
@ -4,6 +4,7 @@ import { userEvent, within } from '@storybook/test';
|
||||
import { ComponentDecorator } from 'twenty-ui';
|
||||
|
||||
import { Select, SelectProps } from '../Select';
|
||||
import { IconPlus } from 'packages/twenty-ui';
|
||||
|
||||
type RenderProps = SelectProps<string | number | null>;
|
||||
|
||||
@ -56,3 +57,13 @@ export const Disabled: Story = {
|
||||
export const WithSearch: Story = {
|
||||
args: { withSearchInput: true },
|
||||
};
|
||||
|
||||
export const CallToActionButton: Story = {
|
||||
args: {
|
||||
callToActionButton: {
|
||||
onClick: () => {},
|
||||
Icon: IconPlus,
|
||||
text: 'Add action',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -4,10 +4,18 @@ import { WorkflowEditActionFormBase } from '@/workflow/components/WorkflowEditAc
|
||||
import { WorkflowSendEmailStep } from '@/workflow/types/Workflow';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useEffect } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import { IconMail } from 'twenty-ui';
|
||||
import { IconMail, IconPlus, isDefined } from 'twenty-ui';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { Select, SelectOption } from '@/ui/input/components/Select';
|
||||
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||
import { ConnectedAccount } from '@/accounts/types/ConnectedAccount';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth';
|
||||
import { workflowIdState } from '@/workflow/states/workflowIdState';
|
||||
import { GMAIL_SEND_SCOPE } from '@/accounts/constants/GmailSendScope';
|
||||
|
||||
const StyledTriggerSettings = styled.div`
|
||||
padding: ${({ theme }) => theme.spacing(6)};
|
||||
@ -28,6 +36,7 @@ type WorkflowEditActionFormSendEmailProps =
|
||||
};
|
||||
|
||||
type SendEmailFormData = {
|
||||
connectedAccountId: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
};
|
||||
@ -36,35 +45,70 @@ export const WorkflowEditActionFormSendEmail = (
|
||||
props: WorkflowEditActionFormSendEmailProps,
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
|
||||
const { triggerGoogleApisOAuth } = useTriggerGoogleApisOAuth();
|
||||
const workflowId = useRecoilValue(workflowIdState);
|
||||
const redirectUrl = `/object/workflow/${workflowId}`;
|
||||
|
||||
const form = useForm<SendEmailFormData>({
|
||||
defaultValues: {
|
||||
connectedAccountId: '',
|
||||
subject: '',
|
||||
body: '',
|
||||
},
|
||||
disabled: props.readonly,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.setValue('subject', props.action.settings.subject ?? '');
|
||||
form.setValue('body', props.action.settings.template ?? '');
|
||||
}, [props.action.settings.subject, props.action.settings.template, form]);
|
||||
|
||||
const saveAction = useDebouncedCallback((formData: SendEmailFormData) => {
|
||||
if (props.readonly === true) {
|
||||
const checkConnectedAccountScopes = async (
|
||||
connectedAccountId: string | null,
|
||||
) => {
|
||||
const connectedAccount = accounts.find(
|
||||
(account) => account.id === connectedAccountId,
|
||||
);
|
||||
if (!isDefined(connectedAccount)) {
|
||||
return;
|
||||
}
|
||||
const scopes = connectedAccount.scopes;
|
||||
if (
|
||||
!isDefined(scopes) ||
|
||||
!isDefined(scopes.find((scope) => scope === GMAIL_SEND_SCOPE))
|
||||
) {
|
||||
await triggerGoogleApisOAuth({
|
||||
redirectLocation: redirectUrl,
|
||||
loginHint: connectedAccount.handle,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
props.onActionUpdate({
|
||||
...props.action,
|
||||
settings: {
|
||||
...props.action.settings,
|
||||
title: formData.subject,
|
||||
subject: formData.subject,
|
||||
template: formData.body,
|
||||
},
|
||||
});
|
||||
}, 1_000);
|
||||
useEffect(() => {
|
||||
form.setValue(
|
||||
'connectedAccountId',
|
||||
props.action.settings.connectedAccountId ?? '',
|
||||
);
|
||||
form.setValue('subject', props.action.settings.subject ?? '');
|
||||
form.setValue('body', props.action.settings.body ?? '');
|
||||
}, [props.action.settings, form]);
|
||||
|
||||
const saveAction = useDebouncedCallback(
|
||||
async (formData: SendEmailFormData, checkScopes = false) => {
|
||||
if (props.readonly === true) {
|
||||
return;
|
||||
}
|
||||
props.onActionUpdate({
|
||||
...props.action,
|
||||
settings: {
|
||||
...props.action.settings,
|
||||
connectedAccountId: formData.connectedAccountId,
|
||||
subject: formData.subject,
|
||||
body: formData.body,
|
||||
},
|
||||
});
|
||||
if (checkScopes === true) {
|
||||
await checkConnectedAccountScopes(formData.connectedAccountId);
|
||||
}
|
||||
},
|
||||
1_000,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@ -72,52 +116,120 @@ export const WorkflowEditActionFormSendEmail = (
|
||||
};
|
||||
}, [saveAction]);
|
||||
|
||||
const handleSave = form.handleSubmit(saveAction);
|
||||
const handleSave = (checkScopes = false) =>
|
||||
form.handleSubmit((formData: SendEmailFormData) =>
|
||||
saveAction(formData, checkScopes),
|
||||
)();
|
||||
|
||||
const filter: { or: object[] } = {
|
||||
or: [
|
||||
{
|
||||
accountOwnerId: {
|
||||
eq: currentWorkspaceMember?.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (
|
||||
isDefined(props.action.settings.connectedAccountId) &&
|
||||
props.action.settings.connectedAccountId !== ''
|
||||
) {
|
||||
filter.or.push({
|
||||
id: {
|
||||
eq: props.action.settings.connectedAccountId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { records: accounts, loading } = useFindManyRecords<ConnectedAccount>({
|
||||
objectNameSingular: 'connectedAccount',
|
||||
filter,
|
||||
});
|
||||
|
||||
let emptyOption: SelectOption<string | null> = { label: 'None', value: null };
|
||||
const connectedAccountOptions: SelectOption<string | null>[] = [];
|
||||
|
||||
accounts.forEach((account) => {
|
||||
const selectOption = {
|
||||
label: account.handle,
|
||||
value: account.id,
|
||||
};
|
||||
if (account.accountOwnerId === currentWorkspaceMember?.id) {
|
||||
connectedAccountOptions.push(selectOption);
|
||||
} else {
|
||||
// This handle the case when the current connected account does not belong to the currentWorkspaceMember
|
||||
// In that case, current connected account email is displayed, but cannot be selected
|
||||
emptyOption = selectOption;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<WorkflowEditActionFormBase
|
||||
ActionIcon={<IconMail color={theme.color.blue} />}
|
||||
actionTitle="Send Email"
|
||||
actionType="Email"
|
||||
>
|
||||
<StyledTriggerSettings>
|
||||
<Controller
|
||||
name="subject"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
label="Subject"
|
||||
placeholder="Thank you for building such an awesome CRM!"
|
||||
value={field.value}
|
||||
disabled={field.disabled}
|
||||
onChange={(email) => {
|
||||
field.onChange(email);
|
||||
!loading && (
|
||||
<WorkflowEditActionFormBase
|
||||
ActionIcon={<IconMail color={theme.color.blue} />}
|
||||
actionTitle="Send Email"
|
||||
actionType="Email"
|
||||
>
|
||||
<StyledTriggerSettings>
|
||||
<Controller
|
||||
name="connectedAccountId"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
dropdownId="select-connected-account-id"
|
||||
label="Account"
|
||||
fullWidth
|
||||
emptyOption={emptyOption}
|
||||
value={field.value}
|
||||
options={connectedAccountOptions}
|
||||
callToActionButton={{
|
||||
onClick: () =>
|
||||
triggerGoogleApisOAuth({ redirectLocation: redirectUrl }),
|
||||
Icon: IconPlus,
|
||||
text: 'Add account',
|
||||
}}
|
||||
onChange={(connectedAccountId) => {
|
||||
field.onChange(connectedAccountId);
|
||||
handleSave(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="subject"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
label="Subject"
|
||||
placeholder="Enter email subject (use {{variable}} for dynamic content)"
|
||||
value={field.value}
|
||||
onChange={(email) => {
|
||||
field.onChange(email);
|
||||
handleSave();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
handleSave();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="body"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<TextArea
|
||||
label="Body"
|
||||
placeholder="Thank you so much!"
|
||||
value={field.value}
|
||||
minRows={4}
|
||||
disabled={field.disabled}
|
||||
onChange={(email) => {
|
||||
field.onChange(email);
|
||||
|
||||
handleSave();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</StyledTriggerSettings>
|
||||
</WorkflowEditActionFormBase>
|
||||
<Controller
|
||||
name="body"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<TextArea
|
||||
label="Body"
|
||||
placeholder="Enter email body (use {{variable}} for dynamic content)"
|
||||
value={field.value}
|
||||
minRows={4}
|
||||
onChange={(email) => {
|
||||
field.onChange(email);
|
||||
handleSave();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</StyledTriggerSettings>
|
||||
</WorkflowEditActionFormBase>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@ -14,13 +14,9 @@ export type WorkflowCodeStepSettings = BaseWorkflowStepSettings & {
|
||||
};
|
||||
|
||||
export type WorkflowSendEmailStepSettings = BaseWorkflowStepSettings & {
|
||||
connectedAccountId: string;
|
||||
subject?: string;
|
||||
template?: string;
|
||||
title?: string;
|
||||
callToAction?: {
|
||||
value: string;
|
||||
href: string;
|
||||
};
|
||||
body?: string;
|
||||
};
|
||||
|
||||
type BaseWorkflowStep = {
|
||||
|
||||
@ -33,9 +33,9 @@ export const getStepDefaultDefinition = (
|
||||
type: 'SEND_EMAIL',
|
||||
valid: false,
|
||||
settings: {
|
||||
subject: 'hello',
|
||||
title: 'hello',
|
||||
template: '{{title}}',
|
||||
connectedAccountId: '',
|
||||
subject: '',
|
||||
body: '',
|
||||
errorHandlingOptions: {
|
||||
continueOnFailure: {
|
||||
value: false,
|
||||
|
||||
@ -12,4 +12,5 @@ export type FeatureFlagKey =
|
||||
| 'IS_WORKSPACE_FAVORITE_ENABLED'
|
||||
| 'IS_SEARCH_ENABLED'
|
||||
| 'IS_QUERY_RUNNER_TWENTY_ORM_ENABLED'
|
||||
| 'IS_GMAIL_SEND_EMAIL_SCOPE_ENABLED'
|
||||
| 'IS_WORKSPACE_MIGRATED_FOR_SEARCH';
|
||||
|
||||
Reference in New Issue
Block a user