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

View File

@ -24,6 +24,8 @@ export class WorkflowVisualizerPage {
readonly useAsDraftButton: Locator;
readonly overrideDraftButton: Locator;
readonly discardDraftButton: Locator;
readonly seeRunsButton: Locator;
readonly goBackInCommandMenu: Locator;
#actionNames: Record<WorkflowActionType, string> = {
'create-record': 'Create Record',
@ -31,6 +33,7 @@ export class WorkflowVisualizerPage {
'delete-record': 'Delete Record',
code: 'Code',
'send-email': 'Send Email',
form: 'Form',
};
#createdActionNames: Record<WorkflowActionType, string> = {
@ -39,6 +42,7 @@ export class WorkflowVisualizerPage {
'delete-record': 'Delete Record',
code: 'Code - Serverless Function',
'send-email': 'Send Email',
form: 'Form',
};
#triggerNames: Record<WorkflowTriggerType, string> = {
@ -84,6 +88,10 @@ export class WorkflowVisualizerPage {
this.discardDraftButton = page.getByRole('button', {
name: 'Discard Draft',
});
this.seeRunsButton = page.getByRole('link', { name: 'See runs' });
this.goBackInCommandMenu = this.commandMenu
.getByRole('button')
.and(this.commandMenu.getByTestId('command-menu-go-back-button'));
}
async createOneWorkflow() {

View File

@ -9,4 +9,5 @@ export type WorkflowActionType =
| 'update-record'
| 'delete-record'
| 'code'
| 'send-email';
| 'send-email'
| 'form';

View File

@ -56,3 +56,113 @@ test('The workflow run visualizer shows the executed draft version without the l
'Create Record',
);
});
test('Workflow Runs with a pending form step can be opened in the side panel and then in full screen', async ({
workflowVisualizer,
page,
}) => {
await workflowVisualizer.createInitialTrigger('manual');
const manualTriggerAvailabilitySelect = page.getByRole('button', {
name: 'When record(s) are selected',
});
await manualTriggerAvailabilitySelect.click();
const alwaysAvailableOption = page.getByText(
'When no record(s) are selected',
);
await alwaysAvailableOption.click();
await workflowVisualizer.closeSidePanel();
const { createdStepId: firstStepId } =
await workflowVisualizer.createStep('form');
await workflowVisualizer.closeSidePanel();
const launchTestButton = page.getByLabel(workflowVisualizer.workflowName);
await launchTestButton.click();
const goToExecutionPageLink = page.getByRole('link', {
name: 'View execution details',
});
await expect(goToExecutionPageLink).toBeVisible();
await workflowVisualizer.seeRunsButton.click();
const workflowRunName = `#1 - ${workflowVisualizer.workflowName}`;
const workflowRunNameCell = page.getByRole('cell', { name: workflowRunName });
await expect(workflowRunNameCell).toBeVisible();
const recordTableOptionsButton = page.getByText('Options');
await recordTableOptionsButton.click();
const layoutButton = page.getByText('Layout');
await layoutButton.click();
const openInButton = page.getByText('Open in');
await openInButton.click();
const openInSidePanelOption = page.getByRole('option', {
name: 'Side panel',
});
await openInSidePanelOption.click();
// 1. Exit the dropdown
await workflowRunNameCell.click();
// 2. Actually open the workflow run in the side panel
await workflowRunNameCell.click();
await expect(workflowVisualizer.stepHeaderInCommandMenu).toContainText(
'Form',
);
await workflowVisualizer.goBackInCommandMenu.click();
const workflowRunNameInCommandMenu =
workflowVisualizer.commandMenu.getByText(workflowRunName);
await expect(workflowRunNameInCommandMenu).toBeVisible();
await workflowVisualizer.triggerNode.click();
await expect(workflowVisualizer.stepHeaderInCommandMenu).toContainText(
'Launch manually',
);
await workflowVisualizer.goBackInCommandMenu.click();
const formStep = workflowVisualizer.getStepNode(firstStepId);
await formStep.click();
await workflowVisualizer.goBackInCommandMenu.click();
const openInFullScreenButton = workflowVisualizer.commandMenu.getByRole(
'button',
{ name: 'Open' },
);
await openInFullScreenButton.click();
const workflowRunNameInShowPage = page
.getByText(`#1 - ${workflowVisualizer.workflowName}`)
.nth(1);
await expect(workflowRunNameInShowPage).toBeVisible();
// Expect the side panel to be opened by default on the form.
await expect(workflowVisualizer.stepHeaderInCommandMenu).toContainText(
'Form',
);
});

