diff --git a/src/elements/content-uploader/ContentUploader.tsx b/src/elements/content-uploader/ContentUploader.tsx index 4dabc98f51..b058a628c5 100644 --- a/src/elements/content-uploader/ContentUploader.tsx +++ b/src/elements/content-uploader/ContentUploader.tsx @@ -977,22 +977,41 @@ class ContentUploader extends Component { */ updateViewAndCollection(items: UploadItem[], callback?: () => void) { const { + enableModernizedUploads, isPartialUploadEnabled, isResumableUploadsEnabled, onComplete, useUploadsManager, }: ContentUploaderProps = this.props; - const someUploadIsInProgress = items.some(uploadItem => uploadItem.status !== STATUS_COMPLETE); + // When the modernized flow is on, canceled items are kept in the list + // but treated as terminal so they do not block completion logic. The + // legacy view/state machine sees canceled items as non-COMPLETE which + // would otherwise stall progress and emit a stale success notification. + const isTerminalForModernized = (uploadItem: UploadItem) => + enableModernizedUploads && uploadItem.status === STATUS_CANCELED; + const someUploadIsInProgress = items.some( + uploadItem => uploadItem.status !== STATUS_COMPLETE && !isTerminalForModernized(uploadItem), + ); const someUploadHasFailed = items.some(uploadItem => uploadItem.status === STATUS_ERROR); const allItemsArePending = !items.some(uploadItem => uploadItem.status !== STATUS_PENDING); const noFileIsPendingOrInProgress = items.every( uploadItem => uploadItem.status !== STATUS_PENDING && uploadItem.status !== STATUS_IN_PROGRESS, ); const areAllItemsFinished = items.every( - uploadItem => uploadItem.status === STATUS_COMPLETE || uploadItem.status === STATUS_ERROR, + uploadItem => + uploadItem.status === STATUS_COMPLETE || + uploadItem.status === STATUS_ERROR || + isTerminalForModernized(uploadItem), ); const uploadItemsStatus = isResumableUploadsEnabled ? areAllItemsFinished : noFileIsPendingOrInProgress; + // In the modernized flow, suppress the completion notification when + // no item actually finished successfully (e.g. user canceled every + // upload). This prevents a misleading "upload complete" toast/callback + // from any of the onComplete call sites below. + const hasCompletedItem = items.some(uploadItem => uploadItem.status === STATUS_COMPLETE); + const shouldFireOnComplete = !enableModernizedUploads || hasCompletedItem; + let view = ''; if ((items && items.length === 0) || allItemsArePending) { view = VIEW_UPLOAD_EMPTY; @@ -1001,7 +1020,9 @@ class ContentUploader extends Component { view = VIEW_UPLOAD_SUCCESS; if (!useUploadsManager) { - onComplete(cloneDeep(filesToBeUploaded.map(item => item.boxFile))); + if (shouldFireOnComplete) { + onComplete(cloneDeep(filesToBeUploaded.map(item => item.boxFile))); + } // Reset item collection after successful upload items = []; } @@ -1013,7 +1034,9 @@ class ContentUploader extends Component { view = VIEW_UPLOAD_SUCCESS; if (!useUploadsManager) { - onComplete(cloneDeep(items.map(item => item.boxFile))); + if (shouldFireOnComplete) { + onComplete(cloneDeep(items.map(item => item.boxFile))); + } // Reset item collection after successful upload items = []; } @@ -1023,7 +1046,9 @@ class ContentUploader extends Component { if (this.isAutoExpanded) { this.resetUploadManagerExpandState(); } // Else manually expanded so don't close - onComplete(items); + if (shouldFireOnComplete) { + onComplete(items); + } } const state: Partial = { diff --git a/src/elements/content-uploader/__tests__/ContentUploader.test.js b/src/elements/content-uploader/__tests__/ContentUploader.test.js index 4682cdf902..664346e49d 100644 --- a/src/elements/content-uploader/__tests__/ContentUploader.test.js +++ b/src/elements/content-uploader/__tests__/ContentUploader.test.js @@ -1026,6 +1026,72 @@ describe('elements/content-uploader/ContentUploader', () => { expect(complete.api.cancel).not.toHaveBeenCalled(); }); + describe('updateViewAndCollection with canceled items', () => { + let onComplete; + let wrapper; + let instance; + + beforeEach(() => { + onComplete = jest.fn(); + wrapper = getWrapper({ + enableModernizedUploads: true, + useUploadsManager: true, + onComplete, + }); + instance = wrapper.instance(); + }); + + test('should not fire onComplete when all items are canceled (modernized)', () => { + instance.updateViewAndCollection([ + { status: STATUS_CANCELED, file: { name: 'a' } }, + { status: STATUS_CANCELED, file: { name: 'b' } }, + ]); + expect(onComplete).not.toHaveBeenCalled(); + }); + + test('should fire onComplete when at least one item completes (modernized)', () => { + instance.updateViewAndCollection([ + { status: STATUS_COMPLETE, file: { name: 'a' } }, + { status: STATUS_CANCELED, file: { name: 'b' } }, + ]); + expect(onComplete).toHaveBeenCalled(); + }); + + test('should treat canceled items as terminal and resolve to VIEW_UPLOAD_SUCCESS (modernized)', () => { + instance.updateViewAndCollection([ + { status: STATUS_COMPLETE, file: { name: 'a' } }, + { status: STATUS_CANCELED, file: { name: 'b' } }, + ]); + expect(wrapper.state('view')).toBe(VIEW_UPLOAD_SUCCESS); + }); + + test('should not fire onComplete on the partial-upload path when all items are canceled (modernized, no manager)', () => { + onComplete = jest.fn(); + wrapper = getWrapper({ + enableModernizedUploads: true, + isPartialUploadEnabled: true, + useUploadsManager: false, + onComplete, + }); + wrapper.instance().updateViewAndCollection([ + { status: STATUS_CANCELED, file: { name: 'a' } }, + { status: STATUS_CANCELED, file: { name: 'b' } }, + ]); + expect(onComplete).not.toHaveBeenCalled(); + }); + + test('should preserve legacy behavior when modernized flag is off', () => { + onComplete = jest.fn(); + wrapper = getWrapper({ + enableModernizedUploads: false, + useUploadsManager: true, + onComplete, + }); + wrapper.instance().updateViewAndCollection([{ status: STATUS_COMPLETE, file: { name: 'a' } }]); + expect(onComplete).toHaveBeenCalled(); + }); + }); + test('handleUploadsManagerRetryAll should restart errored and canceled items', () => { const onClickRetry = jest.fn(); const wrapper = getWrapper({ enableModernizedUploads: true, onClickRetry });