Add workflow email action (#7279)

- Add the SAVE_EMAIL action. This action requires more setting
parameters than the Serverless Function action.
- Changed the way we computed the workflow diagram. It now preserves
some properties, like the `selected` property. That's necessary to not
close the right drawer when the workflow back-end data change.
- Added the possibility to set a label to a TextArea. This uses a
`<label>` HTML element and the `useId()` hook to create an id linking
the label with the input.
This commit is contained in:
Baptiste Devessier
2024-10-01 14:22:14 +02:00
committed by GitHub
parent 0d570caff5
commit cde255a031
19 changed files with 512 additions and 106 deletions

View File

@ -1,10 +1,12 @@
import { WorkflowEditActionForm } from '@/workflow/components/WorkflowEditActionForm';
import { WorkflowEditActionFormSendEmail } from '@/workflow/components/WorkflowEditActionFormSendEmail';
import { WorkflowEditActionFormServerlessFunction } from '@/workflow/components/WorkflowEditActionFormServerlessFunction';
import { WorkflowEditTriggerForm } from '@/workflow/components/WorkflowEditTriggerForm';
import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId';
import { useUpdateWorkflowVersionStep } from '@/workflow/hooks/useUpdateWorkflowVersionStep';
import { useUpdateWorkflowVersionTrigger } from '@/workflow/hooks/useUpdateWorkflowVersionTrigger';
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { findStepPositionOrThrow } from '@/workflow/utils/findStepPositionOrThrow';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
@ -75,19 +77,39 @@ export const RightDrawerWorkflowEditStepContent = ({
workflow,
});
if (stepDefinition.type === 'trigger') {
return (
<WorkflowEditTriggerForm
trigger={stepDefinition.definition}
onTriggerUpdate={updateTrigger}
/>
);
switch (stepDefinition.type) {
case 'trigger': {
return (
<WorkflowEditTriggerForm
trigger={stepDefinition.definition}
onTriggerUpdate={updateTrigger}
/>
);
}
case 'action': {
switch (stepDefinition.definition.type) {
case 'CODE': {
return (
<WorkflowEditActionFormServerlessFunction
action={stepDefinition.definition}
onActionUpdate={updateStep}
/>
);
}
case 'SEND_EMAIL': {
return (
<WorkflowEditActionFormSendEmail
action={stepDefinition.definition}
onActionUpdate={updateStep}
/>
);
}
}
}
}
return (
<WorkflowEditActionForm
action={stepDefinition.definition}
onActionUpdate={updateStep}
/>
return assertUnreachable(
stepDefinition,
`Unsupported step: ${JSON.stringify(stepDefinition)}`,
);
};

View File

@ -1,8 +1,9 @@
import { WorkflowDiagramBaseStepNode } from '@/workflow/components/WorkflowDiagramBaseStepNode';
import { WorkflowDiagramStepNodeData } from '@/workflow/types/WorkflowDiagram';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconCode, IconPlaylistAdd } from 'twenty-ui';
import { IconCode, IconMail, IconPlaylistAdd } from 'twenty-ui';
const StyledStepNodeLabelIconContainer = styled.div`
align-items: center;
@ -32,16 +33,33 @@ export const WorkflowDiagramStepNode = ({
</StyledStepNodeLabelIconContainer>
);
}
case 'condition': {
return null;
}
case 'action': {
return (
<StyledStepNodeLabelIconContainer>
<IconCode size={theme.icon.size.sm} color={theme.color.orange} />
</StyledStepNodeLabelIconContainer>
);
switch (data.actionType) {
case 'CODE': {
return (
<StyledStepNodeLabelIconContainer>
<IconCode
size={theme.icon.size.sm}
color={theme.color.orange}
/>
</StyledStepNodeLabelIconContainer>
);
}
case 'SEND_EMAIL': {
return (
<StyledStepNodeLabelIconContainer>
<IconMail size={theme.icon.size.sm} color={theme.color.blue} />
</StyledStepNodeLabelIconContainer>
);
}
}
}
}
return null;
return assertUnreachable(data);
};
return (

View File

@ -0,0 +1,61 @@
import styled from '@emotion/styled';
import React from 'react';
const StyledTriggerHeader = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
display: flex;
flex-direction: column;
padding: ${({ theme }) => theme.spacing(6)};
`;
const StyledTriggerHeaderTitle = styled.p`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
font-size: ${({ theme }) => theme.font.size.xl};
margin: ${({ theme }) => theme.spacing(3)} 0;
`;
const StyledTriggerHeaderType = styled.p`
color: ${({ theme }) => theme.font.color.tertiary};
margin: 0;
`;
const StyledTriggerHeaderIconContainer = styled.div`
align-self: flex-start;
display: flex;
justify-content: center;
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.light};
border-radius: ${({ theme }) => theme.border.radius.xs};
padding: ${({ theme }) => theme.spacing(1)};
`;
export const WorkflowEditActionFormBase = ({
ActionIcon,
actionTitle,
actionType,
children,
}: {
ActionIcon: React.ReactNode;
actionTitle: string;
actionType: string;
children: React.ReactNode;
}) => {
return (
<>
<StyledTriggerHeader>
<StyledTriggerHeaderIconContainer>
{ActionIcon}
</StyledTriggerHeaderIconContainer>
<StyledTriggerHeaderTitle>{actionTitle}</StyledTriggerHeaderTitle>
<StyledTriggerHeaderType>{actionType}</StyledTriggerHeaderType>
</StyledTriggerHeader>
{children}
</>
);
};

View File

@ -0,0 +1,109 @@
import { TextArea } from '@/ui/input/components/TextArea';
import { TextInput } from '@/ui/input/components/TextInput';
import { WorkflowEditActionFormBase } from '@/workflow/components/WorkflowEditActionFormBase';
import { WorkflowSendEmailStep } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { IconMail } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce';
const StyledTriggerSettings = styled.div`
padding: ${({ theme }) => theme.spacing(6)};
display: flex;
flex-direction: column;
row-gap: ${({ theme }) => theme.spacing(4)};
`;
type SendEmailFormData = {
subject: string;
body: string;
};
export const WorkflowEditActionFormSendEmail = ({
action,
onActionUpdate,
}: {
action: WorkflowSendEmailStep;
onActionUpdate: (action: WorkflowSendEmailStep) => void;
}) => {
const theme = useTheme();
const form = useForm<SendEmailFormData>({
defaultValues: {
subject: '',
body: '',
},
});
useEffect(() => {
form.setValue('subject', action.settings.subject ?? '');
form.setValue('body', action.settings.template ?? '');
}, [action.settings.subject, action.settings.template, form]);
const saveAction = useDebouncedCallback((formData: SendEmailFormData) => {
onActionUpdate({
...action,
settings: {
...action.settings,
title: formData.subject,
subject: formData.subject,
template: formData.body,
},
});
}, 1_000);
useEffect(() => {
return () => {
saveAction.flush();
};
}, [saveAction]);
const handleSave = form.handleSubmit(saveAction);
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}
onChange={(email) => {
field.onChange(email);
handleSave();
}}
/>
)}
/>
<Controller
name="body"
control={form.control}
render={({ field }) => (
<TextArea
label="Body"
placeholder="Thank you so much!"
value={field.value}
minRows={4}
onChange={(email) => {
field.onChange(email);
handleSave();
}}
/>
)}
/>
</StyledTriggerSettings>
</WorkflowEditActionFormBase>
);
};

