diff --git a/src/common/types/upload.js b/src/common/types/upload.js index b512e30b55..15d710ad81 100644 --- a/src/common/types/upload.js +++ b/src/common/types/upload.js @@ -1,13 +1,22 @@ // @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'; +// TODO: replace with `UploadItemStatus` from @box/uploads-manager once 'inprogress' is aligned to 'uploading'. type UploadStatus = | typeof STATUS_PENDING | 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 218f306964..ba71f07d1d 100644 --- a/src/constants.js +++ b/src/constants.js @@ -250,6 +250,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 9df47ab4f1..4dabc98f51 100644 --- a/src/elements/content-uploader/ContentUploader.tsx +++ b/src/elements/content-uploader/ContentUploader.tsx @@ -42,6 +42,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, @@ -1168,6 +1169,72 @@ 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; + item.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. + */ + handleUploadsManagerCancelAll = () => { + const { onCancel } = this.props; + const cancelable: UploadItem[] = this.itemsRef.current.filter( + item => item.status === STATUS_PENDING || item.status === STATUS_IN_PROGRESS, + ); + cancelable.forEach(item => this.markItemCanceled(item)); + 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. + * Only used by the modernized flow. + */ + handleUploadsManagerRetryAll = () => { + // Snapshot since removeFileFromUploadQueue mutates itemsRef.current. + const targets = this.itemsRef.current.filter( + item => item.status === STATUS_ERROR || item.status === STATUS_CANCELED, + ); + targets.forEach(item => this.retryUploadsManagerItem(item)); + }; + + /** + * Retry a single errored or canceled item: drop name-in-use, resume resumable + * 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; + // 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; + if (isResumable) { + item.bytesUploadedOnLastResume = api.totalUploadedBytes; + this.resumeFile(item); + onClickResume(item); + } else { + this.resetFile(item); + this.uploadFile(item); + onClickRetry(item); + } + }; + /** * Expands the upload manager * @@ -1239,24 +1306,40 @@ class ContentUploader extends Component { return this.state.items.find(item => getUploadItemKey(item, rootFolderId) === key); }; - handleModernizedItemAction = (key: string) => { + handleUploadsManagerItemCancel = (key: string) => { const item = this.findItemByUploadKey(key); - if (item) { - this.onClick(item); - } else { + if (!item) { // eslint-disable-next-line no-console - console.warn(`ContentUploader: no upload item found for key "${key}" on action.`); + console.warn(`ContentUploader: no upload item found for key "${key}" on cancel.`); + return; + } + if (item.status === STATUS_PENDING || item.status === STATUS_IN_PROGRESS) { + this.markItemCanceled(item); + this.updateViewAndCollection([...this.itemsRef.current]); } }; - handleModernizedItemRemove = (key: string) => { + handleUploadsManagerItemRetry = (key: string) => { const item = this.findItemByUploadKey(key); - if (item) { - this.removeFileFromUploadQueue(item); - } else { + if (!item) { + // eslint-disable-next-line no-console + console.warn(`ContentUploader: no upload item found for key "${key}" on retry.`); + return; + } + if (item.status !== STATUS_ERROR && item.status !== STATUS_CANCELED) { + return; + } + this.retryUploadsManagerItem(item); + }; + + handleUploadsManagerItemRemove = (key: string) => { + const item = this.findItemByUploadKey(key); + if (!item) { // eslint-disable-next-line no-console console.warn(`ContentUploader: no upload item found for key "${key}" on remove.`); + return; } + this.removeFileFromUploadQueue(item); }; /** @@ -1348,9 +1431,11 @@ class ContentUploader extends Component { items={mapToModernizedUploadItems(items, rootFolderId)} isExpanded={isUploadsManagerExpanded} onToggle={this.toggleUploadsManager} - onItemCancel={this.handleModernizedItemAction} - onItemRetry={this.handleModernizedItemAction} - onItemRemove={this.handleModernizedItemRemove} + onItemCancel={this.handleUploadsManagerItemCancel} + onItemRetry={this.handleUploadsManagerItemRetry} + onItemRemove={this.handleUploadsManagerItemRemove} + onCancelAll={this.handleUploadsManagerCancelAll} + onRetryAll={this.handleUploadsManagerRetryAll} /> ); diff --git a/src/elements/content-uploader/__tests__/ContentUploader.test.js b/src/elements/content-uploader/__tests__/ContentUploader.test.js index 2658b7414c..4682cdf902 100644 --- a/src/elements/content-uploader/__tests__/ContentUploader.test.js +++ b/src/elements/content-uploader/__tests__/ContentUploader.test.js @@ -8,11 +8,13 @@ import Footer from '../Footer'; import UploadsManager from '../UploadsManager'; import DroppableContent from '../DroppableContent'; import { + ERROR_CODE_ITEM_NAME_IN_USE, STATUS_PENDING, STATUS_IN_PROGRESS, STATUS_STAGED, STATUS_COMPLETE, STATUS_ERROR, + STATUS_CANCELED, VIEW_ERROR, VIEW_UPLOAD_EMPTY, VIEW_UPLOAD_IN_PROGRESS, @@ -862,22 +864,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(); - const onClickSpy = jest.spyOn(instance, 'onClick').mockImplementation(() => {}); + instance.itemsRef.current = [item]; wrapper.find(UploadsManagerBP).prop('onItemCancel')('foo.pdf'); - expect(onClickSpy).toHaveBeenCalledWith(item); + expect(cancelMock).toHaveBeenCalled(); + expect(item.status).toBe(STATUS_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(); + instance.itemsRef.current = [item]; + + wrapper.find(UploadsManagerBP).prop('onItemCancel')('foo.pdf'); + + expect(cancelMock).not.toHaveBeenCalled(); + expect(item.status).toBe(STATUS_COMPLETE); }); test('should call removeFileFromUploadQueue when onItemRemove is invoked', () => { @@ -899,14 +925,27 @@ describe('elements/content-uploader/ContentUploader', () => { }); test('should no-op when modernized id does not match any item', () => { - const wrapper = getWrapper({ enableModernizedUploads: true }); - wrapper.setState({ items: [] }); + const onClickCancel = jest.fn(); + const wrapper = getWrapper({ enableModernizedUploads: true, onClickCancel }); + const item = { + name: 'foo.pdf', + extension: 'pdf', + progress: 50, + status: STATUS_IN_PROGRESS, + file: { name: 'foo.pdf' }, + api: { cancel: jest.fn() }, + }; + wrapper.setState({ items: [item] }); const instance = wrapper.instance(); - const onClickSpy = jest.spyOn(instance, 'onClick').mockImplementation(() => {}); + instance.itemsRef.current = [item]; + const markSpy = jest.spyOn(instance, 'markItemCanceled'); wrapper.find(UploadsManagerBP).prop('onItemCancel')('missing-id'); - expect(onClickSpy).not.toHaveBeenCalled(); + expect(markSpy).not.toHaveBeenCalled(); + expect(item.api.cancel).not.toHaveBeenCalled(); + expect(onClickCancel).not.toHaveBeenCalled(); + expect(item.status).toBe(STATUS_IN_PROGRESS); }); test('should not crash when state contains a folder item without a file', () => { @@ -935,16 +974,224 @@ describe('elements/content-uploader/ContentUploader', () => { progress: 0, status: STATUS_PENDING, isFolder: true, - api: {}, + api: { cancel: jest.fn() }, }; wrapper.setState({ items: [folderItem] }); const instance = wrapper.instance(); - const onClickSpy = jest.spyOn(instance, 'onClick').mockImplementation(() => {}); + instance.itemsRef.current = [folderItem]; const folderId = wrapper.find(UploadsManagerBP).prop('items')[0].id; wrapper.find(UploadsManagerBP).prop('onItemCancel')(folderId); - expect(onClickSpy).toHaveBeenCalledWith(folderItem); + expect(folderItem.status).toBe(STATUS_CANCELED); + }); + + test('handleUploadsManagerCancelAll 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(STATUS_CANCELED); + expect(pending.status).toBe(STATUS_CANCELED); + expect(complete.status).toBe(STATUS_COMPLETE); + expect(inProgress.api.cancel).toHaveBeenCalled(); + expect(pending.api.cancel).toHaveBeenCalled(); + expect(complete.api.cancel).not.toHaveBeenCalled(); + }); + + test('handleUploadsManagerRetryAll should restart errored and canceled items', () => { + const onClickRetry = jest.fn(); + const wrapper = getWrapper({ enableModernizedUploads: true, onClickRetry }); + 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: 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); + expect(onClickRetry).toHaveBeenCalledTimes(2); + expect(onClickRetry).toHaveBeenCalledWith(errored); + expect(onClickRetry).toHaveBeenCalledWith(canceled); + }); + + test('handleUploadsManagerRetryAll should call onClickResume for resumable chunked items', () => { + const onClickResume = jest.fn(); + const wrapper = getWrapper({ + enableModernizedUploads: true, + chunked: true, + isResumableUploadsEnabled: true, + onClickResume, + }); + const resumable = { + name: 'big.bin', + extension: 'bin', + progress: 25, + status: STATUS_ERROR, + file: { name: 'big.bin', size: CHUNKED_UPLOAD_MIN_SIZE_BYTES + 1 }, + api: { sessionId: 'sess-1', totalUploadedBytes: 1024 }, + isFolder: false, + }; + wrapper.setState({ items: [resumable] }); + const instance = wrapper.instance(); + instance.itemsRef.current = [resumable]; + jest.spyOn(UploaderUtils, 'isMultiputSupported').mockReturnValue(true); + const resumeSpy = jest.spyOn(instance, 'resumeFile').mockImplementation(() => {}); + + wrapper.find(UploadsManagerBP).prop('onRetryAll')(); + + expect(resumeSpy).toHaveBeenCalledWith(resumable); + expect(onClickResume).toHaveBeenCalledWith(resumable); + expect(resumable.bytesUploadedOnLastResume).toBe(1024); + }); + + test('handleUploadsManagerRetryAll should drop name-in-use items instead of retrying', () => { + const onClickCancel = jest.fn(); + const onCancel = jest.fn(); + const wrapper = getWrapper({ enableModernizedUploads: true, onClickCancel, onCancel }); + const conflict = { + name: 'dup.pdf', + extension: 'pdf', + progress: 0, + status: STATUS_ERROR, + error: { code: ERROR_CODE_ITEM_NAME_IN_USE }, + file: { name: 'dup.pdf', size: 100 }, + api: { cancel: jest.fn() }, + isFolder: false, + }; + const ok = { + name: 'ok.pdf', + extension: 'pdf', + progress: 0, + status: STATUS_ERROR, + file: { name: 'ok.pdf', size: 100 }, + api: {}, + isFolder: false, + }; + wrapper.setState({ items: [conflict, ok] }); + const instance = wrapper.instance(); + instance.itemsRef.current = [conflict, ok]; + const resetSpy = jest.spyOn(instance, 'resetFile').mockImplementation(() => {}); + const uploadFileSpy = jest.spyOn(instance, 'uploadFile').mockImplementation(() => {}); + + wrapper.find(UploadsManagerBP).prop('onRetryAll')(); + + expect(onClickCancel).toHaveBeenCalledWith(conflict); + expect(conflict.api.cancel).toHaveBeenCalled(); + expect(instance.itemsRef.current).not.toContain(conflict); + expect(resetSpy).toHaveBeenCalledTimes(1); + expect(resetSpy).toHaveBeenCalledWith(ok); + expect(uploadFileSpy).toHaveBeenCalledWith(ok); + }); + + test('handleUploadsManagerItemRetry should fire onClickRetry for non-resumable items', () => { + const onClickRetry = jest.fn(); + const wrapper = getWrapper({ enableModernizedUploads: true, onClickRetry }); + const item = { + name: 'a.pdf', + extension: 'pdf', + progress: 0, + status: STATUS_ERROR, + file: { name: 'a.pdf', size: 100 }, + api: {}, + isFolder: false, + }; + wrapper.setState({ items: [item] }); + const instance = wrapper.instance(); + instance.itemsRef.current = [item]; + const resetSpy = jest.spyOn(instance, 'resetFile').mockImplementation(() => {}); + const uploadFileSpy = jest.spyOn(instance, 'uploadFile').mockImplementation(() => {}); + + const { id } = wrapper.find(UploadsManagerBP).prop('items')[0]; + wrapper.find(UploadsManagerBP).prop('onItemRetry')(id); + + expect(resetSpy).toHaveBeenCalledWith(item); + expect(uploadFileSpy).toHaveBeenCalledWith(item); + expect(onClickRetry).toHaveBeenCalledWith(item); + }); + + test('handleUploadsManagerItemRetry should drop name-in-use items instead of retrying', () => { + const onClickCancel = jest.fn(); + const wrapper = getWrapper({ enableModernizedUploads: true, onClickCancel }); + const item = { + name: 'dup.pdf', + extension: 'pdf', + progress: 0, + status: STATUS_ERROR, + error: { code: ERROR_CODE_ITEM_NAME_IN_USE }, + file: { name: 'dup.pdf', size: 100 }, + api: { cancel: jest.fn() }, + isFolder: false, + }; + wrapper.setState({ items: [item] }); + const instance = wrapper.instance(); + instance.itemsRef.current = [item]; + const resetSpy = jest.spyOn(instance, 'resetFile').mockImplementation(() => {}); + const uploadFileSpy = jest.spyOn(instance, 'uploadFile').mockImplementation(() => {}); + + const { id } = wrapper.find(UploadsManagerBP).prop('items')[0]; + wrapper.find(UploadsManagerBP).prop('onItemRetry')(id); + + expect(onClickCancel).toHaveBeenCalledWith(item); + expect(item.api.cancel).toHaveBeenCalled(); + expect(instance.itemsRef.current).not.toContain(item); + expect(resetSpy).not.toHaveBeenCalled(); + expect(uploadFileSpy).not.toHaveBeenCalled(); }); }); }); diff --git a/src/elements/content-uploader/utils/__tests__/mapToModernizedUploadItem.test.ts b/src/elements/content-uploader/utils/__tests__/mapToModernizedUploadItem.test.ts index 3376be23a1..cc5ac9aef4 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 3fd0badbdb..95d4d3f6bc 100644 --- a/src/elements/content-uploader/utils/mapToModernizedUploadItem.ts +++ b/src/elements/content-uploader/utils/mapToModernizedUploadItem.ts @@ -1,5 +1,12 @@ import type { UploadItem, UploadItemStatus } from '@box/uploads-manager'; -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 { getFileId } from '../../../utils/uploads'; import { UploadItem as LegacyUploadItem, FolderUploadItem } from '../../../common/types/upload'; @@ -9,6 +16,7 @@ const STATUS_MAP: Record = { [STATUS_STAGED]: 'staged', [STATUS_COMPLETE]: 'complete', [STATUS_ERROR]: 'error', + [STATUS_CANCELED]: 'canceled', }; export function getUploadItemKey(item: LegacyUploadItem | FolderUploadItem, rootFolderId: string): string {