feat: wip import csv [part 1] (#1033)

* feat: wip import csv

* feat: start implementing twenty UI

* feat: new radio button component

* feat: use new radio button component and fix scroll issue

* fix: max height modal

* feat: wip try to customize react-data-grid to match design

* feat: wip match columns

* feat: wip match column selection

* feat: match column

* feat: clean heading component & try to fix scroll in last step

* feat: validation step

* fix: small cleaning and remove unused component

* feat: clean folder architecture

* feat: remove translations

* feat: remove chackra theme

* feat: remove unused libraries

* feat: use option button to open spreadsheet & fix stories

* Fix lint and fix imports

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Jérémy M
2023-08-16 00:12:47 +02:00
committed by GitHub
parent 1ca41021cf
commit 56cada6335
95 changed files with 7042 additions and 99 deletions

View File

@ -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<RsiProps<any>> = {
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 = <T extends string>(props: RsiProps<T>) => {
return (
<Providers rsiValues={props}>
<ModalWrapper isOpen={props.isOpen} onClose={props.onClose}>
<Steps />
</ModalWrapper>
</Providers>
);
};
SpreadsheetImport.defaultProps = defaultRSIProps;

View File

@ -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 && (
<SpreadsheetImport
isOpen={true}
onClose={handleClose}
{...spreadsheetImportInternalState.options}
/>
)}
</>
);
};

View File

@ -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<typeof MatchColumnsStep> = {
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 (
<Providers rsiValues={mockRsiValues}>
<ModalWrapper isOpen={true} onClose={() => null}>
<MatchColumnsStep
headerValues={mockData[0] as string[]}
data={mockData.slice(1)}
onContinue={() => null}
/>
</ModalWrapper>
</Providers>
);
}

View File

@ -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<typeof SelectHeaderStep> = {
title: 'Modules/SpreadsheetImport/SelectHeaderStep',
component: SelectHeaderStep,
parameters: {
layout: 'fullscreen',
},
};
export default meta;
export function Default() {
return (
<Providers rsiValues={mockRsiValues}>
<ModalWrapper isOpen={true} onClose={() => null}>
<SelectHeaderStep
data={headerSelectionTableFields}
onContinue={() => Promise.resolve()}
/>
</ModalWrapper>
</Providers>
);
}

View File

@ -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<typeof SelectSheetStep> = {
title: 'Modules/SpreadsheetImport/SelectSheetStep',
component: SelectSheetStep,
parameters: {
layout: 'fullscreen',
},
};
export default meta;
const sheetNames = ['Sheet1', 'Sheet2', 'Sheet3'];
export function Default() {
return (
<Providers rsiValues={mockRsiValues}>
<ModalWrapper isOpen={true} onClose={() => null}>
<SelectSheetStep
sheetNames={sheetNames}
onContinue={() => Promise.resolve()}
/>
</ModalWrapper>
</Providers>
);
}

View File

@ -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<typeof UploadStep> = {
title: 'Modules/SpreadsheetImport/UploadStep',
component: UploadStep,
parameters: {
layout: 'fullscreen',
},
};
export default meta;
export function Default() {
return (
<Providers rsiValues={mockRsiValues}>
<ModalWrapper isOpen={true} onClose={() => null}>
<UploadStep onContinue={() => Promise.resolve()} />
</ModalWrapper>
</Providers>
);
}

View File

@ -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<typeof ValidationStep> = {
title: 'Modules/SpreadsheetImport/ValidationStep',
component: ValidationStep,
parameters: {
layout: 'fullscreen',
},
};
export default meta;
const file = new File([''], 'file.csv');
export function Default() {
return (
<Providers rsiValues={mockRsiValues}>
<ModalWrapper isOpen={true} onClose={() => null}>
<ValidationStep initialData={editableTableInitialData} file={file} />
</ModalWrapper>
</Providers>
);
}

View File

