TWNTY-6135 - Improve Data Importer Select Matching (#6338)

### Description:

- we move all logic about the unmatchedOptions to a new component called
UnmatchColumn, because as it will be a full line in the table, it was
better to update where the component will be rendered
- In the latest changes to keep the columns when we change the step to
step 3 and go back to step 2, we added a fallback state
initialComputedColumnsState that saves the columns and only reverts the
updates when we go back to step 1 or close by clicking the X button

### Refs: 

#6135

```
It was necessary to add references and floating styles to the generic component to fix the bug when the last option was open and the dropdown was being hidden in the next row of the spreadsheet table. We fixed the same problem that occurs in the companies table as well
```

we used this approach mentioned on this documentation to be able to use
the hook without calling it on each component, we are calling only once,
on the shared component
<https://floating-ui.com/docs/useFloating#elements>\
before:


![](https://assets-service.gitstart.com/25493/2c994e0f-6548-4a9e-8b22-2c6eccb73b2e.png)

now:


![](https://assets-service.gitstart.com/25493/f56fd516-7e95-4616-b1ed-c9ea5195a8ae.png)###
Demo: <https://jam.dev/c/e0e0b921-7551-4a94-ac1c-8a50c53fdb0c>

Fixes #6135

NOTES: the enter key are not working on main branch too

---------

Co-authored-by: gitstart-twenty <gitstart-twenty@users.noreply.github.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
gitstart-app[bot]
2024-08-22 17:42:49 +02:00
committed by GitHub
parent eab202f107
commit 9898ca3e53
44 changed files with 1209 additions and 657 deletions

View File

@ -1,29 +1,15 @@
import styled from '@emotion/styled';
import { useRef, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { useClearField } from '@/object-record/record-field/hooks/useClearField';
import { useSelectField } from '@/object-record/record-field/meta-types/hooks/useSelectField';
import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent';
import { SINGLE_ENTITY_SELECT_BASE_LIST } from '@/object-record/relation-picker/constants/SingleEntitySelectBaseList';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
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 { SelectOption } from '@/spreadsheet-import/types';
import { SelectInput } from '@/ui/input/components/SelectInput';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { useSelectableListStates } from '@/ui/layout/selectable-list/hooks/internal/useSelectableListStates';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { MenuItemSelectTag } from '@/ui/navigation/menu-item/components/MenuItemSelectTag';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from '~/utils/isDefined';
const StyledRelationPickerContainer = styled.div`
left: -1px;
position: absolute;
top: -1px;
`;
import { useState } from 'react';
import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-ui';
type SelectFieldInputProps = {
onSubmit?: FieldInputEvent;
@ -36,55 +22,30 @@ export const SelectFieldInput = ({
}: SelectFieldInputProps) => {
const { persistField, fieldDefinition, fieldValue, hotkeyScope } =
useSelectField();
const { selectedItemIdState } = useSelectableListStates({
selectableListScopeId: SINGLE_ENTITY_SELECT_BASE_LIST,
});
const [selectWrapperRef, setSelectWrapperRef] =
useState<HTMLDivElement | null>(null);
const [filteredOptions, setFilteredOptions] = useState<SelectOption[]>([]);
const { handleResetSelectedPosition } = useSelectableList(
SINGLE_ENTITY_SELECT_BASE_LIST,
);
const clearField = useClearField();
const selectedItemId = useRecoilValue(selectedItemIdState);
const [searchFilter, setSearchFilter] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const selectedOption = fieldDefinition.metadata.options.find(
(option) => option.value === fieldValue,
);
const optionsToSelect =
fieldDefinition.metadata.options.filter((option) => {
return (
option.value !== fieldValue &&
option.label.toLowerCase().includes(searchFilter.toLowerCase())
);
}) || [];
const optionsInDropDown = selectedOption
? [selectedOption, ...optionsToSelect]
: optionsToSelect;
// handlers
const handleClearField = () => {
clearField();
onCancel?.();
};
useListenClickOutside({
refs: [containerRef],
callback: (event) => {
event.stopImmediatePropagation();
const handleSubmit = (option: SelectOption) => {
onSubmit?.(() => persistField(option?.value));
const weAreNotInAnHTMLInput = !(
event.target instanceof HTMLInputElement &&
event.target.tagName === 'INPUT'
);
if (weAreNotInAnHTMLInput && isDefined(onCancel)) {
onCancel();
handleResetSelectedPosition();
}
},
});
handleResetSelectedPosition();
};
useScopedHotkeys(
Key.Escape,
@ -96,81 +57,40 @@ export const SelectFieldInput = ({
[onCancel, handleResetSelectedPosition],
);
useScopedHotkeys(
Key.Enter,
() => {
const selectedOption = optionsInDropDown.find((option) =>
option.label.toLowerCase().includes(searchFilter.toLowerCase()),
);
if (isDefined(selectedOption)) {
onSubmit?.(() => persistField(selectedOption.value));
}
handleResetSelectedPosition();
},
hotkeyScope,
);
const optionIds = [
`No ${fieldDefinition.label}`,
...optionsInDropDown.map((option) => option.value),
...filteredOptions.map((option) => option.value),
];
return (
<SelectableList
selectableListId={SINGLE_ENTITY_SELECT_BASE_LIST}
selectableItemIdArray={optionIds}
hotkeyScope={hotkeyScope}
onEnter={(itemId) => {
const option = optionsInDropDown.find(
(option) => option.value === itemId,
);
if (isDefined(option)) {
onSubmit?.(() => persistField(option.value));
handleResetSelectedPosition();
}
}}
>
<StyledRelationPickerContainer ref={containerRef}>
<DropdownMenu data-select-disable>
<DropdownMenuSearchInput
value={searchFilter}
onChange={(event) => setSearchFilter(event.currentTarget.value)}
autoFocus
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{fieldDefinition.metadata.isNullable ?? (
<MenuItemSelectTag
key={`No ${fieldDefinition.label}`}
selected={false}
text={`No ${fieldDefinition.label}`}
color="transparent"
variant="outline"
onClick={handleClearField}
isKeySelected={selectedItemId === `No ${fieldDefinition.label}`}
/>
)}
{optionsInDropDown.map((option) => {
return (
<MenuItemSelectTag
key={option.value}
selected={option.value === fieldValue}
text={option.label}
color={option.color}
onClick={() => {
onSubmit?.(() => persistField(option.value));
handleResetSelectedPosition();
}}
isKeySelected={selectedItemId === option.value}
/>
);
})}
</DropdownMenuItemsContainer>
</DropdownMenu>
</StyledRelationPickerContainer>
</SelectableList>
<div ref={setSelectWrapperRef}>
<SelectableList
selectableListId={SINGLE_ENTITY_SELECT_BASE_LIST}
selectableItemIdArray={optionIds}
hotkeyScope={hotkeyScope}
onEnter={(itemId) => {
const option = filteredOptions.find(
(option) => option.value === itemId,
);
if (isDefined(option)) {
onSubmit?.(() => persistField(option.value));
handleResetSelectedPosition();
}
}}
>
<SelectInput
parentRef={selectWrapperRef}
onOptionSelected={handleSubmit}
options={fieldDefinition.metadata.options}
onCancel={onCancel}
defaultOption={selectedOption}
onFilterChange={setFilteredOptions}
onClear={
fieldDefinition.metadata.isNullable ? handleClearField : undefined
}
clearLabel={fieldDefinition.label}
/>
</SelectableList>
</div>
);
};

View File

@ -123,6 +123,38 @@ export const useBuildAvailableFieldsForImport = () => {
),
});
});
} else if (fieldMetadataItem.type === FieldMetadataType.Select) {
availableFieldsForImport.push({
icon: getIcon(fieldMetadataItem.icon),
label: fieldMetadataItem.label,
key: fieldMetadataItem.name,
fieldType: {
type: 'select',
options:
fieldMetadataItem.options?.map((option) => ({
label: option.label,
value: option.value,
color: option.color,
})) || [],
},
fieldValidationDefinitions: getSpreadSheetFieldValidationDefinitions(
fieldMetadataItem.type,
fieldMetadataItem.label + ' (ID)',
),
});
} else if (fieldMetadataItem.type === FieldMetadataType.Boolean) {
availableFieldsForImport.push({
icon: getIcon(fieldMetadataItem.icon),
label: fieldMetadataItem.label,
key: fieldMetadataItem.name,
fieldType: {
type: 'checkbox',
},
fieldValidationDefinitions: getSpreadSheetFieldValidationDefinitions(
fieldMetadataItem.type,
fieldMetadataItem.label,
),
});
} else {
availableFieldsForImport.push({
icon: getIcon(fieldMetadataItem.icon),

View File

@ -1,12 +1,13 @@
import { FieldValidationDefinition } from '@/spreadsheet-import/types';
import {
FieldValidationDefinition,
SpreadsheetImportFieldType,
} from '@/spreadsheet-import/types';
import { IconComponent } from 'twenty-ui';
export type AvailableFieldForImport = {
icon: IconComponent;
label: string;
key: string;
fieldType: {
type: 'input' | 'checkbox';
};
fieldType: SpreadsheetImportFieldType;
fieldValidationDefinitions?: FieldValidationDefinition[];
};

View File

@ -20,7 +20,7 @@ const StyledTitle = styled.span`
`;
const StyledDescription = styled.span`
color: ${({ theme }) => theme.font.color.primary};
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
margin-top: ${({ theme }) => theme.spacing(3)};

View File

@ -10,6 +10,7 @@ const StyledModal = styled(Modal)`
height: 61%;
min-height: 600px;
min-width: 800px;
padding: 0;
position: relative;
width: 63%;
@media (max-width: ${MOBILE_VIEWPORT}px) {
@ -42,7 +43,7 @@ export const ModalWrapper = ({
return (
<>
{isOpen && (
<StyledModal size="large" onClose={onClose} isClosable={true}>
<StyledModal size="large">
<StyledRtlLtr dir={rtl ? 'rtl' : 'ltr'}>
<ModalCloseButton onClose={onClose} />
{children}

View File

@ -5,15 +5,15 @@ import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const RsiContext = createContext({} as any);
type ProvidersProps<T extends string> = {
type ReactSpreadsheetImportContextProviderProps<T extends string> = {
children: React.ReactNode;
values: SpreadsheetImportDialogOptions<T>;
};
export const Providers = <T extends string>({
export const ReactSpreadsheetImportContextProvider = <T extends string>({
children,
values,
}: ProvidersProps<T>) => {
}: ReactSpreadsheetImportContextProviderProps<T>) => {
if (isUndefinedOrNull(values.fields)) {
throw new Error('Fields must be provided to spreadsheet-import');
}

View File

@ -7,8 +7,9 @@ import { Modal } from '@/ui/layout/modal/components/Modal';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
const StyledFooter = styled(Modal.Footer)`
gap: ${({ theme }) => theme.spacing(2)};
gap: ${({ theme }) => theme.spacing(2.5)};
justify-content: space-between;
padding: ${({ theme }) => theme.spacing(6)} ${({ theme }) => theme.spacing(8)};
`;
type StepNavigationButtonProps = {
@ -23,21 +24,23 @@ export const StepNavigationButton = ({
title,
isLoading,
onBack,
}: StepNavigationButtonProps) => (
<StyledFooter>
{!isUndefinedOrNull(onBack) && (
}: StepNavigationButtonProps) => {
return (
<StyledFooter>
{!isUndefinedOrNull(onBack) && (
<MainButton
Icon={isLoading ? CircularProgressBar : undefined}
title="Back"
onClick={!isLoading ? onBack : undefined}
variant="secondary"
/>
)}
<MainButton
Icon={isLoading ? CircularProgressBar : undefined}
title="Back"
onClick={!isLoading ? onBack : undefined}
variant="secondary"
title={title}
onClick={!isLoading ? onClick : undefined}
variant="primary"
/>
)}
<MainButton
Icon={isLoading ? CircularProgressBar : undefined}
title={title}
onClick={!isLoading ? onClick : undefined}
variant="primary"
/>
</StyledFooter>
);
</StyledFooter>
);
};

View File

@ -3,7 +3,7 @@ import { RecoilRoot, useRecoilState } from 'recoil';
import { useOpenSpreadsheetImportDialog } from '@/spreadsheet-import/hooks/useOpenSpreadsheetImportDialog';
import { spreadsheetImportDialogState } from '@/spreadsheet-import/states/spreadsheetImportDialogState';
import { StepType } from '@/spreadsheet-import/steps/components/UploadFlow';
import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType';
import {
ImportedRow,
SpreadsheetImportDialogOptions,
@ -38,7 +38,7 @@ export const mockedSpreadsheetOptions: SpreadsheetImportDialogOptions<Spreadshee
autoMapHeaders: true,
autoMapDistance: 1,
initialStepState: {
type: StepType.upload,
type: SpreadsheetImportStepType.upload,
},
dateFormat: 'MM/DD/YY',
parseRaw: true,

View File

@ -1,13 +1,13 @@
import { useState } from 'react';
import { act, renderHook } from '@testing-library/react';
import { useState } from 'react';
import { useSpreadsheetImportInitialStep } from '@/spreadsheet-import/hooks/useSpreadsheetImportInitialStep';
import { StepType } from '@/spreadsheet-import/steps/components/UploadFlow';
import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType';
describe('useSpreadsheetImportInitialStep', () => {
it('should return correct number for each step type', async () => {
const { result } = renderHook(() => {
const [step, setStep] = useState<StepType | undefined>();
const [step, setStep] = useState<SpreadsheetImportStepType | undefined>();
const { initialStep } = useSpreadsheetImportInitialStep(step);
return { initialStep, setStep };
});
@ -15,31 +15,31 @@ describe('useSpreadsheetImportInitialStep', () => {
expect(result.current.initialStep).toBe(-1);
act(() => {
result.current.setStep(StepType.upload);
result.current.setStep(SpreadsheetImportStepType.upload);
});
expect(result.current.initialStep).toBe(0);
act(() => {
result.current.setStep(StepType.selectSheet);
result.current.setStep(SpreadsheetImportStepType.selectSheet);
});
expect(result.current.initialStep).toBe(0);
act(() => {
result.current.setStep(StepType.selectHeader);
result.current.setStep(SpreadsheetImportStepType.selectHeader);
});
expect(result.current.initialStep).toBe(0);
act(() => {
result.current.setStep(StepType.matchColumns);
result.current.setStep(SpreadsheetImportStepType.matchColumns);
});
expect(result.current.initialStep).toBe(2);
act(() => {
result.current.setStep(StepType.validateData);
result.current.setStep(SpreadsheetImportStepType.validateData);
});
expect(result.current.initialStep).toBe(3);

View File

@ -1,11 +1,13 @@
import { renderHook } from '@testing-library/react';
import { Providers } from '@/spreadsheet-import/components/Providers';
import { ReactSpreadsheetImportContextProvider } from '@/spreadsheet-import/components/ReactSpreadsheetImportContextProvider';
import { mockedSpreadsheetOptions } from '@/spreadsheet-import/hooks/__tests__/useSpreadsheetImport.test';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<Providers values={mockedSpreadsheetOptions}>{children}</Providers>
<ReactSpreadsheetImportContextProvider values={mockedSpreadsheetOptions}>
{children}
</ReactSpreadsheetImportContextProvider>
);
describe('useSpreadsheetImportInternal', () => {

View File

@ -1,21 +1,22 @@
import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType';
import { useMemo } from 'react';
import { StepType } from '@/spreadsheet-import/steps/components/UploadFlow';
export const useSpreadsheetImportInitialStep = (initialStep?: StepType) => {
export const useSpreadsheetImportInitialStep = (
initialStep?: SpreadsheetImportStepType,
) => {
const steps = ['uploadStep', 'matchColumnsStep', 'validationStep'] as const;
const initialStepNumber = useMemo(() => {
switch (initialStep) {
case StepType.upload:
case SpreadsheetImportStepType.upload:
return 0;
case StepType.selectSheet:
case SpreadsheetImportStepType.selectSheet:
return 0;
case StepType.selectHeader:
case SpreadsheetImportStepType.selectHeader:
return 0;
case StepType.matchColumns:
case SpreadsheetImportStepType.matchColumns:
return 2;
case StepType.validateData:
case SpreadsheetImportStepType.validateData:
return 3;
default:
return -1;

View File

@ -1,7 +1,7 @@
import { useContext } from 'react';
import { SetRequired } from 'type-fest';
import { RsiContext } from '@/spreadsheet-import/components/Providers';
import { RsiContext } from '@/spreadsheet-import/components/ReactSpreadsheetImportContextProvider';
import { defaultSpreadsheetImportProps } from '@/spreadsheet-import/provider/components/SpreadsheetImport';
import { SpreadsheetImportDialogOptions } from '@/spreadsheet-import/types';

View File

@ -1,6 +1,6 @@
import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper';
import { Providers } from '@/spreadsheet-import/components/Providers';
import { Steps } from '@/spreadsheet-import/steps/components/Steps';
import { ReactSpreadsheetImportContextProvider } from '@/spreadsheet-import/components/ReactSpreadsheetImportContextProvider';
import { SpreadsheetImportStepperContainer } from '@/spreadsheet-import/steps/components/SpreadsheetImportStepperContainer';
import { SpreadsheetImportDialogOptions as SpreadsheetImportProps } from '@/spreadsheet-import/types';
export const defaultSpreadsheetImportProps: Partial<
@ -25,11 +25,11 @@ export const SpreadsheetImport = <T extends string>(
props: SpreadsheetImportProps<T>,
) => {
return (
<Providers values={props}>
<ReactSpreadsheetImportContextProvider values={props}>
<ModalWrapper isOpen={props.isOpen} onClose={props.onClose}>
<Steps />
<SpreadsheetImportStepperContainer />
</ModalWrapper>
</Providers>
</ReactSpreadsheetImportContextProvider>
);
};

View File

@ -1,8 +1,9 @@
import React from 'react';
import { useRecoilState } from 'recoil';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { spreadsheetImportDialogState } from '@/spreadsheet-import/states/spreadsheetImportDialogState';
import { matchColumnsState } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/states/initialComputedColumnsState';
import { SpreadsheetImport } from './SpreadsheetImport';
type SpreadsheetImportProviderProps = React.PropsWithChildren;
@ -14,11 +15,15 @@ export const SpreadsheetImportProvider = (
spreadsheetImportDialogState,
);
const setMatchColumnsState = useSetRecoilState(matchColumnsState);
const handleClose = () => {
setSpreadsheetImportDialog({
isOpen: false,
options: null,
});
setMatchColumnsState([]);
};
return (

View File

@ -4,7 +4,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { Heading } from '@/spreadsheet-import/components/Heading';
import { StepNavigationButton } from '@/spreadsheet-import/components/StepNavigationButton';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import { Field, ImportedRow } from '@/spreadsheet-import/types';
import {
Field,
ImportedRow,
ImportedStructuredRow,
} from '@/spreadsheet-import/types';
import { findUnmatchedRequiredFields } from '@/spreadsheet-import/utils/findUnmatchedRequiredFields';
import { getMatchedColumns } from '@/spreadsheet-import/utils/getMatchedColumns';
import { normalizeTableData } from '@/spreadsheet-import/utils/normalizeTableData';
@ -16,6 +20,12 @@ import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/Snac
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { UnmatchColumn } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/UnmatchColumn';
import { initialComputedColumnsState } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/states/initialComputedColumnsState';
import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep';
import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType';
import { useRecoilState } from 'recoil';
import { ColumnGrid } from './components/ColumnGrid';
import { TemplateColumn } from './components/TemplateColumn';
import { UserTableColumn } from './components/UserTableColumn';
@ -45,15 +55,15 @@ const StyledColumn = styled.span`
font-weight: ${({ theme }) => theme.font.weight.regular};
`;
export type MatchColumnsStepProps<T extends string> = {
export type MatchColumnsStepProps = {
data: ImportedRow[];
headerValues: ImportedRow;
onContinue: (
data: any[],
rawData: ImportedRow[],
columns: Columns<T>,
) => void;
onBack: () => void;
onBack?: () => void;
setCurrentStepState: (currentStepState: SpreadsheetImportStep) => void;
setPreviousStepState: (currentStepState: SpreadsheetImportStep) => void;
currentStepState: SpreadsheetImportStep;
nextStep: () => void;
errorToast: (message: string) => void;
};
export enum ColumnType {
@ -121,28 +131,30 @@ export type Columns<T extends string> = Column<T>[];
export const MatchColumnsStep = <T extends string>({
data,
headerValues,
onContinue,
onBack,
}: MatchColumnsStepProps<T>) => {
setCurrentStepState,
setPreviousStepState,
currentStepState,
nextStep,
errorToast,
}: MatchColumnsStepProps) => {
const { enqueueDialog } = useDialogManager();
const { enqueueSnackBar } = useSnackBar();
const dataExample = data.slice(0, 2);
const { fields, autoMapHeaders, autoMapDistance } =
useSpreadsheetImportInternal<T>();
const [isLoading, setIsLoading] = useState(false);
const [columns, setColumns] = useState<Columns<T>>(
// Do not remove spread, it indexes empty array elements, otherwise map() skips over them
([...headerValues] as string[]).map((value, index) => ({
type: ColumnType.empty,
index,
header: value ?? '',
})),
const [columns, setColumns] = useRecoilState(
initialComputedColumnsState(headerValues),
);
const { matchColumnsStepHook } = useSpreadsheetImportInternal();
const onIgnore = useCallback(
(columnIndex: number) => {
setColumns(
columns.map((column, index) =>
columnIndex === index ? setIgnoreColumn<T>(column) : column,
columnIndex === index ? setIgnoreColumn<string>(column) : column,
),
);
},
@ -176,7 +188,7 @@ export const MatchColumnsStep = <T extends string>({
(column) => 'value' in column && column.value === field.key,
);
setColumns(
columns.map<Column<T>>((column, index) => {
columns.map<Column<string>>((column, index) => {
if (columnIndex === index) {
return setColumn(column, field, data);
} else if (index === existingFieldIndex) {
@ -192,7 +204,44 @@ export const MatchColumnsStep = <T extends string>({
);
}
},
[columns, onRevertIgnore, onIgnore, fields, data, enqueueSnackBar],
[
columns,
onRevertIgnore,
onIgnore,
fields,
setColumns,
data,
enqueueSnackBar,
],
);
const onContinue = useCallback(
async (
values: ImportedStructuredRow<string>[],
rawData: ImportedRow[],
columns: Columns<string>,
) => {
try {
const data = await matchColumnsStepHook(values, rawData, columns);
setCurrentStepState({
type: SpreadsheetImportStepType.validateData,
data,
importedColumns: columns,
});
setPreviousStepState(currentStepState);
nextStep();
} catch (e) {
errorToast((e as Error).message);
}
},
[
errorToast,
matchColumnsStepHook,
nextStep,
setPreviousStepState,
setCurrentStepState,
currentStepState,
],
);
const onSubChange = useCallback(
@ -262,7 +311,10 @@ export const MatchColumnsStep = <T extends string>({
]);
useEffect(() => {
if (autoMapHeaders) {
const isInitialColumnsState = columns.every(
(column) => column.type === ColumnType.empty,
);
if (autoMapHeaders && isInitialColumnsState) {
setColumns(getMatchedColumns(columns, fields, data, autoMapDistance));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -290,16 +342,25 @@ export const MatchColumnsStep = <T extends string>({
columns={columns}
columnIndex={columnIndex}
onChange={onChange}
/>
)}
renderUnmatchedColumn={(columns, columnIndex) => (
<UnmatchColumn
columns={columns}
columnIndex={columnIndex}
onSubChange={onSubChange}
/>
)}
/>
</StyledContent>
<StepNavigationButton
onBack={onBack}
onClick={handleOnContinue}
isLoading={isLoading}
title="Continue"
title="Next Step"
onBack={() => {
onBack?.();
setColumns([]);
}}
/>
</>
);

View File

@ -1,5 +1,5 @@
import React from 'react';
import styled from '@emotion/styled';
import React from 'react';
import { Columns } from '../MatchColumnsStep';
@ -24,9 +24,12 @@ const StyledGrid = styled.div`
type HeightProps = {
height?: `${number}px`;
withBorder?: boolean;
};
const StyledGridRow = styled.div<HeightProps>`
border-bottom: ${({ withBorder, theme }) =>
withBorder && `1px solid ${theme.border.color.medium}`};
box-sizing: border-box;
display: flex;
flex-direction: row;
@ -34,7 +37,7 @@ const StyledGridRow = styled.div<HeightProps>`
`;
type PositionProps = {
position: 'left' | 'right';
position: 'left' | 'right' | 'full-line';
};
const StyledGridCell = styled.div<PositionProps>`
@ -50,11 +53,21 @@ const StyledGridCell = styled.div<PositionProps>`
return `
padding-left: ${theme.spacing(4)};
padding-right: ${theme.spacing(2)};
padding-top: ${theme.spacing(4)};
`;
}
if (position === 'full-line') {
return `
padding-left: ${theme.spacing(2)};
padding-right: ${theme.spacing(4)};
padding-top: ${theme.spacing(0)};
width: 100%;
`;
}
return `
padding-left: ${theme.spacing(2)};
padding-right: ${theme.spacing(4)};
padding-top: ${theme.spacing(4)};
`;
}};
`;
@ -89,12 +102,17 @@ type ColumnGridProps<T extends string> = {
columns: Columns<T>,
columnIndex: number,
) => React.ReactNode;
renderUnmatchedColumn: (
columns: Columns<T>,
columnIndex: number,
) => React.ReactNode;
};
export const ColumnGrid = <T extends string>({
columns,
renderUserColumn,
renderTemplateColumn,
renderUnmatchedColumn,
}: ColumnGridProps<T>) => {
return (
<>
@ -107,15 +125,29 @@ export const ColumnGrid = <T extends string>({
{columns.map((column, index) => {
const userColumn = renderUserColumn(columns, index);
const templateColumn = renderTemplateColumn(columns, index);
const unmatchedColumn = renderUnmatchedColumn(columns, index);
const isSelect = 'matchedOptions' in columns[index];
const isLast = index === columns.length - 1;
if (React.isValidElement(userColumn)) {
return (
<StyledGridRow key={index}>
<StyledGridCell position="left">{userColumn}</StyledGridCell>
<StyledGridCell position="right">
{templateColumn}
</StyledGridCell>
</StyledGridRow>
<div key={index}>
<StyledGridRow withBorder={!isSelect && !isLast}>
<StyledGridCell position="left">
{userColumn}
</StyledGridCell>
<StyledGridCell position="right">
{templateColumn}
</StyledGridCell>
</StyledGridRow>
{isSelect && (
<StyledGridRow withBorder={!isLast}>
<StyledGridCell position="full-line">
{unmatchedColumn}
</StyledGridCell>
</StyledGridRow>
)}
</div>
);
}

View File

@ -1,10 +1,14 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { MatchColumnSelect } from '@/spreadsheet-import/components/MatchColumnSelect';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import { SelectOption } from '@/spreadsheet-import/types';
import { getFieldOptions } from '@/spreadsheet-import/utils/getFieldOptions';
import { SelectInput } from '@/ui/input/components/SelectInput';
import { useState } from 'react';
import { IconChevronDown, Tag, TagColor } from 'twenty-ui';
import {
MatchedOptions,
MatchedSelectColumn,
@ -12,45 +16,106 @@ import {
} from '../MatchColumnsStep';
const StyledContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(4)};
justify-content: space-between;
padding-bottom: ${({ theme }) => theme.spacing(1)};
padding-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledSelectLabel = styled.span`
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};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
padding-bottom: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(1)};
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: MatchedOptions<T> | Partial<MatchedOptions<T>>;
column: MatchedSelectColumn<T> | MatchedSelectOptionsColumn<T>;
onSubChange: (val: T, index: number, option: string) => void;
placeholder: string;
selectedOption?: MatchedOptions<T> | Partial<MatchedOptions<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 [selectWrapperRef, setSelectWrapperRef] =
useState<HTMLDivElement | null>(null);
const theme = useTheme();
const handleSelect = (selectedOption: SelectOption) => {
onSubChange(selectedOption.value as T, column.index, option.entry ?? '');
setIsOpen(false);
};
return (
<StyledContainer>
<StyledSelectLabel>{option.entry}</StyledSelectLabel>
<MatchColumnSelect
value={value}
placeholder="Select..."
onChange={(value) =>
onSubChange(value?.value as T, column.index, option.entry ?? '')
}
options={options}
name={option.entry}
/>
<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"
ref={setSelectWrapperRef}
>
<Tag
text={value?.label ?? placeholder}
color={value?.color as TagColor}
/>
<StyledIconChevronDown size={theme.icon.size.md} />
{isOpen && (
<SelectInput
parentRef={selectWrapperRef}
defaultOption={value}
options={options}
onOptionSelected={handleSelect}
onCancel={() => setIsOpen(false)}
/>
)}
</StyledControlContainer>
</StyledContainer>
);
};

View File

@ -1,22 +1,10 @@
// TODO: We should create our own accordion component
import {
Accordion,
AccordionIcon,
AccordionItem,
AccordionPanel,
AccordionButton as ChakraAccordionButton,
} from '@chakra-ui/accordion';
import styled from '@emotion/styled';
import { IconChevronDown, IconForbid } from 'twenty-ui';
import { IconForbid } from 'twenty-ui';
import { MatchColumnSelect } from '@/spreadsheet-import/components/MatchColumnSelect';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import { Fields } from '@/spreadsheet-import/types';
import { Column, Columns, ColumnType } from '../MatchColumnsStep';
import { isDefined } from '~/utils/isDefined';
import { SubMatchingSelect } from './SubMatchingSelect';
import { Columns, ColumnType } from '../MatchColumnsStep';
const StyledContainer = styled.div`
display: flex;
@ -25,89 +13,38 @@ const StyledContainer = styled.div`
width: 100%;
`;
const StyledAccordionButton = styled(ChakraAccordionButton)`
align-items: center;
background-color: ${({ theme }) => theme.accent.secondary};
border: none;
border-radius: ${({ theme }) => theme.border.radius.sm};
box-sizing: border-box;
color: ${({ theme }) => theme.font.color.primary};
display: flex;
flex-direction: row;
margin-top: ${({ theme }) => theme.spacing(2)};
padding-bottom: ${({ theme }) => theme.spacing(1)};
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(1)};
width: 100%;
&:hover {
background-color: ${({ theme }) => theme.accent.primary};
}
`;
const StyledAccordionContainer = styled.div`
display: flex;
width: 100%;
`;
const StyledAccordionLabel = styled.span`
color: ${({ theme }) => theme.font.color.primary};
display: flex;
flex: 1;
font-size: ${({ theme }) => theme.font.size.sm};
padding-left: ${({ theme }) => theme.spacing(1)};
text-align: left;
`;
const getAccordionTitle = <T extends string>(
fields: Fields<T>,
column: Column<T>,
) => {
const fieldLabel = fields.find(
(field) => 'value' in column && field.key === column.value,
)?.label;
return `Match ${fieldLabel} (${
'matchedOptions' in column &&
column.matchedOptions.filter((option) => !isDefined(option.value)).length
} Unmatched)`;
};
type TemplateColumnProps<T extends string> = {
columns: Columns<T>;
columns: Columns<string>;
columnIndex: number;
onChange: (val: T, index: number) => void;
onSubChange: (val: T, index: number, option: string) => void;
};
export const TemplateColumn = <T extends string>({
columns,
columnIndex,
onChange,
onSubChange,
}: TemplateColumnProps<T>) => {
const { fields } = useSpreadsheetImportInternal<T>();
const column = columns[columnIndex];
const isIgnored = column.type === ColumnType.ignored;
const isSelect = 'matchedOptions' in column;
const fieldOptions = fields.map(({ icon, label, key }) => {
const isSelected =
columns.findIndex((column) => {
if ('value' in column) {
return column.value === key;
}
return false;
}) !== -1;
return {
icon,
icon: icon,
value: key,
label,
label: label,
disabled: isSelected,
} as const;
});
const selectOptions = [
{
icon: IconForbid,
@ -116,9 +53,11 @@ export const TemplateColumn = <T extends string>({
},
...fieldOptions,
];
const selectValue = fieldOptions.find(
({ value }) => 'value' in column && column.value === value,
);
const ignoreValue = selectOptions.find(
({ value }) => value === 'do-not-import',
);
@ -132,30 +71,6 @@ export const TemplateColumn = <T extends string>({
options={selectOptions}
name={column.header}
/>
{isSelect && (
<StyledAccordionContainer>
<Accordion allowMultiple width="100%">
<AccordionItem border="none" py={1}>
<StyledAccordionButton data-testid="accordion-button">
<StyledAccordionLabel>
{getAccordionTitle<T>(fields, column)}
</StyledAccordionLabel>
<AccordionIcon as={IconChevronDown} />
</StyledAccordionButton>
<AccordionPanel pb={4} pr={3} display="flex" flexDir="column">
{column.matchedOptions.map((option) => (
<SubMatchingSelect
option={option}
column={column}
onSubChange={onSubChange}
key={option.entry}
/>
))}
</AccordionPanel>
</AccordionItem>
</Accordion>
</StyledAccordionContainer>
)}
</StyledContainer>
);
};

View File

@ -0,0 +1,111 @@
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import { SubMatchingSelect } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/SubMatchingSelect';
import { Column } from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep';
import { Fields } from '@/spreadsheet-import/types';
import {
Accordion,
AccordionIcon,
AccordionItem,
AccordionPanel,
AccordionButton as ChakraAccordionButton,
} from '@chakra-ui/accordion';
import styled from '@emotion/styled';
import { IconChevronDown, IconInfoCircle, isDefined } from 'twenty-ui';
const StyledAccordionButton = styled(ChakraAccordionButton)`
align-items: center;
background-color: ${({ theme }) => theme.accent.secondary};
border: none;
border-radius: ${({ theme }) => theme.border.radius.md};
box-sizing: border-box;
color: ${({ theme }) => theme.font.color.primary};
display: flex;
flex-direction: row;
padding: ${({ theme }) => theme.spacing(2)};
width: 100%;
height: 40px;
&:hover {
background-color: ${({ theme }) => theme.accent.primary};
}
`;
const StyledAccordionContainer = styled.div`
display: flex;
width: 100%;
height: auto;
`;
const StyledAccordionLabel = styled.span`
color: ${({ theme }) => theme.color.blue};
display: flex;
flex: 1;
font-size: ${({ theme }) => theme.font.size.sm};
align-items: center;
gap: ${({ theme }) => theme.spacing(2)};
text-align: left;
`;
const StyledIconChevronDown = styled(IconChevronDown)`
color: ${({ theme }) => theme.color.blue} !important;
`;
const getAccordionTitle = <T extends string>(
fields: Fields<T>,
column: Column<T>,
) => {
const fieldLabel = fields.find(
(field) => 'value' in column && field.key === column.value,
)?.label;
return `Match ${fieldLabel} (${
'matchedOptions' in column &&
column.matchedOptions.filter((option) => !isDefined(option.value)).length
} Unmatched)`;
};
type UnmatchColumnProps<T extends string> = {
columns: Column<T>[];
columnIndex: number;
onSubChange: (val: T, index: number, option: string) => void;
};
export const UnmatchColumn = <T extends string>({
columns,
columnIndex,
onSubChange,
}: UnmatchColumnProps<T>) => {
const { fields } = useSpreadsheetImportInternal<T>();
const column = columns[columnIndex];
const isSelect = 'matchedOptions' in column;
return (
isSelect && (
<StyledAccordionContainer>
<Accordion allowMultiple width="100%" height="100%">
<AccordionItem border="none" py={1} height="100%">
<StyledAccordionButton data-testid="accordion-button">
<StyledAccordionLabel>
<IconInfoCircle />
{getAccordionTitle(fields, column)}
</StyledAccordionLabel>
<AccordionIcon as={StyledIconChevronDown} />
</StyledAccordionButton>
<AccordionPanel mt={16} gap={12} display="flex" flexDir="column">
{column.matchedOptions.map((option) => (
<SubMatchingSelect
option={option}
column={column}
onSubChange={onSubChange}
key={option.entry}
placeholder="Select an option"
/>
))}
</AccordionPanel>
</AccordionItem>
</Accordion>
</StyledAccordionContainer>
)
);
};

View File

@ -0,0 +1,41 @@
import {
Columns,
ColumnType,
} from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep';
import { ImportedRow } from '@/spreadsheet-import/types';
import { atom, selectorFamily } from 'recoil';
export const matchColumnsState = atom({
key: 'MatchColumnsState',
default: [] as Columns<string>,
});
export const initialComputedColumnsState = selectorFamily<
Columns<string>,
ImportedRow
>({
key: 'InitialComputedColumnsState',
get:
(headerValues: ImportedRow) =>
({ get }) => {
const currentState = get(matchColumnsState) as Columns<string>;
if (currentState.length === 0) {
// Do not remove spread, it indexes empty array elements, otherwise map() skips over them
const initialState = ([...headerValues] as string[]).map(
(value, index) => ({
type: ColumnType.empty,
index,
header: value ?? '',
}),
);
return initialState as Columns<string>;
} else {
return currentState;
}
},
set:
() =>
({ set }, newValue) => {
set(matchColumnsState, newValue as Columns<string>);
},
});

View File

@ -6,6 +6,10 @@ import { StepNavigationButton } from '@/spreadsheet-import/components/StepNaviga
import { ImportedRow } from '@/spreadsheet-import/types';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep';
import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType';
import { SelectHeaderTable } from './components/SelectHeaderTable';
const StyledHeading = styled(Heading)`
@ -20,17 +24,22 @@ const StyledTableContainer = styled.div`
type SelectHeaderStepProps = {
importedRows: ImportedRow[];
onContinue: (
headerValues: ImportedRow,
importedRows: ImportedRow[],
) => Promise<void>;
setCurrentStepState: (currentStepState: SpreadsheetImportStep) => void;
nextStep: () => void;
setPreviousStepState: (currentStepState: SpreadsheetImportStep) => void;
errorToast: (message: string) => void;
onBack: () => void;
currentStepState: SpreadsheetImportStep;
};
export const SelectHeaderStep = ({
importedRows,
onContinue,
setCurrentStepState,
nextStep,
setPreviousStepState,
errorToast,
onBack,
currentStepState,
}: SelectHeaderStepProps) => {
const [selectedRowIndexes, setSelectedRowIndexes] = useState<
ReadonlySet<number>
@ -38,6 +47,34 @@ export const SelectHeaderStep = ({
const [isLoading, setIsLoading] = useState(false);
const { selectHeaderStepHook } = useSpreadsheetImportInternal();
const onContinue = useCallback(
async (...args: Parameters<typeof selectHeaderStepHook>) => {
try {
const { importedRows: data, headerRow: headerValues } =
await selectHeaderStepHook(...args);
setCurrentStepState({
type: SpreadsheetImportStepType.matchColumns,
data,
headerValues,
});
setPreviousStepState(currentStepState);
nextStep();
} catch (e) {
errorToast((e as Error).message);
}
},
[
errorToast,
nextStep,
selectHeaderStepHook,
setPreviousStepState,
setCurrentStepState,
currentStepState,
],
);
const handleContinue = useCallback(async () => {
const [selectedRowIndex] = Array.from(new Set(selectedRowIndexes));
// We consider data above header to be redundant

View File

@ -3,10 +3,16 @@ import { useCallback, useState } from 'react';
import { Heading } from '@/spreadsheet-import/components/Heading';
import { StepNavigationButton } from '@/spreadsheet-import/components/StepNavigationButton';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep';
import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType';
import { exceedsMaxRecords } from '@/spreadsheet-import/utils/exceedsMaxRecords';
import { mapWorkbook } from '@/spreadsheet-import/utils/mapWorkbook';
import { Radio } from '@/ui/input/components/Radio';
import { RadioGroup } from '@/ui/input/components/RadioGroup';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { WorkBook } from 'xlsx-ugnis';
const StyledContent = styled(Modal.Content)`
align-items: center;
@ -27,19 +33,65 @@ const StyledRadioContainer = styled.div`
type SelectSheetStepProps = {
sheetNames: string[];
onContinue: (sheetName: string) => Promise<void>;
onBack: () => void;
setCurrentStepState: (data: SpreadsheetImportStep) => void;
errorToast: (message: string) => void;
setPreviousStepState: (data: SpreadsheetImportStep) => void;
currentStepState: {
type: SpreadsheetImportStepType.selectSheet;
workbook: WorkBook;
};
};
export const SelectSheetStep = ({
sheetNames,
onContinue,
setCurrentStepState,
errorToast,
setPreviousStepState,
onBack,
currentStepState,
}: SelectSheetStepProps) => {
const [isLoading, setIsLoading] = useState(false);
const [value, setValue] = useState(sheetNames[0]);
const { maxRecords, uploadStepHook } = useSpreadsheetImportInternal();
const onContinue = useCallback(
async (sheetName: string) => {
if (
maxRecords > 0 &&
exceedsMaxRecords(
currentStepState.workbook.Sheets[sheetName],
maxRecords,
)
) {
errorToast(`Too many records. Up to ${maxRecords.toString()} allowed`);
return;
}
try {
const mappedWorkbook = await uploadStepHook(
mapWorkbook(currentStepState.workbook, sheetName),
);
setCurrentStepState({
type: SpreadsheetImportStepType.selectHeader,
data: mappedWorkbook,
});
setPreviousStepState(currentStepState);
} catch (e) {
errorToast((e as Error).message);
}
},
[
errorToast,
maxRecords,
currentStepState,
setPreviousStepState,
setCurrentStepState,
uploadStepHook,
],
);
const handleOnContinue = useCallback(
async (data: typeof value) => {
setIsLoading(true);
@ -65,7 +117,7 @@ export const SelectSheetStep = ({
onClick={() => handleOnContinue(value)}
onBack={onBack}
isLoading={isLoading}
title="Continue"
title="Next Step"
/>
</>
);

View File

@ -0,0 +1,146 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useCallback, useState } from 'react';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import { CircularProgressBar } from '@/ui/feedback/progress-bar/components/CircularProgressBar';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep';
import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType';
import { MatchColumnsStep } from './MatchColumnsStep/MatchColumnsStep';
import { SelectHeaderStep } from './SelectHeaderStep/SelectHeaderStep';
import { SelectSheetStep } from './SelectSheetStep/SelectSheetStep';
import { UploadStep } from './UploadStep/UploadStep';
import { ValidationStep } from './ValidationStep/ValidationStep';
const StyledProgressBarContainer = styled(Modal.Content)`
align-items: center;
display: flex;
justify-content: center;
`;
type SpreadsheetImportStepperProps = {
nextStep: () => void;
prevStep: () => void;
};
export const SpreadsheetImportStepper = ({
nextStep,
prevStep,
}: SpreadsheetImportStepperProps) => {
const theme = useTheme();
const { initialStepState } = useSpreadsheetImportInternal();
const [currentStepState, setCurrentStepState] =
useState<SpreadsheetImportStep>(
initialStepState || { type: SpreadsheetImportStepType.upload },
);
const [previousStepState, setPreviousStepState] =
useState<SpreadsheetImportStep>(
initialStepState || { type: SpreadsheetImportStepType.upload },
);
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const { enqueueSnackBar } = useSnackBar();
const errorToast = useCallback(
(description: string) => {
enqueueSnackBar(description, {
title: 'Error',
variant: SnackBarVariant.Error,
});
},
[enqueueSnackBar],
);
const onBack = useCallback(() => {
setCurrentStepState(previousStepState);
prevStep();
}, [prevStep, previousStepState]);
switch (currentStepState.type) {
case SpreadsheetImportStepType.upload:
return (
<UploadStep
setUploadedFile={setUploadedFile}
currentStepState={currentStepState}
setPreviousStepState={setPreviousStepState}
setCurrentStepState={setCurrentStepState}
errorToast={errorToast}
nextStep={nextStep}
/>
);
case SpreadsheetImportStepType.selectSheet:
return (
<SelectSheetStep
sheetNames={currentStepState.workbook.SheetNames}
setCurrentStepState={setCurrentStepState}
currentStepState={currentStepState}
errorToast={errorToast}
setPreviousStepState={setPreviousStepState}
onBack={onBack}
/>
);
case SpreadsheetImportStepType.selectHeader:
return (
<SelectHeaderStep
importedRows={currentStepState.data}
setCurrentStepState={setCurrentStepState}
nextStep={nextStep}
setPreviousStepState={setPreviousStepState}
errorToast={errorToast}
onBack={onBack}
currentStepState={currentStepState}
/>
);
case SpreadsheetImportStepType.matchColumns:
return (
<MatchColumnsStep
data={currentStepState.data}
headerValues={currentStepState.headerValues}
setCurrentStepState={setCurrentStepState}
setPreviousStepState={setPreviousStepState}
currentStepState={currentStepState}
nextStep={nextStep}
onBack={() => {
onBack();
}}
errorToast={errorToast}
/>
);
case SpreadsheetImportStepType.validateData:
if (!uploadedFile) {
throw new Error('File not found');
}
return (
<ValidationStep
initialData={currentStepState.data}
importedColumns={currentStepState.importedColumns}
file={uploadedFile}
setCurrentStepState={setCurrentStepState}
onBack={() => {
onBack();
setPreviousStepState(
initialStepState || { type: SpreadsheetImportStepType.upload },
);
}}
/>
);
case SpreadsheetImportStepType.loading:
default:
return (
<StyledProgressBarContainer>
<CircularProgressBar
size={80}
barWidth={8}
barColor={theme.font.color.primary}
/>
</StyledProgressBarContainer>
);
}
};

View File

@ -8,7 +8,7 @@ import { StepBar } from '@/ui/navigation/step-bar/components/StepBar';
import { useStepBar } from '@/ui/navigation/step-bar/hooks/useStepBar';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { UploadFlow } from './UploadFlow';
import { SpreadsheetImportStepper } from './SpreadsheetImportStepper';
const StyledHeader = styled(Modal.Header)`
background-color: ${({ theme }) => theme.background.secondary};
@ -29,7 +29,7 @@ const stepTitles = {
validationStep: 'Validate data',
} as const;
export const Steps = () => {
export const SpreadsheetImportStepperContainer = () => {
const { initialStepState } = useSpreadsheetImportInternal();
const { steps, initialStep } = useSpreadsheetImportInitialStep(
@ -45,11 +45,15 @@ export const Steps = () => {
<StyledHeader>
<StepBar activeStep={activeStep}>
{steps.map((key) => (
<StepBar.Step label={stepTitles[key]} key={key} />
<StepBar.Step
activeStep={activeStep}
label={stepTitles[key]}
key={key}
/>
))}
</StepBar>
</StyledHeader>
<UploadFlow nextStep={nextStep} prevStep={prevStep} />
<SpreadsheetImportStepper nextStep={nextStep} prevStep={prevStep} />
</>
);
};

View File

@ -1,260 +0,0 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useCallback, useState } from 'react';
import { WorkBook } from 'xlsx-ugnis';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import { ImportedRow } from '@/spreadsheet-import/types';
import { exceedsMaxRecords } from '@/spreadsheet-import/utils/exceedsMaxRecords';
import { mapWorkbook } from '@/spreadsheet-import/utils/mapWorkbook';
import { CircularProgressBar } from '@/ui/feedback/progress-bar/components/CircularProgressBar';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { Columns, MatchColumnsStep } from './MatchColumnsStep/MatchColumnsStep';
import { SelectHeaderStep } from './SelectHeaderStep/SelectHeaderStep';
import { SelectSheetStep } from './SelectSheetStep/SelectSheetStep';
import { UploadStep } from './UploadStep/UploadStep';
import { ValidationStep } from './ValidationStep/ValidationStep';
const StyledProgressBarContainer = styled(Modal.Content)`
align-items: center;
display: flex;
justify-content: center;
`;
export enum StepType {
upload = 'upload',
selectSheet = 'selectSheet',
selectHeader = 'selectHeader',
matchColumns = 'matchColumns',
validateData = 'validateData',
loading = 'loading',
}
export type StepState =
| {
type: StepType.upload;
}
| {
type: StepType.selectSheet;
workbook: WorkBook;
}
| {
type: StepType.selectHeader;
data: ImportedRow[];
}
| {
type: StepType.matchColumns;
data: ImportedRow[];
headerValues: ImportedRow;
}
| {
type: StepType.validateData;
data: any[];
importedColumns: Columns<string>;
}
| {
type: StepType.loading;
};
interface UploadFlowProps {
nextStep: () => void;
prevStep: () => void;
}
export const UploadFlow = ({ nextStep, prevStep }: UploadFlowProps) => {
const theme = useTheme();
const { initialStepState } = useSpreadsheetImportInternal();
const [state, setState] = useState<StepState>(
initialStepState || { type: StepType.upload },
);
const [previousState, setPreviousState] = useState<StepState>(
initialStepState || { type: StepType.upload },
);
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
const {
maxRecords,
uploadStepHook,
selectHeaderStepHook,
matchColumnsStepHook,
selectHeader,
} = useSpreadsheetImportInternal();
const { enqueueSnackBar } = useSnackBar();
const errorToast = useCallback(
(description: string) => {
enqueueSnackBar(description, {
title: 'Error',
variant: SnackBarVariant.Error,
});
},
[enqueueSnackBar],
);
const onBack = useCallback(() => {
setState(previousState);
prevStep();
}, [prevStep, previousState]);
switch (state.type) {
case StepType.upload:
return (
<UploadStep
onContinue={async (workbook, file) => {
setUploadedFile(file);
const isSingleSheet = workbook.SheetNames.length === 1;
if (isSingleSheet) {
if (
maxRecords > 0 &&
exceedsMaxRecords(
workbook.Sheets[workbook.SheetNames[0]],
maxRecords,
)
) {
errorToast(
`Too many records. Up to ${maxRecords.toString()} allowed`,
);
return;
}
try {
const mappedWorkbook = await uploadStepHook(
mapWorkbook(workbook),
);
if (selectHeader) {
setState({
type: StepType.selectHeader,
data: mappedWorkbook,
});
} else {
// Automatically select first row as header
const trimmedData = mappedWorkbook.slice(1);
const { importedRows: data, headerRow: headerValues } =
await selectHeaderStepHook(mappedWorkbook[0], trimmedData);
setState({
type: StepType.matchColumns,
data,
headerValues,
});
}
} catch (e) {
errorToast((e as Error).message);
}
} else {
setState({ type: StepType.selectSheet, workbook });
}
setPreviousState(state);
nextStep();
}}
/>
);
case StepType.selectSheet:
return (
<SelectSheetStep
sheetNames={state.workbook.SheetNames}
onContinue={async (sheetName) => {
if (
maxRecords > 0 &&
exceedsMaxRecords(state.workbook.Sheets[sheetName], maxRecords)
) {
errorToast(
`Too many records. Up to ${maxRecords.toString()} allowed`,
);
return;
}
try {
const mappedWorkbook = await uploadStepHook(
mapWorkbook(state.workbook, sheetName),
);
setState({
type: StepType.selectHeader,
data: mappedWorkbook,
});
setPreviousState(state);
} catch (e) {
errorToast((e as Error).message);
}
}}
onBack={onBack}
/>
);
case StepType.selectHeader:
return (
<SelectHeaderStep
importedRows={state.data}
onContinue={async (...args) => {
try {
const { importedRows: data, headerRow: headerValues } =
await selectHeaderStepHook(...args);
setState({
type: StepType.matchColumns,
data,
headerValues,
});
setPreviousState(state);
nextStep();
} catch (e) {
errorToast((e as Error).message);
}
}}
onBack={onBack}
/>
);
case StepType.matchColumns:
return (
<MatchColumnsStep
data={state.data}
headerValues={state.headerValues}
onContinue={async (values, rawData, columns) => {
try {
const data = await matchColumnsStepHook(values, rawData, columns);
setState({
type: StepType.validateData,
data,
importedColumns: columns,
});
setPreviousState(state);
nextStep();
} catch (e) {
errorToast((e as Error).message);
}
}}
onBack={onBack}
/>
);
case StepType.validateData:
if (!uploadedFile) {
throw new Error('File not found');
}
return (
<ValidationStep
initialData={state.data}
importedColumns={state.importedColumns}
file={uploadedFile}
onSubmitStart={() =>
setState({
type: StepType.loading,
})
}
onBack={() => {
onBack();
setPreviousState(initialStepState || { type: StepType.upload });
}}
/>
);
case StepType.loading:
default:
return (
<StyledProgressBarContainer>
<CircularProgressBar
size={80}
barWidth={8}
barColor={theme.font.color.primary}
/>
</StyledProgressBarContainer>
);
}
};

View File

@ -3,6 +3,12 @@ import { useCallback, useState } from 'react';
import { WorkBook } from 'xlsx-ugnis';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep';
import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType';
import { exceedsMaxRecords } from '@/spreadsheet-import/utils/exceedsMaxRecords';
import { mapWorkbook } from '@/spreadsheet-import/utils/mapWorkbook';
import { DropZone } from './components/DropZone';
const StyledContent = styled(Modal.Content)`
@ -10,11 +16,86 @@ const StyledContent = styled(Modal.Content)`
`;
type UploadStepProps = {
onContinue: (data: WorkBook, file: File) => Promise<void>;
setUploadedFile: (file: File) => void;
setCurrentStepState: (data: any) => void;
errorToast: (message: string) => void;
nextStep: () => void;
setPreviousStepState: (data: any) => void;
currentStepState: SpreadsheetImportStep;
};
export const UploadStep = ({ onContinue }: UploadStepProps) => {
export const UploadStep = ({
setUploadedFile,
setCurrentStepState,
errorToast,
nextStep,
setPreviousStepState,
currentStepState,
}: UploadStepProps) => {
const [isLoading, setIsLoading] = useState(false);
const { maxRecords, uploadStepHook, selectHeaderStepHook, selectHeader } =
useSpreadsheetImportInternal();
const onContinue = useCallback(
async (workbook: WorkBook, file: File) => {
setUploadedFile(file);
const isSingleSheet = workbook.SheetNames.length === 1;
if (isSingleSheet) {
if (
maxRecords > 0 &&
exceedsMaxRecords(workbook.Sheets[workbook.SheetNames[0]], maxRecords)
) {
errorToast(
`Too many records. Up to ${maxRecords.toString()} allowed`,
);
return;
}
try {
const mappedWorkbook = await uploadStepHook(mapWorkbook(workbook));
if (selectHeader) {
setCurrentStepState({
type: SpreadsheetImportStepType.selectHeader,
data: mappedWorkbook,
});
} else {
// Automatically select first row as header
const trimmedData = mappedWorkbook.slice(1);
const { importedRows: data, headerRow: headerValues } =
await selectHeaderStepHook(mappedWorkbook[0], trimmedData);
setCurrentStepState({
type: SpreadsheetImportStepType.matchColumns,
data,
headerValues,
});
}
} catch (e) {
errorToast((e as Error).message);
}
} else {
setCurrentStepState({
type: SpreadsheetImportStepType.selectSheet,
workbook,
});
}
setPreviousStepState(currentStepState);
nextStep();
},
[
errorToast,
maxRecords,
nextStep,
selectHeader,
selectHeaderStepHook,
setPreviousStepState,
setCurrentStepState,
setUploadedFile,
currentStepState,
uploadStepHook,
],
);
const handleOnContinue = useCallback(
async (data: WorkBook, file: File) => {

View File

@ -79,7 +79,7 @@ const StyledText = styled.span`
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
text-align: center;
padding: 15px;
padding: 16px;
`;
type DropZoneProps = {

View File

@ -1,6 +1,12 @@
import styled from '@emotion/styled';
import { useCallback, useMemo, useState } from 'react';
// @ts-expect-error Todo: remove usage of react-data-grid
import {
Dispatch,
SetStateAction,
useCallback,
useMemo,
useState,
} from 'react';
// @ts-expect-error Todo: remove usage of react-data-grid`
import { RowsChangeData } from 'react-data-grid';
import { IconTrash } from 'twenty-ui';
@ -22,6 +28,8 @@ import { Button } from '@/ui/input/button/components/Button';
import { Toggle } from '@/ui/input/components/Toggle';
import { isDefined } from '~/utils/isDefined';
import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep';
import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType';
import { Modal } from '@/ui/layout/modal/components/Modal';
import { generateColumns } from './components/columns';
import { ImportedStructuredRowMetadata } from './types';
@ -71,15 +79,15 @@ type ValidationStepProps<T extends string> = {
initialData: ImportedStructuredRow<T>[];
importedColumns: Columns<string>;
file: File;
onSubmitStart?: () => void;
onBack: () => void;
setCurrentStepState: Dispatch<SetStateAction<SpreadsheetImportStep>>;
};
export const ValidationStep = <T extends string>({
initialData,
importedColumns,
file,
onSubmitStart,
setCurrentStepState,
onBack,
}: ValidationStepProps<T>) => {
const { enqueueDialog } = useDialogManager();
@ -209,7 +217,11 @@ export const ValidationStep = <T extends string>({
allStructuredRows: data,
} satisfies ImportValidationResult<T>,
);
onSubmitStart?.();
setCurrentStepState({
type: SpreadsheetImportStepType.loading,
});
await onSubmit(calculatedData, file);
onClose();
};

View File

@ -1,8 +1,9 @@
import { Meta } from '@storybook/react';
import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper';
import { Providers } from '@/spreadsheet-import/components/Providers';
import { ReactSpreadsheetImportContextProvider } from '@/spreadsheet-import/components/ReactSpreadsheetImportContextProvider';
import { MatchColumnsStep } from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep';
import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep';
import { mockRsiValues } from '@/spreadsheet-import/tests/mockRsiValues';
import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
@ -61,15 +62,19 @@ const mockData = [
export const Default = () => (
<DialogManagerScope dialogManagerScopeId="dialog-manager">
<Providers values={mockRsiValues}>
<ReactSpreadsheetImportContextProvider values={mockRsiValues}>
<ModalWrapper isOpen={true} onClose={() => null}>
<MatchColumnsStep
headerValues={mockData[0] as string[]}
data={mockData.slice(1)}
onContinue={() => null}
onBack={() => null}
setCurrentStepState={() => null}
setPreviousStepState={() => null}
currentStepState={{} as SpreadsheetImportStep}
nextStep={() => null}
errorToast={() => null}
/>
</ModalWrapper>
</Providers>
</ReactSpreadsheetImportContextProvider>
</DialogManagerScope>
);

View File

@ -1,8 +1,9 @@
import { Meta } from '@storybook/react';
import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper';
import { Providers } from '@/spreadsheet-import/components/Providers';
import { ReactSpreadsheetImportContextProvider } from '@/spreadsheet-import/components/ReactSpreadsheetImportContextProvider';
import { SelectHeaderStep } from '@/spreadsheet-import/steps/components/SelectHeaderStep/SelectHeaderStep';
import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType';
import {
headerSelectionTableFields,
mockRsiValues,
@ -21,14 +22,21 @@ export default meta;
export const Default = () => (
<DialogManagerScope dialogManagerScopeId="dialog-manager">
<Providers values={mockRsiValues}>
<ReactSpreadsheetImportContextProvider values={mockRsiValues}>
<ModalWrapper isOpen={true} onClose={() => null}>
<SelectHeaderStep
importedRows={headerSelectionTableFields}
onContinue={() => Promise.resolve()}
setCurrentStepState={() => null}
nextStep={() => Promise.resolve()}
setPreviousStepState={() => null}
errorToast={() => null}
onBack={() => Promise.resolve()}
currentStepState={{
type: SpreadsheetImportStepType.selectHeader,
data: headerSelectionTableFields,
}}
/>
</ModalWrapper>
</Providers>
</ReactSpreadsheetImportContextProvider>
</DialogManagerScope>
);

View File

@ -1,8 +1,9 @@
import { Meta } from '@storybook/react';
import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper';
import { Providers } from '@/spreadsheet-import/components/Providers';
import { ReactSpreadsheetImportContextProvider } from '@/spreadsheet-import/components/ReactSpreadsheetImportContextProvider';
import { SelectSheetStep } from '@/spreadsheet-import/steps/components/SelectSheetStep/SelectSheetStep';
import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType';
import { mockRsiValues } from '@/spreadsheet-import/tests/mockRsiValues';
import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope';
@ -20,14 +21,39 @@ const sheetNames = ['Sheet1', 'Sheet2', 'Sheet3'];
export const Default = () => (
<DialogManagerScope dialogManagerScopeId="dialog-manager">
<Providers values={mockRsiValues}>
<ReactSpreadsheetImportContextProvider values={mockRsiValues}>
<ModalWrapper isOpen={true} onClose={() => null}>
<SelectSheetStep
sheetNames={sheetNames}
onContinue={() => Promise.resolve()}
setCurrentStepState={() => {}}
setPreviousStepState={() => {}}
currentStepState={{
type: SpreadsheetImportStepType.selectSheet,
workbook: {
SheetNames: sheetNames,
Sheets: {
Sheet1: {
A1: 1,
A2: 2,
A3: 3,
},
Sheet2: {
A1: 1,
A2: 2,
A3: 3,
},
Sheet3: {
A1: 1,
A2: 2,
A3: 3,
},
},
},
}}
errorToast={() => null}
onBack={() => Promise.resolve()}
/>
</ModalWrapper>
</Providers>
</ReactSpreadsheetImportContextProvider>
</DialogManagerScope>
);

View File

@ -5,16 +5,16 @@ import { within } from '@storybook/test';
import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { Steps } from '../Steps';
import { SpreadsheetImportStepperContainer } from '../SpreadsheetImportStepperContainer';
const meta: Meta<typeof Steps> = {
const meta: Meta<typeof SpreadsheetImportStepperContainer> = {
title: 'Modules/SpreadsheetImport/Steps',
component: Steps,
component: SpreadsheetImportStepperContainer,
decorators: [ComponentWithRecoilScopeDecorator, SnackBarDecorator],
};
export default meta;
type Story = StoryObj<typeof Steps>;
type Story = StoryObj<typeof SpreadsheetImportStepperContainer>;
export const Default: Story = {
play: async () => {

View File

@ -1,8 +1,9 @@
import { Meta } from '@storybook/react';
import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper';
import { Providers } from '@/spreadsheet-import/components/Providers';
import { ReactSpreadsheetImportContextProvider } from '@/spreadsheet-import/components/ReactSpreadsheetImportContextProvider';
import { UploadStep } from '@/spreadsheet-import/steps/components/UploadStep/UploadStep';
import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType';
import { mockRsiValues } from '@/spreadsheet-import/tests/mockRsiValues';
import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
@ -20,10 +21,19 @@ export default meta;
export const Default = () => (
<DialogManagerScope dialogManagerScopeId="dialog-manager">
<Providers values={mockRsiValues}>
<ReactSpreadsheetImportContextProvider values={mockRsiValues}>
<ModalWrapper isOpen={true} onClose={() => null}>
<UploadStep onContinue={() => Promise.resolve()} />
<UploadStep
setUploadedFile={() => null}
setCurrentStepState={() => null}
errorToast={() => null}
nextStep={() => null}
setPreviousStepState={() => null}
currentStepState={{
type: SpreadsheetImportStepType.upload,
}}
/>
</ModalWrapper>
</Providers>
</ReactSpreadsheetImportContextProvider>
</DialogManagerScope>
);

View File

@ -1,7 +1,7 @@
import { Meta } from '@storybook/react';
import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper';
import { Providers } from '@/spreadsheet-import/components/Providers';
import { ReactSpreadsheetImportContextProvider } from '@/spreadsheet-import/components/ReactSpreadsheetImportContextProvider';
import { ValidationStep } from '@/spreadsheet-import/steps/components/ValidationStep/ValidationStep';
import {
editableTableInitialData,
@ -24,15 +24,16 @@ const file = new File([''], 'file.csv');
export const Default = () => (
<DialogManagerScope dialogManagerScopeId="dialog-manager">
<Providers values={mockRsiValues}>
<ReactSpreadsheetImportContextProvider values={mockRsiValues}>
<ModalWrapper isOpen={true} onClose={() => null}>
<ValidationStep
initialData={editableTableInitialData}
file={file}
importedColumns={importedColums}
onBack={() => Promise.resolve()}
setCurrentStepState={() => null}
/>
</ModalWrapper>
</Providers>
</ReactSpreadsheetImportContextProvider>
</DialogManagerScope>
);

View File

@ -0,0 +1,30 @@
import { Columns } from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep';
import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType';
import { ImportedRow } from '@/spreadsheet-import/types';
import { WorkBook } from 'xlsx-ugnis';
export type SpreadsheetImportStep =
| {
type: SpreadsheetImportStepType.upload;
}
| {
type: SpreadsheetImportStepType.selectSheet;
workbook: WorkBook;
}
| {
type: SpreadsheetImportStepType.selectHeader;
data: ImportedRow[];
}
| {
type: SpreadsheetImportStepType.matchColumns;
data: ImportedRow[];
headerValues: ImportedRow;
}
| {
type: SpreadsheetImportStepType.validateData;
data: any[];
importedColumns: Columns<string>;
}
| {
type: SpreadsheetImportStepType.loading;
};

View File

@ -0,0 +1,8 @@
export enum SpreadsheetImportStepType {
upload = 'upload',
selectSheet = 'selectSheet',
selectHeader = 'selectHeader',
matchColumns = 'matchColumns',
validateData = 'validateData',
loading = 'loading',
}

View File

@ -1,9 +1,9 @@
import { IconComponent } from 'twenty-ui';
import { IconComponent, ThemeColor } from 'twenty-ui';
import { ReadonlyDeep } from 'type-fest';
import { Columns } from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep';
import { StepState } from '@/spreadsheet-import/steps/components/UploadFlow';
import { ImportedStructuredRowMetadata } from '@/spreadsheet-import/steps/components/ValidationStep/types';
import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep';
export type SpreadsheetImportDialogOptions<FieldNames extends string> = {
// Is modal visible.
@ -47,7 +47,7 @@ export type SpreadsheetImportDialogOptions<FieldNames extends string> = {
// Headers matching accuracy: 1 for strict and up for more flexible matching
autoMapDistance?: number;
// Initial Step state to be rendered on load
initialStepState?: StepState;
initialStepState?: SpreadsheetImportStep;
// Sets SheetJS dateNF option. If date parsing is applied, date will be formatted e.g. "yyyy-mm-dd hh:mm:ss", "m/d/yy h:mm", 'mmm-yy', etc.
dateFormat?: string;
// Sets SheetJS "raw" option. If true, parsing will only be applied to xlsx date fields.
@ -67,25 +67,6 @@ export type ImportedStructuredRow<T extends string> = {
// Data model RSI uses for spreadsheet imports
export type Fields<T extends string> = ReadonlyDeep<Field<T>[]>;
export type Field<T extends string> = {
// Icon
icon: IconComponent | null | undefined;
// UI-facing field label
label: string;
// Field's unique identifier
key: T;
// UI-facing additional information displayed via tooltip and ? icon
description?: string;
// Alternate labels used for fields' auto-matching, e.g. "fname" -> "firstName"
alternateMatches?: string[];
// Validations used for field entries
fieldValidationDefinitions?: FieldValidationDefinition[];
// Field entry component, default: Input
fieldType: Checkbox | Select | Input;
// UI-facing values shown to user as field examples pre-upload phase
example?: string;
};
export type Checkbox = {
type: 'checkbox';
// Alternate values to be treated as booleans, e.g. {yes: true, no: false}
@ -107,12 +88,35 @@ export type SelectOption = {
value: string;
// Disabled option when already select
disabled?: boolean;
// Option color
color?: ThemeColor;
};
export type Input = {
type: 'input';
};
export type SpreadsheetImportFieldType = Checkbox | Select | Input;
export type Field<T extends string> = {
// Icon
icon: IconComponent | null | undefined;
// UI-facing field label
label: string;
// Field's unique identifier
key: T;
// UI-facing additional information displayed via tooltip and ? icon
description?: string;
// Alternate labels used for fields' auto-matching, e.g. "fname" -> "firstName"
alternateMatches?: string[];
// Validations used for field entries
fieldValidationDefinitions?: FieldValidationDefinition[];
// Field entry component, default: Input
fieldType: SpreadsheetImportFieldType;
// UI-facing values shown to user as field examples pre-upload phase
example?: string;
};
export type FieldValidationDefinition =
| RequiredValidation
| UniqueValidation

View File

@ -14,7 +14,7 @@ import { setColumn } from './setColumn';
export const getMatchedColumns = <T extends string>(
columns: Columns<T>,
fields: Fields<T>,
data: MatchColumnsStepProps<T>['data'],
data: MatchColumnsStepProps['data'],
autoMapDistance: number,
) =>
columns.reduce<Column<T>[]>((arr, column) => {

View File

@ -11,7 +11,7 @@ import { uniqueEntries } from './uniqueEntries';
export const setColumn = <T extends string>(
oldColumn: Column<T>,
field?: Field<T>,
data?: MatchColumnsStepProps<T>['data'],
data?: MatchColumnsStepProps['data'],
): Column<T> => {
if (field?.fieldType.type === 'select') {
const fieldOptions = field.fieldType.options;

View File

@ -6,7 +6,7 @@ import {
} from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep';
export const uniqueEntries = <T extends string>(
data: MatchColumnsStepProps<T>['data'],
data: MatchColumnsStepProps['data'],
index: number,
): Partial<MatchedOptions<T>>[] =>
uniqBy(

View File

@ -68,7 +68,9 @@ const StyledControlLabel = styled.div`
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledIconChevronDown = styled(IconChevronDown)<{ disabled?: boolean }>`
const StyledIconChevronDown = styled(IconChevronDown)<{
disabled?: boolean;
}>`
color: ${({ disabled, theme }) =>
disabled ? theme.font.color.extraLight : theme.font.color.tertiary};
`;

View File

@ -0,0 +1,180 @@
import styled from '@emotion/styled';
import { SelectOption } from '@/spreadsheet-import/types';
import { SelectFieldHotkeyScope } from '@/object-record/select/types/SelectFieldHotkeyScope';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
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 { MenuItemSelectTag } from '@/ui/navigation/menu-item/components/MenuItemSelectTag';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useTheme } from '@emotion/react';
import {
ReferenceType,
autoUpdate,
flip,
offset,
size,
useFloating,
} from '@floating-ui/react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Key } from 'ts-key-enum';
import { TagColor, isDefined } from 'twenty-ui';
const StyledRelationPickerContainer = styled.div`
left: -1px;
position: absolute;
top: -1px;
z-index: ${({ theme }) => theme.lastLayerZIndex};
`;
interface SelectInputProps {
onOptionSelected: (selectedOption: SelectOption) => void;
options: SelectOption[];
onCancel?: () => void;
defaultOption?: SelectOption;
parentRef?: ReferenceType | null | undefined;
onFilterChange?: (filteredOptions: SelectOption[]) => void;
onClear?: () => void;
clearLabel?: string;
}
export const SelectInput = ({
onOptionSelected,
onClear,
clearLabel,
options,
onCancel,
defaultOption,
parentRef,
onFilterChange,
}: SelectInputProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const theme = useTheme();
const [searchFilter, setSearchFilter] = useState('');
const [selectedOption, setSelectedOption] = useState<
SelectOption | undefined
>(defaultOption);
const optionsToSelect = useMemo(
() =>
options.filter((option) => {
return (
option.value !== selectedOption?.value &&
option.label.toLowerCase().includes(searchFilter.toLowerCase())
);
}) || [],
[options, searchFilter, selectedOption?.value],
);
const optionsInDropDown = useMemo(
() =>
selectedOption ? [selectedOption, ...optionsToSelect] : optionsToSelect,
[optionsToSelect, selectedOption],
);
const handleOptionChange = (option: SelectOption) => {
setSelectedOption(option);
onOptionSelected(option);
};
const { refs, floatingStyles } = useFloating({
elements: { reference: parentRef },
strategy: 'absolute',
middleware: [
offset(() => {
return parseInt(theme.spacing(2), 10);
}),
flip(),
size(),
],
whileElementsMounted: autoUpdate,
open: true,
placement: 'bottom-start',
});
const setHotkeyScope = useSetHotkeyScope();
useEffect(() => {
setHotkeyScope(SelectFieldHotkeyScope.SelectField);
}, [setHotkeyScope]);
useEffect(() => {
onFilterChange?.(optionsInDropDown);
}, [onFilterChange, optionsInDropDown]);
useListenClickOutside({
refs: [refs.floating],
callback: (event) => {
event.stopImmediatePropagation();
const weAreNotInAnHTMLInput = !(
event.target instanceof HTMLInputElement &&
event.target.tagName === 'INPUT'
);
if (weAreNotInAnHTMLInput && isDefined(onCancel)) {
onCancel();
}
},
});
useScopedHotkeys(
Key.Enter,
() => {
const selectedOption = optionsInDropDown.find((option) =>
option.label.toLowerCase().includes(searchFilter.toLowerCase()),
);
if (isDefined(selectedOption)) {
handleOptionChange(selectedOption);
}
},
SelectFieldHotkeyScope.SelectField,
[searchFilter, optionsInDropDown],
);
return (
<StyledRelationPickerContainer
ref={refs.setFloating}
style={floatingStyles}
>
<DropdownMenu ref={containerRef} data-select-disable>
<DropdownMenuSearchInput
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
autoFocus
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{onClear && clearLabel && (
<MenuItemSelectTag
key={`No ${clearLabel}`}
selected={false}
text={`No ${clearLabel}`}
color="transparent"
variant="outline"
onClick={() => {
setSelectedOption(undefined);
onClear();
}}
/>
)}
{optionsInDropDown.map((option) => {
return (
<MenuItemSelectTag
key={option.value}
selected={selectedOption?.value === option.value}
text={option.label}
color={option.color as TagColor}
onClick={() => handleOptionChange(option)}
/>
);
})}
</DropdownMenuItemsContainer>
</DropdownMenu>
</StyledRelationPickerContainer>
);
};

View File

@ -14,11 +14,15 @@ const StyledContainer = styled.div<{ isLast: boolean }>`
}
`;
const StyledStepCircle = styled(motion.div)`
const StyledStepCircle = styled(motion.div)<{ isNextStep: boolean }>`
align-items: center;
border-radius: 50%;
border-style: solid;
border-width: 1px;
border-color: ${({ theme, isNextStep }) =>
isNextStep
? theme.border.color.inverted
: theme.border.color.medium} !important;
display: flex;
flex-basis: auto;
flex-shrink: 0;
@ -29,17 +33,20 @@ const StyledStepCircle = styled(motion.div)`
width: 20px;
`;
const StyledStepIndex = styled.span`
color: ${({ theme }) => theme.font.color.tertiary};
const StyledStepIndex = styled.span<{ isNextStep: boolean }>`
color: ${({ theme, isNextStep }) =>
isNextStep ? theme.font.color.secondary : theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
const StyledStepLabel = styled.span<{ isActive: boolean }>`
color: ${({ theme, isActive }) =>
isActive ? theme.font.color.primary : theme.font.color.tertiary};
const StyledStepLabel = styled.span<{ isActive: boolean; isNextStep: boolean }>`
color: ${({ theme, isActive, isNextStep }) =>
isActive || isNextStep
? theme.font.color.primary
: theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-left: ${({ theme }) => theme.spacing(2)};
white-space: nowrap;
`;
@ -58,6 +65,7 @@ export type StepProps = React.PropsWithChildren &
isLast?: boolean;
index?: number;
label: string;
activeStep?: number;
};
export const Step = ({
@ -66,6 +74,7 @@ export const Step = ({
index = 0,
label,
children,
activeStep = 0,
}: StepProps) => {
const theme = useTheme();
const isMobile = useIsMobile();
@ -94,11 +103,14 @@ export const Step = ({
},
};
const isNextStep = activeStep + 1 === index;
return (
<StyledContainer isLast={isLast}>
<StyledStepCircle
variants={variantsCircle}
animate={isActive ? 'active' : 'inactive'}
isNextStep={isNextStep}
>
{isActive && (
<AnimatedCheckmark
@ -106,9 +118,13 @@ export const Step = ({
color={theme.grayScale.gray0}
/>
)}
{!isActive && <StyledStepIndex>{index + 1}</StyledStepIndex>}
{!isActive && (
<StyledStepIndex isNextStep={isNextStep}>{index + 1}</StyledStepIndex>
)}
</StyledStepCircle>
<StyledStepLabel isActive={isActive}>{label}</StyledStepLabel>
<StyledStepLabel isNextStep={isNextStep} isActive={isActive}>
{label}
</StyledStepLabel>
{!isLast && !isMobile && (
<StyledStepLine
variants={variantsLine}

View File

@ -66,7 +66,7 @@ const StyledIconContainer = styled.div`
type TagWeight = 'regular' | 'medium';
type TagVariant = 'solid' | 'outline';
type TagColor = ThemeColor | 'transparent';
export type TagColor = ThemeColor | 'transparent';
type TagProps = {
className?: string;