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 {