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,5 +1,5 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { FocusEventHandler } from 'react'; import { FocusEventHandler, useId } from 'react';
import TextareaAutosize from 'react-textarea-autosize'; import TextareaAutosize from 'react-textarea-autosize';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
@@ -10,6 +10,7 @@ import { InputHotkeyScope } from '../types/InputHotkeyScope';
const MAX_ROWS = 5; const MAX_ROWS = 5;
export type TextAreaProps = { export type TextAreaProps = {
label?: string;
disabled?: boolean; disabled?: boolean;
minRows?: number; minRows?: number;
onChange?: (value: string) => void; onChange?: (value: string) => void;
@@ -18,6 +19,20 @@ export type TextAreaProps = {
className?: string; className?: string;
}; };
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
width: 100%;
`;
const StyledLabel = styled.label`
color: ${({ theme }) => theme.font.color.light};
display: block;
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(1)};
`;
const StyledTextArea = styled(TextareaAutosize)` const StyledTextArea = styled(TextareaAutosize)`
background-color: ${({ theme }) => theme.background.transparent.lighter}; background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium}; border: 1px solid ${({ theme }) => theme.border.color.medium};
@@ -48,6 +63,7 @@ const StyledTextArea = styled(TextareaAutosize)`
`; `;
export const TextArea = ({ export const TextArea = ({
label,
disabled, disabled,
placeholder, placeholder,
minRows = 1, minRows = 1,
@@ -57,6 +73,8 @@ export const TextArea = ({
}: TextAreaProps) => { }: TextAreaProps) => {
const computedMinRows = Math.min(minRows, MAX_ROWS); const computedMinRows = Math.min(minRows, MAX_ROWS);
const inputId = useId();
const { const {
goBackToPreviousHotkeyScope, goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope, setHotkeyScopeAndMemorizePreviousScope,
@@ -71,18 +89,23 @@ export const TextArea = ({
}; };
return ( return (
<StyledTextArea <StyledContainer>
placeholder={placeholder} {label && <StyledLabel htmlFor={inputId}>{label}</StyledLabel>}
maxRows={MAX_ROWS}
minRows={computedMinRows} <StyledTextArea
value={value} id={inputId}
onChange={(event) => placeholder={placeholder}
onChange?.(turnIntoEmptyStringIfWhitespacesOnly(event.target.value)) maxRows={MAX_ROWS}
} minRows={computedMinRows}
onFocus={handleFocus} value={value}
onBlur={handleBlur} onChange={(event) =>
disabled={disabled} onChange?.(turnIntoEmptyStringIfWhitespacesOnly(event.target.value))
className={className} }
/> onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
className={className}
/>
</StyledContainer>
); );
}; };

View File

@@ -1,7 +1,9 @@
import { useState } from 'react';
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import { ComponentDecorator } from 'twenty-ui'; import { ComponentDecorator } from 'twenty-ui';
import { expect } from '@storybook/jest';
import { userEvent, within } from '@storybook/test';
import { TextArea, TextAreaProps } from '../TextArea'; import { TextArea, TextAreaProps } from '../TextArea';
type RenderProps = TextAreaProps; type RenderProps = TextAreaProps;
@@ -37,3 +39,20 @@ export const Filled: Story = {
export const Disabled: Story = { export const Disabled: Story = {
args: { disabled: true, value: 'Lorem Ipsum' }, args: { disabled: true, value: 'Lorem Ipsum' },
}; };
export const WithLabel: Story = {
args: { label: 'My Textarea' },
play: async () => {
const canvas = within(document.body);
const label = await canvas.findByText('My Textarea');
expect(label).toBeVisible();
await userEvent.click(label);
const input = await canvas.findByRole('textbox');
expect(input).toHaveFocus();
},
};

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

View File

@@ -1,8 +1,9 @@
import { WorkflowDiagramBaseStepNode } from '@/workflow/components/WorkflowDiagramBaseStepNode'; import { WorkflowDiagramBaseStepNode } from '@/workflow/components/WorkflowDiagramBaseStepNode';
import { WorkflowDiagramStepNodeData } from '@/workflow/types/WorkflowDiagram'; import { WorkflowDiagramStepNodeData } from '@/workflow/types/WorkflowDiagram';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { IconCode, IconPlaylistAdd } from 'twenty-ui'; import { IconCode, IconMail, IconPlaylistAdd } from 'twenty-ui';
const StyledStepNodeLabelIconContainer = styled.div` const StyledStepNodeLabelIconContainer = styled.div`
align-items: center; align-items: center;
@@ -32,16 +33,33 @@ export const WorkflowDiagramStepNode = ({
</StyledStepNodeLabelIconContainer> </StyledStepNodeLabelIconContainer>
); );
} }
case 'condition': {
return null;
}
case 'action': { case 'action': {
return ( switch (data.actionType) {
<StyledStepNodeLabelIconContainer> case 'CODE': {
<IconCode size={theme.icon.size.sm} color={theme.color.orange} /> return (
</StyledStepNodeLabelIconContainer> <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 ( 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 { useGetManyServerlessFunctions } from '@/settings/serverless-functions/hooks/useGetManyServerlessFunctions';
import { Select, SelectOption } from '@/ui/input/components/Select'; 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 { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { IconCode, isDefined } from 'twenty-ui'; 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` const StyledTriggerSettings = styled.div`
padding: ${({ theme }) => theme.spacing(6)}; padding: ${({ theme }) => theme.spacing(6)};
display: flex; display: flex;
@@ -43,12 +13,12 @@ const StyledTriggerSettings = styled.div`
row-gap: ${({ theme }) => theme.spacing(4)}; row-gap: ${({ theme }) => theme.spacing(4)};
`; `;
export const WorkflowEditActionForm = ({ export const WorkflowEditActionFormServerlessFunction = ({
action, action,
onActionUpdate, onActionUpdate,
}: { }: {
action: WorkflowAction; action: WorkflowCodeStep;
onActionUpdate: (trigger: WorkflowAction) => void; onActionUpdate: (trigger: WorkflowCodeStep) => void;
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
@@ -67,19 +37,11 @@ export const WorkflowEditActionForm = ({
]; ];
return ( return (
<> <WorkflowEditActionFormBase
<StyledTriggerHeader> ActionIcon={<IconCode color={theme.color.orange} />}
<StyledTriggerHeaderIconContainer> actionTitle="Code - Serverless Function"
<IconCode color={theme.color.orange} /> actionType="Code"
</StyledTriggerHeaderIconContainer> >
<StyledTriggerHeaderTitle>
Code - Serverless Function
</StyledTriggerHeaderTitle>
<StyledTriggerHeaderType>Code</StyledTriggerHeaderType>
</StyledTriggerHeader>
<StyledTriggerSettings> <StyledTriggerSettings>
<Select <Select
dropdownId="workflow-edit-action-function" dropdownId="workflow-edit-action-function"
@@ -98,6 +60,6 @@ export const WorkflowEditActionForm = ({
}} }}
/> />
</StyledTriggerSettings> </StyledTriggerSettings>
</> </WorkflowEditActionFormBase>
); );
}; };

View File

@@ -1,10 +1,16 @@
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { workflowDiagramState } from '@/workflow/states/workflowDiagramState'; import { workflowDiagramState } from '@/workflow/states/workflowDiagramState';
import { workflowIdState } from '@/workflow/states/workflowIdState'; 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 { addCreateStepNodes } from '@/workflow/utils/addCreateStepNodes';
import { getWorkflowVersionDiagram } from '@/workflow/utils/getWorkflowVersionDiagram'; import { getWorkflowVersionDiagram } from '@/workflow/utils/getWorkflowVersionDiagram';
import { mergeWorkflowDiagrams } from '@/workflow/utils/mergeWorkflowDiagrams';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil'; import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui'; import { isDefined } from 'twenty-ui';
type WorkflowEffectProps = { type WorkflowEffectProps = {
@@ -23,6 +29,34 @@ export const WorkflowEffect = ({
setWorkflowId(workflowId); setWorkflowId(workflowId);
}, [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(() => { useEffect(() => {
const currentVersion = workflowWithCurrentVersion?.currentVersion; const currentVersion = workflowWithCurrentVersion?.currentVersion;
if (!isDefined(currentVersion)) { if (!isDefined(currentVersion)) {
@@ -31,12 +65,12 @@ export const WorkflowEffect = ({
return; return;
} }
const lastWorkflowDiagram = getWorkflowVersionDiagram(currentVersion); computeAndMergeNewWorkflowDiagram(currentVersion);
const workflowDiagramWithCreateStepNodes = }, [
addCreateStepNodes(lastWorkflowDiagram); computeAndMergeNewWorkflowDiagram,
setWorkflowDiagram,
setWorkflowDiagram(workflowDiagramWithCreateStepNodes); workflowWithCurrentVersion?.currentVersion,
}, [setWorkflowDiagram, workflowWithCurrentVersion?.currentVersion]); ]);
return null; return null;
}; };

View File

@@ -11,4 +11,9 @@ export const ACTIONS: Array<{
type: 'CODE', type: 'CODE',
icon: IconSettingsAutomation, icon: IconSettingsAutomation,
}, },
{
label: 'Send Email',
type: 'SEND_EMAIL',
icon: IconSettingsAutomation,
},
]; ];

View File

@@ -25,7 +25,7 @@ export const useUpdateWorkflowVersionStep = ({
workflowId: workflow.id, workflowId: workflow.id,
}); });
const updateStep = async (updatedStep: WorkflowStep) => { const updateStep = async <T extends WorkflowStep>(updatedStep: T) => {
if (!isDefined(workflow.currentVersion)) { if (!isDefined(workflow.currentVersion)) {
throw new Error('Can not update an undefined workflow version.'); throw new Error('Can not update an undefined workflow version.');
} }

View File

@@ -1,4 +1,4 @@
type WorkflowBaseSettingsType = { type BaseWorkflowStepSettings = {
errorHandlingOptions: { errorHandlingOptions: {
retryOnFailure: { retryOnFailure: {
value: boolean; value: boolean;
@@ -9,27 +9,42 @@ type WorkflowBaseSettingsType = {
}; };
}; };
export type WorkflowCodeSettingsType = WorkflowBaseSettingsType & { export type WorkflowCodeStepSettings = BaseWorkflowStepSettings & {
serverlessFunctionId: string; serverlessFunctionId: string;
}; };
export type WorkflowActionType = 'CODE'; export type WorkflowSendEmailStepSettings = BaseWorkflowStepSettings & {
subject?: string;
template?: string;
title?: string;
callToAction?: {
value: string;
href: string;
};
};
type CommonWorkflowAction = { type BaseWorkflowStep = {
id: string; id: string;
name: string; name: string;
valid: boolean; valid: boolean;
}; };
type WorkflowCodeAction = CommonWorkflowAction & { export type WorkflowCodeStep = BaseWorkflowStep & {
type: 'CODE'; type: 'CODE';
settings: WorkflowCodeSettingsType; settings: WorkflowCodeStepSettings;
}; };
export type WorkflowAction = WorkflowCodeAction; export type WorkflowSendEmailStep = BaseWorkflowStep & {
type: 'SEND_EMAIL';
settings: WorkflowSendEmailStepSettings;
};
export type WorkflowAction = WorkflowCodeStep | WorkflowSendEmailStep;
export type WorkflowStep = WorkflowAction; export type WorkflowStep = WorkflowAction;
export type WorkflowActionType = WorkflowAction['type'];
export type WorkflowStepType = WorkflowStep['type']; export type WorkflowStepType = WorkflowStep['type'];
export type WorkflowTriggerType = 'DATABASE_EVENT'; export type WorkflowTriggerType = 'DATABASE_EVENT';

View File

@@ -1,3 +1,4 @@
import { WorkflowActionType } from '@/workflow/types/Workflow';
import { Edge, Node } from '@xyflow/react'; import { Edge, Node } from '@xyflow/react';
export type WorkflowDiagramNode = Node<WorkflowDiagramNodeData>; export type WorkflowDiagramNode = Node<WorkflowDiagramNodeData>;
@@ -8,10 +9,16 @@ export type WorkflowDiagram = {
edges: Array<WorkflowDiagramEdge>; edges: Array<WorkflowDiagramEdge>;
}; };
export type WorkflowDiagramStepNodeData = { export type WorkflowDiagramStepNodeData =
nodeType: 'trigger' | 'condition' | 'action'; | {
label: string; nodeType: 'trigger' | 'condition';
}; label: string;
}
| {
nodeType: 'action';
actionType: WorkflowActionType;
label: string;
};
export type WorkflowDiagramCreateStepNodeData = { export type WorkflowDiagramCreateStepNodeData = {
nodeType: 'create-step'; nodeType: 'create-step';

View File

@@ -72,6 +72,7 @@ describe('generateWorkflowDiagram', () => {
for (const [index, step] of steps.entries()) { for (const [index, step] of steps.entries()) {
expect(stepNodes[index].data).toEqual({ expect(stepNodes[index].data).toEqual({
nodeType: 'action', nodeType: 'action',
actionType: 'CODE',
label: step.name, label: step.name,
}); });
} }

View File

@@ -0,0 +1,72 @@
import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram';
import { mergeWorkflowDiagrams } from '../mergeWorkflowDiagrams';
it('Preserves the properties defined in the previous version but not in the next one', () => {
const previousDiagram: WorkflowDiagram = {
nodes: [
{
data: { nodeType: 'action', label: '', actionType: 'CODE' },
id: '1',
position: { x: 0, y: 0 },
selected: true,
},
],
edges: [],
};
const nextDiagram: WorkflowDiagram = {
nodes: [
{
data: { nodeType: 'action', label: '', actionType: 'CODE' },
id: '1',
position: { x: 0, y: 0 },
},
],
edges: [],
};
expect(mergeWorkflowDiagrams(previousDiagram, nextDiagram)).toEqual({
nodes: [
{
data: { nodeType: 'action', label: '', actionType: 'CODE' },
id: '1',
position: { x: 0, y: 0 },
selected: true,
},
],
edges: [],
});
});
it('Replaces duplicated properties with the next value', () => {
const previousDiagram: WorkflowDiagram = {
nodes: [
{
data: { nodeType: 'action', label: '', actionType: 'CODE' },
id: '1',
position: { x: 0, y: 0 },
},
],
edges: [],
};
const nextDiagram: WorkflowDiagram = {
nodes: [
{
data: { nodeType: 'action', label: '2', actionType: 'CODE' },
id: '1',
position: { x: 0, y: 0 },
},
],
edges: [],
};
expect(mergeWorkflowDiagrams(previousDiagram, nextDiagram)).toEqual({
nodes: [
{
data: { nodeType: 'action', label: '2', actionType: 'CODE' },
id: '1',
position: { x: 0, y: 0 },
},
],
edges: [],
});
});

View File

@@ -0,0 +1,3 @@
export const assertUnreachable = (x: never, errorMessage?: string): never => {
throw new Error(errorMessage ?? "Didn't expect to get here.");
};

View File

@@ -33,6 +33,7 @@ export const generateWorkflowDiagram = ({
id: nodeId, id: nodeId,
data: { data: {
nodeType: 'action', nodeType: 'action',
actionType: step.type,
label: step.name, label: step.name,
}, },
position: { position: {

View File

@@ -26,6 +26,27 @@ export const getStepDefaultDefinition = (
}, },
}; };
} }
case 'SEND_EMAIL': {
return {
id: newStepId,
name: 'Send Email',
type: 'SEND_EMAIL',
valid: false,
settings: {
subject: 'hello',
title: 'hello',
template: '{{title}}',
errorHandlingOptions: {
continueOnFailure: {
value: false,
},
retryOnFailure: {
value: false,
},
},
},
};
}
default: { default: {
throw new Error(`Unknown type: ${type}`); throw new Error(`Unknown type: ${type}`);
} }

View File

@@ -0,0 +1,33 @@
import {
WorkflowDiagram,
WorkflowDiagramNode,
} from '@/workflow/types/WorkflowDiagram';
const nodePropertiesToPreserve: Array<keyof WorkflowDiagramNode> = ['selected'];
export const mergeWorkflowDiagrams = (
previousDiagram: WorkflowDiagram,
nextDiagram: WorkflowDiagram,
): WorkflowDiagram => {
const lastNodes = nextDiagram.nodes.map((nextNode) => {
const previousNode = previousDiagram.nodes.find(
(previousNode) => previousNode.id === nextNode.id,
);
const nodeWithPreservedProperties = nodePropertiesToPreserve.reduce(
(nodeToSet, propertyToPreserve) => {
return Object.assign(nodeToSet, {
[propertyToPreserve]: previousNode?.[propertyToPreserve],
});
},
{} as Partial<WorkflowDiagramNode>,
);
return Object.assign(nodeWithPreservedProperties, nextNode);
});
return {
nodes: lastNodes,
edges: nextDiagram.edges,
};
};

View File

@@ -1,14 +1,14 @@
import { WorkflowStep } from '@/workflow/types/Workflow'; import { WorkflowStep } from '@/workflow/types/Workflow';
import { findStepPositionOrThrow } from '@/workflow/utils/findStepPositionOrThrow'; import { findStepPositionOrThrow } from '@/workflow/utils/findStepPositionOrThrow';
export const replaceStep = ({ export const replaceStep = <T extends WorkflowStep>({
steps: stepsInitial, steps: stepsInitial,
stepId, stepId,
stepToReplace, stepToReplace,
}: { }: {
steps: Array<WorkflowStep>; steps: Array<WorkflowStep>;
stepId: string; stepId: string;
stepToReplace: Partial<Omit<WorkflowStep, 'id'>>; stepToReplace: Partial<Omit<T, 'id'>>;
}) => { }) => {
const steps = structuredClone(stepsInitial); const steps = structuredClone(stepsInitial);