mirror of
https://github.com/lingble/twenty.git
synced 2025-11-03 14:17:58 +00:00
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:
@@ -29,7 +29,7 @@ module.exports = {
|
|||||||
'~/(.+)': "<rootDir>/src/$1",
|
'~/(.+)': "<rootDir>/src/$1",
|
||||||
'@/(.+)': "<rootDir>/src/modules/$1",
|
'@/(.+)': "<rootDir>/src/modules/$1",
|
||||||
'@testing/(.+)': "<rootDir>/src/testing/$1",
|
'@testing/(.+)': "<rootDir>/src/testing/$1",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||||
"@blocknote/core": "^0.8.2",
|
"@blocknote/core": "^0.8.2",
|
||||||
"@blocknote/react": "^0.8.2",
|
"@blocknote/react": "^0.8.2",
|
||||||
|
"@chakra-ui/accordion": "^2.3.0",
|
||||||
|
"@chakra-ui/system": "^2.6.0",
|
||||||
"@emotion/react": "^11.10.6",
|
"@emotion/react": "^11.10.6",
|
||||||
"@emotion/styled": "^11.10.5",
|
"@emotion/styled": "^11.10.5",
|
||||||
"@floating-ui/react": "^0.24.3",
|
"@floating-ui/react": "^0.24.3",
|
||||||
@@ -29,27 +31,32 @@
|
|||||||
"hex-rgb": "^5.0.0",
|
"hex-rgb": "^5.0.0",
|
||||||
"immer": "^10.0.2",
|
"immer": "^10.0.2",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
|
"js-levenshtein": "^1.1.6",
|
||||||
"jwt-decode": "^3.1.2",
|
"jwt-decode": "^3.1.2",
|
||||||
"libphonenumber-js": "^1.10.26",
|
"libphonenumber-js": "^1.10.26",
|
||||||
"lodash.debounce": "^4.0.8",
|
"lodash.debounce": "^4.0.8",
|
||||||
"luxon": "^3.3.0",
|
"luxon": "^3.3.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
"react-data-grid": "7.0.0-beta.13",
|
||||||
"react-datepicker": "^4.11.0",
|
"react-datepicker": "^4.11.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-dropzone": "^14.2.3",
|
||||||
"react-hook-form": "^7.45.1",
|
"react-hook-form": "^7.45.1",
|
||||||
"react-hotkeys-hook": "^4.4.0",
|
"react-hotkeys-hook": "^4.4.0",
|
||||||
"react-loading-skeleton": "^3.3.1",
|
"react-loading-skeleton": "^3.3.1",
|
||||||
"react-modal": "^3.16.1",
|
|
||||||
"react-responsive": "^9.0.2",
|
"react-responsive": "^9.0.2",
|
||||||
"react-router-dom": "^6.4.4",
|
"react-router-dom": "^6.4.4",
|
||||||
|
"react-select-event": "^5.5.1",
|
||||||
"react-textarea-autosize": "^8.4.1",
|
"react-textarea-autosize": "^8.4.1",
|
||||||
"react-tooltip": "^5.13.1",
|
"react-tooltip": "^5.13.1",
|
||||||
"recoil": "^0.7.7",
|
"recoil": "^0.7.7",
|
||||||
"scroll-into-view": "^1.16.2",
|
"scroll-into-view": "^1.16.2",
|
||||||
"ts-key-enum": "^2.0.12",
|
"ts-key-enum": "^2.0.12",
|
||||||
|
"type-fest": "^4.1.0",
|
||||||
"url": "^0.11.1",
|
"url": "^0.11.1",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
"web-vitals": "^2.1.4",
|
"web-vitals": "^2.1.4",
|
||||||
|
"xlsx-ugnis": "^0.19.3",
|
||||||
"yup": "^1.2.0"
|
"yup": "^1.2.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
15
front/src/hooks/useCombinedRefs.ts
Normal file
15
front/src/hooks/useCombinedRefs.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import React, { Ref, RefCallback } from 'react';
|
||||||
|
|
||||||
|
export function useCombinedRefs<T>(
|
||||||
|
...refs: (Ref<T> | undefined)[]
|
||||||
|
): RefCallback<T> {
|
||||||
|
return (node: T) => {
|
||||||
|
for (const ref of refs) {
|
||||||
|
if (typeof ref === 'function') {
|
||||||
|
ref(node);
|
||||||
|
} else if (ref !== null && ref !== undefined) {
|
||||||
|
(ref as React.MutableRefObject<T | null>).current = node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import { RecoilRoot } from 'recoil';
|
|||||||
|
|
||||||
import { ApolloProvider } from '@/apollo/components/ApolloProvider';
|
import { ApolloProvider } from '@/apollo/components/ApolloProvider';
|
||||||
import { ClientConfigProvider } from '@/client-config/components/ClientConfigProvider';
|
import { ClientConfigProvider } from '@/client-config/components/ClientConfigProvider';
|
||||||
|
import { SpreadsheetImportProvider } from '@/spreadsheet-import/components/SpreadsheetImportProvider';
|
||||||
|
import { DialogProvider } from '@/ui/dialog/components/DialogProvider';
|
||||||
import { SnackBarProvider } from '@/ui/snack-bar/components/SnackBarProvider';
|
import { SnackBarProvider } from '@/ui/snack-bar/components/SnackBarProvider';
|
||||||
import { AppThemeProvider } from '@/ui/theme/components/AppThemeProvider';
|
import { AppThemeProvider } from '@/ui/theme/components/AppThemeProvider';
|
||||||
import { ThemeType } from '@/ui/theme/constants/theme';
|
import { ThemeType } from '@/ui/theme/constants/theme';
|
||||||
@@ -31,9 +33,13 @@ root.render(
|
|||||||
<AuthAutoRouter />
|
<AuthAutoRouter />
|
||||||
<AppThemeProvider>
|
<AppThemeProvider>
|
||||||
<SnackBarProvider>
|
<SnackBarProvider>
|
||||||
<StrictMode>
|
<DialogProvider>
|
||||||
<App />
|
<SpreadsheetImportProvider>
|
||||||
</StrictMode>
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
</SpreadsheetImportProvider>
|
||||||
|
</DialogProvider>
|
||||||
</SnackBarProvider>
|
</SnackBarProvider>
|
||||||
</AppThemeProvider>
|
</AppThemeProvider>
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function TaskRow({ task }: { task: TaskForList }) {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
checked={!!task.completedAt}
|
checked={!!task.completedAt}
|
||||||
shape={CheckboxShape.Rounded}
|
shape={CheckboxShape.Rounded}
|
||||||
onChange={completeTask}
|
onCheckedChange={completeTask}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<StyledTaskTitle completed={task.completedAt !== null}>
|
<StyledTaskTitle completed={task.completedAt !== null}>
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
|
||||||
import { Modal as UIModal } from '@/ui/modal/components/Modal';
|
import { Modal as UIModal } from '@/ui/modal/components/Modal';
|
||||||
|
|
||||||
|
const StyledContent = styled(UIModal.Content)`
|
||||||
|
align-items: center;
|
||||||
|
width: calc(400px - ${({ theme }) => theme.spacing(10 * 2)});
|
||||||
|
`;
|
||||||
|
|
||||||
type Props = React.ComponentProps<'div'>;
|
type Props = React.ComponentProps<'div'>;
|
||||||
|
|
||||||
export function AuthModal({ children, ...restProps }: Props) {
|
export function AuthModal({ children, ...restProps }: Props) {
|
||||||
return (
|
return (
|
||||||
<UIModal isOpen={true} {...restProps}>
|
<UIModal isOpen={true} {...restProps}>
|
||||||
{children}
|
<StyledContent>{children}</StyledContent>
|
||||||
</UIModal>
|
</UIModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
120
front/src/modules/spreadsheet-import/components/core/Table.tsx
Normal file
120
front/src/modules/spreadsheet-import/components/core/Table.tsx
Normal 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} />
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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: '',
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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'} />;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -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" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
];
|
||||||
@@ -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 };
|
||||||
11
front/src/modules/spreadsheet-import/hooks/useRsi.ts
Normal file
11
front/src/modules/spreadsheet-import/hooks/useRsi.ts
Normal 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,
|
||||||
|
);
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
170
front/src/modules/spreadsheet-import/tests/mockRsiValues.ts
Normal file
170
front/src/modules/spreadsheet-import/tests/mockRsiValues.ts
Normal 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'],
|
||||||
|
];
|
||||||
157
front/src/modules/spreadsheet-import/types/index.ts
Normal file
157
front/src/modules/spreadsheet-import/types/index.ts
Normal 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)[];
|
||||||
|
};
|
||||||
130
front/src/modules/spreadsheet-import/utils/dataMutations.ts
Normal file
130
front/src/modules/spreadsheet-import/utils/dataMutations.ts
Normal 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;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
31
front/src/modules/spreadsheet-import/utils/findMatch.ts
Normal file
31
front/src/modules/spreadsheet-import/utils/findMatch.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -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) || [];
|
||||||
@@ -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>),
|
||||||
|
];
|
||||||
@@ -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 : [];
|
||||||
|
};
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
11
front/src/modules/spreadsheet-import/utils/mapWorkbook.ts
Normal file
11
front/src/modules/spreadsheet-import/utils/mapWorkbook.ts
Normal 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[][];
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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>),
|
||||||
|
);
|
||||||
13
front/src/modules/spreadsheet-import/utils/readFilesAsync.ts
Normal file
13
front/src/modules/spreadsheet-import/utils/readFilesAsync.ts
Normal 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);
|
||||||
|
});
|
||||||
|
};
|
||||||
44
front/src/modules/spreadsheet-import/utils/setColumn.ts
Normal file
44
front/src/modules/spreadsheet-import/utils/setColumn.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
30
front/src/modules/spreadsheet-import/utils/setSubColumn.ts
Normal file
30
front/src/modules/spreadsheet-import/utils/setSubColumn.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
15
front/src/modules/spreadsheet-import/utils/uniqueEntries.ts
Normal file
15
front/src/modules/spreadsheet-import/utils/uniqueEntries.ts
Normal 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);
|
||||||
@@ -38,7 +38,9 @@ type OwnProps = {
|
|||||||
const StyledColorSample = styled.div<{ colorName: string }>`
|
const StyledColorSample = styled.div<{ colorName: string }>`
|
||||||
background-color: ${({ theme, colorName }) =>
|
background-color: ${({ theme, colorName }) =>
|
||||||
theme.tag.background[colorName]};
|
theme.tag.background[colorName]};
|
||||||
border: 1px solid ${({ theme, colorName }) => theme.color[colorName]};
|
border: 1px solid
|
||||||
|
${({ theme, colorName }) =>
|
||||||
|
theme.color[colorName as keyof typeof theme.color]};
|
||||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||||
height: 12px;
|
height: 12px;
|
||||||
width: 12px;
|
width: 12px;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
|
import { TablerIconsProps } from '@tabler/icons-react';
|
||||||
|
|
||||||
import { SoonPill } from '@/ui/pill/components/SoonPill';
|
import { SoonPill } from '@/ui/pill/components/SoonPill';
|
||||||
import { rgba } from '@/ui/theme/constants/colors';
|
import { rgba } from '@/ui/theme/constants/colors';
|
||||||
@@ -58,6 +59,8 @@ const StyledButton = styled.button<
|
|||||||
case 'primary':
|
case 'primary':
|
||||||
case 'secondary':
|
case 'secondary':
|
||||||
return `${theme.background.transparent.medium}`;
|
return `${theme.background.transparent.medium}`;
|
||||||
|
case 'danger':
|
||||||
|
return `${theme.border.color.danger}`;
|
||||||
case 'tertiary':
|
case 'tertiary':
|
||||||
default:
|
default:
|
||||||
return 'none';
|
return 'none';
|
||||||
@@ -80,6 +83,7 @@ const StyledButton = styled.button<
|
|||||||
switch (variant) {
|
switch (variant) {
|
||||||
case 'primary':
|
case 'primary':
|
||||||
case 'secondary':
|
case 'secondary':
|
||||||
|
case 'danger':
|
||||||
return position === 'middle' ? `1px 0 1px 0` : `1px`;
|
return position === 'middle' ? `1px 0 1px 0` : `1px`;
|
||||||
case 'tertiary':
|
case 'tertiary':
|
||||||
default:
|
default:
|
||||||
@@ -98,10 +102,13 @@ const StyledButton = styled.button<
|
|||||||
|
|
||||||
color: ${({ theme, variant, disabled }) => {
|
color: ${({ theme, variant, disabled }) => {
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
if (variant === 'primary') {
|
switch (variant) {
|
||||||
return theme.color.gray0;
|
case 'primary':
|
||||||
} else {
|
return theme.color.gray0;
|
||||||
return theme.font.color.extraLight;
|
case 'danger':
|
||||||
|
return theme.border.color.danger;
|
||||||
|
default:
|
||||||
|
return theme.font.color.extraLight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +163,8 @@ const StyledButton = styled.button<
|
|||||||
switch (variant) {
|
switch (variant) {
|
||||||
case 'primary':
|
case 'primary':
|
||||||
return `background: linear-gradient(0deg, ${theme.background.transparent.medium} 0%, ${theme.background.transparent.medium} 100%), ${theme.color.blue}`;
|
return `background: linear-gradient(0deg, ${theme.background.transparent.medium} 0%, ${theme.background.transparent.medium} 100%), ${theme.color.blue}`;
|
||||||
|
case 'danger':
|
||||||
|
return `background: ${theme.background.transparent.danger}`;
|
||||||
default:
|
default:
|
||||||
return `background: ${theme.background.tertiary}`;
|
return `background: ${theme.background.tertiary}`;
|
||||||
}
|
}
|
||||||
@@ -178,7 +187,7 @@ const StyledButton = styled.button<
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export function Button({
|
export function Button({
|
||||||
icon,
|
icon: initialIcon,
|
||||||
title,
|
title,
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
variant = ButtonVariant.Primary,
|
variant = ButtonVariant.Primary,
|
||||||
@@ -188,6 +197,16 @@ export function Button({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
...props
|
...props
|
||||||
}: ButtonProps) {
|
}: ButtonProps) {
|
||||||
|
const icon = useMemo(() => {
|
||||||
|
if (!initialIcon || !React.isValidElement(initialIcon)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return React.cloneElement<TablerIconsProps>(initialIcon as any, {
|
||||||
|
size: 14,
|
||||||
|
});
|
||||||
|
}, [initialIcon]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledButton
|
<StyledButton
|
||||||
fullWidth={fullWidth}
|
fullWidth={fullWidth}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
export type CheckmarkProps = React.ComponentProps<typeof motion.path> & {
|
||||||
|
isAnimating?: boolean;
|
||||||
|
color?: string;
|
||||||
|
duration?: number;
|
||||||
|
size?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AnimatedCheckmark({
|
||||||
|
isAnimating = false,
|
||||||
|
color = '#FFF',
|
||||||
|
duration = 0.5,
|
||||||
|
size = 28,
|
||||||
|
...restProps
|
||||||
|
}: CheckmarkProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 52 52"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
>
|
||||||
|
<motion.path
|
||||||
|
{...restProps}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={4}
|
||||||
|
d="M14 27l7.8 7.8L38 14"
|
||||||
|
pathLength="1"
|
||||||
|
strokeDasharray="1"
|
||||||
|
strokeDashoffset={isAnimating ? '1' : '0'}
|
||||||
|
animate={{ strokeDashoffset: isAnimating ? '0' : '1' }}
|
||||||
|
transition={{ duration }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
front/src/modules/ui/dialog/components/Dialog.tsx
Normal file
125
front/src/modules/ui/dialog/components/Dialog.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
import { Button, ButtonVariant } from '@/ui/button/components/Button';
|
||||||
|
|
||||||
|
const DialogOverlay = styled(motion.div)`
|
||||||
|
align-items: center;
|
||||||
|
background: ${({ theme }) => theme.background.overlay};
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
justify-content: center;
|
||||||
|
left: 0;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
width: 100vw;
|
||||||
|
z-index: 9999;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DialogContainer = styled(motion.div)`
|
||||||
|
background: ${({ theme }) => theme.background.primary};
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 2em;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DialogTitle = styled.span`
|
||||||
|
color: ${({ theme }) => theme.font.color.primary};
|
||||||
|
font-size: ${({ theme }) => theme.font.size.md};
|
||||||
|
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||||
|
margin-bottom: ${({ theme }) => theme.spacing(6)};
|
||||||
|
text-align: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DialogMessage = styled.span`
|
||||||
|
color: ${({ theme }) => theme.font.color.primary};
|
||||||
|
font-size: ${({ theme }) => theme.font.size.sm};
|
||||||
|
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||||
|
margin-bottom: ${({ theme }) => theme.spacing(6)};
|
||||||
|
text-align: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DialogButton = styled(Button)`
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: ${({ theme }) => theme.spacing(2)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export type DialogButtonOptions = Omit<
|
||||||
|
React.ComponentProps<typeof Button>,
|
||||||
|
'fullWidth'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type DialogProps = React.ComponentPropsWithoutRef<typeof motion.div> & {
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
buttons?: DialogButtonOptions[];
|
||||||
|
allowDismiss?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
onClose?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Dialog({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
buttons = [],
|
||||||
|
allowDismiss = true,
|
||||||
|
children,
|
||||||
|
onClose,
|
||||||
|
...rootProps
|
||||||
|
}: DialogProps) {
|
||||||
|
const closeSnackbar = useCallback(() => {
|
||||||
|
onClose && onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const dialogVariants = {
|
||||||
|
open: { opacity: 1 },
|
||||||
|
closed: { opacity: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const containerVariants = {
|
||||||
|
open: { y: 0 },
|
||||||
|
closed: { y: '50vh' },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogOverlay
|
||||||
|
variants={dialogVariants}
|
||||||
|
initial="closed"
|
||||||
|
animate="open"
|
||||||
|
exit="closed"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (allowDismiss) {
|
||||||
|
e.stopPropagation();
|
||||||
|
closeSnackbar();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContainer
|
||||||
|
variants={containerVariants}
|
||||||
|
transition={{ damping: 15, stiffness: 100 }}
|
||||||
|
{...rootProps}
|
||||||
|
>
|
||||||
|
{title && <DialogTitle>{title}</DialogTitle>}
|
||||||
|
{message && <DialogMessage>{message}</DialogMessage>}
|
||||||
|
{children}
|
||||||
|
{buttons.map((button) => (
|
||||||
|
<DialogButton
|
||||||
|
key={button.title}
|
||||||
|
onClick={(e) => {
|
||||||
|
button?.onClick?.(e);
|
||||||
|
closeSnackbar();
|
||||||
|
}}
|
||||||
|
fullWidth={true}
|
||||||
|
variant={button.variant ?? ButtonVariant.Secondary}
|
||||||
|
{...button}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</DialogContainer>
|
||||||
|
</DialogOverlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
front/src/modules/ui/dialog/components/DialogProvider.tsx
Normal file
26
front/src/modules/ui/dialog/components/DialogProvider.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { useRecoilState } from 'recoil';
|
||||||
|
|
||||||
|
import { dialogInternalState } from '../states/dialogState';
|
||||||
|
|
||||||
|
import { Dialog } from './Dialog';
|
||||||
|
|
||||||
|
export function DialogProvider({ children }: React.PropsWithChildren) {
|
||||||
|
const [dialogState, setDialogState] = useRecoilState(dialogInternalState);
|
||||||
|
|
||||||
|
// Handle dialog close event
|
||||||
|
const handleDialogClose = (id: string) => {
|
||||||
|
setDialogState((prevState) => ({
|
||||||
|
...prevState,
|
||||||
|
queue: prevState.queue.filter((snackBar) => snackBar.id !== id),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
{dialogState.queue.map((dialog) => (
|
||||||
|
<Dialog {...dialog} onClose={() => handleDialogClose(dialog.id)} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
front/src/modules/ui/dialog/hooks/useDialog.ts
Normal file
17
front/src/modules/ui/dialog/hooks/useDialog.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useSetRecoilState } from 'recoil';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
import { DialogOptions, dialogSetQueueState } from '../states/dialogState';
|
||||||
|
|
||||||
|
export function useDialog() {
|
||||||
|
const setDialogQueue = useSetRecoilState(dialogSetQueueState);
|
||||||
|
|
||||||
|
const enqueueDialog = (options?: Omit<DialogOptions, 'id'>) => {
|
||||||
|
setDialogQueue({
|
||||||
|
id: uuidv4(),
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { enqueueDialog };
|
||||||
|
}
|
||||||
39
front/src/modules/ui/dialog/states/dialogState.ts
Normal file
39
front/src/modules/ui/dialog/states/dialogState.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { atom, selector } from 'recoil';
|
||||||
|
|
||||||
|
import { DialogProps } from '../components/Dialog';
|
||||||
|
|
||||||
|
export type DialogOptions = DialogProps & {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DialogState = {
|
||||||
|
maxQueue: number;
|
||||||
|
queue: DialogOptions[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dialogInternalState = atom<DialogState>({
|
||||||
|
key: 'dialog/internal-state',
|
||||||
|
default: {
|
||||||
|
maxQueue: 2,
|
||||||
|
queue: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dialogSetQueueState = selector<DialogOptions | null>({
|
||||||
|
key: 'dialog/queue-state',
|
||||||
|
get: ({ get: _get }) => null, // We don't care about getting the value
|
||||||
|
set: ({ set }, newValue) =>
|
||||||
|
set(dialogInternalState, (prev) => {
|
||||||
|
if (prev.queue.length >= prev.maxQueue) {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
queue: [...prev.queue.slice(1), newValue] as DialogOptions[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
queue: [...prev.queue, newValue] as DialogOptions[],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -7,13 +7,15 @@ import { hoverBackground } from '@/ui/theme/constants/effects';
|
|||||||
|
|
||||||
import { DropdownMenuItem } from './DropdownMenuItem';
|
import { DropdownMenuItem } from './DropdownMenuItem';
|
||||||
|
|
||||||
type Props = {
|
type Props = React.ComponentProps<'li'> & {
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
onClick: () => void;
|
|
||||||
hovered?: boolean;
|
hovered?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DropdownMenuSelectableItemContainer = styled(DropdownMenuItem)<Props>`
|
const DropdownMenuSelectableItemContainer = styled(DropdownMenuItem)<
|
||||||
|
Pick<Props, 'hovered'>
|
||||||
|
>`
|
||||||
${hoverBackground};
|
${hoverBackground};
|
||||||
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -27,12 +29,15 @@ const DropdownMenuSelectableItemContainer = styled(DropdownMenuItem)<Props>`
|
|||||||
width: calc(100% - ${({ theme }) => theme.spacing(2)});
|
width: calc(100% - ${({ theme }) => theme.spacing(2)});
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledLeftContainer = styled.div`
|
const StyledLeftContainer = styled.div<Pick<Props, 'disabled'>>`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
gap: ${({ theme }) => theme.spacing(2)};
|
gap: ${({ theme }) => theme.spacing(2)};
|
||||||
|
|
||||||
|
opacity: ${({ disabled }) => (disabled ? 0.5 : 1)};
|
||||||
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -45,9 +50,19 @@ export function DropdownMenuSelectableItem({
|
|||||||
onClick,
|
onClick,
|
||||||
children,
|
children,
|
||||||
hovered,
|
hovered,
|
||||||
|
disabled,
|
||||||
|
...restProps
|
||||||
}: React.PropsWithChildren<Props>) {
|
}: React.PropsWithChildren<Props>) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
|
function handleClick(event: React.MouseEvent<HTMLLIElement>) {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onClick?.(event);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hovered) {
|
if (hovered) {
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
@@ -58,12 +73,12 @@ export function DropdownMenuSelectableItem({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuSelectableItemContainer
|
<DropdownMenuSelectableItemContainer
|
||||||
onClick={onClick}
|
{...restProps}
|
||||||
selected={selected}
|
onClick={handleClick}
|
||||||
hovered={hovered}
|
hovered={hovered}
|
||||||
data-testid="dropdown-menu-item"
|
data-testid="dropdown-menu-item"
|
||||||
>
|
>
|
||||||
<StyledLeftContainer>{children}</StyledLeftContainer>
|
<StyledLeftContainer disabled={disabled}>{children}</StyledLeftContainer>
|
||||||
<StyledRightIcon>
|
<StyledRightIcon>
|
||||||
{selected && <IconCheck size={theme.icon.size.md} />}
|
{selected && <IconCheck size={theme.icon.size.md} />}
|
||||||
</StyledRightIcon>
|
</StyledRightIcon>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
export { IconAddressBook } from './components/IconAddressBook';
|
|
||||||
export {
|
export {
|
||||||
IconAlertCircle,
|
IconAlertCircle,
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
IconArchive,
|
IconArchive,
|
||||||
|
IconArrowBack,
|
||||||
IconArrowNarrowDown,
|
IconArrowNarrowDown,
|
||||||
IconArrowNarrowUp,
|
IconArrowNarrowUp,
|
||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
@@ -26,10 +26,13 @@ export {
|
|||||||
IconColorSwatch,
|
IconColorSwatch,
|
||||||
IconMessageCircle as IconComment,
|
IconMessageCircle as IconComment,
|
||||||
IconCopy,
|
IconCopy,
|
||||||
|
IconCross,
|
||||||
IconCurrencyDollar,
|
IconCurrencyDollar,
|
||||||
IconEye,
|
IconEye,
|
||||||
IconEyeOff,
|
IconEyeOff,
|
||||||
|
IconFileImport,
|
||||||
IconFileUpload,
|
IconFileUpload,
|
||||||
|
IconForbid,
|
||||||
IconHeart,
|
IconHeart,
|
||||||
IconHelpCircle,
|
IconHelpCircle,
|
||||||
IconInbox,
|
IconInbox,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { IconCheck, IconMinus } from '@/ui/icon';
|
|||||||
export enum CheckboxVariant {
|
export enum CheckboxVariant {
|
||||||
Primary = 'primary',
|
Primary = 'primary',
|
||||||
Secondary = 'secondary',
|
Secondary = 'secondary',
|
||||||
|
Tertiary = 'tertiary',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum CheckboxShape {
|
export enum CheckboxShape {
|
||||||
@@ -21,7 +22,8 @@ export enum CheckboxSize {
|
|||||||
type OwnProps = {
|
type OwnProps = {
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
indeterminate?: boolean;
|
indeterminate?: boolean;
|
||||||
onChange?: (value: boolean) => void;
|
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onCheckedChange?: (value: boolean) => void;
|
||||||
variant?: CheckboxVariant;
|
variant?: CheckboxVariant;
|
||||||
size?: CheckboxSize;
|
size?: CheckboxSize;
|
||||||
shape?: CheckboxShape;
|
shape?: CheckboxShape;
|
||||||
@@ -33,13 +35,15 @@ const StyledInputContainer = styled.div`
|
|||||||
position: relative;
|
position: relative;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledInput = styled.input<{
|
type InputProps = {
|
||||||
checkboxSize: CheckboxSize;
|
checkboxSize: CheckboxSize;
|
||||||
variant: CheckboxVariant;
|
variant: CheckboxVariant;
|
||||||
indeterminate?: boolean;
|
indeterminate?: boolean;
|
||||||
shape?: CheckboxShape;
|
shape?: CheckboxShape;
|
||||||
isChecked: boolean;
|
isChecked?: boolean;
|
||||||
}>`
|
};
|
||||||
|
|
||||||
|
const StyledInput = styled.input<InputProps>`
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -61,18 +65,25 @@ const StyledInput = styled.input<{
|
|||||||
checkboxSize === CheckboxSize.Large ? '18px' : '12px'};
|
checkboxSize === CheckboxSize.Large ? '18px' : '12px'};
|
||||||
background: ${({ theme, indeterminate, isChecked }) =>
|
background: ${({ theme, indeterminate, isChecked }) =>
|
||||||
indeterminate || isChecked ? theme.color.blue : 'transparent'};
|
indeterminate || isChecked ? theme.color.blue : 'transparent'};
|
||||||
border-color: ${({ theme, indeterminate, isChecked, variant }) =>
|
border-color: ${({ theme, indeterminate, isChecked, variant }) => {
|
||||||
indeterminate || isChecked
|
switch (true) {
|
||||||
? theme.color.blue
|
case indeterminate || isChecked:
|
||||||
: variant === CheckboxVariant.Primary
|
return theme.color.blue;
|
||||||
? theme.border.color.inverted
|
case variant === CheckboxVariant.Primary:
|
||||||
: theme.border.color.secondaryInverted};
|
return theme.border.color.inverted;
|
||||||
|
case variant === CheckboxVariant.Tertiary:
|
||||||
|
return theme.border.color.medium;
|
||||||
|
default:
|
||||||
|
return theme.border.color.secondaryInverted;
|
||||||
|
}
|
||||||
|
}};
|
||||||
border-radius: ${({ theme, shape }) =>
|
border-radius: ${({ theme, shape }) =>
|
||||||
shape === CheckboxShape.Rounded
|
shape === CheckboxShape.Rounded
|
||||||
? theme.border.radius.rounded
|
? theme.border.radius.rounded
|
||||||
: theme.border.radius.sm};
|
: theme.border.radius.sm};
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-width: 1px;
|
border-width: ${({ variant }) =>
|
||||||
|
variant === CheckboxVariant.Tertiary ? '2px' : '1px'};
|
||||||
content: '';
|
content: '';
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -81,8 +92,11 @@ const StyledInput = styled.input<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
& + label > svg {
|
& + label > svg {
|
||||||
--padding: ${({ checkboxSize }) =>
|
--padding: ${({ checkboxSize, variant }) =>
|
||||||
checkboxSize === CheckboxSize.Large ? '2px' : '1px'};
|
checkboxSize === CheckboxSize.Large ||
|
||||||
|
variant === CheckboxVariant.Tertiary
|
||||||
|
? '2px'
|
||||||
|
: '1px'};
|
||||||
--size: ${({ checkboxSize }) =>
|
--size: ${({ checkboxSize }) =>
|
||||||
checkboxSize === CheckboxSize.Large ? '16px' : '12px'};
|
checkboxSize === CheckboxSize.Large ? '16px' : '12px'};
|
||||||
height: var(--size);
|
height: var(--size);
|
||||||
@@ -97,6 +111,7 @@ const StyledInput = styled.input<{
|
|||||||
export function Checkbox({
|
export function Checkbox({
|
||||||
checked,
|
checked,
|
||||||
onChange,
|
onChange,
|
||||||
|
onCheckedChange,
|
||||||
indeterminate,
|
indeterminate,
|
||||||
variant = CheckboxVariant.Primary,
|
variant = CheckboxVariant.Primary,
|
||||||
size = CheckboxSize.Small,
|
size = CheckboxSize.Small,
|
||||||
@@ -108,9 +123,11 @@ export function Checkbox({
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setIsInternalChecked(checked);
|
setIsInternalChecked(checked);
|
||||||
}, [checked]);
|
}, [checked]);
|
||||||
function handleChange(value: boolean) {
|
|
||||||
onChange?.(value);
|
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
setIsInternalChecked(!isInternalChecked);
|
onChange?.(event);
|
||||||
|
onCheckedChange?.(event.target.checked);
|
||||||
|
setIsInternalChecked(event.target.checked);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -126,7 +143,7 @@ export function Checkbox({
|
|||||||
checkboxSize={size}
|
checkboxSize={size}
|
||||||
shape={shape}
|
shape={shape}
|
||||||
isChecked={isInternalChecked}
|
isChecked={isInternalChecked}
|
||||||
onChange={(event) => handleChange(event.target.checked)}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="checkbox">
|
<label htmlFor="checkbox">
|
||||||
{indeterminate ? (
|
{indeterminate ? (
|
||||||
|
|||||||
157
front/src/modules/ui/input/radio/components/Radio.tsx
Normal file
157
front/src/modules/ui/input/radio/components/Radio.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
import { rgba } from '@/ui/theme/constants/colors';
|
||||||
|
|
||||||
|
import { RadioGroup } from './RadioGroup';
|
||||||
|
|
||||||
|
export enum RadioSize {
|
||||||
|
Large = 'large',
|
||||||
|
Small = 'small',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum LabelPosition {
|
||||||
|
Left = 'left',
|
||||||
|
Right = 'right',
|
||||||
|
}
|
||||||
|
|
||||||
|
const Container = styled.div<{ labelPosition?: LabelPosition }>`
|
||||||
|
${({ labelPosition }) =>
|
||||||
|
labelPosition === LabelPosition.Left
|
||||||
|
? `
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
`
|
||||||
|
: `
|
||||||
|
flex-direction: row;
|
||||||
|
`};
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
`;
|
||||||
|
|
||||||
|
type RadioInputProps = {
|
||||||
|
radioSize?: RadioSize;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RadioInput = styled(motion.input)<RadioInputProps>`
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid ${({ theme }) => theme.font.color.secondary};
|
||||||
|
border-radius: 50%;
|
||||||
|
:hover {
|
||||||
|
background-color: ${({ theme, checked }) => {
|
||||||
|
if (!checked) {
|
||||||
|
return theme.background.tertiary;
|
||||||
|
}
|
||||||
|
}};
|
||||||
|
outline: 4px solid
|
||||||
|
${({ theme, checked }) => {
|
||||||
|
if (!checked) {
|
||||||
|
return theme.background.tertiary;
|
||||||
|
}
|
||||||
|
return rgba(theme.color.blue, 0.12);
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
&:checked {
|
||||||
|
background-color: ${({ theme }) => theme.color.blue};
|
||||||
|
border: none;
|
||||||
|
&::after {
|
||||||
|
background-color: ${({ theme }) => theme.color.gray0};
|
||||||
|
border-radius: 50%;
|
||||||
|
content: '';
|
||||||
|
height: ${({ radioSize }) =>
|
||||||
|
radioSize === RadioSize.Large ? '8px' : '6px'};
|
||||||
|
left: 50%;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: ${({ radioSize }) =>
|
||||||
|
radioSize === RadioSize.Large ? '8px' : '6px'};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.12;
|
||||||
|
}
|
||||||
|
height: ${({ radioSize }) =>
|
||||||
|
radioSize === RadioSize.Large ? '18px' : '16px'};
|
||||||
|
position: relative;
|
||||||
|
width: ${({ radioSize }) =>
|
||||||
|
radioSize === RadioSize.Large ? '18px' : '16px'};
|
||||||
|
`;
|
||||||
|
|
||||||
|
type LabelProps = {
|
||||||
|
disabled?: boolean;
|
||||||
|
labelPosition?: LabelPosition;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Label = styled.label<LabelProps>`
|
||||||
|
color: ${({ theme }) => theme.font.color.primary};
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: ${({ theme }) => theme.font.size.sm};
|
||||||
|
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||||
|
margin-left: ${({ theme, labelPosition }) =>
|
||||||
|
labelPosition === LabelPosition.Right ? theme.spacing(2) : '0px'};
|
||||||
|
margin-right: ${({ theme, labelPosition }) =>
|
||||||
|
labelPosition === LabelPosition.Left ? theme.spacing(2) : '0px'};
|
||||||
|
opacity: ${({ disabled }) => (disabled ? 0.32 : 1)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export type RadioProps = {
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
className?: string;
|
||||||
|
checked?: boolean;
|
||||||
|
value?: string;
|
||||||
|
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onCheckedChange?: (checked: boolean) => void;
|
||||||
|
size?: RadioSize;
|
||||||
|
disabled?: boolean;
|
||||||
|
labelPosition?: LabelPosition;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Radio({
|
||||||
|
checked,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onCheckedChange,
|
||||||
|
size = RadioSize.Small,
|
||||||
|
labelPosition = LabelPosition.Right,
|
||||||
|
disabled = false,
|
||||||
|
...restProps
|
||||||
|
}: RadioProps) {
|
||||||
|
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
onChange?.(event);
|
||||||
|
onCheckedChange?.(event.target.checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container {...restProps} labelPosition={labelPosition}>
|
||||||
|
<RadioInput
|
||||||
|
type="radio"
|
||||||
|
id="input-radio"
|
||||||
|
name="input-radio"
|
||||||
|
data-testid="input-radio"
|
||||||
|
checked={checked}
|
||||||
|
value={value}
|
||||||
|
radioSize={size}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={handleChange}
|
||||||
|
initial={{ scale: 0.95 }}
|
||||||
|
animate={{ scale: checked ? 1.05 : 0.95 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||||
|
/>
|
||||||
|
{value && (
|
||||||
|
<Label
|
||||||
|
htmlFor="input-radio"
|
||||||
|
labelPosition={labelPosition}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Radio.Group = RadioGroup;
|
||||||
39
front/src/modules/ui/input/radio/components/RadioGroup.tsx
Normal file
39
front/src/modules/ui/input/radio/components/RadioGroup.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useTheme } from '@emotion/react';
|
||||||
|
|
||||||
|
import { RadioProps } from './Radio';
|
||||||
|
|
||||||
|
type RadioGroupProps = React.PropsWithChildren & {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onValueChange?: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RadioGroup({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onValueChange,
|
||||||
|
children,
|
||||||
|
}: RadioGroupProps) {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
onChange?.(event);
|
||||||
|
onValueChange?.(event.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{React.Children.map(children, (child) => {
|
||||||
|
if (React.isValidElement<RadioProps>(child)) {
|
||||||
|
return React.cloneElement(child, {
|
||||||
|
style: { marginBottom: theme.spacing(2) },
|
||||||
|
checked: child.props.value === value,
|
||||||
|
onChange: handleChange,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
|
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
|
||||||
|
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||||
|
|
||||||
|
import { LabelPosition, Radio, RadioSize } from '../Radio';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Radio> = {
|
||||||
|
title: 'UI/Input/Radio',
|
||||||
|
component: Radio,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof Radio>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
value: 'Radio',
|
||||||
|
checked: false,
|
||||||
|
disabled: false,
|
||||||
|
size: RadioSize.Small,
|
||||||
|
},
|
||||||
|
decorators: [ComponentDecorator],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Catalog: Story = {
|
||||||
|
args: {
|
||||||
|
value: 'Radio',
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
value: { control: false },
|
||||||
|
size: { control: false },
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
catalog: {
|
||||||
|
dimensions: [
|
||||||
|
{
|
||||||
|
name: 'checked',
|
||||||
|
values: [false, true],
|
||||||
|
props: (checked: boolean) => ({ checked }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'disabled',
|
||||||
|
values: [false, true],
|
||||||
|
props: (disabled: boolean) => ({ disabled }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'size',
|
||||||
|
values: Object.values(RadioSize),
|
||||||
|
props: (size: RadioSize) => ({ size }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'labelPosition',
|
||||||
|
values: Object.values(LabelPosition),
|
||||||
|
props: (labelPosition: string) => ({
|
||||||
|
labelPosition,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
decorators: [CatalogDecorator],
|
||||||
|
};
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
ChangeEvent,
|
ChangeEvent,
|
||||||
FocusEventHandler,
|
FocusEventHandler,
|
||||||
|
ForwardedRef,
|
||||||
|
forwardRef,
|
||||||
InputHTMLAttributes,
|
InputHTMLAttributes,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
@@ -13,6 +15,7 @@ import { IconAlertCircle } from '@/ui/icon';
|
|||||||
import { IconEye, IconEyeOff } from '@/ui/icon/index';
|
import { IconEye, IconEyeOff } from '@/ui/icon/index';
|
||||||
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
|
||||||
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
|
||||||
|
import { useCombinedRefs } from '~/hooks/useCombinedRefs';
|
||||||
|
|
||||||
import { InputHotkeyScope } from '../types/InputHotkeyScope';
|
import { InputHotkeyScope } from '../types/InputHotkeyScope';
|
||||||
|
|
||||||
@@ -95,22 +98,26 @@ const StyledTrailingIcon = styled.div`
|
|||||||
|
|
||||||
const INPUT_TYPE_PASSWORD = 'password';
|
const INPUT_TYPE_PASSWORD = 'password';
|
||||||
|
|
||||||
export function TextInput({
|
function TextInputComponent(
|
||||||
label,
|
{
|
||||||
value,
|
label,
|
||||||
onChange,
|
value,
|
||||||
onFocus,
|
onChange,
|
||||||
onBlur,
|
onFocus,
|
||||||
fullWidth,
|
onBlur,
|
||||||
error,
|
fullWidth,
|
||||||
required,
|
error,
|
||||||
type,
|
required,
|
||||||
disableHotkeys = false,
|
type,
|
||||||
...props
|
disableHotkeys = false,
|
||||||
}: OwnProps): JSX.Element {
|
...props
|
||||||
|
}: OwnProps,
|
||||||
|
ref: ForwardedRef<HTMLInputElement>,
|
||||||
|
): JSX.Element {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const combinedRef = useCombinedRefs(ref, inputRef);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
goBackToPreviousHotkeyScope,
|
goBackToPreviousHotkeyScope,
|
||||||
@@ -151,7 +158,7 @@ export function TextInput({
|
|||||||
<StyledInputContainer>
|
<StyledInputContainer>
|
||||||
<StyledInput
|
<StyledInput
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
ref={inputRef}
|
ref={combinedRef}
|
||||||
tabIndex={props.tabIndex ?? 0}
|
tabIndex={props.tabIndex ?? 0}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
@@ -189,3 +196,5 @@ export function TextInput({
|
|||||||
</StyledContainer>
|
</StyledContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const TextInput = forwardRef(TextInputComponent);
|
||||||
|
|||||||
@@ -7,20 +7,42 @@ import {
|
|||||||
useListenClickOutside,
|
useListenClickOutside,
|
||||||
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||||
|
|
||||||
const StyledContainer = styled.div`
|
const ModalDiv = styled(motion.div)`
|
||||||
align-items: center;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: ${({ theme }) => theme.spacing(10)};
|
|
||||||
width: calc(400px - ${({ theme }) => theme.spacing(10 * 2)});
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ModalDiv = styled(motion.div)`
|
|
||||||
background: ${({ theme }) => theme.background.primary};
|
background: ${({ theme }) => theme.background.primary};
|
||||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 90vh;
|
||||||
z-index: 10000; // should be higher than Backdrop's z-index
|
z-index: 10000; // should be higher than Backdrop's z-index
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const StyledHeader = styled.div`
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
height: 60px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: ${({ theme }) => theme.spacing(5)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledContent = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: ${({ theme }) => theme.spacing(10)};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledFooter = styled.div`
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
height: 60px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: ${({ theme }) => theme.spacing(5)};
|
||||||
|
`;
|
||||||
|
|
||||||
const BackDrop = styled(motion.div)`
|
const BackDrop = styled(motion.div)`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: ${({ theme }) => theme.background.overlay};
|
background: ${({ theme }) => theme.background.overlay};
|
||||||
@@ -34,7 +56,31 @@ const BackDrop = styled(motion.div)`
|
|||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
type Props = React.PropsWithChildren &
|
/**
|
||||||
|
* Modal components
|
||||||
|
*/
|
||||||
|
type ModalHeaderProps = React.PropsWithChildren & React.ComponentProps<'div'>;
|
||||||
|
|
||||||
|
function ModalHeader({ children, ...restProps }: ModalHeaderProps) {
|
||||||
|
return <StyledHeader {...restProps}>{children}</StyledHeader>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModalContentProps = React.PropsWithChildren & React.ComponentProps<'div'>;
|
||||||
|
|
||||||
|
function ModalContent({ children, ...restProps }: ModalContentProps) {
|
||||||
|
return <StyledContent {...restProps}>{children}</StyledContent>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModalFooterProps = React.PropsWithChildren & React.ComponentProps<'div'>;
|
||||||
|
|
||||||
|
function ModalFooter({ children, ...restProps }: ModalFooterProps) {
|
||||||
|
return <StyledFooter {...restProps}>{children}</StyledFooter>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal
|
||||||
|
*/
|
||||||
|
type ModalProps = React.PropsWithChildren &
|
||||||
React.ComponentProps<'div'> & {
|
React.ComponentProps<'div'> & {
|
||||||
isOpen?: boolean;
|
isOpen?: boolean;
|
||||||
onOutsideClick?: () => void;
|
onOutsideClick?: () => void;
|
||||||
@@ -51,8 +97,8 @@ export function Modal({
|
|||||||
children,
|
children,
|
||||||
onOutsideClick,
|
onOutsideClick,
|
||||||
...restProps
|
...restProps
|
||||||
}: Props) {
|
}: ModalProps) {
|
||||||
const modalRef = useRef(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useListenClickOutside({
|
useListenClickOutside({
|
||||||
refs: [modalRef],
|
refs: [modalRef],
|
||||||
@@ -67,15 +113,23 @@ export function Modal({
|
|||||||
return (
|
return (
|
||||||
<BackDrop>
|
<BackDrop>
|
||||||
<ModalDiv
|
<ModalDiv
|
||||||
layout
|
// framer-motion seems to have typing problems with refs
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
ref={modalRef}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate="visible"
|
animate="visible"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
|
layout
|
||||||
variants={modalVariants}
|
variants={modalVariants}
|
||||||
ref={modalRef}
|
{...restProps}
|
||||||
>
|
>
|
||||||
<StyledContainer {...restProps}>{children}</StyledContainer>
|
{children}
|
||||||
</ModalDiv>
|
</ModalDiv>
|
||||||
</BackDrop>
|
</BackDrop>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Modal.Header = ModalHeader;
|
||||||
|
Modal.Content = ModalContent;
|
||||||
|
Modal.Footer = ModalFooter;
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ const StyledContentContainer = styled.div`
|
|||||||
|
|
||||||
const args = {
|
const args = {
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
children: <StyledContentContainer>Lorem ipsum</StyledContentContainer>,
|
children: (
|
||||||
|
<Modal.Content>
|
||||||
|
<StyledContentContainer>Lorem ipsum</StyledContentContainer>
|
||||||
|
</Modal.Content>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Default: Story = {
|
export const Default: Story = {
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import React, { useEffect, useMemo } from 'react';
|
||||||
|
import { motion, useAnimation } from 'framer-motion';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
size?: number;
|
||||||
|
barWidth?: number;
|
||||||
|
barColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CircularProgressBar({
|
||||||
|
size = 50,
|
||||||
|
barWidth = 5,
|
||||||
|
barColor = 'currentColor',
|
||||||
|
}: Props) {
|
||||||
|
const controls = useAnimation();
|
||||||
|
|
||||||
|
const circumference = useMemo(
|
||||||
|
() => 2 * Math.PI * (size / 2 - barWidth),
|
||||||
|
[size, barWidth],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function animateIndeterminate() {
|
||||||
|
const baseSegment = Math.max(5, circumference / 10); // Adjusting for smaller values
|
||||||
|
|
||||||
|
// Adjusted sequence based on baseSegment
|
||||||
|
const dashSequences = [
|
||||||
|
`${baseSegment} ${circumference - baseSegment}`,
|
||||||
|
`${baseSegment * 2} ${circumference - baseSegment * 2}`,
|
||||||
|
`${baseSegment * 3} ${circumference - baseSegment * 3}`,
|
||||||
|
`${baseSegment * 2} ${circumference - baseSegment * 2}`,
|
||||||
|
`${baseSegment} ${circumference - baseSegment}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
await controls.start({
|
||||||
|
strokeDasharray: dashSequences,
|
||||||
|
rotate: [0, 720],
|
||||||
|
transition: {
|
||||||
|
strokeDasharray: {
|
||||||
|
duration: 2,
|
||||||
|
ease: 'linear',
|
||||||
|
repeat: Infinity,
|
||||||
|
repeatType: 'loop',
|
||||||
|
},
|
||||||
|
rotate: {
|
||||||
|
duration: 2,
|
||||||
|
ease: 'linear',
|
||||||
|
repeat: Infinity,
|
||||||
|
repeatType: 'loop',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
animateIndeterminate();
|
||||||
|
}, [circumference, controls]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.svg width={size} height={size} animate={controls}>
|
||||||
|
<motion.circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={size / 2 - barWidth}
|
||||||
|
fill="none"
|
||||||
|
stroke={barColor}
|
||||||
|
strokeWidth={barWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</motion.svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
|
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
|
||||||
|
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
|
||||||
|
|
||||||
|
import { CircularProgressBar } from '../CircularProgressBar';
|
||||||
|
|
||||||
|
const meta: Meta<typeof CircularProgressBar> = {
|
||||||
|
title: 'UI/CircularProgressBar/CircularProgressBar',
|
||||||
|
component: CircularProgressBar,
|
||||||
|
args: {
|
||||||
|
size: 50,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof CircularProgressBar>;
|
||||||
|
const args = {};
|
||||||
|
const defaultArgTypes = {
|
||||||
|
control: false,
|
||||||
|
};
|
||||||
|
export const Default: Story = {
|
||||||
|
args,
|
||||||
|
decorators: [ComponentDecorator],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Catalog = {
|
||||||
|
args: {
|
||||||
|
...args,
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
strokeWidth: defaultArgTypes,
|
||||||
|
segmentColor: defaultArgTypes,
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
catalog: {
|
||||||
|
dimensions: [
|
||||||
|
{
|
||||||
|
name: 'barColor',
|
||||||
|
values: [undefined, 'red'],
|
||||||
|
props: (barColor: string) => ({ barColor }),
|
||||||
|
labels: (color: string) => `Segment Color: ${color ?? 'default'}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'barWidth',
|
||||||
|
values: [undefined, 5, 10],
|
||||||
|
props: (barWidth: number) => ({ barWidth }),
|
||||||
|
labels: (width: number) =>
|
||||||
|
`Stroke Width: ${width ? width + ' px' : 'default'}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'size',
|
||||||
|
values: [undefined, 80, 30],
|
||||||
|
props: (size: number) => ({ size }),
|
||||||
|
labels: (size: number) => `Size: ${size ? size + ' px' : 'default'}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
decorators: [CatalogDecorator],
|
||||||
|
};
|
||||||
117
front/src/modules/ui/step-bar/components/Step.tsx
Normal file
117
front/src/modules/ui/step-bar/components/Step.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useTheme } from '@emotion/react';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
import { AnimatedCheckmark } from '@/ui/checkmark/components/AnimatedCheckmark';
|
||||||
|
|
||||||
|
const Container = styled.div<{ isLast: boolean }>`
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-grow: ${({ isLast }) => (isLast ? '0' : '1')};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StepCircle = styled(motion.div)<{ isCurrent: boolean }>`
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 1px;
|
||||||
|
display: flex;
|
||||||
|
flex-basis: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 20px;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
width: 20px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StepIndex = styled.span`
|
||||||
|
color: ${({ theme }) => theme.font.color.tertiary};
|
||||||
|
font-size: ${({ theme }) => theme.font.size.md};
|
||||||
|
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StepLabel = styled.span<{ isActive: boolean }>`
|
||||||
|
color: ${({ theme, isActive }) =>
|
||||||
|
isActive ? theme.font.color.primary : theme.font.color.tertiary};
|
||||||
|
font-size: ${({ theme }) => theme.font.size.md};
|
||||||
|
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||||
|
margin-left: ${({ theme }) => theme.spacing(2)};
|
||||||
|
white-space: nowrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StepLine = styled(motion.div)<{ isActive: boolean }>`
|
||||||
|
height: 2px;
|
||||||
|
margin-left: ${({ theme }) => theme.spacing(2)};
|
||||||
|
margin-right: ${({ theme }) => theme.spacing(2)};
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export type StepProps = React.PropsWithChildren &
|
||||||
|
React.ComponentProps<'div'> & {
|
||||||
|
isActive?: boolean;
|
||||||
|
isLast?: boolean;
|
||||||
|
index?: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Step = ({
|
||||||
|
isActive = false,
|
||||||
|
isLast = false,
|
||||||
|
index = 0,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: StepProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const variantsCircle = {
|
||||||
|
active: {
|
||||||
|
backgroundColor: theme.font.color.primary,
|
||||||
|
borderColor: theme.font.color.primary,
|
||||||
|
transition: { duration: 0.5 },
|
||||||
|
},
|
||||||
|
inactive: {
|
||||||
|
backgroundColor: theme.background.transparent.lighter,
|
||||||
|
borderColor: theme.border.color.medium,
|
||||||
|
transition: { duration: 0.5 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const variantsLine = {
|
||||||
|
active: {
|
||||||
|
backgroundColor: theme.font.color.primary,
|
||||||
|
transition: { duration: 0.5 },
|
||||||
|
},
|
||||||
|
inactive: {
|
||||||
|
backgroundColor: theme.border.color.medium,
|
||||||
|
transition: { duration: 0.5 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container isLast={isLast}>
|
||||||
|
<StepCircle
|
||||||
|
isCurrent={isActive}
|
||||||
|
variants={variantsCircle}
|
||||||
|
animate={isActive ? 'active' : 'inactive'}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<AnimatedCheckmark isAnimating={isActive} color={theme.color.gray0} />
|
||||||
|
)}
|
||||||
|
{!isActive && <StepIndex>{index + 1}</StepIndex>}
|
||||||
|
</StepCircle>
|
||||||
|
<StepLabel isActive={isActive}>{label}</StepLabel>
|
||||||
|
{!isLast && (
|
||||||
|
<StepLine
|
||||||
|
isActive={isActive}
|
||||||
|
variants={variantsLine}
|
||||||
|
animate={isActive ? 'active' : 'inactive'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isActive && children}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Step.displayName = 'StepBar/Step';
|
||||||
42
front/src/modules/ui/step-bar/components/StepBar.tsx
Normal file
42
front/src/modules/ui/step-bar/components/StepBar.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import styled from '@emotion/styled';
|
||||||
|
|
||||||
|
import { Step, StepProps } from './Step';
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: space-between;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export type StepsProps = React.PropsWithChildren &
|
||||||
|
React.ComponentProps<'div'> & {
|
||||||
|
activeStep: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StepBar = ({ children, activeStep, ...restProps }: StepsProps) => {
|
||||||
|
return (
|
||||||
|
<Container {...restProps}>
|
||||||
|
{React.Children.map(children, (child, index) => {
|
||||||
|
if (!React.isValidElement(child)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the child is not a Step, return it as-is
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-expect-error
|
||||||
|
if (child.type?.displayName !== Step.displayName) {
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
return React.cloneElement<StepProps>(child as any, {
|
||||||
|
index,
|
||||||
|
isActive: index <= activeStep,
|
||||||
|
isLast: index === React.Children.count(children) - 1,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
StepBar.Step = Step;
|
||||||
59
front/src/modules/ui/step-bar/hooks/useStepBar.ts
Normal file
59
front/src/modules/ui/step-bar/hooks/useStepBar.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
import { useRecoilState } from 'recoil';
|
||||||
|
|
||||||
|
import { stepBarInternalState } from '../states/stepBarInternalState';
|
||||||
|
|
||||||
|
export type StepsOptions = {
|
||||||
|
initialStep: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useStepBar({ initialStep }: StepsOptions) {
|
||||||
|
const [stepsState, setStepsState] = useRecoilState(stepBarInternalState);
|
||||||
|
|
||||||
|
function nextStep() {
|
||||||
|
setStepsState((prevState) => ({
|
||||||
|
...prevState,
|
||||||
|
activeStep: prevState.activeStep + 1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevStep() {
|
||||||
|
setStepsState((prevState) => ({
|
||||||
|
...prevState,
|
||||||
|
activeStep: prevState.activeStep - 1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setStepsState((prevState) => ({
|
||||||
|
...prevState,
|
||||||
|
activeStep: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const setStep = useCallback(
|
||||||
|
(step: number) => {
|
||||||
|
setStepsState((prevState) => ({
|
||||||
|
...prevState,
|
||||||
|
activeStep: step,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[setStepsState],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialStep !== undefined) {
|
||||||
|
setStep(initialStep);
|
||||||
|
}
|
||||||
|
// We only want this to happen on mount
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
nextStep,
|
||||||
|
prevStep,
|
||||||
|
reset,
|
||||||
|
setStep,
|
||||||
|
activeStep: stepsState.activeStep,
|
||||||
|
};
|
||||||
|
}
|
||||||
12
front/src/modules/ui/step-bar/states/stepBarInternalState.ts
Normal file
12
front/src/modules/ui/step-bar/states/stepBarInternalState.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { atom } from 'recoil';
|
||||||
|
|
||||||
|
export type StepsState = {
|
||||||
|
activeStep: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stepBarInternalState = atom<StepsState>({
|
||||||
|
key: 'step-bar/internal-state',
|
||||||
|
default: {
|
||||||
|
activeStep: -1,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import { useTheme } from '@emotion/react';
|
|||||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
|
|
||||||
|
import { useSpreadsheetImport } from '@/spreadsheet-import/hooks/useSpreadsheetImport';
|
||||||
import { IconButton } from '@/ui/button/components/IconButton';
|
import { IconButton } from '@/ui/button/components/IconButton';
|
||||||
import { DropdownMenuHeader } from '@/ui/dropdown/components/DropdownMenuHeader';
|
import { DropdownMenuHeader } from '@/ui/dropdown/components/DropdownMenuHeader';
|
||||||
import { DropdownMenuInput } from '@/ui/dropdown/components/DropdownMenuInput';
|
import { DropdownMenuInput } from '@/ui/dropdown/components/DropdownMenuInput';
|
||||||
@@ -20,7 +21,13 @@ import type {
|
|||||||
ViewFieldMetadata,
|
ViewFieldMetadata,
|
||||||
} from '@/ui/editable-field/types/ViewField';
|
} from '@/ui/editable-field/types/ViewField';
|
||||||
import DropdownButton from '@/ui/filter-n-sort/components/DropdownButton';
|
import DropdownButton from '@/ui/filter-n-sort/components/DropdownButton';
|
||||||
import { IconChevronLeft, IconMinus, IconPlus, IconTag } from '@/ui/icon';
|
import {
|
||||||
|
IconChevronLeft,
|
||||||
|
IconFileImport,
|
||||||
|
IconMinus,
|
||||||
|
IconPlus,
|
||||||
|
IconTag,
|
||||||
|
} from '@/ui/icon';
|
||||||
import {
|
import {
|
||||||
hiddenTableColumnsState,
|
hiddenTableColumnsState,
|
||||||
tableColumnsState,
|
tableColumnsState,
|
||||||
@@ -58,6 +65,8 @@ export const TableOptionsDropdownButton = ({
|
|||||||
}: TableOptionsDropdownButtonProps) => {
|
}: TableOptionsDropdownButtonProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const { openSpreadsheetImport } = useSpreadsheetImport();
|
||||||
|
|
||||||
const [isUnfolded, setIsUnfolded] = useState(false);
|
const [isUnfolded, setIsUnfolded] = useState(false);
|
||||||
const [selectedOption, setSelectedOption] = useState<Option | undefined>(
|
const [selectedOption, setSelectedOption] = useState<Option | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
@@ -85,6 +94,16 @@ export const TableOptionsDropdownButton = ({
|
|||||||
setHotkeyScopeAndMemorizePreviousScope,
|
setHotkeyScopeAndMemorizePreviousScope,
|
||||||
} = usePreviousHotkeyScope();
|
} = usePreviousHotkeyScope();
|
||||||
|
|
||||||
|
function handleImport() {
|
||||||
|
openSpreadsheetImport({
|
||||||
|
onSubmit: (datam, file) => {
|
||||||
|
console.log('datam', datam);
|
||||||
|
console.log('file', file);
|
||||||
|
},
|
||||||
|
fields: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const handleColumnVisibilityChange = useCallback(
|
const handleColumnVisibilityChange = useCallback(
|
||||||
(columnId: string, nextIsVisible: boolean) => {
|
(columnId: string, nextIsVisible: boolean) => {
|
||||||
const nextColumns = columns.map((column) =>
|
const nextColumns = columns.map((column) =>
|
||||||
@@ -226,6 +245,12 @@ export const TableOptionsDropdownButton = ({
|
|||||||
<IconTag size={theme.icon.size.md} />
|
<IconTag size={theme.icon.size.md} />
|
||||||
Properties
|
Properties
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{false && (
|
||||||
|
<DropdownMenuItem onClick={handleImport}>
|
||||||
|
<IconFileImport size={theme.icon.size.md} />
|
||||||
|
Import
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
</DropdownMenuItemsContainer>
|
</DropdownMenuItemsContainer>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import DarkNoise from '../assets/dark-noise.jpg';
|
import DarkNoise from '../assets/dark-noise.jpg';
|
||||||
import LightNoise from '../assets/light-noise.png';
|
import LightNoise from '../assets/light-noise.png';
|
||||||
|
|
||||||
import { grayScale, rgba } from './colors';
|
import { color, grayScale, rgba } from './colors';
|
||||||
|
|
||||||
export const backgroundLight = {
|
export const backgroundLight = {
|
||||||
noisy: `url(${LightNoise.toString()});`,
|
noisy: `url(${LightNoise.toString()});`,
|
||||||
@@ -16,6 +16,7 @@ export const backgroundLight = {
|
|||||||
medium: rgba(grayScale.gray100, 0.08),
|
medium: rgba(grayScale.gray100, 0.08),
|
||||||
light: rgba(grayScale.gray100, 0.04),
|
light: rgba(grayScale.gray100, 0.04),
|
||||||
lighter: rgba(grayScale.gray100, 0.02),
|
lighter: rgba(grayScale.gray100, 0.02),
|
||||||
|
danger: rgba(color.red, 0.08),
|
||||||
},
|
},
|
||||||
overlay: rgba(grayScale.gray80, 0.8),
|
overlay: rgba(grayScale.gray80, 0.8),
|
||||||
};
|
};
|
||||||
@@ -33,6 +34,7 @@ export const backgroundDark = {
|
|||||||
medium: rgba(grayScale.gray0, 0.1),
|
medium: rgba(grayScale.gray0, 0.1),
|
||||||
light: rgba(grayScale.gray0, 0.06),
|
light: rgba(grayScale.gray0, 0.06),
|
||||||
lighter: rgba(grayScale.gray0, 0.03),
|
lighter: rgba(grayScale.gray0, 0.03),
|
||||||
|
danger: rgba(color.red, 0.08),
|
||||||
},
|
},
|
||||||
overlay: rgba(grayScale.gray80, 0.8),
|
overlay: rgba(grayScale.gray80, 0.8),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { grayScale } from './colors';
|
import { color, grayScale, rgba } from './colors';
|
||||||
|
|
||||||
const common = {
|
const common = {
|
||||||
radius: {
|
radius: {
|
||||||
@@ -16,6 +16,7 @@ export const borderLight = {
|
|||||||
light: grayScale.gray15,
|
light: grayScale.gray15,
|
||||||
secondaryInverted: grayScale.gray50,
|
secondaryInverted: grayScale.gray50,
|
||||||
inverted: grayScale.gray60,
|
inverted: grayScale.gray60,
|
||||||
|
danger: rgba(color.red, 0.14),
|
||||||
},
|
},
|
||||||
...common,
|
...common,
|
||||||
};
|
};
|
||||||
@@ -27,6 +28,7 @@ export const borderDark = {
|
|||||||
light: grayScale.gray70,
|
light: grayScale.gray70,
|
||||||
secondaryInverted: grayScale.gray35,
|
secondaryInverted: grayScale.gray35,
|
||||||
inverted: grayScale.gray20,
|
inverted: grayScale.gray20,
|
||||||
|
danger: rgba(color.red, 0.14),
|
||||||
},
|
},
|
||||||
...common,
|
...common,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const grayScale = {
|
|||||||
gray0: '#ffffff',
|
gray0: '#ffffff',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const color: { [key: string]: string } = {
|
export const color = {
|
||||||
yellow: '#ffd338',
|
yellow: '#ffd338',
|
||||||
yellow80: '#2e2a1a',
|
yellow80: '#2e2a1a',
|
||||||
yellow70: '#453d1e',
|
yellow70: '#453d1e',
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Tooltip } from 'react-tooltip';
|
import { Tooltip } from 'react-tooltip';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
|
|
||||||
|
import { rgba } from '../theme/constants/colors';
|
||||||
|
|
||||||
export enum TooltipPosition {
|
export enum TooltipPosition {
|
||||||
Top = 'top',
|
Top = 'top',
|
||||||
Left = 'left',
|
Left = 'left',
|
||||||
@@ -9,16 +11,21 @@ export enum TooltipPosition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const AppTooltip = styled(Tooltip)`
|
export const AppTooltip = styled(Tooltip)`
|
||||||
background-color: ${({ theme }) => theme.background.primary};
|
backdrop-filter: ${({ theme }) => theme.blur.strong};
|
||||||
box-shadow: ${({ theme }) => theme.boxShadow.light};
|
background-color: ${({ theme }) => rgba(theme.color.gray80, 0.8)};
|
||||||
|
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||||
|
|
||||||
color: ${({ theme }) => theme.font.color.primary};
|
box-shadow: ${({ theme }) => theme.boxShadow.light};
|
||||||
|
color: ${({ theme }) => theme.color.gray0};
|
||||||
|
|
||||||
font-size: ${({ theme }) => theme.font.size.sm};
|
font-size: ${({ theme }) => theme.font.size.sm};
|
||||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||||
|
|
||||||
max-width: 40%;
|
max-width: 40%;
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
padding: ${({ theme }) => theme.spacing(2)};
|
padding: ${({ theme }) => theme.spacing(2)};
|
||||||
|
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
|
||||||
z-index: ${({ theme }) => theme.lastLayerZIndex};
|
z-index: ${({ theme }) => theme.lastLayerZIndex};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-empty-function */
|
||||||
import { getOperationName } from '@apollo/client/utilities';
|
import { getOperationName } from '@apollo/client/utilities';
|
||||||
import { useTheme } from '@emotion/react';
|
import { useTheme } from '@emotion/react';
|
||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es5",
|
"target": "es6",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|||||||
275
front/yarn.lock
275
front/yarn.lock
@@ -1562,6 +1562,157 @@
|
|||||||
"@tiptap/react" "^2.0.3"
|
"@tiptap/react" "^2.0.3"
|
||||||
react-icons "^4.3.1"
|
react-icons "^4.3.1"
|
||||||
|
|
||||||
|
"@chakra-ui/accordion@^2.3.0":
|
||||||
|
version "2.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@chakra-ui/accordion/-/accordion-2.3.0.tgz#2c85fd2d2734b176f019f8db9f4e075007b4e1fb"
|
||||||
|
integrity sha512-A4TkRw3Jnt+Fam6dSSJ62rskdrvjF3JGctYcfXlojfFIpHPuIw4pDwfZgNAxlaxWkcj0e7JJKlQ88dnZW+QfFg==
|
||||||
|
dependencies:
|
||||||
|
"@chakra-ui/descendant" "3.1.0"
|
||||||
|
"@chakra-ui/icon" "3.1.0"
|
||||||
|
"@chakra-ui/react-context" "2.1.0"
|
||||||
|
"@chakra-ui/react-use-controllable-state" "2.1.0"
|
||||||
|
"@chakra-ui/react-use-merge-refs" "2.1.0"
|
||||||
|
"@chakra-ui/shared-utils" "2.0.5"
|
||||||
|
"@chakra-ui/transition" "2.1.0"
|
||||||
|
|
||||||
|
"@chakra-ui/anatomy@2.2.0":
|
||||||
|
version "2.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@chakra-ui/anatomy/-/anatomy-2.2.0.tgz#788229829f853dcd03314cd7ddd4f19f056ec24e"
|
||||||
|
integrity sha512-cD8Ms5C8+dFda0LrORMdxiFhAZwOIY1BSlCadz6/mHUIgNdQy13AHPrXiq6qWdMslqVHq10k5zH7xMPLt6kjFg==
|
||||||
|
|
||||||
|
"@chakra-ui/color-mode@2.2.0":
|
||||||
|
version "2.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@chakra-ui/color-mode/-/color-mode-2.2.0.tgz#828d47234c74ba2fb4c5dd63a63331aead20b9f6"
|
||||||
|
integrity sha512-niTEA8PALtMWRI9wJ4LL0CSBDo8NBfLNp4GD6/0hstcm3IlbBHTVKxN6HwSaoNYfphDQLxCjT4yG+0BJA5tFpg==
|
||||||
|
dependencies:
|
||||||
|
"@chakra-ui/react-use-safe-layout-effect" "2.1.0"
|
||||||
|
|
||||||
|
"@chakra-ui/descendant@3.1.0":
|
||||||
|
version "3.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@chakra-ui/descendant/-/descendant-3.1.0.tgz#f3b80ed13ffc4bf1d615b3ed5541bd0905375cca"
|
||||||
|
integrity sha512-VxCIAir08g5w27klLyi7PVo8BxhW4tgU/lxQyujkmi4zx7hT9ZdrcQLAted/dAa+aSIZ14S1oV0Q9lGjsAdxUQ==
|
||||||
|
dependencies:
|
||||||
|
"@chakra-ui/react-context" "2.1.0"
|
||||||
|
"@chakra-ui/react-use-merge-refs" "2.1.0"
|
||||||
|
|
||||||
|
"@chakra-ui/icon@3.1.0":
|
||||||
|
version "3.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@chakra-ui/icon/-/icon-3.1.0.tgz#48312c071b3a0ed20ce807c8bd24d5f3e9cfdb7f"
|
||||||
|
integrity sha512-t6v0lGCXRbwUJycN8A/nDTuLktMP+LRjKbYJnd2oL6Pm2vOl99XwEQ5cAEyEa4XoseYNEgXiLR+2TfvgfNFvcw==
|
||||||
|
dependencies:
|
||||||
|
"@chakra-ui/shared-utils" "2.0.5"
|
||||||
|
|
||||||
|
"@chakra-ui/object-utils@2.1.0":
|
||||||
|
version "2.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@chakra-ui/object-utils/-/object-utils-2.1.0.tgz#a4ecf9cea92f1de09f5531f53ffdc41e0b19b6c3"
|
||||||
|
integrity sha512-tgIZOgLHaoti5PYGPTwK3t/cqtcycW0owaiOXoZOcpwwX/vlVb+H1jFsQyWiiwQVPt9RkoSLtxzXamx+aHH+bQ==
|
||||||
|
|
||||||
|
"@chakra-ui/react-context@2.1.0":
|
||||||
|
version "2.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@chakra-ui/react-context/-/react-context-2.1.0.tgz#4858be1d5ff1c8ac0a0ec088d93a3b7f1cbbff99"
|
||||||
|
integrity sha512-iahyStvzQ4AOwKwdPReLGfDesGG+vWJfEsn0X/NoGph/SkN+HXtv2sCfYFFR9k7bb+Kvc6YfpLlSuLvKMHi2+w==
|
||||||
|
|
||||||
|
"@chakra-ui/react-use-callback-ref@2.1.0":
|
||||||
|
version "2.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@chakra-ui/react-use-callback-ref/-/react-use-callback-ref-2.1.0.tgz#a508085f4d9e7d84d4ceffdf5f41745c9ac451d7"
|
||||||
|
integrity sha512-efnJrBtGDa4YaxDzDE90EnKD3Vkh5a1t3w7PhnRQmsphLy3g2UieasoKTlT2Hn118TwDjIv5ZjHJW6HbzXA9wQ==
|
||||||
|
|
||||||
|
"@chakra-ui/react-use-controllable-state@2.1.0":
|
||||||
|
version "2.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@chakra-ui/react-use-controllable-state/-/react-use-controllable-state-2.1.0.tgz#8fb6fa2f45d0c04173582ae8297e604ffdb9c7d9"
|
||||||
|
integrity sha512-QR/8fKNokxZUs4PfxjXuwl0fj/d71WPrmLJvEpCTkHjnzu7LnYvzoe2wB867IdooQJL0G1zBxl0Dq+6W1P3jpg==
|
||||||
|
dependencies:
|
||||||
|
"@chakra-ui/react-use-callback-ref" "2.1.0"
|
||||||
|
|
||||||
|
"@chakra-ui/react-use-merge-refs@2.1.0":
|
||||||
|
version "2.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@chakra-ui/react-use-merge-refs/-/react-use-merge-refs-2.1.0.tgz#c0c233527abdbea9a1348269c192012205762314"
|
||||||
|
integrity sha512-lERa6AWF1cjEtWSGjxWTaSMvneccnAVH4V4ozh8SYiN9fSPZLlSG3kNxfNzdFvMEhM7dnP60vynF7WjGdTgQbQ==
|
||||||
|
|
||||||
|
"@chakra-ui/react-use-safe-layout-effect@2.1.0":
|
||||||
|
version "2.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@chakra-ui/react-use-safe-layout-effect/-/react-use-safe-layout-effect-2.1.0.tgz#3a95f0ba6fd5d2d0aa14919160f2c825f13e686f"
|
||||||
|
integrity sha512-Knbrrx/bcPwVS1TorFdzrK/zWA8yuU/eaXDkNj24IrKoRlQrSBFarcgAEzlCHtzuhufP3OULPkELTzz91b0tCw==
|
||||||
|
|
||||||
|
"@chakra-ui/react-utils@2.0.12":
|
||||||
|
version "2.0.12"
|
||||||
|
resolved "https://registry.yarnpkg.com/@chakra-ui/react-utils/-/react-utils-2.0.12.tgz#d6b773b9a5b2e51dce61f51ac8a0e9a0f534f479"
|
||||||
|
integrity sha512-GbSfVb283+YA3kA8w8xWmzbjNWk14uhNpntnipHCftBibl0lxtQ9YqMFQLwuFOO0U2gYVocszqqDWX+XNKq9hw==
|
||||||
|
dependencies:
|
||||||
|
"@chakra-ui/utils" "2.0.15"
|
||||||
|
|
||||||
|
"@chakra-ui/shared-utils@2.0.5":
|
||||||
|
version "2.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/@chakra-ui/shared-utils/-/shared-utils-2.0.5.tgz#cb2b49705e113853647f1822142619570feba081"
|
||||||
|
integrity sha512-4/Wur0FqDov7Y0nCXl7HbHzCg4aq86h+SXdoUeuCMD3dSj7dpsVnStLYhng1vxvlbUnLpdF4oz5Myt3i/a7N3Q==
|
||||||
|
|
||||||
|
"@chakra-ui/styled-system@2.9.1":
|
||||||
|
version "2.9.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@chakra-ui/styled-system/-/styled-system-2.9.1.tgz#888a4901b2afa174461259a8875379adb0363934"
|
||||||
|
integrity sha512-jhYKBLxwOPi9/bQt9kqV3ELa/4CjmNNruTyXlPp5M0v0+pDMUngPp48mVLoskm9RKZGE0h1qpvj/jZ3K7c7t8w==
|
||||||
|
dependencies:
|
||||||
|
"@chakra-ui/shared-utils" "2.0.5"
|
||||||
|
csstype "^3.0.11"
|
||||||
|
lodash.mergewith "4.6.2"
|
||||||
|
|
||||||
|
"@chakra-ui/system@^2.6.0":
|
||||||
|
version "2.6.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@chakra-ui/system/-/system-2.6.0.tgz#29f65bb0887ee0816bbb7b4b098ca5aa4918c409"
|
||||||
|
integrity sha512-MgAFRz9V1pW0dplwWsB99hx49LCC+LsrkMala7KXcP0OvWdrkjw+iu+voBksO3626+glzgIwlZW113Eja+7JEQ==
|
||||||
|
dependencies:
|
||||||
|
"@chakra-ui/color-mode" "2.2.0"
|
||||||
|
"@chakra-ui/object-utils" "2.1.0"
|
||||||
|
"@chakra-ui/react-utils" "2.0.12"
|
||||||
|
"@chakra-ui/styled-system" "2.9.1"
|
||||||
|
"@chakra-ui/theme-utils" "2.0.19"
|
||||||
|
"@chakra-ui/utils" "2.0.15"
|
||||||
|
react-fast-compare "3.2.1"
|
||||||
|
|
||||||
|
"@chakra-ui/theme-tools@2.1.0":
|
||||||
|
version "2.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@chakra-ui/theme-tools/-/theme-tools-2.1.0.tgz#ad34f2fdda61305ac96f69ad9ce38ad12f8bfdbf"
|
||||||
|
integrity sha512-TKv4trAY8q8+DWdZrpSabTd3SZtZrnzFDwUdzhbWBhFEDEVR3fAkRTPpnPDtf1X9w1YErWn3QAcMACVFz4+vkw==
|
||||||
|
dependencies:
|
||||||
|
"@chakra-ui/anatomy" "2.2.0"
|
||||||
|
"@chakra-ui/shared-utils" "2.0.5"
|
||||||
|
color2k "^2.0.0"
|
||||||
|
|
||||||
|
"@chakra-ui/theme-utils@2.0.19":
|
||||||
|
version "2.0.19"
|
||||||
|
resolved "https://registry.yarnpkg.com/@chakra-ui/theme-utils/-/theme-utils-2.0.19.tgz#47e6af43f8ef22403686b779ca1a869ab1b7a5ec"
|
||||||
|
integrity sha512-UQ+KvozTN86+0oA80rdQd1a++4rm4ulo+DEabkgwNpkK3yaWsucOxkDQpi2sMIMvw5X0oaWvNBZJuVyK7HdOXg==
|
||||||
|
dependencies:
|
||||||
|
"@chakra-ui/shared-utils" "2.0.5"
|
||||||
|
"@chakra-ui/styled-system" "2.9.1"
|
||||||
|
"@chakra-ui/theme" "3.2.0"
|
||||||
|
lodash.mergewith "4.6.2"
|
||||||
|
|
||||||
|
"@chakra-ui/theme@3.2.0":
|
||||||
|
version "3.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@chakra-ui/theme/-/theme-3.2.0.tgz#b8232d770e542895cf82535942a74ad803bb7133"
|
||||||
|
integrity sha512-q9mppdkhmaBnvOT8REr/lVNNBX/prwm50EzObJ+r+ErVhNQDc55gCFmtr+It3xlcCqmOteG6XUdwRCJz8qzOqg==
|
||||||
|
dependencies:
|
||||||
|
"@chakra-ui/anatomy" "2.2.0"
|
||||||
|
"@chakra-ui/shared-utils" "2.0.5"
|
||||||
|
"@chakra-ui/theme-tools" "2.1.0"
|
||||||
|
|
||||||
|
"@chakra-ui/transition@2.1.0":
|
||||||
|
version "2.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@chakra-ui/transition/-/transition-2.1.0.tgz#c8e95564f7ab356e78119780037bae5ad150c7b3"
|
||||||
|
integrity sha512-orkT6T/Dt+/+kVwJNy7zwJ+U2xAZ3EU7M3XCs45RBvUnZDr/u9vdmaM/3D/rOpmQJWgQBwKPJleUXrYWUagEDQ==
|
||||||
|
dependencies:
|
||||||
|
"@chakra-ui/shared-utils" "2.0.5"
|
||||||
|
|
||||||
|
"@chakra-ui/utils@2.0.15":
|
||||||
|
version "2.0.15"
|
||||||
|
resolved "https://registry.yarnpkg.com/@chakra-ui/utils/-/utils-2.0.15.tgz#bd800b1cff30eb5a5e8c36fa039f49984b4c5e4a"
|
||||||
|
integrity sha512-El4+jL0WSaYYs+rJbuYFDbjmfCcfGDmRY95GO4xwzit6YAPZBLcR65rOEwLps+XWluZTy1xdMrusg/hW0c1aAA==
|
||||||
|
dependencies:
|
||||||
|
"@types/lodash.mergewith" "4.6.7"
|
||||||
|
css-box-model "1.2.1"
|
||||||
|
framesync "6.1.2"
|
||||||
|
lodash.mergewith "4.6.2"
|
||||||
|
|
||||||
"@colors/colors@1.5.0":
|
"@colors/colors@1.5.0":
|
||||||
version "1.5.0"
|
version "1.5.0"
|
||||||
resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9"
|
resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9"
|
||||||
@@ -4729,6 +4880,20 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-2.30.0.tgz#4ea3c4da56fd5653bb9d0be0dc7feaa33602555c"
|
resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-2.30.0.tgz#4ea3c4da56fd5653bb9d0be0dc7feaa33602555c"
|
||||||
integrity sha512-tvtmkI4ALjKThVVORh++sB9JnkFY7eGInKxNy+Df7WVQiF7T85tlvGADzlgX4Ic+CK5MIUzZ0jhOlQ/RRlgXpg==
|
integrity sha512-tvtmkI4ALjKThVVORh++sB9JnkFY7eGInKxNy+Df7WVQiF7T85tlvGADzlgX4Ic+CK5MIUzZ0jhOlQ/RRlgXpg==
|
||||||
|
|
||||||
|
"@testing-library/dom@>=7":
|
||||||
|
version "9.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-9.3.1.tgz#8094f560e9389fb973fe957af41bf766937a9ee9"
|
||||||
|
integrity sha512-0DGPd9AR3+iDTjGoMpxIkAsUihHZ3Ai6CneU6bRRrffXMgzCdlNk43jTrD2/5LT6CBb3MWTP8v510JzYtahD2w==
|
||||||
|
dependencies:
|
||||||
|
"@babel/code-frame" "^7.10.4"
|
||||||
|
"@babel/runtime" "^7.12.5"
|
||||||
|
"@types/aria-query" "^5.0.1"
|
||||||
|
aria-query "5.1.3"
|
||||||
|
chalk "^4.1.0"
|
||||||
|
dom-accessibility-api "^0.5.9"
|
||||||
|
lz-string "^1.5.0"
|
||||||
|
pretty-format "^27.0.2"
|
||||||
|
|
||||||
"@testing-library/dom@^8.3.0", "@testing-library/dom@^8.5.0":
|
"@testing-library/dom@^8.3.0", "@testing-library/dom@^8.5.0":
|
||||||
version "8.20.1"
|
version "8.20.1"
|
||||||
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.20.1.tgz#2e52a32e46fc88369eef7eef634ac2a192decd9f"
|
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.20.1.tgz#2e52a32e46fc88369eef7eef634ac2a192decd9f"
|
||||||
@@ -5238,6 +5403,13 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@types/lodash" "*"
|
"@types/lodash" "*"
|
||||||
|
|
||||||
|
"@types/lodash.mergewith@4.6.7":
|
||||||
|
version "4.6.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/lodash.mergewith/-/lodash.mergewith-4.6.7.tgz#eaa65aa5872abdd282f271eae447b115b2757212"
|
||||||
|
integrity sha512-3m+lkO5CLRRYU0fhGRp7zbsGi6+BZj0uTVSwvcKU+nSlhjA9/QRNfuSGnD2mX6hQA7ZbmcCkzk5h4ZYGOtk14A==
|
||||||
|
dependencies:
|
||||||
|
"@types/lodash" "*"
|
||||||
|
|
||||||
"@types/lodash@*", "@types/lodash@^4.14.167":
|
"@types/lodash@*", "@types/lodash@^4.14.167":
|
||||||
version "4.14.195"
|
version "4.14.195"
|
||||||
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.195.tgz#bafc975b252eb6cea78882ce8a7b6bf22a6de632"
|
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.195.tgz#bafc975b252eb6cea78882ce8a7b6bf22a6de632"
|
||||||
@@ -6274,6 +6446,11 @@ at-least-node@^1.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2"
|
resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2"
|
||||||
integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==
|
integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==
|
||||||
|
|
||||||
|
attr-accept@^2.2.2:
|
||||||
|
version "2.2.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.2.tgz#646613809660110749e92f2c10833b70968d929b"
|
||||||
|
integrity sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==
|
||||||
|
|
||||||
auto-bind@~4.0.0:
|
auto-bind@~4.0.0:
|
||||||
version "4.0.0"
|
version "4.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/auto-bind/-/auto-bind-4.0.0.tgz#e3589fc6c2da8f7ca43ba9f84fa52a744fc997fb"
|
resolved "https://registry.yarnpkg.com/auto-bind/-/auto-bind-4.0.0.tgz#e3589fc6c2da8f7ca43ba9f84fa52a744fc997fb"
|
||||||
@@ -7870,6 +8047,11 @@ clsx@1.1.1:
|
|||||||
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188"
|
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188"
|
||||||
integrity sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==
|
integrity sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==
|
||||||
|
|
||||||
|
clsx@^1.1.1:
|
||||||
|
version "1.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12"
|
||||||
|
integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
|
||||||
|
|
||||||
cmdk@^0.2.0:
|
cmdk@^0.2.0:
|
||||||
version "0.2.0"
|
version "0.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/cmdk/-/cmdk-0.2.0.tgz#53c52d56d8776c8bb8ced1055b5054100c388f7c"
|
resolved "https://registry.yarnpkg.com/cmdk/-/cmdk-0.2.0.tgz#53c52d56d8776c8bb8ced1055b5054100c388f7c"
|
||||||
@@ -7926,6 +8108,11 @@ color-support@^1.1.2:
|
|||||||
resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2"
|
resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2"
|
||||||
integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==
|
integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==
|
||||||
|
|
||||||
|
color2k@^2.0.0:
|
||||||
|
version "2.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/color2k/-/color2k-2.0.2.tgz#ac2b4aea11c822a6bcb70c768b5a289f4fffcebb"
|
||||||
|
integrity sha512-kJhwH5nAwb34tmyuqq/lgjEKzlFXn1U99NlnB6Ws4qVaERcRUYeYP1cBw6BJ4vxaWStAUEef4WMr7WjOCnBt8w==
|
||||||
|
|
||||||
colord@^2.9.1:
|
colord@^2.9.1:
|
||||||
version "2.9.3"
|
version "2.9.3"
|
||||||
resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.3.tgz#4f8ce919de456f1d5c1c368c307fe20f3e59fb43"
|
resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.3.tgz#4f8ce919de456f1d5c1c368c307fe20f3e59fb43"
|
||||||
@@ -8259,7 +8446,7 @@ css-blank-pseudo@^3.0.3:
|
|||||||
dependencies:
|
dependencies:
|
||||||
postcss-selector-parser "^6.0.9"
|
postcss-selector-parser "^6.0.9"
|
||||||
|
|
||||||
css-box-model@^1.2.1:
|
css-box-model@1.2.1, css-box-model@^1.2.1:
|
||||||
version "1.2.1"
|
version "1.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/css-box-model/-/css-box-model-1.2.1.tgz#59951d3b81fd6b2074a62d49444415b0d2b4d7c1"
|
resolved "https://registry.yarnpkg.com/css-box-model/-/css-box-model-1.2.1.tgz#59951d3b81fd6b2074a62d49444415b0d2b4d7c1"
|
||||||
integrity sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==
|
integrity sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==
|
||||||
@@ -8493,7 +8680,7 @@ csstype@3.0.9:
|
|||||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.9.tgz#6410af31b26bd0520933d02cbc64fce9ce3fbf0b"
|
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.9.tgz#6410af31b26bd0520933d02cbc64fce9ce3fbf0b"
|
||||||
integrity sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw==
|
integrity sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw==
|
||||||
|
|
||||||
csstype@^3.0.2:
|
csstype@^3.0.11, csstype@^3.0.2:
|
||||||
version "3.1.2"
|
version "3.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
|
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
|
||||||
integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==
|
integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==
|
||||||
@@ -9729,11 +9916,6 @@ execa@^5.0.0, execa@^5.1.1:
|
|||||||
signal-exit "^3.0.3"
|
signal-exit "^3.0.3"
|
||||||
strip-final-newline "^2.0.0"
|
strip-final-newline "^2.0.0"
|
||||||
|
|
||||||
exenv@^1.2.0:
|
|
||||||
version "1.2.2"
|
|
||||||
resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d"
|
|
||||||
integrity sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==
|
|
||||||
|
|
||||||
exit@^0.1.2:
|
exit@^0.1.2:
|
||||||
version "0.1.2"
|
version "0.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c"
|
resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c"
|
||||||
@@ -9978,6 +10160,13 @@ file-loader@^6.2.0:
|
|||||||
loader-utils "^2.0.0"
|
loader-utils "^2.0.0"
|
||||||
schema-utils "^3.0.0"
|
schema-utils "^3.0.0"
|
||||||
|
|
||||||
|
file-selector@^0.6.0:
|
||||||
|
version "0.6.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.6.0.tgz#fa0a8d9007b829504db4d07dd4de0310b65287dc"
|
||||||
|
integrity sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==
|
||||||
|
dependencies:
|
||||||
|
tslib "^2.4.0"
|
||||||
|
|
||||||
file-system-cache@2.3.0:
|
file-system-cache@2.3.0:
|
||||||
version "2.3.0"
|
version "2.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/file-system-cache/-/file-system-cache-2.3.0.tgz#201feaf4c8cd97b9d0d608e96861bb6005f46fe6"
|
resolved "https://registry.yarnpkg.com/file-system-cache/-/file-system-cache-2.3.0.tgz#201feaf4c8cd97b9d0d608e96861bb6005f46fe6"
|
||||||
@@ -10207,6 +10396,13 @@ framer-motion@^10.12.17:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
"@emotion/is-prop-valid" "^0.8.2"
|
"@emotion/is-prop-valid" "^0.8.2"
|
||||||
|
|
||||||
|
framesync@6.1.2:
|
||||||
|
version "6.1.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/framesync/-/framesync-6.1.2.tgz#755eff2fb5b8f3b4d2b266dd18121b300aefea27"
|
||||||
|
integrity sha512-jBTqhX6KaQVDyus8muwZbBeGGP0XgujBRbQ7gM7BRdS3CadCZIHiawyzYLnafYcvZIh5j8WE7cxZKFn7dXhu9g==
|
||||||
|
dependencies:
|
||||||
|
tslib "2.4.0"
|
||||||
|
|
||||||
fresh@0.5.2:
|
fresh@0.5.2:
|
||||||
version "0.5.2"
|
version "0.5.2"
|
||||||
resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
|
resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
|
||||||
@@ -13108,6 +13304,11 @@ lodash.merge@^4.6.2:
|
|||||||
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
|
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
|
||||||
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
|
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
|
||||||
|
|
||||||
|
lodash.mergewith@4.6.2:
|
||||||
|
version "4.6.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz#617121f89ac55f59047c7aec1ccd6654c6590f55"
|
||||||
|
integrity sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==
|
||||||
|
|
||||||
lodash.sortby@^4.7.0:
|
lodash.sortby@^4.7.0:
|
||||||
version "4.7.0"
|
version "4.7.0"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
|
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
|
||||||
@@ -15721,6 +15922,13 @@ react-colorful@^5.1.2:
|
|||||||
resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.6.1.tgz#7dc2aed2d7c72fac89694e834d179e32f3da563b"
|
resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.6.1.tgz#7dc2aed2d7c72fac89694e834d179e32f3da563b"
|
||||||
integrity sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==
|
integrity sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==
|
||||||
|
|
||||||
|
react-data-grid@7.0.0-beta.13:
|
||||||
|
version "7.0.0-beta.13"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-data-grid/-/react-data-grid-7.0.0-beta.13.tgz#c0728258cbbb033af611eed50ed124744a76ee76"
|
||||||
|
integrity sha512-vhBdkC2KqAawmmzYTcNlfhfjcYFQsinNr5pPTUG6/3DzLfYWo1S6nl48wgPWgyD9uDclV3H5NWvKSSwQTTsYMQ==
|
||||||
|
dependencies:
|
||||||
|
clsx "^1.1.1"
|
||||||
|
|
||||||
react-datepicker@^4.11.0:
|
react-datepicker@^4.11.0:
|
||||||
version "4.15.0"
|
version "4.15.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-datepicker/-/react-datepicker-4.15.0.tgz#489834773fbcf87852273b4642f0c5bd3811cef7"
|
resolved "https://registry.yarnpkg.com/react-datepicker/-/react-datepicker-4.15.0.tgz#489834773fbcf87852273b4642f0c5bd3811cef7"
|
||||||
@@ -15792,6 +16000,15 @@ react-dom@^18.2.0:
|
|||||||
loose-envify "^1.1.0"
|
loose-envify "^1.1.0"
|
||||||
scheduler "^0.23.0"
|
scheduler "^0.23.0"
|
||||||
|
|
||||||
|
react-dropzone@^14.2.3:
|
||||||
|
version "14.2.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-14.2.3.tgz#0acab68308fda2d54d1273a1e626264e13d4e84b"
|
||||||
|
integrity sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==
|
||||||
|
dependencies:
|
||||||
|
attr-accept "^2.2.2"
|
||||||
|
file-selector "^0.6.0"
|
||||||
|
prop-types "^15.8.1"
|
||||||
|
|
||||||
react-element-to-jsx-string@^15.0.0:
|
react-element-to-jsx-string@^15.0.0:
|
||||||
version "15.0.0"
|
version "15.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-element-to-jsx-string/-/react-element-to-jsx-string-15.0.0.tgz#1cafd5b6ad41946ffc8755e254da3fc752a01ac6"
|
resolved "https://registry.yarnpkg.com/react-element-to-jsx-string/-/react-element-to-jsx-string-15.0.0.tgz#1cafd5b6ad41946ffc8755e254da3fc752a01ac6"
|
||||||
@@ -15806,6 +16023,11 @@ react-error-overlay@^6.0.11:
|
|||||||
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb"
|
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb"
|
||||||
integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==
|
integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==
|
||||||
|
|
||||||
|
react-fast-compare@3.2.1:
|
||||||
|
version "3.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.1.tgz#53933d9e14f364281d6cba24bfed7a4afb808b5f"
|
||||||
|
integrity sha512-xTYf9zFim2pEif/Fw16dBiXpe0hoy5PxcD8+OwBnTtNLfIm3g6WxhKNurY+6OmdH1u6Ta/W/Vl6vjbYP1MFnDg==
|
||||||
|
|
||||||
react-fast-compare@^3.0.1:
|
react-fast-compare@^3.0.1:
|
||||||
version "3.2.2"
|
version "3.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49"
|
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49"
|
||||||
@@ -15851,26 +16073,11 @@ react-is@^18.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
|
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
|
||||||
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
|
integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
|
||||||
|
|
||||||
react-lifecycles-compat@^3.0.0:
|
|
||||||
version "3.0.4"
|
|
||||||
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
|
|
||||||
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
|
|
||||||
|
|
||||||
react-loading-skeleton@^3.3.1:
|
react-loading-skeleton@^3.3.1:
|
||||||
version "3.3.1"
|
version "3.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/react-loading-skeleton/-/react-loading-skeleton-3.3.1.tgz#cd6e3a626ee86c76a46c14e2379243f2f8834e1b"
|
resolved "https://registry.yarnpkg.com/react-loading-skeleton/-/react-loading-skeleton-3.3.1.tgz#cd6e3a626ee86c76a46c14e2379243f2f8834e1b"
|
||||||
integrity sha512-NilqqwMh2v9omN7LteiDloEVpFyMIa0VGqF+ukqp0ncVlYu1sKYbYGX9JEl+GtOT9TKsh04zCHAbavnQ2USldA==
|
integrity sha512-NilqqwMh2v9omN7LteiDloEVpFyMIa0VGqF+ukqp0ncVlYu1sKYbYGX9JEl+GtOT9TKsh04zCHAbavnQ2USldA==
|
||||||
|
|
||||||
react-modal@^3.16.1:
|
|
||||||
version "3.16.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-3.16.1.tgz#34018528fc206561b1a5467fc3beeaddafb39b2b"
|
|
||||||
integrity sha512-VStHgI3BVcGo7OXczvnJN7yT2TWHJPDXZWyI/a0ssFNhGZWsPmB8cF0z33ewDXq4VfYMO1vXgiv/g8Nj9NDyWg==
|
|
||||||
dependencies:
|
|
||||||
exenv "^1.2.0"
|
|
||||||
prop-types "^15.7.2"
|
|
||||||
react-lifecycles-compat "^3.0.0"
|
|
||||||
warning "^4.0.3"
|
|
||||||
|
|
||||||
react-onclickoutside@^6.12.2:
|
react-onclickoutside@^6.12.2:
|
||||||
version "6.13.0"
|
version "6.13.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-6.13.0.tgz#e165ea4e5157f3da94f4376a3ab3e22a565f4ffc"
|
resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-6.13.0.tgz#e165ea4e5157f3da94f4376a3ab3e22a565f4ffc"
|
||||||
@@ -16000,6 +16207,13 @@ react-scripts@5.0.1:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents "^2.3.2"
|
fsevents "^2.3.2"
|
||||||
|
|
||||||
|
react-select-event@^5.5.1:
|
||||||
|
version "5.5.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-select-event/-/react-select-event-5.5.1.tgz#d67e04a6a51428b1534b15ecb1b82afbe5edddcb"
|
||||||
|
integrity sha512-goAx28y0+iYrbqZA2FeRTreHHs/ZtSuKxtA+J5jpKT5RHPCbVZJ4MqACfPnWyFXsEec+3dP5bCrNTxIX8oYe9A==
|
||||||
|
dependencies:
|
||||||
|
"@testing-library/dom" ">=7"
|
||||||
|
|
||||||
react-style-object-to-css@^1.1.2:
|
react-style-object-to-css@^1.1.2:
|
||||||
version "1.1.2"
|
version "1.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/react-style-object-to-css/-/react-style-object-to-css-1.1.2.tgz#3e97da92c9edf8190e30f4b27026e74285940498"
|
resolved "https://registry.yarnpkg.com/react-style-object-to-css/-/react-style-object-to-css-1.1.2.tgz#3e97da92c9edf8190e30f4b27026e74285940498"
|
||||||
@@ -17949,6 +18163,11 @@ tsconfig-paths@^3.14.1:
|
|||||||
minimist "^1.2.6"
|
minimist "^1.2.6"
|
||||||
strip-bom "^3.0.0"
|
strip-bom "^3.0.0"
|
||||||
|
|
||||||
|
tslib@2.4.0:
|
||||||
|
version "2.4.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3"
|
||||||
|
integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==
|
||||||
|
|
||||||
tslib@^1.8.1, tslib@^1.9.0:
|
tslib@^1.8.1, tslib@^1.9.0:
|
||||||
version "1.14.1"
|
version "1.14.1"
|
||||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
|
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
|
||||||
@@ -18023,6 +18242,11 @@ type-fest@^3.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-3.12.0.tgz#4ce26edc1ccc59fc171e495887ef391fe1f5280e"
|
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-3.12.0.tgz#4ce26edc1ccc59fc171e495887ef391fe1f5280e"
|
||||||
integrity sha512-qj9wWsnFvVEMUDbESiilKeXeHL7FwwiFcogfhfyjmvT968RXSvnl23f1JOClTHYItsi7o501C/7qVllscUP3oA==
|
integrity sha512-qj9wWsnFvVEMUDbESiilKeXeHL7FwwiFcogfhfyjmvT968RXSvnl23f1JOClTHYItsi7o501C/7qVllscUP3oA==
|
||||||
|
|
||||||
|
type-fest@^4.1.0:
|
||||||
|
version "4.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.1.0.tgz#7e214aa81b843478396f832164ae8f058be95f9e"
|
||||||
|
integrity sha512-VJGJVepayd8OWavP+rgXt4i3bfLk+tSomTV7r4mca2XD/oTCWnkJlNkpXavkxdmtU2aKdAmFGeHvoQutOVHCZg==
|
||||||
|
|
||||||
type-is@~1.6.18:
|
type-is@~1.6.18:
|
||||||
version "1.6.18"
|
version "1.6.18"
|
||||||
resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
|
resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
|
||||||
@@ -18545,7 +18769,7 @@ walker@^1.0.7, walker@^1.0.8:
|
|||||||
dependencies:
|
dependencies:
|
||||||
makeerror "1.0.12"
|
makeerror "1.0.12"
|
||||||
|
|
||||||
warning@^4.0.2, warning@^4.0.3:
|
warning@^4.0.2:
|
||||||
version "4.0.3"
|
version "4.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3"
|
resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3"
|
||||||
integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==
|
integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==
|
||||||
@@ -19126,6 +19350,11 @@ ws@^7.4.6:
|
|||||||
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591"
|
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591"
|
||||||
integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==
|
integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==
|
||||||
|
|
||||||
|
xlsx-ugnis@^0.19.3:
|
||||||
|
version "0.19.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/xlsx-ugnis/-/xlsx-ugnis-0.19.3.tgz#4e27b2158338faba506ec66b5deb69deef955789"
|
||||||
|
integrity sha512-NeaoBgm3Bdml5fjh4n6jfzF+tT25S9Rd4W0+i4Wx80P1bQl7jbyqW2xxVbdYh0CpfPa9WkVkeQcKqMYbyBX+HA==
|
||||||
|
|
||||||
xml-name-validator@^3.0.0:
|
xml-name-validator@^3.0.0:
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"
|
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"
|
||||||
|
|||||||
Reference in New Issue
Block a user