Add in and integrate react-spreadsheet-import

This commit is contained in:
2025-02-18 11:58:22 -05:00
parent 169407a729
commit 89d4605577
81 changed files with 33233 additions and 1068 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -10,9 +10,26 @@
"preview": "vite preview"
},
"dependencies": {
"@chakra-ui/button": "^2.1.0",
"@chakra-ui/checkbox": "^2.3.2",
"@chakra-ui/form-control": "^2.2.0",
"@chakra-ui/hooks": "^2.4.3",
"@chakra-ui/icons": "^2.2.4",
"@chakra-ui/input": "^2.1.2",
"@chakra-ui/layout": "^2.3.1",
"@chakra-ui/modal": "^2.3.1",
"@chakra-ui/popper": "^3.1.0",
"@chakra-ui/react": "^2.8.1",
"@chakra-ui/select": "^2.1.2",
"@chakra-ui/system": "^2.6.2",
"@chakra-ui/theme": "^3.4.7",
"@chakra-ui/theme-tools": "^2.2.7",
"@chakra-ui/utils": "^2.2.3",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2",
@@ -33,35 +50,47 @@
"@radix-ui/react-tooltip": "^1.1.6",
"@shadcn/ui": "^0.0.4",
"@tabler/icons-react": "^3.28.1",
"@tanstack/react-query": "^5.63.0",
"@tanstack/react-query": "^5.66.7",
"@tanstack/react-table": "^8.20.6",
"@tanstack/react-virtual": "^3.11.2",
"@tanstack/virtual-core": "^3.11.2",
"@types/js-levenshtein": "^1.1.3",
"@types/uuid": "^10.0.0",
"chakra-react-select": "^4.7.5",
"chakra-ui-steps": "^2.0.4",
"chart.js": "^4.4.7",
"chartjs-adapter-date-fns": "^3.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"date-fns": "^3.6.0",
"framer-motion": "^12.4.4",
"js-levenshtein": "^1.1.6",
"lodash": "^4.17.21",
"lucide-react": "^0.469.0",
"motion": "^11.18.0",
"next-themes": "^0.4.4",
"react": "^18.3.1",
"react-chartjs-2": "^5.3.0",
"react-data-grid": "^7.0.0-beta.13",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-dropzone": "^14.3.5",
"react-icons": "^5.4.0",
"react-router-dom": "^7.1.1",
"recharts": "^2.15.0",
"sonner": "^1.7.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"tanstack": "^1.0.0",
"vaul": "^1.1.2"
"uuid": "^11.0.5",
"vaul": "^1.1.2",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/fs-extra": "^11.0.4",
"@types/lodash": "^4.17.14",
"@types/lodash": "^4.17.15",
"@types/node": "^22.10.5",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",

View File

@@ -15,6 +15,8 @@ import { RequireAuth } from './components/auth/RequireAuth';
import Forecasting from "@/pages/Forecasting";
import { Vendors } from '@/pages/Vendors';
import { Categories } from '@/pages/Categories';
import { Import } from '@/pages/import/Import';
import { ChakraProvider } from '@chakra-ui/react';
const queryClient = new QueryClient();
@@ -50,6 +52,7 @@ function App() {
return (
<QueryClientProvider client={queryClient}>
<ChakraProvider>
<Toaster richColors position="top-center" />
<Routes>
<Route path="/login" element={<Login />} />
@@ -60,6 +63,7 @@ function App() {
}>
<Route path="/" element={<Dashboard />} />
<Route path="/products" element={<Products />} />
<Route path="/import" element={<Import />} />
<Route path="/categories" element={<Categories />} />
<Route path="/vendors" element={<Vendors />} />
<Route path="/orders" element={<Orders />} />
@@ -70,6 +74,7 @@ function App() {
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</ChakraProvider>
</QueryClientProvider>
);
}

View File

@@ -8,6 +8,7 @@ import {
LogOut,
Users,
Tags,
FileSpreadsheet,
} from "lucide-react";
import { IconCrystalBall } from "@tabler/icons-react";
import {
@@ -35,6 +36,11 @@ const items = [
icon: Package,
url: "/products",
},
{
title: "Import",
icon: FileSpreadsheet,
url: "/import",
},
{
title: "Forecasting",
icon: IconCrystalBall,

View File

@@ -9,7 +9,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
@@ -30,22 +30,25 @@ const CardHeader = React.forwardRef<
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<div
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
@@ -73,4 +76,16 @@ const CardFooter = React.forwardRef<
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
const ScrollArea = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("overflow-auto", className)}
{...props}
/>
))
ScrollArea.displayName = "ScrollArea"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent, ScrollArea }

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface CodeProps extends React.HTMLAttributes<HTMLPreElement> {}
const Code = React.forwardRef<HTMLPreElement, CodeProps>(
({ className, ...props }, ref) => {
return (
<pre
ref={ref}
className={cn(
"rounded-lg bg-muted px-4 py-4 font-mono text-sm",
className
)}
{...props}
/>
)
}
)
Code.displayName = "Code"
export { Code }

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 UGNIS,
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,341 @@
<h1 align="center">RSI react-spreadsheet-import ⚡️</h1>
<div align="center">
![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/UgnisSoftware/react-spreadsheet-import/test.yaml)
![GitHub](https://img.shields.io/github/license/UgnisSoftware/react-spreadsheet-import) [![npm](https://img.shields.io/npm/v/react-spreadsheet-import)](https://www.npmjs.com/package/react-spreadsheet-import)
</div>
<br />
A component used for importing XLS / XLSX / CSV documents built with [**Chakra UI**](https://chakra-ui.com). Import flow combines:
- 📥 Uploader
- ⚙️ Parser
- 📊 File preview
- 🧪 UI for column mapping
- ✏ UI for validating and editing data
✨ [**Demo**](https://ugnissoftware.github.io/react-spreadsheet-import/iframe.html?id=react-spreadsheet-import--basic&args=&viewMode=story) ✨
<br />
## Features
- Custom styles - edit Chakra UI theme to match your project's styles 🎨
- Custom validation rules - make sure valid data is being imported, easily spot and correct errors
- Hooks - alter raw data after upload or make adjustments on data changes
- Auto-mapping columns - automatically map most likely value to your template values, e.g. `name` -> `firstName`
<br />
![rsi-preview](https://user-images.githubusercontent.com/45755753/159503528-90aacb69-128f-4ece-b45b-ab97d403a9d3.gif)
## Figma
We provide full figma designs. You can copy the designs
[here](https://www.figma.com/community/file/1080776795891439629)
## Getting started
```sh
npm i react-spreadsheet-import
```
Using the component: (it's up to you when the flow is open and what you do on submit with the imported data)
```tsx
import { ReactSpreadsheetImport } from "react-spreadsheet-import";
<ReactSpreadsheetImport isOpen={isOpen} onClose={onClose} onSubmit={onSubmit} fields={fields} />
```
## Required Props
```tsx
// Determines if modal is visible.
isOpen: Boolean
// Called when flow is closed without reaching submit.
onClose: () => void
// Called after user completes the flow. Provides data array, where data keys matches your field keys.
onSubmit: (data, file) => void | Promise<any>
```
### Fields
Fields describe what data you are trying to collect.
```tsx
const fields = [
{
// Visible in table header and when matching columns.
label: "Name",
// This is the key used for this field when we call onSubmit.
key: "name",
// Allows for better automatic column matching. Optional.
alternateMatches: ["first name", "first"],
// Used when editing and validating information.
fieldType: {
// There are 3 types - "input" / "checkbox" / "select".
type: "input",
},
// Used in the first step to provide an example of what data is expected in this field. Optional.
example: "Stephanie",
// Can have multiple validations that are visible in Validation Step table.
validations: [
{
// Can be "required" / "unique" / "regex"
rule: "required",
errorMessage: "Name is required",
// There can be "info" / "warning" / "error" levels. Optional. Default "error".
level: "error",
},
],
},
] as const
```
## Optional Props
### Hooks
You can transform and validate data with custom hooks. There are hooks after each step:
- **uploadStepHook** - runs only once after uploading the file.
- **selectHeaderStepHook** - runs only once after selecting the header row in spreadsheet.
- **matchColumnsStepHook** - runs only once after column matching. Operations on data that are expensive should be done here.
The last step - validation step has 2 unique hooks that run only in that step with different performance tradeoffs:
- **tableHook** - runs at the start and on any change. Runs on all rows. Very expensive, but can change rows that depend on other rows.
- **rowHook** - runs at the start and on any row change. Runs only on the rows changed. Fastest, most validations and transformations should be done here.
Example:
```tsx
<ReactSpreadsheetImport
rowHook={(data, addError) => {
// Validation
if (data.name === "John") {
addError("name", { message: "No Johns allowed", level: "info" })
}
// Transformation
return { ...data, name: "Not John" }
// Sorry John
}}
/>
```
### Initial state
In rare case when you need to skip the beginning of the flow, you can start the flow from any of the steps.
- **initialStepState** - initial state of component that will be rendered on load.
```tsx
initialStepState?: StepState
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[]
}
type RawData = Array<string | undefined>
// XLSX.workbook type is native to SheetJS and can be viewed here: https://github.com/SheetJS/sheetjs/blob/83ddb4c1203f6bac052d8c1608b32fead02ea32f/types/index.d.ts#L269
```
Example:
```tsx
import { ReactSpreadsheetImport, StepType } from "react-spreadsheet-import";
<ReactSpreadsheetImport
initialStepState={{
type: StepType.matchColumns,
data: [
["Josh", "2"],
["Charlie", "3"],
["Lena", "50"],
],
headerValues: ["name", "age"],
}}
/>
```
### Dates and time
Excel stores dates and times as numbers - offsets from an epoch. When reading xlsx files SheetJS provides date formatting helpers.
**Default date import format** is `yyyy-mm-dd`. Date parsing with SheetJS sometimes yields unexpected results, therefore thorough date validations are recommended.
- **dateFormat** - sets SheetJS `dateNF` option. Can be used to format dates when importing sheet data.
- **parseRaw** - sets SheetJS `raw` option. If `true`, date formatting will be applied to XLSX date fields only. Default is `true`
Common date-time formats can be viewed [here](https://docs.sheetjs.com/docs/csf/features/dates/#date-and-time-number-formats).
### Other optional props
```tsx
// Allows submitting with errors. Default: true
allowInvalidSubmit?: boolean
// Translations for each text. See customisation bellow
translations?: object
// Theme configuration passed to underlying Chakra-UI. See customisation bellow
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
// When field type is "select", automatically match values if possible. Default: false
autoMapSelectValues?: boolean
// Headers matching accuracy: 1 for strict and up for more flexible matching. Default: 2
autoMapDistance?: number
// Enable navigation in stepper component and show back button. Default: false
isNavigationEnabled?: boolean
```
## Customisation
### Customising styles (colors, fonts)
You can see default theme we use [here](https://github.com/UgnisSoftware/react-spreadsheet-import/blob/master/src/theme.ts). Your override should match this object's structure.
There are 3 ways you can style the component:
1.) Change theme colors globally
```jsx
<ReactSpreadsheetImport
{...mockRsiValues}
isOpen={isOpen}
onClose={onClose}
onSubmit={setData}
customTheme={{
colors: {
background: 'white',
...
rsi: {
// your brand colors should go here
50: '...'
...
500: 'teal',
...
900: "...",
},
},
}}
/>
```
<img width="1189" alt="Screenshot 2022-04-13 at 10 24 34" src="https://user-images.githubusercontent.com/5903616/163123718-15c05ad8-243b-4a81-8141-c47216047468.png">
2.) Change all components of the same type, like all Buttons, at the same time
```jsx
<ReactSpreadsheetImport
{...mockRsiValues}
isOpen={isOpen}
onClose={onClose}
onSubmit={setData}
customTheme={{
components: {
Button: {
baseStyle: {
borderRadius: "none",
},
defaultProps: {
colorScheme: "yellow",
},
},
},
}}
/>
```
<img width="1191" alt="Screenshot 2022-04-13 at 11 04 30" src="https://user-images.githubusercontent.com/5903616/163130213-82f955b4-5081-49e0-8f43-8857d480dacd.png">
3.) Change components specifically in each Step.
```jsx
<ReactSpreadsheetImport
{...mockRsiValues}
isOpen={isOpen}
onClose={onClose}
onSubmit={setData}
customTheme={{
components: {
UploadStep: {
baseStyle: {
dropzoneButton: {
bg: "red",
},
},
},
},
}}
/>
```
<img width="1182" alt="Screenshot 2022-04-13 at 10 21 58" src="https://user-images.githubusercontent.com/5903616/163123694-5b79179e-037e-4f9d-b1a9-6078f758bb7e.png">
Underneath we use Chakra-UI, you can send in a custom theme for us to apply. Read more about themes [here](https://chakra-ui.com/docs/styled-system/theming/theme)
### Changing text (translations)
You can change any text in the flow:
```tsx
<ReactSpreadsheetImport
translations={{
uploadStep: {
title: "Upload Employees",
},
}}
/>
```
You can see all the translation keys [here](https://github.com/UgnisSoftware/react-spreadsheet-import/blob/master/src/translationsRSIProps.ts)
## VS other libraries
Flatfile vs react-spreadsheet-import and Dromo vs react-spreadsheet-import:
| | RSI | Flatfile | Dromo |
| ------------------------------ | -------------- | ----------- | ----------- |
| Licence | MIT | Proprietary | Proprietary |
| Price | Free | Paid | Paid |
| Support | Github Issues | Enterprise | Enterprise |
| Self-host | Yes | Paid | Paid |
| Hosted solution | In development | Yes | Yes |
| On-prem deployment | N/A | Yes | Yes |
| Hooks | Yes | Yes | Yes |
| Automatic header matching | Yes | Yes | Yes |
| Data validation | Yes | Yes | Yes |
| Custom styling | Yes | Yes | Yes |
| Translations | Yes | Yes | Yes |
| Trademarked words `Data Hooks` | No | Yes | No |
React-spreadsheet-import can be used as a free and open-source alternative to Flatfile and Dromo.
## Contributing
Feel free to open issues if you have any questions or notice bugs. If you want different component behaviour, consider forking the project.
## Credits
Created by Ugnis. [Julita Kriauciunaite](https://github.com/JulitorK) and [Karolis Masiulis](https://github.com/masiulis). You can contact us at `info@ugnis.com`

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,174 @@
{
"name": "react-spreadsheet-import",
"version": "4.7.1",
"description": "React spreadsheet import for xlsx and csv files with column matching and validation",
"main": "./dist-commonjs/index.js",
"module": "./dist/index.js",
"types": "./types/index.d.ts",
"files": [
"dist-commonjs",
"dist",
"types"
],
"scripts": {
"start": "storybook dev -p 6006",
"test:unit": "jest",
"test:e2e": "npx playwright test",
"test:chromatic": "npx chromatic ",
"ts": "tsc",
"lint": "eslint \"src/**/*.{ts,tsx}\"",
"lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix",
"prebuild": "npm run clean",
"build": "rollup -c rollup.config.ts",
"build-storybook": "storybook build -o docs-build",
"release:patch": "git checkout master && git pull && npm run test:unit && npm run ts && npm run build && npm version patch && git add -A && git push && git push --tags && npm publish",
"release:minor": "git checkout master && git pull && npm run test:unit && npm run ts && npm run build && npm version minor && git add -A && git push && git push --tags && npm publish",
"release:major": "git checkout master && git pull && npm run test:unit && npm run ts && npm run build && npm version major && git add -A && git push && git push --tags && npm publish",
"clean": "rimraf dist dist-commonjs types"
},
"repository": {
"type": "git",
"url": "git+https://github.com/UgnisSoftware/react-spreadsheet-import.git"
},
"keywords": [
"React",
"spreadsheet",
"import",
"upload",
"csv",
"xlsx",
"validate",
"automatic",
"match"
],
"author": {
"name": "Ugnis"
},
"license": "MIT",
"bugs": {
"url": "https://github.com/UgnisSoftware/react-spreadsheet-import/issues"
},
"homepage": "https://github.com/UgnisSoftware/react-spreadsheet-import#readme",
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"dependencies": {
"@chakra-ui/react": "^2.8.1",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"chakra-react-select": "^4.7.5",
"chakra-ui-steps": "2.0.4",
"framer-motion": "^10.16.4",
"js-levenshtein": "1.1.6",
"lodash": "4.17.21",
"react-data-grid": "7.0.0-beta.13",
"react-dropzone": "14.2.3",
"react-icons": "4.11.0",
"uuid": "^9.0.1",
"xlsx-ugnis": "0.20.3"
},
"devDependencies": {
"@babel/core": "7.23.2",
"@babel/preset-env": "7.23.2",
"@babel/preset-react": "7.22.15",
"@babel/preset-typescript": "7.23.2",
"@emotion/jest": "11.11.0",
"@jest/types": "27.5.1",
"@playwright/test": "^1.39.0",
"@storybook/addon-essentials": "7.5.1",
"@storybook/addon-interactions": "7.5.1",
"@storybook/addon-links": "7.5.1",
"@storybook/blocks": "7.5.1",
"@storybook/cli": "7.5.1",
"@storybook/react": "7.5.1",
"@storybook/react-webpack5": "7.5.1",
"@storybook/testing-library": "^0.0.14-next.2",
"@testing-library/dom": "9.3.0",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "14.0.0",
"@testing-library/user-event": "14.4.3",
"@types/jest": "27.4.1",
"@types/js-levenshtein": "1.1.1",
"@types/node": "^20.8.7",
"@types/react": "18.2.6",
"@types/react-dom": "18.2.4",
"@types/styled-system": "5.1.16",
"@types/uuid": "9.0.1",
"@typescript-eslint/eslint-plugin": "5.59.7",
"@typescript-eslint/parser": "5.59.7",
"babel-loader": "9.1.3",
"chromatic": "^7.4.0",
"eslint": "8.41.0",
"eslint-config-prettier": "8.8.0",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.32.2",
"eslint-plugin-react-hooks": "4.6.0",
"jest": "27.5.1",
"jest-watch-typeahead": "1.0.0",
"lint-staged": "13.2.2",
"prettier": "2.8.8",
"prop-types": "15.8.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-select-event": "5.5.1",
"rollup": "2.70.1",
"rollup-plugin-typescript2": "0.31.2",
"storybook": "7.5.1",
"ts-essentials": "9.3.2",
"ts-jest": "27.1.4",
"ttypescript": "1.5.15",
"typescript": "4.9.5",
"typescript-transform-paths": "3.4.6"
},
"overrides": {
"semver": "^7.5.3"
},
"lint-staged": {
"*.{ts,tsx}": "eslint",
"*.{js,ts,tsx,md,html,css,json}": "prettier --write"
},
"prettier": {
"tabWidth": 2,
"trailingComma": "all",
"semi": false,
"printWidth": 120
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "jsdom",
"moduleFileExtensions": [
"ts",
"tsx",
"js",
"jsx",
"mjs"
],
"transform": {
"^.+\\.(ts|tsx)?$": "ts-jest/dist",
"^.+\\.mjs$": "ts-jest/dist"
},
"moduleNameMapper": {
"~/(.*)": "<rootDir>/src/$1"
},
"modulePathIgnorePatterns": [
"<rootDir>/e2e/"
],
"transformIgnorePatterns": [
"[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs)$"
],
"setupFiles": [
"./src/tests/setup.ts"
],
"globals": {
"ts-jest": {
"tsconfig": "tsconfig.json"
}
},
"watchPlugins": [
"jest-watch-typeahead/filename",
"jest-watch-typeahead/testname"
]
},
"readme": "ERROR: No README data found!"
}

View File

@@ -0,0 +1,41 @@
import merge from "lodash/merge"
import { Steps } from "./steps/Steps"
import { rtlThemeSupport, themeOverrides } from "./theme"
import { Providers } from "./components/Providers"
import type { RsiProps } from "./types"
import { ModalWrapper } from "./components/ModalWrapper"
import { translations } from "./translationsRSIProps"
export const defaultTheme = themeOverrides
export const defaultRSIProps: Partial<RsiProps<any>> = {
autoMapHeaders: true,
autoMapSelectValues: false,
allowInvalidSubmit: true,
autoMapDistance: 2,
isNavigationEnabled: false,
translations: translations,
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 ReactSpreadsheetImport = <T extends string>(propsWithoutDefaults: RsiProps<T>) => {
const props = merge({}, defaultRSIProps, propsWithoutDefaults)
const mergedTranslations =
props.translations !== translations ? merge(translations, props.translations) : translations
const mergedThemes = props.rtl
? merge(defaultTheme, rtlThemeSupport, props.customTheme)
: merge(defaultTheme, props.customTheme)
return (
<Providers theme={mergedThemes} rsiValues={{ ...props, translations: mergedTranslations }}>
<ModalWrapper isOpen={props.isOpen} onClose={props.onClose}>
<Steps />
</ModalWrapper>
</Providers>
)
}

View File

@@ -0,0 +1,41 @@
import {
AlertDialog,
AlertDialogBody,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogContent,
AlertDialogOverlay,
Button,
} from "@chakra-ui/react"
import { useRef } from "react"
import { useRsi } from "../../hooks/useRsi"
interface Props {
isOpen: boolean
onClose: () => void
onConfirm: () => void
}
export const ConfirmCloseAlert = ({ isOpen, onClose, onConfirm }: Props) => {
const { translations } = useRsi()
const cancelRef = useRef<HTMLButtonElement | null>(null)
return (
<AlertDialog isOpen={isOpen} onClose={onClose} leastDestructiveRef={cancelRef} isCentered id="rsi">
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader>{translations.alerts.confirmClose.headerTitle}</AlertDialogHeader>
<AlertDialogBody>{translations.alerts.confirmClose.bodyText}</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose} variant="secondary">
{translations.alerts.confirmClose.cancelButtonTitle}
</Button>
<Button colorScheme="red" onClick={onConfirm} ml={3}>
{translations.alerts.confirmClose.exitButtonTitle}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
)
}

View File

@@ -0,0 +1,49 @@
import {
AlertDialog,
AlertDialogBody,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogContent,
AlertDialogOverlay,
Button,
} from "@chakra-ui/react"
import { useRef } from "react"
import { useRsi } from "../../hooks/useRsi"
interface Props {
isOpen: boolean
onClose: () => void
onConfirm: () => void
}
export const SubmitDataAlert = ({ isOpen, onClose, onConfirm }: Props) => {
const { allowInvalidSubmit, translations } = useRsi()
const cancelRef = useRef<HTMLButtonElement | null>(null)
return (
<AlertDialog isOpen={isOpen} onClose={onClose} leastDestructiveRef={cancelRef} isCentered id="rsi">
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
{translations.alerts.submitIncomplete.headerTitle}
</AlertDialogHeader>
<AlertDialogBody>
{allowInvalidSubmit
? translations.alerts.submitIncomplete.bodyText
: translations.alerts.submitIncomplete.bodyTextSubmitForbidden}
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose} variant="secondary">
{translations.alerts.submitIncomplete.cancelButtonTitle}
</Button>
{allowInvalidSubmit && (
<Button onClick={onConfirm} ml={3}>
{translations.alerts.submitIncomplete.finishButtonTitle}
</Button>
)}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
)
}

View File

@@ -0,0 +1,57 @@
import {
AlertDialog,
AlertDialogBody,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogContent,
AlertDialogOverlay,
Button,
Text,
Box,
} from "@chakra-ui/react"
import { useRef } from "react"
import { useRsi } from "../../hooks/useRsi"
interface Props {
isOpen: boolean
onClose: () => void
onConfirm: () => void
fields: string[]
}
export const UnmatchedFieldsAlert = ({ isOpen, onClose, onConfirm, fields }: Props) => {
const { allowInvalidSubmit, translations } = useRsi()
const cancelRef = useRef<HTMLButtonElement | null>(null)
return (
<AlertDialog isOpen={isOpen} onClose={onClose} leastDestructiveRef={cancelRef} isCentered id="rsi">
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
{translations.alerts.unmatchedRequiredFields.headerTitle}
</AlertDialogHeader>
<AlertDialogBody>
{translations.alerts.unmatchedRequiredFields.bodyText}
<Box pt={3}>
<Text display="inline">{translations.alerts.unmatchedRequiredFields.listTitle}</Text>
<Text display="inline" fontWeight="bold">
{" "}
{fields.join(", ")}
</Text>
</Box>
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose} variant="secondary">
{translations.alerts.unmatchedRequiredFields.cancelButtonTitle}
</Button>
{allowInvalidSubmit && (
<Button onClick={onConfirm} ml={3}>
{translations.alerts.unmatchedRequiredFields.continueButtonTitle}
</Button>
)}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
)
}

View File

@@ -0,0 +1,33 @@
import { Button, ModalFooter, useStyleConfig } from "@chakra-ui/react"
import { themeOverrides } from "../theme"
type ContinueButtonProps = {
onContinue: (val: any) => void
onBack?: () => void
title: string
backTitle?: string
isLoading?: boolean
}
export const ContinueButton = ({ onContinue, onBack, title, backTitle, isLoading }: ContinueButtonProps) => {
const styles = useStyleConfig("Modal") as (typeof themeOverrides)["components"]["Modal"]["baseStyle"]
const nextButtonMobileWidth = onBack ? "8rem" : "100%"
return (
<ModalFooter>
{onBack && (
<Button size="md" sx={styles.backButton} onClick={onBack} isLoading={isLoading} variant="link">
{backTitle}
</Button>
)}
<Button
size="lg"
w={{ base: nextButtonMobileWidth, md: "21rem" }}
sx={styles.continueButton}
onClick={onContinue}
isLoading={isLoading}
>
{title}
</Button>
</ModalFooter>
)
}

View File

@@ -0,0 +1,25 @@
import { Box } from "@chakra-ui/react"
type FadingWrapperProps = {
gridColumn: string
gridRow: string
}
export const FadingWrapper = ({ gridColumn, gridRow }: FadingWrapperProps) => (
<>
<Box
gridColumn={gridColumn}
gridRow={gridRow}
borderRadius="1.2rem"
border="1px solid"
borderColor="border"
pointerEvents="none"
/>
<Box
gridColumn={gridColumn}
gridRow={gridRow}
pointerEvents="none"
bgGradient="linear(to bottom, backgroundAlpha, background)"
/>
</>
)

View File

@@ -0,0 +1,40 @@
import { IconButton, useStyleConfig } from "@chakra-ui/react"
import { CgClose } from "react-icons/cg"
import { ConfirmCloseAlert } from "./Alerts/ConfirmCloseAlert"
import { useState } from "react"
import { themeOverrides } from "../theme"
type ModalCloseButtonProps = {
onClose: () => void
}
export const ModalCloseButton = ({ onClose }: ModalCloseButtonProps) => {
const [showModal, setShowModal] = useState(false)
const styles = useStyleConfig("Modal") as (typeof themeOverrides)["components"]["Modal"]["baseStyle"]
return (
<>
<ConfirmCloseAlert
isOpen={showModal}
onClose={() => setShowModal(false)}
onConfirm={() => {
setShowModal(false)
onClose()
}}
/>
<IconButton
right="14px"
top="20px"
variant="unstyled"
sx={styles.closeModalButton}
aria-label="Close modal"
icon={<CgClose />}
color="white"
position="fixed"
transform="translate(50%, -50%)"
onClick={() => setShowModal(true)}
zIndex="toast"
dir="ltr"
/>
</>
)
}

View File

@@ -0,0 +1,31 @@
import type React from "react"
import { Modal, ModalContent, ModalOverlay } from "@chakra-ui/react"
import { ModalCloseButton } from "./ModalCloseButton"
import { useRsi } from "../hooks/useRsi"
type Props = {
children: React.ReactNode
isOpen: boolean
onClose: () => void
}
export const ModalWrapper = ({ children, isOpen, onClose }: Props) => {
const { rtl } = useRsi()
return (
<Modal
isOpen={isOpen}
onClose={onClose}
id="rsi"
variant="rsi"
closeOnEsc={false}
closeOnOverlayClick={false}
scrollBehavior="inside"
>
<div dir={rtl ? "rtl" : "ltr"}>
<ModalOverlay />
<ModalCloseButton onClose={onClose} />
<ModalContent>{children}</ModalContent>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,33 @@
import { ChakraProvider, extendTheme } from "@chakra-ui/react"
import { createContext } from "react"
import type { RsiProps } from "../types"
import type { CustomTheme } from "../theme"
export const RsiContext = createContext({} as any)
type ProvidersProps<T extends string> = {
children: React.ReactNode
theme: CustomTheme
rsiValues: RsiProps<T>
}
export const rootId = "chakra-modal-rsi"
export const Providers = <T extends string>({ children, theme, rsiValues }: ProvidersProps<T>) => {
const mergedTheme = extendTheme(theme)
if (!rsiValues.fields) {
throw new Error("Fields must be provided to react-spreadsheet-import")
}
return (
<RsiContext.Provider value={rsiValues}>
<ChakraProvider>
{/* cssVarsRoot used to override RSI defaultTheme but not the rest of chakra defaultTheme */}
<ChakraProvider cssVarsRoot={`#${rootId}`} theme={mergedTheme}>
{children}
</ChakraProvider>
</ChakraProvider>
</RsiContext.Provider>
)
}

View File

@@ -0,0 +1,30 @@
import { Select } from "chakra-react-select"
import type { SelectOption } from "../../types"
import { customComponents } from "./MenuPortal"
import { useStyleConfig } from "@chakra-ui/react"
import type { Styles } from "../../steps/MatchColumnsStep/components/ColumnGrid"
interface Props {
onChange: (value: SelectOption | null) => void
value?: SelectOption
options: readonly SelectOption[]
placeholder?: string
name?: string
}
export const MatchColumnSelect = ({ onChange, value, options, placeholder, name }: Props) => {
const styles = useStyleConfig("MatchColumnsStep") as Styles
return (
<Select<SelectOption, false>
value={value || null}
colorScheme="gray"
useBasicStyles
onChange={onChange}
placeholder={placeholder}
options={options}
chakraStyles={styles.select}
menuPosition="fixed"
components={customComponents}
aria-label={name}
/>
)
}

View File

@@ -0,0 +1,75 @@
import React, { useEffect, useLayoutEffect, useState } from "react"
import ReactDOM from "react-dom"
import { Box, useTheme } from "@chakra-ui/react"
import { usePopper } from "@chakra-ui/popper"
import { rootId } from "../Providers"
import { useRsi } from "../../hooks/useRsi"
function createWrapperAndAppendToBody(wrapperId: string) {
const wrapperElement = document.createElement("div")
wrapperElement.setAttribute("id", wrapperId)
document.body.appendChild(wrapperElement)
return wrapperElement
}
export const SELECT_DROPDOWN_ID = "react-select-dropdown-wrapper"
interface PortalProps {
controlElement: HTMLDivElement | null
children: React.ReactNode
}
const MenuPortal = (props: PortalProps) => {
const theme = useTheme()
const { rtl } = useRsi()
const { popperRef, referenceRef } = usePopper({
strategy: "fixed",
matchWidth: true,
})
const [wrapperElement, setWrapperElement] = useState<HTMLElement | null>(null)
useLayoutEffect(() => {
let element = document.getElementById(SELECT_DROPDOWN_ID)
let systemCreated = false
if (!element) {
systemCreated = true
element = createWrapperAndAppendToBody(SELECT_DROPDOWN_ID)
}
setWrapperElement(element)
return () => {
if (systemCreated && element?.parentNode) {
element.parentNode.removeChild(element)
}
}
}, [])
useEffect(() => {
referenceRef(props.controlElement)
}, [props.controlElement, referenceRef])
// wrapperElement state will be null on very first render.
if (wrapperElement === null) return null
return ReactDOM.createPortal(
<Box
dir={rtl ? "rtl" : "ltr"}
ref={popperRef}
zIndex={theme.zIndices.tooltip}
sx={{
"&[data-popper-reference-hidden]": {
visibility: "hidden",
pointerEvents: "none",
},
}}
id={rootId}
>
{props.children}
</Box>,
wrapperElement,
)
}
export const customComponents = {
MenuPortal,
}

View File

@@ -0,0 +1,33 @@
import { rootId } from "../Providers"
import { Select } from "chakra-react-select"
import type { SelectOption } from "../../types"
import { useStyleConfig } from "@chakra-ui/react"
import type { themeOverrides } from "../../theme"
interface Props {
onChange: (value: SelectOption | null) => void
value?: SelectOption
options: readonly SelectOption[]
}
export const TableSelect = ({ onChange, value, options }: Props) => {
const styles = useStyleConfig(
"ValidationStep",
) as (typeof themeOverrides)["components"]["ValidationStep"]["baseStyle"]
return (
<Select<SelectOption, false>
autoFocus
useBasicStyles
size="sm"
value={value}
onChange={onChange}
placeholder=" "
closeMenuOnScroll
menuPosition="fixed"
menuIsOpen
menuPortalTarget={document.getElementById(rootId)}
options={options}
chakraStyles={styles.select}
/>
)
}

View File

@@ -0,0 +1,23 @@
import type { DataGridProps, Column } from "react-data-grid"
import DataGrid from "react-data-grid"
import { useRsi } from "../hooks/useRsi"
export type { Column }
export type Props<TRow> = DataGridProps<TRow> & {
rowHeight?: number
hiddenHeader?: boolean
className?: string
style?: React.CSSProperties
}
export const Table = <TRow,>({ className, ...props }: Props<TRow>) => {
const { rtl } = useRsi()
return (
<DataGrid
className={"rdg-light " + (className || "")}
direction={rtl ? "rtl" : "ltr"}
{...props}
/>
)
}

View File

@@ -0,0 +1,9 @@
import { useContext } from "react"
import { RsiContext } from "../components/Providers"
import type { RsiProps } from "../types"
import type { MarkRequired } from "ts-essentials"
import type { defaultRSIProps } from "../ReactSpreadsheetImport"
import type { Translations } from "../translationsRSIProps"
export const useRsi = <T extends string>() =>
useContext<MarkRequired<RsiProps<T>, keyof typeof defaultRSIProps> & { translations: Translations }>(RsiContext)

View File

@@ -0,0 +1,2 @@
export { StepType } from "./steps/UploadFlow"
export { ReactSpreadsheetImport } from "./ReactSpreadsheetImport"

View File

@@ -0,0 +1,196 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { useToast } from "@chakra-ui/react"
import { UserTableColumn } from "./components/UserTableColumn"
import { useRsi } from "../../hooks/useRsi"
import { TemplateColumn } from "./components/TemplateColumn"
import { ColumnGrid } from "./components/ColumnGrid"
import { setColumn } from "./utils/setColumn"
import { setIgnoreColumn } from "./utils/setIgnoreColumn"
import { setSubColumn } from "./utils/setSubColumn"
import { normalizeTableData } from "./utils/normalizeTableData"
import type { Field, RawData } from "../../types"
import { getMatchedColumns } from "./utils/getMatchedColumns"
import { UnmatchedFieldsAlert } from "../../components/Alerts/UnmatchedFieldsAlert"
import { findUnmatchedRequiredFields } from "./utils/findUnmatchedRequiredFields"
export type MatchColumnsProps<T extends string> = {
data: RawData[]
headerValues: RawData
onContinue: (data: any[], rawData: RawData[], columns: Columns<T>) => void
onBack?: () => 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,
onBack,
}: MatchColumnsProps<T>) => {
const toast = useToast()
const dataExample = data.slice(0, 2)
const { fields, autoMapHeaders, autoMapSelectValues, autoMapDistance, translations } = 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 [showUnmatchedFieldsAlert, setShowUnmatchedFieldsAlert] = useState(false)
const onChange = useCallback(
(value: T, columnIndex: number) => {
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) => {
columnIndex === index ? setColumn(column, field, data) : column
if (columnIndex === index) {
return setColumn(column, field, data, autoMapSelectValues)
} else if (index === existingFieldIndex) {
toast({
status: "warning",
variant: "left-accent",
position: "bottom-left",
title: translations.matchColumnsStep.duplicateColumnWarningTitle,
description: translations.matchColumnsStep.duplicateColumnWarningDescription,
isClosable: true,
})
return setColumn(column)
} else {
return column
}
}),
)
},
[
autoMapSelectValues,
columns,
data,
fields,
toast,
translations.matchColumnsStep.duplicateColumnWarningDescription,
translations.matchColumnsStep.duplicateColumnWarningTitle,
],
)
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 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 handleOnContinue = useCallback(async () => {
if (unmatchedRequiredFields.length > 0) {
setShowUnmatchedFieldsAlert(true)
} else {
setIsLoading(true)
await onContinue(normalizeTableData(columns, data, fields), data, columns)
setIsLoading(false)
}
}, [unmatchedRequiredFields.length, onContinue, columns, data, fields])
const handleAlertOnContinue = useCallback(async () => {
setShowUnmatchedFieldsAlert(false)
setIsLoading(true)
await onContinue(normalizeTableData(columns, data, fields), data, columns)
setIsLoading(false)
}, [onContinue, columns, data, fields])
useEffect(
() => {
if (autoMapHeaders) {
setColumns(getMatchedColumns(columns, fields, data, autoMapDistance, autoMapSelectValues))
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
)
return (
<>
<UnmatchedFieldsAlert
isOpen={showUnmatchedFieldsAlert}
onClose={() => setShowUnmatchedFieldsAlert(false)}
fields={unmatchedRequiredFields}
onConfirm={handleAlertOnContinue}
/>
<ColumnGrid
columns={columns}
onContinue={handleOnContinue}
onBack={onBack}
isLoading={isLoading}
userColumn={(column) => (
<UserTableColumn
column={column}
onIgnore={onIgnore}
onRevertIgnore={onRevertIgnore}
entries={dataExample.map((row) => row[column.index])}
/>
)}
templateColumn={(column) => <TemplateColumn column={column} onChange={onChange} onSubChange={onSubChange} />}
/>
</>
)
}

View File

@@ -0,0 +1,77 @@
import type React from "react"
import type { Column, Columns } from "../MatchColumnsStep"
import { Box, Flex, Heading, ModalBody, Text, useStyleConfig } from "@chakra-ui/react"
import { FadingWrapper } from "../../../components/FadingWrapper"
import { ContinueButton } from "../../../components/ContinueButton"
import { useRsi } from "../../../hooks/useRsi"
import type { themeOverrides } from "../../../theme"
type ColumnGridProps<T extends string> = {
columns: Columns<T>
userColumn: (column: Column<T>) => React.ReactNode
templateColumn: (column: Column<T>) => React.ReactNode
onContinue: (val: Record<string, string>[]) => void
onBack?: () => void
isLoading: boolean
}
export type Styles = (typeof themeOverrides)["components"]["MatchColumnsStep"]["baseStyle"]
export const ColumnGrid = <T extends string>({
columns,
userColumn,
templateColumn,
onContinue,
onBack,
isLoading,
}: ColumnGridProps<T>) => {
const { translations } = useRsi()
const styles = useStyleConfig("MatchColumnsStep") as Styles
return (
<>
<ModalBody flexDir="column" p={8} overflow="auto">
<Heading sx={styles.heading}>{translations.matchColumnsStep.title}</Heading>
<Flex
flex={1}
display="grid"
gridTemplateRows="auto auto auto 1fr"
gridTemplateColumns={`0.75rem repeat(${columns.length}, minmax(18rem, auto)) 0.75rem`}
>
<Box gridColumn={`1/${columns.length + 3}`}>
<Text sx={styles.title}>{translations.matchColumnsStep.userTableTitle}</Text>
</Box>
{columns.map((column, index) => (
<Box gridRow="2/3" gridColumn={`${index + 2}/${index + 3}`} pt={3} key={column.header + index}>
{userColumn(column)}
</Box>
))}
<FadingWrapper gridColumn={`1/${columns.length + 3}`} gridRow="2/3" />
<Box gridColumn={`1/${columns.length + 3}`} mt={7}>
<Text sx={styles.title}>{translations.matchColumnsStep.templateTitle}</Text>
</Box>
<FadingWrapper gridColumn={`1/${columns.length + 3}`} gridRow="4/5" />
{columns.map((column, index) => (
<Box
gridRow="4/5"
gridColumn={`${index + 2}/${index + 3}`}
key={column.header + index}
py="1.125rem"
pl={2}
pr={3}
>
{templateColumn(column)}
</Box>
))}
</Flex>
</ModalBody>
<ContinueButton
isLoading={isLoading}
onContinue={onContinue}
onBack={onBack}
title={translations.matchColumnsStep.nextButtonTitle}
backTitle={translations.matchColumnsStep.backButtonTitle}
/>
</>
)
}

View File

@@ -0,0 +1,42 @@
import { chakra, useStyleConfig, Flex } from "@chakra-ui/react"
import { dataAttr } from "@chakra-ui/utils"
import { motion } from "framer-motion"
import { CgCheck } from "react-icons/cg"
const MotionFlex = motion(Flex)
const animationConfig = {
transition: {
duration: 0.1,
},
exit: { scale: 0.5, opacity: 0 },
initial: { scale: 0.5, opacity: 0 },
animate: { scale: 1, opacity: 1 },
}
type MatchIconProps = {
isChecked: boolean
}
export const MatchIcon = (props: MatchIconProps) => {
const style = useStyleConfig("MatchIcon", props)
return (
<chakra.div
__css={style}
minW={6}
minH={6}
w={6}
h={6}
ml="0.875rem"
mr={3}
data-highlighted={dataAttr(props.isChecked)}
data-testid="column-checkmark"
>
{props.isChecked && (
<MotionFlex {...animationConfig}>
<CgCheck size="24px" />
</MotionFlex>
)}
</chakra.div>
)
}

View File

@@ -0,0 +1,32 @@
import { Box, Text, useStyleConfig } from "@chakra-ui/react"
import { MatchColumnSelect } from "../../../components/Selects/MatchColumnSelect"
import { getFieldOptions } from "../utils/getFieldOptions"
import { useRsi } from "../../../hooks/useRsi"
import type { MatchedOptions, MatchedSelectColumn, MatchedSelectOptionsColumn } from "../MatchColumnsStep"
import type { Styles } from "./ColumnGrid"
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 styles = useStyleConfig("MatchColumnsStep") as Styles
const { translations, fields } = useRsi<T>()
const options = getFieldOptions(fields, column.value)
const value = options.find((opt) => opt.value == option.value)
return (
<Box pl={2} pb="0.375rem">
<Text sx={styles.selectColumn.selectLabel}>{option.entry}</Text>
<MatchColumnSelect
value={value}
placeholder={translations.matchColumnsStep.subSelectPlaceholder}
onChange={(value) => onSubChange(value?.value as T, column.index, option.entry!)}
options={options}
name={option.entry}
/>
</Box>
)
}

View File

@@ -0,0 +1,96 @@
import {
Flex,
Text,
Accordion,
AccordionItem,
AccordionButton,
AccordionIcon,
Box,
AccordionPanel,
useStyleConfig,
} from "@chakra-ui/react"
import { useRsi } from "../../../hooks/useRsi"
import type { Column } from "../MatchColumnsStep"
import { ColumnType } from "../MatchColumnsStep"
import { MatchIcon } from "./MatchIcon"
import type { Fields } from "../../../types"
import type { Translations } from "../../../translationsRSIProps"
import { MatchColumnSelect } from "../../../components/Selects/MatchColumnSelect"
import { SubMatchingSelect } from "./SubMatchingSelect"
import type { Styles } from "./ColumnGrid"
const getAccordionTitle = <T extends string>(fields: Fields<T>, column: Column<T>, translations: Translations) => {
const fieldLabel = fields.find((field) => "value" in column && field.key === column.value)!.label
return `${translations.matchColumnsStep.matchDropdownTitle} ${fieldLabel} (${
"matchedOptions" in column && column.matchedOptions.filter((option) => !option.value).length
} ${translations.matchColumnsStep.unmatched})`
}
type TemplateColumnProps<T extends string> = {
onChange: (val: T, index: number) => void
onSubChange: (val: T, index: number, option: string) => void
column: Column<T>
}
export const TemplateColumn = <T extends string>({ column, onChange, onSubChange }: TemplateColumnProps<T>) => {
const { translations, fields } = useRsi<T>()
const styles = useStyleConfig("MatchColumnsStep") as Styles
const isIgnored = column.type === ColumnType.ignored
const isChecked =
column.type === ColumnType.matched ||
column.type === ColumnType.matchedCheckbox ||
column.type === ColumnType.matchedSelectOptions
const isSelect = "matchedOptions" in column
const selectOptions = fields.map(({ label, key }) => ({ value: key, label }))
const selectValue = selectOptions.find(({ value }) => "value" in column && column.value === value)
return (
<Flex minH={10} w="100%" flexDir="column" justifyContent="center">
{isIgnored ? (
<Text sx={styles.selectColumn.text}>{translations.matchColumnsStep.ignoredColumnText}</Text>
) : (
<>
<Flex alignItems="center" minH={10} w="100%">
<Box flex={1}>
<MatchColumnSelect
placeholder={translations.matchColumnsStep.selectPlaceholder}
value={selectValue}
onChange={(value) => onChange(value?.value as T, column.index)}
options={selectOptions}
name={column.header}
/>
</Box>
<MatchIcon isChecked={isChecked} />
</Flex>
{isSelect && (
<Flex width="100%">
<Accordion allowMultiple width="100%">
<AccordionItem border="none" py={1}>
<AccordionButton
_hover={{ bg: "transparent" }}
_focus={{ boxShadow: "none" }}
px={0}
py={4}
data-testid="accordion-button"
>
<AccordionIcon />
<Box textAlign="left">
<Text sx={styles.selectColumn.accordionLabel}>
{getAccordionTitle<T>(fields, column, translations)}
</Text>
</Box>
</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>
</Flex>
)}
</>
)}
</Flex>
)
}

View File

@@ -0,0 +1,54 @@
import { Box, Flex, IconButton, Text, useStyleConfig } from "@chakra-ui/react"
import { CgClose, CgUndo } from "react-icons/cg"
import type { Column } from "../MatchColumnsStep"
import { ColumnType } from "../MatchColumnsStep"
import { dataAttr } from "@chakra-ui/utils"
import type { Styles } from "./ColumnGrid"
import type { RawData } from "../../../types"
type UserTableColumnProps<T extends string> = {
column: Column<T>
entries: RawData
onIgnore: (index: number) => void
onRevertIgnore: (index: number) => void
}
export const UserTableColumn = <T extends string>(props: UserTableColumnProps<T>) => {
const styles = useStyleConfig("MatchColumnsStep") as Styles
const {
column: { header, index, type },
entries,
onIgnore,
onRevertIgnore,
} = props
const isIgnored = type === ColumnType.ignored
return (
<Box>
<Flex px={6} justifyContent="space-between" alignItems="center" mb={4}>
<Text sx={styles.userTable.header} data-ignored={dataAttr(isIgnored)}>
{header}
</Text>
{type === ColumnType.ignored ? (
<IconButton
aria-label="Ignore column"
icon={<CgUndo />}
onClick={() => onRevertIgnore(index)}
{...styles.userTable.ignoreButton}
/>
) : (
<IconButton
aria-label="Ignore column"
icon={<CgClose />}
onClick={() => onIgnore(index)}
{...styles.userTable.ignoreButton}
/>
)}
</Flex>
{entries.map((entry, index) => (
<Text key={(entry || "") + index} sx={styles.userTable.cell} data-ignored={dataAttr(isIgnored)}>
{entry}
</Text>
))}
</Box>
)
}

View File

@@ -0,0 +1,33 @@
import { defaultTheme } from "../../../ReactSpreadsheetImport"
import { MatchColumnsStep } from "../MatchColumnsStep"
import { Providers } from "../../../components/Providers"
import { mockRsiValues } from "../../../stories/mockRsiValues"
import { ModalWrapper } from "../../../components/ModalWrapper"
export default {
title: "Match Columns Steps",
parameters: {
layout: "fullscreen",
},
}
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 const Basic = () => (
<Providers theme={defaultTheme} rsiValues={mockRsiValues}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<MatchColumnsStep headerValues={mockData[0] as string[]} data={mockData.slice(1)} onContinue={() => {}} />
</ModalWrapper>
</Providers>
)

View File

@@ -0,0 +1,870 @@
import "@testing-library/jest-dom"
import { render, waitFor, screen } from "@testing-library/react"
import { MatchColumnsStep } from "../MatchColumnsStep"
import { defaultTheme, ReactSpreadsheetImport } from "../../../ReactSpreadsheetImport"
import { mockRsiValues } from "../../../stories/mockRsiValues"
import { Providers } from "../../../components/Providers"
import { ModalWrapper } from "../../../components/ModalWrapper"
import userEvent from "@testing-library/user-event"
import type { Fields } from "../../../types"
import selectEvent from "react-select-event"
import { translations } from "../../../translationsRSIProps"
import { SELECT_DROPDOWN_ID } from "../../../components/Selects/MenuPortal"
import { StepType } from "../../UploadFlow"
const fields: Fields<any> = [
{
label: "Name",
key: "name",
fieldType: {
type: "input",
},
example: "Stephanie",
},
{
label: "Mobile Phone",
key: "mobile",
fieldType: {
type: "input",
},
example: "+12323423",
},
{
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 theme={defaultTheme} rsiValues={{ ...mockRsiValues, fields }}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<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 theme={defaultTheme} rsiValues={{ ...mockRsiValues, fields, autoMapHeaders: false }}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<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 theme={defaultTheme} rsiValues={{ ...mockRsiValues, fields, autoMapDistance: 1 }}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<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 = [
{
label: "Name",
key: "name",
alternateMatches: ["first name"],
fieldType: {
type: "input",
},
example: "Stephanie",
},
] as const
const onContinue = jest.fn()
render(
<Providers theme={defaultTheme} rsiValues={{ ...mockRsiValues, fields: alternativeFields }}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<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 select values on mount", async () => {
const header = ["first name", "count", "Email"]
const OPTION_RESULT_ONE = "John"
const OPTION_RESULT_ONE_VALUE = "1"
const OPTION_RESULT_TWO = "Dane"
const OPTION_RESULT_TWO_VALUE = "2"
const OPTION_RESULT_THREE = "Kane"
const data = [
// match by option label
[OPTION_RESULT_ONE, "123", "j@j.com"],
// match by option value
[OPTION_RESULT_TWO_VALUE, "333", "dane@bane.com"],
// do not match
[OPTION_RESULT_THREE, "534", "kane@linch.com"],
]
const options = [
{ label: OPTION_RESULT_ONE, value: OPTION_RESULT_ONE_VALUE },
{ label: OPTION_RESULT_TWO, value: OPTION_RESULT_TWO_VALUE },
]
// finds only names with automatic matching
const result = [{ name: OPTION_RESULT_ONE_VALUE }, { name: OPTION_RESULT_TWO_VALUE }, { name: undefined }]
const alternativeFields = [
{
label: "Name",
key: "name",
alternateMatches: ["first name"],
fieldType: {
type: "select",
options,
},
example: "Stephanie",
},
] as const
const onContinue = jest.fn()
render(
<Providers
theme={defaultTheme}
rsiValues={{ ...mockRsiValues, fields: alternativeFields, autoMapSelectValues: true }}
>
<ModalWrapper isOpen={true} onClose={() => {}}>
<MatchColumnsStep headerValues={header} data={data} onContinue={onContinue} />
</ModalWrapper>
</Providers>,
)
expect(screen.getByText(/1 Unmatched/)).toBeInTheDocument()
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("Does not auto match select values when autoMapSelectValues:false", async () => {
const header = ["first name", "count", "Email"]
const OPTION_RESULT_ONE = "John"
const OPTION_RESULT_ONE_VALUE = "1"
const OPTION_RESULT_TWO = "Dane"
const OPTION_RESULT_TWO_VALUE = "2"
const OPTION_RESULT_THREE = "Kane"
const data = [
// match by option label
[OPTION_RESULT_ONE, "123", "j@j.com"],
// match by option value
[OPTION_RESULT_TWO_VALUE, "333", "dane@bane.com"],
// do not match
[OPTION_RESULT_THREE, "534", "kane@linch.com"],
]
const options = [
{ label: OPTION_RESULT_ONE, value: OPTION_RESULT_ONE_VALUE },
{ label: OPTION_RESULT_TWO, value: OPTION_RESULT_TWO_VALUE },
]
const result = [{ name: undefined }, { name: undefined }, { name: undefined }]
const alternativeFields = [
{
label: "Name",
key: "name",
alternateMatches: ["first name"],
fieldType: {
type: "select",
options,
},
example: "Stephanie",
},
] as const
const onContinue = jest.fn()
render(
<Providers
theme={defaultTheme}
rsiValues={{ ...mockRsiValues, fields: alternativeFields, autoMapSelectValues: false }}
>
<ModalWrapper isOpen={true} onClose={() => {}}>
<MatchColumnsStep headerValues={header} data={data} onContinue={onContinue} />
</ModalWrapper>
</Providers>,
)
expect(screen.getByText(/3 Unmatched/)).toBeInTheDocument()
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 select values on select", async () => {
const header = ["first name", "count", "Email"]
const OPTION_RESULT_ONE = "John"
const OPTION_RESULT_ONE_VALUE = "1"
const OPTION_RESULT_TWO = "Dane"
const OPTION_RESULT_TWO_VALUE = "2"
const OPTION_RESULT_THREE = "Kane"
const data = [
// match by option label
[OPTION_RESULT_ONE, "123", "j@j.com"],
// match by option value
[OPTION_RESULT_TWO_VALUE, "333", "dane@bane.com"],
// do not match
[OPTION_RESULT_THREE, "534", "kane@linch.com"],
]
const options = [
{ label: OPTION_RESULT_ONE, value: OPTION_RESULT_ONE_VALUE },
{ label: OPTION_RESULT_TWO, value: OPTION_RESULT_TWO_VALUE },
]
// finds only names with automatic matching
const result = [{ name: OPTION_RESULT_ONE_VALUE }, { name: OPTION_RESULT_TWO_VALUE }, { name: undefined }]
const alternativeFields = [
{
label: "Name",
key: "name",
fieldType: {
type: "select",
options,
},
example: "Stephanie",
},
] as const
const onContinue = jest.fn()
render(
<Providers
theme={defaultTheme}
rsiValues={{ ...mockRsiValues, fields: alternativeFields, autoMapSelectValues: true }}
>
<ModalWrapper isOpen={true} onClose={() => {}}>
<MatchColumnsStep headerValues={header} data={data} onContinue={onContinue} />
<div id={SELECT_DROPDOWN_ID} />
</ModalWrapper>
</Providers>,
)
await selectEvent.select(screen.getByLabelText(header[0]), alternativeFields[0].label, {
container: document.getElementById(SELECT_DROPDOWN_ID)!,
})
expect(screen.getByText(/1 Unmatched/)).toBeInTheDocument()
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 theme={defaultTheme} rsiValues={{ ...mockRsiValues, fields }}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<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 = [
{
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 theme={defaultTheme} rsiValues={{ ...mockRsiValues, fields }}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<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 theme={defaultTheme} rsiValues={{ ...mockRsiValues, fields }}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<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 theme={defaultTheme} rsiValues={{ ...mockRsiValues, fields }}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<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 theme={defaultTheme} rsiValues={{ ...mockRsiValues, fields }}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<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 theme={defaultTheme} rsiValues={{ ...mockRsiValues, fields }}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<MatchColumnsStep headerValues={header} data={data} onContinue={onContinue} />
<div id={SELECT_DROPDOWN_ID} />
</ModalWrapper>
</Providers>,
)
await selectEvent.select(screen.getByLabelText(header[0]), fields[0].label, {
container: document.getElementById(SELECT_DROPDOWN_ID)!,
})
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 theme={defaultTheme} rsiValues={{ ...mockRsiValues, fields }}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<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()
await selectEvent.select(screen.getByLabelText(header[0]), fields[0].label, {
container: document.getElementById(SELECT_DROPDOWN_ID)!,
})
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 = [
{
label: "Team",
key: "team",
fieldType: {
type: "select",
options: options,
},
},
] as const
const onContinue = jest.fn()
render(
<Providers theme={defaultTheme} rsiValues={{ ...mockRsiValues, fields: enumFields }}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<MatchColumnsStep headerValues={header} data={data} onContinue={onContinue} />
<div id={SELECT_DROPDOWN_ID} />
</ModalWrapper>
</Providers>,
)
expect(screen.queryByTestId("accordion-button")).not.toBeInTheDocument()
await selectEvent.select(screen.getByLabelText(header[0]), enumFields[0].label, {
container: document.getElementById(SELECT_DROPDOWN_ID)!,
})
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: document.getElementById(SELECT_DROPDOWN_ID)!,
})
await selectEvent.select(screen.getByLabelText(data[1][0]), options[1].label, {
container: document.getElementById(SELECT_DROPDOWN_ID)!,
})
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 theme={defaultTheme} rsiValues={{ ...mockRsiValues, fields }}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<MatchColumnsStep headerValues={header} data={data} onContinue={onContinue} />
</ModalWrapper>
</Providers>,
)
const ignoreButton = screen.getAllByLabelText("Ignore column")[0]
expect(screen.queryByText(translations.matchColumnsStep.ignoredColumnText)).not.toBeInTheDocument()
await userEvent.click(ignoreButton)
expect(screen.queryByText(translations.matchColumnsStep.ignoredColumnText)).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 = [
{
label: "Name",
key: "name",
fieldType: {
type: "input",
},
example: "Stephanie",
validations: [
{
rule: "required",
errorMessage: "Hello",
},
],
},
] as const
const onContinue = jest.fn()
render(
<Providers theme={defaultTheme} rsiValues={{ ...mockRsiValues, fields: requiredFields }}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<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(translations.alerts.unmatchedRequiredFields.bodyText)).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 theme={defaultTheme} rsiValues={{ ...mockRsiValues, fields }}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<MatchColumnsStep headerValues={header} data={data} onContinue={onContinue} />
<div id={SELECT_DROPDOWN_ID} />
</ModalWrapper>
</Providers>,
)
await selectEvent.select(screen.getByLabelText(header[0]), fields[0].label, {
container: document.getElementById(SELECT_DROPDOWN_ID)!,
})
await selectEvent.select(screen.getByLabelText(header[1]), fields[0].label, {
container: document.getElementById(SELECT_DROPDOWN_ID)!,
})
const toasts = await screen.queryAllByText(translations.matchColumnsStep.duplicateColumnWarningDescription)
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(
<ReactSpreadsheetImport
{...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(
<ReactSpreadsheetImport
{...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)
return undefined as any
})
const mockValues = {
...mockRsiValues,
fields: mockRsiValues.fields.filter((field) => field.key === "name" || field.key === "age"),
}
render(
<ReactSpreadsheetImport
{...mockValues}
matchColumnsStepHook={matchColumnsStepHook}
initialStepState={{
type: StepType.matchColumns,
data: [
["Josh", "2"],
["Charlie", "3"],
["Lena", "50"],
],
headerValues: ["name", "age"],
}}
/>,
)
const continueButton = screen.getByText(CONTINUE_BUTTON)
await userEvent.click(continueButton)
const errorToast = await screen.findAllByText(ERROR_MESSAGE, undefined, { timeout: 5000 })
expect(errorToast?.[0]).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,26 @@
import lavenstein from "js-levenshtein"
import type { Fields } from "../../../types"
type AutoMatchAccumulator<T> = {
distance: number
value: T
}
export const findMatch = <T extends string>(
header: string,
fields: Fields<T>,
autoMapDistance: number,
): T | undefined => {
const smallestValue = fields.reduce<AutoMatchAccumulator<T>>((acc, field) => {
const distance = Math.min(
...[
lavenstein(field.key, header),
...(field.alternateMatches?.map((alternate) => lavenstein(alternate, header)) || []),
],
)
return distance < acc.distance || acc.distance === undefined
? ({ value: field.key, distance } as AutoMatchAccumulator<T>)
: acc
}, {} as AutoMatchAccumulator<T>)
return smallestValue.distance <= autoMapDistance ? smallestValue.value : undefined
}

View File

@@ -0,0 +1,8 @@
import type { Fields } from "../../../types"
import type { Columns } from "../MatchColumnsStep"
export const findUnmatchedRequiredFields = <T extends string>(fields: Fields<T>, columns: Columns<T>) =>
fields
.filter((field) => field.validations?.some((validation) => validation.rule === "required"))
.filter((field) => columns.findIndex((column) => "value" in column && column.value === field.key) === -1)
.map((field) => field.label) || []

View File

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

View File

@@ -0,0 +1,41 @@
import lavenstein from "js-levenshtein"
import { findMatch } from "./findMatch"
import type { Field, Fields } from "../../../types"
import { setColumn } from "./setColumn"
import type { Column, Columns } from "../MatchColumnsStep"
import type { MatchColumnsProps } from "../MatchColumnsStep"
export const getMatchedColumns = <T extends string>(
columns: Columns<T>,
fields: Fields<T>,
data: MatchColumnsProps<T>["data"],
autoMapDistance: number,
autoMapSelectValues?: boolean,
) =>
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, autoMapSelectValues),
...arr.slice(duplicateIndex + 1),
setColumn(column),
]
: [
...arr.slice(0, duplicateIndex),
setColumn(arr[duplicateIndex]),
...arr.slice(duplicateIndex + 1),
setColumn(column, field, data, autoMapSelectValues),
]
} else {
return [...arr, setColumn(column, field, data, autoMapSelectValues)]
}
} else {
return [...arr, column]
}
}, [])

View File

@@ -0,0 +1,13 @@
const booleanWhitelist: Record<string, boolean> = {
yes: true,
no: false,
true: true,
false: false,
}
export const normalizeCheckboxValue = (value: string | undefined): boolean => {
if (value && value.toLowerCase() in booleanWhitelist) {
return booleanWhitelist[value.toLowerCase()]
}
return false
}

View File

@@ -0,0 +1,42 @@
import type { Columns } from "../MatchColumnsStep"
import { ColumnType } from "../MatchColumnsStep"
import type { Data, Fields, RawData } from "../../../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 ("booleanMatches" in field.fieldType && Object.keys(field.fieldType).length) {
const booleanMatchKey = Object.keys(field.fieldType.booleanMatches || []).find(
(key) => key.toLowerCase() === curr?.toLowerCase(),
)!
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, value }) => entry === curr)
acc[column.value] = matchedOption?.value || undefined
return acc
}
case ColumnType.empty:
case ColumnType.ignored: {
return acc
}
default:
return acc
}
}, {} as Data<T>),
)

