Handle empty values for boolean form field (#14884)

This PR uses a select component instead of `BooleanInput` for boolean
form fields. This makes it clear that the default value is `undefined`.

## Before


https://github.com/user-attachments/assets/84061638-0ba0-4f79-965e-90517d45aa3e

## After


https://github.com/user-attachments/assets/cdbcf935-5c5b-46d1-a1cc-110670b460ee

Fixes https://github.com/twentyhq/twenty/issues/14851
This commit is contained in:
Baptiste Devessier
2025-10-06 11:57:55 +02:00
committed by GitHub
parent f6240fabd0
commit 6b463736cc
64 changed files with 353 additions and 61 deletions
@@ -3,16 +3,16 @@ import { FormFieldInputInnerContainer } from '@/object-record/record-field/ui/fo
import { FormFieldInputRowContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputRowContainer';
import { VariableChipStandalone } from '@/object-record/record-field/ui/form-types/components/VariableChipStandalone';
import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
import { BooleanInput } from '@/ui/field/input/components/BooleanInput';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { Select } from '@/ui/input/components/Select';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import styled from '@emotion/styled';
import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro';
import { useId, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
const StyledBooleanInputContainer = styled.div`
padding-inline: ${({ theme }) => theme.spacing(2)};
`;
import { IconCheck, IconCircleOff, IconX } from 'twenty-ui/display';
type FormBooleanFieldInputProps = {
label?: string;
@@ -22,6 +22,22 @@ type FormBooleanFieldInputProps = {
readonly?: boolean;
};
const parseStringifiedBooleanToBoolean = (value: string) => {
if (value === 'true') {
return true;
}
return false;
};
const castBooleanToStringifiedBoolean = (value: boolean | undefined) => {
if (value === undefined) {
return '';
}
return value ? 'true' : 'false';
};
export const FormBooleanFieldInput = ({
label,
defaultValue,
@@ -29,12 +45,19 @@ export const FormBooleanFieldInput = ({
readonly,
VariablePicker,
}: FormBooleanFieldInputProps) => {
const theme = useTheme();
const { t } = useLingui();
const instanceId = useId();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const [draftValue, setDraftValue] = useState<
| {
type: 'static';
value: boolean;
value: boolean | undefined;
editingMode: 'view' | 'edit';
}
| {
type: 'variable';
@@ -48,17 +71,29 @@ export const FormBooleanFieldInput = ({
}
: {
type: 'static',
value: Boolean(defaultValue),
value: defaultValue,
editingMode: 'view',
},
);
const handleChange = (newValue: boolean) => {
const defaultEmptyOption = {
label: t`Select a value`,
value: '',
icon: IconCircleOff,
};
const onSelect = (option: string) => {
const optionAsBoolean = parseStringifiedBooleanToBoolean(option);
setDraftValue({
type: 'static',
value: newValue,
value: optionAsBoolean,
editingMode: 'view',
});
onChange(newValue);
removeFocusItemFromFocusStackById({ focusId: instanceId });
onChange(optionAsBoolean);
};
const handleVariableTagInsert = (variableName: string) => {
@@ -74,6 +109,7 @@ export const FormBooleanFieldInput = ({
setDraftValue({
type: 'static',
value: false,
editingMode: 'view',
});
onChange(false);
@@ -84,32 +120,40 @@ export const FormBooleanFieldInput = ({
{label ? <InputLabel>{label}</InputLabel> : null}
<FormFieldInputRowContainer>
<FormFieldInputInnerContainer
formFieldInputInstanceId={instanceId}
hasRightElement={isDefined(VariablePicker) && !readonly}
>
{draftValue.type === 'static' ? (
<StyledBooleanInputContainer>
<BooleanInput
value={draftValue.value}
readonly={readonly}
onToggle={handleChange}
/>
</StyledBooleanInputContainer>
) : (
{draftValue.type === 'static' ? (
<Select
dropdownId={`${instanceId}-select-display`}
options={[
{ label: 'True', value: 'true', Icon: IconCheck },
{ label: 'False', value: 'false', Icon: IconX },
]}
value={castBooleanToStringifiedBoolean(draftValue.value)}
onChange={onSelect}
emptyOption={defaultEmptyOption}
fullWidth
hasRightElement={isDefined(VariablePicker) && !readonly}
disabled={readonly}
dropdownWidth={GenericDropdownContentWidth.ExtraLarge}
dropdownOffset={{ y: parseInt(theme.spacing(1), 10) }}
/>
) : (
<FormFieldInputInnerContainer
formFieldInputInstanceId={instanceId}
hasRightElement={isDefined(VariablePicker) && !readonly}
>
<VariableChipStandalone
rawVariableName={draftValue.value}
onRemove={readonly ? undefined : handleUnlinkVariable}
/>
)}
</FormFieldInputInnerContainer>
</FormFieldInputInnerContainer>
)}
{VariablePicker && !readonly ? (
{isDefined(VariablePicker) && !readonly && (
<VariablePicker
instanceId={instanceId}
onVariableSelect={handleVariableTagInsert}
/>
) : null}
)}
</FormFieldInputRowContainer>
</FormFieldInputContainer>
);
@@ -1,5 +1,9 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { expect, userEvent, within } from '@storybook/test';
import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
import { getCanvasElementForDropdownTesting } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
import { MOCKED_STEP_ID } from '~/testing/mock-data/workflow';
import { FormBooleanFieldInput } from '../FormBooleanFieldInput';
const meta: Meta<typeof FormBooleanFieldInput> = {
@@ -7,6 +11,7 @@ const meta: Meta<typeof FormBooleanFieldInput> = {
component: FormBooleanFieldInput,
args: {},
argTypes: {},
decorators: [WorkflowStepDecorator, I18nFrontDecorator],
};
export default meta;
@@ -18,7 +23,7 @@ export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('False');
await canvas.findByText('Select a value');
},
};
@@ -33,6 +38,17 @@ export const WithLabel: Story = {
},
};
export const EmptyByDefault: Story = {
args: {
defaultValue: undefined,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('Select a value');
},
};
export const TrueByDefault: Story = {
args: {
defaultValue: true,
@@ -88,3 +104,85 @@ export const Disabled: Story = {
expect(variablePicker).not.toBeInTheDocument();
},
};
export const ChangesValueToTrue: Story = {
args: {
defaultValue: undefined,
label: 'Boolean',
onChange: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const select = await canvas.findByText('Select a value');
await userEvent.click(select);
const trueOption = await within(
getCanvasElementForDropdownTesting(),
).findByText('True');
await userEvent.click(trueOption);
await waitFor(() => {
expect(args.onChange).toHaveBeenCalledWith(true);
});
},
};
export const ChangesValueToFalse: Story = {
args: {
defaultValue: undefined,
label: 'Boolean',
onChange: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const select = await canvas.findByText('Select a value');
await userEvent.click(select);
const falseOption = await within(
getCanvasElementForDropdownTesting(),
).findByText('False');
await userEvent.click(falseOption);
await waitFor(() => {
expect(args.onChange).toHaveBeenCalledWith(false);
});
},
};
export const ChangesValueToVariable: Story = {
args: {
defaultValue: undefined,
label: 'Boolean',
VariablePicker: ({ onVariableSelect }) => {
return (
<button
onClick={() => {
onVariableSelect(`{{${MOCKED_STEP_ID}.name}}`);
}}
>
Add variable
</button>
);
},
onChange: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const addVariableButton = await canvas.findByRole('button', {
name: 'Add variable',
});
await userEvent.click(addVariableButton);
await waitFor(() => {
expect(args.onChange).toHaveBeenCalledWith(`{{${MOCKED_STEP_ID}.name}}`);
});
},
};