View File

@ -19,7 +19,6 @@ import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectab
import { getShowPageTabListComponentId } from '@/ui/layout/show-page/utils/getShowPageTabListComponentId';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-steps/constants/WorkflowRunStepSidePanelTabListComponentId';
import { WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-steps/workflow-actions/code-action/constants/WorkflowServerlessFunctionTabListComponentId';
import { WorkflowServerlessFunctionTabId } from '@/workflow/workflow-steps/workflow-actions/code-action/types/WorkflowServerlessFunctionTabId';
import { useRecoilCallback } from 'recoil';
@ -61,12 +60,6 @@ export const useCommandMenuCloseAnimationCompleteCleanup = () => {
emitRightDrawerCloseEvent();
set(isCommandMenuClosingState, false);
set(
activeTabIdComponentState.atomFamily({
instanceId: WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID,
}),
null,
);
set(
activeTabIdComponentState.atomFamily({
instanceId: WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID,

View File

@ -11,21 +11,25 @@ import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-sto
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getIconColorForObjectType } from '@/object-metadata/utils/getIconColorForObjectType';
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
import { useRunWorkflowRunOpeningInCommandMenuSideEffects } from '@/workflow/hooks/useRunWorkflowRunOpeningInCommandMenuSideEffects';
import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro';
import { useRecoilCallback } from 'recoil';
import { v4 } from 'uuid';
import { capitalize } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { v4 } from 'uuid';
export const useOpenRecordInCommandMenu = () => {
const { navigateCommandMenu } = useCommandMenu();
const theme = useTheme();
const { getIcon } = useIcons();
const { navigateCommandMenu } = useCommandMenu();
const { runWorkflowRunOpeningInCommandMenuSideEffects } =
useRunWorkflowRunOpeningInCommandMenuSideEffects();
const openRecordInCommandMenu = useRecoilCallback(
({ set, snapshot }) => {
return ({
@ -147,9 +151,21 @@ export const useOpenRecordInCommandMenu = () => {
pageId: pageComponentInstanceId,
resetNavigationStack: false,
});
if (objectNameSingular === CoreObjectNameSingular.WorkflowRun) {
runWorkflowRunOpeningInCommandMenuSideEffects({
objectMetadataItem,
recordId,
});
}
};
},
[getIcon, navigateCommandMenu, theme],
[
getIcon,
navigateCommandMenu,
runWorkflowRunOpeningInCommandMenuSideEffects,
theme,
],
);
return {

View File

@ -1,17 +1,21 @@
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { workflowIdComponentState } from '@/command-menu/pages/workflow/states/workflowIdComponentState';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { useSetInitialWorkflowRunRightDrawerTab } from '@/workflow/workflow-diagram/hooks/useSetInitialWorkflowRunRightDrawerTab';
import { WorkflowDiagramRunStatus } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { t } from '@lingui/core/macro';
import { useRecoilCallback } from 'recoil';
import { v4 } from 'uuid';
import {
IconBolt,
IconComponent,
IconSettingsAutomation,
} from 'twenty-ui/display';
import { v4 } from 'uuid';
export const useWorkflowCommandMenu = () => {
const { navigateCommandMenu } = useNavigateCommandMenu();
const { setInitialWorkflowRunRightDrawerTab } =
useSetInitialWorkflowRunRightDrawerTab();
const openWorkflowTriggerTypeInCommandMenu = useRecoilCallback(
({ set }) => {
@ -99,7 +103,19 @@ export const useWorkflowCommandMenu = () => {
const openWorkflowRunViewStepInCommandMenu = useRecoilCallback(
({ set }) => {
return (workflowId: string, title: string, icon: IconComponent) => {
return ({
workflowId,
title,
icon,
workflowSelectedNode,
stepExecutionStatus,
}: {
workflowId: string;
title: string;
icon: IconComponent;
workflowSelectedNode: string;
stepExecutionStatus: WorkflowDiagramRunStatus;
}) => {
const pageId = v4();
set(
@ -113,9 +129,14 @@ export const useWorkflowCommandMenu = () => {
pageIcon: icon,
pageId,
});
setInitialWorkflowRunRightDrawerTab({
workflowSelectedNode,
stepExecutionStatus,
});
};
},
[navigateCommandMenu],
[navigateCommandMenu, setInitialWorkflowRunRightDrawerTab],
);
return {

View File

@ -1,7 +1,9 @@
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 { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
import { SingleTabProps, TabList } from '@/ui/layout/tab/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { useComponentInstanceStateContext } from '@/ui/utilities/state/component-state/hooks/useComponentInstanceStateContext';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
@ -11,13 +13,13 @@ import { useWorkflowSelectedNodeOrThrow } from '@/workflow/workflow-diagram/hook
import { WorkflowRunStepInputDetail } from '@/workflow/workflow-steps/components/WorkflowRunStepInputDetail';
import { WorkflowRunStepNodeDetail } from '@/workflow/workflow-steps/components/WorkflowRunStepNodeDetail';
import { WorkflowRunStepOutputDetail } from '@/workflow/workflow-steps/components/WorkflowRunStepOutputDetail';
import { WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-steps/constants/WorkflowRunStepSidePanelTabListComponentId';
import {
WorkflowRunTabId,
WorkflowRunTabIdType,
} from '@/workflow/workflow-steps/types/WorkflowRunTabId';
import { getWorkflowRunStepExecutionStatus } from '@/workflow/workflow-steps/utils/getWorkflowRunStepExecutionStatus';
import styled from '@emotion/styled';
import { isNull } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { IconLogin2, IconLogout, IconStepInto } from 'twenty-ui/display';
@ -41,9 +43,18 @@ export const CommandMenuWorkflowRunViewStep = () => {
const workflowRun = useWorkflowRun({ workflowRunId });
const commandMenuPageComponentInstance = useComponentInstanceStateContext(
CommandMenuPageComponentInstanceContext,
);
if (isNull(commandMenuPageComponentInstance)) {
throw new Error(
'CommandMenuPageComponentInstanceContext is not defined. This component should be used within CommandMenuPageComponentInstanceContext.',
);
}
const activeTabId = useRecoilComponentValueV2(
activeTabIdComponentState,
WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID,
commandMenuPageComponentInstance.instanceId,
);
if (!isDefined(workflowRun)) {
@ -90,9 +101,7 @@ export const CommandMenuWorkflowRunViewStep = () => {
<StyledTabList
tabs={tabs}
behaveAsLinks={false}
componentInstanceId={
WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID
}
componentInstanceId={commandMenuPageComponentInstance.instanceId}
/>
{activeTabId === WorkflowRunTabId.OUTPUT ? (

View File

@ -145,7 +145,7 @@ export const useRecordShowContainerTabs = (
tabs: {
workflow: {
title: 'Flow',
position: 0,
position: 101,
Icon: IconSettings,
cards: [{ type: CardType.WorkflowCard }],
hide: {
@ -168,7 +168,7 @@ export const useRecordShowContainerTabs = (
tabs: {
workflowVersion: {
title: 'Flow',
position: 0,
position: 101,
Icon: IconSettings,
cards: [{ type: CardType.WorkflowVersionCard }],
hide: {
@ -190,7 +190,7 @@ export const useRecordShowContainerTabs = (
tabs: {
workflowRun: {
title: 'Flow',
position: 0,
position: 101,
Icon: IconSettings,
cards: [{ type: CardType.WorkflowRunCard }],
hide: {

View File

@ -0,0 +1,84 @@
import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandMenu';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { flowState } from '@/workflow/states/flowState';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { workflowRunIdState } from '@/workflow/states/workflowRunIdState';
import { WorkflowRun } from '@/workflow/types/Workflow';
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
import { generateWorkflowRunDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowRunDiagram';
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey';
import { useApolloClient } from '@apollo/client';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
export const useRunWorkflowRunOpeningInCommandMenuSideEffects = () => {
const apolloClient = useApolloClient();
const { openWorkflowRunViewStepInCommandMenu } = useWorkflowCommandMenu();
const { getIcon } = useIcons();
const runWorkflowRunOpeningInCommandMenuSideEffects = useRecoilCallback(
({ snapshot, set }) =>
({
objectMetadataItem,
recordId,
}: {
objectMetadataItem: ObjectMetadataItem;
recordId: string;
}) => {
const objectMetadataItems = getSnapshotValue(
snapshot,
objectMetadataItemsState,
);
const workflowRunRecord = getRecordFromCache<WorkflowRun>({
objectMetadataItem,
cache: apolloClient.cache,
recordId,
objectMetadataItems,
});
if (
!(isDefined(workflowRunRecord) && isDefined(workflowRunRecord.output))
) {
throw new Error(
`No workflow run record found for record ID ${recordId}`,
);
}
const { stepToOpenByDefault } = generateWorkflowRunDiagram({
steps: workflowRunRecord.output.flow.steps,
stepsOutput: workflowRunRecord.output.stepsOutput,
trigger: workflowRunRecord.output.flow.trigger,
});
if (!isDefined(stepToOpenByDefault)) {
return;
}
set(workflowRunIdState, workflowRunRecord.id);
set(workflowIdState, workflowRunRecord.workflowId);
set(flowState, {
workflowVersionId: workflowRunRecord.workflowVersionId,
trigger: workflowRunRecord.output.flow.trigger,
steps: workflowRunRecord.output.flow.steps,
});
set(workflowSelectedNodeState, stepToOpenByDefault.id);
openWorkflowRunViewStepInCommandMenu({
workflowId: workflowRunRecord.workflowId,
title: stepToOpenByDefault.data.name,
icon: getIcon(getWorkflowNodeIconKey(stepToOpenByDefault.data)),
workflowSelectedNode: stepToOpenByDefault.id,
stepExecutionStatus: stepToOpenByDefault.data.runStatus,
});
},
[apolloClient.cache, getIcon, openWorkflowRunViewStepInCommandMenu],
);
return {
runWorkflowRunOpeningInCommandMenuSideEffects,
};
};

View File

@ -2,6 +2,7 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { WorkflowRun } from '@/workflow/types/Workflow';
import { workflowRunSchema } from '@/workflow/validation-schemas/workflowSchema';
import { useMemo } from 'react';
export const useWorkflowRun = ({
workflowRunId,
@ -13,7 +14,10 @@ export const useWorkflowRun = ({
objectRecordId: workflowRunId,
});
const { success, data: record } = workflowRunSchema.safeParse(rawRecord);
const { success, data: record } = useMemo(
() => workflowRunSchema.safeParse(rawRecord),
[rawRecord],
);
if (!success) {
return undefined;

View File

@ -1,4 +1,5 @@
import { createState } from 'twenty-ui/utilities';
export const workflowRunIdState = createState<string | undefined>({
key: 'workflowRunIdState',
defaultValue: undefined,

View File

@ -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.',
const setWorkflowReactFlowRef = useRecoilCallback(
({ set }) =>
(node: HTMLDivElement | null) => {
set(workflowReactFlowRefState, { current: node });
},
[],
);
}
return {
...diagram,
nodes: applyNodeChanges(nodeChanges, diagram.nodes),
};
});
};
const handleEdgesChange = (
edgeChanges: Array<EdgeChange<WorkflowDiagramEdge>>,
@ -209,19 +199,28 @@ export const WorkflowDiagramCanvasBase = ({
);
}, [reactflow, rightDrawerState, rightDrawerWidth]);
return (
<StyledResetReactflowStyles ref={containerRef}>
<WorkflowDiagramCustomMarkers />
<ReactFlow
ref={(node) => {
if (isDefined(node)) {
setWorkflowReactFlowRefState({ current: node });
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.',
);
}
}}
onInit={() => {
return {
...diagram,
nodes: applyNodeChanges(changes, diagram.nodes),
};
});
},
[],
);
const handleInit = () => {
if (!isDefined(containerRef.current)) {
throw new Error('Expect the container ref to be defined');
return;
}
const flowBounds = reactflow.getNodesBounds(reactflow.getNodes());
@ -231,14 +230,24 @@ export const WorkflowDiagramCanvasBase = ({
y: 150,
zoom: defaultFitViewOptions.maxZoom,
});
}}
onInit?.();
};
return (
<StyledResetReactflowStyles ref={containerRef}>
<WorkflowDiagramCustomMarkers />
<ReactFlow
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} />

View File

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

View File

@ -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 handleSelectionChange = useCallback(
({ nodes }: OnSelectionChangeParams) => {
const selectedNode = nodes[0] as WorkflowDiagramNode | undefined;
const selectedNode = nodes[0] as WorkflowRunDiagramNode | undefined;
if (!isDefined(selectedNode)) {
return;
}
setWorkflowSelectedNode(selectedNode.id);
set(workflowSelectedNodeState, selectedNode.id);
const selectedNodeData =
selectedNode.data as WorkflowRunDiagramStepNodeData;
const selectedNodeData = selectedNode.data;
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({

View File

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

View File

@ -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]);
useEffect(() => {
if (!isDefined(workflowRun?.output)) {
setFlow(undefined);
setWorkflowDiagram(undefined);
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;
}
setFlow({
workflowVersionId: workflowRun.workflowVersionId,
trigger: workflowRun.output.flow.trigger,
steps: workflowRun.output.flow.steps,
set(workflowDiagramStatusState, 'computing-diagram');
set(flowState, {
workflowVersionId,
trigger: workflowRunOutput.flow.trigger,
steps: workflowRunOutput.flow.steps,
});
const nextWorkflowDiagram = generateWorkflowRunDiagram({
trigger: workflowRun.output.flow.trigger,
steps: workflowRun.output.flow.steps,
stepsOutput: workflowRun.output.stepsOutput,
const { diagram: baseWorkflowRunDiagram, stepToOpenByDefault } =
generateWorkflowRunDiagram({
trigger: workflowRunOutput.flow.trigger,
steps: workflowRunOutput.flow.steps,
stepsOutput: workflowRunOutput.stepsOutput,
});
setWorkflowDiagram(nextWorkflowDiagram);
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(() => {
handleWorkflowRunDiagramGeneration({
workflowRunOutput: workflowRun?.output ?? undefined,
workflowVersionId: workflowRun?.workflowVersionId,
skipNodeSelection: isInRightDrawer,
});
}, [
setFlow,
setWorkflowDiagram,
handleWorkflowRunDiagramGeneration,
isInRightDrawer,
workflowRun?.output,
workflowRun?.workflowVersionId,
]);

View File

@ -0,0 +1,74 @@
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandMenu';
import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { workflowDiagramStatusState } from '@/workflow/workflow-diagram/states/workflowDiagramStatusState';
import { workflowRunStepToOpenByDefaultState } from '@/workflow/workflow-diagram/states/workflowRunStepToOpenByDefaultState';
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey';
import { useContext } from 'react';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
export const useHandleWorkflowRunDiagramCanvasInit = () => {
const { getIcon } = useIcons();
const { openWorkflowRunViewStepInCommandMenu } = useWorkflowCommandMenu();
const { isInRightDrawer } = useContext(ActionMenuContext);
const handleWorkflowRunDiagramCanvasInit = useRecoilCallback(
({ snapshot, set }) =>
() => {
const workflowDiagramStatus = getSnapshotValue(
snapshot,
workflowDiagramStatusState,
);
if (workflowDiagramStatus !== 'computing-dimensions') {
throw new Error(
'Sequence error: reactflow should be considered initialized only when the workflow diagram status is computing-dimensions.',
);
}
set(workflowDiagramStatusState, 'done');
if (isInRightDrawer) {
return;
}
const workflowStepToOpenByDefault = getSnapshotValue(
snapshot,
workflowRunStepToOpenByDefaultState,
);
if (isDefined(workflowStepToOpenByDefault)) {
const workflowId = getSnapshotValue(snapshot, workflowIdState);
if (!isDefined(workflowId)) {
throw new Error(
'The workflow id must be set; ensure the workflow id is always set before rendering the workflow diagram.',
);
}
set(workflowSelectedNodeState, workflowStepToOpenByDefault.id);
openWorkflowRunViewStepInCommandMenu({
workflowId,
title: workflowStepToOpenByDefault.data.name,
icon: getIcon(
getWorkflowNodeIconKey(workflowStepToOpenByDefault.data),
),
workflowSelectedNode: workflowStepToOpenByDefault.id,
stepExecutionStatus: workflowStepToOpenByDefault.data.runStatus,
});
set(workflowRunStepToOpenByDefaultState, undefined);
}
},
[getIcon, isInRightDrawer, openWorkflowRunViewStepInCommandMenu],
);
return {
handleWorkflowRunDiagramCanvasInit,
};
};

View File

@ -0,0 +1,76 @@
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 { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { WorkflowDiagramRunStatus } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { WorkflowRunTabId } from '@/workflow/workflow-steps/types/WorkflowRunTabId';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const useSetInitialWorkflowRunRightDrawerTab = () => {
const setInitialWorkflowRunRightDrawerTab = useRecoilCallback(
({ snapshot, set }) =>
({
workflowSelectedNode,
stepExecutionStatus,
}: {
workflowSelectedNode: string;
stepExecutionStatus: WorkflowDiagramRunStatus;
}) => {
const commandMenuPageInfo = getSnapshotValue(
snapshot,
commandMenuPageInfoState,
);
const activeWorkflowRunRightDrawerTab = getSnapshotValue(
snapshot,
activeTabIdComponentState.atomFamily({
instanceId: commandMenuPageInfo.instanceId,
}),
) as WorkflowRunTabId | null;
const isInputTabDisabled = getIsInputTabDisabled({
stepExecutionStatus,
workflowSelectedNode,
});
const isOutputTabDisabled = getIsOutputTabDisabled({
stepExecutionStatus,
});
if (!isDefined(activeWorkflowRunRightDrawerTab)) {
const defaultTabId = isOutputTabDisabled
? WorkflowRunTabId.NODE
: WorkflowRunTabId.OUTPUT;
set(
activeTabIdComponentState.atomFamily({
instanceId: commandMenuPageInfo.instanceId,
}),
defaultTabId,
);
return;
}
if (
(isInputTabDisabled &&
activeWorkflowRunRightDrawerTab === WorkflowRunTabId.INPUT) ||
(isOutputTabDisabled &&
activeWorkflowRunRightDrawerTab === WorkflowRunTabId.OUTPUT)
) {
set(
activeTabIdComponentState.atomFamily({
instanceId: commandMenuPageInfo.instanceId,
}),
WorkflowRunTabId.NODE,
);
}
},
[],
);
return {
setInitialWorkflowRunRightDrawerTab,
};
};

View File

@ -0,0 +1,8 @@
import { createState } from 'twenty-ui/utilities';
export const workflowDiagramStatusState = createState<
'computing-diagram' | 'computing-dimensions' | 'done'
>({
key: 'workflowDiagramStatusState',
defaultValue: 'computing-diagram',
});

View File

@ -0,0 +1,13 @@
import { WorkflowRunDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { createState } from 'twenty-ui/utilities';
export const workflowRunStepToOpenByDefaultState = createState<
| {
id: string;
data: WorkflowRunDiagramStepNodeData;
}
| undefined
>({
key: 'workflowStepIdToOpenByDefaultState',
defaultValue: undefined,
});

View File

@ -1,4 +1,5 @@
import { createState } from 'twenty-ui/utilities';
export const workflowSelectedNodeState = createState<string | undefined>({
key: 'workflowSelectedNodeState',
defaultValue: undefined,

View File

@ -3,6 +3,7 @@ import {
WorkflowStep,
WorkflowTrigger,
} from '@/workflow/types/Workflow';
import { FieldMetadataType } from 'twenty-shared/types';
import { getUuidV4Mock } from '~/testing/utils/getUuidV4Mock';
import { generateWorkflowRunDiagram } from '../generateWorkflowRunDiagram';
@ -87,6 +88,7 @@ describe('generateWorkflowRunDiagram', () => {
expect(result).toMatchInlineSnapshot(`
{
"diagram": {
"edges": [
{
"deletable": false,
@ -144,7 +146,6 @@ describe('generateWorkflowRunDiagram', () => {
"x": 0,
"y": 0,
},
"selected": false,
},
{
"data": {
@ -158,7 +159,6 @@ describe('generateWorkflowRunDiagram', () => {
"x": 0,
"y": 150,
},
"selected": false,
},
{
"data": {
@ -172,9 +172,10 @@ describe('generateWorkflowRunDiagram', () => {
"x": 0,
"y": 300,
},
"selected": false,
},
],
},
"stepToOpenByDefault": undefined,
}
`);
});
@ -263,6 +264,7 @@ describe('generateWorkflowRunDiagram', () => {
expect(result).toMatchInlineSnapshot(`
{
"diagram": {
"edges": [
{
"deletable": false,
@ -322,7 +324,6 @@ describe('generateWorkflowRunDiagram', () => {
"x": 0,
"y": 0,
},
"selected": false,
},
{
"data": {
@ -336,7 +337,6 @@ describe('generateWorkflowRunDiagram', () => {
"x": 0,
"y": 150,
},
"selected": false,
},
{
"data": {
@ -350,9 +350,10 @@ describe('generateWorkflowRunDiagram', () => {
"x": 0,
"y": 300,
},
"selected": false,
},
],
},
"stepToOpenByDefault": undefined,
}
`);
});
@ -428,6 +429,7 @@ describe('generateWorkflowRunDiagram', () => {
expect(result).toMatchInlineSnapshot(`
{
"diagram": {
"edges": [
{
"deletable": false,
@ -485,7 +487,6 @@ describe('generateWorkflowRunDiagram', () => {
"x": 0,
"y": 0,
},
"selected": false,
},
{
"data": {
@ -499,7 +500,6 @@ describe('generateWorkflowRunDiagram', () => {
"x": 0,
"y": 150,
},
"selected": false,
},
{
"data": {
@ -513,9 +513,10 @@ describe('generateWorkflowRunDiagram', () => {
"x": 0,
"y": 300,
},
"selected": false,
},
],
},
"stepToOpenByDefault": undefined,
}
`);
});
@ -614,6 +615,7 @@ describe('generateWorkflowRunDiagram', () => {
expect(result).toMatchInlineSnapshot(`
{
"diagram": {
"edges": [
{
"deletable": false,
@ -681,7 +683,6 @@ describe('generateWorkflowRunDiagram', () => {
"x": 0,
"y": 0,
},
"selected": false,
},
{
"data": {
@ -695,7 +696,6 @@ describe('generateWorkflowRunDiagram', () => {
"x": 0,
"y": 150,
},
"selected": false,
},
{
"data": {
@ -709,7 +709,6 @@ describe('generateWorkflowRunDiagram', () => {
"x": 0,
"y": 300,
},
"selected": false,
},
{
"data": {
@ -723,9 +722,112 @@ describe('generateWorkflowRunDiagram', () => {
"x": 0,
"y": 450,
},
"selected": false,
},
],
},
"stepToOpenByDefault": undefined,
}
`);
});
it('marks node as running when a Form step is pending and return its data as the stepToOpenByDefault object', () => {
const trigger: WorkflowTrigger = {
name: 'Company created',
type: 'DATABASE_EVENT',
settings: {
eventName: 'company.created',
outputSchema: {},
},
};
const steps: WorkflowStep[] = [
{
id: 'step1',
name: 'Step 1',
type: 'FORM',
valid: true,
settings: {
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
input: [
{
id: 'field-1',
name: 'text',
label: 'Text Field',
type: FieldMetadataType.TEXT,
placeholder: 'Enter text',
settings: {},
},
],
outputSchema: {},
},
},
];
const stepsOutput = {
step1: {
result: undefined,
error: undefined,
pendingEvent: true,
},
};
const result = generateWorkflowRunDiagram({ trigger, steps, stepsOutput });
expect(result).toMatchInlineSnapshot(`
{
"diagram": {
"edges": [
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-13",
"markerEnd": "workflow-edge-green-arrow-rounded",
"markerStart": "workflow-edge-green-circle",
"selectable": false,
"source": "trigger",
"target": "step1",
"type": "success",
},
],
"nodes": [
{
"data": {
"icon": "IconPlaylistAdd",
"name": "Company created",
"nodeType": "trigger",
"runStatus": "success",
"triggerType": "DATABASE_EVENT",
},
"id": "trigger",
"position": {
"x": 0,
"y": 0,
},
},
{
"data": {
"actionType": "FORM",
"name": "Step 1",
"nodeType": "action",
"runStatus": "running",
},
"id": "step1",
"position": {
"x": 0,
"y": 0,
},
},
],
},
"stepToOpenByDefault": {
"data": {
"actionType": "FORM",
"name": "Step 1",
"nodeType": "action",
"runStatus": "running",
},
"id": "step1",
},
}
`);
});

View File

@ -0,0 +1,91 @@
import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { selectWorkflowDiagramNode } from '../selectWorkflowDiagramNode';
describe('selectWorkflowDiagramNode', () => {
it('should select the specified node', () => {
const diagram: WorkflowDiagram = {
nodes: [
{
id: '1',
selected: false,
position: { x: 0, y: 0 },
data: {
name: 'Node 1',
nodeType: 'action',
actionType: 'CODE',
},
},
{
id: '2',
selected: false,
position: { x: 0, y: 150 },
data: {
name: 'Node 2',
nodeType: 'action',
actionType: 'CODE',
},
},
],
edges: [],
};
const result = selectWorkflowDiagramNode({
diagram,
nodeIdToSelect: '1',
});
expect(result.nodes[0].selected).toBe(true);
expect(result.nodes[1].selected).toBe(false);
});
it('should return same diagram when node is not found', () => {
const diagram: WorkflowDiagram = {
nodes: [
{
id: '1',
selected: false,
position: { x: 0, y: 0 },
data: {
name: 'Node 1',
nodeType: 'action',
actionType: 'CODE',
},
},
],
edges: [],
};
const result = selectWorkflowDiagramNode({
diagram,
nodeIdToSelect: 'non-existent',
});
expect(result).toEqual(diagram);
});
it('should not mutate original diagram', () => {
const diagram: WorkflowDiagram = {
nodes: [
{
id: '1',
selected: false,
position: { x: 0, y: 0 },
data: {
name: 'Node 1',
nodeType: 'action',
actionType: 'CODE',
},
},
],
edges: [],
};
const originalDiagram = JSON.parse(JSON.stringify(diagram));
selectWorkflowDiagramNode({
diagram,
nodeIdToSelect: '1',
});
expect(diagram).toEqual(originalDiagram);
});
});

View File

@ -12,6 +12,8 @@ import {
WorkflowRunDiagram,
WorkflowRunDiagramEdge,
WorkflowRunDiagramNode,
WorkflowRunDiagramNodeData,
WorkflowRunDiagramStepNodeData,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getWorkflowDiagramTriggerNode } from '@/workflow/workflow-diagram/utils/getWorkflowDiagramTriggerNode';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
@ -26,7 +28,22 @@ export const generateWorkflowRunDiagram = ({
trigger: WorkflowTrigger;
steps: Array<WorkflowStep>;
stepsOutput: WorkflowRunOutputStepsOutput | undefined;
}): WorkflowRunDiagram => {
}): {
diagram: WorkflowRunDiagram;
stepToOpenByDefault:
| {
id: string;
data: WorkflowRunDiagramStepNodeData;
}
| undefined;
} => {
let stepToOpenByDefault:
| {
id: string;
data: WorkflowRunDiagramStepNodeData;
}
| undefined = undefined;
const triggerBase = getWorkflowDiagramTriggerNode({ trigger });
const nodes: Array<WorkflowRunDiagramNode> = [
@ -97,21 +114,29 @@ export const generateWorkflowRunDiagram = ({
}
}
nodes.push({
id: nodeId,
data: {
const nodeData: WorkflowRunDiagramNodeData = {
nodeType: 'action',
actionType: step.type,
name: step.name,
runStatus,
},
};
nodes.push({
id: nodeId,
data: nodeData,
position: {
x: xPos,
y: yPos,
},
selected: isPendingFormAction,
});
if (isPendingFormAction) {
stepToOpenByDefault = {
id: nodeId,
data: nodeData,
};
}
processNode({
stepIndex: stepIndex + 1,
parentNodeId: nodeId,
@ -134,7 +159,10 @@ export const generateWorkflowRunDiagram = ({
});
return {
diagram: {
nodes,
edges,
},
stepToOpenByDefault,
};
};

View File

@ -0,0 +1,23 @@
import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
export const selectWorkflowDiagramNode = <T extends WorkflowDiagram>({
diagram,
nodeIdToSelect,
}: {
diagram: T;
nodeIdToSelect: string;
}): T => {
return {
...diagram,
nodes: diagram.nodes.map((node) => {
if (node.id === nodeIdToSelect) {
return {
...node,
selected: true,
};
}
return node;
}),
};
};

View File

@ -1,2 +0,0 @@
export const WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID =
'workflow-run-step-side-panel-tab-list';