Visualize workflow run step input (#10677)

- Compute the context the selected step had access to during its
execution and display it with the `<JsonNestedNode />` component
- Ensure several steps with the same name can be displayed in order
- Prevent access to the input tab in a few cases
- Hide the input tab when the trigger node is selected as this node
takes no input
- Hide the input tab when the selected node has not been executed yet or
is currently executed
- Fallback to the Node tab when the Input tab can't be accessed

## Successful workflow execution


https://github.com/user-attachments/assets/4a2bb5f5-450c-46ed-b2d7-a14d3b1e5c1f

## Failed workflow execution


https://github.com/user-attachments/assets/3be2784e-e76c-48ab-aef5-17f63410898e

Closes https://github.com/twentyhq/core-team-issues/issues/433
This commit is contained in:
Baptiste Devessier
2025-03-06 17:49:10 +01:00
committed by GitHub
parent 9d78dc322d
commit cb5f4820d7
22 changed files with 12943 additions and 74 deletions

View File

@ -2,10 +2,16 @@ import { ShowPageSubContainerTabListContainer } from '@/ui/layout/show-page/comp
import { SingleTabProps, TabList } from '@/ui/layout/tab/components/TabList';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
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 { 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';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-shared';
import { IconLogin2, IconLogout, IconStepInto } from 'twenty-ui';
const StyledTabListContainer = styled(ShowPageSubContainerTabListContainer)`
@ -17,17 +23,41 @@ type TabId = 'node' | 'input' | 'output';
export const RightDrawerWorkflowRunViewStep = () => {
const flow = useFlowOrThrow();
const workflowSelectedNode = useWorkflowSelectedNodeOrThrow();
const workflowRunId = useWorkflowRunIdOrThrow();
const workflowRun = useWorkflowRun({ workflowRunId });
const { activeTabId } = useTabList<TabId>(
WORKFLOW_RUN_STEP_SIDE_PANEL_TAB_LIST_COMPONENT_ID,
);
const stepExecutionStatus = isDefined(workflowRun)
? getWorkflowRunStepExecutionStatus({
workflowRunOutput: workflowRun.output,
stepId: workflowSelectedNode,
})
: undefined;
const isInputTabDisabled =
workflowSelectedNode === TRIGGER_STEP_ID ||
stepExecutionStatus === 'running' ||
stepExecutionStatus === 'not-executed';
const tabs: SingleTabProps<TabId>[] = [
{ id: 'node', title: 'Node', Icon: IconStepInto },
{ id: 'input', title: 'Input', Icon: IconLogin2 },
{
id: 'input',
title: 'Input',
Icon: IconLogin2,
disabled: isInputTabDisabled,
},
{ id: 'output', title: 'Output', Icon: IconLogout },
];
if (!isDefined(workflowRun)) {
return null;
}
return (
<>
<StyledTabListContainer>
@ -46,6 +76,10 @@ export const RightDrawerWorkflowRunViewStep = () => {
steps={flow.steps}
/>
) : null}
{activeTabId === 'input' ? (
<WorkflowRunStepInputDetail stepId={workflowSelectedNode} />
) : null}
</>
);
};

View File

@ -0,0 +1,47 @@
import { JsonNestedNode } from '@/workflow/components/json-visualizer/components/JsonNestedNode';
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow';
import { getWorkflowRunStepContext } from '@/workflow/workflow-steps/utils/getWorkflowRunStepContext';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-shared';
import { IconBrackets } from 'twenty-ui';
const StyledContainer = styled.div`
padding-block: ${({ theme }) => theme.spacing(4)};
padding-inline: ${({ theme }) => theme.spacing(3)};
`;
export const WorkflowRunStepInputDetail = ({ stepId }: { stepId: string }) => {
const workflowRunId = useWorkflowRunIdOrThrow();
const workflowRun = useWorkflowRun({ workflowRunId });
if (
!(
isDefined(workflowRun) &&
isDefined(workflowRun.context) &&
isDefined(workflowRun.output?.flow)
)
) {
return null;
}
const stepContext = getWorkflowRunStepContext({
context: workflowRun.context,
flow: workflowRun.output.flow,
stepId,
});
return (
<StyledContainer>
<JsonNestedNode
elements={stepContext.map(({ id, name, context }) => ({
id,
label: name,
value: context,
}))}
Icon={IconBrackets}
depth={0}
/>
</StyledContainer>
);
};

View File

@ -0,0 +1,142 @@
import { flowState } from '@/workflow/states/flowState';
import { workflowRunIdState } from '@/workflow/states/workflowRunIdState';
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import styled from '@emotion/styled';
import { Meta, StoryObj } from '@storybook/react';
import { expect, userEvent, waitFor, within } from '@storybook/test';
import { graphql, HttpResponse } from 'msw';
import { useSetRecoilState } from 'recoil';
import { ComponentDecorator, RouterDecorator } from 'twenty-ui';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { oneFailedWorkflowRunQueryResult } from '~/testing/mock-data/workflow-run';
import { RightDrawerWorkflowRunViewStep } from '../RightDrawerWorkflowRunViewStep';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
width: 500px;
`;
const meta: Meta<typeof RightDrawerWorkflowRunViewStep> = {
title: 'Modules/Workflow/RightDrawerWorkflowRunViewStep',
component: RightDrawerWorkflowRunViewStep,
decorators: [
(Story) => (
<StyledWrapper>
<Story />
</StyledWrapper>
),
I18nFrontDecorator,
ComponentDecorator,
(Story) => {
const setFlow = useSetRecoilState(flowState);
const setWorkflowSelectedNode = useSetRecoilState(
workflowSelectedNodeState,
);
const setWorkflowRunId = useSetRecoilState(workflowRunIdState);
setFlow(oneFailedWorkflowRunQueryResult.workflowRun.output.flow);
setWorkflowSelectedNode(
oneFailedWorkflowRunQueryResult.workflowRun.output.flow.steps[0].id,
);
setWorkflowRunId(oneFailedWorkflowRunQueryResult.workflowRun.id);
return <Story />;
},
RouterDecorator,
ObjectMetadataItemsDecorator,
WorkspaceDecorator,
],
parameters: {
msw: {
handlers: [
graphql.query('FindOneWorkflowRun', () => {
return HttpResponse.json({
data: oneFailedWorkflowRunQueryResult,
});
}),
...graphqlMocks.handlers,
],
},
},
};
export default meta;
type Story = StoryObj<typeof RightDrawerWorkflowRunViewStep>;
export const NodeTab: Story = {};
export const InputTab: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole('button', { name: 'Input' }));
expect(await canvas.findByText('Trigger')).toBeVisible();
},
};
export const InputTabDisabledForTrigger: Story = {
decorators: [
(Story) => {
const setWorkflowSelectedNode = useSetRecoilState(
workflowSelectedNodeState,
);
setWorkflowSelectedNode(TRIGGER_STEP_ID);
return <Story />;
},
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const inputTab = await canvas.findByRole('button', { name: 'Input' });
expect(inputTab).toBeDisabled();
},
};
export const InputTabNotExecutedStep: 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 inputTab = await canvas.findByRole('button', { name: 'Input' });
expect(inputTab).toBeDisabled();
},
};
export const OutputTab: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByRole('button', { name: 'Output' }),
);
await waitFor(() => {
expect(canvas.queryByText('Create Record')).not.toBeInTheDocument();
});
},
};

