feat: Add bulk input mode for select options #5539 (#17459)

- Implemented bulk input mode in SettingsDataModelFieldSelectForm to
allow users to input multiple options at once.
- Added convertBulkTextToOptions and convertOptionsToBulkText utility
functions for handling bulk text conversion.
- Created tests for both conversion functions to ensure correct
functionality.

Screenshot:
<img width="601" height="752" alt="image"
src="https://github.com/user-attachments/assets/95932860-090e-4743-9bd9-7aa6747ad04f"
/>
<img width="543" height="362" alt="image"
src="https://github.com/user-attachments/assets/84490808-2e0f-4126-951e-167b35f1b0d0"
/>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
This commit is contained in:
Eniola Osabiya
2026-01-29 02:49:38 -06:00
committed by GitHub
parent 9ee6917c86
commit 1cc08b84c4
5 changed files with 585 additions and 101 deletions
@@ -8,6 +8,8 @@ import { selectOptionsSchema } from '@/object-metadata/validation-schemas/select
import { multiSelectFieldDefaultValueSchema } from '@/object-record/record-field/ui/validation-schemas/multiSelectFieldDefaultValueSchema';
import { selectFieldDefaultValueSchema } from '@/object-record/record-field/ui/validation-schemas/selectFieldDefaultValueSchema';
import { useSelectSettingsFormInitialValues } from '@/settings/data-model/fields/forms/select/hooks/useSelectSettingsFormInitialValues';
import { convertBulkTextToOptions } from '@/settings/data-model/fields/forms/select/utils/convertBulkTextToOptions';
import { convertOptionsToBulkText } from '@/settings/data-model/fields/forms/select/utils/convertOptionsToBulkText';
import { generateNewSelectOption } from '@/settings/data-model/fields/forms/select/utils/generateNewSelectOption';
import { isSelectOptionDefaultValue } from '@/settings/data-model/utils/isSelectOptionDefaultValue';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
@@ -19,6 +21,12 @@ import { applySimpleQuotesToString } from '~/utils/string/applySimpleQuotesToStr
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { AdvancedSettingsWrapper } from '@/settings/components/AdvancedSettingsWrapper';
import { TextArea } from '@/ui/input/components/TextArea';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState';
import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro';
@@ -26,9 +34,16 @@ import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { IconPlus, IconPoint } from 'twenty-ui/display';
import { LightButton } from 'twenty-ui/input';
import {
IconDotsVertical,
IconPencil,
IconPlus,
IconPoint,
IconTrash,
} from 'twenty-ui/display';
import { LightButton, LightIconButton } from 'twenty-ui/input';
import { CardContent, CardFooter } from 'twenty-ui/layout';
import { MenuItem } from 'twenty-ui/navigation';
import { SettingsDataModelFieldSelectFormOptionRow } from './SettingsDataModelFieldSelectFormOptionRow';
export const settingsDataModelFieldSelectFormSchema = z.object({
@@ -58,6 +73,7 @@ const StyledContainer = styled(CardContent)`
const StyledOptionsLabel = styled.div<{
isAdvancedModeEnabled: boolean;
isBulkInputMode: boolean;
}>`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.xs};
@@ -65,8 +81,9 @@ const StyledOptionsLabel = styled.div<{
margin-bottom: ${({ theme }) => theme.spacing(1.5)};
margin-top: ${({ theme }) => theme.spacing(1)};
width: 100%;
margin-left: ${({ theme, isAdvancedModeEnabled }) =>
theme.spacing(isAdvancedModeEnabled ? 10 : 0)};
margin-left: ${({ theme, isAdvancedModeEnabled, isBulkInputMode }) =>
theme.spacing(isAdvancedModeEnabled && !isBulkInputMode ? 10 : 0)};
};
`;
const StyledApiKeyContainer = styled.div`
@@ -82,14 +99,18 @@ const StyledApiKey = styled.span`
margin-bottom: ${({ theme }) => theme.spacing(1.5)};
margin-top: ${({ theme }) => theme.spacing(1)};
width: 100%;
white-space: nowrap;
`;
const StyledLabelContainer = styled.div`
align-items: center;
display: flex;
width: 100%;
`;
const StyledIconContainer = styled.div`
align-items: center;
border-right: 1px solid ${({ theme }) => theme.color.yellow};
display: flex;
@@ -111,6 +132,26 @@ const StyledButton = styled(LightButton)`
width: 100%;
`;
const StyledOptionsHeaderContainer = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: ${({ theme }) => theme.spacing(1.5)};
margin-top: ${({ theme }) => theme.spacing(1)};
width: 100%;
`;
const StyledTextAreaContainer = styled.div`
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledHelpText = styled.div`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.xs};
margin-top: ${({ theme }) => theme.spacing(1)};
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsDataModelFieldSelectForm = ({
existingFieldMetadataId,
fieldType,
@@ -137,6 +178,12 @@ export const SettingsDataModelFieldSelectForm = ({
} = useFormContext<SettingsDataModelFieldSelectFormValues>();
const [hasAppliedNewOption, setHasAppliedNewOption] = useState(false);
const [isBulkInputMode, setIsBulkInputMode] = useState(false);
const [bulkInputText, setBulkInputText] = useState('');
const OPTIONS_DROPDOWN_ID =
'settings-data-model-field-select-options-dropdown';
const { closeDropdown: closeOptionsDropdown } = useCloseDropdown();
useEffect(() => {
const newOptionValue = searchParams.get('newOption');
@@ -270,109 +317,191 @@ export const SettingsDataModelFieldSelectForm = ({
render={({ field: { onChange, value: options } }) => (
<>
<StyledContainer>
<StyledLabelContainer>
<AdvancedSettingsWrapper animationDimension="width" hideDot>
<StyledApiKeyContainer>
<StyledIconContainer>
<StyledIconPoint
size={12}
color={theme.color.yellow}
fill={theme.color.yellow}
<StyledOptionsHeaderContainer>
<StyledLabelContainer>
{!isBulkInputMode && (
<AdvancedSettingsWrapper animationDimension="width" hideDot>
<StyledApiKeyContainer>
<StyledIconContainer>
<StyledIconPoint
size={12}
color={theme.color.yellow}
fill={theme.color.yellow}
/>
</StyledIconContainer>
<StyledApiKey>{t`API values`}</StyledApiKey>
</StyledApiKeyContainer>
</AdvancedSettingsWrapper>
)}
<StyledOptionsLabel
isAdvancedModeEnabled={isAdvancedModeEnabled}
isBulkInputMode={isBulkInputMode}
>
{t`Options`}
</StyledOptionsLabel>
</StyledLabelContainer>
{!disabled && (
<Dropdown
dropdownId={OPTIONS_DROPDOWN_ID}
clickableComponent={
<LightIconButton
Icon={IconDotsVertical}
accent="tertiary"
/>
</StyledIconContainer>
<StyledApiKey>{t`API values`}</StyledApiKey>
</StyledApiKeyContainer>
</AdvancedSettingsWrapper>
<StyledOptionsLabel
isAdvancedModeEnabled={isAdvancedModeEnabled}
>
{t`Options`}
</StyledOptionsLabel>
</StyledLabelContainer>
<DraggableList
onDragEnd={(result) =>
!disabled
? handleDragEnd(options, result, onChange)
: undefined
}
draggableItems={
<>
{options.map((option, index) => (
<DraggableItem
isInsideScrollableContainer
key={option.id}
draggableId={option.id}
index={index}
isDragDisabled={options.length === 1}
itemComponent={
<SettingsDataModelFieldSelectFormOptionRow
key={option.id}
option={option}
isNewRow={index === options.length - 1}
onChange={(nextOption) => {
if (disabled) {
return;
}
dropdownComponents={
<DropdownContent
widthInPixels={GenericDropdownContentWidth.Narrow}
>
<DropdownMenuItemsContainer>
<MenuItem
text={
isBulkInputMode ? t`Single edit` : t`Bulk edit`
}
LeftIcon={IconPencil}
onClick={() => {
if (!isBulkInputMode) {
setBulkInputText(
convertOptionsToBulkText(options),
);
}
const nextOptions = toSpliced(
options,
index,
1,
nextOption,
setIsBulkInputMode(
(currentInputMode) => !currentInputMode,
);
onChange(nextOptions);
// Update option value in defaultValue if value has changed
if (
nextOption.value !== option.value &&
isOptionDefaultValue(option.value)
) {
handleRemoveOptionAsDefault(option.value);
handleSetOptionAsDefault(nextOption.value);
}
}}
onRemove={() => {
if (disabled) {
return;
}
const nextOptions = toSpliced(
options,
index,
1,
).map((option, nextOptionIndex) => ({
...option,
position: nextOptionIndex,
}));
onChange(nextOptions);
}}
isDefault={isOptionDefaultValue(option.value)}
fieldIsNullable={!!isNullable}
onSetAsDefault={() => {
if (disabled) {
return;
}
handleSetOptionAsDefault(option.value);
}}
onRemoveAsDefault={() => {
if (disabled) {
return;
}
handleRemoveOptionAsDefault(option.value);
}}
onInputEnter={() => {
if (disabled) {
return;
}
handleInputEnter();
closeOptionsDropdown(OPTIONS_DROPDOWN_ID);
}}
/>
}
/>
))}
</>
}
/>
<MenuItem
text={t`Remove all`}
accent="danger"
LeftIcon={IconTrash}
onClick={() => {
onChange([]);
closeOptionsDropdown(OPTIONS_DROPDOWN_ID);
}}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
)}
</StyledOptionsHeaderContainer>
{isBulkInputMode ? (
<StyledTextAreaContainer>
<TextArea
textAreaId="bulk-options-input"
placeholder={t`Enter one option per line`}
value={bulkInputText}
onChange={(nextOptionAsText) => {
if (disabled) {
return;
}
const nextOptions = convertBulkTextToOptions(
nextOptionAsText,
options,
);
onChange(nextOptions);
setBulkInputText(nextOptionAsText);
}}
minRows={5}
maxRows={15}
disabled={disabled}
/>
<StyledHelpText>
{t`Enter one option per line. Each line will become a new option.`}
</StyledHelpText>
</StyledTextAreaContainer>
) : (
<>
<DraggableList
onDragEnd={(result) =>
!disabled
? handleDragEnd(options, result, onChange)
: undefined
}
draggableItems={
<>
{options.map((option, index) => (
<DraggableItem
isInsideScrollableContainer
key={option.id}
draggableId={option.id}
index={index}
isDragDisabled={options.length === 1}
itemComponent={
<SettingsDataModelFieldSelectFormOptionRow
key={option.id}
option={option}
isNewRow={index === options.length - 1}
onChange={(nextOption) => {
if (disabled) {
return;
}
const nextOptions = toSpliced(
options,
index,
1,
nextOption,
);
onChange(nextOptions);
// Update option value in defaultValue if value has changed
if (
nextOption.value !== option.value &&
isOptionDefaultValue(option.value)
) {
handleRemoveOptionAsDefault(option.value);
handleSetOptionAsDefault(nextOption.value);
}
}}
onRemove={() => {
if (disabled) {
return;
}
const nextOptions = toSpliced(
options,
index,
1,
).map((option, nextOptionIndex) => ({
...option,
position: nextOptionIndex,
}));
onChange(nextOptions);
}}
isDefault={isOptionDefaultValue(option.value)}
fieldIsNullable={!!isNullable}
onSetAsDefault={() => {
if (disabled) {
return;
}
handleSetOptionAsDefault(option.value);
}}
onRemoveAsDefault={() => {
if (disabled) {
return;
}
handleRemoveOptionAsDefault(option.value);
}}
onInputEnter={() => {
if (disabled) {
return;
}
handleInputEnter();
}}
/>
}
/>
))}
</>
}
/>
</>
)}
</StyledContainer>
{!disabled && (
{!disabled && !isBulkInputMode && (
<StyledFooter>
<StyledButton
title={t`Add option`}
@@ -0,0 +1,200 @@
import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
import { convertBulkTextToOptions } from '@/settings/data-model/fields/forms/select/utils/convertBulkTextToOptions';
describe('convertBulkTextToOptions', () => {
it('converts newline-separated text to options', () => {
// Given
const text = 'Option 1\nOption 2\nOption 3';
const currentOptions: FieldMetadataItemOption[] = [];
// When
const result = convertBulkTextToOptions(text, currentOptions);
// Then
expect(result).toHaveLength(3);
expect(result[0].label).toBe('Option 1');
expect(result[0].position).toBe(0);
expect(result[1].label).toBe('Option 2');
expect(result[1].position).toBe(1);
expect(result[2].label).toBe('Option 3');
expect(result[2].position).toBe(2);
});
it('returns empty array for empty text', () => {
// Given
const text = '';
const currentOptions: FieldMetadataItemOption[] = [];
// When
const result = convertBulkTextToOptions(text, currentOptions);
// Then
expect(result).toHaveLength(0);
});
it('returns empty array for whitespace-only text', () => {
// Given
const text = ' \n \n ';
const currentOptions: FieldMetadataItemOption[] = [];
// When
const result = convertBulkTextToOptions(text, currentOptions);
// Then
expect(result).toHaveLength(0);
});
it('trims whitespace from lines', () => {
// Given
const text = ' Option 1 \n Option 2 ';
const currentOptions: FieldMetadataItemOption[] = [];
// When
const result = convertBulkTextToOptions(text, currentOptions);
// Then
expect(result).toHaveLength(2);
expect(result[0].label).toBe('Option 1');
expect(result[1].label).toBe('Option 2');
});
it('filters out empty lines', () => {
// Given
const text = 'Option 1\n\n\nOption 2\n\nOption 3';
const currentOptions: FieldMetadataItemOption[] = [];
// When
const result = convertBulkTextToOptions(text, currentOptions);
// Then
expect(result).toHaveLength(3);
expect(result[0].label).toBe('Option 1');
expect(result[1].label).toBe('Option 2');
expect(result[2].label).toBe('Option 3');
});
it('preserves existing option metadata when label matches (case-insensitive)', () => {
// Given
const text = 'option 1\nOption 2';
const currentOptions: FieldMetadataItemOption[] = [
{
id: 'existing-id-1',
label: 'Option 1',
value: 'OPTION_1',
color: 'blue',
position: 0,
},
{
id: 'existing-id-2',
label: 'Option 2',
value: 'OPTION_2',
color: 'green',
position: 1,
},
];
// When
const result = convertBulkTextToOptions(text, currentOptions);
// Then
expect(result).toHaveLength(2);
expect(result[0].id).toBe('existing-id-1');
expect(result[0].color).toBe('blue');
expect(result[0].label).toBe('Option 1'); // preserves original label casing
expect(result[0].position).toBe(0);
expect(result[1].id).toBe('existing-id-2');
expect(result[1].color).toBe('green');
expect(result[1].label).toBe('Option 2'); // preserves original label casing
expect(result[1].position).toBe(1);
});
it('creates new options for labels that do not exist', () => {
// Given
const text = 'Existing Option\nNew Option';
const currentOptions: FieldMetadataItemOption[] = [
{
id: 'existing-id',
label: 'Existing Option',
value: 'EXISTING_OPTION',
color: 'blue',
position: 0,
},
];
// When
const result = convertBulkTextToOptions(text, currentOptions);
// Then
expect(result).toHaveLength(2);
expect(result[0].id).toBe('existing-id');
expect(result[1].label).toBe('New Option');
expect(result[1].id).toBeDefined();
expect(result[1].id).not.toBe('existing-id');
});
it('updates positions correctly when reordering', () => {
// Given
const text = 'Option 2\nOption 1';
const currentOptions: FieldMetadataItemOption[] = [
{
id: 'id-1',
label: 'Option 1',
value: 'OPTION_1',
color: 'blue',
position: 0,
},
{
id: 'id-2',
label: 'Option 2',
value: 'OPTION_2',
color: 'green',
position: 1,
},
];
// When
const result = convertBulkTextToOptions(text, currentOptions);
// Then
expect(result).toHaveLength(2);
expect(result[0].id).toBe('id-2');
expect(result[0].position).toBe(0);
expect(result[1].id).toBe('id-1');
expect(result[1].position).toBe(1);
});
it('handles mixed existing and new options', () => {
// Given
const text = 'Existing\nNew 1\nAnother Existing\nNew 2';
const currentOptions: FieldMetadataItemOption[] = [
{
id: 'existing-1',
label: 'Existing',
value: 'EXISTING',
color: 'blue',
position: 0,
},
{
id: 'existing-2',
label: 'Another Existing',
value: 'ANOTHER_EXISTING',
color: 'red',
position: 1,
},
];
// When
const result = convertBulkTextToOptions(text, currentOptions);
// Then
expect(result).toHaveLength(4);
expect(result[0].id).toBe('existing-1');
expect(result[0].position).toBe(0);
expect(result[1].label).toBe('New 1');
expect(result[1].position).toBe(1);
expect(result[2].id).toBe('existing-2');
expect(result[2].position).toBe(2);
expect(result[3].label).toBe('New 2');
expect(result[3].position).toBe(3);
});
});
@@ -0,0 +1,102 @@
import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
import { convertOptionsToBulkText } from '@/settings/data-model/fields/forms/select/utils/convertOptionsToBulkText';
describe('convertOptionsToBulkText', () => {
it('converts options to newline-separated text', () => {
// Given
const options: FieldMetadataItemOption[] = [
{
id: '1',
label: 'Option 1',
value: 'OPTION_1',
color: 'blue',
position: 0,
},
{
id: '2',
label: 'Option 2',
value: 'OPTION_2',
color: 'green',
position: 1,
},
{
id: '3',
label: 'Option 3',
value: 'OPTION_3',
color: 'red',
position: 2,
},
];
// When
const result = convertOptionsToBulkText(options);
// Then
expect(result).toBe('Option 1\nOption 2\nOption 3');
});
it('returns empty string for empty options array', () => {
// Given
const options: FieldMetadataItemOption[] = [];
// When
const result = convertOptionsToBulkText(options);
// Then
expect(result).toBe('');
});
it('handles single option', () => {
// Given
const options: FieldMetadataItemOption[] = [
{
id: '1',
label: 'Single Option',
value: 'SINGLE_OPTION',
color: 'blue',
position: 0,
},
];
// When
const result = convertOptionsToBulkText(options);
// Then
expect(result).toBe('Single Option');
});
it('preserves special characters in labels', () => {
// Given
const options: FieldMetadataItemOption[] = [
{
id: '1',
label: 'Option with spaces',
value: 'OPTION_WITH_SPACES',
color: 'blue',
position: 0,
},
{
id: '2',
label: 'Option-with-dashes',
value: 'OPTION_WITH_DASHES',
color: 'green',
position: 1,
},
{
id: '3',
label: 'Option_with_underscores',
value: 'OPTION_WITH_UNDERSCORES',
color: 'red',
position: 2,
},
];
// When
const result = convertOptionsToBulkText(options);
// Then
expect(result).toBe(
'Option with spaces\nOption-with-dashes\nOption_with_underscores',
);
});
});
@@ -0,0 +1,46 @@
import { isDefined } from 'twenty-shared/utils';
import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
import { generateNewSelectOption } from '@/settings/data-model/fields/forms/select/utils/generateNewSelectOption';
export const convertBulkTextToOptions = (
text: string,
currentOptions: FieldMetadataItemOption[],
): FieldMetadataItemOption[] => {
const parsedBulkTextOptions = text
.trim()
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
const newBulkSelectOptions: FieldMetadataItemOption[] = [];
for (
let optionIndex = 0;
optionIndex < parsedBulkTextOptions.length;
optionIndex++
) {
const label = parsedBulkTextOptions[optionIndex];
// try to find an existing option with the same label, so we can keep its id, color, value, and label
const existingOption = currentOptions.find(
(opt) => opt.label.toLowerCase() === label.toLowerCase(),
);
if (isDefined(existingOption)) {
// reuse existing option meta (including original label), just update position
newBulkSelectOptions.push({
...existingOption,
position: optionIndex,
});
} else {
// create new option if not found
newBulkSelectOptions.push({
...generateNewSelectOption(newBulkSelectOptions, label),
position: optionIndex,
});
}
}
return newBulkSelectOptions;
};
@@ -0,0 +1,7 @@
import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
export const convertOptionsToBulkText = (
options: FieldMetadataItemOption[],
): string => {
return options.map((option) => option.label).join('\n');
};