Refacto form select input (#11426)

- small fixes on form design
- refacto form select input to use the existing select component
This commit is contained in:
Thomas Trompette
2025-04-07 18:56:59 +02:00
committed by GitHub
parent ff59658d39
commit 17b7e703b4
14 changed files with 80 additions and 298 deletions

View File

@ -92,9 +92,7 @@ export const FormFieldInput = ({
onChange={onChange}
VariablePicker={VariablePicker}
options={field.metadata.options}
clearLabel={field.label}
readonly={readonly}
placeholder={placeholder}
/>
) : isFieldFullName(field) ? (
<FormFullNameFieldInput

View File

@ -27,8 +27,7 @@ export const FormCountryCodeSelectInput = ({
({ countryName, countryCode, callingCode, Flag }) => ({
label: `${countryName} (+${callingCode})`,
value: countryCode,
color: 'transparent',
icon: (props: IconComponentProps) =>
Icon: (props: IconComponentProps) =>
Flag({ width: props.size, height: props.size }),
}),
);
@ -36,7 +35,7 @@ export const FormCountryCodeSelectInput = ({
{
label: 'No country',
value: '',
icon: IconCircleOff,
Icon: IconCircleOff,
},
...countryList,
];
@ -62,7 +61,6 @@ export const FormCountryCodeSelectInput = ({
defaultValue={selectedCountryCode}
readonly={readonly}
VariablePicker={VariablePicker}
preventDisplayPadding
/>
);
};

View File

@ -24,8 +24,7 @@ export const FormCountrySelectInput = ({
({ countryName, Flag }) => ({
label: countryName,
value: countryName,
color: 'transparent',
icon: (props: IconComponentProps) =>
Icon: (props: IconComponentProps) =>
Flag({ width: props.size, height: props.size }),
}),
);
@ -33,7 +32,7 @@ export const FormCountrySelectInput = ({
{
label: 'No country',
value: '',
icon: IconCircleOff,
Icon: IconCircleOff,
},
...countryList,
];
@ -59,8 +58,6 @@ export const FormCountrySelectInput = ({
defaultValue={selectedCountryName}
readonly={readonly}
VariablePicker={VariablePicker}
placeholder="Select a country"
preventDisplayPadding
/>
);
};

View File

@ -8,6 +8,7 @@ import { FormFieldCurrencyValue } from '@/object-record/record-field/types/Field
import { SETTINGS_FIELD_CURRENCY_CODES } from '@/settings/data-model/constants/SettingsFieldCurrencyCodes';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { useMemo } from 'react';
import { IconCircleOff } from 'twenty-ui/display';
type FormCurrencyFieldInputProps = {
label?: string;
@ -25,13 +26,22 @@ export const FormCurrencyFieldInput = ({
readonly,
}: FormCurrencyFieldInputProps) => {
const currencies = useMemo(() => {
return Object.entries(SETTINGS_FIELD_CURRENCY_CODES).map(
const currencies = Object.entries(SETTINGS_FIELD_CURRENCY_CODES).map(
([key, { Icon, label }]) => ({
value: key,
icon: Icon,
Icon,
label: `${label} (${key})`,
}),
);
return [
{
label: 'No currency',
value: '',
Icon: IconCircleOff,
},
...currencies,
];
}, []);
const handleAmountMicrosChange = (
@ -59,11 +69,8 @@ export const FormCurrencyFieldInput = ({
defaultValue={defaultValue?.currencyCode ?? ''}
onChange={handleCurrencyCodeChange}
options={currencies}
clearLabel={'Currency Code'}
VariablePicker={VariablePicker}
readonly={readonly}
placeholder="Select a currency"
preventDisplayPadding
/>
<FormNumberFieldInput
label="Amount Micros"

View File

@ -3,24 +3,16 @@ import { FormFieldInputInputContainer } from '@/object-record/record-field/form-
import { FormFieldInputRowContainer } from '@/object-record/record-field/form-types/components/FormFieldInputRowContainer';
import { VariableChipStandalone } from '@/object-record/record-field/form-types/components/VariableChipStandalone';
import { VariablePickerComponent } from '@/object-record/record-field/form-types/types/VariablePickerComponent';
import { SELECT_FIELD_INPUT_SELECTABLE_LIST_COMPONENT_INSTANCE_ID } from '@/object-record/record-field/meta-types/input/constants/SelectFieldInputSelectableListComponentInstanceId';
import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope';
import { SelectDisplay } from '@/ui/field/display/components/SelectDisplay';
import { SelectInput } from '@/ui/field/input/components/SelectInput';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { Select } from '@/ui/input/components/Select';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useId, useState } from 'react';
import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared/utils';
import { IconChevronDown } from 'twenty-ui/display';
import { SelectOption } from 'twenty-ui/input';
import { VisibilityHidden } from 'twenty-ui/accessibility';
type FormSelectFieldInputProps = {
label?: string;
@ -28,68 +20,22 @@ type FormSelectFieldInputProps = {
onChange: (value: string | null) => void;
VariablePicker?: VariablePickerComponent;
options: SelectOption[];
clearLabel?: string;
readonly?: boolean;
preventDisplayPadding?: boolean;
placeholder?: string;
};
const StyledDisplayModeReadonlyContainer = styled.div`
align-items: center;
background: transparent;
border: none;
display: flex;
font-family: inherit;
padding-inline: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const StyledDisplayModeContainer = styled(StyledDisplayModeReadonlyContainer)`
cursor: pointer;
&:hover,
&[data-open='true'] {
background-color: ${({ theme }) => theme.background.transparent.lighter};
}
`;
const StyledPlaceholder = styled.div`
color: ${({ theme }) => theme.font.color.light};
font-weight: ${({ theme }) => theme.font.weight.medium};
width: 100%;
`;
const StyledSelectInputContainer = styled.div`
position: absolute;
z-index: 1;
top: ${({ theme }) => theme.spacing(8)};
`;
const StyledSelectDisplayContainer = styled.div`
display: flex;
width: 100%;
`;
export const FormSelectFieldInput = ({
label,
defaultValue,
onChange,
VariablePicker,
options,
clearLabel,
readonly,
preventDisplayPadding,
placeholder,
}: FormSelectFieldInputProps) => {
const inputId = useId();
const hotkeyScope = InlineCellHotkeyScope.InlineCell;
const theme = useTheme();
const {
setHotkeyScopeAndMemorizePreviousScope,
goBackToPreviousHotkeyScope,
} = usePreviousHotkeyScope();
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope();
const [draftValue, setDraftValue] = useState<
| {
@ -114,7 +60,7 @@ export const FormSelectFieldInput = ({
},
);
const onSubmit = (option: string) => {
const onSelect = (option: string) => {
setDraftValue({
type: 'static',
value: option,
@ -139,38 +85,10 @@ export const FormSelectFieldInput = ({
goBackToPreviousHotkeyScope();
};
const [filteredOptions, setFilteredOptions] = useState<SelectOption[]>([]);
const { resetSelectedItem } = useSelectableList(
SELECT_FIELD_INPUT_SELECTABLE_LIST_COMPONENT_INSTANCE_ID,
);
const clearField = () => {
setDraftValue({
type: 'static',
editingMode: 'view',
value: '',
});
onChange(null);
};
const selectedOption = options.find(
(option) => option.value === draftValue.value,
);
const handleClearField = () => {
clearField();
goBackToPreviousHotkeyScope();
};
const handleSubmit = (option: SelectOption) => {
onSubmit(option.value);
resetSelectedItem();
};
const handleUnlinkVariable = () => {
setDraftValue({
type: 'static',
@ -190,131 +108,43 @@ export const FormSelectFieldInput = ({
onChange(variableName);
};
const handleDisplayModeClick = () => {
if (draftValue.type !== 'static') {
throw new Error(
'This function can only be called when editing a static value.',
);
}
setDraftValue({
...draftValue,
editingMode: 'edit',
});
setHotkeyScopeAndMemorizePreviousScope(hotkeyScope);
};
const handleSelectEnter = (itemId: string) => {
const option = filteredOptions.find((option) => option.value === itemId);
if (isDefined(option)) {
onSubmit(option.value);
resetSelectedItem();
}
};
useScopedHotkeys(
Key.Escape,
() => {
onCancel();
resetSelectedItem();
},
hotkeyScope,
[onCancel, resetSelectedItem],
[onCancel],
);
const optionIds = [
`No ${label}`,
...filteredOptions.map((option) => option.value),
];
const placeholderText = placeholder ?? label;
return (
<FormFieldInputContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<FormFieldInputRowContainer>
<FormFieldInputInputContainer
hasRightElement={isDefined(VariablePicker) && !readonly}
>
{draftValue.type === 'static' ? (
readonly ? (
<StyledDisplayModeReadonlyContainer>
{isDefined(selectedOption) ? (
<StyledSelectDisplayContainer>
<SelectDisplay
color={selectedOption.color ?? 'transparent'}
label={selectedOption.label}
Icon={selectedOption.Icon ?? undefined}
preventPadding={preventDisplayPadding}
/>
</StyledSelectDisplayContainer>
) : (
<StyledPlaceholder />
)}
<IconChevronDown
size={theme.icon.size.md}
color={theme.font.color.light}
/>
</StyledDisplayModeReadonlyContainer>
) : (
<StyledDisplayModeContainer
data-open={draftValue.editingMode === 'edit'}
onClick={handleDisplayModeClick}
>
<VisibilityHidden>Edit</VisibilityHidden>
{isDefined(selectedOption) ? (
<StyledSelectDisplayContainer>
<SelectDisplay
color={selectedOption.color ?? 'transparent'}
label={selectedOption.label}
Icon={selectedOption.Icon ?? undefined}
preventPadding={preventDisplayPadding}
/>
</StyledSelectDisplayContainer>
) : (
<StyledPlaceholder>{placeholderText}</StyledPlaceholder>
)}
<IconChevronDown
size={theme.icon.size.md}
color={theme.font.color.tertiary}
/>
</StyledDisplayModeContainer>
)
) : (
{draftValue.type === 'static' ? (
<Select
dropdownId={`${inputId}-select-display`}
options={options}
value={selectedOption?.value}
onChange={onSelect}
fullWidth
hasRightElement={isDefined(VariablePicker) && !readonly}
withSearchInput
disabled={readonly}
/>
) : (
<FormFieldInputInputContainer
hasRightElement={isDefined(VariablePicker) && !readonly}
>
<VariableChipStandalone
rawVariableName={draftValue.value}
onRemove={readonly ? undefined : handleUnlinkVariable}
/>
)}
</FormFieldInputInputContainer>
<StyledSelectInputContainer>
{!readonly &&
draftValue.type === 'static' &&
draftValue.editingMode === 'edit' && (
<OverlayContainer>
<SelectInput
selectableListComponentInstanceId={
SELECT_FIELD_INPUT_SELECTABLE_LIST_COMPONENT_INSTANCE_ID
}
selectableItemIdArray={optionIds}
hotkeyScope={hotkeyScope}
onEnter={handleSelectEnter}
onOptionSelected={handleSubmit}
options={options}
onCancel={onCancel}
defaultOption={selectedOption}
onFilterChange={setFilteredOptions}
onClear={handleClearField}
clearLabel={clearLabel}
/>
</OverlayContainer>
)}
</StyledSelectInputContainer>
</FormFieldInputInputContainer>
)}
{VariablePicker && !readonly && (
{isDefined(VariablePicker) && !readonly && (
<VariablePicker
inputId={inputId}
onVariableSelect={handleVariableTagInsert}

View File

@ -37,77 +37,6 @@ export const Default: Story = {
},
};
export const SelectCountryCode: Story = {
args: {
label: 'Phone',
onChange: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const defaultEmptyOption = await canvas.findByText('No country');
expect(defaultEmptyOption).toBeVisible();
await userEvent.click(defaultEmptyOption);
const searchInput = await canvas.findByPlaceholderText('Search');
expect(searchInput).toBeVisible();
await userEvent.type(searchInput, 'France');
const franceOption = await canvas.findByText(/France/);
await userEvent.click(franceOption);
await waitFor(() => {
expect(args.onChange).toHaveBeenCalledWith({
primaryPhoneNumber: '',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '33',
});
});
expect(args.onChange).toHaveBeenCalledTimes(1);
},
};
export const SelectEmptyCountryCode: Story = {
args: {
label: 'Phone',
onChange: fn(),
defaultValue: {
primaryPhoneNumber: '',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '33',
},
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const defaultSelectedOption = await canvas.findByText(/France/);
expect(defaultSelectedOption).toBeVisible();
await userEvent.click(defaultSelectedOption);
const searchInput = await canvas.findByPlaceholderText('Search');
expect(searchInput).toBeVisible();
const emptyOption = await canvas.findByText('No country');
await userEvent.click(emptyOption);
await waitFor(() => {
expect(args.onChange).toHaveBeenCalledWith({
primaryPhoneNumber: '',
primaryPhoneCountryCode: '',
primaryPhoneCallingCode: '',
});
});
expect(args.onChange).toHaveBeenCalledTimes(1);
},
};
export const WithVariablesAsDefaultValues: Story = {
args: {
label: 'Phone',

View File

@ -10,10 +10,10 @@ import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { SelectControl } from '@/ui/input/components/SelectControl';
import { DropdownOffset } from '@/ui/layout/dropdown/types/DropdownOffset';
import { isDefined } from 'twenty-shared/utils';
import { SelectHotkeyScope } from '../types/SelectHotkeyScope';
import { IconComponent } from 'twenty-ui/display';
import { MenuItem, MenuItemSelect } from 'twenty-ui/navigation';
import { SelectOption } from 'twenty-ui/input';
import { MenuItem, MenuItemSelect } from 'twenty-ui/navigation';
import { SelectHotkeyScope } from '../types/SelectHotkeyScope';
export type SelectSizeVariant = 'small' | 'default';
@ -43,6 +43,7 @@ export type SelectProps<Value extends SelectValue> = {
needIconCheck?: boolean;
callToActionButton?: CallToActionButton;
dropdownOffset?: DropdownOffset;
hasRightElement?: boolean;
};
const StyledContainer = styled.div<{ fullWidth?: boolean }>`
@ -75,6 +76,7 @@ export const Select = <Value extends SelectValue>({
needIconCheck,
callToActionButton,
dropdownOffset,
hasRightElement,
}: SelectProps<Value>) => {
const selectContainerRef = useRef<HTMLDivElement>(null);
@ -121,6 +123,7 @@ export const Select = <Value extends SelectValue>({
selectedOption={selectedOption}
isDisabled={isDisabled}
selectSizeVariant={selectSizeVariant}
hasRightElement={hasRightElement}
/>
) : (
<Dropdown
@ -133,6 +136,7 @@ export const Select = <Value extends SelectValue>({
selectedOption={selectedOption}
isDisabled={isDisabled}
selectSizeVariant={selectSizeVariant}
hasRightElement={hasRightElement}
/>
}
dropdownComponents={

View File

@ -1,5 +1,5 @@
import { SelectSizeVariant } from '@/ui/input/components/Select';
import { useTheme } from '@emotion/react';
import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-shared/utils';
import { IconChevronDown, OverflowingTextWithTooltip } from 'twenty-ui/display';
@ -13,6 +13,7 @@ const StyledControlContainer = styled.div<{
hasIcon: boolean;
selectSizeVariant?: SelectSizeVariant;
textAccent: SelectControlTextAccent;
hasRightElement?: boolean;
}>`
display: grid;
grid-template-columns: ${({ hasIcon }) =>
@ -26,7 +27,22 @@ const StyledControlContainer = styled.div<{
padding: 0 ${({ theme }) => theme.spacing(2)};
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
border-top-left-radius: ${({ theme }) => theme.border.radius.sm};
border-bottom-left-radius: ${({ theme }) => theme.border.radius.sm};
${({ hasRightElement, theme }) =>
!hasRightElement
? css`
border-right: auto;
border-bottom-right-radius: ${theme.border.radius.sm};
border-top-right-radius: ${theme.border.radius.sm};
`
: css`
border-right: none;
border-bottom-right-radius: none;
border-top-right-radius: none;
`}
color: ${({ disabled, theme, textAccent }) =>
disabled
? theme.font.color.tertiary
@ -49,6 +65,7 @@ type SelectControlProps = {
isDisabled?: boolean;
selectSizeVariant?: SelectSizeVariant;
textAccent?: SelectControlTextAccent;
hasRightElement?: boolean;
};
export const SelectControl = ({
@ -56,6 +73,7 @@ export const SelectControl = ({
isDisabled,
selectSizeVariant,
textAccent = 'default',
hasRightElement,
}: SelectControlProps) => {
const theme = useTheme();
@ -65,6 +83,7 @@ export const SelectControl = ({
hasIcon={isDefined(selectedOption.Icon)}
selectSizeVariant={selectSizeVariant}
textAccent={textAccent}
hasRightElement={hasRightElement}
>
{isDefined(selectedOption.Icon) ? (
<selectedOption.Icon

View File

@ -9,7 +9,7 @@ const StyledWorkflowStepBody = styled.div`
overflow-y: scroll;
padding-block: ${({ theme }) => theme.spacing(4)};
padding-inline: ${({ theme }) => theme.spacing(3)};
row-gap: ${({ theme }) => theme.spacing(6)};
row-gap: ${({ theme }) => theme.spacing(4)};
`;
export { StyledWorkflowStepBody as WorkflowStepBody };

View File

@ -20,13 +20,13 @@ import { useDebouncedCallback } from 'use-debounce';
import { isNonEmptyString } from '@sniptt/guards';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import {
IconChevronDown,
IconPlus,
IconTrash,
useIcons,
} from 'twenty-ui/display';
import { v4 } from 'uuid';
export type WorkflowEditActionFormBuilderProps = {
action: WorkflowFormAction;
@ -207,7 +207,7 @@ export const WorkflowEditActionFormBuilder = ({
? field.placeholder
: getDefaultFormFieldSettings(field.type).placeholder}
</StyledPlaceholder>
{!isFieldSelected(field.id) && (
{field.type === 'RECORD' && (
<IconChevronDown
size={theme.icon.size.md}
color={theme.font.color.tertiary}
@ -260,15 +260,15 @@ export const WorkflowEditActionFormBuilder = ({
<FormFieldInputInputContainer
hasRightElement={false}
onClick={() => {
const { label, placeholder, name } =
getDefaultFormFieldSettings(FieldMetadataType.TEXT);
const { label, name } = getDefaultFormFieldSettings(
FieldMetadataType.TEXT,
);
const newField: WorkflowFormActionField = {
id: v4(),
name,
type: FieldMetadataType.TEXT,
label,
placeholder,
};
setFormData([...formData, newField]);
@ -280,6 +280,8 @@ export const WorkflowEditActionFormBuilder = ({
input: [...action.settings.input, newField],
},
});
setSelectedField(newField.id);
}}
>
<StyledFieldContainer>

View File

@ -43,7 +43,7 @@ const StyledSettingsHeader = styled.div`
padding-right: ${({ theme }) => theme.spacing(2)};
padding-left: ${({ theme }) => theme.spacing(3)};
grid-template-columns: 1fr 24px;
padding-bottom: ${({ theme }) => theme.spacing(3)};
padding-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledTitleContainer = styled.div`
@ -110,7 +110,7 @@ export const WorkflowEditActionFormFieldSettings = ({
}
const type = newType as WorkflowFormFieldType;
const { name, label, placeholder, settings } =
const { name, label, settings } =
getDefaultFormFieldSettings(type);
onChange({
@ -118,12 +118,10 @@ export const WorkflowEditActionFormFieldSettings = ({
type,
name,
label,
placeholder,
settings,
});
}}
defaultValue={field.type}
preventDisplayPadding
/>
</FormFieldInputContainer>
<WorkflowFormFieldSettingsByType

View File

@ -5,8 +5,8 @@ import { InputLabel } from '@/ui/input/components/InputLabel';
import { Select } from '@/ui/input/components/Select';
import { getDefaultFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/utils/getDefaultFormFieldSettings';
import styled from '@emotion/styled';
import { SelectOption } from 'twenty-ui/input';
import { useIcons } from 'twenty-ui/display';
import { SelectOption } from 'twenty-ui/input';
type WorkflowFormFieldSettingsRecordPickerProps = {
label?: string;

View File

@ -2,11 +2,11 @@ import { WorkflowFormAction } from '@/workflow/types/Workflow';
import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
import { FieldMetadataType } from 'twenty-shared/types';
import { ComponentDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
import { WorkflowEditActionFormFieldSettings } from '../WorkflowEditActionFormFieldSettings';
import { ComponentDecorator } from 'twenty-ui/testing';
const meta: Meta<typeof WorkflowEditActionFormFieldSettings> = {
title: 'Modules/Workflow/Actions/Form/WorkflowEditActionFormFieldSettings',
@ -60,7 +60,7 @@ export const TextFieldSettings: Story = {
const placeholderInput = await canvas.findByText('Enter text');
expect(placeholderInput).toBeVisible();
const closeButton = await canvas.findByRole('button');
const closeButton = await canvas.findByTestId('close-button');
await userEvent.click(closeButton);
expect(args.onClose).toHaveBeenCalled();
},
@ -87,7 +87,7 @@ export const NumberFieldSettings: Story = {
const placeholderInput = await canvas.findByText('Enter number');
expect(placeholderInput).toBeInTheDocument();
const closeButton = await canvas.findByRole('button');
const closeButton = await canvas.findByTestId('close-button');
await userEvent.click(closeButton);
expect(args.onClose).toHaveBeenCalled();
},

View File

@ -31,7 +31,7 @@ export const getDefaultFormFieldSettings = (type: WorkflowFormFieldType) => {
id: v4(),
name: 'record',
label: 'Record',
placeholder: 'Select a record',
placeholder: `Select a record`,
settings: {
objectName: 'company',
},