From 312632e686a9c2456eecb29371e6867b5d92701a Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Fri, 13 Jun 2025 15:43:16 +0200 Subject: [PATCH] update import auto matching (#12552) Screenshot 2025-06-11 at 17 45 13 closes https://github.com/twentyhq/core-team-issues/issues/905 --- .../utils/getFieldMetadataTypeLabel.ts | 19 ++++ .../utils/getFilterableFieldTypeLabel.ts | 8 -- .../getSettingsNonCompositeFieldTypeLabels.ts | 10 -- ...ColumnSelectFieldSelectDropdownContent.tsx | 102 +++++++++++++----- ...umnSelectSubFieldSelectDropdownContent.tsx | 20 +++- .../components/MatchColumnToFieldSelect.tsx | 11 ++ ...useComputeColumnSuggestionsAndAutoMatch.ts | 42 ++++++++ .../MatchColumnsStep/MatchColumnsStep.tsx | 21 +--- .../components/TemplateColumn.tsx | 32 +++--- .../suggestedFieldsByColumnHeaderState.ts | 7 ++ .../SelectHeaderStep/SelectHeaderStep.tsx | 11 ++ .../components/UploadStep/UploadStep.tsx | 10 ++ .../utils/getMatchedColumnsWithFuse.ts | 56 ++++++++-- .../utils/spreadsheetBuildFieldOptions.ts | 29 +++++ 14 files changed, 283 insertions(+), 95 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getFieldMetadataTypeLabel.ts delete mode 100644 packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getFilterableFieldTypeLabel.ts delete mode 100644 packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getSettingsNonCompositeFieldTypeLabels.ts create mode 100644 packages/twenty-front/src/modules/spreadsheet-import/hooks/useComputeColumnSuggestionsAndAutoMatch.ts create mode 100644 packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/states/suggestedFieldsByColumnHeaderState.ts create mode 100644 packages/twenty-front/src/modules/spreadsheet-import/utils/spreadsheetBuildFieldOptions.ts diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getFieldMetadataTypeLabel.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getFieldMetadataTypeLabel.ts new file mode 100644 index 000000000..15d9732ff --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getFieldMetadataTypeLabel.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getFilterableFieldTypeLabel.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getFilterableFieldTypeLabel.ts deleted file mode 100644 index ab73d2be5..000000000 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getFilterableFieldTypeLabel.ts +++ /dev/null @@ -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; -}; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getSettingsNonCompositeFieldTypeLabels.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getSettingsNonCompositeFieldTypeLabels.ts deleted file mode 100644 index 67a40d164..000000000 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getSettingsNonCompositeFieldTypeLabels.ts +++ /dev/null @@ -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; -}; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnSelectFieldSelectDropdownContent.tsx b/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnSelectFieldSelectDropdownContent.tsx index 2247ee06d..44268e052 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnSelectFieldSelectDropdownContent.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnSelectFieldSelectDropdownContent.tsx @@ -1,4 +1,5 @@ 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 { DO_NOT_IMPORT_OPTION_KEY } from '@/spreadsheet-import/constants/DoNotImportOptionKey'; 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 { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; 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 { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; +import styled from '@emotion/styled'; import { useLingui } from '@lingui/react/macro'; +import { isNonEmptyString } from '@sniptt/guards'; 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'; +const StyledContainer = styled.div` + max-height: 360px; +`; + export const MatchColumnSelectFieldSelectDropdownContent = ({ selectedValue, onSelectFieldMetadataItem, + onSelectSuggestedOption, onCancelSelect, onDoNotImportSelect, options, + suggestedOptions, }: { selectedValue: SelectOption | undefined; onSelectFieldMetadataItem: ( selectedFieldMetadataItem: FieldMetadataItem, ) => void; + onSelectSuggestedOption: (selectedSuggestedOption: SelectOption) => void; onCancelSelect: () => void; onDoNotImportSelect: () => void; - options: readonly ReadonlyDeep[]; + options: readonly ReadonlyDeep< + SelectOption & { fieldMetadataTypeLabel?: string } + >[]; + suggestedOptions: readonly ReadonlyDeep< + SelectOption & { fieldMetadataTypeLabel?: string } + >[]; }) => { const [searchFilter, setSearchFilter] = useState(''); @@ -53,6 +70,10 @@ export const MatchColumnSelectFieldSelectDropdownContent = ({ onSelectFieldMetadataItem(fieldMetadataItem); }; + const handleSuggestedOptionClick = (suggestedOption: SelectOption) => { + onSelectSuggestedOption(suggestedOption); + }; + const handleCancelClick = () => { onCancelSelect(); }; @@ -60,7 +81,7 @@ export const MatchColumnSelectFieldSelectDropdownContent = ({ const { t } = useLingui(); return ( - + - - - {filteredAvailableFieldMetadataItems.map((field) => ( - handleFieldClick(field)} - disabled={ - options.find((option) => option.value === field.name)?.disabled && - selectedValue?.value !== field.name - } - LeftIcon={getIcon(field.icon)} - text={field.label} - hasSubMenu={isCompositeFieldType(field.type)} - /> - ))} - + + + {!isNonEmptyString(searchFilter) && ( + <> + + + + {suggestedOptions.length > 0 && ( + <> + + + + {suggestedOptions.map((option) => ( + handleSuggestedOptionClick(option)} + disabled={option.disabled} + LeftIcon={option.Icon} + text={option.label} + contextualText={option.fieldMetadataTypeLabel} + /> + ))} + + + )} + + + + )} + + {filteredAvailableFieldMetadataItems.map((field) => ( + 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)} + /> + ))} + + + ); }; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnSelectSubFieldSelectDropdownContent.tsx b/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnSelectSubFieldSelectDropdownContent.tsx index e1c1eabdc..8a41b646c 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnSelectSubFieldSelectDropdownContent.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnSelectSubFieldSelectDropdownContent.tsx @@ -1,7 +1,9 @@ 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 { getSubFieldOptionKey } from '@/object-record/spreadsheet-import/utils/getSubFieldOptionKey'; 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 { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader'; import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent'; @@ -66,10 +68,17 @@ export const MatchColumnSelectSubFieldSelectDropdownContent = ({ return isDefined(correspondingOption); }) - .filter((subFieldName) => subFieldName.includes(searchFilter)); + .filter((subFieldName) => + getCompositeSubFieldLabel( + fieldMetadataItem.type as CompositeFieldType, + subFieldName, + ) + .toLowerCase() + .includes(searchFilter.toLowerCase()), + ); return ( - + handleSubFieldSelect(subFieldName)} LeftIcon={getIcon(fieldMetadataItem.icon)} - text={ - (fieldMetadataItemSettings.labelBySubField as any)[subFieldName] - } + text={getCompositeSubFieldLabel( + fieldMetadataItem.type as CompositeFieldType, + subFieldName, + )} disabled={ options.find( (option) => diff --git a/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnToFieldSelect.tsx b/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnToFieldSelect.tsx index 45c34a1c3..4cc66d240 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnToFieldSelect.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnToFieldSelect.tsx @@ -20,6 +20,7 @@ interface MatchColumnToFieldSelectProps { onChange: (value: ReadonlyDeep | null) => void; value?: ReadonlyDeep; options: readonly ReadonlyDeep[]; + suggestedOptions: readonly ReadonlyDeep[]; placeholder?: string; } @@ -32,6 +33,7 @@ export const MatchColumnToFieldSelect = ({ onChange, value, options, + suggestedOptions, placeholder, columnIndex, }: MatchColumnToFieldSelectProps) => { @@ -83,6 +85,13 @@ export const MatchColumnToFieldSelect = ({ } }; + const handleSelectSuggestedOption = ( + selectedSuggestedOption: SelectOption, + ) => { + onChange(selectedSuggestedOption); + closeDropdown(); + }; + const handleDoNotImportSelect = () => { if (isDefined(doNotImportOption)) { onChange(doNotImportOption); @@ -138,9 +147,11 @@ export const MatchColumnToFieldSelect = ({ ) } diff --git a/packages/twenty-front/src/modules/spreadsheet-import/hooks/useComputeColumnSuggestionsAndAutoMatch.ts b/packages/twenty-front/src/modules/spreadsheet-import/hooks/useComputeColumnSuggestionsAndAutoMatch.ts new file mode 100644 index 000000000..5640640da --- /dev/null +++ b/packages/twenty-front/src/modules/spreadsheet-import/hooks/useComputeColumnSuggestionsAndAutoMatch.ts @@ -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 = () => { + const { fields, autoMapHeaders } = useSpreadsheetImportInternal(); + + 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; +}; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep.tsx index 514cf7f95..d8db1b066 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep.tsx @@ -1,5 +1,5 @@ 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 { 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 { SpreadsheetColumns } from '@/spreadsheet-import/types/SpreadsheetColumns'; import { SpreadsheetImportField } from '@/spreadsheet-import/types/SpreadsheetImportField'; -import { getMatchedColumnsWithFuse } from '@/spreadsheet-import/utils/getMatchedColumnsWithFuse'; import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; import { Trans, useLingui } from '@lingui/react/macro'; import { useRecoilState } from 'recoil'; @@ -80,7 +79,7 @@ export const MatchColumnsStep = ({ const { enqueueDialog } = useDialogManager(); const { enqueueSnackBar } = useSnackBar(); const dataExample = data.slice(0, 2); - const { fields, autoMapHeaders } = useSpreadsheetImportInternal(); + const { fields } = useSpreadsheetImportInternal(); const [isLoading, setIsLoading] = useState(false); const [columns, setColumns] = useRecoilState( initialComputedColumnsSelector(headerValues), @@ -256,22 +255,6 @@ export const MatchColumnsStep = ({ 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( (column) => ![SpreadsheetColumnType.ignored, SpreadsheetColumnType.empty].includes( diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/TemplateColumn.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/TemplateColumn.tsx index 6c59cfb71..f7232a204 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/TemplateColumn.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/TemplateColumn.tsx @@ -3,10 +3,12 @@ import styled from '@emotion/styled'; 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 { suggestedFieldsByColumnHeaderState } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/states/suggestedFieldsByColumnHeaderState'; import { SpreadsheetColumnType } from '@/spreadsheet-import/types/SpreadsheetColumnType'; import { SpreadsheetColumns } from '@/spreadsheet-import/types/SpreadsheetColumns'; +import { spreadsheetBuildFieldOptions } from '@/spreadsheet-import/utils/spreadsheetBuildFieldOptions'; import { useLingui } from '@lingui/react/macro'; -import { FieldMetadataType } from 'twenty-shared/types'; +import { useRecoilValue } from 'recoil'; import { IconForbid } from 'twenty-ui/display'; const StyledContainer = styled.div` @@ -28,29 +30,20 @@ export const TemplateColumn = ({ onChange, }: TemplateColumnProps) => { const { fields } = useSpreadsheetImportInternal(); + const suggestedFieldsByColumnHeader = useRecoilValue( + suggestedFieldsByColumnHeaderState, + ); + const column = columns[columnIndex]; const isIgnored = column.type === SpreadsheetColumnType.ignored; const { t } = useLingui(); - const fieldOptions = fields - .filter((field) => field.fieldMetadataType !== FieldMetadataType.RICH_TEXT) - .map(({ Icon, label, key }) => { - 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, - } as const; - }); + const fieldOptions = spreadsheetBuildFieldOptions(fields, columns); + const suggestedFieldOptions = spreadsheetBuildFieldOptions( + suggestedFieldsByColumnHeader[column.header] ?? [], + columns, + ); const selectOptions = [ { @@ -76,6 +69,7 @@ export const TemplateColumn = ({ value={isIgnored ? ignoreValue : selectValue} onChange={(value) => onChange(value?.value as T, column.index)} options={selectOptions} + suggestedOptions={suggestedFieldOptions} columnIndex={column.index.toString()} /> diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/states/suggestedFieldsByColumnHeaderState.ts b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/states/suggestedFieldsByColumnHeaderState.ts new file mode 100644 index 000000000..8ba18b3ed --- /dev/null +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/states/suggestedFieldsByColumnHeaderState.ts @@ -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[]>, +}); diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/SelectHeaderStep.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/SelectHeaderStep.tsx index a06a0dc65..238ee79ed 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/SelectHeaderStep.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/SelectHeaderStep.tsx @@ -7,6 +7,7 @@ import { ImportedRow } from '@/spreadsheet-import/types'; import { Modal } from '@/ui/layout/modal/components/Modal'; +import { useComputeColumnSuggestionsAndAutoMatch } from '@/spreadsheet-import/hooks/useComputeColumnSuggestionsAndAutoMatch'; import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep'; import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType'; @@ -50,11 +51,20 @@ export const SelectHeaderStep = ({ const { selectHeaderStepHook } = useSpreadsheetImportInternal(); + const computeColumnSuggestionsAndAutoMatch = + useComputeColumnSuggestionsAndAutoMatch(); + const handleContinue = useCallback( async (...args: Parameters) => { try { const { importedRows: data, headerRow: headerValues } = await selectHeaderStepHook(...args); + + await computeColumnSuggestionsAndAutoMatch({ + headerValues, + data, + }); + setCurrentStepState({ type: SpreadsheetImportStepType.matchColumns, data, @@ -73,6 +83,7 @@ export const SelectHeaderStep = ({ setPreviousStepState, setCurrentStepState, currentStepState, + computeColumnSuggestionsAndAutoMatch, ], ); diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/UploadStep.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/UploadStep.tsx index 6119bb189..f1cba4fe7 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/UploadStep.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/UploadStep.tsx @@ -4,6 +4,7 @@ import { WorkBook } from 'xlsx-ugnis'; import { Modal } from '@/ui/layout/modal/components/Modal'; +import { useComputeColumnSuggestionsAndAutoMatch } from '@/spreadsheet-import/hooks/useComputeColumnSuggestionsAndAutoMatch'; import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep'; import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType'; @@ -36,6 +37,9 @@ export const UploadStep = ({ const { maxRecords, uploadStepHook, selectHeaderStepHook, selectHeader } = useSpreadsheetImportInternal(); + const computeColumnSuggestionsAndAutoMatch = + useComputeColumnSuggestionsAndAutoMatch(); + const handleContinue = useCallback( async (workbook: WorkBook, file: File) => { setUploadedFile(file); @@ -63,6 +67,11 @@ export const UploadStep = ({ const { importedRows: data, headerRow: headerValues } = await selectHeaderStepHook(mappedWorkbook[0], trimmedData); + await computeColumnSuggestionsAndAutoMatch({ + headerValues, + data, + }); + setCurrentStepState({ type: SpreadsheetImportStepType.matchColumns, data, @@ -92,6 +101,7 @@ export const UploadStep = ({ setUploadedFile, currentStepState, uploadStepHook, + computeColumnSuggestionsAndAutoMatch, ], ); diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/getMatchedColumnsWithFuse.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/getMatchedColumnsWithFuse.ts index 7a3748285..0a3bab326 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/utils/getMatchedColumnsWithFuse.ts +++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/getMatchedColumnsWithFuse.ts @@ -1,32 +1,68 @@ 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 { SpreadsheetColumns } from '@/spreadsheet-import/types/SpreadsheetColumns'; +import { SpreadsheetColumnType } from '@/spreadsheet-import/types/SpreadsheetColumnType'; import { setColumn } from '@/spreadsheet-import/utils/setColumn'; import Fuse from 'fuse.js'; import { isDefined } from 'twenty-shared/utils'; -export const getMatchedColumnsWithFuse = ( - columns: SpreadsheetColumns, - fields: SpreadsheetImportFields, - data: MatchColumnsStepProps['data'], -) => { +export const getMatchedColumnsWithFuse = ({ + columns, + fields, + data, +}: { + columns: SpreadsheetColumns; + fields: SpreadsheetImportFields; + data: MatchColumnsStepProps['data']; +}) => { const matchedColumns: SpreadsheetColumn[] = []; const fieldsToSearch = new Fuse(fields, { keys: ['label'], includeScore: true, + ignoreLocation: true, threshold: 0.3, }); + const suggestedFieldsByColumnHeader: Record< + SpreadsheetColumn['header'], + SpreadsheetImportField[] + > = {}; + for (const column of columns) { 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 newColumn = setColumn(column, firstMatch as any, data); + const isFirstMatchValid = + 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, + ); + + if (isFirstMatchValid && isFieldStillUnmatched) { + const newColumn = setColumn(column, firstMatch.item as any, data); matchedColumns.push(newColumn); } else { @@ -34,5 +70,5 @@ export const getMatchedColumnsWithFuse = ( } } - return { matchedColumns }; + return { matchedColumns, suggestedFieldsByColumnHeader }; }; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/spreadsheetBuildFieldOptions.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/spreadsheetBuildFieldOptions.ts new file mode 100644 index 000000000..359eb7910 --- /dev/null +++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/spreadsheetBuildFieldOptions.ts @@ -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 = ( + fields: SpreadsheetImportFields, + columns: SpreadsheetColumns, +) => { + 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; + }); +};