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

View File

@ -9,4 +9,5 @@ export type WorkflowActionType =
| 'update-record' | 'update-record'
| 'delete-record' | 'delete-record'
| 'code' | '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', '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 { getShowPageTabListComponentId } from '@/ui/layout/show-page/utils/getShowPageTabListComponentId';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState'; import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; 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 { 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 { WorkflowServerlessFunctionTabId } from '@/workflow/workflow-steps/workflow-actions/code-action/types/WorkflowServerlessFunctionTabId';
import { useRecoilCallback } from 'recoil'; import { useRecoilCallback } from 'recoil';
@ -61,12 +60,6 @@ export const useCommandMenuCloseAnimationCompleteCleanup = () => {
emitRightDrawerCloseEvent(); emitRightDrawerCloseEvent();
set(isCommandMenuClosingState, false); set(isCommandMenuClosingState, false);
set(
activeTabIdComponentState.atomFamily({
instanceId: WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID,
}),
null,
);
set( set(
activeTabIdComponentState.atomFamily({ activeTabIdComponentState.atomFamily({
instanceId: WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID, 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 { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType'; import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector'; import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getIconColorForObjectType } from '@/object-metadata/utils/getIconColorForObjectType'; import { getIconColorForObjectType } from '@/object-metadata/utils/getIconColorForObjectType';
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState'; import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
import { useRunWorkflowRunOpeningInCommandMenuSideEffects } from '@/workflow/hooks/useRunWorkflowRunOpeningInCommandMenuSideEffects';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { useRecoilCallback } from 'recoil'; import { useRecoilCallback } from 'recoil';
import { v4 } from 'uuid';
import { capitalize } from 'twenty-shared/utils'; import { capitalize } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display'; import { useIcons } from 'twenty-ui/display';
import { v4 } from 'uuid';
export const useOpenRecordInCommandMenu = () => { export const useOpenRecordInCommandMenu = () => {
const { navigateCommandMenu } = useCommandMenu();
const theme = useTheme(); const theme = useTheme();
const { getIcon } = useIcons(); const { getIcon } = useIcons();
const { navigateCommandMenu } = useCommandMenu();
const { runWorkflowRunOpeningInCommandMenuSideEffects } =
useRunWorkflowRunOpeningInCommandMenuSideEffects();
const openRecordInCommandMenu = useRecoilCallback( const openRecordInCommandMenu = useRecoilCallback(
({ set, snapshot }) => { ({ set, snapshot }) => {
return ({ return ({
@ -147,9 +151,21 @@ export const useOpenRecordInCommandMenu = () => {
pageId: pageComponentInstanceId, pageId: pageComponentInstanceId,
resetNavigationStack: false, resetNavigationStack: false,
}); });
if (objectNameSingular === CoreObjectNameSingular.WorkflowRun) {
runWorkflowRunOpeningInCommandMenuSideEffects({
objectMetadataItem,
recordId,
});
}
}; };
}, },
[getIcon, navigateCommandMenu, theme], [
getIcon,
navigateCommandMenu,
runWorkflowRunOpeningInCommandMenuSideEffects,
theme,
],
); );
return { return {

View File

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

View File

@ -1,7 +1,9 @@
import { getIsInputTabDisabled } from '@/command-menu/pages/workflow/step/view-run/utils/getIsInputTabDisabled'; 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 { 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 { SingleTabProps, TabList } from '@/ui/layout/tab/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState'; 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 { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow'; import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun'; 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 { WorkflowRunStepInputDetail } from '@/workflow/workflow-steps/components/WorkflowRunStepInputDetail';
import { WorkflowRunStepNodeDetail } from '@/workflow/workflow-steps/components/WorkflowRunStepNodeDetail'; import { WorkflowRunStepNodeDetail } from '@/workflow/workflow-steps/components/WorkflowRunStepNodeDetail';
import { WorkflowRunStepOutputDetail } from '@/workflow/workflow-steps/components/WorkflowRunStepOutputDetail'; 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 { import {
WorkflowRunTabId, WorkflowRunTabId,
WorkflowRunTabIdType, WorkflowRunTabIdType,
} from '@/workflow/workflow-steps/types/WorkflowRunTabId'; } from '@/workflow/workflow-steps/types/WorkflowRunTabId';
import { getWorkflowRunStepExecutionStatus } from '@/workflow/workflow-steps/utils/getWorkflowRunStepExecutionStatus'; import { getWorkflowRunStepExecutionStatus } from '@/workflow/workflow-steps/utils/getWorkflowRunStepExecutionStatus';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { isNull } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { IconLogin2, IconLogout, IconStepInto } from 'twenty-ui/display'; import { IconLogin2, IconLogout, IconStepInto } from 'twenty-ui/display';
@ -41,9 +43,18 @@ export const CommandMenuWorkflowRunViewStep = () => {
const workflowRun = useWorkflowRun({ workflowRunId }); 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( const activeTabId = useRecoilComponentValueV2(
activeTabIdComponentState, activeTabIdComponentState,
WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID, commandMenuPageComponentInstance.instanceId,
); );
if (!isDefined(workflowRun)) { if (!isDefined(workflowRun)) {
@ -90,9 +101,7 @@ export const CommandMenuWorkflowRunViewStep = () => {
<StyledTabList <StyledTabList
tabs={tabs} tabs={tabs}
behaveAsLinks={false} behaveAsLinks={false}
componentInstanceId={ componentInstanceId={commandMenuPageComponentInstance.instanceId}
WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID
}
/> />
{activeTabId === WorkflowRunTabId.OUTPUT ? ( {activeTabId === WorkflowRunTabId.OUTPUT ? (

View File

@ -145,7 +145,7 @@ export const useRecordShowContainerTabs = (
tabs: { tabs: {
workflow: { workflow: {
title: 'Flow', title: 'Flow',
position: 0, position: 101,
Icon: IconSettings, Icon: IconSettings,
cards: [{ type: CardType.WorkflowCard }], cards: [{ type: CardType.WorkflowCard }],
hide: { hide: {
@ -168,7 +168,7 @@ export const useRecordShowContainerTabs = (
tabs: { tabs: {
workflowVersion: { workflowVersion: {
title: 'Flow', title: 'Flow',
position: 0, position: 101,
Icon: IconSettings, Icon: IconSettings,
cards: [{ type: CardType.WorkflowVersionCard }], cards: [{ type: CardType.WorkflowVersionCard }],
hide: { hide: {
@ -190,7 +190,7 @@ export const useRecordShowContainerTabs = (
tabs: { tabs: {
workflowRun: { workflowRun: {
title: 'Flow', title: 'Flow',
position: 0, position: 101,
Icon: IconSettings, Icon: IconSettings,
cards: [{ type: CardType.WorkflowRunCard }], cards: [{ type: CardType.WorkflowRunCard }],
hide: { 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 { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { WorkflowRun } from '@/workflow/types/Workflow'; import { WorkflowRun } from '@/workflow/types/Workflow';
import { workflowRunSchema } from '@/workflow/validation-schemas/workflowSchema'; import { workflowRunSchema } from '@/workflow/validation-schemas/workflowSchema';
import { useMemo } from 'react';
export const useWorkflowRun = ({ export const useWorkflowRun = ({
workflowRunId, workflowRunId,
@ -13,7 +14,10 @@ export const useWorkflowRun = ({
objectRecordId: workflowRunId, objectRecordId: workflowRunId,
}); });
const { success, data: record } = workflowRunSchema.safeParse(rawRecord); const { success, data: record } = useMemo(
() => workflowRunSchema.safeParse(rawRecord),
[rawRecord],
);
if (!success) { if (!success) {
return undefined; return undefined;

View File

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

View File

@ -26,10 +26,10 @@ import {
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import React, { useEffect, useMemo, useRef } from 'react'; 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 { isDefined } from 'twenty-shared/utils';
import { THEME_COMMON } from 'twenty-ui/theme';
import { Tag, TagColor } from 'twenty-ui/components'; import { Tag, TagColor } from 'twenty-ui/components';
import { THEME_COMMON } from 'twenty-ui/theme';
const StyledResetReactflowStyles = styled.div` const StyledResetReactflowStyles = styled.div`
height: 100%; height: 100%;
@ -72,7 +72,7 @@ const StyledStatusTagContainer = styled.div`
left: 0; left: 0;
top: 0; top: 0;
position: absolute; position: absolute;
padding: ${({ theme }) => theme.spacing(2)}; padding: ${({ theme }) => theme.spacing(4)};
`; `;
const defaultFitViewOptions = { const defaultFitViewOptions = {
@ -87,6 +87,7 @@ export const WorkflowDiagramCanvasBase = ({
tagContainerTestId, tagContainerTestId,
tagColor, tagColor,
tagText, tagText,
onInit,
}: { }: {
nodeTypes: Partial< nodeTypes: Partial<
Record< Record<
@ -114,13 +115,11 @@ export const WorkflowDiagramCanvasBase = ({
tagContainerTestId: string; tagContainerTestId: string;
tagColor: TagColor; tagColor: TagColor;
tagText: string; tagText: string;
onInit?: () => void;
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const reactflow = useReactFlow(); const reactflow = useReactFlow();
const setWorkflowReactFlowRefState = useSetRecoilState(
workflowReactFlowRefState,
);
const workflowDiagram = useRecoilValue(workflowDiagramState); const workflowDiagram = useRecoilValue(workflowDiagramState);
@ -140,22 +139,13 @@ export const WorkflowDiagramCanvasBase = ({
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState); const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
const handleNodesChange = ( const setWorkflowReactFlowRef = useRecoilCallback(
nodeChanges: Array<NodeChange<WorkflowDiagramNode>>, ({ set }) =>
) => { (node: HTMLDivElement | null) => {
setWorkflowDiagram((diagram) => { set(workflowReactFlowRefState, { current: node });
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 handleEdgesChange = ( const handleEdgesChange = (
edgeChanges: Array<EdgeChange<WorkflowDiagramEdge>>, edgeChanges: Array<EdgeChange<WorkflowDiagramEdge>>,
@ -209,36 +199,55 @@ export const WorkflowDiagramCanvasBase = ({
); );
}, [reactflow, rightDrawerState, rightDrawerWidth]); }, [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 ( return (
<StyledResetReactflowStyles ref={containerRef}> <StyledResetReactflowStyles ref={containerRef}>
<WorkflowDiagramCustomMarkers /> <WorkflowDiagramCustomMarkers />
<ReactFlow <ReactFlow
ref={(node) => { ref={setWorkflowReactFlowRef}
if (isDefined(node)) { onInit={handleInit}
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,
});
}}
minZoom={defaultFitViewOptions.minZoom} minZoom={defaultFitViewOptions.minZoom}
maxZoom={defaultFitViewOptions.maxZoom} maxZoom={defaultFitViewOptions.maxZoom}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
edgeTypes={edgeTypes} edgeTypes={edgeTypes}
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}
onNodesChange={handleNodesChange} onNodesChange={handleNodesChanges}
onEdgesChange={handleEdgesChange} onEdgesChange={handleEdgesChange}
onBeforeDelete={async () => { onBeforeDelete={async () => {
// Abort all non-programmatic deletions // Abort all non-programmatic deletions
@ -251,6 +260,7 @@ export const WorkflowDiagramCanvasBase = ({
nodesDraggable={false} nodesDraggable={false}
nodesConnectable={false} nodesConnectable={false}
paneClickDistance={10} // Fix small unwanted user dragging does not select node paneClickDistance={10} // Fix small unwanted user dragging does not select node
preventScrolling={false}
> >
<Background color={theme.border.color.medium} size={2} /> <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 { WorkflowDiagramStepNodeReadonly } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeReadonly';
import { WorkflowDiagramSuccessEdge } from '@/workflow/workflow-diagram/components/WorkflowDiagramSuccessEdge'; import { WorkflowDiagramSuccessEdge } from '@/workflow/workflow-diagram/components/WorkflowDiagramSuccessEdge';
import { WorkflowRunDiagramCanvasEffect } from '@/workflow/workflow-diagram/components/WorkflowRunDiagramCanvasEffect'; 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 { getWorkflowRunStatusTagProps } from '@/workflow/workflow-diagram/utils/getWorkflowRunStatusTagProps';
import { ReactFlowProvider } from '@xyflow/react'; import { ReactFlowProvider } from '@xyflow/react';
@ -16,6 +17,9 @@ export const WorkflowRunDiagramCanvas = ({
workflowRunStatus, workflowRunStatus,
}); });
const { handleWorkflowRunDiagramCanvasInit } =
useHandleWorkflowRunDiagramCanvasInit();
return ( return (
<ReactFlowProvider> <ReactFlowProvider>
<WorkflowDiagramCanvasBase <WorkflowDiagramCanvasBase
@ -29,6 +33,7 @@ export const WorkflowRunDiagramCanvas = ({
tagContainerTestId="workflow-run-status" tagContainerTestId="workflow-run-status"
tagColor={tagProps.color} tagColor={tagProps.color}
tagText={tagProps.text} tagText={tagProps.text}
onInit={handleWorkflowRunDiagramCanvasInit}
/> />
<WorkflowRunDiagramCanvasEffect /> <WorkflowRunDiagramCanvasEffect />

View File

@ -1,121 +1,61 @@
import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandMenu'; 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 { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { workflowIdState } from '@/workflow/states/workflowIdState'; import { workflowIdState } from '@/workflow/states/workflowIdState';
import { workflowDiagramStatusState } from '@/workflow/workflow-diagram/states/workflowDiagramStatusState';
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState'; import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
import { import { WorkflowRunDiagramNode } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
WorkflowDiagramNode,
WorkflowDiagramRunStatus,
WorkflowRunDiagramStepNodeData,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey'; 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 { OnSelectionChangeParams, useOnSelectionChange } from '@xyflow/react';
import { useCallback } from 'react'; import { useRecoilCallback } from 'recoil';
import { useRecoilCallback, useRecoilValue, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display'; import { useIcons } from 'twenty-ui/display';
export const WorkflowRunDiagramCanvasEffect = () => { export const WorkflowRunDiagramCanvasEffect = () => {
const { getIcon } = useIcons(); const { getIcon } = useIcons();
const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState);
const { openWorkflowRunViewStepInCommandMenu } = useWorkflowCommandMenu(); const { openWorkflowRunViewStepInCommandMenu } = useWorkflowCommandMenu();
const workflowId = useRecoilValue(workflowIdState); const handleSelectionChange = useRecoilCallback(
const resetWorkflowRunRightDrawerTabIfNeeded = useRecoilCallback(
({ snapshot, set }) => ({ snapshot, set }) =>
({ ({ nodes }: OnSelectionChangeParams) => {
workflowSelectedNode, const workflowId = getSnapshotValue(snapshot, workflowIdState);
stepExecutionStatus,
}: { if (!isDefined(workflowId)) {
workflowSelectedNode: string; throw new Error('Expected the workflowId to be defined.');
stepExecutionStatus: WorkflowDiagramRunStatus; }
}) => {
const activeWorkflowRunRightDrawerTab = getSnapshotValue( const workflowDiagramStatus = getSnapshotValue(
snapshot, snapshot,
activeTabIdComponentState.atomFamily({ workflowDiagramStatusState,
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,
);
// 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; return;
} }
if ( const selectedNode = nodes[0] as WorkflowRunDiagramNode | undefined;
(isInputTabDisabled &&
activeWorkflowRunRightDrawerTab === WorkflowRunTabId.INPUT) || if (!isDefined(selectedNode)) {
(isOutputTabDisabled && return;
activeWorkflowRunRightDrawerTab === WorkflowRunTabId.OUTPUT)
) {
set(
activeTabIdComponentState.atomFamily({
instanceId: WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID,
}),
WorkflowRunTabId.NODE,
);
} }
},
[],
);
const handleSelectionChange = useCallback( set(workflowSelectedNodeState, selectedNode.id);
({ nodes }: OnSelectionChangeParams) => {
const selectedNode = nodes[0] as WorkflowDiagramNode | undefined;
if (!isDefined(selectedNode)) { const selectedNodeData = selectedNode.data;
return;
}
setWorkflowSelectedNode(selectedNode.id); openWorkflowRunViewStepInCommandMenu({
const selectedNodeData =
selectedNode.data as WorkflowRunDiagramStepNodeData;
if (isDefined(workflowId)) {
openWorkflowRunViewStepInCommandMenu(
workflowId, workflowId,
selectedNodeData.name, title: selectedNodeData.name,
getIcon(getWorkflowNodeIconKey(selectedNodeData)), icon: getIcon(getWorkflowNodeIconKey(selectedNodeData)),
);
resetWorkflowRunRightDrawerTabIfNeeded({
workflowSelectedNode: selectedNode.id, workflowSelectedNode: selectedNode.id,
stepExecutionStatus: selectedNodeData.runStatus, stepExecutionStatus: selectedNodeData.runStatus,
}); });
} },
}, [getIcon, openWorkflowRunViewStepInCommandMenu],
[
setWorkflowSelectedNode,
resetWorkflowRunRightDrawerTabIfNeeded,
workflowId,
openWorkflowRunViewStepInCommandMenu,
getIcon,
],
); );
useOnSelectionChange({ useOnSelectionChange({

View File

@ -1,6 +1,8 @@
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun'; import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { WorkflowRunDiagramCanvas } from '@/workflow/workflow-diagram/components/WorkflowRunDiagramCanvas'; import { WorkflowRunDiagramCanvas } from '@/workflow/workflow-diagram/components/WorkflowRunDiagramCanvas';
import { workflowDiagramStatusState } from '@/workflow/workflow-diagram/states/workflowDiagramStatusState';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
const StyledContainer = styled.div` const StyledContainer = styled.div`
@ -13,8 +15,12 @@ export const WorkflowRunVisualizer = ({
workflowRunId: string; workflowRunId: string;
}) => { }) => {
const workflowRun = useWorkflowRun({ workflowRunId }); const workflowRun = useWorkflowRun({ workflowRunId });
const workflowDiagramStatus = useRecoilValue(workflowDiagramStatusState);
if (!isDefined(workflowRun)) { if (
!isDefined(workflowRun) ||
workflowDiagramStatus === 'computing-diagram'
) {
return null; return null;
} }

View File

@ -1,13 +1,18 @@
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useStepsOutputSchema } from '@/workflow/hooks/useStepsOutputSchema'; import { useStepsOutputSchema } from '@/workflow/hooks/useStepsOutputSchema';
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun'; import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion'; import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
import { flowState } from '@/workflow/states/flowState'; import { flowState } from '@/workflow/states/flowState';
import { workflowIdState } from '@/workflow/states/workflowIdState'; import { workflowIdState } from '@/workflow/states/workflowIdState';
import { workflowRunIdState } from '@/workflow/states/workflowRunIdState'; import { workflowRunIdState } from '@/workflow/states/workflowRunIdState';
import { WorkflowRunOutput } from '@/workflow/types/Workflow';
import { workflowDiagramState } from '@/workflow/workflow-diagram/states/workflowDiagramState'; 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 { generateWorkflowRunDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowRunDiagram';
import { useEffect } from 'react'; import { selectWorkflowDiagramNode } from '@/workflow/workflow-diagram/utils/selectWorkflowDiagramNode';
import { useSetRecoilState } from 'recoil'; import { useContext, useEffect } from 'react';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
export const WorkflowRunVisualizerEffect = ({ export const WorkflowRunVisualizerEffect = ({
@ -20,10 +25,10 @@ export const WorkflowRunVisualizerEffect = ({
const setWorkflowRunId = useSetRecoilState(workflowRunIdState); const setWorkflowRunId = useSetRecoilState(workflowRunIdState);
const setWorkflowId = useSetRecoilState(workflowIdState); const setWorkflowId = useSetRecoilState(workflowIdState);
const setFlow = useSetRecoilState(flowState);
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
const { populateStepsOutputSchema } = useStepsOutputSchema(); const { populateStepsOutputSchema } = useStepsOutputSchema();
const { isInRightDrawer } = useContext(ActionMenuContext);
useEffect(() => { useEffect(() => {
setWorkflowRunId(workflowRunId); setWorkflowRunId(workflowRunId);
}, [setWorkflowRunId, workflowRunId]); }, [setWorkflowRunId, workflowRunId]);
@ -32,33 +37,72 @@ export const WorkflowRunVisualizerEffect = ({
if (!isDefined(workflowRun)) { if (!isDefined(workflowRun)) {
return; return;
} }
setWorkflowId(workflowRun.workflowId); setWorkflowId(workflowRun.workflowId);
}, [setWorkflowId, workflowRun]); }, [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(() => { useEffect(() => {
if (!isDefined(workflowRun?.output)) { handleWorkflowRunDiagramGeneration({
setFlow(undefined); workflowRunOutput: workflowRun?.output ?? undefined,
setWorkflowDiagram(undefined); workflowVersionId: workflowRun?.workflowVersionId,
skipNodeSelection: isInRightDrawer,
return;
}
setFlow({
workflowVersionId: workflowRun.workflowVersionId,
trigger: workflowRun.output.flow.trigger,
steps: workflowRun.output.flow.steps,
}); });
const nextWorkflowDiagram = generateWorkflowRunDiagram({
trigger: workflowRun.output.flow.trigger,
steps: workflowRun.output.flow.steps,
stepsOutput: workflowRun.output.stepsOutput,
});
setWorkflowDiagram(nextWorkflowDiagram);
}, [ }, [
setFlow, handleWorkflowRunDiagramGeneration,
setWorkflowDiagram, isInRightDrawer,
workflowRun?.output, workflowRun?.output,
workflowRun?.workflowVersionId, 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'; import { createState } from 'twenty-ui/utilities';
export const workflowSelectedNodeState = createState<string | undefined>({ export const workflowSelectedNodeState = createState<string | undefined>({
key: 'workflowSelectedNodeState', key: 'workflowSelectedNodeState',
defaultValue: undefined, defaultValue: undefined,

View File

@ -3,6 +3,7 @@ import {
WorkflowStep, WorkflowStep,
WorkflowTrigger, WorkflowTrigger,
} from '@/workflow/types/Workflow'; } from '@/workflow/types/Workflow';
import { FieldMetadataType } from 'twenty-shared/types';
import { getUuidV4Mock } from '~/testing/utils/getUuidV4Mock'; import { getUuidV4Mock } from '~/testing/utils/getUuidV4Mock';
import { generateWorkflowRunDiagram } from '../generateWorkflowRunDiagram'; import { generateWorkflowRunDiagram } from '../generateWorkflowRunDiagram';
@ -87,94 +88,94 @@ describe('generateWorkflowRunDiagram', () => {
expect(result).toMatchInlineSnapshot(` expect(result).toMatchInlineSnapshot(`
{ {
"edges": [ "diagram": {
{ "edges": [
"deletable": false, {
"id": "8f3b2121-f194-4ba4-9fbf-0", "deletable": false,
"markerEnd": "workflow-edge-green-arrow-rounded", "id": "8f3b2121-f194-4ba4-9fbf-0",
"markerStart": "workflow-edge-green-circle", "markerEnd": "workflow-edge-green-arrow-rounded",
"selectable": false, "markerStart": "workflow-edge-green-circle",
"source": "trigger", "selectable": false,
"target": "step1", "source": "trigger",
"type": "success", "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",
}, },
"id": "trigger", {
"position": { "deletable": false,
"x": 0, "id": "8f3b2121-f194-4ba4-9fbf-1",
"y": 0, "markerEnd": "workflow-edge-arrow-rounded",
"markerStart": "workflow-edge-gray-circle",
"selectable": false,
"source": "step1",
"target": "step2",
}, },
}, {
{ "deletable": false,
"data": { "id": "8f3b2121-f194-4ba4-9fbf-2",
"actionType": "CODE", "markerEnd": "workflow-edge-arrow-rounded",
"name": "Step 1", "markerStart": "workflow-edge-gray-circle",
"nodeType": "action", "selectable": false,
"runStatus": "failure", "source": "step2",
"target": "step3",
}, },
"id": "step1", ],
"position": { "nodes": [
"x": 0, {
"y": 0, "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",
"data": { "name": "Step 1",
"actionType": "CODE", "nodeType": "action",
"name": "Step 2", "runStatus": "failure",
"nodeType": "action", },
"runStatus": "not-executed", "id": "step1",
"position": {
"x": 0,
"y": 0,
},
}, },
"id": "step2", {
"position": { "data": {
"x": 0, "actionType": "CODE",
"y": 150, "name": "Step 2",
"nodeType": "action",
"runStatus": "not-executed",
},
"id": "step2",
"position": {
"x": 0,
"y": 150,
},
}, },
"selected": false, {
}, "data": {
{ "actionType": "CODE",
"data": { "name": "Step 3",
"actionType": "CODE", "nodeType": "action",
"name": "Step 3", "runStatus": "not-executed",
"nodeType": "action", },
"runStatus": "not-executed", "id": "step3",
"position": {
"x": 0,
"y": 300,
},
}, },
"id": "step3", ],
"position": { },
"x": 0, "stepToOpenByDefault": undefined,
"y": 300,
},
"selected": false,
},
],
} }
`); `);
}); });
@ -263,96 +264,96 @@ describe('generateWorkflowRunDiagram', () => {
expect(result).toMatchInlineSnapshot(` expect(result).toMatchInlineSnapshot(`
{ {
"edges": [ "diagram": {
{ "edges": [
"deletable": false, {
"id": "8f3b2121-f194-4ba4-9fbf-3", "deletable": false,
"markerEnd": "workflow-edge-green-arrow-rounded", "id": "8f3b2121-f194-4ba4-9fbf-3",
"markerStart": "workflow-edge-green-circle", "markerEnd": "workflow-edge-green-arrow-rounded",
"selectable": false, "markerStart": "workflow-edge-green-circle",
"source": "trigger", "selectable": false,
"target": "step1", "source": "trigger",
"type": "success", "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",
}, },
"id": "trigger", {
"position": { "deletable": false,
"x": 0, "id": "8f3b2121-f194-4ba4-9fbf-4",
"y": 0, "markerEnd": "workflow-edge-green-arrow-rounded",
"markerStart": "workflow-edge-green-circle",
"selectable": false,
"source": "step1",
"target": "step2",
"type": "success",
}, },
}, {
{ "deletable": false,
"data": { "id": "8f3b2121-f194-4ba4-9fbf-5",
"actionType": "CODE", "markerEnd": "workflow-edge-green-arrow-rounded",
"name": "Step 1", "markerStart": "workflow-edge-green-circle",
"nodeType": "action", "selectable": false,
"runStatus": "success", "source": "step2",
"target": "step3",
"type": "success",
}, },
"id": "step1", ],
"position": { "nodes": [
"x": 0, {
"y": 0, "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",
"data": { "name": "Step 1",
"actionType": "CODE", "nodeType": "action",
"name": "Step 2", "runStatus": "success",
"nodeType": "action", },
"runStatus": "success", "id": "step1",
"position": {
"x": 0,
"y": 0,
},
}, },
"id": "step2", {
"position": { "data": {
"x": 0, "actionType": "CODE",
"y": 150, "name": "Step 2",
"nodeType": "action",
"runStatus": "success",
},
"id": "step2",
"position": {
"x": 0,
"y": 150,
},
}, },
"selected": false, {
}, "data": {
{ "actionType": "CODE",
"data": { "name": "Step 3",
"actionType": "CODE", "nodeType": "action",
"name": "Step 3", "runStatus": "success",
"nodeType": "action", },
"runStatus": "success", "id": "step3",
"position": {
"x": 0,
"y": 300,
},
}, },
"id": "step3", ],
"position": { },
"x": 0, "stepToOpenByDefault": undefined,
"y": 300,
},
"selected": false,
},
],
} }
`); `);
}); });
@ -428,94 +429,94 @@ describe('generateWorkflowRunDiagram', () => {
expect(result).toMatchInlineSnapshot(` expect(result).toMatchInlineSnapshot(`
{ {
"edges": [ "diagram": {
{ "edges": [
"deletable": false, {
"id": "8f3b2121-f194-4ba4-9fbf-6", "deletable": false,
"markerEnd": "workflow-edge-green-arrow-rounded", "id": "8f3b2121-f194-4ba4-9fbf-6",
"markerStart": "workflow-edge-green-circle", "markerEnd": "workflow-edge-green-arrow-rounded",
"selectable": false, "markerStart": "workflow-edge-green-circle",
"source": "trigger", "selectable": false,
"target": "step1", "source": "trigger",
"type": "success", "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",
}, },
"id": "trigger", {
"position": { "deletable": false,
"x": 0, "id": "8f3b2121-f194-4ba4-9fbf-7",
"y": 0, "markerEnd": "workflow-edge-arrow-rounded",
"markerStart": "workflow-edge-gray-circle",
"selectable": false,
"source": "step1",
"target": "step2",
}, },
}, {
{ "deletable": false,
"data": { "id": "8f3b2121-f194-4ba4-9fbf-8",
"actionType": "CODE", "markerEnd": "workflow-edge-arrow-rounded",
"name": "Step 1", "markerStart": "workflow-edge-gray-circle",
"nodeType": "action", "selectable": false,
"runStatus": "running", "source": "step2",
"target": "step3",
}, },
"id": "step1", ],
"position": { "nodes": [
"x": 0, {
"y": 0, "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",
"data": { "name": "Step 1",
"actionType": "CODE", "nodeType": "action",
"name": "Step 2", "runStatus": "running",
"nodeType": "action", },
"runStatus": "not-executed", "id": "step1",
"position": {
"x": 0,
"y": 0,
},
}, },
"id": "step2", {
"position": { "data": {
"x": 0, "actionType": "CODE",
"y": 150, "name": "Step 2",
"nodeType": "action",
"runStatus": "not-executed",
},
"id": "step2",
"position": {
"x": 0,
"y": 150,
},
}, },
"selected": false, {
}, "data": {
{ "actionType": "CODE",
"data": { "name": "Step 3",
"actionType": "CODE", "nodeType": "action",
"name": "Step 3", "runStatus": "not-executed",
"nodeType": "action", },
"runStatus": "not-executed", "id": "step3",
"position": {
"x": 0,
"y": 300,
},
}, },
"id": "step3", ],
"position": { },
"x": 0, "stepToOpenByDefault": undefined,
"y": 300,
},
"selected": false,
},
],
} }
`); `);
}); });
@ -614,118 +615,219 @@ describe('generateWorkflowRunDiagram', () => {
expect(result).toMatchInlineSnapshot(` expect(result).toMatchInlineSnapshot(`
{ {
"edges": [ "diagram": {
{ "edges": [
"deletable": false, {
"id": "8f3b2121-f194-4ba4-9fbf-9", "deletable": false,
"markerEnd": "workflow-edge-green-arrow-rounded", "id": "8f3b2121-f194-4ba4-9fbf-9",
"markerStart": "workflow-edge-green-circle", "markerEnd": "workflow-edge-green-arrow-rounded",
"selectable": false, "markerStart": "workflow-edge-green-circle",
"source": "trigger", "selectable": false,
"target": "step1", "source": "trigger",
"type": "success", "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",
}, },
{ "id": "step1",
"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,
},
],
} }
`); `);
}); });

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