Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@
"@box/threaded-annotations": "^4.1.12",
"@box/types": "^2.1.8",
"@box/unified-share-modal": "^2.15.16",
"@box/uploads-manager": "^1.17.2",
"@box/uploads-manager": "^2.4.0",
"@box/user-selector": "^2.1.39",
"@cfaester/enzyme-adapter-react-18": "^0.8.0",
"@chromatic-com/storybook": "^5.2.1",
Expand Down Expand Up @@ -317,7 +317,7 @@
"@box/threaded-annotations": "^4.1.12",
"@box/types": "^2.1.8",
"@box/unified-share-modal": "^2.15.16",
"@box/uploads-manager": "^1.17.2",
"@box/uploads-manager": "^2.4.0",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for updating 👍

"@box/user-selector": "^2.1.39",
"@hapi/address": "^2.1.4",
"@tanstack/react-virtual": "^3.13.12",
Expand Down
3 changes: 3 additions & 0 deletions src/common/types/upload.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ type FolderUploadItem = {
type UploadItem = {
api: PlainUploadAPI | MultiputUploadAPI,
boxFile?: BoxItem,
bytesUploaded?: number,
bytesUploadedOnLastResume?: number,
dedupeKey?: string,
error?: Object,
Expand All @@ -74,8 +75,10 @@ type UploadItem = {
name: string,
options?: UploadItemAPIOptions,
progress: number,
remainingMs?: number,
size: number,
status: UploadStatus,
totalBytes?: number,
};

type MultiputConfig = {
Expand Down
24 changes: 23 additions & 1 deletion src/elements/content-uploader/ContentUploader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import Footer from './Footer';
import ModernizedUploadsManagerDropZone from './ModernizedUploadsManagerDropZone';
import UploadsManager from './UploadsManager';
import { getUploadItemKey, mapToModernizedUploadItems } from './utils/mapToModernizedUploadItem';
import { updateEta, getRemainingMs, type EtaState } from './utils/uploadEta';
import './ModernizedUploadsManagerPanel.scss';
import API from '../../api';
import Browser from '../../utils/Browser';
Expand Down Expand Up @@ -116,6 +117,7 @@ export interface ContentUploaderProps {
uploadHost: string;
useUploadsManager?: boolean;
enableModernizedUploads?: boolean;
isUploadEtaEnabled?: boolean;
isUpgradeModalEnabled?: boolean;
isExpanded?: boolean;
onToggle?: (isExpanded: boolean) => void;
Expand Down Expand Up @@ -171,6 +173,8 @@ class ContentUploader extends Component<ContentUploaderProps, State> {

itemIdsRef: React.MutableRefObject<Object>;

etaByItem: WeakMap<UploadItem, EtaState> = new WeakMap();

static defaultProps = {
apiHost: DEFAULT_HOSTNAME_API,
chunked: true,
Expand Down Expand Up @@ -203,6 +207,7 @@ class ContentUploader extends Component<ContentUploaderProps, State> {
uploadHost: DEFAULT_HOSTNAME_UPLOAD,
useUploadsManager: false,
enableModernizedUploads: false,
isUploadEtaEnabled: false,
isUpgradeModalEnabled: false,
modernizedDismissDelayMs: HIDE_MODERNIZED_UPLOAD_MANAGER_DELAY_MS,
};
Expand Down Expand Up @@ -1017,6 +1022,11 @@ class ContentUploader extends Component<ContentUploaderProps, State> {
item.status = STATUS_PENDING;
delete item.error;

// Drop byte/ETA progress from the previous attempt
item.bytesUploaded = 0;
item.remainingMs = undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to clear item.totalBytes here too?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

totalBytes is the file's size, which doesn't change across retries, unlike the per-attempt bytesUploaded/remainingMs we reset here.

this.etaByItem.delete(item);

const updatedItems = [...this.itemsRef.current];
updatedItems[this.itemsRef.current.indexOf(item)] = item;

Expand Down Expand Up @@ -1260,6 +1270,13 @@ class ContentUploader extends Component<ContentUploaderProps, State> {
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, event.total, Date.now());
this.etaByItem.set(item, nextEta);
item.bytesUploaded = event.loaded;
item.totalBytes = event.total;
item.remainingMs = getRemainingMs(nextEta);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const { onProgress } = this.props;
onProgress(item);

Expand Down Expand Up @@ -1826,6 +1843,7 @@ class ContentUploader extends Component<ContentUploaderProps, State> {
className,
canDropOnUploadsManager,
enableModernizedUploads,
isUploadEtaEnabled,
fileLimit,
isDraggingItemsToUploadsManager = false,
isFolderUploadEnabled,
Expand Down Expand Up @@ -1896,7 +1914,11 @@ class ContentUploader extends Component<ContentUploaderProps, State> {
onMouseLeave={this.handleModernizedMouseLeave}
>
<UploadsManagerBP
items={mapToModernizedUploadItems(uploadsManagerItems, rootFolderId)}
items={mapToModernizedUploadItems(
uploadsManagerItems,
rootFolderId,
isUploadEtaEnabled,
)}
isExpanded={isUploadsManagerExpanded}
onToggle={this.toggleUploadsManager}
onItemCancel={this.handleUploadsManagerItemCancel}
Expand Down
82 changes: 82 additions & 0 deletions src/elements/content-uploader/__tests__/ContentUploader.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,23 @@ describe('elements/content-uploader/ContentUploader', () => {
expect(item.status).toBe(STATUS_PENDING);
expect(item.error).toBeUndefined();
});

test('should clear byte/ETA progress from the previous attempt', () => {
const wrapper = getWrapper();
const instance = wrapper.instance();
const item = {
api: { cancel: jest.fn() },
file: { size: 10 },
bytesUploaded: 8,
remainingMs: 4200,
};
instance.etaByItem.set(item, { etaMs: 4200 });

instance.resetFile(item);
expect(item.bytesUploaded).toBe(0);
expect(item.remainingMs).toBeUndefined();
expect(instance.etaByItem.has(item)).toBe(false);
});
});

describe('resumeFile()', () => {
Expand Down Expand Up @@ -2251,4 +2268,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 remainingMs 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.remainingMs).toBeUndefined();
});

test('estimates remainingMs 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 -> ~1.667s -> ~1666.67ms
expect(item.remainingMs).toBeCloseTo((500 / 300) * 1000, 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();
});
});
});
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -36,6 +42,69 @@ export const withModernizedUploads = {
},
};

const modernizedInProgressItems = [
{
id: '1',
name: 'annual-report',
extension: 'pdf',
status: 'uploading',
progress: 45,
bytesUploaded: 2.4 * MB,
totalBytes: 5.3 * MB,
remainingMs: 5000, // "5 sec left"
},
{
id: '2',
name: 'quarterly-presentation',
extension: 'pptx',
status: 'uploading',
progress: 20,
bytesUploaded: 50 * MB,
totalBytes: 250 * MB,
remainingMs: 183000, // "3 min left"
},
{
id: '3',
name: 'full-backup',
extension: 'zip',
status: 'uploading',
progress: 10,
bytesUploaded: 0.4 * GB,
totalBytes: 4 * GB,
remainingMs: 4200000, // "1 hr left"
},
{
id: '4',
name: 'logo',
extension: 'png',
status: 'uploading',
progress: 60,
bytesUploaded: 150 * KB,
totalBytes: 253 * KB,
remainingMs: 12000, // "12 sec left"
},
];

export const withModernizedUploadsInProgress = {
name: 'With Modernized Uploads (in progress · size + ETA)',
render: () => (
<div style={{ maxWidth: 480 }}>
<UploadsManagerBP
items={modernizedInProgressItems}
isExpanded
onToggle={() => {}}
onItemCancel={() => {}}
onItemRetry={() => {}}
onItemRemove={() => {}}
onItemShare={() => {}}
onItemOpen={() => {}}
onCancelAll={() => {}}
onRetryAll={() => {}}
/>
</div>
),
};

export default {
title: 'Elements/ContentUploader',
component: ContentUploader,
Expand Down
Loading
Loading