Workflow iterator continues on faillure (#18325)

<img width="450" height="212" alt="Capture d’écran 2026-03-03 à 11 41
54"
src="https://github.com/user-attachments/assets/b2c29a48-7dc0-4b16-a085-8f305d21f7ca"
/>

New status `FAIL_SAFE` added. This status propagates to the following
nodes until reaching the iterator, that will start the new iteration.

The difference with `SKIP` is that, when the parent nodes have at least
one `FAIL_SAFE`, it becomes `FAIL_SAFE` too. While a parent 1 `SKIP` +
parent 2 `SUCCESS` => to be executed.

I also thought about just going back to the iterator as a break would,
but since we have branches, it may lead to inconsistent statuses with
parallel updates.
This commit is contained in:
Thomas Trompette
2026-03-03 14:18:57 +01:00
committed by GitHub
parent d48c58640c
commit b2b3a3f860
29 changed files with 1410 additions and 456 deletions
@@ -0,0 +1,78 @@
import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer';
import { FormFieldInputInnerContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputRowContainer';
import { InputHint } from '@/ui/input/components/InputHint';
import { InputLabel } from '@/ui/input/components/InputLabel';
import styled from '@emotion/styled';
import { useId } from 'react';
import { Toggle } from 'twenty-ui/input';
type FormBooleanFieldToggleInputProps = {
label?: string;
description: string;
hint?: string;
value: boolean;
onChange: (value: boolean) => void;
disabled?: boolean;
};
const StyledDescription = styled.span`
align-items: center;
color: ${({ theme }) => theme.font.color.secondary};
display: flex;
font-size: ${({ theme }) => theme.font.size.md};
padding-left: ${({ theme }) => theme.spacing(2)};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const StyledToggleContainer = styled.div`
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.lighter};
border-top: 1px solid ${({ theme }) => theme.border.color.medium};
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
border-right: 1px solid ${({ theme }) => theme.border.color.medium};
border-bottom-right-radius: ${({ theme }) => theme.border.radius.sm};
border-top-right-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
padding-right: ${({ theme }) => theme.spacing(2)};
`;
export const FormBooleanFieldToggleInput = ({
label,
description,
hint,
value,
onChange,
disabled,
}: FormBooleanFieldToggleInputProps) => {
const instanceId = useId();
return (
<FormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<FormFieldInputRowContainer>
<FormFieldInputInnerContainer
formFieldInputInstanceId={instanceId}
hasRightElement
preventFocusStackUpdate
>
<StyledDescription>{description}</StyledDescription>
</FormFieldInputInnerContainer>
<StyledToggleContainer>
<Toggle
value={value}
onChange={onChange}
disabled={disabled}
toggleSize="small"
/>
</StyledToggleContainer>
</FormFieldInputRowContainer>
{hint && <InputHint>{hint}</InputHint>}
</FormFieldInputContainer>
);
};
@@ -0,0 +1,86 @@
import { FormBooleanFieldToggleInput } from '@/object-record/record-field/ui/form-types/components/FormBooleanFieldToggleInput';
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
const meta: Meta<typeof FormBooleanFieldToggleInput> = {
title: 'UI/Data/Field/Form/Input/FormBooleanFieldToggleInput',
component: FormBooleanFieldToggleInput,
args: {
description: 'Continue on iteration failure',
value: false,
onChange: fn(),
},
argTypes: {},
};
export default meta;
type Story = StoryObj<typeof FormBooleanFieldToggleInput>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('Continue on iteration failure');
const checkbox = canvas.getByRole('checkbox');
expect(checkbox).not.toBeChecked();
},
};
export const WithLabel: Story = {
args: {
label: 'Settings',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('Settings');
await canvas.findByText('Continue on iteration failure');
},
};
export const WithHint: Story = {
args: {
hint: 'If enabled, the workflow will continue to the next iteration even if the current one fails.',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText(
'If enabled, the workflow will continue to the next iteration even if the current one fails.',
);
},
};
export const ToggledOn: Story = {
args: {
value: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const checkbox = canvas.getByRole('checkbox');
expect(checkbox).toBeChecked();
},
};
export const TogglesValue: Story = {
args: {
value: false,
onChange: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const checkbox = canvas.getByRole('checkbox');
await userEvent.click(checkbox);
await waitFor(() => {
expect(args.onChange).toHaveBeenCalledWith(true);
});
},
};