@ -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<any> = [
{
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(
<Providers rsiValues={{ ...mockRsiValues, fields }}>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<MatchColumnsStep
headerValues={header}
data={data}
onContinue={onContinue}
/>
</ModalWrapper>
</Providers>,
);
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(
<Providers
rsiValues={{ ...mockRsiValues, fields, autoMapHeaders: false }}
>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<MatchColumnsStep
headerValues={header}
data={data}
onContinue={onContinue}
/>
</ModalWrapper>
</Providers>,
);
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(
<Providers rsiValues={{ ...mockRsiValues, fields, autoMapDistance: 1 }}>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<MatchColumnsStep
headerValues={header}
data={data}
onContinue={onContinue}
/>
</ModalWrapper>
</Providers>,
);
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(
<Providers rsiValues={{ ...mockRsiValues, fields: alternativeFields }}>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<MatchColumnsStep
headerValues={header}
data={data}
onContinue={onContinue}
/>
</ModalWrapper>
</Providers>,
);
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(
<Providers rsiValues={{ ...mockRsiValues, fields }}>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<MatchColumnsStep
headerValues={header}
data={data}
onContinue={onContinue}
/>
</ModalWrapper>
</Providers>,
);
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(
<Providers rsiValues={{ ...mockRsiValues, fields }}>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<MatchColumnsStep
headerValues={header}
data={data}
onContinue={onContinue}
/>
</ModalWrapper>
</Providers>,
);
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(
<Providers rsiValues={{ ...mockRsiValues, fields }}>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<MatchColumnsStep
headerValues={header}
data={data}
onContinue={onContinue}
/>
</ModalWrapper>
</Providers>,
);
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(
<Providers rsiValues={{ ...mockRsiValues, fields }}>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<MatchColumnsStep
headerValues={header}
data={data}
onContinue={onContinue}
/>
</ModalWrapper>
</Providers>,
);
// 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(
<Providers rsiValues={{ ...mockRsiValues, fields }}>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<MatchColumnsStep
headerValues={header}
data={data}
onContinue={onContinue}
/>
</ModalWrapper>
</Providers>,
);
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(
<Providers rsiValues={{ ...mockRsiValues, fields }}>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<MatchColumnsStep
headerValues={header}
data={data}
onContinue={onContinue}
/>
<div id={SELECT_DROPDOWN_ID} />
</ModalWrapper>
</Providers>,
);
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(
<Providers rsiValues={{ ...mockRsiValues, fields }}>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<MatchColumnsStep
headerValues={header}
data={data}
onContinue={onContinue}
/>
<div id={SELECT_DROPDOWN_ID} />
</ModalWrapper>
</Providers>,
);
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(
<Providers rsiValues={{ ...mockRsiValues, fields: enumFields }}>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<MatchColumnsStep
headerValues={header}
data={data}
onContinue={onContinue}
/>
<div id={SELECT_DROPDOWN_ID} />
</ModalWrapper>
</Providers>,
);
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(
<Providers rsiValues={{ ...mockRsiValues, fields }}>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<MatchColumnsStep
headerValues={header}
data={data}
onContinue={onContinue}
/>
</ModalWrapper>
</Providers>,
);
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(
<Providers rsiValues={{ ...mockRsiValues, fields: requiredFields }}>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<MatchColumnsStep
headerValues={header}
data={data}
onContinue={onContinue}
/>
</ModalWrapper>
</Providers>,
);
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(
<Providers rsiValues={{ ...mockRsiValues, fields }}>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<MatchColumnsStep
headerValues={header}
data={data}
onContinue={onContinue}
/>
<div id={SELECT_DROPDOWN_ID} />
</ModalWrapper>
</Providers>,
);
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(
<SpreadsheetImport
{...mockValues}
matchColumnsStepHook={matchColumnsStepHook}
initialStepState={{
type: StepType.matchColumns,
data: [
['Josh', '2'],
['Charlie', '3'],
['Lena', '50'],
],
headerValues: ['name', 'age'],
}}
/>,
);
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(
<SpreadsheetImport
{...mockValues}
matchColumnsStepHook={matchColumnsStepHook}
initialStepState={{
type: StepType.matchColumns,
data: [
['Josh', '2'],
['Charlie', '3'],
['Lena', '50'],
],
headerValues: ['name', 'age'],
}}
/>,
);
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(
<SpreadsheetImport
{...mockValues}
matchColumnsStepHook={matchColumnsStepHook}
initialStepState={{
type: StepType.matchColumns,
data: [
['Josh', '2'],
['Charlie', '3'],
['Lena', '50'],
],
headerValues: ['name', 'age'],
}}
/>,
);
const continueButton = screen.getByText(CONTINUE_BUTTON);
await userEvent.click(continueButton);
const errorToast = await screen.findAllByText(ERROR_MESSAGE, undefined, {
timeout: 5000,
});
expect(errorToast?.[0]).toBeInTheDocument();
});
});

View File

@ -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(
<Providers rsiValues={mockRsiValues}>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<SelectHeaderStep data={data} onContinue={onContinue} />
</ModalWrapper>
</Providers>,
);
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(
<SpreadsheetImport
{...mockRsiValues}
selectHeaderStepHook={selectHeaderStepHook}
/>,
);
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(
<SpreadsheetImport
{...mockRsiValues}
selectHeaderStepHook={selectHeaderStepHook}
initialStepState={{
type: StepType.selectHeader,
data: [
['name', 'age'],
['Josh', '2'],
['Charlie', '3'],
['Lena', '50'],
],
}}
/>,
);
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(
<SpreadsheetImport
{...mockRsiValues}
selectHeaderStepHook={selectHeaderStepHook}
initialStepState={{
type: StepType.selectHeader,
data: [
['name', 'age'],
['Josh', '2'],
['Charlie', '3'],
['Lena', '50'],
],
}}
/>,
);
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(
<SpreadsheetImport
{...mockRsiValues}
dateFormat="yyyy/mm/dd"
parseRaw={true}
/>,
);
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(
<SpreadsheetImport
{...mockRsiValues}
dateFormat="yyyy/mm/dd"
parseRaw={false}
/>,
);
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(<SpreadsheetImport {...mockRsiValues} dateFormat="yyyy/mm/dd" />);
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(
<SpreadsheetImport
{...mockRsiValues}
selectHeaderStepHook={selectHeaderStepHook}
/>,
);
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();
},
);
});

View File

@ -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(<SpreadsheetImport {...mockRsiValues} />);
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(<SpreadsheetImport {...mockRsiValues} />);
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(
<Providers rsiValues={mockRsiValues}>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<SelectSheetStep sheetNames={sheetNames} onContinue={onContinue} />
</ModalWrapper>
</Providers>,
);
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(
<SpreadsheetImport {...mockRsiValues} uploadStepHook={uploadStepHook} />,
);
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();
});

View File

@ -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(
<Providers rsiValues={mockRsiValues}>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<UploadStep onContinue={onContinue} />
</ModalWrapper>
</Providers>,
);
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(
<SpreadsheetImport {...mockRsiValues} uploadStepHook={uploadStepHook} />,
);
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(
<SpreadsheetImport {...mockRsiValues} uploadStepHook={uploadStepHook} />,
);
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(
<SpreadsheetImport {...mockRsiValues} uploadStepHook={uploadStepHook} />,
);
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();
});

View File

