54d92f0c21
The upload progress was all modifying the last uploading file in the list. This commit corrects it such that each progress event matches the file uplaoding. In addition, this commit alos prevents the pregress bar from reaching 100 before the request completes.
297 lines
8.4 KiB
TypeScript
297 lines
8.4 KiB
TypeScript
import { Box, Input, InputLabel } from '@mui/material';
|
|
import EventEmitter from 'events';
|
|
import {
|
|
ChangeEventHandler,
|
|
FormEventHandler,
|
|
ReactElement,
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
|
|
import { UPLOAD_FILE_TYPES } from '../../lib/consts/UPLOAD_FILE_TYPES';
|
|
|
|
import api from '../../lib/api';
|
|
import { ProgressBar } from '../Bars';
|
|
import ContainedButton from '../ContainedButton';
|
|
import FileInfo from './FileInfo';
|
|
import MessageBox from '../MessageBox';
|
|
import { BodyText } from '../Text';
|
|
|
|
type FileUploadFormProps = {
|
|
onFileUploadComplete?: () => void;
|
|
eventEmitter?: EventEmitter;
|
|
};
|
|
|
|
type SelectedFile = Pick<
|
|
FileDetailMetadata,
|
|
'fileName' | 'fileLocations' | 'fileType'
|
|
> & {
|
|
file: File;
|
|
};
|
|
|
|
type InUploadFile = Pick<FileDetailMetadata, 'fileName'> & {
|
|
progressValue: number;
|
|
};
|
|
|
|
const FILE_UPLOAD_FORM_DEFAULT_PROPS: Partial<FileUploadFormProps> = {
|
|
onFileUploadComplete: undefined,
|
|
eventEmitter: undefined,
|
|
};
|
|
|
|
const FILE_UPLOAD_PERCENT_MAX = 99;
|
|
|
|
const FileUploadForm = (
|
|
{
|
|
onFileUploadComplete,
|
|
eventEmitter,
|
|
}: FileUploadFormProps = FILE_UPLOAD_FORM_DEFAULT_PROPS as FileUploadFormProps,
|
|
): JSX.Element => {
|
|
const selectFileRef = useRef<HTMLInputElement>();
|
|
|
|
const [selectedFiles, setSelectedFiles] = useState<SelectedFile[]>([]);
|
|
const [inUploadFiles, setInUploadFiles] = useState<
|
|
(InUploadFile | undefined)[]
|
|
>([]);
|
|
|
|
const convertMIMETypeToFileTypeKey = (fileMIMEType: string): FileType => {
|
|
const fileTypesIterator = UPLOAD_FILE_TYPES.entries();
|
|
|
|
let fileType: FileType | undefined;
|
|
|
|
do {
|
|
const fileTypesResult = fileTypesIterator.next();
|
|
|
|
if (fileTypesResult.value) {
|
|
const [fileTypeKey, [mimeTypeToUse]] = fileTypesResult.value;
|
|
|
|
if (fileMIMEType === mimeTypeToUse) {
|
|
fileType = fileTypeKey;
|
|
}
|
|
} else {
|
|
fileType = 'other';
|
|
}
|
|
} while (!fileType);
|
|
|
|
return fileType;
|
|
};
|
|
|
|
const autocompleteAfterSelectFile: ChangeEventHandler<HTMLInputElement> = ({
|
|
target: { files },
|
|
}) => {
|
|
if (files) {
|
|
setSelectedFiles(
|
|
Array.from(files).map(
|
|
(file): SelectedFile => ({
|
|
file,
|
|
fileName: file.name,
|
|
fileLocations: [],
|
|
fileType: convertMIMETypeToFileTypeKey(file.type),
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
};
|
|
|
|
const generateFileInfoOnChangeHandler =
|
|
(fileIndex: number): FileInfoChangeHandler =>
|
|
(inputValues) => {
|
|
selectedFiles[fileIndex] = {
|
|
...selectedFiles[fileIndex],
|
|
...inputValues,
|
|
};
|
|
};
|
|
|
|
const uploadFiles: FormEventHandler<HTMLFormElement> = (event) => {
|
|
event.preventDefault();
|
|
|
|
while (selectedFiles.length > 0) {
|
|
const selectedFile = selectedFiles.shift();
|
|
|
|
if (selectedFile) {
|
|
const { file, fileName } = selectedFile;
|
|
|
|
const fileFormData = new FormData();
|
|
|
|
fileFormData.append('file', new File([file], fileName, { ...file }));
|
|
// Re-add when the back-end tools can support changing file type on file upload.
|
|
// Note: get file type from destructuring selectedFile.
|
|
// fileFormData.append('file-type', fileType);
|
|
|
|
const inUploadFile: InUploadFile = { fileName, progressValue: 0 };
|
|
|
|
inUploadFiles.push(inUploadFile);
|
|
|
|
api
|
|
.post('/file', fileFormData, {
|
|
headers: {
|
|
'Content-Type': 'multipart/form-data',
|
|
},
|
|
onUploadProgress: (
|
|
(fName: string) =>
|
|
({ loaded, total }) => {
|
|
setInUploadFiles((previous) => {
|
|
const fInfo = previous.find((f) => f?.fileName === fName);
|
|
|
|
if (!fInfo) return previous;
|
|
|
|
/**
|
|
* Use 99 as upper limit because progress event doesn't
|
|
* represent the "bytes out of total" written to the remote
|
|
* disk. The write completes when the request completes.
|
|
*/
|
|
fInfo.progressValue = Math.round(
|
|
(loaded / total) * FILE_UPLOAD_PERCENT_MAX,
|
|
);
|
|
|
|
return [...previous];
|
|
});
|
|
}
|
|
)(fileName),
|
|
})
|
|
.then(
|
|
((fName: string) => () => {
|
|
setInUploadFiles((previous) => {
|
|
const fInfo = previous.find((f) => f?.fileName === fName);
|
|
|
|
if (!fInfo) return previous;
|
|
|
|
fInfo.progressValue = 100;
|
|
|
|
return [...previous];
|
|
});
|
|
|
|
setTimeout(() => {
|
|
setInUploadFiles((previous) => {
|
|
const fIndex = previous.findIndex(
|
|
(f) => f?.fileName === fName,
|
|
);
|
|
|
|
if (fIndex === -1) return previous;
|
|
|
|
delete previous[fIndex];
|
|
|
|
return [...previous];
|
|
});
|
|
}, 5000);
|
|
|
|
onFileUploadComplete?.call(null);
|
|
})(fileName),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Clears "staging area" (selected files) and populates "in-progress area" (in-upload files).
|
|
setSelectedFiles([]);
|
|
setInUploadFiles([...inUploadFiles]);
|
|
};
|
|
|
|
useEffect(() => {
|
|
eventEmitter?.addListener('openFilePicker', () => {
|
|
selectFileRef.current?.click();
|
|
});
|
|
|
|
eventEmitter?.addListener('clearSelectedFiles', () => {
|
|
setSelectedFiles([]);
|
|
});
|
|
}, [eventEmitter]);
|
|
|
|
return (
|
|
<form onSubmit={uploadFiles}>
|
|
<InputLabel htmlFor="select-file">
|
|
<Input
|
|
id="select-file"
|
|
inputProps={{ multiple: true }}
|
|
onChange={autocompleteAfterSelectFile}
|
|
ref={selectFileRef}
|
|
sx={{ display: 'none' }}
|
|
type="file"
|
|
/>
|
|
</InputLabel>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
|
{inUploadFiles.reduce<ReactElement[]>((previous, upFile) => {
|
|
if (!upFile) return previous;
|
|
|
|
const { fileName, progressValue } = upFile;
|
|
|
|
previous.push(
|
|
<Box
|
|
key={`in-upload-${fileName}`}
|
|
sx={{
|
|
alignItems: { md: 'center' },
|
|
display: 'flex',
|
|
flexDirection: { xs: 'column', md: 'row' },
|
|
'& > :first-child': {
|
|
minWidth: 100,
|
|
overflow: 'hidden',
|
|
overflowWrap: 'normal',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
width: { xs: '100%', md: 200 },
|
|
wordBreak: 'keep-all',
|
|
},
|
|
'& > :last-child': { flexGrow: 1 },
|
|
}}
|
|
>
|
|
<BodyText text={fileName} />
|
|
<ProgressBar progressPercentage={progressValue} />
|
|
</Box>,
|
|
);
|
|
|
|
return previous;
|
|
}, [])}
|
|
</Box>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
'& > :not(:first-child)': { marginTop: '1em' },
|
|
}}
|
|
>
|
|
{selectedFiles.length > 0 && (
|
|
<MessageBox type="info">
|
|
Uploaded files will be listed automatically, but it may take a while
|
|
for larger files to finish uploading and appear on the list.
|
|
</MessageBox>
|
|
)}
|
|
{selectedFiles.map(
|
|
(
|
|
{
|
|
file: { name: originalFileName },
|
|
fileName,
|
|
// Re-add when the back-end tools can support changing file type on file upload.
|
|
// Note: file type must be supplied to FileInfo.
|
|
// fileType,
|
|
fileLocations,
|
|
},
|
|
fileIndex,
|
|
) => (
|
|
<FileInfo
|
|
{...{ fileName, fileLocations }}
|
|
// Use a non-changing key to prevent recreating the component.
|
|
// fileName holds the string from the file-name input, thus it changes when users makes a change.
|
|
key={`selected-${originalFileName}`}
|
|
onChange={generateFileInfoOnChangeHandler(fileIndex)}
|
|
/>
|
|
),
|
|
)}
|
|
{selectedFiles.length > 0 && (
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'row',
|
|
justifyContent: 'flex-end',
|
|
}}
|
|
>
|
|
<ContainedButton type="submit">Upload</ContainedButton>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</form>
|
|
);
|
|
};
|
|
|
|
FileUploadForm.defaultProps = FILE_UPLOAD_FORM_DEFAULT_PROPS;
|
|
|
|
export default FileUploadForm;
|