diff --git a/packages/synapse-react-client/src/utils/hooks/useUploadFileEntity/useTrackFileUploads.test.ts b/packages/synapse-react-client/src/utils/hooks/useUploadFileEntity/useTrackFileUploads.test.ts new file mode 100644 index 00000000000..313d622f183 --- /dev/null +++ b/packages/synapse-react-client/src/utils/hooks/useUploadFileEntity/useTrackFileUploads.test.ts @@ -0,0 +1,420 @@ +import { act, renderHook as _renderHook } from '@testing-library/react' +import { createWrapper } from '../../../testutils/TestingLibraryUtils' +import { useTrackFileUploads } from './useTrackFileUploads' + +describe('useTrackUploads', () => { + function renderHook() { + return _renderHook(() => useTrackFileUploads(), { + wrapper: createWrapper(), + }) + } + test('trackNewFiles', () => { + const file = new File([''], 'file.txt') + + const { result: hook } = renderHook() + + act(() => { + hook.current.trackNewFiles({ + file: file, + parentId: 'syn123', + existingEntityId: null, + }) + }) + + expect(hook.current.trackedUploadProgress.size).toBe(1) + expect(hook.current.trackedUploadProgress.get(file)).toMatchObject({ + parentId: 'syn123', + abortController: expect.any(AbortController), + progress: { value: 0, total: 1 }, + status: 'PREPARING', + }) + expect( + hook.current.trackedUploadProgress.get(file)?.abortController.signal + .aborted, + ).toBe(false) + }) + + test('trackNewFiles cancels an ongoing upload', () => { + const file = new File([''], 'file.txt') + + const { result: hook } = renderHook() + + act(() => { + hook.current.trackNewFiles({ + file: file, + parentId: 'syn123', + existingEntityId: null, + }) + }) + + const originalAbortController = + hook.current.trackedUploadProgress.get(file)?.abortController + + act(() => { + hook.current.trackNewFiles({ + file: file, + parentId: 'syn456', + existingEntityId: null, + }) + }) + + expect(hook.current.trackedUploadProgress.size).toBe(1) + expect(hook.current.trackedUploadProgress.get(file)).toMatchObject({ + parentId: 'syn456', + abortController: expect.any(AbortController), + progress: { value: 0, total: 1 }, + status: 'PREPARING', + }) + expect(originalAbortController?.signal.aborted).toBe(true) + }) + + test('setProgress', () => { + const file = new File([''], 'file.txt') + + const { result: hook } = renderHook() + + act(() => { + hook.current.trackNewFiles({ + file: file, + parentId: 'syn123', + existingEntityId: null, + }) + + hook.current.setProgress(file, { value: 10, total: 20 }) + }) + + expect(hook.current.trackedUploadProgress.size).toBe(1) + expect(hook.current.trackedUploadProgress.get(file)).toMatchObject({ + parentId: 'syn123', + abortController: expect.any(AbortController), + progress: { value: 10, total: 20 }, + status: 'PREPARING', + }) + }) + + test('setIsUploading', () => { + const file = new File([''], 'file.txt') + + const { result: hook } = renderHook() + + act(() => { + hook.current.trackNewFiles({ + file: file, + parentId: 'syn123', + existingEntityId: null, + }) + + hook.current.setIsUploading(file) + }) + + expect(hook.current.trackedUploadProgress.size).toBe(1) + expect(hook.current.trackedUploadProgress.get(file)).toMatchObject({ + parentId: 'syn123', + abortController: expect.any(AbortController), + progress: { value: 0, total: 1 }, + status: 'UPLOADING', + }) + }) + test('setComplete', () => { + const file = new File([''], 'file.txt') + + const { result: hook } = renderHook() + + act(() => { + hook.current.trackNewFiles({ + file: file, + parentId: 'syn123', + existingEntityId: null, + }) + + hook.current.setComplete(file) + }) + + expect(hook.current.trackedUploadProgress.size).toBe(1) + expect(hook.current.trackedUploadProgress.get(file)).toMatchObject({ + parentId: 'syn123', + abortController: expect.any(AbortController), + progress: { value: 0, total: 1 }, + status: 'COMPLETE', + }) + }) + test('setFailed', () => { + const file = new File([''], 'file.txt') + + const { result: hook } = renderHook() + + act(() => { + hook.current.trackNewFiles({ + file: file, + parentId: 'syn123', + existingEntityId: null, + }) + + hook.current.setFailed(file, 'some reason') + }) + + expect(hook.current.trackedUploadProgress.size).toBe(1) + expect(hook.current.trackedUploadProgress.get(file)).toMatchObject({ + parentId: 'syn123', + abortController: expect.any(AbortController), + progress: { value: 0, total: 1 }, + status: 'FAILED', + failureReason: 'some reason', + }) + }) + + test('cancelUpload', () => { + const file = new File([''], 'file.txt') + + const { result: hook } = renderHook() + + act(() => { + hook.current.trackNewFiles({ + file: file, + parentId: 'syn123', + existingEntityId: null, + }) + }) + + act(() => { + hook.current.cancelUpload(file) + }) + + expect(hook.current.trackedUploadProgress.size).toBe(1) + expect(hook.current.trackedUploadProgress.get(file)).toMatchObject({ + parentId: 'syn123', + abortController: expect.any(AbortController), + progress: { value: 0, total: 1 }, + status: 'CANCELED_BY_USER', + }) + expect( + hook.current.trackedUploadProgress.get(file)?.abortController.signal + .aborted, + ).toBe(true) + }) + + test('pauseUpload', () => { + const file = new File([''], 'file.txt') + + const { result: hook } = renderHook() + + act(() => { + hook.current.trackNewFiles({ + file: file, + parentId: 'syn123', + existingEntityId: null, + }) + }) + + act(() => { + hook.current.pauseUpload(file) + }) + + expect(hook.current.trackedUploadProgress.size).toBe(1) + expect(hook.current.trackedUploadProgress.get(file)).toMatchObject({ + parentId: 'syn123', + abortController: expect.any(AbortController), + progress: { value: 0, total: 1 }, + status: 'PAUSED', + }) + expect( + hook.current.trackedUploadProgress.get(file)?.abortController.signal + .aborted, + ).toBe(true) + }) + test('removeUpload', () => { + const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}) + + const file1 = new File([''], 'file.txt') + const file2 = new File([''], 'file.txt') + const file3 = new File([''], 'file.txt') + + const { result: hook } = renderHook() + + act(() => { + hook.current.trackNewFiles( + { + file: file1, + parentId: 'syn123', + existingEntityId: null, + }, + { + file: file2, + parentId: 'syn123', + existingEntityId: null, + }, + { + file: file3, + parentId: 'syn123', + existingEntityId: null, + }, + ) + + hook.current.cancelUpload(file1) + hook.current.setFailed(file2, 'some reason') + + // Removal should only work for files that are cancelled or failed + hook.current.removeUpload(file1) + hook.current.removeUpload(file2) + hook.current.removeUpload(file3) + }) + + // File 3 remains uncancelled + expect(hook.current.trackedUploadProgress.size).toBe(1) + expect(hook.current.trackedUploadProgress.get(file3)).toMatchObject({ + parentId: 'syn123', + abortController: expect.any(AbortController), + progress: { value: 0, total: 1 }, + status: 'PREPARING', + }) + expect( + hook.current.trackedUploadProgress.get(file3)?.abortController.signal + .aborted, + ).toBe(false) + expect(consoleWarnSpy).toHaveBeenCalledTimes(1) + expect(consoleWarnSpy).toHaveBeenCalledWith( + `Attempted to remove tracked upload progress before it was canceled.`, + { + parentId: 'syn123', + abortController: expect.any(AbortController), + progress: { value: 0, total: 1 }, + status: 'PREPARING', + }, + ) + }) + + test('isUploading, activeUploadCount', () => { + const preparingFile = new File([''], 'file.txt') + const uploadingFile = new File([''], 'file.txt') + const pausedFile = new File([''], 'file.txt') + const canceledFile = new File([''], 'file.txt') + const failedFile = new File([''], 'file.txt') + const completedFile = new File([''], 'file.txt') + + const { result: hook } = renderHook() + + act(() => { + hook.current.trackNewFiles( + { + file: preparingFile, + parentId: 'syn123', + existingEntityId: null, + }, + { + file: uploadingFile, + parentId: 'syn123', + existingEntityId: null, + }, + { + file: pausedFile, + parentId: 'syn123', + existingEntityId: null, + }, + { + file: canceledFile, + parentId: 'syn123', + existingEntityId: null, + }, + { + file: failedFile, + parentId: 'syn123', + existingEntityId: null, + }, + { + file: completedFile, + parentId: 'syn123', + existingEntityId: null, + }, + ) + + hook.current.setIsUploading(uploadingFile) + hook.current.pauseUpload(pausedFile) + hook.current.cancelUpload(canceledFile) + hook.current.setFailed(failedFile, 'some reason') + hook.current.setComplete(completedFile) + }) + + // Should count PREPARING, UPLOADING, and PAUSED + expect(hook.current.activeUploadCount).toBe(3) + expect(hook.current.isUploading).toBe(true) + + act(() => { + hook.current.cancelUpload(uploadingFile) + hook.current.cancelUpload(preparingFile) + hook.current.cancelUpload(pausedFile) + }) + + expect(hook.current.activeUploadCount).toBe(0) + expect(hook.current.isUploading).toBe(false) + }) + + test('isUploadComplete', () => { + const file1 = new File([''], 'file.txt') + const file2 = new File([''], 'file.txt') + + const { result: hook } = renderHook() + + // Initially false + expect(hook.current.isUploadComplete).toBe(false) + + // Add one file + act(() => { + hook.current.trackNewFiles({ + file: file1, + parentId: 'syn123', + existingEntityId: null, + }) + }) + + expect(hook.current.isUploadComplete).toBe(false) + + // File starts uploading... + act(() => { + hook.current.setIsUploading(file1) + }) + + expect(hook.current.isUploadComplete).toBe(false) + + // Pause it... + act(() => { + hook.current.pauseUpload(file1) + }) + expect(hook.current.isUploadComplete).toBe(false) + + // Mark it as canceled + act(() => { + hook.current.cancelUpload(file1) + }) + expect(hook.current.isUploadComplete).toBe(false) // no files ever successfully uploaded + + // Mark it as complete + act(() => { + hook.current.setComplete(file1) + }) + expect(hook.current.isUploadComplete).toBe(true) + + // Add a new file + act(() => { + hook.current.trackNewFiles({ + file: file2, + parentId: 'syn123', + existingEntityId: null, + }) + }) + expect(hook.current.isUploadComplete).toBe(false) + + // If one file is complete and the other is canceled, isUploadComplete should be true + act(() => { + hook.current.cancelUpload(file2) + }) + expect(hook.current.isUploadComplete).toBe(true) + + // Multiple files complete + act(() => { + hook.current.setComplete(file2) + }) + expect(hook.current.isUploadComplete).toBe(true) + }) +}) diff --git a/packages/synapse-react-client/src/utils/hooks/useUploadFileEntity/useTrackFileUploads.ts b/packages/synapse-react-client/src/utils/hooks/useUploadFileEntity/useTrackFileUploads.ts new file mode 100644 index 00000000000..b2727e27a0b --- /dev/null +++ b/packages/synapse-react-client/src/utils/hooks/useUploadFileEntity/useTrackFileUploads.ts @@ -0,0 +1,185 @@ +import { useState } from 'react' +import { ProgressCallback } from '../../../synapse-client/index' +import { FilePreparedForUpload } from './usePrepareFileEntityUpload' + +export type TrackedUploadProgress = { + parentId: string + progress: ProgressCallback + abortController: AbortController + status: + | 'PREPARING' + | 'UPLOADING' + | 'PAUSED' + | 'CANCELED_BY_USER' + | 'FAILED' + | 'COMPLETE' + failureReason?: string +} + +const PENDING_UPLOAD_STATES: TrackedUploadProgress['status'][] = [ + 'PREPARING', + 'UPLOADING', + 'PAUSED', +] + +/** + * Hook used to track the state of multiple file uploads, providing methods that can be used to interact with the upload state. + */ +export function useTrackFileUploads() { + const [trackedUploadProgress, setTrackedUploadProgress] = useState< + Map + >(new Map()) + + /** + * Adds new files to be tracked and puts them in the "PREPARED" state. + * Returns the new map of TrackedUploadProgress in case the new value is needed before + * the state variable is updated. + * @param preparedFiles + */ + function trackNewFiles( + ...preparedFiles: FilePreparedForUpload[] + ): Map { + // If a matching file that is uploading already exists, cancel it! + preparedFiles.forEach(preparedFile => { + const existingUpload = trackedUploadProgress.get(preparedFile.file) + if (existingUpload && existingUpload.status !== 'CANCELED_BY_USER') { + cancelUpload(preparedFile.file) + } + }) + + // Re-initialize progress tracking state variable + const newTrackedUploadProgress: Map = new Map( + trackedUploadProgress.entries(), + ) + + preparedFiles.forEach(preparedFile => { + newTrackedUploadProgress.set(preparedFile.file, { + parentId: preparedFile.parentId, + abortController: new AbortController(), + // Note that this is number of parts uploaded, not file size or % + progress: { value: 0, total: 1 }, + status: 'PREPARING', + }) + }) + + setTrackedUploadProgress(newTrackedUploadProgress) + + return newTrackedUploadProgress + } + + function setProgress(file: File, progress: ProgressCallback) { + setTrackedUploadProgress(prev => { + const newMap = new Map(prev.entries()) + const entry = newMap.get(file) + if (entry) { + newMap.set(file, { + ...entry, + progress: { + ...entry.progress, + ...progress, + }, + }) + } + return newMap + }) + } + + function setStatus( + file: File, + status: TrackedUploadProgress['status'], + failureReason?: string, + ) { + setTrackedUploadProgress(prev => { + const newMap = new Map(prev.entries()) + const entry = newMap.get(file) + if (entry) { + newMap.set(file, { + ...entry, + status, + failureReason, + }) + } + return newMap + }) + } + + function setIsUploading(file: File) { + setStatus(file, 'UPLOADING') + } + + function setComplete(file: File) { + setStatus(file, 'COMPLETE') + } + + function setFailed(file: File, failureReason: string) { + setStatus(file, 'FAILED', failureReason) + } + + function cancelUpload(file: File) { + const entry = trackedUploadProgress.get(file) + if (entry != null) { + entry.abortController.abort() + } + setStatus(file, 'CANCELED_BY_USER') + } + + function pauseUpload(file: File) { + const entry = trackedUploadProgress.get(file) + if (entry != null) { + entry.abortController.abort() + } + setStatus(file, 'PAUSED') + } + + /** + * Removes the file from the list if the upload state is CANCELED_BY_USER or FAILED. Otherwise, a noop. + */ + function removeUpload(file: File) { + setTrackedUploadProgress(prev => { + const newMap = new Map(prev.entries()) + const entry = newMap.get(file) + + if (entry == null) { + // nothing to do + } else { + if (entry.status != 'CANCELED_BY_USER' && entry.status != 'FAILED') { + console.warn( + `Attempted to remove tracked upload progress before it was canceled.`, + entry, + ) + } else { + newMap.delete(file) + } + } + return newMap + }) + } + + const activeUploadCount = [...trackedUploadProgress].filter( + ([_file, progress]) => PENDING_UPLOAD_STATES.includes(progress.status), + ).length + const isUploading = activeUploadCount > 0 + + const isUploadComplete = + // At least one file is complete + [...trackedUploadProgress].some( + ([_file, progress]) => progress.status === 'COMPLETE', + ) && + // No files are pending upload + !isUploading + + return { + trackedUploadProgress, + setProgress, + setIsUploading, + trackNewFiles, + pauseUpload, + cancelUpload, + removeUpload, + setComplete, + setFailed, + isUploading, + isUploadComplete, + activeUploadCount, + } +}