diff --git a/.gitignore b/.gitignore index 54a517ba..6c9b17e8 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ coverage yarn.lock es package-lock.json +pnpm-lock.yaml tmp/ .history .storybook diff --git a/docs/examples/beforeUpload.tsx b/docs/examples/beforeUpload.tsx index cefd7752..c4527c53 100644 --- a/docs/examples/beforeUpload.tsx +++ b/docs/examples/beforeUpload.tsx @@ -1,6 +1,6 @@ /* eslint no-console:0 */ -import { Action } from '@/interface'; +import type { Action } from '@/interface'; import Upload from 'rc-upload'; const props = { diff --git a/docs/examples/customRequest.tsx b/docs/examples/customRequest.tsx index afe58fd5..8fb7cfd4 100644 --- a/docs/examples/customRequest.tsx +++ b/docs/examples/customRequest.tsx @@ -2,7 +2,7 @@ import React from 'react'; import axios from 'axios'; import Upload from 'rc-upload'; -import { UploadRequestOption } from '@/interface'; +import type { UploadRequestOption } from '@/interface'; const uploadProps = { action: '/upload.do', diff --git a/package.json b/package.json index 5243d86c..e488acf9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "rc-upload", - "version": "4.8.1", + "name": "@rc-component/upload", + "version": "1.0.0", "description": "upload ui component for react", "keywords": [ "react", @@ -38,16 +38,18 @@ }, "dependencies": { "@babel/runtime": "^7.18.3", - "classnames": "^2.2.5", - "rc-util": "^5.2.0" + "@rc-component/util": "^1.2.0", + "classnames": "^2.2.5" }, "devDependencies": { "@rc-component/father-plugin": "^1.0.0", "@testing-library/jest-dom": "^6.1.5", "@testing-library/react": "^16.2.0", "@types/jest": "^29.5.11", - "@types/react": "^18.0.0", - "@types/react-dom": "^18.0.0", + "@types/node": "^22.12.0", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@types/sinon": "^17.0.3", "@umijs/fabric": "^4.0.1", "axios": "^1.7.2", "co-busboy": "^1.3.0", @@ -61,15 +63,15 @@ "np": "^10.0.7", "raf": "^3.4.0", "rc-test": "^7.0.13", - "react": "^18.0.0", - "react-dom": "^18.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", "regenerator-runtime": "^0.14.1", "sinon": "^9.0.2", "typescript": "^5.3.3", "vinyl-fs": "^4.0.0" }, "peerDependencies": { - "react": ">=16.9.0", - "react-dom": ">=16.9.0" + "react": ">=18.0.0", + "react-dom": ">=18.0.0" } } diff --git a/src/AjaxUploader.tsx b/src/AjaxUploader.tsx index 383d10f3..2518eee4 100644 --- a/src/AjaxUploader.tsx +++ b/src/AjaxUploader.tsx @@ -1,7 +1,7 @@ -/* eslint react/no-is-mounted:0,react/sort-comp:0,react/prop-types:0 */ -import clsx from 'classnames'; -import pickAttrs from 'rc-util/lib/pickAttrs'; -import React, { Component } from 'react'; +/* eslint-disable react-hooks/exhaustive-deps */ +import classnames from 'classnames'; +import pickAttrs from '@rc-component/util/lib/pickAttrs'; +import React from 'react'; import attrAccept from './attr-accept'; import type { BeforeUploadFileType, @@ -9,6 +9,7 @@ import type { UploadProgressEvent, UploadProps, UploadRequestError, + UploadRequestOption, } from './interface'; import defaultRequest from './request'; import traverseFileTree from './traverseFileTree'; @@ -21,122 +22,77 @@ interface ParsedFileInfo { parsedFile: RcFile; } -class AjaxUploader extends Component { - state = { uid: getUid() }; - - reqs: Record = {}; - - private fileInput: HTMLInputElement; - - private _isMounted: boolean; - - onChange = (e: React.ChangeEvent) => { - const { accept, directory } = this.props; - const { files } = e.target; - const acceptedFiles = [...files].filter( - (file: RcFile) => !directory || attrAccept(file, accept), - ); - this.uploadFiles(acceptedFiles); - this.reset(); - }; - - onClick = (event: React.MouseEvent | React.KeyboardEvent) => { - const el = this.fileInput; - if (!el) { - return; - } - - const target = event.target as HTMLElement; - const { onClick } = this.props; - - if (target && target.tagName === 'BUTTON') { - const parent = el.parentNode as HTMLInputElement; - parent.focus(); - target.blur(); - } - el.click(); - if (onClick) { - onClick(event); - } - }; - - onKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - this.onClick(e); - } - }; - - onFileDrop = async (e: React.DragEvent) => { - const { multiple } = this.props; - - e.preventDefault(); - - if (e.type === 'dragover') { - return; - } - - if (this.props.directory) { - const files = await traverseFileTree( - Array.prototype.slice.call(e.dataTransfer.items), - (_file: RcFile) => attrAccept(_file, this.props.accept), - ); - this.uploadFiles(files); - } else { - let files = [...e.dataTransfer.files].filter((file: RcFile) => - attrAccept(file, this.props.accept), - ); - - if (multiple === false) { - files = files.slice(0, 1); +const AjaxUploader: React.FC>> = props => { + const { + component: Tag, + prefixCls, + className, + classNames = {}, + disabled, + id, + name, + style, + styles = {}, + multiple, + accept, + capture, + children, + directory, + openFileDialogOnClick, + hasControlInside, + action, + headers, + withCredentials, + method, + onMouseEnter, + onMouseLeave, + data, + beforeUpload, + onStart, + customRequest, + ...otherProps + } = props; + + const [uid, setUid] = React.useState(getUid()); + + const isMountedRef = React.useRef(false); + const inputRef = React.useRef(null); + const reqsRef = React.useRef>>({}); + + const abort = React.useCallback((file?: any) => { + if (file) { + const internalUid = file.uid ? file.uid : file; + if (reqsRef.current[internalUid]?.abort) { + reqsRef.current[internalUid].abort(); } - - this.uploadFiles(files); + reqsRef.current[internalUid] = undefined; + } else { + Object.keys(reqsRef.current).forEach(key => { + if (reqsRef.current[key]?.abort) { + reqsRef.current[key].abort(); + } + }); + reqsRef.current = {}; } - }; - - componentDidMount() { - this._isMounted = true; - } + }, []); - componentWillUnmount() { - this._isMounted = false; - this.abort(); - } - - uploadFiles = (files: File[]) => { - const originFiles = [...files] as RcFile[]; - const postFiles = originFiles.map((file: RcFile & { uid?: string }) => { - // eslint-disable-next-line no-param-reassign - file.uid = getUid(); - return this.processFile(file, originFiles); - }); - - // Batch upload files - Promise.all(postFiles).then(fileList => { - const { onBatchStart } = this.props; - - onBatchStart?.(fileList.map(({ origin, parsedFile }) => ({ file: origin, parsedFile }))); - - fileList - .filter(file => file.parsedFile !== null) - .forEach(file => { - this.post(file); - }); - }); - }; + React.useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + abort(); + }; + }, []); /** * Process file before upload. When all the file is ready, we start upload. */ - processFile = async (file: RcFile, fileList: RcFile[]): Promise => { - const { beforeUpload } = this.props; - + const processFile = async (file: RcFile, fileList: RcFile[]): Promise => { let transformedFile: BeforeUploadFileType | void = file; if (beforeUpload) { - try { + if (typeof beforeUpload === 'function') { transformedFile = await beforeUpload(file, fileList); - } catch (e) { - // Rejection will also trade as false + } else { transformedFile = false; } if (transformedFile === false) { @@ -149,8 +105,6 @@ class AjaxUploader extends Component { } } - // Get latest action - const { action } = this.props; let mergedAction: string; if (typeof action === 'function') { mergedAction = await action(file); @@ -158,8 +112,6 @@ class AjaxUploader extends Component { mergedAction = action; } - // Get latest data - const { data } = this.props; let mergedData: Record; if (typeof data === 'function') { mergedData = await data(file); @@ -193,143 +145,154 @@ class AjaxUploader extends Component { }; }; - post({ data, origin, action, parsedFile }: ParsedFileInfo) { - if (!this._isMounted) { + const post = (info: ParsedFileInfo) => { + if (!isMountedRef.current) { return; } - const { onStart, customRequest, name, headers, withCredentials, method } = this.props; + const { origin, parsedFile } = info; - const { uid } = origin; const request = customRequest || defaultRequest; - const requestOption = { - action, + const requestOption: UploadRequestOption = { + action: info.action, filename: name, - data, + data: info.data, file: parsedFile, headers, withCredentials, method: method || 'post', onProgress: (e: UploadProgressEvent) => { - const { onProgress } = this.props; - onProgress?.(e, parsedFile); + props.onProgress?.(e, parsedFile); }, onSuccess: (ret: any, xhr: XMLHttpRequest) => { - const { onSuccess } = this.props; - onSuccess?.(ret, parsedFile, xhr); - - delete this.reqs[uid]; + props.onSuccess?.(ret, parsedFile, xhr); + reqsRef.current[origin.uid] = undefined; }, onError: (err: UploadRequestError, ret: any) => { - const { onError } = this.props; - onError?.(err, ret, parsedFile); - - delete this.reqs[uid]; + props.onError?.(err, ret, parsedFile); + reqsRef.current[origin.uid] = undefined; }, }; - onStart(origin); - this.reqs[uid] = request(requestOption); - } + reqsRef.current[origin.uid] = request(requestOption); + }; - reset() { - this.setState({ - uid: getUid(), + const uploadFiles = (files: File[]) => { + const originFiles = [...files] as RcFile[]; + const postFiles = originFiles.map((file: RcFile & { uid?: string }) => { + // eslint-disable-next-line no-param-reassign + file.uid = getUid(); + return processFile(file, originFiles); }); - } - abort(file?: any) { - const { reqs } = this; - if (file) { - 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 => { - if (reqs[uid] && reqs[uid].abort) { - reqs[uid].abort(); - } - delete reqs[uid]; - }); + // Batch upload files + Promise.all(postFiles).then(fileList => { + props.onBatchStart?.( + fileList.map(({ origin, parsedFile }) => ({ file: origin, parsedFile })), + ); + fileList.filter(file => file.parsedFile !== null).forEach(file => post(file)); + }); + }; + + const onChange = (e: React.ChangeEvent) => { + const { files } = e.target; + const acceptedFiles = [...files].filter( + (file: RcFile) => !directory || attrAccept(file, accept), + ); + uploadFiles(acceptedFiles); + setUid(getUid()); + }; + + const onClick = ( + event: React.MouseEvent | React.KeyboardEvent, + ) => { + if (!inputRef.current) { + return; } - } + const target = event.target as HTMLElement; + if (target?.tagName.toUpperCase() === 'BUTTON') { + const parent = inputRef.current.parentNode as HTMLInputElement; + parent.focus(); + target.blur(); + } + inputRef.current.click(); + props.onClick?.(event); + }; - saveFileInput = (node: HTMLInputElement) => { - this.fileInput = node; + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + onClick(e); + } }; - render() { - const { - component: Tag, - prefixCls, - className, - classNames = {}, - disabled, - id, - name, - style, - styles = {}, - multiple, - accept, - capture, - children, - directory, - openFileDialogOnClick, - onMouseEnter, - onMouseLeave, - hasControlInside, - ...otherProps - } = this.props; - const cls = clsx({ - [prefixCls]: true, - [`${prefixCls}-disabled`]: disabled, - [className]: className, - }); - // 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: hasControlInside ? undefined : '0', - }; - return ( - - e.stopPropagation()} // https://github.com/ant-design/ant-design/issues/19948 - key={this.state.uid} - style={{ display: 'none', ...styles.input }} - className={classNames.input} - accept={accept} - {...dirProps} - multiple={multiple} - onChange={this.onChange} - {...(capture != null ? { capture } : {})} - /> - {children} - - ); - } + const onFileDrop = async (e: React.DragEvent) => { + e.preventDefault(); + if (e.type === 'dragover') { + return; + } + if (directory) { + const files = await traverseFileTree( + Array.prototype.slice.call(e.dataTransfer.items), + (f: RcFile) => attrAccept(f, accept), + ); + uploadFiles(files); + } else { + const allFiles = [...e.dataTransfer.files].filter((file: RcFile) => attrAccept(file, accept)); + uploadFiles(multiple === false ? allFiles.slice(0, 1) : allFiles); + } + }; + + // because input don't have directory/webkitdirectory type declaration + const dirProps = directory ? { directory: 'directory', webkitdirectory: 'webkitdirectory' } : {}; + + const events = disabled + ? {} + : { + onClick: openFileDialogOnClick ? onClick : () => {}, + onKeyDown: openFileDialogOnClick ? onKeyDown : () => {}, + onMouseEnter, + onMouseLeave, + onDrop: onFileDrop, + onDragOver: onFileDrop, + }; + + return ( + + e.stopPropagation()} // https://github.com/ant-design/ant-design/issues/19948 + key={uid} + style={{ display: 'none', ...styles.input }} + className={classNames.input} + accept={accept} + {...dirProps} + multiple={multiple} + onChange={onChange} + {...(capture != null ? { capture } : {})} + /> + {children} + + ); +}; + +if (process.env.NODE_ENV !== 'production') { + AjaxUploader.displayName = 'AjaxUploader'; } export default AjaxUploader; diff --git a/src/Upload.tsx b/src/Upload.tsx index 23541e31..36275390 100644 --- a/src/Upload.tsx +++ b/src/Upload.tsx @@ -1,42 +1,53 @@ -/* eslint react/prop-types:0 */ -import React, { Component } from 'react'; +import React from 'react'; import AjaxUpload from './AjaxUploader'; -import type { UploadProps, RcFile } from './interface'; +import type { UploadProps } from './interface'; function empty() {} -class Upload extends Component { - static defaultProps = { - component: 'span', - prefixCls: 'rc-upload', - data: {}, - headers: {}, - name: 'file', - multipart: false, - onStart: empty, - onError: empty, - onSuccess: empty, - multiple: false, - beforeUpload: null, - customRequest: null, - withCredentials: false, - openFileDialogOnClick: true, - hasControlInside: false, - }; +const Upload: React.FC> = props => { + const { + component = 'span', + prefixCls = 'rc-upload', + data = {}, + headers = {}, + name = 'file', + onStart = empty, + onError = empty, + onSuccess = empty, + multiple = false, + beforeUpload, + customRequest, + withCredentials = false, + openFileDialogOnClick = true, + hasControlInside = false, + children, + ...otherProps + } = props; + return ( + + {children} + + ); +}; - private uploader: AjaxUpload; - - abort(file: RcFile) { - this.uploader.abort(file); - } - - saveUploader = (node: AjaxUpload) => { - this.uploader = node; - }; - - render() { - return ; - } +if (process.env.NODE_ENV !== 'production') { + Upload.displayName = 'Upload'; } export default Upload; diff --git a/src/attr-accept.ts b/src/attr-accept.ts index 7d1caec4..c9da0266 100644 --- a/src/attr-accept.ts +++ b/src/attr-accept.ts @@ -1,4 +1,4 @@ -import warning from 'rc-util/lib/warning'; +import warning from '@rc-component/util/lib/warning'; import type { RcFile } from './interface'; export default (file: RcFile, acceptedFiles: string | string[]) => { diff --git a/src/interface.tsx b/src/interface.tsx index 1c6e1d5e..4765c47b 100644 --- a/src/interface.tsx +++ b/src/interface.tsx @@ -34,9 +34,9 @@ export interface UploadProps openFileDialogOnClick?: boolean; prefixCls?: string; id?: string; - onMouseEnter?: (e: React.MouseEvent) => void; - onMouseLeave?: (e: React.MouseEvent) => void; - onClick?: (e: React.MouseEvent | React.KeyboardEvent) => void; + onMouseEnter?: (e: React.MouseEvent) => void; + onMouseLeave?: (e: React.MouseEvent) => void; + onClick?: (e: React.MouseEvent | React.KeyboardEvent) => void; classNames?: { input?: string; }; diff --git a/src/request.ts b/src/request.ts index 898847d0..96d175ca 100644 --- a/src/request.ts +++ b/src/request.ts @@ -17,12 +17,12 @@ function getBody(xhr: XMLHttpRequest) { try { return JSON.parse(text); - } catch (e) { + } catch { return text; } } -export default function upload(option: UploadRequestOption) { +function upload(option: UploadRequestOption) { // eslint-disable-next-line no-undef const xhr = new XMLHttpRequest(); @@ -105,3 +105,5 @@ export default function upload(option: UploadRequestOption) { }, }; } + +export default upload; diff --git a/src/traverseFileTree.ts b/src/traverseFileTree.ts index 6544ac58..7ae668f6 100644 --- a/src/traverseFileTree.ts +++ b/src/traverseFileTree.ts @@ -14,18 +14,21 @@ interface InternalDataTransferItem extends DataTransferItem { const traverseFileTree = async (files: InternalDataTransferItem[], isAccepted) => { const flattenFileList = []; const progressFileList = []; - files.forEach(file => progressFileList.push(file.webkitGetAsEntry() as any)); + + files.forEach(file => { + progressFileList.push(file.webkitGetAsEntry() as any); + }); async function readDirectory(directory: InternalDataTransferItem) { const dirReader = directory.createReader(); - const entries = []; + const entries: InternalDataTransferItem[] = []; while (true) { - const results = await new Promise((resolve) => { + const results = await new Promise(resolve => { dirReader.readEntries(resolve, () => resolve([])); }); const n = results.length; - + if (!n) { break; } diff --git a/src/uid.ts b/src/uid.ts index 3ad3f8e3..28523eea 100644 --- a/src/uid.ts +++ b/src/uid.ts @@ -1,7 +1,9 @@ -const now = +new Date(); +const now = Date.now(); + let index = 0; -export default function uid() { - // eslint-disable-next-line no-plusplus +function uid() { return `rc-upload-${now}-${++index}`; } + +export default uid; diff --git a/tests/setup.js b/tests/setup.js index 154da504..6036b841 100644 --- a/tests/setup.js +++ b/tests/setup.js @@ -1,7 +1,2 @@ global.requestAnimationFrame = cb => setTimeout(cb, 0); require('regenerator-runtime'); - -const Enzyme = require('enzyme'); -const Adapter = require('enzyme-adapter-react-16'); - -Enzyme.configure({ adapter: new Adapter() }); diff --git a/tests/uploader.spec.tsx b/tests/uploader.spec.tsx index 344a61d2..4d7a129e 100644 --- a/tests/uploader.spec.tsx +++ b/tests/uploader.spec.tsx @@ -1,18 +1,18 @@ import { fireEvent, render } from '@testing-library/react'; -import { resetWarned } from 'rc-util/lib/warning'; +import { resetWarned } from '@rc-component/util/lib/warning'; import React from 'react'; import sinon from 'sinon'; import { format } from 'util'; import Upload, { type UploadProps } from '../src'; -const sleep = (timeout = 500) => new Promise(resolve => setTimeout(resolve, timeout)); +const sleep = (timeout = 500) => new Promise(resolve => setTimeout(resolve, timeout)); -function Item(name) { +function Item(name: string) { this.name = name; this.toString = () => this.name; } -const makeFileSystemEntry = item => { +const makeFileSystemEntry = (item: any) => { const isDirectory = Array.isArray(item.children); const ret = { isDirectory, @@ -50,13 +50,13 @@ const makeFileSystemEntryAsync = item => { return { async readEntries(handle, error) { await sleep(100); - + if (!first) { return handle([]); } if (item.error && first) { - return error && error(new Error('read file error')) + return error && error(new Error('read file error')); } first = false; @@ -377,16 +377,18 @@ describe('uploader', () => { }); it('should pass file to request', done => { - const fakeRequest = jest.fn((file) => { - expect(file).toEqual(expect.objectContaining({ - filename: 'file', // <= https://github.com/react-component/upload/pull/574 - file: expect.any(File), - method: 'post', - onError: expect.any(Function), - onProgress: expect.any(Function), - onSuccess: expect.any(Function), - data: expect.anything(), - })); + const fakeRequest = jest.fn(file => { + expect(file).toEqual( + expect.objectContaining({ + filename: 'file', // <= https://github.com/react-component/upload/pull/574 + file: expect.any(File), + method: 'post', + onError: expect.any(Function), + onProgress: expect.any(Function), + onSuccess: expect.any(Function), + data: expect.anything(), + }), + ); done(); }); @@ -563,14 +565,14 @@ describe('uploader', () => { fireEvent.drop(input, { dataTransfer: { items: [makeDataTransferItemAsync(files)] } }); const mockStart = jest.fn(); handlers.onStart = mockStart; - + setTimeout(() => { expect(mockStart.mock.calls.length).toBe(2); done(); }, 1000); }); - it('dragging and dropping files to upload through asynchronous file reading with some readEntries method throw error', (done) => { + it('dragging and dropping files to upload through asynchronous file reading with some readEntries method throw error', done => { const input = uploader.container.querySelector('input')!; const files = { @@ -593,7 +595,7 @@ describe('uploader', () => { name: '8.png', }, ], - } + }, ], }, { @@ -612,7 +614,7 @@ describe('uploader', () => { fireEvent.drop(input, { dataTransfer: { items: [makeDataTransferItemAsync(files)] } }); const mockStart = jest.fn(); handlers.onStart = mockStart; - + setTimeout(() => { expect(mockStart.mock.calls.length).toBe(1); done();