From c9d6a6303b48a022f259af42a948fd4fc3d489d5 Mon Sep 17 00:00:00 2001 From: Hleb Kryshyn Date: Wed, 20 May 2026 11:17:42 +0200 Subject: [PATCH 1/4] feat(uploads-manager): add enableModernizedUploads feature flag to ContentUploader --- .../content-uploader/ContentUploader.tsx | 9 +++++- .../__tests__/ContentUploader.test.js | 30 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/elements/content-uploader/ContentUploader.tsx b/src/elements/content-uploader/ContentUploader.tsx index f177e7cd81..8afe7f9e12 100644 --- a/src/elements/content-uploader/ContentUploader.tsx +++ b/src/elements/content-uploader/ContentUploader.tsx @@ -104,6 +104,7 @@ export interface ContentUploaderProps { token?: Token; uploadHost: string; useUploadsManager?: boolean; + enableModernizedUploads?: boolean; } type State = { @@ -169,6 +170,7 @@ class ContentUploader extends Component { rootFolderId: DEFAULT_ROOT, uploadHost: DEFAULT_HOSTNAME_UPLOAD, useUploadsManager: false, + enableModernizedUploads: false, }; /** @@ -1268,6 +1270,7 @@ class ContentUploader extends Component { render() { const { className, + enableModernizedUploads, fileLimit, isDraggingItemsToUploadsManager = false, isFolderUploadEnabled, @@ -1297,7 +1300,11 @@ class ContentUploader extends Component { return ( - {useUploadsManager ? ( + {enableModernizedUploads ? ( +
+ +
+ ) : useUploadsManager ? (
{ expect(instance.addToQueue.mock.calls[0][0].length).toBe(mockFoldersList.length); }); }); + + describe('render()', () => { + describe('enableModernizedUploads', () => { + test('should render legacy UploadsManager when enableModernizedUploads is false and useUploadsManager is true', () => { + const wrapper = getWrapper({ enableModernizedUploads: false, useUploadsManager: true }); + expect(wrapper.find(UploadsManager)).toHaveLength(1); + expect(wrapper.find(DroppableContent)).toHaveLength(0); + }); + + test('should render DroppableContent when enableModernizedUploads is false and useUploadsManager is false', () => { + const wrapper = getWrapper({ enableModernizedUploads: false, useUploadsManager: false }); + expect(wrapper.find(DroppableContent)).toHaveLength(1); + expect(wrapper.find(UploadsManager)).toHaveLength(0); + }); + + test('should render modernized uploads placeholder when enableModernizedUploads is true', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + expect(wrapper.find(UploadsManager)).toHaveLength(0); + expect(wrapper.find(DroppableContent)).toHaveLength(0); + }); + + test('should render modernized uploads placeholder even when useUploadsManager is true', () => { + const wrapper = getWrapper({ enableModernizedUploads: true, useUploadsManager: true }); + expect(wrapper.find(UploadsManager)).toHaveLength(0); + expect(wrapper.find(DroppableContent)).toHaveLength(0); + }); + }); + }); }); From e7246f108ee86f4f4864912783915ab8107221a9 Mon Sep 17 00:00:00 2001 From: Hleb Kryshyn Date: Wed, 20 May 2026 11:17:42 +0200 Subject: [PATCH 2/4] feat(uploads-manager): add enableModernizedUploads feature flag to ContentUploader --- scripts/jest/jest.config.js | 2 +- .../content-uploader/ContentUploader.tsx | 106 ++++++++++-------- .../stories/ContentUploader.stories.js | 6 + 3 files changed, 67 insertions(+), 47 deletions(-) diff --git a/scripts/jest/jest.config.js b/scripts/jest/jest.config.js index ad447fdb77..4a7f6bb249 100644 --- a/scripts/jest/jest.config.js +++ b/scripts/jest/jest.config.js @@ -28,6 +28,6 @@ module.exports = { testMatch: ['**/__tests__/**/*.test.+(js|jsx|ts|tsx)'], testPathIgnorePatterns: ['stories.test.js$', 'stories.test.tsx$', 'stories.test.d.ts'], transformIgnorePatterns: [ - 'node_modules/(?!(@box/activity-feed|@box/collaboration-popover|@box/react-virtualized/dist/es|@box/cldr-data|@box/blueprint-web|@box/blueprint-web-assets|@box/metadata-editor|@box/box-ai-content-answers|@box/box-ai-agent-selector|@box/item-icon|@box/combobox-with-api|@box/tree|@box/metadata-filter|@box/metadata-view|@box/content-field|@box/types|@box/box-item-type-selector|@box/unified-share-modal|@box/user-selector|@box/copy-input|@box/readable-time|@box/threaded-annotations)/)', + 'node_modules/(?!(@box/activity-feed|@box/collaboration-popover|@box/react-virtualized/dist/es|@box/cldr-data|@box/blueprint-web|@box/blueprint-web-assets|@box/metadata-editor|@box/box-ai-content-answers|@box/box-ai-agent-selector|@box/item-icon|@box/combobox-with-api|@box/tree|@box/metadata-filter|@box/metadata-view|@box/content-field|@box/types|@box/box-item-type-selector|@box/unified-share-modal|@box/user-selector|@box/copy-input|@box/readable-time|@box/threaded-annotations|@box/uploads-manager)/)', ], }; diff --git a/src/elements/content-uploader/ContentUploader.tsx b/src/elements/content-uploader/ContentUploader.tsx index 8afe7f9e12..9923bf3ced 100644 --- a/src/elements/content-uploader/ContentUploader.tsx +++ b/src/elements/content-uploader/ContentUploader.tsx @@ -6,6 +6,7 @@ import flow from 'lodash/flow'; import getProp from 'lodash/get'; import noop from 'lodash/noop'; import uniqueid from 'lodash/uniqueId'; +import { UploadsManager as UploadsManagerBP } from '@box/uploads-manager'; import { TooltipProvider } from '@box/blueprint-web'; import { AxiosRequestConfig, AxiosResponse } from 'axios'; import DroppableContent from './DroppableContent'; @@ -1297,55 +1298,68 @@ class ContentUploader extends Component { be: !useUploadsManager, }); + const renderUploader = () => { + if (enableModernizedUploads) { + return ( +
+ + +
+ ); + } + + if (useUploadsManager) { + return ( +
+ + +
+ ) + } + + return ( +
+ + +
+
+ ) + } + return ( - {enableModernizedUploads ? ( -
- -
- ) : useUploadsManager ? ( -
- - -
- ) : ( -
- - -
-
- )} + {renderUploader()}
); diff --git a/src/elements/content-uploader/stories/ContentUploader.stories.js b/src/elements/content-uploader/stories/ContentUploader.stories.js index 6ddd6f8591..693d44d0a0 100644 --- a/src/elements/content-uploader/stories/ContentUploader.stories.js +++ b/src/elements/content-uploader/stories/ContentUploader.stories.js @@ -10,6 +10,12 @@ export const withTheming = { }, }; +export const withModernizedUploads = { + args: { + enableModernizedUploads: true, + }, +}; + export default { title: 'Elements/ContentUploader', component: ContentUploader, From b7bf9cc1fbec4d0fbb0f2bb8a04b969bc1ae3e69 Mon Sep 17 00:00:00 2001 From: Hleb Kryshyn Date: Wed, 20 May 2026 15:50:01 +0200 Subject: [PATCH 3/4] feat(uploads-manager): integrate shared feature into ContentUploader Wire @box/uploads-manager UploadsManager into ContentUploader behind the enableModernizedUploads flag. Maps legacy upload state to the shared feature's item shape and delegates per-item cancel/retry/remove actions to existing handlers. --- .../content-uploader/ContentUploader.tsx | 33 +++++++- .../__tests__/ContentUploader.test.js | 84 ++++++++++++++++++- .../mapToModernizedUploadItem.test.ts | 79 +++++++++++++++++ .../utils/mapToModernizedUploadItem.ts | 47 +++++++++++ 4 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 src/elements/content-uploader/utils/__tests__/mapToModernizedUploadItem.test.ts create mode 100644 src/elements/content-uploader/utils/mapToModernizedUploadItem.ts diff --git a/src/elements/content-uploader/ContentUploader.tsx b/src/elements/content-uploader/ContentUploader.tsx index 9923bf3ced..1dfda498a5 100644 --- a/src/elements/content-uploader/ContentUploader.tsx +++ b/src/elements/content-uploader/ContentUploader.tsx @@ -12,6 +12,7 @@ import { AxiosRequestConfig, AxiosResponse } from 'axios'; import DroppableContent from './DroppableContent'; import Footer from './Footer'; import UploadsManager from './UploadsManager'; +import { mapToModernizedUploadItems } from './utils/mapToModernizedUploadItem'; import API from '../../api'; import Browser from '../../utils/Browser'; import Internationalize from '../common/Internationalize'; @@ -1219,6 +1220,28 @@ class ContentUploader extends Component { } }; + /** + * Find legacy UploadItem by the id used by the modernized uploads manager. + */ + findItemByModernizedId = (id: string): UploadItem | undefined => { + const { rootFolderId } = this.props; + return this.state.items.find(item => getFileId(item.file, rootFolderId) === id); + }; + + handleModernizedItemAction = (id: string) => { + const item = this.findItemByModernizedId(id); + if (item) { + this.onClick(item); + } + }; + + handleModernizedItemRemove = (id: string) => { + const item = this.findItemByModernizedId(id); + if (item) { + this.removeFileFromUploadQueue(item); + } + }; + /** * Empties the items queue * @@ -1282,6 +1305,7 @@ class ContentUploader extends Component { messages, onClose, onUpgradeCTAClick, + rootFolderId, theme, useUploadsManager, }: ContentUploaderProps = this.props; @@ -1303,7 +1327,14 @@ class ContentUploader extends Component { return (
- +
); } diff --git a/src/elements/content-uploader/__tests__/ContentUploader.test.js b/src/elements/content-uploader/__tests__/ContentUploader.test.js index 579b9a9f97..2bc646ada7 100644 --- a/src/elements/content-uploader/__tests__/ContentUploader.test.js +++ b/src/elements/content-uploader/__tests__/ContentUploader.test.js @@ -6,6 +6,7 @@ import { ContentUploaderComponent, CHUNKED_UPLOAD_MIN_SIZE_BYTES } from '../Cont import Footer from '../Footer'; import UploadsManager from '../UploadsManager'; import DroppableContent from '../DroppableContent'; +import { UploadsManager as UploadsManagerBP } from '@box/uploads-manager'; import { STATUS_PENDING, STATUS_IN_PROGRESS, @@ -772,16 +773,93 @@ describe('elements/content-uploader/ContentUploader', () => { expect(wrapper.find(UploadsManager)).toHaveLength(0); }); - test('should render modernized uploads placeholder when enableModernizedUploads is true', () => { + test('should render modernized UploadsManagerBP when enableModernizedUploads is true', () => { const wrapper = getWrapper({ enableModernizedUploads: true }); + expect(wrapper.find(UploadsManagerBP)).toHaveLength(1); expect(wrapper.find(UploadsManager)).toHaveLength(0); expect(wrapper.find(DroppableContent)).toHaveLength(0); }); - test('should render modernized uploads placeholder even when useUploadsManager is true', () => { + test('should render modernized UploadsManagerBP even when useUploadsManager is true', () => { const wrapper = getWrapper({ enableModernizedUploads: true, useUploadsManager: true }); + expect(wrapper.find(UploadsManagerBP)).toHaveLength(1); expect(wrapper.find(UploadsManager)).toHaveLength(0); - expect(wrapper.find(DroppableContent)).toHaveLength(0); + }); + + test('should map state.items to modernized item shape', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + wrapper.setState({ + items: [ + { + name: 'foo.pdf', + extension: 'pdf', + progress: 42, + status: STATUS_IN_PROGRESS, + file: { name: 'foo.pdf' }, + }, + ], + }); + const items = wrapper.find(UploadsManagerBP).prop('items'); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + name: 'foo.pdf', + extension: 'pdf', + progress: 42, + status: 'uploading', + }); + }); + + test('should pass isExpanded from state', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + wrapper.setState({ isUploadsManagerExpanded: true }); + expect(wrapper.find(UploadsManagerBP).prop('isExpanded')).toBe(true); + }); + + test('should call onClick when onItemCancel is invoked', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + const item = { + name: 'foo.pdf', + extension: 'pdf', + progress: 0, + status: STATUS_PENDING, + file: { name: 'foo.pdf' }, + }; + wrapper.setState({ items: [item] }); + const instance = wrapper.instance(); + const onClickSpy = jest.spyOn(instance, 'onClick').mockImplementation(() => {}); + + wrapper.find(UploadsManagerBP).prop('onItemCancel')('foo.pdf'); + + expect(onClickSpy).toHaveBeenCalledWith(item); + }); + + test('should call removeFileFromUploadQueue when onItemRemove is invoked', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + const item = { + name: 'foo.pdf', + extension: 'pdf', + progress: 0, + status: STATUS_COMPLETE, + file: { name: 'foo.pdf' }, + }; + wrapper.setState({ items: [item] }); + const instance = wrapper.instance(); + const removeSpy = jest.spyOn(instance, 'removeFileFromUploadQueue').mockImplementation(() => {}); + + wrapper.find(UploadsManagerBP).prop('onItemRemove')('foo.pdf'); + + expect(removeSpy).toHaveBeenCalledWith(item); + }); + + test('should no-op when modernized id does not match any item', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + wrapper.setState({ items: [] }); + const instance = wrapper.instance(); + const onClickSpy = jest.spyOn(instance, 'onClick').mockImplementation(() => {}); + + wrapper.find(UploadsManagerBP).prop('onItemCancel')('missing-id'); + + expect(onClickSpy).not.toHaveBeenCalled(); }); }); }); diff --git a/src/elements/content-uploader/utils/__tests__/mapToModernizedUploadItem.test.ts b/src/elements/content-uploader/utils/__tests__/mapToModernizedUploadItem.test.ts new file mode 100644 index 0000000000..4d521f8d86 --- /dev/null +++ b/src/elements/content-uploader/utils/__tests__/mapToModernizedUploadItem.test.ts @@ -0,0 +1,79 @@ +import { + STATUS_PENDING, + STATUS_IN_PROGRESS, + STATUS_STAGED, + STATUS_COMPLETE, + STATUS_ERROR, +} from '../../../../constants'; +import { mapToModernizedUploadItem, mapToModernizedUploadItems } from '../mapToModernizedUploadItem'; + +const buildLegacyItem = (overrides = {}) => ({ + name: 'foo.pdf', + extension: 'pdf', + progress: 50, + status: STATUS_IN_PROGRESS, + size: 100, + file: { name: 'foo.pdf' } as File, + api: {} as never, + ...overrides, +}); + +describe('mapToModernizedUploadItem()', () => { + test('maps core fields', () => { + const result = mapToModernizedUploadItem(buildLegacyItem(), '0'); + expect(result).toEqual({ + id: 'foo.pdf', + name: 'foo.pdf', + extension: 'pdf', + progress: 50, + status: 'uploading', + isFolder: undefined, + errorMessage: undefined, + }); + }); + + test.each([ + [STATUS_PENDING, 'pending'], + [STATUS_IN_PROGRESS, 'uploading'], + [STATUS_STAGED, 'staged'], + [STATUS_COMPLETE, 'complete'], + [STATUS_ERROR, 'error'], + ])('maps legacy status %s to modernized %s', (legacy, modernized) => { + const result = mapToModernizedUploadItem(buildLegacyItem({ status: legacy }), '0'); + expect(result.status).toBe(modernized); + }); + + test('extracts errorMessage from item.error', () => { + const result = mapToModernizedUploadItem( + buildLegacyItem({ status: STATUS_ERROR, error: { message: 'Boom' } }), + '0', + ); + expect(result.errorMessage).toBe('Boom'); + }); + + test('forwards isFolder', () => { + const result = mapToModernizedUploadItem(buildLegacyItem({ isFolder: true }), '0'); + expect(result.isFolder).toBe(true); + }); + + test('defaults missing extension and progress', () => { + const result = mapToModernizedUploadItem( + buildLegacyItem({ extension: undefined, progress: undefined }), + '0', + ); + expect(result.extension).toBe(''); + expect(result.progress).toBe(0); + }); +}); + +describe('mapToModernizedUploadItems()', () => { + test('maps a list', () => { + const result = mapToModernizedUploadItems( + [buildLegacyItem({ name: 'a.pdf', file: { name: 'a.pdf' } as File }), buildLegacyItem({ name: 'b.pdf', file: { name: 'b.pdf' } as File })], + '0', + ); + expect(result).toHaveLength(2); + expect(result[0].id).toBe('a.pdf'); + expect(result[1].id).toBe('b.pdf'); + }); +}); diff --git a/src/elements/content-uploader/utils/mapToModernizedUploadItem.ts b/src/elements/content-uploader/utils/mapToModernizedUploadItem.ts new file mode 100644 index 0000000000..c98389f974 --- /dev/null +++ b/src/elements/content-uploader/utils/mapToModernizedUploadItem.ts @@ -0,0 +1,47 @@ +import { + STATUS_PENDING, + STATUS_IN_PROGRESS, + STATUS_STAGED, + STATUS_COMPLETE, + STATUS_ERROR, +} from '../../../constants'; +import { getFileId } from '../../../utils/uploads'; +import { UploadItem as LegacyUploadItem } from '../../../common/types/upload'; + +type ModernizedStatus = 'pending' | 'uploading' | 'staged' | 'complete' | 'error' | 'canceled'; + +export interface ModernizedUploadItem { + id: string; + name: string; + extension: string; + progress: number; + status: ModernizedStatus; + isFolder?: boolean; + errorMessage?: string; +} + +const STATUS_MAP: Record = { + [STATUS_PENDING]: 'pending', + [STATUS_IN_PROGRESS]: 'uploading', + [STATUS_STAGED]: 'staged', + [STATUS_COMPLETE]: 'complete', + [STATUS_ERROR]: 'error', +}; + +export function mapToModernizedUploadItem(item: LegacyUploadItem, rootFolderId: string): ModernizedUploadItem { + const errorMessage = item.error ? (item.error as { message?: string }).message : undefined; + + return { + id: getFileId(item.file, rootFolderId), + name: item.name, + extension: item.extension ?? '', + progress: item.progress ?? 0, + status: STATUS_MAP[item.status] ?? 'pending', + isFolder: item.isFolder, + errorMessage, + }; +} + +export function mapToModernizedUploadItems(items: LegacyUploadItem[], rootFolderId: string): ModernizedUploadItem[] { + return items.map(item => mapToModernizedUploadItem(item, rootFolderId)); +} From 884a7b333fa199e4c73722267e8d25057424c109 Mon Sep 17 00:00:00 2001 From: Hleb Kryshyn Date: Wed, 20 May 2026 17:12:51 +0200 Subject: [PATCH 4/4] 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 {