update import auto matching (#12552)
<img width="800" alt="Screenshot 2025-06-11 at 17 45 13" src="https://github.com/user-attachments/assets/ecc04d41-d74a-424a-9f83-14a793cf4268" /> closes https://github.com/twentyhq/core-team-issues/issues/905
This commit is contained in:
@ -0,0 +1,19 @@
|
|||||||
|
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
|
||||||
|
import { isNonCompositeField } from '@/object-record/object-filter-dropdown/utils/isNonCompositeField';
|
||||||
|
import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs';
|
||||||
|
import { SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsNonCompositeFieldTypeConfigs';
|
||||||
|
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||||
|
|
||||||
|
export const getFieldMetadataTypeLabel = (fieldType: FieldMetadataType) => {
|
||||||
|
//TODO: Remove ?.label > .label when we have a proper type for field (issue #1097)
|
||||||
|
if (
|
||||||
|
isNonCompositeField(fieldType) ||
|
||||||
|
fieldType === FieldMetadataType.RELATION
|
||||||
|
)
|
||||||
|
return SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS[
|
||||||
|
fieldType as keyof typeof SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS
|
||||||
|
]?.label;
|
||||||
|
|
||||||
|
if (isCompositeFieldType(fieldType))
|
||||||
|
return SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[fieldType]?.label;
|
||||||
|
};
|
||||||
@ -1,8 +0,0 @@
|
|||||||
import { FilterableFieldType } from '@/object-record/record-filter/types/FilterableFieldType';
|
|
||||||
import { SETTINGS_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsFieldTypeConfigs';
|
|
||||||
|
|
||||||
export const getFilterableFieldTypeLabel = (
|
|
||||||
filterableFieldType: FilterableFieldType,
|
|
||||||
) => {
|
|
||||||
return SETTINGS_FIELD_TYPE_CONFIGS[filterableFieldType].label;
|
|
||||||
};
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import { SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsNonCompositeFieldTypeConfigs';
|
|
||||||
import { SettingsNonCompositeFieldType } from '@/settings/data-model/types/SettingsNonCompositeFieldType';
|
|
||||||
|
|
||||||
export const getSettingsNonCompositeFieldTypeLabel = (
|
|
||||||
settingsNonCompositeFieldType: SettingsNonCompositeFieldType,
|
|
||||||
) => {
|
|
||||||
return SETTINGS_NON_COMPOSITE_FIELD_TYPE_CONFIGS[
|
|
||||||
settingsNonCompositeFieldType
|
|
||||||
].label;
|
|
||||||
};
|
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||||
|
import { getFieldMetadataTypeLabel } from '@/object-record/object-filter-dropdown/utils/getFieldMetadataTypeLabel';
|
||||||
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
|
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
|
||||||
import { DO_NOT_IMPORT_OPTION_KEY } from '@/spreadsheet-import/constants/DoNotImportOptionKey';
|
import { DO_NOT_IMPORT_OPTION_KEY } from '@/spreadsheet-import/constants/DoNotImportOptionKey';
|
||||||
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
||||||
@ -7,28 +8,44 @@ import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenu
|
|||||||
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
|
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
|
||||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||||
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
|
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
|
||||||
|
import { DropdownMenuSectionLabel } from '@/ui/layout/dropdown/components/DropdownMenuSectionLabel';
|
||||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||||
|
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { isNonEmptyString } from '@sniptt/guards';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { IconForbid, IconX, useIcons } from 'twenty-ui/display';
|
import { IconForbid, IconX, useIcons } from 'twenty-ui/display';
|
||||||
import { SelectOption } from 'twenty-ui/input';
|
import { SelectOption } from 'twenty-ui/input';
|
||||||
import { MenuItemSelect } from 'twenty-ui/navigation';
|
import { MenuItemSelect } from 'twenty-ui/navigation';
|
||||||
import { ReadonlyDeep } from 'type-fest';
|
import { ReadonlyDeep } from 'type-fest';
|
||||||
|
|
||||||
|
const StyledContainer = styled.div`
|
||||||
|
max-height: 360px;
|
||||||
|
`;
|
||||||
|
|
||||||
export const MatchColumnSelectFieldSelectDropdownContent = ({
|
export const MatchColumnSelectFieldSelectDropdownContent = ({
|
||||||
selectedValue,
|
selectedValue,
|
||||||
onSelectFieldMetadataItem,
|
onSelectFieldMetadataItem,
|
||||||
|
onSelectSuggestedOption,
|
||||||
onCancelSelect,
|
onCancelSelect,
|
||||||
onDoNotImportSelect,
|
onDoNotImportSelect,
|
||||||
options,
|
options,
|
||||||
|
suggestedOptions,
|
||||||
}: {
|
}: {
|
||||||
selectedValue: SelectOption | undefined;
|
selectedValue: SelectOption | undefined;
|
||||||
onSelectFieldMetadataItem: (
|
onSelectFieldMetadataItem: (
|
||||||
selectedFieldMetadataItem: FieldMetadataItem,
|
selectedFieldMetadataItem: FieldMetadataItem,
|
||||||
) => void;
|
) => void;
|
||||||
|
onSelectSuggestedOption: (selectedSuggestedOption: SelectOption) => void;
|
||||||
onCancelSelect: () => void;
|
onCancelSelect: () => void;
|
||||||
onDoNotImportSelect: () => void;
|
onDoNotImportSelect: () => void;
|
||||||
options: readonly ReadonlyDeep<SelectOption>[];
|
options: readonly ReadonlyDeep<
|
||||||
|
SelectOption & { fieldMetadataTypeLabel?: string }
|
||||||
|
>[];
|
||||||
|
suggestedOptions: readonly ReadonlyDeep<
|
||||||
|
SelectOption & { fieldMetadataTypeLabel?: string }
|
||||||
|
>[];
|
||||||
}) => {
|
}) => {
|
||||||
const [searchFilter, setSearchFilter] = useState('');
|
const [searchFilter, setSearchFilter] = useState('');
|
||||||
|
|
||||||
@ -53,6 +70,10 @@ export const MatchColumnSelectFieldSelectDropdownContent = ({
|
|||||||
onSelectFieldMetadataItem(fieldMetadataItem);
|
onSelectFieldMetadataItem(fieldMetadataItem);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSuggestedOptionClick = (suggestedOption: SelectOption) => {
|
||||||
|
onSelectSuggestedOption(suggestedOption);
|
||||||
|
};
|
||||||
|
|
||||||
const handleCancelClick = () => {
|
const handleCancelClick = () => {
|
||||||
onCancelSelect();
|
onCancelSelect();
|
||||||
};
|
};
|
||||||
@ -60,7 +81,7 @@ export const MatchColumnSelectFieldSelectDropdownContent = ({
|
|||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownContent>
|
<DropdownContent widthInPixels={320}>
|
||||||
<DropdownMenuHeader
|
<DropdownMenuHeader
|
||||||
StartComponent={
|
StartComponent={
|
||||||
<DropdownMenuHeaderLeftComponent
|
<DropdownMenuHeaderLeftComponent
|
||||||
@ -75,30 +96,63 @@ export const MatchColumnSelectFieldSelectDropdownContent = ({
|
|||||||
value={searchFilter}
|
value={searchFilter}
|
||||||
onChange={handleFilterChange}
|
onChange={handleFilterChange}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
placeholder={t`Search fields`}
|
||||||
/>
|
/>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItemsContainer hasMaxHeight>
|
<StyledContainer>
|
||||||
<MenuItemSelect
|
<ScrollWrapper componentInstanceId="match-column-select-field-select-dropdown-content">
|
||||||
selected={selectedValue?.value === DO_NOT_IMPORT_OPTION_KEY}
|
{!isNonEmptyString(searchFilter) && (
|
||||||
onClick={onDoNotImportSelect}
|
<>
|
||||||
LeftIcon={IconForbid}
|
<DropdownMenuItemsContainer scrollable={false}>
|
||||||
text={t`Do not import`}
|
<MenuItemSelect
|
||||||
/>
|
selected={selectedValue?.value === DO_NOT_IMPORT_OPTION_KEY}
|
||||||
{filteredAvailableFieldMetadataItems.map((field) => (
|
onClick={onDoNotImportSelect}
|
||||||
<MenuItemSelect
|
LeftIcon={IconForbid}
|
||||||
key={field.id}
|
text={t`Do not import`}
|
||||||
selected={selectedValue?.value === field.name}
|
/>
|
||||||
onClick={() => handleFieldClick(field)}
|
</DropdownMenuItemsContainer>
|
||||||
disabled={
|
{suggestedOptions.length > 0 && (
|
||||||
options.find((option) => option.value === field.name)?.disabled &&
|
<>
|
||||||
selectedValue?.value !== field.name
|
<DropdownMenuSeparator />
|
||||||
}
|
<DropdownMenuSectionLabel label={t`Suggested`} />
|
||||||
LeftIcon={getIcon(field.icon)}
|
<DropdownMenuItemsContainer scrollable={false}>
|
||||||
text={field.label}
|
{suggestedOptions.map((option) => (
|
||||||
hasSubMenu={isCompositeFieldType(field.type)}
|
<MenuItemSelect
|
||||||
/>
|
key={option.value}
|
||||||
))}
|
selected={selectedValue?.value === option.value}
|
||||||
</DropdownMenuItemsContainer>
|
onClick={() => handleSuggestedOptionClick(option)}
|
||||||
|
disabled={option.disabled}
|
||||||
|
LeftIcon={option.Icon}
|
||||||
|
text={option.label}
|
||||||
|
contextualText={option.fieldMetadataTypeLabel}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</DropdownMenuItemsContainer>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuSectionLabel label={t`All fields`} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItemsContainer scrollable={false}>
|
||||||
|
{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}
|
||||||
|
contextualText={getFieldMetadataTypeLabel(field.type)}
|
||||||
|
hasSubMenu={isCompositeFieldType(field.type)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</DropdownMenuItemsContainer>
|
||||||
|
</ScrollWrapper>
|
||||||
|
</StyledContainer>
|
||||||
</DropdownContent>
|
</DropdownContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||||
|
import { getCompositeSubFieldLabel } from '@/object-record/object-filter-dropdown/utils/getCompositeSubFieldLabel';
|
||||||
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
|
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
|
||||||
import { getSubFieldOptionKey } from '@/object-record/spreadsheet-import/utils/getSubFieldOptionKey';
|
import { getSubFieldOptionKey } from '@/object-record/spreadsheet-import/utils/getSubFieldOptionKey';
|
||||||
import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs';
|
import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs';
|
||||||
|
import { CompositeFieldType } from '@/settings/data-model/types/CompositeFieldType';
|
||||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||||
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
|
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
|
||||||
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
|
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
|
||||||
@ -66,10 +68,17 @@ export const MatchColumnSelectSubFieldSelectDropdownContent = ({
|
|||||||
|
|
||||||
return isDefined(correspondingOption);
|
return isDefined(correspondingOption);
|
||||||
})
|
})
|
||||||
.filter((subFieldName) => subFieldName.includes(searchFilter));
|
.filter((subFieldName) =>
|
||||||
|
getCompositeSubFieldLabel(
|
||||||
|
fieldMetadataItem.type as CompositeFieldType,
|
||||||
|
subFieldName,
|
||||||
|
)
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(searchFilter.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownContent>
|
<DropdownContent widthInPixels={320}>
|
||||||
<DropdownMenuHeader
|
<DropdownMenuHeader
|
||||||
StartComponent={
|
StartComponent={
|
||||||
<DropdownMenuHeaderLeftComponent
|
<DropdownMenuHeaderLeftComponent
|
||||||
@ -92,9 +101,10 @@ export const MatchColumnSelectSubFieldSelectDropdownContent = ({
|
|||||||
key={subFieldName}
|
key={subFieldName}
|
||||||
onClick={() => handleSubFieldSelect(subFieldName)}
|
onClick={() => handleSubFieldSelect(subFieldName)}
|
||||||
LeftIcon={getIcon(fieldMetadataItem.icon)}
|
LeftIcon={getIcon(fieldMetadataItem.icon)}
|
||||||
text={
|
text={getCompositeSubFieldLabel(
|
||||||
(fieldMetadataItemSettings.labelBySubField as any)[subFieldName]
|
fieldMetadataItem.type as CompositeFieldType,
|
||||||
}
|
subFieldName,
|
||||||
|
)}
|
||||||
disabled={
|
disabled={
|
||||||
options.find(
|
options.find(
|
||||||
(option) =>
|
(option) =>
|
||||||
|
|||||||
@ -20,6 +20,7 @@ interface MatchColumnToFieldSelectProps {
|
|||||||
onChange: (value: ReadonlyDeep<SelectOption> | null) => void;
|
onChange: (value: ReadonlyDeep<SelectOption> | null) => void;
|
||||||
value?: ReadonlyDeep<SelectOption>;
|
value?: ReadonlyDeep<SelectOption>;
|
||||||
options: readonly ReadonlyDeep<SelectOption>[];
|
options: readonly ReadonlyDeep<SelectOption>[];
|
||||||
|
suggestedOptions: readonly ReadonlyDeep<SelectOption>[];
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,6 +33,7 @@ export const MatchColumnToFieldSelect = ({
|
|||||||
onChange,
|
onChange,
|
||||||
value,
|
value,
|
||||||
options,
|
options,
|
||||||
|
suggestedOptions,
|
||||||
placeholder,
|
placeholder,
|
||||||
columnIndex,
|
columnIndex,
|
||||||
}: MatchColumnToFieldSelectProps) => {
|
}: MatchColumnToFieldSelectProps) => {
|
||||||
@ -83,6 +85,13 @@ export const MatchColumnToFieldSelect = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSelectSuggestedOption = (
|
||||||
|
selectedSuggestedOption: SelectOption,
|
||||||
|
) => {
|
||||||
|
onChange(selectedSuggestedOption);
|
||||||
|
closeDropdown();
|
||||||
|
};
|
||||||
|
|
||||||
const handleDoNotImportSelect = () => {
|
const handleDoNotImportSelect = () => {
|
||||||
if (isDefined(doNotImportOption)) {
|
if (isDefined(doNotImportOption)) {
|
||||||
onChange(doNotImportOption);
|
onChange(doNotImportOption);
|
||||||
@ -138,9 +147,11 @@ export const MatchColumnToFieldSelect = ({
|
|||||||
<MatchColumnSelectFieldSelectDropdownContent
|
<MatchColumnSelectFieldSelectDropdownContent
|
||||||
selectedValue={value}
|
selectedValue={value}
|
||||||
onSelectFieldMetadataItem={handleFieldMetadataItemSelect}
|
onSelectFieldMetadataItem={handleFieldMetadataItemSelect}
|
||||||
|
onSelectSuggestedOption={handleSelectSuggestedOption}
|
||||||
onCancelSelect={handleCancelSelectClick}
|
onCancelSelect={handleCancelSelectClick}
|
||||||
onDoNotImportSelect={handleDoNotImportSelect}
|
onDoNotImportSelect={handleDoNotImportSelect}
|
||||||
options={options}
|
options={options}
|
||||||
|
suggestedOptions={suggestedOptions}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,42 @@
|
|||||||
|
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
||||||
|
import {
|
||||||
|
initialComputedColumnsSelector,
|
||||||
|
matchColumnsState,
|
||||||
|
} from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/states/initialComputedColumnsState';
|
||||||
|
import { suggestedFieldsByColumnHeaderState } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/states/suggestedFieldsByColumnHeaderState';
|
||||||
|
import { ImportedRow } from '@/spreadsheet-import/types';
|
||||||
|
import { getMatchedColumnsWithFuse } from '@/spreadsheet-import/utils/getMatchedColumnsWithFuse';
|
||||||
|
import { useRecoilCallback } from 'recoil';
|
||||||
|
|
||||||
|
export const useComputeColumnSuggestionsAndAutoMatch = <T extends string>() => {
|
||||||
|
const { fields, autoMapHeaders } = useSpreadsheetImportInternal<T>();
|
||||||
|
|
||||||
|
const computeColumnSuggestionsAndAutoMatch = useRecoilCallback(
|
||||||
|
({ set, snapshot }) =>
|
||||||
|
async ({
|
||||||
|
headerValues,
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
headerValues: ImportedRow;
|
||||||
|
data: ImportedRow[];
|
||||||
|
}) => {
|
||||||
|
if (autoMapHeaders) {
|
||||||
|
const columns = snapshot
|
||||||
|
.getLoadable(initialComputedColumnsSelector(headerValues))
|
||||||
|
.getValue();
|
||||||
|
|
||||||
|
const { matchedColumns, suggestedFieldsByColumnHeader } =
|
||||||
|
getMatchedColumnsWithFuse({ columns, fields, data });
|
||||||
|
|
||||||
|
set(matchColumnsState, matchedColumns);
|
||||||
|
set(
|
||||||
|
suggestedFieldsByColumnHeaderState,
|
||||||
|
suggestedFieldsByColumnHeader,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[autoMapHeaders, fields],
|
||||||
|
);
|
||||||
|
|
||||||
|
return computeColumnSuggestionsAndAutoMatch;
|
||||||
|
};
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { StepNavigationButton } from '@/spreadsheet-import/components/StepNavigationButton';
|
import { StepNavigationButton } from '@/spreadsheet-import/components/StepNavigationButton';
|
||||||
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
||||||
@ -27,7 +27,6 @@ import { SpreadsheetColumn } from '@/spreadsheet-import/types/SpreadsheetColumn'
|
|||||||
import { SpreadsheetColumnType } from '@/spreadsheet-import/types/SpreadsheetColumnType';
|
import { SpreadsheetColumnType } from '@/spreadsheet-import/types/SpreadsheetColumnType';
|
||||||
import { SpreadsheetColumns } from '@/spreadsheet-import/types/SpreadsheetColumns';
|
import { SpreadsheetColumns } from '@/spreadsheet-import/types/SpreadsheetColumns';
|
||||||
import { SpreadsheetImportField } from '@/spreadsheet-import/types/SpreadsheetImportField';
|
import { SpreadsheetImportField } from '@/spreadsheet-import/types/SpreadsheetImportField';
|
||||||
import { getMatchedColumnsWithFuse } from '@/spreadsheet-import/utils/getMatchedColumnsWithFuse';
|
|
||||||
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||||
import { Trans, useLingui } from '@lingui/react/macro';
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
import { useRecoilState } from 'recoil';
|
import { useRecoilState } from 'recoil';
|
||||||
@ -80,7 +79,7 @@ export const MatchColumnsStep = <T extends string>({
|
|||||||
const { enqueueDialog } = useDialogManager();
|
const { enqueueDialog } = useDialogManager();
|
||||||
const { enqueueSnackBar } = useSnackBar();
|
const { enqueueSnackBar } = useSnackBar();
|
||||||
const dataExample = data.slice(0, 2);
|
const dataExample = data.slice(0, 2);
|
||||||
const { fields, autoMapHeaders } = useSpreadsheetImportInternal<T>();
|
const { fields } = useSpreadsheetImportInternal<T>();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [columns, setColumns] = useRecoilState(
|
const [columns, setColumns] = useRecoilState(
|
||||||
initialComputedColumnsSelector(headerValues),
|
initialComputedColumnsSelector(headerValues),
|
||||||
@ -256,22 +255,6 @@ export const MatchColumnsStep = <T extends string>({
|
|||||||
t,
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const isInitialColumnsState = columns.every(
|
|
||||||
(column) => column.type === SpreadsheetColumnType.empty,
|
|
||||||
);
|
|
||||||
if (autoMapHeaders && isInitialColumnsState) {
|
|
||||||
const { matchedColumns } = getMatchedColumnsWithFuse(
|
|
||||||
columns,
|
|
||||||
fields,
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
|
|
||||||
setColumns(matchedColumns);
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const hasMatchedColumns = columns.some(
|
const hasMatchedColumns = columns.some(
|
||||||
(column) =>
|
(column) =>
|
||||||
![SpreadsheetColumnType.ignored, SpreadsheetColumnType.empty].includes(
|
![SpreadsheetColumnType.ignored, SpreadsheetColumnType.empty].includes(
|
||||||
|
|||||||
@ -3,10 +3,12 @@ import styled from '@emotion/styled';
|
|||||||
import { MatchColumnToFieldSelect } from '@/spreadsheet-import/components/MatchColumnToFieldSelect';
|
import { MatchColumnToFieldSelect } from '@/spreadsheet-import/components/MatchColumnToFieldSelect';
|
||||||
import { DO_NOT_IMPORT_OPTION_KEY } from '@/spreadsheet-import/constants/DoNotImportOptionKey';
|
import { DO_NOT_IMPORT_OPTION_KEY } from '@/spreadsheet-import/constants/DoNotImportOptionKey';
|
||||||
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
||||||
|
import { suggestedFieldsByColumnHeaderState } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/states/suggestedFieldsByColumnHeaderState';
|
||||||
import { SpreadsheetColumnType } from '@/spreadsheet-import/types/SpreadsheetColumnType';
|
import { SpreadsheetColumnType } from '@/spreadsheet-import/types/SpreadsheetColumnType';
|
||||||
import { SpreadsheetColumns } from '@/spreadsheet-import/types/SpreadsheetColumns';
|
import { SpreadsheetColumns } from '@/spreadsheet-import/types/SpreadsheetColumns';
|
||||||
|
import { spreadsheetBuildFieldOptions } from '@/spreadsheet-import/utils/spreadsheetBuildFieldOptions';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { FieldMetadataType } from 'twenty-shared/types';
|
import { useRecoilValue } from 'recoil';
|
||||||
import { IconForbid } from 'twenty-ui/display';
|
import { IconForbid } from 'twenty-ui/display';
|
||||||
|
|
||||||
const StyledContainer = styled.div`
|
const StyledContainer = styled.div`
|
||||||
@ -28,29 +30,20 @@ export const TemplateColumn = <T extends string>({
|
|||||||
onChange,
|
onChange,
|
||||||
}: TemplateColumnProps<T>) => {
|
}: TemplateColumnProps<T>) => {
|
||||||
const { fields } = useSpreadsheetImportInternal<T>();
|
const { fields } = useSpreadsheetImportInternal<T>();
|
||||||
|
const suggestedFieldsByColumnHeader = useRecoilValue(
|
||||||
|
suggestedFieldsByColumnHeaderState,
|
||||||
|
);
|
||||||
|
|
||||||
const column = columns[columnIndex];
|
const column = columns[columnIndex];
|
||||||
const isIgnored = column.type === SpreadsheetColumnType.ignored;
|
const isIgnored = column.type === SpreadsheetColumnType.ignored;
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
const fieldOptions = fields
|
const fieldOptions = spreadsheetBuildFieldOptions(fields, columns);
|
||||||
.filter((field) => field.fieldMetadataType !== FieldMetadataType.RICH_TEXT)
|
const suggestedFieldOptions = spreadsheetBuildFieldOptions(
|
||||||
.map(({ Icon, label, key }) => {
|
suggestedFieldsByColumnHeader[column.header] ?? [],
|
||||||
const isSelected =
|
columns,
|
||||||
columns.findIndex((column) => {
|
);
|
||||||
if ('value' in column) {
|
|
||||||
return column.value === key;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}) !== -1;
|
|
||||||
|
|
||||||
return {
|
|
||||||
Icon: Icon,
|
|
||||||
value: key,
|
|
||||||
label: label,
|
|
||||||
disabled: isSelected,
|
|
||||||
} as const;
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectOptions = [
|
const selectOptions = [
|
||||||
{
|
{
|
||||||
@ -76,6 +69,7 @@ export const TemplateColumn = <T extends string>({
|
|||||||
value={isIgnored ? ignoreValue : selectValue}
|
value={isIgnored ? ignoreValue : selectValue}
|
||||||
onChange={(value) => onChange(value?.value as T, column.index)}
|
onChange={(value) => onChange(value?.value as T, column.index)}
|
||||||
options={selectOptions}
|
options={selectOptions}
|
||||||
|
suggestedOptions={suggestedFieldOptions}
|
||||||
columnIndex={column.index.toString()}
|
columnIndex={column.index.toString()}
|
||||||
/>
|
/>
|
||||||
</StyledContainer>
|
</StyledContainer>
|
||||||
|
|||||||
@ -0,0 +1,7 @@
|
|||||||
|
import { SpreadsheetImportField } from '@/spreadsheet-import/types';
|
||||||
|
import { createState } from 'twenty-ui/utilities';
|
||||||
|
|
||||||
|
export const suggestedFieldsByColumnHeaderState = createState({
|
||||||
|
key: 'suggestedFieldsByColumnHeaderState',
|
||||||
|
defaultValue: {} as Record<string, SpreadsheetImportField<string>[]>,
|
||||||
|
});
|
||||||
@ -7,6 +7,7 @@ import { ImportedRow } from '@/spreadsheet-import/types';
|
|||||||
|
|
||||||
import { Modal } from '@/ui/layout/modal/components/Modal';
|
import { Modal } from '@/ui/layout/modal/components/Modal';
|
||||||
|
|
||||||
|
import { useComputeColumnSuggestionsAndAutoMatch } from '@/spreadsheet-import/hooks/useComputeColumnSuggestionsAndAutoMatch';
|
||||||
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
||||||
import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep';
|
import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep';
|
||||||
import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType';
|
import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType';
|
||||||
@ -50,11 +51,20 @@ export const SelectHeaderStep = ({
|
|||||||
|
|
||||||
const { selectHeaderStepHook } = useSpreadsheetImportInternal();
|
const { selectHeaderStepHook } = useSpreadsheetImportInternal();
|
||||||
|
|
||||||
|
const computeColumnSuggestionsAndAutoMatch =
|
||||||
|
useComputeColumnSuggestionsAndAutoMatch();
|
||||||
|
|
||||||
const handleContinue = useCallback(
|
const handleContinue = useCallback(
|
||||||
async (...args: Parameters<typeof selectHeaderStepHook>) => {
|
async (...args: Parameters<typeof selectHeaderStepHook>) => {
|
||||||
try {
|
try {
|
||||||
const { importedRows: data, headerRow: headerValues } =
|
const { importedRows: data, headerRow: headerValues } =
|
||||||
await selectHeaderStepHook(...args);
|
await selectHeaderStepHook(...args);
|
||||||
|
|
||||||
|
await computeColumnSuggestionsAndAutoMatch({
|
||||||
|
headerValues,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
setCurrentStepState({
|
setCurrentStepState({
|
||||||
type: SpreadsheetImportStepType.matchColumns,
|
type: SpreadsheetImportStepType.matchColumns,
|
||||||
data,
|
data,
|
||||||
@ -73,6 +83,7 @@ export const SelectHeaderStep = ({
|
|||||||
setPreviousStepState,
|
setPreviousStepState,
|
||||||
setCurrentStepState,
|
setCurrentStepState,
|
||||||
currentStepState,
|
currentStepState,
|
||||||
|
computeColumnSuggestionsAndAutoMatch,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { WorkBook } from 'xlsx-ugnis';
|
|||||||
|
|
||||||
import { Modal } from '@/ui/layout/modal/components/Modal';
|
import { Modal } from '@/ui/layout/modal/components/Modal';
|
||||||
|
|
||||||
|
import { useComputeColumnSuggestionsAndAutoMatch } from '@/spreadsheet-import/hooks/useComputeColumnSuggestionsAndAutoMatch';
|
||||||
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
||||||
import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep';
|
import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep';
|
||||||
import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType';
|
import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType';
|
||||||
@ -36,6 +37,9 @@ export const UploadStep = ({
|
|||||||
const { maxRecords, uploadStepHook, selectHeaderStepHook, selectHeader } =
|
const { maxRecords, uploadStepHook, selectHeaderStepHook, selectHeader } =
|
||||||
useSpreadsheetImportInternal();
|
useSpreadsheetImportInternal();
|
||||||
|
|
||||||
|
const computeColumnSuggestionsAndAutoMatch =
|
||||||
|
useComputeColumnSuggestionsAndAutoMatch();
|
||||||
|
|
||||||
const handleContinue = useCallback(
|
const handleContinue = useCallback(
|
||||||
async (workbook: WorkBook, file: File) => {
|
async (workbook: WorkBook, file: File) => {
|
||||||
setUploadedFile(file);
|
setUploadedFile(file);
|
||||||
@ -63,6 +67,11 @@ export const UploadStep = ({
|
|||||||
const { importedRows: data, headerRow: headerValues } =
|
const { importedRows: data, headerRow: headerValues } =
|
||||||
await selectHeaderStepHook(mappedWorkbook[0], trimmedData);
|
await selectHeaderStepHook(mappedWorkbook[0], trimmedData);
|
||||||
|
|
||||||
|
await computeColumnSuggestionsAndAutoMatch({
|
||||||
|
headerValues,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
setCurrentStepState({
|
setCurrentStepState({
|
||||||
type: SpreadsheetImportStepType.matchColumns,
|
type: SpreadsheetImportStepType.matchColumns,
|
||||||
data,
|
data,
|
||||||
@ -92,6 +101,7 @@ export const UploadStep = ({
|
|||||||
setUploadedFile,
|
setUploadedFile,
|
||||||
currentStepState,
|
currentStepState,
|
||||||
uploadStepHook,
|
uploadStepHook,
|
||||||
|
computeColumnSuggestionsAndAutoMatch,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -1,32 +1,68 @@
|
|||||||
import { MatchColumnsStepProps } from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep';
|
import { MatchColumnsStepProps } from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep';
|
||||||
|
|
||||||
import { SpreadsheetImportFields } from '@/spreadsheet-import/types';
|
import {
|
||||||
|
SpreadsheetImportField,
|
||||||
|
SpreadsheetImportFields,
|
||||||
|
} from '@/spreadsheet-import/types';
|
||||||
import { SpreadsheetColumn } from '@/spreadsheet-import/types/SpreadsheetColumn';
|
import { SpreadsheetColumn } from '@/spreadsheet-import/types/SpreadsheetColumn';
|
||||||
import { SpreadsheetColumns } from '@/spreadsheet-import/types/SpreadsheetColumns';
|
import { SpreadsheetColumns } from '@/spreadsheet-import/types/SpreadsheetColumns';
|
||||||
|
import { SpreadsheetColumnType } from '@/spreadsheet-import/types/SpreadsheetColumnType';
|
||||||
import { setColumn } from '@/spreadsheet-import/utils/setColumn';
|
import { setColumn } from '@/spreadsheet-import/utils/setColumn';
|
||||||
import Fuse from 'fuse.js';
|
import Fuse from 'fuse.js';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
|
||||||
export const getMatchedColumnsWithFuse = <T extends string>(
|
export const getMatchedColumnsWithFuse = <T extends string>({
|
||||||
columns: SpreadsheetColumns<T>,
|
columns,
|
||||||
fields: SpreadsheetImportFields<T>,
|
fields,
|
||||||
data: MatchColumnsStepProps['data'],
|
data,
|
||||||
) => {
|
}: {
|
||||||
|
columns: SpreadsheetColumns<T>;
|
||||||
|
fields: SpreadsheetImportFields<T>;
|
||||||
|
data: MatchColumnsStepProps['data'];
|
||||||
|
}) => {
|
||||||
const matchedColumns: SpreadsheetColumn<T>[] = [];
|
const matchedColumns: SpreadsheetColumn<T>[] = [];
|
||||||
|
|
||||||
const fieldsToSearch = new Fuse(fields, {
|
const fieldsToSearch = new Fuse(fields, {
|
||||||
keys: ['label'],
|
keys: ['label'],
|
||||||
includeScore: true,
|
includeScore: true,
|
||||||
|
ignoreLocation: true,
|
||||||
threshold: 0.3,
|
threshold: 0.3,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const suggestedFieldsByColumnHeader: Record<
|
||||||
|
SpreadsheetColumn<T>['header'],
|
||||||
|
SpreadsheetImportField<T>[]
|
||||||
|
> = {};
|
||||||
|
|
||||||
for (const column of columns) {
|
for (const column of columns) {
|
||||||
const fieldsThatMatch = fieldsToSearch.search(column.header);
|
const fieldsThatMatch = fieldsToSearch.search(column.header);
|
||||||
|
|
||||||
const firstMatch = fieldsThatMatch[0]?.item ?? null;
|
const firstMatch = fieldsThatMatch[0] || null;
|
||||||
|
const secondMatch = fieldsThatMatch[1] || null;
|
||||||
|
|
||||||
if (isDefined(firstMatch)) {
|
const isFirstMatchValid =
|
||||||
const newColumn = setColumn(column, firstMatch as any, data);
|
isDefined(firstMatch?.item) &&
|
||||||
|
isDefined(firstMatch?.score) &&
|
||||||
|
firstMatch.score < 0.4 &&
|
||||||
|
((isDefined(secondMatch?.score) &&
|
||||||
|
secondMatch.score !== firstMatch.score) ||
|
||||||
|
!isDefined(secondMatch));
|
||||||
|
|
||||||
|
const isFieldStillUnmatched = !matchedColumns.some(
|
||||||
|
(matchedColumn) =>
|
||||||
|
(matchedColumn.type === SpreadsheetColumnType.matched ||
|
||||||
|
matchedColumn.type === SpreadsheetColumnType.matchedCheckbox ||
|
||||||
|
matchedColumn.type === SpreadsheetColumnType.matchedSelect ||
|
||||||
|
matchedColumn.type === SpreadsheetColumnType.matchedSelectOptions) &&
|
||||||
|
matchedColumn?.value === firstMatch?.item?.key,
|
||||||
|
);
|
||||||
|
|
||||||
|
suggestedFieldsByColumnHeader[column.header] = fieldsThatMatch.map(
|
||||||
|
(match) => match.item as SpreadsheetImportField<T>,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isFirstMatchValid && isFieldStillUnmatched) {
|
||||||
|
const newColumn = setColumn(column, firstMatch.item as any, data);
|
||||||
|
|
||||||
matchedColumns.push(newColumn);
|
matchedColumns.push(newColumn);
|
||||||
} else {
|
} else {
|
||||||
@ -34,5 +70,5 @@ export const getMatchedColumnsWithFuse = <T extends string>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { matchedColumns };
|
return { matchedColumns, suggestedFieldsByColumnHeader };
|
||||||
};
|
};
|
||||||
|
|||||||
@ -0,0 +1,29 @@
|
|||||||
|
import { getFieldMetadataTypeLabel } from '@/object-record/object-filter-dropdown/utils/getFieldMetadataTypeLabel';
|
||||||
|
import { SpreadsheetImportFields } from '@/spreadsheet-import/types';
|
||||||
|
import { SpreadsheetColumns } from '@/spreadsheet-import/types/SpreadsheetColumns';
|
||||||
|
import { FieldMetadataType } from 'twenty-shared/types';
|
||||||
|
|
||||||
|
export const spreadsheetBuildFieldOptions = <T extends string>(
|
||||||
|
fields: SpreadsheetImportFields<T>,
|
||||||
|
columns: SpreadsheetColumns<string>,
|
||||||
|
) => {
|
||||||
|
return fields
|
||||||
|
.filter((field) => field.fieldMetadataType !== FieldMetadataType.RICH_TEXT)
|
||||||
|
.map(({ Icon, label, key, fieldMetadataType }) => {
|
||||||
|
const isSelected =
|
||||||
|
columns.findIndex((column) => {
|
||||||
|
if ('value' in column) {
|
||||||
|
return column.value === key;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}) !== -1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
Icon: Icon,
|
||||||
|
value: key,
|
||||||
|
label: label,
|
||||||
|
disabled: isSelected,
|
||||||
|
fieldMetadataTypeLabel: getFieldMetadataTypeLabel(fieldMetadataType),
|
||||||
|
} as const;
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user