Skip to content

Commit

Permalink
Create new steps in workflow editor (#6764)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Devessier authored Aug 30, 2024
1 parent 26eba76 commit f7c99dd
Show file tree
Hide file tree
Showing 33 changed files with 766 additions and 67 deletions.
11 changes: 11 additions & 0 deletions packages/twenty-front/setupTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,14 @@
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/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));
};
Original file line number Diff line number Diff line change
Expand Up @@ -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.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: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' },
{ loc: AppPath.Authorize, isLoggedIn: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: '/settings/billing' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,5 @@ export enum CoreObjectNameSingular {
MessageThreadSubscriber = 'messageThreadSubscriber',
Workflow = 'workflow',
MessageChannelMessageAssociation = 'messageChannelMessageAssociation',
WorkflowVersion = 'workflowVersion',
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.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: SubscriptionStatus.Canceled, onboardingStatus: OnboardingStatus.Completed, res: false },
{ loc: AppPath.Authorize, isLogged: true, subscriptionStatus: SubscriptionStatus.Unpaid, onboardingStatus: OnboardingStatus.Completed, res: false },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import { isRightDrawerMinimizedState } from '@/ui/layout/right-drawer/states/isR

import { RightDrawerTopBar } from '@/ui/layout/right-drawer/components/RightDrawerTopBar';
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 { rightDrawerPageState } from '../states/rightDrawerPageState';
import { RightDrawerPages } from '../types/RightDrawerPages';
import { RightDrawerWorkflow } from '@/workflow/components/RightDrawerWorkflow';

const StyledRightDrawerPage = styled.div`
display: flex;
Expand All @@ -36,7 +37,10 @@ const RIGHT_DRAWER_PAGES_CONFIG: ComponentByRightDrawerPage = {
[RightDrawerPages.ViewCalendarEvent]: <RightDrawerCalendarEvent />,
[RightDrawerPages.ViewRecord]: <RightDrawerRecord />,
[RightDrawerPages.Copilot]: <RightDrawerAIChat />,
[RightDrawerPages.Workflow]: <RightDrawerWorkflow />,
[RightDrawerPages.WorkflowStepSelectAction]: (
<RightDrawerWorkflowSelectAction />
),
[RightDrawerPages.WorkflowStepEdit]: <RightDrawerWorkflowEditStep />,
};

export const RightDrawerRouter = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ export const RIGHT_DRAWER_PAGE_ICONS = {
[RightDrawerPages.ViewCalendarEvent]: 'IconCalendarEvent',
[RightDrawerPages.ViewRecord]: 'Icon123',
[RightDrawerPages.Copilot]: 'IconSparkles',
[RightDrawerPages.Workflow]: 'IconSparkles',
[RightDrawerPages.WorkflowStepEdit]: 'IconSparkles',
[RightDrawerPages.WorkflowStepSelectAction]: 'IconSparkles',
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ export const RIGHT_DRAWER_PAGE_TITLES = {
[RightDrawerPages.ViewCalendarEvent]: 'Calendar Event',
[RightDrawerPages.ViewRecord]: 'Record Editor',
[RightDrawerPages.Copilot]: 'Copilot',
[RightDrawerPages.Workflow]: 'Workflow',
[RightDrawerPages.WorkflowStepEdit]: 'Workflow',
[RightDrawerPages.WorkflowStepSelectAction]: 'Workflow',
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ export enum RightDrawerPages {
ViewCalendarEvent = 'view-calendar-event',
ViewRecord = 'view-record',
Copilot = 'copilot',
Workflow = 'workflow',
WorkflowStepSelectAction = 'workflow-step-select-action',
WorkflowStepEdit = 'workflow-step-edit',
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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>;
};
Original file line number Diff line number Diff line change
@@ -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} />;
};
Original file line number Diff line number Diff line change
@@ -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>
</>
);
};
Original file line number Diff line number Diff line change
@@ -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 { showPageWorkflowDiagramState } from '@/workflow/states/showPageWorkflowDiagramState';
import {
Expand Down Expand Up @@ -80,6 +81,8 @@ export const WorkflowShowPageDiagram = ({
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
>
<WorkflowShowPageDiagramEffect />

<Background color={GRAY_SCALE.gray25} size={2} />
</ReactFlow>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
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 { Handle, Position } from '@xyflow/react';
import { IconPlus } from 'twenty-ui';
Expand All @@ -10,17 +8,11 @@ export const StyledTargetHandle = styled(Handle)`
`;

export const WorkflowShowPageDiagramCreateStepNode = () => {
const { openRightDrawer } = useRightDrawer();

const handleCreateStepNodeButtonClick = () => {
openRightDrawer(RightDrawerPages.Workflow);
};

return (
<div>
<>
<StyledTargetHandle type="target" position={Position.Top} />

<IconButton Icon={IconPlus} onClick={handleCreateStepNodeButtonClick} />
</div>
<IconButton Icon={IconPlus} />
</>
);
};
Original file line number Diff line number Diff line change
@@ -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;
};
Loading

0 comments on commit f7c99dd

Please sign in to comment.