diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..ff56d10 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,21 @@ +const base = require("@umijs/fabric/dist/eslint"); + +module.exports = { + ...base, + rules: { + ...base.rules, + "react/no-array-index-key": 0, + "react/sort-comp": 0, + "@typescript-eslint/no-explicit-any": 0, + "@typescript-eslint/no-empty-interface": 0, + "@typescript-eslint/no-inferrable-types": 0, + "react/no-find-dom-node": 0, + "react/require-default-props": 0, + "no-confusing-arrow": 0, + "import/no-named-as-default-member": 0, + "jsx-a11y/label-has-for": 0, + "jsx-a11y/label-has-associated-control": 0, + "import/no-extraneous-dependencies": 0, + "no-underscore-dangle": 0, + }, +}; diff --git a/.fatherrc.js b/.fatherrc.js new file mode 100644 index 0000000..9d8c16b --- /dev/null +++ b/.fatherrc.js @@ -0,0 +1,9 @@ +export default { + cjs: "babel", + esm: { type: "babel", importLibToEs: true }, + preCommit: { + eslint: true, + prettier: true, + }, + runtimeHelpers: true, +}; diff --git a/.gitignore b/.gitignore index 9d6f5bc..bc73d2e 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,5 @@ es package-lock.json tmp/ .history +.storybook +.doc diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..895b8bd --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "proseWrap": "never", + "printWidth": 100 +} diff --git a/.travis.yml b/.travis.yml index 8edbf2c..70cdd0e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,7 +7,7 @@ notifications: - yiminghe@gmail.com node_js: -- 6 +- 10 before_install: - | @@ -29,4 +29,5 @@ env: matrix: - TEST_TYPE=lint - TEST_TYPE=test - - TEST_TYPE=coverage \ No newline at end of file + - TEST_TYPE=coverage + - TEST_TYPE=compile diff --git a/examples/asyncAction.html b/examples/asyncAction.html deleted file mode 100644 index e69de29..0000000 diff --git a/examples/asyncAction.js b/examples/asyncAction.tsx similarity index 69% rename from examples/asyncAction.js rename to examples/asyncAction.tsx index a81ef6f..61b6773 100644 --- a/examples/asyncAction.js +++ b/examples/asyncAction.tsx @@ -1,11 +1,10 @@ /* eslint no-console:0 */ import React from 'react'; -import ReactDOM from 'react-dom'; -import Upload from 'rc-upload'; +import Upload from '..'; const props = { action: () => { - return new Promise((resolve) => { + return new Promise(resolve => { setTimeout(() => { resolve('/upload.do'); }, 2000); @@ -31,10 +30,12 @@ const Test = () => { }} >
- 开始上传 + + 开始上传 +
); }; -ReactDOM.render(, document.getElementById('__react-content')); +export default Test; diff --git a/examples/beforeUpload.html b/examples/beforeUpload.html deleted file mode 100644 index 48cdce8..0000000 --- a/examples/beforeUpload.html +++ /dev/null @@ -1 +0,0 @@ -placeholder diff --git a/examples/beforeUpload.js b/examples/beforeUpload.tsx similarity index 73% rename from examples/beforeUpload.js rename to examples/beforeUpload.tsx index 8417f28..fcad0fe 100644 --- a/examples/beforeUpload.js +++ b/examples/beforeUpload.tsx @@ -1,8 +1,7 @@ /* eslint no-console:0 */ import React from 'react'; -import ReactDOM from 'react-dom'; -import Upload from 'rc-upload'; +import Upload from '..'; const props = { action: '/upload.do', @@ -18,7 +17,7 @@ const props = { }, beforeUpload(file, fileList) { console.log(file, fileList); - return new Promise((resolve) => { + return new Promise(resolve => { console.log('start check'); setTimeout(() => { console.log('check finshed'); @@ -36,10 +35,12 @@ const Test = () => { }} >
- 开始上传 + + 开始上传 +
); }; -ReactDOM.render(, document.getElementById('__react-content')); +export default Test; diff --git a/examples/customRequest.html b/examples/customRequest.html deleted file mode 100644 index 48cdce8..0000000 --- a/examples/customRequest.html +++ /dev/null @@ -1 +0,0 @@ -placeholder diff --git a/examples/customRequest.js b/examples/customRequest.tsx similarity index 85% rename from examples/customRequest.js rename to examples/customRequest.tsx index 9ef7b2d..611251d 100644 --- a/examples/customRequest.js +++ b/examples/customRequest.tsx @@ -1,8 +1,7 @@ /* eslint no-console:0 */ import React from 'react'; -import ReactDOM from 'react-dom'; -import Upload from 'rc-upload'; import axios from 'axios'; +import Upload from '..'; const uploadProps = { action: '/upload.do', @@ -49,7 +48,7 @@ const uploadProps = { withCredentials, headers, onUploadProgress: ({ total, loaded }) => { - onProgress({ percent: Math.round(loaded / total * 100).toFixed(2) }, file); + onProgress({ percent: Math.round((loaded / total) * 100).toFixed(2) }, file); }, }) .then(({ data: response }) => { @@ -74,11 +73,11 @@ const Test = () => { >
- +
); }; -ReactDOM.render(, document.getElementById('__react-content')); +export default Test; diff --git a/examples/directoryUpload.html b/examples/directoryUpload.html deleted file mode 100644 index b3a4252..0000000 --- a/examples/directoryUpload.html +++ /dev/null @@ -1 +0,0 @@ -placeholder \ No newline at end of file diff --git a/examples/directoryUpload.js b/examples/directoryUpload.js deleted file mode 100644 index ad035fe..0000000 --- a/examples/directoryUpload.js +++ /dev/null @@ -1,49 +0,0 @@ -/* eslint no-console:0 */ - -import React from 'react'; -import ReactDOM from 'react-dom'; -import Upload from 'rc-upload'; - -class Test extends React.Component { - constructor(props) { - super(props); - this.uploaderProps = { - action: '/upload.do', - data: { a: 1, b: 2 }, - headers: { - Authorization: 'xxxxxxx', - }, - directory: true, - beforeUpload(file) { - console.log('beforeUpload', file.name); - }, - onStart: (file) => { - console.log('onStart', file.name); - }, - onSuccess(file) { - console.log('onSuccess', file); - }, - onProgress(step, file) { - console.log('onProgress', Math.round(step.percent), file.name); - }, - onError(err) { - console.log('onError', err); - }, - }; - } - render() { - return (
- -
- 开始上传 -
- -
); - } -} - -ReactDOM.render(, document.getElementById('__react-content')); diff --git a/examples/directoryUpload.tsx b/examples/directoryUpload.tsx new file mode 100644 index 0000000..3615dd3 --- /dev/null +++ b/examples/directoryUpload.tsx @@ -0,0 +1,43 @@ +/* eslint no-console:0 */ + +import React from 'react'; +import Upload from '..'; + +const Test = () => { + const uploaderProps = { + action: '/upload.do', + data: { a: 1, b: 2 }, + directory: true, + beforeUpload(file) { + console.log('beforeUpload', file.name); + }, + onStart: file => { + console.log('onStart', file.name); + }, + onSuccess(file) { + console.log('onSuccess', file); + }, + onProgress(step, file) { + console.log('onProgress', Math.round(step.percent), file.name); + }, + onError(err) { + console.log('onError', err); + }, + }; + + return ( +
+
+ + 开始上传 + +
+
+ ); +}; + +export default Test; diff --git a/examples/drag.html b/examples/drag.html deleted file mode 100644 index 48cdce8..0000000 --- a/examples/drag.html +++ /dev/null @@ -1 +0,0 @@ -placeholder diff --git a/examples/drag.js b/examples/drag.tsx similarity index 67% rename from examples/drag.js rename to examples/drag.tsx index da307e6..cb695fa 100644 --- a/examples/drag.js +++ b/examples/drag.tsx @@ -1,8 +1,6 @@ /* eslint no-console:0 */ - import React from 'react'; -import ReactDOM from 'react-dom'; -import Upload from 'rc-upload'; +import Upload from '..'; const props = { action: '/upload.do', @@ -11,7 +9,7 @@ const props = { beforeUpload(file) { console.log('beforeUpload', file.name); }, - onStart: (file) => { + onStart: file => { console.log('onStart', file.name); }, onSuccess(file) { @@ -27,4 +25,20 @@ const props = { // openFileDialogOnClick: false }; -ReactDOM.render(, document.getElementById('__react-content')); +const Test = () => { + return ( +
+
+ + 开始上传 + +
+
+ ); +}; + +export default Test; diff --git a/examples/simple.html b/examples/simple.html deleted file mode 100644 index 48cdce8..0000000 --- a/examples/simple.html +++ /dev/null @@ -1 +0,0 @@ -placeholder diff --git a/examples/simple.js b/examples/simple.js deleted file mode 100644 index cffc81d..0000000 --- a/examples/simple.js +++ /dev/null @@ -1,98 +0,0 @@ -/* eslint no-console:0 */ - -import React from 'react'; -import ReactDOM from 'react-dom'; -import Upload from 'rc-upload'; - -const style = ` - .rc-upload-disabled { - opacity:0.5; - `; - -class Test extends React.Component { - constructor(props) { - super(props); - this.uploaderProps = { - action: '/upload.do', - data: { a: 1, b: 2 }, - headers: { - Authorization: 'xxxxxxx', - }, - multiple: true, - beforeUpload(file) { - console.log('beforeUpload', file.name); - }, - onStart: (file) => { - console.log('onStart', file.name); - }, - onSuccess(file) { - console.log('onSuccess', file); - }, - onProgress(step, file) { - console.log('onProgress', Math.round(step.percent), file.name); - }, - onError(err) { - console.log('onError', err); - }, - }; - this.state = { - destroyed: false, - }; - } - destroy = () => { - this.setState({ - destroyed: true, - }); - } - render() { - if (this.state.destroyed) { - return null; - } - return (
-

固定位置

- - - - - -

滚动

- -
-
- - 开始上传2 - -
- - -
- - -
); - } -} - -ReactDOM.render(, document.getElementById('__react-content')); diff --git a/examples/simple.tsx b/examples/simple.tsx new file mode 100644 index 0000000..713f830 --- /dev/null +++ b/examples/simple.tsx @@ -0,0 +1,76 @@ +/* eslint no-console:0 */ + +import React from 'react'; +import Upload from '..'; + +const style = ` + .rc-upload-disabled { + opacity:0.5; + `; + +const uploaderProps = { + action: '/upload.do', + data: { a: 1, b: 2 }, + multiple: true, + beforeUpload(file) { + console.log('beforeUpload', file.name); + }, + onStart: file => { + console.log('onStart', file.name); + }, + onSuccess(file) { + console.log('onSuccess', file); + }, + onProgress(step, file) { + console.log('onProgress', Math.round(step.percent), file.name); + }, + onError(err) { + console.log('onError', err); + }, +}; + +const Test = () => { + const [destroyed, setDestroyed] = React.useState(false); + + const destroy = () => { + setDestroyed(true); + }; + + if (destroyed) { + return null; + } + + return ( +
+

固定位置

+ +
+ + 开始上传 + +
+

滚动

+
+
+ + 开始上传2 + +
+ +
+ ); +}; + +export default Test; diff --git a/examples/transformFile.html b/examples/transformFile.html deleted file mode 100644 index 48cdce8..0000000 --- a/examples/transformFile.html +++ /dev/null @@ -1 +0,0 @@ -placeholder diff --git a/examples/transformFile.js b/examples/transformFile.tsx similarity index 84% rename from examples/transformFile.js rename to examples/transformFile.tsx index 31b23ba..b918d5c 100644 --- a/examples/transformFile.js +++ b/examples/transformFile.tsx @@ -1,7 +1,6 @@ /* eslint no-console:0 */ import React from 'react'; -import ReactDOM from 'react-dom'; -import Upload from 'rc-upload'; +import Upload from '..'; const uploadProps = { action: '/upload.do', @@ -23,7 +22,7 @@ const uploadProps = { console.log('onProgress', `${percent}%`, file.name); }, transformFile(file) { - return new Promise((resolve) => { + return new Promise(resolve => { // eslint-disable-next-line no-undef const reader = new FileReader(); reader.readAsDataURL(file); @@ -50,11 +49,11 @@ const Test = () => { >
- +
); }; -ReactDOM.render(, document.getElementById('__react-content')); +export default Test; diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..0a09639 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,4 @@ +module.exports = { + setupFiles: ["./tests/setup.js"], + snapshotSerializers: [require.resolve("enzyme-to-json/serializer")], +}; diff --git a/now.json b/now.json new file mode 100644 index 0000000..fd2f2ad --- /dev/null +++ b/now.json @@ -0,0 +1,11 @@ +{ + "version": 2, + "name": "rc-upload", + "builds": [ + { + "src": "package.json", + "use": "@now/static-build", + "config": { "distDir": ".doc" } + } + ] +} diff --git a/package.json b/package.json index 1383f26..da896af 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-upload", - "version": "3.2.0", + "version": "3.2.1", "description": "upload ui component for react", "keywords": [ "react", @@ -23,40 +23,46 @@ ], "main": "./lib/index", "module": "./es/index", - "config": { - "port": 8020 - }, "scripts": { - "build": "rc-tools run build", - "gh-pages": "rc-tools run gh-pages", - "start": "node server", - "compile": "rc-tools run compile", - "pub": "rc-tools run pub", - "lint": "rc-tools run lint", - "test": "jest --setupTestFrameworkScriptFile=raf/polyfill", - "coverage": "jest --coverage && cat ./coverage/lcov.info | coveralls" + "start": "cross-env NODE_ENV=development father doc dev --storybook", + "build": "father doc build --storybook", + "compile": "father build", + "gh-pages": "npm run build && father doc deploy", + "prepublishOnly": "npm run compile && np --yolo --no-publish", + "postpublish": "npm run gh-pages", + "lint": "eslint src/ --ext .ts,.tsx,.jsx,.js,.md", + "prettier": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", + "test": "father test", + "coverage": "father test --coverage" }, "devDependencies": { + "@types/jest": "^26.0.0", + "@types/react": "^16.9.2", + "@types/react-dom": "^16.9.0", + "@umijs/fabric": "^2.0.0", "axios": "^0.20.0", "co-busboy": "^1.3.0", "coveralls": "^3.0.3", - "expect.js": "0.3.x", + "cross-env": "^7.0.0", + "enzyme": "^3.1.1", + "enzyme-adapter-react-16": "^1.0.1", + "enzyme-to-json": "^3.1.2", + "eslint": "^7.1.0", + "father": "^2.22.0", "fs-extra": "^9.0.0", "gh-pages": "^3.0.0", - "jest": "^20.0.1", - "pre-commit": "1.x", "raf": "^3.4.0", "rc-tools": "8.x", "react": "^16.0.0", "react-dom": "^16.0.0", "sinon": "^9.0.2", + "typescript": "^3.7.2", "vinyl-fs": "^3.0.3" }, - "pre-commit": [ - "lint" - ], "dependencies": { - "classnames": "^2.2.5" + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.2.0" }, "jest": { "collectCoverageFrom": [ diff --git a/src/AjaxUploader.jsx b/src/AjaxUploader.tsx similarity index 52% rename from src/AjaxUploader.jsx rename to src/AjaxUploader.tsx index 2a7612b..c19fade 100644 --- a/src/AjaxUploader.jsx +++ b/src/AjaxUploader.tsx @@ -1,57 +1,52 @@ /* eslint react/no-is-mounted:0,react/sort-comp:0,react/prop-types:0 */ -import React, { Component } from 'react'; +import React, { Component, ReactElement } from 'react'; import classNames from 'classnames'; +import pickAttrs from 'rc-util/lib/pickAttrs'; import defaultRequest from './request'; import getUid from './uid'; import attrAccept from './attr-accept'; import traverseFileTree from './traverseFileTree'; +import { UploadProps, UploadProgressEvent, UploadRequestError, RcFile } from './interface'; -const dataOrAriaAttributeProps = (props) => { - return Object.keys(props).reduce( - (acc, key) => { - if (key.substr(0, 5) === 'data-' || key.substr(0, 5) === 'aria-' || key === 'role') { - acc[key] = props[key]; - } - return acc; - }, - {}, - ); -}; +class AjaxUploader extends Component { + state = { uid: getUid() }; + + reqs: any = {}; -class AjaxUploader extends Component { - state = { uid: getUid() } + private fileInput: HTMLInputElement; - reqs = {} + private _isMounted: boolean; - onChange = e => { - const files = e.target.files; + onChange = (e: React.ChangeEvent) => { + const { files } = e.target; this.uploadFiles(files); this.reset(); - } + }; - onClick = (e) => { + onClick = (e: React.MouseEvent | React.KeyboardEvent) => { const el = this.fileInput; if (!el) { return; } const { children, onClick } = this.props; - if (children && children.type === 'button') { - el.parentNode.focus(); - el.parentNode.querySelector('button').blur(); + if (children && (children as ReactElement).type === 'button') { + const parent = el.parentNode as HTMLInputElement; + parent.focus(); + parent.querySelector('button').blur(); } el.click(); if (onClick) { onClick(e); } - } + }; - onKeyDown = e => { + onKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { - this.onClick(); + this.onClick(e); } - } + }; - onFileDrop = e => { + onFileDrop = (e: React.DragEvent) => { const { multiple } = this.props; e.preventDefault(); @@ -62,15 +57,14 @@ class AjaxUploader extends Component { if (this.props.directory) { traverseFileTree( - Array.prototype.slice - .call(e.dataTransfer.items), + Array.prototype.slice.call(e.dataTransfer.items), this.uploadFiles, - _file => attrAccept(_file, this.props.accept) + (_file: RcFile) => attrAccept(_file, this.props.accept), ); } else { let files = Array.prototype.slice .call(e.dataTransfer.files) - .filter(file => attrAccept(file, this.props.accept)); + .filter((file: RcFile) => attrAccept(file, this.props.accept)); if (multiple === false) { files = files.slice(0, 1); @@ -78,7 +72,7 @@ class AjaxUploader extends Component { this.uploadFiles(files); } - } + }; componentDidMount() { this._isMounted = true; @@ -89,10 +83,11 @@ class AjaxUploader extends Component { this.abort(); } - uploadFiles = (files) => { - const postFiles = Array.prototype.slice.call(files); + uploadFiles = (files: FileList) => { + const postFiles: Array = Array.prototype.slice.call(files); postFiles - .map(file => { + .map((file: RcFile & { uid?: string }) => { + // eslint-disable-next-line no-param-reassign file.uid = getUid(); return file; }) @@ -101,7 +96,7 @@ class AjaxUploader extends Component { }); }; - upload(file, fileList) { + upload(file: RcFile, fileList: Array) { const { props } = this; if (!props.beforeUpload) { // always async in case use react state to keep fileList @@ -109,33 +104,31 @@ class AjaxUploader extends Component { } const before = props.beforeUpload(file, fileList); - if (before && before.then) { - before.then((processedFile) => { - const processedFileType = Object.prototype.toString.call(processedFile); - if (processedFileType === '[object File]' || processedFileType === '[object Blob]') { - return this.post(processedFile); - } - return this.post(file); - }).catch(e => { - // eslint-disable-next-line no-console - console.log(e); - }); + if (before && typeof before !== 'boolean' && before.then) { + before + .then(processedFile => { + const processedFileType = Object.prototype.toString.call(processedFile); + if (processedFileType === '[object File]' || processedFileType === '[object Blob]') { + return this.post(processedFile); + } + return this.post(file); + }) + .catch(e => { + // eslint-disable-next-line no-console + console.log(e); + }); } else if (before !== false) { setTimeout(() => this.post(file), 0); } return undefined; } - post(file) { + post(file: RcFile) { if (!this._isMounted) { return; } const { props } = this; - const { - onStart, - onProgress, - transformFile = (originFile) => originFile, - } = props; + const { onStart, onProgress, transformFile = originFile => originFile } = props; new Promise(resolve => { let { action } = props; @@ -143,21 +136,22 @@ class AjaxUploader extends Component { action = action(file); } return resolve(action); - }).then(action => { + }).then((action: string) => { const { uid } = file; const request = props.customRequest || defaultRequest; const transform = Promise.resolve(transformFile(file)) - .then((transformedFile) => { + .then(transformedFile => { let { data } = props; if (typeof data === 'function') { data = data(transformedFile); } return Promise.all([transformedFile, data]); - }).catch(e => { + }) + .catch(e => { console.error(e); // eslint-disable-line no-console }); - transform.then(([transformedFile, data]) => { + transform.then(([transformedFile, data]: [RcFile, object]) => { const requestOption = { action, filename: props.name, @@ -166,20 +160,23 @@ class AjaxUploader extends Component { headers: props.headers, withCredentials: props.withCredentials, method: props.method || 'post', - onProgress: onProgress ? e => { - onProgress(e, file); - } : null, - onSuccess: (ret, xhr) => { + onProgress: onProgress + ? (e: UploadProgressEvent) => { + onProgress(e, file); + } + : null, + onSuccess: (ret: any, xhr: XMLHttpRequest) => { delete this.reqs[uid]; props.onSuccess(ret, file, xhr); }, - onError: (err, ret) => { + onError: (err: UploadRequestError, ret: any) => { delete this.reqs[uid]; props.onError(err, ret, file); }, }; - this.reqs[uid] = request(requestOption); + onStart(file); + this.reqs[uid] = request(requestOption); }); }); } @@ -190,19 +187,16 @@ class AjaxUploader extends Component { }); } - abort(file) { + abort(file?: any) { const { reqs } = this; if (file) { - let uid = file; - if (file && file.uid) { - uid = file.uid; - } + const uid = file.uid ? file.uid : file; if (reqs[uid] && reqs[uid].abort) { reqs[uid].abort(); } delete reqs[uid]; } else { - Object.keys(reqs).forEach((uid) => { + Object.keys(reqs).forEach(uid => { if (reqs[uid] && reqs[uid].abort) { reqs[uid].abort(); } @@ -211,15 +205,25 @@ class AjaxUploader extends Component { } } - saveFileInput = (node) => { + saveFileInput = (node: HTMLInputElement) => { this.fileInput = node; - } + }; render() { const { - component: Tag, prefixCls, className, disabled, id, - style, multiple, accept, children, directory, openFileDialogOnClick, - onMouseEnter, onMouseLeave, + component: Tag, + prefixCls, + className, + disabled, + id, + style, + multiple, + accept, + children, + directory, + openFileDialogOnClick, + onMouseEnter, + onMouseLeave, ...otherProps } = this.props; const cls = classNames({ @@ -227,24 +231,25 @@ class AjaxUploader extends Component { [`${prefixCls}-disabled`]: disabled, [className]: className, }); - const events = disabled ? {} : { - onClick: openFileDialogOnClick ? this.onClick : () => {}, - onKeyDown: openFileDialogOnClick ? this.onKeyDown : () => {}, - onMouseEnter, - onMouseLeave, - onDrop: this.onFileDrop, - onDragOver: this.onFileDrop, - tabIndex: '0', - }; + // because input don't have directory/webkitdirectory type declaration + const dirProps: any = directory + ? { directory: 'directory', webkitdirectory: 'webkitdirectory' } + : {}; + const events = disabled + ? {} + : { + onClick: openFileDialogOnClick ? this.onClick : () => {}, + onKeyDown: openFileDialogOnClick ? this.onKeyDown : () => {}, + onMouseEnter, + onMouseLeave, + onDrop: this.onFileDrop, + onDragOver: this.onFileDrop, + tabIndex: '0', + }; return ( - + diff --git a/src/Upload.jsx b/src/Upload.tsx similarity index 73% rename from src/Upload.jsx rename to src/Upload.tsx index 303a774..10df10a 100644 --- a/src/Upload.jsx +++ b/src/Upload.tsx @@ -1,11 +1,11 @@ /* eslint react/prop-types:0 */ import React, { Component } from 'react'; import AjaxUpload from './AjaxUploader'; +import { UploadProps, RcFile } from './interface'; -function empty() { -} +function empty() {} -class Upload extends Component { +class Upload extends Component { static defaultProps = { component: 'span', prefixCls: 'rc-upload', @@ -21,15 +21,17 @@ class Upload extends Component { customRequest: null, withCredentials: false, openFileDialogOnClick: true, - } + }; - abort(file) { + private uploader: AjaxUpload; + + abort(file: RcFile) { this.uploader.abort(file); } - saveUploader = (node) => { + saveUploader = (node: AjaxUpload) => { this.uploader = node; - } + }; render() { return ; diff --git a/src/attr-accept.js b/src/attr-accept.ts similarity index 78% rename from src/attr-accept.js rename to src/attr-accept.ts index 4099a0f..f6ae14e 100644 --- a/src/attr-accept.js +++ b/src/attr-accept.ts @@ -1,8 +1,10 @@ -function endsWith(str, suffix) { +import { RcFile } from './interface'; + +function endsWith(str: string, suffix: string) { return str.indexOf(suffix, str.length - suffix.length) !== -1; } -export default (file, acceptedFiles) => { +export default (file: RcFile, acceptedFiles: string | Array) => { if (file && acceptedFiles) { const acceptedFilesArray = Array.isArray(acceptedFiles) ? acceptedFiles @@ -15,7 +17,8 @@ export default (file, acceptedFiles) => { const validType = type.trim(); if (validType.charAt(0) === '.') { return endsWith(fileName.toLowerCase(), validType.toLowerCase()); - } else if (/\/\*$/.test(validType)) { + } + if (/\/\*$/.test(validType)) { // This is something like a image/* mime type return baseMimeType === validType.replace(/\/.*$/, ''); } diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 1eeceb0..0000000 --- a/src/index.js +++ /dev/null @@ -1,4 +0,0 @@ -// export this package's api -import Upload from './Upload'; - -export default Upload; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..1cbbd3e --- /dev/null +++ b/src/index.ts @@ -0,0 +1,6 @@ +import Upload from './Upload'; +import { UploadProps } from './interface'; + +export { UploadProps }; + +export default Upload; diff --git a/src/interface.tsx b/src/interface.tsx new file mode 100644 index 0000000..a919680 --- /dev/null +++ b/src/interface.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; + +export interface UploadProps + extends Omit, 'onError' | 'onProgress'> { + name?: string; + style?: React.CSSProperties; + className?: string; + disabled?: boolean; + component?: React.JSXElementConstructor; + action?: string | ((file: RcFile) => string); + method?: UploadRequestMethod; + directory?: boolean; + data?: object | ((file: RcFile | string | Blob) => object); + headers?: UploadRequestHeader; + accept?: string; + multiple?: boolean; + onStart?: (file: RcFile) => void; + onError?: (error: Error, ret: object, file: RcFile) => void; + onSuccess?: (response: object, file: RcFile, xhr: object) => void; + onProgress?: (event: UploadProgressEvent, file: RcFile) => void; + beforeUpload?: (file: RcFile, FileList: RcFile[]) => boolean | Promise; + customRequest?: () => void; + withCredentials?: boolean; + openFileDialogOnClick?: boolean; + transformFile?: (file: RcFile) => string | Blob | RcFile | PromiseLike; + prefixCls?: string; + id?: string; + onMouseEnter?: (e: React.MouseEvent) => void; + onMouseLeave?: (e: React.MouseEvent) => void; + onClick?: (e: React.MouseEvent | React.KeyboardEvent) => void; +} + +export interface UploadProgressEvent extends ProgressEvent { + percent: number; +} + +export type UploadRequestMethod = 'POST' | 'PUT' | 'PATCH' | 'post' | 'put' | 'patch'; + +export interface UploadRequestHeader { + [key: string]: string; +} + +export interface UploadRequestError extends Error { + status?: number; + method?: UploadRequestMethod; + url?: string; +} + +export interface UploadRequestOption { + onProgress?: (event: UploadProgressEvent) => void; + onError?: (event: UploadRequestError | ProgressEvent, body?: T) => void; + onSuccess?: (body: T, xhr: XMLHttpRequest) => void; + data?: object; + filename?: string; + file: RcFile; + withCredentials?: boolean; + action: string; + headers?: UploadRequestHeader; + method: UploadRequestMethod; +} + +export interface RcFile extends File { + uid: string; +} diff --git a/src/request.js b/src/request.ts similarity index 81% rename from src/request.js rename to src/request.ts index 38b4c89..d41b38e 100644 --- a/src/request.js +++ b/src/request.ts @@ -1,13 +1,15 @@ -function getError(option, xhr) { +import { UploadRequestOption, UploadRequestError, UploadProgressEvent } from './interface'; + +function getError(option: UploadRequestOption, xhr: XMLHttpRequest) { const msg = `cannot ${option.method} ${option.action} ${xhr.status}'`; - const err = new Error(msg); + const err = new Error(msg) as UploadRequestError; err.status = xhr.status; err.method = option.method; err.url = option.action; return err; } -function getBody(xhr) { +function getBody(xhr: XMLHttpRequest) { const text = xhr.responseText || xhr.response; if (!text) { return text; @@ -20,25 +22,14 @@ function getBody(xhr) { } } -// option { -// onProgress: (event: { percent: number }): void, -// onError: (event: Error, body?: Object): void, -// onSuccess: (body: Object): void, -// data: Object, -// filename: String, -// file: File, -// withCredentials: Boolean, -// action: String, -// headers: Object, -// } -export default function upload(option) { +export default function upload(option: UploadRequestOption) { // eslint-disable-next-line no-undef const xhr = new XMLHttpRequest(); if (option.onProgress && xhr.upload) { - xhr.upload.onprogress = function progress(e) { + xhr.upload.onprogress = function progress(e: UploadProgressEvent) { if (e.total > 0) { - e.percent = e.loaded / e.total * 100; + e.percent = (e.loaded / e.total) * 100; } option.onProgress(e); }; @@ -103,7 +94,7 @@ export default function upload(option) { Object.keys(headers).forEach(h => { if (headers[h] !== null) { xhr.setRequestHeader(h, headers[h]); - } + } }); xhr.send(formData); diff --git a/src/traverseFileTree.js b/src/traverseFileTree.ts similarity index 56% rename from src/traverseFileTree.js rename to src/traverseFileTree.ts index 3747a92..9de92cc 100644 --- a/src/traverseFileTree.js +++ b/src/traverseFileTree.ts @@ -1,9 +1,21 @@ -function loopFiles(item, callback) { +import { RcFile } from './interface'; + +interface InternalDataTransferItem extends DataTransferItem { + isFile: boolean; + file: (cd: (file: RcFile & { webkitRelativePath?: string }) => void) => void; + createReader: () => any; + fullPath: string; + isDirectory: boolean; + name: string; + path: string; +} + +function loopFiles(item: InternalDataTransferItem, callback) { const dirReader = item.createReader(); let fileList = []; function sequence() { - dirReader.readEntries((entries) => { + dirReader.readEntries((entries: Array) => { const entryList = Array.prototype.slice.apply(entries); fileList = fileList.concat(entryList); @@ -21,11 +33,13 @@ function loopFiles(item, callback) { sequence(); } -const traverseFileTree = (files, callback, isAccepted) => { - const _traverseFileTree = (item, path) => { - path = path || ''; +const traverseFileTree = (files: Array, callback, isAccepted) => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const _traverseFileTree = (item: InternalDataTransferItem, path?: string) => { + // eslint-disable-next-line no-param-reassign + item.path = path || ''; if (item.isFile) { - item.file((file) => { + item.file(file => { if (isAccepted(file)) { // https://github.com/ant-design/ant-design/issues/16426 if (item.fullPath && !file.webkitRelativePath) { @@ -34,6 +48,7 @@ const traverseFileTree = (files, callback, isAccepted) => { writable: true, }, }); + // eslint-disable-next-line no-param-reassign file.webkitRelativePath = item.fullPath.replace(/^\//, ''); Object.defineProperties(file, { webkitRelativePath: { @@ -45,8 +60,8 @@ const traverseFileTree = (files, callback, isAccepted) => { } }); } else if (item.isDirectory) { - loopFiles(item, (entries) => { - entries.forEach((entryItem) => { + loopFiles(item, (entries: Array) => { + entries.forEach(entryItem => { _traverseFileTree(entryItem, `${path}${item.name}/`); }); }); diff --git a/src/uid.js b/src/uid.ts similarity index 57% rename from src/uid.js rename to src/uid.ts index 789a6f6..3ad3f8e 100644 --- a/src/uid.js +++ b/src/uid.ts @@ -1,6 +1,7 @@ -const now = +(new Date()); +const now = +new Date(); let index = 0; export default function uid() { + // eslint-disable-next-line no-plusplus return `rc-upload-${now}-${++index}`; } diff --git a/tests/index.js b/tests/index.js deleted file mode 100644 index ed92f29..0000000 --- a/tests/index.js +++ /dev/null @@ -1,2 +0,0 @@ -import './uploader.spec'; -import './request.spec'; diff --git a/tests/request.spec.js b/tests/request.spec.js index 3997b2c..f5fae71 100644 --- a/tests/request.spec.js +++ b/tests/request.spec.js @@ -1,13 +1,11 @@ /* eslint no-console:0 */ -import expect from 'expect.js'; import sinon from 'sinon'; import request from '../src/request'; let xhr; let requests; -const empty = () => { -}; +const empty = () => {}; const option = { onSuccess: empty, action: 'upload.do', @@ -37,8 +35,8 @@ describe('request', () => { it('upload request success', done => { option.onError = done; option.onSuccess = ret => { - expect(ret).to.eql({ success: true }); - expect(requests[0].requestBody.getAll('c[]')).to.eql([3, 4]); + expect(ret).toEqual({ success: true }); + expect(requests[0].requestBody.getAll('c[]')).toEqual(['3', '4']); done(); }; request(option); @@ -47,7 +45,7 @@ describe('request', () => { it('40x code should be error', done => { option.onError = e => { - expect(e.toString()).to.contain('404'); + expect(e.toString()).toContain('404'); done(); }; @@ -59,7 +57,7 @@ describe('request', () => { it('2xx code should be success', done => { option.onError = done; option.onSuccess = ret => { - expect(ret).to.equal(''); + expect(ret).toEqual(''); done(); }; request(option); @@ -68,7 +66,7 @@ describe('request', () => { it('get headers', () => { request(option); - expect(requests[0].requestHeaders).to.eql({ + expect(requests[0].requestHeaders).toEqual({ 'X-Requested-With': 'XMLHttpRequest', from: 'hello', }); @@ -77,6 +75,6 @@ describe('request', () => { it('can empty X-Requested-With', () => { option.headers['X-Requested-With'] = null; request(option); - expect(requests[0].requestHeaders).to.eql({ from: 'hello' }); + expect(requests[0].requestHeaders).toEqual({ from: 'hello' }); }); }); diff --git a/tests/setup.js b/tests/setup.js new file mode 100644 index 0000000..4c18e28 --- /dev/null +++ b/tests/setup.js @@ -0,0 +1,6 @@ +global.requestAnimationFrame = cb => setTimeout(cb, 0); + +const Enzyme = require('enzyme'); +const Adapter = require('enzyme-adapter-react-16'); + +Enzyme.configure({ adapter: new Adapter() }); diff --git a/tests/uploader.spec.js b/tests/uploader.spec.js index b509544..2df046d 100644 --- a/tests/uploader.spec.js +++ b/tests/uploader.spec.js @@ -1,14 +1,10 @@ /* eslint no-console:0 */ -import expect from 'expect.js'; import React from 'react'; -import ReactDOM from 'react-dom'; -import TestUtils from 'react-dom/test-utils'; import { format } from 'util'; +import { mount } from 'enzyme'; import sinon from 'sinon'; import Uploader from '../index'; -const { Simulate } = TestUtils; - function Item(name) { this.name = name; this.toString = () => this.name; @@ -73,7 +69,6 @@ describe('uploader', () => { return; } - let node; let uploader; const handlers = {}; @@ -105,83 +100,48 @@ describe('uploader', () => { }, }; - beforeEach(done => { - node = document.createElement('div'); - document.body.appendChild(node); - - ReactDOM.render(, node, function init() { - uploader = this; - done(); - }); + beforeEach(() => { + uploader = mount(); }); afterEach(() => { - ReactDOM.unmountComponentAtNode(node); + uploader.unmount(); }); - it('with id', done => { - ReactDOM.render(, node, function init() { - expect(TestUtils.findRenderedDOMComponentWithTag(this, 'input').id).to.be('bamboo'); - done(); - }); + it('with id', () => { + const wrapper = mount(); + expect(wrapper.find('input').props().id).toBe('bamboo'); }); - it('should pass through data attributes', done => { - ReactDOM.render( - ( - - ), - node, - function init() { - expect(TestUtils.findRenderedDOMComponentWithTag(this, 'input') - .getAttribute('data-testid')) - .to.be('data-testid'); - expect(TestUtils.findRenderedDOMComponentWithTag(this, 'input') - .getAttribute('data-my-custom-attr')) - .to.be('custom data attribute'); - done(); - } + it('should pass through data & aria attributes', () => { + const wrapper = mount( + , ); + expect(wrapper.find('input').props()['data-testid']).toBe('data-testid'); + expect(wrapper.find('input').props()['data-my-custom-attr']).toBe('custom data attribute'); + expect(wrapper.find('input').props()['aria-label']).toBe('Upload a file'); }); - it('should pass through aria attributes', done => { - ReactDOM.render(, node, function init() { - expect(TestUtils.findRenderedDOMComponentWithTag(this, 'input') - .getAttribute('aria-label')) - .to.be('Upload a file'); - done(); - }); + it('should pass through role attributes', () => { + const wrapper = mount(); + expect(wrapper.find('input').props().role).toBe('button'); }); - it('should pass through role attributes', done => { - ReactDOM.render(, node, function init() { - expect(TestUtils.findRenderedDOMComponentWithTag(this, 'input') - .getAttribute('role')) - .to.be('button'); - done(); - }); - }); - - it('should not pass through unknown props', done => { - ReactDOM.render(, - node, - () => { - // Fails when React reports unrecognized prop is added to DOM in console.error - done(); - } - ); + it('should not pass through unknown props', () => { + const wrapper = mount(); + expect(wrapper.find('input').props().customProp).toBeUndefined(); }); it('create works', () => { - expect(TestUtils.scryRenderedDOMComponentsWithTag(uploader, 'span').length).to.be(1); + expect(uploader.find('span').length).toBeTruthy(); }); it('upload success', done => { - const input = TestUtils.findRenderedDOMComponentWithTag(uploader, 'input'); - + const input = uploader.find('input').first(); const files = [ { name: 'success.png', @@ -193,24 +153,22 @@ describe('uploader', () => { files.item = i => files[i]; handlers.onSuccess = (ret, file) => { - expect(ret[1]).to.eql(file.name); - expect(file).to.have.property('uid'); + expect(ret[1]).toEqual(file.name); + expect(file).toHaveProperty('uid'); done(); }; handlers.onError = err => { done(err); }; - - Simulate.change(input, { target: { files } }); - + input.simulate('change', { target: { files } }); setTimeout(() => { requests[0].respond(200, {}, `["","${files[0].name}"]`); }, 100); }); it('upload error', done => { - const input = TestUtils.findRenderedDOMComponentWithTag(uploader, 'input'); + const input = uploader.find('input').first(); const files = [ { @@ -223,20 +181,20 @@ describe('uploader', () => { files.item = i => files[i]; handlers.onError = (err, ret) => { - expect(err instanceof Error).to.equal(true); - expect(err.status).to.equal(400); - expect(ret).to.equal('error 400'); + expect(err instanceof Error).toEqual(true); + expect(err.status).toEqual(400); + expect(ret).toEqual('error 400'); done(); }; - Simulate.change(input, { target: { files } }); + input.simulate('change', { target: { files } }); setTimeout(() => { requests[0].respond(400, {}, `error 400`); }, 100); }); it('drag to upload', done => { - const input = TestUtils.findRenderedDOMComponentWithTag(uploader, 'input'); + const input = uploader.find('input').first(); const files = [ { @@ -249,8 +207,8 @@ describe('uploader', () => { files.item = i => files[i]; handlers.onSuccess = (ret, file) => { - expect(ret[1]).to.eql(file.name); - expect(file).to.have.property('uid'); + expect(ret[1]).toEqual(file.name); + expect(file).toHaveProperty('uid'); done(); }; @@ -258,7 +216,7 @@ describe('uploader', () => { done(err); }; - Simulate.drop(input, { dataTransfer: { files } }); + input.simulate('drop', { dataTransfer: { files } }); setTimeout(() => { requests[0].respond(200, {}, `["","${files[0].name}"]`); @@ -266,7 +224,7 @@ describe('uploader', () => { }); it('drag unaccepted type files to upload will not trigger onStart', done => { - const input = TestUtils.findRenderedDOMComponentWithTag(uploader, 'input'); + const input = uploader.find('input').first(); const files = [ { name: 'success.jpg', @@ -276,66 +234,57 @@ describe('uploader', () => { }, ]; files.item = i => files[i]; - Simulate.drop(input, { dataTransfer: { files } }); + input.simulate('drop', { dataTransfer: { files } }); const mockStart = jest.fn(); handlers.onStart = mockStart; setTimeout(() => { - expect(mockStart.mock.calls.length).to.be(0); + expect(mockStart.mock.calls.length).toBe(0); done(); }, 100); }); it('drag files with multiple false', done => { - ReactDOM.unmountComponentAtNode(node); - - // Create new one - node = document.createElement('div'); - document.body.appendChild(node); - - ReactDOM.render(, node, function init() { - uploader = this; - - const input = TestUtils.findRenderedDOMComponentWithTag(uploader, 'input'); + const wrapper = mount(); + const input = wrapper.find('input').first(); - const files = [ - { - name: 'success.png', - toString() { - return this.name; - }, + const files = [ + { + name: 'success.png', + toString() { + return this.name; }, - { - name: 'filtered.png', - toString() { - return this.name; - }, + }, + { + name: 'filtered.png', + toString() { + return this.name; }, - ]; - files.item = i => files[i]; - - // Only can trigger once - let triggerTimes = 0; - handlers.onStart = () => { - triggerTimes += 1; - }; - - handlers.onSuccess = (ret, file) => { - expect(ret[1]).to.eql(file.name); - expect(file).to.have.property('uid'); - expect(triggerTimes).to.eql(1); - done(); - }; + }, + ]; + files.item = i => files[i]; + + // Only can trigger once + let triggerTimes = 0; + handlers.onStart = () => { + triggerTimes += 1; + }; + + handlers.onSuccess = (ret, file) => { + expect(ret[1]).toEqual(file.name); + expect(file).toHaveProperty('uid'); + expect(triggerTimes).toEqual(1); + done(); + }; - handlers.onError = err => { - done(err); - }; + handlers.onError = err => { + done(err); + }; - Simulate.drop(input, { dataTransfer: { files } }); + input.simulate('drop', { dataTransfer: { files } }); - setTimeout(() => { - requests[0].respond(200, {}, `["","${files[0].name}"]`); - }, 100); - }); + setTimeout(() => { + requests[0].respond(200, {}, `["","${files[0].name}"]`); + }, 100); }); it('support action and data is function returns Promise', done => { @@ -353,30 +302,29 @@ describe('uploader', () => { }, 1000); }); }; - ReactDOM.render(, node, function init() { - uploader = this; - const input = TestUtils.findRenderedDOMComponentWithTag(uploader, 'input'); - const files = [ - { - name: 'success.png', - toString() { - return this.name; - }, + const wrapper = mount(); + const input = wrapper.find('input').first(); + + const files = [ + { + name: 'success.png', + toString() { + return this.name; }, - ]; - files.item = i => files[i]; - Simulate.change(input, { target: { files } }); + }, + ]; + files.item = i => files[i]; + input.simulate('change', { target: { files } }); + setTimeout(() => { + expect(requests.length).toBe(0); setTimeout(() => { - expect(requests.length).to.be(0); - setTimeout(() => { - console.log(requests); - expect(requests.length).to.be(1); - expect(requests[0].url).to.be('/upload.do'); - expect(requests[0].requestBody.get('field1')).to.be('a'); - done(); - }, 2000); - }, 100); - }); + console.log(requests); + expect(requests.length).toBe(1); + expect(requests[0].url).toBe('/upload.do'); + expect(requests[0].requestBody.get('field1')).toBe('a'); + done(); + }, 2000); + }, 100); }); }); @@ -385,7 +333,6 @@ describe('uploader', () => { return; } - let node; let uploader; const handlers = {}; @@ -417,18 +364,12 @@ describe('uploader', () => { }, }; - beforeEach(done => { - node = document.createElement('div'); - document.body.appendChild(node); - - ReactDOM.render(, node, function init() { - uploader = this; - done(); - }); + beforeEach(() => { + uploader = mount(); }); it('unaccepted type files to upload will not trigger onStart', done => { - const input = TestUtils.findRenderedDOMComponentWithTag(uploader, 'input'); + const input = uploader.find('input').first(); const files = { name: 'foo', children: [ @@ -442,78 +383,70 @@ describe('uploader', () => { }, ], }; - Simulate.drop(input, { dataTransfer: { items: [makeDataTransferItem(files)] } }); + input.simulate('drop', { dataTransfer: { items: [makeDataTransferItem(files)] } }); const mockStart = jest.fn(); handlers.onStart = mockStart; setTimeout(() => { - expect(mockStart.mock.calls.length).to.be(0); + expect(mockStart.mock.calls.length).toBe(0); done(); }, 100); }); }); describe('transform file before request', () => { - let node; let uploader; - beforeEach(done => { - node = document.createElement('div'); - document.body.appendChild(node); - - ReactDOM.render(, node, function init() { - uploader = this; - done(); - }); + beforeEach(() => { + uploader = mount(); }); afterEach(() => { - ReactDOM.unmountComponentAtNode(node); + uploader.unmount(); }); it('transform file function should be called before data function', done => { const props = { action: '/test', - data (file) { - return new Promise((resolve) => { + data(file) { + return new Promise(resolve => { setTimeout(() => { resolve({ - url: file.url - }) - }, 500) - }) + url: file.url, + }); + }, 500); + }); }, - transformFile (file) { - return new Promise((resolve) => { + transformFile(file) { + return new Promise(resolve => { setTimeout(() => { + // eslint-disable-next-line no-param-reassign file.url = 'this is file url'; resolve(file); }, 500); }); }, }; - ReactDOM.render(, node, function init() { - uploader = this; - const input = TestUtils.findRenderedDOMComponentWithTag(uploader, 'input'); + const wrapper = mount(); + const input = wrapper.find('input').first(); - const files = [ - { - name: 'success.png', - toString() { - return this.name; - }, + const files = [ + { + name: 'success.png', + toString() { + return this.name; }, - ]; + }, + ]; - files.item = i => files[i]; + files.item = i => files[i]; - Simulate.change(input, { target: { files } }); + input.simulate('change', { target: { files } }); + setTimeout(() => { setTimeout(() => { - setTimeout(() => { - expect(requests[0].requestBody.get('url')).to.be('this is file url'); - done(); - }, 1000); - }, 100); - }); + expect(requests[0].requestBody.get('url')).toBe('this is file url'); + done(); + }, 1000); + }, 100); }); it('noes not affect receive origin file when transform file is null', done => { @@ -529,33 +462,31 @@ describe('uploader', () => { return null; }, }; - ReactDOM.render(, node, function init() { - uploader = this; - const input = TestUtils.findRenderedDOMComponentWithTag(uploader, 'input'); + const wrapper = mount(); + const input = wrapper.find('input').first(); - const files = [ - { - name: 'success.png', - toString() { - return this.name; - }, + const files = [ + { + name: 'success.png', + toString() { + return this.name; }, - ]; + }, + ]; - files.item = i => files[i]; + files.item = i => files[i]; - handlers.onSuccess = (ret, file) => { - expect(ret[1]).to.eql(file.name); - expect(file).to.have.property('uid'); - done(); - }; + handlers.onSuccess = (ret, file) => { + expect(ret[1]).toEqual(file.name); + expect(file).toHaveProperty('uid'); + done(); + }; - Simulate.change(input, { target: { files } }); + input.simulate('change', { target: { files } }); - setTimeout(() => { - requests[0].respond(200, {}, `["","${files[0].name}"]`); - }, 100); - }); + setTimeout(() => { + requests[0].respond(200, {}, `["","${files[0].name}"]`); + }, 100); }); }); });