Add in and integrate react-spreadsheet-import
This commit is contained in:
4270
inventory/package-lock.json
generated
4270
inventory/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,9 +10,26 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@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-accordion": "^1.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-avatar": "^1.1.2",
|
"@radix-ui/react-avatar": "^1.1.2",
|
||||||
@@ -33,35 +50,47 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.1.6",
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
"@shadcn/ui": "^0.0.4",
|
"@shadcn/ui": "^0.0.4",
|
||||||
"@tabler/icons-react": "^3.28.1",
|
"@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-table": "^8.20.6",
|
||||||
"@tanstack/react-virtual": "^3.11.2",
|
"@tanstack/react-virtual": "^3.11.2",
|
||||||
"@tanstack/virtual-core": "^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",
|
"chart.js": "^4.4.7",
|
||||||
"chartjs-adapter-date-fns": "^3.0.0",
|
"chartjs-adapter-date-fns": "^3.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
"date-fns": "^3.6.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",
|
"lucide-react": "^0.469.0",
|
||||||
"motion": "^11.18.0",
|
"motion": "^11.18.0",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-chartjs-2": "^5.3.0",
|
"react-chartjs-2": "^5.3.0",
|
||||||
|
"react-data-grid": "^7.0.0-beta.13",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-dropzone": "^14.3.5",
|
||||||
|
"react-icons": "^5.4.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
"sonner": "^1.7.1",
|
"sonner": "^1.7.1",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tanstack": "^1.0.0",
|
"tanstack": "^1.0.0",
|
||||||
"vaul": "^1.1.2"
|
"uuid": "^11.0.5",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@types/lodash": "^4.17.14",
|
"@types/lodash": "^4.17.15",
|
||||||
"@types/node": "^22.10.5",
|
"@types/node": "^22.10.5",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { RequireAuth } from './components/auth/RequireAuth';
|
|||||||
import Forecasting from "@/pages/Forecasting";
|
import Forecasting from "@/pages/Forecasting";
|
||||||
import { Vendors } from '@/pages/Vendors';
|
import { Vendors } from '@/pages/Vendors';
|
||||||
import { Categories } from '@/pages/Categories';
|
import { Categories } from '@/pages/Categories';
|
||||||
|
import { Import } from '@/pages/import/Import';
|
||||||
|
import { ChakraProvider } from '@chakra-ui/react';
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -50,6 +52,7 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ChakraProvider>
|
||||||
<Toaster richColors position="top-center" />
|
<Toaster richColors position="top-center" />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
@@ -60,6 +63,7 @@ function App() {
|
|||||||
}>
|
}>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/products" element={<Products />} />
|
<Route path="/products" element={<Products />} />
|
||||||
|
<Route path="/import" element={<Import />} />
|
||||||
<Route path="/categories" element={<Categories />} />
|
<Route path="/categories" element={<Categories />} />
|
||||||
<Route path="/vendors" element={<Vendors />} />
|
<Route path="/vendors" element={<Vendors />} />
|
||||||
<Route path="/orders" element={<Orders />} />
|
<Route path="/orders" element={<Orders />} />
|
||||||
@@ -70,6 +74,7 @@ function App() {
|
|||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</ChakraProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
Users,
|
Users,
|
||||||
Tags,
|
Tags,
|
||||||
|
FileSpreadsheet,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { IconCrystalBall } from "@tabler/icons-react";
|
import { IconCrystalBall } from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +36,11 @@ const items = [
|
|||||||
icon: Package,
|
icon: Package,
|
||||||
url: "/products",
|
url: "/products",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Import",
|
||||||
|
icon: FileSpreadsheet,
|
||||||
|
url: "/import",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Forecasting",
|
title: "Forecasting",
|
||||||
icon: IconCrystalBall,
|
icon: IconCrystalBall,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-xl border bg-card text-card-foreground shadow",
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -30,22 +30,25 @@ const CardHeader = React.forwardRef<
|
|||||||
CardHeader.displayName = "CardHeader"
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
const CardTitle = React.forwardRef<
|
const CardTitle = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLParagraphElement,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<h3
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
className={cn(
|
||||||
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
CardTitle.displayName = "CardTitle"
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
const CardDescription = React.forwardRef<
|
const CardDescription = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLParagraphElement,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div
|
<p
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -73,4 +76,16 @@ const CardFooter = React.forwardRef<
|
|||||||
))
|
))
|
||||||
CardFooter.displayName = "CardFooter"
|
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 }
|
||||||
|
|||||||
23
inventory/src/components/ui/code.tsx
Normal file
23
inventory/src/components/ui/code.tsx
Normal 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 }
|
||||||
21
inventory/src/lib/react-spreadsheet-import/LICENSE
Normal file
21
inventory/src/lib/react-spreadsheet-import/LICENSE
Normal 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.
|
||||||
341
inventory/src/lib/react-spreadsheet-import/README.md
Normal file
341
inventory/src/lib/react-spreadsheet-import/README.md
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
<h1 align="center">RSI react-spreadsheet-import ⚡️</h1>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|

|
||||||
|
 [](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 />
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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`
|
||||||
23367
inventory/src/lib/react-spreadsheet-import/package-lock.json
generated
Normal file
23367
inventory/src/lib/react-spreadsheet-import/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
174
inventory/src/lib/react-spreadsheet-import/package.json
Normal file
174
inventory/src/lib/react-spreadsheet-import/package.json
Normal 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!"
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
2
inventory/src/lib/react-spreadsheet-import/src/index.ts
Normal file
2
inventory/src/lib/react-spreadsheet-import/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { StepType } from "./steps/UploadFlow"
|
||||||
|
export { ReactSpreadsheetImport } from "./ReactSpreadsheetImport"
|
||||||
@@ -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} />}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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) || []
|
||||||
@@ -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 : []
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const booleanWhitelist: Record<string, boolean> = {
|
||||||
|
yes: true,
|
||||||
|
no: false,
|
||||||
|
true: true,
|
||||||
|
false: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeCheckboxValue = (value: string | undefined): boolean => {
|
||||||
|
if (value && value.toLowerCase() in booleanWhitelist) {
|
||||||
|
return booleanWhitelist[value.toLowerCase()]
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,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>),
|
||||||
|
)
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 />
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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"} />
|
||||||
|
}
|
||||||
@@ -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)"
|
||||||
|
/>
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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>),
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
}
|
||||||
@@ -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"],
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
509
inventory/src/lib/react-spreadsheet-import/src/theme.ts
Normal file
509
inventory/src/lib/react-spreadsheet-import/src/theme.ts
Normal 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>
|
||||||
@@ -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
|
||||||
160
inventory/src/lib/react-spreadsheet-import/src/types.ts
Normal file
160
inventory/src/lib/react-spreadsheet-import/src/types.ts
Normal 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)[]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}, {}),
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
99
inventory/src/pages/import/Import.tsx
Normal file
99
inventory/src/pages/import/Import.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user