Visualize iterator sub step's output by iteration index (#14747)

## Demo


https://github.com/user-attachments/assets/27afff85-4f33-4e91-b28a-3dc868bb9ad7

## With several sub steps

> [!NOTE]
> Please note that as I created the component state, the iteration index
will be different for every step of a workflow run. We might have one
component state instance per iterator node. We can implement it in
another PR if we think it's the way to go.


https://github.com/user-attachments/assets/6b8cd06d-9051-474d-b438-dd6155cc3810
This commit is contained in:
Baptiste Devessier
2025-09-29 15:40:42 +02:00
committed by GitHub
parent 358ab54690
commit 9b88cee1ce
15 changed files with 502 additions and 135 deletions
@@ -1,3 +1,4 @@
import { CommandMenuWorkflowRunStepContentComponentInstanceContext } from '@/command-menu/pages/workflow/step/view-run/states/contexts/CommandMenuWorkflowRunStepContentComponentInstanceContext';
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 { getShouldFocusNodeTab } from '@/command-menu/pages/workflow/step/view-run/utils/getShouldFocusNodeTab';
@@ -12,6 +13,7 @@ import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow';
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { WorkflowIteratorSubStepSwitcher } from '@/workflow/workflow-steps/components/WorkflowIteratorSubStepSwitcher';
import { WorkflowRunStepInputDetail } from '@/workflow/workflow-steps/components/WorkflowRunStepInputDetail';
import { WorkflowRunStepNodeDetail } from '@/workflow/workflow-steps/components/WorkflowRunStepNodeDetail';
import { WorkflowRunStepOutputDetail } from '@/workflow/workflow-steps/components/WorkflowRunStepOutputDetail';
@@ -112,46 +114,54 @@ export const CommandMenuWorkflowRunViewStepContent = () => {
];
return (
<StyledContainer>
{shouldFocusNodeTab ? (
<WorkflowRunStepNodeDetail
stepId={workflowSelectedNode}
trigger={flow.trigger}
steps={flow.steps}
stepExecutionStatus={stepExecutionStatus}
/>
) : (
<>
<StyledTabList
tabs={tabs}
behaveAsLinks={false}
componentInstanceId={commandMenuPageComponentInstance.instanceId}
<CommandMenuWorkflowRunStepContentComponentInstanceContext.Provider
value={{
instanceId: `${workflowRunId}_${workflowSelectedNode}`,
}}
>
<StyledContainer>
{shouldFocusNodeTab ? (
<WorkflowRunStepNodeDetail
stepId={workflowSelectedNode}
trigger={flow.trigger}
steps={flow.steps}
stepExecutionStatus={stepExecutionStatus}
/>
{activeTabId === WorkflowRunTabId.OUTPUT ? (
<WorkflowRunStepOutputDetail
key={workflowSelectedNode}
stepId={workflowSelectedNode}
) : (
<>
<StyledTabList
tabs={tabs}
behaveAsLinks={false}
componentInstanceId={commandMenuPageComponentInstance.instanceId}
/>
) : null}
{activeTabId === WorkflowRunTabId.NODE ? (
<WorkflowRunStepNodeDetail
stepId={workflowSelectedNode}
trigger={flow.trigger}
steps={flow.steps}
stepExecutionStatus={stepExecutionStatus}
/>
) : null}
{activeTabId === WorkflowRunTabId.OUTPUT ? (
<WorkflowRunStepOutputDetail
key={workflowSelectedNode}
stepId={workflowSelectedNode}
/>
) : null}
{activeTabId === WorkflowRunTabId.INPUT ? (
<WorkflowRunStepInputDetail
key={workflowSelectedNode}
stepId={workflowSelectedNode}
/>
) : null}
</>
)}
</StyledContainer>
{activeTabId === WorkflowRunTabId.NODE ? (
<WorkflowRunStepNodeDetail
stepId={workflowSelectedNode}
trigger={flow.trigger}
steps={flow.steps}
stepExecutionStatus={stepExecutionStatus}
/>
) : null}
{activeTabId === WorkflowRunTabId.INPUT ? (
<WorkflowRunStepInputDetail
key={workflowSelectedNode}
stepId={workflowSelectedNode}
/>
) : null}
<WorkflowIteratorSubStepSwitcher stepId={workflowSelectedNode} />
</>
)}
</StyledContainer>
</CommandMenuWorkflowRunStepContentComponentInstanceContext.Provider>
);
};
@@ -0,0 +1,6 @@
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
export const CommandMenuWorkflowRunStepContentComponentInstanceContext =
createComponentInstanceContext({
instanceId: '',
});
@@ -0,0 +1,10 @@
import { CommandMenuWorkflowRunStepContentComponentInstanceContext } from '@/command-menu/pages/workflow/step/view-run/states/contexts/CommandMenuWorkflowRunStepContentComponentInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const workflowRunIteratorSubStepIterationIndexComponentState =
createComponentState<number>({
key: 'workflowRunIteratorSubStepIterationIndexComponentState',
defaultValue: 0,
componentInstanceContext:
CommandMenuWorkflowRunStepContentComponentInstanceContext,
});
@@ -0,0 +1,33 @@
import { type WorkflowStep } from '@/workflow/types/Workflow';
import { isLastStepOfLoop } from '@/workflow/workflow-diagram/utils/isLastStepOfLoop';
export const isParentStep = ({
currentStep,
potentialParentStep,
steps,
}: {
currentStep: WorkflowStep;
potentialParentStep: WorkflowStep;
steps: WorkflowStep[];
}): boolean => {
if (potentialParentStep.type === 'ITERATOR') {
return !!(
potentialParentStep.settings.input.initialLoopStepIds?.includes(
currentStep.id,
) || potentialParentStep.nextStepIds?.includes(currentStep.id)
);
}
if (currentStep.type === 'ITERATOR') {
return !!(
potentialParentStep.nextStepIds?.includes(currentStep.id) &&
!isLastStepOfLoop({
iterator: currentStep,
stepId: potentialParentStep.id,
steps,
})
);
}
return !!potentialParentStep.nextStepIds?.includes(currentStep.id);
};
@@ -30,58 +30,53 @@ export const WorkflowActionFooter = ({
const workflowId = useCommandMenuWorkflowIdOrThrow();
const { openWorkflowEditStepTypeInCommandMenu } = useWorkflowCommandMenu();
const OptionsDropdown = () => {
return (
<Dropdown
dropdownId={dropdownId}
data-select-disable
clickableComponent={
<Button title="Options" hotkeys={[getOsControlSymbol(), 'O']} />
}
dropdownPlacement="top-end"
dropdownOffset={{ y: parseInt(theme.spacing(2), 10) }}
globalHotkeysConfig={{
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: false,
}}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={['change-node-type', 'duplicate']}
>
<MenuItem
onClick={() => {
closeDropdown(dropdownId);
openWorkflowEditStepTypeInCommandMenu(workflowId);
}}
text={t`Change node type`}
LeftIcon={IconPencil}
/>
<MenuItem
onClick={() => {
closeDropdown(dropdownId);
duplicateStep({ stepId });
}}
text={t`Duplicate node`}
LeftIcon={IconCopyPlus}
/>
</SelectableList>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
);
};
const OptionsDropdown = (
<Dropdown
dropdownId={dropdownId}
data-select-disable
clickableComponent={
<Button title="Options" hotkeys={[getOsControlSymbol(), 'O']} />
}
dropdownPlacement="top-end"
dropdownOffset={{ y: parseInt(theme.spacing(2), 10) }}
globalHotkeysConfig={{
enableGlobalHotkeysWithModifiers: true,
enableGlobalHotkeysConflictingWithKeyboard: false,
}}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={['change-node-type', 'duplicate']}
>
<MenuItem
onClick={() => {
closeDropdown(dropdownId);
openWorkflowEditStepTypeInCommandMenu(workflowId);
}}
text={t`Change node type`}
LeftIcon={IconPencil}
/>
<MenuItem
onClick={() => {
closeDropdown(dropdownId);
duplicateStep({ stepId });
}}
text={t`Duplicate node`}
LeftIcon={IconCopyPlus}
/>
</SelectableList>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
);
return (
<RightDrawerFooter
actions={[
<OptionsDropdown key="options" />,
...(additionalActions ?? []),
]}
actions={[OptionsDropdown, ...(additionalActions ?? [])]}
/>
);
};
@@ -0,0 +1,126 @@
import { workflowRunIteratorSubStepIterationIndexComponentState } from '@/command-menu/pages/workflow/step/view-run/states/workflowRunIteratorSubStepIterationIndexComponentState';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow';
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
import { getIsDescendantOfIterator } from '@/workflow/workflow-steps/utils/getIsDescendantOfIterator';
import { getWorkflowRunAllStepInfoHistory } from '@/workflow/workflow-steps/utils/getWorkflowRunAllStepInfoHistory';
import styled from '@emotion/styled';
import { plural } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconChevronLeft, IconChevronRight } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
const StyledContainer = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding-block: ${({ theme }) => theme.spacing(2)};
padding-inline: ${({ theme }) => theme.spacing(3)};
`;
const StyledCounter = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
export const WorkflowIteratorSubStepSwitcher = ({
stepId,
}: {
stepId: string;
}) => {
const flow = useFlowOrThrow();
const workflowRunId = useWorkflowRunIdOrThrow();
const workflowRun = useWorkflowRun({ workflowRunId });
const [
workflowRunIteratorSubStepIterationIndex,
setWorkflowRunIteratorSubStepIterationIndex,
] = useRecoilComponentState(
workflowRunIteratorSubStepIterationIndexComponentState,
);
const stepDefinition = getStepDefinitionOrThrow({
stepId,
trigger: flow.trigger,
steps: flow.steps,
});
const stepInfo = workflowRun?.state?.stepInfos[stepId];
if (
!isDefined(stepInfo) ||
!isDefined(workflowRun?.state) ||
!isDefined(flow.steps) ||
stepDefinition?.type !== 'action'
) {
return null;
}
const allStepInfos = getWorkflowRunAllStepInfoHistory({
stepInfo,
});
const isDescendantOfIterator = getIsDescendantOfIterator({
stepId,
steps: flow.steps,
});
const workflowRunIteratorSubStepIterationsCount = allStepInfos.length;
const canGoToPreviousIndex = workflowRunIteratorSubStepIterationIndex > 0;
const canGoToNextIndex =
workflowRunIteratorSubStepIterationIndex <
workflowRunIteratorSubStepIterationsCount - 1;
const handleDecrementIndex = () => {
if (!canGoToPreviousIndex) {
return;
}
setWorkflowRunIteratorSubStepIterationIndex(
workflowRunIteratorSubStepIterationIndex - 1,
);
};
const handleIncrementIndex = () => {
if (!canGoToNextIndex) {
return;
}
setWorkflowRunIteratorSubStepIterationIndex(
workflowRunIteratorSubStepIterationIndex + 1,
);
};
if (!isDescendantOfIterator) {
return null;
}
return (
<StyledContainer>
<IconButton
Icon={IconChevronLeft}
size="small"
disabled={!canGoToPreviousIndex}
onClick={handleDecrementIndex}
/>
<StyledCounter>
{workflowRunIteratorSubStepIterationIndex + 1}/
{plural(workflowRunIteratorSubStepIterationsCount, {
one: '# item',
other: '# items',
})}
</StyledCounter>
<IconButton
Icon={IconChevronRight}
size="small"
disabled={!canGoToNextIndex}
onClick={handleIncrementIndex}
/>
</StyledContainer>
);
};
@@ -3,6 +3,8 @@ import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThro
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
import { WorkflowRunStepJsonContainer } from '@/workflow/workflow-steps/components/WorkflowRunStepJsonContainer';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { useWorkflowRunStepInfo } from '@/workflow/workflow-steps/hooks/useWorkflowRunStepInfo';
import { getWorkflowRunStepInfoToDisplayAsOutput } from '@/workflow/workflow-steps/utils/getWorkflowRunStepInfoToDisplayAsOutput';
import { getActionHeaderTypeOrThrow } from '@/workflow/workflow-steps/workflow-actions/utils/getActionHeaderTypeOrThrow';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { getActionIconColorOrThrow } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow';
@@ -29,13 +31,15 @@ export const WorkflowRunStepOutputDetail = ({ stepId }: { stepId: string }) => {
const workflowRunId = useWorkflowRunIdOrThrow();
const workflowRun = useWorkflowRun({ workflowRunId });
if (!isDefined(workflowRun?.state?.stepInfos)) {
const stepInfo = useWorkflowRunStepInfo({ stepId });
if (!isDefined(workflowRun?.state) || !isDefined(stepInfo)) {
return null;
}
const stepInfo = workflowRun.state.stepInfos[stepId];
const { status: _, ...stepInfoWithoutStatus } = stepInfo ?? {};
const stepInfoToDisplay = getWorkflowRunStepInfoToDisplayAsOutput({
stepInfo,
});
const stepDefinition = getStepDefinitionOrThrow({
stepId,
@@ -89,7 +93,7 @@ export const WorkflowRunStepOutputDetail = ({ stepId }: { stepId: string }) => {
<WorkflowRunStepJsonContainer>
<JsonTree
value={stepInfoWithoutStatus ?? t`No output available`}
value={stepInfoToDisplay ?? t`No output available`}
shouldExpandNodeInitially={isTwoFirstDepths}
emptyArrayLabel={t`Empty Array`}
emptyObjectLabel={t`Empty Object`}
@@ -0,0 +1,25 @@
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 { getWorkflowRunAllStepInfoHistory } from '@/workflow/workflow-steps/utils/getWorkflowRunAllStepInfoHistory';
import { isDefined } from 'twenty-shared/utils';
export const useWorkflowRunStepInfo = ({ stepId }: { stepId: string }) => {
const workflowRunId = useWorkflowRunIdOrThrow();
const workflowRun = useWorkflowRun({ workflowRunId });
const workflowRunIteratorSubStepIterationIndex = useRecoilComponentValue(
workflowRunIteratorSubStepIterationIndexComponentState,
);
const stepInfo = workflowRun?.state?.stepInfos[stepId];
if (!isDefined(stepInfo)) {
return undefined;
}
const allStepInfoHistory = getWorkflowRunAllStepInfoHistory({ stepInfo });
return allStepInfoHistory[workflowRunIteratorSubStepIterationIndex];
};
@@ -0,0 +1,113 @@
import { type WorkflowStep } from '@/workflow/types/Workflow';
import { getIsDescendantOfIterator } from '../getIsDescendantOfIterator';
describe('getIsDescendantOfIterator', () => {
const iteratorStep: WorkflowStep = {
id: 'iterator1',
name: 'Iterator Step',
type: 'ITERATOR',
valid: true,
nextStepIds: ['step3'],
settings: {
input: {
initialLoopStepIds: ['step2'],
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: true },
},
},
};
const codeStep2: WorkflowStep = {
id: 'step2',
name: 'Second Step',
type: 'CODE',
valid: true,
nextStepIds: ['iterator1'],
settings: {
input: {
serverlessFunctionId: 'func2',
serverlessFunctionVersion: '1.0.0',
serverlessFunctionInput: {},
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: true },
},
},
};
const codeStep3: WorkflowStep = {
id: 'step3',
name: 'Third Step',
type: 'CODE',
valid: true,
nextStepIds: [],
settings: {
input: {
serverlessFunctionId: 'func3',
serverlessFunctionVersion: '1.0.0',
serverlessFunctionInput: {},
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: true },
},
},
};
const workflow: WorkflowStep[] = [iteratorStep, codeStep2, codeStep3];
it('returns true for direct loop step descendant', () => {
expect(
getIsDescendantOfIterator({ stepId: codeStep2.id, steps: workflow }),
).toBe(true);
});
it('returns true for indirect descendant', () => {
// step3 is pointed to by iterator1.nextStepIds
expect(
getIsDescendantOfIterator({ stepId: codeStep3.id, steps: workflow }),
).toBe(true);
});
it('returns false for iterator itself', () => {
expect(
getIsDescendantOfIterator({ stepId: iteratorStep.id, steps: workflow }),
).toBe(false);
});
it('returns false for step not connected to iterator', () => {
const unrelatedStep: WorkflowStep = {
id: 'step4',
name: 'Unrelated Step',
type: 'CODE',
valid: true,
nextStepIds: [],
settings: {
input: {
serverlessFunctionId: 'func4',
serverlessFunctionVersion: '1.0.0',
serverlessFunctionInput: {},
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: true },
},
},
};
expect(
getIsDescendantOfIterator({
stepId: unrelatedStep.id,
steps: [...workflow, unrelatedStep],
}),
).toBe(false);
});
});
// ...existing test code...
@@ -0,0 +1,46 @@
import { type WorkflowStep } from '@/workflow/types/Workflow';
import { isParentStep } from '@/workflow/workflow-diagram/utils/isParentStep';
export const getIsDescendantOfIterator = ({
stepId,
steps,
}: {
stepId: string;
steps: WorkflowStep[];
}): boolean => {
const hasIteratorAncestor = (
currentStepId: string,
visited = new Set<string>(),
): boolean => {
const currentStep = steps.find((step) => step.id === currentStepId);
if (!currentStep) {
throw new Error(`Step with ID ${currentStepId} not found`);
}
const parentSteps = steps
.filter((step) =>
isParentStep({
currentStep,
potentialParentStep: step,
steps,
}),
)
.filter((step) => !visited.has(step.id));
for (const parent of parentSteps) {
visited.add(currentStepId);
if (parent.type === 'ITERATOR') {
return true;
}
if (hasIteratorAncestor(parent.id, visited)) {
return true;
}
}
return false;
};
return hasIteratorAncestor(stepId);
};
@@ -1,36 +1,5 @@
import { type WorkflowStep } from '@/workflow/types/Workflow';
import { isLastStepOfLoop } from '@/workflow/workflow-diagram/utils/isLastStepOfLoop';
const isParentStep = ({
currentStep,
potentialParentStep,
steps,
}: {
currentStep: WorkflowStep;
potentialParentStep: WorkflowStep;
steps: WorkflowStep[];
}) => {
if (potentialParentStep.type === 'ITERATOR') {
return (
potentialParentStep.settings.input.initialLoopStepIds?.includes(
currentStep.id,
) || potentialParentStep.nextStepIds?.includes(currentStep.id)
);
}
if (currentStep.type === 'ITERATOR') {
return (
potentialParentStep.nextStepIds?.includes(currentStep.id) &&
!isLastStepOfLoop({
iterator: currentStep,
stepId: potentialParentStep.id,
steps,
})
);
}
return potentialParentStep.nextStepIds?.includes(currentStep.id);
};
import { isParentStep } from '@/workflow/workflow-diagram/utils/isParentStep';
export const getPreviousSteps = ({
steps,
@@ -0,0 +1,15 @@
import { isArray } from '@sniptt/guards';
import { type WorkflowRunStepInfo } from 'twenty-shared/workflow';
export const getWorkflowRunAllStepInfoHistory = ({
stepInfo,
}: {
stepInfo: WorkflowRunStepInfo;
}) => {
const allStepInfoHistory: WorkflowRunStepInfo[] = [
...(isArray(stepInfo?.history) ? stepInfo.history : []),
stepInfo,
];
return allStepInfoHistory;
};
@@ -0,0 +1,11 @@
import { type WorkflowRunStepInfo } from 'twenty-shared/workflow';
export const getWorkflowRunStepInfoToDisplayAsOutput = ({
stepInfo,
}: {
stepInfo: WorkflowRunStepInfo;
}) => {
const { status: _status, history: _history, ...infoToDisplay } = stepInfo;
return infoToDisplay;
};
@@ -3,6 +3,15 @@ import { workflowRunStepStatusSchema } from './workflow-run-step-status-schema';
export const workflowRunStateStepInfoSchema = z.object({
result: z.any().optional(),
error: z.any().optional(),
error: z.string().optional(),
status: workflowRunStepStatusSchema,
get history() {
return z.array(
workflowRunStateStepInfoSchema.pick({
result: true,
status: true,
error: true,
})
).optional();
}
});
@@ -1,3 +1,7 @@
import { type workflowRunStateStepInfoSchema } from "@/workflow/schemas/workflow-run-state-step-info-schema";
import { type workflowRunStateStepInfosSchema } from "@/workflow/schemas/workflow-run-state-step-infos-schema";
import type z from "zod";
export enum StepStatus {
NOT_STARTED = 'NOT_STARTED',
RUNNING = 'RUNNING',
@@ -7,15 +11,6 @@ export enum StepStatus {
PENDING = 'PENDING',
}
export type WorkflowRunStepInfo = {
result?: object;
error?: string;
status: StepStatus;
history?: {
status: StepStatus;
result?: object;
error?: string;
}[];
};
export type WorkflowRunStepInfo = z.infer<typeof workflowRunStateStepInfoSchema>
export type WorkflowRunStepInfos = Record<string, WorkflowRunStepInfo>;
export type WorkflowRunStepInfos = z.infer<typeof workflowRunStateStepInfosSchema>;