mirror of
https://github.com/lingble/twenty.git
synced 2025-10-30 12:22:29 +00:00
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:
committed by
GitHub
parent
0d570caff5
commit
cde255a031
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,4 +11,9 @@ export const ACTIONS: Array<{
|
|||||||
type: 'CODE',
|
type: 'CODE',
|
||||||
icon: IconSettingsAutomation,
|
icon: IconSettingsAutomation,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Send Email',
|
||||||
|
type: 'SEND_EMAIL',
|
||||||
|
icon: IconSettingsAutomation,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const assertUnreachable = (x: never, errorMessage?: string): never => {
|
||||||
|
throw new Error(errorMessage ?? "Didn't expect to get here.");
|
||||||
|
};
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user