View File

@@ -0,0 +1,38 @@
import type { Field } from "../../../types"
import { Column, ColumnType, MatchColumnsProps, MatchedOptions } from "../MatchColumnsStep"
import { uniqueEntries } from "./uniqueEntries"
export const setColumn = <T extends string>(
oldColumn: Column<T>,
field?: Field<T>,
data?: MatchColumnsProps<T>["data"],
autoMapSelectValues?: boolean,
): Column<T> => {
switch (field?.fieldType.type) {
case "select":
const fieldOptions = field.fieldType.options
const uniqueData = uniqueEntries(data || [], oldColumn.index) as MatchedOptions<T>[]
const matchedOptions = autoMapSelectValues
? uniqueData.map((record) => {
const value = fieldOptions.find(
(fieldOption) => fieldOption.value === record.entry || fieldOption.label === record.entry,
)?.value
return value ? ({ ...record, value } as MatchedOptions<T>) : (record as MatchedOptions<T>)
})
: uniqueData
const allMatched = matchedOptions.filter((o) => o.value).length == uniqueData?.length
return {
...oldColumn,
type: allMatched ? ColumnType.matchedSelectOptions : ColumnType.matchedSelect,
value: field.key,
matchedOptions,
}
case "checkbox":
return { index: oldColumn.index, type: ColumnType.matchedCheckbox, value: field.key, header: oldColumn.header }
case "input":
return { index: oldColumn.index, type: ColumnType.matched, value: field.key, header: oldColumn.header }
default:
return { index: oldColumn.index, header: oldColumn.header, type: ColumnType.empty }
}
}

