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

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 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({

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]);
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,
]);

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,94 +88,94 @@ describe('generateWorkflowRunDiagram', () => {
expect(result).toMatchInlineSnapshot(`
{
"edges": [
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-0",
"markerEnd": "workflow-edge-green-arrow-rounded",
"markerStart": "workflow-edge-green-circle",
"selectable": false,
"source": "trigger",
"target": "step1",
"type": "success",
},
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-1",
"markerEnd": "workflow-edge-arrow-rounded",
"markerStart": "workflow-edge-gray-circle",
"selectable": false,
"source": "step1",
"target": "step2",
},
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-2",
"markerEnd": "workflow-edge-arrow-rounded",
"markerStart": "workflow-edge-gray-circle",
"selectable": false,
"source": "step2",
"target": "step3",
},
],
"nodes": [
{
"data": {
"icon": "IconPlaylistAdd",
"name": "Company created",
"nodeType": "trigger",
"runStatus": "success",
"triggerType": "DATABASE_EVENT",
"diagram": {
"edges": [
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-0",
"markerEnd": "workflow-edge-green-arrow-rounded",
"markerStart": "workflow-edge-green-circle",
"selectable": false,
"source": "trigger",
"target": "step1",
"type": "success",
},
"id": "trigger",
"position": {
"x": 0,
"y": 0,
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-1",
"markerEnd": "workflow-edge-arrow-rounded",
"markerStart": "workflow-edge-gray-circle",
"selectable": false,
"source": "step1",
"target": "step2",
},
},
{
"data": {
"actionType": "CODE",
"name": "Step 1",
"nodeType": "action",
"runStatus": "failure",
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-2",
"markerEnd": "workflow-edge-arrow-rounded",
"markerStart": "workflow-edge-gray-circle",
"selectable": false,
"source": "step2",
"target": "step3",
},
"id": "step1",
"position": {
"x": 0,
"y": 0,
],
"nodes": [
{
"data": {
"icon": "IconPlaylistAdd",
"name": "Company created",
"nodeType": "trigger",
"runStatus": "success",
"triggerType": "DATABASE_EVENT",
},
"id": "trigger",
"position": {
"x": 0,
"y": 0,
},
},
"selected": false,
},
{
"data": {
"actionType": "CODE",
"name": "Step 2",
"nodeType": "action",
"runStatus": "not-executed",
{
"data": {
"actionType": "CODE",
"name": "Step 1",
"nodeType": "action",
"runStatus": "failure",
},
"id": "step1",
"position": {
"x": 0,
"y": 0,
},
},
"id": "step2",
"position": {
"x": 0,
"y": 150,
{
"data": {
"actionType": "CODE",
"name": "Step 2",
"nodeType": "action",
"runStatus": "not-executed",
},
"id": "step2",
"position": {
"x": 0,
"y": 150,
},
},
"selected": false,
},
{
"data": {
"actionType": "CODE",
"name": "Step 3",
"nodeType": "action",
"runStatus": "not-executed",
{
"data": {
"actionType": "CODE",
"name": "Step 3",
"nodeType": "action",
"runStatus": "not-executed",
},
"id": "step3",
"position": {
"x": 0,
"y": 300,
},
},
"id": "step3",
"position": {
"x": 0,
"y": 300,
},
"selected": false,
},
],
],
},
"stepToOpenByDefault": undefined,
}
`);
});
@ -263,96 +264,96 @@ describe('generateWorkflowRunDiagram', () => {
expect(result).toMatchInlineSnapshot(`
{
"edges": [
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-3",
"markerEnd": "workflow-edge-green-arrow-rounded",
"markerStart": "workflow-edge-green-circle",
"selectable": false,
"source": "trigger",
"target": "step1",
"type": "success",
},
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-4",
"markerEnd": "workflow-edge-green-arrow-rounded",
"markerStart": "workflow-edge-green-circle",
"selectable": false,
"source": "step1",
"target": "step2",
"type": "success",
},
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-5",
"markerEnd": "workflow-edge-green-arrow-rounded",
"markerStart": "workflow-edge-green-circle",
"selectable": false,
"source": "step2",
"target": "step3",
"type": "success",
},
],
"nodes": [
{
"data": {
"icon": "IconPlaylistAdd",
"name": "Company created",
"nodeType": "trigger",
"runStatus": "success",
"triggerType": "DATABASE_EVENT",
"diagram": {
"edges": [
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-3",
"markerEnd": "workflow-edge-green-arrow-rounded",
"markerStart": "workflow-edge-green-circle",
"selectable": false,
"source": "trigger",
"target": "step1",
"type": "success",
},
"id": "trigger",
"position": {
"x": 0,
"y": 0,
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-4",
"markerEnd": "workflow-edge-green-arrow-rounded",
"markerStart": "workflow-edge-green-circle",
"selectable": false,
"source": "step1",
"target": "step2",
"type": "success",
},
},
{
"data": {
"actionType": "CODE",
"name": "Step 1",
"nodeType": "action",
"runStatus": "success",
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-5",
"markerEnd": "workflow-edge-green-arrow-rounded",
"markerStart": "workflow-edge-green-circle",
"selectable": false,
"source": "step2",
"target": "step3",
"type": "success",
},
"id": "step1",
"position": {
"x": 0,
"y": 0,
],
"nodes": [
{
"data": {
"icon": "IconPlaylistAdd",
"name": "Company created",
"nodeType": "trigger",
"runStatus": "success",
"triggerType": "DATABASE_EVENT",
},
"id": "trigger",
"position": {
"x": 0,
"y": 0,
},
},
"selected": false,
},
{
"data": {
"actionType": "CODE",
"name": "Step 2",
"nodeType": "action",
"runStatus": "success",
{
"data": {
"actionType": "CODE",
"name": "Step 1",
"nodeType": "action",
"runStatus": "success",
},
"id": "step1",
"position": {
"x": 0,
"y": 0,
},
},
"id": "step2",
"position": {
"x": 0,
"y": 150,
{
"data": {
"actionType": "CODE",
"name": "Step 2",
"nodeType": "action",
"runStatus": "success",
},
"id": "step2",
"position": {
"x": 0,
"y": 150,
},
},
"selected": false,
},
{
"data": {
"actionType": "CODE",
"name": "Step 3",
"nodeType": "action",
"runStatus": "success",
{
"data": {
"actionType": "CODE",
"name": "Step 3",
"nodeType": "action",
"runStatus": "success",
},
"id": "step3",
"position": {
"x": 0,
"y": 300,
},
},
"id": "step3",
"position": {
"x": 0,
"y": 300,
},
"selected": false,
},
],
],
},
"stepToOpenByDefault": undefined,
}
`);
});
@ -428,94 +429,94 @@ describe('generateWorkflowRunDiagram', () => {
expect(result).toMatchInlineSnapshot(`
{
"edges": [
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-6",
"markerEnd": "workflow-edge-green-arrow-rounded",
"markerStart": "workflow-edge-green-circle",
"selectable": false,
"source": "trigger",
"target": "step1",
"type": "success",
},
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-7",
"markerEnd": "workflow-edge-arrow-rounded",
"markerStart": "workflow-edge-gray-circle",
"selectable": false,
"source": "step1",
"target": "step2",
},
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-8",
"markerEnd": "workflow-edge-arrow-rounded",
"markerStart": "workflow-edge-gray-circle",
"selectable": false,
"source": "step2",
"target": "step3",
},
],
"nodes": [
{
"data": {
"icon": "IconPlaylistAdd",
"name": "Company created",
"nodeType": "trigger",
"runStatus": "success",
"triggerType": "DATABASE_EVENT",
"diagram": {
"edges": [
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-6",
"markerEnd": "workflow-edge-green-arrow-rounded",
"markerStart": "workflow-edge-green-circle",
"selectable": false,
"source": "trigger",
"target": "step1",
"type": "success",
},
"id": "trigger",
"position": {
"x": 0,
"y": 0,
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-7",
"markerEnd": "workflow-edge-arrow-rounded",
"markerStart": "workflow-edge-gray-circle",
"selectable": false,
"source": "step1",
"target": "step2",
},
},
{
"data": {
"actionType": "CODE",
"name": "Step 1",
"nodeType": "action",
"runStatus": "running",
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-8",
"markerEnd": "workflow-edge-arrow-rounded",
"markerStart": "workflow-edge-gray-circle",
"selectable": false,
"source": "step2",
"target": "step3",
},
"id": "step1",
"position": {
"x": 0,
"y": 0,
],
"nodes": [
{
"data": {
"icon": "IconPlaylistAdd",
"name": "Company created",
"nodeType": "trigger",
"runStatus": "success",
"triggerType": "DATABASE_EVENT",
},
"id": "trigger",
"position": {
"x": 0,
"y": 0,
},
},
"selected": false,
},
{
"data": {
"actionType": "CODE",
"name": "Step 2",
"nodeType": "action",
"runStatus": "not-executed",
{
"data": {
"actionType": "CODE",
"name": "Step 1",
"nodeType": "action",
"runStatus": "running",
},
"id": "step1",
"position": {
"x": 0,
"y": 0,
},
},
"id": "step2",
"position": {
"x": 0,
"y": 150,
{
"data": {
"actionType": "CODE",
"name": "Step 2",
"nodeType": "action",
"runStatus": "not-executed",
},
"id": "step2",
"position": {
"x": 0,
"y": 150,
},
},
"selected": false,
},
{
"data": {
"actionType": "CODE",
"name": "Step 3",
"nodeType": "action",
"runStatus": "not-executed",
{
"data": {
"actionType": "CODE",
"name": "Step 3",
"nodeType": "action",
"runStatus": "not-executed",
},
"id": "step3",
"position": {
"x": 0,
"y": 300,
},
},
"id": "step3",
"position": {
"x": 0,
"y": 300,
},
"selected": false,
},
],
],
},
"stepToOpenByDefault": undefined,
}
`);
});
@ -614,118 +615,219 @@ describe('generateWorkflowRunDiagram', () => {
expect(result).toMatchInlineSnapshot(`
{
"edges": [
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-9",
"markerEnd": "workflow-edge-green-arrow-rounded",
"markerStart": "workflow-edge-green-circle",
"selectable": false,
"source": "trigger",
"target": "step1",
"type": "success",
"diagram": {
"edges": [
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-9",
"markerEnd": "workflow-edge-green-arrow-rounded",
"markerStart": "workflow-edge-green-circle",
"selectable": false,
"source": "trigger",
"target": "step1",
"type": "success",
},
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-10",
"markerEnd": "workflow-edge-green-arrow-rounded",
"markerStart": "workflow-edge-green-circle",
"selectable": false,
"source": "step1",
"target": "step2",
"type": "success",
},
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-11",
"markerEnd": "workflow-edge-arrow-rounded",
"markerStart": "workflow-edge-gray-circle",
"selectable": false,
"source": "step2",
"target": "step3",
},
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-12",
"markerEnd": "workflow-edge-arrow-rounded",
"markerStart": "workflow-edge-gray-circle",
"selectable": false,
"source": "step3",
"target": "step4",
},
],
"nodes": [
{
"data": {
"icon": "IconPlaylistAdd",
"name": "Company created",
"nodeType": "trigger",
"runStatus": "success",
"triggerType": "DATABASE_EVENT",
},
"id": "trigger",
"position": {
"x": 0,
"y": 0,
},
},
{
"data": {
"actionType": "CODE",
"name": "Step 1",
"nodeType": "action",
"runStatus": "success",
},
"id": "step1",
"position": {
"x": 0,
"y": 0,
},
},
{
"data": {
"actionType": "CODE",
"name": "Step 2",
"nodeType": "action",
"runStatus": "running",
},
"id": "step2",
"position": {
"x": 0,
"y": 150,
},
},
{
"data": {
"actionType": "CODE",
"name": "Step 3",
"nodeType": "action",
"runStatus": "not-executed",
},
"id": "step3",
"position": {
"x": 0,
"y": 300,
},
},
{
"data": {
"actionType": "CODE",
"name": "Step 4",
"nodeType": "action",
"runStatus": "not-executed",
},
"id": "step4",
"position": {
"x": 0,
"y": 450,
},
},
],
},
"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",
},
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-10",
"markerEnd": "workflow-edge-green-arrow-rounded",
"markerStart": "workflow-edge-green-circle",
"selectable": false,
"source": "step1",
"target": "step2",
"type": "success",
},
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-11",
"markerEnd": "workflow-edge-arrow-rounded",
"markerStart": "workflow-edge-gray-circle",
"selectable": false,
"source": "step2",
"target": "step3",
},
{
"deletable": false,
"id": "8f3b2121-f194-4ba4-9fbf-12",
"markerEnd": "workflow-edge-arrow-rounded",
"markerStart": "workflow-edge-gray-circle",
"selectable": false,
"source": "step3",
"target": "step4",
},
],
"nodes": [
{
"data": {
"icon": "IconPlaylistAdd",
"name": "Company created",
"nodeType": "trigger",
"runStatus": "success",
"triggerType": "DATABASE_EVENT",
},
"id": "trigger",
"position": {
"x": 0,
"y": 0,
},
},
{
"data": {
"actionType": "CODE",
"name": "Step 1",
"nodeType": "action",
"runStatus": "success",
},
"id": "step1",
"position": {
"x": 0,
"y": 0,
},
"selected": false,
},
{
"data": {
"actionType": "CODE",
"name": "Step 2",
"nodeType": "action",
"runStatus": "running",
},
"id": "step2",
"position": {
"x": 0,
"y": 150,
},
"selected": false,
},
{
"data": {
"actionType": "CODE",
"name": "Step 3",
"nodeType": "action",
"runStatus": "not-executed",
},
"id": "step3",
"position": {
"x": 0,
"y": 300,
},
"selected": false,
},
{
"data": {
"actionType": "CODE",
"name": "Step 4",
"nodeType": "action",
"runStatus": "not-executed",
},
"id": "step4",
"position": {
"x": 0,
"y": 450,
},
"selected": false,
},
],
"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 = ({
}
}
const nodeData: WorkflowRunDiagramNodeData = {
nodeType: 'action',
actionType: step.type,
name: step.name,
runStatus,
};
nodes.push({
id: nodeId,
data: {
nodeType: 'action',
actionType: step.type,
name: step.name,
runStatus,
},
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 {
nodes,
edges,
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';