Skip to content
Closed
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",
"@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;
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);

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