/* eslint-disable max-len */ import React, { ReactNode, CSSProperties, RefObject, ChangeEvent, DragEvent } from 'react'; import cls from 'classnames'; import PropTypes from 'prop-types'; import { noop } from 'lodash-es'; import UploadFoundation, { BaseFileItem, UploadAdapter, BeforeUploadObjectResult, AfterUploadResult } from '@douyinfe/semi-foundation/upload/foundation'; import { strings, cssClasses } from '@douyinfe/semi-foundation/upload/constants'; import FileCard from './fileCard'; import BaseComponent, { ValidateStatus } from '../_base/baseComponent'; import LocaleConsumer from '../locale/localeConsumer'; import { IconUpload } from '@douyinfe/semi-icons'; import { ArrayElement } from '../_base/base'; import { Locale } from '../locale/interface'; import '@douyinfe/semi-foundation/upload/upload.scss'; const prefixCls = cssClasses.PREFIX; export interface FileItem extends BaseFileItem { validateMessage?: ReactNode; } export type UploadListType = ArrayElement; export type PromptPositionType = ArrayElement; export interface RenderFileItemProps extends FileItem { previewFile: (fileItem: FileItem) => ReactNode; listType: UploadListType; onRemove: () => void; onRetry: () => void; onReplace: () => void; key: string; showRetry: boolean; showReplace: boolean; style: CSSProperties; disabled: boolean; onPreviewClick: () => void; } export interface BeforeUploadProps { file: FileItem; fileList: Array; } export interface AfterUploadProps { file: FileItem; fileList: Array; response: any; } export interface OnChangeProps { fileList: Array; currentFile: FileItem; } export interface customRequestArgs { fileName: string; // Current file name data: Record; // User-set props.data file: FileItem; fileInstance: File; // Original File Object which extends to the blob, the file object actually acquired by the browser (https://developer.mozilla.org/zh-CN/docs/Web/API/File) onProgress: (event: { total: number; loaded: number }) => any; // The function that should be called during the upload process, the event needs to contain the total and loaded attributes onError: (userXhr: { status?: number }, e: Event) => any; // Functions to call in case of upload error onSuccess: (response: any, e: Event) => any; // The function that should be called after the upload is successful, the response is the request result after the upload is successful withCredentials: boolean; // User-set props.with Credentials action: string; // User-set props.action } export interface CustomError extends Error { status: number; method: string; url: string; } export interface UploadProps { accept?: string; action: string; afterUpload?: (object: AfterUploadProps) => AfterUploadResult; beforeUpload?: (object: BeforeUploadProps) => BeforeUploadObjectResult | Promise | boolean; beforeClear?: (fileList: Array) => boolean | Promise; beforeRemove?: (file: FileItem, fileList: Array) => boolean | Promise; capture?: boolean | string | undefined; children?: ReactNode; className?: string; customRequest?: (object: customRequestArgs) => void; data?: Record | ((file: File) => Record); defaultFileList?: Array; directory?: boolean; disabled?: boolean; dragIcon?: ReactNode; dragMainText?: ReactNode; dragSubText?: ReactNode; draggable?: boolean; fileList?: Array; fileName?: string; headers?: Record | ((file: File) => Record); itemStyle?: CSSProperties; limit?: number; listType?: UploadListType; maxSize?: number; minSize?: number; multiple?: boolean; name?: string; onAcceptInvalid?: (files: File[]) => void; onChange?: (object: OnChangeProps) => void; onClear?: () => void; onDrop?: (e: Event, files: Array, fileList: Array) => void; onError?: (e: CustomError, file: File, fileList: Array, xhr: XMLHttpRequest) => void; onExceed?: (fileList: Array) => void; onFileChange?: (files: Array) => void; onOpenFileDialog?: () => void; onPreviewClick?: (fileItem: FileItem) => void; onProgress?: (percent: number, file: File, fileList: Array) => void; onRemove?: (currentFile: File, fileList: Array, currentFileItem: FileItem) => void; onRetry?: (fileItem: FileItem) => void; onSizeError?: (file: File, fileList: Array) => void; onSuccess?: (responseBody: any, file: File, fileList: Array) => void; previewFile?: (fileItem: FileItem) => ReactNode; prompt?: ReactNode; promptPosition?: PromptPositionType; renderFileItem?: (renderFileItemProps: RenderFileItemProps) => ReactNode; showClear?: boolean; showReplace?: boolean; // Display replacement function showRetry?: boolean; showUploadList?: boolean; style?: CSSProperties; timeout?: number; transformFile?: (file: File) => FileItem; uploadTrigger?: 'auto' | 'custom'; validateMessage?: ReactNode; validateStatus?: ValidateStatus; withCredentials?: boolean; } export interface UploadState { dragAreaStatus: 'default' | 'legal' | 'illegal'; // Status of the drag zone fileList: Array; inputKey: number; localUrls: Array; replaceIdx: number; replaceInputKey: number; } class Upload extends BaseComponent { static propTypes = { accept: PropTypes.string, // Limit allowed file types action: PropTypes.string.isRequired, afterUpload: PropTypes.func, beforeClear: PropTypes.func, beforeRemove: PropTypes.func, beforeUpload: PropTypes.func, children: PropTypes.node, className: PropTypes.string, customRequest: PropTypes.func, data: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), // Extra parameters attached when uploading defaultFileList: PropTypes.array, directory: PropTypes.bool, // Support folder upload disabled: PropTypes.bool, dragIcon: PropTypes.node, dragMainText: PropTypes.node, dragSubText: PropTypes.node, draggable: PropTypes.bool, fileList: PropTypes.array, // files had been uploaded fileName: PropTypes.string, // same as name, to avoid props conflict in Form.Upload headers: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), itemStyle: PropTypes.object, limit: PropTypes.number, // 最大允许上传文件个数 listType: PropTypes.oneOf(strings.LIST_TYPE), maxSize: PropTypes.number, // 文件大小限制,单位kb minSize: PropTypes.number, // 文件大小限制,单位kb multiple: PropTypes.bool, name: PropTypes.string, // file name onAcceptInvalid: PropTypes.func, onChange: PropTypes.func, onClear: PropTypes.func, onDrop: PropTypes.func, onError: PropTypes.func, onExceed: PropTypes.func, // Callback exceeding limit onFileChange: PropTypes.func, // Callback when file is selected onOpenFileDialog: PropTypes.func, onPreviewClick: PropTypes.func, onProgress: PropTypes.func, onRemove: PropTypes.func, onRetry: PropTypes.func, onSizeError: PropTypes.func, // Callback with invalid file size onSuccess: PropTypes.func, previewFile: PropTypes.func, // Custom preview prompt: PropTypes.node, promptPosition: PropTypes.oneOf(strings.PROMPT_POSITION), renderFileItem: PropTypes.func, showClear: PropTypes.bool, showReplace: PropTypes.bool, showRetry: PropTypes.bool, showUploadList: PropTypes.bool, // whether to show fileList style: PropTypes.object, timeout: PropTypes.number, transformFile: PropTypes.func, uploadTrigger: PropTypes.oneOf(strings.UPLOAD_TRIGGER), // auto、custom validateMessage: PropTypes.node, validateStatus: PropTypes.oneOf(strings.VALIDATE_STATUS), withCredentials: PropTypes.bool, }; static defaultProps: Partial = { defaultFileList: [], disabled: false, listType: 'list' as const, multiple: false, onAcceptInvalid: noop, onChange: noop, beforeRemove: () => true, beforeClear: () => true, onClear: noop, onDrop: noop, onError: noop, onExceed: noop, onFileChange: noop, onOpenFileDialog: noop, onProgress: noop, onRemove: noop, onRetry: noop, onSizeError: noop, onSuccess: noop, promptPosition: 'right' as const, showClear: true, showReplace: false, showRetry: true, showUploadList: true, uploadTrigger: 'auto' as const, withCredentials: false, }; static FileCard = FileCard; constructor(props: UploadProps) { super(props); this.state = { fileList: props.defaultFileList || [], replaceIdx: -1, inputKey: Math.random(), replaceInputKey: Math.random(), // Status of the drag zone dragAreaStatus: 'default', localUrls: [], }; this.foundation = new UploadFoundation(this.adapter); this.inputRef = React.createRef(); this.replaceInputRef = React.createRef(); } static getDerivedStateFromProps(props: UploadProps): Partial | null { const { fileList } = props; if ('fileList' in props) { return { fileList: fileList || [] }; } return null; } get adapter(): UploadAdapter { return { ...super.adapter, notifyFileSelect: (files): void => this.props.onFileChange(files), notifyError: (error, fileInstance, fileList, xhr): void => this.props.onError(error, fileInstance, fileList, xhr), notifySuccess: (responseBody, file, fileList): void => this.props.onSuccess(responseBody, file, fileList), notifyProgress: (percent, file, fileList): void => this.props.onProgress(percent, file, fileList), notifyRemove: (file, fileList, fileItem): void => this.props.onRemove(file, fileList, fileItem), notifySizeError: (file, fileList): void => this.props.onSizeError(file, fileList), notifyExceed: (fileList): void => this.props.onExceed(fileList), updateFileList: (fileList, cb): void => { if (typeof cb === 'function') { this.setState({ fileList }, cb); } else { this.setState({ fileList }); } }, notifyBeforeUpload: ({ file, fileList }): boolean | BeforeUploadObjectResult | Promise => this.props.beforeUpload({ file, fileList }), notifyAfterUpload: ({ response, file, fileList }): AfterUploadResult => this.props.afterUpload({ response, file, fileList }), resetInput: (): void => { this.setState(prevState => ({ inputKey: Math.random() })); }, resetReplaceInput: (): void => { this.setState(prevState => ({ replaceInputKey: Math.random() })); }, updateDragAreaStatus: (dragAreaStatus: string): void => this.setState({ dragAreaStatus } as { dragAreaStatus: 'default' | 'legal' | 'illegal' }), notifyChange: ({ currentFile, fileList }): void => this.props.onChange({ currentFile, fileList }), updateLocalUrls: (urls): void => this.setState({ localUrls: urls }), notifyClear: (): void => this.props.onClear(), notifyPreviewClick: (file): void => this.props.onPreviewClick(file), notifyDrop: (e, files, fileList): void => this.props.onDrop(e, files, fileList), notifyAcceptInvalid: (invalidFiles): void => this.props.onAcceptInvalid(invalidFiles), notifyBeforeRemove: (file, fileList): boolean | Promise => this.props.beforeRemove(file, fileList), notifyBeforeClear: (fileList): boolean | Promise => this.props.beforeClear(fileList), }; } foundation: UploadFoundation; inputRef: RefObject = null; replaceInputRef: RefObject = null; componentWillUnmount(): void { this.foundation.destroy(); } onClick = (): void => { const { inputRef, props } = this; const { onOpenFileDialog } = props; const isDisabled = Boolean(this.props.disabled); if (isDisabled || !inputRef || !inputRef.current) { return; } inputRef.current.click(); if (onOpenFileDialog && typeof onOpenFileDialog) { onOpenFileDialog(); } }; onChange = (e: ChangeEvent): void => { const { files } = e.target; this.foundation.handleChange(files); }; replace = (index: number): void => { this.setState({ replaceIdx: index }, () => { this.replaceInputRef.current.click(); }); }; onReplaceChange = (e: ChangeEvent): void => { const { files } = e.target; this.foundation.handleReplaceChange(files); }; clear = (): void => { this.foundation.handleClear(); }; remove = (fileItem: FileItem): void => { this.foundation.handleRemove(fileItem); }; upload = (): void => { const { fileList } = this.state; this.foundation.startUpload(fileList); }; renderFile = (file: FileItem, index: number, locale: Locale['Upload']): ReactNode => { const { name, status, validateMessage, _sizeInvalid } = file; const { previewFile, listType, itemStyle, showRetry, renderFileItem, disabled, onPreviewClick, showReplace } = this.props; const onRemove = (): void => this.remove(file); const onRetry = (): void => { this.foundation.retry(file); }; const onReplace = (): void => { this.replace(index); }; const fileCardProps = { ...file, previewFile, listType, onRemove, onRetry, key: `${name}${index}`, showRetry: typeof file.showRetry !== 'undefined' ? file.showRetry : showRetry, style: itemStyle, disabled, showReplace: typeof file.showReplace !== 'undefined' ? file.showReplace : showReplace, onReplace, onPreviewClick: typeof onPreviewClick !== 'undefined' ? (): void => this.foundation.handlePreviewClick(file) : undefined, }; if (status === strings.FILE_STATUS_UPLOAD_FAIL && !validateMessage) { fileCardProps.validateMessage = locale.fail; } if (_sizeInvalid && !validateMessage) { fileCardProps.validateMessage = locale.illegalSize; } if (typeof renderFileItem === 'undefined') { return ; } else { return renderFileItem(fileCardProps); } }; renderFileList = (): ReactNode => { const { showUploadList, listType, limit, disabled, children } = this.props; const { fileList: stateFileList } = this.state; const fileList = this.props.fileList || stateFileList; const isPicType = listType === strings.FILE_LIST_PIC; const showAddTriggerInList = isPicType && (limit ? limit > fileList.length : true); const uploadAddCls = cls(`${prefixCls }-add`, { [`${prefixCls }-picture-add`]: isPicType, [`${prefixCls}-picture-add-disabled`]: disabled }); const addContent = (
{children}
); if (!showUploadList || !fileList.length) { if (showAddTriggerInList) { return addContent; } return null; } const fileListCls = cls(`${prefixCls }-file-list`, { [`${prefixCls }-picture-file-list`]: isPicType, }); const titleCls = `${prefixCls }-file-list-title`; const mainCls = `${prefixCls }-file-list-main`; const showTitle = limit !== 1 && fileList.length && listType !== strings.FILE_LIST_PIC; const showClear = this.props.showClear && !disabled; return ( {(locale: Locale['Upload']): ReactNode => (
{showTitle ? (
{locale.selectedFiles} {showClear ? ( {locale.clear} ) : null}
) : null}
{fileList.map((file, index) => this.renderFile(file, index, locale))} {showAddTriggerInList ? addContent : null}
)}
); }; onDrop = (e: DragEvent): void => { this.foundation.handleDrop(e); }; onDragOver = (e: DragEvent): void => { // When a drag element moves within the target element this.foundation.handleDragOver(e); }; onDragLeave = (e: DragEvent): void => { this.foundation.handleDragLeave(e); }; onDragEnter = (e: DragEvent): void => { this.foundation.handleDragEnter(e); }; renderDragArea = (): ReactNode => { const { dragAreaStatus } = this.state; const { children, dragIcon, dragMainText, dragSubText } = this.props; const dragAreaBaseCls = `${prefixCls }-drag-area`; const dragAreaCls = cls(dragAreaBaseCls, { [`${dragAreaBaseCls }-legal`]: dragAreaStatus === strings.DRAG_AREA_LEGAL, [`${dragAreaBaseCls }-illegal`]: dragAreaStatus === strings.DRAG_AREA_ILLEGAL, [`${dragAreaBaseCls }-custom`]: children, }); return ( {(locale: Locale['Upload']): ReactNode => (
{children ? ( children ) : ( <>
{dragIcon || }
{dragMainText || locale.mainText}
{dragSubText}
{dragAreaStatus === strings.DRAG_AREA_LEGAL && ( {locale.legalTips} )} {dragAreaStatus === strings.DRAG_AREA_ILLEGAL && ( {locale.illegalTips} )}
)}
)}
); }; render(): ReactNode { const { style, className, multiple, accept, disabled, children, capture, listType, prompt, promptPosition, draggable, validateMessage, validateStatus, directory, } = this.props; const uploadCls = cls(prefixCls, { [`${prefixCls }-picture`]: listType === strings.FILE_LIST_PIC, [`${prefixCls }-disabled`]: disabled, [`${prefixCls }-default`]: validateStatus === 'default', [`${prefixCls }-error`]: validateStatus === 'error', [`${prefixCls }-warning`]: validateStatus === 'warning', [`${prefixCls }-success`]: validateStatus === 'success', }, className); const uploadAddCls = cls(`${prefixCls }-add`); const inputCls = cls(`${prefixCls }-hidden-input`); const inputReplaceCls = cls(`${prefixCls }-hidden-input-replace`); const promptCls = cls(`${prefixCls }-prompt`); const validateMsgCls = cls(`${prefixCls }-validate-message`); const dirProps = directory ? { directory: 'directory', webkitdirectory: 'webkitdirectory' } : {}; const addContent = listType !== strings.FILE_LIST_PIC ? (
{children}
) : null; return (
{draggable ? this.renderDragArea() : addContent} {prompt ?
{prompt}
: null} {validateMessage ?
{validateMessage}
: null} {this.renderFileList()}
); } } export default Upload;