@ -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(
<Providers rsiValues={{ ...mockValues, onSubmit: onSubmit }}>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<ValidationStep initialData={[]} file={file} />
</ModalWrapper>
</Providers>,
);
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(
<Providers rsiValues={{ ...mockValues, fields }}>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<ValidationStep initialData={initialData} file={file} />
</ModalWrapper>
</Providers>,
);
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(
<Providers rsiValues={{ ...mockValues, fields, onSubmit }}>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<ValidationStep initialData={initialData} file={file} />
</ModalWrapper>
</Providers>,
);
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(
<Providers rsiValues={{ ...mockValues, fields }}>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<ValidationStep initialData={initialData} file={file} />
</ModalWrapper>
</Providers>,
);
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(
<Providers rsiValues={{ ...mockValues, fields }}>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<ValidationStep initialData={initialData} file={file} />
</ModalWrapper>
</Providers>,
);
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(
<Providers rsiValues={{ ...mockValues, fields }}>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<ValidationStep initialData={initialData} file={file} />
</ModalWrapper>
</Providers>,
);
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(
<Providers rsiValues={{ ...mockValues, fields }}>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<ValidationStep initialData={initialData} file={file} />
</ModalWrapper>
</Providers>,
);
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<HTMLInputElement>('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(
<Providers
rsiValues={{
...mockValues,
fields,
}}
>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<ValidationStep initialData={initialData} file={file} />
</ModalWrapper>
</Providers>,
);
// input
const nameCell = screen.getByRole('gridcell', {
name: NAME,
});
await userEvent.click(nameCell);
const input: HTMLInputElement | null =
screen.getByRole<HTMLInputElement>('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(
<Providers
rsiValues={{
...mockValues,
fields,
rowHook: (value) => ({
name: value.name?.toString()?.split(/(\s+)/)[0],
lastName: value.name?.toString()?.split(/(\s+)/)[2],
}),
}}
>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<ValidationStep initialData={initialData} file={file} />
</ModalWrapper>
</Providers>,
);
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(
<Providers
rsiValues={{
...mockValues,
fields,
rowHook: (value, setError) => {
if (value.name === WRONG_NAME) {
setError(fields[0].key, {
message: 'Wrong name',
level: 'error',
});
}
return value;
},
}}
>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<ValidationStep initialData={initialData} file={file} />
</ModalWrapper>
</Providers>,
);
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<HTMLInputElement>('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(
<Providers
rsiValues={{
...mockValues,
fields,
tableHook: (data) =>
data.map((value) => ({
name: value.name + ADDITION,
})),
}}
>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<ValidationStep initialData={initialData} file={file} />
</ModalWrapper>
</Providers>,
);
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(
<Providers
rsiValues={{
...mockValues,
fields,
tableHook: (data, setError) => {
data.forEach((value, index) => {
if (value.name === WRONG_NAME) {
setError(index, fields[0].key, {
message: 'Wrong name',
level: 'error',
});
}
return value;
});
return data;
},
}}
>
<ModalWrapper isOpen={true} onClose={jest.fn()}>
<ValidationStep initialData={initialData} file={file} />
</ModalWrapper>
</Providers>,
);
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<HTMLInputElement>('textbox');
await userEvent.keyboard(RIGHT_NAME + '{enter}');
await expect(await screen.findAllByRole('row')).toHaveLength(2);
});
});

View File

@ -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) => (
<Footer>
<Button
icon={isLoading && <CircularProgressBar size={16} barWidth={2} />}
title={title}
onClick={!isLoading ? onContinue : undefined}
/>
</Footer>
);

View File

@ -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 (
<Container {...props}>
<Title>{title}</Title>
{description && <Description>{description}</Description>}
</Container>
);
}

View File

@ -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<SelectOption> | null) => void;
value?: ReadonlyDeep<SelectOption>;
options: readonly ReadonlyDeep<SelectOption>[];
placeholder?: string;
name?: string;
}
export const MatchColumnSelect = ({
onChange,
value,
options: initialOptions,
placeholder,
name,
}: Props) => {
const theme = useTheme();
const dropdownItemRef = useRef<HTMLDivElement>(null);
const dropdownContainerRef = useRef<HTMLDivElement>(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<HTMLInputElement>) {
const value = event.currentTarget.value;
setSearchFilter(value);
debouncedHandleSearchFilter(value);
}
function handleDropdownItemClick() {
setIsOpen(true);
}
function handleChange(option: ReadonlyDeep<SelectOption>) {
onChange(option);
setIsOpen(false);
}
function renderIcon(icon: ReadonlyDeep<React.ReactNode>) {
if (icon && React.isValidElement(icon)) {
return React.cloneElement<TablerIconsProps>(icon as any, {
size: 16,
color: theme.font.color.primary,
});
}
return null;
}
useListenClickOutside({
refs: [dropdownContainerRef],
callback: () => {
setIsOpen(false);
},
});
useUpdateEffect(() => {
setOptions(initialOptions);
}, [initialOptions]);
return (
<>
<DropdownItem
id={name}
ref={(node) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
dropdownItemRef.current = node;
refs.setReference(node);
}}
onClick={handleDropdownItemClick}
>
{renderIcon(value?.icon)}
<DropdownLabel isPlaceholder={!value?.label}>
{value?.label ?? placeholder}
</DropdownLabel>
<IconChevronDown size={16} color={theme.font.color.tertiary} />
</DropdownItem>
{isOpen &&
createPortal(
<FloatingDropdown ref={refs.setFloating} style={floatingStyles}>
<DropdownMenu
ref={dropdownContainerRef}
width={dropdownItemRef.current?.clientWidth}
>
<DropdownMenuInput
value={searchFilter}
onChange={handleFilterChange}
autoFocus
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{options?.map((option) => (
<>
<DropdownMenuSelectableItem
id={option.value}
key={option.label}
selected={value?.label === option.label}
onClick={() => handleChange(option)}
disabled={
option.disabled && value?.value !== option.value
}
>
{renderIcon(option?.icon)}
{option.label}
</DropdownMenuSelectableItem>
{option.disabled &&
value?.value !== option.value &&
createPortal(
<AppTooltip
anchorSelect={`#${option.value}`}
content="You are already importing this column."
place="right"
offset={-20}
/>,
document.body,
)}
</>
))}
{options?.length === 0 && (
<DropdownMenuItem>No result</DropdownMenuItem>
)}
</DropdownMenuItemsContainer>
</DropdownMenu>
</FloatingDropdown>,
document.body,
)}
</>
);
};