View File

@@ -0,0 +1,7 @@
import { Column, ColumnType } from "../MatchColumnsStep"
export const setIgnoreColumn = <T extends string>({ header, index }: Column<T>): Column<T> => ({
header,
index,
type: ColumnType.ignored,
})

View File

@@ -0,0 +1,14 @@
import { ColumnType, MatchedOptions, MatchedSelectColumn, MatchedSelectOptionsColumn } from "../MatchColumnsStep"
export const setSubColumn = <T>(
oldColumn: MatchedSelectColumn<T> | MatchedSelectOptionsColumn<T>,
entry: string,
value: string,
): MatchedSelectColumn<T> | MatchedSelectOptionsColumn<T> => {
const options = oldColumn.matchedOptions.map((option) => (option.entry === entry ? { ...option, value } : option))
const allMathced = options.every(({ value }) => !!value)
if (allMathced) {
return { ...oldColumn, matchedOptions: options as MatchedOptions<T>[], type: ColumnType.matchedSelectOptions }
} else {
return { ...oldColumn, matchedOptions: options as MatchedOptions<T>[], type: ColumnType.matchedSelect }
}
}

View File

@@ -0,0 +1,11 @@
import uniqBy from "lodash/uniqBy"
import type { MatchColumnsProps, MatchedOptions } from "../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)

