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
@@ -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`}