Improve CSV import sub-field selection (#11601)
This PR adds a better UX for selecting sub-fields when importing CSV files. Before : <img width="395" alt="image" src="https://github.com/user-attachments/assets/5a599e7d-ed07-4531-8306-9d70e7cfa37d" /> After : <img width="298" alt="image" src="https://github.com/user-attachments/assets/2be8a1df-d089-4341-970e-6db2b269141e" /> <img width="296" alt="image" src="https://github.com/user-attachments/assets/584285f4-4e71-4abd-9adf-11819cab0dc5" /> - A util `getSubFieldOptionKey` has been made to be able to reference the sub field in the `options` object of the spreadsheet import. - New components have been created : `MatchColumnSelectFieldSelectDropdownContent`, `MatchColumnSelectSubFieldSelectDropdownContent` and `MatchColumnSelectV2` - Extracted the hard-coded option do not import into a constant `DO_NOT_IMPORT_OPTION_KEY` - Passed `availableFieldMetadataItems` to spreadsheet global options so it's available anywhere in the hierarchy of components. --------- Co-authored-by: Charles Bochet <charles@twenty.com> Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
This commit is contained in:
-138
@@ -1,138 +0,0 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ReadonlyDeep } from 'type-fest';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { AppTooltip } from 'twenty-ui/display';
|
||||
import { SelectOption } from 'twenty-ui/input';
|
||||
import { MenuItem, MenuItemSelect } from 'twenty-ui/navigation';
|
||||
import { v4 } from 'uuid';
|
||||
import { useUpdateEffect } from '~/hooks/useUpdateEffect';
|
||||
|
||||
interface MatchColumnSelectProps {
|
||||
columnIndex: string;
|
||||
onChange: (value: ReadonlyDeep<SelectOption> | null) => void;
|
||||
value?: ReadonlyDeep<SelectOption>;
|
||||
options: readonly ReadonlyDeep<SelectOption>[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const MatchColumnSelect = ({
|
||||
onChange,
|
||||
value,
|
||||
options: initialOptions,
|
||||
placeholder,
|
||||
columnIndex,
|
||||
}: MatchColumnSelectProps) => {
|
||||
const dropdownId = `match-column-select-dropdown-${columnIndex}`;
|
||||
|
||||
const { closeDropdown } = useDropdown(dropdownId);
|
||||
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
const [options, setOptions] = useState(initialOptions);
|
||||
|
||||
const handleSearchFilterChange = useCallback(
|
||||
(text: string) => {
|
||||
setOptions(
|
||||
initialOptions.filter((option) =>
|
||||
option.label.toLowerCase().includes(text.toLowerCase()),
|
||||
),
|
||||
);
|
||||
},
|
||||
[initialOptions],
|
||||
);
|
||||
|
||||
const debouncedHandleSearchFilter = useDebouncedCallback(
|
||||
handleSearchFilterChange,
|
||||
100,
|
||||
{
|
||||
leading: true,
|
||||
},
|
||||
);
|
||||
|
||||
const handleFilterChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.value;
|
||||
|
||||
setSearchFilter(value);
|
||||
debouncedHandleSearchFilter(value);
|
||||
};
|
||||
|
||||
const handleChange = (option: ReadonlyDeep<SelectOption>) => {
|
||||
onChange(option);
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
useUpdateEffect(() => {
|
||||
setOptions(initialOptions);
|
||||
}, [initialOptions]);
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
dropdownId={dropdownId}
|
||||
dropdownHotkeyScope={{
|
||||
scope: dropdownId,
|
||||
}}
|
||||
dropdownPlacement="bottom-start"
|
||||
clickableComponent={
|
||||
<MenuItem
|
||||
LeftIcon={value?.Icon}
|
||||
text={value?.label ?? placeholder ?? ''}
|
||||
accent={value?.label ? 'default' : 'placeholder'}
|
||||
/>
|
||||
}
|
||||
dropdownComponents={
|
||||
<>
|
||||
<DropdownMenuSearchInput
|
||||
value={searchFilter}
|
||||
onChange={handleFilterChange}
|
||||
autoFocus
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemsContainer hasMaxHeight>
|
||||
{options?.map((option) => {
|
||||
const id = `${v4()}-${option.value}`;
|
||||
return (
|
||||
<React.Fragment key={id}>
|
||||
<div id={id}>
|
||||
<MenuItemSelect
|
||||
selected={value?.label === option.label}
|
||||
onClick={() => handleChange(option)}
|
||||
disabled={
|
||||
option.disabled && value?.value !== option.value
|
||||
}
|
||||
LeftIcon={option?.Icon}
|
||||
text={option.label}
|
||||
/>
|
||||
</div>
|
||||
{option.disabled &&
|
||||
value?.value !== option.value &&
|
||||
createPortal(
|
||||
<AppTooltip
|
||||
key={id}
|
||||
anchorSelect={`#${id}`}
|
||||
content={t`You are already importing this column.`}
|
||||
place="right"
|
||||
offset={-20}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{options?.length === 0 && (
|
||||
<MenuItem key="No results" text={t`No results`} />
|
||||
)}
|
||||
</DropdownMenuItemsContainer>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { isCompositeField } from '@/object-record/object-filter-dropdown/utils/isCompositeField';
|
||||
import { DO_NOT_IMPORT_OPTION_KEY } from '@/spreadsheet-import/constants/DoNotImportOptionKey';
|
||||
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
||||
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
|
||||
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
import { IconForbid, IconX, useIcons } from 'twenty-ui/display';
|
||||
import { SelectOption } from 'twenty-ui/input';
|
||||
import { MenuItemSelect } from 'twenty-ui/navigation';
|
||||
import { ReadonlyDeep } from 'type-fest';
|
||||
|
||||
export const MatchColumnSelectFieldSelectDropdownContent = ({
|
||||
selectedValue,
|
||||
onSelectFieldMetadataItem,
|
||||
onCancelSelect,
|
||||
onDoNotImportSelect,
|
||||
options,
|
||||
}: {
|
||||
selectedValue: SelectOption | undefined;
|
||||
onSelectFieldMetadataItem: (
|
||||
selectedFieldMetadataItem: FieldMetadataItem,
|
||||
) => void;
|
||||
onCancelSelect: () => void;
|
||||
onDoNotImportSelect: () => void;
|
||||
options: readonly ReadonlyDeep<SelectOption>[];
|
||||
}) => {
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
|
||||
const handleFilterChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.value;
|
||||
|
||||
setSearchFilter(value);
|
||||
};
|
||||
|
||||
const { availableFieldMetadataItems } = useSpreadsheetImportInternal();
|
||||
|
||||
const filteredAvailableFieldMetadataItems =
|
||||
availableFieldMetadataItems.filter(
|
||||
(field) =>
|
||||
field.label.toLowerCase().includes(searchFilter.toLowerCase()) ||
|
||||
field.name.toLowerCase().includes(searchFilter.toLowerCase()),
|
||||
);
|
||||
|
||||
const { getIcon } = useIcons();
|
||||
|
||||
const handleFieldClick = (fieldMetadataItem: FieldMetadataItem) => {
|
||||
onSelectFieldMetadataItem(fieldMetadataItem);
|
||||
};
|
||||
|
||||
const handleCancelClick = () => {
|
||||
onCancelSelect();
|
||||
};
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuHeader
|
||||
StartComponent={
|
||||
<DropdownMenuHeaderLeftComponent
|
||||
onClick={handleCancelClick}
|
||||
Icon={IconX}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Select matching field
|
||||
</DropdownMenuHeader>
|
||||
<DropdownMenuSearchInput
|
||||
value={searchFilter}
|
||||
onChange={handleFilterChange}
|
||||
autoFocus
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemsContainer hasMaxHeight width={200}>
|
||||
<MenuItemSelect
|
||||
selected={selectedValue?.value === DO_NOT_IMPORT_OPTION_KEY}
|
||||
onClick={onDoNotImportSelect}
|
||||
LeftIcon={IconForbid}
|
||||
text={t`Do not import`}
|
||||
/>
|
||||
{filteredAvailableFieldMetadataItems.map((field) => (
|
||||
<MenuItemSelect
|
||||
key={field.id}
|
||||
selected={selectedValue?.value === field.name}
|
||||
onClick={() => handleFieldClick(field)}
|
||||
disabled={
|
||||
options.find((option) => option.value === field.name)?.disabled &&
|
||||
selectedValue?.value !== field.name
|
||||
}
|
||||
LeftIcon={getIcon(field.icon)}
|
||||
text={field.label}
|
||||
hasSubMenu={isCompositeField(field.type)}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuItemsContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { isCompositeField } from '@/object-record/object-filter-dropdown/utils/isCompositeField';
|
||||
import { getSubFieldOptionKey } from '@/object-record/spreadsheet-import/utils/getSubFieldOptionKey';
|
||||
import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs';
|
||||
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
|
||||
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import { useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import {
|
||||
IconChevronLeft,
|
||||
OverflowingTextWithTooltip,
|
||||
useIcons,
|
||||
} from 'twenty-ui/display';
|
||||
import { SelectOption } from 'twenty-ui/input';
|
||||
import { MenuItem } from 'twenty-ui/navigation';
|
||||
import { ReadonlyDeep } from 'type-fest';
|
||||
|
||||
export const MatchColumnSelectSubFieldSelectDropdownContent = ({
|
||||
fieldMetadataItem,
|
||||
onSubFieldSelect,
|
||||
options,
|
||||
onBack,
|
||||
}: {
|
||||
fieldMetadataItem: FieldMetadataItem;
|
||||
onSubFieldSelect: (subFieldNameSelected: string) => void;
|
||||
options: readonly ReadonlyDeep<SelectOption>[];
|
||||
onBack: () => void;
|
||||
}) => {
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
|
||||
const { getIcon } = useIcons();
|
||||
|
||||
const handleFilterChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.value;
|
||||
|
||||
setSearchFilter(value);
|
||||
};
|
||||
|
||||
const handleSubFieldSelect = (subFieldName: string) => {
|
||||
onSubFieldSelect(subFieldName);
|
||||
};
|
||||
|
||||
const handleSubMenuBack = () => {
|
||||
setSearchFilter('');
|
||||
onBack();
|
||||
};
|
||||
|
||||
if (!isCompositeField(fieldMetadataItem.type)) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const fieldMetadataItemSettings =
|
||||
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[fieldMetadataItem.type];
|
||||
|
||||
const subFieldNamesThatExistInOptions = fieldMetadataItemSettings.subFields
|
||||
.filter((subFieldName) => {
|
||||
const optionKey = getSubFieldOptionKey(fieldMetadataItem, subFieldName);
|
||||
|
||||
const correspondingOption = options.find(
|
||||
(option) => option.value === optionKey,
|
||||
);
|
||||
|
||||
return isDefined(correspondingOption);
|
||||
})
|
||||
.filter((subFieldName) => subFieldName.includes(searchFilter));
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuHeader
|
||||
StartComponent={
|
||||
<DropdownMenuHeaderLeftComponent
|
||||
onClick={handleSubMenuBack}
|
||||
Icon={IconChevronLeft}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<OverflowingTextWithTooltip text={fieldMetadataItem.label} />
|
||||
</DropdownMenuHeader>
|
||||
<DropdownMenuSearchInput
|
||||
value={searchFilter}
|
||||
onChange={handleFilterChange}
|
||||
autoFocus
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemsContainer hasMaxHeight width={200}>
|
||||
{subFieldNamesThatExistInOptions.map((subFieldName) => (
|
||||
<MenuItem
|
||||
key={subFieldName}
|
||||
onClick={() => handleSubFieldSelect(subFieldName)}
|
||||
LeftIcon={getIcon(fieldMetadataItem.icon)}
|
||||
text={
|
||||
(fieldMetadataItemSettings.labelBySubField as any)[subFieldName]
|
||||
}
|
||||
disabled={
|
||||
options.find(
|
||||
(option) =>
|
||||
option.value ===
|
||||
getSubFieldOptionKey(fieldMetadataItem, subFieldName),
|
||||
)?.disabled
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuItemsContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import { useState } from 'react';
|
||||
import { ReadonlyDeep } from 'type-fest';
|
||||
|
||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { isCompositeField } from '@/object-record/object-filter-dropdown/utils/isCompositeField';
|
||||
import { getSubFieldOptionKey } from '@/object-record/spreadsheet-import/utils/getSubFieldOptionKey';
|
||||
import { MatchColumnSelectFieldSelectDropdownContent } from '@/spreadsheet-import/components/MatchColumnSelectFieldSelectDropdownContent';
|
||||
import { MatchColumnSelectSubFieldSelectDropdownContent } from '@/spreadsheet-import/components/MatchColumnSelectSubFieldSelectDropdownContent';
|
||||
import { DO_NOT_IMPORT_OPTION_KEY } from '@/spreadsheet-import/constants/DoNotImportOptionKey';
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { SelectOption } from 'twenty-ui/input';
|
||||
import { MenuItem } from 'twenty-ui/navigation';
|
||||
|
||||
interface MatchColumnToFieldSelectProps {
|
||||
columnIndex: string;
|
||||
onChange: (value: ReadonlyDeep<SelectOption> | null) => void;
|
||||
value?: ReadonlyDeep<SelectOption>;
|
||||
options: readonly ReadonlyDeep<SelectOption>[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const MatchColumnToFieldSelect = ({
|
||||
onChange,
|
||||
value,
|
||||
options,
|
||||
placeholder,
|
||||
columnIndex,
|
||||
}: MatchColumnToFieldSelectProps) => {
|
||||
const dropdownId = `match-column-select-v2-dropdown-${columnIndex}`;
|
||||
|
||||
const { closeDropdown } = useDropdown(dropdownId);
|
||||
|
||||
const [selectedFieldMetadataItem, setSelectedFieldMetadataItem] =
|
||||
useState<FieldMetadataItem | null>(null);
|
||||
|
||||
const handleFieldMetadataItemSelect = (
|
||||
selectedFieldMetadataItem: FieldMetadataItem,
|
||||
) => {
|
||||
setSelectedFieldMetadataItem(selectedFieldMetadataItem);
|
||||
|
||||
if (!isCompositeField(selectedFieldMetadataItem.type)) {
|
||||
const correspondingOption = options.find(
|
||||
(option) => option.value === selectedFieldMetadataItem.name,
|
||||
);
|
||||
|
||||
if (isDefined(correspondingOption)) {
|
||||
setSelectedFieldMetadataItem(null);
|
||||
|
||||
onChange(correspondingOption);
|
||||
closeDropdown();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubFieldSelect = (subFieldNameSelected: string) => {
|
||||
if (!isDefined(selectedFieldMetadataItem)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const correspondingOption = options.find((option) => {
|
||||
const optionKey = getSubFieldOptionKey(
|
||||
selectedFieldMetadataItem,
|
||||
subFieldNameSelected,
|
||||
);
|
||||
|
||||
return option.value === optionKey;
|
||||
});
|
||||
|
||||
if (isDefined(correspondingOption)) {
|
||||
setSelectedFieldMetadataItem(null);
|
||||
|
||||
onChange(correspondingOption);
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoNotImportSelect = () => {
|
||||
if (isDefined(doNotImportOption)) {
|
||||
onChange(doNotImportOption);
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickOutside = () => {
|
||||
setSelectedFieldMetadataItem(null);
|
||||
};
|
||||
|
||||
const handleSubFieldBack = () => {
|
||||
setSelectedFieldMetadataItem(null);
|
||||
};
|
||||
|
||||
const handleCancelSelectClick = () => {
|
||||
setSelectedFieldMetadataItem(null);
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const doNotImportOption = options.find(
|
||||
(option) => option.value === DO_NOT_IMPORT_OPTION_KEY,
|
||||
);
|
||||
|
||||
const shouldDisplaySubFieldMetadataItemSelect = isDefined(
|
||||
selectedFieldMetadataItem?.type,
|
||||
)
|
||||
? isCompositeField(selectedFieldMetadataItem?.type)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
dropdownId={dropdownId}
|
||||
dropdownHotkeyScope={{
|
||||
scope: dropdownId,
|
||||
}}
|
||||
dropdownPlacement="bottom-start"
|
||||
clickableComponent={
|
||||
<MenuItem
|
||||
LeftIcon={value?.Icon}
|
||||
text={value?.label ?? placeholder ?? ''}
|
||||
accent={value?.label ? 'default' : 'placeholder'}
|
||||
/>
|
||||
}
|
||||
dropdownComponents={
|
||||
shouldDisplaySubFieldMetadataItemSelect && selectedFieldMetadataItem ? (
|
||||
<MatchColumnSelectSubFieldSelectDropdownContent
|
||||
fieldMetadataItem={selectedFieldMetadataItem}
|
||||
onSubFieldSelect={handleSubFieldSelect}
|
||||
options={options}
|
||||
onBack={handleSubFieldBack}
|
||||
/>
|
||||
) : (
|
||||
<MatchColumnSelectFieldSelectDropdownContent
|
||||
selectedValue={value}
|
||||
onSelectFieldMetadataItem={handleFieldMetadataItemSelect}
|
||||
onCancelSelect={handleCancelSelectClick}
|
||||
onDoNotImportSelect={handleDoNotImportSelect}
|
||||
options={options}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onClickOutside={handleClickOutside}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -5,8 +5,8 @@ import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpre
|
||||
import { useDialogManager } from '@/ui/feedback/dialog-manager/hooks/useDialogManager';
|
||||
import { useStepBar } from '@/ui/navigation/step-bar/hooks/useStepBar';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { IconButton } from 'twenty-ui/input';
|
||||
import { IconX } from 'twenty-ui/display';
|
||||
import { IconButton } from 'twenty-ui/input';
|
||||
|
||||
const StyledCloseButtonContainer = styled.div`
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user