View File

@@ -0,0 +1,47 @@
import { useCallback, useState } from "react"
import { Heading, ModalBody, useStyleConfig, Box } from "@chakra-ui/react"
import { SelectHeaderTable } from "./components/SelectHeaderTable"
import { ContinueButton } from "../../components/ContinueButton"
import { useRsi } from "../../hooks/useRsi"
import type { themeOverrides } from "../../theme"
import type { RawData } from "../../types"
type SelectHeaderProps = {
data: RawData[]
onContinue: (headerValues: RawData, data: RawData[]) => Promise<void>
onBack?: () => void
}
export const SelectHeaderStep = ({ data, onContinue, onBack }: SelectHeaderProps) => {
const styles = useStyleConfig(
"SelectHeaderStep",
) as (typeof themeOverrides)["components"]["SelectHeaderStep"]["baseStyle"]
const { translations } = useRsi()
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 (
<>
<ModalBody pb={0}>
<Heading {...styles.heading}>{translations.selectHeaderStep.title}</Heading>
<SelectHeaderTable data={data} selectedRows={selectedRows} setSelectedRows={setSelectedRows} />
</ModalBody>
<ContinueButton
onContinue={handleContinue}
onBack={onBack}
title={translations.selectHeaderStep.nextButtonTitle}
backTitle={translations.selectHeaderStep.backButtonTitle}
isLoading={isLoading}
/>
</>
)
}

View File

@@ -0,0 +1,59 @@
import { useMemo } from "react"
import { Table, type Column } from "../../../components/Table"
import { generateSelectionColumns } from "./columns"
import type { RawData } from "../../../types"
import { Box, Text } from "@chakra-ui/react"
interface Props {
data: RawData[]
selectedRows: ReadonlySet<number>
setSelectedRows: (rows: ReadonlySet<number>) => void
}
type TableRow = string[];
export const SelectHeaderTable = ({ data, selectedRows, setSelectedRows }: Props) => {
const columns = useMemo(() => generateSelectionColumns(data), [data])
if (!data || data.length === 0) {
return (
<Box p={4}>
<Text>No data available to select headers from.</Text>
</Box>
);
}
return (
<Box position="relative" height="400px" border="1px solid" borderColor="gray.200" borderRadius="md">
<Text mb={2} fontSize="sm" color="gray.600" p={2}>
Select the row that contains your column headers
</Text>
<Box position="absolute" top="40px" bottom={0} left={0} right={0}>
<Table<TableRow>
rows={data as TableRow[]}
columns={columns as Column<TableRow>[]}
selectedRows={selectedRows}
rowKeyGetter={(row: TableRow) => data.indexOf(row)}
onSelectedRowsChange={(newRows: Set<number>) => {
console.log('Row selection changed:', newRows);
// allow selecting only one row
newRows.forEach((value: number) => {
if (!selectedRows.has(value)) {
setSelectedRows(new Set([value]))
return
}
})
}}
onRowClick={(row: TableRow) => {
console.log('Row clicked:', row);
setSelectedRows(new Set([data.indexOf(row)]))
}}
className="rdg-static"
rowHeight={45}
headerRowHeight={45}
style={{ height: '100%' }}
/>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,56 @@
import DataGrid, { Column, FormatterProps, useRowSelection } from "react-data-grid"
import { Radio, Box } from "@chakra-ui/react"
import type { RawData } from "../../../types"
const SELECT_COLUMN_KEY = "select-row"
function SelectFormatter(props: FormatterProps<unknown>) {
const [isRowSelected, onRowSelectionChange] = useRowSelection()
return (
<Box display="flex" alignItems="center" height="100%" pl={2}>
<Radio
bg="white"
aria-label="Select as header row"
isChecked={isRowSelected}
onChange={(event) => {
onRowSelectionChange({
row: props.row,
checked: Boolean(event.target.checked),
isShiftClick: (event.nativeEvent as MouseEvent).shiftKey,
})
}}
/>
</Box>
)
}
export const SelectColumn: Column<any, any> = {
key: SELECT_COLUMN_KEY,
name: "Select Header",
width: 100,
minWidth: 100,
maxWidth: 100,
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: `Column ${index + 1}`,
width: 150,
formatter: ({ row }) => (
<Box p={2} overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap">
{row[index]}
</Box>
),
})),
]
}

