├── .editorconfig
├── .github
└── workflows
│ └── test.yml
├── .gitignore
├── .prettierrc
├── .storybook
├── main.js
└── preview.js
├── LICENSE.md
├── README.md
├── demo-sandbox
├── .gitignore
├── .prettierrc
├── index.css
├── index.jsx
└── package.json
├── package.json
├── react-csv-importer-demo-20200915.gif
├── src
├── .eslintrc.json
├── .stylelintrc
├── components
│ ├── IconButton.scss
│ ├── IconButton.tsx
│ ├── Importer.scss
│ ├── Importer.stories.tsx
│ ├── Importer.tsx
│ ├── ImporterField.tsx
│ ├── ImporterFrame.scss
│ ├── ImporterFrame.tsx
│ ├── ImporterProps.ts
│ ├── ProgressDisplay.scss
│ ├── ProgressDisplay.tsx
│ ├── TextButton.scss
│ ├── TextButton.tsx
│ ├── fields-step
│ │ ├── ColumnDragCard.scss
│ │ ├── ColumnDragCard.tsx
│ │ ├── ColumnDragObject.scss
│ │ ├── ColumnDragObject.tsx
│ │ ├── ColumnDragSourceArea.scss
│ │ ├── ColumnDragSourceArea.tsx
│ │ ├── ColumnDragState.tsx
│ │ ├── ColumnDragTargetArea.scss
│ │ ├── ColumnDragTargetArea.tsx
│ │ ├── ColumnPreview.tsx
│ │ └── FieldsStep.tsx
│ └── file-step
│ │ ├── FileSelector.scss
│ │ ├── FileSelector.tsx
│ │ ├── FileStep.scss
│ │ ├── FileStep.tsx
│ │ ├── FormatDataRowPreview.scss
│ │ ├── FormatDataRowPreview.tsx
│ │ ├── FormatErrorMessage.scss
│ │ ├── FormatErrorMessage.tsx
│ │ ├── FormatRawPreview.scss
│ │ └── FormatRawPreview.tsx
├── index.ts
├── locale
│ ├── ImporterLocale.ts
│ ├── LocaleContext.tsx
│ ├── index.ts
│ ├── locale_daDK.ts
│ ├── locale_deDE.ts
│ ├── locale_enUS.ts
│ ├── locale_itIT.ts
│ ├── locale_ptBR.ts
│ └── locale_trTR.ts
├── parser.ts
└── theme.scss
├── test
├── .eslintrc.json
├── basics.test.ts
├── bom.test.ts
├── customConfig.test.ts
├── encoding.test.ts
├── fixtures
│ ├── bom.csv
│ ├── customDelimited.txt
│ ├── encodingWindows1250.csv
│ ├── noeof.csv
│ └── simple.csv
├── noeof.test.ts
├── public
│ └── index.html
├── testServer.ts
├── uiSetup.ts
└── webdriver.ts
├── tsconfig.base.json
├── tsconfig.json
├── webpack.config.js
└── yarn.lock
/.editorconfig:
--------------------------------------------------------------------------------
1 | [*]
2 | end_of_line = lf
3 | trim_trailing_whitespace = true
4 | insert_final_newline = true
5 | indent_style = space
6 | indent_size = 2
7 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: e2e tests
2 |
3 | on:
4 | push:
5 | branches: [master]
6 | pull_request:
7 |
8 | jobs:
9 | test:
10 | runs-on: ubuntu-latest
11 |
12 | strategy:
13 | matrix:
14 | node-version: [16.x]
15 |
16 | steps:
17 | - uses: actions/checkout@v3
18 | - name: Use Node.js ${{ matrix.node-version }}
19 | uses: actions/setup-node@v3
20 | with:
21 | node-version: ${{ matrix.node-version }}
22 | - run: yarn --frozen-lockfile
23 | - run: yarn test-prep
24 | - run: yarn test
25 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /dist
3 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 80,
3 | "singleQuote": true,
4 | "trailingComma": "none"
5 | }
6 |
--------------------------------------------------------------------------------
/.storybook/main.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
3 | addons: [
4 | '@storybook/addon-links',
5 | '@storybook/addon-essentials',
6 | '@storybook/preset-scss'
7 | ]
8 | };
9 |
--------------------------------------------------------------------------------
/.storybook/preview.js:
--------------------------------------------------------------------------------
1 |
2 | export const parameters = {
3 | actions: { argTypesRegex: "^on[A-Z].*" },
4 | }
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Beamworks Enterprise Software Inc.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React CSV Importer
2 |
3 | [](https://www.npmjs.com/package/react-csv-importer) [](https://github.com/beamworks/react-csv-importer/actions)
4 |
5 | This library combines an uploader + CSV parser + raw file preview + UI for custom user column
6 | mapping, all in one.
7 |
8 | Use this to provide a typical bulk data import experience:
9 |
10 | - 📤 drag-drop or select a file for upload
11 | - 👓 preview the raw uploaded data
12 | - ✏ pick which columns to import
13 | - ⏳ wait for backend logic to finish processing data
14 |
15 | 
16 |
17 | [Try it in the live code sandbox](https://codesandbox.io/s/github/beamworks/react-csv-importer/tree/master/demo-sandbox)
18 |
19 | ### Feature summary:
20 |
21 | - raw file preview
22 | - drag-drop UI to remap input columns as needed
23 | - i18n (EN, DA, DE, IT, PT, TR or custom)
24 | - screen reader accessibility (yes, really!)
25 | - keyboard a11y
26 | - standalone CSS stylesheet (no frameworks required)
27 | - existing parser implementation: Papa Parse CSV
28 | - TypeScript support
29 |
30 | ### Enterprise-level data file handling:
31 |
32 | - 1GB+ CSV file size (true streaming support without crashing browser)
33 | - automatically strip leading BOM character in data
34 | - async parsing logic (pause file read while your app makes backend updates)
35 | - fixes a [multibyte streaming issue in PapaParse](https://github.com/mholt/PapaParse/issues/908)
36 |
37 | ## Install
38 |
39 | ```sh
40 | # using NPM
41 | npm install --save react-csv-importer
42 |
43 | # using Yarn
44 | yarn add react-csv-importer
45 | ```
46 |
47 | Make sure that the bundled CSS stylesheet (`/dist/index.css`) is present in your app's page or bundle.
48 |
49 | This package is easy to fork with your own customizations, and you can use your fork directly as a [Git dependency](https://docs.npmjs.com/cli/v7/configuring-npm/package-json#git-urls-as-dependencies) in any of your projects, see below. For simple CSS customization you can also just override the built-in styling with your own style rules.
50 |
51 | ## How It Works
52 |
53 | Render the React CSV Importer UI component where you need it in your app. This will present the upload widget to the user. After a file is selected and reviewed by the user, CSV file data is parsed in-browser and passed to your front-end code as a list of JSON objects. Each object will have fields corresponding to the columns that the user selected.
54 |
55 | Large files (can be up to 1GB and more!) are parsed in chunks: return a promise from your data handler and the file reader will pause until you are ready for more data.
56 |
57 | Instead of a custom CSV parser this library uses the popular Papa Parse CSV reader. Because the file reader runs in-browser, your backend (if you have one) never has to deal with raw CSV data.
58 |
59 | ## Example Usage
60 |
61 | ```js
62 | import { Importer, ImporterField } from 'react-csv-importer';
63 |
64 | // include the widget CSS file whichever way your bundler supports it
65 | import 'react-csv-importer/dist/index.css';
66 |
67 | // in your component code:
68 | {
70 | // required, may be called several times
71 | // receives a list of parsed objects based on defined fields and user column mapping;
72 | // (if this callback returns a promise, the widget will wait for it before parsing more data)
73 | for (row of rows) {
74 | await myAppMethod(row);
75 | }
76 | }}
77 | defaultNoHeader={false} // optional, keeps "data has headers" checkbox off by default
78 | restartable={false} // optional, lets user choose to upload another file when import is complete
79 | onStart={({ file, preview, fields, columnFields }) => {
80 | // optional, invoked when user has mapped columns and started import
81 | prepMyAppForIncomingData();
82 | }}
83 | onComplete={({ file, preview, fields, columnFields }) => {
84 | // optional, invoked right after import is done (but user did not dismiss/reset the widget yet)
85 | showMyAppToastNotification();
86 | }}
87 | onClose={({ file, preview, fields, columnFields }) => {
88 | // optional, if this is specified the user will see a "Finish" button after import is done,
89 | // which will call this when clicked
90 | goToMyAppNextPage();
91 | }}
92 |
93 | // CSV options passed directly to PapaParse if specified:
94 | // delimiter={...}
95 | // newline={...}
96 | // quoteChar={...}
97 | // escapeChar={...}
98 | // comments={...}
99 | // skipEmptyLines={...}
100 | // delimitersToGuess={...}
101 | // chunkSize={...} // defaults to 10000
102 | // encoding={...} // defaults to utf-8, see FileReader API
103 | >
104 |
105 |
106 |
107 |
108 | ;
109 | ```
110 |
111 | In the above example, if the user uploads a CSV file with column headers "Name", "Email" and so on, the columns will be automatically matched to fields with same labels. If any of the headers do not match, the user will have an opportunity to manually remap columns to the defined fields.
112 |
113 | The `preview` object available to some callbacks above contains a snippet of CSV file information (only the first portion of the file is read, not the entire thing). The structure is:
114 |
115 | ```js
116 | {
117 | rawData: '...', // raw string contents of first file chunk
118 | columns: [ // array of preview columns, e.g.:
119 | { index: 0, header: 'Date', values: [ '2020-09-20', '2020-09-25' ] },
120 | { index: 1, header: 'Name', values: [ 'Alice', 'Bob' ] }
121 | ],
122 | skipHeaders: false, // true when user selected that data has no headers
123 | parseWarning: undefined, // any non-blocking warning object produced by Papa Parse
124 | }
125 | ```
126 |
127 | Importer component children may be defined as a render-prop function that receives an object with `preview` and `file` fields (see above). It can then, for example, dynamically return different fields depending which headers are present in the CSV.
128 |
129 | For more, please see [storybook examples](src/components/Importer.stories.tsx).
130 |
131 | ## Internationalization (i18n) and Localization (l10n)
132 |
133 | You can swap the text used in the UI to a different locale.
134 |
135 | ```
136 | import { Importer, deDE } from 'react-csv-importer';
137 |
138 | // provide the locale to main UI
139 |
143 | ```
144 |
145 | These locales are provided as part of the NPM module:
146 |
147 | - `en-US`
148 | - `de-DE`
149 | - `it-IT`
150 | - `pt-BR`
151 | - `da-DK`
152 | - `tr-TR`
153 |
154 | You can also pass your own fully custom locale definition as the locale value. See `ImporterLocale` interface in `src/locale` for the full definition, and use an existing locale like `en-US` as basis. For better performance, please ensure that the customized locale value does not change on every render.
155 |
156 | ## Dependencies
157 |
158 | - [Papa Parse](https://www.papaparse.com/) for CSV parsing
159 | - [react-dropzone](https://react-dropzone.js.org/) for file upload
160 | - [@use-gesture/react](https://github.com/pmndrs/use-gesture) for drag-and-drop
161 |
162 | ## Local Development
163 |
164 | Perform local `git clone`, etc. Then ensure modules are installed:
165 |
166 | ```sh
167 | yarn
168 | ```
169 |
170 | To start Storybook to have a hot-reloaded local sandbox:
171 |
172 | ```sh
173 | yarn storybook
174 | ```
175 |
176 | To run the end-to-end test suite:
177 |
178 | ```sh
179 | yarn test
180 | ```
181 |
182 | You can use your own fork of this library in your own project by referencing the forked repo as a [Git dependency](https://docs.npmjs.com/cli/v7/configuring-npm/package-json#git-urls-as-dependencies). NPM will then run the `prepare` script, which runs the same Webpack/dist command as when the NPM package is published, so your custom dependency should work just as conveniently as the stock NPM version. Of course if your custom fixes could be useful to the rest of us then please submit a PR to this repo!
183 |
184 | ## Changes
185 |
186 | - 0.8.1
187 | - fix double-start issue for React 18 dev mode
188 | - 0.8.0
189 | - more translations (thanks [**@p-multani-0**](https://github.com/p-multani-0), [**@GuilhermeMelati**](https://github.com/GuilhermeMelati), [**@tobiaskkd**](https://github.com/tobiaskkd) and [**@memoricab**](https://github.com/memoricab))
190 | - refactor to work with later versions of @use-gesture/react (thanks [**@dbismut**](https://github.com/dbismut))
191 | - upgrade to newer version of react-dropzone
192 | - rename assumeNoHeaders to defaultNoHeader (with deprecation warning)
193 | - rename processChunk to dataHandler (with deprecation warning)
194 | - expose display width customization (`displayColumnPageSize`, `displayFieldRowSize`)
195 | - bug fixes for button type and labels
196 | - 0.7.1
197 | - fix peerDependencies for React 18+ (thanks [**@timarney**](https://github.com/timarney))
198 | - hide Finish button by default
199 | - button label tweaks
200 | - 0.7.0
201 | - add i18n (thanks [**@tstehr**](https://github.com/tstehr) and [**@Valodim**](https://github.com/Valodim))
202 | - 0.6.0
203 | - improve multibyte stream parsing safety
204 | - support all browser encodings via TextDecoder
205 | - remove readable-web-to-node-stream dependency
206 | - bug fix for preview of short no-EOL files
207 | - 0.5.2
208 | - update readable-web-to-node-stream to have stream shim
209 | - use npm prepare script for easier fork installs
210 | - 0.5.1
211 | - correctly use custom Papa Parse config for the main processing stream
212 | - drag-drop fixes on scrolled pages
213 | - bug fixes for older Safari, mobile issues
214 | - 0.5.0
215 | - report file preview to callbacks and render-prop
216 | - report startIndex in processChunk callback
217 | - 0.4.1
218 | - clearer error display
219 | - add more information about ongoing import
220 | - 0.4.0
221 | - auto-assign column headers
222 | - 0.3.0
223 | - allow passing PapaParse config options
224 | - 0.2.3
225 | - tweak TS compilation targets
226 | - live editable sandbox link in docs
227 | - 0.2.2
228 | - empty file checks
229 | - fix up package metadata
230 | - extra docs
231 | - 0.2.1
232 | - update index.d.ts generation
233 | - 0.2.0
234 | - bundling core package using Webpack
235 | - added source maps
236 | - 0.1.0
237 | - first beta release
238 | - true streaming support using shim for PapaParse
239 | - lifecycle hooks receive info about the import
240 |
--------------------------------------------------------------------------------
/demo-sandbox/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 |
3 | # ignore this lockfile to keep versioning simple
4 | /yarn.lock
5 |
--------------------------------------------------------------------------------
/demo-sandbox/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 80,
3 | "singleQuote": false
4 | }
5 |
--------------------------------------------------------------------------------
/demo-sandbox/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: Arial, Helvetica, sans-serif;
3 | padding: 1em;
4 | background: #e0f4f8;
5 | background-image: radial-gradient(#d0e0e0 1px, transparent 0);
6 | background-size: 24px 24px;
7 | }
8 |
9 | h1 {
10 | color: #304040;
11 | }
12 |
--------------------------------------------------------------------------------
/demo-sandbox/index.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 | import { Importer, ImporterField } from "react-csv-importer";
4 |
5 | // theme CSS for React CSV Importer
6 | import "react-csv-importer/dist/index.css";
7 |
8 | // basic styling and font for sandbox window
9 | import "./index.css";
10 |
11 | // sample importer usage snippet, play around with the settings and try it out!
12 | // (open console output to see sample results)
13 | ReactDOM.render(
14 |
15 |
React CSV Importer sandbox
16 |
17 | {
19 | // required, receives a list of parsed objects based on defined fields and user column mapping;
20 | // may be called several times if file is large
21 | // (if this callback returns a promise, the widget will wait for it before parsing more data)
22 | console.log("received batch of rows", rows);
23 |
24 | // mock timeout to simulate processing
25 | await new Promise((resolve) => setTimeout(resolve, 500));
26 | }}
27 | chunkSize={10000} // optional, internal parsing chunk size in bytes
28 | defaultNoHeader={false} // optional, keeps "data has headers" checkbox off by default
29 | restartable={false} // optional, lets user choose to upload another file when import is complete
30 | onStart={({ file, fields }) => {
31 | // optional, invoked when user has mapped columns and started import
32 | console.log("starting import of file", file, "with fields", fields);
33 | }}
34 | onComplete={({ file, fields }) => {
35 | // optional, invoked right after import is done (but user did not dismiss/reset the widget yet)
36 | console.log("finished import of file", file, "with fields", fields);
37 | }}
38 | onClose={() => {
39 | // optional, invoked when import is done and user clicked "Finish"
40 | // (if this is not specified, the widget lets the user upload another file)
41 | console.log("importer dismissed");
42 | }}
43 | >
44 |
45 |
46 |
47 |
48 |
49 | ,
50 | document.getElementById("root")
51 | );
52 |
--------------------------------------------------------------------------------
/demo-sandbox/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "demo-sandbox",
3 | "version": "0.0.1",
4 | "description": "Sample react-csv-importer usage snippet",
5 | "main": "index.jsx",
6 | "author": "Nick Matantsev ",
7 | "license": "ISC",
8 | "dependencies": {
9 | "react": "^18.0.0",
10 | "react-csv-importer": "^0.8.1",
11 | "react-dom": "^18.0.0"
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-csv-importer",
3 | "version": "0.8.1",
4 | "description": "React CSV import widget with user-customizable mapping",
5 | "keywords": [
6 | "react",
7 | "csv",
8 | "upload",
9 | "parser",
10 | "import",
11 | "preview",
12 | "raw preview",
13 | "TextDecoder",
14 | "papa parse",
15 | "papaparse"
16 | ],
17 | "repository": {
18 | "type": "git",
19 | "url": "https://github.com/beamworks/react-csv-importer"
20 | },
21 | "main": "dist/index.js",
22 | "types": "dist/index.d.ts",
23 | "files": [
24 | "dist/**"
25 | ],
26 | "scripts": {
27 | "prepare": "webpack --mode production && dts-bundle-generator -o dist/index.d.ts src/index.ts",
28 | "lint": "eslint --max-warnings=0 --ext ts --ext tsx src",
29 | "lint-fix": "eslint --max-warnings=0 --ext ts --ext tsx src --fix",
30 | "stylelint": "stylelint \"src/**/*.scss\"",
31 | "stylelint-fix": "stylelint \"src/**/*.scss\" --fix",
32 | "test-prep": "yarn global add chromedriver@latest",
33 | "test": "cross-env TS_NODE_COMPILER_OPTIONS={\\\"module\\\":\\\"commonjs\\\"} mocha --require ts-node/register --timeout 30000 test/**/*.test.ts",
34 | "storybook": "start-storybook -p 6006",
35 | "build-storybook": "build-storybook",
36 | "dist": "yarn prepare"
37 | },
38 | "author": "Nick Matantsev ",
39 | "license": "MIT",
40 | "husky": {
41 | "hooks": {
42 | "pre-commit": "lint-staged"
43 | }
44 | },
45 | "lint-staged": {
46 | "src/**/*.{ts,tsx}": "eslint --max-warnings=0",
47 | "src/**/*.scss": "stylelint",
48 | "test/**/*.{js,ts}": "eslint --max-warnings=0"
49 | },
50 | "devDependencies": {
51 | "@babel/core": "^7.11.6",
52 | "@storybook/addon-actions": "^6.0.21",
53 | "@storybook/addon-essentials": "^6.0.21",
54 | "@storybook/addon-links": "^6.0.21",
55 | "@storybook/preset-scss": "^1.0.2",
56 | "@storybook/react": "^6.0.21",
57 | "@types/chai": "^4.2.14",
58 | "@types/mocha": "^8.2.0",
59 | "@types/papaparse": "^5.2.2",
60 | "@types/react": "^16.9.49",
61 | "@types/react-dom": "^16.9.8",
62 | "@types/selenium-webdriver": "^4.0.11",
63 | "@types/webpack-dev-server": "^3.11.1",
64 | "@typescript-eslint/eslint-plugin": "^4.1.0",
65 | "@typescript-eslint/parser": "^4.1.0",
66 | "babel-loader": "^8.1.0",
67 | "chai": "^4.2.0",
68 | "clean-webpack-plugin": "^3.0.0",
69 | "cross-env": "^7.0.3",
70 | "css-loader": "^4.3.0",
71 | "dotenv-webpack": "^2.0.0",
72 | "dts-bundle-generator": "^6.0.0",
73 | "eslint": "^7.8.1",
74 | "eslint-config-prettier": "^6.11.0",
75 | "eslint-plugin-prettier": "^3.1.4",
76 | "eslint-plugin-react": "^7.20.6",
77 | "eslint-plugin-react-hooks": "^4.1.0",
78 | "expose-loader": "^1.0.3",
79 | "file-loader": "^6.1.0",
80 | "husky": "^4.3.0",
81 | "lint-staged": "^10.3.0",
82 | "mini-css-extract-plugin": "^0.11.1",
83 | "mocha": "^8.2.1",
84 | "prettier": "^2.1.1",
85 | "react": "^16.8.3",
86 | "react-dom": "^16.8.3",
87 | "react-is": "^16.13.1",
88 | "rimraf": "^3.0.2",
89 | "sass": "^1.26.10",
90 | "sass-loader": "^10.0.2",
91 | "selenium-webdriver": "^4.0.0-alpha.8",
92 | "style-loader": "^1.2.1",
93 | "stylelint": "^13.7.0",
94 | "stylelint-config-standard": "^20.0.0",
95 | "stylelint-order": "^4.1.0",
96 | "stylelint-scss": "^3.18.0",
97 | "ts-loader": "^8.0.3",
98 | "ts-node": "^9.1.1",
99 | "typescript": "^4.0.2",
100 | "webpack": "^4.44.1",
101 | "webpack-cli": "^3.3.12",
102 | "webpack-dev-server": "^3.7.0"
103 | },
104 | "peerDependencies": {
105 | "react": "^16.8.0 || >=17.0.0",
106 | "react-dom": "^16.8.0 || >=17.0.0"
107 | },
108 | "dependencies": {
109 | "@use-gesture/react": "^10.2.11",
110 | "papaparse": "^5.3.0",
111 | "react-dropzone": "^12.1.0"
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/react-csv-importer-demo-20200915.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/beamworks/react-csv-importer/775a75cce773101356fc9d201bf53196cd3323b7/react-csv-importer-demo-20200915.gif
--------------------------------------------------------------------------------
/src/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es6": true
5 | },
6 | "settings": {
7 | "react": {
8 | "version": "detect"
9 | }
10 | },
11 | "parser": "@typescript-eslint/parser",
12 | "extends": [
13 | "eslint:recommended",
14 | "plugin:@typescript-eslint/recommended",
15 | "plugin:react/recommended",
16 | "plugin:react-hooks/recommended",
17 | "plugin:prettier/recommended",
18 | "prettier/@typescript-eslint",
19 | "prettier/react"
20 | ],
21 | "plugins": ["@typescript-eslint", "react", "react-hooks"],
22 | "rules": {
23 | "react/prop-types": "off"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/.stylelintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "stylelint-config-standard",
3 | "plugins": [
4 | "stylelint-scss",
5 | "stylelint-order"
6 | ],
7 | "rules": {
8 | "at-rule-no-unknown": null,
9 | "scss/at-rule-no-unknown": true,
10 | "declaration-empty-line-before": [ "always", { "except": "first-nested", "ignore": [ "after-declaration", "after-comment" ] } ],
11 | "no-descending-specificity": null,
12 | "order/order": [
13 | { "type": "at-rule", "name": "include" },
14 | "custom-properties",
15 | "declarations",
16 | { "type": "at-rule", "name": "media", "hasBlock": true },
17 | "rules"
18 | ],
19 | "order/properties-order": [
20 | "content",
21 | "position",
22 | {
23 | "groupName": "layoutChildOptions",
24 | "properties": [
25 | "top",
26 | "right",
27 | "bottom",
28 | "left",
29 | "z-index",
30 | "clear",
31 | "float",
32 | "align-self",
33 | "flex",
34 | "flex-basis",
35 | "flex-grow",
36 | "flex-shrink",
37 | "order"
38 | ]
39 | },
40 | "display",
41 | "visibility",
42 | "appearance",
43 | {
44 | "groupName": "layoutContainerOptions",
45 | "properties": [
46 | "table-layout",
47 | "flex-direction",
48 | "flex-flow",
49 | "flex-wrap",
50 | "align-content",
51 | "align-items",
52 | "justify-content"
53 | ]
54 | },
55 | {
56 | "groupName": "blockOuter",
57 | "properties": [
58 | "margin",
59 | "margin-top",
60 | "margin-right",
61 | "margin-bottom",
62 | "margin-left"
63 | ]
64 | },
65 | {
66 | "groupName": "blockSize",
67 | "properties": [
68 | "box-sizing",
69 | "max-width",
70 | "max-height",
71 | "min-width",
72 | "min-height",
73 | "width",
74 | "height"
75 | ]
76 | },
77 | {
78 | "groupName": "blockInner",
79 | "properties": [
80 | "border",
81 | "border-top",
82 | "border-right",
83 | "border-bottom",
84 | "border-left",
85 | "padding",
86 | "padding-top",
87 | "padding-right",
88 | "padding-bottom",
89 | "padding-left",
90 | "overflow",
91 | "overflow-x",
92 | "overflow-y",
93 | "border-radius",
94 | "border-top-left-radius",
95 | "border-top-right-radius",
96 | "border-bottom-right-radius",
97 | "border-bottom-left-radius",
98 | "background",
99 | "background-attachment",
100 | "background-blend-mode",
101 | "background-color",
102 | "background-image",
103 | "background-position",
104 | "background-repeat",
105 | "background-size",
106 | "box-shadow"
107 | ]
108 | },
109 | {
110 | "groupName": "typography",
111 | "properties": [
112 | "text-align",
113 | "text-indent",
114 | "list-style",
115 | "list-style-position",
116 | "line-height",
117 | "font-family",
118 | "font-size",
119 | "font-style",
120 | "font-weight",
121 | "letter-spacing",
122 | "color",
123 | "text-decoration",
124 | "text-overflow",
125 | "text-transform"
126 | ]
127 | },
128 | {
129 | "groupName": "transform",
130 | "properties": [
131 | "transform",
132 | "transform-origin",
133 | "transform-perspective"
134 | ]
135 | },
136 | {
137 | "groupName": "compositing",
138 | "properties": [
139 | "clip",
140 | "fill",
141 | "mix-blend-mode",
142 | "opacity"
143 | ]
144 | },
145 | {
146 | "groupName": "animation",
147 | "properties": [
148 | "transition",
149 | "animation",
150 | "animation-name",
151 | "animation-timing-function",
152 | "animation-delay",
153 | "animation-duration",
154 | "animation-direction",
155 | "animation-fill-mode",
156 | "animation-iteration-count",
157 | "animation-play-state",
158 | "will-change"
159 | ]
160 | },
161 | "cursor"
162 | ]
163 | }
164 | }
165 |
--------------------------------------------------------------------------------
/src/components/IconButton.scss:
--------------------------------------------------------------------------------
1 | @import '../theme.scss';
2 |
3 | .CSVImporter_IconButton {
4 | display: flex;
5 | align-items: center;
6 | justify-content: center;
7 | margin: 0; // override default
8 | width: 3em;
9 | height: 3em;
10 | border: 0;
11 | padding: 0;
12 | border-radius: 50%;
13 | background: transparent;
14 | font-size: inherit;
15 | color: $fgColor;
16 | cursor: pointer;
17 |
18 | &:hover:not(:disabled) {
19 | background: rgba($controlBorderColor, 0.25);
20 | }
21 |
22 | &:disabled {
23 | cursor: default;
24 | }
25 |
26 | &[data-small='true'] {
27 | width: 2em;
28 | height: 2em;
29 | }
30 |
31 | &[data-focus-only='true'] {
32 | opacity: 0;
33 | pointer-events: none;
34 |
35 | &:focus {
36 | opacity: 1;
37 | }
38 | }
39 |
40 | > span {
41 | display: block;
42 | width: 1.75em;
43 | height: 1.75em;
44 | background-position: 50% 50%;
45 | background-repeat: no-repeat;
46 | background-size: cover;
47 |
48 | &[data-type='back'] {
49 | // MUI ChevronLeft
50 | background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZvY3VzYWJsZT0iZmFsc2UiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE1LjQxIDcuNDFMMTQgNmwtNiA2IDYgNiAxLjQxLTEuNDFMMTAuODMgMTJ6Ij48L3BhdGg+PC9zdmc+');
51 | }
52 |
53 | &[data-type='forward'] {
54 | // MUI ChevronRight
55 | background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZvY3VzYWJsZT0iZmFsc2UiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwIDZMOC41OSA3LjQxIDEzLjE3IDEybC00LjU4IDQuNTlMMTAgMThsNi02eiI+PC9wYXRoPjwvc3ZnPg==');
56 | }
57 |
58 | &[data-type='replay'] {
59 | // MUI Replay
60 | background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZvY3VzYWJsZT0iZmFsc2UiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyIDVWMUw3IDZsNSA1VjdjMy4zMSAwIDYgMi42OSA2IDZzLTIuNjkgNi02IDYtNi0yLjY5LTYtNkg0YzAgNC40MiAzLjU4IDggOCA4czgtMy41OCA4LTgtMy41OC04LTgtOHoiPjwvcGF0aD48L3N2Zz4=');
61 | }
62 |
63 | &[data-type='arrowBack'] {
64 | // MUI ArrowBack
65 | background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZvY3VzYWJsZT0iZmFsc2UiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTIwIDExSDcuODNsNS41OS01LjU5TDEyIDRsLTggOCA4IDggMS40MS0xLjQxTDcuODMgMTNIMjB2LTJ6Ij48L3BhdGg+PC9zdmc+');
66 | }
67 |
68 | &[data-type='close'] {
69 | // MUI Close
70 | background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZvY3VzYWJsZT0iZmFsc2UiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE5IDYuNDFMMTcuNTkgNSAxMiAxMC41OSA2LjQxIDUgNSA2LjQxIDEwLjU5IDEyIDUgMTcuNTkgNi40MSAxOSAxMiAxMy40MSAxNy41OSAxOSAxOSAxNy41OSAxMy40MSAxMnoiPjwvcGF0aD48L3N2Zz4=');
71 | }
72 | }
73 |
74 | &:disabled > span {
75 | opacity: 0.25;
76 | }
77 |
78 | &[data-small='true'] > span {
79 | font-size: 0.75em;
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/src/components/IconButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import './IconButton.scss';
4 |
5 | export const IconButton: React.FC<{
6 | label: string;
7 | type: 'back' | 'forward' | 'replay' | 'arrowBack' | 'close';
8 | small?: boolean;
9 | focusOnly?: boolean;
10 | disabled?: boolean;
11 | onClick?: () => void;
12 | }> = ({ type, label, small, focusOnly, disabled, onClick }) => {
13 | return (
14 |
23 |
24 |
25 | );
26 | };
27 |
--------------------------------------------------------------------------------
/src/components/Importer.scss:
--------------------------------------------------------------------------------
1 | .CSVImporter_Importer {
2 | // base styling for all content
3 | box-sizing: border-box;
4 | line-height: 1.4;
5 |
6 | * {
7 | box-sizing: border-box;
8 | }
9 | }
10 |
11 | // prevent text selection while dragging on mobile
12 | // (must be on body per https://www.reddit.com/r/webdev/comments/g1wvsb/ios_safari_how_to_disable_longpress_text_selection/)
13 | body.CSVImporter_dragging {
14 | -webkit-user-select: none; // needed for Safari
15 | user-select: none;
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/Importer.stories.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { Story, Meta } from '@storybook/react';
3 |
4 | import { ImporterProps } from './ImporterProps';
5 | import { Importer, ImporterField } from './Importer';
6 | import { deDE } from '../locale';
7 |
8 | export default {
9 | title: 'Importer',
10 | component: Importer,
11 | parameters: {
12 | actions: { argTypesRegex: '^on.*|dataHandler' }
13 | }
14 | } as Meta;
15 |
16 | type SampleImporterProps = ImporterProps<{ fieldA: string }>;
17 |
18 | export const Main: Story = (args: SampleImporterProps) => {
19 | return (
20 |
21 |
22 |
23 |
24 | );
25 | };
26 |
27 | export const LocaleDE: Story = (
28 | args: SampleImporterProps
29 | ) => {
30 | return (
31 |
32 |
33 |
34 |
35 | );
36 | };
37 |
38 | export const Timesheet: Story = (
39 | args: SampleImporterProps
40 | ) => {
41 | return (
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | );
51 | };
52 |
53 | export const CustomDelimiterConfig: Story = (
54 | args: SampleImporterProps
55 | ) => {
56 | return (
57 |
58 |
59 |
60 |
61 | );
62 | };
63 |
64 | CustomDelimiterConfig.args = {
65 | delimiter: '!' // use a truly unusual delimiter that PapaParse would not guess normally
66 | };
67 |
68 | export const InsideScrolledPage: Story = (
69 | args: SampleImporterProps
70 | ) => {
71 | return (
72 |
73 | Scroll below
74 |
75 |
76 |
77 |
78 |
79 |
80 | );
81 | };
82 |
83 | export const CustomWidth: Story = (
84 | args: SampleImporterProps
85 | ) => {
86 | return (
87 |
88 |
89 |
90 |
91 |
92 |
93 | );
94 | };
95 |
96 | CustomWidth.args = {
97 | displayColumnPageSize: 2, // fewer columns for e.g. a narrower display
98 | displayFieldRowSize: 3 // fewer columns for e.g. a narrower display
99 | };
100 |
101 | export const RenderProp: Story = (
102 | args: SampleImporterProps
103 | ) => {
104 | return (
105 |
106 | {({ preview }) => {
107 | return (
108 | <>
109 |
110 |
111 |
112 | {preview &&
113 | preview.columns.map(({ header, index }) =>
114 | header ? (
115 |
120 | ) : null
121 | )}
122 | >
123 | );
124 | }}
125 |
126 | );
127 | };
128 |
129 | const PresetSelector: React.FC<{
130 | children: (fieldContent: React.ReactNode) => React.ReactElement;
131 | }> = ({ children }) => {
132 | const [selection, setSelection] = useState('Person');
133 |
134 | return (
135 |
136 |
137 | setSelection(event.target.value)}
141 | >
142 | Person
143 | Car
144 |
145 |
146 |
147 | {children(
148 | selection === 'Person' ? (
149 | <>
150 |
151 |
152 | >
153 | ) : (
154 | <>
155 |
156 |
157 | >
158 | )
159 | )}
160 |
161 | );
162 | };
163 |
164 | export const ChooseFieldPresets: Story = (
165 | args: SampleImporterProps
166 | ) => {
167 | return (
168 |
169 | {(fields) => {fields} }
170 |
171 | );
172 | };
173 |
--------------------------------------------------------------------------------
/src/components/Importer.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo, useState, useEffect } from 'react';
2 |
3 | import { BaseRow } from '../parser';
4 | import { FileStep, FileStepState } from './file-step/FileStep';
5 | import { generatePreviewColumns } from './fields-step/ColumnPreview';
6 | import { FieldsStep, FieldsStepState } from './fields-step/FieldsStep';
7 | import { ProgressDisplay } from './ProgressDisplay';
8 | import { ImporterFilePreview, ImporterProps } from './ImporterProps';
9 |
10 | // re-export from a central spot
11 | export { ImporterField } from './ImporterField';
12 | import { useFieldDefinitions } from './ImporterField';
13 |
14 | import './Importer.scss';
15 | import { LocaleContext } from '../locale/LocaleContext';
16 | import { enUS } from '../locale';
17 |
18 | export function Importer(
19 | props: ImporterProps
20 | ): React.ReactElement {
21 | const {
22 | dataHandler,
23 | processChunk,
24 | defaultNoHeader,
25 | assumeNoHeaders,
26 | restartable,
27 | displayFieldRowSize,
28 | displayColumnPageSize,
29 | onStart,
30 | onComplete,
31 | onClose,
32 | children: content,
33 | locale: userLocale,
34 | ...customPapaParseConfig
35 | } = props;
36 |
37 | // helper to combine our displayed content and the user code that provides field definitions
38 | const [fields, userFieldContentWrapper] = useFieldDefinitions();
39 |
40 | const [fileState, setFileState] = useState(null);
41 | const [fileAccepted, setFileAccepted] = useState(false);
42 |
43 | const [fieldsState, setFieldsState] = useState(null);
44 | const [fieldsAccepted, setFieldsAccepted] = useState(false);
45 |
46 | // reset field assignments when file changes
47 | const activeFile = fileState && fileState.file;
48 | useEffect(() => {
49 | if (activeFile) {
50 | setFieldsState(null);
51 | }
52 | }, [activeFile]);
53 |
54 | const externalPreview = useMemo(() => {
55 | // generate stable externally-visible data objects
56 | const externalColumns =
57 | fileState &&
58 | generatePreviewColumns(fileState.firstRows, fileState.hasHeaders);
59 | return (
60 | fileState &&
61 | externalColumns && {
62 | rawData: fileState.firstChunk,
63 | columns: externalColumns,
64 | skipHeaders: !fileState.hasHeaders,
65 | parseWarning: fileState.parseWarning
66 | }
67 | );
68 | }, [fileState]);
69 |
70 | // fall back to enUS if no locale provided
71 | const locale = userLocale ?? enUS;
72 |
73 | if (!fileAccepted || fileState === null || externalPreview === null) {
74 | return (
75 |
76 |
77 | {
82 | setFileState(parsedPreview);
83 | }}
84 | onAccept={() => {
85 | setFileAccepted(true);
86 | }}
87 | />
88 |
89 |
90 | );
91 | }
92 |
93 | if (!fieldsAccepted || fieldsState === null) {
94 | return (
95 |
96 |
97 | {
104 | setFieldsState(state);
105 | }}
106 | onAccept={() => {
107 | setFieldsAccepted(true);
108 | }}
109 | onCancel={() => {
110 | // keep existing preview data and assignments
111 | setFileAccepted(false);
112 | }}
113 | />
114 |
115 | {userFieldContentWrapper(
116 | // render the provided child content that defines the fields
117 | typeof content === 'function'
118 | ? content({
119 | file: fileState && fileState.file,
120 | preview: externalPreview
121 | })
122 | : content
123 | )}
124 |
125 |
126 | );
127 | }
128 |
129 | return (
130 |
131 |
132 |
{
142 | // reset all state
143 | setFileState(null);
144 | setFileAccepted(false);
145 | setFieldsState(null);
146 | setFieldsAccepted(false);
147 | }
148 | : undefined
149 | }
150 | onComplete={onComplete}
151 | onClose={onClose}
152 | />
153 |
154 |
155 | );
156 | }
157 |
--------------------------------------------------------------------------------
/src/components/ImporterField.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo, useState, useEffect, useContext } from 'react';
2 |
3 | import { ImporterFieldProps } from './ImporterProps';
4 |
5 | export interface Field {
6 | name: string;
7 | label: string;
8 | isOptional: boolean;
9 | }
10 |
11 | // internal context for registering field definitions
12 | type FieldDef = Field & { instanceId: symbol };
13 | type FieldListSetter = (prev: FieldDef[]) => FieldDef[];
14 |
15 | const FieldDefinitionContext = React.createContext<
16 | ((setter: FieldListSetter) => void) | null
17 | >(null);
18 |
19 | // internal helper to allow user code to provide field definitions
20 | export function useFieldDefinitions(): [
21 | Field[],
22 | (content: React.ReactNode) => React.ReactElement
23 | ] {
24 | const [fields, setFields] = useState([]);
25 |
26 | const userFieldContentWrapper = (content: React.ReactNode) => (
27 |
28 | {content}
29 |
30 | );
31 |
32 | return [fields, userFieldContentWrapper];
33 | }
34 |
35 | // defines a field to be filled from file column during import
36 | export const ImporterField: React.FC = ({
37 | name,
38 | label,
39 | optional
40 | }) => {
41 | // make unique internal ID (this is never rendered in HTML and does not affect SSR)
42 | const instanceId = useMemo(() => Symbol('internal unique field ID'), []);
43 | const fieldSetter = useContext(FieldDefinitionContext);
44 |
45 | // update central list as needed
46 | useEffect(() => {
47 | if (!fieldSetter) {
48 | console.error('importer field must be a child of importer'); // @todo
49 | return;
50 | }
51 |
52 | fieldSetter((prev) => {
53 | const copy = [...prev];
54 | const existingIndex = copy.findIndex(
55 | (item) => item.instanceId === instanceId
56 | );
57 |
58 | // add or update the field definition instance in-place
59 | // (using internal field instance ID helps gracefully tolerate duplicates, renames, etc)
60 | const newField = {
61 | instanceId,
62 | name,
63 | label,
64 | isOptional: !!optional
65 | };
66 | if (existingIndex === -1) {
67 | copy.push(newField);
68 | } else {
69 | copy[existingIndex] = newField;
70 | }
71 |
72 | return copy;
73 | });
74 | }, [instanceId, fieldSetter, name, label, optional]);
75 |
76 | // on component unmount, remove this field from list by ID
77 | useEffect(() => {
78 | if (!fieldSetter) {
79 | console.error('importer field must be a child of importer'); // @todo
80 | return;
81 | }
82 |
83 | return () => {
84 | fieldSetter((prev) =>
85 | prev.filter((field) => field.instanceId !== instanceId)
86 | );
87 | };
88 | }, [instanceId, fieldSetter]);
89 |
90 | return null;
91 | };
92 |
--------------------------------------------------------------------------------
/src/components/ImporterFrame.scss:
--------------------------------------------------------------------------------
1 | @import '../theme.scss';
2 |
3 | // @todo use em instead of rem
4 | .CSVImporter_ImporterFrame {
5 | border: 1px solid $controlBorderColor;
6 | padding: 1.2em;
7 | border-radius: $borderRadius;
8 | background: $controlBgColor;
9 |
10 | &__header {
11 | display: flex;
12 | align-items: center;
13 | margin-top: -1em; // cancel out button padding
14 | margin-bottom: 0.2em;
15 | margin-left: -1em;
16 | }
17 |
18 | &__headerTitle {
19 | padding-bottom: 0.1em; // centering nudge
20 | overflow: hidden;
21 | font-size: $titleFontSize;
22 | color: $textColor;
23 | text-overflow: ellipsis;
24 | white-space: nowrap;
25 | }
26 |
27 | &__headerCrumbSeparator {
28 | flex: none;
29 | display: flex; // for correct icon alignment
30 | margin-right: 0.5em;
31 | margin-left: 0.5em;
32 | font-size: 1.2em;
33 | opacity: 0.5;
34 |
35 | > span {
36 | display: block;
37 | width: 1em;
38 | height: 1em;
39 | // MUI ChevronRight
40 | background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZvY3VzYWJsZT0iZmFsc2UiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwIDZMOC41OSA3LjQxIDEzLjE3IDEybC00LjU4IDQuNTlMMTAgMThsNi02eiI+PC9wYXRoPjwvc3ZnPg==');
41 | background-position: 50% 50%;
42 | background-repeat: no-repeat;
43 | background-size: cover;
44 | }
45 | }
46 |
47 | &__headerSubtitle {
48 | flex: none;
49 | padding-bottom: 0.1em; // centering nudge
50 | font-size: $titleFontSize;
51 | color: $textColor;
52 | }
53 |
54 | &__footer {
55 | display: flex;
56 | align-items: center;
57 |
58 | margin-top: 1.2em;
59 | }
60 |
61 | &__footerFill {
62 | flex: 1 1 0;
63 | }
64 |
65 | &__footerError {
66 | flex: none;
67 | line-height: 0.8; // in case of line break
68 | color: $errorTextColor;
69 | word-break: break-word;
70 | }
71 |
72 | &__footerSecondary {
73 | flex: none;
74 | display: flex; // for more consistent button alignment
75 | margin-left: 1em;
76 | }
77 |
78 | &__footerNext {
79 | flex: none;
80 | display: flex; // for more consistent button alignment
81 | margin-left: 1em;
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/components/ImporterFrame.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef, useEffect } from 'react';
2 |
3 | import { TextButton } from './TextButton';
4 | import { IconButton } from './IconButton';
5 |
6 | import './ImporterFrame.scss';
7 | import { useLocale } from '../locale/LocaleContext';
8 |
9 | export const ImporterFrame: React.FC<{
10 | fileName: string;
11 | subtitle?: string; // @todo allow multiple crumbs
12 | secondaryDisabled?: boolean;
13 | secondaryLabel?: string;
14 | nextDisabled?: boolean;
15 | nextLabel: string | false;
16 | error?: string | null;
17 | onSecondary?: () => void;
18 | onNext: () => void;
19 | onCancel?: () => void;
20 | }> = ({
21 | fileName,
22 | subtitle,
23 | secondaryDisabled,
24 | secondaryLabel,
25 | nextDisabled,
26 | nextLabel,
27 | error,
28 | onSecondary,
29 | onNext,
30 | onCancel,
31 | children
32 | }) => {
33 | const titleRef = useRef(null);
34 | const subtitleRef = useRef(null);
35 |
36 | useEffect(() => {
37 | if (subtitleRef.current) {
38 | subtitleRef.current.focus();
39 | } else if (titleRef.current) {
40 | titleRef.current.focus();
41 | }
42 | }, []);
43 |
44 | const l10n = useLocale('general');
45 |
46 | return (
47 |
48 |
49 |
55 |
56 |
61 | {fileName}
62 |
63 |
64 | {subtitle ? (
65 | <>
66 |
67 |
68 |
69 |
74 | {subtitle}
75 |
76 | >
77 | ) : null}
78 |
79 |
80 | {children}
81 |
82 |
83 |
84 |
85 | {error ? (
86 |
87 | {error}
88 |
89 | ) : null}
90 |
91 | {secondaryLabel ? (
92 |
93 |
94 | {secondaryLabel}
95 |
96 |
97 | ) : null}
98 |
99 | {nextLabel !== false ? (
100 |
101 |
102 | {nextLabel}
103 |
104 |
105 | ) : null}
106 |
107 |
108 | );
109 | };
110 |
--------------------------------------------------------------------------------
/src/components/ImporterProps.ts:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { ImporterLocale } from '../locale';
3 | import { CustomizablePapaParseConfig, ParseCallback, BaseRow } from '../parser';
4 |
5 | // information for displaying a spreadsheet-style column
6 | export interface ImporterPreviewColumn {
7 | index: number; // 0-based position inside spreadsheet
8 | header?: string; // header, if present
9 | values: string[]; // row values after the header
10 | }
11 |
12 | export interface ImporterFilePreview {
13 | rawData: string; // raw first data chunk consumed by parser for preview
14 | columns: ImporterPreviewColumn[]; // per-column parsed preview
15 | skipHeaders: boolean; // true if user has indicated that file has no headers
16 | parseWarning?: Papa.ParseError; // any non-blocking PapaParse message
17 | }
18 |
19 | // separate props definition to safely include in tests
20 | export interface ImportInfo {
21 | file: File;
22 | preview: ImporterFilePreview;
23 | fields: string[]; // list of fields that user has assigned
24 | columnFields: (string | undefined)[]; // per-column list of field names (or undefined if unassigned)
25 | }
26 |
27 | export type ImporterContentRenderProp = (info: {
28 | file: File | null;
29 | preview: ImporterFilePreview | null;
30 | }) => React.ReactNode;
31 |
32 | export interface ImporterFieldProps {
33 | name: string;
34 | label: string;
35 | optional?: boolean;
36 | }
37 |
38 | export type ImporterDataHandlerProps =
39 | | {
40 | dataHandler: ParseCallback;
41 | processChunk?: undefined; // for ease of rest-spread
42 | }
43 | | {
44 | /**
45 | * @deprecated renamed to `dataHandler`
46 | */
47 | processChunk: ParseCallback;
48 | dataHandler?: undefined; // disambiguate from newer naming
49 | };
50 |
51 | export type ImporterProps = ImporterDataHandlerProps<
52 | Row
53 | > & {
54 | defaultNoHeader?: boolean;
55 | /**
56 | * @deprecated renamed to `defaultNoHeader`
57 | */
58 | assumeNoHeaders?: boolean;
59 |
60 | displayColumnPageSize?: number;
61 | displayFieldRowSize?: number;
62 |
63 | restartable?: boolean;
64 | onStart?: (info: ImportInfo) => void;
65 | onComplete?: (info: ImportInfo) => void;
66 | onClose?: (info: ImportInfo) => void;
67 | children?: ImporterContentRenderProp | React.ReactNode;
68 | locale?: ImporterLocale;
69 | } & CustomizablePapaParseConfig;
70 |
--------------------------------------------------------------------------------
/src/components/ProgressDisplay.scss:
--------------------------------------------------------------------------------
1 | @import '../theme.scss';
2 |
3 | .CSVImporter_ProgressDisplay {
4 | padding: 2em;
5 |
6 | &__status {
7 | text-align: center;
8 | font-size: $titleFontSize;
9 | color: $textColor;
10 |
11 | &.-pending {
12 | color: $textSecondaryColor;
13 | }
14 | }
15 |
16 | &__count {
17 | text-align: right;
18 | font-size: 1em;
19 | color: $textSecondaryColor;
20 |
21 | > var {
22 | display: inline-block;
23 | width: 1px;
24 | height: 1px;
25 | overflow: hidden;
26 | opacity: 0;
27 | }
28 | }
29 |
30 | &__progressBar {
31 | position: relative; // for indicator
32 | width: 100%;
33 | height: 0.5em;
34 | background: $fillColor;
35 | }
36 |
37 | &__progressBarIndicator {
38 | position: absolute;
39 | top: 0;
40 | left: 0;
41 | width: 0; // dynamically set in code
42 | height: 100%;
43 | background: $textColor;
44 |
45 | transition: width 0.2s ease-out;
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/components/ProgressDisplay.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect, useMemo, useRef } from 'react';
2 |
3 | import { processFile, ParseCallback, BaseRow } from '../parser';
4 | import { FileStepState } from './file-step/FileStep';
5 | import { FieldsStepState } from './fields-step/FieldsStep';
6 | import { ImporterFilePreview, ImportInfo } from './ImporterProps';
7 | import { ImporterFrame } from './ImporterFrame';
8 |
9 | import './ProgressDisplay.scss';
10 | import { useLocale } from '../locale/LocaleContext';
11 |
12 | // compute actual UTF-8 bytes used by a string
13 | // (inspired by https://stackoverflow.com/questions/10576905/how-to-convert-javascript-unicode-notation-code-to-utf-8)
14 | function countUTF8Bytes(item: string) {
15 | // re-encode into UTF-8
16 | const escaped = encodeURIComponent(item);
17 |
18 | // convert byte escape sequences into single characters
19 | const normalized = escaped.replace(/%\d\d/g, '_');
20 |
21 | return normalized.length;
22 | }
23 |
24 | export function ProgressDisplay({
25 | fileState,
26 | fieldsState,
27 | externalPreview,
28 | dataHandler,
29 | onStart,
30 | onComplete,
31 | onRestart,
32 | onClose
33 | }: React.PropsWithChildren<{
34 | fileState: FileStepState;
35 | fieldsState: FieldsStepState;
36 | externalPreview: ImporterFilePreview;
37 | dataHandler: ParseCallback;
38 | onStart?: (info: ImportInfo) => void;
39 | onComplete?: (info: ImportInfo) => void;
40 | onRestart?: () => void;
41 | onClose?: (info: ImportInfo) => void;
42 | }>): React.ReactElement {
43 | const [progressCount, setProgressCount] = useState(0);
44 | const [isComplete, setIsComplete] = useState(false);
45 | const [error, setError] = useState(null);
46 | const [isDismissed, setIsDismissed] = useState(false); // prevents double-clicking finish
47 |
48 | // info object exposed to the progress callbacks
49 | const importInfo = useMemo(() => {
50 | const fieldList = Object.keys(fieldsState.fieldAssignments);
51 |
52 | const columnSparseList: (string | undefined)[] = [];
53 | fieldList.forEach((field) => {
54 | const col = fieldsState.fieldAssignments[field];
55 | if (col !== undefined) {
56 | columnSparseList[col] = field;
57 | }
58 | });
59 |
60 | return {
61 | file: fileState.file,
62 | preview: externalPreview,
63 | fields: fieldList,
64 | columnFields: [...columnSparseList]
65 | };
66 | }, [fileState, fieldsState, externalPreview]);
67 |
68 | // estimate number of rows
69 | const estimatedRowCount = useMemo(() => {
70 | // sum up sizes of all the parsed preview rows and get estimated average
71 | const totalPreviewRowBytes = fileState.firstRows.reduce(
72 | (prevCount, row) => {
73 | const rowBytes = row.reduce((prev, item) => {
74 | return prev + countUTF8Bytes(item) + 1; // add a byte for separator or newline
75 | }, 0);
76 |
77 | return prevCount + rowBytes;
78 | },
79 | 0
80 | );
81 |
82 | const averagePreviewRowSize =
83 | totalPreviewRowBytes / fileState.firstRows.length;
84 |
85 | // divide file size by estimated row size (or fall back to a sensible amount)
86 | return averagePreviewRowSize > 1
87 | ? fileState.file.size / averagePreviewRowSize
88 | : 100;
89 | }, [fileState]);
90 |
91 | // notify on start of processing
92 | // (separate effect in case of errors)
93 | const onStartRef = useRef(onStart); // wrap in ref to avoid re-triggering (only first instance is needed)
94 | useEffect(() => {
95 | if (onStartRef.current) {
96 | onStartRef.current(importInfo);
97 | }
98 | }, [importInfo]);
99 |
100 | // notify on end of processing
101 | // (separate effect in case of errors)
102 | const onCompleteRef = useRef(onComplete); // wrap in ref to avoid re-triggering
103 | onCompleteRef.current = onComplete;
104 | useEffect(() => {
105 | if (isComplete && onCompleteRef.current) {
106 | onCompleteRef.current(importInfo);
107 | }
108 | }, [importInfo, isComplete]);
109 |
110 | // ensure status gets focus when complete, in case status role is not read out
111 | const statusRef = useRef(null);
112 | useEffect(() => {
113 | if ((isComplete || error) && statusRef.current) {
114 | statusRef.current.focus();
115 | }
116 | }, [isComplete, error]);
117 |
118 | // trigger processing from an effect to mitigate React 18 double-run in dev
119 | const [ready, setReady] = useState(false);
120 | useEffect(() => {
121 | setReady(true);
122 | }, []);
123 |
124 | // perform main async parse
125 | const dataHandlerRef = useRef(dataHandler); // wrap in ref to avoid re-triggering
126 | const asyncLockRef = useRef(0);
127 | useEffect(() => {
128 | // avoid running on first render due to React 18 double-run
129 | if (!ready) {
130 | return;
131 | }
132 |
133 | const oplock = asyncLockRef.current;
134 |
135 | processFile(
136 | { ...fileState, fieldAssignments: fieldsState.fieldAssignments },
137 | (deltaCount) => {
138 | // ignore if stale
139 | if (oplock !== asyncLockRef.current) {
140 | return; // @todo signal abort
141 | }
142 |
143 | setProgressCount((prev) => prev + deltaCount);
144 | },
145 | dataHandlerRef.current
146 | ).then(
147 | () => {
148 | // ignore if stale
149 | if (oplock !== asyncLockRef.current) {
150 | return;
151 | }
152 |
153 | setIsComplete(true);
154 | },
155 | (error) => {
156 | // ignore if stale
157 | if (oplock !== asyncLockRef.current) {
158 | return;
159 | }
160 |
161 | setError(error);
162 | }
163 | );
164 |
165 | return () => {
166 | // invalidate current oplock on change or unmount
167 | asyncLockRef.current += 1;
168 | };
169 | }, [ready, fileState, fieldsState]);
170 |
171 | // simulate asymptotic progress percentage
172 | const progressPercentage = useMemo(() => {
173 | if (isComplete) {
174 | return 100;
175 | }
176 |
177 | // inputs hand-picked so that correctly estimated total is about 75% of the bar
178 | const progressPower = 2.5 * (progressCount / estimatedRowCount);
179 | const progressLeft = 0.5 ** progressPower;
180 |
181 | // convert to .1 percent precision for smoother bar display
182 | return Math.floor(1000 - 1000 * progressLeft) / 10;
183 | }, [estimatedRowCount, progressCount, isComplete]);
184 |
185 | const l10n = useLocale('progressStep');
186 |
187 | return (
188 | {
201 | if (onClose) {
202 | setIsDismissed(true);
203 | onClose(importInfo);
204 | } else if (onRestart) {
205 | onRestart();
206 | }
207 | }}
208 | >
209 |
210 | {isComplete || error ? (
211 |
217 | {error ? l10n.statusError : l10n.statusComplete}
218 |
219 | ) : (
220 |
224 | {l10n.statusPending}
225 |
226 | )}
227 |
228 |
229 | {l10n.processedRowsLabel} {progressCount}
230 |
231 |
232 |
238 |
239 |
240 | );
241 | }
242 |
--------------------------------------------------------------------------------
/src/components/TextButton.scss:
--------------------------------------------------------------------------------
1 | @import '../theme.scss';
2 |
3 | .CSVImporter_TextButton {
4 | display: block;
5 | margin: 0; // override default
6 | border: 1px solid $controlBorderColor;
7 | padding: 0.4em 1em 0.5em;
8 | border-radius: $borderRadius;
9 | background: $fillColor;
10 | font-size: inherit;
11 | color: $fgColor;
12 | cursor: pointer;
13 |
14 | &:hover:not(:disabled) {
15 | background: darken($fillColor, 10%);
16 | }
17 |
18 | &:disabled {
19 | opacity: 0.25;
20 | cursor: default;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/TextButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import './TextButton.scss';
4 |
5 | export const TextButton: React.FC<{
6 | disabled?: boolean;
7 | onClick?: () => void;
8 | }> = ({ disabled, onClick, children }) => {
9 | return (
10 |
16 | {children}
17 |
18 | );
19 | };
20 |
--------------------------------------------------------------------------------
/src/components/fields-step/ColumnDragCard.scss:
--------------------------------------------------------------------------------
1 | @import '../../theme.scss';
2 |
3 | .CSVImporter_ColumnDragCard {
4 | position: relative;
5 | z-index: 0; // reset stacking context
6 | padding: 0.5em 0.75em;
7 | border-radius: $borderRadius;
8 | background: $controlBgColor;
9 | box-shadow: 0 1px 1px rgba(#000, 0.25);
10 | cursor: default;
11 |
12 | &[data-draggable='true'] {
13 | cursor: grab;
14 |
15 | // avoid triggering scroll on iOS Safari (needed despite preventDefault also being used)
16 | touch-action: none;
17 | }
18 |
19 | &[data-dummy='true'] {
20 | border-radius: 0;
21 | background: $fillColor;
22 | box-shadow: none;
23 | opacity: 0.5;
24 | user-select: none;
25 | }
26 |
27 | &[data-error='true'] {
28 | background: rgba($errorTextColor, 0.25);
29 | color: $textColor;
30 | }
31 |
32 | &[data-shadow='true'] {
33 | background: $fillColor;
34 | box-shadow: none;
35 | color: rgba($textColor, 0.25); // reduce text
36 | }
37 |
38 | &[data-drop-indicator='true'] {
39 | box-shadow: 0 1px 2px rgba(#000, 0.5);
40 | color: $fgColor;
41 | }
42 |
43 | &__cardHeader {
44 | margin-top: -0.25em;
45 | margin-right: -0.5em;
46 | margin-bottom: 0.25em;
47 | margin-left: -0.5em;
48 | height: 1.5em; // sized to be covered by small button
49 | font-weight: bold;
50 | color: $textSecondaryColor;
51 |
52 | & > b {
53 | display: flex;
54 | align-items: center;
55 | justify-content: center;
56 | height: 100%;
57 | background: $fillColor;
58 | line-height: 1; // centered by parent anyway
59 | }
60 |
61 | > var {
62 | display: block;
63 | margin-bottom: -1px;
64 | width: 1px; // non-zero size for reader
65 | height: 1px;
66 | overflow: hidden;
67 | }
68 | }
69 |
70 | &__cardPaper[data-draggable='true']:hover &__cardHeader,
71 | &__cardPaper[data-dragged='true'] &__cardHeader {
72 | color: $fgColor;
73 | }
74 |
75 | &__cardValue {
76 | margin-top: 0.25em;
77 | overflow: hidden;
78 | line-height: 1.25em; // might not be inherited from main content
79 | font-size: 0.75em;
80 | text-overflow: ellipsis;
81 | white-space: nowrap;
82 |
83 | &[data-header='true'] {
84 | text-align: center;
85 | font-style: italic;
86 | color: $textSecondaryColor;
87 | }
88 |
89 | & + div {
90 | margin-top: 0;
91 | }
92 | }
93 |
94 | &[data-shadow='true'] > &__cardValue[data-header='true'] {
95 | color: rgba($textSecondaryColor, 0.25); // reduce text
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/src/components/fields-step/ColumnDragCard.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo } from 'react';
2 |
3 | import { PREVIEW_ROW_COUNT } from '../../parser';
4 | import { Column } from './ColumnPreview';
5 |
6 | import './ColumnDragCard.scss';
7 | import { useLocale } from '../../locale/LocaleContext';
8 |
9 | // @todo sort out "grabbing" cursor state (does not work with pointer-events:none)
10 | export const ColumnDragCard: React.FC<{
11 | hasHeaders?: boolean; // for correct display of dummy card
12 | column?: Column;
13 | rowCount?: number;
14 | hasError?: boolean;
15 | isAssigned?: boolean;
16 | isShadow?: boolean;
17 | isDraggable?: boolean;
18 | isDragged?: boolean;
19 | isDropIndicator?: boolean;
20 | }> = ({
21 | hasHeaders,
22 | column: optionalColumn,
23 | rowCount = PREVIEW_ROW_COUNT,
24 | hasError,
25 | isAssigned,
26 | isShadow,
27 | isDraggable,
28 | isDragged,
29 | isDropIndicator
30 | }) => {
31 | const isDummy = !optionalColumn;
32 |
33 | const column = useMemo(
34 | () =>
35 | optionalColumn || {
36 | index: -1,
37 | code: '',
38 | header: hasHeaders ? '' : undefined,
39 | values: [...new Array(PREVIEW_ROW_COUNT)].map(() => '')
40 | },
41 | [optionalColumn, hasHeaders]
42 | );
43 |
44 | const headerValue = column.header;
45 | const dataValues = column.values.slice(
46 | 0,
47 | headerValue === undefined ? rowCount : rowCount - 1
48 | );
49 |
50 | const l10n = useLocale('fieldsStep');
51 |
52 | return (
53 | // not changing variant dynamically because it causes a height jump
54 |
64 |
65 | {isDummy ? (
66 | {l10n.columnCardDummyHeader}
67 | ) : (
68 | {l10n.getColumnCardHeader(column.code)}
69 | )}
70 | {isDummy || isAssigned ? '\u00a0' : {column.code} }
71 |
72 |
73 | {headerValue !== undefined ? (
74 |
75 | {headerValue || '\u00a0'}
76 |
77 | ) : null}
78 |
79 | {/* all values grouped into one readable string */}
80 |
81 | {dataValues.map((value, valueIndex) => (
82 |
86 | {value || '\u00a0'}
87 |
88 | ))}
89 |
90 |
91 | );
92 | };
93 |
--------------------------------------------------------------------------------
/src/components/fields-step/ColumnDragObject.scss:
--------------------------------------------------------------------------------
1 | .CSVImporter_ColumnDragObject {
2 | &__overlay {
3 | // scroll-independent container
4 | position: fixed;
5 | top: 0;
6 | left: 0;
7 | width: 100vw;
8 | height: 100vh;
9 | overflow: none; // clipping to avoid triggering scrollbar when dragging near edges
10 | pointer-events: none;
11 | }
12 |
13 | &__positioner {
14 | // movement of mouse gesture inside overlay
15 | position: absolute; // @todo this is not working with scroll
16 | top: 0;
17 | left: 0;
18 | min-width: 8em; // in case could not compute
19 | width: 0; // dynamically set at drag start
20 | height: 0; // dynamically set at drag start
21 | }
22 |
23 | &__holder {
24 | // placement of visible card relative to mouse pointer
25 | position: absolute;
26 | top: -0.75em;
27 | left: -0.75em;
28 | width: 100%;
29 | opacity: 0.9;
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/components/fields-step/ColumnDragObject.tsx:
--------------------------------------------------------------------------------
1 | import React, { useRef, useLayoutEffect } from 'react';
2 | import { createPortal } from 'react-dom';
3 |
4 | import { ColumnDragCard } from './ColumnDragCard';
5 | import { DragState } from './ColumnDragState';
6 |
7 | import './ColumnDragObject.scss';
8 |
9 | export const ColumnDragObject: React.FC<{
10 | dragState: DragState | null;
11 | }> = ({ dragState }) => {
12 | const referenceBoxRef = useRef(null);
13 |
14 | // the dragged box is wrapped in a no-events overlay to clip against screen edges
15 | const dragBoxRef = useRef(null);
16 | const dragObjectPortal =
17 | dragState && dragState.pointerStartInfo
18 | ? createPortal(
19 | ,
29 | document.body
30 | )
31 | : null;
32 |
33 | // set up initial position when pointer-based gesture is started
34 | const pointerStartInfo = dragState && dragState.pointerStartInfo;
35 | useLayoutEffect(() => {
36 | // ignore non-pointer drag states
37 | if (!pointerStartInfo || !dragBoxRef.current) {
38 | return;
39 | }
40 |
41 | // place based on initial position + size relative to viewport overlay
42 | const rect = pointerStartInfo.initialClientRect;
43 | dragBoxRef.current.style.left = `${rect.left}px`;
44 | dragBoxRef.current.style.top = `${rect.top}px`;
45 | dragBoxRef.current.style.width = `${rect.width}px`;
46 | dragBoxRef.current.style.height = `${rect.height}px`;
47 |
48 | // copy known cascaded font style from main content into portal content
49 | // @todo consider other text style properties?
50 | if (referenceBoxRef.current) {
51 | const computedStyle = window.getComputedStyle(referenceBoxRef.current);
52 | dragBoxRef.current.style.fontFamily = computedStyle.fontFamily;
53 | dragBoxRef.current.style.fontSize = computedStyle.fontSize;
54 | dragBoxRef.current.style.fontWeight = computedStyle.fontWeight;
55 | dragBoxRef.current.style.fontStyle = computedStyle.fontStyle;
56 | dragBoxRef.current.style.letterSpacing = computedStyle.letterSpacing;
57 | }
58 | }, [pointerStartInfo]);
59 |
60 | // subscribe to live position updates without state changes
61 | useLayoutEffect(() => {
62 | if (dragState) {
63 | const updateListener = (movement: number[]) => {
64 | if (!dragBoxRef.current) return;
65 |
66 | // update the visible offset relative to starting position
67 | const [x, y] = movement;
68 | dragBoxRef.current.style.transform = `translate(${x}px, ${y}px)`;
69 | };
70 |
71 | dragState.updateListeners.push(updateListener);
72 |
73 | // clean up listener
74 | return () => {
75 | const removeIndex = dragState.updateListeners.indexOf(updateListener);
76 | if (removeIndex !== -1) {
77 | dragState.updateListeners.splice(removeIndex, 1);
78 | }
79 | };
80 | }
81 | }, [dragState]);
82 |
83 | return {dragObjectPortal}
;
84 | };
85 |
--------------------------------------------------------------------------------
/src/components/fields-step/ColumnDragSourceArea.scss:
--------------------------------------------------------------------------------
1 | @import '../../theme.scss';
2 |
3 | .CSVImporter_ColumnDragSourceArea {
4 | display: flex;
5 | margin-top: 0.5em;
6 | margin-bottom: 1em;
7 |
8 | &__control {
9 | flex: none;
10 | display: flex;
11 | align-items: center;
12 | }
13 |
14 | &__page {
15 | position: relative; // for indicator
16 | flex: 1 1 0;
17 | display: flex;
18 | padding-top: 0.5em; // some room for the indicator
19 | padding-left: 0.5em; // match interior box spacing
20 | }
21 |
22 | &__pageIndicator {
23 | position: absolute;
24 | top: -0.5em;
25 | right: 0;
26 | left: 0;
27 | text-align: center;
28 | font-size: 0.75em;
29 | }
30 |
31 | &__pageFiller {
32 | flex: 1 1 0;
33 | margin-right: 0.5em; // match interior box spacing
34 | }
35 |
36 | &__box {
37 | position: relative; // for action
38 | flex: 1 1 0;
39 | margin-right: 0.5em;
40 | width: 0; // prevent internal sizing from affecting placement
41 | }
42 |
43 | &__boxAction {
44 | position: absolute;
45 | top: 0; // icon button padding matches card padding
46 | right: 0;
47 | z-index: 1; // right above content
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/components/fields-step/ColumnDragSourceArea.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useMemo } from 'react';
2 | import { useDrag } from '@use-gesture/react';
3 |
4 | import { FieldAssignmentMap } from '../../parser';
5 | import { Column } from './ColumnPreview';
6 | import { DragState } from './ColumnDragState';
7 | import { ColumnDragCard } from './ColumnDragCard';
8 | import { IconButton } from '../IconButton';
9 |
10 | import './ColumnDragSourceArea.scss';
11 | import { useLocale } from '../../locale/LocaleContext';
12 |
13 | const DEFAULT_PAGE_SIZE = 5; // fraction of 10 for easier counting
14 |
15 | // @todo readable status text if not mouse-drag
16 | const SourceBox: React.FC<{
17 | column: Column;
18 | fieldAssignments: FieldAssignmentMap;
19 | dragState: DragState | null;
20 | eventBinder: (column: Column) => ReturnType;
21 | onSelect: (column: Column) => void;
22 | onUnassign: (column: Column) => void;
23 | }> = ({
24 | column,
25 | fieldAssignments,
26 | dragState,
27 | eventBinder,
28 | onSelect,
29 | onUnassign
30 | }) => {
31 | const isDragged = dragState ? column === dragState.column : false;
32 |
33 | const isAssigned = useMemo(
34 | () =>
35 | Object.keys(fieldAssignments).some(
36 | (fieldName) => fieldAssignments[fieldName] === column.index
37 | ),
38 | [fieldAssignments, column]
39 | );
40 |
41 | const eventHandlers = useMemo(() => eventBinder(column), [
42 | eventBinder,
43 | column
44 | ]);
45 |
46 | const l10n = useLocale('fieldsStep');
47 |
48 | return (
49 |
50 |
54 |
60 |
61 |
62 | {/* tab order after column contents */}
63 |
64 | {isAssigned ? (
65 | {
71 | onUnassign(column);
72 | }}
73 | />
74 | ) : (
75 | {
86 | onSelect(column);
87 | }}
88 | />
89 | )}
90 |
91 |
92 | );
93 | };
94 |
95 | // @todo current page indicator (dots)
96 | export const ColumnDragSourceArea: React.FC<{
97 | columns: Column[];
98 | columnPageSize?: number;
99 | fieldAssignments: FieldAssignmentMap;
100 | dragState: DragState | null;
101 | eventBinder: (column: Column) => ReturnType;
102 | onSelect: (column: Column) => void;
103 | onUnassign: (column: Column) => void;
104 | }> = ({
105 | columns,
106 | columnPageSize,
107 | fieldAssignments,
108 | dragState,
109 | eventBinder,
110 | onSelect,
111 | onUnassign
112 | }) => {
113 | // sanitize page size setting
114 | const pageSize = Math.round(Math.max(1, columnPageSize ?? DEFAULT_PAGE_SIZE));
115 |
116 | // track pagination state (resilient to page size changes)
117 | const [pageStart, setPageStart] = useState(0);
118 | const [pageChanged, setPageChanged] = useState(false);
119 |
120 | const page = Math.floor(pageStart / pageSize); // round down in case page size changes
121 | const pageCount = Math.ceil(columns.length / pageSize);
122 |
123 | // display page items and fill up with dummy divs up to pageSize
124 | const pageContents = columns
125 | .slice(page * pageSize, (page + 1) * pageSize)
126 | .map((column, columnIndex) => (
127 |
136 | ));
137 |
138 | while (pageContents.length < pageSize) {
139 | pageContents.push(
140 |
144 | );
145 | }
146 |
147 | const l10n = useLocale('fieldsStep');
148 |
149 | return (
150 |
154 |
155 | {
160 | setPageStart(
161 | (prev) => Math.max(0, Math.floor(prev / pageSize) - 1) * pageSize
162 | );
163 | setPageChanged(true);
164 | }}
165 | />
166 |
167 |
168 | {dragState && !dragState.pointerStartInfo ? (
169 |
173 | {l10n.getDragSourceActiveStatus(dragState.column.code)}
174 |
175 | ) : (
176 | // show page number if needed (and treat as status role if it has changed)
177 | // @todo changing role to status does not seem to work
178 | pageCount > 1 && (
179 |
183 | {l10n.getDragSourcePageIndicator(page + 1, pageCount)}
184 |
185 | )
186 | )}
187 |
188 | {pageContents}
189 |
190 |
191 | = pageCount - 1}
195 | onClick={() => {
196 | setPageStart(
197 | (prev) =>
198 | Math.min(pageCount - 1, Math.floor(prev / pageSize) + 1) *
199 | pageSize
200 | );
201 | }}
202 | />
203 |
204 |
205 | );
206 | };
207 |
--------------------------------------------------------------------------------
/src/components/fields-step/ColumnDragState.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useCallback, useRef } from 'react';
2 |
3 | import { Column } from './ColumnPreview';
4 |
5 | export interface DragState {
6 | // null if this is a non-pointer-initiated state
7 | pointerStartInfo: {
8 | // position + size of originating card relative to viewport overlay
9 | initialClientRect: DOMRectReadOnly;
10 | } | null;
11 |
12 | column: Column;
13 | dropFieldName: string | null;
14 | updateListeners: ((xy: number[]) => void)[];
15 | }
16 |
17 | export interface DragInfo {
18 | dragState: DragState | null;
19 | columnSelectHandler: (column: Column) => void;
20 | dragStartHandler: (
21 | column: Column,
22 | startFieldName: string | undefined,
23 | initialClientRect: DOMRectReadOnly
24 | ) => void;
25 | dragMoveHandler: (movement: [number, number]) => void;
26 | dragEndHandler: () => void;
27 |
28 | dragHoverHandler: (fieldName: string, isOn: boolean) => void;
29 | assignHandler: (fieldName: string) => void;
30 | unassignHandler: (column: Column) => void;
31 | }
32 |
33 | // state machine to represent the steps taken to assign a column to target field:
34 | // - pick column (drag start or keyboard select)
35 | // - hover over field (while dragging only)
36 | // - assign picked column to field (drag end)
37 | // @todo move the useDrag setup outside as well?
38 | export function useColumnDragState(
39 | onColumnAssignment: (column: Column, fieldName: string | null) => void
40 | ): DragInfo {
41 | // wrap in ref to avoid re-triggering effects
42 | const onColumnAssignmentRef = useRef(onColumnAssignment);
43 | onColumnAssignmentRef.current = onColumnAssignment;
44 |
45 | const [dragState, setDragState] = useState(null);
46 |
47 | const dragStartHandler = useCallback(
48 | (
49 | column: Column,
50 | startFieldName: string | undefined,
51 | initialClientRect: DOMRectReadOnly
52 | ) => {
53 | // create new pointer-based drag state
54 | setDragState({
55 | pointerStartInfo: {
56 | initialClientRect
57 | },
58 | column,
59 | dropFieldName: startFieldName !== undefined ? startFieldName : null,
60 | updateListeners: []
61 | });
62 | },
63 | []
64 | );
65 |
66 | const dragMoveHandler = useCallback(
67 | (movement: [number, number]) => {
68 | // @todo figure out a cleaner event stream solution
69 | if (dragState) {
70 | const listeners = dragState.updateListeners;
71 | for (const listener of listeners) {
72 | listener(movement);
73 | }
74 | }
75 | },
76 | [dragState]
77 | );
78 |
79 | const dragEndHandler = useCallback(() => {
80 | setDragState(null);
81 |
82 | if (dragState) {
83 | onColumnAssignmentRef.current(dragState.column, dragState.dropFieldName);
84 | }
85 | }, [dragState]);
86 |
87 | const columnSelectHandler = useCallback((column: Column) => {
88 | setDragState((prev) => {
89 | // toggle off if needed
90 | if (prev && prev.column === column) {
91 | return null;
92 | }
93 |
94 | return {
95 | pointerStartInfo: null, // no draggable position information
96 | column,
97 | dropFieldName: null,
98 | updateListeners: []
99 | };
100 | });
101 | }, []);
102 |
103 | const dragHoverHandler = useCallback((fieldName: string, isOn: boolean) => {
104 | setDragState((prev): DragState | null => {
105 | if (!prev) {
106 | return prev;
107 | }
108 |
109 | if (isOn) {
110 | // set the new drop target
111 | return {
112 | ...prev,
113 | dropFieldName: fieldName
114 | };
115 | } else if (prev.dropFieldName === fieldName) {
116 | // clear drop target if we are still the current one
117 | return {
118 | ...prev,
119 | dropFieldName: null
120 | };
121 | }
122 |
123 | // no changes by default
124 | return prev;
125 | });
126 | }, []);
127 |
128 | const assignHandler = useCallback(
129 | (fieldName: string) => {
130 | // clear active drag state
131 | setDragState(null);
132 |
133 | if (dragState) {
134 | onColumnAssignmentRef.current(dragState.column, fieldName);
135 | }
136 | },
137 | [dragState]
138 | );
139 |
140 | const unassignHandler = useCallback((column: Column) => {
141 | // clear active drag state
142 | setDragState(null);
143 |
144 | onColumnAssignmentRef.current(column, null);
145 | }, []);
146 |
147 | return {
148 | dragState,
149 | dragStartHandler,
150 | dragMoveHandler,
151 | dragEndHandler,
152 | dragHoverHandler,
153 | columnSelectHandler,
154 | assignHandler,
155 | unassignHandler
156 | };
157 | }
158 |
--------------------------------------------------------------------------------
/src/components/fields-step/ColumnDragTargetArea.scss:
--------------------------------------------------------------------------------
1 | @import '../../theme.scss';
2 |
3 | .CSVImporter_ColumnDragTargetArea {
4 | display: flex;
5 | flex-wrap: wrap;
6 | align-items: flex-start;
7 |
8 | &__box {
9 | flex-basis: 25%;
10 | flex-grow: 0;
11 | flex-shrink: 1;
12 | width: 0; // avoid interference from internal width
13 | padding-top: 1em; // not using margin for cleaner percentage calculation
14 | padding-right: 1em;
15 | }
16 |
17 | &__boxLabel {
18 | margin-bottom: 0.25em;
19 | font-weight: bold;
20 | color: $textColor;
21 | word-break: break-word;
22 |
23 | & > b {
24 | margin-left: 0.25em;
25 | color: $errorTextColor;
26 | }
27 | }
28 |
29 | &__boxValue {
30 | position: relative; // for action and placeholder
31 | z-index: 0; // contain the z-indexes of contents (to prevent e.g. placeholder being above drag object)
32 | }
33 |
34 | &__boxValueAction {
35 | position: absolute;
36 | top: 0; // icon button padding matches card padding
37 | right: 0;
38 | z-index: 1; // right above content
39 | }
40 |
41 | &__boxPlaceholderHelp {
42 | position: absolute;
43 | top: 0;
44 | left: 0;
45 | z-index: 1; // right above content
46 | display: flex;
47 | align-items: center;
48 | justify-content: center;
49 | width: 100%;
50 | height: 98%; // nudge up a bit
51 | padding: 0.5em;
52 | text-align: center; // in case text wraps
53 | color: $textSecondaryColor; // @todo font-size
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/components/fields-step/ColumnDragTargetArea.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo, useRef } from 'react';
2 | import { useDrag } from '@use-gesture/react';
3 |
4 | import { FieldAssignmentMap } from '../../parser';
5 | import { Column } from './ColumnPreview';
6 | import { DragState } from './ColumnDragState';
7 | import { ColumnDragCard } from './ColumnDragCard';
8 | import { IconButton } from '../IconButton';
9 | import { Field } from '../ImporterField';
10 |
11 | export type FieldTouchedMap = { [name: string]: boolean | undefined };
12 |
13 | import './ColumnDragTargetArea.scss';
14 | import { useLocale } from '../../locale/LocaleContext';
15 |
16 | const TargetBox: React.FC<{
17 | field: Field;
18 | hasHeaders: boolean; // for correct display of dummy card
19 | flexBasis?: string; // style override
20 | touched?: boolean;
21 | assignedColumn: Column | null;
22 | dragState: DragState | null;
23 | eventBinder: (
24 | column: Column,
25 | startFieldName?: string
26 | ) => ReturnType;
27 | onHover: (fieldName: string, isOn: boolean) => void;
28 | onAssign: (fieldName: string) => void;
29 | onUnassign: (column: Column) => void;
30 | }> = ({
31 | field,
32 | hasHeaders,
33 | flexBasis,
34 | touched,
35 | assignedColumn,
36 | dragState,
37 | eventBinder,
38 | onHover,
39 | onAssign,
40 | onUnassign
41 | }) => {
42 | // respond to hover events when there is active mouse drag happening
43 | // (not keyboard-emulated one)
44 | const containerRef = useRef(null);
45 |
46 | // if this field is the current highlighted drop target,
47 | // get the originating column data for display
48 | const sourceColumn =
49 | dragState && dragState.dropFieldName === field.name
50 | ? dragState.column
51 | : null;
52 |
53 | // see if currently assigned column is being dragged again
54 | const isReDragged = dragState ? dragState.column === assignedColumn : false;
55 |
56 | // drag start handlers for columns that can be re-dragged (i.e. are assigned)
57 | const dragStartHandlers = useMemo(
58 | () =>
59 | assignedColumn && !isReDragged
60 | ? eventBinder(assignedColumn, field.name)
61 | : {},
62 | [eventBinder, assignedColumn, isReDragged, field.name]
63 | );
64 |
65 | const valueContents = useMemo(() => {
66 | if (sourceColumn) {
67 | return (
68 |
69 | );
70 | }
71 |
72 | if (assignedColumn) {
73 | return (
74 |
80 | );
81 | }
82 |
83 | const hasError = touched && !field.isOptional;
84 | return (
85 |
90 | );
91 | }, [hasHeaders, field, touched, assignedColumn, sourceColumn, isReDragged]);
92 |
93 | const l10n = useLocale('fieldsStep');
94 |
95 | // @todo mouse cursor changes to reflect draggable state
96 | return (
97 | onHover(field.name, true)}
107 | onPointerLeave={() => onHover(field.name, false)}
108 | >
109 |
110 | {field.label}
111 | {field.isOptional ? null : * }
112 |
113 |
114 |
115 | {!sourceColumn && !assignedColumn && (
116 |
120 | {l10n.dragTargetPlaceholder}
121 |
122 | )}
123 |
124 |
125 | {valueContents}
126 |
127 |
128 | {/* tab order after column contents */}
129 | {dragState && !dragState.pointerStartInfo ? (
130 |
131 | onAssign(field.name)}
136 | />
137 |
138 | ) : (
139 | !sourceColumn &&
140 | assignedColumn && (
141 |
142 | onUnassign(assignedColumn)}
147 | />
148 |
149 | )
150 | )}
151 |
152 |
153 | );
154 | };
155 |
156 | export const ColumnDragTargetArea: React.FC<{
157 | hasHeaders: boolean; // for correct display of dummy card
158 | fields: Field[];
159 | columns: Column[];
160 | fieldRowSize?: number;
161 | fieldTouched: FieldTouchedMap;
162 | fieldAssignments: FieldAssignmentMap;
163 | dragState: DragState | null;
164 | eventBinder: (
165 | // @todo import type from drag state tracker
166 | column: Column,
167 | startFieldName?: string
168 | ) => ReturnType;
169 | onHover: (fieldName: string, isOn: boolean) => void;
170 | onAssign: (fieldName: string) => void;
171 | onUnassign: (column: Column) => void;
172 | }> = ({
173 | hasHeaders,
174 | fields,
175 | columns,
176 | fieldRowSize,
177 | fieldTouched,
178 | fieldAssignments,
179 | dragState,
180 | eventBinder,
181 | onHover,
182 | onAssign,
183 | onUnassign
184 | }) => {
185 | const l10n = useLocale('fieldsStep');
186 |
187 | // override flex basis for unusual situations
188 | const flexBasis = fieldRowSize ? `${100 / fieldRowSize}%` : undefined;
189 |
190 | return (
191 |
195 | {fields.map((field) => {
196 | const assignedColumnIndex = fieldAssignments[field.name];
197 |
198 | return (
199 |
216 | );
217 | })}
218 |
219 | );
220 | };
221 |
--------------------------------------------------------------------------------
/src/components/fields-step/ColumnPreview.tsx:
--------------------------------------------------------------------------------
1 | import { ImporterPreviewColumn } from '../ImporterProps';
2 |
3 | export interface Column extends ImporterPreviewColumn {
4 | code: string;
5 | }
6 |
7 | // spreadsheet-style column code computation (A, B, ..., Z, AA, AB, ..., etc)
8 | export function generateColumnCode(value: number): string {
9 | // ignore dummy index
10 | if (value < 0) {
11 | return '';
12 | }
13 |
14 | // first, determine how many base-26 letters there should be
15 | // (because the notation is not purely positional)
16 | let digitCount = 1;
17 | let base = 0;
18 | let next = 26;
19 |
20 | while (next <= value) {
21 | digitCount += 1;
22 | base = next;
23 | next = next * 26 + 26;
24 | }
25 |
26 | // then, apply normal positional digit computation on remainder above base
27 | let remainder = value - base;
28 |
29 | const digits = [];
30 | while (digits.length < digitCount) {
31 | const lastDigit = remainder % 26;
32 | remainder = Math.floor((remainder - lastDigit) / 26); // applying floor just in case
33 |
34 | // store ASCII code, with A as 0
35 | digits.unshift(65 + lastDigit);
36 | }
37 |
38 | return String.fromCharCode.apply(null, digits);
39 | }
40 |
41 | // prepare spreadsheet-like column display information for given raw data preview
42 | export function generatePreviewColumns(
43 | firstRows: string[][],
44 | hasHeaders: boolean
45 | ): ImporterPreviewColumn[] {
46 | const columnStubs = [...new Array(firstRows[0].length)];
47 |
48 | return columnStubs.map((empty, index) => {
49 | const values = firstRows.map((row) => row[index] || '');
50 |
51 | const headerValue = hasHeaders ? values.shift() : undefined;
52 |
53 | return {
54 | index,
55 | header: headerValue,
56 | values
57 | };
58 | });
59 | }
60 |
--------------------------------------------------------------------------------
/src/components/fields-step/FieldsStep.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useMemo, useEffect, useRef } from 'react';
2 | import { useDrag } from '@use-gesture/react';
3 |
4 | import { FieldAssignmentMap } from '../../parser';
5 | import { FileStepState } from '../file-step/FileStep';
6 | import { ImporterFrame } from '../ImporterFrame';
7 | import {
8 | generatePreviewColumns,
9 | generateColumnCode,
10 | Column
11 | } from './ColumnPreview';
12 | import { useColumnDragState } from './ColumnDragState';
13 | import { ColumnDragObject } from './ColumnDragObject';
14 | import { ColumnDragSourceArea } from './ColumnDragSourceArea';
15 | import { ColumnDragTargetArea, FieldTouchedMap } from './ColumnDragTargetArea';
16 | import { Field } from '../ImporterField';
17 | import { useLocale } from '../../locale/LocaleContext';
18 |
19 | export interface FieldsStepState {
20 | fieldAssignments: FieldAssignmentMap;
21 | }
22 |
23 | export const FieldsStep: React.FC<{
24 | fields: Field[]; // current field definitions
25 | displayFieldRowSize?: number; // override defaults for unusual widths
26 | displayColumnPageSize?: number;
27 |
28 | fileState: FileStepState; // output from the file selector step
29 | prevState: FieldsStepState | null; // confirmed field selections so far
30 |
31 | onChange: (state: FieldsStepState) => void;
32 | onAccept: () => void;
33 | onCancel: () => void;
34 | }> = ({
35 | fields,
36 | displayColumnPageSize,
37 | displayFieldRowSize,
38 | fileState,
39 | prevState,
40 | onChange,
41 | onAccept,
42 | onCancel
43 | }) => {
44 | const l10n = useLocale('fieldsStep');
45 |
46 | const onChangeRef = useRef(onChange);
47 | onChangeRef.current = onChange;
48 |
49 | const columns = useMemo(
50 | () =>
51 | generatePreviewColumns(
52 | fileState.firstRows,
53 | fileState.hasHeaders
54 | ).map((item) => ({ ...item, code: generateColumnCode(item.index) })),
55 | [fileState]
56 | );
57 |
58 | // field assignments state
59 | const [fieldAssignments, setFieldAssignments] = useState(
60 | prevState ? prevState.fieldAssignments : {}
61 | );
62 |
63 | // make sure there are no extra fields
64 | useEffect(() => {
65 | const removedFieldNames = Object.keys(fieldAssignments).filter(
66 | (existingFieldName) =>
67 | !fields.some((field) => field.name === existingFieldName)
68 | );
69 |
70 | if (removedFieldNames.length > 0) {
71 | // @todo put everything inside this setter
72 | setFieldAssignments((prev) => {
73 | const copy = { ...prev };
74 |
75 | removedFieldNames.forEach((fieldName) => {
76 | delete copy[fieldName];
77 | });
78 |
79 | return copy;
80 | });
81 | }
82 | }, [fields, fieldAssignments]);
83 |
84 | // for any field, try to find an automatic match from known column names
85 | useEffect(() => {
86 | // prep insensitive/fuzzy match stems for known columns
87 | const columnStemMap: Record = {};
88 | for (const column of columns) {
89 | const stem = column.header?.trim().toLowerCase() || undefined;
90 |
91 | if (stem) {
92 | columnStemMap[stem] = column.index;
93 | }
94 | }
95 |
96 | setFieldAssignments((prev) => {
97 | // prepare a lookup of already assigned columns
98 | const assignedColumns = columns.map(() => false);
99 |
100 | for (const fieldName of Object.keys(prev)) {
101 | const assignedColumnIndex = prev[fieldName];
102 | if (assignedColumnIndex !== undefined) {
103 | assignedColumns[assignedColumnIndex] = true;
104 | }
105 | }
106 |
107 | // augment with new auto-assignments
108 | const copy = { ...prev };
109 | for (const field of fields) {
110 | // ignore if field is already assigned
111 | if (copy[field.name] !== undefined) {
112 | continue;
113 | }
114 |
115 | // find by field stem
116 | const fieldLabelStem = field.label.trim().toLowerCase(); // @todo consider normalizing other whitespace/non-letters
117 | const matchingColumnIndex = columnStemMap[fieldLabelStem];
118 |
119 | // ignore if equivalent column not found
120 | if (matchingColumnIndex === undefined) {
121 | continue;
122 | }
123 |
124 | // ignore if column is already assigned
125 | if (assignedColumns[matchingColumnIndex]) {
126 | continue;
127 | }
128 |
129 | // auto-assign the column
130 | copy[field.name] = matchingColumnIndex;
131 | }
132 |
133 | return copy;
134 | });
135 | }, [fields, columns]);
136 |
137 | // track which fields need to show validation warning
138 | const [fieldTouched, setFieldTouched] = useState({});
139 | const [validationError, setValidationError] = useState(null);
140 |
141 | // clean up touched field map when dynamic field list changes
142 | useEffect(() => {
143 | setFieldTouched((prev) => {
144 | const result: FieldTouchedMap = {};
145 | for (const field of fields) {
146 | result[field.name] = prev[field.name];
147 | }
148 |
149 | return result;
150 | });
151 | }, [fields]);
152 |
153 | // abstract mouse drag/keyboard state tracker
154 | const {
155 | dragState,
156 |
157 | dragStartHandler,
158 | dragMoveHandler,
159 | dragEndHandler,
160 | dragHoverHandler,
161 |
162 | columnSelectHandler,
163 | assignHandler,
164 | unassignHandler
165 | } = useColumnDragState((column: Column, fieldName: string | null) => {
166 | // update field assignment map state
167 | setFieldAssignments((prev) => {
168 | const currentFieldName = Object.keys(prev).find(
169 | (fieldName) => prev[fieldName] === column.index
170 | );
171 |
172 | // see if there is nothing to do
173 | if (currentFieldName === undefined && fieldName === null) {
174 | return prev;
175 | }
176 |
177 | const copy = { ...prev };
178 |
179 | // ensure dropped column does not show up elsewhere
180 | if (currentFieldName) {
181 | delete copy[currentFieldName];
182 | }
183 |
184 | // set new field column
185 | if (fieldName !== null) {
186 | copy[fieldName] = column.index;
187 | }
188 |
189 | return copy;
190 | });
191 |
192 | // mark for validation display
193 | if (fieldName) {
194 | setFieldTouched((prev) => {
195 | if (prev[fieldName]) {
196 | return prev;
197 | }
198 |
199 | return { ...prev, [fieldName]: true };
200 | });
201 | }
202 | });
203 |
204 | // drag gesture wire-up
205 | const bindDrag = useDrag(
206 | ({ first, last, movement, xy, args, currentTarget }) => {
207 | if (first) {
208 | const [column, startFieldName] = args as [Column, string | undefined];
209 | const initialClientRect =
210 | currentTarget instanceof HTMLElement
211 | ? currentTarget.getBoundingClientRect()
212 | : new DOMRect(xy[0], xy[1], 0, 0); // fall back on just pointer position
213 |
214 | dragStartHandler(column, startFieldName, initialClientRect);
215 | } else if (last) {
216 | dragEndHandler();
217 | } else {
218 | dragMoveHandler(movement);
219 | }
220 | },
221 | {
222 | pointer: { capture: false } // turn off pointer capture to avoid interfering with hover tests
223 | }
224 | );
225 |
226 | // when dragging, set root-level user-select:none to prevent text selection, see Importer.scss
227 | // (done via class toggle to avoid interfering with any other dynamic style changes)
228 | useEffect(() => {
229 | if (dragState) {
230 | document.body.classList.add('CSVImporter_dragging');
231 | } else {
232 | // remove text selection prevention after a delay (otherwise on iOS it still selects something)
233 | const timeoutId = setTimeout(() => {
234 | document.body.classList.remove('CSVImporter_dragging');
235 | }, 200);
236 |
237 | return () => {
238 | // if another drag state comes along then cancel our delay and just clean up class right away
239 | clearTimeout(timeoutId);
240 | document.body.classList.remove('CSVImporter_dragging');
241 | };
242 | }
243 | }, [dragState]);
244 |
245 | // notify of current state
246 | useEffect(() => {
247 | onChangeRef.current({ fieldAssignments: { ...fieldAssignments } });
248 | }, [fieldAssignments]);
249 |
250 | return (
251 | {
257 | // mark all fields as touched (to show all the errors now)
258 | const fullTouchedMap: typeof fieldTouched = {};
259 | fields.forEach((field) => {
260 | fullTouchedMap[field.name] = true;
261 | });
262 | setFieldTouched(fullTouchedMap);
263 |
264 | // submit if validation succeeds
265 | const hasUnassignedRequired = fields.some(
266 | (field) =>
267 | !field.isOptional && fieldAssignments[field.name] === undefined
268 | );
269 |
270 | if (!hasUnassignedRequired) {
271 | onAccept();
272 | } else {
273 | setValidationError(l10n.requiredFieldsError);
274 | }
275 | }}
276 | nextLabel={l10n.nextButton}
277 | >
278 |
287 |
288 |
301 |
302 |
303 |
304 | );
305 | };
306 |
--------------------------------------------------------------------------------
/src/components/file-step/FileSelector.scss:
--------------------------------------------------------------------------------
1 | @import '../../theme.scss';
2 |
3 | .CSVImporter_FileSelector {
4 | border: 0.25em dashed $fgColor;
5 | padding: 4em;
6 | border-radius: $borderRadius;
7 | background: $fillColor;
8 | text-align: center;
9 | color: $textColor;
10 | cursor: pointer;
11 |
12 | &[data-active='true'] {
13 | background: darken($fillColor, 10%);
14 | transition: background 0.1s ease-out;
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/file-step/FileSelector.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useRef } from 'react';
2 | import { useDropzone } from 'react-dropzone';
3 | import { useLocale } from '../../locale/LocaleContext';
4 |
5 | import './FileSelector.scss';
6 |
7 | export const FileSelector: React.FC<{ onSelected: (file: File) => void }> = ({
8 | onSelected
9 | }) => {
10 | const onSelectedRef = useRef(onSelected);
11 | onSelectedRef.current = onSelected;
12 |
13 | const dropHandler = useCallback((acceptedFiles: File[]) => {
14 | // silently ignore if nothing to do
15 | if (acceptedFiles.length < 1) {
16 | return;
17 | }
18 |
19 | const file = acceptedFiles[0];
20 | onSelectedRef.current(file);
21 | }, []);
22 |
23 | const { getRootProps, getInputProps, isDragActive } = useDropzone({
24 | onDrop: dropHandler
25 | });
26 |
27 | const l10n = useLocale('fileStep');
28 |
29 | return (
30 |
35 |
36 |
37 | {isDragActive ? (
38 | {l10n.activeDragDropPrompt}
39 | ) : (
40 | {l10n.initialDragDropPrompt}
41 | )}
42 |
43 | );
44 | };
45 |
--------------------------------------------------------------------------------
/src/components/file-step/FileStep.scss:
--------------------------------------------------------------------------------
1 | @import '../../theme.scss';
2 |
3 | .CSVImporter_FileStep {
4 | &__header {
5 | display: flex;
6 | align-items: center;
7 | margin-bottom: 0.5em;
8 | font-size: $titleFontSize;
9 | color: $textSecondaryColor;
10 | }
11 |
12 | &__headerToggle {
13 | display: flex;
14 | align-items: center;
15 | margin-top: -0.5em; // allow for larger toggle element
16 | margin-bottom: -0.5em;
17 | margin-left: 1.5em;
18 | color: $textColor;
19 | cursor: pointer;
20 |
21 | > input[type='checkbox'] {
22 | margin-right: 0.5em;
23 | width: 1.2em;
24 | height: 1.2em;
25 | cursor: pointer;
26 | }
27 | }
28 |
29 | &__mainPendingBlock {
30 | display: flex;
31 | align-content: center;
32 | justify-content: center;
33 | padding: 2em;
34 | color: $textSecondaryColor;
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/file-step/FileStep.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo, useRef, useEffect, useState } from 'react';
2 |
3 | import {
4 | parsePreview,
5 | PreviewResults,
6 | PreviewReport,
7 | CustomizablePapaParseConfig
8 | } from '../../parser';
9 | import { ImporterFrame } from '../ImporterFrame';
10 | import { FileSelector } from './FileSelector';
11 | import { FormatRawPreview } from './FormatRawPreview';
12 | import { FormatDataRowPreview } from './FormatDataRowPreview';
13 | import { FormatErrorMessage } from './FormatErrorMessage';
14 |
15 | import './FileStep.scss';
16 | import { useLocale } from '../../locale/LocaleContext';
17 |
18 | export interface FileStepState extends PreviewReport {
19 | papaParseConfig: CustomizablePapaParseConfig; // config that was used for preview parsing
20 | hasHeaders: boolean;
21 | }
22 |
23 | export const FileStep: React.FC<{
24 | customConfig: CustomizablePapaParseConfig;
25 | defaultNoHeader?: boolean;
26 | prevState: FileStepState | null;
27 | onChange: (state: FileStepState | null) => void;
28 | onAccept: () => void;
29 | }> = ({ customConfig, defaultNoHeader, prevState, onChange, onAccept }) => {
30 | const l10n = useLocale('fileStep');
31 |
32 | // seed from previous state as needed
33 | const [selectedFile, setSelectedFile] = useState(
34 | prevState ? prevState.file : null
35 | );
36 |
37 | const [preview, setPreview] = useState(
38 | () =>
39 | prevState && {
40 | parseError: undefined,
41 | ...prevState
42 | }
43 | );
44 |
45 | const [papaParseConfig, setPapaParseConfig] = useState(
46 | prevState ? prevState.papaParseConfig : customConfig
47 | );
48 |
49 | const [hasHeaders, setHasHeaders] = useState(
50 | prevState ? prevState.hasHeaders : false
51 | );
52 |
53 | // wrap in ref to avoid triggering effect
54 | const customConfigRef = useRef(customConfig);
55 | customConfigRef.current = customConfig;
56 | const defaultNoHeaderRef = useRef(defaultNoHeader);
57 | defaultNoHeaderRef.current = defaultNoHeader;
58 | const onChangeRef = useRef(onChange);
59 | onChangeRef.current = onChange;
60 |
61 | // notify of current state
62 | useEffect(() => {
63 | onChangeRef.current(
64 | preview && !preview.parseError
65 | ? { ...preview, papaParseConfig, hasHeaders }
66 | : null
67 | );
68 | }, [preview, papaParseConfig, hasHeaders]);
69 |
70 | // perform async preview parse once for the given file
71 | const asyncLockRef = useRef(0);
72 | useEffect(() => {
73 | // clear other state when file selector is reset
74 | if (!selectedFile) {
75 | setPreview(null);
76 | return;
77 | }
78 |
79 | // preserve existing state when parsing for this file is already complete
80 | if (preview && preview.file === selectedFile) {
81 | return;
82 | }
83 |
84 | const oplock = asyncLockRef.current;
85 |
86 | // lock in the current PapaParse config instance for use in multiple spots
87 | const config = customConfigRef.current;
88 |
89 | // kick off the preview parse
90 | parsePreview(selectedFile, config).then((results) => {
91 | // ignore if stale
92 | if (oplock !== asyncLockRef.current) {
93 | return;
94 | }
95 |
96 | // save the results and the original config
97 | setPreview(results);
98 | setPapaParseConfig(config);
99 |
100 | // pre-fill headers flag (only possible with >1 lines)
101 | setHasHeaders(
102 | results.parseError
103 | ? false
104 | : !defaultNoHeaderRef.current && !results.isSingleLine
105 | );
106 | });
107 |
108 | return () => {
109 | // invalidate current oplock on change or unmount
110 | asyncLockRef.current += 1;
111 | };
112 | }, [selectedFile, preview]);
113 |
114 | // clear selected file
115 | // preview result content to display
116 | const reportBlock = useMemo(() => {
117 | if (!preview) {
118 | return null;
119 | }
120 |
121 | if (preview.parseError) {
122 | return (
123 |
124 | setSelectedFile(null)}>
125 | {l10n.getImportError(
126 | preview.parseError.message || String(preview.parseError)
127 | )}
128 |
129 |
130 | );
131 | }
132 |
133 | return (
134 |
135 |
136 | {l10n.rawFileContentsHeading}
137 |
138 |
139 |
setSelectedFile(null)}
143 | />
144 |
145 | {preview.parseWarning ? null : (
146 | <>
147 |
148 | {l10n.previewImportHeading}
149 | {!preview.isSingleLine && ( // hide setting if only one line anyway
150 |
151 | {
155 | setHasHeaders((prev) => !prev);
156 | }}
157 | />
158 | {l10n.dataHasHeadersCheckbox}
159 |
160 | )}
161 |
162 |
166 | >
167 | )}
168 |
169 | );
170 | }, [preview, hasHeaders, l10n]);
171 |
172 | if (!selectedFile) {
173 | return setSelectedFile(file)} />;
174 | }
175 |
176 | return (
177 | {
181 | if (!preview || preview.parseError) {
182 | throw new Error('unexpected missing preview info');
183 | }
184 |
185 | onAccept();
186 | }}
187 | onCancel={() => setSelectedFile(null)}
188 | nextLabel={l10n.nextButton}
189 | >
190 | {reportBlock || (
191 |
192 | {l10n.previewLoadingStatus}
193 |
194 | )}
195 |
196 | );
197 | };
198 |
--------------------------------------------------------------------------------
/src/components/file-step/FormatDataRowPreview.scss:
--------------------------------------------------------------------------------
1 | @import '../../theme.scss';
2 |
3 | .CSVImporter_FormatDataRowPreview {
4 | max-height: 12em;
5 | min-height: 6em;
6 | border: 1px solid $controlBorderColor;
7 | overflow: scroll;
8 |
9 | &__table {
10 | width: 100%;
11 | border-spacing: 0;
12 | border-collapse: collapse;
13 |
14 | > thead > tr > th {
15 | font-style: italic;
16 | font-weight: normal;
17 | color: $textSecondaryColor;
18 | }
19 |
20 | > thead > tr > th,
21 | > tbody > tr > td {
22 | border-right: 1px solid rgba($controlBorderColor, 0.5);
23 | padding: 0.5em 0.5em;
24 | line-height: 1;
25 | font-size: 0.75em;
26 | white-space: nowrap;
27 |
28 | &:last-child {
29 | border-right: none;
30 | }
31 | }
32 |
33 | // shrink space between rows
34 | > thead + tbody > tr:first-child > td,
35 | > tbody > tr + tr > td {
36 | padding-top: 0;
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/file-step/FormatDataRowPreview.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import './FormatDataRowPreview.scss';
4 |
5 | export const FormatDataRowPreview: React.FC<{
6 | hasHeaders: boolean;
7 | rows: string[][];
8 | // eslint-disable-next-line react/display-name
9 | }> = React.memo(({ hasHeaders, rows }) => {
10 | const headerRow = hasHeaders ? rows[0] : null;
11 | const bodyRows = hasHeaders ? rows.slice(1) : rows;
12 |
13 | return (
14 |
15 |
16 | {headerRow && (
17 |
18 |
19 | {headerRow.map((item, itemIndex) => (
20 | {item}
21 | ))}
22 |
23 |
24 | )}
25 |
26 |
27 | {bodyRows.map((row, rowIndex) => (
28 |
29 | {row.map((item, itemIndex) => (
30 | {item}
31 | ))}
32 |
33 | ))}
34 |
35 |
36 |
37 | );
38 | });
39 |
--------------------------------------------------------------------------------
/src/components/file-step/FormatErrorMessage.scss:
--------------------------------------------------------------------------------
1 | @import '../../theme.scss';
2 |
3 | .CSVImporter_FormatErrorMessage {
4 | display: flex;
5 | align-items: center;
6 | padding: 0.5em 1em;
7 | border-radius: $borderRadius;
8 | background: $fillColor;
9 | color: $errorTextColor;
10 |
11 | & > span {
12 | flex: 1 1 0;
13 | margin-right: 1em;
14 | width: 0; // avoid sizing on inner content
15 | word-break: break-word;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/file-step/FormatErrorMessage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { TextButton } from '../TextButton';
4 |
5 | import './FormatErrorMessage.scss';
6 | import { useLocale } from '../../locale/LocaleContext';
7 |
8 | export const FormatErrorMessage: React.FC<{
9 | onCancelClick: () => void;
10 | // eslint-disable-next-line react/display-name
11 | }> = React.memo(({ onCancelClick, children }) => {
12 | const l10n = useLocale('fileStep');
13 | return (
14 |
15 | {children}
16 | {l10n.goBackButton}
17 |
18 | );
19 | });
20 |
--------------------------------------------------------------------------------
/src/components/file-step/FormatRawPreview.scss:
--------------------------------------------------------------------------------
1 | @import '../../theme.scss';
2 |
3 | .CSVImporter_FormatRawPreview {
4 | &__scroll {
5 | margin-bottom: 1.2em;
6 | height: 6em;
7 | overflow: auto;
8 | border-radius: $borderRadius;
9 | background: $invertedBgColor;
10 | color: $invertedTextColor;
11 | }
12 |
13 | &__pre {
14 | margin: 0; // override default
15 | padding: 0.5em 1em;
16 | line-height: 1.25;
17 | font-size: 1.15em;
18 |
19 | & > aside {
20 | display: inline-block;
21 | margin-left: 0.2em;
22 | padding: 0 0.25em;
23 | border-radius: $borderRadius * 0.5;
24 | background: $controlBgColor;
25 | font-size: 0.75em;
26 | color: $controlBorderColor;
27 | opacity: 0.75;
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/file-step/FormatRawPreview.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useLocale } from '../../locale/LocaleContext';
3 |
4 | import { FormatErrorMessage } from './FormatErrorMessage';
5 |
6 | import './FormatRawPreview.scss';
7 |
8 | const RAW_PREVIEW_SIZE = 500;
9 |
10 | export const FormatRawPreview: React.FC<{
11 | chunk: string;
12 | warning?: Papa.ParseError;
13 | onCancelClick: () => void;
14 | // eslint-disable-next-line react/display-name
15 | }> = React.memo(({ chunk, warning, onCancelClick }) => {
16 | const chunkSlice = chunk.slice(0, RAW_PREVIEW_SIZE);
17 | const chunkHasMore = chunk.length > RAW_PREVIEW_SIZE;
18 |
19 | const l10n = useLocale('fileStep');
20 |
21 | return (
22 |
23 |
24 |
25 | {chunkSlice}
26 | {chunkHasMore && }
27 |
28 |
29 |
30 | {warning ? (
31 |
32 | {l10n.getDataFormatError(warning.message || String(warning))}
33 |
34 | ) : null}
35 |
36 | );
37 | });
38 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './components/ImporterProps';
2 | export * from './components/Importer';
3 | export * from './locale';
4 |
--------------------------------------------------------------------------------
/src/locale/ImporterLocale.ts:
--------------------------------------------------------------------------------
1 | export interface ImporterLocale {
2 | general: {
3 | goToPreviousStepTooltip: string;
4 | };
5 |
6 | fileStep: {
7 | initialDragDropPrompt: string;
8 | activeDragDropPrompt: string;
9 |
10 | getImportError: (message: string) => string;
11 | getDataFormatError: (message: string) => string;
12 | goBackButton: string;
13 | nextButton: string;
14 |
15 | rawFileContentsHeading: string;
16 | previewImportHeading: string;
17 | dataHasHeadersCheckbox: string;
18 | previewLoadingStatus: string;
19 | };
20 |
21 | fieldsStep: {
22 | stepSubtitle: string;
23 | requiredFieldsError: string;
24 | nextButton: string;
25 |
26 | dragSourceAreaCaption: string;
27 | getDragSourcePageIndicator: (
28 | currentPage: number,
29 | pageCount: number
30 | ) => string;
31 | getDragSourceActiveStatus: (columnCode: string) => string;
32 | nextColumnsTooltip: string;
33 | previousColumnsTooltip: string;
34 | clearAssignmentTooltip: string;
35 | selectColumnTooltip: string;
36 | unselectColumnTooltip: string;
37 |
38 | dragTargetAreaCaption: string;
39 | getDragTargetOptionalCaption: (field: string) => string;
40 | getDragTargetRequiredCaption: (field: string) => string;
41 | dragTargetPlaceholder: string;
42 | getDragTargetAssignTooltip: (columnCode: string) => string;
43 | dragTargetClearTooltip: string;
44 |
45 | columnCardDummyHeader: string;
46 | getColumnCardHeader: (code: string) => string;
47 | };
48 |
49 | progressStep: {
50 | stepSubtitle: string;
51 | uploadMoreButton: string;
52 | finishButton: string;
53 | statusError: string;
54 | statusComplete: string;
55 | statusPending: string;
56 | processedRowsLabel: string;
57 | };
58 | }
59 |
--------------------------------------------------------------------------------
/src/locale/LocaleContext.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { ImporterLocale, enUS } from '.';
3 | import { useContext } from 'react';
4 |
5 | export const LocaleContext = React.createContext(enUS);
6 |
7 | type I18nNamespace = keyof ImporterLocale;
8 |
9 | export function useLocale(
10 | namespace: N
11 | ): ImporterLocale[N] {
12 | const locale = useContext(LocaleContext);
13 | return locale[namespace]; // not using memo for basic property getter
14 | }
15 |
--------------------------------------------------------------------------------
/src/locale/index.ts:
--------------------------------------------------------------------------------
1 | export type { ImporterLocale } from './ImporterLocale';
2 |
3 | export { enUS } from './locale_enUS';
4 | export { deDE } from './locale_deDE';
5 | export { itIT } from './locale_itIT';
6 | export { ptBR } from './locale_ptBR';
7 | export { daDK } from './locale_daDK';
8 | export { trTR } from './locale_trTR';
9 |
--------------------------------------------------------------------------------
/src/locale/locale_daDK.ts:
--------------------------------------------------------------------------------
1 | import { ImporterLocale } from './ImporterLocale';
2 |
3 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types -- all exports are ImporterLocale which is already fully typed */
4 | export const daDK: ImporterLocale = {
5 | general: {
6 | goToPreviousStepTooltip: 'Gå til forrige trin'
7 | },
8 |
9 | fileStep: {
10 | initialDragDropPrompt:
11 | 'Træk og slip CSV-fil her eller klik for at vælge fra en mappe',
12 | activeDragDropPrompt: 'Slip CSV-fil her...',
13 |
14 | getImportError: (message) => `Import-fejl: ${message}`,
15 | getDataFormatError: (message) =>
16 | `Kontrollér venligst data-formatering: ${message}`,
17 | goBackButton: 'Gå tilbage',
18 | nextButton: 'Vælg kolonner',
19 |
20 | rawFileContentsHeading: 'Rå filindhold',
21 | previewImportHeading: 'Forhåndsvis Import',
22 | dataHasHeadersCheckbox: 'Data sidehoved',
23 | previewLoadingStatus: 'Indlæser forhåndsvisning...'
24 | },
25 |
26 | fieldsStep: {
27 | stepSubtitle: 'Vælg kolonner',
28 | requiredFieldsError: 'Tildel venligst alle påkrævede felter',
29 | nextButton: 'Importér',
30 |
31 | dragSourceAreaCaption: 'Kolonner til import',
32 | getDragSourcePageIndicator: (currentPage: number, pageCount: number) =>
33 | `Side ${currentPage} af ${pageCount}`,
34 | getDragSourceActiveStatus: (columnCode: string) =>
35 | `Tildeler kolonne ${columnCode}`,
36 | nextColumnsTooltip: 'Vis næste kolonner',
37 | previousColumnsTooltip: 'Vis forrige kolonner',
38 | clearAssignmentTooltip: 'Ryd kolonne-tildeling',
39 | selectColumnTooltip: 'Vælg kolonne til tildeling',
40 | unselectColumnTooltip: 'Fravælg kolonne',
41 |
42 | dragTargetAreaCaption: 'Mål-felter',
43 | getDragTargetOptionalCaption: (field) => `${field} (valgfri)`,
44 | getDragTargetRequiredCaption: (field) => `${field} (påkrævet)`,
45 | dragTargetPlaceholder: 'Træk kolonne hertil',
46 | getDragTargetAssignTooltip: (columnCode: string) =>
47 | `Tildel kolonne ${columnCode}`,
48 | dragTargetClearTooltip: 'Ryd kolonne-tildeling',
49 |
50 | columnCardDummyHeader: 'Disponibelt felt',
51 | getColumnCardHeader: (code) => `Column ${code}`
52 | },
53 |
54 | progressStep: {
55 | stepSubtitle: 'Importér',
56 | uploadMoreButton: 'Upload Mere',
57 | finishButton: 'Færdiggør',
58 | statusError: 'Kunne ikke importere',
59 | statusComplete: 'Færdig',
60 | statusPending: 'Importerer...',
61 | processedRowsLabel: 'Processerede rækker:'
62 | }
63 | };
64 |
--------------------------------------------------------------------------------
/src/locale/locale_deDE.ts:
--------------------------------------------------------------------------------
1 | import { ImporterLocale } from './ImporterLocale';
2 |
3 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types -- all exports are ImporterLocale which is already fully typed */
4 | export const deDE: ImporterLocale = {
5 | general: {
6 | goToPreviousStepTooltip: 'Zum vorherigen Schritt'
7 | },
8 |
9 | fileStep: {
10 | initialDragDropPrompt:
11 | 'CSV-Datei auf dieses Feld ziehen, oder klicken um eine Datei auszuwählen',
12 | activeDragDropPrompt: 'CSV-Datei auf dieses Feld ziehen...',
13 | nextButton: 'Spalten auswählen',
14 |
15 | getImportError: (message) => `Fehler beim Import: ${message}`,
16 | getDataFormatError: (message: string) =>
17 | `Bitte Datenformat überprüfen: ${message}`,
18 | goBackButton: 'Zurück',
19 |
20 | rawFileContentsHeading: 'Originaler Datei-Inhalt',
21 | previewImportHeading: 'Import-Vorschau',
22 | dataHasHeadersCheckbox: 'Mit Kopfzeile',
23 | previewLoadingStatus: 'Vorschau wird geladen...'
24 | },
25 |
26 | fieldsStep: {
27 | stepSubtitle: 'Spalten auswählen',
28 | requiredFieldsError:
29 | 'Bitte weise allen nicht optionalen Spalten einen Wert zu',
30 | nextButton: 'Importieren',
31 |
32 | dragSourceAreaCaption: 'Zu importierende Spalte',
33 | getDragSourcePageIndicator: (currentPage: number, pageCount: number) =>
34 | `Seite ${currentPage} von ${pageCount}`,
35 | getDragSourceActiveStatus: (columnCode: string) =>
36 | `Spalte ${columnCode} zuweisen`,
37 | nextColumnsTooltip: 'Nächste Spalten anzeigen',
38 | previousColumnsTooltip: 'Vorherige Spalten anzeigen',
39 | clearAssignmentTooltip: 'Zugewiesene Spalte entfernen',
40 | selectColumnTooltip: 'Spalte zum Zuweisen auswählen',
41 | unselectColumnTooltip: 'Spalte abwählen',
42 |
43 | dragTargetAreaCaption: 'Zielfelder',
44 | getDragTargetOptionalCaption: (field) => `${field} (optional)`,
45 | getDragTargetRequiredCaption: (field) => `${field} (erforderlich)`,
46 | dragTargetPlaceholder: 'Spalte hierher ziehen',
47 | getDragTargetAssignTooltip: (columnCode: string) =>
48 | `Spalte ${columnCode} zuweisen`,
49 | dragTargetClearTooltip: 'Zugewiesene Spalte entfernen',
50 |
51 | columnCardDummyHeader: 'Nicht zugewiesenes Feld',
52 | getColumnCardHeader: (code) => `Spalte ${code}`
53 | },
54 |
55 | progressStep: {
56 | stepSubtitle: 'Importieren',
57 | uploadMoreButton: 'Weitere hochladen',
58 | finishButton: 'Abschließen',
59 | statusError: 'Konnte nicht importiert werden',
60 | statusComplete: 'Fertig',
61 | statusPending: 'Wird importiert...',
62 | processedRowsLabel: 'Verarbeitete Zeilen:'
63 | }
64 | };
65 |
--------------------------------------------------------------------------------
/src/locale/locale_enUS.ts:
--------------------------------------------------------------------------------
1 | import { ImporterLocale } from './ImporterLocale';
2 |
3 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types -- all exports are ImporterLocale which is already fully typed */
4 | export const enUS: ImporterLocale = {
5 | general: {
6 | goToPreviousStepTooltip: 'Go to previous step'
7 | },
8 |
9 | fileStep: {
10 | initialDragDropPrompt:
11 | 'Drag-and-drop CSV file here, or click to select in folder',
12 | activeDragDropPrompt: 'Drop CSV file here...',
13 |
14 | getImportError: (message) => `Import error: ${message}`,
15 | getDataFormatError: (message) => `Please check data formatting: ${message}`,
16 | goBackButton: 'Go Back',
17 | nextButton: 'Choose columns',
18 |
19 | rawFileContentsHeading: 'Raw File Contents',
20 | previewImportHeading: 'Preview Import',
21 | dataHasHeadersCheckbox: 'Data has headers',
22 | previewLoadingStatus: 'Loading preview...'
23 | },
24 |
25 | fieldsStep: {
26 | stepSubtitle: 'Select Columns',
27 | requiredFieldsError: 'Please assign all required fields',
28 | nextButton: 'Import',
29 |
30 | dragSourceAreaCaption: 'Columns to import',
31 | getDragSourcePageIndicator: (currentPage: number, pageCount: number) =>
32 | `Page ${currentPage} of ${pageCount}`,
33 | getDragSourceActiveStatus: (columnCode: string) =>
34 | `Assigning column ${columnCode}`,
35 | nextColumnsTooltip: 'Show next columns',
36 | previousColumnsTooltip: 'Show previous columns',
37 | clearAssignmentTooltip: 'Clear column assignment',
38 | selectColumnTooltip: 'Select column for assignment',
39 | unselectColumnTooltip: 'Unselect column',
40 |
41 | dragTargetAreaCaption: 'Target fields',
42 | getDragTargetOptionalCaption: (field) => `${field} (optional)`,
43 | getDragTargetRequiredCaption: (field) => `${field} (required)`,
44 | dragTargetPlaceholder: 'Drag column here',
45 | getDragTargetAssignTooltip: (columnCode: string) =>
46 | `Assign column ${columnCode}`,
47 | dragTargetClearTooltip: 'Clear column assignment',
48 |
49 | columnCardDummyHeader: 'Unassigned field',
50 | getColumnCardHeader: (code) => `Column ${code}`
51 | },
52 |
53 | progressStep: {
54 | stepSubtitle: 'Import',
55 | uploadMoreButton: 'Upload More',
56 | finishButton: 'Finish',
57 | statusError: 'Could not import',
58 | statusComplete: 'Complete',
59 | statusPending: 'Importing...',
60 | processedRowsLabel: 'Processed rows:'
61 | }
62 | };
63 |
--------------------------------------------------------------------------------
/src/locale/locale_itIT.ts:
--------------------------------------------------------------------------------
1 | import { ImporterLocale } from './ImporterLocale';
2 |
3 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types -- all exports are ImporterLocale which is already fully typed */
4 | export const itIT: ImporterLocale = {
5 | general: {
6 | goToPreviousStepTooltip: 'Torna indietro'
7 | },
8 |
9 | fileStep: {
10 | initialDragDropPrompt:
11 | 'Trascina qui il file CSV, o clicca per selezionarlo dal PC',
12 | activeDragDropPrompt: 'Rilascia qui il file CSV...',
13 |
14 | getImportError: (message) => `Errore durante l'importazione: ${message}`,
15 | getDataFormatError: (message) =>
16 | `Si prega di controllare il formato dei dati: ${message}`,
17 | goBackButton: 'Torna indietro',
18 | nextButton: 'Seleziona le colonne',
19 |
20 | rawFileContentsHeading: 'Contenuto delfile caricato',
21 | previewImportHeading: 'Anteprima dei dati',
22 | dataHasHeadersCheckbox: 'Intestazione presente nel file',
23 | previewLoadingStatus: 'Caricamento anteprima...'
24 | },
25 |
26 | fieldsStep: {
27 | stepSubtitle: 'Seleziona le colonne',
28 | requiredFieldsError: 'Si prega di assegnare tutte le colonne richieste',
29 | nextButton: 'Importa',
30 |
31 | dragSourceAreaCaption: 'Colonne da importare',
32 | getDragSourcePageIndicator: (currentPage: number, pageCount: number) =>
33 | `Pagina ${currentPage} di ${pageCount}`,
34 | getDragSourceActiveStatus: (columnCode: string) =>
35 | `Assegnamento alla colonna ${columnCode}`,
36 | nextColumnsTooltip: 'Mostra colonna successiva',
37 | previousColumnsTooltip: 'Mostra colonna precedente',
38 | clearAssignmentTooltip: 'Cancella tutti gli assegnamenti delle colonne',
39 | selectColumnTooltip: 'Seleziona una colonna da assegnare',
40 | unselectColumnTooltip: 'Deseleziona colonna',
41 |
42 | dragTargetAreaCaption: 'Campi richiesti',
43 | getDragTargetOptionalCaption: (field) => `${field} (opzionale)`,
44 | getDragTargetRequiredCaption: (field) => `${field} (obbligatorio)`,
45 | dragTargetPlaceholder: 'Trascina qui la colonna',
46 | getDragTargetAssignTooltip: (columnCode: string) =>
47 | `Assegnamento alla colonna ${columnCode}`,
48 | dragTargetClearTooltip: 'Cancella gli assegnamenti alla colonna',
49 |
50 | columnCardDummyHeader: 'Campo non assegnato',
51 | getColumnCardHeader: (code) => `Column ${code}`
52 | },
53 |
54 | progressStep: {
55 | stepSubtitle: 'Importa',
56 | uploadMoreButton: 'Carica altri dati',
57 | finishButton: 'Fine',
58 | statusError: 'Errore di caricamento',
59 | statusComplete: 'Completato',
60 | statusPending: 'Caricamento...',
61 | processedRowsLabel: 'Righe processate:'
62 | }
63 | };
64 |
--------------------------------------------------------------------------------
/src/locale/locale_ptBR.ts:
--------------------------------------------------------------------------------
1 | import { ImporterLocale } from './ImporterLocale';
2 |
3 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types -- all exports are ImporterLocale which is already fully typed */
4 | export const ptBR: ImporterLocale = {
5 | general: {
6 | goToPreviousStepTooltip: 'Voltar a etapa anterior'
7 | },
8 |
9 | fileStep: {
10 | initialDragDropPrompt:
11 | 'Arraste e solte o arquivo CSV aqui ou clique para selecionar na pasta',
12 | activeDragDropPrompt: 'Arraste e solte o arquivo CSV aqui...',
13 |
14 | getImportError: (message) => `Erro ao importar: ${message}`,
15 | getDataFormatError: (message) =>
16 | `Por favor confira a formatação dos dados: ${message}`,
17 | goBackButton: 'Voltar',
18 | nextButton: 'Escolher Colunas',
19 |
20 | rawFileContentsHeading: 'Conteúdo Bruto do Arquivo',
21 | previewImportHeading: 'Visualizar Importação',
22 | dataHasHeadersCheckbox: 'Os dados têm cabeçalhos',
23 | previewLoadingStatus: 'Carregando visualização...'
24 | },
25 |
26 | fieldsStep: {
27 | stepSubtitle: 'Selecionar Colunas',
28 | requiredFieldsError: 'Atribua todos os campos obrigatórios',
29 | nextButton: 'Importar',
30 |
31 | dragSourceAreaCaption: 'Colunas para importar',
32 | getDragSourcePageIndicator: (currentPage: number, pageCount: number) =>
33 | `Página ${currentPage} de ${pageCount}`,
34 | getDragSourceActiveStatus: (columnCode: string) =>
35 | `Atribuindo coluna ${columnCode}`,
36 | nextColumnsTooltip: 'Mostrar as próximas colunas',
37 | previousColumnsTooltip: 'Mostrar colunas anteriores',
38 | clearAssignmentTooltip: 'Limpar atribuição de coluna',
39 | selectColumnTooltip: 'Selecione a coluna para atribuição',
40 | unselectColumnTooltip: 'Desmarcar coluna',
41 |
42 | dragTargetAreaCaption: 'Campos de destino',
43 | getDragTargetOptionalCaption: (field) => `${field} (opcional)`,
44 | getDragTargetRequiredCaption: (field) => `${field} (obrigatório)`,
45 | dragTargetPlaceholder: 'Arraste a coluna aqui',
46 | getDragTargetAssignTooltip: (columnCode: string) =>
47 | `Atribuir coluna ${columnCode}`,
48 | dragTargetClearTooltip: 'Limpar atribuição de coluna',
49 |
50 | columnCardDummyHeader: 'Campo não atribuído',
51 | getColumnCardHeader: (code) => `Coluna ${code}`
52 | },
53 |
54 | progressStep: {
55 | stepSubtitle: 'Importar',
56 | uploadMoreButton: 'Carregar mais',
57 | finishButton: 'Finalizar',
58 | statusError: 'Não foi possível importar',
59 | statusComplete: 'Completo',
60 | statusPending: 'Importando...',
61 | processedRowsLabel: 'Linhas processadas:'
62 | }
63 | };
64 |
--------------------------------------------------------------------------------
/src/locale/locale_trTR.ts:
--------------------------------------------------------------------------------
1 | import { ImporterLocale } from './ImporterLocale';
2 |
3 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types -- all exports are ImporterLocale which is already fully typed */
4 | export const trTR: ImporterLocale = {
5 | general: {
6 | goToPreviousStepTooltip: 'Bir önceki adıma geri dön'
7 | },
8 |
9 | fileStep: {
10 | initialDragDropPrompt:
11 | 'CSV dosyasını sürükleyin veya kutunun içine tıklayıp dosyayı seçin',
12 | activeDragDropPrompt: 'CSV dosyasını buraya bırakın...',
13 |
14 | getImportError: (message) => `Import hatası: ${message}`,
15 | getDataFormatError: (message) =>
16 | `Lütfen veri formatını kontrol edin: ${message}`,
17 | goBackButton: 'Geri',
18 | nextButton: 'Kolonları Seç',
19 |
20 | rawFileContentsHeading: 'CSV dosyası içeriği',
21 | previewImportHeading: 'Import önizleme',
22 | dataHasHeadersCheckbox: 'Veride başlıklar var',
23 | previewLoadingStatus: 'Önizleme yükleniyor...'
24 | },
25 |
26 | fieldsStep: {
27 | stepSubtitle: 'Kolonları seçin',
28 | requiredFieldsError: 'Lütfen zorunlu tüm alanları doldurun.',
29 | nextButton: 'Import',
30 |
31 | dragSourceAreaCaption: 'Import edilecek kolonlar',
32 | getDragSourcePageIndicator: (currentPage: number, pageCount: number) =>
33 | `${pageCount} sayfadan ${currentPage}. sayfadasınız`,
34 | getDragSourceActiveStatus: (columnCode: string) =>
35 | `${columnCode}. kolon atanıyor`,
36 | nextColumnsTooltip: 'Sıradaki kolonları göster',
37 | previousColumnsTooltip: 'Önceki kolonları göster',
38 | clearAssignmentTooltip: 'Kolon atamayı temizle',
39 | selectColumnTooltip: 'Atamak için kolon seçiniz',
40 | unselectColumnTooltip: 'Kolonu seçmeyi bırak',
41 |
42 | dragTargetAreaCaption: 'Hedef alanlar',
43 | getDragTargetOptionalCaption: (field) => `${field} (opsiyonel)`,
44 | getDragTargetRequiredCaption: (field) => `${field} (zorunlu)`,
45 | dragTargetPlaceholder: 'Kolonu buraya sürükle',
46 | getDragTargetAssignTooltip: (columnCode: string) =>
47 | `${columnCode}. kolonu ata`,
48 | dragTargetClearTooltip: 'Kolon atamayı temizle',
49 |
50 | columnCardDummyHeader: 'Atanmamış alan',
51 | getColumnCardHeader: (code) => `Kolon ${code}`
52 | },
53 |
54 | progressStep: {
55 | stepSubtitle: 'Import',
56 | uploadMoreButton: 'Sonrakileri yükle',
57 | finishButton: 'Bitir',
58 | statusError: 'Import edilemedi',
59 | statusComplete: 'Tamamlandı',
60 | statusPending: 'Import ediliyor...',
61 | processedRowsLabel: 'İşlenen satır sayısı:'
62 | }
63 | };
64 |
--------------------------------------------------------------------------------
/src/parser.ts:
--------------------------------------------------------------------------------
1 | import Papa from 'papaparse';
2 | import { Readable } from 'stream';
3 |
4 | export interface CustomizablePapaParseConfig {
5 | delimiter?: Papa.ParseConfig['delimiter'];
6 | newline?: Papa.ParseConfig['newline'];
7 | quoteChar?: Papa.ParseConfig['quoteChar'];
8 | escapeChar?: Papa.ParseConfig['escapeChar'];
9 | comments?: Papa.ParseConfig['comments'];
10 | skipEmptyLines?: Papa.ParseConfig['skipEmptyLines'];
11 | delimitersToGuess?: Papa.ParseConfig['delimitersToGuess'];
12 | chunkSize?: Papa.ParseConfig['chunkSize'];
13 | encoding?: Papa.ParseConfig['encoding'];
14 | }
15 |
16 | export interface PreviewReport {
17 | file: File;
18 | firstChunk: string;
19 | firstRows: string[][]; // always PREVIEW_ROWS count
20 | isSingleLine: boolean;
21 | parseWarning?: Papa.ParseError;
22 | }
23 |
24 | // success/failure report from the preview parse attempt
25 | export type PreviewResults =
26 | | {
27 | parseError: Error | Papa.ParseError;
28 | file: File;
29 | }
30 | | ({
31 | parseError: undefined;
32 | } & PreviewReport);
33 |
34 | export const PREVIEW_ROW_COUNT = 5;
35 |
36 | // for each given target field name, source from original CSV column index
37 | export type FieldAssignmentMap = { [name: string]: number | undefined };
38 |
39 | export type BaseRow = { [name: string]: unknown };
40 |
41 | export type ParseCallback = (
42 | rows: Row[],
43 | info: {
44 | startIndex: number;
45 | }
46 | ) => void | Promise;
47 |
48 | // polyfill as implemented in https://github.com/eligrey/Blob.js/blob/master/Blob.js#L653
49 | // (this is for Safari pre v14.1)
50 | function streamForBlob(blob: Blob) {
51 | if (blob.stream) {
52 | return blob.stream();
53 | }
54 |
55 | const res = new Response(blob);
56 | if (res.body) {
57 | return res.body;
58 | }
59 |
60 | throw new Error('This browser does not support client-side file reads');
61 | }
62 |
63 | // incredibly cheap wrapper exposing a subset of stream.Readable interface just for PapaParse usage
64 | // @todo chunk size
65 | function nodeStreamWrapper(stream: ReadableStream, encoding: string): Readable {
66 | let dataHandler: ((chunk: string) => void) | null = null;
67 | let endHandler: ((unused: unknown) => void) | null = null;
68 | let errorHandler: ((error: unknown) => void) | null = null;
69 | let isStopped = false;
70 |
71 | let pausePromise: Promise | null = null;
72 | let pauseResolver: (() => void) | null = null;
73 |
74 | async function runReaderPump() {
75 | // ensure this is truly in the next tick after uncorking
76 | await Promise.resolve();
77 |
78 | const streamReader = stream.getReader();
79 | const decoder = new TextDecoder(encoding); // this also strips BOM by default
80 |
81 | try {
82 | // main reader pump loop
83 | while (!isStopped) {
84 | // perform read from upstream
85 | const { done, value } = await streamReader.read();
86 |
87 | // wait if we became paused since last data event
88 | if (pausePromise) {
89 | await pausePromise;
90 | }
91 |
92 | // check again if stopped and unlistened
93 | if (isStopped || !dataHandler || !endHandler) {
94 | return;
95 | }
96 |
97 | // final data flush and end notification
98 | if (done) {
99 | const lastChunkString = decoder.decode(value); // value is empty but pass just in case
100 | if (lastChunkString) {
101 | dataHandler(lastChunkString);
102 | }
103 |
104 | endHandler(undefined);
105 | return;
106 | }
107 |
108 | // otherwise, normal data event after stream-safe decoding
109 | const chunkString = decoder.decode(value, { stream: true });
110 | dataHandler(chunkString);
111 | }
112 | } finally {
113 | // always release the lock
114 | streamReader.releaseLock();
115 | }
116 | }
117 |
118 | const self = {
119 | // marker properties to make PapaParse think this is a Readable object
120 | readable: true,
121 | read() {
122 | throw new Error('only flowing mode is emulated');
123 | },
124 |
125 | on(event: string, callback: (param: unknown) => void) {
126 | switch (event) {
127 | case 'data':
128 | if (dataHandler) {
129 | throw new Error('two data handlers not supported');
130 | }
131 | dataHandler = callback;
132 |
133 | // flowing state started, run the main pump loop
134 | runReaderPump().catch((error) => {
135 | if (errorHandler) {
136 | errorHandler(error);
137 | } else {
138 | // rethrow to show error in console
139 | throw error;
140 | }
141 | });
142 |
143 | return;
144 | case 'end':
145 | if (endHandler) {
146 | throw new Error('two end handlers not supported');
147 | }
148 | endHandler = callback;
149 | return;
150 | case 'error':
151 | if (errorHandler) {
152 | throw new Error('two error handlers not supported');
153 | }
154 | errorHandler = callback;
155 | return;
156 | }
157 |
158 | throw new Error('unknown stream shim event: ' + event);
159 | },
160 |
161 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
162 | removeListener(event: string, callback: (param: unknown) => void) {
163 | // stop and clear everything for simplicity
164 | isStopped = true;
165 | dataHandler = null;
166 | endHandler = null;
167 | errorHandler = null;
168 | },
169 |
170 | pause() {
171 | if (!pausePromise) {
172 | pausePromise = new Promise((resolve) => {
173 | pauseResolver = resolve;
174 | });
175 | }
176 | return self;
177 | },
178 |
179 | resume() {
180 | if (pauseResolver) {
181 | pauseResolver(); // waiting code will proceed in next tick
182 | pausePromise = null;
183 | pauseResolver = null;
184 | }
185 | return self;
186 | }
187 | };
188 |
189 | // pass ourselves off as a real Node stream
190 | return (self as unknown) as Readable;
191 | }
192 |
193 | export function parsePreview(
194 | file: File,
195 | customConfig: CustomizablePapaParseConfig
196 | ): Promise {
197 | // wrap synchronous errors in promise
198 | return new Promise((resolve) => {
199 | let firstChunk: string | null = null;
200 | let firstWarning: Papa.ParseError | undefined = undefined;
201 | const rowAccumulator: string[][] = [];
202 |
203 | function reportSuccess() {
204 | // PapaParse normally complains first anyway, but might as well flag it
205 | if (rowAccumulator.length === 0) {
206 | return {
207 | parseError: new Error('File is empty'),
208 | file
209 | };
210 | }
211 |
212 | // remember whether this file has only one line
213 | const isSingleLine = rowAccumulator.length === 1;
214 |
215 | // fill preview with blanks if needed
216 | while (rowAccumulator.length < PREVIEW_ROW_COUNT) {
217 | rowAccumulator.push([]);
218 | }
219 |
220 | resolve({
221 | file,
222 | parseError: undefined,
223 | parseWarning: firstWarning || undefined,
224 | firstChunk: firstChunk || '',
225 | firstRows: rowAccumulator,
226 | isSingleLine
227 | });
228 | }
229 |
230 | // use our own multibyte-safe streamer, bail after first chunk
231 | // (this used to add skipEmptyLines but that was hiding possible parse errors)
232 | // @todo wait for upstream multibyte fix in PapaParse: https://github.com/mholt/PapaParse/issues/908
233 | const nodeStream = nodeStreamWrapper(
234 | streamForBlob(file),
235 | customConfig.encoding || 'utf-8'
236 | );
237 |
238 | Papa.parse(nodeStream, {
239 | ...customConfig,
240 |
241 | chunkSize: 10000, // not configurable, preview only @todo make configurable
242 | preview: PREVIEW_ROW_COUNT,
243 |
244 | error: (error) => {
245 | resolve({
246 | parseError: error,
247 | file
248 | });
249 | },
250 | beforeFirstChunk: (chunk) => {
251 | firstChunk = chunk;
252 | },
253 | chunk: ({ data, errors }, parser) => {
254 | data.forEach((row) => {
255 | const stringRow = (row as unknown[]).map((item) =>
256 | typeof item === 'string' ? item : ''
257 | );
258 |
259 | rowAccumulator.push(stringRow);
260 | });
261 |
262 | if (errors.length > 0 && !firstWarning) {
263 | firstWarning = errors[0];
264 | }
265 |
266 | // finish parsing once we got enough data, otherwise try for more
267 | // (in some cases PapaParse flushes out last line as separate chunk)
268 | if (rowAccumulator.length >= PREVIEW_ROW_COUNT) {
269 | nodeStream.pause(); // parser does not pause source stream, do it here explicitly
270 | parser.abort();
271 |
272 | reportSuccess();
273 | }
274 | },
275 | complete: reportSuccess
276 | });
277 | }).catch((error) => {
278 | return {
279 | parseError: error, // delegate message display to UI logic
280 | file
281 | };
282 | });
283 | }
284 |
285 | export interface ParserInput {
286 | file: File;
287 | papaParseConfig: CustomizablePapaParseConfig;
288 | hasHeaders: boolean;
289 | fieldAssignments: FieldAssignmentMap;
290 | }
291 |
292 | export function processFile(
293 | input: ParserInput,
294 | reportProgress: (deltaCount: number) => void,
295 | callback: ParseCallback
296 | ): Promise {
297 | const { file, hasHeaders, papaParseConfig, fieldAssignments } = input;
298 | const fieldNames = Object.keys(fieldAssignments);
299 |
300 | // wrap synchronous errors in promise
301 | return new Promise((resolve, reject) => {
302 | // skip first line if needed
303 | let skipLine = hasHeaders;
304 | let processedCount = 0;
305 |
306 | // use our own multibyte-safe decoding streamer
307 | // @todo wait for upstream multibyte fix in PapaParse: https://github.com/mholt/PapaParse/issues/908
308 | const nodeStream = nodeStreamWrapper(
309 | streamForBlob(file),
310 | papaParseConfig.encoding || 'utf-8'
311 | );
312 |
313 | Papa.parse(nodeStream, {
314 | ...papaParseConfig,
315 | chunkSize: papaParseConfig.chunkSize || 10000, // our own preferred default
316 |
317 | error: (error) => {
318 | reject(error);
319 | },
320 | chunk: ({ data }, parser) => {
321 | // pause to wait until the rows are consumed
322 | nodeStream.pause(); // parser does not pause source stream, do it here explicitly
323 | parser.pause();
324 |
325 | const skipped = skipLine && data.length > 0;
326 |
327 | const rows = (skipped ? data.slice(1) : data).map((row) => {
328 | const stringRow = (row as unknown[]).map((item) =>
329 | typeof item === 'string' ? item : ''
330 | );
331 |
332 | const record = {} as { [name: string]: string | undefined };
333 |
334 | fieldNames.forEach((fieldName) => {
335 | const columnIndex = fieldAssignments[fieldName];
336 | if (columnIndex !== undefined) {
337 | record[fieldName] = stringRow[columnIndex];
338 | }
339 | });
340 |
341 | return record as Row; // @todo look into a more precise setup
342 | });
343 |
344 | // clear line skip flag if there was anything to skip
345 | if (skipped) {
346 | skipLine = false;
347 | }
348 |
349 | // info snapshot for processing callback
350 | const info = {
351 | startIndex: processedCount
352 | };
353 |
354 | processedCount += rows.length;
355 |
356 | // @todo collect errors
357 | reportProgress(rows.length);
358 |
359 | // wrap sync errors in promise
360 | // (avoid invoking callback if there are no rows to consume)
361 | const whenConsumed = new Promise((resolve) => {
362 | const result = rows.length ? callback(rows, info) : undefined;
363 |
364 | // introduce delay to allow a frame render
365 | setTimeout(() => resolve(result), 0);
366 | });
367 |
368 | // unpause parsing when done
369 | whenConsumed.then(
370 | () => {
371 | nodeStream.resume();
372 | parser.resume();
373 | },
374 | () => {
375 | // @todo collect errors
376 | nodeStream.resume();
377 | parser.resume();
378 | }
379 | );
380 | },
381 | complete: () => {
382 | resolve();
383 | }
384 | });
385 | });
386 | }
387 |
--------------------------------------------------------------------------------
/src/theme.scss:
--------------------------------------------------------------------------------
1 | $fgColor: #000;
2 | $fillColor: #f0f0f0;
3 | $controlBorderColor: #808080;
4 | $controlBgColor: #fff;
5 |
6 | $invertedTextColor: #f0f0f0;
7 | $invertedBgColor: #404040;
8 |
9 | $textColor: #202020;
10 | $textSecondaryColor: #808080;
11 | $textDisabledColor: rgba($textColor, 0.5);
12 | $errorTextColor: #c00000;
13 | $titleFontSize: 1.15em; // relative to body font
14 |
15 | $borderRadius: 0.4em;
16 |
--------------------------------------------------------------------------------
/test/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "node": true,
4 | "mocha": true
5 | },
6 | "parser": "@typescript-eslint/parser",
7 | "extends": [
8 | "eslint:recommended",
9 | "plugin:@typescript-eslint/recommended",
10 | "plugin:prettier/recommended",
11 | "prettier/@typescript-eslint"
12 | ],
13 | "plugins": ["@typescript-eslint"]
14 | }
15 |
--------------------------------------------------------------------------------
/test/basics.test.ts:
--------------------------------------------------------------------------------
1 | import { By, until } from 'selenium-webdriver';
2 | import { expect } from 'chai';
3 | import path from 'path';
4 |
5 | import { runTestServer } from './testServer';
6 | import { runDriver } from './webdriver';
7 | import { runUI } from './uiSetup';
8 |
9 | // extra timeout allowance on CI
10 | const testTimeoutMs = process.env.CI ? 20000 : 10000;
11 |
12 | describe('importer basics', () => {
13 | const appUrl = runTestServer();
14 | const getDriver = runDriver();
15 | const initUI = runUI(getDriver);
16 |
17 | beforeEach(async () => {
18 | await getDriver().get(appUrl);
19 |
20 | await initUI((React, ReactDOM, ReactCSVImporter, ReactCSVImporterField) => {
21 | ReactDOM.render(
22 | React.createElement(
23 | ReactCSVImporter,
24 | {
25 | dataHandler: (rows, info) => {
26 | ((window as unknown) as Record<
27 | string,
28 | unknown
29 | >).TEST_DATA_HANDLER_ROWS = rows;
30 | ((window as unknown) as Record<
31 | string,
32 | unknown
33 | >).TEST_DATA_HANDLER_INFO = info;
34 |
35 | return new Promise((resolve) => {
36 | ((window as unknown) as Record<
37 | string,
38 | unknown
39 | >).TEST_DATA_HANDLER_RESOLVE = resolve;
40 | });
41 | }
42 | },
43 | [
44 | React.createElement(ReactCSVImporterField, {
45 | name: 'fieldA',
46 | label: 'Field A'
47 | }),
48 | React.createElement(ReactCSVImporterField, {
49 | name: 'fieldB',
50 | label: 'Field B',
51 | optional: true
52 | })
53 | ]
54 | ),
55 | document.getElementById('root')
56 | );
57 | });
58 | });
59 |
60 | it('shows file selector', async () => {
61 | const fileInput = await getDriver().findElement(By.xpath('//input'));
62 | expect(await fileInput.getAttribute('type')).to.equal('file');
63 | });
64 |
65 | describe('with file selected', () => {
66 | beforeEach(async () => {
67 | const filePath = path.resolve(__dirname, './fixtures/simple.csv');
68 |
69 | const fileInput = await getDriver().findElement(By.xpath('//input'));
70 | await fileInput.sendKeys(filePath);
71 |
72 | await getDriver().wait(
73 | until.elementLocated(By.xpath('//*[contains(., "Raw File Contents")]')),
74 | 300 // extra time
75 | );
76 | });
77 |
78 | it('shows file name under active focus for screen reader', async () => {
79 | const focusedHeading = await getDriver().switchTo().activeElement();
80 | expect(await focusedHeading.getText()).to.equal('simple.csv');
81 | });
82 |
83 | it('shows raw file contents', async () => {
84 | const rawPreview = await getDriver().findElement(By.xpath('//pre'));
85 | expect(await rawPreview.getText()).to.have.string('AAAA,BBBB,CCCC,DDDD');
86 | });
87 |
88 | it('shows a preview table', async () => {
89 | const tablePreview = await getDriver().findElement(By.xpath('//table'));
90 |
91 | // header row
92 | const tableCols = await tablePreview.findElements(
93 | By.xpath('thead/tr/th')
94 | );
95 | const tableColStrings = await tableCols.reduce(
96 | async (acc, col) => [...(await acc), await col.getText()],
97 | Promise.resolve([] as string[])
98 | );
99 | expect(tableColStrings).to.deep.equal(['ColA', 'ColB', 'ColC', 'ColD']);
100 |
101 | // first data row
102 | const firstDataCells = await tablePreview.findElements(
103 | By.xpath('tbody/tr[1]/td')
104 | );
105 | const firstDataCellStrings = await firstDataCells.reduce(
106 | async (acc, col) => [...(await acc), await col.getText()],
107 | Promise.resolve([] as string[])
108 | );
109 | expect(firstDataCellStrings).to.deep.equal([
110 | 'AAAA',
111 | 'BBBB',
112 | 'CCCC',
113 | 'DDDD'
114 | ]);
115 | });
116 |
117 | it('allows toggling header row', async () => {
118 | const headersCheckbox = await getDriver().findElement(
119 | By.xpath(
120 | '//label[contains(., "Data has headers")]/input[@type="checkbox"]'
121 | )
122 | );
123 |
124 | await headersCheckbox.click();
125 |
126 | // ensure there are no headers now
127 | const tablePreview = await getDriver().findElement(By.xpath('//table'));
128 | const tableCols = await tablePreview.findElements(
129 | By.xpath('thead/tr/th')
130 | );
131 | expect(tableCols.length).to.equal(0);
132 |
133 | // first data row should now show the header strings
134 | const firstDataCells = await tablePreview.findElements(
135 | By.xpath('tbody/tr[1]/td')
136 | );
137 | const firstDataCellStrings = await firstDataCells.reduce(
138 | async (acc, col) => [...(await acc), await col.getText()],
139 | Promise.resolve([] as string[])
140 | );
141 | expect(firstDataCellStrings).to.deep.equal([
142 | 'ColA',
143 | 'ColB',
144 | 'ColC',
145 | 'ColD'
146 | ]);
147 | });
148 |
149 | describe('with preview accepted', () => {
150 | beforeEach(async () => {
151 | const nextButton = await getDriver().findElement(
152 | By.xpath('//button[text() = "Choose columns"]')
153 | );
154 |
155 | await nextButton.click();
156 |
157 | await getDriver().wait(
158 | until.elementLocated(By.xpath('//*[contains(., "Select Columns")]')),
159 | 300 // extra time
160 | );
161 | });
162 |
163 | it('shows selection prompt under active focus for screen reader', async () => {
164 | const focusedHeading = await getDriver().switchTo().activeElement();
165 | expect(await focusedHeading.getText()).to.equal('Select Columns');
166 | });
167 |
168 | it('shows target fields', async () => {
169 | const targetFields = await getDriver().findElements(
170 | By.xpath('//section[@aria-label = "Target fields"]/section')
171 | );
172 |
173 | expect(targetFields.length).to.equal(2);
174 | expect(await targetFields[0].getAttribute('aria-label')).to.equal(
175 | 'Field A (required)'
176 | );
177 | expect(await targetFields[1].getAttribute('aria-label')).to.equal(
178 | 'Field B (optional)'
179 | );
180 | });
181 |
182 | it('does not allow to proceed without assignment', async () => {
183 | const nextButton = await getDriver().findElement(
184 | By.xpath('//button[text() = "Import"]')
185 | );
186 |
187 | await nextButton.click();
188 |
189 | await getDriver().wait(
190 | until.elementLocated(
191 | By.xpath('//*[contains(., "Please assign all required fields")]')
192 | ),
193 | 300 // extra time
194 | );
195 | });
196 |
197 | it('offers keyboard-only select start buttons', async () => {
198 | const selectButtons = await getDriver().findElements(
199 | By.xpath('//button[@aria-label = "Select column for assignment"]')
200 | );
201 |
202 | expect(selectButtons.length).to.equal(4);
203 | });
204 |
205 | describe('with assigned field', () => {
206 | beforeEach(async () => {
207 | // start the keyboard-based selection mode
208 | const focusedHeading = await getDriver().switchTo().activeElement();
209 | await focusedHeading.sendKeys('\t'); // tab to next element
210 |
211 | const selectButton = await getDriver().findElement(
212 | By.xpath(
213 | '//button[@aria-label = "Select column for assignment"][1]'
214 | )
215 | );
216 | await selectButton.sendKeys('\n'); // cannot use click
217 |
218 | await getDriver().wait(
219 | until.elementLocated(
220 | By.xpath('//*[contains(., "Assigning column A")]')
221 | ),
222 | 200
223 | );
224 |
225 | const assignButton = await getDriver().findElement(
226 | By.xpath('//button[@aria-label = "Assign column A"]')
227 | );
228 | await assignButton.click();
229 | });
230 |
231 | describe('with confirmation to start processing', () => {
232 | beforeEach(async () => {
233 | const nextButton = await getDriver().findElement(
234 | By.xpath('//button[text() = "Import"]')
235 | );
236 |
237 | await nextButton.click();
238 |
239 | await getDriver().wait(
240 | until.elementLocated(
241 | By.xpath(
242 | '//button[@aria-label = "Go to previous step"]/../*[contains(., "Import")]'
243 | )
244 | ),
245 | 200
246 | );
247 | });
248 |
249 | it('sets focus on next heading', async () => {
250 | const focusedHeading = await getDriver().switchTo().activeElement();
251 | expect(await focusedHeading.getText()).to.equal('Import');
252 | });
253 |
254 | it('does not finish until dataHandler returns', async () => {
255 | await getDriver().sleep(300);
256 |
257 | const focusedHeading = await getDriver().switchTo().activeElement();
258 | expect(await focusedHeading.getText()).to.equal('Import');
259 | });
260 |
261 | describe('after dataHandler is complete', () => {
262 | beforeEach(async () => {
263 | await getDriver().executeScript(
264 | 'window.TEST_DATA_HANDLER_RESOLVE()'
265 | );
266 | await getDriver().wait(
267 | until.elementLocated(By.xpath('//*[contains(., "Complete")]')),
268 | 200
269 | );
270 | });
271 |
272 | it('has active focus on completion message', async () => {
273 | const focusedHeading = await getDriver()
274 | .switchTo()
275 | .activeElement();
276 | expect(await focusedHeading.getText()).to.equal('Complete');
277 | });
278 |
279 | it('produces parsed data with correct fields', async () => {
280 | const parsedData = await getDriver().executeScript(
281 | 'return window.TEST_DATA_HANDLER_ROWS'
282 | );
283 | const chunkInfo = await getDriver().executeScript(
284 | 'return window.TEST_DATA_HANDLER_INFO'
285 | );
286 |
287 | expect(parsedData).to.deep.equal([
288 | { fieldA: 'AAAA' },
289 | { fieldA: 'EEEE' }
290 | ]);
291 | expect(chunkInfo).to.deep.equal({ startIndex: 0 });
292 | });
293 |
294 | it('does not show any interactable buttons', async () => {
295 | const anyButtons = await getDriver().findElements(
296 | By.xpath('//button')
297 | );
298 |
299 | expect(anyButtons.length).to.equal(1);
300 | expect(await anyButtons[0].getAttribute('aria-label')).to.equal(
301 | 'Go to previous step'
302 | );
303 | expect(await anyButtons[0].getAttribute('disabled')).to.equal(
304 | 'true'
305 | );
306 | });
307 | });
308 | });
309 | });
310 | });
311 | });
312 | }).timeout(testTimeoutMs);
313 |
--------------------------------------------------------------------------------
/test/bom.test.ts:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 | import path from 'path';
3 |
4 | import { runTestServer } from './testServer';
5 | import { runDriver } from './webdriver';
6 | import { runUI, uiHelperSetup } from './uiSetup';
7 | import { ImportInfo } from '../src/components/ImporterProps';
8 |
9 | type RawWindow = Record;
10 |
11 | // extra timeout allowance on CI
12 | const testTimeoutMs = process.env.CI ? 20000 : 10000;
13 |
14 | describe('importer with input containing BOM character', () => {
15 | const appUrl = runTestServer();
16 | const getDriver = runDriver();
17 | const initUI = runUI(getDriver);
18 | const {
19 | uploadFile,
20 | getDisplayedPreviewData,
21 | advanceToFieldStepAndFinish
22 | } = uiHelperSetup(getDriver);
23 |
24 | beforeEach(async () => {
25 | await getDriver().get(appUrl);
26 |
27 | await initUI((React, ReactDOM, ReactCSVImporter, ReactCSVImporterField) => {
28 | ReactDOM.render(
29 | React.createElement(
30 | ReactCSVImporter,
31 | {
32 | onStart: (info) => {
33 | ((window as unknown) as RawWindow).TEST_ON_START_INFO = info;
34 | },
35 | dataHandler: (rows, info) => {
36 | ((window as unknown) as RawWindow).TEST_DATA_HANDLER_ROWS = rows;
37 | ((window as unknown) as RawWindow).TEST_DATA_HANDLER_INFO = info;
38 | }
39 | },
40 | [
41 | React.createElement(ReactCSVImporterField, {
42 | name: 'fieldA',
43 | label: 'Field A'
44 | }),
45 | React.createElement(ReactCSVImporterField, {
46 | name: 'fieldB',
47 | label: 'Field B',
48 | optional: true
49 | })
50 | ]
51 | ),
52 | document.getElementById('root')
53 | );
54 | });
55 | });
56 |
57 | describe('at preview stage', () => {
58 | beforeEach(async () => {
59 | await uploadFile(path.resolve(__dirname, './fixtures/bom.csv'));
60 | });
61 |
62 | it('shows correctly parsed preview table', async () => {
63 | expect(await getDisplayedPreviewData()).to.deep.equal([
64 | ['Date', 'Open', 'High', 'Low', 'Close', 'Adj Close', 'Volume'],
65 | [
66 | '2019-09-16',
67 | '299.839996',
68 | '301.140015',
69 | '299.450012',
70 | '300.160004',
71 | '294.285339',
72 | '58191200'
73 | ]
74 | ]);
75 | });
76 |
77 | describe('after accepting and assigning fields', () => {
78 | beforeEach(async () => {
79 | await advanceToFieldStepAndFinish();
80 | });
81 |
82 | it('reports correct import info', async () => {
83 | const importInfo = await getDriver().executeScript(
84 | 'return window.TEST_ON_START_INFO'
85 | );
86 |
87 | expect(importInfo).to.have.property('preview');
88 |
89 | const { preview } = importInfo as ImportInfo;
90 | expect(preview).to.have.property('columns');
91 | expect(preview.columns).to.be.an('array');
92 |
93 | expect(preview.columns.map((item) => item.header)).to.deep.equal([
94 | 'Date', // should not have BOM prefix
95 | 'Open',
96 | 'High',
97 | 'Low',
98 | 'Close',
99 | 'Adj Close',
100 | 'Volume'
101 | ]);
102 | });
103 |
104 | it('produces parsed data with correct fields', async () => {
105 | const parsedData = await getDriver().executeScript(
106 | 'return window.TEST_DATA_HANDLER_ROWS'
107 | );
108 | const chunkInfo = await getDriver().executeScript(
109 | 'return window.TEST_DATA_HANDLER_INFO'
110 | );
111 |
112 | expect(parsedData).to.deep.equal([{ fieldA: '2019-09-16' }]);
113 | expect(chunkInfo).to.deep.equal({ startIndex: 0 });
114 | });
115 | });
116 | });
117 | }).timeout(testTimeoutMs);
118 |
--------------------------------------------------------------------------------
/test/customConfig.test.ts:
--------------------------------------------------------------------------------
1 | import { By, until } from 'selenium-webdriver';
2 | import { expect } from 'chai';
3 | import path from 'path';
4 |
5 | import { runTestServer } from './testServer';
6 | import { runDriver } from './webdriver';
7 | import { runUI } from './uiSetup';
8 |
9 | // extra timeout allowance on CI
10 | const testTimeoutMs = process.env.CI ? 20000 : 10000;
11 |
12 | describe('importer with custom Papa Parse config', () => {
13 | const appUrl = runTestServer();
14 | const getDriver = runDriver();
15 | const initUI = runUI(getDriver);
16 |
17 | beforeEach(async () => {
18 | await getDriver().get(appUrl);
19 |
20 | await initUI((React, ReactDOM, ReactCSVImporter, ReactCSVImporterField) => {
21 | ReactDOM.render(
22 | React.createElement(
23 | ReactCSVImporter,
24 | {
25 | delimiter: '!', // not a normal guessable delimiter for Papa Parse
26 | dataHandler: (rows, info) => {
27 | ((window as unknown) as Record<
28 | string,
29 | unknown
30 | >).TEST_DATA_HANDLER_ROWS = rows;
31 | ((window as unknown) as Record<
32 | string,
33 | unknown
34 | >).TEST_DATA_HANDLER_INFO = info;
35 | }
36 | },
37 | [
38 | React.createElement(ReactCSVImporterField, {
39 | name: 'fieldA',
40 | label: 'Field A'
41 | }),
42 | React.createElement(ReactCSVImporterField, {
43 | name: 'fieldB',
44 | label: 'Field B',
45 | optional: true
46 | })
47 | ]
48 | ),
49 | document.getElementById('root')
50 | );
51 | });
52 | });
53 |
54 | describe('at preview stage', () => {
55 | beforeEach(async () => {
56 | const filePath = path.resolve(
57 | __dirname,
58 | './fixtures/customDelimited.txt'
59 | );
60 |
61 | const fileInput = await getDriver().findElement(By.xpath('//input'));
62 | await fileInput.sendKeys(filePath);
63 |
64 | await getDriver().wait(
65 | until.elementLocated(By.xpath('//*[contains(., "Raw File Contents")]')),
66 | 300 // extra time
67 | );
68 | });
69 |
70 | it('shows correctly parsed preview table', async () => {
71 | const tablePreview = await getDriver().findElement(By.xpath('//table'));
72 |
73 | // header row
74 | const tableCols = await tablePreview.findElements(
75 | By.xpath('thead/tr/th')
76 | );
77 | const tableColStrings = await tableCols.reduce(
78 | async (acc, col) => [...(await acc), await col.getText()],
79 | Promise.resolve([] as string[])
80 | );
81 | expect(tableColStrings).to.deep.equal(['val1', 'val2']);
82 |
83 | // first data row
84 | const firstDataCells = await tablePreview.findElements(
85 | By.xpath('tbody/tr[1]/td')
86 | );
87 | const firstDataCellStrings = await firstDataCells.reduce(
88 | async (acc, col) => [...(await acc), await col.getText()],
89 | Promise.resolve([] as string[])
90 | );
91 | expect(firstDataCellStrings).to.deep.equal(['val3', 'val4']);
92 | });
93 |
94 | describe('after accepting and assigning fields', () => {
95 | beforeEach(async () => {
96 | const previewNextButton = await getDriver().findElement(
97 | By.xpath('//button[text() = "Choose columns"]')
98 | );
99 |
100 | await previewNextButton.click();
101 |
102 | await getDriver().wait(
103 | until.elementLocated(By.xpath('//*[contains(., "Select Columns")]')),
104 | 300 // extra time
105 | );
106 |
107 | // start the keyboard-based selection mode
108 | const focusedHeading = await getDriver().switchTo().activeElement();
109 | await focusedHeading.sendKeys('\t'); // tab to next element
110 |
111 | const selectButton = await getDriver().findElement(
112 | By.xpath('//button[@aria-label = "Select column for assignment"][1]')
113 | );
114 | await selectButton.sendKeys('\n'); // cannot use click
115 |
116 | await getDriver().wait(
117 | until.elementLocated(
118 | By.xpath('//*[contains(., "Assigning column A")]')
119 | ),
120 | 200
121 | );
122 |
123 | const assignButton = await getDriver().findElement(
124 | By.xpath('//button[@aria-label = "Assign column A"]')
125 | );
126 | await assignButton.click();
127 |
128 | const fieldsNextButton = await getDriver().findElement(
129 | By.xpath('//button[text() = "Import"]')
130 | );
131 |
132 | await fieldsNextButton.click();
133 |
134 | await getDriver().wait(
135 | until.elementLocated(
136 | By.xpath(
137 | '//button[@aria-label = "Go to previous step"]/../*[contains(., "Import")]'
138 | )
139 | ),
140 | 200
141 | );
142 |
143 | await getDriver().wait(
144 | until.elementLocated(By.xpath('//*[contains(., "Complete")]')),
145 | 200
146 | );
147 | });
148 |
149 | it('produces parsed data with correct fields', async () => {
150 | const parsedData = await getDriver().executeScript(
151 | 'return window.TEST_DATA_HANDLER_ROWS'
152 | );
153 | const chunkInfo = await getDriver().executeScript(
154 | 'return window.TEST_DATA_HANDLER_INFO'
155 | );
156 |
157 | expect(parsedData).to.deep.equal([{ fieldA: 'val3' }]);
158 | expect(chunkInfo).to.deep.equal({ startIndex: 0 });
159 | });
160 | });
161 | });
162 | }).timeout(testTimeoutMs);
163 |
--------------------------------------------------------------------------------
/test/encoding.test.ts:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 | import path from 'path';
3 |
4 | import { runTestServer } from './testServer';
5 | import { runDriver } from './webdriver';
6 | import { runUI, uiHelperSetup } from './uiSetup';
7 |
8 | type RawWindow = Record;
9 |
10 | // extra timeout allowance on CI
11 | const testTimeoutMs = process.env.CI ? 20000 : 10000;
12 |
13 | describe('importer with custom encoding setting', () => {
14 | const appUrl = runTestServer();
15 | const getDriver = runDriver();
16 | const initUI = runUI(getDriver);
17 | const {
18 | uploadFile,
19 | getDisplayedPreviewData,
20 | advanceToFieldStepAndFinish
21 | } = uiHelperSetup(getDriver);
22 |
23 | beforeEach(async () => {
24 | await getDriver().get(appUrl);
25 |
26 | await initUI((React, ReactDOM, ReactCSVImporter, ReactCSVImporterField) => {
27 | ReactDOM.render(
28 | React.createElement(
29 | ReactCSVImporter,
30 | {
31 | encoding: 'windows-1250', // encoding incompatible with UTF-8
32 | delimiter: ',',
33 | dataHandler: (rows, info) => {
34 | ((window as unknown) as RawWindow).TEST_DATA_HANDLER_ROWS = rows;
35 | ((window as unknown) as RawWindow).TEST_DATA_HANDLER_INFO = info;
36 | }
37 | },
38 | [
39 | React.createElement(ReactCSVImporterField, {
40 | name: 'fieldA',
41 | label: 'Field A'
42 | }),
43 | React.createElement(ReactCSVImporterField, {
44 | name: 'fieldB',
45 | label: 'Field B',
46 | optional: true
47 | })
48 | ]
49 | ),
50 | document.getElementById('root')
51 | );
52 | });
53 | });
54 |
55 | describe('at preview stage', () => {
56 | beforeEach(async () => {
57 | await uploadFile(
58 | path.resolve(__dirname, './fixtures/encodingWindows1250.csv')
59 | );
60 | });
61 |
62 | it('shows correctly parsed preview table', async () => {
63 | expect(await getDisplayedPreviewData()).to.deep.equal([
64 | ['value1', 'value2'],
65 | ['Montréal', 'Köppen']
66 | ]);
67 | });
68 |
69 | describe('after accepting and assigning fields', () => {
70 | beforeEach(async () => {
71 | await advanceToFieldStepAndFinish();
72 | });
73 |
74 | it('produces parsed data with correct fields', async () => {
75 | const parsedData = await getDriver().executeScript(
76 | 'return window.TEST_DATA_HANDLER_ROWS'
77 | );
78 | const chunkInfo = await getDriver().executeScript(
79 | 'return window.TEST_DATA_HANDLER_INFO'
80 | );
81 |
82 | expect(parsedData).to.deep.equal([{ fieldA: 'Montréal' }]);
83 | expect(chunkInfo).to.deep.equal({ startIndex: 0 });
84 | });
85 | });
86 | });
87 | }).timeout(testTimeoutMs);
88 |
--------------------------------------------------------------------------------
/test/fixtures/bom.csv:
--------------------------------------------------------------------------------
1 | Date,Open,High,Low,Close,Adj Close,Volume
2 | 2019-09-16,299.839996,301.140015,299.450012,300.160004,294.285339,58191200
3 |
--------------------------------------------------------------------------------
/test/fixtures/customDelimited.txt:
--------------------------------------------------------------------------------
1 | val1!val2
2 | val3!val4
3 |
--------------------------------------------------------------------------------
/test/fixtures/encodingWindows1250.csv:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/beamworks/react-csv-importer/775a75cce773101356fc9d201bf53196cd3323b7/test/fixtures/encodingWindows1250.csv
--------------------------------------------------------------------------------
/test/fixtures/noeof.csv:
--------------------------------------------------------------------------------
1 | ColA,ColB,ColC,ColD
2 | AAAA,BBBB,CCCC,DDDD
3 | EEEE,FFFF,GGGG,HHHH
--------------------------------------------------------------------------------
/test/fixtures/simple.csv:
--------------------------------------------------------------------------------
1 | ColA,ColB,ColC,ColD
2 | AAAA,BBBB,CCCC,DDDD
3 | EEEE,FFFF,GGGG,HHHH
4 |
--------------------------------------------------------------------------------
/test/noeof.test.ts:
--------------------------------------------------------------------------------
1 | import { expect } from 'chai';
2 | import path from 'path';
3 |
4 | import { runTestServer } from './testServer';
5 | import { runDriver } from './webdriver';
6 | import { runUI, uiHelperSetup } from './uiSetup';
7 |
8 | // extra timeout allowance on CI
9 | const testTimeoutMs = process.env.CI ? 20000 : 10000;
10 |
11 | describe('importer with input not terminated by EOL character at end of file', () => {
12 | const appUrl = runTestServer();
13 | const getDriver = runDriver();
14 | const initUI = runUI(getDriver);
15 | const {
16 | uploadFile,
17 | getDisplayedPreviewData,
18 | advanceToFieldStepAndFinish
19 | } = uiHelperSetup(getDriver);
20 |
21 | beforeEach(async () => {
22 | await getDriver().get(appUrl);
23 |
24 | await initUI((React, ReactDOM, ReactCSVImporter, ReactCSVImporterField) => {
25 | ReactDOM.render(
26 | React.createElement(
27 | ReactCSVImporter,
28 | {
29 | dataHandler: (rows, info) => {
30 | const rawWin = window as any; // eslint-disable-line @typescript-eslint/no-explicit-any
31 | rawWin.TEST_DATA_HANDLER_ROWS = (
32 | rawWin.TEST_DATA_HANDLER_ROWS || []
33 | ).concat(rows);
34 | rawWin.TEST_DATA_HANDLER_INFO = info;
35 | }
36 | },
37 | [
38 | React.createElement(ReactCSVImporterField, {
39 | name: 'fieldA',
40 | label: 'Field A'
41 | }),
42 | React.createElement(ReactCSVImporterField, {
43 | name: 'fieldB',
44 | label: 'Field B',
45 | optional: true
46 | })
47 | ]
48 | ),
49 | document.getElementById('root')
50 | );
51 | });
52 | });
53 |
54 | describe('at preview stage', () => {
55 | beforeEach(async () => {
56 | await uploadFile(path.resolve(__dirname, './fixtures/noeof.csv'));
57 | });
58 |
59 | it('shows correctly parsed preview table', async () => {
60 | expect(await getDisplayedPreviewData()).to.deep.equal([
61 | ['ColA', 'ColB', 'ColC', 'ColD'],
62 | ['AAAA', 'BBBB', 'CCCC', 'DDDD']
63 | ]);
64 | });
65 |
66 | describe('after accepting and assigning fields', () => {
67 | beforeEach(async () => {
68 | await advanceToFieldStepAndFinish();
69 | });
70 |
71 | it('produces parsed data with correct fields', async () => {
72 | // await getDriver().sleep(10000);
73 |
74 | const parsedData = await getDriver().executeScript(
75 | 'return window.TEST_DATA_HANDLER_ROWS'
76 | );
77 | const chunkInfo = await getDriver().executeScript(
78 | 'return window.TEST_DATA_HANDLER_INFO'
79 | );
80 |
81 | expect(parsedData).to.deep.equal([
82 | { fieldA: 'AAAA' },
83 | { fieldA: 'EEEE' }
84 | ]);
85 |
86 | // chunk start may be 1 because the parser "flushes" the last line separately?
87 | expect(chunkInfo).to.deep.equal({ startIndex: 1 });
88 | });
89 | });
90 | });
91 | }).timeout(testTimeoutMs);
92 |
--------------------------------------------------------------------------------
/test/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/test/testServer.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import webpack from 'webpack';
3 | import WebpackDevServer from 'webpack-dev-server';
4 |
5 | const TEST_SERVER_PORT = 8090;
6 |
7 | // @todo use pre-built dist folder instead (to properly test production artifacts)
8 | // eslint-disable-next-line @typescript-eslint/no-var-requires
9 | const appWebpackConfig = require('../webpack.config');
10 |
11 | export function runTestServer(): string {
12 | let testDevServer: WebpackDevServer | null = null; // internal handle
13 |
14 | const serverUrl = `http://localhost:${TEST_SERVER_PORT}`;
15 |
16 | before(async function () {
17 | // override config to allow direct in-browser usage with test code
18 | const webpackConfig = {
19 | ...appWebpackConfig,
20 |
21 | module: {
22 | ...appWebpackConfig.module,
23 |
24 | rules: [
25 | ...appWebpackConfig.module.rules,
26 |
27 | {
28 | test: require.resolve('react'),
29 | loader: 'expose-loader',
30 | options: {
31 | exposes: ['React']
32 | }
33 | },
34 | {
35 | test: require.resolve('react-dom'),
36 | loader: 'expose-loader',
37 | options: {
38 | exposes: ['ReactDOM']
39 | }
40 | }
41 | ]
42 | },
43 |
44 | output: {
45 | ...appWebpackConfig.output,
46 | publicPath: '/',
47 |
48 | // browser-friendly settings
49 | libraryTarget: 'global',
50 | library: 'ReactCSVImporter'
51 | },
52 |
53 | // ensure everything is included instead of generating require() statements
54 | externals: {},
55 |
56 | mode: 'production',
57 | watch: false
58 | };
59 |
60 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
61 | const compiler = webpack(webpackConfig as any);
62 |
63 | const devServer = new WebpackDevServer(compiler, {
64 | contentBase: path.resolve(__dirname, './public'), // static test helper content
65 | hot: false,
66 | liveReload: false,
67 | noInfo: true,
68 | stats: 'errors-only'
69 | });
70 |
71 | // store reference for later cleanup
72 | testDevServer = devServer;
73 |
74 | const serverListenPromise = new Promise((resolve, reject) => {
75 | devServer.listen(TEST_SERVER_PORT, 'localhost', function (err) {
76 | if (err) {
77 | reject(err);
78 | } else {
79 | resolve();
80 | }
81 | });
82 | });
83 |
84 | const serverCompilationPromise = new Promise((resolve) => {
85 | compiler.hooks.done.tap('_', () => {
86 | resolve();
87 | });
88 | });
89 |
90 | await Promise.all([serverListenPromise, serverCompilationPromise]);
91 | });
92 |
93 | after(async function () {
94 | const devServer = testDevServer;
95 | testDevServer = null;
96 |
97 | if (!devServer) {
98 | throw new Error('dev server not initialized');
99 | }
100 |
101 | // wait for server to fully close
102 | await new Promise((resolve) => {
103 | devServer.close(() => {
104 | resolve();
105 | });
106 | });
107 | });
108 |
109 | return serverUrl;
110 | }
111 |
--------------------------------------------------------------------------------
/test/uiSetup.ts:
--------------------------------------------------------------------------------
1 | import { By, until, ThenableWebDriver } from 'selenium-webdriver';
2 | import ReactModule from 'react';
3 | import ReactDOMModule from 'react-dom';
4 |
5 | import {
6 | ImporterProps,
7 | ImporterFieldProps
8 | } from '../src/components/ImporterProps';
9 |
10 | export type ScriptBody = (
11 | r: typeof ReactModule,
12 | rd: typeof ReactDOMModule,
13 | im: (
14 | props: ImporterProps>
15 | ) => ReactModule.ReactElement,
16 | imf: (props: ImporterFieldProps) => ReactModule.ReactElement
17 | ) => void;
18 |
19 | export function runUI(
20 | getDriver: () => ThenableWebDriver
21 | ): (script: ScriptBody) => Promise {
22 | async function runScript(script: ScriptBody) {
23 | await getDriver().executeScript(
24 | `(${script.toString()})(React, ReactDOM, ReactCSVImporter.Importer, ReactCSVImporter.ImporterField)`
25 | );
26 | }
27 |
28 | // always clean up
29 | afterEach(async () => {
30 | await runScript((React, ReactDOM) => {
31 | ReactDOM.unmountComponentAtNode(
32 | document.getElementById('root') || document.body
33 | );
34 | });
35 | });
36 |
37 | return async function initUI(script: ScriptBody) {
38 | await runScript(script);
39 |
40 | await getDriver().wait(
41 | until.elementLocated(By.xpath('//span[contains(., "Drag-and-drop")]')),
42 | 300 // a little extra time
43 | );
44 | };
45 | }
46 |
47 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
48 | export function uiHelperSetup(getDriver: () => ThenableWebDriver) {
49 | return {
50 | async uploadFile(filePath: string) {
51 | const fileInput = await getDriver().findElement(By.xpath('//input'));
52 | await fileInput.sendKeys(filePath);
53 |
54 | await getDriver().wait(
55 | until.elementLocated(By.xpath('//*[contains(., "Raw File Contents")]')),
56 | 300 // extra time
57 | );
58 | },
59 |
60 | async getDisplayedPreviewData() {
61 | const tablePreview = await getDriver().findElement(By.xpath('//table'));
62 |
63 | // header row
64 | const tableCols = await tablePreview.findElements(
65 | By.xpath('thead/tr/th')
66 | );
67 | const tableColStrings = await tableCols.reduce(
68 | async (acc, col) => [...(await acc), await col.getText()],
69 | Promise.resolve([] as string[])
70 | );
71 |
72 | // first data row
73 | const firstDataCells = await tablePreview.findElements(
74 | By.xpath('tbody/tr[1]/td')
75 | );
76 | const firstDataCellStrings = await firstDataCells.reduce(
77 | async (acc, col) => [...(await acc), await col.getText()],
78 | Promise.resolve([] as string[])
79 | );
80 |
81 | return [tableColStrings, firstDataCellStrings];
82 | },
83 |
84 | async advanceToFieldStepAndFinish() {
85 | const previewNextButton = await getDriver().findElement(
86 | By.xpath('//button[text() = "Choose columns"]')
87 | );
88 |
89 | await previewNextButton.click();
90 |
91 | await getDriver().wait(
92 | until.elementLocated(By.xpath('//*[contains(., "Select Columns")]')),
93 | 300 // extra time
94 | );
95 |
96 | // start the keyboard-based selection mode
97 | const focusedHeading = await getDriver().switchTo().activeElement();
98 | await focusedHeading.sendKeys('\t'); // tab to next element
99 |
100 | const selectButton = await getDriver().findElement(
101 | By.xpath('//button[@aria-label = "Select column for assignment"][1]')
102 | );
103 | await selectButton.sendKeys('\n'); // cannot use click
104 |
105 | await getDriver().wait(
106 | until.elementLocated(
107 | By.xpath('//*[contains(., "Assigning column A")]')
108 | ),
109 | 200
110 | );
111 |
112 | const assignButton = await getDriver().findElement(
113 | By.xpath('//button[@aria-label = "Assign column A"]')
114 | );
115 | await assignButton.click();
116 |
117 | const fieldsNextButton = await getDriver().findElement(
118 | By.xpath('//button[text() = "Import"]')
119 | );
120 |
121 | await fieldsNextButton.click();
122 |
123 | await getDriver().wait(
124 | until.elementLocated(
125 | By.xpath(
126 | '//button[@aria-label = "Go to previous step"]/../*[contains(., "Import")]'
127 | )
128 | ),
129 | 200
130 | );
131 |
132 | await getDriver().wait(
133 | until.elementLocated(By.xpath('//*[contains(., "Complete")]')),
134 | 200
135 | );
136 | }
137 | };
138 | }
139 |
--------------------------------------------------------------------------------
/test/webdriver.ts:
--------------------------------------------------------------------------------
1 | import * as path from 'path';
2 | import * as child_process from 'child_process';
3 | import { Builder, ThenableWebDriver } from 'selenium-webdriver';
4 | import * as chrome from 'selenium-webdriver/chrome';
5 |
6 | async function getGlobalChromedriverPath() {
7 | const yarnGlobalPath = await new Promise((resolve, reject) => {
8 | child_process.exec('yarn global dir', { timeout: 8000 }, (err, result) => {
9 | if (err) {
10 | reject(err);
11 | } else {
12 | resolve(result.trim());
13 | }
14 | });
15 | });
16 |
17 | return path.resolve(
18 | yarnGlobalPath,
19 | './node_modules/chromedriver/lib/chromedriver',
20 | process.platform === 'win32' ? './chromedriver.exe' : './chromedriver'
21 | );
22 | }
23 |
24 | export function runDriver(): () => ThenableWebDriver {
25 | let webdriver: ThenableWebDriver | null = null;
26 |
27 | // same webdriver instance serves all the tests in the suite
28 | before(async function () {
29 | const chromedriverPath = await getGlobalChromedriverPath();
30 |
31 | const service = new chrome.ServiceBuilder(chromedriverPath).build();
32 | chrome.setDefaultService(service);
33 |
34 | webdriver = new Builder()
35 | .forBrowser('chrome')
36 | .setChromeOptions(
37 | process.env.CI ? new chrome.Options().headless() : new chrome.Options()
38 | )
39 | .build();
40 | });
41 |
42 | after(async function () {
43 | if (!webdriver) {
44 | throw new Error(
45 | 'cannot clean up webdriver because it was not initialized'
46 | );
47 | }
48 |
49 | await webdriver.quit();
50 |
51 | // complete cleanup
52 | webdriver = null;
53 | });
54 |
55 | // expose singleton getter
56 | return () => {
57 | if (!webdriver) {
58 | throw new Error('webdriver not initialized');
59 | }
60 |
61 | return webdriver;
62 | };
63 | }
64 |
--------------------------------------------------------------------------------
/tsconfig.base.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es6",
4 | "lib": ["dom", "dom.iterable", "es6"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "esModuleInterop": true,
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "module": "es6",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "noEmit": true,
16 | "jsx": "react"
17 | },
18 | "include": ["src"]
19 | }
20 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.base.json",
3 | "include": ["src", "test"]
4 | }
5 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const webpack = require('webpack');
3 | const { CleanWebpackPlugin } = require('clean-webpack-plugin');
4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin');
5 |
6 | module.exports = {
7 | entry: { index: './src/index.ts' },
8 | output: {
9 | path: path.resolve(__dirname, 'dist'),
10 | libraryTarget: 'commonjs2'
11 | },
12 | module: {
13 | rules: [
14 | {
15 | test: /\.tsx?$/,
16 | use: [
17 | {
18 | loader: 'ts-loader',
19 | options: {
20 | configFile: 'tsconfig.base.json',
21 | compilerOptions: {
22 | noEmit: false
23 | }
24 | }
25 | }
26 | ],
27 | exclude: /node_modules/
28 | },
29 | {
30 | test: /\.scss$/,
31 | use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader']
32 | }
33 | ]
34 | },
35 | externals: {
36 | papaparse: 'papaparse',
37 | react: 'react',
38 | 'react-dom': 'react-dom',
39 | 'react-dropzone': 'react-dropzone',
40 | 'react-use-gesture': 'react-use-gesture'
41 | },
42 | resolve: {
43 | extensions: ['.tsx', '.ts', '.js']
44 | },
45 | devtool: 'cheap-source-map',
46 | optimization: {
47 | minimize: false
48 | },
49 | plugins: [new MiniCssExtractPlugin(), new CleanWebpackPlugin()]
50 | };
51 |
--------------------------------------------------------------------------------