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
2024-08-30 15:51:36 +02:00
committed by GitHub
parent 26eba76fb5
commit f7c99ddc7a
33 changed files with 766 additions and 67 deletions

View File

@ -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>
);
};

View File

@ -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>;
};

View File

@ -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} />;
};

View File

@ -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>
</>
);
};

View File

@ -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 {
@ -80,6 +81,8 @@ export const WorkflowShowPageDiagram = ({
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
>
<WorkflowShowPageDiagramEffect />
<Background color={GRAY_SCALE.gray25} size={2} />
</ReactFlow>
);

View File

@ -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';
@ -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} />
</>
);
};

View File

@ -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;
};

View File

@ -2,6 +2,7 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { showPageWorkflowDiagramState } from '@/workflow/states/showPageWorkflowDiagramState';
import { showPageWorkflowErrorState } from '@/workflow/states/showPageWorkflowErrorState';
import { showPageWorkflowIdState } from '@/workflow/states/showPageWorkflowIdState';
import { showPageWorkflowLoadingState } from '@/workflow/states/showPageWorkflowLoadingState';
import { Workflow } from '@/workflow/types/Workflow';
import { addCreateStepNodes } from '@/workflow/utils/addCreateStepNodes';
@ -32,6 +33,7 @@ export const WorkflowShowPageEffect = ({
},
});
const setShowPageWorkflowId = useSetRecoilState(showPageWorkflowIdState);
const setCurrentWorkflowData = useSetRecoilState(
showPageWorkflowDiagramState,
);
@ -40,6 +42,10 @@ export const WorkflowShowPageEffect = ({
);
const setCurrentWorkflowError = useSetRecoilState(showPageWorkflowErrorState);
useEffect(() => {
setShowPageWorkflowId(workflowId);
}, [setShowPageWorkflowId, workflowId]);
useEffect(() => {
const flowLastVersion = getWorkflowLastDiagramVersion(workflow);
const flowWithCreateStepNodes = addCreateStepNodes(flowLastVersion);