import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { UploadTask, Storage } from '@aws-amplify/storage'; import { translate, uploadFile, isValidExtension } from '@aws-amplify/ui'; import { Logger } from 'aws-amplify'; import { Button as UploadButton, ComponentClassNames, VisuallyHidden, } from '../../../primitives'; import { useFileUploader } from './hooks/useFileUploader'; import { UploadPreviewer } from './UploadPreviewer'; import { UploadDropZone } from './UploadDropZone'; import { UploadTracker } from './UploadTracker'; import { FileState, FileUploaderProps } from './types'; import { useDeprecationWarning } from '../../../hooks/useDeprecationWarning'; const isUploadTask = (value: unknown): value is UploadTask => typeof (value as UploadTask)?.resume === 'function'; const logger = new Logger('AmplifyUI:Storage'); export function FileUploader({ acceptedFileTypes, shouldAutoProceed = false, isPreviewerVisible, maxFileCount, maxSize, hasMultipleFiles = true, onError, onSuccess, showImages = true, accessLevel, variation = 'drop', isResumable = false, ...rest }: FileUploaderProps): JSX.Element { useDeprecationWarning({ shouldWarn: true, message: 'FileUploader has exited Dev Preview and was renamed to StorageManager with some API changes. Please migrate to the StorageManager component, as the FileUploader component is no longer supported and will be removed in a future major release. https://ui.docs.amplify.aws/react/connected-components/storage/storagemanager', }); if (!acceptedFileTypes || !accessLevel) { logger.warn( 'FileUploader requires accessLevel and acceptedFileTypes props' ); } // File Previewer loading state const [isLoading, setLoading] = useState(false); const [autoLoad, setAutoLoad] = useState(false); const { addTargetFiles, fileStatuses, inDropZone, setFileStatuses, setShowPreviewer, showPreviewer, ...dropZoneProps } = useFileUploader({ maxSize: maxSize!, acceptedFileTypes, hasMultipleFiles, isLoading, setAutoLoad, }); // Creates aggregate percentage to show during downloads const aggregatePercentage = Math.floor( fileStatuses.reduce((prev, curr) => prev + (curr?.percentage ?? 0), 0) / fileStatuses.length ); // checks if all downloads completed to 100% const isSuccessful = fileStatuses.length === 0 ? false : fileStatuses.every((status) => status?.percentage === 100); // Displays if over max files const hasMaxFilesError = fileStatuses.filter((file) => file.percentage !== 100).length > // @ts-ignore maxFileCount; useEffect(() => { // Loading ends when all files are at 100% if (Math.floor(aggregatePercentage) === 100) { setLoading(false); } }, [aggregatePercentage]); useEffect(() => { // @ts-ignore setShowPreviewer(isPreviewerVisible); }, [setShowPreviewer, isPreviewerVisible]); // Previewer Methods const progressCallback = useCallback( (index: number) => { return (progress: { loaded: number; total: number }) => { setFileStatuses((prevFileStatuses) => { const prevStatus = { ...prevFileStatuses[index] }; /** * When a file is zero bytes, the progress.total will equal zero. * Therefore, this will prevent a divide by zero error. */ const progressPercentage = progress.total !== 0 ? Math.floor((progress.loaded / progress.total) * 100) : 100; const fileState: FileState = progressPercentage !== 100 ? FileState.LOADING : FileState.SUCCESS; const updatedStatus = { ...prevStatus, percentage: progressPercentage, fileState, }; prevFileStatuses[index] = updatedStatus; return [...prevFileStatuses]; }); }; }, [setFileStatuses] ); const errorCallback = useCallback( (index: number) => { return (err: string) => { setFileStatuses((prevFileStatuses) => { const prevStatus = { ...prevFileStatuses[index] }; const updatedStatus = { ...prevStatus, fileState: 'error' as FileState, fileErrors: translate(err.toString()), }; prevFileStatuses[index] = updatedStatus; return [...prevFileStatuses]; }); setLoading(false); if (typeof onError === 'function') onError(err); }; }, [onError, setFileStatuses] ); const onPause = useCallback( (index: number): (() => void) => { return function () { const status = fileStatuses[index]; if (isUploadTask(status.uploadTask)) { status.uploadTask.pause(); } const newFileStatuses = [...fileStatuses]; newFileStatuses[index] = { ...status, fileState: FileState.PAUSED }; setFileStatuses(newFileStatuses); }; }, [fileStatuses, setFileStatuses] ); const onResume = useCallback( (index: number): (() => void) => { return function () { const status = fileStatuses[index]; if (isUploadTask(status.uploadTask)) { status.uploadTask.resume(); } const newFileStatuses = [...fileStatuses]; newFileStatuses[index] = { ...status, fileState: FileState.RESUME }; setFileStatuses(newFileStatuses); }; }, [fileStatuses, setFileStatuses] ); const onFileClick = useCallback(() => { // start upload setLoading(true); const uploadTasksTemp: UploadTask[] = []; fileStatuses.forEach((status, i) => { if (status?.fileState === FileState.SUCCESS) return; const uploadTask = uploadFile({ file: status.file!, fileName: status.name!, level: accessLevel, isResumable, progressCallback: progressCallback(i), errorCallback: errorCallback(i), // @ts-ignore completeCallback: onSuccess, ...rest, }); if (isUploadTask(uploadTask) && isResumable) { uploadTasksTemp.push(uploadTask); } }); setFileStatuses((prevFileStatuses) => prevFileStatuses.map((status, index) => ({ ...status, uploadTask: uploadTasksTemp?.[index], fileState: status.fileState === FileState.INIT ? FileState.LOADING : status.fileState, percentage: status.percentage ?? 0, })) ); }, [ fileStatuses, setFileStatuses, accessLevel, isResumable, progressCallback, errorCallback, onSuccess, rest, ]); const onFileChange = useCallback( (event: React.ChangeEvent<HTMLInputElement>) => { if (!event.target.files || event.target.files.length === 0) { return; } const { files } = event.target; // Spread files here because a I need a File[] instead, it's easier to iterate through const addedFilesLength = addTargetFiles!([...files]); // only show previewer if the added files are great then 0 if (addedFilesLength > 0) { // @ts-ignore setShowPreviewer(true); setAutoLoad(true); } }, [addTargetFiles, setShowPreviewer] ); const onClear = useCallback(() => { setShowPreviewer!(false); setFileStatuses([]); }, [setFileStatuses, setShowPreviewer]); const onFileCancel = useCallback( (index: number) => { return () => { const { fileState, uploadTask } = fileStatuses[index]; if (fileState === 'loading' && isUploadTask(uploadTask)) { // if downloading use uploadTask and stop download Storage.cancel(uploadTask); setLoading(false); } const updatedFiles = fileStatuses.filter((_, i) => i !== index); setFileStatuses(updatedFiles); }; }, [fileStatuses, setFileStatuses] ); // Tracker methods const onSaveEdit = useCallback( (index: number) => { return (value: string) => { // no empty file names if (value.trim().length === 0) return; const newFileStatuses = [...fileStatuses]; const status = fileStatuses[index]; const validExtension = isValidExtension(value, status.file!.name); newFileStatuses[index] = { ...status, name: value, fileState: !validExtension ? FileState.ERROR : FileState.INIT, fileErrors: validExtension ? undefined : translate('Extension not allowed'), }; setFileStatuses(newFileStatuses); }; }, [fileStatuses, setFileStatuses] ); const updateFileState = useCallback( (index: number, fileState: FileState) => { setFileStatuses((prevFileStatuses) => { const newFileStatuses = [...prevFileStatuses]; const status = newFileStatuses[index]; // Check if extension is valid before setting state const validExtension = isValidExtension(status.name!, status.file!.name) ? FileState.INIT : FileState.ERROR; const updatedFileState = fileState === FileState.INIT ? validExtension : fileState; newFileStatuses[index] = { ...status, fileState: updatedFileState, }; return newFileStatuses; }); }, [setFileStatuses] ); const onCancelEdit = useCallback( (index: number) => { return () => { updateFileState(index, FileState.INIT); }; }, [updateFileState] ); const onStartEdit = useCallback( (index: number) => { return (_: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { updateFileState(index, FileState.EDITING); }; }, [updateFileState] ); useEffect(() => { if (shouldAutoProceed && autoLoad && !hasMaxFilesError) { onFileClick(); } else { return; } setAutoLoad(false); }, [shouldAutoProceed, onFileClick, autoLoad, hasMaxFilesError]); const hiddenInput = React.useRef<HTMLInputElement>(null); const accept = acceptedFileTypes?.join(); const uploadButton = useMemo( () => ( <> <UploadButton className={ComponentClassNames.FileUploaderDropZoneButton} isDisabled={isLoading} onClick={() => { hiddenInput?.current?.click(); hiddenInput.current!.value = ''; }} size="small" > {translate('Browse files')} </UploadButton> <VisuallyHidden> <input type="file" tabIndex={-1} ref={hiddenInput} onChange={onFileChange} multiple={hasMultipleFiles} accept={accept} /> </VisuallyHidden> </> ), [isLoading, onFileChange, hasMultipleFiles, accept] ); if (showPreviewer) { return ( <UploadPreviewer dropZone={ <UploadDropZone {...dropZoneProps} inDropZone={inDropZone}> {uploadButton} </UploadDropZone> } fileStatuses={fileStatuses} isLoading={isLoading} isSuccessful={isSuccessful} hasMaxFilesError={hasMaxFilesError} maxFileCount={maxFileCount!} onClear={onClear} onFileClick={onFileClick} aggregatePercentage={aggregatePercentage} > {fileStatuses?.map((status, index) => ( <UploadTracker errorMessage={status?.fileErrors!} file={status.file!} fileState={status?.fileState!} hasImage={status.file?.type.startsWith('image/')!} showImage={showImages} key={index} name={status.name!} onCancel={onFileCancel(index)} onCancelEdit={onCancelEdit(index)} onPause={onPause(index)} onResume={onResume(index)} onSaveEdit={onSaveEdit(index)} onStartEdit={onStartEdit(index)} percentage={status.percentage!} isResumable={isResumable} /> ))} </UploadPreviewer> ); } if (variation === 'button') { return uploadButton; } else { return ( <UploadDropZone {...dropZoneProps} inDropZone={inDropZone}> {uploadButton} </UploadDropZone> ); } }