├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── examples └── basic │ ├── client.js │ ├── index.html │ ├── index.js │ ├── package.json │ └── style.css ├── index.js ├── package.json └── src ├── Receiver.js ├── UploadHandler.js ├── UploadManager.js ├── __tests__ ├── Receiver-test.js ├── UploadHandler-test.js └── UploadManager-test.js ├── constants └── status.js └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ['es2015', 'react'] 3 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "es6": true 6 | }, 7 | "parserOptions": { 8 | "ecmaVersion": 6, 9 | "sourceType": "module", 10 | "ecmaFeatures": { 11 | "jsx": true 12 | } 13 | }, 14 | "plugins": [ 15 | "react" 16 | ], 17 | "extends": [ 18 | "eslint:recommended", 19 | "plugin:react/recommended" 20 | ] 21 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | # IDE 30 | .idea 31 | 32 | # Transformed source 33 | lib 34 | 35 | # npmignore 36 | .npmignore 37 | 38 | # Examples 39 | examples/**/bundle.js -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # ES2015 source 2 | src 3 | 4 | # Test coverage 5 | coverage 6 | 7 | # Examples 8 | examples 9 | 10 | # Babel 11 | .babelrc 12 | 13 | # ESLint 14 | .eslintrc 15 | 16 | # IDE 17 | .idea 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 lionng429 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-file-uploader 2 | 3 | react-file-uploader is a set of customizable React components that helps you to build a file uploader in your application easily. 4 | 5 | It is expected to be production ready from v1.0.0, although v0.4.1 provides a stable but very simple and limited usage. 6 | 7 | The uploading implementation is coupled with [superagent](https://visionmedia.github.io/superagent/), and the `method`, `header` and `data` are configurable with props. 8 | 9 | ## Installation 10 | 11 | To install: 12 | 13 | ```sh 14 | npm install --save react-file-uploader 15 | ``` 16 | 17 | # Documentation 18 | 19 | this module currently contains 4 major entities, which are 20 | 21 | 1. Receiver 22 | 2. UploadManager 23 | 3. UploadHandler 24 | 4. File Status 25 | 26 | # Receiver 27 | 28 | `Receiver` helps you to manage the **Drag and Drop** functionality. Once you mounted the `Receiver` component, your application will start listen to `dragenter`, `dragover`, `dragleave` and `drop` events. 29 | 30 | ``` 31 | import { Receiver } from 'react-file-uploader'; 32 | 33 | 43 |
44 | visual layer of the receiver (drag & drop panel) 45 |
46 |
47 | ``` 48 | 49 | ## Props 50 | 51 | * wrapperId - `string`: Optional HTML element id for the DnD area. If not given, `window` will be used instead. 52 | * customClass - `string | array`: the class name(s) for the `div` wrapper 53 | * style - `object`: the style for the `div` wrapper 54 | * isOpen - `boolean` `required`: to control in the parent component whether the Receiver is visble. 55 | * onDragEnter - `function` `required`: when `isOpen` is `false`, this will be fired with the window event of `dragenter` once . 56 | 57 | You may make use of the drag & drop event callbacks. 58 | 59 | ``` 60 | // @param e Object DragEvent 61 | function onDragEnter(e) { 62 | this.setState({ isReceiverOpen: true }); 63 | } 64 | ``` 65 | 66 | * onDragOver - `function`: this will be fired with the window event of `dragover`. 67 | 68 | ``` 69 | // @param e Object DragEvent 70 | function onDragOver(e) { 71 | // your codes here 72 | } 73 | ``` 74 | 75 | * onDragLeave - `function` `required`: when the drag event entirely left the client (i.e. `dragLevel === 0`), this will be fired with the window event of `dragleave` once. 76 | 77 | ``` 78 | // @param e Object DragEvent 79 | function onDragLeave(e) { 80 | this.setState({ isReceiverOpen: false }); 81 | } 82 | ``` 83 | 84 | * onFileDrop - `function` `required`: this will be fired with the window event of `drop`. You may execute any validation / checking process here i.e. *size*, *file type*, etc. 85 | 86 | ``` 87 | // @param e Object DragEvent 88 | // @param files Array the files dropped on the target node 89 | function onFileDrop(e, uploads) { 90 | // check if the files are drop on the targeted DOM 91 | const node = ReactDOM.findDOMNode(this.refs.uploadPanel); 92 | if (e.target !== node) { 93 | return; 94 | } 95 | 96 | let newUploads = uploads.map(upload => { 97 | // check file size 98 | if (upload.data.size > 1000 * 1000) { 99 | return Object.assign({}, upload, { error: 'file size exceeded 1MB' }); 100 | } 101 | }) 102 | 103 | // put files into state/stores by setState/action 104 | this.setState({ 105 | uploads: this.state.uploads.concat(newUploads)), 106 | }); 107 | 108 | // close the Receiver after file dropped 109 | this.setState({ isReceiverOpen: false }); 110 | } 111 | ``` 112 | 113 | # UploadManager 114 | 115 | Upload Manager serves as a high order component which helps you to manage the upload related parameters and functions. It prepares the upload function with [`superagent`](https://github.com/visionmedia/superagent) the children elements, and helps you to update the lifecycle status of the uploading files. 116 | 117 | ``` 118 | import { UploadManager } from 'react-file-uploader'; 119 | 120 | 135 | // UploadHandler as children 136 | { 137 | files.map(file => ( 138 | 139 | )) 140 | } 141 | 142 | ``` 143 | 144 | ## Props 145 | 146 | * component - `string`: the DOM tag name of the wrapper. By default it is an unordered list `ul`. 147 | * customClass - `string | array`: the class name(s) for the wrapper 148 | * formDataParser - **DEPRECATED** this prop function is renamed as `uploadDataHandler` starting from v1.0.0. 149 | 150 | * onUploadAbort - `function`: this will be fired when the upload request is aborted. This function is available from v1.0.0. 151 | 152 | ``` 153 | /** 154 | * @param fileId {string} identifier of a file / an upload task 155 | * @param changes {object} changes object containing the new property of an upload 156 | * @param changes.status {number} file status ABORTED 157 | */ 158 | let onUploadAbort = (fileId, { status }) => { ... } 159 | ``` 160 | 161 | * onUploadStart - `function`: this will be fired when the upload request is just sent. 162 | 163 | ``` 164 | /** 165 | * @param fileId {string} identifier of a file / an upload task 166 | * @param changes {object} changes object containing the new property of an upload 167 | * @param changes.status {number} file status UPLOADING 168 | */ 169 | let onUploadStart = (fileId, { status }) => { ... } 170 | ``` 171 | 172 | * onUploadProgress - `function`: this will be fired when the upload request returns progress. From v1.0.0, this callback is debounced for `props.progressDebounce` ms. 173 | 174 | ``` 175 | /** 176 | * @param fileId {string} identifier of a file / an upload task 177 | * @param changes {object} changes object containing the new property of an upload 178 | * @param changes.progress {number} upload progress in percentage 179 | * @param changes.status {number} file status UPLOADING 180 | */ 181 | let onUploadProgress = (fileId, { progress, status }) { ... } 182 | ``` 183 | 184 | * onUploadEnd - `function` `required`: this will be fired upon the end of upload request. 185 | 186 | ``` 187 | /** 188 | * @param fileId {string} identifier of a file / an upload task 189 | * @param changes {object} changes object containing the new property of an upload 190 | * @param changes.error {object} error returned from `props.uploadErrorHandler` 191 | * @param changes.progress {number} upload progress in percentage, either 0 or 100 with a corresponding FAILED or UPLOADED status 192 | * @param changes.result {object} upload result / response object returned from `props.uploadErrorHandler` 193 | * @param changes.status {number} file status, either FAILED or UPLOADED 194 | */ 195 | // @param file Object the file object returned with either UPLOADED or FAILED status and 100% progress. When it is wilh FAILED status, error property should be also assigned to the file object. 196 | let onUploadEnd = (fileId, { error, progress, result, status }) => { ... } 197 | ``` 198 | 199 | * progressDebounce - `number`: debounce value in ms for the callback on superagent `progress`. 200 | * reqConfigs - `object`: the exposed superagent configs including `accept`, `method`, `timeout` and `withCredentials`. 201 | * style - `object`: the style property for the wrapper. 202 | * uploadUrl - `string` `required`: the url of the upload end point from your server. 203 | * uploadDataHandler - `function`: this function is to parse the data to be sent as request data. From v1.0.0, the first argument will become a upload task object instead of the File instance. 204 | 205 | ``` 206 | let uploadDataHandler = (upload) => { 207 | // for FormData 208 | const formData = new FormData(); 209 | formData.append('file', upload.data); 210 | formData.append('custom-key', 'custom-value'); 211 | return formData; 212 | 213 | // for AWS S3 214 | return upload.data; 215 | } 216 | ``` 217 | 218 | * uploadErrorHandler - `function`: this function is to process the arguments of `(err, res)` in `superagent.end()`. In this function, you can resolve the error and result according to your upload api response. Default implementation is available as defaultProps. 219 | 220 | ``` 221 | function uploadErrorHandler(err, res) { 222 | const body = res.body ? clone(res.body) : {}; 223 | let error = null; 224 | 225 | if (err) { 226 | error = err.message; 227 | } else if (body.errors) { 228 | error = body.errors; 229 | } 230 | 231 | delete body.errors; 232 | 233 | return { error, result: body }; 234 | } 235 | ``` 236 | 237 | * uploadHeader - **DEPRECATED** this prop is deprecated and replaced by `uploadHeaderHandler`. 238 | 239 | * uploadHeaderHandler - `function`: the function is to parse the header object to be sent as request header. 240 | 241 | ``` 242 | let uploadHeaderHandler = (upload) => { 243 | // for AWS S3 244 | return { 245 | 'Content-Type': upload.data.type, 246 | 'Content-Disposition': 'inline' 247 | }; 248 | } 249 | ``` 250 | 251 | # UploadHandler 252 | 253 | Upload Handler helps you to execute the upload lifecycle, which is `start`, `progress` and `end`. It also acts as the presentation layer of a file, showing users the info of the **_uploading / uploaded_** file. 254 | 255 | ``` 256 | import { UploadHandler } from 'react-file-uploader'; 257 | 258 | 265 | { 266 | // From v1.0.0, you can pass a render function as children, so to have access to the prepared `upload` and `abort` function. 267 | ({ upload, abort }) => ( 268 |
269 |
{file.data.name}
270 |
271 | {file.id} 272 | {file.data.type} 273 | {file.data.size / 1000 / 1000} MB 274 | {file.progress}% 275 | 276 | {this.getStatusString(file.status)} 277 | 278 | {file.error} 279 | { 280 | ((index % 2 === 1 && file.status === 0) || file.status === -2) && ( 281 | 282 | ) 283 | } 284 | { 285 | file.status === 1 && ( 286 | 287 | ) 288 | } 289 |
290 |
291 | ) 292 | } 293 |
294 | ``` 295 | 296 | ## Props 297 | 298 | * `abort` - `function` the function to abort the upload request. It is provided by UploadManager HOC by default. 299 | * `autoStart` - `boolean`: when `autoStart` is set, upon the UploadHandler `componentDidMount`, it will detect if the file i.e. *as props* is with the `PENDING` status and initialise an upload request which is sent to the `uploadUrl` you passed to the `UploadManager`. 300 | * `component` - `string`: the DOM tag name of the wrapper. 301 | * `customClass` - `string | array`: the class name(s) for the wrapper 302 | * `style` - `object`: the style for the wrapper 303 | * `file` - `object` `required`: the file object that is **_uploaded / going to be uploaded_**. 304 | * `upload` - `function`: the function that contains the upload logic, you may pass it directly when you are using `UploadHandler` alone, or it could be prepared by `UploadManager`. 305 | 306 | ``` 307 | // @param url String API upload end point 308 | // @param file Object File Object 309 | let upload = (url, file) => { ... } 310 | ``` 311 | 312 | # File Status 313 | 314 | `react-file-uploader` defines a set of status constants to represent the file status. The corresponding status will be assign to a file object throughout the uploading life cycle. 315 | 316 | ``` 317 | ABORTED = -2 318 | FAILED = -1 319 | PENDING = 0 320 | UPLOADING = 1 321 | UPLOADED = 2 322 | ``` 323 | 324 | # TODOs 325 | 326 | * complete test cases 327 | * add real-world example 328 | * verify and provide better support to Amazon Simple Storage Service 329 | 330 | # License 331 | 332 | MIT -------------------------------------------------------------------------------- /examples/basic/client.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import * as FileUploader from '../../src/index'; 4 | 5 | class MyComponent extends Component { 6 | constructor(props) { 7 | super(props); 8 | 9 | this.state = { 10 | isPanelOpen: false, 11 | isDragOver: false, 12 | files: [], 13 | }; 14 | 15 | this.uploadPanel = undefined; 16 | this.openPanel = this.openPanel.bind(this); 17 | this.closePanel = this.closePanel.bind(this); 18 | this.onDragOver = this.onDragOver.bind(this); 19 | this.onFileDrop = this.onFileDrop.bind(this); 20 | this.onFileProgress = this.onFileProgress.bind(this); 21 | this.onFileUpdate = this.onFileUpdate.bind(this); 22 | this.setUploadPanelRef = this.setUploadPanelRef.bind(this); 23 | } 24 | 25 | openPanel() { 26 | this.setState({ isPanelOpen: true }); 27 | } 28 | 29 | closePanel() { 30 | this.setState({ isPanelOpen: false }); 31 | } 32 | 33 | onDragOver(e) { 34 | // your codes here: 35 | // if you want to check if the files are dragged over 36 | // a specific DOM node 37 | const node = ReactDOM.findDOMNode(this.uploadPanel); 38 | 39 | if (this.state.isDragOver !== (e.target === node)) { 40 | this.setState({ 41 | isDragOver: e.target === node, 42 | }); 43 | } 44 | } 45 | 46 | onFileDrop({ target }, files) { 47 | const node = ReactDOM.findDOMNode(this.uploadPanel); 48 | 49 | if (target !== node) { 50 | this.closePanel(); 51 | return false; 52 | } 53 | 54 | const uploads = files.map((item = {}) => { 55 | if (item.data.size > 100 * 1000 * 1000) { 56 | return Object.assign({}, item, { 57 | status: FileUploader.status.FAILED, 58 | error: 'file size exceeded maximum', 59 | }); 60 | } 61 | 62 | return item; 63 | }); 64 | 65 | this.setState({ 66 | files: this.state.files.concat(uploads), 67 | }); 68 | 69 | // if you want to close the panel upon file drop 70 | this.closePanel(); 71 | } 72 | 73 | onFileProgress(fileId, fileData) { 74 | const { files = [] } = this.state, 75 | newFiles = files.map(item => item.id === fileId ? Object.assign({}, item, fileData) : item); 76 | 77 | this.setState({ 78 | files: newFiles, 79 | }); 80 | } 81 | 82 | onFileUpdate(fileId, fileData) { 83 | const { files = [] } = this.state, 84 | newFiles = files.map(item => item.id === fileId ? Object.assign({}, item, fileData) : item); 85 | 86 | this.setState({ 87 | files: newFiles, 88 | }); 89 | } 90 | 91 | setUploadPanelRef(ref) { 92 | this.uploadPanel = ref; 93 | } 94 | 95 | getStatusString(status) { 96 | switch (status) { 97 | case -2: 98 | return 'aborted'; 99 | 100 | case -1: 101 | return 'failed'; 102 | 103 | case 0: 104 | return 'pending'; 105 | 106 | case 1: 107 | return 'uploading'; 108 | 109 | case 2: 110 | return 'uploaded'; 111 | 112 | default: 113 | return ''; 114 | } 115 | } 116 | 117 | render() { 118 | return ( 119 |
120 |