View File

@ -0,0 +1 @@
export type WorkflowRunTabId = 'node' | 'input' | 'output';

View File

@ -0,0 +1,266 @@
import { WorkflowRunFlow } from '@/workflow/types/Workflow';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { getWorkflowRunStepContext } from '../getWorkflowRunStepContext';
describe('getWorkflowRunStepContext', () => {
it('should return an empty array for trigger step', () => {
const flow = {
trigger: {
name: 'Company Created',
type: 'DATABASE_EVENT',
settings: {
eventName: 'company.created',
outputSchema: {},
},
},
steps: [],
} satisfies WorkflowRunFlow;
const context = {
[TRIGGER_STEP_ID]: { company: { id: '123' } },
};
const result = getWorkflowRunStepContext({
stepId: TRIGGER_STEP_ID,
flow,
context,
});
expect(result).toEqual([]);
});
it('should include previous steps context', () => {
const flow = {
trigger: {
name: 'Company Created',
type: 'DATABASE_EVENT',
settings: {
eventName: 'company.created',
outputSchema: {},
},
},
steps: [
{
id: 'step1',
name: 'Create company',
type: 'CREATE_RECORD',
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
objectName: 'Company',
objectRecord: {},
},
outputSchema: {},
},
valid: true,
},
{
id: 'step2',
name: 'Send Email',
type: 'SEND_EMAIL',
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
connectedAccountId: '123',
email: '',
},
outputSchema: {},
},
valid: true,
},
],
} satisfies WorkflowRunFlow;
const context = {
[TRIGGER_STEP_ID]: { company: { id: '123' } },
step1: { taskId: '456' },
};
const result = getWorkflowRunStepContext({
stepId: 'step2',
flow,
context,
});
expect(result).toEqual([
{
id: TRIGGER_STEP_ID,
name: 'Company Created',
context: { company: { id: '123' } },
},
{
id: 'step1',
name: 'Create company',
context: { taskId: '456' },
},
]);
});
it('should not include subsequent steps context', () => {
const flow = {
trigger: {
name: 'Company Created',
type: 'DATABASE_EVENT',
settings: {
eventName: 'company.created',
outputSchema: {},
},
},
steps: [
{
id: 'step1',
name: 'Create company',
type: 'CREATE_RECORD',
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
objectName: 'Company',
objectRecord: {},
},
outputSchema: {},
},
valid: true,
},
{
id: 'step2',
name: 'Send Email',
type: 'SEND_EMAIL',
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
connectedAccountId: '123',
email: '',
},
outputSchema: {},
},
valid: true,
},
],
} satisfies WorkflowRunFlow;
const context = {
[TRIGGER_STEP_ID]: { company: { id: '123' } },
step1: { taskId: '456' },
step2: { emailId: '789' },
};
const result = getWorkflowRunStepContext({
stepId: 'step1',
flow,
context,
});
expect(result).toEqual([
{
id: TRIGGER_STEP_ID,
name: 'Company Created',
context: { company: { id: '123' } },
},
]);
});
it('should handle multiple steps with the same name', () => {
const flow = {
trigger: {
name: 'Company Created',
type: 'DATABASE_EVENT',
settings: {
eventName: 'company.created',
outputSchema: {},
},
},
steps: [
{
id: 'step1',
name: 'Create Note',
type: 'CREATE_RECORD',
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
objectName: 'Note',
objectRecord: {},
},
outputSchema: {},
},
valid: true,
},
{
id: 'step2',
name: 'Create Note',
type: 'CREATE_RECORD',
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
objectName: 'Note',
objectRecord: {},
},
outputSchema: {},
},
valid: true,
},
{
id: 'step3',
name: 'Create Note',
type: 'CREATE_RECORD',
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
objectName: 'Note',
objectRecord: {},
},
outputSchema: {},
},
valid: true,
},
],
} satisfies WorkflowRunFlow;
const context = {
[TRIGGER_STEP_ID]: { company: { id: '123' } },
step1: { noteId: '456' },
step2: { noteId: '789' },
step3: { noteId: '101' },
};
const result = getWorkflowRunStepContext({
stepId: 'step3',
flow,
context,
});
expect(result).toEqual([
{
id: TRIGGER_STEP_ID,
name: 'Company Created',
context: { company: { id: '123' } },
},
{
id: 'step1',
name: 'Create Note',
context: { noteId: '456' },
},
{
id: 'step2',
name: 'Create Note',
context: { noteId: '789' },
},
]);
});
});