View File

@@ -0,0 +1,19 @@
import { headerSelectionTableFields, mockRsiValues } from "../../../stories/mockRsiValues"
import { SelectHeaderStep } from "../SelectHeaderStep"
import { Providers } from "../../../components/Providers"
import { ModalWrapper } from "../../../components/ModalWrapper"
import { defaultTheme } from "../../../ReactSpreadsheetImport"
export default {
title: "Select Header Step",
parameters: {
layout: "fullscreen",
},
}
export const Basic = () => (
<Providers theme={defaultTheme} rsiValues={mockRsiValues}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<SelectHeaderStep data={headerSelectionTableFields} onContinue={async () => {}} />
</ModalWrapper>
</Providers>
)

View File

@@ -0,0 +1,217 @@
import "@testing-library/jest-dom"
import { render, waitFor, screen, fireEvent } from "@testing-library/react"
import { SelectHeaderStep } from "../SelectHeaderStep"
import { defaultTheme, ReactSpreadsheetImport } from "../../../ReactSpreadsheetImport"
import { mockRsiValues } from "../../../stories/mockRsiValues"
import { Providers } from "../../../components/Providers"
import { ModalWrapper } from "../../../components/ModalWrapper"
import userEvent from "@testing-library/user-event"
import { readFileSync } from "fs"
import { StepType } from "../../UploadFlow"
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()
const onBack = jest.fn()
render(
<Providers theme={defaultTheme} rsiValues={mockRsiValues}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<SelectHeaderStep data={data} onContinue={onContinue} onBack={onBack} />
</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(<ReactSpreadsheetImport {...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(
<ReactSpreadsheetImport
{...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)
return undefined as any
})
render(
<ReactSpreadsheetImport
{...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(<ReactSpreadsheetImport {...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(<ReactSpreadsheetImport {...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(<ReactSpreadsheetImport {...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(<ReactSpreadsheetImport {...mockRsiValues} selectHeaderStepHook={selectHeaderStepHook} />)
const uploader = screen.getByTestId("rsi-dropzone")
const data = readFileSync(__dirname + "/../../../../static/TrailingCellsWorkbook.xlsx")
fireEvent.drop(uploader, {
target: {
files: [
new File([data], "testFile.xlsx", {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
}),
],
},
})
const trailingCell = await screen.findByText(TRAILING_CELL, undefined, { timeout: 10000 })
expect(trailingCell).toBeInTheDocument()
const nextButton = screen.getByRole("button", {
name: "Next",
})
await userEvent.click(nextButton)
const trailingCellNextPage = await screen.findByText(TRAILING_CELL, undefined, { timeout: 10000 })
expect(trailingCellNextPage).not.toBeInTheDocument()
},
)
})

View File

@@ -0,0 +1,52 @@
import { Heading, ModalBody, Radio, RadioGroup, Stack, useStyleConfig, Text } from "@chakra-ui/react"
import { useCallback, useState } from "react"
import { ContinueButton } from "../../components/ContinueButton"
import { useRsi } from "../../hooks/useRsi"
import type { themeOverrides } from "../../theme"
type SelectSheetProps = {
sheetNames: string[]
onContinue: (sheetName: string) => Promise<void>
onBack?: () => void
}
export const SelectSheetStep = ({ sheetNames, onContinue, onBack }: SelectSheetProps) => {
const [isLoading, setIsLoading] = useState(false)
const { translations } = useRsi()
const [value, setValue] = useState(sheetNames[0])
const styles = useStyleConfig(
"SelectSheetStep",
) as (typeof themeOverrides)["components"]["SelectSheetStep"]["baseStyle"]
const handleOnContinue = useCallback(
async (data: typeof value) => {
setIsLoading(true)
await onContinue(data)
setIsLoading(false)
},
[onContinue],
)
return (
<>
<ModalBody alignItems="center" justifyContent="center" p={8} flex={1}>
<Heading {...styles.heading}>{translations.uploadStep.selectSheet.title}</Heading>
<RadioGroup onChange={(value) => setValue(value)} value={value}>
<Stack spacing={8}>
{sheetNames.map((sheetName) => (
<Radio value={sheetName} key={sheetName} {...styles.radio}>
<Text {...styles.radioLabel}>{sheetName}</Text>
</Radio>
))}
</Stack>
</RadioGroup>
</ModalBody>
<ContinueButton
isLoading={isLoading}
onContinue={() => handleOnContinue(value)}
onBack={onBack}
title={translations.uploadStep.selectSheet.nextButtonTitle}
backTitle={translations.uploadStep.selectSheet.backButtonTitle}
/>
</>
)
}

View File

@@ -0,0 +1,22 @@
import { defaultTheme } from "../../../ReactSpreadsheetImport"
import { SelectSheetStep } from "../SelectSheetStep"
import { mockRsiValues } from "../../../stories/mockRsiValues"
import { Providers } from "../../../components/Providers"
import { ModalWrapper } from "../../../components/ModalWrapper"
export default {
title: "Select Sheet Step",
parameters: {
layout: "fullscreen",
},
}
const sheetNames = ["Sheet1", "Sheet2", "Sheet3"]
export const Basic = () => (
<Providers theme={defaultTheme} rsiValues={mockRsiValues}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<SelectSheetStep sheetNames={sheetNames} onContinue={async () => {}} />
</ModalWrapper>
</Providers>
)

View File

@@ -0,0 +1,118 @@
import "@testing-library/jest-dom"
import { render, waitFor, screen, fireEvent, act } from "@testing-library/react"
import { SelectSheetStep } from "../SelectSheetStep"
import { defaultTheme, ReactSpreadsheetImport } from "../../../ReactSpreadsheetImport"
import { mockRsiValues } from "../../../stories/mockRsiValues"
import { Providers } from "../../../components/Providers"
import { ModalWrapper } from "../../../components/ModalWrapper"
import userEvent from "@testing-library/user-event"
import { readFileSync } from "fs"
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(<ReactSpreadsheetImport {...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(<ReactSpreadsheetImport {...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 theme={defaultTheme} rsiValues={mockRsiValues}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<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)
return undefined as any
})
render(<ReactSpreadsheetImport {...mockRsiValues} uploadStepHook={uploadStepHook} />)
const uploader = screen.getByTestId("rsi-dropzone")
const data = readFileSync(__dirname + "/../../../../static/Workbook1.xlsx")
fireEvent.drop(uploader, {
target: {
files: [
new File([data], "testFile.xlsx", {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
}),
],
},
})
const nextButton = await screen.findByRole(
"button",
{
name: "Next",
},
{ timeout: 5000 },
)
await userEvent.click(nextButton)
const errorToast = await screen.findAllByText(ERROR_MESSAGE, undefined, { timeout: 5000 })
expect(errorToast?.[0]).toBeInTheDocument()
})

View File

@@ -0,0 +1,62 @@
import { StepState, StepType, UploadFlow } from "./UploadFlow"
import { ModalHeader } from "@chakra-ui/react"
import { useSteps, Step, Steps as Stepper } from "chakra-ui-steps"
import { CgCheck } from "react-icons/cg"
import { useRsi } from "../hooks/useRsi"
import { useRef, useState } from "react"
import { steps, stepTypeToStepIndex, stepIndexToStepType } from "../utils/steps"
const CheckIcon = ({ color }: { color: string }) => <CgCheck size="36px" color={color} />
export const Steps = () => {
const { initialStepState, translations, isNavigationEnabled } = useRsi()
const initialStep = stepTypeToStepIndex(initialStepState?.type)
const { nextStep, activeStep, setStep } = useSteps({
initialStep,
})
const [state, setState] = useState<StepState>(initialStepState || { type: StepType.upload })
const history = useRef<StepState[]>([])
const onClickStep = (stepIndex: number) => {
const type = stepIndexToStepType(stepIndex)
const historyIdx = history.current.findIndex((v) => v.type === type)
if (historyIdx === -1) return
const nextHistory = history.current.slice(0, historyIdx + 1)
history.current = nextHistory
setState(nextHistory[nextHistory.length - 1])
setStep(stepIndex)
}
const onBack = () => {
onClickStep(Math.max(activeStep - 1, 0))
}
const onNext = (v: StepState) => {
history.current.push(state)
setState(v)
v.type !== StepType.selectSheet && nextStep()
}
return (
<>
<ModalHeader display={["none", "none", "block"]}>
<Stepper
activeStep={activeStep}
checkIcon={CheckIcon}
onClickStep={isNavigationEnabled ? onClickStep : undefined}
responsive={false}
>
{steps.map((key) => (
<Step label={translations[key].title} key={key} />
))}
</Stepper>
</ModalHeader>
<UploadFlow state={state} onNext={onNext} onBack={isNavigationEnabled ? onBack : undefined} />
</>
)
}

View File

@@ -0,0 +1,170 @@
import { useCallback, useState } from "react"
import { Progress, useToast } from "@chakra-ui/react"
import type XLSX from "xlsx-ugnis"
import { UploadStep } from "./UploadStep/UploadStep"
import { SelectHeaderStep } from "./SelectHeaderStep/SelectHeaderStep"
import { SelectSheetStep } from "./SelectSheetStep/SelectSheetStep"
import { mapWorkbook } from "../utils/mapWorkbook"
import { ValidationStep } from "./ValidationStep/ValidationStep"
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
import { MatchColumnsStep } from "./MatchColumnsStep/MatchColumnsStep"
import { exceedsMaxRecords } from "../utils/exceedsMaxRecords"
import { useRsi } from "../hooks/useRsi"
import type { RawData } from "../types"
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 {
state: StepState
onNext: (v: StepState) => void
onBack?: () => void
}
export const UploadFlow = ({ state, onNext, onBack }: Props) => {
const {
maxRecords,
translations,
uploadStepHook,
selectHeaderStepHook,
matchColumnsStepHook,
fields,
rowHook,
tableHook,
} = useRsi()
const [uploadedFile, setUploadedFile] = useState<File | null>(null)
const toast = useToast()
const errorToast = useCallback(
(description: string) => {
toast({
status: "error",
variant: "left-accent",
position: "bottom-left",
title: `${translations.alerts.toast.error}`,
description,
isClosable: true,
})
},
[toast, translations],
)
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(translations.uploadStep.maxRecordsExceeded(maxRecords.toString()))
return
}
try {
const mappedWorkbook = await uploadStepHook(mapWorkbook(workbook))
onNext({
type: StepType.selectHeader,
data: mappedWorkbook,
})
} catch (e) {
errorToast((e as Error).message)
}
} else {
onNext({ type: StepType.selectSheet, workbook })
}
}}
/>
)
case StepType.selectSheet:
return (
<SelectSheetStep
sheetNames={state.workbook.SheetNames}
onContinue={async (sheetName) => {
if (maxRecords && exceedsMaxRecords(state.workbook.Sheets[sheetName], maxRecords)) {
errorToast(translations.uploadStep.maxRecordsExceeded(maxRecords.toString()))
return
}
try {
const mappedWorkbook = await uploadStepHook(mapWorkbook(state.workbook, sheetName))
onNext({
type: StepType.selectHeader,
data: mappedWorkbook,
})
} catch (e) {
errorToast((e as Error).message)
}
}}
onBack={onBack}
/>
)
case StepType.selectHeader:
return (
<SelectHeaderStep
data={state.data}
onContinue={async (...args) => {
try {
const { data, headerValues } = await selectHeaderStepHook(...args)
onNext({
type: StepType.matchColumns,
data,
headerValues,
})
} catch (e) {
errorToast((e as Error).message)
}
}}
onBack={onBack}
/>
)
case StepType.matchColumns:
return (
<MatchColumnsStep
data={state.data}
headerValues={state.headerValues}
onContinue={async (values, rawData, columns) => {
try {
const data = await matchColumnsStepHook(values, rawData, columns)
const dataWithMeta = await addErrorsAndRunHooks(data, fields, rowHook, tableHook)
onNext({
type: StepType.validateData,
data: dataWithMeta,
})
} catch (e) {
errorToast((e as Error).message)
}
}}
onBack={onBack}
/>
)
case StepType.validateData:
return <ValidationStep initialData={state.data} file={uploadedFile!} onBack={onBack} />
default:
return <Progress isIndeterminate />
}
}

View File

@@ -0,0 +1,38 @@
import type XLSX from "xlsx-ugnis"
import { Box, Heading, ModalBody, Text, useStyleConfig } from "@chakra-ui/react"
import { DropZone } from "./components/DropZone"
import { useRsi } from "../../hooks/useRsi"
import { ExampleTable } from "./components/ExampleTable"
import { useCallback, useState } from "react"
import { FadingOverlay } from "./components/FadingOverlay"
import type { themeOverrides } from "../../theme"
type UploadProps = {
onContinue: (data: XLSX.WorkBook, file: File) => Promise<void>
}
export const UploadStep = ({ onContinue }: UploadProps) => {
const [isLoading, setIsLoading] = useState(false)
const styles = useStyleConfig("UploadStep") as (typeof themeOverrides)["components"]["UploadStep"]["baseStyle"]
const { translations, fields } = useRsi()
const handleOnContinue = useCallback(
async (data: XLSX.WorkBook, file: File) => {
setIsLoading(true)
await onContinue(data, file)
setIsLoading(false)
},
[onContinue],
)
return (
<ModalBody>
<Heading sx={styles.heading}>{translations.uploadStep.title}</Heading>
<Text sx={styles.title}>{translations.uploadStep.manifestTitle}</Text>
<Text sx={styles.subtitle}>{translations.uploadStep.manifestDescription}</Text>
<Box sx={styles.tableWrapper}>
<ExampleTable fields={fields} />
<FadingOverlay />
</Box>
<DropZone onContinue={handleOnContinue} isLoading={isLoading} />
</ModalBody>
)
}

View File

@@ -0,0 +1,87 @@
import { Box, Button, Text, useStyleConfig, useToast } from "@chakra-ui/react"
import { useDropzone } from "react-dropzone"
import * as XLSX from "xlsx"
import { useState } from "react"
import { getDropZoneBorder } from "../utils/getDropZoneBorder"
import { useRsi } from "../../../hooks/useRsi"
import { readFileAsync } from "../utils/readFilesAsync"
import type { themeOverrides } from "../../../theme"
import type { AcceptedFile } from "../../../types"
type DropZoneProps = {
onContinue: (data: XLSX.WorkBook, file: File) => void
isLoading: boolean
}
export const DropZone = ({ onContinue, isLoading }: DropZoneProps) => {
const { translations, maxFileSize, dateFormat, parseRaw } = useRsi()
const styles = useStyleConfig("UploadStep") as (typeof themeOverrides)["components"]["UploadStep"]["baseStyle"]
const toast = useToast()
const [loading, setLoading] = useState(false)
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) => {
toast({
status: "error",
variant: "left-accent",
position: "bottom-left",
title: `${fileRejection.file.name} ${translations.uploadStep.dropzone.errorToastDescription}`,
description: fileRejection.errors[0].message,
isClosable: true,
})
})
},
onDropAccepted: async ([file]) => {
setLoading(true)
const arrayBuffer = await readFileAsync(file)
const workbook = XLSX.read(arrayBuffer, {
cellDates: true,
dateNF: dateFormat,
raw: parseRaw,
dense: true,
type: 'array',
codepage: 65001, // UTF-8
WTF: false // Don't throw on errors
})
setLoading(false)
onContinue(workbook, file)
},
})
return (
<Box
{...getRootProps()}
{...getDropZoneBorder(styles.dropZoneBorder)}
width="100%"
display="flex"
justifyContent="center"
alignItems="center"
flexDirection="column"
flex={1}
>
<input {...getInputProps()} data-testid="rsi-dropzone" />
{isDragActive ? (
<Text sx={styles.dropzoneText}>{translations.uploadStep.dropzone.activeDropzoneTitle}</Text>
) : loading || isLoading ? (
<Text sx={styles.dropzoneText}>{translations.uploadStep.dropzone.loadingTitle}</Text>
) : (
<>
<Text sx={styles.dropzoneText}>{translations.uploadStep.dropzone.title}</Text>
<Button sx={styles.dropzoneButton} onClick={open}>
{translations.uploadStep.dropzone.buttonTitle}
</Button>
</>
)}
</Box>
)
}

View File

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

View File

@@ -0,0 +1,13 @@
import { Box } from "@chakra-ui/react"
export const FadingOverlay = () => (
<Box
position="absolute"
left={0}
right={0}
bottom={0}
height="48px"
pointerEvents="none"
bgGradient="linear(to bottom, backgroundAlpha, background)"
/>
)

