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:  now: ### 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:
committed by
GitHub
parent
eab202f107
commit
9898ca3e53
@ -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 { useClearField } from '@/object-record/record-field/hooks/useClearField';
|
||||||
import { useSelectField } from '@/object-record/record-field/meta-types/hooks/useSelectField';
|
import { useSelectField } from '@/object-record/record-field/meta-types/hooks/useSelectField';
|
||||||
import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent';
|
import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent';
|
||||||
import { SINGLE_ENTITY_SELECT_BASE_LIST } from '@/object-record/relation-picker/constants/SingleEntitySelectBaseList';
|
import { SINGLE_ENTITY_SELECT_BASE_LIST } from '@/object-record/relation-picker/constants/SingleEntitySelectBaseList';
|
||||||
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
|
import { SelectOption } from '@/spreadsheet-import/types';
|
||||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
import { SelectInput } from '@/ui/input/components/SelectInput';
|
||||||
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
|
|
||||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
|
||||||
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
|
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 { 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 { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
import { useState } from 'react';
|
||||||
import { isDefined } from '~/utils/isDefined';
|
import { Key } from 'ts-key-enum';
|
||||||
|
import { isDefined } from 'twenty-ui';
|
||||||
const StyledRelationPickerContainer = styled.div`
|
|
||||||
left: -1px;
|
|
||||||
position: absolute;
|
|
||||||
top: -1px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
type SelectFieldInputProps = {
|
type SelectFieldInputProps = {
|
||||||
onSubmit?: FieldInputEvent;
|
onSubmit?: FieldInputEvent;
|
||||||
@ -36,55 +22,30 @@ export const SelectFieldInput = ({
|
|||||||
}: SelectFieldInputProps) => {
|
}: SelectFieldInputProps) => {
|
||||||
const { persistField, fieldDefinition, fieldValue, hotkeyScope } =
|
const { persistField, fieldDefinition, fieldValue, hotkeyScope } =
|
||||||
useSelectField();
|
useSelectField();
|
||||||
const { selectedItemIdState } = useSelectableListStates({
|
const [selectWrapperRef, setSelectWrapperRef] =
|
||||||
selectableListScopeId: SINGLE_ENTITY_SELECT_BASE_LIST,
|
useState<HTMLDivElement | null>(null);
|
||||||
});
|
|
||||||
|
const [filteredOptions, setFilteredOptions] = useState<SelectOption[]>([]);
|
||||||
|
|
||||||
const { handleResetSelectedPosition } = useSelectableList(
|
const { handleResetSelectedPosition } = useSelectableList(
|
||||||
SINGLE_ENTITY_SELECT_BASE_LIST,
|
SINGLE_ENTITY_SELECT_BASE_LIST,
|
||||||
);
|
);
|
||||||
const clearField = useClearField();
|
const clearField = useClearField();
|
||||||
|
|
||||||
const selectedItemId = useRecoilValue(selectedItemIdState);
|
|
||||||
const [searchFilter, setSearchFilter] = useState('');
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const selectedOption = fieldDefinition.metadata.options.find(
|
const selectedOption = fieldDefinition.metadata.options.find(
|
||||||
(option) => option.value === fieldValue,
|
(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
|
// handlers
|
||||||
const handleClearField = () => {
|
const handleClearField = () => {
|
||||||
clearField();
|
clearField();
|
||||||
onCancel?.();
|
onCancel?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
useListenClickOutside({
|
const handleSubmit = (option: SelectOption) => {
|
||||||
refs: [containerRef],
|
onSubmit?.(() => persistField(option?.value));
|
||||||
callback: (event) => {
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
|
|
||||||
const weAreNotInAnHTMLInput = !(
|
handleResetSelectedPosition();
|
||||||
event.target instanceof HTMLInputElement &&
|
};
|
||||||
event.target.tagName === 'INPUT'
|
|
||||||
);
|
|
||||||
if (weAreNotInAnHTMLInput && isDefined(onCancel)) {
|
|
||||||
onCancel();
|
|
||||||
handleResetSelectedPosition();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useScopedHotkeys(
|
useScopedHotkeys(
|
||||||
Key.Escape,
|
Key.Escape,
|
||||||
@ -96,81 +57,40 @@ export const SelectFieldInput = ({
|
|||||||
[onCancel, handleResetSelectedPosition],
|
[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 = [
|
const optionIds = [
|
||||||
`No ${fieldDefinition.label}`,
|
`No ${fieldDefinition.label}`,
|
||||||
...optionsInDropDown.map((option) => option.value),
|
...filteredOptions.map((option) => option.value),
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectableList
|
<div ref={setSelectWrapperRef}>
|
||||||
selectableListId={SINGLE_ENTITY_SELECT_BASE_LIST}
|
<SelectableList
|
||||||
selectableItemIdArray={optionIds}
|
selectableListId={SINGLE_ENTITY_SELECT_BASE_LIST}
|
||||||
hotkeyScope={hotkeyScope}
|
selectableItemIdArray={optionIds}
|
||||||
onEnter={(itemId) => {
|
hotkeyScope={hotkeyScope}
|
||||||
const option = optionsInDropDown.find(
|
onEnter={(itemId) => {
|
||||||
(option) => option.value === itemId,
|
const option = filteredOptions.find(
|
||||||
);
|
(option) => option.value === itemId,
|
||||||
if (isDefined(option)) {
|
);
|
||||||
onSubmit?.(() => persistField(option.value));
|
if (isDefined(option)) {
|
||||||
handleResetSelectedPosition();
|
onSubmit?.(() => persistField(option.value));
|
||||||
}
|
handleResetSelectedPosition();
|
||||||
}}
|
}
|
||||||
>
|
}}
|
||||||
<StyledRelationPickerContainer ref={containerRef}>
|
>
|
||||||
<DropdownMenu data-select-disable>
|
<SelectInput
|
||||||
<DropdownMenuSearchInput
|
parentRef={selectWrapperRef}
|
||||||
value={searchFilter}
|
onOptionSelected={handleSubmit}
|
||||||
onChange={(event) => setSearchFilter(event.currentTarget.value)}
|
options={fieldDefinition.metadata.options}
|
||||||
autoFocus
|
onCancel={onCancel}
|
||||||
/>
|
defaultOption={selectedOption}
|
||||||
<DropdownMenuSeparator />
|
onFilterChange={setFilteredOptions}
|
||||||
|
onClear={
|
||||||
<DropdownMenuItemsContainer hasMaxHeight>
|
fieldDefinition.metadata.isNullable ? handleClearField : undefined
|
||||||
{fieldDefinition.metadata.isNullable ?? (
|
}
|
||||||
<MenuItemSelectTag
|
clearLabel={fieldDefinition.label}
|
||||||
key={`No ${fieldDefinition.label}`}
|
/>
|
||||||
selected={false}
|
</SelectableList>
|
||||||
text={`No ${fieldDefinition.label}`}
|
</div>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 {
|
} else {
|
||||||
availableFieldsForImport.push({
|
availableFieldsForImport.push({
|
||||||
icon: getIcon(fieldMetadataItem.icon),
|
icon: getIcon(fieldMetadataItem.icon),
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import { FieldValidationDefinition } from '@/spreadsheet-import/types';
|
import {
|
||||||
|
FieldValidationDefinition,
|
||||||
|
SpreadsheetImportFieldType,
|
||||||
|
} from '@/spreadsheet-import/types';
|
||||||
import { IconComponent } from 'twenty-ui';
|
import { IconComponent } from 'twenty-ui';
|
||||||
|
|
||||||
export type AvailableFieldForImport = {
|
export type AvailableFieldForImport = {
|
||||||
icon: IconComponent;
|
icon: IconComponent;
|
||||||
label: string;
|
label: string;
|
||||||
key: string;
|
key: string;
|
||||||
fieldType: {
|
fieldType: SpreadsheetImportFieldType;
|
||||||
type: 'input' | 'checkbox';
|
|
||||||
};
|
|
||||||
fieldValidationDefinitions?: FieldValidationDefinition[];
|
fieldValidationDefinitions?: FieldValidationDefinition[];
|
||||||
};
|
};
|
||||||
|
|||||||
@ -20,7 +20,7 @@ const StyledTitle = styled.span`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledDescription = 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-size: ${({ theme }) => theme.font.size.sm};
|
||||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||||
margin-top: ${({ theme }) => theme.spacing(3)};
|
margin-top: ${({ theme }) => theme.spacing(3)};
|
||||||
|
|||||||
@ -10,6 +10,7 @@ const StyledModal = styled(Modal)`
|
|||||||
height: 61%;
|
height: 61%;
|
||||||
min-height: 600px;
|
min-height: 600px;
|
||||||
min-width: 800px;
|
min-width: 800px;
|
||||||
|
padding: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 63%;
|
width: 63%;
|
||||||
@media (max-width: ${MOBILE_VIEWPORT}px) {
|
@media (max-width: ${MOBILE_VIEWPORT}px) {
|
||||||
@ -42,7 +43,7 @@ export const ModalWrapper = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<StyledModal size="large" onClose={onClose} isClosable={true}>
|
<StyledModal size="large">
|
||||||
<StyledRtlLtr dir={rtl ? 'rtl' : 'ltr'}>
|
<StyledRtlLtr dir={rtl ? 'rtl' : 'ltr'}>
|
||||||
<ModalCloseButton onClose={onClose} />
|
<ModalCloseButton onClose={onClose} />
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -5,15 +5,15 @@ import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
|||||||
|
|
||||||
export const RsiContext = createContext({} as any);
|
export const RsiContext = createContext({} as any);
|
||||||
|
|
||||||
type ProvidersProps<T extends string> = {
|
type ReactSpreadsheetImportContextProviderProps<T extends string> = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
values: SpreadsheetImportDialogOptions<T>;
|
values: SpreadsheetImportDialogOptions<T>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Providers = <T extends string>({
|
export const ReactSpreadsheetImportContextProvider = <T extends string>({
|
||||||
children,
|
children,
|
||||||
values,
|
values,
|
||||||
}: ProvidersProps<T>) => {
|
}: ReactSpreadsheetImportContextProviderProps<T>) => {
|
||||||
if (isUndefinedOrNull(values.fields)) {
|
if (isUndefinedOrNull(values.fields)) {
|
||||||
throw new Error('Fields must be provided to spreadsheet-import');
|
throw new Error('Fields must be provided to spreadsheet-import');
|
||||||
}
|
}
|
||||||
@ -7,8 +7,9 @@ import { Modal } from '@/ui/layout/modal/components/Modal';
|
|||||||
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
||||||
|
|
||||||
const StyledFooter = styled(Modal.Footer)`
|
const StyledFooter = styled(Modal.Footer)`
|
||||||
gap: ${({ theme }) => theme.spacing(2)};
|
gap: ${({ theme }) => theme.spacing(2.5)};
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
padding: ${({ theme }) => theme.spacing(6)} ${({ theme }) => theme.spacing(8)};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
type StepNavigationButtonProps = {
|
type StepNavigationButtonProps = {
|
||||||
@ -23,21 +24,23 @@ export const StepNavigationButton = ({
|
|||||||
title,
|
title,
|
||||||
isLoading,
|
isLoading,
|
||||||
onBack,
|
onBack,
|
||||||
}: StepNavigationButtonProps) => (
|
}: StepNavigationButtonProps) => {
|
||||||
<StyledFooter>
|
return (
|
||||||
{!isUndefinedOrNull(onBack) && (
|
<StyledFooter>
|
||||||
|
{!isUndefinedOrNull(onBack) && (
|
||||||
|
<MainButton
|
||||||
|
Icon={isLoading ? CircularProgressBar : undefined}
|
||||||
|
title="Back"
|
||||||
|
onClick={!isLoading ? onBack : undefined}
|
||||||
|
variant="secondary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<MainButton
|
<MainButton
|
||||||
Icon={isLoading ? CircularProgressBar : undefined}
|
Icon={isLoading ? CircularProgressBar : undefined}
|
||||||
title="Back"
|
title={title}
|
||||||
onClick={!isLoading ? onBack : undefined}
|
onClick={!isLoading ? onClick : undefined}
|
||||||
variant="secondary"
|
variant="primary"
|
||||||
/>
|
/>
|
||||||
)}
|
</StyledFooter>
|
||||||
<MainButton
|
);
|
||||||
Icon={isLoading ? CircularProgressBar : undefined}
|
};
|
||||||
title={title}
|
|
||||||
onClick={!isLoading ? onClick : undefined}
|
|
||||||
variant="primary"
|
|
||||||
/>
|
|
||||||
</StyledFooter>
|
|
||||||
);
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { RecoilRoot, useRecoilState } from 'recoil';
|
|||||||
|
|
||||||
import { useOpenSpreadsheetImportDialog } from '@/spreadsheet-import/hooks/useOpenSpreadsheetImportDialog';
|
import { useOpenSpreadsheetImportDialog } from '@/spreadsheet-import/hooks/useOpenSpreadsheetImportDialog';
|
||||||
import { spreadsheetImportDialogState } from '@/spreadsheet-import/states/spreadsheetImportDialogState';
|
import { spreadsheetImportDialogState } from '@/spreadsheet-import/states/spreadsheetImportDialogState';
|
||||||
import { StepType } from '@/spreadsheet-import/steps/components/UploadFlow';
|
import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType';
|
||||||
import {
|
import {
|
||||||
ImportedRow,
|
ImportedRow,
|
||||||
SpreadsheetImportDialogOptions,
|
SpreadsheetImportDialogOptions,
|
||||||
@ -38,7 +38,7 @@ export const mockedSpreadsheetOptions: SpreadsheetImportDialogOptions<Spreadshee
|
|||||||
autoMapHeaders: true,
|
autoMapHeaders: true,
|
||||||
autoMapDistance: 1,
|
autoMapDistance: 1,
|
||||||
initialStepState: {
|
initialStepState: {
|
||||||
type: StepType.upload,
|
type: SpreadsheetImportStepType.upload,
|
||||||
},
|
},
|
||||||
dateFormat: 'MM/DD/YY',
|
dateFormat: 'MM/DD/YY',
|
||||||
parseRaw: true,
|
parseRaw: true,
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { act, renderHook } from '@testing-library/react';
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useSpreadsheetImportInitialStep } from '@/spreadsheet-import/hooks/useSpreadsheetImportInitialStep';
|
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', () => {
|
describe('useSpreadsheetImportInitialStep', () => {
|
||||||
it('should return correct number for each step type', async () => {
|
it('should return correct number for each step type', async () => {
|
||||||
const { result } = renderHook(() => {
|
const { result } = renderHook(() => {
|
||||||
const [step, setStep] = useState<StepType | undefined>();
|
const [step, setStep] = useState<SpreadsheetImportStepType | undefined>();
|
||||||
const { initialStep } = useSpreadsheetImportInitialStep(step);
|
const { initialStep } = useSpreadsheetImportInitialStep(step);
|
||||||
return { initialStep, setStep };
|
return { initialStep, setStep };
|
||||||
});
|
});
|
||||||
@ -15,31 +15,31 @@ describe('useSpreadsheetImportInitialStep', () => {
|
|||||||
expect(result.current.initialStep).toBe(-1);
|
expect(result.current.initialStep).toBe(-1);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.setStep(StepType.upload);
|
result.current.setStep(SpreadsheetImportStepType.upload);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.initialStep).toBe(0);
|
expect(result.current.initialStep).toBe(0);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.setStep(StepType.selectSheet);
|
result.current.setStep(SpreadsheetImportStepType.selectSheet);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.initialStep).toBe(0);
|
expect(result.current.initialStep).toBe(0);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.setStep(StepType.selectHeader);
|
result.current.setStep(SpreadsheetImportStepType.selectHeader);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.initialStep).toBe(0);
|
expect(result.current.initialStep).toBe(0);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.setStep(StepType.matchColumns);
|
result.current.setStep(SpreadsheetImportStepType.matchColumns);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.initialStep).toBe(2);
|
expect(result.current.initialStep).toBe(2);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.setStep(StepType.validateData);
|
result.current.setStep(SpreadsheetImportStepType.validateData);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.initialStep).toBe(3);
|
expect(result.current.initialStep).toBe(3);
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
import { renderHook } from '@testing-library/react';
|
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 { mockedSpreadsheetOptions } from '@/spreadsheet-import/hooks/__tests__/useSpreadsheetImport.test';
|
||||||
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
||||||
|
|
||||||
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
<Providers values={mockedSpreadsheetOptions}>{children}</Providers>
|
<ReactSpreadsheetImportContextProvider values={mockedSpreadsheetOptions}>
|
||||||
|
{children}
|
||||||
|
</ReactSpreadsheetImportContextProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
describe('useSpreadsheetImportInternal', () => {
|
describe('useSpreadsheetImportInternal', () => {
|
||||||
|
|||||||
@ -1,21 +1,22 @@
|
|||||||
|
import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { StepType } from '@/spreadsheet-import/steps/components/UploadFlow';
|
export const useSpreadsheetImportInitialStep = (
|
||||||
|
initialStep?: SpreadsheetImportStepType,
|
||||||
export const useSpreadsheetImportInitialStep = (initialStep?: StepType) => {
|
) => {
|
||||||
const steps = ['uploadStep', 'matchColumnsStep', 'validationStep'] as const;
|
const steps = ['uploadStep', 'matchColumnsStep', 'validationStep'] as const;
|
||||||
|
|
||||||
const initialStepNumber = useMemo(() => {
|
const initialStepNumber = useMemo(() => {
|
||||||
switch (initialStep) {
|
switch (initialStep) {
|
||||||
case StepType.upload:
|
case SpreadsheetImportStepType.upload:
|
||||||
return 0;
|
return 0;
|
||||||
case StepType.selectSheet:
|
case SpreadsheetImportStepType.selectSheet:
|
||||||
return 0;
|
return 0;
|
||||||
case StepType.selectHeader:
|
case SpreadsheetImportStepType.selectHeader:
|
||||||
return 0;
|
return 0;
|
||||||
case StepType.matchColumns:
|
case SpreadsheetImportStepType.matchColumns:
|
||||||
return 2;
|
return 2;
|
||||||
case StepType.validateData:
|
case SpreadsheetImportStepType.validateData:
|
||||||
return 3;
|
return 3;
|
||||||
default:
|
default:
|
||||||
return -1;
|
return -1;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useContext } from 'react';
|
import { useContext } from 'react';
|
||||||
import { SetRequired } from 'type-fest';
|
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 { defaultSpreadsheetImportProps } from '@/spreadsheet-import/provider/components/SpreadsheetImport';
|
||||||
import { SpreadsheetImportDialogOptions } from '@/spreadsheet-import/types';
|
import { SpreadsheetImportDialogOptions } from '@/spreadsheet-import/types';
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper';
|
import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper';
|
||||||
import { Providers } from '@/spreadsheet-import/components/Providers';
|
import { ReactSpreadsheetImportContextProvider } from '@/spreadsheet-import/components/ReactSpreadsheetImportContextProvider';
|
||||||
import { Steps } from '@/spreadsheet-import/steps/components/Steps';
|
import { SpreadsheetImportStepperContainer } from '@/spreadsheet-import/steps/components/SpreadsheetImportStepperContainer';
|
||||||
import { SpreadsheetImportDialogOptions as SpreadsheetImportProps } from '@/spreadsheet-import/types';
|
import { SpreadsheetImportDialogOptions as SpreadsheetImportProps } from '@/spreadsheet-import/types';
|
||||||
|
|
||||||
export const defaultSpreadsheetImportProps: Partial<
|
export const defaultSpreadsheetImportProps: Partial<
|
||||||
@ -25,11 +25,11 @@ export const SpreadsheetImport = <T extends string>(
|
|||||||
props: SpreadsheetImportProps<T>,
|
props: SpreadsheetImportProps<T>,
|
||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
<Providers values={props}>
|
<ReactSpreadsheetImportContextProvider values={props}>
|
||||||
<ModalWrapper isOpen={props.isOpen} onClose={props.onClose}>
|
<ModalWrapper isOpen={props.isOpen} onClose={props.onClose}>
|
||||||
<Steps />
|
<SpreadsheetImportStepperContainer />
|
||||||
</ModalWrapper>
|
</ModalWrapper>
|
||||||
</Providers>
|
</ReactSpreadsheetImportContextProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useRecoilState } from 'recoil';
|
import { useRecoilState, useSetRecoilState } from 'recoil';
|
||||||
|
|
||||||
import { spreadsheetImportDialogState } from '@/spreadsheet-import/states/spreadsheetImportDialogState';
|
import { spreadsheetImportDialogState } from '@/spreadsheet-import/states/spreadsheetImportDialogState';
|
||||||
|
|
||||||
|
import { matchColumnsState } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/states/initialComputedColumnsState';
|
||||||
import { SpreadsheetImport } from './SpreadsheetImport';
|
import { SpreadsheetImport } from './SpreadsheetImport';
|
||||||
|
|
||||||
type SpreadsheetImportProviderProps = React.PropsWithChildren;
|
type SpreadsheetImportProviderProps = React.PropsWithChildren;
|
||||||
@ -14,11 +15,15 @@ export const SpreadsheetImportProvider = (
|
|||||||
spreadsheetImportDialogState,
|
spreadsheetImportDialogState,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setMatchColumnsState = useSetRecoilState(matchColumnsState);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setSpreadsheetImportDialog({
|
setSpreadsheetImportDialog({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
options: null,
|
options: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setMatchColumnsState([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -4,7 +4,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import { Heading } from '@/spreadsheet-import/components/Heading';
|
import { Heading } from '@/spreadsheet-import/components/Heading';
|
||||||
import { StepNavigationButton } from '@/spreadsheet-import/components/StepNavigationButton';
|
import { StepNavigationButton } from '@/spreadsheet-import/components/StepNavigationButton';
|
||||||
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
||||||
import { Field, ImportedRow } from '@/spreadsheet-import/types';
|
import {
|
||||||
|
Field,
|
||||||
|
ImportedRow,
|
||||||
|
ImportedStructuredRow,
|
||||||
|
} from '@/spreadsheet-import/types';
|
||||||
import { findUnmatchedRequiredFields } from '@/spreadsheet-import/utils/findUnmatchedRequiredFields';
|
import { findUnmatchedRequiredFields } from '@/spreadsheet-import/utils/findUnmatchedRequiredFields';
|
||||||
import { getMatchedColumns } from '@/spreadsheet-import/utils/getMatchedColumns';
|
import { getMatchedColumns } from '@/spreadsheet-import/utils/getMatchedColumns';
|
||||||
import { normalizeTableData } from '@/spreadsheet-import/utils/normalizeTableData';
|
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 { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||||
|
|
||||||
import { Modal } from '@/ui/layout/modal/components/Modal';
|
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 { ColumnGrid } from './components/ColumnGrid';
|
||||||
import { TemplateColumn } from './components/TemplateColumn';
|
import { TemplateColumn } from './components/TemplateColumn';
|
||||||
import { UserTableColumn } from './components/UserTableColumn';
|
import { UserTableColumn } from './components/UserTableColumn';
|
||||||
@ -45,15 +55,15 @@ const StyledColumn = styled.span`
|
|||||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export type MatchColumnsStepProps<T extends string> = {
|
export type MatchColumnsStepProps = {
|
||||||
data: ImportedRow[];
|
data: ImportedRow[];
|
||||||
headerValues: ImportedRow;
|
headerValues: ImportedRow;
|
||||||
onContinue: (
|
onBack?: () => void;
|
||||||
data: any[],
|
setCurrentStepState: (currentStepState: SpreadsheetImportStep) => void;
|
||||||
rawData: ImportedRow[],
|
setPreviousStepState: (currentStepState: SpreadsheetImportStep) => void;
|
||||||
columns: Columns<T>,
|
currentStepState: SpreadsheetImportStep;
|
||||||
) => void;
|
nextStep: () => void;
|
||||||
onBack: () => void;
|
errorToast: (message: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum ColumnType {
|
export enum ColumnType {
|
||||||
@ -121,28 +131,30 @@ export type Columns<T extends string> = Column<T>[];
|
|||||||
export const MatchColumnsStep = <T extends string>({
|
export const MatchColumnsStep = <T extends string>({
|
||||||
data,
|
data,
|
||||||
headerValues,
|
headerValues,
|
||||||
onContinue,
|
|
||||||
onBack,
|
onBack,
|
||||||
}: MatchColumnsStepProps<T>) => {
|
setCurrentStepState,
|
||||||
|
setPreviousStepState,
|
||||||
|
currentStepState,
|
||||||
|
nextStep,
|
||||||
|
errorToast,
|
||||||
|
}: MatchColumnsStepProps) => {
|
||||||
const { enqueueDialog } = useDialogManager();
|
const { enqueueDialog } = useDialogManager();
|
||||||
const { enqueueSnackBar } = useSnackBar();
|
const { enqueueSnackBar } = useSnackBar();
|
||||||
const dataExample = data.slice(0, 2);
|
const dataExample = data.slice(0, 2);
|
||||||
const { fields, autoMapHeaders, autoMapDistance } =
|
const { fields, autoMapHeaders, autoMapDistance } =
|
||||||
useSpreadsheetImportInternal<T>();
|
useSpreadsheetImportInternal<T>();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [columns, setColumns] = useState<Columns<T>>(
|
const [columns, setColumns] = useRecoilState(
|
||||||
// Do not remove spread, it indexes empty array elements, otherwise map() skips over them
|
initialComputedColumnsState(headerValues),
|
||||||
([...headerValues] as string[]).map((value, index) => ({
|
|
||||||
type: ColumnType.empty,
|
|
||||||
index,
|
|
||||||
header: value ?? '',
|
|
||||||
})),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { matchColumnsStepHook } = useSpreadsheetImportInternal();
|
||||||
|
|
||||||
const onIgnore = useCallback(
|
const onIgnore = useCallback(
|
||||||
(columnIndex: number) => {
|
(columnIndex: number) => {
|
||||||
setColumns(
|
setColumns(
|
||||||
columns.map((column, index) =>
|
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,
|
(column) => 'value' in column && column.value === field.key,
|
||||||
);
|
);
|
||||||
setColumns(
|
setColumns(
|
||||||
columns.map<Column<T>>((column, index) => {
|
columns.map<Column<string>>((column, index) => {
|
||||||
if (columnIndex === index) {
|
if (columnIndex === index) {
|
||||||
return setColumn(column, field, data);
|
return setColumn(column, field, data);
|
||||||
} else if (index === existingFieldIndex) {
|
} 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(
|
const onSubChange = useCallback(
|
||||||
@ -262,7 +311,10 @@ export const MatchColumnsStep = <T extends string>({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoMapHeaders) {
|
const isInitialColumnsState = columns.every(
|
||||||
|
(column) => column.type === ColumnType.empty,
|
||||||
|
);
|
||||||
|
if (autoMapHeaders && isInitialColumnsState) {
|
||||||
setColumns(getMatchedColumns(columns, fields, data, autoMapDistance));
|
setColumns(getMatchedColumns(columns, fields, data, autoMapDistance));
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@ -290,16 +342,25 @@ export const MatchColumnsStep = <T extends string>({
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
columnIndex={columnIndex}
|
columnIndex={columnIndex}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderUnmatchedColumn={(columns, columnIndex) => (
|
||||||
|
<UnmatchColumn
|
||||||
|
columns={columns}
|
||||||
|
columnIndex={columnIndex}
|
||||||
onSubChange={onSubChange}
|
onSubChange={onSubChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</StyledContent>
|
</StyledContent>
|
||||||
<StepNavigationButton
|
<StepNavigationButton
|
||||||
onBack={onBack}
|
|
||||||
onClick={handleOnContinue}
|
onClick={handleOnContinue}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
title="Continue"
|
title="Next Step"
|
||||||
|
onBack={() => {
|
||||||
|
onBack?.();
|
||||||
|
setColumns([]);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
import { Columns } from '../MatchColumnsStep';
|
import { Columns } from '../MatchColumnsStep';
|
||||||
|
|
||||||
@ -24,9 +24,12 @@ const StyledGrid = styled.div`
|
|||||||
|
|
||||||
type HeightProps = {
|
type HeightProps = {
|
||||||
height?: `${number}px`;
|
height?: `${number}px`;
|
||||||
|
withBorder?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const StyledGridRow = styled.div<HeightProps>`
|
const StyledGridRow = styled.div<HeightProps>`
|
||||||
|
border-bottom: ${({ withBorder, theme }) =>
|
||||||
|
withBorder && `1px solid ${theme.border.color.medium}`};
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@ -34,7 +37,7 @@ const StyledGridRow = styled.div<HeightProps>`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
type PositionProps = {
|
type PositionProps = {
|
||||||
position: 'left' | 'right';
|
position: 'left' | 'right' | 'full-line';
|
||||||
};
|
};
|
||||||
|
|
||||||
const StyledGridCell = styled.div<PositionProps>`
|
const StyledGridCell = styled.div<PositionProps>`
|
||||||
@ -50,11 +53,21 @@ const StyledGridCell = styled.div<PositionProps>`
|
|||||||
return `
|
return `
|
||||||
padding-left: ${theme.spacing(4)};
|
padding-left: ${theme.spacing(4)};
|
||||||
padding-right: ${theme.spacing(2)};
|
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 `
|
return `
|
||||||
padding-left: ${theme.spacing(2)};
|
padding-left: ${theme.spacing(2)};
|
||||||
padding-right: ${theme.spacing(4)};
|
padding-right: ${theme.spacing(4)};
|
||||||
|
padding-top: ${theme.spacing(4)};
|
||||||
`;
|
`;
|
||||||
}};
|
}};
|
||||||
`;
|
`;
|
||||||
@ -89,12 +102,17 @@ type ColumnGridProps<T extends string> = {
|
|||||||
columns: Columns<T>,
|
columns: Columns<T>,
|
||||||
columnIndex: number,
|
columnIndex: number,
|
||||||
) => React.ReactNode;
|
) => React.ReactNode;
|
||||||
|
renderUnmatchedColumn: (
|
||||||
|
columns: Columns<T>,
|
||||||
|
columnIndex: number,
|
||||||
|
) => React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ColumnGrid = <T extends string>({
|
export const ColumnGrid = <T extends string>({
|
||||||
columns,
|
columns,
|
||||||
renderUserColumn,
|
renderUserColumn,
|
||||||
renderTemplateColumn,
|
renderTemplateColumn,
|
||||||
|
renderUnmatchedColumn,
|
||||||
}: ColumnGridProps<T>) => {
|
}: ColumnGridProps<T>) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -107,15 +125,29 @@ export const ColumnGrid = <T extends string>({
|
|||||||
{columns.map((column, index) => {
|
{columns.map((column, index) => {
|
||||||
const userColumn = renderUserColumn(columns, index);
|
const userColumn = renderUserColumn(columns, index);
|
||||||
const templateColumn = renderTemplateColumn(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)) {
|
if (React.isValidElement(userColumn)) {
|
||||||
return (
|
return (
|
||||||
<StyledGridRow key={index}>
|
<div key={index}>
|
||||||
<StyledGridCell position="left">{userColumn}</StyledGridCell>
|
<StyledGridRow withBorder={!isSelect && !isLast}>
|
||||||
<StyledGridCell position="right">
|
<StyledGridCell position="left">
|
||||||
{templateColumn}
|
{userColumn}
|
||||||
</StyledGridCell>
|
</StyledGridCell>
|
||||||
</StyledGridRow>
|
<StyledGridCell position="right">
|
||||||
|
{templateColumn}
|
||||||
|
</StyledGridCell>
|
||||||
|
</StyledGridRow>
|
||||||
|
{isSelect && (
|
||||||
|
<StyledGridRow withBorder={!isLast}>
|
||||||
|
<StyledGridCell position="full-line">
|
||||||
|
{unmatchedColumn}
|
||||||
|
</StyledGridCell>
|
||||||
|
</StyledGridRow>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
|
import { useTheme } from '@emotion/react';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
|
|
||||||
import { MatchColumnSelect } from '@/spreadsheet-import/components/MatchColumnSelect';
|
|
||||||
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
||||||
import { SelectOption } from '@/spreadsheet-import/types';
|
import { SelectOption } from '@/spreadsheet-import/types';
|
||||||
|
|
||||||
import { getFieldOptions } from '@/spreadsheet-import/utils/getFieldOptions';
|
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 {
|
import {
|
||||||
MatchedOptions,
|
MatchedOptions,
|
||||||
MatchedSelectColumn,
|
MatchedSelectColumn,
|
||||||
@ -12,45 +16,106 @@ import {
|
|||||||
} from '../MatchColumnsStep';
|
} from '../MatchColumnsStep';
|
||||||
|
|
||||||
const StyledContainer = styled.div`
|
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-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};
|
color: ${({ theme }) => theme.font.color.primary};
|
||||||
font-size: ${({ theme }) => theme.font.size.sm};
|
cursor: ${({ cursor }) => cursor};
|
||||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
display: flex;
|
||||||
padding-bottom: ${({ theme }) => theme.spacing(2)};
|
gap: ${({ theme }) => theme.spacing(1)};
|
||||||
padding-top: ${({ 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> {
|
interface SubMatchingSelectProps<T> {
|
||||||
option: MatchedOptions<T> | Partial<MatchedOptions<T>>;
|
option: MatchedOptions<T> | Partial<MatchedOptions<T>>;
|
||||||
column: MatchedSelectColumn<T> | MatchedSelectOptionsColumn<T>;
|
column: MatchedSelectColumn<T> | MatchedSelectOptionsColumn<T>;
|
||||||
onSubChange: (val: T, index: number, option: string) => void;
|
onSubChange: (val: T, index: number, option: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
selectedOption?: MatchedOptions<T> | Partial<MatchedOptions<T>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SubMatchingSelect = <T extends string>({
|
export const SubMatchingSelect = <T extends string>({
|
||||||
option,
|
option,
|
||||||
column,
|
column,
|
||||||
onSubChange,
|
onSubChange,
|
||||||
|
placeholder,
|
||||||
}: SubMatchingSelectProps<T>) => {
|
}: SubMatchingSelectProps<T>) => {
|
||||||
const { fields } = useSpreadsheetImportInternal<T>();
|
const { fields } = useSpreadsheetImportInternal<T>();
|
||||||
const options = getFieldOptions(fields, column.value) as SelectOption[];
|
const options = getFieldOptions(fields, column.value) as SelectOption[];
|
||||||
const value = options.find((opt) => opt.value === option.value);
|
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 (
|
return (
|
||||||
<StyledContainer>
|
<StyledContainer>
|
||||||
<StyledSelectLabel>{option.entry}</StyledSelectLabel>
|
<StyledControlContainer cursor="default">
|
||||||
<MatchColumnSelect
|
<StyledControlLabel>
|
||||||
value={value}
|
<StyledLabel>{option.entry}</StyledLabel>
|
||||||
placeholder="Select..."
|
</StyledControlLabel>
|
||||||
onChange={(value) =>
|
<StyledIconChevronDown
|
||||||
onSubChange(value?.value as T, column.index, option.entry ?? '')
|
size={theme.font.size.md}
|
||||||
}
|
color={theme.font.color.tertiary}
|
||||||
options={options}
|
/>
|
||||||
name={option.entry}
|
</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>
|
</StyledContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 styled from '@emotion/styled';
|
||||||
import { IconChevronDown, IconForbid } from 'twenty-ui';
|
import { IconForbid } from 'twenty-ui';
|
||||||
|
|
||||||
import { MatchColumnSelect } from '@/spreadsheet-import/components/MatchColumnSelect';
|
import { MatchColumnSelect } from '@/spreadsheet-import/components/MatchColumnSelect';
|
||||||
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal';
|
||||||
import { Fields } from '@/spreadsheet-import/types';
|
|
||||||
|
|
||||||
import { Column, Columns, ColumnType } from '../MatchColumnsStep';
|
import { Columns, ColumnType } from '../MatchColumnsStep';
|
||||||
|
|
||||||
import { isDefined } from '~/utils/isDefined';
|
|
||||||
import { SubMatchingSelect } from './SubMatchingSelect';
|
|
||||||
|
|
||||||
const StyledContainer = styled.div`
|
const StyledContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -25,89 +13,38 @@ const StyledContainer = styled.div`
|
|||||||
width: 100%;
|
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> = {
|
type TemplateColumnProps<T extends string> = {
|
||||||
columns: Columns<T>;
|
columns: Columns<string>;
|
||||||
columnIndex: number;
|
columnIndex: number;
|
||||||
onChange: (val: T, index: number) => void;
|
onChange: (val: T, index: number) => void;
|
||||||
onSubChange: (val: T, index: number, option: string) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TemplateColumn = <T extends string>({
|
export const TemplateColumn = <T extends string>({
|
||||||
columns,
|
columns,
|
||||||
columnIndex,
|
columnIndex,
|
||||||
onChange,
|
onChange,
|
||||||
onSubChange,
|
|
||||||
}: TemplateColumnProps<T>) => {
|
}: TemplateColumnProps<T>) => {
|
||||||
const { fields } = useSpreadsheetImportInternal<T>();
|
const { fields } = useSpreadsheetImportInternal<T>();
|
||||||
const column = columns[columnIndex];
|
const column = columns[columnIndex];
|
||||||
const isIgnored = column.type === ColumnType.ignored;
|
const isIgnored = column.type === ColumnType.ignored;
|
||||||
const isSelect = 'matchedOptions' in column;
|
|
||||||
const fieldOptions = fields.map(({ icon, label, key }) => {
|
const fieldOptions = fields.map(({ icon, label, key }) => {
|
||||||
const isSelected =
|
const isSelected =
|
||||||
columns.findIndex((column) => {
|
columns.findIndex((column) => {
|
||||||
if ('value' in column) {
|
if ('value' in column) {
|
||||||
return column.value === key;
|
return column.value === key;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}) !== -1;
|
}) !== -1;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
icon,
|
icon: icon,
|
||||||
value: key,
|
value: key,
|
||||||
label,
|
label: label,
|
||||||
disabled: isSelected,
|
disabled: isSelected,
|
||||||
} as const;
|
} as const;
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectOptions = [
|
const selectOptions = [
|
||||||
{
|
{
|
||||||
icon: IconForbid,
|
icon: IconForbid,
|
||||||
@ -116,9 +53,11 @@ export const TemplateColumn = <T extends string>({
|
|||||||
},
|
},
|
||||||
...fieldOptions,
|
...fieldOptions,
|
||||||
];
|
];
|
||||||
|
|
||||||
const selectValue = fieldOptions.find(
|
const selectValue = fieldOptions.find(
|
||||||
({ value }) => 'value' in column && column.value === value,
|
({ value }) => 'value' in column && column.value === value,
|
||||||
);
|
);
|
||||||
|
|
||||||
const ignoreValue = selectOptions.find(
|
const ignoreValue = selectOptions.find(
|
||||||
({ value }) => value === 'do-not-import',
|
({ value }) => value === 'do-not-import',
|
||||||
);
|
);
|
||||||
@ -132,30 +71,6 @@ export const TemplateColumn = <T extends string>({
|
|||||||
options={selectOptions}
|
options={selectOptions}
|
||||||
name={column.header}
|
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>
|
</StyledContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>);
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -6,6 +6,10 @@ import { StepNavigationButton } from '@/spreadsheet-import/components/StepNaviga
|
|||||||
import { ImportedRow } from '@/spreadsheet-import/types';
|
import { ImportedRow } from '@/spreadsheet-import/types';
|
||||||
|
|
||||||
import { Modal } from '@/ui/layout/modal/components/Modal';
|
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';
|
import { SelectHeaderTable } from './components/SelectHeaderTable';
|
||||||
|
|
||||||
const StyledHeading = styled(Heading)`
|
const StyledHeading = styled(Heading)`
|
||||||
@ -20,17 +24,22 @@ const StyledTableContainer = styled.div`
|
|||||||
|
|
||||||
type SelectHeaderStepProps = {
|
type SelectHeaderStepProps = {
|
||||||
importedRows: ImportedRow[];
|
importedRows: ImportedRow[];
|
||||||
onContinue: (
|
setCurrentStepState: (currentStepState: SpreadsheetImportStep) => void;
|
||||||
headerValues: ImportedRow,
|
nextStep: () => void;
|
||||||
importedRows: ImportedRow[],
|
setPreviousStepState: (currentStepState: SpreadsheetImportStep) => void;
|
||||||
) => Promise<void>;
|
errorToast: (message: string) => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
currentStepState: SpreadsheetImportStep;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SelectHeaderStep = ({
|
export const SelectHeaderStep = ({
|
||||||
importedRows,
|
importedRows,
|
||||||
onContinue,
|
setCurrentStepState,
|
||||||
|
nextStep,
|
||||||
|
setPreviousStepState,
|
||||||
|
errorToast,
|
||||||
onBack,
|
onBack,
|
||||||
|
currentStepState,
|
||||||
}: SelectHeaderStepProps) => {
|
}: SelectHeaderStepProps) => {
|
||||||
const [selectedRowIndexes, setSelectedRowIndexes] = useState<
|
const [selectedRowIndexes, setSelectedRowIndexes] = useState<
|
||||||
ReadonlySet<number>
|
ReadonlySet<number>
|
||||||
@ -38,6 +47,34 @@ export const SelectHeaderStep = ({
|
|||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
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 handleContinue = useCallback(async () => {
|
||||||
const [selectedRowIndex] = Array.from(new Set(selectedRowIndexes));
|
const [selectedRowIndex] = Array.from(new Set(selectedRowIndexes));
|
||||||
// We consider data above header to be redundant
|
// We consider data above header to be redundant
|
||||||
|
|||||||
@ -3,10 +3,16 @@ import { useCallback, useState } from 'react';
|
|||||||
|
|
||||||
import { Heading } from '@/spreadsheet-import/components/Heading';
|
import { Heading } from '@/spreadsheet-import/components/Heading';
|
||||||
import { StepNavigationButton } from '@/spreadsheet-import/components/StepNavigationButton';
|
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 { Radio } from '@/ui/input/components/Radio';
|
||||||
import { RadioGroup } from '@/ui/input/components/RadioGroup';
|
import { RadioGroup } from '@/ui/input/components/RadioGroup';
|
||||||
|
|
||||||
import { Modal } from '@/ui/layout/modal/components/Modal';
|
import { Modal } from '@/ui/layout/modal/components/Modal';
|
||||||
|
import { WorkBook } from 'xlsx-ugnis';
|
||||||
|
|
||||||
const StyledContent = styled(Modal.Content)`
|
const StyledContent = styled(Modal.Content)`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -27,19 +33,65 @@ const StyledRadioContainer = styled.div`
|
|||||||
|
|
||||||
type SelectSheetStepProps = {
|
type SelectSheetStepProps = {
|
||||||
sheetNames: string[];
|
sheetNames: string[];
|
||||||
onContinue: (sheetName: string) => Promise<void>;
|
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
setCurrentStepState: (data: SpreadsheetImportStep) => void;
|
||||||
|
errorToast: (message: string) => void;
|
||||||
|
setPreviousStepState: (data: SpreadsheetImportStep) => void;
|
||||||
|
currentStepState: {
|
||||||
|
type: SpreadsheetImportStepType.selectSheet;
|
||||||
|
workbook: WorkBook;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SelectSheetStep = ({
|
export const SelectSheetStep = ({
|
||||||
sheetNames,
|
sheetNames,
|
||||||
onContinue,
|
setCurrentStepState,
|
||||||
|
errorToast,
|
||||||
|
setPreviousStepState,
|
||||||
onBack,
|
onBack,
|
||||||
|
currentStepState,
|
||||||
}: SelectSheetStepProps) => {
|
}: SelectSheetStepProps) => {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const [value, setValue] = useState(sheetNames[0]);
|
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(
|
const handleOnContinue = useCallback(
|
||||||
async (data: typeof value) => {
|
async (data: typeof value) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@ -65,7 +117,7 @@ export const SelectSheetStep = ({
|
|||||||
onClick={() => handleOnContinue(value)}
|
onClick={() => handleOnContinue(value)}
|
||||||
onBack={onBack}
|
onBack={onBack}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
title="Continue"
|
title="Next Step"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -8,7 +8,7 @@ import { StepBar } from '@/ui/navigation/step-bar/components/StepBar';
|
|||||||
import { useStepBar } from '@/ui/navigation/step-bar/hooks/useStepBar';
|
import { useStepBar } from '@/ui/navigation/step-bar/hooks/useStepBar';
|
||||||
|
|
||||||
import { Modal } from '@/ui/layout/modal/components/Modal';
|
import { Modal } from '@/ui/layout/modal/components/Modal';
|
||||||
import { UploadFlow } from './UploadFlow';
|
import { SpreadsheetImportStepper } from './SpreadsheetImportStepper';
|
||||||
|
|
||||||
const StyledHeader = styled(Modal.Header)`
|
const StyledHeader = styled(Modal.Header)`
|
||||||
background-color: ${({ theme }) => theme.background.secondary};
|
background-color: ${({ theme }) => theme.background.secondary};
|
||||||
@ -29,7 +29,7 @@ const stepTitles = {
|
|||||||
validationStep: 'Validate data',
|
validationStep: 'Validate data',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const Steps = () => {
|
export const SpreadsheetImportStepperContainer = () => {
|
||||||
const { initialStepState } = useSpreadsheetImportInternal();
|
const { initialStepState } = useSpreadsheetImportInternal();
|
||||||
|
|
||||||
const { steps, initialStep } = useSpreadsheetImportInitialStep(
|
const { steps, initialStep } = useSpreadsheetImportInitialStep(
|
||||||
@ -45,11 +45,15 @@ export const Steps = () => {
|
|||||||
<StyledHeader>
|
<StyledHeader>
|
||||||
<StepBar activeStep={activeStep}>
|
<StepBar activeStep={activeStep}>
|
||||||
{steps.map((key) => (
|
{steps.map((key) => (
|
||||||
<StepBar.Step label={stepTitles[key]} key={key} />
|
<StepBar.Step
|
||||||
|
activeStep={activeStep}
|
||||||
|
label={stepTitles[key]}
|
||||||
|
key={key}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</StepBar>
|
</StepBar>
|
||||||
</StyledHeader>
|
</StyledHeader>
|
||||||
<UploadFlow nextStep={nextStep} prevStep={prevStep} />
|
<SpreadsheetImportStepper nextStep={nextStep} prevStep={prevStep} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -3,6 +3,12 @@ import { useCallback, useState } from 'react';
|
|||||||
import { WorkBook } from 'xlsx-ugnis';
|
import { WorkBook } from 'xlsx-ugnis';
|
||||||
|
|
||||||
import { Modal } from '@/ui/layout/modal/components/Modal';
|
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';
|
import { DropZone } from './components/DropZone';
|
||||||
|
|
||||||
const StyledContent = styled(Modal.Content)`
|
const StyledContent = styled(Modal.Content)`
|
||||||
@ -10,11 +16,86 @@ const StyledContent = styled(Modal.Content)`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
type UploadStepProps = {
|
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 [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(
|
const handleOnContinue = useCallback(
|
||||||
async (data: WorkBook, file: File) => {
|
async (data: WorkBook, file: File) => {
|
||||||
|
|||||||
@ -79,7 +79,7 @@ const StyledText = styled.span`
|
|||||||
font-size: ${({ theme }) => theme.font.size.sm};
|
font-size: ${({ theme }) => theme.font.size.sm};
|
||||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 15px;
|
padding: 16px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
type DropZoneProps = {
|
type DropZoneProps = {
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import {
|
||||||
// @ts-expect-error Todo: remove usage of react-data-grid
|
Dispatch,
|
||||||
|
SetStateAction,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
// @ts-expect-error Todo: remove usage of react-data-grid`
|
||||||
import { RowsChangeData } from 'react-data-grid';
|
import { RowsChangeData } from 'react-data-grid';
|
||||||
import { IconTrash } from 'twenty-ui';
|
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 { Toggle } from '@/ui/input/components/Toggle';
|
||||||
import { isDefined } from '~/utils/isDefined';
|
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 { Modal } from '@/ui/layout/modal/components/Modal';
|
||||||
import { generateColumns } from './components/columns';
|
import { generateColumns } from './components/columns';
|
||||||
import { ImportedStructuredRowMetadata } from './types';
|
import { ImportedStructuredRowMetadata } from './types';
|
||||||
@ -71,15 +79,15 @@ type ValidationStepProps<T extends string> = {
|
|||||||
initialData: ImportedStructuredRow<T>[];
|
initialData: ImportedStructuredRow<T>[];
|
||||||
importedColumns: Columns<string>;
|
importedColumns: Columns<string>;
|
||||||
file: File;
|
file: File;
|
||||||
onSubmitStart?: () => void;
|
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
setCurrentStepState: Dispatch<SetStateAction<SpreadsheetImportStep>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ValidationStep = <T extends string>({
|
export const ValidationStep = <T extends string>({
|
||||||
initialData,
|
initialData,
|
||||||
importedColumns,
|
importedColumns,
|
||||||
file,
|
file,
|
||||||
onSubmitStart,
|
setCurrentStepState,
|
||||||
onBack,
|
onBack,
|
||||||
}: ValidationStepProps<T>) => {
|
}: ValidationStepProps<T>) => {
|
||||||
const { enqueueDialog } = useDialogManager();
|
const { enqueueDialog } = useDialogManager();
|
||||||
@ -209,7 +217,11 @@ export const ValidationStep = <T extends string>({
|
|||||||
allStructuredRows: data,
|
allStructuredRows: data,
|
||||||
} satisfies ImportValidationResult<T>,
|
} satisfies ImportValidationResult<T>,
|
||||||
);
|
);
|
||||||
onSubmitStart?.();
|
|
||||||
|
setCurrentStepState({
|
||||||
|
type: SpreadsheetImportStepType.loading,
|
||||||
|
});
|
||||||
|
|
||||||
await onSubmit(calculatedData, file);
|
await onSubmit(calculatedData, file);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { Meta } from '@storybook/react';
|
import { Meta } from '@storybook/react';
|
||||||
|
|
||||||
import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper';
|
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 { MatchColumnsStep } from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep';
|
||||||
|
import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep';
|
||||||
import { mockRsiValues } from '@/spreadsheet-import/tests/mockRsiValues';
|
import { mockRsiValues } from '@/spreadsheet-import/tests/mockRsiValues';
|
||||||
import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope';
|
import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope';
|
||||||
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
|
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
|
||||||
@ -61,15 +62,19 @@ const mockData = [
|
|||||||
|
|
||||||
export const Default = () => (
|
export const Default = () => (
|
||||||
<DialogManagerScope dialogManagerScopeId="dialog-manager">
|
<DialogManagerScope dialogManagerScopeId="dialog-manager">
|
||||||
<Providers values={mockRsiValues}>
|
<ReactSpreadsheetImportContextProvider values={mockRsiValues}>
|
||||||
<ModalWrapper isOpen={true} onClose={() => null}>
|
<ModalWrapper isOpen={true} onClose={() => null}>
|
||||||
<MatchColumnsStep
|
<MatchColumnsStep
|
||||||
headerValues={mockData[0] as string[]}
|
headerValues={mockData[0] as string[]}
|
||||||
data={mockData.slice(1)}
|
data={mockData.slice(1)}
|
||||||
onContinue={() => null}
|
|
||||||
onBack={() => null}
|
onBack={() => null}
|
||||||
|
setCurrentStepState={() => null}
|
||||||
|
setPreviousStepState={() => null}
|
||||||
|
currentStepState={{} as SpreadsheetImportStep}
|
||||||
|
nextStep={() => null}
|
||||||
|
errorToast={() => null}
|
||||||
/>
|
/>
|
||||||
</ModalWrapper>
|
</ModalWrapper>
|
||||||
</Providers>
|
</ReactSpreadsheetImportContextProvider>
|
||||||
</DialogManagerScope>
|
</DialogManagerScope>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { Meta } from '@storybook/react';
|
import { Meta } from '@storybook/react';
|
||||||
|
|
||||||
import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper';
|
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 { SelectHeaderStep } from '@/spreadsheet-import/steps/components/SelectHeaderStep/SelectHeaderStep';
|
||||||
|
import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType';
|
||||||
import {
|
import {
|
||||||
headerSelectionTableFields,
|
headerSelectionTableFields,
|
||||||
mockRsiValues,
|
mockRsiValues,
|
||||||
@ -21,14 +22,21 @@ export default meta;
|
|||||||
|
|
||||||
export const Default = () => (
|
export const Default = () => (
|
||||||
<DialogManagerScope dialogManagerScopeId="dialog-manager">
|
<DialogManagerScope dialogManagerScopeId="dialog-manager">
|
||||||
<Providers values={mockRsiValues}>
|
<ReactSpreadsheetImportContextProvider values={mockRsiValues}>
|
||||||
<ModalWrapper isOpen={true} onClose={() => null}>
|
<ModalWrapper isOpen={true} onClose={() => null}>
|
||||||
<SelectHeaderStep
|
<SelectHeaderStep
|
||||||
importedRows={headerSelectionTableFields}
|
importedRows={headerSelectionTableFields}
|
||||||
onContinue={() => Promise.resolve()}
|
setCurrentStepState={() => null}
|
||||||
|
nextStep={() => Promise.resolve()}
|
||||||
|
setPreviousStepState={() => null}
|
||||||
|
errorToast={() => null}
|
||||||
onBack={() => Promise.resolve()}
|
onBack={() => Promise.resolve()}
|
||||||
|
currentStepState={{
|
||||||
|
type: SpreadsheetImportStepType.selectHeader,
|
||||||
|
data: headerSelectionTableFields,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</ModalWrapper>
|
</ModalWrapper>
|
||||||
</Providers>
|
</ReactSpreadsheetImportContextProvider>
|
||||||
</DialogManagerScope>
|
</DialogManagerScope>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { Meta } from '@storybook/react';
|
import { Meta } from '@storybook/react';
|
||||||
|
|
||||||
import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper';
|
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 { SelectSheetStep } from '@/spreadsheet-import/steps/components/SelectSheetStep/SelectSheetStep';
|
||||||
|
import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType';
|
||||||
import { mockRsiValues } from '@/spreadsheet-import/tests/mockRsiValues';
|
import { mockRsiValues } from '@/spreadsheet-import/tests/mockRsiValues';
|
||||||
import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope';
|
import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope';
|
||||||
|
|
||||||
@ -20,14 +21,39 @@ const sheetNames = ['Sheet1', 'Sheet2', 'Sheet3'];
|
|||||||
|
|
||||||
export const Default = () => (
|
export const Default = () => (
|
||||||
<DialogManagerScope dialogManagerScopeId="dialog-manager">
|
<DialogManagerScope dialogManagerScopeId="dialog-manager">
|
||||||
<Providers values={mockRsiValues}>
|
<ReactSpreadsheetImportContextProvider values={mockRsiValues}>
|
||||||
<ModalWrapper isOpen={true} onClose={() => null}>
|
<ModalWrapper isOpen={true} onClose={() => null}>
|
||||||
<SelectSheetStep
|
<SelectSheetStep
|
||||||
sheetNames={sheetNames}
|
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()}
|
onBack={() => Promise.resolve()}
|
||||||
/>
|
/>
|
||||||
</ModalWrapper>
|
</ModalWrapper>
|
||||||
</Providers>
|
</ReactSpreadsheetImportContextProvider>
|
||||||
</DialogManagerScope>
|
</DialogManagerScope>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -5,16 +5,16 @@ import { within } from '@storybook/test';
|
|||||||
import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator';
|
import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator';
|
||||||
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
|
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',
|
title: 'Modules/SpreadsheetImport/Steps',
|
||||||
component: Steps,
|
component: SpreadsheetImportStepperContainer,
|
||||||
decorators: [ComponentWithRecoilScopeDecorator, SnackBarDecorator],
|
decorators: [ComponentWithRecoilScopeDecorator, SnackBarDecorator],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof Steps>;
|
type Story = StoryObj<typeof SpreadsheetImportStepperContainer>;
|
||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
play: async () => {
|
play: async () => {
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { Meta } from '@storybook/react';
|
import { Meta } from '@storybook/react';
|
||||||
|
|
||||||
import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper';
|
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 { UploadStep } from '@/spreadsheet-import/steps/components/UploadStep/UploadStep';
|
||||||
|
import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType';
|
||||||
import { mockRsiValues } from '@/spreadsheet-import/tests/mockRsiValues';
|
import { mockRsiValues } from '@/spreadsheet-import/tests/mockRsiValues';
|
||||||
import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope';
|
import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope';
|
||||||
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
|
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
|
||||||
@ -20,10 +21,19 @@ export default meta;
|
|||||||
|
|
||||||
export const Default = () => (
|
export const Default = () => (
|
||||||
<DialogManagerScope dialogManagerScopeId="dialog-manager">
|
<DialogManagerScope dialogManagerScopeId="dialog-manager">
|
||||||
<Providers values={mockRsiValues}>
|
<ReactSpreadsheetImportContextProvider values={mockRsiValues}>
|
||||||
<ModalWrapper isOpen={true} onClose={() => null}>
|
<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>
|
</ModalWrapper>
|
||||||
</Providers>
|
</ReactSpreadsheetImportContextProvider>
|
||||||
</DialogManagerScope>
|
</DialogManagerScope>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Meta } from '@storybook/react';
|
import { Meta } from '@storybook/react';
|
||||||
|
|
||||||
import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper';
|
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 { ValidationStep } from '@/spreadsheet-import/steps/components/ValidationStep/ValidationStep';
|
||||||
import {
|
import {
|
||||||
editableTableInitialData,
|
editableTableInitialData,
|
||||||
@ -24,15 +24,16 @@ const file = new File([''], 'file.csv');
|
|||||||
|
|
||||||
export const Default = () => (
|
export const Default = () => (
|
||||||
<DialogManagerScope dialogManagerScopeId="dialog-manager">
|
<DialogManagerScope dialogManagerScopeId="dialog-manager">
|
||||||
<Providers values={mockRsiValues}>
|
<ReactSpreadsheetImportContextProvider values={mockRsiValues}>
|
||||||
<ModalWrapper isOpen={true} onClose={() => null}>
|
<ModalWrapper isOpen={true} onClose={() => null}>
|
||||||
<ValidationStep
|
<ValidationStep
|
||||||
initialData={editableTableInitialData}
|
initialData={editableTableInitialData}
|
||||||
file={file}
|
file={file}
|
||||||
importedColumns={importedColums}
|
importedColumns={importedColums}
|
||||||
onBack={() => Promise.resolve()}
|
onBack={() => Promise.resolve()}
|
||||||
|
setCurrentStepState={() => null}
|
||||||
/>
|
/>
|
||||||
</ModalWrapper>
|
</ModalWrapper>
|
||||||
</Providers>
|
</ReactSpreadsheetImportContextProvider>
|
||||||
</DialogManagerScope>
|
</DialogManagerScope>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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;
|
||||||
|
};
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
export enum SpreadsheetImportStepType {
|
||||||
|
upload = 'upload',
|
||||||
|
selectSheet = 'selectSheet',
|
||||||
|
selectHeader = 'selectHeader',
|
||||||
|
matchColumns = 'matchColumns',
|
||||||
|
validateData = 'validateData',
|
||||||
|
loading = 'loading',
|
||||||
|
}
|
||||||
@ -1,9 +1,9 @@
|
|||||||
import { IconComponent } from 'twenty-ui';
|
import { IconComponent, ThemeColor } from 'twenty-ui';
|
||||||
import { ReadonlyDeep } from 'type-fest';
|
import { ReadonlyDeep } from 'type-fest';
|
||||||
|
|
||||||
import { Columns } from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep';
|
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 { ImportedStructuredRowMetadata } from '@/spreadsheet-import/steps/components/ValidationStep/types';
|
||||||
|
import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep';
|
||||||
|
|
||||||
export type SpreadsheetImportDialogOptions<FieldNames extends string> = {
|
export type SpreadsheetImportDialogOptions<FieldNames extends string> = {
|
||||||
// Is modal visible.
|
// 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
|
// Headers matching accuracy: 1 for strict and up for more flexible matching
|
||||||
autoMapDistance?: number;
|
autoMapDistance?: number;
|
||||||
// Initial Step state to be rendered on load
|
// 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.
|
// 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;
|
dateFormat?: string;
|
||||||
// Sets SheetJS "raw" option. If true, parsing will only be applied to xlsx date fields.
|
// 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
|
// Data model RSI uses for spreadsheet imports
|
||||||
export type Fields<T extends string> = ReadonlyDeep<Field<T>[]>;
|
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 = {
|
export type Checkbox = {
|
||||||
type: 'checkbox';
|
type: 'checkbox';
|
||||||
// Alternate values to be treated as booleans, e.g. {yes: true, no: false}
|
// Alternate values to be treated as booleans, e.g. {yes: true, no: false}
|
||||||
@ -107,12 +88,35 @@ export type SelectOption = {
|
|||||||
value: string;
|
value: string;
|
||||||
// Disabled option when already select
|
// Disabled option when already select
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
// Option color
|
||||||
|
color?: ThemeColor;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Input = {
|
export type Input = {
|
||||||
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 =
|
export type FieldValidationDefinition =
|
||||||
| RequiredValidation
|
| RequiredValidation
|
||||||
| UniqueValidation
|
| UniqueValidation
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import { setColumn } from './setColumn';
|
|||||||
export const getMatchedColumns = <T extends string>(
|
export const getMatchedColumns = <T extends string>(
|
||||||
columns: Columns<T>,
|
columns: Columns<T>,
|
||||||
fields: Fields<T>,
|
fields: Fields<T>,
|
||||||
data: MatchColumnsStepProps<T>['data'],
|
data: MatchColumnsStepProps['data'],
|
||||||
autoMapDistance: number,
|
autoMapDistance: number,
|
||||||
) =>
|
) =>
|
||||||
columns.reduce<Column<T>[]>((arr, column) => {
|
columns.reduce<Column<T>[]>((arr, column) => {
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import { uniqueEntries } from './uniqueEntries';
|
|||||||
export const setColumn = <T extends string>(
|
export const setColumn = <T extends string>(
|
||||||
oldColumn: Column<T>,
|
oldColumn: Column<T>,
|
||||||
field?: Field<T>,
|
field?: Field<T>,
|
||||||
data?: MatchColumnsStepProps<T>['data'],
|
data?: MatchColumnsStepProps['data'],
|
||||||
): Column<T> => {
|
): Column<T> => {
|
||||||
if (field?.fieldType.type === 'select') {
|
if (field?.fieldType.type === 'select') {
|
||||||
const fieldOptions = field.fieldType.options;
|
const fieldOptions = field.fieldType.options;
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import {
|
|||||||
} from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep';
|
} from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep';
|
||||||
|
|
||||||
export const uniqueEntries = <T extends string>(
|
export const uniqueEntries = <T extends string>(
|
||||||
data: MatchColumnsStepProps<T>['data'],
|
data: MatchColumnsStepProps['data'],
|
||||||
index: number,
|
index: number,
|
||||||
): Partial<MatchedOptions<T>>[] =>
|
): Partial<MatchedOptions<T>>[] =>
|
||||||
uniqBy(
|
uniqBy(
|
||||||
|
|||||||
@ -68,7 +68,9 @@ const StyledControlLabel = styled.div`
|
|||||||
gap: ${({ theme }) => theme.spacing(1)};
|
gap: ${({ theme }) => theme.spacing(1)};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledIconChevronDown = styled(IconChevronDown)<{ disabled?: boolean }>`
|
const StyledIconChevronDown = styled(IconChevronDown)<{
|
||||||
|
disabled?: boolean;
|
||||||
|
}>`
|
||||||
color: ${({ disabled, theme }) =>
|
color: ${({ disabled, theme }) =>
|
||||||
disabled ? theme.font.color.extraLight : theme.font.color.tertiary};
|
disabled ? theme.font.color.extraLight : theme.font.color.tertiary};
|
||||||
`;
|
`;
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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;
|
align-items: center;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
|
border-color: ${({ theme, isNextStep }) =>
|
||||||
|
isNextStep
|
||||||
|
? theme.border.color.inverted
|
||||||
|
: theme.border.color.medium} !important;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-basis: auto;
|
flex-basis: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@ -29,17 +33,20 @@ const StyledStepCircle = styled(motion.div)`
|
|||||||
width: 20px;
|
width: 20px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledStepIndex = styled.span`
|
const StyledStepIndex = styled.span<{ isNextStep: boolean }>`
|
||||||
color: ${({ theme }) => theme.font.color.tertiary};
|
color: ${({ theme, isNextStep }) =>
|
||||||
|
isNextStep ? theme.font.color.secondary : theme.font.color.tertiary};
|
||||||
font-size: ${({ theme }) => theme.font.size.md};
|
font-size: ${({ theme }) => theme.font.size.md};
|
||||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledStepLabel = styled.span<{ isActive: boolean }>`
|
const StyledStepLabel = styled.span<{ isActive: boolean; isNextStep: boolean }>`
|
||||||
color: ${({ theme, isActive }) =>
|
color: ${({ theme, isActive, isNextStep }) =>
|
||||||
isActive ? theme.font.color.primary : theme.font.color.tertiary};
|
isActive || isNextStep
|
||||||
|
? theme.font.color.primary
|
||||||
|
: theme.font.color.tertiary};
|
||||||
font-size: ${({ theme }) => theme.font.size.md};
|
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)};
|
margin-left: ${({ theme }) => theme.spacing(2)};
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
`;
|
`;
|
||||||
@ -58,6 +65,7 @@ export type StepProps = React.PropsWithChildren &
|
|||||||
isLast?: boolean;
|
isLast?: boolean;
|
||||||
index?: number;
|
index?: number;
|
||||||
label: string;
|
label: string;
|
||||||
|
activeStep?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Step = ({
|
export const Step = ({
|
||||||
@ -66,6 +74,7 @@ export const Step = ({
|
|||||||
index = 0,
|
index = 0,
|
||||||
label,
|
label,
|
||||||
children,
|
children,
|
||||||
|
activeStep = 0,
|
||||||
}: StepProps) => {
|
}: StepProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
@ -94,11 +103,14 @@ export const Step = ({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isNextStep = activeStep + 1 === index;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledContainer isLast={isLast}>
|
<StyledContainer isLast={isLast}>
|
||||||
<StyledStepCircle
|
<StyledStepCircle
|
||||||
variants={variantsCircle}
|
variants={variantsCircle}
|
||||||
animate={isActive ? 'active' : 'inactive'}
|
animate={isActive ? 'active' : 'inactive'}
|
||||||
|
isNextStep={isNextStep}
|
||||||
>
|
>
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<AnimatedCheckmark
|
<AnimatedCheckmark
|
||||||
@ -106,9 +118,13 @@ export const Step = ({
|
|||||||
color={theme.grayScale.gray0}
|
color={theme.grayScale.gray0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!isActive && <StyledStepIndex>{index + 1}</StyledStepIndex>}
|
{!isActive && (
|
||||||
|
<StyledStepIndex isNextStep={isNextStep}>{index + 1}</StyledStepIndex>
|
||||||
|
)}
|
||||||
</StyledStepCircle>
|
</StyledStepCircle>
|
||||||
<StyledStepLabel isActive={isActive}>{label}</StyledStepLabel>
|
<StyledStepLabel isNextStep={isNextStep} isActive={isActive}>
|
||||||
|
{label}
|
||||||
|
</StyledStepLabel>
|
||||||
{!isLast && !isMobile && (
|
{!isLast && !isMobile && (
|
||||||
<StyledStepLine
|
<StyledStepLine
|
||||||
variants={variantsLine}
|
variants={variantsLine}
|
||||||
|
|||||||
@ -66,7 +66,7 @@ const StyledIconContainer = styled.div`
|
|||||||
|
|
||||||
type TagWeight = 'regular' | 'medium';
|
type TagWeight = 'regular' | 'medium';
|
||||||
type TagVariant = 'solid' | 'outline';
|
type TagVariant = 'solid' | 'outline';
|
||||||
type TagColor = ThemeColor | 'transparent';
|
export type TagColor = ThemeColor | 'transparent';
|
||||||
|
|
||||||
type TagProps = {
|
type TagProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user