diff --git a/api/src/app.ts b/api/src/app.ts index 5800a8295c..0d2c6021fa 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -26,11 +26,10 @@ const app: express.Express = express(); app.use(function (req: any, res: any, next: any) { defaultLog.info(`${req.method} ${req.url}`); - res.setHeader('Access-Control-Allow-Credentials', true); res.setHeader('Access-Control-Allow-Headers', 'X-Requested-With, Content-Type, Authorization, responseType'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE, HEAD'); res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.setHeader('Cache-Control', 'no-store'); next(); }); diff --git a/app/src/components/attachments/FileUpload.test.tsx b/app/src/components/attachments/FileUpload.test.tsx index a9316b3a01..5881e651d5 100644 --- a/app/src/components/attachments/FileUpload.test.tsx +++ b/app/src/components/attachments/FileUpload.test.tsx @@ -1,5 +1,4 @@ import { fireEvent, render, waitFor } from '@testing-library/react'; -import { APIError } from 'hooks/api/useAxios'; import { useBiohubApi } from 'hooks/useBioHubApi'; import React from 'react'; import FileUpload from './FileUpload'; @@ -27,154 +26,61 @@ describe('FileUpload', () => { mockBiohubApi().project.uploadProjectAttachments.mockClear(); }); - it('matches the snapshot', () => { - const { asFragment } = renderContainer(); + it('renders the dropZone component', async () => { + const { getByText } = renderContainer(); - expect(asFragment()).toMatchSnapshot(); + expect(getByText('Drag your files here', { exact: false })).toBeVisible(); }); - it('handles file upload success', async () => { - let resolveRef: (value: unknown) => void; + it('renders an item in the list for each file added', async () => { + mockBiohubApi().project.uploadProjectAttachments.mockReturnValue(Promise.resolve()); - const mockUploadPromise = new Promise(function (resolve: any, reject: any) { - resolveRef = resolve; - }); - - mockBiohubApi().project.uploadProjectAttachments.mockReturnValue(mockUploadPromise); - - const { asFragment, getByTestId, getByText } = renderContainer(); - - const testFile = new File(['test png content'], 'testpng.txt', { type: 'text/plain' }); + const { getByTestId, getByText } = renderContainer(); const dropZoneInput = getByTestId('drop-zone-input'); - fireEvent.change(dropZoneInput, { target: { files: [testFile] } }); - - await waitFor(() => { - expect(mockBiohubApi().project.uploadProjectAttachments).toHaveBeenCalledWith( - projectId, - [testFile], - expect.any(Object), - expect.any(Function) - ); - - expect(getByText('testpng.txt')).toBeVisible(); - - expect(getByText('Uploading')).toBeVisible(); + fireEvent.change(dropZoneInput, { + target: { + files: [ + new File([`test png content`], `testpng0.txt`, { type: 'text/plain' }), + new File([`test png content`], `testpng1.txt`, { type: 'text/plain' }), + new File([`test png content`], `testpng2.txt`, { type: 'text/plain' }), + new File([`test png content`], `testpng3.txt`, { type: 'text/plain' }), + new File([`test png content`], `testpng4.txt`, { type: 'text/plain' }) + ] + } }); - // Manually trigger the upload resolve to simulate a successful upload - // @ts-ignore - resolveRef(null); - await waitFor(() => { - expect(getByText('Complete')).toBeVisible(); + expect(getByText('testpng0.txt')).toBeVisible(); + expect(getByText('testpng1.txt')).toBeVisible(); + expect(getByText('testpng2.txt')).toBeVisible(); + expect(getByText('testpng3.txt')).toBeVisible(); + expect(getByText('testpng4.txt')).toBeVisible(); }); - - // expect file list item to show complete state - expect(asFragment()).toMatchSnapshot(); }); - it('handles file upload API rejection', async () => { - let rejectRef: (reason: unknown) => void; - - const mockUploadPromise = new Promise(function (resolve: any, reject: any) { - rejectRef = reject; - }); - - mockBiohubApi().project.uploadProjectAttachments.mockReturnValue(mockUploadPromise); - - const { asFragment, getByTestId, getByText, getByTitle } = renderContainer(); + it('removes an item from the list when the onCancel callback is triggered', async () => { + mockBiohubApi().project.uploadProjectAttachments.mockReturnValue(new Promise(() => {})); - const testFile = new File(['test png content'], 'testpng.txt', { type: 'text/plain' }); + const { getByTestId, getByText, getByTitle, queryByText } = renderContainer(); const dropZoneInput = getByTestId('drop-zone-input'); - fireEvent.change(dropZoneInput, { target: { files: [testFile] } }); - - await waitFor(() => { - expect(mockBiohubApi().project.uploadProjectAttachments).toHaveBeenCalledWith( - projectId, - [testFile], - expect.any(Object), - expect.any(Function) - ); - - expect(getByText('testpng.txt')).toBeVisible(); - - expect(getByText('Uploading')).toBeVisible(); - }); - - // Manually trigger the upload reject to simulate an unsuccessful upload - // @ts-ignore - rejectRef(new APIError({ response: { data: { message: 'File was evil!' } } } as any)); - - await waitFor(() => { - expect(getByText('File was evil!')).toBeVisible(); - }); - - // expect file list item to show error state - expect(asFragment()).toMatchSnapshot(); - - const removeButton = getByTitle('Clear File'); - - await waitFor(() => { - expect(removeButton).toBeVisible(); + fireEvent.change(dropZoneInput, { + target: { + files: [new File([`test png content`], `testpng0.txt`, { type: 'text/plain' })] + } }); - fireEvent.click(removeButton); - - // expect file list item to be removed - expect(asFragment()).toMatchSnapshot(); - }); - - it('handles file upload DropZone rejection for file too large', async () => { - const { asFragment, getByTestId, getByText } = renderContainer(); - - const testFile = new File(['test png content'], 'testpng.txt', { type: 'text/plain' }); - // force file size to be 500MB - Object.defineProperty(testFile, 'size', { value: 1024 * 1024 * 500 }); // 500MB file - - const dropZoneInput = getByTestId('drop-zone-input'); - - fireEvent.change(dropZoneInput, { target: { files: [testFile] } }); - await waitFor(() => { - expect(getByText('testpng.txt')).toBeVisible(); - - expect(getByText('File size exceeds maximum')).toBeVisible(); + expect(getByText('testpng0.txt')).toBeVisible(); }); - // expect file list item to show error state - expect(asFragment()).toMatchSnapshot(); - }); - - it('handles file upload DropZone rejection for too many files uploaded at once', async () => { - const { asFragment, getByTestId, getByText, getAllByText } = renderContainer(); - - const getTestFiles = (num: number): File[] => { - const files = []; - for (let i = 0; i < num; i++) { - files.push(new File([`test png ${i} content`], `testpng${i}.txt`, { type: 'text/plain' })); - } - return files; - }; - - const dropZoneInput = getByTestId('drop-zone-input'); - - fireEvent.change(dropZoneInput, { target: { files: getTestFiles(11) } }); + fireEvent.click(getByTitle('Cancel Upload')); await waitFor(() => { - const errorMessages = getAllByText('Number of files uploaded at once exceeds maximum'); - expect(errorMessages.length).toEqual(11); + expect(queryByText('testpng0.txt')).not.toBeInTheDocument(); }); - - expect(getByText('testpng1.txt')).toBeVisible(); - expect(getByText('testpng3.txt')).toBeVisible(); - expect(getByText('testpng7.txt')).toBeVisible(); - expect(getByText('testpng10.txt')).toBeVisible(); - - // expect file list item to show error state - expect(asFragment()).toMatchSnapshot(); }); }); diff --git a/app/src/components/attachments/FileUpload.tsx b/app/src/components/attachments/FileUpload.tsx index 1ed26b9f25..f93ad23a12 100644 --- a/app/src/components/attachments/FileUpload.tsx +++ b/app/src/components/attachments/FileUpload.tsx @@ -1,73 +1,21 @@ import Box from '@material-ui/core/Box'; -import IconButton from '@material-ui/core/IconButton'; -import LinearProgress from '@material-ui/core/LinearProgress'; import List from '@material-ui/core/List'; -import ListItem from '@material-ui/core/ListItem'; import { Theme } from '@material-ui/core/styles/createMuiTheme'; -import Typography from '@material-ui/core/Typography'; import makeStyles from '@material-ui/core/styles/makeStyles'; -import { mdiCheck, mdiWindowClose } from '@mdi/js'; -import Icon from '@mdi/react'; -import axios, { CancelTokenSource } from 'axios'; -import { APIError } from 'hooks/api/useAxios'; -import { useBiohubApi } from 'hooks/useBioHubApi'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { FileError, FileRejection } from 'react-dropzone'; import DropZone from './DropZone'; +import { MemoizedFileUploadItem } from './FileUploadItem'; const useStyles = makeStyles((theme: Theme) => ({ dropZone: { border: '2px dashed grey', cursor: 'default' - }, - uploadListItem: { - border: '1px solid grey' - }, - completeIcon: { - color: theme.palette.success.main - }, - errorIcon: { - color: theme.palette.error.main - }, - linearProgressBar: { - height: '10px' - }, - uploadingColor: { - backgroundColor: 'rgba(25, 118, 210, 0.5)', // primary.main with reduced opacity - height: '5px' - }, - uploadingBarColor: { - backgroundColor: theme.palette.primary.main - }, - completeColor: { - backgroundColor: 'rgba(76, 175, 80, 0.5)', // success.main with reduced opacity - height: '5px' - }, - completeBarColor: { - backgroundColor: theme.palette.success.main - }, - failedColor: { - backgroundColor: 'rgba(244, 67, 54, 0.5)', // error.main with reduced opacity - height: '5px' - }, - failedBarColor: { - backgroundColor: theme.palette.error.main } })); -export enum UploadFileStatus { - PENDING = 'Pending', - UPLOADING = 'Uploading', - PROCESSING = 'Finishing Upload', - FAILED = 'Failed', - COMPLETE = 'Complete' -} - export interface IUploadFile { file: File; - status: UploadFileStatus; - progress: number; - cancelTokenSource: CancelTokenSource; error?: string; } @@ -82,10 +30,12 @@ export interface IFileUploadProps { export const FileUpload: React.FC = (props) => { const classes = useStyles(); - const biohubApi = useBiohubApi(); - const [files, setFiles] = useState([]); + const [fileUploadItems, setFileUploadItems] = useState([]); + + const [fileToRemove, setFileToRemove] = useState(''); + /** * Handles files which are added (via either drag/drop or browsing). * @@ -105,10 +55,7 @@ export const FileUpload: React.FC = (props) => { } newAcceptedFiles.push({ - file: item, - status: UploadFileStatus.PENDING, - progress: 0, - cancelTokenSource: axios.CancelToken.source() + file: item }); }); @@ -122,18 +69,30 @@ export const FileUpload: React.FC = (props) => { newRejectedFiles.push({ file: item.file, - status: UploadFileStatus.PENDING, - progress: 0, - cancelTokenSource: axios.CancelToken.source(), error: getErrorCodeMessage(item.errors[0]) }); }); - setFiles((currentFiles) => { - return [...currentFiles, ...newAcceptedFiles, ...newRejectedFiles]; - }); + setFiles((currentFiles) => [...currentFiles, ...newAcceptedFiles, ...newRejectedFiles]); - newAcceptedFiles.forEach((item) => startFileUpload(item)); + setFileUploadItems( + fileUploadItems.concat([ + ...newAcceptedFiles.map((item) => getFileUploadItem(item.file, item.error)), + ...newRejectedFiles.map((item) => getFileUploadItem(item.file, item.error)) + ]) + ); + }; + + const getFileUploadItem = (file: File, error?: string) => { + return ( + setFileToRemove(file.name)} + /> + ); }; const getErrorCodeMessage = (fileError: FileError) => { @@ -147,221 +106,33 @@ export const FileUpload: React.FC = (props) => { } }; - /** - * Update the array of files. - * - * @param {IUploadFile} fileToUpdate - * @param {Partial} updatedFileProperties - */ - const updateFile = (fileToUpdate: IUploadFile, updatedFileAttributes: Partial) => { - setFiles((currentFiles) => { - return currentFiles.map((item) => { - if (item.file.name === fileToUpdate.file.name) { - return { ...item, ...updatedFileAttributes }; - } - - return item; - }); - }); - }; - - /** - * Cancel the upload or delete the file if the upload has passed the point of cancelling. - * - * @param {IUploadFile} fileToCancel - */ - const cancelUpload = (fileToCancel: IUploadFile) => { - // Cancel any active upload request for this file - // Note: this only cancels the initial upload of the file data to the API, and not the upload from the API to S3. - fileToCancel.cancelTokenSource.cancel(); - - removeFile(fileToCancel); - }; - - /** - * Remove the file from the list. - * - * @param {IUploadFile} fileToDelete - */ - const removeFile = (fileToRemove: IUploadFile) => { - setFiles((currentFiles) => currentFiles.filter((item) => item.file.name !== fileToRemove.file.name)); - }; - - /** - * Updates a file's status, and kicks off the file upload request. - * - * @param {IUploadFile} fileToUpload - */ - const startFileUpload = (fileToUpload: IUploadFile) => { - updateFile(fileToUpload, { - status: UploadFileStatus.UPLOADING - }); - - uploadFile(fileToUpload); - }; - - /** - * Upload a single file. Update its state on progress, resolve, and reject events. - * - * @param {IUploadFile} fileToUpload - */ - const uploadFile = async (fileToUpload: IUploadFile) => { - biohubApi.project - .uploadProjectAttachments( - props.projectId, - [fileToUpload.file], - fileToUpload.cancelTokenSource, - (progressEvent: ProgressEvent) => handleFileUploadProgress(fileToUpload, progressEvent) - ) - .then( - () => handleFileUploadSuccess(fileToUpload), - (error: APIError) => handleFileUploadFailure(fileToUpload, error) - ); - }; - - /** - * Update a file's state on progress. - * - * @param {IUploadFile} fileToUpdate - * @param {ProgressEvent} progressEvent - */ - const handleFileUploadProgress = (fileToUpdate: IUploadFile, progressEvent: ProgressEvent) => { - updateFile(fileToUpdate, { - progress: Math.round((progressEvent.loaded / progressEvent.total) * 100), - status: progressEvent.loaded === progressEvent.total ? UploadFileStatus.PROCESSING : UploadFileStatus.UPLOADING - }); - }; - - /** - * Update a file's state on successful upload. - * - * @param {IUploadFile} fileToUpdate - */ - const handleFileUploadSuccess = (fileToUpdate: IUploadFile) => { - updateFile(fileToUpdate, { status: UploadFileStatus.COMPLETE, progress: 100 }); - }; - - /** - * Update a file's state on un-successful upload. - * - * @param {IUploadFile} fileToUpdate - * @param {APIError} error - */ - const handleFileUploadFailure = (fileToUpdate: IUploadFile, error: APIError) => { - updateFile(fileToUpdate, { status: UploadFileStatus.FAILED, error: error.message }); - }; - - const ProgressBar = (progressBarProps: { file: IUploadFile }) => { - const { file } = progressBarProps; - - if (file.status === UploadFileStatus.PROCESSING) { - return ( - - ); - } - - if (file.status === UploadFileStatus.COMPLETE) { - return ( - - ); - } - - if (file.status === UploadFileStatus.FAILED) { - return ( - - ); - } - - // status is pending or uploading - return ( - - ); - }; - - const FileButton = (fileButtonProps: { file: IUploadFile }) => { - const { file } = fileButtonProps; - - if (file.status === UploadFileStatus.PENDING || file.status === UploadFileStatus.UPLOADING) { - return ( - - cancelUpload(file)}> - - - - ); + useEffect(() => { + if (!fileToRemove) { + return; } - if (file.status === UploadFileStatus.COMPLETE) { - return ( - - - - ); - } + const removeFile = (fileName: string) => { + const index = files.findIndex((item) => item.file.name === fileName); - if (file.status === UploadFileStatus.FAILED) { - return ( - - removeFile(file)}> - - - - ); - } - - // status is processing, show no icon - return ; - }; + if (index === -1) { + return; + } - /** - * Builds and returns a file list. - * - * @param {*} uploadFileListProps - * @return {*} - */ - const UploadFileList: React.FC = (uploadFileListProps) => { - if (!files?.length) { - return <>; - } + setFiles((currentFiles) => { + const newFiles = [...currentFiles]; + newFiles.splice(index, 1); + return newFiles; + }); - const listItems = uploadFileListProps.files.map((file, index) => { - return ( - - - - - {file.file.name} - {file.error || file.status} - - - - - - - - - - - ); - }); + setFileUploadItems((currentFileUploadItems) => { + const newFileUploadItems = [...currentFileUploadItems]; + newFileUploadItems.splice(index, 1); + return newFileUploadItems; + }); + }; - return {listItems}; - }; + removeFile(fileToRemove); + }, [fileToRemove, fileUploadItems, files]); return ( @@ -369,7 +140,7 @@ export const FileUpload: React.FC = (props) => { - + {fileUploadItems} ); diff --git a/app/src/components/attachments/FileUploadItem.test.tsx b/app/src/components/attachments/FileUploadItem.test.tsx new file mode 100644 index 0000000000..d975511e1e --- /dev/null +++ b/app/src/components/attachments/FileUploadItem.test.tsx @@ -0,0 +1,172 @@ +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { APIError } from 'hooks/api/useAxios'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import React from 'react'; +import FileUploadItem, { IFileUploadItemProps } from './FileUploadItem'; + +jest.mock('../../hooks/useBioHubApi'); +const mockUseBiohubApi = { + project: { + uploadProjectAttachments: jest.fn, []>() + } +}; + +const mockBiohubApi = ((useBiohubApi as unknown) as jest.Mock).mockReturnValue( + mockUseBiohubApi +); + +const projectId = 1; +const onCancel = jest.fn(); + +const renderContainer = (props: IFileUploadItemProps) => { + return render(); +}; + +describe('FileUploadItem', () => { + beforeEach(() => { + // clear mocks before each test + mockBiohubApi().project.uploadProjectAttachments.mockClear(); + }); + + it('calls props.onCancel when the `X` button is clicked', async () => { + let rejectRef: (value: unknown) => void; + + const mockUploadPromise = new Promise(function (resolve: any, reject: any) { + rejectRef = reject; + }); + + mockBiohubApi().project.uploadProjectAttachments.mockReturnValue(mockUploadPromise); + + const testFile = new File(['test png content'], 'testpng.txt', { type: 'text/plain' }); + + const { getByText, getByTitle } = renderContainer({ + projectId, + file: testFile, + error: '', + onCancel: () => onCancel() + }); + + await waitFor(() => { + expect(mockBiohubApi().project.uploadProjectAttachments).toHaveBeenCalledWith( + projectId, + [testFile], + expect.any(Object), + expect.any(Function) + ); + + expect(getByText('testpng.txt')).toBeVisible(); + + expect(getByText('Uploading')).toBeVisible(); + }); + + const cancelButton = getByTitle('Cancel Upload'); + + expect(cancelButton).toBeVisible(); + + fireEvent.click(cancelButton); + + // Manually trigger the upload reject to simulate a cancelled request + // @ts-ignore + rejectRef({ message: '' }); + + await waitFor(() => { + expect(onCancel).toBeCalledTimes(1); + }); + }); + + it('handles file upload success', async () => { + let resolveRef: (value: unknown) => void; + + const mockUploadPromise = new Promise(function (resolve: any, reject: any) { + resolveRef = resolve; + }); + + mockBiohubApi().project.uploadProjectAttachments.mockReturnValue(mockUploadPromise); + + const testFile = new File(['test png content'], 'testpng.txt', { type: 'text/plain' }); + + const { getByText } = renderContainer({ + projectId, + file: testFile, + error: '', + onCancel: () => onCancel() + }); + + await waitFor(() => { + expect(mockBiohubApi().project.uploadProjectAttachments).toHaveBeenCalledWith( + projectId, + [testFile], + expect.any(Object), + expect.any(Function) + ); + + expect(getByText('testpng.txt')).toBeVisible(); + + expect(getByText('Uploading')).toBeVisible(); + }); + + // Manually trigger the upload resolve to simulate a successful upload + // @ts-ignore + resolveRef(null); + + await waitFor(() => { + expect(getByText('Complete')).toBeVisible(); + }); + }); + + it('handles file upload API rejection', async () => { + let rejectRef: (reason: unknown) => void; + + const mockUploadPromise = new Promise(function (resolve: any, reject: any) { + rejectRef = reject; + }); + + mockBiohubApi().project.uploadProjectAttachments.mockReturnValue(mockUploadPromise); + + const testFile = new File(['test png content'], 'testpng.txt', { type: 'text/plain' }); + + const { getByText } = renderContainer({ + projectId, + file: testFile, + error: '', + onCancel: () => onCancel() + }); + + await waitFor(() => { + expect(mockBiohubApi().project.uploadProjectAttachments).toHaveBeenCalledWith( + projectId, + [testFile], + expect.any(Object), + expect.any(Function) + ); + + expect(getByText('testpng.txt')).toBeVisible(); + + expect(getByText('Uploading')).toBeVisible(); + }); + + // Manually trigger the upload reject to simulate an unsuccessful upload + // @ts-ignore + rejectRef(new APIError({ response: { data: { message: 'api error message' } } } as any)); + + await waitFor(() => { + expect(getByText('api error message')).toBeVisible(); + }); + }); + + it('shows an error message if the component initially receives an error', async () => { + const testFile = new File(['test png content'], 'testpng.txt', { type: 'text/plain' }); + + const { getByText } = renderContainer({ + projectId, + file: testFile, + error: 'initial error message', + onCancel: () => onCancel() + }); + + await waitFor(() => { + expect(getByText('testpng.txt')).toBeVisible(); + expect(getByText('initial error message')).toBeVisible(); + }); + }); +}); diff --git a/app/src/components/attachments/FileUploadItem.tsx b/app/src/components/attachments/FileUploadItem.tsx new file mode 100644 index 0000000000..5ac2b37fda --- /dev/null +++ b/app/src/components/attachments/FileUploadItem.tsx @@ -0,0 +1,311 @@ +import Box from '@material-ui/core/Box'; +import IconButton from '@material-ui/core/IconButton'; +import LinearProgress from '@material-ui/core/LinearProgress'; +import ListItem from '@material-ui/core/ListItem'; +import { Theme } from '@material-ui/core/styles/createMuiTheme'; +import makeStyles from '@material-ui/core/styles/makeStyles'; +import Typography from '@material-ui/core/Typography'; +import { mdiCheck, mdiWindowClose } from '@mdi/js'; +import Icon from '@mdi/react'; +import axios, { CancelTokenSource } from 'axios'; +import { APIError } from 'hooks/api/useAxios'; +import { useBiohubApi } from 'hooks/useBioHubApi'; +import useIsMounted from 'hooks/useIsMounted'; +import React, { useCallback, useEffect, useState } from 'react'; + +const useStyles = makeStyles((theme: Theme) => ({ + uploadListItem: { + border: '1px solid grey' + }, + completeIcon: { + color: theme.palette.success.main + }, + errorIcon: { + color: theme.palette.error.main + }, + linearProgressBar: { + height: '10px' + }, + uploadingColor: { + backgroundColor: 'rgba(25, 118, 210, 0.5)', // primary.main with reduced opacity + height: '5px' + }, + uploadingBarColor: { + backgroundColor: theme.palette.primary.main + }, + completeColor: { + backgroundColor: 'rgba(76, 175, 80, 0.5)', // success.main with reduced opacity + height: '5px' + }, + completeBarColor: { + backgroundColor: theme.palette.success.main + }, + failedColor: { + backgroundColor: 'rgba(244, 67, 54, 0.5)', // error.main with reduced opacity + height: '5px' + }, + failedBarColor: { + backgroundColor: theme.palette.error.main + } +})); + +export enum UploadFileStatus { + PENDING = 'Pending', + UPLOADING = 'Uploading', + FINISHING_UPLOAD = 'Finishing Upload', + FAILED = 'Failed', + COMPLETE = 'Complete' +} + +export interface IUploadFile { + file: File; + status: UploadFileStatus; + progress: number; + cancelTokenSource: CancelTokenSource; + error?: string; +} + +export interface IFileUploadItemProps { + projectId: number; + file: File; + error?: string; + onCancel: () => void; +} + +const FileUploadItem: React.FC = (props) => { + const isMounted = useIsMounted(); + + const classes = useStyles(); + + const biohubApi = useBiohubApi(); + + const [file] = useState(props.file); + const [error, setError] = useState(props.error); + + const [status, setStatus] = useState(UploadFileStatus.PENDING); + const [progress, setProgress] = useState(0); + const [cancelToken] = useState(axios.CancelToken.source()); + + // indicates that the active requests should cancel + const [initiateCancel, setInitiateCancel] = useState(false); + // indicates that the active requests are in a state where they can be safely cancelled + const [isSafeToCancel, setIsSafeToCancel] = useState(false); + + const handleFileUploadError = useCallback(() => { + setStatus(UploadFileStatus.FAILED); + setProgress(0); + }, []); + + useEffect(() => { + if (error) { + handleFileUploadError(); + return; + } + + if (status !== UploadFileStatus.PENDING) { + return; + } + + const handleFileUploadProgress = (progressEvent: ProgressEvent) => { + if (!isMounted()) { + // component is unmounted, don't perform any state changes when the upload request emits progress + return; + } + + setProgress(Math.round((progressEvent.loaded / progressEvent.total) * 100)); + + if (progressEvent.loaded === progressEvent.total) { + setStatus(UploadFileStatus.FINISHING_UPLOAD); + } + }; + + const handleFileUploadSuccess = () => { + if (!isMounted()) { + // component is unmounted, don't perform any state changes when the upload request resolves + return; + } + + setStatus(UploadFileStatus.COMPLETE); + setProgress(100); + + // the upload request has finished and its safe to call the onCancel prop + setIsSafeToCancel(true); + }; + + biohubApi.project + .uploadProjectAttachments(props.projectId, [file], cancelToken, handleFileUploadProgress) + .then(handleFileUploadSuccess, (error: APIError) => setError(error?.message)) + .catch(); + + setStatus(UploadFileStatus.UPLOADING); + }, [file, biohubApi, status, cancelToken, props.projectId, isMounted, initiateCancel, error, handleFileUploadError]); + + useEffect(() => { + if (!isMounted()) { + // component is unmounted, don't perform any state changes when the upload request rejects + return; + } + + if (error && !initiateCancel && !isSafeToCancel) { + // the api request will reject if it is cancelled OR if it fails, so only conditionally treat the upload as a failure + handleFileUploadError(); + } + + // the upload request has finished (either from failing or cancelling) and its safe to call the onCancel prop + setIsSafeToCancel(true); + }, [error, initiateCancel, isSafeToCancel, isMounted, handleFileUploadError]); + + useEffect(() => { + if (!initiateCancel) { + return; + } + + // cancel the active request + cancelToken.cancel(); + }, [initiateCancel, cancelToken]); + + useEffect(() => { + if (!isSafeToCancel || !initiateCancel) { + return; + } + + // trigger the parents onCancel hook, as this component is in a state where it can be safely cancelled + props.onCancel(); + }, [initiateCancel, isSafeToCancel, props]); + + return ( + + + + + {file.name} + {error || status} + + + + + + + setInitiateCancel(true)} /> + + + + ); +}; + +export default FileUploadItem; + +export const MemoizedFileUploadItem = React.memo(FileUploadItem, (prevProps, nextProps) => { + return prevProps.file.name === nextProps.file.name; +}); + +interface IActionButtonProps { + status: UploadFileStatus; + onCancel: () => void; +} + +/** + * Upload action button. + * + * Changes color and icon depending on the status. + * + * @param {*} props + * @return {*} + */ +const ActionButton: React.FC = (props) => { + const classes = useStyles(); + + if (props.status === UploadFileStatus.PENDING || props.status === UploadFileStatus.UPLOADING) { + return ( + + props.onCancel()}> + + + + ); + } + + if (props.status === UploadFileStatus.COMPLETE) { + return ( + + + + ); + } + + if (props.status === UploadFileStatus.FAILED) { + return ( + + props.onCancel()}> + + + + ); + } + + // status is FINISHING_UPLOAD, show no action button + return ; +}; + +export const MemoizedActionButton = React.memo(ActionButton, (prevProps, nextProps) => { + return prevProps.status === nextProps.status; +}); + +interface IProgressBarProps { + status: UploadFileStatus; + progress: number; +} + +/** + * Upload progress bar. + * + * Changes color and style depending on the status. + * + * @param {*} props + * @return {*} + */ +const ProgressBar: React.FC = (props) => { + const classes = useStyles(); + + if (props.status === UploadFileStatus.FINISHING_UPLOAD) { + return ( + + ); + } + + if (props.status === UploadFileStatus.COMPLETE) { + return ( + + ); + } + + if (props.status === UploadFileStatus.FAILED) { + return ( + + ); + } + + // status is PENDING or UPLOADING + return ( + + ); +}; + +export const MemoizedProgressBar = React.memo(ProgressBar, (prevProps, nextProps) => { + return prevProps.status === nextProps.status && prevProps.progress === nextProps.progress; +}); diff --git a/app/src/components/attachments/__snapshots__/FileUpload.test.tsx.snap b/app/src/components/attachments/__snapshots__/FileUpload.test.tsx.snap deleted file mode 100644 index 53b38e88dc..0000000000 --- a/app/src/components/attachments/__snapshots__/FileUpload.test.tsx.snap +++ /dev/null @@ -1,1454 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`FileUpload handles file upload API rejection 1`] = ` - -
-
-
-
- -
- - - -

- Drag your files here, or - - Browse Files - -

- - Maximum file size: 50 MB - - - Maximum file count: 10 - -
-
-
-
-
-
    -
  • -
    -
    -
    -

    - testpng.txt -

    -

    - File was evil! -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
  • -
-
-
-
-`; - -exports[`FileUpload handles file upload API rejection 2`] = ` - -
-
-
-
- -
- - - -

- Drag your files here, or - - Browse Files - -

- - Maximum file size: 50 MB - - - Maximum file count: 10 - -
-
-
-
-
-
- -`; - -exports[`FileUpload handles file upload DropZone rejection for file too large 1`] = ` - -
-
-
-
- -
- - - -

- Drag your files here, or - - Browse Files - -

- - Maximum file size: 50 MB - - - Maximum file count: 10 - -
-
-
-
-
-
    -
  • -
    -
    -
    -

    - testpng.txt -

    -

    - File size exceeds maximum -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
  • -
-
-
-
-`; - -exports[`FileUpload handles file upload DropZone rejection for too many files uploaded at once 1`] = ` - -
-
-
-
- -
- - - -

- Drag your files here, or - - Browse Files - -

- - Maximum file size: 50 MB - - - Maximum file count: 10 - -
-
-
-
-
-
    -
  • -
    -
    -
    -

    - testpng0.txt -

    -

    - Number of files uploaded at once exceeds maximum -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
  • -
  • -
    -
    -
    -

    - testpng1.txt -

    -

    - Number of files uploaded at once exceeds maximum -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
  • -
  • -
    -
    -
    -

    - testpng2.txt -

    -

    - Number of files uploaded at once exceeds maximum -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
  • -
  • -
    -
    -
    -

    - testpng3.txt -

    -

    - Number of files uploaded at once exceeds maximum -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
  • -
  • -
    -
    -
    -

    - testpng4.txt -

    -

    - Number of files uploaded at once exceeds maximum -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
  • -
  • -
    -
    -
    -

    - testpng5.txt -

    -

    - Number of files uploaded at once exceeds maximum -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
  • -
  • -
    -
    -
    -

    - testpng6.txt -

    -

    - Number of files uploaded at once exceeds maximum -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
  • -
  • -
    -
    -
    -

    - testpng7.txt -

    -

    - Number of files uploaded at once exceeds maximum -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
  • -
  • -
    -
    -
    -

    - testpng8.txt -

    -

    - Number of files uploaded at once exceeds maximum -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
  • -
  • -
    -
    -
    -

    - testpng9.txt -

    -

    - Number of files uploaded at once exceeds maximum -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
  • -
  • -
    -
    -
    -

    - testpng10.txt -

    -

    - Number of files uploaded at once exceeds maximum -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    -
    -
  • -
-
-
-
-`; - -exports[`FileUpload handles file upload success 1`] = ` - -
-
-
-
- -
- - - -

- Drag your files here, or - - Browse Files - -

- - Maximum file size: 50 MB - - - Maximum file count: 10 - -
-
-
-
-
-
    -
  • -
    -
    -
    -

    - testpng.txt -

    -

    - Complete -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    - - - -
    -
    -
    -
  • -
-
-
-
-`; - -exports[`FileUpload matches the snapshot 1`] = ` - -
-
-
-
- -
- - - -

- Drag your files here, or - - Browse Files - -

- - Maximum file size: 50 MB - - - Maximum file count: 10 - -
-
-
-
-
-
- -`; diff --git a/app/src/hooks/useIsMounted.tsx b/app/src/hooks/useIsMounted.tsx new file mode 100644 index 0000000000..73973bc507 --- /dev/null +++ b/app/src/hooks/useIsMounted.tsx @@ -0,0 +1,36 @@ +import { useRef, useEffect, useCallback } from 'react'; + +/** + * Use to track if a component is mounted/unmounted. + * + * Why? If a component is running an async operation, and becomes unmounted during its execution, then any state + * related actions that would normally run when the async operation finishes can no longer be run (as the component + * is no longer mounted). Check the value of this function before making touching state, and skip if it returns false. + * + * Example: + * + * const isMounted = useIsMounted() + * + * callThatReturnsAPromise().then((value) => { + * if (isMounted()) { + * return; + * } + * updateState(value) + * ) + * + * @export + * @return {*} {() => boolean} + */ +export default function useIsMounted(): () => boolean { + const ref = useRef(false); + + useEffect(() => { + ref.current = true; + + return () => { + ref.current = false; + }; + }, []); + + return useCallback(() => ref.current, [ref]); +}