View File

@ -0,0 +1,230 @@
import { getWorkflowRunStepExecutionStatus } from '../getWorkflowRunStepExecutionStatus';
describe('getWorkflowRunStepExecutionStatus', () => {
const stepId = '453e0084-aca2-45b9-8d1c-458a2b8ac70a';
it('should return not-executed when the output is null', () => {
expect(
getWorkflowRunStepExecutionStatus({
workflowRunOutput: null,
stepId,
}),
).toBe('not-executed');
});
it('should return success when step has result', () => {
expect(
getWorkflowRunStepExecutionStatus({
workflowRunOutput: {
flow: {
steps: [
{
id: stepId,
name: 'Code - Serverless Function',
type: 'CODE',
valid: false,
settings: {
input: {
serverlessFunctionId:
'5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2c',
serverlessFunctionInput: {
a: null,
b: null,
},
serverlessFunctionVersion: 'draft',
},
outputSchema: {
link: {
tab: 'test',
icon: 'IconVariable',
label: 'Generate Function Output',
isLeaf: true,
},
_outputSchemaType: 'LINK',
},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
},
],
trigger: {
type: 'MANUAL',
settings: {
outputSchema: {},
},
},
},
stepsOutput: {
[stepId]: {
result: {},
},
},
},
stepId,
}),
).toBe('success');
});
it('should return failure when workflow has error', () => {
const error = 'fn(...).then is not a function';
expect(
getWorkflowRunStepExecutionStatus({
workflowRunOutput: {
flow: {
steps: [
{
id: stepId,
name: 'Code - Serverless Function',
type: 'CODE',
valid: false,
settings: {
input: {
serverlessFunctionId:
'5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2c',
serverlessFunctionInput: {
a: null,
b: null,
},
serverlessFunctionVersion: 'draft',
},
outputSchema: {
link: {
tab: 'test',
icon: 'IconVariable',
label: 'Generate Function Output',
isLeaf: true,
},
_outputSchemaType: 'LINK',
},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
},
],
trigger: {
type: 'MANUAL',
settings: {
outputSchema: {},
},
},
},
error,
stepsOutput: {
[stepId]: {
error,
},
},
},
stepId,
}),
).toBe('failure');
});
it('should return not-executed when step has no output', () => {
const secondStepId = '5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2c';
expect(
getWorkflowRunStepExecutionStatus({
workflowRunOutput: {
flow: {
steps: [
{
id: stepId,
name: 'Code - Serverless Function',
type: 'CODE',
valid: false,
settings: {
input: {
serverlessFunctionId:
'5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2c',
serverlessFunctionInput: {
a: null,
b: null,
},
serverlessFunctionVersion: 'draft',
},
outputSchema: {
link: {
tab: 'test',
icon: 'IconVariable',
label: 'Generate Function Output',
isLeaf: true,
},
_outputSchemaType: 'LINK',
},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
},
{
id: secondStepId,
name: 'Code - Serverless Function',
type: 'CODE',
valid: false,
settings: {
input: {
serverlessFunctionId:
'5f7b9b44-bb07-41ba-aef8-ec0eaa5eea2c',
serverlessFunctionInput: {
a: null,
b: null,
},
serverlessFunctionVersion: 'draft',
},
outputSchema: {
link: {
tab: 'test',
icon: 'IconVariable',
label: 'Generate Function Output',
isLeaf: true,
},
_outputSchemaType: 'LINK',
},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
},
],
trigger: {
type: 'MANUAL',
settings: {
outputSchema: {},
},
},
},
stepsOutput: {
[stepId]: {
result: {},
},
},
},
stepId: secondStepId,
}),
).toBe('not-executed');
});
});

View File

@ -0,0 +1,38 @@
import { WorkflowRunContext, WorkflowRunFlow } from '@/workflow/types/Workflow';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
export const getWorkflowRunStepContext = ({
stepId,
flow,
context,
}: {
stepId: string;
context: WorkflowRunContext;
flow: WorkflowRunFlow;
}) => {
const stepContext: Array<{ id: string; name: string; context: any }> = [];
if (stepId === TRIGGER_STEP_ID) {
return stepContext;
}
stepContext.push({
id: TRIGGER_STEP_ID,
name: flow.trigger.name ?? 'Trigger',
context: context[TRIGGER_STEP_ID],
});
for (const step of flow.steps) {
if (step.id === stepId) {
break;
}
stepContext.push({
id: step.id,
name: step.name,
context: context[step.id],
});
}
return stepContext;
};

View File

@ -0,0 +1,28 @@
import { WorkflowRunOutput } from '@/workflow/types/Workflow';
import { WorkflowDiagramRunStatus } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { isNull } from '@sniptt/guards';
import { isDefined } from 'twenty-shared';
export const getWorkflowRunStepExecutionStatus = ({
workflowRunOutput,
stepId,
}: {
workflowRunOutput: WorkflowRunOutput | null;
stepId: string;
}): WorkflowDiagramRunStatus => {
if (isNull(workflowRunOutput)) {
return 'not-executed';
}
const stepOutput = workflowRunOutput.stepsOutput?.[stepId];
if (isDefined(stepOutput?.error)) {
return 'failure';
}
if (isDefined(stepOutput?.result)) {
return 'success';
}
return 'not-executed';
};