View File

@ -1,41 +1,11 @@
import { useGetManyServerlessFunctions } from '@/settings/serverless-functions/hooks/useGetManyServerlessFunctions';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowAction } from '@/workflow/types/Workflow';
import { WorkflowEditActionFormBase } from '@/workflow/components/WorkflowEditActionFormBase';
import { WorkflowCodeStep } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconCode, isDefined } from 'twenty-ui';
const StyledTriggerHeader = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
display: flex;
flex-direction: column;
padding: ${({ theme }) => theme.spacing(6)};
`;
const StyledTriggerHeaderTitle = styled.p`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
font-size: ${({ theme }) => theme.font.size.xl};
margin: ${({ theme }) => theme.spacing(3)} 0;
`;
const StyledTriggerHeaderType = styled.p`
color: ${({ theme }) => theme.font.color.tertiary};
margin: 0;
`;
const StyledTriggerHeaderIconContainer = styled.div`
align-self: flex-start;
display: flex;
justify-content: center;
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.light};
border-radius: ${({ theme }) => theme.border.radius.xs};
padding: ${({ theme }) => theme.spacing(1)};
`;
const StyledTriggerSettings = styled.div`
padding: ${({ theme }) => theme.spacing(6)};
display: flex;
@ -43,12 +13,12 @@ const StyledTriggerSettings = styled.div`
row-gap: ${({ theme }) => theme.spacing(4)};
`;
export const WorkflowEditActionForm = ({
export const WorkflowEditActionFormServerlessFunction = ({
action,
onActionUpdate,
}: {
action: WorkflowAction;
onActionUpdate: (trigger: WorkflowAction) => void;
action: WorkflowCodeStep;
onActionUpdate: (trigger: WorkflowCodeStep) => void;
}) => {
const theme = useTheme();
@ -67,19 +37,11 @@ export const WorkflowEditActionForm = ({
];
return (
<>
<StyledTriggerHeader>
<StyledTriggerHeaderIconContainer>
<IconCode color={theme.color.orange} />
</StyledTriggerHeaderIconContainer>
<StyledTriggerHeaderTitle>
Code - Serverless Function
</StyledTriggerHeaderTitle>
<StyledTriggerHeaderType>Code</StyledTriggerHeaderType>
</StyledTriggerHeader>
<WorkflowEditActionFormBase
ActionIcon={<IconCode color={theme.color.orange} />}
actionTitle="Code - Serverless Function"
actionType="Code"
>
<StyledTriggerSettings>
<Select
dropdownId="workflow-edit-action-function"
@ -98,6 +60,6 @@ export const WorkflowEditActionForm = ({
}}
/>
</StyledTriggerSettings>
</>
</WorkflowEditActionFormBase>
);
};

View File

@ -1,10 +1,16 @@
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { workflowDiagramState } from '@/workflow/states/workflowDiagramState';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import {
WorkflowVersion,
WorkflowWithCurrentVersion,
} from '@/workflow/types/Workflow';
import { addCreateStepNodes } from '@/workflow/utils/addCreateStepNodes';
import { getWorkflowVersionDiagram } from '@/workflow/utils/getWorkflowVersionDiagram';
import { mergeWorkflowDiagrams } from '@/workflow/utils/mergeWorkflowDiagrams';
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui';
type WorkflowEffectProps = {
@ -23,6 +29,34 @@ export const WorkflowEffect = ({
setWorkflowId(workflowId);
}, [setWorkflowId, workflowId]);
const computeAndMergeNewWorkflowDiagram = useRecoilCallback(
({ snapshot, set }) => {
return (currentVersion: WorkflowVersion) => {
const previousWorkflowDiagram = getSnapshotValue(
snapshot,
workflowDiagramState,
);
const nextWorkflowDiagram = getWorkflowVersionDiagram(currentVersion);
let mergedWorkflowDiagram = nextWorkflowDiagram;
if (isDefined(previousWorkflowDiagram)) {
mergedWorkflowDiagram = mergeWorkflowDiagrams(
previousWorkflowDiagram,
nextWorkflowDiagram,
);
}
const workflowDiagramWithCreateStepNodes = addCreateStepNodes(
mergedWorkflowDiagram,
);
set(workflowDiagramState, workflowDiagramWithCreateStepNodes);
};
},
[],
);
useEffect(() => {
const currentVersion = workflowWithCurrentVersion?.currentVersion;
if (!isDefined(currentVersion)) {
@ -31,12 +65,12 @@ export const WorkflowEffect = ({
return;
}
const lastWorkflowDiagram = getWorkflowVersionDiagram(currentVersion);
const workflowDiagramWithCreateStepNodes =
addCreateStepNodes(lastWorkflowDiagram);
setWorkflowDiagram(workflowDiagramWithCreateStepNodes);
}, [setWorkflowDiagram, workflowWithCurrentVersion?.currentVersion]);
computeAndMergeNewWorkflowDiagram(currentVersion);
}, [
computeAndMergeNewWorkflowDiagram,
setWorkflowDiagram,
workflowWithCurrentVersion?.currentVersion,
]);
return null;
};