View File

@@ -0,0 +1,32 @@
import type { Column } from "react-data-grid"
import { Box, Tooltip } from "@chakra-ui/react"
import type { Fields } from "../../../types"
import { CgInfo } from "react-icons/cg"
export const generateColumns = <T extends string>(fields: Fields<T>) =>
fields.map(
(column): Column<any> => ({
key: column.key,
name: column.label,
minWidth: 150,
headerRenderer: () => (
<Box display="flex" gap={1} alignItems="center" position="relative">
<Box flex={1} overflow="hidden" textOverflow="ellipsis">
{column.label}
</Box>
{column.description && (
<Tooltip placement="top" hasArrow label={column.description}>
<Box flex={"0 0 auto"}>
<CgInfo size="16px" />
</Box>
</Tooltip>
)}
</Box>
),
formatter: ({ row }) => (
<Box minWidth="100%" minHeight="100%" overflow="hidden" textOverflow="ellipsis">
{row[column.key]}
</Box>
),
}),
)

View File

@@ -0,0 +1,22 @@
import { UploadStep } from "../UploadStep"
import { defaultTheme } from "../../../ReactSpreadsheetImport"
import { mockRsiValues } from "../../../stories/mockRsiValues"
import { Providers } from "../../../components/Providers"
import { ModalWrapper } from "../../../components/ModalWrapper"
export default {
title: "Upload Step",
parameters: {
layout: "fullscreen",
},
}
export const Basic = () => {
return (
<Providers theme={defaultTheme} rsiValues={mockRsiValues}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<UploadStep onContinue={async () => {}} />
</ModalWrapper>
</Providers>
)
}

View File

@@ -0,0 +1,86 @@
import "@testing-library/jest-dom"
import { render, fireEvent, waitFor, screen } from "@testing-library/react"
import { UploadStep } from "../UploadStep"
import { defaultTheme, ReactSpreadsheetImport } from "../../../ReactSpreadsheetImport"
import { mockRsiValues } from "../../../stories/mockRsiValues"
import { Providers } from "../../../components/Providers"
import { ModalWrapper } from "../../../components/ModalWrapper"
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 theme={defaultTheme} rsiValues={mockRsiValues}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<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(<ReactSpreadsheetImport {...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(<ReactSpreadsheetImport {...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)
return undefined as any
})
render(<ReactSpreadsheetImport {...mockRsiValues} uploadStepHook={uploadStepHook} />)
const uploader = screen.getByTestId("rsi-dropzone")
fireEvent.drop(uploader, {
target: { files: [file] },
})
const errorToast = await screen.findAllByText(ERROR_MESSAGE, undefined, { timeout: 5000 })
expect(errorToast?.[0]).toBeInTheDocument()
})

View File

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

View File

@@ -0,0 +1,9 @@
export const getDropZoneBorder = (color: string) => {
return {
bgGradient: `repeating-linear(0deg, ${color}, ${color} 10px, transparent 10px, transparent 20px, ${color} 20px), repeating-linear-gradient(90deg, ${color}, ${color} 10px, transparent 10px, transparent 20px, ${color} 20px), repeating-linear-gradient(180deg, ${color}, ${color} 10px, transparent 10px, transparent 20px, ${color} 20px), repeating-linear-gradient(270deg, ${color}, ${color} 10px, transparent 10px, transparent 20px, ${color} 20px)`,
backgroundSize: "2px 100%, 100% 2px, 2px 100% , 100% 2px",
backgroundPosition: "0 0, 0 0, 100% 0, 0 100%",
backgroundRepeat: "no-repeat",
borderRadius: "4px",
}
}

View File

@@ -0,0 +1,13 @@
export const readFileAsync = (file: File) => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
resolve(reader.result)
}
reader.onerror = reject
reader.readAsArrayBuffer(file)
})
}

View File

@@ -0,0 +1,187 @@
import { useCallback, useMemo, useState } from "react"
import { Box, Button, Heading, ModalBody, Switch, useStyleConfig, useToast } from "@chakra-ui/react"
import { ContinueButton } from "../../components/ContinueButton"
import { useRsi } from "../../hooks/useRsi"
import type { Meta } from "./types"
import { addErrorsAndRunHooks } from "./utils/dataMutations"
import { generateColumns } from "./components/columns"
import { Table } from "../../components/Table"
import { SubmitDataAlert } from "../../components/Alerts/SubmitDataAlert"
import type { Data } from "../../types"
import type { themeOverrides } from "../../theme"
import type { RowsChangeData } from "react-data-grid"
type Props<T extends string> = {
initialData: (Data<T> & Meta)[]
file: File
onBack?: () => void
}
export const ValidationStep = <T extends string>({ initialData, file, onBack }: Props<T>) => {
const { translations, fields, onClose, onSubmit, rowHook, tableHook } = useRsi<T>()
const styles = useStyleConfig(
"ValidationStep",
) as (typeof themeOverrides)["components"]["ValidationStep"]["baseStyle"]
const toast = useToast()
const [data, setData] = useState<(Data<T> & Meta)[]>(initialData)
const [selectedRows, setSelectedRows] = useState<ReadonlySet<number | string>>(new Set())
const [filterByErrors, setFilterByErrors] = useState(false)
const [showSubmitAlert, setShowSubmitAlert] = useState(false)
const [isSubmitting, setSubmitting] = useState(false)
const updateData = useCallback(
async (rows: typeof data, indexes?: number[]) => {
// Check if hooks are async - if they are we want to apply changes optimistically for better UX
if (rowHook?.constructor.name === "AsyncFunction" || tableHook?.constructor.name === "AsyncFunction") {
setData(rows)
}
addErrorsAndRunHooks<T>(rows, fields, rowHook, tableHook, indexes).then((data) => setData(data))
},
[rowHook, tableHook, fields],
)
const deleteSelectedRows = () => {
if (selectedRows.size) {
const newData = data.filter((value) => !selectedRows.has(value.__index))
updateData(newData)
setSelectedRows(new Set())
}
}
const updateRows = 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 realIndexes = changes && Object.keys(changes).map((index) => Number(index))
const newData = Object.assign([], data, changes)
updateData(newData, realIndexes)
},
[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 },
)
setShowSubmitAlert(false)
setSubmitting(true)
const response = onSubmit(calculatedData, file)
if (response?.then) {
response
.then(() => {
onClose()
})
.catch((err: Error) => {
toast({
status: "error",
variant: "left-accent",
position: "bottom-left",
title: `${translations.alerts.submitError.title}`,
description: err?.message || `${translations.alerts.submitError.defaultMessage}`,
isClosable: true,
})
})
.finally(() => {
setSubmitting(false)
})
} else {
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 {
setShowSubmitAlert(true)
}
}
return (
<>
<SubmitDataAlert isOpen={showSubmitAlert} onClose={() => setShowSubmitAlert(false)} onConfirm={submitData} />
<ModalBody pb={0}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb="2rem" flexWrap="wrap" gap="8px">
<Heading sx={styles.heading}>{translations.validationStep.title}</Heading>
<Box display="flex" gap="16px" alignItems="center" flexWrap="wrap">
<Button variant="outline" size="sm" onClick={deleteSelectedRows}>
{translations.validationStep.discardButtonTitle}
</Button>
<Switch
display="flex"
alignItems="center"
isChecked={filterByErrors}
onChange={() => setFilterByErrors(!filterByErrors)}
>
{translations.validationStep.filterSwitchTitle}
</Switch>
</Box>
</Box>
<Table
rowKeyGetter={rowKeyGetter}
rows={tableData}
onRowsChange={updateRows}
columns={columns}
selectedRows={selectedRows}
onSelectedRowsChange={setSelectedRows}
components={{
noRowsFallback: (
<Box display="flex" justifyContent="center" gridColumn="1/-1" mt="32px">
{filterByErrors
? translations.validationStep.noRowsMessageWhenFiltered
: translations.validationStep.noRowsMessage}
</Box>
),
}}
/>
</ModalBody>
<ContinueButton
isLoading={isSubmitting}
onContinue={onContinue}
onBack={onBack}
title={translations.validationStep.nextButtonTitle}
backTitle={translations.validationStep.backButtonTitle}
/>
</>
)
}

View File

@@ -0,0 +1,167 @@
import DataGrid, { Column, useRowSelection } from "react-data-grid"
import { Box, Checkbox, Input, Switch, Tooltip } from "@chakra-ui/react"
import type { Data, Fields } from "../../../types"
import type { ChangeEvent } from "react"
import type { Meta } from "../types"
import { CgInfo } from "react-icons/cg"
import { TableSelect } from "../../../components/Selects/TableSelect"
const SELECT_COLUMN_KEY = "select-row"
function autoFocusAndSelect(input: HTMLInputElement | null) {
input?.focus()
input?.select()
}
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,
cellClass: "rdg-checkbox",
formatter: (props) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [isRowSelected, onRowSelectionChange] = useRowSelection()
return (
<Checkbox
bg="white"
aria-label="Select"
isChecked={isRowSelected}
onChange={(event) => {
onRowSelectionChange({
row: props.row,
checked: Boolean(event.target.checked),
isShiftClick: (event.nativeEvent as MouseEvent).shiftKey,
})
}}
/>
)
},
},
...fields.map(
(column): Column<Data<T> & Meta> => ({
key: column.key,
name: column.label,
minWidth: 150,
resizable: true,
headerRenderer: () => (
<Box display="flex" gap={1} alignItems="center" position="relative">
<Box flex={1} overflow="hidden" textOverflow="ellipsis">
{column.label}
</Box>
{column.description && (
<Tooltip placement="top" hasArrow label={column.description}>
<Box flex={"0 0 auto"}>
<CgInfo size="16px" />
</Box>
</Tooltip>
)}
</Box>
),
editable: column.fieldType.type !== "checkbox",
editor: ({ row, onRowChange, onClose }) => {
let component
switch (column.fieldType.type) {
case "select":
component = (
<TableSelect
value={column.fieldType.options.find((option) => option.value === (row[column.key] as string))}
onChange={(value) => {
onRowChange({ ...row, [column.key]: value?.value }, true)
}}
options={column.fieldType.options}
/>
)
break
default:
component = (
<Box paddingInlineStart="0.5rem">
<Input
ref={autoFocusAndSelect}
variant="unstyled"
autoFocus
size="small"
value={row[column.key] as string}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onRowChange({ ...row, [column.key]: event.target.value })
}}
onBlur={() => onClose(true)}
/>
</Box>
)
}
return component
},
editorOptions: {
editOnClick: true,
},
formatter: ({ row, onRowChange }) => {
let component
switch (column.fieldType.type) {
case "checkbox":
component = (
<Box
display="flex"
alignItems="center"
height="100%"
onClick={(event) => {
event.stopPropagation()
}}
>
<Switch
isChecked={row[column.key] as boolean}
onChange={() => {
onRowChange({ ...row, [column.key]: !row[column.key as T] })
}}
/>
</Box>
)
break
case "select":
component = (
<Box minWidth="100%" minHeight="100%" overflow="hidden" textOverflow="ellipsis">
{column.fieldType.options.find((option) => option.value === row[column.key as T])?.label || null}
</Box>
)
break
default:
component = (
<Box minWidth="100%" minHeight="100%" overflow="hidden" textOverflow="ellipsis">
{row[column.key as T]}
</Box>
)
}
if (row.__errors?.[column.key]) {
return (
<Tooltip placement="top" hasArrow label={row.__errors?.[column.key]?.message}>
{component}
</Tooltip>
)
}
return component
},
cellClass: (row: Meta) => {
switch (row.__errors?.[column.key]?.level) {
case "error":
return "rdg-cell-error"
case "warning":
return "rdg-cell-warning"
case "info":
return "rdg-cell-info"
default:
return ""
}
},
}),
),
]

View File

@@ -0,0 +1,26 @@
import { editableTableInitialData, mockRsiValues } from "../../../stories/mockRsiValues"
import { ValidationStep } from "../ValidationStep"
import { Providers } from "../../../components/Providers"
import { defaultTheme } from "../../../ReactSpreadsheetImport"
import { ModalWrapper } from "../../../components/ModalWrapper"
import { addErrorsAndRunHooks } from "../utils/dataMutations"
export default {
title: "Validation Step",
parameters: {
layout: "fullscreen",
},
}
const file = new File([""], "file.csv")
const data = await addErrorsAndRunHooks(editableTableInitialData, mockRsiValues.fields)
export const Basic = () => {
return (
<Providers theme={defaultTheme} rsiValues={mockRsiValues}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<ValidationStep initialData={data} file={file} />
</ModalWrapper>
</Providers>
)
}

View File

