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 7def066ed26d..2d9b4072e059 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 7128e6129844..7e4edf83926a 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 0716f0acf516..d6cf50ca9aca 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 e58e452c9ab3..26b7fccd7092 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 1b69206fd819..bb7f3d3b2528 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 b8b548c5a222..e2b182ae3594 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 2f09b9f41083..6462fcd8c666 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 1ae9cd6f7c19..dc1d67124a72 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 2e8e8f0396cf..5053c2104ae3 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 f0b52b8b1e31..e645fac42334 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 0cf87b8138eb..fd5aec6c3b03 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 8237c146409d..50bc89f4358a 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 88041e2718d1..6e93d3dfb650 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 666af5c1f43b..20b0051c9147 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 ea053f2f29ef..3d1ad999af7e 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 7a4abd135f32..9a7e7e5f556c 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}; + 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-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)}; + 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 6190742aadf0..265ead428a09 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 000000000000..3e1cd3abb2b2 --- /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 000000000000..ed147aadd6ae --- /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 a7107cb71b89..55c46e433457 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 58e61ba9723c..ba4e3bc3202b 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 000000000000..7b85b17cdd05 --- /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 1b21a981bec7..11d5e6a6caf8 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 a74495da4fbc..000000000000 --- 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 54f1a0403711..9d3109601152 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 c368cdd66b59..534deb1e0b8a 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 bcd0405becaf..21b6d034923c 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 d7eaff8699dd..e3a8499646ce 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 c5b5f05242b9..d6de08e0d1d1 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 30b8e48731b4..3c37538e472a 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 420fccde9d3c..e2427d6175a7 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 0b469fea0f31..fb7e9d78d6e4 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 1a5adabc0346..9126371d1dc2 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 000000000000..ad04f1512648 --- /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 000000000000..9c2bf555dd25 --- /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 fa5cf6d97586..d63692460300 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 661466154ceb..4397231640a8 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 191cdf2081cc..ceb7d205884a 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 0e82bc44fe41..803f37c5af88 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 6a3964769d45..c31a48a198ea 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 000000000000..e08131d9a147 --- /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 01a93c9ac5f6..1b08f142b28e 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 && (