View File

@ -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 (
<>
<CloseButtonContainer>
<IconButton
icon={<IconX size={16} color={theme.font.color.tertiary} />}
onClick={handleClose}
/>
</CloseButtonContainer>
</>
);
};

View File

@ -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 (
<StyledModal isOpen={isOpen}>
<StyledRtlLtr dir={rtl ? 'rtl' : 'ltr'}>
<ModalCloseButton onClose={onClose} />
{children}
</StyledRtlLtr>
</StyledModal>
);
};

View File

@ -0,0 +1,25 @@
import { createContext } from 'react';
import type { RsiProps } from '@/spreadsheet-import/types';
export const RsiContext = createContext({} as any);
type ProvidersProps<T extends string> = {
children: React.ReactNode;
rsiValues: RsiProps<T>;
};
export const rootId = 'chakra-modal-rsi';
export const Providers = <T extends string>({
children,
rsiValues,
}: ProvidersProps<T>) => {
if (!rsiValues.fields) {
throw new Error('Fields must be provided to spreadsheet-import');
}
return (
<RsiContext.Provider value={rsiValues}>{children}</RsiContext.Provider>
);
};

View File

@ -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<Data> = DataGridProps<Data> & {
rowHeight?: number;
hiddenHeader?: boolean;
};
export const Table = <Data,>(props: Props<Data>) => {
const { rtl } = useRsi();
return (
<StyledDataGrid direction={rtl ? 'rtl' : 'ltr'} rowHeight={52} {...props} />
);
};

View File

