From 9898ca3e53b6ea4be3514b274938b63c2f679a50 Mon Sep 17 00:00:00 2001 From: "gitstart-app[bot]" <57568882+gitstart-app[bot]@users.noreply.github.com> Date: Thu, 22 Aug 2024 17:42:49 +0200 Subject: [PATCH] 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 \ before: ![](https://assets-service.gitstart.com/25493/2c994e0f-6548-4a9e-8b22-2c6eccb73b2e.png) now: ![](https://assets-service.gitstart.com/25493/f56fd516-7e95-4616-b1ed-c9ea5195a8ae.png)### Demo: Fixes #6135 NOTES: the enter key are not working on main branch too --------- Co-authored-by: gitstart-twenty Co-authored-by: Lucas Bordeau --- .../input/components/SelectFieldInput.tsx | 168 +++-------- .../hooks/useBuildAvailableFieldsForImport.ts | 32 +++ .../types/AvailableFieldForImport.ts | 9 +- .../spreadsheet-import/components/Heading.tsx | 2 +- .../components/ModalWrapper.tsx | 3 +- ...ReactSpreadsheetImportContextProvider.tsx} | 6 +- .../components/StepNavigationButton.tsx | 35 +-- .../__tests__/useSpreadsheetImport.test.tsx | 4 +- .../useSpreadsheetImportInitialStep.test.ts | 16 +- .../useSpreadsheetImportInternal.test.tsx | 6 +- .../hooks/useSpreadsheetImportInitialStep.ts | 17 +- .../hooks/useSpreadsheetImportInternal.ts | 2 +- .../provider/components/SpreadsheetImport.tsx | 10 +- .../components/SpreadsheetImportProvider.tsx | 7 +- .../MatchColumnsStep/MatchColumnsStep.tsx | 107 +++++-- .../components/ColumnGrid.tsx | 48 +++- .../components/SubMatchingSelect.tsx | 99 +++++-- .../components/TemplateColumn.tsx | 103 +------ .../components/UnmatchColumn.tsx | 111 ++++++++ .../states/initialComputedColumnsState.ts | 41 +++ .../SelectHeaderStep/SelectHeaderStep.tsx | 47 +++- .../SelectSheetStep/SelectSheetStep.tsx | 58 +++- .../components/SpreadsheetImportStepper.tsx | 146 ++++++++++ ... => SpreadsheetImportStepperContainer.tsx} | 12 +- .../steps/components/UploadFlow.tsx | 260 ------------------ .../components/UploadStep/UploadStep.tsx | 85 +++++- .../UploadStep/components/DropZone.tsx | 2 +- .../ValidationStep/ValidationStep.tsx | 22 +- .../__stories__/MatchColumns.stories.tsx | 13 +- .../__stories__/SelectHeader.stories.tsx | 16 +- .../__stories__/SelectSheet.stories.tsx | 34 ++- .../components/__stories__/Steps.stories.tsx | 8 +- .../components/__stories__/Upload.stories.tsx | 18 +- .../__stories__/Validation.stories.tsx | 7 +- .../steps/types/SpreadsheetImportStep.ts | 30 ++ .../steps/types/SpreadsheetImportStepType.ts | 8 + .../modules/spreadsheet-import/types/index.ts | 48 ++-- .../utils/getMatchedColumns.ts | 2 +- .../spreadsheet-import/utils/setColumn.ts | 2 +- .../spreadsheet-import/utils/uniqueEntries.ts | 2 +- .../modules/ui/input/components/Select.tsx | 4 +- .../ui/input/components/SelectInput.tsx | 180 ++++++++++++ .../navigation/step-bar/components/Step.tsx | 34 ++- .../src/display/tag/components/Tag.tsx | 2 +- 44 files changed, 1209 insertions(+), 657 deletions(-) rename packages/twenty-front/src/modules/spreadsheet-import/components/{Providers.tsx => ReactSpreadsheetImportContextProvider.tsx} (72%) create mode 100644 packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/UnmatchColumn.tsx create mode 100644 packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/states/initialComputedColumnsState.ts create mode 100644 packages/twenty-front/src/modules/spreadsheet-import/steps/components/SpreadsheetImportStepper.tsx rename packages/twenty-front/src/modules/spreadsheet-import/steps/components/{Steps.tsx => SpreadsheetImportStepperContainer.tsx} (81%) delete mode 100644 packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadFlow.tsx create mode 100644 packages/twenty-front/src/modules/spreadsheet-import/steps/types/SpreadsheetImportStep.ts create mode 100644 packages/twenty-front/src/modules/spreadsheet-import/steps/types/SpreadsheetImportStepType.ts create mode 100644 packages/twenty-front/src/modules/ui/input/components/SelectInput.tsx diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/SelectFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/SelectFieldInput.tsx index 7def066ed..2d9b4072e 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/SelectFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/SelectFieldInput.tsx @@ -1,29 +1,15 @@ -import styled from '@emotion/styled'; -import { useRef, useState } from 'react'; -import { useRecoilValue } from 'recoil'; -import { Key } from 'ts-key-enum'; - import { useClearField } from '@/object-record/record-field/hooks/useClearField'; import { useSelectField } from '@/object-record/record-field/meta-types/hooks/useSelectField'; import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent'; import { SINGLE_ENTITY_SELECT_BASE_LIST } from '@/object-record/relation-picker/constants/SingleEntitySelectBaseList'; -import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; -import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; -import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput'; -import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; +import { SelectOption } from '@/spreadsheet-import/types'; +import { SelectInput } from '@/ui/input/components/SelectInput'; import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; -import { useSelectableListStates } from '@/ui/layout/selectable-list/hooks/internal/useSelectableListStates'; import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; -import { MenuItemSelectTag } from '@/ui/navigation/menu-item/components/MenuItemSelectTag'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; -import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; -import { isDefined } from '~/utils/isDefined'; - -const StyledRelationPickerContainer = styled.div` - left: -1px; - position: absolute; - top: -1px; -`; +import { useState } from 'react'; +import { Key } from 'ts-key-enum'; +import { isDefined } from 'twenty-ui'; type SelectFieldInputProps = { onSubmit?: FieldInputEvent; @@ -36,55 +22,30 @@ export const SelectFieldInput = ({ }: SelectFieldInputProps) => { const { persistField, fieldDefinition, fieldValue, hotkeyScope } = useSelectField(); - const { selectedItemIdState } = useSelectableListStates({ - selectableListScopeId: SINGLE_ENTITY_SELECT_BASE_LIST, - }); + const [selectWrapperRef, setSelectWrapperRef] = + useState(null); + + const [filteredOptions, setFilteredOptions] = useState([]); + const { handleResetSelectedPosition } = useSelectableList( SINGLE_ENTITY_SELECT_BASE_LIST, ); const clearField = useClearField(); - const selectedItemId = useRecoilValue(selectedItemIdState); - const [searchFilter, setSearchFilter] = useState(''); - const containerRef = useRef(null); - const selectedOption = fieldDefinition.metadata.options.find( (option) => option.value === fieldValue, ); - - const optionsToSelect = - fieldDefinition.metadata.options.filter((option) => { - return ( - option.value !== fieldValue && - option.label.toLowerCase().includes(searchFilter.toLowerCase()) - ); - }) || []; - - const optionsInDropDown = selectedOption - ? [selectedOption, ...optionsToSelect] - : optionsToSelect; - // handlers const handleClearField = () => { clearField(); onCancel?.(); }; - useListenClickOutside({ - refs: [containerRef], - callback: (event) => { - event.stopImmediatePropagation(); + const handleSubmit = (option: SelectOption) => { + onSubmit?.(() => persistField(option?.value)); - const weAreNotInAnHTMLInput = !( - event.target instanceof HTMLInputElement && - event.target.tagName === 'INPUT' - ); - if (weAreNotInAnHTMLInput && isDefined(onCancel)) { - onCancel(); - handleResetSelectedPosition(); - } - }, - }); + handleResetSelectedPosition(); + }; useScopedHotkeys( Key.Escape, @@ -96,81 +57,40 @@ export const SelectFieldInput = ({ [onCancel, handleResetSelectedPosition], ); - useScopedHotkeys( - Key.Enter, - () => { - const selectedOption = optionsInDropDown.find((option) => - option.label.toLowerCase().includes(searchFilter.toLowerCase()), - ); - - if (isDefined(selectedOption)) { - onSubmit?.(() => persistField(selectedOption.value)); - } - handleResetSelectedPosition(); - }, - hotkeyScope, - ); - const optionIds = [ `No ${fieldDefinition.label}`, - ...optionsInDropDown.map((option) => option.value), + ...filteredOptions.map((option) => option.value), ]; return ( - { - const option = optionsInDropDown.find( - (option) => option.value === itemId, - ); - if (isDefined(option)) { - onSubmit?.(() => persistField(option.value)); - handleResetSelectedPosition(); - } - }} - > - - - setSearchFilter(event.currentTarget.value)} - autoFocus - /> - - - - {fieldDefinition.metadata.isNullable ?? ( - - )} - - {optionsInDropDown.map((option) => { - return ( - { - onSubmit?.(() => persistField(option.value)); - handleResetSelectedPosition(); - }} - isKeySelected={selectedItemId === option.value} - /> - ); - })} - - - - +
+ { + const option = filteredOptions.find( + (option) => option.value === itemId, + ); + if (isDefined(option)) { + onSubmit?.(() => persistField(option.value)); + handleResetSelectedPosition(); + } + }} + > + + +
); }; diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useBuildAvailableFieldsForImport.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useBuildAvailableFieldsForImport.ts index 7128e6129..7e4edf839 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useBuildAvailableFieldsForImport.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/useBuildAvailableFieldsForImport.ts @@ -123,6 +123,38 @@ export const useBuildAvailableFieldsForImport = () => { ), }); }); + } else if (fieldMetadataItem.type === FieldMetadataType.Select) { + availableFieldsForImport.push({ + icon: getIcon(fieldMetadataItem.icon), + label: fieldMetadataItem.label, + key: fieldMetadataItem.name, + fieldType: { + type: 'select', + options: + fieldMetadataItem.options?.map((option) => ({ + label: option.label, + value: option.value, + color: option.color, + })) || [], + }, + fieldValidationDefinitions: getSpreadSheetFieldValidationDefinitions( + fieldMetadataItem.type, + fieldMetadataItem.label + ' (ID)', + ), + }); + } else if (fieldMetadataItem.type === FieldMetadataType.Boolean) { + availableFieldsForImport.push({ + icon: getIcon(fieldMetadataItem.icon), + label: fieldMetadataItem.label, + key: fieldMetadataItem.name, + fieldType: { + type: 'checkbox', + }, + fieldValidationDefinitions: getSpreadSheetFieldValidationDefinitions( + fieldMetadataItem.type, + fieldMetadataItem.label, + ), + }); } else { availableFieldsForImport.push({ icon: getIcon(fieldMetadataItem.icon), diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/types/AvailableFieldForImport.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/types/AvailableFieldForImport.ts index 0716f0acf..d6cf50ca9 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/types/AvailableFieldForImport.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/types/AvailableFieldForImport.ts @@ -1,12 +1,13 @@ -import { FieldValidationDefinition } from '@/spreadsheet-import/types'; +import { + FieldValidationDefinition, + SpreadsheetImportFieldType, +} from '@/spreadsheet-import/types'; import { IconComponent } from 'twenty-ui'; export type AvailableFieldForImport = { icon: IconComponent; label: string; key: string; - fieldType: { - type: 'input' | 'checkbox'; - }; + fieldType: SpreadsheetImportFieldType; fieldValidationDefinitions?: FieldValidationDefinition[]; }; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/components/Heading.tsx b/packages/twenty-front/src/modules/spreadsheet-import/components/Heading.tsx index e58e452c9..26b7fccd7 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/components/Heading.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/components/Heading.tsx @@ -20,7 +20,7 @@ const StyledTitle = styled.span` `; const StyledDescription = styled.span` - color: ${({ theme }) => theme.font.color.primary}; + color: ${({ theme }) => theme.font.color.secondary}; font-size: ${({ theme }) => theme.font.size.sm}; font-weight: ${({ theme }) => theme.font.weight.regular}; margin-top: ${({ theme }) => theme.spacing(3)}; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/components/ModalWrapper.tsx b/packages/twenty-front/src/modules/spreadsheet-import/components/ModalWrapper.tsx index 1b69206fd..bb7f3d3b2 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/components/ModalWrapper.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/components/ModalWrapper.tsx @@ -10,6 +10,7 @@ const StyledModal = styled(Modal)` height: 61%; min-height: 600px; min-width: 800px; + padding: 0; position: relative; width: 63%; @media (max-width: ${MOBILE_VIEWPORT}px) { @@ -42,7 +43,7 @@ export const ModalWrapper = ({ return ( <> {isOpen && ( - + {children} diff --git a/packages/twenty-front/src/modules/spreadsheet-import/components/Providers.tsx b/packages/twenty-front/src/modules/spreadsheet-import/components/ReactSpreadsheetImportContextProvider.tsx similarity index 72% rename from packages/twenty-front/src/modules/spreadsheet-import/components/Providers.tsx rename to packages/twenty-front/src/modules/spreadsheet-import/components/ReactSpreadsheetImportContextProvider.tsx index b8b548c5a..e2b182ae3 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/components/Providers.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/components/ReactSpreadsheetImportContextProvider.tsx @@ -5,15 +5,15 @@ import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; export const RsiContext = createContext({} as any); -type ProvidersProps = { +type ReactSpreadsheetImportContextProviderProps = { children: React.ReactNode; values: SpreadsheetImportDialogOptions; }; -export const Providers = ({ +export const ReactSpreadsheetImportContextProvider = ({ children, values, -}: ProvidersProps) => { +}: ReactSpreadsheetImportContextProviderProps) => { if (isUndefinedOrNull(values.fields)) { throw new Error('Fields must be provided to spreadsheet-import'); } diff --git a/packages/twenty-front/src/modules/spreadsheet-import/components/StepNavigationButton.tsx b/packages/twenty-front/src/modules/spreadsheet-import/components/StepNavigationButton.tsx index 2f09b9f41..6462fcd8c 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/components/StepNavigationButton.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/components/StepNavigationButton.tsx @@ -7,8 +7,9 @@ import { Modal } from '@/ui/layout/modal/components/Modal'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; const StyledFooter = styled(Modal.Footer)` - gap: ${({ theme }) => theme.spacing(2)}; + gap: ${({ theme }) => theme.spacing(2.5)}; justify-content: space-between; + padding: ${({ theme }) => theme.spacing(6)} ${({ theme }) => theme.spacing(8)}; `; type StepNavigationButtonProps = { @@ -23,21 +24,23 @@ export const StepNavigationButton = ({ title, isLoading, onBack, -}: StepNavigationButtonProps) => ( - - {!isUndefinedOrNull(onBack) && ( +}: StepNavigationButtonProps) => { + return ( + + {!isUndefinedOrNull(onBack) && ( + + )} - )} - - -); + + ); +}; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/hooks/__tests__/useSpreadsheetImport.test.tsx b/packages/twenty-front/src/modules/spreadsheet-import/hooks/__tests__/useSpreadsheetImport.test.tsx index 1ae9cd6f7..dc1d67124 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/hooks/__tests__/useSpreadsheetImport.test.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/hooks/__tests__/useSpreadsheetImport.test.tsx @@ -3,7 +3,7 @@ import { RecoilRoot, useRecoilState } from 'recoil'; import { useOpenSpreadsheetImportDialog } from '@/spreadsheet-import/hooks/useOpenSpreadsheetImportDialog'; import { spreadsheetImportDialogState } from '@/spreadsheet-import/states/spreadsheetImportDialogState'; -import { StepType } from '@/spreadsheet-import/steps/components/UploadFlow'; +import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType'; import { ImportedRow, SpreadsheetImportDialogOptions, @@ -38,7 +38,7 @@ export const mockedSpreadsheetOptions: SpreadsheetImportDialogOptions { it('should return correct number for each step type', async () => { const { result } = renderHook(() => { - const [step, setStep] = useState(); + const [step, setStep] = useState(); const { initialStep } = useSpreadsheetImportInitialStep(step); return { initialStep, setStep }; }); @@ -15,31 +15,31 @@ describe('useSpreadsheetImportInitialStep', () => { expect(result.current.initialStep).toBe(-1); act(() => { - result.current.setStep(StepType.upload); + result.current.setStep(SpreadsheetImportStepType.upload); }); expect(result.current.initialStep).toBe(0); act(() => { - result.current.setStep(StepType.selectSheet); + result.current.setStep(SpreadsheetImportStepType.selectSheet); }); expect(result.current.initialStep).toBe(0); act(() => { - result.current.setStep(StepType.selectHeader); + result.current.setStep(SpreadsheetImportStepType.selectHeader); }); expect(result.current.initialStep).toBe(0); act(() => { - result.current.setStep(StepType.matchColumns); + result.current.setStep(SpreadsheetImportStepType.matchColumns); }); expect(result.current.initialStep).toBe(2); act(() => { - result.current.setStep(StepType.validateData); + result.current.setStep(SpreadsheetImportStepType.validateData); }); expect(result.current.initialStep).toBe(3); diff --git a/packages/twenty-front/src/modules/spreadsheet-import/hooks/__tests__/useSpreadsheetImportInternal.test.tsx b/packages/twenty-front/src/modules/spreadsheet-import/hooks/__tests__/useSpreadsheetImportInternal.test.tsx index 2e8e8f039..5053c2104 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/hooks/__tests__/useSpreadsheetImportInternal.test.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/hooks/__tests__/useSpreadsheetImportInternal.test.tsx @@ -1,11 +1,13 @@ import { renderHook } from '@testing-library/react'; -import { Providers } from '@/spreadsheet-import/components/Providers'; +import { ReactSpreadsheetImportContextProvider } from '@/spreadsheet-import/components/ReactSpreadsheetImportContextProvider'; import { mockedSpreadsheetOptions } from '@/spreadsheet-import/hooks/__tests__/useSpreadsheetImport.test'; import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; const Wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} + + {children} + ); describe('useSpreadsheetImportInternal', () => { diff --git a/packages/twenty-front/src/modules/spreadsheet-import/hooks/useSpreadsheetImportInitialStep.ts b/packages/twenty-front/src/modules/spreadsheet-import/hooks/useSpreadsheetImportInitialStep.ts index f0b52b8b1..e645fac42 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/hooks/useSpreadsheetImportInitialStep.ts +++ b/packages/twenty-front/src/modules/spreadsheet-import/hooks/useSpreadsheetImportInitialStep.ts @@ -1,21 +1,22 @@ +import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType'; import { useMemo } from 'react'; -import { StepType } from '@/spreadsheet-import/steps/components/UploadFlow'; - -export const useSpreadsheetImportInitialStep = (initialStep?: StepType) => { +export const useSpreadsheetImportInitialStep = ( + initialStep?: SpreadsheetImportStepType, +) => { const steps = ['uploadStep', 'matchColumnsStep', 'validationStep'] as const; const initialStepNumber = useMemo(() => { switch (initialStep) { - case StepType.upload: + case SpreadsheetImportStepType.upload: return 0; - case StepType.selectSheet: + case SpreadsheetImportStepType.selectSheet: return 0; - case StepType.selectHeader: + case SpreadsheetImportStepType.selectHeader: return 0; - case StepType.matchColumns: + case SpreadsheetImportStepType.matchColumns: return 2; - case StepType.validateData: + case SpreadsheetImportStepType.validateData: return 3; default: return -1; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/hooks/useSpreadsheetImportInternal.ts b/packages/twenty-front/src/modules/spreadsheet-import/hooks/useSpreadsheetImportInternal.ts index 0cf87b813..fd5aec6c3 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/hooks/useSpreadsheetImportInternal.ts +++ b/packages/twenty-front/src/modules/spreadsheet-import/hooks/useSpreadsheetImportInternal.ts @@ -1,7 +1,7 @@ import { useContext } from 'react'; import { SetRequired } from 'type-fest'; -import { RsiContext } from '@/spreadsheet-import/components/Providers'; +import { RsiContext } from '@/spreadsheet-import/components/ReactSpreadsheetImportContextProvider'; import { defaultSpreadsheetImportProps } from '@/spreadsheet-import/provider/components/SpreadsheetImport'; import { SpreadsheetImportDialogOptions } from '@/spreadsheet-import/types'; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/provider/components/SpreadsheetImport.tsx b/packages/twenty-front/src/modules/spreadsheet-import/provider/components/SpreadsheetImport.tsx index 8237c1464..50bc89f43 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/provider/components/SpreadsheetImport.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/provider/components/SpreadsheetImport.tsx @@ -1,6 +1,6 @@ import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper'; -import { Providers } from '@/spreadsheet-import/components/Providers'; -import { Steps } from '@/spreadsheet-import/steps/components/Steps'; +import { ReactSpreadsheetImportContextProvider } from '@/spreadsheet-import/components/ReactSpreadsheetImportContextProvider'; +import { SpreadsheetImportStepperContainer } from '@/spreadsheet-import/steps/components/SpreadsheetImportStepperContainer'; import { SpreadsheetImportDialogOptions as SpreadsheetImportProps } from '@/spreadsheet-import/types'; export const defaultSpreadsheetImportProps: Partial< @@ -25,11 +25,11 @@ export const SpreadsheetImport = ( props: SpreadsheetImportProps, ) => { return ( - + - + - + ); }; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/provider/components/SpreadsheetImportProvider.tsx b/packages/twenty-front/src/modules/spreadsheet-import/provider/components/SpreadsheetImportProvider.tsx index 88041e271..6e93d3dfb 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/provider/components/SpreadsheetImportProvider.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/provider/components/SpreadsheetImportProvider.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { useRecoilState } from 'recoil'; +import { useRecoilState, useSetRecoilState } from 'recoil'; import { spreadsheetImportDialogState } from '@/spreadsheet-import/states/spreadsheetImportDialogState'; +import { matchColumnsState } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/states/initialComputedColumnsState'; import { SpreadsheetImport } from './SpreadsheetImport'; type SpreadsheetImportProviderProps = React.PropsWithChildren; @@ -14,11 +15,15 @@ export const SpreadsheetImportProvider = ( spreadsheetImportDialogState, ); + const setMatchColumnsState = useSetRecoilState(matchColumnsState); + const handleClose = () => { setSpreadsheetImportDialog({ isOpen: false, options: null, }); + + setMatchColumnsState([]); }; return ( diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep.tsx index 666af5c1f..20b0051c9 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep.tsx @@ -4,7 +4,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Heading } from '@/spreadsheet-import/components/Heading'; import { StepNavigationButton } from '@/spreadsheet-import/components/StepNavigationButton'; import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; -import { Field, ImportedRow } from '@/spreadsheet-import/types'; +import { + Field, + ImportedRow, + ImportedStructuredRow, +} from '@/spreadsheet-import/types'; import { findUnmatchedRequiredFields } from '@/spreadsheet-import/utils/findUnmatchedRequiredFields'; import { getMatchedColumns } from '@/spreadsheet-import/utils/getMatchedColumns'; import { normalizeTableData } from '@/spreadsheet-import/utils/normalizeTableData'; @@ -16,6 +20,12 @@ import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/Snac import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { Modal } from '@/ui/layout/modal/components/Modal'; + +import { UnmatchColumn } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/UnmatchColumn'; +import { initialComputedColumnsState } from '@/spreadsheet-import/steps/components/MatchColumnsStep/components/states/initialComputedColumnsState'; +import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep'; +import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType'; +import { useRecoilState } from 'recoil'; import { ColumnGrid } from './components/ColumnGrid'; import { TemplateColumn } from './components/TemplateColumn'; import { UserTableColumn } from './components/UserTableColumn'; @@ -45,15 +55,15 @@ const StyledColumn = styled.span` font-weight: ${({ theme }) => theme.font.weight.regular}; `; -export type MatchColumnsStepProps = { +export type MatchColumnsStepProps = { data: ImportedRow[]; headerValues: ImportedRow; - onContinue: ( - data: any[], - rawData: ImportedRow[], - columns: Columns, - ) => void; - onBack: () => void; + onBack?: () => void; + setCurrentStepState: (currentStepState: SpreadsheetImportStep) => void; + setPreviousStepState: (currentStepState: SpreadsheetImportStep) => void; + currentStepState: SpreadsheetImportStep; + nextStep: () => void; + errorToast: (message: string) => void; }; export enum ColumnType { @@ -121,28 +131,30 @@ export type Columns = Column[]; export const MatchColumnsStep = ({ data, headerValues, - onContinue, onBack, -}: MatchColumnsStepProps) => { + setCurrentStepState, + setPreviousStepState, + currentStepState, + nextStep, + errorToast, +}: MatchColumnsStepProps) => { const { enqueueDialog } = useDialogManager(); const { enqueueSnackBar } = useSnackBar(); const dataExample = data.slice(0, 2); const { fields, autoMapHeaders, autoMapDistance } = useSpreadsheetImportInternal(); const [isLoading, setIsLoading] = useState(false); - const [columns, setColumns] = useState>( - // Do not remove spread, it indexes empty array elements, otherwise map() skips over them - ([...headerValues] as string[]).map((value, index) => ({ - type: ColumnType.empty, - index, - header: value ?? '', - })), + const [columns, setColumns] = useRecoilState( + initialComputedColumnsState(headerValues), ); + + const { matchColumnsStepHook } = useSpreadsheetImportInternal(); + const onIgnore = useCallback( (columnIndex: number) => { setColumns( columns.map((column, index) => - columnIndex === index ? setIgnoreColumn(column) : column, + columnIndex === index ? setIgnoreColumn(column) : column, ), ); }, @@ -176,7 +188,7 @@ export const MatchColumnsStep = ({ (column) => 'value' in column && column.value === field.key, ); setColumns( - columns.map>((column, index) => { + columns.map>((column, index) => { if (columnIndex === index) { return setColumn(column, field, data); } else if (index === existingFieldIndex) { @@ -192,7 +204,44 @@ export const MatchColumnsStep = ({ ); } }, - [columns, onRevertIgnore, onIgnore, fields, data, enqueueSnackBar], + [ + columns, + onRevertIgnore, + onIgnore, + fields, + setColumns, + data, + enqueueSnackBar, + ], + ); + + const onContinue = useCallback( + async ( + values: ImportedStructuredRow[], + rawData: ImportedRow[], + columns: Columns, + ) => { + try { + const data = await matchColumnsStepHook(values, rawData, columns); + setCurrentStepState({ + type: SpreadsheetImportStepType.validateData, + data, + importedColumns: columns, + }); + setPreviousStepState(currentStepState); + nextStep(); + } catch (e) { + errorToast((e as Error).message); + } + }, + [ + errorToast, + matchColumnsStepHook, + nextStep, + setPreviousStepState, + setCurrentStepState, + currentStepState, + ], ); const onSubChange = useCallback( @@ -262,7 +311,10 @@ export const MatchColumnsStep = ({ ]); useEffect(() => { - if (autoMapHeaders) { + const isInitialColumnsState = columns.every( + (column) => column.type === ColumnType.empty, + ); + if (autoMapHeaders && isInitialColumnsState) { setColumns(getMatchedColumns(columns, fields, data, autoMapDistance)); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -290,16 +342,25 @@ export const MatchColumnsStep = ({ columns={columns} columnIndex={columnIndex} onChange={onChange} + /> + )} + renderUnmatchedColumn={(columns, columnIndex) => ( + )} /> { + onBack?.(); + setColumns([]); + }} /> ); diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/ColumnGrid.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/ColumnGrid.tsx index ea053f2f2..3d1ad999a 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/ColumnGrid.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/ColumnGrid.tsx @@ -1,5 +1,5 @@ -import React from 'react'; import styled from '@emotion/styled'; +import React from 'react'; import { Columns } from '../MatchColumnsStep'; @@ -24,9 +24,12 @@ const StyledGrid = styled.div` type HeightProps = { height?: `${number}px`; + withBorder?: boolean; }; const StyledGridRow = styled.div` + border-bottom: ${({ withBorder, theme }) => + withBorder && `1px solid ${theme.border.color.medium}`}; box-sizing: border-box; display: flex; flex-direction: row; @@ -34,7 +37,7 @@ const StyledGridRow = styled.div` `; type PositionProps = { - position: 'left' | 'right'; + position: 'left' | 'right' | 'full-line'; }; const StyledGridCell = styled.div` @@ -50,11 +53,21 @@ const StyledGridCell = styled.div` return ` padding-left: ${theme.spacing(4)}; padding-right: ${theme.spacing(2)}; + padding-top: ${theme.spacing(4)}; + `; + } + if (position === 'full-line') { + return ` + padding-left: ${theme.spacing(2)}; + padding-right: ${theme.spacing(4)}; + padding-top: ${theme.spacing(0)}; + width: 100%; `; } return ` padding-left: ${theme.spacing(2)}; padding-right: ${theme.spacing(4)}; + padding-top: ${theme.spacing(4)}; `; }}; `; @@ -89,12 +102,17 @@ type ColumnGridProps = { columns: Columns, columnIndex: number, ) => React.ReactNode; + renderUnmatchedColumn: ( + columns: Columns, + columnIndex: number, + ) => React.ReactNode; }; export const ColumnGrid = ({ columns, renderUserColumn, renderTemplateColumn, + renderUnmatchedColumn, }: ColumnGridProps) => { return ( <> @@ -107,15 +125,29 @@ export const ColumnGrid = ({ {columns.map((column, index) => { const userColumn = renderUserColumn(columns, index); const templateColumn = renderTemplateColumn(columns, index); + const unmatchedColumn = renderUnmatchedColumn(columns, index); + const isSelect = 'matchedOptions' in columns[index]; + const isLast = index === columns.length - 1; if (React.isValidElement(userColumn)) { return ( - - {userColumn} - - {templateColumn} - - +
+ + + {userColumn} + + + {templateColumn} + + + {isSelect && ( + + + {unmatchedColumn} + + + )} +
); } diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/SubMatchingSelect.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/SubMatchingSelect.tsx index 7a4abd135..9a7e7e5f5 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/SubMatchingSelect.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/SubMatchingSelect.tsx @@ -1,10 +1,14 @@ +import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { MatchColumnSelect } from '@/spreadsheet-import/components/MatchColumnSelect'; import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; import { SelectOption } from '@/spreadsheet-import/types'; + import { getFieldOptions } from '@/spreadsheet-import/utils/getFieldOptions'; +import { SelectInput } from '@/ui/input/components/SelectInput'; +import { useState } from 'react'; +import { IconChevronDown, Tag, TagColor } from 'twenty-ui'; import { MatchedOptions, MatchedSelectColumn, @@ -12,45 +16,106 @@ import { } from '../MatchColumnsStep'; const StyledContainer = styled.div` + align-items: center; + display: flex; + gap: ${({ theme }) => theme.spacing(4)}; + justify-content: space-between; padding-bottom: ${({ theme }) => theme.spacing(1)}; - padding-left: ${({ theme }) => theme.spacing(2)}; `; -const StyledSelectLabel = styled.span` +const StyledControlContainer = styled.div<{ cursor: string }>` + align-items: center; + background-color: ${({ theme }) => theme.background.transparent.lighter}; + border: 1px solid ${({ theme }) => theme.border.color.medium}; + box-sizing: border-box; + border-radius: ${({ theme }) => theme.border.radius.sm}; color: ${({ theme }) => theme.font.color.primary}; - font-size: ${({ theme }) => theme.font.size.sm}; - font-weight: ${({ theme }) => theme.font.weight.medium}; - padding-bottom: ${({ theme }) => theme.spacing(2)}; - padding-top: ${({ theme }) => theme.spacing(1)}; + cursor: ${({ cursor }) => cursor}; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; + height: ${({ theme }) => theme.spacing(8)}; + justify-content: space-between; + padding: 0 ${({ theme }) => theme.spacing(2)}; + width: 100%; +`; + +const StyledLabel = styled.span` + color: ${({ theme }) => theme.font.color.primary}; + font-weight: ${({ theme }) => theme.font.weight.regular}; + font-size: ${({ theme }) => theme.font.size.md}; +`; + +const StyledControlLabel = styled.div` + align-items: center; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledIconChevronDown = styled(IconChevronDown)` + color: ${({ theme }) => theme.font.color.tertiary}; `; interface SubMatchingSelectProps { option: MatchedOptions | Partial>; column: MatchedSelectColumn | MatchedSelectOptionsColumn; onSubChange: (val: T, index: number, option: string) => void; + placeholder: string; + selectedOption?: MatchedOptions | Partial>; } export const SubMatchingSelect = ({ option, column, onSubChange, + placeholder, }: SubMatchingSelectProps) => { const { fields } = useSpreadsheetImportInternal(); const options = getFieldOptions(fields, column.value) as SelectOption[]; const value = options.find((opt) => opt.value === option.value); + const [isOpen, setIsOpen] = useState(false); + const [selectWrapperRef, setSelectWrapperRef] = + useState(null); + + const theme = useTheme(); + + const handleSelect = (selectedOption: SelectOption) => { + onSubChange(selectedOption.value as T, column.index, option.entry ?? ''); + setIsOpen(false); + }; return ( - {option.entry} - - onSubChange(value?.value as T, column.index, option.entry ?? '') - } - options={options} - name={option.entry} - /> + + + {option.entry} + + + + setIsOpen(!isOpen)} + id="control" + ref={setSelectWrapperRef} + > + + + + {isOpen && ( + setIsOpen(false)} + /> + )} + ); }; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/TemplateColumn.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/TemplateColumn.tsx index 6190742aa..265ead428 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/TemplateColumn.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/TemplateColumn.tsx @@ -1,22 +1,10 @@ -// TODO: We should create our own accordion component -import { - Accordion, - AccordionIcon, - AccordionItem, - AccordionPanel, - AccordionButton as ChakraAccordionButton, -} from '@chakra-ui/accordion'; import styled from '@emotion/styled'; -import { IconChevronDown, IconForbid } from 'twenty-ui'; +import { IconForbid } from 'twenty-ui'; import { MatchColumnSelect } from '@/spreadsheet-import/components/MatchColumnSelect'; import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; -import { Fields } from '@/spreadsheet-import/types'; -import { Column, Columns, ColumnType } from '../MatchColumnsStep'; - -import { isDefined } from '~/utils/isDefined'; -import { SubMatchingSelect } from './SubMatchingSelect'; +import { Columns, ColumnType } from '../MatchColumnsStep'; const StyledContainer = styled.div` display: flex; @@ -25,89 +13,38 @@ const StyledContainer = styled.div` width: 100%; `; -const StyledAccordionButton = styled(ChakraAccordionButton)` - align-items: center; - background-color: ${({ theme }) => theme.accent.secondary}; - border: none; - border-radius: ${({ theme }) => theme.border.radius.sm}; - box-sizing: border-box; - color: ${({ theme }) => theme.font.color.primary}; - display: flex; - flex-direction: row; - margin-top: ${({ theme }) => theme.spacing(2)}; - padding-bottom: ${({ theme }) => theme.spacing(1)}; - padding-left: ${({ theme }) => theme.spacing(2)}; - padding-right: ${({ theme }) => theme.spacing(2)}; - padding-top: ${({ theme }) => theme.spacing(1)}; - width: 100%; - - &:hover { - background-color: ${({ theme }) => theme.accent.primary}; - } -`; - -const StyledAccordionContainer = styled.div` - display: flex; - width: 100%; -`; - -const StyledAccordionLabel = styled.span` - color: ${({ theme }) => theme.font.color.primary}; - display: flex; - flex: 1; - font-size: ${({ theme }) => theme.font.size.sm}; - padding-left: ${({ theme }) => theme.spacing(1)}; - text-align: left; -`; - -const getAccordionTitle = ( - fields: Fields, - column: Column, -) => { - 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 = { - columns: Columns; + columns: Columns; columnIndex: number; onChange: (val: T, index: number) => void; - onSubChange: (val: T, index: number, option: string) => void; }; export const TemplateColumn = ({ columns, columnIndex, onChange, - onSubChange, }: TemplateColumnProps) => { const { fields } = useSpreadsheetImportInternal(); const column = columns[columnIndex]; const isIgnored = column.type === ColumnType.ignored; - const isSelect = 'matchedOptions' in column; + const fieldOptions = fields.map(({ icon, label, key }) => { const isSelected = columns.findIndex((column) => { if ('value' in column) { return column.value === key; } - return false; }) !== -1; return { - icon, + icon: icon, value: key, - label, + label: label, disabled: isSelected, } as const; }); + const selectOptions = [ { icon: IconForbid, @@ -116,9 +53,11 @@ export const TemplateColumn = ({ }, ...fieldOptions, ]; + const selectValue = fieldOptions.find( ({ value }) => 'value' in column && column.value === value, ); + const ignoreValue = selectOptions.find( ({ value }) => value === 'do-not-import', ); @@ -132,30 +71,6 @@ export const TemplateColumn = ({ options={selectOptions} name={column.header} /> - {isSelect && ( - - - - - - {getAccordionTitle(fields, column)} - - - - - {column.matchedOptions.map((option) => ( - - ))} - - - - - )} ); }; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/UnmatchColumn.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/UnmatchColumn.tsx new file mode 100644 index 000000000..3e1cd3abb --- /dev/null +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/UnmatchColumn.tsx @@ -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 = ( + fields: Fields, + column: Column, +) => { + 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 = { + columns: Column[]; + columnIndex: number; + onSubChange: (val: T, index: number, option: string) => void; +}; + +export const UnmatchColumn = ({ + columns, + columnIndex, + onSubChange, +}: UnmatchColumnProps) => { + const { fields } = useSpreadsheetImportInternal(); + + const column = columns[columnIndex]; + const isSelect = 'matchedOptions' in column; + + return ( + isSelect && ( + + + + + + + {getAccordionTitle(fields, column)} + + + + + {column.matchedOptions.map((option) => ( + + ))} + + + + + ) + ); +}; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/states/initialComputedColumnsState.ts b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/states/initialComputedColumnsState.ts new file mode 100644 index 000000000..ed147aadd --- /dev/null +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/MatchColumnsStep/components/states/initialComputedColumnsState.ts @@ -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, +}); + +export const initialComputedColumnsState = selectorFamily< + Columns, + ImportedRow +>({ + key: 'InitialComputedColumnsState', + get: + (headerValues: ImportedRow) => + ({ get }) => { + const currentState = get(matchColumnsState) as Columns; + 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; + } else { + return currentState; + } + }, + set: + () => + ({ set }, newValue) => { + set(matchColumnsState, newValue as Columns); + }, +}); diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/SelectHeaderStep.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/SelectHeaderStep.tsx index a7107cb71..55c46e433 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/SelectHeaderStep.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/SelectHeaderStep.tsx @@ -6,6 +6,10 @@ import { StepNavigationButton } from '@/spreadsheet-import/components/StepNaviga import { ImportedRow } from '@/spreadsheet-import/types'; import { Modal } from '@/ui/layout/modal/components/Modal'; + +import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; +import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep'; +import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType'; import { SelectHeaderTable } from './components/SelectHeaderTable'; const StyledHeading = styled(Heading)` @@ -20,17 +24,22 @@ const StyledTableContainer = styled.div` type SelectHeaderStepProps = { importedRows: ImportedRow[]; - onContinue: ( - headerValues: ImportedRow, - importedRows: ImportedRow[], - ) => Promise; + setCurrentStepState: (currentStepState: SpreadsheetImportStep) => void; + nextStep: () => void; + setPreviousStepState: (currentStepState: SpreadsheetImportStep) => void; + errorToast: (message: string) => void; onBack: () => void; + currentStepState: SpreadsheetImportStep; }; export const SelectHeaderStep = ({ importedRows, - onContinue, + setCurrentStepState, + nextStep, + setPreviousStepState, + errorToast, onBack, + currentStepState, }: SelectHeaderStepProps) => { const [selectedRowIndexes, setSelectedRowIndexes] = useState< ReadonlySet @@ -38,6 +47,34 @@ export const SelectHeaderStep = ({ const [isLoading, setIsLoading] = useState(false); + const { selectHeaderStepHook } = useSpreadsheetImportInternal(); + + const onContinue = useCallback( + async (...args: Parameters) => { + try { + const { importedRows: data, headerRow: headerValues } = + await selectHeaderStepHook(...args); + setCurrentStepState({ + type: SpreadsheetImportStepType.matchColumns, + data, + headerValues, + }); + setPreviousStepState(currentStepState); + nextStep(); + } catch (e) { + errorToast((e as Error).message); + } + }, + [ + errorToast, + nextStep, + selectHeaderStepHook, + setPreviousStepState, + setCurrentStepState, + currentStepState, + ], + ); + const handleContinue = useCallback(async () => { const [selectedRowIndex] = Array.from(new Set(selectedRowIndexes)); // We consider data above header to be redundant diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectSheetStep/SelectSheetStep.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectSheetStep/SelectSheetStep.tsx index 58e61ba97..ba4e3bc32 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectSheetStep/SelectSheetStep.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectSheetStep/SelectSheetStep.tsx @@ -3,10 +3,16 @@ import { useCallback, useState } from 'react'; import { Heading } from '@/spreadsheet-import/components/Heading'; import { StepNavigationButton } from '@/spreadsheet-import/components/StepNavigationButton'; +import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; +import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep'; +import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType'; +import { exceedsMaxRecords } from '@/spreadsheet-import/utils/exceedsMaxRecords'; +import { mapWorkbook } from '@/spreadsheet-import/utils/mapWorkbook'; import { Radio } from '@/ui/input/components/Radio'; import { RadioGroup } from '@/ui/input/components/RadioGroup'; import { Modal } from '@/ui/layout/modal/components/Modal'; +import { WorkBook } from 'xlsx-ugnis'; const StyledContent = styled(Modal.Content)` align-items: center; @@ -27,19 +33,65 @@ const StyledRadioContainer = styled.div` type SelectSheetStepProps = { sheetNames: string[]; - onContinue: (sheetName: string) => Promise; onBack: () => void; + setCurrentStepState: (data: SpreadsheetImportStep) => void; + errorToast: (message: string) => void; + setPreviousStepState: (data: SpreadsheetImportStep) => void; + currentStepState: { + type: SpreadsheetImportStepType.selectSheet; + workbook: WorkBook; + }; }; export const SelectSheetStep = ({ sheetNames, - onContinue, + setCurrentStepState, + errorToast, + setPreviousStepState, onBack, + currentStepState, }: SelectSheetStepProps) => { const [isLoading, setIsLoading] = useState(false); const [value, setValue] = useState(sheetNames[0]); + const { maxRecords, uploadStepHook } = useSpreadsheetImportInternal(); + + const onContinue = useCallback( + async (sheetName: string) => { + if ( + maxRecords > 0 && + exceedsMaxRecords( + currentStepState.workbook.Sheets[sheetName], + maxRecords, + ) + ) { + errorToast(`Too many records. Up to ${maxRecords.toString()} allowed`); + return; + } + try { + const mappedWorkbook = await uploadStepHook( + mapWorkbook(currentStepState.workbook, sheetName), + ); + setCurrentStepState({ + type: SpreadsheetImportStepType.selectHeader, + data: mappedWorkbook, + }); + setPreviousStepState(currentStepState); + } catch (e) { + errorToast((e as Error).message); + } + }, + [ + errorToast, + maxRecords, + currentStepState, + setPreviousStepState, + setCurrentStepState, + uploadStepHook, + ], + ); + const handleOnContinue = useCallback( async (data: typeof value) => { setIsLoading(true); @@ -65,7 +117,7 @@ export const SelectSheetStep = ({ onClick={() => handleOnContinue(value)} onBack={onBack} isLoading={isLoading} - title="Continue" + title="Next Step" /> ); diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SpreadsheetImportStepper.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SpreadsheetImportStepper.tsx new file mode 100644 index 000000000..7b85b17cd --- /dev/null +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SpreadsheetImportStepper.tsx @@ -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( + initialStepState || { type: SpreadsheetImportStepType.upload }, + ); + const [previousStepState, setPreviousStepState] = + useState( + initialStepState || { type: SpreadsheetImportStepType.upload }, + ); + + const [uploadedFile, setUploadedFile] = useState(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 ( + + ); + case SpreadsheetImportStepType.selectSheet: + return ( + + ); + case SpreadsheetImportStepType.selectHeader: + return ( + + ); + case SpreadsheetImportStepType.matchColumns: + return ( + { + onBack(); + }} + errorToast={errorToast} + /> + ); + case SpreadsheetImportStepType.validateData: + if (!uploadedFile) { + throw new Error('File not found'); + } + return ( + { + onBack(); + setPreviousStepState( + initialStepState || { type: SpreadsheetImportStepType.upload }, + ); + }} + /> + ); + case SpreadsheetImportStepType.loading: + default: + return ( + + + + ); + } +}; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/Steps.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SpreadsheetImportStepperContainer.tsx similarity index 81% rename from packages/twenty-front/src/modules/spreadsheet-import/steps/components/Steps.tsx rename to packages/twenty-front/src/modules/spreadsheet-import/steps/components/SpreadsheetImportStepperContainer.tsx index 1b21a981b..11d5e6a6c 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/Steps.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SpreadsheetImportStepperContainer.tsx @@ -8,7 +8,7 @@ import { StepBar } from '@/ui/navigation/step-bar/components/StepBar'; import { useStepBar } from '@/ui/navigation/step-bar/hooks/useStepBar'; import { Modal } from '@/ui/layout/modal/components/Modal'; -import { UploadFlow } from './UploadFlow'; +import { SpreadsheetImportStepper } from './SpreadsheetImportStepper'; const StyledHeader = styled(Modal.Header)` background-color: ${({ theme }) => theme.background.secondary}; @@ -29,7 +29,7 @@ const stepTitles = { validationStep: 'Validate data', } as const; -export const Steps = () => { +export const SpreadsheetImportStepperContainer = () => { const { initialStepState } = useSpreadsheetImportInternal(); const { steps, initialStep } = useSpreadsheetImportInitialStep( @@ -45,11 +45,15 @@ export const Steps = () => { {steps.map((key) => ( - + ))} - + ); }; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadFlow.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadFlow.tsx deleted file mode 100644 index a74495da4..000000000 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadFlow.tsx +++ /dev/null @@ -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; - } - | { - 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( - initialStepState || { type: StepType.upload }, - ); - const [previousState, setPreviousState] = useState( - initialStepState || { type: StepType.upload }, - ); - const [uploadedFile, setUploadedFile] = useState(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 ( - { - 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 ( - { - 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 ( - { - 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 ( - { - 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 ( - - setState({ - type: StepType.loading, - }) - } - onBack={() => { - onBack(); - setPreviousState(initialStepState || { type: StepType.upload }); - }} - /> - ); - case StepType.loading: - default: - return ( - - - - ); - } -}; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/UploadStep.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/UploadStep.tsx index 54f1a0403..9d3109601 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/UploadStep.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/UploadStep.tsx @@ -3,6 +3,12 @@ import { useCallback, useState } from 'react'; import { WorkBook } from 'xlsx-ugnis'; import { Modal } from '@/ui/layout/modal/components/Modal'; + +import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; +import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep'; +import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType'; +import { exceedsMaxRecords } from '@/spreadsheet-import/utils/exceedsMaxRecords'; +import { mapWorkbook } from '@/spreadsheet-import/utils/mapWorkbook'; import { DropZone } from './components/DropZone'; const StyledContent = styled(Modal.Content)` @@ -10,11 +16,86 @@ const StyledContent = styled(Modal.Content)` `; type UploadStepProps = { - onContinue: (data: WorkBook, file: File) => Promise; + setUploadedFile: (file: File) => void; + setCurrentStepState: (data: any) => void; + errorToast: (message: string) => void; + nextStep: () => void; + setPreviousStepState: (data: any) => void; + currentStepState: SpreadsheetImportStep; }; -export const UploadStep = ({ onContinue }: UploadStepProps) => { +export const UploadStep = ({ + setUploadedFile, + setCurrentStepState, + errorToast, + nextStep, + setPreviousStepState, + currentStepState, +}: UploadStepProps) => { const [isLoading, setIsLoading] = useState(false); + const { maxRecords, uploadStepHook, selectHeaderStepHook, selectHeader } = + useSpreadsheetImportInternal(); + + const onContinue = useCallback( + async (workbook: WorkBook, file: File) => { + setUploadedFile(file); + const isSingleSheet = workbook.SheetNames.length === 1; + if (isSingleSheet) { + if ( + maxRecords > 0 && + exceedsMaxRecords(workbook.Sheets[workbook.SheetNames[0]], maxRecords) + ) { + errorToast( + `Too many records. Up to ${maxRecords.toString()} allowed`, + ); + return; + } + try { + const mappedWorkbook = await uploadStepHook(mapWorkbook(workbook)); + + if (selectHeader) { + setCurrentStepState({ + type: SpreadsheetImportStepType.selectHeader, + data: mappedWorkbook, + }); + } else { + // Automatically select first row as header + const trimmedData = mappedWorkbook.slice(1); + + const { importedRows: data, headerRow: headerValues } = + await selectHeaderStepHook(mappedWorkbook[0], trimmedData); + + setCurrentStepState({ + type: SpreadsheetImportStepType.matchColumns, + data, + headerValues, + }); + } + } catch (e) { + errorToast((e as Error).message); + } + } else { + setCurrentStepState({ + type: SpreadsheetImportStepType.selectSheet, + workbook, + }); + } + setPreviousStepState(currentStepState); + nextStep(); + }, + [ + errorToast, + maxRecords, + nextStep, + selectHeader, + selectHeaderStepHook, + setPreviousStepState, + setCurrentStepState, + setUploadedFile, + currentStepState, + uploadStepHook, + ], + ); const handleOnContinue = useCallback( async (data: WorkBook, file: File) => { diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/components/DropZone.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/components/DropZone.tsx index c368cdd66..534deb1e0 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/components/DropZone.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/components/DropZone.tsx @@ -79,7 +79,7 @@ const StyledText = styled.span` font-size: ${({ theme }) => theme.font.size.sm}; font-weight: ${({ theme }) => theme.font.weight.medium}; text-align: center; - padding: 15px; + padding: 16px; `; type DropZoneProps = { diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/ValidationStep.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/ValidationStep.tsx index bcd0405be..21b6d0349 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/ValidationStep.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/ValidationStep.tsx @@ -1,6 +1,12 @@ import styled from '@emotion/styled'; -import { useCallback, useMemo, useState } from 'react'; -// @ts-expect-error Todo: remove usage of react-data-grid +import { + Dispatch, + SetStateAction, + useCallback, + useMemo, + useState, +} from 'react'; +// @ts-expect-error Todo: remove usage of react-data-grid` import { RowsChangeData } from 'react-data-grid'; import { IconTrash } from 'twenty-ui'; @@ -22,6 +28,8 @@ import { Button } from '@/ui/input/button/components/Button'; import { Toggle } from '@/ui/input/components/Toggle'; import { isDefined } from '~/utils/isDefined'; +import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep'; +import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType'; import { Modal } from '@/ui/layout/modal/components/Modal'; import { generateColumns } from './components/columns'; import { ImportedStructuredRowMetadata } from './types'; @@ -71,15 +79,15 @@ type ValidationStepProps = { initialData: ImportedStructuredRow[]; importedColumns: Columns; file: File; - onSubmitStart?: () => void; onBack: () => void; + setCurrentStepState: Dispatch>; }; export const ValidationStep = ({ initialData, importedColumns, file, - onSubmitStart, + setCurrentStepState, onBack, }: ValidationStepProps) => { const { enqueueDialog } = useDialogManager(); @@ -209,7 +217,11 @@ export const ValidationStep = ({ allStructuredRows: data, } satisfies ImportValidationResult, ); - onSubmitStart?.(); + + setCurrentStepState({ + type: SpreadsheetImportStepType.loading, + }); + await onSubmit(calculatedData, file); onClose(); }; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/MatchColumns.stories.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/MatchColumns.stories.tsx index d7eaff869..e3a849964 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/MatchColumns.stories.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/MatchColumns.stories.tsx @@ -1,8 +1,9 @@ import { Meta } from '@storybook/react'; import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper'; -import { Providers } from '@/spreadsheet-import/components/Providers'; +import { ReactSpreadsheetImportContextProvider } from '@/spreadsheet-import/components/ReactSpreadsheetImportContextProvider'; import { MatchColumnsStep } from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep'; +import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep'; import { mockRsiValues } from '@/spreadsheet-import/tests/mockRsiValues'; import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; @@ -61,15 +62,19 @@ const mockData = [ export const Default = () => ( - + null}> null} onBack={() => null} + setCurrentStepState={() => null} + setPreviousStepState={() => null} + currentStepState={{} as SpreadsheetImportStep} + nextStep={() => null} + errorToast={() => null} /> - + ); diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/SelectHeader.stories.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/SelectHeader.stories.tsx index c5b5f0524..d6de08e0d 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/SelectHeader.stories.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/SelectHeader.stories.tsx @@ -1,8 +1,9 @@ import { Meta } from '@storybook/react'; import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper'; -import { Providers } from '@/spreadsheet-import/components/Providers'; +import { ReactSpreadsheetImportContextProvider } from '@/spreadsheet-import/components/ReactSpreadsheetImportContextProvider'; import { SelectHeaderStep } from '@/spreadsheet-import/steps/components/SelectHeaderStep/SelectHeaderStep'; +import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType'; import { headerSelectionTableFields, mockRsiValues, @@ -21,14 +22,21 @@ export default meta; export const Default = () => ( - + null}> Promise.resolve()} + setCurrentStepState={() => null} + nextStep={() => Promise.resolve()} + setPreviousStepState={() => null} + errorToast={() => null} onBack={() => Promise.resolve()} + currentStepState={{ + type: SpreadsheetImportStepType.selectHeader, + data: headerSelectionTableFields, + }} /> - + ); diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/SelectSheet.stories.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/SelectSheet.stories.tsx index 30b8e4873..3c37538e4 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/SelectSheet.stories.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/SelectSheet.stories.tsx @@ -1,8 +1,9 @@ import { Meta } from '@storybook/react'; import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper'; -import { Providers } from '@/spreadsheet-import/components/Providers'; +import { ReactSpreadsheetImportContextProvider } from '@/spreadsheet-import/components/ReactSpreadsheetImportContextProvider'; import { SelectSheetStep } from '@/spreadsheet-import/steps/components/SelectSheetStep/SelectSheetStep'; +import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType'; import { mockRsiValues } from '@/spreadsheet-import/tests/mockRsiValues'; import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope'; @@ -20,14 +21,39 @@ const sheetNames = ['Sheet1', 'Sheet2', 'Sheet3']; export const Default = () => ( - + null}> 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()} /> - + ); diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Steps.stories.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Steps.stories.tsx index 420fccde9..e2427d617 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Steps.stories.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Steps.stories.tsx @@ -5,16 +5,16 @@ import { within } from '@storybook/test'; import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; -import { Steps } from '../Steps'; +import { SpreadsheetImportStepperContainer } from '../SpreadsheetImportStepperContainer'; -const meta: Meta = { +const meta: Meta = { title: 'Modules/SpreadsheetImport/Steps', - component: Steps, + component: SpreadsheetImportStepperContainer, decorators: [ComponentWithRecoilScopeDecorator, SnackBarDecorator], }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = { play: async () => { diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Upload.stories.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Upload.stories.tsx index 0b469fea0..fb7e9d78d 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Upload.stories.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Upload.stories.tsx @@ -1,8 +1,9 @@ import { Meta } from '@storybook/react'; import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper'; -import { Providers } from '@/spreadsheet-import/components/Providers'; +import { ReactSpreadsheetImportContextProvider } from '@/spreadsheet-import/components/ReactSpreadsheetImportContextProvider'; import { UploadStep } from '@/spreadsheet-import/steps/components/UploadStep/UploadStep'; +import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType'; import { mockRsiValues } from '@/spreadsheet-import/tests/mockRsiValues'; import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; @@ -20,10 +21,19 @@ export default meta; export const Default = () => ( - + null}> - Promise.resolve()} /> + null} + setCurrentStepState={() => null} + errorToast={() => null} + nextStep={() => null} + setPreviousStepState={() => null} + currentStepState={{ + type: SpreadsheetImportStepType.upload, + }} + /> - + ); diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Validation.stories.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Validation.stories.tsx index 1a5adabc0..9126371d1 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Validation.stories.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Validation.stories.tsx @@ -1,7 +1,7 @@ import { Meta } from '@storybook/react'; import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper'; -import { Providers } from '@/spreadsheet-import/components/Providers'; +import { ReactSpreadsheetImportContextProvider } from '@/spreadsheet-import/components/ReactSpreadsheetImportContextProvider'; import { ValidationStep } from '@/spreadsheet-import/steps/components/ValidationStep/ValidationStep'; import { editableTableInitialData, @@ -24,15 +24,16 @@ const file = new File([''], 'file.csv'); export const Default = () => ( - + null}> Promise.resolve()} + setCurrentStepState={() => null} /> - + ); diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/types/SpreadsheetImportStep.ts b/packages/twenty-front/src/modules/spreadsheet-import/steps/types/SpreadsheetImportStep.ts new file mode 100644 index 000000000..ad04f1512 --- /dev/null +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/types/SpreadsheetImportStep.ts @@ -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; + } + | { + type: SpreadsheetImportStepType.loading; + }; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/types/SpreadsheetImportStepType.ts b/packages/twenty-front/src/modules/spreadsheet-import/steps/types/SpreadsheetImportStepType.ts new file mode 100644 index 000000000..9c2bf555d --- /dev/null +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/types/SpreadsheetImportStepType.ts @@ -0,0 +1,8 @@ +export enum SpreadsheetImportStepType { + upload = 'upload', + selectSheet = 'selectSheet', + selectHeader = 'selectHeader', + matchColumns = 'matchColumns', + validateData = 'validateData', + loading = 'loading', +} diff --git a/packages/twenty-front/src/modules/spreadsheet-import/types/index.ts b/packages/twenty-front/src/modules/spreadsheet-import/types/index.ts index fa5cf6d97..d63692460 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/types/index.ts +++ b/packages/twenty-front/src/modules/spreadsheet-import/types/index.ts @@ -1,9 +1,9 @@ -import { IconComponent } from 'twenty-ui'; +import { IconComponent, ThemeColor } from 'twenty-ui'; import { ReadonlyDeep } from 'type-fest'; import { Columns } from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep'; -import { StepState } from '@/spreadsheet-import/steps/components/UploadFlow'; import { ImportedStructuredRowMetadata } from '@/spreadsheet-import/steps/components/ValidationStep/types'; +import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep'; export type SpreadsheetImportDialogOptions = { // Is modal visible. @@ -47,7 +47,7 @@ export type SpreadsheetImportDialogOptions = { // Headers matching accuracy: 1 for strict and up for more flexible matching autoMapDistance?: number; // Initial Step state to be rendered on load - initialStepState?: StepState; + initialStepState?: SpreadsheetImportStep; // Sets SheetJS dateNF option. If date parsing is applied, date will be formatted e.g. "yyyy-mm-dd hh:mm:ss", "m/d/yy h:mm", 'mmm-yy', etc. dateFormat?: string; // Sets SheetJS "raw" option. If true, parsing will only be applied to xlsx date fields. @@ -67,25 +67,6 @@ export type ImportedStructuredRow = { // Data model RSI uses for spreadsheet imports export type Fields = ReadonlyDeep[]>; -export type Field = { - // Icon - icon: IconComponent | null | undefined; - // UI-facing field label - label: string; - // Field's unique identifier - key: T; - // UI-facing additional information displayed via tooltip and ? icon - description?: string; - // Alternate labels used for fields' auto-matching, e.g. "fname" -> "firstName" - alternateMatches?: string[]; - // Validations used for field entries - fieldValidationDefinitions?: FieldValidationDefinition[]; - // Field entry component, default: Input - fieldType: Checkbox | Select | Input; - // UI-facing values shown to user as field examples pre-upload phase - example?: string; -}; - export type Checkbox = { type: 'checkbox'; // Alternate values to be treated as booleans, e.g. {yes: true, no: false} @@ -107,12 +88,35 @@ export type SelectOption = { value: string; // Disabled option when already select disabled?: boolean; + // Option color + color?: ThemeColor; }; export type Input = { type: 'input'; }; +export type SpreadsheetImportFieldType = Checkbox | Select | Input; + +export type Field = { + // Icon + icon: IconComponent | null | undefined; + // UI-facing field label + label: string; + // Field's unique identifier + key: T; + // UI-facing additional information displayed via tooltip and ? icon + description?: string; + // Alternate labels used for fields' auto-matching, e.g. "fname" -> "firstName" + alternateMatches?: string[]; + // Validations used for field entries + fieldValidationDefinitions?: FieldValidationDefinition[]; + // Field entry component, default: Input + fieldType: SpreadsheetImportFieldType; + // UI-facing values shown to user as field examples pre-upload phase + example?: string; +}; + export type FieldValidationDefinition = | RequiredValidation | UniqueValidation diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/getMatchedColumns.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/getMatchedColumns.ts index 661466154..439723164 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/utils/getMatchedColumns.ts +++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/getMatchedColumns.ts @@ -14,7 +14,7 @@ import { setColumn } from './setColumn'; export const getMatchedColumns = ( columns: Columns, fields: Fields, - data: MatchColumnsStepProps['data'], + data: MatchColumnsStepProps['data'], autoMapDistance: number, ) => columns.reduce[]>((arr, column) => { diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/setColumn.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/setColumn.ts index 191cdf208..ceb7d2058 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/utils/setColumn.ts +++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/setColumn.ts @@ -11,7 +11,7 @@ import { uniqueEntries } from './uniqueEntries'; export const setColumn = ( oldColumn: Column, field?: Field, - data?: MatchColumnsStepProps['data'], + data?: MatchColumnsStepProps['data'], ): Column => { if (field?.fieldType.type === 'select') { const fieldOptions = field.fieldType.options; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/uniqueEntries.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/uniqueEntries.ts index 0e82bc44f..803f37c5a 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/utils/uniqueEntries.ts +++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/uniqueEntries.ts @@ -6,7 +6,7 @@ import { } from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep'; export const uniqueEntries = ( - data: MatchColumnsStepProps['data'], + data: MatchColumnsStepProps['data'], index: number, ): Partial>[] => uniqBy( diff --git a/packages/twenty-front/src/modules/ui/input/components/Select.tsx b/packages/twenty-front/src/modules/ui/input/components/Select.tsx index 6a3964769..c31a48a19 100644 --- a/packages/twenty-front/src/modules/ui/input/components/Select.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/Select.tsx @@ -68,7 +68,9 @@ const StyledControlLabel = styled.div` gap: ${({ theme }) => theme.spacing(1)}; `; -const StyledIconChevronDown = styled(IconChevronDown)<{ disabled?: boolean }>` +const StyledIconChevronDown = styled(IconChevronDown)<{ + disabled?: boolean; +}>` color: ${({ disabled, theme }) => disabled ? theme.font.color.extraLight : theme.font.color.tertiary}; `; diff --git a/packages/twenty-front/src/modules/ui/input/components/SelectInput.tsx b/packages/twenty-front/src/modules/ui/input/components/SelectInput.tsx new file mode 100644 index 000000000..e08131d9a --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/SelectInput.tsx @@ -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(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 ( + + + setSearchFilter(e.target.value)} + autoFocus + /> + + + {onClear && clearLabel && ( + { + setSelectedOption(undefined); + onClear(); + }} + /> + )} + {optionsInDropDown.map((option) => { + return ( + handleOptionChange(option)} + /> + ); + })} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/navigation/step-bar/components/Step.tsx b/packages/twenty-front/src/modules/ui/navigation/step-bar/components/Step.tsx index 01a93c9ac..1b08f142b 100644 --- a/packages/twenty-front/src/modules/ui/navigation/step-bar/components/Step.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/step-bar/components/Step.tsx @@ -14,11 +14,15 @@ const StyledContainer = styled.div<{ isLast: boolean }>` } `; -const StyledStepCircle = styled(motion.div)` +const StyledStepCircle = styled(motion.div)<{ isNextStep: boolean }>` align-items: center; border-radius: 50%; border-style: solid; border-width: 1px; + border-color: ${({ theme, isNextStep }) => + isNextStep + ? theme.border.color.inverted + : theme.border.color.medium} !important; display: flex; flex-basis: auto; flex-shrink: 0; @@ -29,17 +33,20 @@ const StyledStepCircle = styled(motion.div)` width: 20px; `; -const StyledStepIndex = styled.span` - color: ${({ theme }) => theme.font.color.tertiary}; +const StyledStepIndex = styled.span<{ isNextStep: boolean }>` + color: ${({ theme, isNextStep }) => + isNextStep ? theme.font.color.secondary : theme.font.color.tertiary}; font-size: ${({ theme }) => theme.font.size.md}; font-weight: ${({ theme }) => theme.font.weight.medium}; `; -const StyledStepLabel = styled.span<{ isActive: boolean }>` - color: ${({ theme, isActive }) => - isActive ? theme.font.color.primary : theme.font.color.tertiary}; +const StyledStepLabel = styled.span<{ isActive: boolean; isNextStep: boolean }>` + color: ${({ theme, isActive, isNextStep }) => + isActive || isNextStep + ? theme.font.color.primary + : theme.font.color.tertiary}; font-size: ${({ theme }) => theme.font.size.md}; - font-weight: ${({ theme }) => theme.font.weight.medium}; + font-weight: ${({ theme }) => theme.font.weight.semiBold}; margin-left: ${({ theme }) => theme.spacing(2)}; white-space: nowrap; `; @@ -58,6 +65,7 @@ export type StepProps = React.PropsWithChildren & isLast?: boolean; index?: number; label: string; + activeStep?: number; }; export const Step = ({ @@ -66,6 +74,7 @@ export const Step = ({ index = 0, label, children, + activeStep = 0, }: StepProps) => { const theme = useTheme(); const isMobile = useIsMobile(); @@ -94,11 +103,14 @@ export const Step = ({ }, }; + const isNextStep = activeStep + 1 === index; + return ( {isActive && ( )} - {!isActive && {index + 1}} + {!isActive && ( + {index + 1} + )} - {label} + + {label} + {!isLast && !isMobile && (