From 1391dc578d034843c2033412025c9bec902496c2 Mon Sep 17 00:00:00 2001 From: pgolebiowski Date: Tue, 21 Jul 2026 12:41:59 +0200 Subject: [PATCH 01/10] feat(upload-manager): add progress counts to uploads manager item (WEBAPP-65289) --- src/common/types/upload.js | 3 + .../content-uploader/ContentUploader.tsx | 12 +++ .../__tests__/ContentUploader.test.js | 65 ++++++++++++++++ .../stories/ContentUploader.stories.js | 75 +++++++++++++++++++ .../mapToModernizedUploadItem.test.ts | 18 +++++ .../utils/__tests__/uploadEta.test.ts | 56 ++++++++++++++ .../utils/mapToModernizedUploadItem.ts | 6 ++ .../content-uploader/utils/uploadEta.ts | 53 +++++++++++++ 8 files changed, 288 insertions(+) create mode 100644 src/elements/content-uploader/utils/__tests__/uploadEta.test.ts create mode 100644 src/elements/content-uploader/utils/uploadEta.ts diff --git a/src/common/types/upload.js b/src/common/types/upload.js index 15d710ad81..6419ed1f71 100644 --- a/src/common/types/upload.js +++ b/src/common/types/upload.js @@ -65,6 +65,7 @@ type FolderUploadItem = { type UploadItem = { api: PlainUploadAPI | MultiputUploadAPI, boxFile?: BoxItem, + bytesUploaded?: number, bytesUploadedOnLastResume?: number, dedupeKey?: string, error?: Object, @@ -74,8 +75,10 @@ type UploadItem = { name: string, options?: UploadItemAPIOptions, progress: number, + remainingSeconds?: number, size: number, status: UploadStatus, + totalBytes?: number, }; type MultiputConfig = { diff --git a/src/elements/content-uploader/ContentUploader.tsx b/src/elements/content-uploader/ContentUploader.tsx index bb7f84ae05..dd139ee39b 100644 --- a/src/elements/content-uploader/ContentUploader.tsx +++ b/src/elements/content-uploader/ContentUploader.tsx @@ -16,6 +16,7 @@ import Footer from './Footer'; import ModernizedUploadsManagerDropZone from './ModernizedUploadsManagerDropZone'; import UploadsManager from './UploadsManager'; import { getUploadItemKey, mapToModernizedUploadItems } from './utils/mapToModernizedUploadItem'; +import { updateEta, getRemainingSeconds, type EtaState } from './utils/uploadEta'; import './ModernizedUploadsManagerPanel.scss'; import API from '../../api'; import Browser from '../../utils/Browser'; @@ -171,6 +172,10 @@ class ContentUploader extends Component { itemIdsRef: React.MutableRefObject; + // Per-item upload speed tracking for the modernized manager's ETA. WeakMap so + // entries are garbage-collected with their item; no manual cleanup needed. + etaByItem: WeakMap = new WeakMap(); + static defaultProps = { apiHost: DEFAULT_HOSTNAME_API, chunked: true, @@ -1260,6 +1265,13 @@ class ContentUploader extends Component { item.progress = Math.min(Math.round((event.loaded / event.total) * 100), 100); item.status = item.progress === 100 ? STATUS_STAGED : STATUS_IN_PROGRESS; + // Track byte-level progress and a smoothed ETA for the modernized manager. + const nextEta = updateEta(this.etaByItem.get(item), event.loaded, Date.now()); + this.etaByItem.set(item, nextEta); + item.bytesUploaded = event.loaded; + item.totalBytes = event.total; + item.remainingSeconds = getRemainingSeconds(nextEta, event.loaded, event.total); + const { onProgress } = this.props; onProgress(item); diff --git a/src/elements/content-uploader/__tests__/ContentUploader.test.js b/src/elements/content-uploader/__tests__/ContentUploader.test.js index b238206c56..c0e02f79f4 100644 --- a/src/elements/content-uploader/__tests__/ContentUploader.test.js +++ b/src/elements/content-uploader/__tests__/ContentUploader.test.js @@ -2251,4 +2251,69 @@ describe('elements/content-uploader/ContentUploader', () => { expect(wrapper.state('isLargeFileWarningModalOpen')).toBe(false); }); }); + + describe('handleUploadProgress()', () => { + const makeProgressItem = () => ({ + api: {}, + extension: 'txt', + file: new File(['contents'], 'a.txt'), + name: 'a.txt', + progress: 0, + size: 1000, + status: STATUS_PENDING, + }); + + test('records byte progress and total on the item', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + const instance = wrapper.instance(); + const item = makeProgressItem(); + instance.itemsRef.current = [item]; + + instance.handleUploadProgress(item, { loaded: 400, total: 1000 }); + + expect(item.bytesUploaded).toBe(400); + expect(item.totalBytes).toBe(1000); + expect(item.progress).toBe(40); + }); + + test('leaves remainingSeconds undefined on the first progress event (no speed sample yet)', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + const instance = wrapper.instance(); + const item = makeProgressItem(); + instance.itemsRef.current = [item]; + + instance.handleUploadProgress(item, { loaded: 400, total: 1000 }); + + expect(item.remainingSeconds).toBeUndefined(); + }); + + test('estimates remainingSeconds once a second sample arrives', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + const instance = wrapper.instance(); + const item = makeProgressItem(); + instance.itemsRef.current = [item]; + + const nowSpy = jest.spyOn(Date, 'now'); + nowSpy.mockReturnValueOnce(0); + instance.handleUploadProgress(item, { loaded: 200, total: 1000 }); + nowSpy.mockReturnValueOnce(1000); // 1s later, +300 bytes -> 300 B/s + instance.handleUploadProgress(item, { loaded: 500, total: 1000 }); + + // 500 bytes left at 300 B/s + expect(item.remainingSeconds).toBeCloseTo(500 / 300, 5); + nowSpy.mockRestore(); + }); + + test('ignores progress events without a total', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + const instance = wrapper.instance(); + const item = makeProgressItem(); + instance.itemsRef.current = [item]; + + instance.handleUploadProgress(item, { loaded: 400, total: 0 }); + + expect(item.bytesUploaded).toBeUndefined(); + expect(item.totalBytes).toBeUndefined(); + }); + }); }); diff --git a/src/elements/content-uploader/stories/ContentUploader.stories.js b/src/elements/content-uploader/stories/ContentUploader.stories.js index 98285e1361..4211abd13f 100644 --- a/src/elements/content-uploader/stories/ContentUploader.stories.js +++ b/src/elements/content-uploader/stories/ContentUploader.stories.js @@ -1,9 +1,15 @@ // @flow +import * as React from 'react'; import { http, HttpResponse } from 'msw'; +import { UploadsManager as UploadsManagerBP } from '@box/uploads-manager'; import ContentUploader from '../ContentUploader'; import mockTheme from '../../common/__mocks__/mockTheme'; import { DEFAULT_HOSTNAME_API, DEFAULT_HOSTNAME_UPLOAD } from '../../../constants'; +const KB = 1024; +const MB = 1024 * KB; +const GB = 1024 * MB; + export const basic = {}; export const withTheming = { @@ -36,6 +42,75 @@ export const withModernizedUploads = { }, }; +// The modernized uploads manager renders the `X / TOTAL ·