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:
committed by
GitHub
parent
f6240fabd0
commit
6b463736cc
+72
-28
@@ -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>
|
||||
);
|
||||
|
||||
+100
-2
@@ -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}}`);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user