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:
Lucas Bordeau
2025-04-16 15:08:24 +02:00
committed by GitHub
parent b1c0613514
commit bf704bd1bc
17 changed files with 409 additions and 302 deletions

View File

@ -1,4 +1,5 @@
import {
FieldActorValue,
FieldAddressValue,
FieldCurrencyValue,
FieldEmailsValue,
@ -46,5 +47,5 @@ export const COMPOSITE_FIELD_IMPORT_LABELS = {
} satisfies Partial<CompositeFieldLabels<FieldRichTextV2Value>>,
[FieldMetadataType.ACTOR]: {
sourceLabel: 'Source',
},
} satisfies Partial<CompositeFieldLabels<FieldActorValue>>,
};

View File

@ -3,8 +3,8 @@ import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { COMPOSITE_FIELD_IMPORT_LABELS } from '@/object-record/spreadsheet-import/constants/CompositeFieldImportLabels';
import { AvailableFieldForImport } from '@/object-record/spreadsheet-import/types/AvailableFieldForImport';
import { getSpreadSheetFieldValidationDefinitions } from '@/object-record/spreadsheet-import/utils/getSpreadSheetFieldValidationDefinitions';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { useIcons } from 'twenty-ui/display';
import { FieldMetadataType } from '~/generated-metadata/graphql';
type CompositeFieldType = keyof typeof COMPOSITE_FIELD_IMPORT_LABELS;
@ -42,17 +42,17 @@ export const useBuildAvailableFieldsForImport = () => {
validationTypeResolver?: ValidationTypeResolver,
) => {
Object.entries(COMPOSITE_FIELD_IMPORT_LABELS[fieldType]).forEach(
([key, fieldLabel]) => {
const label = `${fieldLabel} (${fieldMetadataItem.label})`;
([key, subFieldLabel]) => {
const label = `${fieldMetadataItem.label} / ${subFieldLabel}`;
// Use the custom validation type if provided, otherwise use the field's type
const validationType = validationTypeResolver
? validationTypeResolver(key, fieldLabel)
? validationTypeResolver(key, subFieldLabel)
: fieldMetadataItem.type;
availableFieldsForImport.push(
createBaseField(fieldMetadataItem, {
label,
key: `${fieldLabel} (${fieldMetadataItem.name})`,
key: `${subFieldLabel} (${fieldMetadataItem.name})`,
fieldValidationDefinitions:
getSpreadSheetFieldValidationDefinitions(validationType, label),
}),
@ -137,6 +137,12 @@ export const useBuildAvailableFieldsForImport = () => {
currencyValidationResolver,
);
},
[FieldMetadataType.ACTOR]: (fieldMetadataItem) => {
handleCompositeFieldWithLabels(
fieldMetadataItem,
FieldMetadataType.ACTOR,
);
},
[FieldMetadataType.RELATION]: (fieldMetadataItem) => {
const label = `${fieldMetadataItem.label} (ID)`;
availableFieldsForImport.push(

View File

@ -74,6 +74,7 @@ export const useOpenObjectRecordsSpreadsheetImportDialog = (
}
},
fields: availableFields,
availableFieldMetadataItems,
});
};

View File

@ -0,0 +1,17 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { COMPOSITE_FIELD_IMPORT_LABELS } from '@/object-record/spreadsheet-import/constants/CompositeFieldImportLabels';
export const getSubFieldOptionKey = (
fieldMetadataItem: FieldMetadataItem,
subFieldName: string,
) => {
const subFieldNameLabelKey = `${subFieldName}Label`;
const subFieldLabel = (
(COMPOSITE_FIELD_IMPORT_LABELS as any)[fieldMetadataItem.type] as any
)[subFieldNameLabelKey];
const subFieldKey = `${subFieldLabel} (${fieldMetadataItem.name})`;
return subFieldKey;
};

View File

@ -156,6 +156,7 @@ export const mockRsiValues = mockComponentBehaviourForTypes({
await sleep(4000, (resolve) => resolve(data));
return data;
},
availableFieldMetadataItems: []
});
export const editableTableInitialData = [

View File

@ -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>
</>
}
/>
);
};

View File

@ -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>
</>
);
};

View File

@ -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>
</>
);
};

View File

@ -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}
/>
);
};

View File

@ -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;

View File

@ -0,0 +1 @@
export const DO_NOT_IMPORT_OPTION_KEY = 'do-not-import';

View File

