diff --git a/front/craco.config.js b/front/craco.config.js index 014f218cf..bd382ac35 100644 --- a/front/craco.config.js +++ b/front/craco.config.js @@ -29,7 +29,7 @@ module.exports = { '~/(.+)': "/src/$1", '@/(.+)': "/src/modules/$1", '@testing/(.+)': "/src/testing/$1", - } - } + }, + }, }, }; diff --git a/front/package.json b/front/package.json index 4e1b73138..557e6a743 100644 --- a/front/package.json +++ b/front/package.json @@ -8,6 +8,8 @@ "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@blocknote/core": "^0.8.2", "@blocknote/react": "^0.8.2", + "@chakra-ui/accordion": "^2.3.0", + "@chakra-ui/system": "^2.6.0", "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.5", "@floating-ui/react": "^0.24.3", @@ -29,27 +31,32 @@ "hex-rgb": "^5.0.0", "immer": "^10.0.2", "js-cookie": "^3.0.5", + "js-levenshtein": "^1.1.6", "jwt-decode": "^3.1.2", "libphonenumber-js": "^1.10.26", "lodash.debounce": "^4.0.8", "luxon": "^3.3.0", "react": "^18.2.0", + "react-data-grid": "7.0.0-beta.13", "react-datepicker": "^4.11.0", "react-dom": "^18.2.0", + "react-dropzone": "^14.2.3", "react-hook-form": "^7.45.1", "react-hotkeys-hook": "^4.4.0", "react-loading-skeleton": "^3.3.1", - "react-modal": "^3.16.1", "react-responsive": "^9.0.2", "react-router-dom": "^6.4.4", + "react-select-event": "^5.5.1", "react-textarea-autosize": "^8.4.1", "react-tooltip": "^5.13.1", "recoil": "^0.7.7", "scroll-into-view": "^1.16.2", "ts-key-enum": "^2.0.12", + "type-fest": "^4.1.0", "url": "^0.11.1", "uuid": "^9.0.0", "web-vitals": "^2.1.4", + "xlsx-ugnis": "^0.19.3", "yup": "^1.2.0" }, "scripts": { diff --git a/front/src/hooks/useCombinedRefs.ts b/front/src/hooks/useCombinedRefs.ts new file mode 100644 index 000000000..dd2488262 --- /dev/null +++ b/front/src/hooks/useCombinedRefs.ts @@ -0,0 +1,15 @@ +import React, { Ref, RefCallback } from 'react'; + +export function useCombinedRefs( + ...refs: (Ref | undefined)[] +): RefCallback { + return (node: T) => { + for (const ref of refs) { + if (typeof ref === 'function') { + ref(node); + } else if (ref !== null && ref !== undefined) { + (ref as React.MutableRefObject).current = node; + } + } + }; +} diff --git a/front/src/index.tsx b/front/src/index.tsx index 665a98710..e3390a20b 100644 --- a/front/src/index.tsx +++ b/front/src/index.tsx @@ -5,6 +5,8 @@ import { RecoilRoot } from 'recoil'; import { ApolloProvider } from '@/apollo/components/ApolloProvider'; import { ClientConfigProvider } from '@/client-config/components/ClientConfigProvider'; +import { SpreadsheetImportProvider } from '@/spreadsheet-import/components/SpreadsheetImportProvider'; +import { DialogProvider } from '@/ui/dialog/components/DialogProvider'; import { SnackBarProvider } from '@/ui/snack-bar/components/SnackBarProvider'; import { AppThemeProvider } from '@/ui/theme/components/AppThemeProvider'; import { ThemeType } from '@/ui/theme/constants/theme'; @@ -31,9 +33,13 @@ root.render( - - - + + + + + + + diff --git a/front/src/modules/activities/components/TaskRow.tsx b/front/src/modules/activities/components/TaskRow.tsx index 30e4bc1b1..019b5b18a 100644 --- a/front/src/modules/activities/components/TaskRow.tsx +++ b/front/src/modules/activities/components/TaskRow.tsx @@ -82,7 +82,7 @@ export function TaskRow({ task }: { task: TaskForList }) { diff --git a/front/src/modules/auth/components/Modal.tsx b/front/src/modules/auth/components/Modal.tsx index 5a6574e0f..d1de7c186 100644 --- a/front/src/modules/auth/components/Modal.tsx +++ b/front/src/modules/auth/components/Modal.tsx @@ -1,13 +1,19 @@ import React from 'react'; +import styled from '@emotion/styled'; import { Modal as UIModal } from '@/ui/modal/components/Modal'; +const StyledContent = styled(UIModal.Content)` + align-items: center; + width: calc(400px - ${({ theme }) => theme.spacing(10 * 2)}); +`; + type Props = React.ComponentProps<'div'>; export function AuthModal({ children, ...restProps }: Props) { return ( - {children} + {children} ); } diff --git a/front/src/modules/spreadsheet-import/components/SpreadsheetImport.tsx b/front/src/modules/spreadsheet-import/components/SpreadsheetImport.tsx new file mode 100644 index 000000000..345d0c99c --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/SpreadsheetImport.tsx @@ -0,0 +1,28 @@ +import type { RsiProps } from '../types'; + +import { ModalWrapper } from './core/ModalWrapper'; +import { Providers } from './core/Providers'; +import { Steps } from './steps/Steps'; + +export const defaultRSIProps: Partial> = { + autoMapHeaders: true, + allowInvalidSubmit: true, + autoMapDistance: 2, + uploadStepHook: async (value) => value, + selectHeaderStepHook: async (headerValues, data) => ({ headerValues, data }), + matchColumnsStepHook: async (table) => table, + dateFormat: 'yyyy-mm-dd', // ISO 8601, + parseRaw: true, +} as const; + +export const SpreadsheetImport = (props: RsiProps) => { + return ( + + + + + + ); +}; + +SpreadsheetImport.defaultProps = defaultRSIProps; diff --git a/front/src/modules/spreadsheet-import/components/SpreadsheetImportProvider.tsx b/front/src/modules/spreadsheet-import/components/SpreadsheetImportProvider.tsx new file mode 100644 index 000000000..11ca40879 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/SpreadsheetImportProvider.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { useRecoilState } from 'recoil'; + +import { spreadsheetImportState } from '../states/spreadsheetImportState'; + +import { SpreadsheetImport } from './SpreadsheetImport'; + +type SpreadsheetImportProviderProps = React.PropsWithChildren; + +export const SpreadsheetImportProvider = ( + props: SpreadsheetImportProviderProps, +) => { + const [spreadsheetImportInternalState, setSpreadsheetImportInternalState] = + useRecoilState(spreadsheetImportState); + + function handleClose() { + setSpreadsheetImportInternalState({ + isOpen: false, + options: null, + }); + } + + return ( + <> + {props.children} + {spreadsheetImportInternalState.isOpen && + spreadsheetImportInternalState.options && ( + + )} + + ); +}; diff --git a/front/src/modules/spreadsheet-import/components/__stories__/MatchColumns.stories.tsx b/front/src/modules/spreadsheet-import/components/__stories__/MatchColumns.stories.tsx new file mode 100644 index 000000000..1358c0672 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/__stories__/MatchColumns.stories.tsx @@ -0,0 +1,71 @@ +import { Meta } from '@storybook/react'; + +import { ModalWrapper } from '@/spreadsheet-import/components/core/ModalWrapper'; +import { Providers } from '@/spreadsheet-import/components/core/Providers'; +import { MatchColumnsStep } from '@/spreadsheet-import/components/steps/MatchColumnsStep/MatchColumnsStep'; +import { mockRsiValues } from '@/spreadsheet-import/tests/mockRsiValues'; + +const meta: Meta = { + title: 'Modules/SpreadsheetImport/MatchColumnsStep', + component: MatchColumnsStep, + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; + +const mockData = [ + ['id', 'first_name', 'last_name', 'email', 'gender', 'ip_address'], + ['2', 'Geno', 'Gencke', 'ggencke0@tinypic.com', 'Female', '17.204.180.40'], + [ + '3', + 'Bertram', + 'Twyford', + 'btwyford1@seattletimes.com', + 'Genderqueer', + '188.98.2.13', + ], + [ + '4', + 'Tersina', + 'Isacke', + 'tisacke2@edublogs.org', + 'Non-binary', + '237.69.180.31', + ], + [ + '5', + 'Yoko', + 'Guilliland', + 'yguilliland3@elegantthemes.com', + 'Male', + '179.123.237.119', + ], + ['6', 'Freida', 'Fearns', 'ffearns4@fotki.com', 'Male', '184.48.15.1'], + ['7', 'Mildrid', 'Mount', 'mmount5@last.fm', 'Male', '26.97.160.103'], + [ + '8', + 'Jolene', + 'Darlington', + 'jdarlington6@jalbum.net', + 'Agender', + '172.14.232.84', + ], + ['9', 'Craig', 'Dickie', 'cdickie7@virginia.edu', 'Male', '143.248.220.47'], + ['10', 'Jere', 'Shier', 'jshier8@comcast.net', 'Agender', '10.143.62.161'], +]; + +export function Default() { + return ( + + null}> + null} + /> + + + ); +} diff --git a/front/src/modules/spreadsheet-import/components/__stories__/SelectHeader.stories.tsx b/front/src/modules/spreadsheet-import/components/__stories__/SelectHeader.stories.tsx new file mode 100644 index 000000000..16d2fdb52 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/__stories__/SelectHeader.stories.tsx @@ -0,0 +1,32 @@ +import { Meta } from '@storybook/react'; + +import { ModalWrapper } from '@/spreadsheet-import/components/core/ModalWrapper'; +import { Providers } from '@/spreadsheet-import/components/core/Providers'; +import { SelectHeaderStep } from '@/spreadsheet-import/components/steps/SelectHeaderStep/SelectHeaderStep'; +import { + headerSelectionTableFields, + mockRsiValues, +} from '@/spreadsheet-import/tests/mockRsiValues'; + +const meta: Meta = { + title: 'Modules/SpreadsheetImport/SelectHeaderStep', + component: SelectHeaderStep, + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; + +export function Default() { + return ( + + null}> + Promise.resolve()} + /> + + + ); +} diff --git a/front/src/modules/spreadsheet-import/components/__stories__/SelectSheet.stories.tsx b/front/src/modules/spreadsheet-import/components/__stories__/SelectSheet.stories.tsx new file mode 100644 index 000000000..67c118b92 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/__stories__/SelectSheet.stories.tsx @@ -0,0 +1,31 @@ +import { Meta } from '@storybook/react'; + +import { ModalWrapper } from '@/spreadsheet-import/components/core/ModalWrapper'; +import { Providers } from '@/spreadsheet-import/components/core/Providers'; +import { SelectSheetStep } from '@/spreadsheet-import/components/steps/SelectSheetStep/SelectSheetStep'; +import { mockRsiValues } from '@/spreadsheet-import/tests/mockRsiValues'; + +const meta: Meta = { + title: 'Modules/SpreadsheetImport/SelectSheetStep', + component: SelectSheetStep, + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; + +const sheetNames = ['Sheet1', 'Sheet2', 'Sheet3']; + +export function Default() { + return ( + + null}> + Promise.resolve()} + /> + + + ); +} diff --git a/front/src/modules/spreadsheet-import/components/__stories__/Upload.stories.tsx b/front/src/modules/spreadsheet-import/components/__stories__/Upload.stories.tsx new file mode 100644 index 000000000..d4e194bbe --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/__stories__/Upload.stories.tsx @@ -0,0 +1,26 @@ +import { Meta } from '@storybook/react'; + +import { ModalWrapper } from '@/spreadsheet-import/components/core/ModalWrapper'; +import { Providers } from '@/spreadsheet-import/components/core/Providers'; +import { UploadStep } from '@/spreadsheet-import/components/steps/UploadStep/UploadStep'; +import { mockRsiValues } from '@/spreadsheet-import/tests/mockRsiValues'; + +const meta: Meta = { + title: 'Modules/SpreadsheetImport/UploadStep', + component: UploadStep, + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; + +export function Default() { + return ( + + null}> + Promise.resolve()} /> + + + ); +} diff --git a/front/src/modules/spreadsheet-import/components/__stories__/Validation.stories.tsx b/front/src/modules/spreadsheet-import/components/__stories__/Validation.stories.tsx new file mode 100644 index 000000000..9277cc41f --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/__stories__/Validation.stories.tsx @@ -0,0 +1,31 @@ +import { Meta } from '@storybook/react'; + +import { ModalWrapper } from '@/spreadsheet-import/components/core/ModalWrapper'; +import { Providers } from '@/spreadsheet-import/components/core/Providers'; +import { ValidationStep } from '@/spreadsheet-import/components/steps/ValidationStep/ValidationStep'; +import { + editableTableInitialData, + mockRsiValues, +} from '@/spreadsheet-import/tests/mockRsiValues'; + +const meta: Meta = { + title: 'Modules/SpreadsheetImport/ValidationStep', + component: ValidationStep, + parameters: { + layout: 'fullscreen', + }, +}; + +export default meta; + +const file = new File([''], 'file.csv'); + +export function Default() { + return ( + + null}> + + + + ); +} diff --git a/front/src/modules/spreadsheet-import/components/__tests__/MatchColumnsStep.test.tsx b/front/src/modules/spreadsheet-import/components/__tests__/MatchColumnsStep.test.tsx new file mode 100644 index 000000000..036df1338 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/__tests__/MatchColumnsStep.test.tsx @@ -0,0 +1,834 @@ +import selectEvent from 'react-select-event'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { ModalWrapper } from '@/spreadsheet-import/components/core/ModalWrapper'; +import { Providers } from '@/spreadsheet-import/components/core/Providers'; +import { SpreadsheetImport } from '@/spreadsheet-import/components/SpreadsheetImport'; +import { MatchColumnsStep } from '@/spreadsheet-import/components/steps/MatchColumnsStep/MatchColumnsStep'; +import { StepType } from '@/spreadsheet-import/components/steps/UploadFlow'; +import { mockRsiValues } from '@/spreadsheet-import/tests/mockRsiValues'; +import type { Fields } from '@/spreadsheet-import/types'; + +import '@testing-library/jest-dom'; + +// TODO: fix this test +const SELECT_DROPDOWN_ID = 'select-dropdown'; + +const fields: Fields = [ + { + icon: null, + label: 'Name', + key: 'name', + fieldType: { + type: 'input', + }, + example: 'Stephanie', + }, + { + icon: null, + label: 'Mobile Phone', + key: 'mobile', + fieldType: { + type: 'input', + }, + example: '+12323423', + }, + { + icon: null, + label: 'Is cool', + key: 'is_cool', + fieldType: { + type: 'checkbox', + }, + example: 'No', + }, +]; + +const CONTINUE_BUTTON = 'Next'; +const MUTATED_ENTRY = 'mutated entry'; +const ERROR_MESSAGE = 'Something happened'; + +describe('Match Columns automatic matching', () => { + test('AutoMatch column and click next', async () => { + const header = ['namezz', 'Phone', 'Email']; + const data = [ + ['John', '123', 'j@j.com'], + ['Dane', '333', 'dane@bane.com'], + ['Kane', '534', 'kane@linch.com'], + ]; + // finds only names with automatic matching + const result = [ + { name: data[0][0] }, + { name: data[1][0] }, + { name: data[2][0] }, + ]; + + const onContinue = jest.fn(); + render( + + + + + , + ); + + const nextButton = screen.getByRole('button', { + name: 'Next', + }); + + await userEvent.click(nextButton); + + await waitFor(() => { + expect(onContinue).toBeCalled(); + }); + expect(onContinue.mock.calls[0][0]).toEqual(result); + }); + + test('AutoMatching disabled does not match any columns', async () => { + const header = ['Name', 'Phone', 'Email']; + const data = [ + ['John', '123', 'j@j.com'], + ['Dane', '333', 'dane@bane.com'], + ['Kane', '534', 'kane@linch.com'], + ]; + // finds only names with automatic matching + const result = [{}, {}, {}]; + + const onContinue = jest.fn(); + render( + + + + + , + ); + + const nextButton = screen.getByRole('button', { + name: 'Next', + }); + + await userEvent.click(nextButton); + + await waitFor(() => { + expect(onContinue).toBeCalled(); + }); + expect(onContinue.mock.calls[0][0]).toEqual(result); + }); + + test('AutoMatching exact values', async () => { + const header = ['Name', 'Phone', 'Email']; + const data = [ + ['John', '123', 'j@j.com'], + ['Dane', '333', 'dane@bane.com'], + ['Kane', '534', 'kane@linch.com'], + ]; + // finds only names with automatic matching + const result = [ + { name: data[0][0] }, + { name: data[1][0] }, + { name: data[2][0] }, + ]; + + const onContinue = jest.fn(); + render( + + + + + , + ); + + const nextButton = screen.getByRole('button', { + name: 'Next', + }); + + await userEvent.click(nextButton); + + await waitFor(() => { + expect(onContinue).toBeCalled(); + }); + expect(onContinue.mock.calls[0][0]).toEqual(result); + }); + + test('AutoMatches only one value', async () => { + const header = ['first name', 'name', 'Email']; + const data = [ + ['John', '123', 'j@j.com'], + ['Dane', '333', 'dane@bane.com'], + ['Kane', '534', 'kane@linch.com'], + ]; + // finds only names with automatic matching + const result = [ + { name: data[0][1] }, + { name: data[1][1] }, + { name: data[2][1] }, + ]; + + const alternativeFields = [ + { + icon: null, + label: 'Name', + key: 'name', + alternateMatches: ['first name'], + fieldType: { + type: 'input', + }, + example: 'Stephanie', + }, + ] as const; + + const onContinue = jest.fn(); + render( + + + + + , + ); + + const nextButton = screen.getByRole('button', { + name: 'Next', + }); + + await userEvent.click(nextButton); + + await waitFor(() => { + expect(onContinue).toBeCalled(); + }); + expect(onContinue.mock.calls[0][0]).toEqual(result); + }); + + test('Boolean-like values are returned as Booleans', async () => { + const header = ['namezz', 'is_cool', 'Email']; + const data = [ + ['John', 'yes', 'j@j.com'], + ['Dane', 'TRUE', 'dane@bane.com'], + ['Kane', 'false', 'kane@linch.com'], + ['Kaney', 'no', 'kane@linch.com'], + ['Kanye', 'maybe', 'kane@linch.com'], + ]; + + const result = [ + { name: data[0][0], is_cool: true }, + { name: data[1][0], is_cool: true }, + { name: data[2][0], is_cool: false }, + { name: data[3][0], is_cool: false }, + { name: data[4][0], is_cool: false }, + ]; + + const onContinue = jest.fn(); + render( + + + + + , + ); + + const nextButton = screen.getByRole('button', { + name: 'Next', + }); + + await userEvent.click(nextButton); + + await waitFor(() => { + expect(onContinue).toBeCalled(); + }); + expect(onContinue.mock.calls[0][0]).toEqual(result); + }); + + test("Boolean-like values are returned as Booleans for 'booleanMatches' props", async () => { + const BOOLEAN_MATCHES_VALUE = 'definitely'; + const header = ['is_cool']; + const data = [['true'], ['false'], [BOOLEAN_MATCHES_VALUE]]; + + const fields = [ + { + icon: null, + label: 'Is cool', + key: 'is_cool', + fieldType: { + type: 'checkbox', + booleanMatches: { [BOOLEAN_MATCHES_VALUE]: true }, + }, + example: 'No', + }, + ] as const; + + const result = [{ is_cool: true }, { is_cool: false }, { is_cool: true }]; + + const onContinue = jest.fn(); + render( + + + + + , + ); + + const nextButton = screen.getByRole('button', { + name: 'Next', + }); + + await userEvent.click(nextButton); + + await waitFor(() => { + expect(onContinue).toBeCalled(); + }); + expect(onContinue.mock.calls[0][0]).toEqual(result); + }); +}); + +describe('Match Columns general tests', () => { + test('Displays all user header columns', async () => { + const header = ['namezz', 'Phone', 'Email']; + const data = [ + ['John', '123', 'j@j.com'], + ['Dane', '333', 'dane@bane.com'], + ['Kane', '534', 'kane@linch.com'], + ]; + + const onContinue = jest.fn(); + render( + + + + + , + ); + + expect(screen.getByText(header[0])).toBeInTheDocument(); + expect(screen.getByText(header[1])).toBeInTheDocument(); + expect(screen.getByText(header[2])).toBeInTheDocument(); + }); + + test('Displays two rows of example data', async () => { + const header = ['namezz', 'Phone', 'Email']; + const data = [ + ['John', '123', 'j@j.com'], + ['Dane', '333', 'dane@bane.com'], + ['Kane', '534', 'kane@linch.com'], + ]; + + const onContinue = jest.fn(); + render( + + + + + , + ); + + // only displays two rows + expect(screen.queryByText(data[0][0])).toBeInTheDocument(); + expect(screen.queryByText(data[0][1])).toBeInTheDocument(); + expect(screen.queryByText(data[0][2])).toBeInTheDocument(); + expect(screen.queryByText(data[1][0])).toBeInTheDocument(); + expect(screen.queryByText(data[1][1])).toBeInTheDocument(); + expect(screen.queryByText(data[1][2])).toBeInTheDocument(); + expect(screen.queryByText(data[2][0])).not.toBeInTheDocument(); + expect(screen.queryByText(data[2][1])).not.toBeInTheDocument(); + expect(screen.queryByText(data[2][2])).not.toBeInTheDocument(); + }); + + test('Displays all fields in selects dropdown', async () => { + const header = ['Something random', 'Phone', 'Email']; + const data = [ + ['John', '123', 'j@j.com'], + ['Dane', '333', 'dane@bane.com'], + ['Kane', '534', 'kane@linch.com'], + ]; + + const onContinue = jest.fn(); + render( + + + + + , + ); + + const firstSelect = screen.getByLabelText(header[0]); + + await userEvent.click(firstSelect); + + fields.forEach((field) => { + expect(screen.queryByText(field.label)).toBeInTheDocument(); + }); + }); + + test('Manually matches first column', async () => { + const header = ['Something random', 'Phone', 'Email']; + const data = [ + ['John', '123', 'j@j.com'], + ['Dane', '333', 'dane@bane.com'], + ['Kane', '534', 'kane@linch.com'], + ]; + const result = [ + { name: data[0][0] }, + { name: data[1][0] }, + { name: data[2][0] }, + ]; + + const onContinue = jest.fn(); + render( + + + +
+ + , + ); + + const container = document.getElementById(SELECT_DROPDOWN_ID); + + if (!container) { + throw new Error('Container not found'); + } + + await selectEvent.select( + screen.getByLabelText(header[0]), + fields[0].label, + { + container, + }, + ); + + const nextButton = screen.getByRole('button', { + name: 'Next', + }); + + await userEvent.click(nextButton); + + await waitFor(() => { + expect(onContinue).toBeCalled(); + }); + expect(onContinue.mock.calls[0][0]).toEqual(result); + }); + + test('Checkmark changes when field is matched', async () => { + const header = ['Something random', 'Phone', 'Email']; + const data = [ + ['John', '123', 'j@j.com'], + ['Dane', '333', 'dane@bane.com'], + ['Kane', '534', 'kane@linch.com'], + ]; + + const onContinue = jest.fn(); + render( + + + +
+ + , + ); + + const checkmark = screen.getAllByTestId('column-checkmark')[0]; + // kinda dumb way to check if it has checkmark or not + expect(checkmark).toBeEmptyDOMElement(); + + const container = document.getElementById(SELECT_DROPDOWN_ID); + + if (!container) { + throw new Error('Container not found'); + } + + await selectEvent.select( + screen.getByLabelText(header[0]), + fields[0].label, + { + container, + }, + ); + + expect(checkmark).not.toBeEmptyDOMElement(); + }); + + test('Selecting select field adds more selects', async () => { + const OPTION_ONE = 'one'; + const OPTION_TWO = 'two'; + const OPTION_RESULT_ONE = 'uno'; + const OPTION_RESULT_TWO = 'dos'; + const options = [ + { label: 'One', value: OPTION_RESULT_ONE }, + { label: 'Two', value: OPTION_RESULT_TWO }, + ]; + const header = ['Something random']; + const data = [[OPTION_ONE], [OPTION_TWO], [OPTION_ONE]]; + + const result = [ + { + team: OPTION_RESULT_ONE, + }, + { + team: OPTION_RESULT_TWO, + }, + { + team: OPTION_RESULT_ONE, + }, + ]; + + const enumFields = [ + { + icon: null, + label: 'Team', + key: 'team', + fieldType: { + type: 'select', + options: options, + }, + }, + ] as const; + + const onContinue = jest.fn(); + render( + + + +
+ + , + ); + + expect(screen.queryByTestId('accordion-button')).not.toBeInTheDocument(); + + const container = document.getElementById(SELECT_DROPDOWN_ID); + + if (!container) { + throw new Error('Container not found'); + } + + await selectEvent.select( + screen.getByLabelText(header[0]), + enumFields[0].label, + { + container, + }, + ); + + expect(screen.queryByTestId('accordion-button')).toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('accordion-button')); + + await selectEvent.select( + screen.getByLabelText(data[0][0]), + options[0].label, + { + container, + }, + ); + + await selectEvent.select( + screen.getByLabelText(data[1][0]), + options[1].label, + { + container, + }, + ); + + const nextButton = screen.getByRole('button', { + name: 'Next', + }); + + await userEvent.click(nextButton); + + await waitFor(() => { + expect(onContinue).toBeCalled(); + }); + expect(onContinue.mock.calls[0][0]).toEqual(result); + }); + + test('Can ignore columns', async () => { + const header = ['Something random', 'Phone', 'Email']; + const data = [ + ['John', '123', 'j@j.com'], + ['Dane', '333', 'dane@bane.com'], + ['Kane', '534', 'kane@linch.com'], + ]; + + const onContinue = jest.fn(); + render( + + + + + , + ); + + const ignoreButton = screen.getAllByLabelText('Ignore column')[0]; + + expect(screen.queryByText('Column ignored')).not.toBeInTheDocument(); + + await userEvent.click(ignoreButton); + + expect(screen.queryByText('Column ignored')).toBeInTheDocument(); + }); + + test('Required unselected fields show warning alert on submit', async () => { + const header = ['Something random', 'Phone', 'Email']; + const data = [ + ['John', '123', 'j@j.com'], + ['Dane', '333', 'dane@bane.com'], + ['Kane', '534', 'kane@linch.com'], + ]; + + const requiredFields = [ + { + icon: null, + label: 'Name', + key: 'name', + fieldType: { + type: 'input', + }, + example: 'Stephanie', + validations: [ + { + rule: 'required', + errorMessage: 'Hello', + }, + ], + }, + ] as const; + + const onContinue = jest.fn(); + render( + + + + + , + ); + + const nextButton = screen.getByRole('button', { + name: 'Next', + }); + + await userEvent.click(nextButton); + + expect(onContinue).not.toBeCalled(); + expect( + screen.queryByText( + 'There are required columns that are not matched or ignored. Do you want to continue?', + ), + ).toBeInTheDocument(); + + const continueButton = screen.getByRole('button', { + name: 'Continue', + }); + + await userEvent.click(continueButton); + + await waitFor(() => { + expect(onContinue).toBeCalled(); + }); + }); + + test('Selecting the same field twice shows toast', async () => { + const header = ['Something random', 'Phone', 'Email']; + const data = [ + ['John', '123', 'j@j.com'], + ['Dane', '333', 'dane@bane.com'], + ['Kane', '534', 'kane@linch.com'], + ]; + + const onContinue = jest.fn(); + render( + + + +
+ + , + ); + const container = document.getElementById(SELECT_DROPDOWN_ID); + + if (!container) { + throw new Error('Container not found'); + } + + await selectEvent.select( + screen.getByLabelText(header[0]), + fields[0].label, + { + container, + }, + ); + await selectEvent.select( + screen.getByLabelText(header[1]), + fields[0].label, + { + container, + }, + ); + + const toasts = await screen.queryAllByText('Columns cannot duplicate'); + + expect(toasts?.[0]).toBeInTheDocument(); + }); + + test('matchColumnsStepHook should be called after columns are matched', async () => { + const matchColumnsStepHook = jest.fn(async (values) => values); + const mockValues = { + ...mockRsiValues, + fields: mockRsiValues.fields.filter( + (field) => field.key === 'name' || field.key === 'age', + ), + }; + render( + , + ); + + const continueButton = screen.getByText(CONTINUE_BUTTON); + await userEvent.click(continueButton); + + await waitFor(() => { + expect(matchColumnsStepHook).toBeCalled(); + }); + }); + + test('matchColumnsStepHook mutations to rawData should show up in ValidationStep', async () => { + const matchColumnsStepHook = jest.fn(async ([firstEntry, ...values]) => { + return [{ ...firstEntry, name: MUTATED_ENTRY }, ...values]; + }); + const mockValues = { + ...mockRsiValues, + fields: mockRsiValues.fields.filter( + (field) => field.key === 'name' || field.key === 'age', + ), + }; + render( + , + ); + + const continueButton = screen.getByText(CONTINUE_BUTTON); + await userEvent.click(continueButton); + + const mutatedEntry = await screen.findByText(MUTATED_ENTRY); + expect(mutatedEntry).toBeInTheDocument(); + }); + + test('Should show error toast if error is thrown in matchColumnsStepHook', async () => { + const matchColumnsStepHook = jest.fn(async () => { + throw new Error(ERROR_MESSAGE); + }); + + const mockValues = { + ...mockRsiValues, + fields: mockRsiValues.fields.filter( + (field) => field.key === 'name' || field.key === 'age', + ), + }; + + render( + , + ); + + const continueButton = screen.getByText(CONTINUE_BUTTON); + await userEvent.click(continueButton); + + const errorToast = await screen.findAllByText(ERROR_MESSAGE, undefined, { + timeout: 5000, + }); + expect(errorToast?.[0]).toBeInTheDocument(); + }); +}); diff --git a/front/src/modules/spreadsheet-import/components/__tests__/SelectHeaderStep.test.tsx b/front/src/modules/spreadsheet-import/components/__tests__/SelectHeaderStep.test.tsx new file mode 100644 index 000000000..e56581da1 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/__tests__/SelectHeaderStep.test.tsx @@ -0,0 +1,257 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { readFileSync } from 'fs'; + +import { ModalWrapper } from '@/spreadsheet-import/components/core/ModalWrapper'; +import { Providers } from '@/spreadsheet-import/components/core/Providers'; +import { SpreadsheetImport } from '@/spreadsheet-import/components/SpreadsheetImport'; +import { SelectHeaderStep } from '@/spreadsheet-import/components/steps/SelectHeaderStep/SelectHeaderStep'; +import { StepType } from '@/spreadsheet-import/components/steps/UploadFlow'; +import { mockRsiValues } from '@/spreadsheet-import/tests/mockRsiValues'; + +import '@testing-library/jest-dom'; + +const MUTATED_HEADER = 'mutated header'; +const CONTINUE_BUTTON = 'Next'; +const ERROR_MESSAGE = 'Something happened'; +const RAW_DATE = '2020-03-03'; +const FORMATTED_DATE = '2020/03/03'; +const TRAILING_CELL = 'trailingcell'; + +describe('Select header step tests', () => { + test('Select header row and click next', async () => { + const data = [ + ['Some random header'], + ['2030'], + ['Name', 'Phone', 'Email'], + ['John', '123', 'j@j.com'], + ['Dane', '333', 'dane@bane.com'], + ]; + const selectRowIndex = 2; + + const onContinue = jest.fn(); + render( + + + + + , + ); + + const radioButtons = screen.getAllByRole('radio'); + + await userEvent.click(radioButtons[selectRowIndex]); + + const nextButton = screen.getByRole('button', { + name: 'Next', + }); + + await userEvent.click(nextButton); + + await waitFor(() => { + expect(onContinue).toBeCalled(); + }); + expect(onContinue.mock.calls[0][0]).toEqual(data[selectRowIndex]); + expect(onContinue.mock.calls[0][1]).toEqual(data.slice(selectRowIndex + 1)); + }); + + test('selectHeaderStepHook should be called after header is selected', async () => { + const selectHeaderStepHook = jest.fn(async (headerValues, data) => { + return { headerValues, data }; + }); + render( + , + ); + const uploader = screen.getByTestId('rsi-dropzone'); + const data = readFileSync(__dirname + '/../../../../static/Workbook2.xlsx'); + fireEvent.drop(uploader, { + target: { + files: [ + new File([data], 'testFile.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }), + ], + }, + }); + const continueButton = await screen.findByText(CONTINUE_BUTTON, undefined, { + timeout: 10000, + }); + fireEvent.click(continueButton); + await waitFor(() => { + expect(selectHeaderStepHook).toBeCalledWith( + ['name', 'age', 'date'], + [ + ['Josh', '2', '2020-03-03'], + ['Charlie', '3', '2010-04-04'], + ['Lena', '50', '1994-02-27'], + ], + ); + }); + }); + test('selectHeaderStepHook should be able to modify raw data', async () => { + const selectHeaderStepHook = jest.fn( + async ([_val, ...headerValues], data) => { + return { headerValues: [MUTATED_HEADER, ...headerValues], data }; + }, + ); + render( + , + ); + const continueButton = screen.getByText(CONTINUE_BUTTON); + fireEvent.click(continueButton); + const mutatedHeader = await screen.findByText(MUTATED_HEADER); + + await waitFor(() => { + expect(mutatedHeader).toBeInTheDocument(); + }); + }); + + test('Should show error toast if error is thrown in selectHeaderStepHook', async () => { + const selectHeaderStepHook = jest.fn(async () => { + throw new Error(ERROR_MESSAGE); + }); + render( + , + ); + const continueButton = screen.getByText(CONTINUE_BUTTON); + await userEvent.click(continueButton); + + const errorToast = await screen.findAllByText(ERROR_MESSAGE, undefined, { + timeout: 5000, + }); + expect(errorToast?.[0]).toBeInTheDocument(); + }); + + test('dateFormat property should NOT be applied to dates read from csv files IF parseRaw=true', async () => { + const file = new File([RAW_DATE], 'test.csv', { + type: 'text/csv', + }); + render( + , + ); + + const uploader = screen.getByTestId('rsi-dropzone'); + fireEvent.drop(uploader, { + target: { files: [file] }, + }); + + const el = await screen.findByText(RAW_DATE, undefined, { timeout: 5000 }); + expect(el).toBeInTheDocument(); + }); + + test('dateFormat property should be applied to dates read from csv files IF parseRaw=false', async () => { + const file = new File([RAW_DATE], 'test.csv', { + type: 'text/csv', + }); + render( + , + ); + + const uploader = screen.getByTestId('rsi-dropzone'); + fireEvent.drop(uploader, { + target: { files: [file] }, + }); + + const el = await screen.findByText(FORMATTED_DATE, undefined, { + timeout: 5000, + }); + expect(el).toBeInTheDocument(); + }); + + test('dateFormat property should be applied to dates read from xlsx files', async () => { + render(); + const uploader = screen.getByTestId('rsi-dropzone'); + const data = readFileSync(__dirname + '/../../../../static/Workbook2.xlsx'); + fireEvent.drop(uploader, { + target: { + files: [ + new File([data], 'testFile.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }), + ], + }, + }); + const el = await screen.findByText(FORMATTED_DATE, undefined, { + timeout: 10000, + }); + expect(el).toBeInTheDocument(); + }); + + test.skip( + 'trailing (not under a header) cells should be rendered in SelectHeaderStep table, ' + + 'but not in MatchColumnStep if a shorter row is selected as a header', + async () => { + const selectHeaderStepHook = jest.fn(async (headerValues, data) => { + return { headerValues, data }; + }); + render( + , + ); + const uploader = screen.getByTestId('rsi-dropzone'); + const data = readFileSync( + __dirname + '/../../../../static/TrailingCellsWorkbook.xlsx', + ); + fireEvent.drop(uploader, { + target: { + files: [ + new File([data], 'testFile.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }), + ], + }, + }); + const trailingCell = await screen.findByText(TRAILING_CELL, undefined, { + timeout: 10000, + }); + expect(trailingCell).toBeInTheDocument(); + const nextButton = screen.getByRole('button', { + name: 'Next', + }); + await userEvent.click(nextButton); + const trailingCellNextPage = await screen.findByText( + TRAILING_CELL, + undefined, + { timeout: 10000 }, + ); + expect(trailingCellNextPage).not.toBeInTheDocument(); + }, + ); +}); diff --git a/front/src/modules/spreadsheet-import/components/__tests__/SelectSheetStep.test.tsx b/front/src/modules/spreadsheet-import/components/__tests__/SelectSheetStep.test.tsx new file mode 100644 index 000000000..9b0ba1abc --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/__tests__/SelectSheetStep.test.tsx @@ -0,0 +1,134 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { readFileSync } from 'fs'; + +import { ModalWrapper } from '@/spreadsheet-import/components/core/ModalWrapper'; +import { Providers } from '@/spreadsheet-import/components/core/Providers'; +import { SpreadsheetImport } from '@/spreadsheet-import/components/SpreadsheetImport'; +import { SelectSheetStep } from '@/spreadsheet-import/components/steps/SelectSheetStep/SelectSheetStep'; +import { mockRsiValues } from '@/spreadsheet-import/tests/mockRsiValues'; + +import '@testing-library/jest-dom'; + +const SHEET_TITLE_1 = 'Sheet1'; +const SHEET_TITLE_2 = 'Sheet2'; +const SELECT_HEADER_TABLE_ENTRY_1 = 'Charlie'; +const SELECT_HEADER_TABLE_ENTRY_2 = 'Josh'; +const SELECT_HEADER_TABLE_ENTRY_3 = '50'; +const ERROR_MESSAGE = 'Something happened'; + +test('Should render select sheet screen if multi-sheet excel file was uploaded', async () => { + render(); + const uploader = screen.getByTestId('rsi-dropzone'); + const data = readFileSync(__dirname + '/../../../../static/Workbook1.xlsx'); + fireEvent.drop(uploader, { + target: { + files: [ + new File([data], 'testFile.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }), + ], + }, + }); + + const sheetTitle = await screen.findByText(SHEET_TITLE_1, undefined, { + timeout: 5000, + }); + const sheetTitle2 = screen.getByRole('radio', { name: SHEET_TITLE_2 }); + expect(sheetTitle).toBeInTheDocument(); + expect(sheetTitle2).toBeInTheDocument(); +}); + +test('Should render select header screen with relevant data if single-sheet excel file was uploaded', async () => { + render(); + const uploader = screen.getByTestId('rsi-dropzone'); + const data = readFileSync(__dirname + '/../../../../static/Workbook2.xlsx'); + fireEvent.drop(uploader, { + target: { + files: [ + new File([data], 'testFile.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }), + ], + }, + }); + const tableEntry1 = await screen.findByText( + SELECT_HEADER_TABLE_ENTRY_1, + undefined, + { timeout: 5000 }, + ); + const tableEntry2 = screen.getByRole('gridcell', { + name: SELECT_HEADER_TABLE_ENTRY_2, + }); + const tableEntry3 = screen.getByRole('gridcell', { + name: SELECT_HEADER_TABLE_ENTRY_3, + }); + + expect(tableEntry1).toBeInTheDocument(); + expect(tableEntry2).toBeInTheDocument(); + expect(tableEntry3).toBeInTheDocument(); +}); + +test('Select sheet and click next', async () => { + const sheetNames = ['Sheet1', 'Sheet2']; + const selectSheetIndex = 1; + + const onContinue = jest.fn(); + render( + + + + + , + ); + + const firstRadio = screen.getByLabelText(sheetNames[selectSheetIndex]); + + await userEvent.click(firstRadio); + + const nextButton = screen.getByRole('button', { + name: 'Next', + }); + + await userEvent.click(nextButton); + + await waitFor(() => { + expect(onContinue).toBeCalled(); + }); + expect(onContinue.mock.calls[0][0]).toEqual(sheetNames[selectSheetIndex]); +}); + +test('Should show error toast if error is thrown in uploadStepHook', async () => { + const uploadStepHook = jest.fn(async () => { + throw new Error(ERROR_MESSAGE); + }); + render( + , + ); + const uploader = screen.getByTestId('rsi-dropzone'); + const data = readFileSync(__dirname + '/../../../../static/Workbook1.xlsx'); + fireEvent.drop(uploader, { + target: { + files: [ + new File([data], 'testFile.xlsx', { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }), + ], + }, + }); + + const nextButton = await screen.findByRole( + 'button', + { + name: 'Next', + }, + { timeout: 5000 }, + ); + + await userEvent.click(nextButton); + + const errorToast = await screen.findAllByText(ERROR_MESSAGE, undefined, { + timeout: 5000, + }); + expect(errorToast?.[0]).toBeInTheDocument(); +}); diff --git a/front/src/modules/spreadsheet-import/components/__tests__/UploadStep.test.tsx b/front/src/modules/spreadsheet-import/components/__tests__/UploadStep.test.tsx new file mode 100644 index 000000000..1551e47b8 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/__tests__/UploadStep.test.tsx @@ -0,0 +1,105 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import { ModalWrapper } from '@/spreadsheet-import/components/core/ModalWrapper'; +import { Providers } from '@/spreadsheet-import/components/core/Providers'; +import { SpreadsheetImport } from '@/spreadsheet-import/components/SpreadsheetImport'; +import { UploadStep } from '@/spreadsheet-import/components/steps/UploadStep/UploadStep'; +import { mockRsiValues } from '@/spreadsheet-import/tests/mockRsiValues'; + +import '@testing-library/jest-dom'; + +const MUTATED_RAW_DATA = 'Bye'; +const ERROR_MESSAGE = 'Something happened while uploading'; + +test('Upload a file', async () => { + const file = new File(['Hello, Hello, Hello, Hello'], 'test.csv', { + type: 'text/csv', + }); + + const onContinue = jest.fn(); + render( + + + + + , + ); + + const uploader = screen.getByTestId('rsi-dropzone'); + fireEvent.drop(uploader, { + target: { files: [file] }, + }); + await waitFor( + () => { + expect(onContinue).toBeCalled(); + }, + { timeout: 5000 }, + ); +}); + +test('Should call uploadStepHook on file upload', async () => { + const file = new File(['Hello, Hello, Hello, Hello'], 'test.csv', { + type: 'text/csv', + }); + const uploadStepHook = jest.fn(async (values) => { + return values; + }); + render( + , + ); + const uploader = screen.getByTestId('rsi-dropzone'); + fireEvent.drop(uploader, { + target: { files: [file] }, + }); + + await waitFor( + () => { + expect(uploadStepHook).toBeCalled(); + }, + { timeout: 5000 }, + ); +}); + +test('uploadStepHook should be able to mutate raw upload data', async () => { + const file = new File(['Hello, Hello, Hello, Hello'], 'test.csv', { + type: 'text/csv', + }); + const uploadStepHook = jest.fn(async ([[, ...values]]) => { + return [[MUTATED_RAW_DATA, ...values]]; + }); + render( + , + ); + + const uploader = screen.getByTestId('rsi-dropzone'); + fireEvent.drop(uploader, { + target: { files: [file] }, + }); + + const el = await screen.findByText(MUTATED_RAW_DATA, undefined, { + timeout: 5000, + }); + expect(el).toBeInTheDocument(); +}); + +test('Should show error toast if error is thrown in uploadStepHook', async () => { + const file = new File(['Hello, Hello, Hello, Hello'], 'test.csv', { + type: 'text/csv', + }); + const uploadStepHook = jest.fn(async () => { + throw new Error(ERROR_MESSAGE); + }); + render( + , + ); + + const uploader = screen.getByTestId('rsi-dropzone'); + fireEvent.drop(uploader, { + target: { files: [file] }, + }); + + const errorToast = await screen.findAllByText(ERROR_MESSAGE, undefined, { + timeout: 5000, + }); + expect(errorToast?.[0]).toBeInTheDocument(); +}); diff --git a/front/src/modules/spreadsheet-import/components/__tests__/ValidationStep.test.tsx b/front/src/modules/spreadsheet-import/components/__tests__/ValidationStep.test.tsx new file mode 100644 index 000000000..408506928 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/__tests__/ValidationStep.test.tsx @@ -0,0 +1,763 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { ModalWrapper } from '@/spreadsheet-import/components/core/ModalWrapper'; +import { Providers } from '@/spreadsheet-import/components/core/Providers'; +import { defaultRSIProps } from '@/spreadsheet-import/components/SpreadsheetImport'; +import { ValidationStep } from '@/spreadsheet-import/components/steps/ValidationStep/ValidationStep'; + +import '@testing-library/jest-dom'; + +const mockValues = { + ...defaultRSIProps, + fields: [], + onSubmit: jest.fn(), + isOpen: true, + onClose: jest.fn(), +} as const; + +const getFilterSwitch = () => + screen.getByRole('checkbox', { + name: 'Show only rows with errors', + }); + +const file = new File([''], 'file.csv'); + +describe('Validation step tests', () => { + test('Submit data', async () => { + const onSubmit = jest.fn(); + render( + + + + + , + ); + + const finishButton = screen.getByRole('button', { + name: 'Confirm', + }); + + await userEvent.click(finishButton); + + await waitFor(() => { + expect(onSubmit).toBeCalledWith( + { all: [], invalidData: [], validData: [] }, + file, + ); + }); + }); + + test('Filters rows with required errors', async () => { + const UNIQUE_NAME = 'very unique name'; + const initialData = [ + { + name: UNIQUE_NAME, + }, + { + name: undefined, + }, + ]; + const fields = [ + { + icon: null, + label: 'Name', + key: 'name', + fieldType: { + type: 'input', + }, + validations: [ + { + rule: 'required', + errorMessage: 'Name is required', + }, + ], + }, + ] as const; + render( + + + + + , + ); + + const allRowsWithHeader = await screen.findAllByRole('row'); + expect(allRowsWithHeader).toHaveLength(3); + + const validRow = screen.getByText(UNIQUE_NAME); + expect(validRow).toBeInTheDocument(); + + const switchFilter = getFilterSwitch(); + + await userEvent.click(switchFilter); + + const filteredRowsWithHeader = await screen.findAllByRole('row'); + expect(filteredRowsWithHeader).toHaveLength(2); + }); + + test('Filters rows with errors, fixes row, removes filter', async () => { + const UNIQUE_NAME = 'very unique name'; + const SECOND_UNIQUE_NAME = 'another unique name'; + const FINAL_NAME = 'just name'; + const initialData = [ + { + name: UNIQUE_NAME, + }, + { + name: undefined, + }, + { + name: SECOND_UNIQUE_NAME, + }, + ]; + const fields = [ + { + icon: null, + label: 'Name', + key: 'name', + fieldType: { + type: 'input', + }, + validations: [ + { + rule: 'required', + errorMessage: 'Name is required', + }, + ], + }, + ] as const; + + const onSubmit = jest.fn(); + render( + + + + + , + ); + + const allRowsWithHeader = await screen.findAllByRole('row'); + expect(allRowsWithHeader).toHaveLength(4); + + const validRow = screen.getByText(UNIQUE_NAME); + expect(validRow).toBeInTheDocument(); + + const switchFilter = getFilterSwitch(); + + await userEvent.click(switchFilter); + + const filteredRowsWithHeader = await screen.findAllByRole('row'); + expect(filteredRowsWithHeader).toHaveLength(2); + + // don't really know another way to select an empty cell + const emptyCell = screen.getAllByRole('gridcell', { name: undefined })[1]; + await userEvent.click(emptyCell); + + await userEvent.keyboard(FINAL_NAME + '{enter}'); + + const filteredRowsNoErrorsWithHeader = await screen.findAllByRole('row'); + expect(filteredRowsNoErrorsWithHeader).toHaveLength(1); + + await userEvent.click(switchFilter); + + const allRowsFixedWithHeader = await screen.findAllByRole('row'); + expect(allRowsFixedWithHeader).toHaveLength(4); + + const finishButton = screen.getByRole('button', { + name: 'Confirm', + }); + + await userEvent.click(finishButton); + + await waitFor(() => { + expect(onSubmit).toBeCalled(); + }); + }); + + test('Filters rows with unique errors', async () => { + const NON_UNIQUE_NAME = 'very unique name'; + const initialData = [ + { + name: NON_UNIQUE_NAME, + }, + { + name: NON_UNIQUE_NAME, + }, + { + name: 'I am fine', + }, + ]; + const fields = [ + { + icon: null, + label: 'Name', + key: 'name', + fieldType: { + type: 'input', + }, + validations: [ + { + rule: 'unique', + errorMessage: 'Name must be unique', + }, + ], + }, + ] as const; + render( + + + + + , + ); + + const allRowsWithHeader = await screen.findAllByRole('row'); + expect(allRowsWithHeader).toHaveLength(4); + + const switchFilter = getFilterSwitch(); + + await userEvent.click(switchFilter); + + const filteredRowsWithHeader = await screen.findAllByRole('row'); + expect(filteredRowsWithHeader).toHaveLength(3); + }); + test('Filters rows with regex errors', async () => { + const NOT_A_NUMBER = 'not a number'; + const initialData = [ + { + name: NOT_A_NUMBER, + }, + { + name: '1234', + }, + { + name: '9999999', + }, + ]; + const fields = [ + { + icon: null, + label: 'Name', + key: 'name', + fieldType: { + type: 'input', + }, + validations: [ + { + rule: 'regex', + errorMessage: 'Name must be unique', + value: '^[0-9]*$', + }, + ], + }, + ] as const; + render( + + + + + , + ); + + const allRowsWithHeader = await screen.findAllByRole('row'); + expect(allRowsWithHeader).toHaveLength(4); + + const switchFilter = getFilterSwitch(); + + await userEvent.click(switchFilter); + + const filteredRowsWithHeader = await screen.findAllByRole('row'); + expect(filteredRowsWithHeader).toHaveLength(2); + }); + + test('Deletes selected rows', async () => { + const FIRST_DELETE = 'first'; + const SECOND_DELETE = 'second'; + const THIRD = 'third'; + + const initialData = [ + { + name: FIRST_DELETE, + }, + { + name: SECOND_DELETE, + }, + { + name: THIRD, + }, + ]; + const fields = [ + { + icon: null, + label: 'Name', + key: 'name', + fieldType: { + type: 'input', + }, + }, + ] as const; + render( + + + + + , + ); + + const allRowsWithHeader = await screen.findAllByRole('row'); + expect(allRowsWithHeader).toHaveLength(4); + + const switchFilters = screen.getAllByRole('checkbox', { + name: 'Select', + }); + + await userEvent.click(switchFilters[0]); + await userEvent.click(switchFilters[1]); + + const discardButton = screen.getByRole('button', { + name: 'Discard selected rows', + }); + + await userEvent.click(discardButton); + + const filteredRowsWithHeader = await screen.findAllByRole('row'); + expect(filteredRowsWithHeader).toHaveLength(2); + + const validRow = screen.getByText(THIRD); + expect(validRow).toBeInTheDocument(); + }); + + test('Deletes selected rows, changes the last one', async () => { + const FIRST_DELETE = 'first'; + const SECOND_DELETE = 'second'; + const THIRD = 'third'; + const THIRD_CHANGED = 'third_changed'; + + const initialData = [ + { + name: FIRST_DELETE, + }, + { + name: SECOND_DELETE, + }, + { + name: THIRD, + }, + ]; + const fields = [ + { + icon: null, + label: 'Name', + key: 'name', + fieldType: { + type: 'input', + }, + }, + ] as const; + render( + + + + + , + ); + + const allRowsWithHeader = await screen.findAllByRole('row'); + expect(allRowsWithHeader).toHaveLength(4); + + const switchFilters = screen.getAllByRole('checkbox', { + name: 'Select', + }); + + await userEvent.click(switchFilters[0]); + await userEvent.click(switchFilters[1]); + + const discardButton = screen.getByRole('button', { + name: 'Discard selected rows', + }); + + await userEvent.click(discardButton); + + const filteredRowsWithHeader = await screen.findAllByRole('row'); + expect(filteredRowsWithHeader).toHaveLength(2); + + const nameCell = screen.getByRole('gridcell', { + name: THIRD, + }); + + await userEvent.click(nameCell); + + screen.getByRole('textbox'); + await userEvent.keyboard(THIRD_CHANGED + '{enter}'); + + const validRow = screen.getByText(THIRD_CHANGED); + expect(validRow).toBeInTheDocument(); + }); + + test('All inputs change values', async () => { + const NAME = 'John'; + const NEW_NAME = 'Johnny'; + const OPTIONS = [ + { value: 'one', label: 'ONE' }, + { value: 'two', label: 'TWO' }, + ] as const; + const initialData = [ + { + name: NAME, + lastName: OPTIONS[0].value, + is_cool: false, + }, + ]; + const fields = [ + { + icon: null, + label: 'Name', + key: 'name', + fieldType: { + type: 'input', + }, + }, + { + icon: null, + label: 'lastName', + key: 'lastName', + fieldType: { + type: 'select', + options: OPTIONS, + }, + }, + { + icon: null, + label: 'is cool', + key: 'is_cool', + fieldType: { + type: 'checkbox', + }, + }, + ] as const; + + render( + + + + + , + ); + + // input + const nameCell = screen.getByRole('gridcell', { + name: NAME, + }); + + await userEvent.click(nameCell); + + const input: HTMLInputElement | null = + screen.getByRole('textbox'); + + expect(input).toHaveValue(NAME); + expect(input).toHaveFocus(); + expect(input.selectionStart).toBe(0); + expect(input.selectionEnd).toBe(NAME.length); + + await userEvent.keyboard(NEW_NAME + '{enter}'); + expect(input).not.toBeInTheDocument(); + + const newNameCell = screen.getByRole('gridcell', { + name: NEW_NAME, + }); + expect(newNameCell).toBeInTheDocument(); + + // select + const lastNameCell = screen.getByRole('gridcell', { + name: OPTIONS[0].label, + }); + await userEvent.click(lastNameCell); + + const newOption = screen.getByRole('button', { + name: OPTIONS[1].label, + }); + await userEvent.click(newOption); + expect(newOption).not.toBeInTheDocument(); + + const newLastName = screen.getByRole('gridcell', { + name: OPTIONS[1].label, + }); + expect(newLastName).toBeInTheDocument(); + + // Boolean + const checkbox = screen.getByRole('checkbox', { + name: '', + }); + + expect(checkbox).not.toBeChecked(); + + await userEvent.click(checkbox); + + expect(checkbox).toBeChecked(); + }); + + test('Row hook transforms data', async () => { + const NAME = 'John'; + const LASTNAME = 'Doe'; + const NEW_NAME = 'Johnny'; + const NEW_LASTNAME = 'CENA'; + const initialData = [ + { + name: NAME + ' ' + LASTNAME, + lastName: undefined, + }, + ]; + const fields = [ + { + icon: null, + label: 'Name', + key: 'name', + fieldType: { + type: 'input', + }, + }, + { + icon: null, + label: 'lastName', + key: 'lastName', + fieldType: { + type: 'input', + }, + }, + ] as const; + + render( + ({ + name: value.name?.toString()?.split(/(\s+)/)[0], + lastName: value.name?.toString()?.split(/(\s+)/)[2], + }), + }} + > + + + + , + ); + + const nameCell = screen.getByRole('gridcell', { + name: NAME, + }); + expect(nameCell).toBeInTheDocument(); + const lastNameCell = screen.getByRole('gridcell', { + name: LASTNAME, + }); + expect(lastNameCell).toBeInTheDocument(); + + // activate input + await userEvent.click(nameCell); + + await userEvent.keyboard(NEW_NAME + ' ' + NEW_LASTNAME + '{enter}'); + + const newNameCell = screen.getByRole('gridcell', { + name: NEW_NAME, + }); + expect(newNameCell).toBeInTheDocument(); + const newLastNameCell = screen.getByRole('gridcell', { + name: NEW_LASTNAME, + }); + expect(newLastNameCell).toBeInTheDocument(); + }); + test('Row hook raises error', async () => { + const WRONG_NAME = 'Johnny'; + const RIGHT_NAME = 'Jonathan'; + const initialData = [ + { + name: WRONG_NAME, + }, + ]; + const fields = [ + { + icon: null, + label: 'Name', + key: 'name', + fieldType: { + type: 'input', + }, + }, + ] as const; + + render( + { + if (value.name === WRONG_NAME) { + setError(fields[0].key, { + message: 'Wrong name', + level: 'error', + }); + } + return value; + }, + }} + > + + + + , + ); + + const switchFilter = getFilterSwitch(); + + await expect(await screen.findAllByRole('row')).toHaveLength(2); + + await userEvent.click(switchFilter); + + await expect(await screen.findAllByRole('row')).toHaveLength(2); + + const nameCell = screen.getByRole('gridcell', { + name: WRONG_NAME, + }); + expect(nameCell).toBeInTheDocument(); + + await userEvent.click(nameCell); + screen.getByRole('textbox'); + + await userEvent.keyboard(RIGHT_NAME + '{enter}'); + + await expect(await screen.findAllByRole('row')).toHaveLength(1); + }); + + test('Table hook transforms data', async () => { + const NAME = 'John'; + const SECOND_NAME = 'Doe'; + const NEW_NAME = 'Jakee'; + const ADDITION = 'last'; + const initialData = [ + { + name: NAME, + }, + { + name: SECOND_NAME, + }, + ]; + const fields = [ + { + icon: null, + label: 'Name', + key: 'name', + fieldType: { + type: 'input', + }, + }, + ] as const; + + render( + + data.map((value) => ({ + name: value.name + ADDITION, + })), + }} + > + + + + , + ); + + const nameCell = screen.getByRole('gridcell', { + name: NAME + ADDITION, + }); + expect(nameCell).toBeInTheDocument(); + const lastNameCell = screen.getByRole('gridcell', { + name: SECOND_NAME + ADDITION, + }); + expect(lastNameCell).toBeInTheDocument(); + + // activate input + await userEvent.click(nameCell); + + await userEvent.keyboard(NEW_NAME + '{enter}'); + + const newNameCell = screen.getByRole('gridcell', { + name: NEW_NAME + ADDITION, + }); + expect(newNameCell).toBeInTheDocument(); + }); + test('Table hook raises error', async () => { + const WRONG_NAME = 'Johnny'; + const RIGHT_NAME = 'Jonathan'; + const initialData = [ + { + name: WRONG_NAME, + }, + { + name: WRONG_NAME, + }, + ]; + const fields = [ + { + icon: null, + label: 'Name', + key: 'name', + fieldType: { + type: 'input', + }, + }, + ] as const; + + render( + { + data.forEach((value, index) => { + if (value.name === WRONG_NAME) { + setError(index, fields[0].key, { + message: 'Wrong name', + level: 'error', + }); + } + return value; + }); + return data; + }, + }} + > + + + + , + ); + + const switchFilter = getFilterSwitch(); + + await expect(await screen.findAllByRole('row')).toHaveLength(3); + + await userEvent.click(switchFilter); + + await expect(await screen.findAllByRole('row')).toHaveLength(3); + + const nameCell = await screen.getAllByRole('gridcell', { + name: WRONG_NAME, + })[0]; + + await userEvent.click(nameCell); + screen.getByRole('textbox'); + + await userEvent.keyboard(RIGHT_NAME + '{enter}'); + + await expect(await screen.findAllByRole('row')).toHaveLength(2); + }); +}); diff --git a/front/src/modules/spreadsheet-import/components/core/ContinueButton.tsx b/front/src/modules/spreadsheet-import/components/core/ContinueButton.tsx new file mode 100644 index 000000000..9e89cc0cf --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/core/ContinueButton.tsx @@ -0,0 +1,37 @@ +import styled from '@emotion/styled'; + +import { MainButton } from '@/ui/button/components/MainButton'; +import { Modal } from '@/ui/modal/components/Modal'; +import { CircularProgressBar } from '@/ui/progress-bar/components/CircularProgressBar'; + +const Footer = styled(Modal.Footer)` + height: 60px; + justify-content: center; + padding: 0px; + padding-left: ${({ theme }) => theme.spacing(30)}; + padding-right: ${({ theme }) => theme.spacing(30)}; +`; + +const Button = styled(MainButton)` + width: 200px; +`; + +type ContinueButtonProps = { + onContinue: (val: any) => void; + title: string; + isLoading?: boolean; +}; + +export const ContinueButton = ({ + onContinue, + title, + isLoading, +}: ContinueButtonProps) => ( +
+
+); diff --git a/front/src/modules/spreadsheet-import/components/core/Heading.tsx b/front/src/modules/spreadsheet-import/components/core/Heading.tsx new file mode 100644 index 000000000..bb79bd862 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/core/Heading.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import styled from '@emotion/styled'; + +export type Props = React.ComponentProps<'div'> & { + title: string; + description?: string; +}; + +const Container = styled.div` + align-items: center; + display: flex; + flex-direction: column; +`; + +const Title = styled.span` + color: ${({ theme }) => theme.font.color.primary}; + font-size: ${({ theme }) => theme.font.size.lg}; + font-weight: ${({ theme }) => theme.font.weight.semiBold}; + text-align: center; +`; + +const Description = styled.span` + color: ${({ theme }) => theme.font.color.primary}; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.regular}; + margin-top: ${({ theme }) => theme.spacing(3)}; + text-align: center; +`; + +export function Heading({ title, description, ...props }: Props) { + return ( + + {title} + {description && {description}} + + ); +} diff --git a/front/src/modules/spreadsheet-import/components/core/MatchColumnSelect.tsx b/front/src/modules/spreadsheet-import/components/core/MatchColumnSelect.tsx new file mode 100644 index 000000000..ffbf1088c --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/core/MatchColumnSelect.tsx @@ -0,0 +1,217 @@ +import React, { useCallback, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { + autoUpdate, + flip, + offset, + size, + useFloating, +} from '@floating-ui/react'; +import { TablerIconsProps } from '@tabler/icons-react'; +import debounce from 'lodash.debounce'; +import { ReadonlyDeep } from 'type-fest'; + +import type { SelectOption } from '@/spreadsheet-import/types'; +import { DropdownMenu } from '@/ui/dropdown/components/DropdownMenu'; +import { DropdownMenuInput } from '@/ui/dropdown/components/DropdownMenuInput'; +import { DropdownMenuItem } from '@/ui/dropdown/components/DropdownMenuItem'; +import { DropdownMenuItemsContainer } from '@/ui/dropdown/components/DropdownMenuItemsContainer'; +import { DropdownMenuSelectableItem } from '@/ui/dropdown/components/DropdownMenuSelectableItem'; +import { DropdownMenuSeparator } from '@/ui/dropdown/components/DropdownMenuSeparator'; +import { IconChevronDown } from '@/ui/icon'; +import { AppTooltip } from '@/ui/tooltip/AppTooltip'; +import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; +import { useUpdateEffect } from '~/hooks/useUpdateEffect'; + +const DropdownItem = styled.div` + align-items: center; + background-color: ${({ theme }) => theme.background.tertiary}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + box-sizing: border-box; + display: flex; + flex-direction: row; + height: 32px; + padding-left: ${({ theme }) => theme.spacing(2)}; + padding-right: ${({ theme }) => theme.spacing(2)}; + width: 100%; + + &:hover { + background-color: ${({ theme }) => theme.background.quaternary}; + } +`; + +const DropdownLabel = styled.span<{ isPlaceholder: boolean }>` + color: ${({ theme, isPlaceholder }) => + isPlaceholder ? theme.font.color.tertiary : theme.font.color.primary}; + display: flex; + flex: 1; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.regular}; + padding-left: ${({ theme }) => theme.spacing(1)}; + padding-right: ${({ theme }) => theme.spacing(1)}; +`; + +const FloatingDropdown = styled.div` + z-index: ${({ theme }) => theme.lastLayerZIndex}; +`; + +interface Props { + onChange: (value: ReadonlyDeep | null) => void; + value?: ReadonlyDeep; + options: readonly ReadonlyDeep[]; + placeholder?: string; + name?: string; +} + +export const MatchColumnSelect = ({ + onChange, + value, + options: initialOptions, + placeholder, + name, +}: Props) => { + const theme = useTheme(); + + const dropdownItemRef = useRef(null); + const dropdownContainerRef = useRef(null); + + const [isOpen, setIsOpen] = useState(false); + const [searchFilter, setSearchFilter] = useState(''); + const [options, setOptions] = useState(initialOptions); + + const { refs, floatingStyles } = useFloating({ + strategy: 'absolute', + middleware: [ + offset(() => { + return parseInt(theme.spacing(2), 10); + }), + flip(), + size(), + ], + whileElementsMounted: autoUpdate, + open: isOpen, + placement: 'bottom-start', + }); + + const handleSearchFilterChange = useCallback( + (text: string) => { + setOptions( + initialOptions.filter((option) => option.label.includes(text)), + ); + }, + [initialOptions], + ); + + const debouncedHandleSearchFilter = debounce(handleSearchFilterChange, 100, { + leading: true, + }); + + function handleFilterChange(event: React.ChangeEvent) { + const value = event.currentTarget.value; + + setSearchFilter(value); + debouncedHandleSearchFilter(value); + } + + function handleDropdownItemClick() { + setIsOpen(true); + } + + function handleChange(option: ReadonlyDeep) { + onChange(option); + setIsOpen(false); + } + + function renderIcon(icon: ReadonlyDeep) { + if (icon && React.isValidElement(icon)) { + return React.cloneElement(icon as any, { + size: 16, + color: theme.font.color.primary, + }); + } + return null; + } + + useListenClickOutside({ + refs: [dropdownContainerRef], + callback: () => { + setIsOpen(false); + }, + }); + + useUpdateEffect(() => { + setOptions(initialOptions); + }, [initialOptions]); + + return ( + <> + { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + dropdownItemRef.current = node; + refs.setReference(node); + }} + onClick={handleDropdownItemClick} + > + {renderIcon(value?.icon)} + + {value?.label ?? placeholder} + + + + {isOpen && + createPortal( + + + + + + {options?.map((option) => ( + <> + handleChange(option)} + disabled={ + option.disabled && value?.value !== option.value + } + > + {renderIcon(option?.icon)} + {option.label} + + {option.disabled && + value?.value !== option.value && + createPortal( + , + document.body, + )} + + ))} + {options?.length === 0 && ( + No result + )} + + + , + document.body, + )} + + ); +}; diff --git a/front/src/modules/spreadsheet-import/components/core/ModalCloseButton.tsx b/front/src/modules/spreadsheet-import/components/core/ModalCloseButton.tsx new file mode 100644 index 000000000..aceadf765 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/core/ModalCloseButton.tsx @@ -0,0 +1,50 @@ +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; + +import { ButtonVariant } from '@/ui/button/components/Button'; +import { IconButton } from '@/ui/button/components/IconButton'; +import { useDialog } from '@/ui/dialog/hooks/useDialog'; +import { IconX } from '@/ui/icon/index'; + +const CloseButtonContainer = styled.div` + align-items: center; + aspect-ratio: 1; + display: flex; + height: 60px; + justify-content: center; + position: absolute; + right: 0; + top: 0; +`; + +type ModalCloseButtonProps = { + onClose: () => void; +}; + +export const ModalCloseButton = ({ onClose }: ModalCloseButtonProps) => { + const theme = useTheme(); + + const { enqueueDialog } = useDialog(); + + function handleClose() { + enqueueDialog({ + title: 'Exit import flow', + message: 'Are you sure? Your current information will not be saved.', + buttons: [ + { title: 'Cancel' }, + { title: 'Exit', onClick: onClose, variant: ButtonVariant.Danger }, + ], + }); + } + + return ( + <> + + } + onClick={handleClose} + /> + + + ); +}; diff --git a/front/src/modules/spreadsheet-import/components/core/ModalWrapper.tsx b/front/src/modules/spreadsheet-import/components/core/ModalWrapper.tsx new file mode 100644 index 000000000..c5e26dd36 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/core/ModalWrapper.tsx @@ -0,0 +1,40 @@ +import type React from 'react'; +import styled from '@emotion/styled'; + +import { useRsi } from '@/spreadsheet-import/hooks/useRsi'; +import { Modal } from '@/ui/modal/components/Modal'; + +import { ModalCloseButton } from './ModalCloseButton'; + +const StyledModal = styled(Modal)` + height: 61%; + min-height: 500px; + min-width: 600px; + position: relative; + width: 53%; +`; + +const StyledRtlLtr = styled.div` + display: flex; + flex: 1; + flex-direction: column; +`; + +type Props = { + children: React.ReactNode; + isOpen: boolean; + onClose: () => void; +}; + +export const ModalWrapper = ({ children, isOpen, onClose }: Props) => { + const { rtl } = useRsi(); + + return ( + + + + {children} + + + ); +}; diff --git a/front/src/modules/spreadsheet-import/components/core/Providers.tsx b/front/src/modules/spreadsheet-import/components/core/Providers.tsx new file mode 100644 index 000000000..c15a32e84 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/core/Providers.tsx @@ -0,0 +1,25 @@ +import { createContext } from 'react'; + +import type { RsiProps } from '@/spreadsheet-import/types'; + +export const RsiContext = createContext({} as any); + +type ProvidersProps = { + children: React.ReactNode; + rsiValues: RsiProps; +}; + +export const rootId = 'chakra-modal-rsi'; + +export const Providers = ({ + children, + rsiValues, +}: ProvidersProps) => { + if (!rsiValues.fields) { + throw new Error('Fields must be provided to spreadsheet-import'); + } + + return ( + {children} + ); +}; diff --git a/front/src/modules/spreadsheet-import/components/core/Table.tsx b/front/src/modules/spreadsheet-import/components/core/Table.tsx new file mode 100644 index 000000000..ac5530e5e --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/core/Table.tsx @@ -0,0 +1,120 @@ +import DataGrid, { DataGridProps } from 'react-data-grid'; +import styled from '@emotion/styled'; + +import { useRsi } from '@/spreadsheet-import/hooks/useRsi'; +import { rgba } from '@/ui/theme/constants/colors'; + +const StyledDataGrid = styled(DataGrid)` + --rdg-background-color: ${({ theme }) => theme.background.primary}; + --rdg-border-color: ${({ theme }) => theme.border.color.medium}; + --rdg-color: ${({ theme }) => theme.font.color.primary}; + --rdg-error-cell-background-color: ${({ theme }) => + rgba(theme.color.red, 0.4)}; + --rdg-font-size: ${({ theme }) => theme.font.size.sm}; + --rdg-frozen-cell-box-shadow: none; + --rdg-header-background-color: ${({ theme }) => theme.background.primary}; + --rdg-info-cell-background-color: ${({ theme }) => theme.color.blue}; + --rdg-row-hover-background-color: ${({ theme }) => + theme.background.secondary}; + --rdg-row-selected-background-color: ${({ theme }) => + theme.background.primary}; + --rdg-row-selected-hover-background-color: ${({ theme }) => + theme.background.secondary}; + --rdg-selection-color: ${({ theme }) => theme.color.blue}; + --rdg-summary-border-color: ${({ theme }) => theme.border.color.medium}; + --rdg-warning-cell-background-color: ${({ theme }) => theme.color.orange}; + --row-selected-hover-background-color: ${({ theme }) => + theme.background.secondary}; + + block-size: 100%; + border: 1px solid ${({ theme }) => theme.border.color.medium}; + border-radius: ${({ theme }) => theme.border.radius.md}; + width: 100%; + + .rdg-header-row .rdg-cell { + box-shadow: none; + color: ${({ theme }) => theme.font.color.tertiary}; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.semiBold}; + letter-spacing: wider; + text-transform: uppercase; + ${({ headerRowHeight }) => { + if (headerRowHeight === 0) { + return ` + border: none; + `; + } + }}; + } + + .rdg-cell { + border-bottom: 1px solid ${({ theme }) => theme.border.color.medium}; + border-inline-end: none; + border-right: none; + box-shadow: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .rdg-row:last-child > .rdg-cell { + border-bottom: none; + } + + .rdg-cell[aria-selected='true'] { + outline: none; + } + + .rdg-cell-error { + background-color: ${({ theme }) => rgba(theme.color.red, 0.08)}; + } + + .rdg-cell-warning { + background-color: ${({ theme }) => rgba(theme.color.orange, 0.08)}; + } + + .rdg-cell-info { + background-color: ${({ theme }) => rgba(theme.color.blue, 0.08)}; + } + + .rdg-static { + cursor: pointer; + } + + .rdg-static .rdg-header-row { + display: none; + } + + .rdg-static .rdg-cell { + --rdg-selection-color: none; + } + + .rdg-example .rdg-cell { + --rdg-selection-color: none; + border-bottom: none; + } + + .rdg-radio { + align-items: center; + display: flex; + } + + .rdg-checkbox { + align-items: center; + display: flex; + line-height: none; + } +` as typeof DataGrid; + +type Props = DataGridProps & { + rowHeight?: number; + hiddenHeader?: boolean; +}; + +export const Table = (props: Props) => { + const { rtl } = useRsi(); + + return ( + + ); +}; diff --git a/front/src/modules/spreadsheet-import/components/steps/MatchColumnsStep/MatchColumnsStep.tsx b/front/src/modules/spreadsheet-import/components/steps/MatchColumnsStep/MatchColumnsStep.tsx new file mode 100644 index 000000000..e1183b8a1 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/steps/MatchColumnsStep/MatchColumnsStep.tsx @@ -0,0 +1,290 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import styled from '@emotion/styled'; + +import { ContinueButton } from '@/spreadsheet-import/components/core/ContinueButton'; +import { Heading } from '@/spreadsheet-import/components/core/Heading'; +import { useRsi } from '@/spreadsheet-import/hooks/useRsi'; +import type { Field, RawData } 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'; +import { setColumn } from '@/spreadsheet-import/utils/setColumn'; +import { setIgnoreColumn } from '@/spreadsheet-import/utils/setIgnoreColumn'; +import { setSubColumn } from '@/spreadsheet-import/utils/setSubColumn'; +import { ButtonVariant } from '@/ui/button/components/Button'; +import { useDialog } from '@/ui/dialog/hooks/useDialog'; +import { Modal } from '@/ui/modal/components/Modal'; +import { useSnackBar } from '@/ui/snack-bar/hooks/useSnackBar'; + +import { ColumnGrid } from './components/ColumnGrid'; +import { TemplateColumn } from './components/TemplateColumn'; +import { UserTableColumn } from './components/UserTableColumn'; + +const StyledContent = styled(Modal.Content)` + align-items: center; +`; + +const StyledColumnsContainer = styled.div` + align-items: center; + display: flex; + flex-direction: column; + margin-bottom: ${({ theme }) => theme.spacing(4)}; +`; + +const StyledColumns = styled.span` + color: ${({ theme }) => theme.font.color.primary}; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.medium}; +`; + +const StyledColumn = styled.span` + color: ${({ theme }) => theme.font.color.primary}; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.regular}; +`; + +export type MatchColumnsProps = { + data: RawData[]; + headerValues: RawData; + onContinue: (data: any[], rawData: RawData[], columns: Columns) => void; +}; + +export enum ColumnType { + empty, + ignored, + matched, + matchedCheckbox, + matchedSelect, + matchedSelectOptions, +} + +export type MatchedOptions = { + entry: string; + value: T; +}; + +type EmptyColumn = { type: ColumnType.empty; index: number; header: string }; +type IgnoredColumn = { + type: ColumnType.ignored; + index: number; + header: string; +}; +type MatchedColumn = { + type: ColumnType.matched; + index: number; + header: string; + value: T; +}; +type MatchedSwitchColumn = { + type: ColumnType.matchedCheckbox; + index: number; + header: string; + value: T; +}; +export type MatchedSelectColumn = { + type: ColumnType.matchedSelect; + index: number; + header: string; + value: T; + matchedOptions: Partial>[]; +}; +export type MatchedSelectOptionsColumn = { + type: ColumnType.matchedSelectOptions; + index: number; + header: string; + value: T; + matchedOptions: MatchedOptions[]; +}; + +export type Column = + | EmptyColumn + | IgnoredColumn + | MatchedColumn + | MatchedSwitchColumn + | MatchedSelectColumn + | MatchedSelectOptionsColumn; + +export type Columns = Column[]; + +export const MatchColumnsStep = ({ + data, + headerValues, + onContinue, +}: MatchColumnsProps) => { + const { enqueueDialog } = useDialog(); + const { enqueueSnackBar } = useSnackBar(); + const dataExample = data.slice(0, 2); + const { fields, autoMapHeaders, autoMapDistance } = useRsi(); + 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 onIgnore = useCallback( + (columnIndex: number) => { + setColumns( + columns.map((column, index) => + columnIndex === index ? setIgnoreColumn(column) : column, + ), + ); + }, + [columns, setColumns], + ); + + const onRevertIgnore = useCallback( + (columnIndex: number) => { + setColumns( + columns.map((column, index) => + columnIndex === index ? setColumn(column) : column, + ), + ); + }, + [columns, setColumns], + ); + + const onChange = useCallback( + (value: T, columnIndex: number) => { + if (value === 'do-not-import') { + if (columns[columnIndex].type === ColumnType.ignored) { + onRevertIgnore(columnIndex); + } else { + onIgnore(columnIndex); + } + } else { + const field = fields.find( + (field) => field.key === value, + ) as unknown as Field; + const existingFieldIndex = columns.findIndex( + (column) => 'value' in column && column.value === field.key, + ); + setColumns( + columns.map>((column, index) => { + if (columnIndex === index) { + return setColumn(column, field, data); + } else if (index === existingFieldIndex) { + enqueueSnackBar('Columns cannot duplicate', { + title: 'Another column unselected', + variant: 'error', + }); + return setColumn(column); + } else { + return column; + } + }), + ); + } + }, + [columns, onRevertIgnore, onIgnore, fields, data, enqueueSnackBar], + ); + + const onSubChange = useCallback( + (value: string, columnIndex: number, entry: string) => { + setColumns( + columns.map((column, index) => + columnIndex === index && 'matchedOptions' in column + ? setSubColumn(column, entry, value) + : column, + ), + ); + }, + [columns, setColumns], + ); + const unmatchedRequiredFields = useMemo( + () => findUnmatchedRequiredFields(fields, columns), + [fields, columns], + ); + + const handleAlertOnContinue = useCallback(async () => { + setIsLoading(true); + await onContinue(normalizeTableData(columns, data, fields), data, columns); + setIsLoading(false); + }, [onContinue, columns, data, fields]); + + const handleOnContinue = useCallback(async () => { + if (unmatchedRequiredFields.length > 0) { + enqueueDialog({ + title: 'Not all columns matched', + message: + 'There are required columns that are not matched or ignored. Do you want to continue?', + children: ( + + Columns not matched: + {unmatchedRequiredFields.map((field) => ( + {field} + ))} + + ), + buttons: [ + { title: 'Cancel' }, + { + title: 'Continue', + onClick: handleAlertOnContinue, + variant: ButtonVariant.Primary, + }, + ], + }); + } else { + setIsLoading(true); + await onContinue( + normalizeTableData(columns, data, fields), + data, + columns, + ); + setIsLoading(false); + } + }, [ + unmatchedRequiredFields, + enqueueDialog, + handleAlertOnContinue, + onContinue, + columns, + data, + fields, + ]); + + useEffect(() => { + if (autoMapHeaders) { + setColumns(getMatchedColumns(columns, fields, data, autoMapDistance)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + + + ( + row[columns[columnIndex].index], + )} + /> + )} + renderTemplateColumn={(columns, columnIndex) => ( + + )} + /> + + + + ); +}; diff --git a/front/src/modules/spreadsheet-import/components/steps/MatchColumnsStep/components/ColumnGrid.tsx b/front/src/modules/spreadsheet-import/components/steps/MatchColumnsStep/components/ColumnGrid.tsx new file mode 100644 index 000000000..1b8f19799 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/steps/MatchColumnsStep/components/ColumnGrid.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import styled from '@emotion/styled'; + +import type { Columns } from '../MatchColumnsStep'; + +const GridContainer = styled.div` + align-items: center; + display: flex; + flex-direction: column; + flex-grow: 1; + height: 0px; + width: 100%; +`; + +const Grid = styled.div` + border: 1px solid ${({ theme }) => theme.border.color.medium}; + border-radius: ${({ theme }) => theme.border.radius.md}; + box-sizing: border-box; + display: flex; + flex-direction: column; + margin-top: ${({ theme }) => theme.spacing(8)}; + width: 75%; +`; + +type HeightProps = { + height?: `${number}px`; +}; + +const GridRow = styled.div` + box-sizing: border-box; + display: flex; + flex-direction: row; + min-height: ${({ height = '64px' }) => height}; +`; + +type PositionProps = { + position: 'left' | 'right'; +}; + +const GridCell = styled.div` + align-items: center; + box-sizing: border-box; + display: flex; + flex: 1; + overflow-x: auto; + padding-bottom: ${({ theme }) => theme.spacing(4)}; + padding-top: ${({ theme }) => theme.spacing(4)}; + ${({ position, theme }) => { + if (position === 'left') { + return ` + padding-left: ${theme.spacing(4)}; + padding-right: ${theme.spacing(2)}; + `; + } + return ` + padding-left: ${theme.spacing(2)}; + padding-right: ${theme.spacing(4)}; + `; + }}; +`; + +const GridHeader = styled.div` + align-items: center; + background-color: ${({ theme }) => theme.background.tertiary}; + box-sizing: border-box; + color: ${({ theme }) => theme.font.color.light}; + display: flex; + flex: 1; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.semiBold}; + padding-left: ${({ theme }) => theme.spacing(4)}; + padding-right: ${({ theme }) => theme.spacing(4)}; + ${({ position, theme }) => { + if (position === 'left') { + return `border-top-left-radius: calc(${theme.border.radius.md} - 1px);`; + } + return `border-top-right-radius: calc(${theme.border.radius.md} - 1px);`; + }}; + text-transform: uppercase; +`; + +type ColumnGridProps = { + columns: Columns; + renderUserColumn: ( + columns: Columns, + columnIndex: number, + ) => React.ReactNode; + renderTemplateColumn: ( + columns: Columns, + columnIndex: number, + ) => React.ReactNode; +}; + +export const ColumnGrid = ({ + columns, + renderUserColumn, + renderTemplateColumn, +}: ColumnGridProps) => { + return ( + <> + + + + Imported data + Twenty fields + + {columns.map((column, index) => { + const userColumn = renderUserColumn(columns, index); + const templateColumn = renderTemplateColumn(columns, index); + + if (React.isValidElement(userColumn)) { + return ( + + {userColumn} + {templateColumn} + + ); + } + + return null; + })} + + + + ); +}; diff --git a/front/src/modules/spreadsheet-import/components/steps/MatchColumnsStep/components/SubMatchingSelect.tsx b/front/src/modules/spreadsheet-import/components/steps/MatchColumnsStep/components/SubMatchingSelect.tsx new file mode 100644 index 000000000..af45862c3 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/steps/MatchColumnsStep/components/SubMatchingSelect.tsx @@ -0,0 +1,56 @@ +import styled from '@emotion/styled'; + +import { MatchColumnSelect } from '@/spreadsheet-import/components/core/MatchColumnSelect'; +import { useRsi } from '@/spreadsheet-import/hooks/useRsi'; +import { SelectOption } from '@/spreadsheet-import/types'; +import { getFieldOptions } from '@/spreadsheet-import/utils/getFieldOptions'; + +import type { + MatchedOptions, + MatchedSelectColumn, + MatchedSelectOptionsColumn, +} from '../MatchColumnsStep'; + +const Container = styled.div` + padding-bottom: ${({ theme }) => theme.spacing(1)}; + padding-left: ${({ theme }) => theme.spacing(2)}; +`; + +const SelectLabel = 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)}; +`; + +interface Props { + option: MatchedOptions | Partial>; + column: MatchedSelectColumn | MatchedSelectOptionsColumn; + onSubChange: (val: T, index: number, option: string) => void; +} + +export const SubMatchingSelect = ({ + option, + column, + onSubChange, +}: Props) => { + const { fields } = useRsi(); + const options = getFieldOptions(fields, column.value) as SelectOption[]; + const value = options.find((opt) => opt.value === option.value); + + return ( + + {option.entry} + + onSubChange(value?.value as T, column.index, option.entry ?? '') + } + options={options} + name={option.entry} + /> + + ); +}; diff --git a/front/src/modules/spreadsheet-import/components/steps/MatchColumnsStep/components/TemplateColumn.tsx b/front/src/modules/spreadsheet-import/components/steps/MatchColumnsStep/components/TemplateColumn.tsx new file mode 100644 index 000000000..d6d6bd0e6 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/steps/MatchColumnsStep/components/TemplateColumn.tsx @@ -0,0 +1,160 @@ +// TODO: We should create our own accordion component +import { + Accordion, + AccordionButton as ChakraAccordionButton, + AccordionIcon, + AccordionItem, + AccordionPanel, +} from '@chakra-ui/accordion'; +import styled from '@emotion/styled'; + +import { MatchColumnSelect } from '@/spreadsheet-import/components/core/MatchColumnSelect'; +import { useRsi } from '@/spreadsheet-import/hooks/useRsi'; +import type { Fields } from '@/spreadsheet-import/types'; +import { IconChevronDown, IconForbid } from '@/ui/icon'; + +import type { Column, Columns } from '../MatchColumnsStep'; +import { ColumnType } from '../MatchColumnsStep'; + +import { SubMatchingSelect } from './SubMatchingSelect'; + +const Container = styled.div` + display: flex; + flex-direction: column; + min-height: 10px; + width: 100%; +`; + +const AccordionButton = 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 AccordionContainer = styled.div` + display: flex; + width: 100%; +`; + +const AccordionLabel = 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.length + } Unmatched)`; +}; + +type TemplateColumnProps = { + 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 } = useRsi(); + 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, + value: key, + label, + disabled: isSelected, + } as const; + }); + const selectOptions = [ + { + icon: , + value: 'do-not-import', + label: 'Do not import', + }, + ...fieldOptions, + ]; + const selectValue = fieldOptions.find( + ({ value }) => 'value' in column && column.value === value, + ); + const ignoreValue = selectOptions.find( + ({ value }) => value === 'do-not-import', + ); + + return ( + + onChange(value?.value as T, column.index)} + options={selectOptions} + name={column.header} + /> + {isSelect && ( + + + + + + {getAccordionTitle(fields, column)} + + + + + {column.matchedOptions.map((option) => ( + + ))} + + + + + )} + + ); +}; diff --git a/front/src/modules/spreadsheet-import/components/steps/MatchColumnsStep/components/UserTableColumn.tsx b/front/src/modules/spreadsheet-import/components/steps/MatchColumnsStep/components/UserTableColumn.tsx new file mode 100644 index 000000000..351e3f0b8 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/steps/MatchColumnsStep/components/UserTableColumn.tsx @@ -0,0 +1,50 @@ +import styled from '@emotion/styled'; + +import type { RawData } from '@/spreadsheet-import/types'; +import { assertNotNull } from '~/utils/assert'; + +import type { Column } from '../MatchColumnsStep'; + +const Container = styled.div` + display: flex; + flex-direction: column; + width: 100%; +`; + +const Value = styled.span` + color: ${({ theme }) => theme.font.color.primary}; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.medium}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const Example = styled.span` + color: ${({ theme }) => theme.font.color.tertiary}; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.medium}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +type UserTableColumnProps = { + column: Column; + entries: RawData; +}; + +export const UserTableColumn = ({ + column, + entries, +}: UserTableColumnProps) => { + const { header } = column; + const entry = entries.find(assertNotNull); + + return ( + + {header} + {entry && {`ex: ${entry}`}} + + ); +}; diff --git a/front/src/modules/spreadsheet-import/components/steps/SelectHeaderStep/SelectHeaderStep.tsx b/front/src/modules/spreadsheet-import/components/steps/SelectHeaderStep/SelectHeaderStep.tsx new file mode 100644 index 000000000..a8a297781 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/steps/SelectHeaderStep/SelectHeaderStep.tsx @@ -0,0 +1,60 @@ +import { useCallback, useState } from 'react'; +import styled from '@emotion/styled'; + +import { ContinueButton } from '@/spreadsheet-import/components/core/ContinueButton'; +import { Heading } from '@/spreadsheet-import/components/core/Heading'; +import type { RawData } from '@/spreadsheet-import/types'; +import { Modal } from '@/ui/modal/components/Modal'; + +import { SelectHeaderTable } from './components/SelectHeaderTable'; + +const StyledHeading = styled(Heading)` + margin-bottom: ${({ theme }) => theme.spacing(8)}; +`; + +const TableContainer = styled.div` + display: flex; + flex-grow: 1; + height: 0px; +`; + +type SelectHeaderProps = { + data: RawData[]; + onContinue: (headerValues: RawData, data: RawData[]) => Promise; +}; + +export const SelectHeaderStep = ({ data, onContinue }: SelectHeaderProps) => { + const [selectedRows, setSelectedRows] = useState>( + new Set([0]), + ); + const [isLoading, setIsLoading] = useState(false); + + const handleContinue = useCallback(async () => { + const [selectedRowIndex] = selectedRows; + // We consider data above header to be redundant + const trimmedData = data.slice(selectedRowIndex + 1); + setIsLoading(true); + await onContinue(data[selectedRowIndex], trimmedData); + setIsLoading(false); + }, [onContinue, data, selectedRows]); + + return ( + <> + + + + + + + + + ); +}; diff --git a/front/src/modules/spreadsheet-import/components/steps/SelectHeaderStep/components/SelectColumn.tsx b/front/src/modules/spreadsheet-import/components/steps/SelectHeaderStep/components/SelectColumn.tsx new file mode 100644 index 000000000..855e0ca70 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/steps/SelectHeaderStep/components/SelectColumn.tsx @@ -0,0 +1,51 @@ +import { Column, FormatterProps, useRowSelection } from 'react-data-grid'; + +import type { RawData } from '@/spreadsheet-import/types'; +import { Radio } from '@/ui/input/radio/components/Radio'; + +const SELECT_COLUMN_KEY = 'select-row'; + +function SelectFormatter(props: FormatterProps) { + const [isRowSelected, onRowSelectionChange] = useRowSelection(); + + return ( + { + onRowSelectionChange({ + row: props.row, + checked: Boolean(event.target.checked), + isShiftClick: (event.nativeEvent as MouseEvent).shiftKey, + }); + }} + /> + ); +} + +export const SelectColumn: Column = { + key: SELECT_COLUMN_KEY, + name: '', + width: 35, + minWidth: 35, + maxWidth: 35, + resizable: false, + sortable: false, + frozen: true, + cellClass: 'rdg-radio', + formatter: SelectFormatter, +}; + +export const generateSelectionColumns = (data: RawData[]) => { + const longestRowLength = data.reduce( + (acc, curr) => (acc > curr.length ? acc : curr.length), + 0, + ); + return [ + SelectColumn, + ...Array.from(Array(longestRowLength), (_, index) => ({ + key: index.toString(), + name: '', + })), + ]; +}; diff --git a/front/src/modules/spreadsheet-import/components/steps/SelectHeaderStep/components/SelectHeaderTable.tsx b/front/src/modules/spreadsheet-import/components/steps/SelectHeaderStep/components/SelectHeaderTable.tsx new file mode 100644 index 000000000..124c48f9c --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/steps/SelectHeaderStep/components/SelectHeaderTable.tsx @@ -0,0 +1,42 @@ +import { useMemo } from 'react'; + +import { Table } from '@/spreadsheet-import/components/core/Table'; +import type { RawData } from '@/spreadsheet-import/types'; + +import { generateSelectionColumns } from './SelectColumn'; + +interface Props { + data: RawData[]; + selectedRows: ReadonlySet; + setSelectedRows: (rows: ReadonlySet) => void; +} + +export const SelectHeaderTable = ({ + data, + selectedRows, + setSelectedRows, +}: Props) => { + const columns = useMemo(() => generateSelectionColumns(data), [data]); + + return ( + data.indexOf(row)} + rows={data} + columns={columns} + selectedRows={selectedRows} + onSelectedRowsChange={(newRows) => { + // allow selecting only one row + newRows.forEach((value) => { + if (!selectedRows.has(value as number)) { + setSelectedRows(new Set([value as number])); + return; + } + }); + }} + onRowClick={(row) => { + setSelectedRows(new Set([data.indexOf(row)])); + }} + headerRowHeight={0} + /> + ); +}; diff --git a/front/src/modules/spreadsheet-import/components/steps/SelectSheetStep/SelectSheetStep.tsx b/front/src/modules/spreadsheet-import/components/steps/SelectSheetStep/SelectSheetStep.tsx new file mode 100644 index 000000000..1feee3e37 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/steps/SelectSheetStep/SelectSheetStep.tsx @@ -0,0 +1,67 @@ +import { useCallback, useState } from 'react'; +import styled from '@emotion/styled'; + +import { Heading } from '@/spreadsheet-import/components/core/Heading'; +import { Radio } from '@/ui/input/radio/components/Radio'; +import { RadioGroup } from '@/ui/input/radio/components/RadioGroup'; +import { Modal } from '@/ui/modal/components/Modal'; + +import { ContinueButton } from '../../core/ContinueButton'; + +const Content = styled(Modal.Content)` + align-items: center; +`; + +const StyledHeading = styled(Heading)` + margin-bottom: ${({ theme }) => theme.spacing(8)}; +`; + +const RadioContainer = styled.div` + display: flex; + flex-direction: column; + flex-grow: 1; + height: 0px; +`; + +type SelectSheetProps = { + sheetNames: string[]; + onContinue: (sheetName: string) => Promise; +}; + +export const SelectSheetStep = ({ + sheetNames, + onContinue, +}: SelectSheetProps) => { + const [isLoading, setIsLoading] = useState(false); + + const [value, setValue] = useState(sheetNames[0]); + + const handleOnContinue = useCallback( + async (data: typeof value) => { + setIsLoading(true); + await onContinue(data); + setIsLoading(false); + }, + [onContinue], + ); + + return ( + <> + + + + setValue(value)} value={value}> + {sheetNames.map((sheetName) => ( + + ))} + + + + handleOnContinue(value)} + title="Next" + /> + + ); +}; diff --git a/front/src/modules/spreadsheet-import/components/steps/Steps.tsx b/front/src/modules/spreadsheet-import/components/steps/Steps.tsx new file mode 100644 index 000000000..b034a8b24 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/steps/Steps.tsx @@ -0,0 +1,47 @@ +import styled from '@emotion/styled'; + +import { useRsi } from '@/spreadsheet-import/hooks/useRsi'; +import { useRsiInitialStep } from '@/spreadsheet-import/hooks/useRsiInitialStep'; +import { Modal } from '@/ui/modal/components/Modal'; +import { StepBar } from '@/ui/step-bar/components/StepBar'; +import { useStepBar } from '@/ui/step-bar/hooks/useStepBar'; + +import { UploadFlow } from './UploadFlow'; + +const Header = styled(Modal.Header)` + background-color: ${({ theme }) => theme.background.secondary}; + border-bottom: 1px solid ${({ theme }) => theme.border.color.medium}; + height: 60px; + padding: 0px; + padding-left: ${({ theme }) => theme.spacing(30)}; + padding-right: ${({ theme }) => theme.spacing(30)}; +`; + +const stepTitles = { + uploadStep: 'Upload file', + matchColumnsStep: 'Match columns', + validationStep: 'Validate data', +} as const; + +export const Steps = () => { + const { initialStepState } = useRsi(); + + const { steps, initialStep } = useRsiInitialStep(initialStepState?.type); + + const { nextStep, activeStep } = useStepBar({ + initialStep, + }); + + return ( + <> +
+ + {steps.map((key) => ( + + ))} + +
+ + + ); +}; diff --git a/front/src/modules/spreadsheet-import/components/steps/UploadFlow.tsx b/front/src/modules/spreadsheet-import/components/steps/UploadFlow.tsx new file mode 100644 index 000000000..fa0f49d87 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/steps/UploadFlow.tsx @@ -0,0 +1,206 @@ +import { useCallback, useState } from 'react'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import type XLSX from 'xlsx-ugnis'; + +import { useRsi } from '@/spreadsheet-import/hooks/useRsi'; +import type { RawData } from '@/spreadsheet-import/types'; +import { exceedsMaxRecords } from '@/spreadsheet-import/utils/exceedsMaxRecords'; +import { mapWorkbook } from '@/spreadsheet-import/utils/mapWorkbook'; +import { Modal } from '@/ui/modal/components/Modal'; +import { CircularProgressBar } from '@/ui/progress-bar/components/CircularProgressBar'; +import { useSnackBar } from '@/ui/snack-bar/hooks/useSnackBar'; + +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 ProgressBarContainer = styled(Modal.Content)` + align-items: center; + display: flex; + justify-content: center; +`; + +export enum StepType { + upload = 'upload', + selectSheet = 'selectSheet', + selectHeader = 'selectHeader', + matchColumns = 'matchColumns', + validateData = 'validateData', +} +export type StepState = + | { + type: StepType.upload; + } + | { + type: StepType.selectSheet; + workbook: XLSX.WorkBook; + } + | { + type: StepType.selectHeader; + data: RawData[]; + } + | { + type: StepType.matchColumns; + data: RawData[]; + headerValues: RawData; + } + | { + type: StepType.validateData; + data: any[]; + }; + +interface Props { + nextStep: () => void; +} + +export const UploadFlow = ({ nextStep }: Props) => { + const theme = useTheme(); + const { initialStepState } = useRsi(); + const [state, setState] = useState( + initialStepState || { type: StepType.upload }, + ); + const [uploadedFile, setUploadedFile] = useState(null); + const { + maxRecords, + uploadStepHook, + selectHeaderStepHook, + matchColumnsStepHook, + } = useRsi(); + const { enqueueSnackBar } = useSnackBar(); + + const errorToast = useCallback( + (description: string) => { + enqueueSnackBar(description, { + title: 'Error', + variant: 'error', + }); + }, + [enqueueSnackBar], + ); + + switch (state.type) { + case StepType.upload: + return ( + { + setUploadedFile(file); + const isSingleSheet = workbook.SheetNames.length === 1; + if (isSingleSheet) { + if ( + maxRecords && + exceedsMaxRecords( + workbook.Sheets[workbook.SheetNames[0]], + maxRecords, + ) + ) { + errorToast( + `Too many records. Up to ${maxRecords.toString()} allowed`, + ); + return; + } + try { + const mappedWorkbook = await uploadStepHook( + mapWorkbook(workbook), + ); + setState({ + type: StepType.selectHeader, + data: mappedWorkbook, + }); + } catch (e) { + errorToast((e as Error).message); + } + } else { + setState({ type: StepType.selectSheet, workbook }); + } + nextStep(); + }} + /> + ); + case StepType.selectSheet: + return ( + { + if ( + maxRecords && + 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, + }); + } catch (e) { + errorToast((e as Error).message); + } + }} + /> + ); + case StepType.selectHeader: + return ( + { + try { + const { data, headerValues } = await selectHeaderStepHook( + ...args, + ); + setState({ + type: StepType.matchColumns, + data, + headerValues, + }); + nextStep(); + } catch (e) { + errorToast((e as Error).message); + } + }} + /> + ); + case StepType.matchColumns: + return ( + { + try { + const data = await matchColumnsStepHook(values, rawData, columns); + setState({ + type: StepType.validateData, + data, + }); + nextStep(); + } catch (e) { + errorToast((e as Error).message); + } + }} + /> + ); + case StepType.validateData: + if (!uploadedFile) { + throw new Error('File not found'); + } + return ; + default: + return ( + + + + ); + } +}; diff --git a/front/src/modules/spreadsheet-import/components/steps/UploadStep/UploadStep.tsx b/front/src/modules/spreadsheet-import/components/steps/UploadStep/UploadStep.tsx new file mode 100644 index 000000000..c0fafd371 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/steps/UploadStep/UploadStep.tsx @@ -0,0 +1,34 @@ +import { useCallback, useState } from 'react'; +import styled from '@emotion/styled'; +import type XLSX from 'xlsx-ugnis'; + +import { Modal } from '@/ui/modal/components/Modal'; + +import { DropZone } from './components/DropZone'; + +const Content = styled(Modal.Content)` + padding: ${({ theme }) => theme.spacing(6)}; +`; + +type UploadProps = { + onContinue: (data: XLSX.WorkBook, file: File) => Promise; +}; + +export const UploadStep = ({ onContinue }: UploadProps) => { + const [isLoading, setIsLoading] = useState(false); + + const handleOnContinue = useCallback( + async (data: XLSX.WorkBook, file: File) => { + setIsLoading(true); + await onContinue(data, file); + setIsLoading(false); + }, + [onContinue], + ); + + return ( + + + + ); +}; diff --git a/front/src/modules/spreadsheet-import/components/steps/UploadStep/components/DropZone.tsx b/front/src/modules/spreadsheet-import/components/steps/UploadStep/components/DropZone.tsx new file mode 100644 index 000000000..c44f4a6ee --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/steps/UploadStep/components/DropZone.tsx @@ -0,0 +1,143 @@ +import { useState } from 'react'; +import { useDropzone } from 'react-dropzone'; +import styled from '@emotion/styled'; +import * as XLSX from 'xlsx-ugnis'; + +import { useRsi } from '@/spreadsheet-import/hooks/useRsi'; +import { readFileAsync } from '@/spreadsheet-import/utils/readFilesAsync'; +import { MainButton } from '@/ui/button/components/MainButton'; +import { useSnackBar } from '@/ui/snack-bar/hooks/useSnackBar'; + +const Container = styled.div` + align-items: center; + background: ${({ theme }) => ` + repeating-linear-gradient( + 0deg, + ${theme.font.color.primary}, + ${theme.font.color.primary} 10px, + transparent 10px, + transparent 20px, + ${theme.font.color.primary} 20px + ), + repeating-linear-gradient( + 90deg, + ${theme.font.color.primary}, + ${theme.font.color.primary} 10px, + transparent 10px, + transparent 20px, + ${theme.font.color.primary} 20px + ), + repeating-linear-gradient( + 180deg, + ${theme.font.color.primary}, + ${theme.font.color.primary} 10px, + transparent 10px, + transparent 20px, + ${theme.font.color.primary} 20px + ), + repeating-linear-gradient( + 270deg, + ${theme.font.color.primary}, + ${theme.font.color.primary} 10px, + transparent 10px, + transparent 20px, + ${theme.font.color.primary} 20px + ); + `}; + background-position: 0 0, 0 0, 100% 0, 0 100%; + background-repeat: no-repeat; + background-size: 2px 100%, 100% 2px, 2px 100%, 100% 2px; + border-radius: ${({ theme }) => theme.border.radius.sm}; + display: flex; + flex: 1; + flex-direction: column; + justify-content: center; + position: relative; +`; + +const Overlay = styled.div` + background: ${({ theme }) => theme.background.transparent.medium}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + bottom: 0px; + left: 0px; + position: absolute; + right: 0px; + top: 0px; +`; + +const Text = styled.span` + color: ${({ theme }) => theme.font.color.primary}; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.medium}; + text-align: center; +`; + +const Button = styled(MainButton)` + margin-top: ${({ theme }) => theme.spacing(2)}; + width: 200px; +`; + +type DropZoneProps = { + onContinue: (data: XLSX.WorkBook, file: File) => void; + isLoading: boolean; +}; + +export const DropZone = ({ onContinue, isLoading }: DropZoneProps) => { + const { maxFileSize, dateFormat, parseRaw } = useRsi(); + + const [loading, setLoading] = useState(false); + + const { enqueueSnackBar } = useSnackBar(); + + const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ + noClick: true, + noKeyboard: true, + maxFiles: 1, + maxSize: maxFileSize, + accept: { + 'application/vnd.ms-excel': ['.xls'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': [ + '.xlsx', + ], + 'text/csv': ['.csv'], + }, + onDropRejected: (fileRejections) => { + setLoading(false); + fileRejections.forEach((fileRejection) => { + enqueueSnackBar(fileRejection.errors[0].message, { + title: `${fileRejection.file.name} upload rejected`, + variant: 'error', + }); + }); + }, + onDropAccepted: async ([file]) => { + setLoading(true); + const arrayBuffer = await readFileAsync(file); + const workbook = XLSX.read(arrayBuffer, { + cellDates: true, + dateNF: dateFormat, + raw: parseRaw, + dense: true, + }); + setLoading(false); + onContinue(workbook, file); + }, + }); + + return ( + + {isDragActive && } + + {isDragActive ? ( + Drop file here... + ) : loading || isLoading ? ( + Processing... + ) : ( + <> + Upload .xlsx, .xls or .csv file +
; +}; diff --git a/front/src/modules/spreadsheet-import/components/steps/UploadStep/components/columns.tsx b/front/src/modules/spreadsheet-import/components/steps/UploadStep/components/columns.tsx new file mode 100644 index 000000000..c5f11b6b1 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/steps/UploadStep/components/columns.tsx @@ -0,0 +1,53 @@ +import type { Column } from 'react-data-grid'; +import { createPortal } from 'react-dom'; +import styled from '@emotion/styled'; + +import type { Fields } from '@/spreadsheet-import/types'; +import { AppTooltip } from '@/ui/tooltip/AppTooltip'; + +const HeaderContainer = styled.div` + align-items: center; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; + position: relative; +`; + +const HeaderLabel = styled.span` + display: flex; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; +`; + +const DefaultContainer = styled.div` + min-height: 100%; + min-width: 100%; + overflow: hidden; + text-overflow: ellipsis; +`; + +export const generateColumns = (fields: Fields) => + fields.map( + (column): Column => ({ + key: column.key, + name: column.label, + minWidth: 150, + headerRenderer: () => ( + + {column.label} + {column.description && + createPortal( + , + document.body, + )} + + ), + formatter: ({ row }) => ( + {row[column.key]} + ), + }), + ); diff --git a/front/src/modules/spreadsheet-import/components/steps/ValidationStep/ValidationStep.tsx b/front/src/modules/spreadsheet-import/components/steps/ValidationStep/ValidationStep.tsx new file mode 100644 index 000000000..07d2e14b6 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/steps/ValidationStep/ValidationStep.tsx @@ -0,0 +1,228 @@ +import { useCallback, useMemo, useState } from 'react'; +import type { RowsChangeData } from 'react-data-grid'; +import styled from '@emotion/styled'; + +import { ContinueButton } from '@/spreadsheet-import/components/core/ContinueButton'; +import { Heading } from '@/spreadsheet-import/components/core/Heading'; +import { Table } from '@/spreadsheet-import/components/core/Table'; +import { useRsi } from '@/spreadsheet-import/hooks/useRsi'; +import type { Data } from '@/spreadsheet-import/types'; +import { addErrorsAndRunHooks } from '@/spreadsheet-import/utils/dataMutations'; +import { Button, ButtonVariant } from '@/ui/button/components/Button'; +import { useDialog } from '@/ui/dialog/hooks/useDialog'; +import { IconTrash } from '@/ui/icon'; +import { Toggle } from '@/ui/input/toggle/components/Toggle'; +import { Modal } from '@/ui/modal/components/Modal'; + +import { generateColumns } from './components/columns'; +import type { Meta } from './types'; + +const Toolbar = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + margin-bottom: ${({ theme }) => theme.spacing(4)}; + margin-top: ${({ theme }) => theme.spacing(8)}; +`; + +const ErrorToggle = styled.div` + align-items: center; + display: flex; + flex-direction: row; +`; + +const ErrorToggleDescription = styled.span` + color: ${({ theme }) => theme.font.color.primary}; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.regular}; + margin-left: ${({ theme }) => theme.spacing(2)}; +`; + +const ScrollContainer = styled.div` + display: flex; + flex-direction: column; + flex-grow: 1; + height: 0px; + width: 100%; +`; + +const NoRowsContainer = styled.div` + display: flex; + grid-column: 1/-1; + justify-content: center; + margin-top: ${({ theme }) => theme.spacing(8)}; +`; + +type Props = { + initialData: Data[]; + file: File; +}; + +export const ValidationStep = ({ + initialData, + file, +}: Props) => { + const { enqueueDialog } = useDialog(); + const { fields, onClose, onSubmit, rowHook, tableHook } = useRsi(); + + const [data, setData] = useState<(Data & Meta)[]>( + useMemo( + () => addErrorsAndRunHooks(initialData, fields, rowHook, tableHook), + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ), + ); + const [selectedRows, setSelectedRows] = useState< + ReadonlySet + >(new Set()); + const [filterByErrors, setFilterByErrors] = useState(false); + + const updateData = useCallback( + (rows: typeof data) => { + setData(addErrorsAndRunHooks(rows, fields, rowHook, tableHook)); + }, + [setData, rowHook, tableHook, fields], + ); + + const deleteSelectedRows = () => { + if (selectedRows.size) { + const newData = data.filter((value) => !selectedRows.has(value.__index)); + updateData(newData); + setSelectedRows(new Set()); + } + }; + + const updateRow = useCallback( + ( + rows: typeof data, + changedData?: RowsChangeData<(typeof data)[number]>, + ) => { + const changes = changedData?.indexes.reduce((acc, index) => { + // when data is filtered val !== actual index in data + const realIndex = data.findIndex( + (value) => value.__index === rows[index].__index, + ); + acc[realIndex] = rows[index]; + return acc; + }, {} as Record); + const newData = Object.assign([], data, changes); + updateData(newData); + }, + [data, updateData], + ); + + const columns = useMemo(() => generateColumns(fields), [fields]); + + const tableData = useMemo(() => { + if (filterByErrors) { + return data.filter((value) => { + if (value?.__errors) { + return Object.values(value.__errors)?.filter( + (err) => err.level === 'error', + ).length; + } + return false; + }); + } + return data; + }, [data, filterByErrors]); + + const rowKeyGetter = useCallback((row: Data & Meta) => row.__index, []); + + const submitData = async () => { + const calculatedData = data.reduce( + (acc, value) => { + const { __index, __errors, ...values } = value; + if (__errors) { + for (const key in __errors) { + if (__errors[key].level === 'error') { + acc.invalidData.push(values as unknown as Data); + return acc; + } + } + } + acc.validData.push(values as unknown as Data); + return acc; + }, + { validData: [] as Data[], invalidData: [] as Data[], all: data }, + ); + onSubmit(calculatedData, file); + onClose(); + }; + const onContinue = () => { + const invalidData = data.find((value) => { + if (value?.__errors) { + return !!Object.values(value.__errors)?.filter( + (err) => err.level === 'error', + ).length; + } + return false; + }); + if (!invalidData) { + submitData(); + } else { + enqueueDialog({ + title: 'Finish flow with errors', + message: + 'There are still some rows that contain errors. Rows with errors will be ignored when submitting.', + buttons: [ + { title: 'Cancel' }, + { + title: 'Submit', + variant: ButtonVariant.Primary, + onClick: submitData, + }, + ], + }); + } + }; + + return ( + <> + + + + + setFilterByErrors(!filterByErrors)} + /> + + Show only rows with errors + + +
+ {filterByErrors + ? 'No data containing errors' + : 'No data found'} + + ), + }} + /> + + + + + ); +}; diff --git a/front/src/modules/spreadsheet-import/components/steps/ValidationStep/components/columns.tsx b/front/src/modules/spreadsheet-import/components/steps/ValidationStep/components/columns.tsx new file mode 100644 index 000000000..edd978f18 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/steps/ValidationStep/components/columns.tsx @@ -0,0 +1,240 @@ +import { Column, useRowSelection } from 'react-data-grid'; +import { createPortal } from 'react-dom'; +import styled from '@emotion/styled'; + +import { MatchColumnSelect } from '@/spreadsheet-import/components/core/MatchColumnSelect'; +import type { Data, Fields } from '@/spreadsheet-import/types'; +import { + Checkbox, + CheckboxVariant, +} from '@/ui/input/checkbox/components/Checkbox'; +import { TextInput } from '@/ui/input/text/components/TextInput'; +import { Toggle } from '@/ui/input/toggle/components/Toggle'; +import { AppTooltip } from '@/ui/tooltip/AppTooltip'; + +import type { Meta } from '../types'; + +const HeaderContainer = styled.div` + align-items: center; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; + position: relative; +`; + +const HeaderLabel = styled.span` + display: flex; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; +`; + +const CheckboxContainer = styled.div` + align-items: center; + box-sizing: content-box; + display: flex; + flex: 1; + height: 100%; + justify-content: center; + line-height: 0; + width: 100%; +`; + +const ToggleContainer = styled.div` + align-items: center; + display: flex; + height: 100%; +`; + +const InputContainer = styled.div` + align-items: center; + display: flex; + min-height: 100%; + min-width: 100%; + padding-right: ${({ theme }) => theme.spacing(2)}; +`; + +const DefaultContainer = styled.div` + min-height: 100%; + min-width: 100%; + overflow: hidden; + text-overflow: ellipsis; +`; + +const SELECT_COLUMN_KEY = 'select-row'; + +export const generateColumns = ( + fields: Fields, +): Column & Meta>[] => [ + { + key: SELECT_COLUMN_KEY, + name: '', + width: 35, + minWidth: 35, + maxWidth: 35, + resizable: false, + sortable: false, + frozen: true, + formatter: (props) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [isRowSelected, onRowSelectionChange] = useRowSelection(); + + return ( + + { + onRowSelectionChange({ + row: props.row, + checked: event.target.checked, + isShiftClick: (event.nativeEvent as MouseEvent).shiftKey, + }); + }} + /> + + ); + }, + }, + ...fields.map( + (column): Column & Meta> => ({ + key: column.key, + name: column.label, + minWidth: 150, + resizable: true, + headerRenderer: () => ( + + {column.label} + {column.description && + createPortal( + , + document.body, + )} + + ), + editable: column.fieldType.type !== 'checkbox', + editor: ({ row, onRowChange, onClose }) => { + let component; + + switch (column.fieldType.type) { + case 'select': { + const value = column.fieldType.options.find( + (option) => + option.value === + (row[column.key as keyof (Data & Meta)] as string), + ); + + component = ( + { + onRowChange({ ...row, [column.key]: value?.value }, true); + }} + options={column.fieldType.options} + /> + ); + break; + } + default: + component = ( + { + onRowChange({ ...row, [column.key]: value }); + }} + autoFocus={true} + onBlur={() => onClose(true)} + /> + ); + } + + return {component}; + }, + editorOptions: { + editOnClick: true, + }, + formatter: ({ row, onRowChange }) => { + let component; + + switch (column.fieldType.type) { + case 'checkbox': + component = ( + { + event.stopPropagation(); + }} + > + { + onRowChange({ + ...row, + [column.key]: !row[column.key as T], + }); + }} + /> + + ); + break; + case 'select': + component = ( + + {column.fieldType.options.find( + (option) => option.value === row[column.key as T], + )?.label || null} + + ); + break; + default: + component = ( + + {row[column.key as T]} + + ); + } + + if (row.__errors?.[column.key]) { + return ( + <> + {component} + {createPortal( + , + document.body, + )} + + ); + } + + return component; + }, + cellClass: (row: Meta) => { + switch (row.__errors?.[column.key]?.level) { + case 'error': + return 'rdg-cell-error'; + case 'warning': + return 'rdg-cell-warning'; + case 'info': + return 'rdg-cell-info'; + default: + return ''; + } + }, + }), + ), +]; diff --git a/front/src/modules/spreadsheet-import/components/steps/ValidationStep/types.ts b/front/src/modules/spreadsheet-import/components/steps/ValidationStep/types.ts new file mode 100644 index 000000000..c7f0a2382 --- /dev/null +++ b/front/src/modules/spreadsheet-import/components/steps/ValidationStep/types.ts @@ -0,0 +1,5 @@ +import type { Info } from '@/spreadsheet-import/types'; + +export type Meta = { __index: string; __errors?: Error | null }; +export type Error = { [key: string]: Info }; +export type Errors = { [id: string]: Error }; diff --git a/front/src/modules/spreadsheet-import/hooks/useRsi.ts b/front/src/modules/spreadsheet-import/hooks/useRsi.ts new file mode 100644 index 000000000..4c2fbe35d --- /dev/null +++ b/front/src/modules/spreadsheet-import/hooks/useRsi.ts @@ -0,0 +1,11 @@ +import { useContext } from 'react'; +import { SetRequired } from 'type-fest'; + +import { RsiContext } from '@/spreadsheet-import/components/core/Providers'; +import { defaultRSIProps } from '@/spreadsheet-import/components/SpreadsheetImport'; +import { RsiProps } from '@/spreadsheet-import/types'; + +export const useRsi = () => + useContext, keyof typeof defaultRSIProps>>( + RsiContext, + ); diff --git a/front/src/modules/spreadsheet-import/hooks/useRsiInitialStep.ts b/front/src/modules/spreadsheet-import/hooks/useRsiInitialStep.ts new file mode 100644 index 000000000..706e34bd0 --- /dev/null +++ b/front/src/modules/spreadsheet-import/hooks/useRsiInitialStep.ts @@ -0,0 +1,26 @@ +import { useMemo } from 'react'; + +import { StepType } from '@/spreadsheet-import/components/steps/UploadFlow'; + +export const useRsiInitialStep = (initialStep?: StepType) => { + const steps = ['uploadStep', 'matchColumnsStep', 'validationStep'] as const; + + const initialStepNumber = useMemo(() => { + switch (initialStep) { + case StepType.upload: + return 0; + case StepType.selectSheet: + return 0; + case StepType.selectHeader: + return 0; + case StepType.matchColumns: + return 2; + case StepType.validateData: + return 3; + default: + return -1; + } + }, [initialStep]); + + return { steps, initialStep: initialStepNumber }; +}; diff --git a/front/src/modules/spreadsheet-import/hooks/useSpreadsheetImport.ts b/front/src/modules/spreadsheet-import/hooks/useSpreadsheetImport.ts new file mode 100644 index 000000000..41ac2187c --- /dev/null +++ b/front/src/modules/spreadsheet-import/hooks/useSpreadsheetImport.ts @@ -0,0 +1,19 @@ +import { useSetRecoilState } from 'recoil'; + +import { spreadsheetImportState } from '@/spreadsheet-import/states/spreadsheetImportState'; +import { RsiProps } from '@/spreadsheet-import/types'; + +export function useSpreadsheetImport() { + const setSpreadSheetImport = useSetRecoilState(spreadsheetImportState); + + const openSpreadsheetImport = ( + options: Omit, 'isOpen' | 'onClose'>, + ) => { + setSpreadSheetImport({ + isOpen: true, + options, + }); + }; + + return { openSpreadsheetImport }; +} diff --git a/front/src/modules/spreadsheet-import/states/spreadsheetImportState.ts b/front/src/modules/spreadsheet-import/states/spreadsheetImportState.ts new file mode 100644 index 000000000..c0c5fde54 --- /dev/null +++ b/front/src/modules/spreadsheet-import/states/spreadsheetImportState.ts @@ -0,0 +1,16 @@ +import { atom } from 'recoil'; + +import { RsiProps } from '../types'; + +export type SpreadsheetImportState = { + isOpen: boolean; + options: Omit, 'isOpen' | 'onClose'> | null; +}; + +export const spreadsheetImportState = atom>({ + key: 'spreadsheetImportState', + default: { + isOpen: false, + options: null, + }, +}); diff --git a/front/src/modules/spreadsheet-import/tests/ReactSpreadsheetImport.test.tsx b/front/src/modules/spreadsheet-import/tests/ReactSpreadsheetImport.test.tsx new file mode 100644 index 000000000..faaece94e --- /dev/null +++ b/front/src/modules/spreadsheet-import/tests/ReactSpreadsheetImport.test.tsx @@ -0,0 +1,33 @@ +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { SpreadsheetImport } from '@/spreadsheet-import/components/SpreadsheetImport'; +import { mockRsiValues } from '@/spreadsheet-import/tests/mockRsiValues'; + +import '@testing-library/jest-dom'; + +test('Close modal', async () => { + let isOpen = true; + const onClose = jest.fn(() => { + isOpen = !isOpen; + }); + const { getByText, getByLabelText } = render( + , + ); + + const closeButton = getByLabelText('Close modal'); + + await userEvent.click(closeButton); + + const confirmButton = getByText('Exit flow'); + + await userEvent.click(confirmButton); + expect(onClose).toBeCalled(); +}); + +test('Should throw error if no fields are provided', async () => { + const errorRender = () => + render(); + + expect(errorRender).toThrow(); +}); diff --git a/front/src/modules/spreadsheet-import/tests/mockRsiValues.ts b/front/src/modules/spreadsheet-import/tests/mockRsiValues.ts new file mode 100644 index 000000000..17dbe206e --- /dev/null +++ b/front/src/modules/spreadsheet-import/tests/mockRsiValues.ts @@ -0,0 +1,170 @@ +import { defaultRSIProps } from '@/spreadsheet-import/components/SpreadsheetImport'; +import type { RsiProps } from '@/spreadsheet-import/types'; + +const fields = [ + { + icon: null, + label: 'Name', + key: 'name', + alternateMatches: ['first name', 'first'], + fieldType: { + type: 'input', + }, + example: 'Stephanie', + validations: [ + { + rule: 'required', + errorMessage: 'Name is required', + }, + ], + }, + { + icon: null, + label: 'Surname', + key: 'surname', + alternateMatches: ['second name', 'last name', 'last'], + fieldType: { + type: 'input', + }, + example: 'McDonald', + validations: [ + { + rule: 'unique', + errorMessage: 'Last name must be unique', + level: 'info', + }, + ], + description: 'Family / Last name', + }, + { + icon: null, + label: 'Age', + key: 'age', + alternateMatches: ['years'], + fieldType: { + type: 'input', + }, + example: '23', + validations: [ + { + rule: 'regex', + value: '^\\d+$', + errorMessage: 'Age must be a number', + level: 'warning', + }, + ], + }, + { + icon: null, + label: 'Team', + key: 'team', + alternateMatches: ['department'], + fieldType: { + type: 'select', + options: [ + { label: 'Team One', value: 'one' }, + { label: 'Team Two', value: 'two' }, + ], + }, + example: 'Team one', + validations: [ + { + rule: 'required', + errorMessage: 'Team is required', + }, + ], + }, + { + icon: null, + label: 'Is manager', + key: 'is_manager', + alternateMatches: ['manages'], + fieldType: { + type: 'checkbox', + booleanMatches: {}, + }, + example: 'true', + }, +] as const; + +const mockComponentBehaviourForTypes = (props: RsiProps) => + props; + +export const mockRsiValues = mockComponentBehaviourForTypes({ + ...defaultRSIProps, + fields: fields, + onSubmit: (data) => { + console.log(data.all.map((value) => value)); + }, + isOpen: true, + onClose: () => { + console.log('onClose'); + }, + uploadStepHook: async (data) => { + await new Promise((resolve) => { + setTimeout(() => resolve(data), 4000); + }); + return data; + }, + selectHeaderStepHook: async (hData, data) => { + await new Promise((resolve) => { + setTimeout( + () => + resolve({ + headerValues: hData, + data, + }), + 4000, + ); + }); + return { + headerValues: hData, + data, + }; + }, + // Runs after column matching and on entry change, more performant + matchColumnsStepHook: async (data) => { + await new Promise((resolve) => { + setTimeout(() => resolve(data), 4000); + }); + return data; + }, +}); + +export const editableTableInitialData = [ + { + name: 'Hello', + surname: 'Hello', + age: '123123', + team: 'one', + is_manager: true, + }, + { + name: 'Hello', + surname: 'Hello', + age: '12312zsas3', + team: 'two', + is_manager: true, + }, + { + name: 'Whooaasdasdawdawdawdiouasdiuasdisdhasd', + surname: 'Hello', + age: '123123', + team: undefined, + is_manager: false, + }, + { + name: 'Goodbye', + surname: 'Goodbye', + age: '111', + team: 'two', + is_manager: true, + }, +]; + +export const headerSelectionTableFields = [ + ['text', 'num', 'select', 'bool'], + ['Hello', '123', 'one', 'true'], + ['Hello', '123', 'one', 'true'], + ['Hello', '123', 'one', 'true'], +]; diff --git a/front/src/modules/spreadsheet-import/types/index.ts b/front/src/modules/spreadsheet-import/types/index.ts new file mode 100644 index 000000000..363015547 --- /dev/null +++ b/front/src/modules/spreadsheet-import/types/index.ts @@ -0,0 +1,157 @@ +import { ReadonlyDeep } from 'type-fest'; + +import { Columns } from '../components/steps/MatchColumnsStep/MatchColumnsStep'; +import { StepState } from '../components/steps/UploadFlow'; +import { Meta } from '../components/steps/ValidationStep/types'; + +export type RsiProps = { + // Is modal visible. + isOpen: boolean; + // callback when RSI is closed before final submit + onClose: () => void; + // Field description for requested data + fields: Fields; + // Runs after file upload step, receives and returns raw sheet data + uploadStepHook?: (data: RawData[]) => Promise; + // Runs after header selection step, receives and returns raw sheet data + selectHeaderStepHook?: ( + headerValues: RawData, + data: RawData[], + ) => Promise<{ headerValues: RawData; data: RawData[] }>; + // Runs once before validation step, used for data mutations and if you want to change how columns were matched + matchColumnsStepHook?: ( + table: Data[], + rawData: RawData[], + columns: Columns, + ) => Promise[]>; + // Runs after column matching and on entry change + rowHook?: RowHook; + // Runs after column matching and on entry change + tableHook?: TableHook; + // Function called after user finishes the flow + onSubmit: (data: Result, file: File) => void; + // Allows submitting with errors. Default: true + allowInvalidSubmit?: boolean; + // Theme configuration passed to underlying Chakra-UI + customTheme?: object; + // Specifies maximum number of rows for a single import + maxRecords?: number; + // Maximum upload filesize (in bytes) + maxFileSize?: number; + // Automatically map imported headers to specified fields if possible. Default: true + autoMapHeaders?: boolean; + // Headers matching accuracy: 1 for strict and up for more flexible matching + autoMapDistance?: number; + // Initial Step state to be rendered on load + initialStepState?: StepState; + // 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. + parseRaw?: boolean; + // Use for right-to-left (RTL) support + rtl?: boolean; +}; + +export type RawData = Array; + +export type Data = { + [key in T]: string | boolean | undefined; +}; + +// Data model RSI uses for spreadsheet imports +export type Fields = ReadonlyDeep[]>; + +export type Field = { + // Icon + icon: React.ReactNode; + // 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 + validations?: Validation[]; + // 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} + booleanMatches?: { [key: string]: boolean }; +}; + +export type Select = { + type: 'select'; + // Options displayed in Select component + options: SelectOption[]; +}; + +export type SelectOption = { + // Icon + icon?: React.ReactNode; + // UI-facing option label + label: string; + // Field entry matching criteria as well as select output + value: string; + // Disabled option when already select + disabled?: boolean; +}; + +export type Input = { + type: 'input'; +}; + +export type Validation = + | RequiredValidation + | UniqueValidation + | RegexValidation; + +export type RequiredValidation = { + rule: 'required'; + errorMessage?: string; + level?: ErrorLevel; +}; + +export type UniqueValidation = { + rule: 'unique'; + allowEmpty?: boolean; + errorMessage?: string; + level?: ErrorLevel; +}; + +export type RegexValidation = { + rule: 'regex'; + value: string; + flags?: string; + errorMessage: string; + level?: ErrorLevel; +}; + +export type RowHook = ( + row: Data, + addError: (fieldKey: T, error: Info) => void, + table: Data[], +) => Data; +export type TableHook = ( + table: Data[], + addError: (rowIndex: number, fieldKey: T, error: Info) => void, +) => Data[]; + +export type ErrorLevel = 'info' | 'warning' | 'error'; + +export type Info = { + message: string; + level: ErrorLevel; +}; + +export type Result = { + validData: Data[]; + invalidData: Data[]; + all: (Data & Meta)[]; +}; diff --git a/front/src/modules/spreadsheet-import/utils/dataMutations.ts b/front/src/modules/spreadsheet-import/utils/dataMutations.ts new file mode 100644 index 000000000..a5579e3b4 --- /dev/null +++ b/front/src/modules/spreadsheet-import/utils/dataMutations.ts @@ -0,0 +1,130 @@ +import { v4 } from 'uuid'; + +import type { + Errors, + Meta, +} from '@/spreadsheet-import/components/steps/ValidationStep/types'; +import type { + Data, + Fields, + Info, + RowHook, + TableHook, +} from '@/spreadsheet-import/types'; + +export const addErrorsAndRunHooks = ( + data: (Data & Partial)[], + fields: Fields, + rowHook?: RowHook, + tableHook?: TableHook, +): (Data & Meta)[] => { + const errors: Errors = {}; + + const addHookError = (rowIndex: number, fieldKey: T, error: Info) => { + errors[rowIndex] = { + ...errors[rowIndex], + [fieldKey]: error, + }; + }; + + if (tableHook) { + data = tableHook(data, addHookError); + } + + if (rowHook) { + data = data.map((value, index) => + rowHook(value, (...props) => addHookError(index, ...props), data), + ); + } + + fields.forEach((field) => { + field.validations?.forEach((validation) => { + switch (validation.rule) { + case 'unique': { + const values = data.map((entry) => entry[field.key as T]); + + const taken = new Set(); // Set of items used at least once + const duplicates = new Set(); // Set of items used multiple times + + values.forEach((value) => { + if (validation.allowEmpty && !value) { + // If allowEmpty is set, we will not validate falsy fields such as undefined or empty string. + return; + } + + if (taken.has(value)) { + duplicates.add(value); + } else { + taken.add(value); + } + }); + + values.forEach((value, index) => { + if (duplicates.has(value)) { + errors[index] = { + ...errors[index], + [field.key]: { + level: validation.level || 'error', + message: validation.errorMessage || 'Field must be unique', + }, + }; + } + }); + break; + } + case 'required': { + data.forEach((entry, index) => { + if ( + entry[field.key as T] === null || + entry[field.key as T] === undefined || + entry[field.key as T] === '' + ) { + errors[index] = { + ...errors[index], + [field.key]: { + level: validation.level || 'error', + message: validation.errorMessage || 'Field is required', + }, + }; + } + }); + break; + } + case 'regex': { + const regex = new RegExp(validation.value, validation.flags); + data.forEach((entry, index) => { + const value = entry[field.key]?.toString() ?? ''; + if (!value.match(regex)) { + errors[index] = { + ...errors[index], + [field.key]: { + level: validation.level || 'error', + message: + validation.errorMessage || + `Field did not match the regex /${validation.value}/${validation.flags} `, + }, + }; + } + }); + break; + } + } + }); + }); + + return data.map((value, index) => { + // This is required only for table. Mutates to prevent needless rerenders + if (!('__index' in value)) { + value.__index = v4(); + } + const newValue = value as Data & Meta; + + if (errors[index]) { + return { ...newValue, __errors: errors[index] }; + } + if (!errors[index] && value?.__errors) { + return { ...newValue, __errors: null }; + } + return newValue; + }); +}; diff --git a/front/src/modules/spreadsheet-import/utils/exceedsMaxRecords.ts b/front/src/modules/spreadsheet-import/utils/exceedsMaxRecords.ts new file mode 100644 index 000000000..8e3043ed0 --- /dev/null +++ b/front/src/modules/spreadsheet-import/utils/exceedsMaxRecords.ts @@ -0,0 +1,12 @@ +import type XLSX from 'xlsx-ugnis'; + +export const exceedsMaxRecords = ( + workSheet: XLSX.WorkSheet, + maxRecords: number, +) => { + const [top, bottom] = + workSheet['!ref'] + ?.split(':') + .map((position) => parseInt(position.replace(/\D/g, ''), 10)) || []; + return bottom - top > maxRecords; +}; diff --git a/front/src/modules/spreadsheet-import/utils/findMatch.ts b/front/src/modules/spreadsheet-import/utils/findMatch.ts new file mode 100644 index 000000000..86e3eb54a --- /dev/null +++ b/front/src/modules/spreadsheet-import/utils/findMatch.ts @@ -0,0 +1,31 @@ +import lavenstein from 'js-levenshtein'; + +import type { Fields } from '@/spreadsheet-import/types'; + +type AutoMatchAccumulator = { + distance: number; + value: T; +}; + +export const findMatch = ( + header: string, + fields: Fields, + autoMapDistance: number, +): T | undefined => { + const smallestValue = fields.reduce>((acc, field) => { + const distance = Math.min( + ...[ + lavenstein(field.key, header), + ...(field.alternateMatches?.map((alternate) => + lavenstein(alternate, header), + ) || []), + ], + ); + return distance < acc.distance || acc.distance === undefined + ? ({ value: field.key, distance } as AutoMatchAccumulator) + : acc; + }, {} as AutoMatchAccumulator); + return smallestValue.distance <= autoMapDistance + ? smallestValue.value + : undefined; +}; diff --git a/front/src/modules/spreadsheet-import/utils/findUnmatchedRequiredFields.ts b/front/src/modules/spreadsheet-import/utils/findUnmatchedRequiredFields.ts new file mode 100644 index 000000000..81dea1cf6 --- /dev/null +++ b/front/src/modules/spreadsheet-import/utils/findUnmatchedRequiredFields.ts @@ -0,0 +1,18 @@ +import type { Columns } from '@/spreadsheet-import/components/steps/MatchColumnsStep/MatchColumnsStep'; +import type { Fields } from '@/spreadsheet-import/types'; + +export const findUnmatchedRequiredFields = ( + fields: Fields, + columns: Columns, +) => + fields + .filter((field) => + field.validations?.some((validation) => validation.rule === 'required'), + ) + .filter( + (field) => + columns.findIndex( + (column) => 'value' in column && column.value === field.key, + ) === -1, + ) + .map((field) => field.label) || []; diff --git a/front/src/modules/spreadsheet-import/utils/generateExampleRow.ts b/front/src/modules/spreadsheet-import/utils/generateExampleRow.ts new file mode 100644 index 000000000..1f4dda370 --- /dev/null +++ b/front/src/modules/spreadsheet-import/utils/generateExampleRow.ts @@ -0,0 +1,14 @@ +import type { Field, Fields } from '@/spreadsheet-import/types'; + +const titleMap: Record['fieldType']['type'], string> = { + checkbox: 'Boolean', + select: 'Options', + input: 'Text', +}; + +export const generateExampleRow = (fields: Fields) => [ + fields.reduce((acc, field) => { + acc[field.key as T] = field.example || titleMap[field.fieldType.type]; + return acc; + }, {} as Record), +]; diff --git a/front/src/modules/spreadsheet-import/utils/getFieldOptions.ts b/front/src/modules/spreadsheet-import/utils/getFieldOptions.ts new file mode 100644 index 000000000..b7fcd5a9b --- /dev/null +++ b/front/src/modules/spreadsheet-import/utils/getFieldOptions.ts @@ -0,0 +1,12 @@ +import type { Fields } from '@/spreadsheet-import/types'; + +export const getFieldOptions = ( + fields: Fields, + fieldKey: string, +) => { + const field = fields.find(({ key }) => fieldKey === key); + if (!field) { + return []; + } + return field.fieldType.type === 'select' ? field.fieldType.options : []; +}; diff --git a/front/src/modules/spreadsheet-import/utils/getMatchedColumns.ts b/front/src/modules/spreadsheet-import/utils/getMatchedColumns.ts new file mode 100644 index 000000000..6b4b2e0b3 --- /dev/null +++ b/front/src/modules/spreadsheet-import/utils/getMatchedColumns.ts @@ -0,0 +1,48 @@ +import lavenstein from 'js-levenshtein'; + +import type { + Column, + Columns, + MatchColumnsProps, +} from '@/spreadsheet-import/components/steps/MatchColumnsStep/MatchColumnsStep'; +import type { Field, Fields } from '@/spreadsheet-import/types'; + +import { findMatch } from './findMatch'; +import { setColumn } from './setColumn'; + +export const getMatchedColumns = ( + columns: Columns, + fields: Fields, + data: MatchColumnsProps['data'], + autoMapDistance: number, +) => + columns.reduce[]>((arr, column) => { + const autoMatch = findMatch(column.header, fields, autoMapDistance); + if (autoMatch) { + const field = fields.find((field) => field.key === autoMatch) as Field; + const duplicateIndex = arr.findIndex( + (column) => 'value' in column && column.value === field.key, + ); + const duplicate = arr[duplicateIndex]; + if (duplicate && 'value' in duplicate) { + return lavenstein(duplicate.value, duplicate.header) < + lavenstein(autoMatch, column.header) + ? [ + ...arr.slice(0, duplicateIndex), + setColumn(arr[duplicateIndex], field, data), + ...arr.slice(duplicateIndex + 1), + setColumn(column), + ] + : [ + ...arr.slice(0, duplicateIndex), + setColumn(arr[duplicateIndex]), + ...arr.slice(duplicateIndex + 1), + setColumn(column, field, data), + ]; + } else { + return [...arr, setColumn(column, field, data)]; + } + } else { + return [...arr, column]; + } + }, []); diff --git a/front/src/modules/spreadsheet-import/utils/mapWorkbook.ts b/front/src/modules/spreadsheet-import/utils/mapWorkbook.ts new file mode 100644 index 000000000..2c208f36c --- /dev/null +++ b/front/src/modules/spreadsheet-import/utils/mapWorkbook.ts @@ -0,0 +1,11 @@ +import * as XLSX from 'xlsx-ugnis'; + +export const mapWorkbook = (workbook: XLSX.WorkBook, sheetName?: string) => { + const worksheet = workbook.Sheets[sheetName || workbook.SheetNames[0]]; + const data = XLSX.utils.sheet_to_json(worksheet, { + header: 1, + blankrows: false, + raw: false, + }); + return data as string[][]; +}; diff --git a/front/src/modules/spreadsheet-import/utils/normalizeCheckboxValue.ts b/front/src/modules/spreadsheet-import/utils/normalizeCheckboxValue.ts new file mode 100644 index 000000000..f8ada87f4 --- /dev/null +++ b/front/src/modules/spreadsheet-import/utils/normalizeCheckboxValue.ts @@ -0,0 +1,13 @@ +const booleanWhitelist: Record = { + yes: true, + no: false, + true: true, + false: false, +}; + +export const normalizeCheckboxValue = (value: string | undefined): boolean => { + if (value && value.toLowerCase() in booleanWhitelist) { + return booleanWhitelist[value.toLowerCase()]; + } + return false; +}; diff --git a/front/src/modules/spreadsheet-import/utils/normalizeTableData.ts b/front/src/modules/spreadsheet-import/utils/normalizeTableData.ts new file mode 100644 index 000000000..34c0129cc --- /dev/null +++ b/front/src/modules/spreadsheet-import/utils/normalizeTableData.ts @@ -0,0 +1,67 @@ +import { + Columns, + ColumnType, +} from '@/spreadsheet-import/components/steps/MatchColumnsStep/MatchColumnsStep'; +import type { Data, Fields, RawData } from '@/spreadsheet-import/types'; + +import { normalizeCheckboxValue } from './normalizeCheckboxValue'; + +export const normalizeTableData = ( + columns: Columns, + data: RawData[], + fields: Fields, +) => + data.map((row) => + columns.reduce((acc, column, index) => { + const curr = row[index]; + switch (column.type) { + case ColumnType.matchedCheckbox: { + const field = fields.find((field) => field.key === column.value); + + if (!field) { + return acc; + } + + if ( + 'booleanMatches' in field.fieldType && + Object.keys(field.fieldType).length + ) { + const booleanMatchKey = Object.keys( + field.fieldType.booleanMatches || [], + ).find((key) => key.toLowerCase() === curr?.toLowerCase()); + + if (!booleanMatchKey) { + return acc; + } + + const booleanMatch = + field.fieldType.booleanMatches?.[booleanMatchKey]; + acc[column.value] = booleanMatchKey + ? booleanMatch + : normalizeCheckboxValue(curr); + } else { + acc[column.value] = normalizeCheckboxValue(curr); + } + return acc; + } + case ColumnType.matched: { + acc[column.value] = curr === '' ? undefined : curr; + return acc; + } + case ColumnType.matchedSelect: + case ColumnType.matchedSelectOptions: { + const matchedOption = column.matchedOptions.find( + ({ entry }) => entry === curr, + ); + acc[column.value] = matchedOption?.value || undefined; + return acc; + } + case ColumnType.empty: + case ColumnType.ignored: { + return acc; + } + default: + return acc; + } + }, {} as Data), + ); diff --git a/front/src/modules/spreadsheet-import/utils/readFilesAsync.ts b/front/src/modules/spreadsheet-import/utils/readFilesAsync.ts new file mode 100644 index 000000000..74225c208 --- /dev/null +++ b/front/src/modules/spreadsheet-import/utils/readFilesAsync.ts @@ -0,0 +1,13 @@ +export const readFileAsync = (file: File) => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = () => { + resolve(reader.result); + }; + + reader.onerror = reject; + + reader.readAsArrayBuffer(file); + }); +}; diff --git a/front/src/modules/spreadsheet-import/utils/setColumn.ts b/front/src/modules/spreadsheet-import/utils/setColumn.ts new file mode 100644 index 000000000..81bc89085 --- /dev/null +++ b/front/src/modules/spreadsheet-import/utils/setColumn.ts @@ -0,0 +1,44 @@ +import { + Column, + ColumnType, + MatchColumnsProps, +} from '@/spreadsheet-import/components/steps/MatchColumnsStep/MatchColumnsStep'; +import type { Field } from '@/spreadsheet-import/types'; + +import { uniqueEntries } from './uniqueEntries'; + +export const setColumn = ( + oldColumn: Column, + field?: Field, + data?: MatchColumnsProps['data'], +): Column => { + switch (field?.fieldType.type) { + case 'select': + return { + ...oldColumn, + type: ColumnType.matchedSelect, + value: field.key, + matchedOptions: uniqueEntries(data || [], oldColumn.index), + }; + case 'checkbox': + return { + index: oldColumn.index, + type: ColumnType.matchedCheckbox, + value: field.key, + header: oldColumn.header, + }; + case 'input': + return { + index: oldColumn.index, + type: ColumnType.matched, + value: field.key, + header: oldColumn.header, + }; + default: + return { + index: oldColumn.index, + header: oldColumn.header, + type: ColumnType.empty, + }; + } +}; diff --git a/front/src/modules/spreadsheet-import/utils/setIgnoreColumn.ts b/front/src/modules/spreadsheet-import/utils/setIgnoreColumn.ts new file mode 100644 index 000000000..836c0c850 --- /dev/null +++ b/front/src/modules/spreadsheet-import/utils/setIgnoreColumn.ts @@ -0,0 +1,13 @@ +import { + Column, + ColumnType, +} from '@/spreadsheet-import/components/steps/MatchColumnsStep/MatchColumnsStep'; + +export const setIgnoreColumn = ({ + header, + index, +}: Column): Column => ({ + header, + index, + type: ColumnType.ignored, +}); diff --git a/front/src/modules/spreadsheet-import/utils/setSubColumn.ts b/front/src/modules/spreadsheet-import/utils/setSubColumn.ts new file mode 100644 index 000000000..e3694a957 --- /dev/null +++ b/front/src/modules/spreadsheet-import/utils/setSubColumn.ts @@ -0,0 +1,30 @@ +import { + ColumnType, + MatchedOptions, + MatchedSelectColumn, + MatchedSelectOptionsColumn, +} from '@/spreadsheet-import/components/steps/MatchColumnsStep/MatchColumnsStep'; + +export const setSubColumn = ( + oldColumn: MatchedSelectColumn | MatchedSelectOptionsColumn, + entry: string, + value: string, +): MatchedSelectColumn | MatchedSelectOptionsColumn => { + const options = oldColumn.matchedOptions.map((option) => + option.entry === entry ? { ...option, value } : option, + ); + const allMathced = options.every(({ value }) => !!value); + if (allMathced) { + return { + ...oldColumn, + matchedOptions: options as MatchedOptions[], + type: ColumnType.matchedSelectOptions, + }; + } else { + return { + ...oldColumn, + matchedOptions: options as MatchedOptions[], + type: ColumnType.matchedSelect, + }; + } +}; diff --git a/front/src/modules/spreadsheet-import/utils/uniqueEntries.ts b/front/src/modules/spreadsheet-import/utils/uniqueEntries.ts new file mode 100644 index 000000000..121bca290 --- /dev/null +++ b/front/src/modules/spreadsheet-import/utils/uniqueEntries.ts @@ -0,0 +1,15 @@ +import uniqBy from 'lodash/uniqBy'; + +import type { + MatchColumnsProps, + MatchedOptions, +} from '@/spreadsheet-import/components/steps/MatchColumnsStep/MatchColumnsStep'; + +export const uniqueEntries = ( + data: MatchColumnsProps['data'], + index: number, +): Partial>[] => + uniqBy( + data.map((row) => ({ entry: row[index] })), + 'entry', + ).filter(({ entry }) => !!entry); diff --git a/front/src/modules/ui/board/components/BoardColumnEditTitleMenu.tsx b/front/src/modules/ui/board/components/BoardColumnEditTitleMenu.tsx index b663e71f9..186653c3b 100644 --- a/front/src/modules/ui/board/components/BoardColumnEditTitleMenu.tsx +++ b/front/src/modules/ui/board/components/BoardColumnEditTitleMenu.tsx @@ -38,7 +38,9 @@ type OwnProps = { const StyledColorSample = styled.div<{ colorName: string }>` background-color: ${({ theme, colorName }) => theme.tag.background[colorName]}; - border: 1px solid ${({ theme, colorName }) => theme.color[colorName]}; + border: 1px solid + ${({ theme, colorName }) => + theme.color[colorName as keyof typeof theme.color]}; border-radius: ${({ theme }) => theme.border.radius.sm}; height: 12px; width: 12px; diff --git a/front/src/modules/ui/button/components/Button.tsx b/front/src/modules/ui/button/components/Button.tsx index 7f0add6ad..3e967a163 100644 --- a/front/src/modules/ui/button/components/Button.tsx +++ b/front/src/modules/ui/button/components/Button.tsx @@ -1,5 +1,6 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import styled from '@emotion/styled'; +import { TablerIconsProps } from '@tabler/icons-react'; import { SoonPill } from '@/ui/pill/components/SoonPill'; import { rgba } from '@/ui/theme/constants/colors'; @@ -58,6 +59,8 @@ const StyledButton = styled.button< case 'primary': case 'secondary': return `${theme.background.transparent.medium}`; + case 'danger': + return `${theme.border.color.danger}`; case 'tertiary': default: return 'none'; @@ -80,6 +83,7 @@ const StyledButton = styled.button< switch (variant) { case 'primary': case 'secondary': + case 'danger': return position === 'middle' ? `1px 0 1px 0` : `1px`; case 'tertiary': default: @@ -98,10 +102,13 @@ const StyledButton = styled.button< color: ${({ theme, variant, disabled }) => { if (disabled) { - if (variant === 'primary') { - return theme.color.gray0; - } else { - return theme.font.color.extraLight; + switch (variant) { + case 'primary': + return theme.color.gray0; + case 'danger': + return theme.border.color.danger; + default: + return theme.font.color.extraLight; } } @@ -156,6 +163,8 @@ const StyledButton = styled.button< switch (variant) { case 'primary': return `background: linear-gradient(0deg, ${theme.background.transparent.medium} 0%, ${theme.background.transparent.medium} 100%), ${theme.color.blue}`; + case 'danger': + return `background: ${theme.background.transparent.danger}`; default: return `background: ${theme.background.tertiary}`; } @@ -178,7 +187,7 @@ const StyledButton = styled.button< `; export function Button({ - icon, + icon: initialIcon, title, fullWidth = false, variant = ButtonVariant.Primary, @@ -188,6 +197,16 @@ export function Button({ disabled = false, ...props }: ButtonProps) { + const icon = useMemo(() => { + if (!initialIcon || !React.isValidElement(initialIcon)) { + return null; + } + + return React.cloneElement(initialIcon as any, { + size: 14, + }); + }, [initialIcon]); + return ( & { + isAnimating?: boolean; + color?: string; + duration?: number; + size?: number; +}; + +export function AnimatedCheckmark({ + isAnimating = false, + color = '#FFF', + duration = 0.5, + size = 28, + ...restProps +}: CheckmarkProps) { + return ( + + + + ); +} diff --git a/front/src/modules/ui/dialog/components/Dialog.tsx b/front/src/modules/ui/dialog/components/Dialog.tsx new file mode 100644 index 000000000..020153b75 --- /dev/null +++ b/front/src/modules/ui/dialog/components/Dialog.tsx @@ -0,0 +1,125 @@ +import { useCallback } from 'react'; +import styled from '@emotion/styled'; +import { motion } from 'framer-motion'; + +import { Button, ButtonVariant } from '@/ui/button/components/Button'; + +const DialogOverlay = styled(motion.div)` + align-items: center; + background: ${({ theme }) => theme.background.overlay}; + display: flex; + height: 100vh; + justify-content: center; + left: 0; + position: fixed; + top: 0; + width: 100vw; + z-index: 9999; +`; + +const DialogContainer = styled(motion.div)` + background: ${({ theme }) => theme.background.primary}; + border-radius: 8px; + display: flex; + flex-direction: column; + max-width: 320px; + padding: 2em; + position: relative; + width: 100%; +`; + +const DialogTitle = styled.span` + color: ${({ theme }) => theme.font.color.primary}; + font-size: ${({ theme }) => theme.font.size.md}; + font-weight: ${({ theme }) => theme.font.weight.semiBold}; + margin-bottom: ${({ theme }) => theme.spacing(6)}; + text-align: center; +`; + +const DialogMessage = styled.span` + color: ${({ theme }) => theme.font.color.primary}; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.regular}; + margin-bottom: ${({ theme }) => theme.spacing(6)}; + text-align: center; +`; + +const DialogButton = styled(Button)` + justify-content: center; + margin-bottom: ${({ theme }) => theme.spacing(2)}; +`; + +export type DialogButtonOptions = Omit< + React.ComponentProps, + 'fullWidth' +>; + +export type DialogProps = React.ComponentPropsWithoutRef & { + title?: string; + message?: string; + buttons?: DialogButtonOptions[]; + allowDismiss?: boolean; + children?: React.ReactNode; + onClose?: () => void; +}; + +export function Dialog({ + title, + message, + buttons = [], + allowDismiss = true, + children, + onClose, + ...rootProps +}: DialogProps) { + const closeSnackbar = useCallback(() => { + onClose && onClose(); + }, [onClose]); + + const dialogVariants = { + open: { opacity: 1 }, + closed: { opacity: 0 }, + }; + + const containerVariants = { + open: { y: 0 }, + closed: { y: '50vh' }, + }; + + return ( + { + if (allowDismiss) { + e.stopPropagation(); + closeSnackbar(); + } + }} + > + + {title && {title}} + {message && {message}} + {children} + {buttons.map((button) => ( + { + button?.onClick?.(e); + closeSnackbar(); + }} + fullWidth={true} + variant={button.variant ?? ButtonVariant.Secondary} + {...button} + /> + ))} + + + ); +} diff --git a/front/src/modules/ui/dialog/components/DialogProvider.tsx b/front/src/modules/ui/dialog/components/DialogProvider.tsx new file mode 100644 index 000000000..8694e71a4 --- /dev/null +++ b/front/src/modules/ui/dialog/components/DialogProvider.tsx @@ -0,0 +1,26 @@ +import { useRecoilState } from 'recoil'; + +import { dialogInternalState } from '../states/dialogState'; + +import { Dialog } from './Dialog'; + +export function DialogProvider({ children }: React.PropsWithChildren) { + const [dialogState, setDialogState] = useRecoilState(dialogInternalState); + + // Handle dialog close event + const handleDialogClose = (id: string) => { + setDialogState((prevState) => ({ + ...prevState, + queue: prevState.queue.filter((snackBar) => snackBar.id !== id), + })); + }; + + return ( + <> + {children} + {dialogState.queue.map((dialog) => ( + handleDialogClose(dialog.id)} /> + ))} + + ); +} diff --git a/front/src/modules/ui/dialog/hooks/useDialog.ts b/front/src/modules/ui/dialog/hooks/useDialog.ts new file mode 100644 index 000000000..75137203f --- /dev/null +++ b/front/src/modules/ui/dialog/hooks/useDialog.ts @@ -0,0 +1,17 @@ +import { useSetRecoilState } from 'recoil'; +import { v4 as uuidv4 } from 'uuid'; + +import { DialogOptions, dialogSetQueueState } from '../states/dialogState'; + +export function useDialog() { + const setDialogQueue = useSetRecoilState(dialogSetQueueState); + + const enqueueDialog = (options?: Omit) => { + setDialogQueue({ + id: uuidv4(), + ...options, + }); + }; + + return { enqueueDialog }; +} diff --git a/front/src/modules/ui/dialog/states/dialogState.ts b/front/src/modules/ui/dialog/states/dialogState.ts new file mode 100644 index 000000000..10e877c09 --- /dev/null +++ b/front/src/modules/ui/dialog/states/dialogState.ts @@ -0,0 +1,39 @@ +import { atom, selector } from 'recoil'; + +import { DialogProps } from '../components/Dialog'; + +export type DialogOptions = DialogProps & { + id: string; +}; + +export type DialogState = { + maxQueue: number; + queue: DialogOptions[]; +}; + +export const dialogInternalState = atom({ + key: 'dialog/internal-state', + default: { + maxQueue: 2, + queue: [], + }, +}); + +export const dialogSetQueueState = selector({ + key: 'dialog/queue-state', + get: ({ get: _get }) => null, // We don't care about getting the value + set: ({ set }, newValue) => + set(dialogInternalState, (prev) => { + if (prev.queue.length >= prev.maxQueue) { + return { + ...prev, + queue: [...prev.queue.slice(1), newValue] as DialogOptions[], + }; + } + + return { + ...prev, + queue: [...prev.queue, newValue] as DialogOptions[], + }; + }), +}); diff --git a/front/src/modules/ui/dropdown/components/DropdownMenuSelectableItem.tsx b/front/src/modules/ui/dropdown/components/DropdownMenuSelectableItem.tsx index 9469a5dfe..8b6d8da09 100644 --- a/front/src/modules/ui/dropdown/components/DropdownMenuSelectableItem.tsx +++ b/front/src/modules/ui/dropdown/components/DropdownMenuSelectableItem.tsx @@ -7,13 +7,15 @@ import { hoverBackground } from '@/ui/theme/constants/effects'; import { DropdownMenuItem } from './DropdownMenuItem'; -type Props = { +type Props = React.ComponentProps<'li'> & { selected?: boolean; - onClick: () => void; hovered?: boolean; + disabled?: boolean; }; -const DropdownMenuSelectableItemContainer = styled(DropdownMenuItem)` +const DropdownMenuSelectableItemContainer = styled(DropdownMenuItem)< + Pick +>` ${hoverBackground}; align-items: center; @@ -27,12 +29,15 @@ const DropdownMenuSelectableItemContainer = styled(DropdownMenuItem)` width: calc(100% - ${({ theme }) => theme.spacing(2)}); `; -const StyledLeftContainer = styled.div` +const StyledLeftContainer = styled.div>` align-items: center; + display: flex; gap: ${({ theme }) => theme.spacing(2)}; + opacity: ${({ disabled }) => (disabled ? 0.5 : 1)}; + overflow: hidden; `; @@ -45,9 +50,19 @@ export function DropdownMenuSelectableItem({ onClick, children, hovered, + disabled, + ...restProps }: React.PropsWithChildren) { const theme = useTheme(); + function handleClick(event: React.MouseEvent) { + if (disabled) { + return; + } + + onClick?.(event); + } + useEffect(() => { if (hovered) { window.scrollTo({ @@ -58,12 +73,12 @@ export function DropdownMenuSelectableItem({ return ( - {children} + {children} {selected && } diff --git a/front/src/modules/ui/icon/index.ts b/front/src/modules/ui/icon/index.ts index bfc5f6ac7..aa0728991 100644 --- a/front/src/modules/ui/icon/index.ts +++ b/front/src/modules/ui/icon/index.ts @@ -1,8 +1,8 @@ -export { IconAddressBook } from './components/IconAddressBook'; export { IconAlertCircle, IconAlertTriangle, IconArchive, + IconArrowBack, IconArrowNarrowDown, IconArrowNarrowUp, IconArrowRight, @@ -26,10 +26,13 @@ export { IconColorSwatch, IconMessageCircle as IconComment, IconCopy, + IconCross, IconCurrencyDollar, IconEye, IconEyeOff, + IconFileImport, IconFileUpload, + IconForbid, IconHeart, IconHelpCircle, IconInbox, diff --git a/front/src/modules/ui/input/checkbox/components/Checkbox.tsx b/front/src/modules/ui/input/checkbox/components/Checkbox.tsx index a4ec455d7..e30e35022 100644 --- a/front/src/modules/ui/input/checkbox/components/Checkbox.tsx +++ b/front/src/modules/ui/input/checkbox/components/Checkbox.tsx @@ -6,6 +6,7 @@ import { IconCheck, IconMinus } from '@/ui/icon'; export enum CheckboxVariant { Primary = 'primary', Secondary = 'secondary', + Tertiary = 'tertiary', } export enum CheckboxShape { @@ -21,7 +22,8 @@ export enum CheckboxSize { type OwnProps = { checked: boolean; indeterminate?: boolean; - onChange?: (value: boolean) => void; + onChange?: (event: React.ChangeEvent) => void; + onCheckedChange?: (value: boolean) => void; variant?: CheckboxVariant; size?: CheckboxSize; shape?: CheckboxShape; @@ -33,13 +35,15 @@ const StyledInputContainer = styled.div` position: relative; `; -const StyledInput = styled.input<{ +type InputProps = { checkboxSize: CheckboxSize; variant: CheckboxVariant; indeterminate?: boolean; shape?: CheckboxShape; - isChecked: boolean; -}>` + isChecked?: boolean; +}; + +const StyledInput = styled.input` cursor: pointer; margin: 0; opacity: 0; @@ -61,18 +65,25 @@ const StyledInput = styled.input<{ checkboxSize === CheckboxSize.Large ? '18px' : '12px'}; background: ${({ theme, indeterminate, isChecked }) => indeterminate || isChecked ? theme.color.blue : 'transparent'}; - border-color: ${({ theme, indeterminate, isChecked, variant }) => - indeterminate || isChecked - ? theme.color.blue - : variant === CheckboxVariant.Primary - ? theme.border.color.inverted - : theme.border.color.secondaryInverted}; + border-color: ${({ theme, indeterminate, isChecked, variant }) => { + switch (true) { + case indeterminate || isChecked: + return theme.color.blue; + case variant === CheckboxVariant.Primary: + return theme.border.color.inverted; + case variant === CheckboxVariant.Tertiary: + return theme.border.color.medium; + default: + return theme.border.color.secondaryInverted; + } + }}; border-radius: ${({ theme, shape }) => shape === CheckboxShape.Rounded ? theme.border.radius.rounded : theme.border.radius.sm}; border-style: solid; - border-width: 1px; + border-width: ${({ variant }) => + variant === CheckboxVariant.Tertiary ? '2px' : '1px'}; content: ''; cursor: pointer; display: inline-block; @@ -81,8 +92,11 @@ const StyledInput = styled.input<{ } & + label > svg { - --padding: ${({ checkboxSize }) => - checkboxSize === CheckboxSize.Large ? '2px' : '1px'}; + --padding: ${({ checkboxSize, variant }) => + checkboxSize === CheckboxSize.Large || + variant === CheckboxVariant.Tertiary + ? '2px' + : '1px'}; --size: ${({ checkboxSize }) => checkboxSize === CheckboxSize.Large ? '16px' : '12px'}; height: var(--size); @@ -97,6 +111,7 @@ const StyledInput = styled.input<{ export function Checkbox({ checked, onChange, + onCheckedChange, indeterminate, variant = CheckboxVariant.Primary, size = CheckboxSize.Small, @@ -108,9 +123,11 @@ export function Checkbox({ React.useEffect(() => { setIsInternalChecked(checked); }, [checked]); - function handleChange(value: boolean) { - onChange?.(value); - setIsInternalChecked(!isInternalChecked); + + function handleChange(event: React.ChangeEvent) { + onChange?.(event); + onCheckedChange?.(event.target.checked); + setIsInternalChecked(event.target.checked); } return ( @@ -126,7 +143,7 @@ export function Checkbox({ checkboxSize={size} shape={shape} isChecked={isInternalChecked} - onChange={(event) => handleChange(event.target.checked)} + onChange={handleChange} />