From 884a7b333fa199e4c73722267e8d25057424c109 Mon Sep 17 00:00:00 2001 From: Hleb Kryshyn Date: Wed, 20 May 2026 17:12:51 +0200 Subject: [PATCH] feat(uploads-manager): implement Cancel All and Retry All handlers Add STATUS_CANCELED constant. ContentUploader gains handleCancelAllUploads, handleRetryAllUploads, plus per-item cancel and retry handlers used by the modernized uploads manager. Canceled items keep their entry in the list rather than being removed. All behavior is gated on the enableModernizedUploads flag; the legacy flow is unchanged. --- src/common/types/upload.js | 12 +- src/constants.js | 1 + .../content-uploader/ContentUploader.tsx | 96 +++++++++++++- .../__tests__/ContentUploader.test.js | 119 ++++++++++++++++-- .../mapToModernizedUploadItem.test.ts | 2 + .../utils/mapToModernizedUploadItem.ts | 2 + 6 files changed, 218 insertions(+), 14 deletions(-) diff --git a/src/common/types/upload.js b/src/common/types/upload.js index 5a080033c6..a1402b5395 100644 --- a/src/common/types/upload.js +++ b/src/common/types/upload.js @@ -1,5 +1,12 @@ // @flow -import { STATUS_PENDING, STATUS_IN_PROGRESS, STATUS_STAGED, STATUS_COMPLETE, STATUS_ERROR } from '../../constants'; +import { + STATUS_PENDING, + STATUS_IN_PROGRESS, + STATUS_STAGED, + STATUS_COMPLETE, + STATUS_ERROR, + STATUS_CANCELED, +} from '../../constants'; import type { Token, BoxItem } from './core'; type UploadStatus = @@ -7,7 +14,8 @@ type UploadStatus = | typeof STATUS_IN_PROGRESS | typeof STATUS_STAGED | typeof STATUS_COMPLETE - | typeof STATUS_ERROR; + | typeof STATUS_ERROR + | typeof STATUS_CANCELED; type FileSystemFileEntry = { createReader: Function, diff --git a/src/constants.js b/src/constants.js index 0f8a4b8777..b3a8385625 100644 --- a/src/constants.js +++ b/src/constants.js @@ -247,6 +247,7 @@ export const CLIENT_VERSION = __VERSION__; /* ---------------------- Statuses -------------------------- */ export const STATUS_ACCEPTED: 'accepted' = 'accepted'; +export const STATUS_CANCELED: 'canceled' = 'canceled'; export const STATUS_COMPLETE: 'complete' = 'complete'; export const STATUS_ERROR: 'error' = 'error'; export const STATUS_INACTIVE: 'inactive' = 'inactive'; diff --git a/src/elements/content-uploader/ContentUploader.tsx b/src/elements/content-uploader/ContentUploader.tsx index 1dfda498a5..592f660c6d 100644 --- a/src/elements/content-uploader/ContentUploader.tsx +++ b/src/elements/content-uploader/ContentUploader.tsx @@ -41,6 +41,7 @@ import { DEFAULT_HOSTNAME_UPLOAD, ERROR_CODE_ITEM_NAME_IN_USE, ERROR_CODE_UPLOAD_FILE_LIMIT, + STATUS_CANCELED, STATUS_COMPLETE, STATUS_ERROR, STATUS_IN_PROGRESS, @@ -1157,6 +1158,61 @@ class ContentUploader extends Component { }); }; + /** + * Mark a single in-progress or pending item as canceled without removing + * it from the queue. Used by the modernized uploads manager flow so that + * canceled items remain visible in the list. + */ + markItemCanceled = (item: UploadItem) => { + const { onClickCancel } = this.props; + const { api } = item; + if (api && typeof api.cancel === 'function') { + api.cancel(); + } + item.status = STATUS_CANCELED; + onClickCancel(item); + }; + + /** + * Cancel every pending or in-progress upload at once. Items keep their row + * in the list with the canceled status. Only used by the modernized flow. + */ + handleCancelAllUploads = () => { + const cancelable: UploadItem[] = this.itemsRef.current.filter( + item => item.status === STATUS_PENDING || item.status === STATUS_IN_PROGRESS, + ); + cancelable.forEach(item => this.markItemCanceled(item)); + if (cancelable.length > 0) { + const { onCancel } = this.props; + onCancel(cancelable); + this.updateViewAndCollection([...this.itemsRef.current]); + } + }; + + /** + * Retry every errored or canceled item. Resumable items (with a sessionId) + * are resumed; everything else is restarted via resetFile + uploadFile. + */ + handleRetryAllUploads = () => { + const { chunked, isResumableUploadsEnabled } = this.props; + this.itemsRef.current.forEach(item => { + if (item.status !== STATUS_ERROR && item.status !== STATUS_CANCELED) { + return; + } + const { file, api } = item; + const isChunkedUpload = + chunked && !item.isFolder && file.size > CHUNKED_UPLOAD_MIN_SIZE_BYTES && isMultiputSupported(); + const isResumable = isResumableUploadsEnabled && isChunkedUpload && api && api.sessionId; + if (isResumable) { + item.bytesUploadedOnLastResume = api.totalUploadedBytes; + this.resumeFile(item); + } else { + this.resetFile(item); + this.uploadFile(item); + } + }); + }; + /** * Expands the upload manager * @@ -1228,10 +1284,38 @@ class ContentUploader extends Component { return this.state.items.find(item => getFileId(item.file, rootFolderId) === id); }; - handleModernizedItemAction = (id: string) => { + handleModernizedItemCancel = (id: string) => { const item = this.findItemByModernizedId(id); - if (item) { - this.onClick(item); + if (!item) { + return; + } + if (item.status === STATUS_PENDING || item.status === STATUS_IN_PROGRESS) { + this.markItemCanceled(item); + this.updateViewAndCollection([...this.itemsRef.current]); + } + }; + + handleModernizedItemRetry = (id: string) => { + const item = this.findItemByModernizedId(id); + if (!item) { + return; + } + const { chunked, isResumableUploadsEnabled, onClickResume, onClickRetry } = this.props; + const { file, api, status } = item; + if (status !== STATUS_ERROR && status !== STATUS_CANCELED) { + return; + } + const isChunkedUpload = + chunked && !item.isFolder && file.size > CHUNKED_UPLOAD_MIN_SIZE_BYTES && isMultiputSupported(); + const isResumable = isResumableUploadsEnabled && isChunkedUpload && api && api.sessionId; + if (isResumable) { + item.bytesUploadedOnLastResume = api.totalUploadedBytes; + this.resumeFile(item); + onClickResume(item); + } else { + this.resetFile(item); + this.uploadFile(item); + onClickRetry(item); } }; @@ -1331,9 +1415,11 @@ class ContentUploader extends Component { items={mapToModernizedUploadItems(items, rootFolderId)} isExpanded={isUploadsManagerExpanded} onToggle={this.toggleUploadsManager} - onItemCancel={this.handleModernizedItemAction} - onItemRetry={this.handleModernizedItemAction} + onItemCancel={this.handleModernizedItemCancel} + onItemRetry={this.handleModernizedItemRetry} onItemRemove={this.handleModernizedItemRemove} + onCancelAll={this.handleCancelAllUploads} + onRetryAll={this.handleRetryAllUploads} /> ); diff --git a/src/elements/content-uploader/__tests__/ContentUploader.test.js b/src/elements/content-uploader/__tests__/ContentUploader.test.js index 2bc646ada7..9924c5a2d5 100644 --- a/src/elements/content-uploader/__tests__/ContentUploader.test.js +++ b/src/elements/content-uploader/__tests__/ContentUploader.test.js @@ -815,22 +815,46 @@ describe('elements/content-uploader/ContentUploader', () => { expect(wrapper.find(UploadsManagerBP).prop('isExpanded')).toBe(true); }); - test('should call onClick when onItemCancel is invoked', () => { + test('should mark in-progress item as canceled when onItemCancel is invoked', () => { const wrapper = getWrapper({ enableModernizedUploads: true }); + const cancelMock = jest.fn(); const item = { name: 'foo.pdf', extension: 'pdf', - progress: 0, - status: STATUS_PENDING, + progress: 50, + status: STATUS_IN_PROGRESS, + file: { name: 'foo.pdf' }, + api: { cancel: cancelMock }, + }; + wrapper.setState({ items: [item] }); + const instance = wrapper.instance(); + instance.itemsRef.current = [item]; + + wrapper.find(UploadsManagerBP).prop('onItemCancel')('foo.pdf'); + + expect(cancelMock).toHaveBeenCalled(); + expect(item.status).toBe('canceled'); + }); + + test('should ignore onItemCancel for already-completed items', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + const cancelMock = jest.fn(); + const item = { + name: 'foo.pdf', + extension: 'pdf', + progress: 100, + status: STATUS_COMPLETE, file: { name: 'foo.pdf' }, + api: { cancel: cancelMock }, }; wrapper.setState({ items: [item] }); const instance = wrapper.instance(); - const onClickSpy = jest.spyOn(instance, 'onClick').mockImplementation(() => {}); + instance.itemsRef.current = [item]; wrapper.find(UploadsManagerBP).prop('onItemCancel')('foo.pdf'); - expect(onClickSpy).toHaveBeenCalledWith(item); + expect(cancelMock).not.toHaveBeenCalled(); + expect(item.status).toBe(STATUS_COMPLETE); }); test('should call removeFileFromUploadQueue when onItemRemove is invoked', () => { @@ -855,11 +879,92 @@ describe('elements/content-uploader/ContentUploader', () => { const wrapper = getWrapper({ enableModernizedUploads: true }); wrapper.setState({ items: [] }); const instance = wrapper.instance(); - const onClickSpy = jest.spyOn(instance, 'onClick').mockImplementation(() => {}); + instance.itemsRef.current = []; + const cancelMock = jest.fn(); wrapper.find(UploadsManagerBP).prop('onItemCancel')('missing-id'); - expect(onClickSpy).not.toHaveBeenCalled(); + expect(cancelMock).not.toHaveBeenCalled(); + }); + + test('handleCancelAllUploads should cancel all in-progress and pending items', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + const inProgress = { + name: 'a.pdf', + extension: 'pdf', + progress: 25, + status: STATUS_IN_PROGRESS, + file: { name: 'a.pdf' }, + api: { cancel: jest.fn() }, + }; + const pending = { + name: 'b.pdf', + extension: 'pdf', + progress: 0, + status: STATUS_PENDING, + file: { name: 'b.pdf' }, + api: { cancel: jest.fn() }, + }; + const complete = { + name: 'c.pdf', + extension: 'pdf', + progress: 100, + status: STATUS_COMPLETE, + file: { name: 'c.pdf' }, + api: { cancel: jest.fn() }, + }; + wrapper.setState({ items: [inProgress, pending, complete] }); + const instance = wrapper.instance(); + instance.itemsRef.current = [inProgress, pending, complete]; + + wrapper.find(UploadsManagerBP).prop('onCancelAll')(); + + expect(inProgress.status).toBe('canceled'); + expect(pending.status).toBe('canceled'); + expect(complete.status).toBe(STATUS_COMPLETE); + expect(inProgress.api.cancel).toHaveBeenCalled(); + expect(pending.api.cancel).toHaveBeenCalled(); + expect(complete.api.cancel).not.toHaveBeenCalled(); + }); + + test('handleRetryAllUploads should restart errored and canceled items', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + const errored = { + name: 'a.pdf', + extension: 'pdf', + progress: 0, + status: STATUS_ERROR, + file: { name: 'a.pdf', size: 100 }, + api: {}, + isFolder: false, + }; + const canceled = { + name: 'b.pdf', + extension: 'pdf', + progress: 0, + status: 'canceled', + file: { name: 'b.pdf', size: 100 }, + api: {}, + isFolder: false, + }; + const complete = { + name: 'c.pdf', + extension: 'pdf', + progress: 100, + status: STATUS_COMPLETE, + file: { name: 'c.pdf', size: 100 }, + api: {}, + }; + wrapper.setState({ items: [errored, canceled, complete] }); + const instance = wrapper.instance(); + instance.itemsRef.current = [errored, canceled, complete]; + const resetSpy = jest.spyOn(instance, 'resetFile').mockImplementation(() => {}); + const uploadFileSpy = jest.spyOn(instance, 'uploadFile').mockImplementation(() => {}); + + wrapper.find(UploadsManagerBP).prop('onRetryAll')(); + + expect(resetSpy).toHaveBeenCalledTimes(2); + expect(uploadFileSpy).toHaveBeenCalledTimes(2); }); }); }); diff --git a/src/elements/content-uploader/utils/__tests__/mapToModernizedUploadItem.test.ts b/src/elements/content-uploader/utils/__tests__/mapToModernizedUploadItem.test.ts index 4d521f8d86..142c438c10 100644 --- a/src/elements/content-uploader/utils/__tests__/mapToModernizedUploadItem.test.ts +++ b/src/elements/content-uploader/utils/__tests__/mapToModernizedUploadItem.test.ts @@ -4,6 +4,7 @@ import { STATUS_STAGED, STATUS_COMPLETE, STATUS_ERROR, + STATUS_CANCELED, } from '../../../../constants'; import { mapToModernizedUploadItem, mapToModernizedUploadItems } from '../mapToModernizedUploadItem'; @@ -38,6 +39,7 @@ describe('mapToModernizedUploadItem()', () => { [STATUS_STAGED, 'staged'], [STATUS_COMPLETE, 'complete'], [STATUS_ERROR, 'error'], + [STATUS_CANCELED, 'canceled'], ])('maps legacy status %s to modernized %s', (legacy, modernized) => { const result = mapToModernizedUploadItem(buildLegacyItem({ status: legacy }), '0'); expect(result.status).toBe(modernized); diff --git a/src/elements/content-uploader/utils/mapToModernizedUploadItem.ts b/src/elements/content-uploader/utils/mapToModernizedUploadItem.ts index c98389f974..f5fe771901 100644 --- a/src/elements/content-uploader/utils/mapToModernizedUploadItem.ts +++ b/src/elements/content-uploader/utils/mapToModernizedUploadItem.ts @@ -4,6 +4,7 @@ import { STATUS_STAGED, STATUS_COMPLETE, STATUS_ERROR, + STATUS_CANCELED, } from '../../../constants'; import { getFileId } from '../../../utils/uploads'; import { UploadItem as LegacyUploadItem } from '../../../common/types/upload'; @@ -26,6 +27,7 @@ const STATUS_MAP: Record = { [STATUS_STAGED]: 'staged', [STATUS_COMPLETE]: 'complete', [STATUS_ERROR]: 'error', + [STATUS_CANCELED]: 'canceled', }; export function mapToModernizedUploadItem(item: LegacyUploadItem, rootFolderId: string): ModernizedUploadItem {