From 1cc08b84c427d283ca8e60e5a80b4197a63c0576 Mon Sep 17 00:00:00 2001 From: Eniola Osabiya Date: Thu, 29 Jan 2026 02:49:38 -0600 Subject: [PATCH] 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: image image --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> --- .../SettingsDataModelFieldSelectForm.tsx | 331 ++++++++++++------ .../convertBulkTextToOptions.test.ts | 200 +++++++++++ .../convertOptionsToBulkText.test.ts | 102 ++++++ .../select/utils/convertBulkTextToOptions.ts | 46 +++ .../select/utils/convertOptionsToBulkText.ts | 7 + 5 files changed, 585 insertions(+), 101 deletions(-) create mode 100644 packages/twenty-front/src/modules/settings/data-model/fields/forms/select/utils/__tests__/convertBulkTextToOptions.test.ts create mode 100644 packages/twenty-front/src/modules/settings/data-model/fields/forms/select/utils/__tests__/convertOptionsToBulkText.test.ts create mode 100644 packages/twenty-front/src/modules/settings/data-model/fields/forms/select/utils/convertBulkTextToOptions.ts create mode 100644 packages/twenty-front/src/modules/settings/data-model/fields/forms/select/utils/convertOptionsToBulkText.ts diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectForm.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectForm.tsx index 64f8afeb67..df56e1f153 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectForm.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectForm.tsx @@ -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(); 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 } }) => ( <> - - - - - + + {!isBulkInputMode && ( + + + + + + {t`API values`} + + + )} + + {t`Options`} + + + {!disabled && ( + - - {t`API values`} - - - - {t`Options`} - - - - !disabled - ? handleDragEnd(options, result, onChange) - : undefined - } - draggableItems={ - <> - {options.map((option, index) => ( - { - if (disabled) { - return; + } + dropdownComponents={ + + + { + 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); }} /> - } - /> - ))} - - } - /> + { + onChange([]); + closeOptionsDropdown(OPTIONS_DROPDOWN_ID); + }} + /> + + + } + /> + )} + + + {isBulkInputMode ? ( + +