├── .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://img.shields.io/npm/v/react-csv-importer)](https://www.npmjs.com/package/react-csv-importer) [![https://github.com/beamworks/react-csv-importer/actions](https://github.com/beamworks/react-csv-importer/actions/workflows/test.yml/badge.svg)](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 | ![React CSV Importer usage demo](https://github.com/beamworks/react-csv-importer/raw/59f967c13bbbd20eb2a663538797dd718f9bc57e/package-core/react-csv-importer-demo-20200915.gif) 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 | 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 | 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 |
233 |
237 |
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 | 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 |
20 |
24 |
25 | 26 |
27 |
28 |
, 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 | 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 | 21 | ))} 22 | 23 | 24 | )} 25 | 26 | 27 | {bodyRows.map((row, rowIndex) => ( 28 | 29 | {row.map((item, itemIndex) => ( 30 | 31 | ))} 32 | 33 | ))} 34 | 35 |
{item}
{item}
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 | --------------------------------------------------------------------------------