@ -45,6 +45,7 @@ export const mockedSpreadsheetOptions: SpreadsheetImportDialogOptions<Spreadshee
parseRaw: true,
rtl: false,
selectHeader: true,
availableFieldMetadataItems: [],
};
describe('useSpreadsheetImport', () => {

View File

@ -17,6 +17,7 @@ import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { DO_NOT_IMPORT_OPTION_KEY } from '@/spreadsheet-import/constants/DoNotImportOptionKey';
import { UnmatchColumn } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/UnmatchColumn';
import { initialComputedColumnsSelector } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/states/initialComputedColumnsState';
import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep';
@ -116,7 +117,7 @@ export const MatchColumnsStep = <T extends string>({
const onChange = useCallback(
(value: T, columnIndex: number) => {
if (value === 'do-not-import') {
if (value === DO_NOT_IMPORT_OPTION_KEY) {
if (columns[columnIndex].type === SpreadsheetColumnType.ignored) {
onRevertIgnore(columnIndex);
} else {

View File

@ -1,130 +0,0 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import { getFieldOptions } from '@/spreadsheet-import/utils/getFieldOptions';
import { SelectFieldHotkeyScope } from '@/object-record/select/types/SelectFieldHotkeyScope';
import {
SpreadsheetMatchedSelectColumn,
SpreadsheetMatchedSelectOptionsColumn,
} from '@/spreadsheet-import/types/SpreadsheetColumn';
import { SpreadsheetMatchedOptions } from '@/spreadsheet-import/types/SpreadsheetMatchedOptions';
import { SelectInput } from '@/ui/input/components/SelectInput';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { useEffect, useState } from 'react';
import { Tag, TagColor } from 'twenty-ui/components';
import { IconChevronDown } from 'twenty-ui/display';
import { SelectOption } from 'twenty-ui/input';
const StyledContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(4)};
justify-content: space-between;
padding-bottom: ${({ theme }) => theme.spacing(1)};
`;
const StyledControlContainer = styled.div<{ cursor: string }>`
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
box-sizing: border-box;
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.primary};
cursor: ${({ cursor }) => cursor};
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ theme }) => theme.spacing(8)};
justify-content: space-between;
padding: 0 ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const StyledLabel = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.regular};
font-size: ${({ theme }) => theme.font.size.md};
`;
const StyledControlLabel = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledIconChevronDown = styled(IconChevronDown)`
color: ${({ theme }) => theme.font.color.tertiary};
`;
interface SubMatchingSelectProps<T> {
option: SpreadsheetMatchedOptions<T> | Partial<SpreadsheetMatchedOptions<T>>;
column:
| SpreadsheetMatchedSelectColumn<T>
| SpreadsheetMatchedSelectOptionsColumn<T>;
onSubChange: (val: T, index: number, option: string) => void;
placeholder: string;
selectedOption?:
| SpreadsheetMatchedOptions<T>
| Partial<SpreadsheetMatchedOptions<T>>;
}
export const SubMatchingSelect = <T extends string>({
option,
column,
onSubChange,
placeholder,
}: SubMatchingSelectProps<T>) => {
const { fields } = useSpreadsheetImportInternal<T>();
const options = getFieldOptions(fields, column.value) as SelectOption[];
const value = options.find((opt) => opt.value === option.value);
const [isOpen, setIsOpen] = useState(false);
const theme = useTheme();
const handleSelect = (selectedOption: SelectOption) => {
onSubChange(selectedOption.value as T, column.index, option.entry ?? '');
setIsOpen(false);
};
const setHotkeyScope = useSetHotkeyScope();
useEffect(() => {
setHotkeyScope(SelectFieldHotkeyScope.SelectField);
}, [setHotkeyScope]);
return (
<StyledContainer>
<StyledControlContainer cursor="default">
<StyledControlLabel>
<StyledLabel>{option.entry}</StyledLabel>
</StyledControlLabel>
<StyledIconChevronDown
size={theme.font.size.md}
color={theme.font.color.tertiary}
/>
</StyledControlContainer>
<StyledControlContainer
cursor="pointer"
onClick={() => setIsOpen(!isOpen)}
id="control"
>
<Tag
text={value?.label ?? placeholder}
color={value?.color as TagColor}
/>
<StyledIconChevronDown size={theme.icon.size.md} />
{isOpen && (
<SelectInput
defaultOption={value}
options={options}
onOptionSelected={handleSelect}
onCancel={() => setIsOpen(false)}
hotkeyScope={SelectFieldHotkeyScope.SelectField}
/>
)}
</StyledControlContainer>
</StyledContainer>
);
};

View File

@ -1,6 +1,7 @@
import styled from '@emotion/styled';
import { MatchColumnSelect } from '@/spreadsheet-import/components/MatchColumnSelect';
import { MatchColumnToFieldSelect } from '@/spreadsheet-import/components/MatchColumnToFieldSelect';
import { DO_NOT_IMPORT_OPTION_KEY } from '@/spreadsheet-import/constants/DoNotImportOptionKey';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import { SpreadsheetColumnType } from '@/spreadsheet-import/types/SpreadsheetColumnType';
import { SpreadsheetColumns } from '@/spreadsheet-import/types/SpreadsheetColumns';
@ -54,7 +55,7 @@ export const TemplateColumn = <T extends string>({
const selectOptions = [
{
Icon: IconForbid,
value: 'do-not-import',
value: DO_NOT_IMPORT_OPTION_KEY,
label: t`Do not import`,
},
...fieldOptions,
@ -65,12 +66,12 @@ export const TemplateColumn = <T extends string>({
);
const ignoreValue = selectOptions.find(
({ value }) => value === 'do-not-import',
({ value }) => value === DO_NOT_IMPORT_OPTION_KEY,
);
return (
<StyledContainer>
<MatchColumnSelect
<MatchColumnToFieldSelect
placeholder={t`Select column...`}
value={isIgnored ? ignoreValue : selectValue}
onChange={(value) => onChange(value?.value as T, column.index)}

View File

@ -9,11 +9,10 @@ import {
} from '@/spreadsheet-import/types';
import { TextInput } from '@/ui/input/components/TextInput';
import { MatchColumnSelect } from '@/spreadsheet-import/components/MatchColumnSelect';
import { isDefined } from 'twenty-shared/utils';
import { ImportedStructuredRowMetadata } from '../types';
import { AppTooltip } from 'twenty-ui/display';
import { Checkbox, CheckboxVariant, Toggle } from 'twenty-ui/input';
import { ImportedStructuredRowMetadata } from '../types';
const StyledHeaderContainer = styled.div`
align-items: center;
@ -61,6 +60,10 @@ const StyledDefaultContainer = styled.div`
text-overflow: ellipsis;
`;
const StyledSelectReadonlyValueContianer = styled.div`
padding-left: ${({ theme }) => theme.spacing(2)};
`;
const SELECT_COLUMN_KEY = 'select-row';
export const generateColumns = <T extends string>(
@ -130,26 +133,10 @@ export const generateColumns = <T extends string>(
switch (column.fieldType.type) {
case 'select': {
const value = column.fieldType.options.find(
(option) => option.value === (row[columnKey] as string),
);
component = (
<MatchColumnSelect
value={
value
? ({
Icon: undefined,
...value,
} as const)
: value
}
onChange={(value) => {
onRowChange({ ...row, [columnKey]: value?.value }, true);
}}
options={column.fieldType.options}
columnIndex={column.key}
/>
<StyledSelectReadonlyValueContianer>
{row[columnKey]}
</StyledSelectReadonlyValueContianer>
);
break;
}

View File

@ -1,8 +1,9 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { SpreadsheetColumns } from '@/spreadsheet-import/types/SpreadsheetColumns';
import { SpreadsheetImportFields } from '@/spreadsheet-import/types/SpreadsheetImportFields';
import { SpreadsheetImportImportValidationResult } from '@/spreadsheet-import/types/SpreadsheetImportImportValidationResult';
import { ImportedRow } from '@/spreadsheet-import/types/SpreadsheetImportImportedRow';
import { ImportedStructuredRow } from '@/spreadsheet-import/types/SpreadsheetImportImportedStructuredRow';
import { SpreadsheetImportImportValidationResult } from '@/spreadsheet-import/types/SpreadsheetImportImportValidationResult';
import { SpreadsheetImportRowHook } from '@/spreadsheet-import/types/SpreadsheetImportRowHook';
import { SpreadsheetImportTableHook } from '@/spreadsheet-import/types/SpreadsheetImportTableHook';
import { SpreadsheetImportStep } from '../steps/types/SpreadsheetImportStep';
@ -58,4 +59,5 @@ export type SpreadsheetImportDialogOptions<FieldNames extends string> = {
rtl?: boolean;
// Allow header selection
selectHeader?: boolean;
availableFieldMetadataItems: FieldMetadataItem[];
};