@ -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<T extends string> = {
data: RawData[];
headerValues: RawData;
onContinue: (data: any[], rawData: RawData[], columns: Columns<T>) => void;
};
export enum ColumnType {
empty,
ignored,
matched,
matchedCheckbox,
matchedSelect,
matchedSelectOptions,
}
export type MatchedOptions<T> = {
entry: string;
value: T;
};
type EmptyColumn = { type: ColumnType.empty; index: number; header: string };
type IgnoredColumn = {
type: ColumnType.ignored;
index: number;
header: string;
};
type MatchedColumn<T> = {
type: ColumnType.matched;
index: number;
header: string;
value: T;
};
type MatchedSwitchColumn<T> = {
type: ColumnType.matchedCheckbox;
index: number;
header: string;
value: T;
};
export type MatchedSelectColumn<T> = {
type: ColumnType.matchedSelect;
index: number;
header: string;
value: T;
matchedOptions: Partial<MatchedOptions<T>>[];
};
export type MatchedSelectOptionsColumn<T> = {
type: ColumnType.matchedSelectOptions;
index: number;
header: string;
value: T;
matchedOptions: MatchedOptions<T>[];
};
export type Column<T extends string> =
| EmptyColumn
| IgnoredColumn
| MatchedColumn<T>
| MatchedSwitchColumn<T>
| MatchedSelectColumn<T>
| MatchedSelectOptionsColumn<T>;
export type Columns<T extends string> = Column<T>[];
export const MatchColumnsStep = <T extends string>({
data,
headerValues,
onContinue,
}: MatchColumnsProps<T>) => {
const { enqueueDialog } = useDialog();
const { enqueueSnackBar } = useSnackBar();
const dataExample = data.slice(0, 2);
const { fields, autoMapHeaders, autoMapDistance } = useRsi<T>();
const [isLoading, setIsLoading] = useState(false);
const [columns, setColumns] = useState<Columns<T>>(
// Do not remove spread, it indexes empty array elements, otherwise map() skips over them
([...headerValues] as string[]).map((value, index) => ({
type: ColumnType.empty,
index,
header: value ?? '',
})),
);
const onIgnore = useCallback(
(columnIndex: number) => {
setColumns(
columns.map((column, index) =>
columnIndex === index ? setIgnoreColumn<T>(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<T>;
const existingFieldIndex = columns.findIndex(
(column) => 'value' in column && column.value === field.key,
);
setColumns(
columns.map<Column<T>>((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: (
<StyledColumnsContainer>
<StyledColumns>Columns not matched:</StyledColumns>
{unmatchedRequiredFields.map((field) => (
<StyledColumn key={field}>{field}</StyledColumn>
))}
</StyledColumnsContainer>
),
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 (
<>
<StyledContent>
<Heading
title="Match Columns"
description="Select the correct field for each column you'd like to import."
/>
<ColumnGrid
columns={columns}
renderUserColumn={(columns, columnIndex) => (
<UserTableColumn
column={columns[columnIndex]}
entries={dataExample.map(
(row) => row[columns[columnIndex].index],
)}
/>
)}
renderTemplateColumn={(columns, columnIndex) => (
<TemplateColumn
columns={columns}
columnIndex={columnIndex}
onChange={onChange}
onSubChange={onSubChange}
/>
)}
/>
</StyledContent>
<ContinueButton
isLoading={isLoading}
onContinue={handleOnContinue}
title="Next"
/>
</>
);
};

View File

@ -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<HeightProps>`
box-sizing: border-box;
display: flex;
flex-direction: row;
min-height: ${({ height = '64px' }) => height};
`;
type PositionProps = {
position: 'left' | 'right';
};
const GridCell = styled.div<PositionProps>`
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<PositionProps>`
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<T extends string> = {
columns: Columns<T>;
renderUserColumn: (
columns: Columns<T>,
columnIndex: number,
) => React.ReactNode;
renderTemplateColumn: (
columns: Columns<T>,
columnIndex: number,
) => React.ReactNode;
};
export const ColumnGrid = <T extends string>({
columns,
renderUserColumn,
renderTemplateColumn,
}: ColumnGridProps<T>) => {
return (
<>
<GridContainer>
<Grid>
<GridRow height="29px">
<GridHeader position="left">Imported data</GridHeader>
<GridHeader position="right">Twenty fields</GridHeader>
</GridRow>
{columns.map((column, index) => {
const userColumn = renderUserColumn(columns, index);
const templateColumn = renderTemplateColumn(columns, index);
if (React.isValidElement(userColumn)) {
return (
<GridRow key={index}>
<GridCell position="left">{userColumn}</GridCell>
<GridCell position="right">{templateColumn}</GridCell>
</GridRow>
);
}
return null;
})}
</Grid>
</GridContainer>
</>
);
};

View File

@ -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<T> {
option: MatchedOptions<T> | Partial<MatchedOptions<T>>;
column: MatchedSelectColumn<T> | MatchedSelectOptionsColumn<T>;
onSubChange: (val: T, index: number, option: string) => void;
}
export const SubMatchingSelect = <T extends string>({
option,
column,
onSubChange,
}: Props<T>) => {
const { fields } = useRsi<T>();
const options = getFieldOptions(fields, column.value) as SelectOption[];
const value = options.find((opt) => opt.value === option.value);
return (
<Container>
<SelectLabel>{option.entry}</SelectLabel>
<MatchColumnSelect
value={value}
placeholder="Select..."
onChange={(value) =>
onSubChange(value?.value as T, column.index, option.entry ?? '')
}
options={options}
name={option.entry}
/>
</Container>
);
};

View File

@ -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 = <T extends string>(
fields: Fields<T>,
column: Column<T>,
) => {
const fieldLabel = fields.find(
(field) => 'value' in column && field.key === column.value,
)?.label;
return `Match ${fieldLabel} (${
'matchedOptions' in column && column.matchedOptions.length
} Unmatched)`;
};
type TemplateColumnProps<T extends string> = {
columns: Columns<T>;
columnIndex: number;
onChange: (val: T, index: number) => void;
onSubChange: (val: T, index: number, option: string) => void;
};
export const TemplateColumn = <T extends string>({
columns,
columnIndex,
onChange,
onSubChange,
}: TemplateColumnProps<T>) => {
const { fields } = useRsi<T>();
const column = columns[columnIndex];
const isIgnored = column.type === ColumnType.ignored;
const isSelect = 'matchedOptions' in column;
const fieldOptions = fields.map(({ icon, label, key }) => {
const isSelected =
columns.findIndex((column) => {
if ('value' in column) {
return column.value === key;
}
return false;
}) !== -1;
return {
icon,
value: key,
label,
disabled: isSelected,
} as const;
});
const selectOptions = [
{
icon: <IconForbid />,
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 (
<Container>
<MatchColumnSelect
placeholder="Select column..."
value={isIgnored ? ignoreValue : selectValue}
onChange={(value) => onChange(value?.value as T, column.index)}
options={selectOptions}
name={column.header}
/>
{isSelect && (
<AccordionContainer>
<Accordion allowMultiple width="100%">
<AccordionItem border="none" py={1}>
<AccordionButton data-testid="accordion-button">
<AccordionLabel>
{getAccordionTitle<T>(fields, column)}
</AccordionLabel>
<AccordionIcon as={IconChevronDown} />
</AccordionButton>
<AccordionPanel pb={4} pr={3} display="flex" flexDir="column">
{column.matchedOptions.map((option) => (
<SubMatchingSelect
option={option}
column={column}
onSubChange={onSubChange}
key={option.entry}
/>
))}
</AccordionPanel>
</AccordionItem>
</Accordion>
</AccordionContainer>
)}
</Container>
);
};

View File

@ -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<T extends string> = {
column: Column<T>;
entries: RawData;
};
export const UserTableColumn = <T extends string>({
column,
entries,
}: UserTableColumnProps<T>) => {
const { header } = column;
const entry = entries.find(assertNotNull);
return (
<Container>
<Value>{header}</Value>
{entry && <Example>{`ex: ${entry}`}</Example>}
</Container>
);
};

View File

@ -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<void>;
};
export const SelectHeaderStep = ({ data, onContinue }: SelectHeaderProps) => {
const [selectedRows, setSelectedRows] = useState<ReadonlySet<number>>(
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 (
<>
<Modal.Content>
<StyledHeading title="Select header row" />
<TableContainer>
<SelectHeaderTable
data={data}
selectedRows={selectedRows}
setSelectedRows={setSelectedRows}
/>
</TableContainer>
</Modal.Content>
<ContinueButton
onContinue={handleContinue}
title="Next"
isLoading={isLoading}
/>
</>
);
};

View File

@ -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<unknown>) {
const [isRowSelected, onRowSelectionChange] = useRowSelection();
return (
<Radio
aria-label="Select"
checked={isRowSelected}
onChange={(event) => {
onRowSelectionChange({
row: props.row,
checked: Boolean(event.target.checked),
isShiftClick: (event.nativeEvent as MouseEvent).shiftKey,
});
}}
/>
);
}
export const SelectColumn: Column<any, any> = {
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: '',
})),
];
};

View File

@ -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<number>;
setSelectedRows: (rows: ReadonlySet<number>) => void;
}
export const SelectHeaderTable = ({
data,
selectedRows,
setSelectedRows,
}: Props) => {
const columns = useMemo(() => generateSelectionColumns(data), [data]);
return (
<Table
rowKeyGetter={(row) => 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}
/>
);
};

View File

@ -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<void>;
};
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 (
<>
<Content>
<StyledHeading title="Select the sheet to use" />
<RadioContainer>
<RadioGroup onValueChange={(value) => setValue(value)} value={value}>
{sheetNames.map((sheetName) => (
<Radio value={sheetName} key={sheetName} />
))}
</RadioGroup>
</RadioContainer>
</Content>
<ContinueButton
isLoading={isLoading}
onContinue={() => handleOnContinue(value)}
title="Next"
/>
</>
);
};

View File

@ -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 (
<>
<Header>
<StepBar activeStep={activeStep}>
{steps.map((key) => (
<StepBar.Step label={stepTitles[key]} key={key} />
))}
</StepBar>
</Header>
<UploadFlow nextStep={nextStep} />
</>
);
};

View File

@ -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<StepState>(
initialStepState || { type: StepType.upload },
);
const [uploadedFile, setUploadedFile] = useState<File | null>(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 (
<UploadStep
onContinue={async (workbook, file) => {
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 (
<SelectSheetStep
sheetNames={state.workbook.SheetNames}
onContinue={async (sheetName) => {
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 (
<SelectHeaderStep
data={state.data}
onContinue={async (...args) => {
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 (
<MatchColumnsStep
data={state.data}
headerValues={state.headerValues}
onContinue={async (values, rawData, columns) => {
try {
const data = await matchColumnsStepHook(values, rawData, columns);
setState({
type: StepType.validateData,
data,
});
nextStep();
} catch (e) {
errorToast((e as Error).message);
}
}}
/>
);
case StepType.validateData:
if (!uploadedFile) {
throw new Error('File not found');
}
return <ValidationStep initialData={state.data} file={uploadedFile} />;
default:
return (
<ProgressBarContainer>
<CircularProgressBar
size={80}
barWidth={8}
barColor={theme.font.color.primary}
/>
</ProgressBarContainer>
);
}
};

View File

@ -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<void>;
};
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 (
<Content>
<DropZone onContinue={handleOnContinue} isLoading={isLoading} />
</Content>
);
};

View File

@ -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 (
<Container {...getRootProps()}>
{isDragActive && <Overlay />}
<input {...getInputProps()} />
{isDragActive ? (
<Text>Drop file here...</Text>
) : loading || isLoading ? (
<Text>Processing...</Text>
) : (
<>
<Text>Upload .xlsx, .xls or .csv file</Text>
<Button onClick={open} title="Select file" />
</>
)}
</Container>
);
};

View File

@ -0,0 +1,18 @@
import { useMemo } from 'react';
import { Table } from '@/spreadsheet-import/components/core/Table';
import type { Fields } from '@/spreadsheet-import/types';
import { generateExampleRow } from '@/spreadsheet-import/utils/generateExampleRow';
import { generateColumns } from './columns';
interface Props<T extends string> {
fields: Fields<T>;
}
export const ExampleTable = <T extends string>({ fields }: Props<T>) => {
const data = useMemo(() => generateExampleRow(fields), [fields]);
const columns = useMemo(() => generateColumns(fields), [fields]);
return <Table rows={data} columns={columns} className={'rdg-example'} />;
};

View File

@ -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 = <T extends string>(fields: Fields<T>) =>
fields.map(
(column): Column<any> => ({
key: column.key,
name: column.label,
minWidth: 150,
headerRenderer: () => (
<HeaderContainer>
<HeaderLabel id={`${column.key}`}>{column.label}</HeaderLabel>
{column.description &&
createPortal(
<AppTooltip
anchorSelect={`#${column.key}`}
place="top"
content={column.description}
/>,
document.body,
)}
</HeaderContainer>
),
formatter: ({ row }) => (
<DefaultContainer>{row[column.key]}</DefaultContainer>
),
}),
);

View File

@ -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<T extends string> = {
initialData: Data<T>[];
file: File;
};
export const ValidationStep = <T extends string>({
initialData,
file,
}: Props<T>) => {
const { enqueueDialog } = useDialog();
const { fields, onClose, onSubmit, rowHook, tableHook } = useRsi<T>();
const [data, setData] = useState<(Data<T> & Meta)[]>(
useMemo(
() => addErrorsAndRunHooks<T>(initialData, fields, rowHook, tableHook),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
),
);
const [selectedRows, setSelectedRows] = useState<
ReadonlySet<number | string>
>(new Set());
const [filterByErrors, setFilterByErrors] = useState(false);
const updateData = useCallback(
(rows: typeof data) => {
setData(addErrorsAndRunHooks<T>(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<number, (typeof data)[number]>);
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<T> & 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<T>);
return acc;
}
}
}
acc.validData.push(values as unknown as Data<T>);
return acc;
},
{ validData: [] as Data<T>[], invalidData: [] as Data<T>[], 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 (
<>
<Modal.Content>
<Heading
title="Review your import"
description="Correct the issues and fill the missing data."
/>
<Toolbar>
<ErrorToggle>
<Toggle
value={filterByErrors}
onChange={() => setFilterByErrors(!filterByErrors)}
/>
<ErrorToggleDescription>
Show only rows with errors
</ErrorToggleDescription>
</ErrorToggle>
<Button
icon={<IconTrash />}
title="Remove"
variant={ButtonVariant.Danger}
onClick={deleteSelectedRows}
disabled={selectedRows.size === 0}
/>
</Toolbar>
<ScrollContainer>
<Table
rowKeyGetter={rowKeyGetter}
rows={tableData}
onRowsChange={updateRow}
columns={columns}
selectedRows={selectedRows}
onSelectedRowsChange={setSelectedRows}
components={{
noRowsFallback: (
<NoRowsContainer>
{filterByErrors
? 'No data containing errors'
: 'No data found'}
</NoRowsContainer>
),
}}
/>
</ScrollContainer>
</Modal.Content>
<ContinueButton onContinue={onContinue} title="Confirm" />
</>
);
};

View File

@ -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 = <T extends string>(
fields: Fields<T>,
): Column<Data<T> & 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 (
<CheckboxContainer>
<Checkbox
aria-label="Select"
checked={isRowSelected}
variant={CheckboxVariant.Tertiary}
onChange={(event) => {
onRowSelectionChange({
row: props.row,
checked: event.target.checked,
isShiftClick: (event.nativeEvent as MouseEvent).shiftKey,
});
}}
/>
</CheckboxContainer>
);
},
},
...fields.map(
(column): Column<Data<T> & Meta> => ({
key: column.key,
name: column.label,
minWidth: 150,
resizable: true,
headerRenderer: () => (
<HeaderContainer>
<HeaderLabel id={`${column.key}`}>{column.label}</HeaderLabel>
{column.description &&
createPortal(
<AppTooltip
anchorSelect={`#${column.key}`}
place="top"
content={column.description}
/>,
document.body,
)}
</HeaderContainer>
),
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<T> & Meta)] as string),
);
component = (
<MatchColumnSelect
value={
value
? ({
icon: null,
...value,
} as const)
: value
}
onChange={(value) => {
onRowChange({ ...row, [column.key]: value?.value }, true);
}}
options={column.fieldType.options}
/>
);
break;
}
default:
component = (
<TextInput
value={row[column.key] as string}
onChange={(value: string) => {
onRowChange({ ...row, [column.key]: value });
}}
autoFocus={true}
onBlur={() => onClose(true)}
/>
);
}
return <InputContainer>{component}</InputContainer>;
},
editorOptions: {
editOnClick: true,
},
formatter: ({ row, onRowChange }) => {
let component;
switch (column.fieldType.type) {
case 'checkbox':
component = (
<ToggleContainer
id={`${column.key}-${row.__index}`}
onClick={(event) => {
event.stopPropagation();
}}
>
<Toggle
value={row[column.key] as boolean}
onChange={() => {
onRowChange({
...row,
[column.key]: !row[column.key as T],
});
}}
/>
</ToggleContainer>
);
break;
case 'select':
component = (
<DefaultContainer id={`${column.key}-${row.__index}`}>
{column.fieldType.options.find(
(option) => option.value === row[column.key as T],
)?.label || null}
</DefaultContainer>
);
break;
default:
component = (
<DefaultContainer id={`${column.key}-${row.__index}`}>
{row[column.key as T]}
</DefaultContainer>
);
}
if (row.__errors?.[column.key]) {
return (
<>
{component}
{createPortal(
<AppTooltip
anchorSelect={`#${column.key}-${row.__index}`}
place="top"
content={row.__errors?.[column.key]?.message}
/>,
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 '';
}
},
}),
),
];