{ this.props.title }

121 |

You can upload files with size with 1 MB at maximum

122 | 131 |

132 | { 133 | !this.state.isDragOver ? 'Drop here' : 'Files detected' 134 | } 135 |

136 |
137 |
138 |

Upload List

139 | 148 | { 149 | this.state.files.map((file, index) => ( 150 | 151 | { 152 | ({ upload, abort }) => ( 153 |
154 |
{file.data.name}
155 |
156 | {file.id} 157 | {file.data.type} 158 | {file.data.size / 1000 / 1000} MB 159 | {file.progress}% 160 | 161 | {this.getStatusString(file.status)} 162 | 163 | {file.error} 164 | { 165 | ((index % 2 === 1 && file.status === 0) || file.status === -2) && ( 166 | 167 | ) 168 | } 169 | { 170 | file.status === 1 && ( 171 | 172 | ) 173 | } 174 |
175 |
176 | ) 177 | } 178 |
179 | )) 180 | } 181 |
182 |
183 |
184 | ); 185 | } 186 | } 187 | 188 | ReactDOM.render(, document.getElementById('app')); 189 | -------------------------------------------------------------------------------- /examples/basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | react-file-uploader examples | basic 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/basic/index.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var express = require('express'); 3 | var app = express(); 4 | var multiparty = require('connect-multiparty')(); 5 | var html = fs.readFileSync('index.html').toString(); 6 | 7 | app.use(express.static(__dirname)); 8 | 9 | app.get('/', function(req, res) { 10 | res.send(html); 11 | }); 12 | 13 | app.post('/upload', multiparty, function(req, res) { 14 | var file = req.files.file; 15 | 16 | fs.unlink(file.path, function(err) { 17 | res.json({ 18 | success: !err, 19 | file: file 20 | }); 21 | }); 22 | }); 23 | 24 | app.listen(3000, function(err) { 25 | console.log('app is started at port 3000'); 26 | }); -------------------------------------------------------------------------------- /examples/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "test": "echo \"Error: no test specified\" && exit 1", 4 | "build": "browserify client.js -o bundle.js -t [ babelify --presets [ es2015 react ] ]" 5 | }, 6 | "dependencies": { 7 | "connect-multiparty": "^2.0.0", 8 | "express": "^4.13.3", 9 | "forever": "^0.15.1", 10 | "react": "^16.0.0", 11 | "react-dom": "^16.0.0" 12 | }, 13 | "devDependencies": { 14 | "babel-preset-es2015": "^6.1.18", 15 | "babel-preset-react": "^6.1.18", 16 | "babelify": "^7.2.0", 17 | "browserify": "^12.0.1" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /examples/basic/style.css: -------------------------------------------------------------------------------- 1 | html, body, #app, #app > div { 2 | width: 100%; 3 | height: 100%; 4 | } 5 | 6 | .upload-panel { 7 | width: 400px; 8 | height: 200px; 9 | border: 2px dashed black; 10 | padding: 0 15px; 11 | } 12 | 13 | .upload-panel.hide { 14 | display: none; 15 | visibility: hidden; 16 | } 17 | 18 | .upload-list { 19 | width: 400px; 20 | height: auto; 21 | min-height: 200px; 22 | border: 2px dashed black; 23 | padding: 0 15px 15px; 24 | overflow: hidden; 25 | } 26 | 27 | .upload-list dd > span { 28 | display: block; 29 | float: left; 30 | width: 100%; 31 | } -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = require('./lib/FileUploader'); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-file-uploader", 3 | "version": "1.0.0", 4 | "description": "A set of file-upload-components with React.js.", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "clean": "rm -rf lib", 8 | "test": "jest", 9 | "test:report": "jest --coverage", 10 | "build:lib": "babel src --out-dir lib", 11 | "build": "npm run eslint && npm run test && npm run clean && npm run build:lib", 12 | "eslint": "eslint ./src/*.js ./src/**/*.js", 13 | "prepublish": "npm run build" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/lionng429/react-file-uploader.git" 18 | }, 19 | "keywords": [ 20 | "react", 21 | "file", 22 | "upload", 23 | "uploader", 24 | "file-upload", 25 | "file-uploader" 26 | ], 27 | "author": "Marston Ng ", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/lionng429/react-file-uploader/issues" 31 | }, 32 | "homepage": "https://github.com/lionng429/react-file-uploader", 33 | "devDependencies": { 34 | "babel": "^6.1.18", 35 | "babel-cli": "^6.2.0", 36 | "babel-jest": "*", 37 | "babel-preset-es2015": "^6.1.18", 38 | "babel-preset-react": "^6.1.18", 39 | "enzyme": "^3.3.0", 40 | "enzyme-adapter-react-16": "^1.1.1", 41 | "eslint": "^4.19.1", 42 | "eslint-plugin-react": "^7.7.0", 43 | "jest-cli": "*", 44 | "jsdom": "^7.0.2", 45 | "nock": "^8.0.0", 46 | "react-dom": "^15.0.0 || ^16.0.0" 47 | }, 48 | "peerDependencies": { 49 | "react": "^15.0.0 || ^16.0.0" 50 | }, 51 | "jest": { 52 | "scriptPreprocessor": "/node_modules/babel-jest", 53 | "testPathDirs": [ 54 | "/src" 55 | ], 56 | "unmockedModulePathPatterns": [ 57 | "/node_modules/react", 58 | "/node_modules/react-dom", 59 | "/node_modules/react-addons-test-utils", 60 | "/node_modules/jsdom", 61 | "/node_modules/lodash", 62 | "/node_modules/debug", 63 | "/node_modules/superagent", 64 | "/node_modules/nock" 65 | ] 66 | }, 67 | "dependencies": { 68 | "classnames": "^2.2.0", 69 | "debug": "^2.2.0", 70 | "invariant": "^2.2.0", 71 | "lodash": ">=3.10.1", 72 | "prop-types": "^15.5.10", 73 | "react": "^15.0.0 || ^16.0.0", 74 | "shortid": "^2.2.6", 75 | "superagent": "^1.4.0" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Receiver.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import invariant from 'invariant'; 4 | import classNames from 'classnames'; 5 | import shortid from 'shortid'; 6 | import status from './constants/status'; 7 | 8 | class Receiver extends Component { 9 | constructor(props) { 10 | super(props); 11 | 12 | this.wrapper = window; 13 | this.onDragEnter = this.onDragEnter.bind(this); 14 | this.onDragOver = this.onDragOver.bind(this); 15 | this.onDragLeave = this.onDragLeave.bind(this); 16 | this.onFileDrop = this.onFileDrop.bind(this); 17 | 18 | // this is to monitor the hierarchy 19 | // for window onDragEnter event 20 | this.state = { 21 | dragLevel: 0, 22 | }; 23 | } 24 | 25 | componentDidMount() { 26 | invariant( 27 | (window.DragEvent || window.Event) && window.DataTransfer, 28 | 'Browser does not support DnD events or File API.' 29 | ); 30 | 31 | const { wrapperId } = this.props; 32 | 33 | if (wrapperId) { 34 | const wrapperElement = document.getElementById(wrapperId); 35 | 36 | invariant( 37 | !!wrapperElement, 38 | `wrapper element with Id ${wrapperId} not found.` 39 | ); 40 | 41 | this.wrapper = wrapperElement; 42 | } 43 | 44 | this.wrapper.addEventListener('dragenter', this.onDragEnter); 45 | this.wrapper.addEventListener('dragleave', this.onDragLeave); 46 | this.wrapper.addEventListener('dragover', this.onDragOver); 47 | this.wrapper.addEventListener('drop', this.onFileDrop); 48 | } 49 | 50 | componentWillReceiveProps(nextProps) { 51 | if (nextProps.wrapperId !== this.props.wrapperId) { 52 | // eslint-disable-next-line no-console 53 | console.warn('[Receiver.js] Change in props.wrapperId is unexpected, no new event listeners will be created.'); 54 | } 55 | } 56 | 57 | componentWillUnmount() { 58 | this.wrapper.removeEventListener('dragenter', this.onDragEnter); 59 | this.wrapper.removeEventListener('dragleave', this.onDragLeave); 60 | this.wrapper.removeEventListener('dragover', this.onDragOver); 61 | this.wrapper.removeEventListener('drop', this.onFileDrop); 62 | } 63 | 64 | onDragEnter(e) { 65 | if (!e.dataTransfer.types.includes('Files')) { 66 | return; 67 | } 68 | 69 | const dragLevel = this.state.dragLevel + 1; 70 | 71 | this.setState({ dragLevel }); 72 | 73 | if (!this.props.isOpen) { 74 | this.props.onDragEnter(e); 75 | } 76 | } 77 | 78 | onDragLeave(e) { 79 | const dragLevel = this.state.dragLevel - 1; 80 | 81 | this.setState({ dragLevel }); 82 | 83 | if (dragLevel === 0) { 84 | this.props.onDragLeave(e); 85 | } 86 | } 87 | 88 | onDragOver(e) { 89 | e.preventDefault(); 90 | this.props.onDragOver(e); 91 | } 92 | 93 | onFileDrop(e) { 94 | e.preventDefault(); 95 | 96 | const uploads = []; 97 | 98 | if (e.dataTransfer && e.dataTransfer.files) { 99 | const fileList = e.dataTransfer.files; 100 | 101 | for (let i = 0; i < fileList.length; i++) { 102 | const upload = { 103 | id: shortid.generate(), 104 | status: status.PENDING, 105 | progress: 0, 106 | src: null, 107 | data: fileList[i] 108 | }; 109 | 110 | uploads.push(upload); 111 | } 112 | } 113 | 114 | // reset drag level once dropped 115 | this.setState({ dragLevel: 0 }); 116 | 117 | this.props.onFileDrop(e, uploads); 118 | } 119 | 120 | render() { 121 | const { isOpen, customClass, style, children } = this.props; 122 | 123 | return ( 124 | isOpen ? ( 125 |
126 | {children} 127 |
128 | ) : null 129 | ); 130 | } 131 | } 132 | 133 | Receiver.propTypes = { 134 | children: PropTypes.oneOfType([ 135 | PropTypes.element, 136 | PropTypes.arrayOf(PropTypes.element), 137 | ]), 138 | customClass: PropTypes.oneOfType([ 139 | PropTypes.string, 140 | PropTypes.arrayOf(PropTypes.string), 141 | ]), 142 | isOpen: PropTypes.bool.isRequired, 143 | onDragEnter: PropTypes.func.isRequired, 144 | onDragOver: PropTypes.func, 145 | onDragLeave: PropTypes.func.isRequired, 146 | onFileDrop: PropTypes.func.isRequired, 147 | style: PropTypes.object, 148 | wrapperId: PropTypes.string, 149 | }; 150 | 151 | Receiver.defaultProps = { 152 | isOpen: false 153 | }; 154 | 155 | export default Receiver; 156 | -------------------------------------------------------------------------------- /src/UploadHandler.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import invariant from 'invariant'; 4 | import classNames from 'classnames'; 5 | import uploadStatus from './constants/status'; 6 | 7 | const debug = require('debug')('react-file-uploader:UploadHandler'); 8 | 9 | class UploadHandler extends Component { 10 | componentDidMount() { 11 | const { file, upload, autoStart } = this.props; 12 | 13 | invariant( 14 | typeof upload === 'function', 15 | '`props.upload` must be a function' 16 | ); 17 | 18 | invariant( 19 | !!file, 20 | '`props.file` must be provided' 21 | ); 22 | 23 | if (file.status === uploadStatus.PENDING && autoStart) { 24 | debug('autoStart in on, calling upload()'); 25 | upload(file); 26 | } 27 | } 28 | 29 | render() { 30 | const { abort, component, customClass, style, upload } = this.props; 31 | 32 | return React.createElement( 33 | component, 34 | { className: classNames(customClass), style }, 35 | typeof this.props.children === 'function' ? this.props.children({ upload, abort }, this) : this.props.children 36 | ); 37 | } 38 | } 39 | 40 | UploadHandler.propTypes = { 41 | abort: PropTypes.func.isRequired, 42 | autoStart: PropTypes.bool, 43 | children: PropTypes.oneOfType([ 44 | PropTypes.element, 45 | PropTypes.arrayOf(PropTypes.element), 46 | PropTypes.func, 47 | ]), 48 | component: PropTypes.string.isRequired, 49 | customClass: PropTypes.oneOfType([ 50 | PropTypes.string, 51 | PropTypes.arrayOf(PropTypes.string), 52 | ]), 53 | file: PropTypes.object.isRequired, 54 | style: PropTypes.object, 55 | upload: PropTypes.func.isRequired, 56 | }; 57 | 58 | UploadHandler.defaultProps = { 59 | component: 'li', 60 | }; 61 | 62 | export default UploadHandler; 63 | -------------------------------------------------------------------------------- /src/UploadManager.js: -------------------------------------------------------------------------------- 1 | import React, { Component, cloneElement } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import invariant from 'invariant'; 4 | import classNames from 'classnames'; 5 | import bindKey from 'lodash/bindKey'; 6 | import clone from 'lodash/clone'; 7 | import debounce from 'lodash/debounce'; 8 | import isEmpty from 'lodash/isEmpty'; 9 | import superagent from 'superagent'; 10 | import uploadStatus from './constants/status'; 11 | 12 | const debug = require('debug')('react-file-uploader:UploadManager'); 13 | 14 | class UploadManager extends Component { 15 | constructor(props) { 16 | super(props); 17 | 18 | this.requests = {}; 19 | this.abort = this.abort.bind(this); 20 | this.upload = this.upload.bind(this); 21 | this.onProgress = debounce(this.onProgress.bind(this), props.progressDebounce); 22 | } 23 | 24 | componentDidMount() { 25 | // eslint-disable-next-line react/prop-types 26 | if (this.props.uploadHeader) { 27 | // eslint-disable-next-line no-console 28 | console.warn('`props.uploadHeader` is DEPRECATED. Please use `props.uploadHeaderHandler` instead.'); 29 | } 30 | 31 | // eslint-disable-next-line react/prop-types 32 | if (this.props.formDataParser) { 33 | // eslint-disable-next-line no-console 34 | console.warn('`props.formDataParser` is DEPRECATED. Please use `props.uploadDataHandler` instead.'); 35 | } 36 | 37 | invariant( 38 | !!this.props.uploadUrl, 39 | 'Upload end point must be provided to upload files' 40 | ); 41 | 42 | invariant( 43 | !!this.props.onUploadEnd, 44 | 'onUploadEnd function must be provided' 45 | ); 46 | } 47 | 48 | onProgress(fileId, progress) { 49 | const { onUploadProgress } = this.props, 50 | request = this.requests[fileId]; 51 | 52 | if (request.xhr && request.xhr.readyState !== 4 && !request.aborted) { 53 | if (typeof onUploadProgress === 'function') { 54 | onUploadProgress(fileId, { 55 | progress, 56 | status: uploadStatus.UPLOADING, 57 | }); 58 | } 59 | } 60 | } 61 | 62 | abort(file = {}) { 63 | const { onUploadAbort } = this.props, 64 | request = this.requests[file.id]; 65 | 66 | if (!request) { 67 | debug('request instance not found.'); 68 | return; 69 | } 70 | 71 | request.abort(); 72 | 73 | if (typeof onUploadAbort === 'function') { 74 | onUploadAbort(file.id, { status: uploadStatus.ABORTED }); 75 | } 76 | } 77 | 78 | upload(url, file) { 79 | const { 80 | reqConfigs: { 81 | accept = 'application/json', 82 | method = 'post', 83 | timeout, 84 | withCredentials = false, 85 | }, 86 | onUploadStart, 87 | onUploadEnd, 88 | uploadDataHandler, 89 | uploadErrorHandler, 90 | uploadHeaderHandler, 91 | } = this.props; 92 | 93 | if (typeof onUploadStart === 'function') { 94 | onUploadStart(file.id, { status: uploadStatus.UPLOADING }); 95 | } 96 | 97 | let header = uploadHeaderHandler(file), 98 | data = uploadDataHandler(file); 99 | 100 | let request = superagent[method.toLowerCase()](url) 101 | .accept(accept); 102 | 103 | if (!isEmpty(header)) { 104 | request.set(header); 105 | } 106 | 107 | if (timeout) { 108 | request.timeout(timeout); 109 | } 110 | 111 | if (withCredentials) { 112 | request.withCredentials(); 113 | } 114 | 115 | this.requests[file.id] = request; 116 | 117 | debug(`start uploading file#${file.id} to ${url}`, file); 118 | 119 | request 120 | .send(data) 121 | .on('progress', ({ percent }) => this.onProgress(file.id, percent)) 122 | .end((err, res) => { 123 | const { error, result } = uploadErrorHandler(err, res); 124 | 125 | if (error) { 126 | debug('failed to upload file', error); 127 | } else { 128 | debug('succeeded to upload file', result); 129 | } 130 | 131 | if (typeof onUploadEnd === 'function') { 132 | onUploadEnd(file.id, { 133 | progress: error && 0 || 100, 134 | error, 135 | result: error && undefined || result, 136 | status: error && uploadStatus.FAILED || uploadStatus.UPLOADED 137 | }); 138 | } 139 | }); 140 | } 141 | 142 | render() { 143 | const { component, customClass, style, children, uploadUrl } = this.props; 144 | 145 | return React.createElement( 146 | component, 147 | { className: classNames(customClass), style }, 148 | React.Children.map(children, child => cloneElement(child, Object.assign({ 149 | abort: bindKey(this, 'abort', child.props.file), 150 | upload: bindKey(this, 'upload', uploadUrl, child.props.file), 151 | }, child.props))) 152 | ); 153 | } 154 | } 155 | 156 | UploadManager.propTypes = { 157 | children: PropTypes.oneOfType([ 158 | PropTypes.element, 159 | PropTypes.arrayOf(PropTypes.element), 160 | ]), 161 | component: PropTypes.string.isRequired, 162 | customClass: PropTypes.oneOfType([ 163 | PropTypes.string, 164 | PropTypes.arrayOf(PropTypes.string), 165 | ]), 166 | onUploadAbort: PropTypes.func, 167 | onUploadStart: PropTypes.func, 168 | onUploadProgress: PropTypes.func, 169 | onUploadEnd: PropTypes.func.isRequired, 170 | progressDebounce: PropTypes.number, 171 | reqConfigs: PropTypes.shape({ 172 | accept: PropTypes.string, 173 | method: PropTypes.string, 174 | timeout: PropTypes.shape({ 175 | response: PropTypes.number, 176 | deadline: PropTypes.number, 177 | }), 178 | withCredentials: PropTypes.bool, 179 | }), 180 | style: PropTypes.object, 181 | uploadDataHandler: PropTypes.func, 182 | uploadErrorHandler: PropTypes.func, 183 | uploadHeaderHandler: PropTypes.func, 184 | uploadUrl: PropTypes.string.isRequired, 185 | }; 186 | 187 | UploadManager.defaultProps = { 188 | component: 'ul', 189 | progressDebounce: 150, 190 | reqConfigs: {}, 191 | uploadDataHandler: (file) => { 192 | const formData = new FormData(); 193 | formData.append('file', file.data); 194 | return formData; 195 | }, 196 | uploadErrorHandler: (err, res = {}) => { 197 | const body = res.body ? clone(res.body) : {}; 198 | let error = null; 199 | 200 | if (err) { 201 | error = err.message; 202 | } else if (body.errors) { 203 | error = body.errors; 204 | } 205 | 206 | delete body.errors; 207 | 208 | return { error, result: body }; 209 | }, 210 | uploadHeaderHandler: () => ({}) 211 | }; 212 | 213 | export default UploadManager; 214 | -------------------------------------------------------------------------------- /src/__tests__/Receiver-test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef, max-len, no-console */ 2 | jest.dontMock('../Receiver'); 3 | jest.dontMock('../index'); 4 | jest.dontMock('classnames'); 5 | 6 | import React from 'react'; 7 | import { mount, shallow, configure } from 'enzyme'; 8 | import Adapter from 'enzyme-adapter-react-16'; 9 | import { jsdom } from 'jsdom'; 10 | import shortid from 'shortid'; 11 | 12 | const FileUploader = require('../index'); 13 | const uploadStatus = FileUploader.status; 14 | const Receiver = FileUploader.Receiver; 15 | 16 | configure({ adapter: new Adapter() }); 17 | 18 | const testFile = { 19 | lastModified: 1465229147840, 20 | lastModifiedDate: 'Tue Jun 07 2016 00:05:47 GMT+0800 (HKT)', 21 | name: 'test.jpg', 22 | size: 1024, 23 | type: 'image/jpg', 24 | webkitRelativePath: '', 25 | }; 26 | 27 | const testFileCopy = JSON.parse(JSON.stringify(testFile)); 28 | 29 | const files = [testFile]; 30 | 31 | const createEvent = (eventName) => { 32 | const event = document.createEvent('HTMLEvents'); 33 | event.initEvent(eventName, false, true); 34 | event.preventDefault = jest.genMockFn(); 35 | event.dataTransfer = { 36 | files, 37 | setData: jest.genMockFunction(), 38 | types: ['Files'] 39 | }; 40 | 41 | return event; 42 | }; 43 | 44 | describe('Receiver', () => { 45 | let dragEnterEvent, 46 | dragOverEvent, 47 | dragLeaveEvent, 48 | dropEvent, 49 | stringClass = 'receiver', 50 | arrayClass = ['react', 'receiver'], 51 | customStyle = { display: 'block' }; 52 | 53 | beforeEach(() => { 54 | global.document = jsdom(); 55 | global.window = document.parentWindow; 56 | global.window.DragEvent = 'DragEvent'; 57 | global.window.DataTransfer = 'DataTransfer'; 58 | 59 | dragEnterEvent = createEvent('dragenter'); 60 | dragOverEvent = createEvent('dragover'); 61 | dragLeaveEvent = createEvent('dragleave'); 62 | dropEvent = createEvent('drop'); 63 | }); 64 | 65 | describe('constructor()', () => { 66 | let emptyFn = () => {}, 67 | component = ( 68 | 74 | ); 75 | 76 | beforeEach(() => { 77 | console.warn = jest.genMockFn(); 78 | }); 79 | 80 | afterEach(() => { 81 | console.warn.mockClear(); 82 | }); 83 | 84 | it('should throw an error if DnD or File API is not supported', () => { 85 | global.window.DragEvent = undefined; 86 | global.window.DataTransfer = undefined; 87 | 88 | expect(() => shallow(component)).toThrow('Browser does not support DnD events or File API.'); 89 | }); 90 | 91 | it('should assign window to this.wrapper if no wrapperId is provided', () => { 92 | const receiver = shallow(component); 93 | expect(receiver.instance().wrapper).toEqual(global.window); 94 | }); 95 | 96 | it('should throw an error if wrapperId is given but element is not found', () => { 97 | expect(() => shallow( 98 | 105 | )).toThrow(); 106 | }); 107 | 108 | it('should not throw an error if wrapperId is given and the element exists', () => { 109 | expect(() => mount(( 110 |
111 | 118 |
119 | ), { attachTo: document.body })).not.toThrow(); 120 | }); 121 | 122 | it('should console.warn when a new wrapperId is given', () => { 123 | const receiver = shallow(component); 124 | receiver.setProps({ wrapperId: 'newRandom' }); 125 | expect(console.warn.mock.calls.length).toBe(1); 126 | }); 127 | }); 128 | 129 | describe('state of dragLevel', () => { 130 | let receiver, 131 | onDragEnter, 132 | onDragOver, 133 | onDragLeave, 134 | onFileDrop; 135 | 136 | beforeEach(() => { 137 | const mockOnDragEnter = jest.genMockFn(); 138 | const mockOnDragOver = jest.genMockFn(); 139 | const mockOnDragLeave = jest.genMockFn(); 140 | const mockOnFileDrop = jest.genMockFn(); 141 | 142 | onDragEnter = mockOnDragEnter; 143 | onDragOver = mockOnDragOver; 144 | onDragLeave = mockOnDragLeave; 145 | onFileDrop = mockOnFileDrop; 146 | 147 | const component = ( 148 | 156 | ); 157 | 158 | receiver = shallow(component); 159 | }); 160 | 161 | it('should increase state of dragLevel by 1 with dragEnter event', () => { 162 | const oldDragLevel = receiver.state().dragLevel; 163 | window.dispatchEvent(dragEnterEvent); 164 | const newDragLevel = receiver.state().dragLevel; 165 | expect(newDragLevel).toEqual(oldDragLevel + 1); 166 | }); 167 | 168 | it('should call onDragEnter with dragEnter event if isOpen is false', () => { 169 | window.dispatchEvent(dragEnterEvent); 170 | expect(onDragEnter).toBeCalled(); 171 | }); 172 | 173 | it('should not call onDragEnter with dragEnter event if isOpen is true', () => { 174 | receiver.setProps({ isOpen: true }); 175 | window.dispatchEvent(dragEnterEvent); 176 | expect(onDragEnter).not.toBeCalled(); 177 | }); 178 | 179 | it('should not call onDragEnter callback with dragEnter event if dataTransfer.types does not include `Files`', () => { 180 | onDragEnter = jest.genMockFn(); 181 | dragEnterEvent.dataTransfer.types = ['HTMLElement']; 182 | 183 | receiver.setProps({ onDragEnter }); 184 | 185 | window.dispatchEvent(dragEnterEvent); 186 | expect(onDragEnter).not.toBeCalled(); 187 | }); 188 | 189 | it('should call event.preventDefault with dragOver event', () => { 190 | window.dispatchEvent(dragOverEvent); 191 | expect(dragOverEvent.preventDefault).toBeCalled(); 192 | }); 193 | 194 | it('should call onDragOver with dragOver event', () => { 195 | window.dispatchEvent(dragOverEvent); 196 | expect(onDragOver).toBeCalled(); 197 | }); 198 | 199 | it('should decrease state of dragLevel by 1 with dragLeave event', () => { 200 | const oldDragLevel = receiver.state().dragLevel; 201 | window.dispatchEvent(dragEnterEvent); 202 | const newDragLevel = receiver.state().dragLevel; 203 | expect(newDragLevel).toEqual(oldDragLevel + 1); 204 | 205 | window.dispatchEvent(dragLeaveEvent); 206 | const finalDragLevel = receiver.state().dragLevel; 207 | expect(finalDragLevel).toEqual(newDragLevel - 1); 208 | expect(onDragLeave).toBeCalled(); 209 | }); 210 | 211 | it('should call onDragLeave if state of dragLevel is not 0', () => { 212 | const oldDragLevel = receiver.state().dragLevel; 213 | window.dispatchEvent(dragEnterEvent); 214 | const newDragLevel = receiver.state().dragLevel; 215 | expect(newDragLevel).toEqual(oldDragLevel + 1); 216 | 217 | window.dispatchEvent(dragEnterEvent); 218 | const newerDragLevel = receiver.state().dragLevel; 219 | expect(newerDragLevel).toEqual(newDragLevel + 1); 220 | 221 | window.dispatchEvent(dragLeaveEvent); 222 | const finalDragLevel = receiver.state().dragLevel; 223 | expect(finalDragLevel).toEqual(newerDragLevel - 1); 224 | expect(onDragLeave).not.toBeCalled(); 225 | 226 | window.dispatchEvent(dragLeaveEvent); 227 | const endDragLevel = receiver.state().dragLevel; 228 | expect(endDragLevel).toEqual(finalDragLevel - 1); 229 | expect(onDragLeave).toBeCalled(); 230 | }); 231 | 232 | it('should call event.preventDefault with drop event', () => { 233 | window.dispatchEvent(dropEvent); 234 | // eslint-disable-next-line no-undef 235 | expect(dropEvent.preventDefault).toBeCalled(); 236 | }); 237 | 238 | it('should call onFileDrop with drop event', () => { 239 | window.dispatchEvent(dropEvent); 240 | expect(onFileDrop).toBeCalled(); 241 | }); 242 | 243 | it('should set state of dragLevel to 0 with dragEnter event', () => { 244 | const oldDragLevel = receiver.state().dragLevel; 245 | window.dispatchEvent(dragEnterEvent); 246 | const newDragLevel = receiver.state().dragLevel; 247 | expect(newDragLevel).toEqual(oldDragLevel + 1); 248 | 249 | window.dispatchEvent(dropEvent); 250 | const finalDragLevel = receiver.state().dragLevel; 251 | expect(finalDragLevel).toEqual(0); 252 | }); 253 | 254 | it('should not call any callback after Receiver did unmount', () => { 255 | receiver.unmount(); 256 | window.dispatchEvent(dragEnterEvent); 257 | expect(onDragEnter).not.toBeCalled(); 258 | 259 | window.dispatchEvent(dragOverEvent); 260 | expect(onDragOver).not.toBeCalled(); 261 | 262 | window.dispatchEvent(dragLeaveEvent); 263 | expect(onDragLeave).not.toBeCalled(); 264 | 265 | window.dispatchEvent(dropEvent); 266 | expect(onFileDrop).not.toBeCalled(); 267 | }); 268 | }); 269 | 270 | describe('callbacks and callback arguments', () => { 271 | let fileId = 'Ghb19rg1', 272 | onDragEnter, 273 | onDragOver, 274 | onDragLeave, 275 | onFileDrop; 276 | 277 | beforeEach(() => { 278 | shortid.generate = jest.genMockFn().mockReturnValue(fileId); 279 | 280 | const mockOnDragEnter = (e) => { 281 | expect(e.type).toBe('dragenter'); 282 | }; 283 | const mockOnDragOver = (e) => { 284 | expect(e.type).toBe('dragover'); 285 | }; 286 | const mockOnDragLeave = (e) => { 287 | expect(e.type).toBe('dragleave'); 288 | }; 289 | const mockOnFileDrop = (e, _files) => { 290 | expect(e.type).toBe('drop'); 291 | expect(shortid.generate).toBeCalled(); 292 | const file = _files[0]; 293 | expect(file.id).toBe(fileId); 294 | expect(file.status).toBe(uploadStatus.PENDING); 295 | expect(file.progress).toBe(0); 296 | expect(file.src).toBe(null); 297 | expect(file.data).toEqual(testFile); 298 | // to test data mutation 299 | expect(testFile).toEqual(testFileCopy); 300 | }; 301 | 302 | onDragEnter = mockOnDragEnter; 303 | onDragOver = mockOnDragOver; 304 | onDragLeave = mockOnDragLeave; 305 | onFileDrop = mockOnFileDrop; 306 | 307 | const component = ( 308 | 316 | ); 317 | 318 | shallow(component); 319 | }); 320 | 321 | afterEach(() => { 322 | shortid.generate.mockClear(); 323 | }); 324 | 325 | it('should execute the onDragEnter callback with a DragEvent with type `dragenter` as argument', () => { 326 | window.dispatchEvent(dragEnterEvent); 327 | }); 328 | 329 | it('should execute the onDragOver callback with a DragEvent with type `dragover` as argument', () => { 330 | window.dispatchEvent(dragOverEvent); 331 | }); 332 | 333 | it('should execute the onDragLeave callback with a DragEvent with type `dragleave` as argument', () => { 334 | window.dispatchEvent(dragLeaveEvent); 335 | }); 336 | 337 | it('should execute the onFileDrop callback with a DragEvent with type `drop` as argument and it should not mutate the dataTransfer.files', () => { 338 | window.dispatchEvent(dropEvent); 339 | }); 340 | }); 341 | 342 | describe('render()', () => { 343 | let receiver, 344 | childrenItems = Array(5).fill().map((value, index) => (
  • {index}
  • )); 345 | 346 | beforeEach(() => { 347 | const mockOnDragEnter = jest.genMockFn(); 348 | const mockOnDragOver = jest.genMockFn(); 349 | const mockOnDragLeave = jest.genMockFn(); 350 | const mockOnFileDrop = jest.genMockFn(); 351 | 352 | const component = ( 353 | 362 | {childrenItems} 363 | 364 | ); 365 | 366 | receiver = shallow(component); 367 | }); 368 | 369 | it('should render nothing if isOpen is false', () => { 370 | expect(receiver.type()).toEqual(null); 371 | expect(receiver.children().exists()).toBe(false); 372 | }); 373 | 374 | it('should render a div wrapper with children if isOpen is true', () => { 375 | receiver.setProps({ isOpen: true }); 376 | expect(receiver.type()).toEqual('div'); 377 | expect(receiver.children().length).toEqual(childrenItems.length); 378 | }); 379 | 380 | it('should render a div wrapper with customClass in string', () => { 381 | receiver.setProps({ isOpen: true, customClass: stringClass }); 382 | expect(receiver.hasClass(stringClass)).toBe(true); 383 | }); 384 | 385 | it('should render a div wrapper with customClass in array', () => { 386 | receiver.setProps({ isOpen: true, customClass: arrayClass }); 387 | arrayClass.forEach((classname) => { 388 | expect(receiver.hasClass(classname)).toBe(true); 389 | }); 390 | }); 391 | 392 | it('should render a div wrapper with applying `props.style`', () => { 393 | receiver.setProps({ isOpen: true, style: customStyle }); 394 | expect(receiver.prop('style')).toEqual(customStyle); 395 | }); 396 | }); 397 | }); 398 | /* eslint-enable no-undef, max-len, no-console */ 399 | -------------------------------------------------------------------------------- /src/__tests__/UploadHandler-test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef, max-len, no-console */ 2 | jest.dontMock('../UploadHandler'); 3 | jest.dontMock('../index'); 4 | jest.dontMock('classnames'); 5 | 6 | import React from 'react'; 7 | import { shallow, configure } from 'enzyme'; 8 | import Adapter from 'enzyme-adapter-react-16'; 9 | 10 | const FileUploader = require('../index'); 11 | const uploadStatus = FileUploader.status; 12 | const UploadHandler = FileUploader.UploadHandler; 13 | 14 | configure({ adapter: new Adapter() }); 15 | 16 | describe('UploadHandler', () => { 17 | let uploadHandler, 18 | component, 19 | mockAbort, 20 | mockUpload, 21 | stringClass = 'receiver', 22 | arrayClass = ['react', 'receiver'], 23 | customStyle = { display: 'block' }, 24 | children = (children), 25 | renderFunction = jest.genMockFn(), 26 | file = { id: 'fileId', status: uploadStatus.PENDING }; 27 | 28 | beforeEach(() => { 29 | mockAbort = jest.genMockFn(); 30 | mockUpload = jest.genMockFn(); 31 | renderFunction.mockReturnValue(children); 32 | 33 | component = ( 34 | 41 | ); 42 | 43 | uploadHandler = shallow(component); 44 | }); 45 | 46 | describe('componentDidMount()', () => { 47 | it('should throw an error if `props.upload` is not a function', () => { 48 | expect(() => shallow( 49 | 53 | )).toThrow('`props.upload` must be a function'); 54 | }); 55 | 56 | it('should throw an error if `props.file` is missing', () => { 57 | expect(() => shallow( 58 | 62 | )).toThrow('`props.file` must be provided'); 63 | }); 64 | 65 | it('should call `props.upload()` if `props.autoStart` is true', () => { 66 | uploadHandler = shallow( 67 | 72 | ); 73 | 74 | expect(mockUpload).toBeCalledWith(file); 75 | }); 76 | }); 77 | 78 | describe('render()', () => { 79 | it('should render a HTML `props.component` element as wrapper', () => { 80 | expect(uploadHandler.type()).toEqual('li'); 81 | uploadHandler.setProps({ component: 'div' }); 82 | expect(uploadHandler.type()).toEqual('div'); 83 | expect(uploadHandler.children().exists()).toBe(false); 84 | }); 85 | 86 | it('should render ReactElement children if it is given', () => { 87 | uploadHandler.setProps({ children }); 88 | expect(uploadHandler.children().matchesElement(children)); 89 | }); 90 | 91 | it('should accept children as render function with { abort, upload } and the instance itself', () => { 92 | uploadHandler.setProps({ children: renderFunction }); 93 | expect(renderFunction).toBeCalledWith({ abort: mockAbort, upload: mockUpload }, uploadHandler.instance()); 94 | expect(uploadHandler.children().matchesElement(children)); 95 | }); 96 | 97 | it('should render a div wrapper with customClass in string', () => { 98 | uploadHandler.setProps({ customClass: stringClass }); 99 | expect(uploadHandler.hasClass(stringClass)).toBe(true); 100 | }); 101 | 102 | it('should render a div wrapper with customClass in array', () => { 103 | uploadHandler.setProps({ customClass: arrayClass }); 104 | arrayClass.forEach((classname) => { 105 | expect(uploadHandler.hasClass(classname)).toBe(true); 106 | }); 107 | }); 108 | 109 | it('should render a div wrapper with applying `props.style`', () => { 110 | uploadHandler.setProps({ style: customStyle }); 111 | expect(uploadHandler.prop('style')).toEqual(customStyle); 112 | }); 113 | }); 114 | }); 115 | /* eslint-enable no-undef, max-len, no-console */ 116 | -------------------------------------------------------------------------------- /src/__tests__/UploadManager-test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef, max-len */ 2 | jest.dontMock('../UploadManager'); 3 | jest.dontMock('../index'); 4 | jest.dontMock('classnames'); 5 | jest.dontMock('lodash'); 6 | 7 | import React from 'react'; 8 | import { shallow, configure } from 'enzyme'; 9 | import Adapter from 'enzyme-adapter-react-16'; 10 | import { jsdom } from 'jsdom'; 11 | import nock from 'nock'; 12 | 13 | const FileUploader = require('../index'); 14 | const UploadManager = FileUploader.UploadManager; 15 | const uploadStatus = FileUploader.status; 16 | 17 | configure({ adapter: new Adapter() }); 18 | 19 | describe('UploadManager', () => { 20 | let stringClass = 'receiver', 21 | arrayClass = ['react', 'receiver'], 22 | uploadPath = 'http://localhost:3000/api/upload', 23 | timeout = { 24 | response: 1000, 25 | deadline: 1000, 26 | }, 27 | children = (

    children

    ), 28 | uploadManager, 29 | onUploadAbort, 30 | onUploadStart, 31 | onUploadProgress, 32 | onUploadEnd, 33 | uploadDataHandler, 34 | uploadHeaderHandler, 35 | err, 36 | errorResponse, 37 | successResponse, 38 | errorHandler, 39 | file, 40 | fileCopy, 41 | customHeader; 42 | 43 | beforeEach(() => { 44 | global.document = jsdom(); 45 | global.window = document.parentWindow; 46 | 47 | customHeader = { 48 | 'Accept': 'customAccept', 49 | 'Content-Type': 'customContentType', 50 | 'Content-Disposition': 'customContentDisposition' 51 | }; 52 | 53 | onUploadAbort = jest.genMockFn(); 54 | onUploadStart = jest.genMockFn(); 55 | onUploadProgress = jest.genMockFn(); 56 | onUploadEnd = jest.genMockFn(); 57 | uploadDataHandler = jest.genMockFn(); 58 | uploadHeaderHandler = jest.genMockFn().mockReturnValue(customHeader); 59 | 60 | file = { id: 'fileId' }; 61 | fileCopy = JSON.parse(JSON.stringify(file)); 62 | 63 | err = new Error('not found'); 64 | errorResponse = { body: { success: false, errors: { message: 'not found' } } }; 65 | successResponse = { body: { success: true } }; 66 | errorHandler = UploadManager.defaultProps.uploadErrorHandler; 67 | 68 | nock('http://localhost:3000') 69 | .filteringRequestBody(() => '*') 70 | .post('/api/upload', '*') 71 | .reply(200, successResponse); 72 | 73 | uploadManager = shallow( 74 | 90 | {children} 91 | 92 | ) 93 | }); 94 | 95 | afterEach(() => { 96 | nock.cleanAll(); 97 | nock.enableNetConnect(); 98 | }); 99 | 100 | describe('render()', () => { 101 | it('should render ul element by default', () => { 102 | expect(uploadManager.type()).toEqual('ul'); 103 | expect(uploadManager.childAt(0).type()).toEqual('p'); 104 | }); 105 | 106 | it('should render wrapper element according to component props', () => { 107 | uploadManager.setProps({ component: 'div' }); 108 | expect(uploadManager.type()).toEqual('div'); 109 | }); 110 | 111 | it('should render a wrapper with customClass in string', () => { 112 | expect(uploadManager.hasClass(stringClass)).toBe(true); 113 | }); 114 | 115 | it('should render a wrapper with customClass in array', () => { 116 | uploadManager.setProps({ customClass: arrayClass }); 117 | 118 | arrayClass.forEach((classname) => { 119 | expect(uploadManager.hasClass(classname)).toBe(true); 120 | }); 121 | }); 122 | }); 123 | 124 | describe('uploadDataHandler()', () => { 125 | it('should return a FileData instance with a file data set', () => { 126 | const file = { data: 'fileData' }, 127 | result = UploadManager.defaultProps.uploadDataHandler(file); 128 | expect(result).toBeInstanceOf(FormData); 129 | expect(result.get('file')).toEqual(file.data); 130 | }); 131 | }); 132 | 133 | describe('uploadErrorHandler()', () => { 134 | it('should return an object contains key of `error` and `result`', () => { 135 | const result = errorHandler(null, successResponse); 136 | expect(result.error).toBeNull(); 137 | expect(result.result).toEqual(successResponse.body); 138 | }); 139 | 140 | it('should return an object with key of `error` with value equals to the first argument if it is not empty', () => { 141 | const result = errorHandler(err, successResponse); 142 | expect(result.error).toEqual(err.message); 143 | expect(result.result).toEqual(successResponse.body); 144 | }); 145 | 146 | it('should return an object with key of `error` with value equals to the value of `body.error` of the second argument if it is not empty', () => { 147 | const result = errorHandler(null, errorResponse); 148 | expect(result.error).toEqual(errorResponse.body.errors); 149 | delete errorResponse.body.errors; 150 | expect(result.result).toEqual(errorResponse.body); 151 | }); 152 | }); 153 | 154 | describe('uploadHeaderHandler()', () => { 155 | it('should return an empty object', () => { 156 | expect(UploadManager.defaultProps.uploadHeaderHandler()).toEqual({}); 157 | }); 158 | }); 159 | 160 | describe('upload()', () => { 161 | it('should declare the request instance', () => { 162 | const instance = uploadManager.instance(); 163 | instance.upload(instance.props.uploadUrl, file); 164 | 165 | const request = instance.requests[file.id]; 166 | expect(request._timeout).toEqual(timeout); 167 | expect(request._header.accept).toEqual(customHeader['Accept']); 168 | expect(request._header['content-type']).toEqual(customHeader['Content-Type']); 169 | expect(request._header['content-disposition']).toEqual(customHeader['Content-Disposition']); 170 | }); 171 | 172 | it('should call `props.onUploadStart` function if it is given', () => { 173 | const instance = uploadManager.instance(); 174 | instance.upload(instance.props.uploadUrl, file); 175 | expect(onUploadStart).toBeCalledWith(file.id, { status: uploadStatus.UPLOADING }); 176 | expect(file).toEqual(fileCopy); 177 | }); 178 | 179 | it('should call `props.uploadDataHandler` function if it is given', () => { 180 | const instance = uploadManager.instance(), 181 | file = {}; 182 | instance.upload(instance.props.uploadUrl, file); 183 | expect(uploadDataHandler).toBeCalledWith(file); 184 | }); 185 | 186 | it('should call `props.uploadHeaderHandler` function if it is given', () => { 187 | const instance = uploadManager.instance(), 188 | file = {}; 189 | instance.upload(instance.props.uploadUrl, file); 190 | expect(uploadHeaderHandler).toBeCalledWith(file); 191 | }); 192 | }); 193 | 194 | describe('abort()', () => { 195 | let instance, request; 196 | 197 | beforeEach(() => { 198 | instance = uploadManager.instance(); 199 | instance.upload(instance.props.uploadUrl, file); 200 | request = instance.requests[file.id]; 201 | request.abort = jest.genMockFn(); 202 | }); 203 | 204 | afterEach(() => { 205 | request.abort.mockClear(); 206 | }); 207 | 208 | it('should call `request.abort()` and `props.onUploadAbort()` if request instance is found.', () => { 209 | instance.abort(); 210 | expect(request.abort).not.toBeCalled(); 211 | 212 | instance.abort(file); 213 | expect(request.abort).toBeCalled(); 214 | expect(onUploadAbort).toBeCalledWith(file.id, { status: uploadStatus.ABORTED }); 215 | }); 216 | }); 217 | 218 | describe('onProgress()', () => { 219 | let instance, request, progress = 10; 220 | 221 | beforeEach(() => { 222 | instance = uploadManager.instance(); 223 | instance.upload(instance.props.uploadUrl, file); 224 | request = instance.requests[file.id]; 225 | request.aborted = false; 226 | request.xhr = {}; 227 | }); 228 | 229 | it('should call `props.onUploadProgress()` if request is not aborted', (done) => { 230 | instance.onProgress(file.id, progress); 231 | setTimeout(() => { 232 | expect(onUploadProgress).toBeCalledWith(file.id, { progress, status: uploadStatus.UPLOADING }); 233 | done(); 234 | }, instance.props.progressDebounce); 235 | }); 236 | }); 237 | }); 238 | /* eslint-enable no-undef, max-len */ 239 | -------------------------------------------------------------------------------- /src/constants/status.js: -------------------------------------------------------------------------------- 1 | export default { 2 | ABORTED: -2, 3 | FAILED: -1, 4 | PENDING: 0, 5 | UPLOADING: 1, 6 | UPLOADED: 2, 7 | }; 8 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Receiver from './Receiver'; 2 | import UploadManager from './UploadManager'; 3 | import UploadHandler from './UploadHandler'; 4 | import status from './constants/status'; 5 | 6 | export { 7 | Receiver, 8 | UploadManager, 9 | UploadHandler, 10 | status, 11 | }; 12 | 13 | --------------------------------------------------------------------------------