Baptiste Devessier
2025-04-24 11:33:17 +02:00
committed by GitHub
parent 0083569606
commit cc211550ae
26 changed files with 1216 additions and 550 deletions
@@ -26,10 +26,10 @@ import {
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import React, { useEffect, useMemo, useRef } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useRecoilCallback, useRecoilValue, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { THEME_COMMON } from 'twenty-ui/theme';
import { Tag, TagColor } from 'twenty-ui/components';
import { THEME_COMMON } from 'twenty-ui/theme';
const StyledResetReactflowStyles = styled.div`
height: 100%;
@@ -72,7 +72,7 @@ const StyledStatusTagContainer = styled.div`
left: 0;
top: 0;
position: absolute;
padding: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(4)};
`;
const defaultFitViewOptions = {
@@ -87,6 +87,7 @@ export const WorkflowDiagramCanvasBase = ({
tagContainerTestId,
tagColor,
tagText,
onInit,
}: {
nodeTypes: Partial<
Record<
@@ -114,13 +115,11 @@ export const WorkflowDiagramCanvasBase = ({
tagContainerTestId: string;
tagColor: TagColor;
tagText: string;
onInit?: () => void;
}) => {
const theme = useTheme();
const reactflow = useReactFlow();
const setWorkflowReactFlowRefState = useSetRecoilState(
workflowReactFlowRefState,
);
const workflowDiagram = useRecoilValue(workflowDiagramState);
@@ -140,22 +139,13 @@ export const WorkflowDiagramCanvasBase = ({
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
const handleNodesChange = (
nodeChanges: Array<NodeChange<WorkflowDiagramNode>>,
) => {
setWorkflowDiagram((diagram) => {
if (isDefined(diagram) === false) {
throw new Error(
'It must be impossible for the nodes to be updated if the diagram is not defined yet. Be sure the diagram is rendered only when defined.',
);
}
return {
...diagram,
nodes: applyNodeChanges(nodeChanges, diagram.nodes),
};
});
};
const setWorkflowReactFlowRef = useRecoilCallback(
({ set }) =>
(node: HTMLDivElement | null) => {
set(workflowReactFlowRefState, { current: node });
},
[],
);
const handleEdgesChange = (
edgeChanges: Array<EdgeChange<WorkflowDiagramEdge>>,
@@ -209,36 +199,55 @@ export const WorkflowDiagramCanvasBase = ({
);
}, [reactflow, rightDrawerState, rightDrawerWidth]);
const handleNodesChanges = useRecoilCallback(
({ set }) =>
(changes: NodeChange<WorkflowDiagramNode>[]) => {
set(workflowDiagramState, (diagram) => {
if (!isDefined(diagram)) {
throw new Error(
'It must be impossible for the nodes to be updated if the diagram is not defined yet. Be sure the diagram is rendered only when defined.',
);
}
return {
...diagram,
nodes: applyNodeChanges(changes, diagram.nodes),
};
});
},
[],
);
const handleInit = () => {
if (!isDefined(containerRef.current)) {
return;
}
const flowBounds = reactflow.getNodesBounds(reactflow.getNodes());
reactflow.setViewport({
x: containerRef.current.offsetWidth / 2 - flowBounds.width / 2,
y: 150,
zoom: defaultFitViewOptions.maxZoom,
});
onInit?.();
};
return (
<StyledResetReactflowStyles ref={containerRef}>
<WorkflowDiagramCustomMarkers />
<ReactFlow
ref={(node) => {
if (isDefined(node)) {
setWorkflowReactFlowRefState({ current: node });
}
}}
onInit={() => {
if (!isDefined(containerRef.current)) {
throw new Error('Expect the container ref to be defined');
}
const flowBounds = reactflow.getNodesBounds(reactflow.getNodes());
reactflow.setViewport({
x: containerRef.current.offsetWidth / 2 - flowBounds.width / 2,
y: 150,
zoom: defaultFitViewOptions.maxZoom,
});
}}
ref={setWorkflowReactFlowRef}
onInit={handleInit}
minZoom={defaultFitViewOptions.minZoom}
maxZoom={defaultFitViewOptions.maxZoom}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
nodes={nodes}
edges={edges}
onNodesChange={handleNodesChange}
onNodesChange={handleNodesChanges}
onEdgesChange={handleEdgesChange}
onBeforeDelete={async () => {
// Abort all non-programmatic deletions
@@ -251,6 +260,7 @@ export const WorkflowDiagramCanvasBase = ({
nodesDraggable={false}
nodesConnectable={false}
paneClickDistance={10} // Fix small unwanted user dragging does not select node
preventScrolling={false}
>
<Background color={theme.border.color.medium} size={2} />
@@ -4,6 +4,7 @@ import { WorkflowDiagramDefaultEdge } from '@/workflow/workflow-diagram/componen
import { WorkflowDiagramStepNodeReadonly } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeReadonly';
import { WorkflowDiagramSuccessEdge } from '@/workflow/workflow-diagram/components/WorkflowDiagramSuccessEdge';
import { WorkflowRunDiagramCanvasEffect } from '@/workflow/workflow-diagram/components/WorkflowRunDiagramCanvasEffect';
import { useHandleWorkflowRunDiagramCanvasInit } from '@/workflow/workflow-diagram/hooks/useHandleWorkflowRunDiagramCanvasInit';
import { getWorkflowRunStatusTagProps } from '@/workflow/workflow-diagram/utils/getWorkflowRunStatusTagProps';
import { ReactFlowProvider } from '@xyflow/react';
@@ -16,6 +17,9 @@ export const WorkflowRunDiagramCanvas = ({
workflowRunStatus,
});
const { handleWorkflowRunDiagramCanvasInit } =
useHandleWorkflowRunDiagramCanvasInit();
return (
<ReactFlowProvider>
<WorkflowDiagramCanvasBase
@@ -29,6 +33,7 @@ export const WorkflowRunDiagramCanvas = ({
tagContainerTestId="workflow-run-status"
tagColor={tagProps.color}
tagText={tagProps.text}
onInit={handleWorkflowRunDiagramCanvasInit}
/>
<WorkflowRunDiagramCanvasEffect />
@@ -1,121 +1,61 @@
import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandMenu';
import { getIsInputTabDisabled } from '@/command-menu/pages/workflow/step/view-run/utils/getIsInputTabDisabled';
import { getIsOutputTabDisabled } from '@/command-menu/pages/workflow/step/view-run/utils/getIsOutputTabDisabled';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { workflowDiagramStatusState } from '@/workflow/workflow-diagram/states/workflowDiagramStatusState';
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
import {
WorkflowDiagramNode,
WorkflowDiagramRunStatus,
WorkflowRunDiagramStepNodeData,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { WorkflowRunDiagramNode } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey';
import { WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-steps/constants/WorkflowRunStepSidePanelTabListComponentId';
import { WorkflowRunTabId } from '@/workflow/workflow-steps/types/WorkflowRunTabId';
import { isNull } from '@sniptt/guards';
import { OnSelectionChangeParams, useOnSelectionChange } from '@xyflow/react';
import { useCallback } from 'react';
import { useRecoilCallback, useRecoilValue, useSetRecoilState } from 'recoil';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
export const WorkflowRunDiagramCanvasEffect = () => {
const { getIcon } = useIcons();
const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState);
const { openWorkflowRunViewStepInCommandMenu } = useWorkflowCommandMenu();
const workflowId = useRecoilValue(workflowIdState);
const resetWorkflowRunRightDrawerTabIfNeeded = useRecoilCallback(
const handleSelectionChange = useRecoilCallback(
({ snapshot, set }) =>
({
workflowSelectedNode,
stepExecutionStatus,
}: {
workflowSelectedNode: string;
stepExecutionStatus: WorkflowDiagramRunStatus;
}) => {
const activeWorkflowRunRightDrawerTab = getSnapshotValue(
({ nodes }: OnSelectionChangeParams) => {
const workflowId = getSnapshotValue(snapshot, workflowIdState);
if (!isDefined(workflowId)) {
throw new Error('Expected the workflowId to be defined.');
}
const workflowDiagramStatus = getSnapshotValue(
snapshot,
activeTabIdComponentState.atomFamily({
instanceId: WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID,
}),
) as WorkflowRunTabId | null;
const isInputTabDisabled = getIsInputTabDisabled({
stepExecutionStatus,
workflowSelectedNode,
});
const isOutputTabDisabled = getIsOutputTabDisabled({
stepExecutionStatus,
});
if (isNull(activeWorkflowRunRightDrawerTab)) {
const defaultTabId = isOutputTabDisabled
? WorkflowRunTabId.NODE
: WorkflowRunTabId.OUTPUT;
set(
activeTabIdComponentState.atomFamily({
instanceId: WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID,
}),
defaultTabId,
);
workflowDiagramStatusState,
);
// The `handleSelectionChange` function is called when the diagram initializes and
// a node is selected. In this case, we don't want to execute the rest of this function.
// We open the Side Panel® synchronously after ReactFlow is initialized and a node is selected,
// animations perform better that way.
if (workflowDiagramStatus !== 'done') {
return;
}
if (
(isInputTabDisabled &&
activeWorkflowRunRightDrawerTab === WorkflowRunTabId.INPUT) ||
(isOutputTabDisabled &&
activeWorkflowRunRightDrawerTab === WorkflowRunTabId.OUTPUT)
) {
set(
activeTabIdComponentState.atomFamily({
instanceId: WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID,
}),
WorkflowRunTabId.NODE,
);
const selectedNode = nodes[0] as WorkflowRunDiagramNode | undefined;
if (!isDefined(selectedNode)) {
return;
}
},
[],
);
const handleSelectionChange = useCallback(
({ nodes }: OnSelectionChangeParams) => {
const selectedNode = nodes[0] as WorkflowDiagramNode | undefined;
set(workflowSelectedNodeState, selectedNode.id);
if (!isDefined(selectedNode)) {
return;
}
const selectedNodeData = selectedNode.data;
setWorkflowSelectedNode(selectedNode.id);
const selectedNodeData =
selectedNode.data as WorkflowRunDiagramStepNodeData;
if (isDefined(workflowId)) {
openWorkflowRunViewStepInCommandMenu(
openWorkflowRunViewStepInCommandMenu({
workflowId,
selectedNodeData.name,
getIcon(getWorkflowNodeIconKey(selectedNodeData)),
);
resetWorkflowRunRightDrawerTabIfNeeded({
title: selectedNodeData.name,
icon: getIcon(getWorkflowNodeIconKey(selectedNodeData)),
workflowSelectedNode: selectedNode.id,
stepExecutionStatus: selectedNodeData.runStatus,
});
}
},
[
setWorkflowSelectedNode,
resetWorkflowRunRightDrawerTabIfNeeded,
workflowId,
openWorkflowRunViewStepInCommandMenu,
getIcon,
],
},
[getIcon, openWorkflowRunViewStepInCommandMenu],
);
useOnSelectionChange({
@@ -1,6 +1,8 @@
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { WorkflowRunDiagramCanvas } from '@/workflow/workflow-diagram/components/WorkflowRunDiagramCanvas';
import { workflowDiagramStatusState } from '@/workflow/workflow-diagram/states/workflowDiagramStatusState';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
const StyledContainer = styled.div`
@@ -13,8 +15,12 @@ export const WorkflowRunVisualizer = ({
workflowRunId: string;
}) => {
const workflowRun = useWorkflowRun({ workflowRunId });
const workflowDiagramStatus = useRecoilValue(workflowDiagramStatusState);
if (!isDefined(workflowRun)) {
if (
!isDefined(workflowRun) ||
workflowDiagramStatus === 'computing-diagram'
) {
return null;
}
@@ -1,13 +1,18 @@
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useStepsOutputSchema } from '@/workflow/hooks/useStepsOutputSchema';
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
import { flowState } from '@/workflow/states/flowState';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { workflowRunIdState } from '@/workflow/states/workflowRunIdState';
import { WorkflowRunOutput } from '@/workflow/types/Workflow';
import { workflowDiagramState } from '@/workflow/workflow-diagram/states/workflowDiagramState';
import { workflowDiagramStatusState } from '@/workflow/workflow-diagram/states/workflowDiagramStatusState';
import { workflowRunStepToOpenByDefaultState } from '@/workflow/workflow-diagram/states/workflowRunStepToOpenByDefaultState';
import { generateWorkflowRunDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowRunDiagram';
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
import { selectWorkflowDiagramNode } from '@/workflow/workflow-diagram/utils/selectWorkflowDiagramNode';
import { useContext, useEffect } from 'react';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const WorkflowRunVisualizerEffect = ({
@@ -20,10 +25,10 @@ export const WorkflowRunVisualizerEffect = ({
const setWorkflowRunId = useSetRecoilState(workflowRunIdState);
const setWorkflowId = useSetRecoilState(workflowIdState);
const setFlow = useSetRecoilState(flowState);
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
const { populateStepsOutputSchema } = useStepsOutputSchema();
const { isInRightDrawer } = useContext(ActionMenuContext);
useEffect(() => {
setWorkflowRunId(workflowRunId);
}, [setWorkflowRunId, workflowRunId]);
@@ -32,33 +37,72 @@ export const WorkflowRunVisualizerEffect = ({
if (!isDefined(workflowRun)) {
return;
}
setWorkflowId(workflowRun.workflowId);
}, [setWorkflowId, workflowRun]);
const handleWorkflowRunDiagramGeneration = useRecoilCallback(
({ set }) =>
({
workflowRunOutput,
workflowVersionId,
skipNodeSelection,
}: {
workflowRunOutput: WorkflowRunOutput | undefined;
workflowVersionId: string | undefined;
skipNodeSelection: boolean;
}) => {
if (!(isDefined(workflowRunOutput) && isDefined(workflowVersionId))) {
set(flowState, undefined);
set(workflowDiagramState, undefined);
return;
}
set(workflowDiagramStatusState, 'computing-diagram');
set(flowState, {
workflowVersionId,
trigger: workflowRunOutput.flow.trigger,
steps: workflowRunOutput.flow.steps,
});
const { diagram: baseWorkflowRunDiagram, stepToOpenByDefault } =
generateWorkflowRunDiagram({
trigger: workflowRunOutput.flow.trigger,
steps: workflowRunOutput.flow.steps,
stepsOutput: workflowRunOutput.stepsOutput,
});
if (isDefined(stepToOpenByDefault) && !skipNodeSelection) {
const workflowRunDiagram = selectWorkflowDiagramNode({
diagram: baseWorkflowRunDiagram,
nodeIdToSelect: stepToOpenByDefault.id,
});
set(workflowDiagramState, workflowRunDiagram);
set(workflowRunStepToOpenByDefaultState, {
id: stepToOpenByDefault.id,
data: stepToOpenByDefault.data,
});
} else {
set(workflowDiagramState, baseWorkflowRunDiagram);
}
set(workflowDiagramStatusState, 'computing-dimensions');
},
[],
);
useEffect(() => {
if (!isDefined(workflowRun?.output)) {
setFlow(undefined);
setWorkflowDiagram(undefined);
return;
}
setFlow({
workflowVersionId: workflowRun.workflowVersionId,
trigger: workflowRun.output.flow.trigger,
steps: workflowRun.output.flow.steps,
handleWorkflowRunDiagramGeneration({
workflowRunOutput: workflowRun?.output ?? undefined,
workflowVersionId: workflowRun?.workflowVersionId,
skipNodeSelection: isInRightDrawer,
});
const nextWorkflowDiagram = generateWorkflowRunDiagram({
trigger: workflowRun.output.flow.trigger,
steps: workflowRun.output.flow.steps,
stepsOutput: workflowRun.output.stepsOutput,
});
setWorkflowDiagram(nextWorkflowDiagram);
}, [
setFlow,
setWorkflowDiagram,
handleWorkflowRunDiagramGeneration,
isInRightDrawer,
workflowRun?.output,
workflowRun?.workflowVersionId,
]);