View File

@ -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 };

View File

@ -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 = <T extends string>() =>
useContext<SetRequired<RsiProps<T>, keyof typeof defaultRSIProps>>(
RsiContext,
);

View File

@ -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 };
};

View File

@ -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<RsiProps<string>, 'isOpen' | 'onClose'>,
) => {
setSpreadSheetImport({
isOpen: true,
options,
});
};
return { openSpreadsheetImport };
}

View File

@ -0,0 +1,16 @@
import { atom } from 'recoil';
import { RsiProps } from '../types';
export type SpreadsheetImportState<T extends string> = {
isOpen: boolean;
options: Omit<RsiProps<T>, 'isOpen' | 'onClose'> | null;
};
export const spreadsheetImportState = atom<SpreadsheetImportState<string>>({
key: 'spreadsheetImportState',
default: {
isOpen: false,
options: null,
},
});

View File

@ -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(
<SpreadsheetImport {...mockRsiValues} onClose={onClose} isOpen={isOpen} />,
);
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(<SpreadsheetImport {...mockRsiValues} fields={undefined} />);
expect(errorRender).toThrow();
});

View File

@ -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 = <T extends string>(props: RsiProps<T>) =>
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'],
];

View File

@ -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<T extends string> = {
// Is modal visible.
isOpen: boolean;
// callback when RSI is closed before final submit
onClose: () => void;
// Field description for requested data
fields: Fields<T>;
// Runs after file upload step, receives and returns raw sheet data
uploadStepHook?: (data: RawData[]) => Promise<RawData[]>;
// 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<T>[],
rawData: RawData[],
columns: Columns<T>,
) => Promise<Data<T>[]>;
// Runs after column matching and on entry change
rowHook?: RowHook<T>;
// Runs after column matching and on entry change
tableHook?: TableHook<T>;
// Function called after user finishes the flow
onSubmit: (data: Result<T>, 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<string | undefined>;
export type Data<T extends string> = {
[key in T]: string | boolean | undefined;
};
// Data model RSI uses for spreadsheet imports
export type Fields<T extends string> = ReadonlyDeep<Field<T>[]>;
export type Field<T extends string> = {
// 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<T extends string> = (
row: Data<T>,
addError: (fieldKey: T, error: Info) => void,
table: Data<T>[],
) => Data<T>;
export type TableHook<T extends string> = (
table: Data<T>[],
addError: (rowIndex: number, fieldKey: T, error: Info) => void,
) => Data<T>[];
export type ErrorLevel = 'info' | 'warning' | 'error';
export type Info = {
message: string;
level: ErrorLevel;
};
export type Result<T extends string> = {
validData: Data<T>[];
invalidData: Data<T>[];
all: (Data<T> & Meta)[];
};

View File

@ -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 = <T extends string>(
data: (Data<T> & Partial<Meta>)[],
fields: Fields<T>,
rowHook?: RowHook<T>,
tableHook?: TableHook<T>,
): (Data<T> & 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<T> & Meta;
if (errors[index]) {
return { ...newValue, __errors: errors[index] };
}
if (!errors[index] && value?.__errors) {
return { ...newValue, __errors: null };
}
return newValue;
});
};

