Skip to main content

Data Model Select Option Bulk Parser

Select option list와 textarea bulk input을 서로 변환하면서 기존 option metadata를 보존하는 패턴이다.

핵심은 Parse labels, preserve matching option identity다.

1. 모듈 코드

  • src/modules/settings/data-model/fields/forms/select/utils/convertBulkTextToOptions.ts: 줄 단위 label을 option 배열로 바꾸고 같은 label의 기존 option은 id/color/value를 재사용한다.
  • src/modules/settings/data-model/fields/forms/select/utils/convertOptionsToBulkText.ts: option 배열을 textarea용 줄 단위 텍스트로 바꾼다.
  • src/modules/settings/data-model/fields/forms/select/utils/generateNewSelectOption.ts: 새 option의 id, color, value, position을 생성한다.
  • src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectForm.tsx: dropdown으로 single/bulk mode를 전환하고 textarea 변경을 form state에 반영한다.
// filepath: src/modules/settings/data-model/fields/forms/select/utils/convertBulkTextToOptions.ts
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(
(option) => option.label.toLowerCase() === label.toLowerCase(),
);

if (isDefined(existingOption)) {
// reuse existing option meta (including original label), just update position
newBulkSelectOptions.push({
...existingOption,
position: optionIndex,
});
} else {
newBulkSelectOptions.push({
...generateNewSelectOption(newBulkSelectOptions, label),
position: optionIndex,
});
}
}

return newBulkSelectOptions;
};

// filepath: src/modules/settings/data-model/fields/forms/select/utils/convertOptionsToBulkText.ts
import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';

export const convertOptionsToBulkText = (
options: FieldMetadataItemOption[],
): string => {
return options.map((option) => option.label).join('\n');
};

// filepath: src/modules/settings/data-model/fields/forms/select/utils/generateNewSelectOption.ts
import { v4 } from 'uuid';

import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
import { generateNewSelectOptionLabel } from '@/settings/data-model/fields/forms/select/utils/generateNewSelectOptionLabel';
import { MAIN_COLOR_NAMES } from 'twenty-ui/theme';
import { getNextThemeColor } from 'twenty-ui/theme-constants';
import { computeOptionValueFromLabel } from '~/pages/settings/data-model/utils/computeOptionValueFromLabel';

export const generateNewSelectOption = (
options: FieldMetadataItemOption[],
label?: string,
): FieldMetadataItemOption => {
const newOptionLabel = label ?? generateNewSelectOptionLabel(options);

return {
color: getNextThemeColor(
MAIN_COLOR_NAMES,
options[options.length - 1]?.color,
),
id: v4(),
label: newOptionLabel,
position: options.length,
value: computeOptionValueFromLabel(newOptionLabel),
};
};

2. 사용 예제

// filepath: src/modules/settings/data-model/fields/forms/select/components/SettingsDataModelFieldSelectForm.tsx
import { convertBulkTextToOptions } from '@/settings/data-model/fields/forms/select/utils/convertBulkTextToOptions';
import { convertOptionsToBulkText } from '@/settings/data-model/fields/forms/select/utils/convertOptionsToBulkText';
import { TextArea } from '@/ui/input/components/TextArea';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useState } from 'react';
import { Controller, useFormContext } from 'react-hook-form';

export const SettingsDataModelFieldSelectForm = ({ disabled = false }) => {
const { control } = useFormContext<SettingsDataModelFieldSelectFormValues>();
const [isBulkInputMode, setIsBulkInputMode] = useState(false);
const [bulkInputText, setBulkInputText] = useState('');

const OPTIONS_DROPDOWN_ID =
'settings-data-model-field-select-options-dropdown';
const { closeDropdown: closeOptionsDropdown } = useCloseDropdown();

return (
<Controller
name="options"
control={control}
render={({ field: { onChange, value: options } }) => (
<>
<Dropdown
dropdownId={OPTIONS_DROPDOWN_ID}
dropdownComponents={
<MenuItem
text={isBulkInputMode ? 'Single edit' : 'Bulk edit'}
onClick={() => {
if (!isBulkInputMode) {
setBulkInputText(convertOptionsToBulkText(options));
}
setIsBulkInputMode((currentInputMode) => !currentInputMode);
closeOptionsDropdown(OPTIONS_DROPDOWN_ID);
}}
/>
}
/>

{isBulkInputMode && (
<TextArea
textAreaId="bulk-options-input"
placeholder="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}
/>
)}
</>
)}
/>
);
};