mirror of
				https://github.com/lingble/twenty.git
				synced 2025-10-30 20:27:55 +00:00 
			
		
		
		
	Create new steps in workflow editor (#6764)
This PR adds the possibility of creating new steps. For now, only actions are available. The steps are stored on the server, and the visualizer is reloaded to include them. Selecting a step opens the right drawer and shows its details. For now, it's only the id of the step, but in the future, it will be the parameters of the step. In the future we'll want to let users add steps at any point in the diagram. As a consequence, it's crucial to be able to walk in the tree that make the steps to find the correct place where to put the new step. I wrote a function that returns where the new step should be inserted. This function will become recursive once we get branching implemented. Things to mention: - Reactflow needs every node and edge to have a unique identifier. In this PR, I chose to use steps' id as nodes' id. That way, it's easy to move from a node to a step, which helps make operations on a step without resolving the step's id from the node's id.
This commit is contained in:
		 Baptiste Devessier
					Baptiste Devessier
				
			
				
					committed by
					
						 GitHub
						GitHub
					
				
			
			
				
	
			
			
			 GitHub
						GitHub
					
				
			
						parent
						
							26eba76fb5
						
					
				
				
					commit
					f7c99ddc7a
				
			| @@ -3,3 +3,14 @@ | |||||||
| // expect(element).toHaveTextContent(/react/i) | // expect(element).toHaveTextContent(/react/i) | ||||||
| // learn more: https://github.com/testing-library/jest-dom | // learn more: https://github.com/testing-library/jest-dom | ||||||
| import '@testing-library/jest-dom'; | import '@testing-library/jest-dom'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * The structuredClone global function is not available in jsdom, it needs to be mocked for now. | ||||||
|  |  * | ||||||
|  |  * The most naive way to mock structuredClone is to use JSON.stringify and JSON.parse. This works | ||||||
|  |  * for arguments with simple types like primitives, arrays and objects, but doesn't work with functions, | ||||||
|  |  * Map, Set, etc. | ||||||
|  |  */ | ||||||
|  | global.structuredClone = (val) => { | ||||||
|  |   return JSON.parse(JSON.stringify(val)); | ||||||
|  | }; | ||||||
|   | |||||||
| @@ -244,6 +244,17 @@ const testCases = [ | |||||||
|   { loc: AppPath.Impersonate, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: AppPath.InviteTeam }, |   { loc: AppPath.Impersonate, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: AppPath.InviteTeam }, | ||||||
|   { loc: AppPath.Impersonate, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: undefined }, |   { loc: AppPath.Impersonate, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: undefined }, | ||||||
|  |  | ||||||
|  |   { loc: AppPath.WorkflowShowPage, isLoggedIn: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: AppPath.PlanRequired }, | ||||||
|  |   { loc: AppPath.WorkflowShowPage, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' }, | ||||||
|  |   { loc: AppPath.WorkflowShowPage, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' }, | ||||||
|  |   { loc: AppPath.WorkflowShowPage, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.PastDue, onboardingStatus: OnboardingStatus.Completed, res: undefined }, | ||||||
|  |   { loc: AppPath.WorkflowShowPage, isLoggedIn: false, subscriptionStatus: undefined, onboardingStatus: undefined, res: AppPath.SignInUp }, | ||||||
|  |   { loc: AppPath.WorkflowShowPage, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.WorkspaceActivation, res: AppPath.CreateWorkspace }, | ||||||
|  |   { loc: AppPath.WorkflowShowPage, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.ProfileCreation, res: AppPath.CreateProfile }, | ||||||
|  |   { loc: AppPath.WorkflowShowPage, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.SyncEmail, res: AppPath.SyncEmails }, | ||||||
|  |   { loc: AppPath.WorkflowShowPage, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: AppPath.InviteTeam }, | ||||||
|  |   { loc: AppPath.WorkflowShowPage, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: undefined }, | ||||||
|  |  | ||||||
|   { loc: AppPath.Authorize, isLoggedIn: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: AppPath.PlanRequired }, |   { loc: AppPath.Authorize, isLoggedIn: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: AppPath.PlanRequired }, | ||||||
|   { loc: AppPath.Authorize, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' }, |   { loc: AppPath.Authorize, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' }, | ||||||
|   { loc: AppPath.Authorize, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' }, |   { loc: AppPath.Authorize, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' }, | ||||||
|   | |||||||
| @@ -30,4 +30,5 @@ export enum CoreObjectNameSingular { | |||||||
|   MessageThreadSubscriber = 'messageThreadSubscriber', |   MessageThreadSubscriber = 'messageThreadSubscriber', | ||||||
|   Workflow = 'workflow', |   Workflow = 'workflow', | ||||||
|   MessageChannelMessageAssociation = 'messageChannelMessageAssociation', |   MessageChannelMessageAssociation = 'messageChannelMessageAssociation', | ||||||
|  |   WorkflowVersion = 'workflowVersion', | ||||||
| } | } | ||||||
|   | |||||||
| @@ -254,6 +254,17 @@ const testCases = [ | |||||||
|   { loc: AppPath.Impersonate, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: true }, |   { loc: AppPath.Impersonate, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: true }, | ||||||
|   { loc: AppPath.Impersonate, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: false }, |   { loc: AppPath.Impersonate, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: false }, | ||||||
|  |  | ||||||
|  |   { loc: AppPath.WorkflowShowPage, isLogged: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: true }, | ||||||
|  |   { loc: AppPath.WorkflowShowPage, isLogged: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: false }, | ||||||
|  |   { loc: AppPath.WorkflowShowPage, isLogged: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: false }, | ||||||
|  |   { loc: AppPath.WorkflowShowPage, isLogged: true, subscriptionStatus: SubscriptionStatus.PastDue, onboardingStatus: OnboardingStatus.Completed, res: false }, | ||||||
|  |   { loc: AppPath.WorkflowShowPage, isLogged: false, subscriptionStatus: undefined, onboardingStatus: undefined, res: true }, | ||||||
|  |   { loc: AppPath.WorkflowShowPage, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.WorkspaceActivation, res: true }, | ||||||
|  |   { loc: AppPath.WorkflowShowPage, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.ProfileCreation, res: true }, | ||||||
|  |   { loc: AppPath.WorkflowShowPage, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.SyncEmail, res: true }, | ||||||
|  |   { loc: AppPath.WorkflowShowPage, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.InviteTeam, res: true }, | ||||||
|  |   { loc: AppPath.WorkflowShowPage, isLogged: true, subscriptionStatus: SubscriptionStatus.Active, onboardingStatus: OnboardingStatus.Completed, res: false }, | ||||||
|  |  | ||||||
|   { loc: AppPath.Authorize, isLogged: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: true }, |   { loc: AppPath.Authorize, isLogged: true, subscriptionStatus: undefined, onboardingStatus: OnboardingStatus.PlanRequired, res: true }, | ||||||
|   { loc: AppPath.Authorize, isLogged: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: false }, |   { loc: AppPath.Authorize, isLogged: true, subscriptionStatus: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: false }, | ||||||
|   { loc: AppPath.Authorize, isLogged: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: false }, |   { loc: AppPath.Authorize, isLogged: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: false }, | ||||||
|   | |||||||
| @@ -9,10 +9,11 @@ import { isRightDrawerMinimizedState } from '@/ui/layout/right-drawer/states/isR | |||||||
|  |  | ||||||
| import { RightDrawerTopBar } from '@/ui/layout/right-drawer/components/RightDrawerTopBar'; | import { RightDrawerTopBar } from '@/ui/layout/right-drawer/components/RightDrawerTopBar'; | ||||||
| import { ComponentByRightDrawerPage } from '@/ui/layout/right-drawer/types/ComponentByRightDrawerPage'; | import { ComponentByRightDrawerPage } from '@/ui/layout/right-drawer/types/ComponentByRightDrawerPage'; | ||||||
|  | import { RightDrawerWorkflowEditStep } from '@/workflow/components/RightDrawerWorkflowEditStep'; | ||||||
|  | import { RightDrawerWorkflowSelectAction } from '@/workflow/components/RightDrawerWorkflowSelectAction'; | ||||||
| import { isDefined } from 'twenty-ui'; | import { isDefined } from 'twenty-ui'; | ||||||
| import { rightDrawerPageState } from '../states/rightDrawerPageState'; | import { rightDrawerPageState } from '../states/rightDrawerPageState'; | ||||||
| import { RightDrawerPages } from '../types/RightDrawerPages'; | import { RightDrawerPages } from '../types/RightDrawerPages'; | ||||||
| import { RightDrawerWorkflow } from '@/workflow/components/RightDrawerWorkflow'; |  | ||||||
|  |  | ||||||
| const StyledRightDrawerPage = styled.div` | const StyledRightDrawerPage = styled.div` | ||||||
|   display: flex; |   display: flex; | ||||||
| @@ -36,7 +37,10 @@ const RIGHT_DRAWER_PAGES_CONFIG: ComponentByRightDrawerPage = { | |||||||
|   [RightDrawerPages.ViewCalendarEvent]: <RightDrawerCalendarEvent />, |   [RightDrawerPages.ViewCalendarEvent]: <RightDrawerCalendarEvent />, | ||||||
|   [RightDrawerPages.ViewRecord]: <RightDrawerRecord />, |   [RightDrawerPages.ViewRecord]: <RightDrawerRecord />, | ||||||
|   [RightDrawerPages.Copilot]: <RightDrawerAIChat />, |   [RightDrawerPages.Copilot]: <RightDrawerAIChat />, | ||||||
|   [RightDrawerPages.Workflow]: <RightDrawerWorkflow />, |   [RightDrawerPages.WorkflowStepSelectAction]: ( | ||||||
|  |     <RightDrawerWorkflowSelectAction /> | ||||||
|  |   ), | ||||||
|  |   [RightDrawerPages.WorkflowStepEdit]: <RightDrawerWorkflowEditStep />, | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export const RightDrawerRouter = () => { | export const RightDrawerRouter = () => { | ||||||
|   | |||||||
| @@ -5,5 +5,6 @@ export const RIGHT_DRAWER_PAGE_ICONS = { | |||||||
|   [RightDrawerPages.ViewCalendarEvent]: 'IconCalendarEvent', |   [RightDrawerPages.ViewCalendarEvent]: 'IconCalendarEvent', | ||||||
|   [RightDrawerPages.ViewRecord]: 'Icon123', |   [RightDrawerPages.ViewRecord]: 'Icon123', | ||||||
|   [RightDrawerPages.Copilot]: 'IconSparkles', |   [RightDrawerPages.Copilot]: 'IconSparkles', | ||||||
|   [RightDrawerPages.Workflow]: 'IconSparkles', |   [RightDrawerPages.WorkflowStepEdit]: 'IconSparkles', | ||||||
|  |   [RightDrawerPages.WorkflowStepSelectAction]: 'IconSparkles', | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -5,5 +5,6 @@ export const RIGHT_DRAWER_PAGE_TITLES = { | |||||||
|   [RightDrawerPages.ViewCalendarEvent]: 'Calendar Event', |   [RightDrawerPages.ViewCalendarEvent]: 'Calendar Event', | ||||||
|   [RightDrawerPages.ViewRecord]: 'Record Editor', |   [RightDrawerPages.ViewRecord]: 'Record Editor', | ||||||
|   [RightDrawerPages.Copilot]: 'Copilot', |   [RightDrawerPages.Copilot]: 'Copilot', | ||||||
|   [RightDrawerPages.Workflow]: 'Workflow', |   [RightDrawerPages.WorkflowStepEdit]: 'Workflow', | ||||||
|  |   [RightDrawerPages.WorkflowStepSelectAction]: 'Workflow', | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -3,5 +3,6 @@ export enum RightDrawerPages { | |||||||
|   ViewCalendarEvent = 'view-calendar-event', |   ViewCalendarEvent = 'view-calendar-event', | ||||||
|   ViewRecord = 'view-record', |   ViewRecord = 'view-record', | ||||||
|   Copilot = 'copilot', |   Copilot = 'copilot', | ||||||
|   Workflow = 'workflow', |   WorkflowStepSelectAction = 'workflow-step-select-action', | ||||||
|  |   WorkflowStepEdit = 'workflow-step-edit', | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,40 +0,0 @@ | |||||||
| import styled from '@emotion/styled'; |  | ||||||
|  |  | ||||||
| const StyledContainer = styled.div` |  | ||||||
|   box-sizing: border-box; |  | ||||||
|   display: flex; |  | ||||||
|   flex-direction: column; |  | ||||||
|   height: 100%; |  | ||||||
|   justify-content: flex-start; |  | ||||||
|   overflow-y: auto; |  | ||||||
|   position: relative; |  | ||||||
| `; |  | ||||||
|  |  | ||||||
| const StyledChatArea = styled.div` |  | ||||||
|   flex: 1; |  | ||||||
|   display: flex; |  | ||||||
|   flex-direction: column; |  | ||||||
|   overflow-y: scroll; |  | ||||||
|   padding: ${({ theme }) => theme.spacing(6)}; |  | ||||||
|   padding-bottom: 0px; |  | ||||||
| `; |  | ||||||
|  |  | ||||||
| const StyledNewMessageArea = styled.div` |  | ||||||
|   display: flex; |  | ||||||
|   flex-direction: column; |  | ||||||
|   padding: ${({ theme }) => theme.spacing(6)}; |  | ||||||
|   padding-top: 0px; |  | ||||||
| `; |  | ||||||
|  |  | ||||||
| export const RightDrawerWorkflow = () => { |  | ||||||
|   const handleCreateCodeBlock = () => {}; |  | ||||||
|  |  | ||||||
|   return ( |  | ||||||
|     <StyledContainer> |  | ||||||
|       <StyledChatArea>{/* TODO */}</StyledChatArea> |  | ||||||
|       <StyledNewMessageArea> |  | ||||||
|         <button onClick={handleCreateCodeBlock}>Create code block</button> |  | ||||||
|       </StyledNewMessageArea> |  | ||||||
|     </StyledContainer> |  | ||||||
|   ); |  | ||||||
| }; |  | ||||||
| @@ -0,0 +1,10 @@ | |||||||
|  | import { showPageWorkflowSelectedNodeState } from '@/workflow/states/showPageWorkflowSelectedNodeState'; | ||||||
|  | import { useRecoilValue } from 'recoil'; | ||||||
|  |  | ||||||
|  | export const RightDrawerWorkflowEditStep = () => { | ||||||
|  |   const showPageWorkflowSelectedNode = useRecoilValue( | ||||||
|  |     showPageWorkflowSelectedNodeState, | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   return <p>{showPageWorkflowSelectedNode}</p>; | ||||||
|  | }; | ||||||
| @@ -0,0 +1,28 @@ | |||||||
|  | import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; | ||||||
|  | import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; | ||||||
|  | import { RightDrawerWorkflowSelectActionContent } from '@/workflow/components/RightDrawerWorkflowSelectActionContent'; | ||||||
|  | import { showPageWorkflowIdState } from '@/workflow/states/showPageWorkflowIdState'; | ||||||
|  | import { Workflow } from '@/workflow/types/Workflow'; | ||||||
|  | import { useRecoilValue } from 'recoil'; | ||||||
|  | import { isDefined } from 'twenty-ui'; | ||||||
|  |  | ||||||
|  | export const RightDrawerWorkflowSelectAction = () => { | ||||||
|  |   const showPageWorkflowId = useRecoilValue(showPageWorkflowIdState); | ||||||
|  |  | ||||||
|  |   const { record: workflow } = useFindOneRecord<Workflow>({ | ||||||
|  |     objectNameSingular: CoreObjectNameSingular.Workflow, | ||||||
|  |     objectRecordId: showPageWorkflowId, | ||||||
|  |     recordGqlFields: { | ||||||
|  |       id: true, | ||||||
|  |       name: true, | ||||||
|  |       versions: true, | ||||||
|  |       publishedVersionId: true, | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   if (!isDefined(workflow)) { | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return <RightDrawerWorkflowSelectActionContent workflow={workflow} />; | ||||||
|  | }; | ||||||
| @@ -0,0 +1,60 @@ | |||||||
|  | import { TabList } from '@/ui/layout/tab/components/TabList'; | ||||||
|  | import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; | ||||||
|  | import { useRightDrawerWorkflowSelectAction } from '@/workflow/hooks/useRightDrawerWorkflowSelectAction'; | ||||||
|  | import { Workflow } from '@/workflow/types/Workflow'; | ||||||
|  | import styled from '@emotion/styled'; | ||||||
|  |  | ||||||
|  | // FIXME: copy-pasted | ||||||
|  | const StyledTabListContainer = styled.div` | ||||||
|  |   align-items: center; | ||||||
|  |   border-bottom: ${({ theme }) => `1px solid ${theme.border.color.light}`}; | ||||||
|  |   box-sizing: border-box; | ||||||
|  |   display: flex; | ||||||
|  |   gap: ${({ theme }) => theme.spacing(2)}; | ||||||
|  |   height: 40px; | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | const StyledActionListContainer = styled.div` | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: column; | ||||||
|  |   height: 100%; | ||||||
|  |   overflow-y: auto; | ||||||
|  |  | ||||||
|  |   padding-block: ${({ theme }) => theme.spacing(1)}; | ||||||
|  |   padding-inline: ${({ theme }) => theme.spacing(2)}; | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | export const TAB_LIST_COMPONENT_ID = | ||||||
|  |   'workflow-select-action-page-right-tab-list'; | ||||||
|  |  | ||||||
|  | export const RightDrawerWorkflowSelectActionContent = ({ | ||||||
|  |   workflow, | ||||||
|  | }: { | ||||||
|  |   workflow: Workflow; | ||||||
|  | }) => { | ||||||
|  |   const tabListId = `${TAB_LIST_COMPONENT_ID}`; | ||||||
|  |  | ||||||
|  |   const { tabs, options, handleActionClick } = | ||||||
|  |     useRightDrawerWorkflowSelectAction({ tabListId, workflow }); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <StyledTabListContainer> | ||||||
|  |         <TabList loading={false} tabListId={tabListId} tabs={tabs} /> | ||||||
|  |       </StyledTabListContainer> | ||||||
|  |  | ||||||
|  |       <StyledActionListContainer> | ||||||
|  |         {options.map((option) => ( | ||||||
|  |           <MenuItem | ||||||
|  |             key={option.id} | ||||||
|  |             LeftIcon={option.icon} | ||||||
|  |             text={option.name} | ||||||
|  |             onClick={() => { | ||||||
|  |               handleActionClick(option.id); | ||||||
|  |             }} | ||||||
|  |           /> | ||||||
|  |         ))} | ||||||
|  |       </StyledActionListContainer> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
| @@ -1,4 +1,5 @@ | |||||||
| import { WorkflowShowPageDiagramCreateStepNode } from '@/workflow/components/WorkflowShowPageDiagramCreateStepNode.tsx'; | import { WorkflowShowPageDiagramCreateStepNode } from '@/workflow/components/WorkflowShowPageDiagramCreateStepNode'; | ||||||
|  | import { WorkflowShowPageDiagramEffect } from '@/workflow/components/WorkflowShowPageDiagramEffect'; | ||||||
| import { WorkflowShowPageDiagramStepNode } from '@/workflow/components/WorkflowShowPageDiagramStepNode'; | import { WorkflowShowPageDiagramStepNode } from '@/workflow/components/WorkflowShowPageDiagramStepNode'; | ||||||
| import { showPageWorkflowDiagramState } from '@/workflow/states/showPageWorkflowDiagramState'; | import { showPageWorkflowDiagramState } from '@/workflow/states/showPageWorkflowDiagramState'; | ||||||
| import { | import { | ||||||
| @@ -80,6 +81,8 @@ export const WorkflowShowPageDiagram = ({ | |||||||
|       onNodesChange={handleNodesChange} |       onNodesChange={handleNodesChange} | ||||||
|       onEdgesChange={handleEdgesChange} |       onEdgesChange={handleEdgesChange} | ||||||
|     > |     > | ||||||
|  |       <WorkflowShowPageDiagramEffect /> | ||||||
|  |  | ||||||
|       <Background color={GRAY_SCALE.gray25} size={2} /> |       <Background color={GRAY_SCALE.gray25} size={2} /> | ||||||
|     </ReactFlow> |     </ReactFlow> | ||||||
|   ); |   ); | ||||||
|   | |||||||
| @@ -1,6 +1,4 @@ | |||||||
| import { IconButton } from '@/ui/input/button/components/IconButton'; | import { IconButton } from '@/ui/input/button/components/IconButton'; | ||||||
| import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; |  | ||||||
| import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; |  | ||||||
| import styled from '@emotion/styled'; | import styled from '@emotion/styled'; | ||||||
| import { Handle, Position } from '@xyflow/react'; | import { Handle, Position } from '@xyflow/react'; | ||||||
| import { IconPlus } from 'twenty-ui'; | import { IconPlus } from 'twenty-ui'; | ||||||
| @@ -10,17 +8,11 @@ export const StyledTargetHandle = styled(Handle)` | |||||||
| `;
 | `;
 | ||||||
| 
 | 
 | ||||||
| export const WorkflowShowPageDiagramCreateStepNode = () => { | export const WorkflowShowPageDiagramCreateStepNode = () => { | ||||||
|   const { openRightDrawer } = useRightDrawer(); |  | ||||||
| 
 |  | ||||||
|   const handleCreateStepNodeButtonClick = () => { |  | ||||||
|     openRightDrawer(RightDrawerPages.Workflow); |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   return ( |   return ( | ||||||
|     <div> |     <> | ||||||
|       <StyledTargetHandle type="target" position={Position.Top} /> |       <StyledTargetHandle type="target" position={Position.Top} /> | ||||||
| 
 | 
 | ||||||
|       <IconButton Icon={IconPlus} onClick={handleCreateStepNodeButtonClick} /> |       <IconButton Icon={IconPlus} /> | ||||||
|     </div> |     </> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
| @@ -0,0 +1,81 @@ | |||||||
|  | import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; | ||||||
|  | import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; | ||||||
|  | import { useStartNodeCreation } from '@/workflow/hooks/useStartNodeCreation'; | ||||||
|  | import { showPageWorkflowDiagramTriggerNodeSelectionState } from '@/workflow/states/showPageWorkflowDiagramTriggerNodeSelectionState'; | ||||||
|  | import { showPageWorkflowSelectedNodeState } from '@/workflow/states/showPageWorkflowSelectedNodeState'; | ||||||
|  | import { | ||||||
|  |   WorkflowDiagramEdge, | ||||||
|  |   WorkflowDiagramNode, | ||||||
|  | } from '@/workflow/types/WorkflowDiagram'; | ||||||
|  | import { | ||||||
|  |   OnSelectionChangeParams, | ||||||
|  |   useOnSelectionChange, | ||||||
|  |   useReactFlow, | ||||||
|  | } from '@xyflow/react'; | ||||||
|  | import { useCallback, useEffect } from 'react'; | ||||||
|  | import { useRecoilValue, useSetRecoilState } from 'recoil'; | ||||||
|  | import { isDefined } from 'twenty-ui'; | ||||||
|  |  | ||||||
|  | export const WorkflowShowPageDiagramEffect = () => { | ||||||
|  |   const reactflow = useReactFlow<WorkflowDiagramNode, WorkflowDiagramEdge>(); | ||||||
|  |  | ||||||
|  |   const { startNodeCreation } = useStartNodeCreation(); | ||||||
|  |  | ||||||
|  |   const { openRightDrawer, closeRightDrawer } = useRightDrawer(); | ||||||
|  |   const setShowPageWorkflowSelectedNode = useSetRecoilState( | ||||||
|  |     showPageWorkflowSelectedNodeState, | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   const showPageWorkflowDiagramTriggerNodeSelection = useRecoilValue( | ||||||
|  |     showPageWorkflowDiagramTriggerNodeSelectionState, | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   const handleSelectionChange = useCallback( | ||||||
|  |     ({ nodes }: OnSelectionChangeParams) => { | ||||||
|  |       const selectedNode = nodes[0] as WorkflowDiagramNode; | ||||||
|  |       const isClosingStep = isDefined(selectedNode) === false; | ||||||
|  |  | ||||||
|  |       if (isClosingStep) { | ||||||
|  |         closeRightDrawer(); | ||||||
|  |  | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       const isCreateStepNode = selectedNode.type === 'create-step'; | ||||||
|  |       if (isCreateStepNode) { | ||||||
|  |         if (selectedNode.data.nodeType !== 'create-step') { | ||||||
|  |           throw new Error('Expected selected node to be a create step node.'); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         startNodeCreation(selectedNode.data.parentNodeId); | ||||||
|  |  | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       setShowPageWorkflowSelectedNode(selectedNode.id); | ||||||
|  |       openRightDrawer(RightDrawerPages.WorkflowStepEdit); | ||||||
|  |     }, | ||||||
|  |     [ | ||||||
|  |       closeRightDrawer, | ||||||
|  |       openRightDrawer, | ||||||
|  |       setShowPageWorkflowSelectedNode, | ||||||
|  |       startNodeCreation, | ||||||
|  |     ], | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   useOnSelectionChange({ | ||||||
|  |     onChange: handleSelectionChange, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (!isDefined(showPageWorkflowDiagramTriggerNodeSelection)) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     reactflow.updateNode(showPageWorkflowDiagramTriggerNodeSelection, { | ||||||
|  |       selected: true, | ||||||
|  |     }); | ||||||
|  |   }, [reactflow, showPageWorkflowDiagramTriggerNodeSelection]); | ||||||
|  |  | ||||||
|  |   return null; | ||||||
|  | }; | ||||||
| @@ -2,6 +2,7 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi | |||||||
| import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; | import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; | ||||||
| import { showPageWorkflowDiagramState } from '@/workflow/states/showPageWorkflowDiagramState'; | import { showPageWorkflowDiagramState } from '@/workflow/states/showPageWorkflowDiagramState'; | ||||||
| import { showPageWorkflowErrorState } from '@/workflow/states/showPageWorkflowErrorState'; | import { showPageWorkflowErrorState } from '@/workflow/states/showPageWorkflowErrorState'; | ||||||
|  | import { showPageWorkflowIdState } from '@/workflow/states/showPageWorkflowIdState'; | ||||||
| import { showPageWorkflowLoadingState } from '@/workflow/states/showPageWorkflowLoadingState'; | import { showPageWorkflowLoadingState } from '@/workflow/states/showPageWorkflowLoadingState'; | ||||||
| import { Workflow } from '@/workflow/types/Workflow'; | import { Workflow } from '@/workflow/types/Workflow'; | ||||||
| import { addCreateStepNodes } from '@/workflow/utils/addCreateStepNodes'; | import { addCreateStepNodes } from '@/workflow/utils/addCreateStepNodes'; | ||||||
| @@ -32,6 +33,7 @@ export const WorkflowShowPageEffect = ({ | |||||||
|     }, |     }, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|  |   const setShowPageWorkflowId = useSetRecoilState(showPageWorkflowIdState); | ||||||
|   const setCurrentWorkflowData = useSetRecoilState( |   const setCurrentWorkflowData = useSetRecoilState( | ||||||
|     showPageWorkflowDiagramState, |     showPageWorkflowDiagramState, | ||||||
|   ); |   ); | ||||||
| @@ -40,6 +42,10 @@ export const WorkflowShowPageEffect = ({ | |||||||
|   ); |   ); | ||||||
|   const setCurrentWorkflowError = useSetRecoilState(showPageWorkflowErrorState); |   const setCurrentWorkflowError = useSetRecoilState(showPageWorkflowErrorState); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     setShowPageWorkflowId(workflowId); | ||||||
|  |   }, [setShowPageWorkflowId, workflowId]); | ||||||
|  |  | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     const flowLastVersion = getWorkflowLastDiagramVersion(workflow); |     const flowLastVersion = getWorkflowLastDiagramVersion(workflow); | ||||||
|     const flowWithCreateStepNodes = addCreateStepNodes(flowLastVersion); |     const flowWithCreateStepNodes = addCreateStepNodes(flowLastVersion); | ||||||
|   | |||||||
| @@ -0,0 +1,47 @@ | |||||||
|  | import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; | ||||||
|  | import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; | ||||||
|  | import { | ||||||
|  |   Workflow, | ||||||
|  |   WorkflowStep, | ||||||
|  |   WorkflowVersion, | ||||||
|  | } from '@/workflow/types/Workflow'; | ||||||
|  | import { getWorkflowLastVersion } from '@/workflow/utils/getWorkflowLastVersion'; | ||||||
|  | import { insertStep } from '@/workflow/utils/insertStep'; | ||||||
|  | import { isDefined } from 'twenty-ui'; | ||||||
|  |  | ||||||
|  | export const useCreateNode = ({ workflow }: { workflow: Workflow }) => { | ||||||
|  |   const { updateOneRecord: updateOneWorkflowVersion } = | ||||||
|  |     useUpdateOneRecord<WorkflowVersion>({ | ||||||
|  |       objectNameSingular: CoreObjectNameSingular.WorkflowVersion, | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |   const createNode = ({ | ||||||
|  |     parentNodeId, | ||||||
|  |     nodeToAdd, | ||||||
|  |   }: { | ||||||
|  |     parentNodeId: string; | ||||||
|  |     nodeToAdd: WorkflowStep; | ||||||
|  |   }) => { | ||||||
|  |     const lastVersion = getWorkflowLastVersion(workflow); | ||||||
|  |     if (!isDefined(lastVersion)) { | ||||||
|  |       throw new Error( | ||||||
|  |         "Can't add a node when no version exists yet. Create a first workflow version before trying to add a node.", | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return updateOneWorkflowVersion({ | ||||||
|  |       idToUpdate: lastVersion.id, | ||||||
|  |       updateOneRecordInput: { | ||||||
|  |         steps: insertStep({ | ||||||
|  |           steps: lastVersion.steps, | ||||||
|  |           parentStepId: parentNodeId, | ||||||
|  |           stepToAdd: nodeToAdd, | ||||||
|  |         }), | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return { | ||||||
|  |     createNode, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
| @@ -0,0 +1,117 @@ | |||||||
|  | import { useTabList } from '@/ui/layout/tab/hooks/useTabList'; | ||||||
|  | import { useCreateNode } from '@/workflow/hooks/useCreateNode'; | ||||||
|  | import { showPageWorkflowDiagramTriggerNodeSelectionState } from '@/workflow/states/showPageWorkflowDiagramTriggerNodeSelectionState'; | ||||||
|  | import { workflowCreateStepFromParentStepIdState } from '@/workflow/states/workflowCreateStepFromParentStepIdState'; | ||||||
|  | import { Workflow } from '@/workflow/types/Workflow'; | ||||||
|  | import { useRecoilValue, useSetRecoilState } from 'recoil'; | ||||||
|  | import { | ||||||
|  |   IconPlaystationSquare, | ||||||
|  |   IconPlug, | ||||||
|  |   IconPlus, | ||||||
|  |   IconSearch, | ||||||
|  |   IconSettingsAutomation, | ||||||
|  | } from 'twenty-ui'; | ||||||
|  | import { v4 } from 'uuid'; | ||||||
|  |  | ||||||
|  | export const useRightDrawerWorkflowSelectAction = ({ | ||||||
|  |   tabListId, | ||||||
|  |   workflow, | ||||||
|  | }: { | ||||||
|  |   tabListId: string; | ||||||
|  |   workflow: Workflow; | ||||||
|  | }) => { | ||||||
|  |   const workflowCreateStepFromParentStepId = useRecoilValue( | ||||||
|  |     workflowCreateStepFromParentStepIdState, | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   const setShowPageWorkflowDiagramTriggerNodeSelection = useSetRecoilState( | ||||||
|  |     showPageWorkflowDiagramTriggerNodeSelectionState, | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   const { createNode } = useCreateNode({ workflow }); | ||||||
|  |  | ||||||
|  |   const allOptions: Array<{ | ||||||
|  |     id: string; | ||||||
|  |     name: string; | ||||||
|  |     type: 'standard' | 'custom'; | ||||||
|  |     icon: any; | ||||||
|  |   }> = [ | ||||||
|  |     { | ||||||
|  |       id: 'create-record', | ||||||
|  |       name: 'Create Record', | ||||||
|  |       type: 'standard', | ||||||
|  |       icon: IconPlus, | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 'find-records', | ||||||
|  |       name: 'Find Records', | ||||||
|  |       type: 'standard', | ||||||
|  |       icon: IconSearch, | ||||||
|  |     }, | ||||||
|  |   ]; | ||||||
|  |  | ||||||
|  |   const tabs = [ | ||||||
|  |     { | ||||||
|  |       id: 'all', | ||||||
|  |       title: 'All', | ||||||
|  |       Icon: IconSettingsAutomation, | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 'standard', | ||||||
|  |       title: 'Standard', | ||||||
|  |       Icon: IconPlaystationSquare, | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       id: 'custom', | ||||||
|  |       title: 'Custom', | ||||||
|  |       Icon: IconPlug, | ||||||
|  |     }, | ||||||
|  |   ]; | ||||||
|  |  | ||||||
|  |   const { activeTabIdState } = useTabList(tabListId); | ||||||
|  |   const activeTabId = useRecoilValue(activeTabIdState); | ||||||
|  |  | ||||||
|  |   const options = allOptions.filter( | ||||||
|  |     (option) => activeTabId === 'all' || option.type === activeTabId, | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   const handleActionClick = async (actionId: string) => { | ||||||
|  |     if (workflowCreateStepFromParentStepId === undefined) { | ||||||
|  |       throw new Error('Select a step to create a new step from first.'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     const newNodeId = v4(); | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * FIXME: For now, the data of the node to create are mostly static. | ||||||
|  |      */ | ||||||
|  |     await createNode({ | ||||||
|  |       parentNodeId: workflowCreateStepFromParentStepId, | ||||||
|  |       nodeToAdd: { | ||||||
|  |         id: newNodeId, | ||||||
|  |         name: actionId, | ||||||
|  |         type: 'CODE_ACTION', | ||||||
|  |         valid: true, | ||||||
|  |         settings: { | ||||||
|  |           serverlessFunctionId: '111', | ||||||
|  |           errorHandlingOptions: { | ||||||
|  |             continueOnFailure: { | ||||||
|  |               value: true, | ||||||
|  |             }, | ||||||
|  |             retryOnFailure: { | ||||||
|  |               value: true, | ||||||
|  |             }, | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     setShowPageWorkflowDiagramTriggerNodeSelection(newNodeId); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return { | ||||||
|  |     tabs, | ||||||
|  |     options, | ||||||
|  |     handleActionClick, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
| @@ -0,0 +1,29 @@ | |||||||
|  | import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; | ||||||
|  | import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; | ||||||
|  | import { workflowCreateStepFromParentStepIdState } from '@/workflow/states/workflowCreateStepFromParentStepIdState'; | ||||||
|  | import { useCallback } from 'react'; | ||||||
|  | import { useSetRecoilState } from 'recoil'; | ||||||
|  |  | ||||||
|  | export const useStartNodeCreation = () => { | ||||||
|  |   const { openRightDrawer } = useRightDrawer(); | ||||||
|  |   const setWorkflowCreateStepFromParentStepId = useSetRecoilState( | ||||||
|  |     workflowCreateStepFromParentStepIdState, | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * This function is used in a context where dependencies shouldn't change much. | ||||||
|  |    * That's why its wrapped in a `useCallback` hook. Removing memoization might break the app unexpectedly. | ||||||
|  |    */ | ||||||
|  |   const startNodeCreation = useCallback( | ||||||
|  |     (parentNodeId: string) => { | ||||||
|  |       setWorkflowCreateStepFromParentStepId(parentNodeId); | ||||||
|  |  | ||||||
|  |       openRightDrawer(RightDrawerPages.WorkflowStepSelectAction); | ||||||
|  |     }, | ||||||
|  |     [openRightDrawer, setWorkflowCreateStepFromParentStepId], | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   return { | ||||||
|  |     startNodeCreation, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
| @@ -0,0 +1,8 @@ | |||||||
|  | import { createState } from 'twenty-ui'; | ||||||
|  |  | ||||||
|  | export const showPageWorkflowDiagramTriggerNodeSelectionState = createState< | ||||||
|  |   string | undefined | ||||||
|  | >({ | ||||||
|  |   key: 'showPageWorkflowDiagramTriggerNodeSelectionState', | ||||||
|  |   defaultValue: undefined, | ||||||
|  | }); | ||||||
| @@ -0,0 +1,6 @@ | |||||||
|  | import { createState } from 'twenty-ui'; | ||||||
|  |  | ||||||
|  | export const showPageWorkflowIdState = createState<string | undefined>({ | ||||||
|  |   key: 'showPageWorkflowIdState', | ||||||
|  |   defaultValue: undefined, | ||||||
|  | }); | ||||||
| @@ -0,0 +1,8 @@ | |||||||
|  | import { createState } from 'twenty-ui'; | ||||||
|  |  | ||||||
|  | export const showPageWorkflowSelectedNodeState = createState< | ||||||
|  |   string | undefined | ||||||
|  | >({ | ||||||
|  |   key: 'showPageWorkflowSelectedNodeState', | ||||||
|  |   defaultValue: undefined, | ||||||
|  | }); | ||||||
| @@ -0,0 +1,8 @@ | |||||||
|  | import { createState } from 'twenty-ui'; | ||||||
|  |  | ||||||
|  | export const workflowCreateStepFromParentStepIdState = createState< | ||||||
|  |   string | undefined | ||||||
|  | >({ | ||||||
|  |   key: 'workflowCreateStepFromParentStepId', | ||||||
|  |   defaultValue: undefined, | ||||||
|  | }); | ||||||
| @@ -13,7 +13,10 @@ export type WorkflowDiagramStepNodeData = { | |||||||
|   label: string; |   label: string; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export type WorkflowDiagramCreateStepNodeData = Record<string, never>; | export type WorkflowDiagramCreateStepNodeData = { | ||||||
|  |   nodeType: 'create-step'; | ||||||
|  |   parentNodeId: string; | ||||||
|  | }; | ||||||
|  |  | ||||||
| export type WorkflowDiagramNodeData = | export type WorkflowDiagramNodeData = | ||||||
|   | WorkflowDiagramStepNodeData |   | WorkflowDiagramStepNodeData | ||||||
|   | |||||||
| @@ -70,8 +70,10 @@ describe('generateWorkflowDiagram', () => { | |||||||
|     const stepNodes = result.nodes.slice(1); |     const stepNodes = result.nodes.slice(1); | ||||||
|  |  | ||||||
|     for (const [index, step] of steps.entries()) { |     for (const [index, step] of steps.entries()) { | ||||||
|       expect(stepNodes[index].data.nodeType).toBe('action'); |       expect(stepNodes[index].data).toEqual({ | ||||||
|       expect(stepNodes[index].data.label).toBe(step.name); |         nodeType: 'action', | ||||||
|  |         label: step.name, | ||||||
|  |       }); | ||||||
|     } |     } | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -0,0 +1,217 @@ | |||||||
|  | import { WorkflowStep, WorkflowVersion } from '@/workflow/types/Workflow'; | ||||||
|  | import { insertStep } from '../insertStep'; | ||||||
|  |  | ||||||
|  | describe('insertStep', () => { | ||||||
|  |   it('returns a deep copy of the provided steps array instead of mutating it', () => { | ||||||
|  |     const workflowVersionInitial: WorkflowVersion = { | ||||||
|  |       __typename: 'WorkflowVersion', | ||||||
|  |       createdAt: '', | ||||||
|  |       id: '1', | ||||||
|  |       name: '', | ||||||
|  |       steps: [], | ||||||
|  |       trigger: { | ||||||
|  |         settings: { eventName: 'company.created' }, | ||||||
|  |         type: 'DATABASE_EVENT', | ||||||
|  |       }, | ||||||
|  |       updatedAt: '', | ||||||
|  |       workflowId: '', | ||||||
|  |     }; | ||||||
|  |     const stepToAdd: WorkflowStep = { | ||||||
|  |       id: 'step-1', | ||||||
|  |       name: '', | ||||||
|  |       settings: { | ||||||
|  |         errorHandlingOptions: { | ||||||
|  |           retryOnFailure: { value: true }, | ||||||
|  |           continueOnFailure: { value: false }, | ||||||
|  |         }, | ||||||
|  |         serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', | ||||||
|  |       }, | ||||||
|  |       type: 'CODE_ACTION', | ||||||
|  |       valid: true, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     const stepsUpdated = insertStep({ | ||||||
|  |       steps: workflowVersionInitial.steps, | ||||||
|  |       stepToAdd, | ||||||
|  |       parentStepId: undefined, | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     expect(workflowVersionInitial.steps).not.toBe(stepsUpdated); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   it('adds the step when the steps array is empty', () => { | ||||||
|  |     const workflowVersionInitial: WorkflowVersion = { | ||||||
|  |       __typename: 'WorkflowVersion', | ||||||
|  |       createdAt: '', | ||||||
|  |       id: '1', | ||||||
|  |       name: '', | ||||||
|  |       steps: [], | ||||||
|  |       trigger: { | ||||||
|  |         settings: { eventName: 'company.created' }, | ||||||
|  |         type: 'DATABASE_EVENT', | ||||||
|  |       }, | ||||||
|  |       updatedAt: '', | ||||||
|  |       workflowId: '', | ||||||
|  |     }; | ||||||
|  |     const stepToAdd: WorkflowStep = { | ||||||
|  |       id: 'step-1', | ||||||
|  |       name: '', | ||||||
|  |       settings: { | ||||||
|  |         errorHandlingOptions: { | ||||||
|  |           retryOnFailure: { value: true }, | ||||||
|  |           continueOnFailure: { value: false }, | ||||||
|  |         }, | ||||||
|  |         serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', | ||||||
|  |       }, | ||||||
|  |       type: 'CODE_ACTION', | ||||||
|  |       valid: true, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     const stepsUpdated = insertStep({ | ||||||
|  |       steps: workflowVersionInitial.steps, | ||||||
|  |       stepToAdd, | ||||||
|  |       parentStepId: undefined, | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     const expectedUpdatedSteps: Array<WorkflowStep> = [stepToAdd]; | ||||||
|  |     expect(stepsUpdated).toEqual(expectedUpdatedSteps); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   it('adds the step at the end of a non-empty steps array', () => { | ||||||
|  |     const workflowVersionInitial: WorkflowVersion = { | ||||||
|  |       __typename: 'WorkflowVersion', | ||||||
|  |       createdAt: '', | ||||||
|  |       id: '1', | ||||||
|  |       name: '', | ||||||
|  |       steps: [ | ||||||
|  |         { | ||||||
|  |           id: 'step-1', | ||||||
|  |           name: '', | ||||||
|  |           settings: { | ||||||
|  |             errorHandlingOptions: { | ||||||
|  |               retryOnFailure: { value: true }, | ||||||
|  |               continueOnFailure: { value: false }, | ||||||
|  |             }, | ||||||
|  |             serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', | ||||||
|  |           }, | ||||||
|  |           type: 'CODE_ACTION', | ||||||
|  |           valid: true, | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           id: 'step-2', | ||||||
|  |           name: '', | ||||||
|  |           settings: { | ||||||
|  |             errorHandlingOptions: { | ||||||
|  |               retryOnFailure: { value: true }, | ||||||
|  |               continueOnFailure: { value: false }, | ||||||
|  |             }, | ||||||
|  |             serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', | ||||||
|  |           }, | ||||||
|  |           type: 'CODE_ACTION', | ||||||
|  |           valid: true, | ||||||
|  |         }, | ||||||
|  |       ], | ||||||
|  |       trigger: { | ||||||
|  |         settings: { eventName: 'company.created' }, | ||||||
|  |         type: 'DATABASE_EVENT', | ||||||
|  |       }, | ||||||
|  |       updatedAt: '', | ||||||
|  |       workflowId: '', | ||||||
|  |     }; | ||||||
|  |     const stepToAdd: WorkflowStep = { | ||||||
|  |       id: 'step-3', | ||||||
|  |       name: '', | ||||||
|  |       settings: { | ||||||
|  |         errorHandlingOptions: { | ||||||
|  |           retryOnFailure: { value: true }, | ||||||
|  |           continueOnFailure: { value: false }, | ||||||
|  |         }, | ||||||
|  |         serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', | ||||||
|  |       }, | ||||||
|  |       type: 'CODE_ACTION', | ||||||
|  |       valid: true, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     const stepsUpdated = insertStep({ | ||||||
|  |       steps: workflowVersionInitial.steps, | ||||||
|  |       stepToAdd, | ||||||
|  |       parentStepId: workflowVersionInitial.steps[1].id, // Note the selected step. | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     const expectedUpdatedSteps: Array<WorkflowStep> = [ | ||||||
|  |       workflowVersionInitial.steps[0], | ||||||
|  |       workflowVersionInitial.steps[1], | ||||||
|  |       stepToAdd, | ||||||
|  |     ]; | ||||||
|  |     expect(stepsUpdated).toEqual(expectedUpdatedSteps); | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   it('adds the step in the middle of a non-empty steps array', () => { | ||||||
|  |     const workflowVersionInitial: WorkflowVersion = { | ||||||
|  |       __typename: 'WorkflowVersion', | ||||||
|  |       createdAt: '', | ||||||
|  |       id: '1', | ||||||
|  |       name: '', | ||||||
|  |       steps: [ | ||||||
|  |         { | ||||||
|  |           id: 'step-1', | ||||||
|  |           name: '', | ||||||
|  |           settings: { | ||||||
|  |             errorHandlingOptions: { | ||||||
|  |               retryOnFailure: { value: true }, | ||||||
|  |               continueOnFailure: { value: false }, | ||||||
|  |             }, | ||||||
|  |             serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', | ||||||
|  |           }, | ||||||
|  |           type: 'CODE_ACTION', | ||||||
|  |           valid: true, | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           id: 'step-2', | ||||||
|  |           name: '', | ||||||
|  |           settings: { | ||||||
|  |             errorHandlingOptions: { | ||||||
|  |               retryOnFailure: { value: true }, | ||||||
|  |               continueOnFailure: { value: false }, | ||||||
|  |             }, | ||||||
|  |             serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', | ||||||
|  |           }, | ||||||
|  |           type: 'CODE_ACTION', | ||||||
|  |           valid: true, | ||||||
|  |         }, | ||||||
|  |       ], | ||||||
|  |       trigger: { | ||||||
|  |         settings: { eventName: 'company.created' }, | ||||||
|  |         type: 'DATABASE_EVENT', | ||||||
|  |       }, | ||||||
|  |       updatedAt: '', | ||||||
|  |       workflowId: '', | ||||||
|  |     }; | ||||||
|  |     const stepToAdd: WorkflowStep = { | ||||||
|  |       id: 'step-3', | ||||||
|  |       name: '', | ||||||
|  |       settings: { | ||||||
|  |         errorHandlingOptions: { | ||||||
|  |           retryOnFailure: { value: true }, | ||||||
|  |           continueOnFailure: { value: false }, | ||||||
|  |         }, | ||||||
|  |         serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997', | ||||||
|  |       }, | ||||||
|  |       type: 'CODE_ACTION', | ||||||
|  |       valid: true, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     const stepsUpdated = insertStep({ | ||||||
|  |       steps: workflowVersionInitial.steps, | ||||||
|  |       stepToAdd, | ||||||
|  |       parentStepId: workflowVersionInitial.steps[0].id, // Note the selected step. | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     const expectedUpdatedSteps: Array<WorkflowStep> = [ | ||||||
|  |       workflowVersionInitial.steps[0], | ||||||
|  |       stepToAdd, | ||||||
|  |       workflowVersionInitial.steps[1], | ||||||
|  |     ]; | ||||||
|  |     expect(stepsUpdated).toEqual(expectedUpdatedSteps); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
| @@ -18,7 +18,10 @@ export const addCreateStepNodes = ({ nodes, edges }: WorkflowDiagram) => { | |||||||
|     const newCreateStepNode: WorkflowDiagramNode = { |     const newCreateStepNode: WorkflowDiagramNode = { | ||||||
|       id: v4(), |       id: v4(), | ||||||
|       type: 'create-step', |       type: 'create-step', | ||||||
|       data: {}, |       data: { | ||||||
|  |         nodeType: 'create-step', | ||||||
|  |         parentNodeId: node.id, | ||||||
|  |       }, | ||||||
|       position: { x: 0, y: 0 }, |       position: { x: 0, y: 0 }, | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -24,7 +24,7 @@ export const generateWorkflowDiagram = ({ | |||||||
|     xPos: number, |     xPos: number, | ||||||
|     yPos: number, |     yPos: number, | ||||||
|   ) => { |   ) => { | ||||||
|     const nodeId = v4(); |     const nodeId = step.id; | ||||||
|     nodes.push({ |     nodes.push({ | ||||||
|       id: nodeId, |       id: nodeId, | ||||||
|       data: { |       data: { | ||||||
| @@ -58,7 +58,7 @@ export const generateWorkflowDiagram = ({ | |||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   // Start with the trigger node |   // Start with the trigger node | ||||||
|   const triggerNodeId = v4(); |   const triggerNodeId = 'trigger'; | ||||||
|   nodes.push({ |   nodes.push({ | ||||||
|     id: triggerNodeId, |     id: triggerNodeId, | ||||||
|     data: { |     data: { | ||||||
|   | |||||||
| @@ -1,9 +1,6 @@ | |||||||
| import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram'; | import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram'; | ||||||
| import Dagre from '@dagrejs/dagre'; | import Dagre from '@dagrejs/dagre'; | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * Set the position of the nodes in the diagram. The positions are computed with a layouting algorithm. |  | ||||||
|  */ |  | ||||||
| export const getOrganizedDiagram = ( | export const getOrganizedDiagram = ( | ||||||
|   diagram: WorkflowDiagram, |   diagram: WorkflowDiagram, | ||||||
| ): WorkflowDiagram => { | ): WorkflowDiagram => { | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| import { Workflow } from '@/workflow/types/Workflow'; | import { Workflow } from '@/workflow/types/Workflow'; | ||||||
| import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram'; | import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram'; | ||||||
| import { generateWorkflowDiagram } from '@/workflow/utils/generateWorkflowDiagram'; | import { generateWorkflowDiagram } from '@/workflow/utils/generateWorkflowDiagram'; | ||||||
|  | import { getWorkflowLastVersion } from '@/workflow/utils/getWorkflowLastVersion'; | ||||||
| import { isDefined } from 'twenty-ui'; | import { isDefined } from 'twenty-ui'; | ||||||
|  |  | ||||||
| const EMPTY_DIAGRAM: WorkflowDiagram = { | const EMPTY_DIAGRAM: WorkflowDiagram = { | ||||||
| @@ -15,7 +16,7 @@ export const getWorkflowLastDiagramVersion = ( | |||||||
|     return EMPTY_DIAGRAM; |     return EMPTY_DIAGRAM; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   const lastVersion = workflow.versions.at(-1); |   const lastVersion = getWorkflowLastVersion(workflow); | ||||||
|   if (!isDefined(lastVersion) || !isDefined(lastVersion.trigger)) { |   if (!isDefined(lastVersion) || !isDefined(lastVersion.trigger)) { | ||||||
|     return EMPTY_DIAGRAM; |     return EMPTY_DIAGRAM; | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -0,0 +1,10 @@ | |||||||
|  | import { Workflow, WorkflowVersion } from '@/workflow/types/Workflow'; | ||||||
|  |  | ||||||
|  | export const getWorkflowLastVersion = ( | ||||||
|  |   workflow: Workflow, | ||||||
|  | ): WorkflowVersion | undefined => { | ||||||
|  |   return workflow.versions | ||||||
|  |     .slice() | ||||||
|  |     .sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1)) | ||||||
|  |     .at(-1); | ||||||
|  | }; | ||||||
| @@ -0,0 +1,61 @@ | |||||||
|  | import { WorkflowStep } from '@/workflow/types/Workflow'; | ||||||
|  |  | ||||||
|  | const findStepPositionOrThrow = ({ | ||||||
|  |   steps, | ||||||
|  |   stepId, | ||||||
|  | }: { | ||||||
|  |   steps: Array<WorkflowStep>; | ||||||
|  |   stepId: string | undefined; | ||||||
|  | }): { steps: Array<WorkflowStep>; index: number } => { | ||||||
|  |   if (stepId === undefined) { | ||||||
|  |     return { | ||||||
|  |       steps, | ||||||
|  |       index: 0, | ||||||
|  |     }; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   for (const [index, step] of steps.entries()) { | ||||||
|  |     if (step.id === stepId) { | ||||||
|  |       return { | ||||||
|  |         steps, | ||||||
|  |         index, | ||||||
|  |       }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // TODO: When condition will have been implemented, put recursivity here. | ||||||
|  |     // if (step.type === "CONDITION") { | ||||||
|  |     //     return findNodePosition({ | ||||||
|  |     //         workflowSteps: step.conditions, | ||||||
|  |     //         stepId, | ||||||
|  |     //     }) | ||||||
|  |     // } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   throw new Error(`Couldn't locate the step. Unreachable step id: ${stepId}.`); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const insertStep = ({ | ||||||
|  |   steps: stepsInitial, | ||||||
|  |   stepToAdd, | ||||||
|  |   parentStepId, | ||||||
|  | }: { | ||||||
|  |   steps: Array<WorkflowStep>; | ||||||
|  |   parentStepId: string | undefined; | ||||||
|  |   stepToAdd: WorkflowStep; | ||||||
|  | }): Array<WorkflowStep> => { | ||||||
|  |   // Make a deep copy of the nested object to prevent unwanted side effects. | ||||||
|  |   const steps = structuredClone(stepsInitial); | ||||||
|  |  | ||||||
|  |   const parentStepPosition = findStepPositionOrThrow({ | ||||||
|  |     steps: steps, | ||||||
|  |     stepId: parentStepId, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   parentStepPosition.steps.splice( | ||||||
|  |     parentStepPosition.index + 1, // The "+ 1" means that we add the step after its parent and not before. | ||||||
|  |     0, | ||||||
|  |     stepToAdd, | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   return steps; | ||||||
|  | }; | ||||||
| @@ -135,6 +135,7 @@ export { | |||||||
|   IconPhoto, |   IconPhoto, | ||||||
|   IconPilcrow, |   IconPilcrow, | ||||||
|   IconPlayerPlay, |   IconPlayerPlay, | ||||||
|  |   IconPlaystationSquare, | ||||||
|   IconPlug, |   IconPlug, | ||||||
|   IconPlus, |   IconPlus, | ||||||
|   IconPresentation, |   IconPresentation, | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user