View File

@ -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;
};

View File

@ -0,0 +1,31 @@
import lavenstein from 'js-levenshtein';
import type { Fields } from '@/spreadsheet-import/types';
type AutoMatchAccumulator<T> = {
distance: number;
value: T;
};
export const findMatch = <T extends string>(
header: string,
fields: Fields<T>,
autoMapDistance: number,
): T | undefined => {
const smallestValue = fields.reduce<AutoMatchAccumulator<T>>((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<T>)
: acc;
}, {} as AutoMatchAccumulator<T>);
return smallestValue.distance <= autoMapDistance
? smallestValue.value
: undefined;
};

View File

@ -0,0 +1,18 @@
import type { Columns } from '@/spreadsheet-import/components/steps/MatchColumnsStep/MatchColumnsStep';
import type { Fields } from '@/spreadsheet-import/types';
export const findUnmatchedRequiredFields = <T extends string>(
fields: Fields<T>,
columns: Columns<T>,
) =>
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) || [];

View File

@ -0,0 +1,14 @@
import type { Field, Fields } from '@/spreadsheet-import/types';
const titleMap: Record<Field<string>['fieldType']['type'], string> = {
checkbox: 'Boolean',
select: 'Options',
input: 'Text',
};
export const generateExampleRow = <T extends string>(fields: Fields<T>) => [
fields.reduce((acc, field) => {
acc[field.key as T] = field.example || titleMap[field.fieldType.type];
return acc;
}, {} as Record<T, string>),
];

