Visualize workflow run step output (#10730)

- Displays the output of the selected step in the `Output` tab
- Access to the `Output` tab is prevented when the selected node is
currently executed or was skipped
- Display the status of the workflow run instead of the status of the
workflow version at the top left corner of the workflow run visualizer
- Fixed the icon's color for disabled tabs
- Use text/primary color for the step's name even when the input is
disabled

## Demo: Successful execution


https://github.com/user-attachments/assets/02e492f3-1589-48e9-926e-7edb031d9210

## Demo: Failed execution


https://github.com/user-attachments/assets/73e5ec86-5f38-4306-aa9a-46b2e73950da

Closes https://github.com/twentyhq/core-team-issues/issues/434
This commit is contained in:
Baptiste Devessier
2025-03-07 17:35:39 +01:00
committed by GitHub
parent 0e1d742f3d
commit b49ec864b1
19 changed files with 219 additions and 107 deletions

View File

@ -83,7 +83,9 @@ export const Tab = ({
const theme = useTheme();
const iconColor = active
? theme.font.color.primary
: theme.font.color.secondary;
: disabled
? theme.font.color.light
: theme.font.color.secondary;
return (
<StyledTab

View File

@ -1,5 +1,5 @@
import { WorkflowRunVisualizerContent } from '@/workflow/components/WorkflowRunVisualizerContent';
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { WorkflowRunDiagramCanvas } from '@/workflow/workflow-diagram/components/WorkflowRunDiagramCanvas';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-shared';
@ -19,7 +19,7 @@ export const WorkflowRunVisualizer = ({
return (
<StyledSourceCodeContainer>
<WorkflowRunVisualizerContent workflowRun={workflowRun} />
<WorkflowRunDiagramCanvas workflowRunStatus={workflowRun.status} />
</StyledSourceCodeContainer>
);
};

View File

@ -1,17 +0,0 @@
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
import { WorkflowRun } from '@/workflow/types/Workflow';
import { WorkflowRunDiagramCanvas } from '@/workflow/workflow-diagram/components/WorkflowRunDiagramCanvas';
import { isDefined } from 'twenty-shared';
export const WorkflowRunVisualizerContent = ({
workflowRun,
}: {
workflowRun: WorkflowRun;
}) => {
const workflowVersion = useWorkflowVersion(workflowRun.workflowVersionId);
if (!isDefined(workflowVersion)) {
return null;
}
return <WorkflowRunDiagramCanvas versionStatus={workflowVersion.status} />;
};

View File

@ -15,6 +15,7 @@ import {
workflowRunOutputSchema,
workflowRunOutputStepsOutputSchema,
workflowRunSchema,
workflowRunStatusSchema,
workflowSendEmailActionSchema,
workflowSendEmailActionSettingsSchema,
workflowTriggerSchema,
@ -107,6 +108,8 @@ export type WorkflowRunContext = z.infer<typeof workflowRunContextSchema>;
export type WorkflowRunFlow = WorkflowRunOutput['flow'];
export type WorkflowRunStatus = z.infer<typeof workflowRunStatusSchema>;
export type WorkflowRun = z.infer<typeof workflowRunSchema>;
export type Workflow = {

View File

@ -197,6 +197,13 @@ export const workflowRunOutputSchema = z.object({
export const workflowRunContextSchema = z.record(z.any());
export const workflowRunStatusSchema = z.enum([
'NOT_STARTED',
'RUNNING',
'COMPLETED',
'FAILED',
]);
export const workflowRunSchema = z
.object({
__typename: z.literal('WorkflowRun'),
@ -204,6 +211,7 @@ export const workflowRunSchema = z
workflowVersionId: z.string(),
output: workflowRunOutputSchema.nullable(),
context: workflowRunContextSchema.nullable(),
status: workflowRunStatusSchema,
createdAt: z.string(),
deletedAt: z.string().nullable(),
endedAt: z.string().nullable(),

View File

@ -1,8 +1,6 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useListenRightDrawerClose } from '@/ui/layout/right-drawer/hooks/useListenRightDrawerClose';
import { WorkflowVersionStatus } from '@/workflow/types/Workflow';
import { WorkflowDiagramCustomMarkers } from '@/workflow/workflow-diagram/components/WorkflowDiagramCustomMarkers';
import { WorkflowVersionStatusTag } from '@/workflow/workflow-diagram/components/WorkflowVersionStatusTag';
import { useRightDrawerState } from '@/workflow/workflow-diagram/hooks/useRightDrawerState';
import { workflowDiagramState } from '@/workflow/workflow-diagram/states/workflowDiagramState';
import { workflowReactFlowRefState } from '@/workflow/workflow-diagram/states/workflowReactFlowRefState';
@ -16,6 +14,8 @@ import { getOrganizedDiagram } from '@/workflow/workflow-diagram/utils/getOrgani
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
applyEdgeChanges,
applyNodeChanges,
Background,
EdgeChange,
EdgeProps,
@ -23,15 +23,13 @@ import {
NodeChange,
NodeProps,
ReactFlow,
applyEdgeChanges,
applyNodeChanges,
useReactFlow,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import React, { useEffect, useMemo, useRef } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared';
import { THEME_COMMON } from 'twenty-ui';
import { Tag, TagColor, THEME_COMMON } from 'twenty-ui';
const StyledResetReactflowStyles = styled.div`
height: 100%;
@ -83,12 +81,13 @@ const defaultFitViewOptions = {
} satisfies FitViewOptions;
export const WorkflowDiagramCanvasBase = ({
status,
nodeTypes,
edgeTypes,
children,
tagContainerTestId,
tagColor,
tagText,
}: {
status: WorkflowVersionStatus;
nodeTypes: Partial<
Record<
WorkflowDiagramNodeType,
@ -112,6 +111,9 @@ export const WorkflowDiagramCanvasBase = ({
>
>;
children?: React.ReactNode;
tagContainerTestId: string;
tagColor: TagColor;
tagText: string;
}) => {
const theme = useTheme();
@ -258,8 +260,8 @@ export const WorkflowDiagramCanvasBase = ({
{children}
</ReactFlow>
<StyledStatusTagContainer data-testid="workflow-visualizer-status">
<WorkflowVersionStatusTag versionStatus={status} />
<StyledStatusTagContainer data-testid={tagContainerTestId}>
<Tag color={tagColor} text={tagText} />
</StyledStatusTagContainer>
</StyledResetReactflowStyles>
);

View File

@ -5,6 +5,7 @@ import { WorkflowDiagramCreateStepNode } from '@/workflow/workflow-diagram/compo
import { WorkflowDiagramDefaultEdge } from '@/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdge';
import { WorkflowDiagramEmptyTrigger } from '@/workflow/workflow-diagram/components/WorkflowDiagramEmptyTrigger';
import { WorkflowDiagramStepNodeEditable } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeEditable';
import { getWorkflowVersionStatusTagProps } from '@/workflow/workflow-diagram/utils/getWorkflowVersionStatusTagProps';
import { ReactFlowProvider } from '@xyflow/react';
export const WorkflowDiagramCanvasEditable = ({
@ -12,10 +13,13 @@ export const WorkflowDiagramCanvasEditable = ({
}: {
versionStatus: WorkflowVersionStatus;
}) => {
const tagProps = getWorkflowVersionStatusTagProps({
workflowVersionStatus: versionStatus,
});
return (
<ReactFlowProvider>
<WorkflowDiagramCanvasBase
status={versionStatus}
nodeTypes={{
default: WorkflowDiagramStepNodeEditable,
'create-step': WorkflowDiagramCreateStepNode,
@ -24,7 +28,11 @@ export const WorkflowDiagramCanvasEditable = ({
edgeTypes={{
default: WorkflowDiagramDefaultEdge,
}}
tagContainerTestId="workflow-visualizer-status"
tagColor={tagProps.color}
tagText={tagProps.text}
/>
<WorkflowDiagramCanvasEditableEffect />
</ReactFlowProvider>
);

View File

@ -5,6 +5,7 @@ import { WorkflowDiagramDefaultEdge } from '@/workflow/workflow-diagram/componen
import { WorkflowDiagramEmptyTrigger } from '@/workflow/workflow-diagram/components/WorkflowDiagramEmptyTrigger';
import { WorkflowDiagramStepNodeReadonly } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeReadonly';
import { WorkflowDiagramSuccessEdge } from '@/workflow/workflow-diagram/components/WorkflowDiagramSuccessEdge';
import { getWorkflowVersionStatusTagProps } from '@/workflow/workflow-diagram/utils/getWorkflowVersionStatusTagProps';
import { ReactFlowProvider } from '@xyflow/react';
export const WorkflowDiagramCanvasReadonly = ({
@ -12,10 +13,13 @@ export const WorkflowDiagramCanvasReadonly = ({
}: {
versionStatus: WorkflowVersionStatus;
}) => {
const tagProps = getWorkflowVersionStatusTagProps({
workflowVersionStatus: versionStatus,
});
return (
<ReactFlowProvider>
<WorkflowDiagramCanvasBase
status={versionStatus}
nodeTypes={{
default: WorkflowDiagramStepNodeReadonly,
'empty-trigger': WorkflowDiagramEmptyTrigger,
@ -24,7 +28,11 @@ export const WorkflowDiagramCanvasReadonly = ({
default: WorkflowDiagramDefaultEdge,
success: WorkflowDiagramSuccessEdge,
}}
tagContainerTestId="workflow-visualizer-status"
tagColor={tagProps.color}
tagText={tagProps.text}
/>
<WorkflowDiagramCanvasReadonlyEffect />
</ReactFlowProvider>
);

View File

@ -1,20 +1,24 @@
import { WorkflowVersionStatus } from '@/workflow/types/Workflow';
import { WorkflowRunStatus } from '@/workflow/types/Workflow';
import { WorkflowDiagramCanvasBase } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase';
import { WorkflowDiagramDefaultEdge } from '@/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdge';
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 { getWorkflowRunStatusTagProps } from '@/workflow/workflow-diagram/utils/getWorkflowRunStatusTagProps';
import { ReactFlowProvider } from '@xyflow/react';
export const WorkflowRunDiagramCanvas = ({
versionStatus,
workflowRunStatus,
}: {
versionStatus: WorkflowVersionStatus;
workflowRunStatus: WorkflowRunStatus;
}) => {
const tagProps = getWorkflowRunStatusTagProps({
workflowRunStatus,
});
return (
<ReactFlowProvider>
<WorkflowDiagramCanvasBase
status={versionStatus}
nodeTypes={{
default: WorkflowDiagramStepNodeReadonly,
}}
@ -22,6 +26,9 @@ export const WorkflowRunDiagramCanvas = ({
default: WorkflowDiagramDefaultEdge,
success: WorkflowDiagramSuccessEdge,
}}
tagContainerTestId="workflow-run-status"
tagColor={tagProps.color}
tagText={tagProps.text}
/>
<WorkflowRunDiagramCanvasEffect />

View File

@ -40,7 +40,10 @@ export const WorkflowRunDiagramCanvasEffect = () => {
workflowRunRightDrawerListActiveTabIdState,
) as WorkflowRunTabId | null;
if (activeWorkflowRunRightDrawerTab === 'input') {
if (
activeWorkflowRunRightDrawerTab === 'input' ||
activeWorkflowRunRightDrawerTab === 'output'
) {
set(workflowRunRightDrawerListActiveTabIdState, 'node');
}
},

View File

@ -1,22 +0,0 @@
import { WorkflowVersionStatus } from '@/workflow/types/Workflow';
import { Tag } from 'twenty-ui';
export const WorkflowVersionStatusTag = ({
versionStatus,
}: {
versionStatus: WorkflowVersionStatus;
}) => {
if (versionStatus === 'ACTIVE') {
return <Tag color="green" text="Active" />;
}
if (versionStatus === 'DRAFT') {
return <Tag color="yellow" text="Draft" />;
}
if (versionStatus === 'ARCHIVED') {
return <Tag color="gray" text="Archived" />;
}
return <Tag color="gray" text="Deactivated" />;
};

View File

@ -41,7 +41,6 @@ type Story = StoryObj<typeof WorkflowDiagramCanvasBase>;
export const DefaultEdge: Story = {
args: {
status: 'DRAFT',
nodeTypes: {
default: WorkflowDiagramStepNodeReadonly,
'create-step': WorkflowDiagramCreateStepNode,
@ -116,7 +115,6 @@ export const DefaultEdge: Story = {
export const SuccessEdge: Story = {
args: {
status: 'DRAFT',
nodeTypes: {
default: WorkflowDiagramStepNodeReadonly,
'create-step': WorkflowDiagramCreateStepNode,

View File

@ -1,43 +0,0 @@
import { Meta, StoryObj } from '@storybook/react';
import { CatalogDecorator, CatalogStory, ComponentDecorator } from 'twenty-ui';
import { WorkflowVersionStatus } from '@/workflow/types/Workflow';
import { WorkflowVersionStatusTag } from '../WorkflowVersionStatusTag';
const meta: Meta<typeof WorkflowVersionStatusTag> = {
title: 'Modules/Workflow/WorkflowVersionStatusTag',
component: WorkflowVersionStatusTag,
};
export default meta;
type Story = StoryObj<typeof WorkflowVersionStatusTag>;
export const Default: Story = {
args: {
versionStatus: 'DRAFT',
},
decorators: [ComponentDecorator],
};
export const Catalog: CatalogStory<Story, typeof WorkflowVersionStatusTag> = {
argTypes: {
versionStatus: { table: { disable: true } },
},
parameters: {
catalog: {
dimensions: [
{
name: 'version status',
values: [
'DRAFT',
'ACTIVE',
'DEACTIVATED',
'ARCHIVED',
] satisfies WorkflowVersionStatus[],
props: (versionStatus: WorkflowVersionStatus) => ({ versionStatus }),
},
],
},
},
decorators: [CatalogDecorator],
};

View File

@ -0,0 +1,34 @@
import { WorkflowRunStatus } from '@/workflow/types/Workflow';
import { TagColor } from 'twenty-ui';
export const getWorkflowRunStatusTagProps = ({
workflowRunStatus,
}: {
workflowRunStatus: WorkflowRunStatus;
}): { color: TagColor; text: string } => {
if (workflowRunStatus === 'NOT_STARTED') {
return {
color: 'gray',
text: 'Not started',
};
}
if (workflowRunStatus === 'RUNNING') {
return {
color: 'yellow',
text: 'Running',
};
}
if (workflowRunStatus === 'COMPLETED') {
return {
color: 'green',
text: 'Completed',
};
}
return {
color: 'red',
text: 'Failed',
};
};

View File

@ -0,0 +1,34 @@
import { WorkflowVersionStatus } from '@/workflow/types/Workflow';
import { TagColor } from 'twenty-ui';
export const getWorkflowVersionStatusTagProps = ({
workflowVersionStatus,
}: {
workflowVersionStatus: WorkflowVersionStatus;
}): { color: TagColor; text: string } => {
if (workflowVersionStatus === 'ARCHIVED') {
return {
color: 'gray',
text: 'Archived',
};
}
if (workflowVersionStatus === 'DRAFT') {
return {
color: 'yellow',
text: 'Draft',
};
}
if (workflowVersionStatus === 'ACTIVE') {
return {
color: 'green',
text: 'Active',
};
}
return {
color: 'gray',
text: 'Deactivated',
};
};

View File

@ -6,6 +6,7 @@ import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow';
import { useWorkflowSelectedNodeOrThrow } from '@/workflow/workflow-diagram/hooks/useWorkflowSelectedNodeOrThrow';
import { WorkflowRunStepInputDetail } from '@/workflow/workflow-steps/components/WorkflowRunStepInputDetail';
import { WorkflowRunStepOutputDetail } from '@/workflow/workflow-steps/components/WorkflowRunStepOutputDetail';
import { WorkflowStepDetail } from '@/workflow/workflow-steps/components/WorkflowStepDetail';
import { WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-steps/constants/WorkflowRunStepSidePanelTabListComponentId';
import { getWorkflowRunStepExecutionStatus } from '@/workflow/workflow-steps/utils/getWorkflowRunStepExecutionStatus';
@ -38,7 +39,7 @@ export const RightDrawerWorkflowRunViewStep = () => {
})
: undefined;
const isInputTabDisabled =
const areInputAndOutputTabsDisabled =
workflowSelectedNode === TRIGGER_STEP_ID ||
stepExecutionStatus === 'running' ||
stepExecutionStatus === 'not-executed';
@ -49,9 +50,14 @@ export const RightDrawerWorkflowRunViewStep = () => {
id: 'input',
title: 'Input',
Icon: IconLogin2,
disabled: isInputTabDisabled,
disabled: areInputAndOutputTabsDisabled,
},
{
id: 'output',
title: 'Output',
Icon: IconLogout,
disabled: areInputAndOutputTabsDisabled,
},
{ id: 'output', title: 'Output', Icon: IconLogout },
];
if (!isDefined(workflowRun)) {
@ -80,6 +86,10 @@ export const RightDrawerWorkflowRunViewStep = () => {
{activeTabId === 'input' ? (
<WorkflowRunStepInputDetail stepId={workflowSelectedNode} />
) : null}
{activeTabId === 'output' ? (
<WorkflowRunStepOutputDetail stepId={workflowSelectedNode} />
) : null}
</>
);
};

View File

@ -0,0 +1,27 @@
import { JsonTree } from '@/workflow/components/json-visualizer/components/JsonTree';
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-shared';
const StyledContainer = styled.div`
padding-block: ${({ theme }) => theme.spacing(4)};
padding-inline: ${({ theme }) => theme.spacing(3)};
`;
export const WorkflowRunStepOutputDetail = ({ stepId }: { stepId: string }) => {
const workflowRunId = useWorkflowRunIdOrThrow();
const workflowRun = useWorkflowRun({ workflowRunId });
if (!isDefined(workflowRun?.output?.stepsOutput)) {
return null;
}
const stepOutput = workflowRun.output.stepsOutput[stepId];
return (
<StyledContainer>
<JsonTree value={stepOutput} />
</StyledContainer>
);
};

View File

@ -26,6 +26,10 @@ const StyledHeaderTitle = styled.div`
font-size: ${({ theme }) => theme.font.size.xl};
width: 420px;
overflow: hidden;
& > input:disabled {
color: ${({ theme }) => theme.font.color.primary};
}
`;
const StyledHeaderType = styled.div`

View File

@ -117,7 +117,6 @@ export const InputTabNotExecutedStep: Story = {
return <Story />;
},
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
@ -138,5 +137,52 @@ export const OutputTab: Story = {
await waitFor(() => {
expect(canvas.queryByText('Create Record')).not.toBeInTheDocument();
});
expect(await canvas.findByText('result')).toBeVisible();
},
};
export const OutputTabDisabledForTrigger: Story = {
decorators: [
(Story) => {
const setWorkflowSelectedNode = useSetRecoilState(
workflowSelectedNodeState,
);
setWorkflowSelectedNode(TRIGGER_STEP_ID);
return <Story />;
},
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const outputTab = await canvas.findByRole('button', { name: 'Output' });
expect(outputTab).toBeDisabled();
},
};
export const OutputTabNotExecutedStep: Story = {
decorators: [
(Story) => {
const setWorkflowSelectedNode = useSetRecoilState(
workflowSelectedNodeState,
);
setWorkflowSelectedNode(
oneFailedWorkflowRunQueryResult.workflowRun.output.flow.steps.at(-1)!
.id,
);
return <Story />;
},
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const outputTab = await canvas.findByRole('button', { name: 'Output' });
expect(outputTab).toBeDisabled();
},
};