From c9d6a6303b48a022f259af42a948fd4fc3d489d5 Mon Sep 17 00:00:00 2001 From: Hleb Kryshyn Date: Wed, 20 May 2026 11:17:42 +0200 Subject: [PATCH 1/3] 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/3] 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/3] 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)); +}