mirror of
				https://github.com/lingble/twenty.git
				synced 2025-10-30 20:27:55 +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:
		 Baptiste Devessier
					Baptiste Devessier
				
			
				
					committed by
					
						 GitHub
						GitHub
					
				
			
			
				
	
			
			
			 GitHub
						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,7 +89,11 @@ export const TextArea = ({ | |||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|  |     <StyledContainer> | ||||||
|  |       {label && <StyledLabel htmlFor={inputId}>{label}</StyledLabel>} | ||||||
|  |  | ||||||
|       <StyledTextArea |       <StyledTextArea | ||||||
|  |         id={inputId} | ||||||
|         placeholder={placeholder} |         placeholder={placeholder} | ||||||
|         maxRows={MAX_ROWS} |         maxRows={MAX_ROWS} | ||||||
|         minRows={computedMinRows} |         minRows={computedMinRows} | ||||||
| @@ -84,5 +106,6 @@ export const TextArea = ({ | |||||||
|         disabled={disabled} |         disabled={disabled} | ||||||
|         className={className} |         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,7 +77,8 @@ export const RightDrawerWorkflowEditStepContent = ({ | |||||||
|     workflow, |     workflow, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   if (stepDefinition.type === 'trigger') { |   switch (stepDefinition.type) { | ||||||
|  |     case 'trigger': { | ||||||
|       return ( |       return ( | ||||||
|         <WorkflowEditTriggerForm |         <WorkflowEditTriggerForm | ||||||
|           trigger={stepDefinition.definition} |           trigger={stepDefinition.definition} | ||||||
| @@ -83,11 +86,30 @@ export const RightDrawerWorkflowEditStepContent = ({ | |||||||
|         /> |         /> | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|  |     case 'action': { | ||||||
|  |       switch (stepDefinition.definition.type) { | ||||||
|  |         case 'CODE': { | ||||||
|           return ( |           return ( | ||||||
|     <WorkflowEditActionForm |             <WorkflowEditActionFormServerlessFunction | ||||||
|               action={stepDefinition.definition} |               action={stepDefinition.definition} | ||||||
|               onActionUpdate={updateStep} |               onActionUpdate={updateStep} | ||||||
|             /> |             /> | ||||||
|           ); |           ); | ||||||
|  |         } | ||||||
|  |         case 'SEND_EMAIL': { | ||||||
|  |           return ( | ||||||
|  |             <WorkflowEditActionFormSendEmail | ||||||
|  |               action={stepDefinition.definition} | ||||||
|  |               onActionUpdate={updateStep} | ||||||
|  |             /> | ||||||
|  |           ); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return assertUnreachable( | ||||||
|  |     stepDefinition, | ||||||
|  |     `Unsupported step: ${JSON.stringify(stepDefinition)}`, | ||||||
|  |   ); | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -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': { | ||||||
|  |         switch (data.actionType) { | ||||||
|  |           case 'CODE': { | ||||||
|             return ( |             return ( | ||||||
|               <StyledStepNodeLabelIconContainer> |               <StyledStepNodeLabelIconContainer> | ||||||
|             <IconCode size={theme.icon.size.sm} color={theme.color.orange} /> |                 <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> |               </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'; |   | { | ||||||
|  |       nodeType: 'trigger' | 'condition'; | ||||||
|       label: string; |       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