View File

@ -0,0 +1,12 @@
import type { Fields } from '@/spreadsheet-import/types';
export const getFieldOptions = <T extends string>(
fields: Fields<T>,
fieldKey: string,
) => {
const field = fields.find(({ key }) => fieldKey === key);
if (!field) {
return [];
}
return field.fieldType.type === 'select' ? field.fieldType.options : [];
};

View File

@ -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 = <T extends string>(
columns: Columns<T>,
fields: Fields<T>,
data: MatchColumnsProps<T>['data'],
autoMapDistance: number,
) =>
columns.reduce<Column<T>[]>((arr, column) => {
const autoMatch = findMatch(column.header, fields, autoMapDistance);
if (autoMatch) {
const field = fields.find((field) => field.key === autoMatch) as Field<T>;
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];
}
}, []);

View File

@ -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[][];
};

View File

@ -0,0 +1,13 @@
const booleanWhitelist: Record<string, boolean> = {
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;
};

View File

@ -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 = <T extends string>(
columns: Columns<T>,
data: RawData[],
fields: Fields<T>,
) =>
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<T>),
);

View File

@ -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);
});
};

View File

@ -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 = <T extends string>(
oldColumn: Column<T>,
field?: Field<T>,
data?: MatchColumnsProps<T>['data'],
): Column<T> => {
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,
};
}
};

View File

@ -0,0 +1,13 @@
import {
Column,
ColumnType,
} from '@/spreadsheet-import/components/steps/MatchColumnsStep/MatchColumnsStep';
export const setIgnoreColumn = <T extends string>({
header,
index,
}: Column<T>): Column<T> => ({
header,
index,
type: ColumnType.ignored,
});

View File

@ -0,0 +1,30 @@
import {
ColumnType,
MatchedOptions,
MatchedSelectColumn,
MatchedSelectOptionsColumn,
} from '@/spreadsheet-import/components/steps/MatchColumnsStep/MatchColumnsStep';
export const setSubColumn = <T>(
oldColumn: MatchedSelectColumn<T> | MatchedSelectOptionsColumn<T>,
entry: string,
value: string,
): MatchedSelectColumn<T> | MatchedSelectOptionsColumn<T> => {
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<T>[],
type: ColumnType.matchedSelectOptions,
};
} else {
return {
...oldColumn,
matchedOptions: options as MatchedOptions<T>[],
type: ColumnType.matchedSelect,
};
}
};

View File

@ -0,0 +1,15 @@
import uniqBy from 'lodash/uniqBy';
import type {
MatchColumnsProps,
MatchedOptions,
} from '@/spreadsheet-import/components/steps/MatchColumnsStep/MatchColumnsStep';
export const uniqueEntries = <T extends string>(
data: MatchColumnsProps<T>['data'],
index: number,
): Partial<MatchedOptions<T>>[] =>
uniqBy(
data.map((row) => ({ entry: row[index] })),
'entry',
).filter(({ entry }) => !!entry);