- 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:
+230
-101
@@ -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`}
|
||||
|
||||
+200
@@ -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);
|
||||
});
|
||||
});
|
||||
+102
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
+46
@@ -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;
|
||||
};
|
||||
+7
@@ -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');
|
||||
};
|
||||
Reference in New Issue
Block a user