Visualize workflow run step input (#14784)

https://github.com/user-attachments/assets/8358a252-0407-4925-a923-7b1d6c7404b0
This commit is contained in:
Baptiste Devessier
2025-09-30 17:09:57 +02:00
committed by GitHub
parent 1938202780
commit 15975a2cfc
4 changed files with 292 additions and 240 deletions
@@ -1,8 +1,11 @@
import { workflowRunIteratorSubStepIterationIndexComponentState } from '@/command-menu/pages/workflow/step/view-run/states/workflowRunIteratorSubStepIterationIndexComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow';
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
import { WorkflowRunStepJsonContainer } from '@/workflow/workflow-steps/components/WorkflowRunStepJsonContainer';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { getIsDescendantOfIterator } from '@/workflow/workflow-steps/utils/getIsDescendantOfIterator';
import { getWorkflowRunStepContext } from '@/workflow/workflow-steps/utils/getWorkflowRunStepContext';
import { getWorkflowVariablesUsedInStep } from '@/workflow/workflow-steps/utils/getWorkflowVariablesUsedInStep';
import { getActionHeaderTypeOrThrow } from '@/workflow/workflow-steps/workflow-actions/utils/getActionHeaderTypeOrThrow';
@@ -33,6 +36,10 @@ export const WorkflowRunStepInputDetail = ({ stepId }: { stepId: string }) => {
(step) => step.id === stepId,
);
const workflowRunIteratorSubStepIterationIndex = useRecoilComponentValue(
workflowRunIteratorSubStepIterationIndexComponentState,
);
if (
!(
isDefined(workflowRun) &&
@@ -71,6 +78,12 @@ export const WorkflowRunStepInputDetail = ({ stepId }: { stepId: string }) => {
stepInfos: workflowRun.state.stepInfos,
flow: workflowRun.state.flow,
stepId,
currentLoopIterationIndex: getIsDescendantOfIterator({
stepId,
steps: workflowRun.state.flow.steps,
})
? workflowRunIteratorSubStepIterationIndex
: undefined,
});
if (stepContext.length === 0) {
@@ -0,0 +1,5 @@
export type WorkflowRunStepContext = {
id: string;
name: string;
context: unknown;
};
@@ -2,284 +2,298 @@ import { type WorkflowRunFlow } from '@/workflow/types/Workflow';
import { StepStatus, TRIGGER_STEP_ID } from 'twenty-shared/workflow';
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: {},
const mockFlow = {
trigger: {
type: 'MANUAL',
name: 'Trigger',
settings: { outputSchema: {} },
},
steps: [
{
id: 'step1',
name: 'Step 1',
type: 'CODE',
nextStepIds: ['step2'],
valid: true,
settings: {
input: {
serverlessFunctionId: '',
serverlessFunctionInput: {},
serverlessFunctionVersion: '',
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
steps: [],
} satisfies WorkflowRunFlow;
const stepInfos = {
[TRIGGER_STEP_ID]: {
result: { company: { id: '123' } },
status: StepStatus.SUCCESS,
},
{
id: 'step2',
name: 'Step 2',
type: 'CODE',
nextStepIds: [],
valid: true,
settings: {
input: {
serverlessFunctionId: '',
serverlessFunctionInput: {},
serverlessFunctionVersion: '',
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
};
},
],
} satisfies WorkflowRunFlow;
const mockStepInfos = {
[TRIGGER_STEP_ID]: { result: 'trigger-result', status: StepStatus.SUCCESS },
step1: { result: 'step1-result', status: StepStatus.SUCCESS },
step2: { result: 'step2-result', status: StepStatus.SUCCESS },
};
const mockStepInfosWithHistory = {
[TRIGGER_STEP_ID]: { result: 'trigger-result', status: StepStatus.SUCCESS },
step1: {
result: 'step1-result',
status: StepStatus.SUCCESS,
history: [
{ result: 'step1-result', status: StepStatus.SUCCESS },
{ result: 'step1-result-2', status: StepStatus.SUCCESS },
],
},
step2: {
result: 'step2-result',
status: StepStatus.SUCCESS,
history: [
{ result: 'step2-result', status: StepStatus.SUCCESS },
{ result: 'step2-result-2', status: StepStatus.SUCCESS },
],
},
};
describe('getWorkflowRunStepContext', () => {
it('returns empty array for trigger step', () => {
const result = getWorkflowRunStepContext({
stepId: TRIGGER_STEP_ID,
flow,
stepInfos,
flow: mockFlow,
stepInfos: mockStepInfos,
currentLoopIterationIndex: undefined,
});
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,
nextStepIds: ['step2'],
},
{
id: 'step2',
name: 'Send Email',
type: 'SEND_EMAIL',
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
connectedAccountId: '123',
email: '',
},
outputSchema: {},
},
valid: true,
nextStepIds: [],
},
],
} satisfies WorkflowRunFlow;
const stepInfos = {
[TRIGGER_STEP_ID]: {
result: { company: { id: '123' } },
status: StepStatus.SUCCESS,
},
step1: { result: { taskId: '456' }, status: StepStatus.SUCCESS },
step2: { result: { taskId: '456' }, status: StepStatus.SUCCESS },
};
it('returns context for previous steps and trigger', () => {
const result = getWorkflowRunStepContext({
stepId: 'step2',
flow,
stepInfos,
flow: mockFlow,
stepInfos: mockStepInfos,
currentLoopIterationIndex: undefined,
});
expect(result).toEqual([
{
id: TRIGGER_STEP_ID,
name: 'Company Created',
context: { company: { id: '123' } },
},
{
id: 'step1',
name: 'Create company',
context: { taskId: '456' },
},
{ id: TRIGGER_STEP_ID, name: 'Trigger', context: 'trigger-result' },
{ id: 'step1', name: 'Step 1', context: 'step1-result' },
]);
});
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 stepInfos = {
[TRIGGER_STEP_ID]: {
result: { company: { id: '123' } },
status: StepStatus.SUCCESS,
},
step1: { result: { taskId: '456' }, status: StepStatus.SUCCESS },
step2: { result: { emailId: '789' }, status: StepStatus.SUCCESS },
};
it('returns context for previous steps with loop index', () => {
const result = getWorkflowRunStepContext({
stepId: 'step1',
flow,
stepInfos,
stepId: 'step2',
flow: mockFlow,
stepInfos: mockStepInfosWithHistory,
currentLoopIterationIndex: 1,
});
expect(result).toEqual([
{
id: TRIGGER_STEP_ID,
name: 'Company Created',
context: { company: { id: '123' } },
},
{ id: TRIGGER_STEP_ID, name: 'Trigger', context: 'trigger-result' },
{ id: 'step1', name: 'Step 1', context: 'step1-result-2' },
]);
});
it('should handle multiple steps with the same name', () => {
const flow = {
it('returns empty array if step not found', () => {
const result = getWorkflowRunStepContext({
stepId: 'unknown',
flow: mockFlow,
stepInfos: mockStepInfos,
currentLoopIterationIndex: undefined,
});
expect(result).toEqual([]);
});
it('handles iterator step type and loop context', () => {
const flowWithIterator = {
trigger: {
name: 'Company Created',
type: 'DATABASE_EVENT',
settings: {
eventName: 'company.created',
outputSchema: {},
},
type: 'MANUAL',
name: 'Trigger',
settings: { outputSchema: {} },
},
steps: [
{
id: 'step1',
name: 'Create Note',
type: 'CREATE_RECORD',
name: 'Step 1',
type: 'CODE',
nextStepIds: ['iterator1'],
valid: true,
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
objectName: 'Note',
objectRecord: {},
serverlessFunctionId: '',
serverlessFunctionInput: {},
serverlessFunctionVersion: '',
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
valid: true,
nextStepIds: ['step2'],
},
{
id: 'step2',
name: 'Create Note',
type: 'CREATE_RECORD',
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
objectName: 'Note',
objectRecord: {},
},
outputSchema: {},
},
valid: true,
nextStepIds: ['step3'],
},
{
id: 'step3',
name: 'Create Note',
type: 'CREATE_RECORD',
settings: {
errorHandlingOptions: {
continueOnFailure: { value: false },
retryOnFailure: { value: false },
},
input: {
objectName: 'Note',
objectRecord: {},
},
outputSchema: {},
},
valid: true,
id: 'iterator1',
name: 'Iterator',
type: 'ITERATOR',
nextStepIds: [],
valid: true,
settings: {
input: {
initialLoopStepIds: ['step2'],
items: [1, 2, 3],
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
},
{
id: 'step2',
name: 'Step 2',
type: 'CODE',
nextStepIds: ['iterator1'],
valid: true,
settings: {
input: {
serverlessFunctionId: '',
serverlessFunctionInput: {},
serverlessFunctionVersion: '',
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
},
],
} satisfies WorkflowRunFlow;
const stepInfos = {
[TRIGGER_STEP_ID]: {
result: { company: { id: '123' } },
result: 'trigger-result',
status: StepStatus.SUCCESS,
},
step1: { result: { noteId: '456' }, status: StepStatus.SUCCESS },
step2: { result: { noteId: '789' }, status: StepStatus.SUCCESS },
step3: { result: { noteId: '101' }, status: StepStatus.SUCCESS },
step1: {
result: 'step1-result',
status: StepStatus.SUCCESS,
},
iterator1: {
result: {
currentItemIndex: 2,
hasProcessedAllItems: true,
},
status: StepStatus.SUCCESS,
history: [
{
result: {
currentItem: 1,
currentItemIndex: 0,
hasProcessedAllItems: false,
},
status: StepStatus.RUNNING,
},
{
result: {
currentItem: 2,
currentItemIndex: 1,
hasProcessedAllItems: false,
},
status: StepStatus.RUNNING,
},
],
},
step2: {
result: 'step2-result-3',
status: StepStatus.SUCCESS,
history: [
{ result: 'step2-result-1', status: StepStatus.SUCCESS },
{ result: 'step2-result-2', status: StepStatus.SUCCESS },
],
},
};
const result = getWorkflowRunStepContext({
stepId: 'step3',
flow,
const step2Iteration0Result = getWorkflowRunStepContext({
stepId: 'step2',
flow: flowWithIterator,
stepInfos,
currentLoopIterationIndex: 0,
});
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' },
},
]);
expect(step2Iteration0Result).toMatchInlineSnapshot(`
[
{
"context": "trigger-result",
"id": "trigger",
"name": "Trigger",
},
{
"context": "step1-result",
"id": "step1",
"name": "Step 1",
},
{
"context": {
"currentItem": 1,
"currentItemIndex": 0,
"hasProcessedAllItems": false,
},
"id": "iterator1",
"name": "Iterator",
},
]
`);
const step2Iteration1Result = getWorkflowRunStepContext({
stepId: 'step2',
flow: flowWithIterator,
stepInfos,
currentLoopIterationIndex: 1,
});
expect(step2Iteration1Result).toMatchInlineSnapshot(`
[
{
"context": "trigger-result",
"id": "trigger",
"name": "Trigger",
},
{
"context": "step1-result",
"id": "step1",
"name": "Step 1",
},
{
"context": {
"currentItem": 2,
"currentItemIndex": 1,
"hasProcessedAllItems": false,
},
"id": "iterator1",
"name": "Iterator",
},
]
`);
});
});
@@ -1,8 +1,9 @@
import { type WorkflowRunFlow } from '@/workflow/types/Workflow';
import { type WorkflowRunStepContext } from '@/workflow/workflow-steps/types/WorkflowRunStepContext';
import { getPreviousSteps } from '@/workflow/workflow-steps/utils/getWorkflowPreviousSteps';
import { getWorkflowRunAllStepInfoHistory } from '@/workflow/workflow-steps/utils/getWorkflowRunAllStepInfoHistory';
import { isDefined } from 'twenty-shared/utils';
import {
getWorkflowRunContext,
TRIGGER_STEP_ID,
type WorkflowRunStepInfos,
} from 'twenty-shared/workflow';
@@ -11,10 +12,12 @@ export const getWorkflowRunStepContext = ({
stepId,
flow,
stepInfos,
currentLoopIterationIndex,
}: {
stepId: string;
stepInfos: WorkflowRunStepInfos;
flow: WorkflowRunFlow;
currentLoopIterationIndex: number | undefined;
}) => {
if (stepId === TRIGGER_STEP_ID) {
return [];
@@ -31,22 +34,39 @@ export const getWorkflowRunStepContext = ({
currentStep,
});
const context = getWorkflowRunContext(stepInfos);
const reversedPreviousSteps = previousSteps.toReversed();
const previousStepsContext = previousSteps.map((step) => {
return {
const reversedPreviousStepsContext: WorkflowRunStepContext[] = [];
let isInLoop = isDefined(currentLoopIterationIndex);
for (const step of reversedPreviousSteps) {
const stepInfoHistory = getWorkflowRunAllStepInfoHistory({
stepInfo: stepInfos[step.id],
});
const historyItemIndex =
isDefined(currentLoopIterationIndex) && isInLoop
? currentLoopIterationIndex
: 0;
reversedPreviousStepsContext.push({
id: step.id,
name: step.name,
context: context[step.id],
};
});
context: stepInfoHistory[historyItemIndex].result,
});
if (step.type === 'ITERATOR') {
isInLoop = false;
}
}
return [
{
id: TRIGGER_STEP_ID,
name: flow.trigger.name ?? 'Trigger',
context: context[TRIGGER_STEP_ID],
context: stepInfos[TRIGGER_STEP_ID].result,
},
...previousStepsContext,
...reversedPreviousStepsContext.toReversed(),
];
};