@@ -0,0 +1,956 @@
import "@testing-library/jest-dom"
import { render, waitFor, screen, act } from "@testing-library/react"
import { ValidationStep } from "../ValidationStep"
import { defaultRSIProps, defaultTheme } from "../../../ReactSpreadsheetImport"
import { Providers } from "../../../components/Providers"
import { ModalWrapper } from "../../../components/ModalWrapper"
import userEvent from "@testing-library/user-event"
import { translations } from "../../../translationsRSIProps"
import { addErrorsAndRunHooks } from "../utils/dataMutations"
import { Fields, RowHook, TableHook } from "../../../types"
type fieldKeys<T extends Fields<string>> = T[number]["key"]
const mockValues = {
...defaultRSIProps,
fields: [],
onSubmit: () => {},
isOpen: true,
onClose: () => {},
} as const
const getFilterSwitch = () =>
screen.getByRole("checkbox", {
name: translations.validationStep.filterSwitchTitle,
})
const file = new File([""], "file.csv")
describe("Validation step tests", () => {
test("Submit data", async () => {
const onSubmit = jest.fn()
render(
<Providers theme={defaultTheme} rsiValues={{ ...mockValues, onSubmit: onSubmit }}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<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("Submit data without returning promise", async () => {
const onSuccess = jest.fn()
const onSubmit = jest.fn(() => {
onSuccess()
})
const onClose = jest.fn()
render(
<Providers theme={defaultTheme} rsiValues={{ ...mockValues, onSubmit, onClose }}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<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)
})
await waitFor(() => {
expect(onSuccess).toBeCalled()
expect(onClose).toBeCalled()
})
})
test("Submit data with a successful async return", async () => {
const onSuccess = jest.fn()
const onSubmit = jest.fn(async (): Promise<void> => {
onSuccess()
return Promise.resolve()
})
const onClose = jest.fn()
render(
<Providers theme={defaultTheme} rsiValues={{ ...mockValues, onSubmit, onClose }}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<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)
})
await waitFor(() => {
expect(onSuccess).toBeCalled()
expect(onClose).toBeCalled()
})
})
test("Submit data with a unsuccessful async return", async () => {
const ERROR_MESSAGE = "ERROR has occurred"
const onReject = jest.fn()
const onSubmit = jest.fn(async (): Promise<void> => {
onReject()
throw new Error(ERROR_MESSAGE)
})
const onClose = jest.fn()
render(
<Providers theme={defaultTheme} rsiValues={{ ...mockValues, onSubmit, onClose }}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<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)
})
const errorToast = await screen.findAllByText(ERROR_MESSAGE, undefined, { timeout: 5000 })
expect(onReject).toBeCalled()
expect(errorToast?.[0]).toBeInTheDocument()
expect(onClose).not.toBeCalled()
})
test("Filters rows with required errors", async () => {
const UNIQUE_NAME = "very unique name"
const fields = [
{
label: "Name",
key: "name",
fieldType: {
type: "input",
},
validations: [
{
rule: "required",
errorMessage: "Name is required",
},
],
},
] as const
const initialData = await addErrorsAndRunHooks(
[
{
name: UNIQUE_NAME,
},
{
name: undefined,
},
],
fields,
)
render(
<Providers theme={defaultTheme} rsiValues={{ ...mockValues, fields }}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<ValidationStep<fieldKeys<typeof fields>> 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 fields = [
{
label: "Name",
key: "name",
fieldType: {
type: "input",
},
validations: [
{
rule: "required",
errorMessage: "Name is required",
},
],
},
] as const
const initialData = await addErrorsAndRunHooks(
[
{
name: UNIQUE_NAME,
},
{
name: undefined,
},
{
name: SECOND_UNIQUE_NAME,
},
],
fields,
)
const onSubmit = jest.fn()
render(
<Providers theme={defaultTheme} rsiValues={{ ...mockValues, fields, onSubmit }}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<ValidationStep<fieldKeys<typeof fields>> 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 fields = [
{
label: "Name",
key: "name",
fieldType: {
type: "input",
},
validations: [
{
rule: "unique",
errorMessage: "Name must be unique",
},
],
},
] as const
const initialData = await addErrorsAndRunHooks(
[
{
name: NON_UNIQUE_NAME,
},
{
name: NON_UNIQUE_NAME,
},
{
name: "I am fine",
},
],
fields,
)
render(
<Providers theme={defaultTheme} rsiValues={{ ...mockValues, fields }}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<ValidationStep<fieldKeys<typeof fields>> 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 fields = [
{
label: "Name",
key: "name",
fieldType: {
type: "input",
},
validations: [
{
rule: "regex",
errorMessage: "Name must be unique",
value: "^[0-9]*$",
},
],
},
] as const
const initialData = await addErrorsAndRunHooks(
[
{
name: NOT_A_NUMBER,
},
{
name: "1234",
},
{
name: "9999999",
},
],
fields,
)
render(
<Providers theme={defaultTheme} rsiValues={{ ...mockValues, fields }}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<ValidationStep<fieldKeys<typeof fields>> 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 fields = [
{
label: "Name",
key: "name",
fieldType: {
type: "input",
},
},
] as const
const initialData = await addErrorsAndRunHooks(
[
{
name: FIRST_DELETE,
},
{
name: SECOND_DELETE,
},
{
name: THIRD,
},
],
fields,
)
render(
<Providers theme={defaultTheme} rsiValues={{ ...mockValues, fields }}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<ValidationStep<fieldKeys<typeof fields>> 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 fields = [
{
label: "Name",
key: "name",
fieldType: {
type: "input",
},
},
] as const
const initialData = await addErrorsAndRunHooks(
[
{
name: FIRST_DELETE,
},
{
name: SECOND_DELETE,
},
{
name: THIRD,
},
],
fields,
)
render(
<Providers theme={defaultTheme} rsiValues={{ ...mockValues, fields }}>
<ModalWrapper isOpen={true} onClose={() => {}}>
<ValidationStep<fieldKeys<typeof fields>> 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 fields = [
{
label: "Name",
key: "name",
fieldType: {
type: "input",
},
},
{
label: "lastName",
key: "lastName",
fieldType: {
type: "select",
options: OPTIONS,
},
},
{
label: "is cool",
key: "is_cool",
fieldType: {
type: "checkbox",
},
},
] as const
const initialData = await addErrorsAndRunHooks(
[
{
name: NAME,
lastName: OPTIONS[0].value,
is_cool: false,
},
],
fields,
)
render(
<Providers
theme={defaultTheme}
rsiValues={{
...mockValues,
fields,
}}
>
<ModalWrapper isOpen={true} onClose={() => {}}>
<ValidationStep<fieldKeys<typeof fields>> 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 fields = [
{
label: "Name",
key: "name",
fieldType: {
type: "input",
},
},
{
label: "lastName",
key: "lastName",
fieldType: {
type: "input",
},
},
] as const
const rowHook: RowHook<fieldKeys<typeof fields>> = (value) => ({
name: value.name?.toString()?.split(/(\s+)/)[0],
lastName: value.name?.toString()?.split(/(\s+)/)[2],
})
const initialData = await addErrorsAndRunHooks(
[
{
name: NAME + " " + LASTNAME,
lastName: undefined,
},
],
fields,
rowHook,
)
await act(async () => {
render(
<Providers
theme={defaultTheme}
rsiValues={{
...mockValues,
fields,
rowHook,
}}
>
<ModalWrapper isOpen={true} onClose={() => {}}>
<ValidationStep<fieldKeys<typeof fields>> 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 only runs on a single row", async () => {
const NAME = "John"
const NEW_NAME = "Kate"
const LAST_NAME = "Doe"
const fields = [
{
label: "Name",
key: "name",
fieldType: {
type: "input",
},
},
{
label: "lastName",
key: "lastName",
fieldType: {
type: "input",
},
},
] as const
const mockedHook = jest.fn((a) => a)
const initialData = await addErrorsAndRunHooks(
[
{
name: NAME,
lastName: LAST_NAME,
},
{
name: "Johnny",
lastName: "Doeson",
},
],
fields,
mockedHook,
)
await act(async () => {
render(
<Providers
theme={defaultTheme}
rsiValues={{
...mockValues,
fields,
rowHook: mockedHook,
}}
>
<ModalWrapper isOpen={true} onClose={() => {}}>
<ValidationStep<fieldKeys<typeof fields>> initialData={initialData} file={file} />
</ModalWrapper>
</Providers>,
)
})
// initially row hook is called for each row
expect(mockedHook.mock.calls.length).toBe(2)
const nameCell = screen.getByRole("gridcell", {
name: NAME,
})
expect(nameCell).toBeInTheDocument()
// activate input
await userEvent.click(nameCell)
await userEvent.keyboard(NEW_NAME + "{enter}")
expect(mockedHook.mock.calls[2][0]?.name).toBe(NEW_NAME)
expect(mockedHook.mock.calls.length).toBe(3)
})
test("Row hook raises error", async () => {
const WRONG_NAME = "Johnny"
const RIGHT_NAME = "Jonathan"
const fields = [
{
label: "Name",
key: "name",
fieldType: {
type: "input",
},
},
] as const
const rowHook: RowHook<fieldKeys<typeof fields>> = (value, setError) => {
if (value.name === WRONG_NAME) {
setError(fields[0].key, { message: "Wrong name", level: "error" })
}
return value
}
const initialData = await addErrorsAndRunHooks(
[
{
name: WRONG_NAME,
},
],
fields,
rowHook,
)
await act(async () =>
render(
<Providers
theme={defaultTheme}
rsiValues={{
...mockValues,
fields,
rowHook,
}}
>
<ModalWrapper isOpen={true} onClose={() => {}}>
<ValidationStep<fieldKeys<typeof fields>> 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 fields = [
{
label: "Name",
key: "name",
fieldType: {
type: "input",
},
},
] as const
const tableHook: TableHook<fieldKeys<typeof fields>> = (data) =>
data.map((value) => ({
name: value.name + ADDITION,
}))
const initialData = await addErrorsAndRunHooks(
[
{
name: NAME,
},
{
name: SECOND_NAME,
},
],
fields,
undefined,
tableHook,
)
await act(async () => {
render(
<Providers
theme={defaultTheme}
rsiValues={{
...mockValues,
fields,
tableHook,
}}
>
<ModalWrapper isOpen={true} onClose={() => {}}>
<ValidationStep<fieldKeys<typeof fields>> 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 fields = [
{
label: "Name",
key: "name",
fieldType: {
type: "input",
},
},
] as const
const tableHook: TableHook<fieldKeys<typeof fields>> = (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
}
const initialData = await addErrorsAndRunHooks(
[
{
name: WRONG_NAME,
},
{
name: WRONG_NAME,
},
],
fields,
undefined,
tableHook,
)
render(
<Providers
theme={defaultTheme}
rsiValues={{
...mockValues,
fields,
tableHook,
}}
>
<ModalWrapper isOpen={true} onClose={() => {}}>
<ValidationStep<fieldKeys<typeof fields>> initialData={initialData} file={file} />
</ModalWrapper>
</Providers>,
)
const switchFilter = getFilterSwitch()
await expect(await screen.findAllByRole("row")).toHaveLength(3)
await userEvent.click(switchFilter)
await expect(await screen.findAllByRole("row")).toHaveLength(3)
const nameCell = await screen.getAllByRole("gridcell", {
name: WRONG_NAME,
})[0]
await userEvent.click(nameCell)
screen.getByRole<HTMLInputElement>("textbox")
await userEvent.keyboard(RIGHT_NAME + "{enter}")
await expect(await screen.findAllByRole("row")).toHaveLength(2)
})
})

View File

@@ -0,0 +1,5 @@
import { InfoWithSource } from "../../types"
export type Meta = { __index: string; __errors?: Error | null }
export type Error = { [key: string]: InfoWithSource }
export type Errors = { [id: string]: Error }

View File

@@ -0,0 +1,151 @@
import type { Data, Fields, Info, RowHook, TableHook } from "../../../types"
import type { Meta, Error, Errors } from "../types"
import { v4 } from "uuid"
import { ErrorSources } from "../../../types"
export const addErrorsAndRunHooks = async <T extends string>(
data: (Data<T> & Partial<Meta>)[],
fields: Fields<T>,
rowHook?: RowHook<T>,
tableHook?: TableHook<T>,
changedRowIndexes?: number[],
): Promise<(Data<T> & Meta)[]> => {
const errors: Errors = {}
const addError = (source: ErrorSources, rowIndex: number, fieldKey: T, error: Info) => {
errors[rowIndex] = {
...errors[rowIndex],
[fieldKey]: { ...error, source },
}
}
if (tableHook) {
data = await tableHook(data, (...props) => addError(ErrorSources.Table, ...props))
}
if (rowHook) {
if (changedRowIndexes) {
for (const index of changedRowIndexes) {
data[index] = await rowHook(data[index], (...props) => addError(ErrorSources.Row, index, ...props), data)
}
} else {
data = await Promise.all(
data.map(async (value, index) =>
rowHook(value, (...props) => addError(ErrorSources.Row, 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)) {
addError(ErrorSources.Table, index, field.key as T, {
level: validation.level || "error",
message: validation.errorMessage || "Field must be unique",
})
}
})
break
}
case "required": {
const dataToValidate = changedRowIndexes ? changedRowIndexes.map((index) => data[index]) : data
dataToValidate.forEach((entry, index) => {
const realIndex = changedRowIndexes ? changedRowIndexes[index] : index
if (entry[field.key as T] === null || entry[field.key as T] === undefined || entry[field.key as T] === "") {
addError(ErrorSources.Row, realIndex, field.key as T, {
level: validation.level || "error",
message: validation.errorMessage || "Field is required",
})
}
})
break
}
case "regex": {
const dataToValidate = changedRowIndexes ? changedRowIndexes.map((index) => data[index]) : data
const regex = new RegExp(validation.value, validation.flags)
dataToValidate.forEach((entry, index) => {
const realIndex = changedRowIndexes ? changedRowIndexes[index] : index
const value = entry[field.key]?.toString() ?? ""
if (!value.match(regex)) {
addError(ErrorSources.Row, realIndex, field.key as T, {
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 we are validating all indexes, or we did full validation on this row - apply all errors
if (!changedRowIndexes || changedRowIndexes.includes(index)) {
if (errors[index]) {
return { ...newValue, __errors: errors[index] }
}
if (!errors[index] && value?.__errors) {
return { ...newValue, __errors: null }
}
}
// if we have not validated this row, keep it's row errors but apply global error changes
else {
// at this point errors[index] contains only table source errors, previous row and table errors are in value.__errors
const hasRowErrors =
value.__errors && Object.values(value.__errors).some((error) => error.source === ErrorSources.Row)
if (!hasRowErrors) {
if (errors[index]) {
return { ...newValue, __errors: errors[index] }
}
return newValue
}
const errorsWithoutTableError = Object.entries(value.__errors!).reduce((acc, [key, value]) => {
if (value.source === ErrorSources.Row) {
acc[key] = value
}
return acc
}, {} as Error)
const newErrors = { ...errorsWithoutTableError, ...errors[index] }
return { ...newValue, __errors: newErrors }
}
return newValue
})
}

View File

@@ -0,0 +1,58 @@
import { ReactSpreadsheetImport } from "../ReactSpreadsheetImport"
import { Box, Link, Code, Button, useDisclosure } from "@chakra-ui/react"
import { mockRsiValues } from "./mockRsiValues"
import { useState } from "react"
import type { Result } from "src/types"
export default {
title: "React spreadsheet import",
}
export const Basic = () => {
const [data, setData] = useState<Result<any> | null>(null)
const { isOpen, onOpen, onClose } = useDisclosure()
return (
<>
<Box py={20} display="flex" gap="8px" alignItems="center">
<Button onClick={onOpen} border="2px solid #7069FA" p="8px" borderRadius="8px">
Open Flow
</Button>
(make sure you have a file to upload)
</Box>
<Link href="./exampleFile.csv" border="2px solid #718096" p="8px" borderRadius="8px" download="exampleCSV">
Download example file
</Link>
<ReactSpreadsheetImport {...mockRsiValues} isOpen={isOpen} onClose={onClose} onSubmit={setData} />
{!!data && (
<Box pt={64} display="flex" gap="8px" flexDirection="column">
<b>Returned data (showing first 100 rows):</b>
<Code
display="flex"
alignItems="center"
borderRadius="16px"
fontSize="12px"
background="#4A5568"
color="white"
p={32}
>
<pre>
{JSON.stringify(
{
validData: data.validData.slice(0, 100),
invalidData: data.invalidData.slice(0, 100),
all: data.all.slice(0, 100),
},
undefined,
4,
)}
</pre>
</Code>
</Box>
)}
</>
)
}
Basic.parameters = {
chromatic: { disableSnapshot: true },
}

View File

@@ -0,0 +1,162 @@
import type { RsiProps } from "../types"
import { defaultRSIProps } from "../ReactSpreadsheetImport"
const fields = [
{
label: "Name",
key: "name",
alternateMatches: ["first name", "first"],
fieldType: {
type: "input",
},
example: "Stephanie",
validations: [
{
rule: "required",
errorMessage: "Name is required",
},
],
},
{
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",
},
{
label: "Age",
key: "age",
alternateMatches: ["years"],
fieldType: {
type: "input",
},
example: "23",
validations: [
{
rule: "regex",
value: "^\\d+$",
errorMessage: "Age must be a number",
level: "warning",
},
],
},
{
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",
},
],
},
{
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: () => {},
// 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"],
["second", "123", "one", "true"],
["third", "123", "one", "true"],
["fourth", "123", "one", "true"],
]

View File

@@ -0,0 +1,24 @@
import "@testing-library/jest-dom"
import { render } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { ReactSpreadsheetImport } from "../ReactSpreadsheetImport"
import { mockRsiValues } from "../stories/mockRsiValues"
test("Close modal", async () => {
let isOpen = true
const onClose = jest.fn(() => {
isOpen = !isOpen
})
const { getByText, getByLabelText } = render(
<ReactSpreadsheetImport {...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()
})

View File

@@ -0,0 +1,63 @@
// Yeeted from https://github.com/adazzle/react-data-grid/blob/main/test/setup.ts
if (typeof window !== "undefined") {
window.ResizeObserver ??= class {
callback: ResizeObserverCallback
constructor(callback: ResizeObserverCallback) {
this.callback = callback
}
observe() {
this.callback([], this)
}
unobserve() {}
disconnect() {}
}
// patch clientWidth/clientHeight to pretend we're rendering DataGrid at 1080p
Object.defineProperties(HTMLDivElement.prototype, {
clientWidth: {
get(this: HTMLDivElement) {
return this.classList.contains("rdg") ? 1920 : 0
},
},
clientHeight: {
get(this: HTMLDivElement) {
return this.classList.contains("rdg") ? 1080 : 0
},
},
})
Element.prototype.setPointerCapture ??= () => {}
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // Deprecated
removeListener: jest.fn(), // Deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
})
Object.defineProperty(global, "ResizeObserver", {
writable: true,
value: jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
})),
})
Object.defineProperty(window.HTMLElement.prototype, "scrollIntoView", {
writable: true,
value: jest.fn(),
})
}
jest.setTimeout(30000)

View File

@@ -0,0 +1,509 @@
import { StepsTheme } from "chakra-ui-steps"
import type { CSSObject } from "@chakra-ui/react"
import type { DeepPartial } from "ts-essentials"
import type { ChakraStylesConfig } from "chakra-react-select"
import type { SelectOption } from "./types"
const StepsComponent: typeof StepsTheme = {
...StepsTheme,
baseStyle: (props: any) => {
const navigationEnabled = !!props.onClickStep
return {
...StepsTheme.baseStyle(props),
stepContainer: {
...StepsTheme.baseStyle(props).stepContainer,
cursor: navigationEnabled ? "pointer" : "initial",
},
label: {
...StepsTheme.baseStyle(props).label,
color: "textColor",
},
}
},
variants: {
circles: (props: any) => ({
...StepsTheme.variants.circles(props),
step: {
...StepsTheme.variants.circles(props).step,
"&:not(:last-child):after": {
...StepsTheme.variants.circles(props).step["&:not(:last-child):after"],
backgroundColor: "background",
},
},
stepIconContainer: {
...StepsTheme.variants.circles(props).stepIconContainer,
flex: "0 0 auto",
bg: "background",
borderColor: "background",
},
}),
},
}
const MatchIconTheme: any = {
baseStyle: (props: any) => {
return {
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "50%",
borderWidth: "2px",
bg: "background",
borderColor: "yellow.500",
color: "background",
transitionDuration: "ultra-fast",
_highlighted: {
bg: "green.500",
borderColor: "green.500",
},
}
},
defaultProps: {
size: "md",
colorScheme: "green",
},
}
export const themeOverrides = {
colors: {
textColor: "#2D3748",
subtitleColor: "#718096",
inactiveColor: "#A0AEC0",
border: "#E2E8F0",
background: "white",
backgroundAlpha: "rgba(255,255,255,0)",
secondaryBackground: "#EDF2F7",
highlight: "#E2E8F0",
rsi: {
50: "#E6E6FF",
100: "#C4C6FF",
200: "#A2A5FC",
300: "#8888FC",
400: "#7069FA",
500: "#5D55FA",
600: "#4D3DF7",
700: "#3525E6",
800: "#1D0EBE",
900: "#0C008C",
},
},
shadows: {
outline: 0,
},
components: {
UploadStep: {
baseStyle: {
heading: {
fontSize: "3xl",
color: "textColor",
mb: "2rem",
},
title: {
fontSize: "2xl",
lineHeight: 8,
fontWeight: "semibold",
color: "textColor",
},
subtitle: {
fontSize: "md",
lineHeight: 6,
color: "subtitleColor",
mb: "1rem",
},
tableWrapper: {
mb: "0.5rem",
position: "relative",
h: "72px",
},
dropzoneText: {
size: "lg",
lineHeight: 7,
fontWeight: "semibold",
color: "textColor",
},
dropZoneBorder: "rsi.500",
dropzoneButton: {
mt: "1rem",
},
},
},
SelectSheetStep: {
baseStyle: {
heading: {
color: "textColor",
mb: 8,
fontSize: "3xl",
},
radio: {},
radioLabel: {
color: "textColor",
},
},
},
SelectHeaderStep: {
baseStyle: {
heading: {
color: "textColor",
mb: 8,
fontSize: "3xl",
},
},
},
MatchColumnsStep: {
baseStyle: {
heading: {
color: "textColor",
mb: 8,
fontSize: "3xl",
},
title: {
color: "textColor",
fontSize: "2xl",
lineHeight: 8,
fontWeight: "semibold",
mb: 4,
},
userTable: {
header: {
fontSize: "xs",
lineHeight: 4,
fontWeight: "bold",
letterSpacing: "wider",
color: "textColor",
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
["&[data-ignored]"]: {
color: "inactiveColor",
},
},
cell: {
fontSize: "sm",
lineHeight: 5,
fontWeight: "medium",
color: "textColor",
px: 6,
py: 4,
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
["&[data-ignored]"]: {
color: "inactiveColor",
},
},
ignoreButton: {
size: "xs",
colorScheme: "gray",
color: "textColor",
},
},
selectColumn: {
text: {
fontSize: "sm",
lineHeight: 5,
fontWeight: "normal",
color: "inactiveColor",
px: 4,
},
accordionLabel: {
color: "blue.600",
fontSize: "sm",
lineHeight: 5,
pl: 1,
},
selectLabel: {
pt: "0.375rem",
pb: 2,
fontSize: "md",
lineHeight: 6,
fontWeight: "medium",
color: "textColor",
},
},
select: {
control: (provided) => ({
...provided,
borderColor: "border",
_hover: {
borderColor: "border",
},
["&[data-focus-visible]"]: {
borderColor: "border",
boxShadow: "none",
},
}),
menu: (provided) => ({
...provided,
p: 0,
mt: 0,
}),
menuList: (provided) => ({
...provided,
bg: "background",
borderColor: "border",
}),
option: (provided, state) => ({
...provided,
color: "textColor",
bg: state.isSelected || state.isFocused ? "highlight" : "background",
overflow: "hidden",
textOverflow: "ellipsis",
display: "block",
whiteSpace: "nowrap",
_hover: {
bg: "highlight",
},
}),
placeholder: (provided) => ({
...provided,
color: "inactiveColor",
}),
noOptionsMessage: (provided) => ({
...provided,
color: "inactiveColor",
}),
} as ChakraStylesConfig<SelectOption>,
},
},
ValidationStep: {
baseStyle: {
heading: {
color: "textColor",
fontSize: "3xl",
},
select: {
valueContainer: (provided) => ({
...provided,
py: 0,
px: 1.5,
}),
inputContainer: (provided) => ({ ...provided, py: 0 }),
control: (provided) => ({ ...provided, border: "none" }),
input: (provided) => ({ ...provided, color: "textColor" }),
menu: (provided) => ({
...provided,
p: 0,
mt: 0,
}),
menuList: (provided) => ({
...provided,
bg: "background",
borderColor: "border",
}),
option: (provided, state) => ({
...provided,
color: "textColor",
bg: state.isSelected || state.isFocused ? "highlight" : "background",
overflow: "hidden",
textOverflow: "ellipsis",
display: "block",
whiteSpace: "nowrap",
}),
noOptionsMessage: (provided) => ({
...provided,
color: "inactiveColor",
}),
} as ChakraStylesConfig<SelectOption>,
},
},
MatchIcon: MatchIconTheme,
Steps: StepsComponent,
Modal: {
baseStyle: {
dialog: {
borderRadius: "lg",
bg: "background",
fontSize: "lg",
color: "textColor",
},
closeModalButton: {},
backButton: {
gridColumn: "1",
gridRow: "1",
justifySelf: "start",
},
continueButton: {
gridColumn: "1 / 3",
gridRow: "1",
justifySelf: "center",
},
},
variants: {
rsi: {
header: {
bg: "secondaryBackground",
px: "2rem",
py: "1.5rem",
},
body: {
bg: "background",
display: "flex",
paddingX: "2rem",
paddingY: "2rem",
flexDirection: "column",
flex: 1,
overflow: "auto",
height: "100%",
},
footer: {
bg: "secondaryBackground",
py: "1.5rem",
px: "2rem",
justifyContent: "center",
display: "grid",
gridTemplateColumns: "1fr 1fr",
gridTemplateRows: "1fr",
gap: "1rem",
},
dialog: {
outline: "unset",
minH: "calc(var(--chakra-vh) - 4rem)",
maxW: "calc(var(--chakra-vw) - 4rem)",
my: "2rem",
borderRadius: "3xl",
overflow: "hidden",
},
},
},
},
Button: {
defaultProps: {
colorScheme: "rsi",
},
},
},
styles: {
global: {
// supporting older browsers but avoiding fill-available CSS as it doesn't work https://github.com/chakra-ui/chakra-ui/blob/073bbcd21a9caa830d71b61d6302f47aaa5c154d/packages/components/css-reset/src/css-reset.tsx#L5
":root": {
"--chakra-vh": "100vh",
"--chakra-vw": "100vw",
},
"@supports (height: 100dvh) and (width: 100dvw) ": {
":root": {
"--chakra-vh": "100dvh",
"--chakra-vw": "100dvw",
},
},
".rdg": {
contain: "size layout style paint",
borderRadius: "lg",
border: "none",
borderTop: "1px solid var(--rdg-border-color)",
blockSize: "100%",
flex: "1",
// we have to use vars here because chakra does not autotransform unknown props
"--rdg-row-height": "35px",
"--rdg-color": "var(--chakra-colors-textColor)",
"--rdg-background-color": "var(--chakra-colors-background)",
"--rdg-header-background-color": "var(--chakra-colors-background)",
"--rdg-row-hover-background-color": "var(--chakra-colors-background)",
"--rdg-selection-color": "var(--chakra-colors-blue-400)",
"--rdg-row-selected-background-color": "var(--chakra-colors-rsi-50)",
"--row-selected-hover-background-color": "var(--chakra-colors-rsi-100)",
"--rdg-error-cell-background-color": "var(--chakra-colors-red-50)",
"--rdg-warning-cell-background-color": "var(--chakra-colors-orange-50)",
"--rdg-info-cell-background-color": "var(--chakra-colors-blue-50)",
"--rdg-border-color": "var(--chakra-colors-border)",
"--rdg-frozen-cell-box-shadow": "none",
"--rdg-font-size": "var(--chakra-fontSizes-sm)",
},
".rdg-header-row .rdg-cell": {
color: "textColor",
fontSize: "xs",
lineHeight: 10,
fontWeight: "bold",
letterSpacing: "wider",
textTransform: "uppercase",
"&:first-of-type": {
borderTopLeftRadius: "lg",
},
"&:last-child": {
borderTopRightRadius: "lg",
},
},
".rdg-row:last-child .rdg-cell:first-of-type": {
borderBottomLeftRadius: "lg",
},
".rdg-row:last-child .rdg-cell:last-child": {
borderBottomRightRadius: "lg",
},
".rdg[dir='rtl']": {
".rdg-row:last-child .rdg-cell:first-of-type": {
borderBottomRightRadius: "lg",
borderBottomLeftRadius: "none",
},
".rdg-row:last-child .rdg-cell:last-child": {
borderBottomLeftRadius: "lg",
borderBottomRightRadius: "none",
},
},
".rdg-cell": {
contain: "size layout style paint",
borderRight: "none",
borderInlineEnd: "none",
borderBottom: "1px solid var(--rdg-border-color)",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
"&[aria-selected='true']": {
boxShadow: "inset 0 0 0 1px var(--rdg-selection-color)",
},
"&:first-of-type": {
boxShadow: "none",
borderInlineStart: "1px solid var(--rdg-border-color)",
},
"&:last-child": {
borderInlineEnd: "1px solid var(--rdg-border-color)",
},
},
".rdg-cell-error": {
backgroundColor: "var(--rdg-error-cell-background-color)",
},
".rdg-cell-warning": {
backgroundColor: "var(--rdg-warning-cell-background-color)",
},
".rdg-cell-info": {
backgroundColor: "var(--rdg-info-cell-background-color)",
},
".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",
borderBottom: "none",
},
".rdg-radio": {
display: "flex",
alignItems: "center",
},
".rdg-checkbox": {
"--rdg-selection-color": "none",
display: "flex",
alignItems: "center",
},
},
},
} as const
export const rtlThemeSupport = {
components: {
Modal: {
baseStyle: {
dialog: {
direction: "rtl",
},
},
},
},
} as const
export type CustomTheme = DeepPartial<typeof themeOverrides>

View File

@@ -0,0 +1,82 @@
import type { DeepPartial } from "ts-essentials"
export const translations = {
uploadStep: {
title: "Upload file",
manifestTitle: "Data that we expect:",
manifestDescription: "(You will have a chance to rename or remove columns in next steps)",
maxRecordsExceeded: (maxRecords: string) => `Too many records. Up to ${maxRecords} allowed`,
dropzone: {
title: "Upload .xlsx, .xls or .csv file",
errorToastDescription: "upload rejected",
activeDropzoneTitle: "Drop file here...",
buttonTitle: "Select file",
loadingTitle: "Processing...",
},
selectSheet: {
title: "Select the sheet to use",
nextButtonTitle: "Next",
backButtonTitle: "Back",
},
},
selectHeaderStep: {
title: "Select header row",
nextButtonTitle: "Next",
backButtonTitle: "Back",
},
matchColumnsStep: {
title: "Match Columns",
nextButtonTitle: "Next",
backButtonTitle: "Back",
userTableTitle: "Your table",
templateTitle: "Will become",
selectPlaceholder: "Select column...",
ignoredColumnText: "Column ignored",
subSelectPlaceholder: "Select...",
matchDropdownTitle: "Match",
unmatched: "Unmatched",
duplicateColumnWarningTitle: "Another column unselected",
duplicateColumnWarningDescription: "Columns cannot duplicate",
},
validationStep: {
title: "Validate data",
nextButtonTitle: "Confirm",
backButtonTitle: "Back",
noRowsMessage: "No data found",
noRowsMessageWhenFiltered: "No data containing errors",
discardButtonTitle: "Discard selected rows",
filterSwitchTitle: "Show only rows with errors",
},
alerts: {
confirmClose: {
headerTitle: "Exit import flow",
bodyText: "Are you sure? Your current information will not be saved.",
cancelButtonTitle: "Cancel",
exitButtonTitle: "Exit flow",
},
submitIncomplete: {
headerTitle: "Errors detected",
bodyText: "There are still some rows that contain errors. Rows with errors will be ignored when submitting.",
bodyTextSubmitForbidden: "There are still some rows containing errors.",
cancelButtonTitle: "Cancel",
finishButtonTitle: "Submit",
},
submitError: {
title: "Error",
defaultMessage: "An error occurred while submitting data",
},
unmatchedRequiredFields: {
headerTitle: "Not all columns matched",
bodyText: "There are required columns that are not matched or ignored. Do you want to continue?",
listTitle: "Columns not matched:",
cancelButtonTitle: "Cancel",
continueButtonTitle: "Continue",
},
toast: {
error: "Error",
},
},
}
export type TranslationsRSIProps = DeepPartial<typeof translations>
export type Translations = typeof translations

View File

@@ -0,0 +1,160 @@
import type { Meta } from "./steps/ValidationStep/types"
import type { DeepReadonly } from "ts-essentials"
import type { TranslationsRSIProps } from "./translationsRSIProps"
import type { Columns } from "./steps/MatchColumnsStep/MatchColumnsStep"
import type { StepState } from "./steps/UploadFlow"
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. You can return a promise that will be awaited.
onSubmit: (data: Result<T>, file: File) => void | Promise<any>
// Allows submitting with errors. Default: true
allowInvalidSubmit?: boolean
// Enable navigation in stepper component and show back button. Default: false
isNavigationEnabled?: boolean
// Translations for each text
translations?: TranslationsRSIProps
// 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
// When field type is "select", automatically match values if possible. Default: false
autoMapSelectValues?: 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> = DeepReadonly<Field<T>[]>
export type Field<T extends string> = {
// 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 = {
// UI-facing option label
label: string
// Field entry matching criteria as well as select output
value: string
}
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> | Promise<Data<T>>
export type TableHook<T extends string> = (
table: Data<T>[],
addError: (rowIndex: number, fieldKey: T, error: Info) => void,
) => Data<T>[] | Promise<Data<T>[]>
export type ErrorLevel = "info" | "warning" | "error"
export type Info = {
message: string
level: ErrorLevel
}
export enum ErrorSources {
Table = "table",
Row = "row",
}
/*
Source determines whether the error is from the full table or row validation
Table validation is tableHook and "unique" validation
Row validation is rowHook and all other validations
it is used to determine if row.__errors should be updated or not depending on different validations
*/
export type InfoWithSource = Info & {
source: ErrorSources
}
export type Result<T extends string> = {
validData: Data<T>[]
invalidData: Data<T>[]
all: (Data<T> & Meta)[]
}

View File

@@ -0,0 +1,6 @@
import type XLSX from "xlsx-ugnis"
export const exceedsMaxRecords = (workSheet: XLSX.WorkSheet, maxRecords: number) => {
const [top, bottom] = workSheet["!ref"]?.split(":").map((position) => parseInt(position.replace(/\D/g, ""), 10)) || []
return bottom - top > maxRecords
}

View File

@@ -0,0 +1,10 @@
export const mapData = (data: string[][], valueMap: string[]) =>
data.map((row) =>
row.reduce<{ [k: string]: string }>((obj, value, index) => {
if (valueMap[index]) {
obj[valueMap[index]] = `${value}`
return obj
}
return obj
}, {}),
)

View File

@@ -0,0 +1,15 @@
import * as XLSX from "xlsx"
import type { RawData } from "../types"
export const mapWorkbook = (workbook: XLSX.WorkBook): RawData[] => {
const firstSheetName = workbook.SheetNames[0]
const worksheet = workbook.Sheets[firstSheetName]
const data = XLSX.utils.sheet_to_json<string[]>(worksheet, {
header: 1,
raw: false,
defval: "",
})
return data
}

View File

@@ -0,0 +1,26 @@
import { StepType } from "../steps/UploadFlow"
export const steps = ["uploadStep", "selectHeaderStep", "matchColumnsStep", "validationStep"] as const
const StepTypeToStepRecord: Record<StepType, (typeof steps)[number]> = {
[StepType.upload]: "uploadStep",
[StepType.selectSheet]: "uploadStep",
[StepType.selectHeader]: "selectHeaderStep",
[StepType.matchColumns]: "matchColumnsStep",
[StepType.validateData]: "validationStep",
}
const StepToStepTypeRecord: Record<(typeof steps)[number], StepType> = {
uploadStep: StepType.upload,
selectHeaderStep: StepType.selectHeader,
matchColumnsStep: StepType.matchColumns,
validationStep: StepType.validateData,
}
export const stepIndexToStepType = (stepIndex: number) => {
const step = steps[stepIndex]
return StepToStepTypeRecord[step] || StepType.upload
}
export const stepTypeToStepIndex = (type?: StepType) => {
const step = StepTypeToStepRecord[type || StepType.upload]
return Math.max(0, steps.indexOf(step))
}

View File

@@ -0,0 +1,99 @@
import { ReactSpreadsheetImport } from "@/lib/react-spreadsheet-import/src";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Box, Card, ScrollArea } from "@/components/ui/card";
import { Code } from "@/components/ui/code";
export function Import() {
const [isOpen, setIsOpen] = useState(false);
const [importedData, setImportedData] = useState<any[] | null>(null);
const fields = [
{
label: "Name",
key: "name",
alternateMatches: ["product", "product name", "item name"],
fieldType: {
type: "input",
},
example: "Widget X",
validations: [
{
rule: "required",
errorMessage: "Name is required",
level: "error",
},
],
},
{
label: "SKU",
key: "sku",
alternateMatches: ["item number", "product code"],
fieldType: {
type: "input",
},
example: "WX-123",
validations: [
{
rule: "required",
errorMessage: "SKU is required",
level: "error",
},
],
},
{
label: "Quantity",
key: "quantity",
alternateMatches: ["qty", "stock", "amount"],
fieldType: {
type: "input",
},
example: "100",
validations: [
{
rule: "required",
errorMessage: "Quantity is required",
level: "error",
},
],
},
];
const handleData = async (data: any, file: File) => {
console.log("Imported Data:", data);
console.log("File:", file);
setImportedData(data);
setIsOpen(false);
};
return (
<div className="container mx-auto p-4">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Import Data</h1>
<Button onClick={() => setIsOpen(true)}>
Import Spreadsheet
</Button>
</div>
{importedData && (
<div className="mt-6">
<h2 className="text-xl font-semibold mb-4">Imported Data</h2>
<Card>
<ScrollArea className="h-[500px] w-full rounded-md border p-4">
<Code>
{JSON.stringify(importedData, null, 2)}
</Code>
</ScrollArea>
</Card>
</div>
)}
<ReactSpreadsheetImport
isOpen={isOpen}
onClose={() => setIsOpen(false)}
onSubmit={handleData}
fields={fields}
/>
</div>
);
}