import { Plus as PlusIcon } from '@transferwise/icons'; import { clsx } from 'clsx'; import { Component } from 'react'; import { injectIntl, WrappedComponentProps } from 'react-intl'; import { Size, Typography } from '../common'; import Title from '../title'; import messages from './Upload.messages'; import { UploadImageStep, ProcessingStep, CompleteStep } from './steps'; import { postData, asyncFileRead, isSizeValid, isTypeValid, getFileType } from './utils'; import { PostDataFetcher, PostDataHTTPOptions, ResponseError } from './utils/postData/postData'; export const MAX_SIZE_DEFAULT = 5000000; export enum UploadStep { UPLOAD_IMAGE_STEP = 'uploadImageStep', } export interface UploadProps extends WrappedComponentProps { /** @default 300 */ animationDelay?: number; csButtonText?: string; csFailureText?: string; csSuccessText?: string; csTooLargeMessage?: string; csWrongTypeMessage?: string; httpOptions?: PostDataHTTPOptions & { fileInputName?: string; data?: Record; }; /** * You can provide a fetcher function with the same interface as the global fetch function, which is used by default. * `function fetcher(input: RequestInfo, init?: RequestInit): Promise` */ fetcher?: PostDataFetcher; /** * Filesize expressed in B.
If set to `null`, no size limit will be applied. * @default 5000000 (5 MB) */ maxSize?: number | null; psButtonText?: string; /** @default false */ psButtonDisabled?: boolean; psProcessingText?: string; /** @default 'md' */ size?: `${Size.SMALL | Size.MEDIUM | Size.LARGE}`; /** * You can provide multiple rules separated by comma, e.g.: "application/pdf,image/*". * Using "*" will allow every file type to be uploaded. * @default 'image/*' */ usAccept?: string; usButtonText?: string; usButtonRetryText?: string; /** @default false */ usDisabled?: boolean; usDropMessage?: string; usHelpImage?: React.ReactNode; /** @default '' */ usLabel?: string; usPlaceholder?: string; /** * Override for the [InlinePrompt icon's default, accessible name](/?path=/docs/other-statusicon-accessibility--docs) * announced by the screen readers * */ errorIconLabel?: string; /** @deprecated Only a single variant exists, please remove this prop. */ uploadStep?: `${UploadStep}`; onCancel?: () => void; onFailure?: (error: unknown) => void; onStart?: (file: File) => void; onSuccess?: (response: string | Response, fileName: string) => void; } interface UploadState { fileName: string; isDroppable: boolean; isComplete: boolean; isError: boolean; isImage: boolean; isProcessing: boolean; isSuccess: boolean; response: unknown; uploadedImage: string | undefined; } export class Upload extends Component { declare props: UploadProps & Required>; static defaultProps = { animationDelay: 300, maxSize: MAX_SIZE_DEFAULT, psButtonDisabled: false, size: 'md', usAccept: 'image/*', usDisabled: false, usLabel: '', } satisfies Partial; dragCounter = 0; timeouts = 0; constructor(props: UploadProps) { super(props); this.state = { fileName: '', isDroppable: false, isComplete: false, isError: false, isImage: false, isProcessing: false, isSuccess: false, response: undefined, uploadedImage: undefined, }; } getErrorMessage(status?: number) { const { csFailureText, csTooLargeMessage, csWrongTypeMessage, maxSize, intl } = this.props; switch (status) { case 413: return ( csTooLargeMessage || (typeof maxSize === 'number' ? intl.formatMessage(messages.csTooLargeMessage, { maxSize: roundFileSize(maxSize) }) : intl.formatMessage(messages.csTooLargeNoLimitMessage)) ); case 415: return csWrongTypeMessage || intl.formatMessage(messages.csWrongTypeMessage); default: return csFailureText || intl.formatMessage(messages.csFailureText); } } onDragLeave(event: React.DragEvent) { event.preventDefault(); this.dragCounter -= 1; if (this.dragCounter === 0) { this.setState({ isDroppable: false }); } } onDragEnter(event: React.DragEvent) { event.preventDefault(); this.dragCounter += 1; const { usDisabled } = this.props; const { isProcessing } = this.state; if (this.dragCounter === 1 && !usDisabled && !isProcessing) { this.setState({ isDroppable: true }); } } async onDrop(event: React.DragEvent) { const { isProcessing } = this.state; event.preventDefault(); if (!isProcessing) { this.reset(); } if (event.dataTransfer?.files?.[0]) { await this.fileDropped(event.dataTransfer.files[0]); } } asyncPost = async (file: File) => { const { httpOptions, fetcher } = this.props; if (httpOptions == null) { throw new Error('Cannot find HTTP options'); } const { fileInputName = file.name, data = {} } = httpOptions; const formData = new FormData(); formData.append(fileInputName, file); Object.keys(data).forEach((key) => formData.append(key, data[key])); return postData(httpOptions, formData, fetcher); }; handleUploadComplete = (type: 'success' | 'error', response: unknown) => { const { animationDelay, onSuccess, onFailure } = this.props; const { fileName } = this.state; window.clearTimeout(this.timeouts); this.timeouts = window.setTimeout(() => { this.setState( { isProcessing: false, isComplete: true, }, () => { if (type === 'success') { onSuccess?.(response as string | Response, fileName); } else { onFailure?.(response); } }, ); }, animationDelay); }; asyncResponse = (response: unknown, type: 'success' | 'error') => { this.setState( { response, isError: type === 'error', isSuccess: type === 'success', }, () => this.handleUploadComplete(type, response), ); }; handleOnClear: React.MouseEventHandler = (event) => { event.preventDefault(); const { onCancel } = this.props; onCancel?.(); this.reset(); }; reset = () => { this.dragCounter = 0; window.clearTimeout(this.timeouts); this.setState({ isComplete: false, isError: false, isProcessing: false, isSuccess: false, }); }; showDataImage = (dataUrl: string) => { const { isImage } = this.state; if (isImage) { this.setState({ uploadedImage: dataUrl, }); } }; fileDropped = async (file: File) => { const { httpOptions, maxSize, onStart, usDisabled, usAccept } = this.props; const { isProcessing } = this.state; if (usDisabled || isProcessing) { return false; } if (!file) { throw new Error('Could not retrieve file'); } this.setState({ fileName: file.name, isDroppable: false, isProcessing: true, }); onStart?.(file); let file64 = null; try { file64 = await asyncFileRead(file); } catch (error) { this.asyncResponse(error, 'error'); } if (!file64) { return false; } this.setState({ isImage: getFileType(file, file64).includes('image'), }); if (!isTypeValid(file, usAccept, file64)) { this.asyncResponse( new ResponseError( new Response(null, { status: 415, statusText: 'Unsupported Media Type', }), ), 'error', ); return false; } if (typeof maxSize === 'number' && !isSizeValid(file, maxSize)) { this.asyncResponse( new ResponseError( new Response(null, { status: 413, statusText: 'Request Entity Too Large', }), ), 'error', ); return false; } if (httpOptions) { // Post the file to provided endpoint let response; try { response = await this.asyncPost(file); } catch (error) { this.asyncResponse(error, 'error'); return false; } this.asyncResponse(response, 'success'); this.showDataImage(file64); return true; } // Post on form submit. And return the encoded image. this.showDataImage(file64); this.asyncResponse(file64, 'success'); return true; }; render() { const { maxSize, usDropMessage, usAccept, usButtonText, usButtonRetryText, usDisabled, usHelpImage, usLabel, usPlaceholder, psButtonText, psProcessingText, psButtonDisabled, csButtonText, csSuccessText, size, intl, errorIconLabel, } = this.props; const { response, fileName, isComplete, isDroppable, isError, isImage, isProcessing, isSuccess, uploadedImage, } = this.state; const placeholder = ((): string => { if (usPlaceholder) { return usPlaceholder; } if (typeof maxSize === 'number') { return intl.formatMessage(messages.usPlaceholder, { maxSize: roundFileSize(maxSize) }); } return intl.formatMessage(messages.usPlaceholderNoLimit); })(); return (
this.onDragEnter(event)} onDragLeave={(event) => this.onDragLeave(event)} onDrop={async (event) => this.onDrop(event)} onDragOver={(event) => event.preventDefault()} > {!isProcessing && !isComplete && ( this.fileDropped(file)} isComplete={isComplete} usAccept={usAccept} usButtonText={usButtonText || intl.formatMessage(messages.usButtonText)} usDisabled={usDisabled} usHelpImage={usHelpImage} usLabel={usLabel} usPlaceholder={placeholder} /> )} {(isSuccess || isComplete) && !isError && ( this.handleOnClear(event)} /> )} {isError && !isProcessing && ( { this.reset(); await this.fileDropped(file); }} isComplete={!isError} usAccept={usAccept} usButtonText={usButtonRetryText || intl.formatMessage(messages.retry)} usDisabled={usDisabled} usHelpImage={null} usLabel={usLabel} usPlaceholder={placeholder} errorMessage={this.getErrorMessage( response != null && typeof response === 'object' && 'status' in response && typeof response.status === 'number' ? response.status : undefined, )} errorIconLabel={errorIconLabel} /> )} {isProcessing && ( this.handleOnClear(event)} /> )} {!isProcessing && (
{usDropMessage || intl.formatMessage(messages.usDropMessage)}
)}
); } } export default injectIntl(Upload); const roundFileSize = (bytes: number) => { const megabytes = bytes / 1000000; if (megabytes >= 0.1) { const isRound = Math.floor(megabytes) === megabytes; return isRound ? `${megabytes}` : megabytes.toFixed(1); } if (megabytes >= 0.01) { return megabytes.toFixed(2); } return megabytes.toFixed(3); };