diff --git a/src/api/uploads/MultiputUpload.js b/src/api/uploads/MultiputUpload.js index 40aa8c9542..a98092b63e 100644 --- a/src/api/uploads/MultiputUpload.js +++ b/src/api/uploads/MultiputUpload.js @@ -65,6 +65,8 @@ class MultiputUpload extends BaseMultiput { isResumableUploadsEnabled: boolean; + isPaused: boolean; + successCallback: Function; progressCallback: Function; @@ -134,6 +136,7 @@ class MultiputUpload extends BaseMultiput { this.clientId = null; this.isResumableUploadsEnabled = false; this.numResumeRetries = 0; + this.isPaused = false; } /** @@ -758,6 +761,7 @@ class MultiputUpload extends BaseMultiput { shouldComputeDigestForNextPart(): boolean { return ( !this.isDestroyed() && + !this.isPaused && this.numPartsDigestComputing === 0 && this.numPartsNotStarted > 0 && this.numPartsDigestReady < this.config.digestReadahead @@ -1126,7 +1130,12 @@ class MultiputUpload extends BaseMultiput { * @return {boolean} */ canStartMorePartUploads(): boolean { - return !this.isDestroyed() && this.numPartsUploading < this.config.parallelism && this.numPartsDigestReady > 0; + return ( + !this.isDestroyed() && + !this.isPaused && + this.numPartsUploading < this.config.parallelism && + this.numPartsDigestReady > 0 + ); } /** @@ -1259,6 +1268,47 @@ class MultiputUpload extends BaseMultiput { this.destroy(); } + /** + * Pauses an upload in progress without destroying the session. In-flight parts + * are reset and paused so the upload can be resumed later via unpause(). The + * reported progress is intentionally left untouched so it stays frozen in the UI. + * + * @return {void} + */ + pause(): void { + if (this.isDestroyed() || this.isPaused) { + return; + } + + this.isPaused = true; + + let nextUploadIndex = this.firstUnuploadedPartIndex; + while (this.numPartsUploading > 0) { + const part = this.parts[nextUploadIndex]; + if (part && part.state === PART_STATE_UPLOADING) { + part.reset(); + part.pause(); + this.numPartsUploading -= 1; + this.numPartsDigestReady += 1; + } + nextUploadIndex += 1; + } + } + + /** + * Resumes a previously paused upload, continuing from where it left off. + * + * @return {void} + */ + unpause(): void { + if (this.isDestroyed() || !this.isPaused) { + return; + } + + this.isPaused = false; + this.processNextParts(); + } + /** * Resolves upload conflict by overwriting or renaming * diff --git a/src/api/uploads/__tests__/MultiputUpload.test.js b/src/api/uploads/__tests__/MultiputUpload.test.js index 5ea3cb010e..84d82ad9a5 100644 --- a/src/api/uploads/__tests__/MultiputUpload.test.js +++ b/src/api/uploads/__tests__/MultiputUpload.test.js @@ -175,15 +175,18 @@ describe('api/uploads/MultiputUpload', () => { // upload pipeline full // upload pipeline not full and not ended // upload pipeline not full and not ended but no digest is ready + // paused test.each` - expected | ended | numPartsUploading | numPartsDigestReady - ${false} | ${true} | ${1} | ${undefined} - ${false} | ${false} | ${2} | ${1} - ${true} | ${false} | ${1} | ${1} - ${false} | ${false} | ${1} | ${0} - `('should return correct value:', ({ expected, ended, numPartsUploading, numPartsDigestReady }) => { + expected | ended | paused | numPartsUploading | numPartsDigestReady + ${false} | ${true} | ${false} | ${1} | ${undefined} + ${false} | ${false} | ${false} | ${2} | ${1} + ${true} | ${false} | ${false} | ${1} | ${1} + ${false} | ${false} | ${false} | ${1} | ${0} + ${false} | ${false} | ${true} | ${1} | ${1} + `('should return correct value:', ({ expected, ended, paused, numPartsUploading, numPartsDigestReady }) => { // Setup multiputUploadTest.destroyed = ended; + multiputUploadTest.isPaused = paused; multiputUploadTest.numPartsUploading = numPartsUploading; multiputUploadTest.numPartsDigestReady = numPartsDigestReady; // Execute @@ -787,6 +790,68 @@ describe('api/uploads/MultiputUpload', () => { }); }); + describe('pause()', () => { + beforeEach(() => { + multiputUploadTest.numPartsUploading = 2; + multiputUploadTest.numPartsDigestReady = 0; + multiputUploadTest.firstUnuploadedPartIndex = 0; + multiputUploadTest.parts = [ + { state: PART_STATE_UPLOADING, reset: jest.fn(), pause: jest.fn() }, + { state: PART_STATE_UPLOADED, reset: jest.fn(), pause: jest.fn() }, + { state: PART_STATE_UPLOADING, reset: jest.fn(), pause: jest.fn() }, + ]; + }); + + test('should set isPaused and reset/pause only in-flight parts', () => { + multiputUploadTest.pause(); + + expect(multiputUploadTest.isPaused).toBe(true); + expect(multiputUploadTest.numPartsUploading).toBe(0); + expect(multiputUploadTest.numPartsDigestReady).toBe(2); + expect(multiputUploadTest.parts[0].pause).toBeCalled(); + expect(multiputUploadTest.parts[1].pause).not.toBeCalled(); + expect(multiputUploadTest.parts[2].pause).toBeCalled(); + }); + + test('should do nothing when destroyed', () => { + multiputUploadTest.destroyed = true; + + multiputUploadTest.pause(); + + expect(multiputUploadTest.isPaused).toBe(false); + expect(multiputUploadTest.parts[0].pause).not.toBeCalled(); + }); + + test('should not pause twice', () => { + multiputUploadTest.isPaused = true; + + multiputUploadTest.pause(); + + expect(multiputUploadTest.parts[0].pause).not.toBeCalled(); + }); + }); + + describe('unpause()', () => { + test('should clear isPaused and resume processing parts', () => { + multiputUploadTest.isPaused = true; + multiputUploadTest.processNextParts = jest.fn(); + + multiputUploadTest.unpause(); + + expect(multiputUploadTest.isPaused).toBe(false); + expect(multiputUploadTest.processNextParts).toBeCalled(); + }); + + test('should do nothing when not paused', () => { + multiputUploadTest.isPaused = false; + multiputUploadTest.processNextParts = jest.fn(); + + multiputUploadTest.unpause(); + + expect(multiputUploadTest.processNextParts).not.toBeCalled(); + }); + }); + describe('updateProgress()', () => { test('should call progressCallback() properly', () => { const prevUploadedBytes = 10; @@ -814,18 +879,21 @@ describe('api/uploads/MultiputUpload', () => { // all parts started // readahead is full // no part computing, there is a part not started and readahead not full + // paused test.each` - expected | ended | numPartsDigestComputing | numPartsNotStarted | numPartsDigestReady - ${false} | ${true} | ${undefined} | ${undefined} | ${undefined} - ${false} | ${false} | ${1} | ${undefined} | ${undefined} - ${false} | ${false} | ${0} | ${0} | ${undefined} - ${false} | ${false} | ${0} | ${1} | ${2} - ${true} | ${false} | ${0} | ${1} | ${1} + expected | ended | paused | numPartsDigestComputing | numPartsNotStarted | numPartsDigestReady + ${false} | ${true} | ${false} | ${undefined} | ${undefined} | ${undefined} + ${false} | ${false} | ${false} | ${1} | ${undefined} | ${undefined} + ${false} | ${false} | ${false} | ${0} | ${0} | ${undefined} + ${false} | ${false} | ${false} | ${0} | ${1} | ${2} + ${true} | ${false} | ${false} | ${0} | ${1} | ${1} + ${false} | ${false} | ${true} | ${0} | ${1} | ${1} `( 'should return correct value', - ({ expected, ended, numPartsDigestComputing, numPartsNotStarted, numPartsDigestReady }) => { + ({ expected, ended, paused, numPartsDigestComputing, numPartsNotStarted, numPartsDigestReady }) => { // Setup - multiputUploadTest.ended = ended; + multiputUploadTest.destroyed = ended; + multiputUploadTest.isPaused = paused; multiputUploadTest.numPartsDigestComputing = numPartsDigestComputing; multiputUploadTest.numPartsNotStarted = numPartsNotStarted; multiputUploadTest.numPartsDigestReady = numPartsDigestReady; diff --git a/src/elements/content-uploader/ContentUploader.tsx b/src/elements/content-uploader/ContentUploader.tsx index 714af473b2..cd3cc01e9c 100644 --- a/src/elements/content-uploader/ContentUploader.tsx +++ b/src/elements/content-uploader/ContentUploader.tsx @@ -157,6 +157,8 @@ class ContentUploader extends Component { isAutoExpanded: boolean = false; + isCancelAllPaused: boolean = false; + modernizedDismissTimer: ReturnType | null = null; isPanelHovered: boolean = false; @@ -890,6 +892,10 @@ class ContentUploader extends Component { upload = () => { const { enableModernizedUploads, isUpgradeModalEnabled, maxFileSize } = this.props; + if (this.isCancelAllPaused) { + return; + } + if ( maxFileSize && enableModernizedUploads && @@ -899,6 +905,7 @@ class ContentUploader extends Component { if (!this.state.isLargeFileWarningModalOpen) { this.setState({ isLargeFileWarningModalOpen: true }); } + return; } @@ -919,6 +926,10 @@ class ContentUploader extends Component { const { enableModernizedUploads, overwrite, rootFolderId } = this.props; const { api, file, options } = item; + if (this.isCancelAllPaused) { + return; + } + const numItemsUploading = this.itemsRef.current.filter(item_t => item_t.status === STATUS_IN_PROGRESS).length; if (numItemsUploading >= UPLOAD_CONCURRENCY) { @@ -1255,6 +1266,23 @@ class ContentUploader extends Component { this.updateViewAndCollection(updatedItems); }; + /** + * Whether an item uploads through the chunked (multiput) API rather than the + * plain oneshot API. This is the same decision getUploadAPI makes when picking + * the API instance, and it determines which items can be paused/resumed in place + * versus cancelled and restarted from scratch. + * + * @param {UploadItem} item - The upload item + * @return {boolean} + */ + getIsChunkedUpload = (item: UploadItem): boolean => { + const { chunked } = this.props; + const { file, isFolder } = item; + return Boolean( + chunked && !isFolder && file && file.size > CHUNKED_UPLOAD_MIN_SIZE_BYTES && isMultiputSupported(), + ); + }; + /** * Updates item based on its status. * @@ -1263,11 +1291,9 @@ class ContentUploader extends Component { * @return {void} */ onClick = (item: UploadItem) => { - const { chunked, isResumableUploadsEnabled, onClickCancel, onClickResume, onClickRetry } = this.props; - const { file, status, error } = item; - const isChunkedUpload = - chunked && !item.isFolder && file.size > CHUNKED_UPLOAD_MIN_SIZE_BYTES && isMultiputSupported(); - const isResumable = isResumableUploadsEnabled && isChunkedUpload && item.api.sessionId; + const { isResumableUploadsEnabled, onClickCancel, onClickResume, onClickRetry } = this.props; + const { status, error } = item; + const isResumable = isResumableUploadsEnabled && this.getIsChunkedUpload(item) && item.api.sessionId; switch (status) { case STATUS_IN_PROGRESS: @@ -1338,19 +1364,68 @@ class ContentUploader extends Component { }; /** - * Open the Cancel All confirmation modal. Wired as the onCancelAll prop - * passed to the modernized uploads manager so the action requires explicit - * confirmation before destroying in-progress uploads. + * Freeze in-progress uploads while the Cancel All confirmation modal is open. + * Chunked uploads are paused so they can resume from the same session; plain + * uploads have no partial server state, so they are cancelled in the background + * and restarted from scratch if the user chooses to keep uploading. Item status + * and progress are intentionally left untouched so the rows stay frozen in place. + */ + pauseUploadsForCancelAll = () => { + this.itemsRef.current.forEach(item => { + // Folder containers have no partial state and their child files are + // tracked as separate queue items handled by this same loop. + if (item.status !== STATUS_IN_PROGRESS || item.isFolder) { + return; + } + const { api } = item; + if (this.getIsChunkedUpload(item)) { + api.pause(); + } else { + api.cancel(); + } + }); + this.isCancelAllPaused = true; + }; + + /** + * Resume uploads frozen by pauseUploadsForCancelAll when the user dismisses the + * modal. Chunked uploads continue from their session; plain uploads (cancelled in + * the background) are restarted from scratch. Pending items that never started are + * picked up by the trailing upload() call. + */ + resumeUploadsForCancelAll = () => { + this.isCancelAllPaused = false; + this.itemsRef.current.forEach(item => { + if (item.status !== STATUS_IN_PROGRESS || item.isFolder) { + return; + } + if (this.getIsChunkedUpload(item)) { + item.api.unpause(); + } else { + this.resetFile(item); + this.uploadFile(item); + } + }); + this.upload(); + }; + + /** + * Open the Cancel All confirmation modal and freeze in-progress uploads. Wired as + * the onCancelAll prop passed to the modernized uploads manager so the action + * requires explicit confirmation before destroying in-progress uploads. */ handleCancelAllClick = () => { + this.pauseUploadsForCancelAll(); this.setState({ isCancelAllModalOpen: true }); }; handleCancelAllDismiss = () => { this.setState({ isCancelAllModalOpen: false }); + this.resumeUploadsForCancelAll(); }; handleCancelAllConfirm = () => { + this.isCancelAllPaused = false; this.setState({ isCancelAllModalOpen: false }); this.handleUploadsManagerCancelAll(); }; @@ -1373,17 +1448,15 @@ class ContentUploader extends Component { * chunked uploads, otherwise restart from scratch. Shared by RetryAll and per-item retry. */ retryUploadsManagerItem = (item: UploadItem) => { - const { chunked, isResumableUploadsEnabled, onClickCancel, onClickResume, onClickRetry } = this.props; - const { file, api, error } = item; + const { isResumableUploadsEnabled, onClickCancel, onClickResume, onClickRetry } = this.props; + const { api, error } = item; // Name-in-use cannot be resolved by retrying — drop the item like the legacy onClick path. if (error?.code === ERROR_CODE_ITEM_NAME_IN_USE) { this.removeFileFromUploadQueue(item); onClickCancel(item); return; } - const isChunkedUpload = - chunked && !item.isFolder && file.size > CHUNKED_UPLOAD_MIN_SIZE_BYTES && isMultiputSupported(); - const isResumable = isResumableUploadsEnabled && isChunkedUpload && api?.sessionId; + const isResumable = isResumableUploadsEnabled && this.getIsChunkedUpload(item) && api?.sessionId; if (isResumable) { item.bytesUploadedOnLastResume = api.totalUploadedBytes; this.resumeFile(item); diff --git a/src/elements/content-uploader/__tests__/ContentUploader.test.js b/src/elements/content-uploader/__tests__/ContentUploader.test.js index 354669af96..c40ec297df 100644 --- a/src/elements/content-uploader/__tests__/ContentUploader.test.js +++ b/src/elements/content-uploader/__tests__/ContentUploader.test.js @@ -1301,25 +1301,62 @@ describe('elements/content-uploader/ContentUploader', () => { expect(folderItem.status).toBe(STATUS_CANCELED); }); - test('onCancelAll should open the confirmation modal instead of canceling directly', () => { + test('onCancelAll should open the modal and freeze uploads without finalizing the cancel', () => { + jest.spyOn(UploaderUtils, 'isMultiputSupported').mockImplementation(() => true); const wrapper = getWrapper({ enableModernizedUploads: true }); - const inProgress = { + const chunked = { + name: 'big.mov', + extension: 'mov', + progress: 40, + status: STATUS_IN_PROGRESS, + file: { name: 'big.mov', size: CHUNKED_UPLOAD_MIN_SIZE_BYTES + 1 }, + api: { cancel: jest.fn(), pause: jest.fn(), unpause: jest.fn() }, + }; + const plain = { name: 'a.pdf', extension: 'pdf', progress: 25, status: STATUS_IN_PROGRESS, - file: { name: 'a.pdf' }, + file: { name: 'a.pdf', size: 1000 }, api: { cancel: jest.fn() }, }; - wrapper.setState({ items: [inProgress] }); + wrapper.setState({ items: [chunked, plain] }); const instance = wrapper.instance(); - instance.itemsRef.current = [inProgress]; + instance.itemsRef.current = [chunked, plain]; wrapper.find(UploadsManagerBP).prop('onCancelAll')(); expect(wrapper.state('isCancelAllModalOpen')).toBe(true); - expect(inProgress.status).toBe(STATUS_IN_PROGRESS); - expect(inProgress.api.cancel).not.toHaveBeenCalled(); + expect(instance.isCancelAllPaused).toBe(true); + // Chunked uploads are paused (resumable); plain uploads are cancelled in the background. + expect(chunked.api.pause).toHaveBeenCalled(); + expect(chunked.api.cancel).not.toHaveBeenCalled(); + expect(plain.api.cancel).toHaveBeenCalled(); + // Status and progress stay frozen so the rows do not change while the modal is open. + expect(chunked.status).toBe(STATUS_IN_PROGRESS); + expect(chunked.progress).toBe(40); + expect(plain.status).toBe(STATUS_IN_PROGRESS); + expect(plain.progress).toBe(25); + }); + + test('onCancelAll should not pause folder container items', () => { + const wrapper = getWrapper({ enableModernizedUploads: true, rootFolderId: '0' }); + const folder = { + name: 'my-folder', + extension: '', + progress: 30, + status: STATUS_IN_PROGRESS, + isFolder: true, + api: { cancel: jest.fn() }, + }; + wrapper.setState({ items: [folder] }); + const instance = wrapper.instance(); + instance.itemsRef.current = [folder]; + + instance.handleCancelAllClick(); + + expect(folder.api.cancel).not.toHaveBeenCalled(); + expect(folder.status).toBe(STATUS_IN_PROGRESS); }); test('handleCancelAllConfirm should cancel all in-progress and pending items and close modal', () => { @@ -1363,23 +1400,79 @@ describe('elements/content-uploader/ContentUploader', () => { expect(complete.api.cancel).not.toHaveBeenCalled(); }); - test('handleCancelAllDismiss should close the modal without canceling uploads', () => { + test('handleCancelAllDismiss should close the modal and resume frozen uploads', () => { + jest.spyOn(UploaderUtils, 'isMultiputSupported').mockImplementation(() => true); const wrapper = getWrapper({ enableModernizedUploads: true }); - const inProgress = { + const chunked = { + name: 'big.mov', + status: STATUS_IN_PROGRESS, + file: { name: 'big.mov', size: CHUNKED_UPLOAD_MIN_SIZE_BYTES + 1 }, + api: { cancel: jest.fn(), pause: jest.fn(), unpause: jest.fn() }, + }; + const plain = { name: 'a.pdf', status: STATUS_IN_PROGRESS, - file: { name: 'a.pdf' }, + file: { name: 'a.pdf', size: 1000 }, api: { cancel: jest.fn() }, }; - wrapper.setState({ items: [inProgress], isCancelAllModalOpen: true }); + wrapper.setState({ items: [chunked, plain], isCancelAllModalOpen: true }); const instance = wrapper.instance(); - instance.itemsRef.current = [inProgress]; + instance.itemsRef.current = [chunked, plain]; + instance.isCancelAllPaused = true; + instance.resetFile = jest.fn(); + instance.uploadFile = jest.fn(); instance.handleCancelAllDismiss(); expect(wrapper.state('isCancelAllModalOpen')).toBe(false); - expect(inProgress.status).toBe(STATUS_IN_PROGRESS); - expect(inProgress.api.cancel).not.toHaveBeenCalled(); + expect(instance.isCancelAllPaused).toBe(false); + // Chunked uploads continue from their session; plain uploads restart from scratch. + expect(chunked.api.unpause).toHaveBeenCalled(); + expect(instance.resetFile).toHaveBeenCalledWith(plain); + expect(instance.uploadFile).toHaveBeenCalledWith(plain); + // Items were never marked canceled. + expect(chunked.status).toBe(STATUS_IN_PROGRESS); + expect(plain.status).toBe(STATUS_IN_PROGRESS); + }); + + test('handleCancelAllConfirm should finalize the cancel even after pausing', () => { + jest.spyOn(UploaderUtils, 'isMultiputSupported').mockImplementation(() => true); + const wrapper = getWrapper({ enableModernizedUploads: true }); + const chunked = { + name: 'big.mov', + status: STATUS_IN_PROGRESS, + file: { name: 'big.mov', size: CHUNKED_UPLOAD_MIN_SIZE_BYTES + 1 }, + api: { cancel: jest.fn(), pause: jest.fn(), unpause: jest.fn() }, + }; + wrapper.setState({ items: [chunked], isCancelAllModalOpen: true }); + const instance = wrapper.instance(); + instance.itemsRef.current = [chunked]; + instance.handleCancelAllClick(); + + instance.handleCancelAllConfirm(); + + expect(instance.isCancelAllPaused).toBe(false); + expect(wrapper.state('isCancelAllModalOpen')).toBe(false); + expect(chunked.status).toBe(STATUS_CANCELED); + expect(chunked.api.cancel).toHaveBeenCalled(); + }); + + test('upload() should not start pending items while paused for cancel-all', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + const pending = { + name: 'a.pdf', + status: STATUS_PENDING, + file: { name: 'a.pdf' }, + api: { cancel: jest.fn() }, + }; + const instance = wrapper.instance(); + instance.itemsRef.current = [pending]; + instance.uploadFile = jest.fn(); + instance.isCancelAllPaused = true; + + instance.upload(); + + expect(instance.uploadFile).not.toHaveBeenCalled(); }); describe('updateViewAndCollection with canceled items', () => {