Workflow runs in side panel (#11669)
Vidéo explicative : https://share.cleanshot.com/VsvWknlW Closes https://github.com/twentyhq/core-team-issues/issues/810 Closes https://github.com/twentyhq/core-team-issues/issues/806 Known issues to fix later: - https://github.com/twentyhq/core-team-issues/issues/879
This commit is contained in:
committed by
GitHub
parent
0083569606
commit
cc211550ae
@ -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() {
|
||||
|
||||
@ -9,4 +9,5 @@ export type WorkflowActionType =
|
||||
| 'update-record'
|
||||
| 'delete-record'
|
||||
| 'code'
|
||||
| 'send-email';
|
||||
| 'send-email'
|
||||
| 'form';
|
||||
|
||||
@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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;
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { createState } from 'twenty-ui/utilities';
|
||||
|
||||
export const workflowRunIdState = createState<string | undefined>({
|
||||
key: 'workflowRunIdState',
|
||||
defaultValue: undefined,
|
||||
|
||||
@ -26,10 +26,10 @@ import {
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { useRecoilCallback, useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { THEME_COMMON } from 'twenty-ui/theme';
|
||||
import { Tag, TagColor } from 'twenty-ui/components';
|
||||
import { THEME_COMMON } from 'twenty-ui/theme';
|
||||
|
||||
const StyledResetReactflowStyles = styled.div`
|
||||
height: 100%;
|
||||
@ -72,7 +72,7 @@ const StyledStatusTagContainer = styled.div`
|
||||
left: 0;
|
||||
top: 0;
|
||||
position: absolute;
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
padding: ${({ theme }) => theme.spacing(4)};
|
||||
`;
|
||||
|
||||
const defaultFitViewOptions = {
|
||||
@ -87,6 +87,7 @@ export const WorkflowDiagramCanvasBase = ({
|
||||
tagContainerTestId,
|
||||
tagColor,
|
||||
tagText,
|
||||
onInit,
|
||||
}: {
|
||||
nodeTypes: Partial<
|
||||
Record<
|
||||
@ -114,13 +115,11 @@ export const WorkflowDiagramCanvasBase = ({
|
||||
tagContainerTestId: string;
|
||||
tagColor: TagColor;
|
||||
tagText: string;
|
||||
onInit?: () => void;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const reactflow = useReactFlow();
|
||||
const setWorkflowReactFlowRefState = useSetRecoilState(
|
||||
workflowReactFlowRefState,
|
||||
);
|
||||
|
||||
const workflowDiagram = useRecoilValue(workflowDiagramState);
|
||||
|
||||
@ -140,22 +139,13 @@ export const WorkflowDiagramCanvasBase = ({
|
||||
|
||||
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
|
||||
|
||||
const handleNodesChange = (
|
||||
nodeChanges: Array<NodeChange<WorkflowDiagramNode>>,
|
||||
) => {
|
||||
setWorkflowDiagram((diagram) => {
|
||||
if (isDefined(diagram) === false) {
|
||||
throw new Error(
|
||||
'It must be impossible for the nodes to be updated if the diagram is not defined yet. Be sure the diagram is rendered only when defined.',
|
||||
const setWorkflowReactFlowRef = useRecoilCallback(
|
||||
({ set }) =>
|
||||
(node: HTMLDivElement | null) => {
|
||||
set(workflowReactFlowRefState, { current: node });
|
||||
},
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...diagram,
|
||||
nodes: applyNodeChanges(nodeChanges, diagram.nodes),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdgesChange = (
|
||||
edgeChanges: Array<EdgeChange<WorkflowDiagramEdge>>,
|
||||
@ -209,19 +199,28 @@ export const WorkflowDiagramCanvasBase = ({
|
||||
);
|
||||
}, [reactflow, rightDrawerState, rightDrawerWidth]);
|
||||
|
||||
return (
|
||||
<StyledResetReactflowStyles ref={containerRef}>
|
||||
<WorkflowDiagramCustomMarkers />
|
||||
|
||||
<ReactFlow
|
||||
ref={(node) => {
|
||||
if (isDefined(node)) {
|
||||
setWorkflowReactFlowRefState({ current: node });
|
||||
const handleNodesChanges = useRecoilCallback(
|
||||
({ set }) =>
|
||||
(changes: NodeChange<WorkflowDiagramNode>[]) => {
|
||||
set(workflowDiagramState, (diagram) => {
|
||||
if (!isDefined(diagram)) {
|
||||
throw new Error(
|
||||
'It must be impossible for the nodes to be updated if the diagram is not defined yet. Be sure the diagram is rendered only when defined.',
|
||||
);
|
||||
}
|
||||
}}
|
||||
onInit={() => {
|
||||
|
||||
return {
|
||||
...diagram,
|
||||
nodes: applyNodeChanges(changes, diagram.nodes),
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleInit = () => {
|
||||
if (!isDefined(containerRef.current)) {
|
||||
throw new Error('Expect the container ref to be defined');
|
||||
return;
|
||||
}
|
||||
|
||||
const flowBounds = reactflow.getNodesBounds(reactflow.getNodes());
|
||||
@ -231,14 +230,24 @@ export const WorkflowDiagramCanvasBase = ({
|
||||
y: 150,
|
||||
zoom: defaultFitViewOptions.maxZoom,
|
||||
});
|
||||
}}
|
||||
|
||||
onInit?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledResetReactflowStyles ref={containerRef}>
|
||||
<WorkflowDiagramCustomMarkers />
|
||||
|
||||
<ReactFlow
|
||||
ref={setWorkflowReactFlowRef}
|
||||
onInit={handleInit}
|
||||
minZoom={defaultFitViewOptions.minZoom}
|
||||
maxZoom={defaultFitViewOptions.maxZoom}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={handleNodesChange}
|
||||
onNodesChange={handleNodesChanges}
|
||||
onEdgesChange={handleEdgesChange}
|
||||
onBeforeDelete={async () => {
|
||||
// Abort all non-programmatic deletions
|
||||
@ -251,6 +260,7 @@ export const WorkflowDiagramCanvasBase = ({
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
paneClickDistance={10} // Fix small unwanted user dragging does not select node
|
||||
preventScrolling={false}
|
||||
>
|
||||
<Background color={theme.border.color.medium} size={2} />
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import { WorkflowDiagramDefaultEdge } from '@/workflow/workflow-diagram/componen
|
||||
import { WorkflowDiagramStepNodeReadonly } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeReadonly';
|
||||
import { WorkflowDiagramSuccessEdge } from '@/workflow/workflow-diagram/components/WorkflowDiagramSuccessEdge';
|
||||
import { WorkflowRunDiagramCanvasEffect } from '@/workflow/workflow-diagram/components/WorkflowRunDiagramCanvasEffect';
|
||||
import { useHandleWorkflowRunDiagramCanvasInit } from '@/workflow/workflow-diagram/hooks/useHandleWorkflowRunDiagramCanvasInit';
|
||||
import { getWorkflowRunStatusTagProps } from '@/workflow/workflow-diagram/utils/getWorkflowRunStatusTagProps';
|
||||
import { ReactFlowProvider } from '@xyflow/react';
|
||||
|
||||
@ -16,6 +17,9 @@ export const WorkflowRunDiagramCanvas = ({
|
||||
workflowRunStatus,
|
||||
});
|
||||
|
||||
const { handleWorkflowRunDiagramCanvasInit } =
|
||||
useHandleWorkflowRunDiagramCanvasInit();
|
||||
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<WorkflowDiagramCanvasBase
|
||||
@ -29,6 +33,7 @@ export const WorkflowRunDiagramCanvas = ({
|
||||
tagContainerTestId="workflow-run-status"
|
||||
tagColor={tagProps.color}
|
||||
tagText={tagProps.text}
|
||||
onInit={handleWorkflowRunDiagramCanvasInit}
|
||||
/>
|
||||
|
||||
<WorkflowRunDiagramCanvasEffect />
|
||||
|
||||
@ -1,121 +1,61 @@
|
||||
import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandMenu';
|
||||
import { getIsInputTabDisabled } from '@/command-menu/pages/workflow/step/view-run/utils/getIsInputTabDisabled';
|
||||
import { getIsOutputTabDisabled } from '@/command-menu/pages/workflow/step/view-run/utils/getIsOutputTabDisabled';
|
||||
import { activeTabIdComponentState } from '@/ui/layout/tab/states/activeTabIdComponentState';
|
||||
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
|
||||
import { workflowIdState } from '@/workflow/states/workflowIdState';
|
||||
import { workflowDiagramStatusState } from '@/workflow/workflow-diagram/states/workflowDiagramStatusState';
|
||||
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
|
||||
import {
|
||||
WorkflowDiagramNode,
|
||||
WorkflowDiagramRunStatus,
|
||||
WorkflowRunDiagramStepNodeData,
|
||||
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
import { WorkflowRunDiagramNode } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey';
|
||||
import { WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-steps/constants/WorkflowRunStepSidePanelTabListComponentId';
|
||||
import { WorkflowRunTabId } from '@/workflow/workflow-steps/types/WorkflowRunTabId';
|
||||
import { isNull } from '@sniptt/guards';
|
||||
import { OnSelectionChangeParams, useOnSelectionChange } from '@xyflow/react';
|
||||
import { useCallback } from 'react';
|
||||
import { useRecoilCallback, useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useIcons } from 'twenty-ui/display';
|
||||
|
||||
export const WorkflowRunDiagramCanvasEffect = () => {
|
||||
const { getIcon } = useIcons();
|
||||
const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState);
|
||||
|
||||
const { openWorkflowRunViewStepInCommandMenu } = useWorkflowCommandMenu();
|
||||
|
||||
const workflowId = useRecoilValue(workflowIdState);
|
||||
|
||||
const resetWorkflowRunRightDrawerTabIfNeeded = useRecoilCallback(
|
||||
const handleSelectionChange = useRecoilCallback(
|
||||
({ snapshot, set }) =>
|
||||
({
|
||||
workflowSelectedNode,
|
||||
stepExecutionStatus,
|
||||
}: {
|
||||
workflowSelectedNode: string;
|
||||
stepExecutionStatus: WorkflowDiagramRunStatus;
|
||||
}) => {
|
||||
const activeWorkflowRunRightDrawerTab = getSnapshotValue(
|
||||
({ nodes }: OnSelectionChangeParams) => {
|
||||
const workflowId = getSnapshotValue(snapshot, workflowIdState);
|
||||
|
||||
if (!isDefined(workflowId)) {
|
||||
throw new Error('Expected the workflowId to be defined.');
|
||||
}
|
||||
|
||||
const workflowDiagramStatus = getSnapshotValue(
|
||||
snapshot,
|
||||
activeTabIdComponentState.atomFamily({
|
||||
instanceId: WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID,
|
||||
}),
|
||||
) as WorkflowRunTabId | null;
|
||||
|
||||
const isInputTabDisabled = getIsInputTabDisabled({
|
||||
stepExecutionStatus,
|
||||
workflowSelectedNode,
|
||||
});
|
||||
const isOutputTabDisabled = getIsOutputTabDisabled({
|
||||
stepExecutionStatus,
|
||||
});
|
||||
|
||||
if (isNull(activeWorkflowRunRightDrawerTab)) {
|
||||
const defaultTabId = isOutputTabDisabled
|
||||
? WorkflowRunTabId.NODE
|
||||
: WorkflowRunTabId.OUTPUT;
|
||||
|
||||
set(
|
||||
activeTabIdComponentState.atomFamily({
|
||||
instanceId: WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID,
|
||||
}),
|
||||
defaultTabId,
|
||||
workflowDiagramStatusState,
|
||||
);
|
||||
|
||||
// The `handleSelectionChange` function is called when the diagram initializes and
|
||||
// a node is selected. In this case, we don't want to execute the rest of this function.
|
||||
// We open the Side Panel® synchronously after ReactFlow is initialized and a node is selected,
|
||||
// animations perform better that way.
|
||||
if (workflowDiagramStatus !== 'done') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(isInputTabDisabled &&
|
||||
activeWorkflowRunRightDrawerTab === WorkflowRunTabId.INPUT) ||
|
||||
(isOutputTabDisabled &&
|
||||
activeWorkflowRunRightDrawerTab === WorkflowRunTabId.OUTPUT)
|
||||
) {
|
||||
set(
|
||||
activeTabIdComponentState.atomFamily({
|
||||
instanceId: WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID,
|
||||
}),
|
||||
WorkflowRunTabId.NODE,
|
||||
);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSelectionChange = useCallback(
|
||||
({ nodes }: OnSelectionChangeParams) => {
|
||||
const selectedNode = nodes[0] as WorkflowDiagramNode | undefined;
|
||||
const selectedNode = nodes[0] as WorkflowRunDiagramNode | undefined;
|
||||
|
||||
if (!isDefined(selectedNode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkflowSelectedNode(selectedNode.id);
|
||||
set(workflowSelectedNodeState, selectedNode.id);
|
||||
|
||||
const selectedNodeData =
|
||||
selectedNode.data as WorkflowRunDiagramStepNodeData;
|
||||
const selectedNodeData = selectedNode.data;
|
||||
|
||||
if (isDefined(workflowId)) {
|
||||
openWorkflowRunViewStepInCommandMenu(
|
||||
openWorkflowRunViewStepInCommandMenu({
|
||||
workflowId,
|
||||
selectedNodeData.name,
|
||||
getIcon(getWorkflowNodeIconKey(selectedNodeData)),
|
||||
);
|
||||
|
||||
resetWorkflowRunRightDrawerTabIfNeeded({
|
||||
title: selectedNodeData.name,
|
||||
icon: getIcon(getWorkflowNodeIconKey(selectedNodeData)),
|
||||
workflowSelectedNode: selectedNode.id,
|
||||
stepExecutionStatus: selectedNodeData.runStatus,
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
setWorkflowSelectedNode,
|
||||
resetWorkflowRunRightDrawerTabIfNeeded,
|
||||
workflowId,
|
||||
openWorkflowRunViewStepInCommandMenu,
|
||||
getIcon,
|
||||
],
|
||||
[getIcon, openWorkflowRunViewStepInCommandMenu],
|
||||
);
|
||||
|
||||
useOnSelectionChange({
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
|
||||
import { WorkflowRunDiagramCanvas } from '@/workflow/workflow-diagram/components/WorkflowRunDiagramCanvas';
|
||||
import { workflowDiagramStatusState } from '@/workflow/workflow-diagram/states/workflowDiagramStatusState';
|
||||
import styled from '@emotion/styled';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
@ -13,8 +15,12 @@ export const WorkflowRunVisualizer = ({
|
||||
workflowRunId: string;
|
||||
}) => {
|
||||
const workflowRun = useWorkflowRun({ workflowRunId });
|
||||
const workflowDiagramStatus = useRecoilValue(workflowDiagramStatusState);
|
||||
|
||||
if (!isDefined(workflowRun)) {
|
||||
if (
|
||||
!isDefined(workflowRun) ||
|
||||
workflowDiagramStatus === 'computing-diagram'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@ -1,13 +1,18 @@
|
||||
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
|
||||
import { useStepsOutputSchema } from '@/workflow/hooks/useStepsOutputSchema';
|
||||
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
|
||||
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
|
||||
import { flowState } from '@/workflow/states/flowState';
|
||||
import { workflowIdState } from '@/workflow/states/workflowIdState';
|
||||
import { workflowRunIdState } from '@/workflow/states/workflowRunIdState';
|
||||
import { WorkflowRunOutput } from '@/workflow/types/Workflow';
|
||||
import { workflowDiagramState } from '@/workflow/workflow-diagram/states/workflowDiagramState';
|
||||
import { workflowDiagramStatusState } from '@/workflow/workflow-diagram/states/workflowDiagramStatusState';
|
||||
import { workflowRunStepToOpenByDefaultState } from '@/workflow/workflow-diagram/states/workflowRunStepToOpenByDefaultState';
|
||||
import { generateWorkflowRunDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowRunDiagram';
|
||||
import { useEffect } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { selectWorkflowDiagramNode } from '@/workflow/workflow-diagram/utils/selectWorkflowDiagramNode';
|
||||
import { useContext, useEffect } from 'react';
|
||||
import { useRecoilCallback, useSetRecoilState } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const WorkflowRunVisualizerEffect = ({
|
||||
@ -20,10 +25,10 @@ export const WorkflowRunVisualizerEffect = ({
|
||||
|
||||
const setWorkflowRunId = useSetRecoilState(workflowRunIdState);
|
||||
const setWorkflowId = useSetRecoilState(workflowIdState);
|
||||
const setFlow = useSetRecoilState(flowState);
|
||||
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
|
||||
const { populateStepsOutputSchema } = useStepsOutputSchema();
|
||||
|
||||
const { isInRightDrawer } = useContext(ActionMenuContext);
|
||||
|
||||
useEffect(() => {
|
||||
setWorkflowRunId(workflowRunId);
|
||||
}, [setWorkflowRunId, workflowRunId]);
|
||||
@ -32,33 +37,72 @@ export const WorkflowRunVisualizerEffect = ({
|
||||
if (!isDefined(workflowRun)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkflowId(workflowRun.workflowId);
|
||||
}, [setWorkflowId, workflowRun]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDefined(workflowRun?.output)) {
|
||||
setFlow(undefined);
|
||||
setWorkflowDiagram(undefined);
|
||||
const handleWorkflowRunDiagramGeneration = useRecoilCallback(
|
||||
({ set }) =>
|
||||
({
|
||||
workflowRunOutput,
|
||||
workflowVersionId,
|
||||
skipNodeSelection,
|
||||
}: {
|
||||
workflowRunOutput: WorkflowRunOutput | undefined;
|
||||
workflowVersionId: string | undefined;
|
||||
skipNodeSelection: boolean;
|
||||
}) => {
|
||||
if (!(isDefined(workflowRunOutput) && isDefined(workflowVersionId))) {
|
||||
set(flowState, undefined);
|
||||
set(workflowDiagramState, undefined);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setFlow({
|
||||
workflowVersionId: workflowRun.workflowVersionId,
|
||||
trigger: workflowRun.output.flow.trigger,
|
||||
steps: workflowRun.output.flow.steps,
|
||||
set(workflowDiagramStatusState, 'computing-diagram');
|
||||
|
||||
set(flowState, {
|
||||
workflowVersionId,
|
||||
trigger: workflowRunOutput.flow.trigger,
|
||||
steps: workflowRunOutput.flow.steps,
|
||||
});
|
||||
|
||||
const nextWorkflowDiagram = generateWorkflowRunDiagram({
|
||||
trigger: workflowRun.output.flow.trigger,
|
||||
steps: workflowRun.output.flow.steps,
|
||||
stepsOutput: workflowRun.output.stepsOutput,
|
||||
const { diagram: baseWorkflowRunDiagram, stepToOpenByDefault } =
|
||||
generateWorkflowRunDiagram({
|
||||
trigger: workflowRunOutput.flow.trigger,
|
||||
steps: workflowRunOutput.flow.steps,
|
||||
stepsOutput: workflowRunOutput.stepsOutput,
|
||||
});
|
||||
|
||||
setWorkflowDiagram(nextWorkflowDiagram);
|
||||
if (isDefined(stepToOpenByDefault) && !skipNodeSelection) {
|
||||
const workflowRunDiagram = selectWorkflowDiagramNode({
|
||||
diagram: baseWorkflowRunDiagram,
|
||||
nodeIdToSelect: stepToOpenByDefault.id,
|
||||
});
|
||||
|
||||
set(workflowDiagramState, workflowRunDiagram);
|
||||
set(workflowRunStepToOpenByDefaultState, {
|
||||
id: stepToOpenByDefault.id,
|
||||
data: stepToOpenByDefault.data,
|
||||
});
|
||||
} else {
|
||||
set(workflowDiagramState, baseWorkflowRunDiagram);
|
||||
}
|
||||
|
||||
set(workflowDiagramStatusState, 'computing-dimensions');
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
handleWorkflowRunDiagramGeneration({
|
||||
workflowRunOutput: workflowRun?.output ?? undefined,
|
||||
workflowVersionId: workflowRun?.workflowVersionId,
|
||||
skipNodeSelection: isInRightDrawer,
|
||||
});
|
||||
}, [
|
||||
setFlow,
|
||||
setWorkflowDiagram,
|
||||
handleWorkflowRunDiagramGeneration,
|
||||
isInRightDrawer,
|
||||
workflowRun?.output,
|
||||
workflowRun?.workflowVersionId,
|
||||
]);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,8 @@
|
||||
import { createState } from 'twenty-ui/utilities';
|
||||
|
||||
export const workflowDiagramStatusState = createState<
|
||||
'computing-diagram' | 'computing-dimensions' | 'done'
|
||||
>({
|
||||
key: 'workflowDiagramStatusState',
|
||||
defaultValue: 'computing-diagram',
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
@ -1,4 +1,5 @@
|
||||
import { createState } from 'twenty-ui/utilities';
|
||||
|
||||
export const workflowSelectedNodeState = createState<string | undefined>({
|
||||
key: 'workflowSelectedNodeState',
|
||||
defaultValue: undefined,
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
WorkflowStep,
|
||||
WorkflowTrigger,
|
||||
} from '@/workflow/types/Workflow';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { getUuidV4Mock } from '~/testing/utils/getUuidV4Mock';
|
||||
import { generateWorkflowRunDiagram } from '../generateWorkflowRunDiagram';
|
||||
|
||||
@ -87,6 +88,7 @@ describe('generateWorkflowRunDiagram', () => {
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
{
|
||||
"diagram": {
|
||||
"edges": [
|
||||
{
|
||||
"deletable": false,
|
||||
@ -144,7 +146,6 @@ describe('generateWorkflowRunDiagram', () => {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"selected": false,
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
@ -158,7 +159,6 @@ describe('generateWorkflowRunDiagram', () => {
|
||||
"x": 0,
|
||||
"y": 150,
|
||||
},
|
||||
"selected": false,
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
@ -172,9 +172,10 @@ describe('generateWorkflowRunDiagram', () => {
|
||||
"x": 0,
|
||||
"y": 300,
|
||||
},
|
||||
"selected": false,
|
||||
},
|
||||
],
|
||||
},
|
||||
"stepToOpenByDefault": undefined,
|
||||
}
|
||||
`);
|
||||
});
|
||||
@ -263,6 +264,7 @@ describe('generateWorkflowRunDiagram', () => {
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
{
|
||||
"diagram": {
|
||||
"edges": [
|
||||
{
|
||||
"deletable": false,
|
||||
@ -322,7 +324,6 @@ describe('generateWorkflowRunDiagram', () => {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"selected": false,
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
@ -336,7 +337,6 @@ describe('generateWorkflowRunDiagram', () => {
|
||||
"x": 0,
|
||||
"y": 150,
|
||||
},
|
||||
"selected": false,
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
@ -350,9 +350,10 @@ describe('generateWorkflowRunDiagram', () => {
|
||||
"x": 0,
|
||||
"y": 300,
|
||||
},
|
||||
"selected": false,
|
||||
},
|
||||
],
|
||||
},
|
||||
"stepToOpenByDefault": undefined,
|
||||
}
|
||||
`);
|
||||
});
|
||||
@ -428,6 +429,7 @@ describe('generateWorkflowRunDiagram', () => {
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
{
|
||||
"diagram": {
|
||||
"edges": [
|
||||
{
|
||||
"deletable": false,
|
||||
@ -485,7 +487,6 @@ describe('generateWorkflowRunDiagram', () => {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"selected": false,
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
@ -499,7 +500,6 @@ describe('generateWorkflowRunDiagram', () => {
|
||||
"x": 0,
|
||||
"y": 150,
|
||||
},
|
||||
"selected": false,
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
@ -513,9 +513,10 @@ describe('generateWorkflowRunDiagram', () => {
|
||||
"x": 0,
|
||||
"y": 300,
|
||||
},
|
||||
"selected": false,
|
||||
},
|
||||
],
|
||||
},
|
||||
"stepToOpenByDefault": undefined,
|
||||
}
|
||||
`);
|
||||
});
|
||||
@ -614,6 +615,7 @@ describe('generateWorkflowRunDiagram', () => {
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
{
|
||||
"diagram": {
|
||||
"edges": [
|
||||
{
|
||||
"deletable": false,
|
||||
@ -681,7 +683,6 @@ describe('generateWorkflowRunDiagram', () => {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"selected": false,
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
@ -695,7 +696,6 @@ describe('generateWorkflowRunDiagram', () => {
|
||||
"x": 0,
|
||||
"y": 150,
|
||||
},
|
||||
"selected": false,
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
@ -709,7 +709,6 @@ describe('generateWorkflowRunDiagram', () => {
|
||||
"x": 0,
|
||||
"y": 300,
|
||||
},
|
||||
"selected": false,
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
@ -723,9 +722,112 @@ describe('generateWorkflowRunDiagram', () => {
|
||||
"x": 0,
|
||||
"y": 450,
|
||||
},
|
||||
"selected": false,
|
||||
},
|
||||
],
|
||||
},
|
||||
"stepToOpenByDefault": undefined,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('marks node as running when a Form step is pending and return its data as the stepToOpenByDefault object', () => {
|
||||
const trigger: WorkflowTrigger = {
|
||||
name: 'Company created',
|
||||
type: 'DATABASE_EVENT',
|
||||
settings: {
|
||||
eventName: 'company.created',
|
||||
outputSchema: {},
|
||||
},
|
||||
};
|
||||
const steps: WorkflowStep[] = [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
type: 'FORM',
|
||||
valid: true,
|
||||
settings: {
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: true },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
input: [
|
||||
{
|
||||
id: 'field-1',
|
||||
name: 'text',
|
||||
label: 'Text Field',
|
||||
type: FieldMetadataType.TEXT,
|
||||
placeholder: 'Enter text',
|
||||
settings: {},
|
||||
},
|
||||
],
|
||||
outputSchema: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
const stepsOutput = {
|
||||
step1: {
|
||||
result: undefined,
|
||||
error: undefined,
|
||||
pendingEvent: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = generateWorkflowRunDiagram({ trigger, steps, stepsOutput });
|
||||
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
{
|
||||
"diagram": {
|
||||
"edges": [
|
||||
{
|
||||
"deletable": false,
|
||||
"id": "8f3b2121-f194-4ba4-9fbf-13",
|
||||
"markerEnd": "workflow-edge-green-arrow-rounded",
|
||||
"markerStart": "workflow-edge-green-circle",
|
||||
"selectable": false,
|
||||
"source": "trigger",
|
||||
"target": "step1",
|
||||
"type": "success",
|
||||
},
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"data": {
|
||||
"icon": "IconPlaylistAdd",
|
||||
"name": "Company created",
|
||||
"nodeType": "trigger",
|
||||
"runStatus": "success",
|
||||
"triggerType": "DATABASE_EVENT",
|
||||
},
|
||||
"id": "trigger",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"actionType": "FORM",
|
||||
"name": "Step 1",
|
||||
"nodeType": "action",
|
||||
"runStatus": "running",
|
||||
},
|
||||
"id": "step1",
|
||||
"position": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"stepToOpenByDefault": {
|
||||
"data": {
|
||||
"actionType": "FORM",
|
||||
"name": "Step 1",
|
||||
"nodeType": "action",
|
||||
"runStatus": "running",
|
||||
},
|
||||
"id": "step1",
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -12,6 +12,8 @@ import {
|
||||
WorkflowRunDiagram,
|
||||
WorkflowRunDiagramEdge,
|
||||
WorkflowRunDiagramNode,
|
||||
WorkflowRunDiagramNodeData,
|
||||
WorkflowRunDiagramStepNodeData,
|
||||
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
|
||||
import { getWorkflowDiagramTriggerNode } from '@/workflow/workflow-diagram/utils/getWorkflowDiagramTriggerNode';
|
||||
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
|
||||
@ -26,7 +28,22 @@ export const generateWorkflowRunDiagram = ({
|
||||
trigger: WorkflowTrigger;
|
||||
steps: Array<WorkflowStep>;
|
||||
stepsOutput: WorkflowRunOutputStepsOutput | undefined;
|
||||
}): WorkflowRunDiagram => {
|
||||
}): {
|
||||
diagram: WorkflowRunDiagram;
|
||||
stepToOpenByDefault:
|
||||
| {
|
||||
id: string;
|
||||
data: WorkflowRunDiagramStepNodeData;
|
||||
}
|
||||
| undefined;
|
||||
} => {
|
||||
let stepToOpenByDefault:
|
||||
| {
|
||||
id: string;
|
||||
data: WorkflowRunDiagramStepNodeData;
|
||||
}
|
||||
| undefined = undefined;
|
||||
|
||||
const triggerBase = getWorkflowDiagramTriggerNode({ trigger });
|
||||
|
||||
const nodes: Array<WorkflowRunDiagramNode> = [
|
||||
@ -97,21 +114,29 @@ export const generateWorkflowRunDiagram = ({
|
||||
}
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
data: {
|
||||
const nodeData: WorkflowRunDiagramNodeData = {
|
||||
nodeType: 'action',
|
||||
actionType: step.type,
|
||||
name: step.name,
|
||||
runStatus,
|
||||
},
|
||||
};
|
||||
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
data: nodeData,
|
||||
position: {
|
||||
x: xPos,
|
||||
y: yPos,
|
||||
},
|
||||
selected: isPendingFormAction,
|
||||
});
|
||||
|
||||
if (isPendingFormAction) {
|
||||
stepToOpenByDefault = {
|
||||
id: nodeId,
|
||||
data: nodeData,
|
||||
};
|
||||
}
|
||||
|
||||
processNode({
|
||||
stepIndex: stepIndex + 1,
|
||||
parentNodeId: nodeId,
|
||||
@ -134,7 +159,10 @@ export const generateWorkflowRunDiagram = ({
|
||||
});
|
||||
|
||||
return {
|
||||
diagram: {
|
||||
nodes,
|
||||
edges,
|
||||
},
|
||||
stepToOpenByDefault,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}),
|
||||
};
|
||||
};
|
||||
@ -1,2 +0,0 @@
|
||||
export const WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID =
|
||||
'workflow-run-step-side-panel-tab-list';
|
||||
Reference in New Issue
Block a user