diff --git a/package.json b/package.json index 7e5a830258..db8688d1ff 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/common/types/upload.js b/src/common/types/upload.js index 15d710ad81..54550095b2 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, + remainingMs?: 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..58637b4b17 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, getRemainingMs, type EtaState } from './utils/uploadEta'; import './ModernizedUploadsManagerPanel.scss'; import API from '../../api'; import Browser from '../../utils/Browser'; @@ -116,6 +117,7 @@ export interface ContentUploaderProps { uploadHost: string; useUploadsManager?: boolean; enableModernizedUploads?: boolean; + isUploadEtaEnabled?: boolean; isUpgradeModalEnabled?: boolean; isExpanded?: boolean; onToggle?: (isExpanded: boolean) => void; @@ -171,6 +173,8 @@ class ContentUploader extends Component { itemIdsRef: React.MutableRefObject; + etaByItem: WeakMap = new WeakMap(); + static defaultProps = { apiHost: DEFAULT_HOSTNAME_API, chunked: true, @@ -203,6 +207,7 @@ class ContentUploader extends Component { uploadHost: DEFAULT_HOSTNAME_UPLOAD, useUploadsManager: false, enableModernizedUploads: false, + isUploadEtaEnabled: false, isUpgradeModalEnabled: false, modernizedDismissDelayMs: HIDE_MODERNIZED_UPLOAD_MANAGER_DELAY_MS, }; @@ -1017,6 +1022,11 @@ class ContentUploader extends Component { 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; @@ -1260,6 +1270,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, 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); @@ -1826,6 +1843,7 @@ class ContentUploader extends Component { className, canDropOnUploadsManager, enableModernizedUploads, + isUploadEtaEnabled, fileLimit, isDraggingItemsToUploadsManager = false, isFolderUploadEnabled, @@ -1896,7 +1914,11 @@ class ContentUploader extends Component { onMouseLeave={this.handleModernizedMouseLeave} > { 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()', () => { @@ -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(); + }); + }); }); diff --git a/src/elements/content-uploader/stories/ContentUploader.stories.js b/src/elements/content-uploader/stories/ContentUploader.stories.js index 98285e1361..fa90c58f0a 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,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: () => ( +
+ {}} + onItemCancel={() => {}} + onItemRetry={() => {}} + onItemRemove={() => {}} + onItemShare={() => {}} + onItemOpen={() => {}} + onCancelAll={() => {}} + onRetryAll={() => {}} + /> +
+ ), +}; + export default { title: 'Elements/ContentUploader', component: ContentUploader, diff --git a/src/elements/content-uploader/utils/__tests__/mapToModernizedUploadItem.test.ts b/src/elements/content-uploader/utils/__tests__/mapToModernizedUploadItem.test.ts index 0f6b08c981..ea870aaef5 100644 --- a/src/elements/content-uploader/utils/__tests__/mapToModernizedUploadItem.test.ts +++ b/src/elements/content-uploader/utils/__tests__/mapToModernizedUploadItem.test.ts @@ -21,7 +21,7 @@ const buildLegacyItem = (overrides = {}) => ({ describe('mapToModernizedUploadItem()', () => { test('maps core fields', () => { - const result = mapToModernizedUploadItem(buildLegacyItem(), '0'); + const result = mapToModernizedUploadItem(buildLegacyItem(), '0', true); expect(result).toEqual({ id: 'foo.pdf', name: 'foo.pdf', @@ -31,9 +31,81 @@ describe('mapToModernizedUploadItem()', () => { isFolder: undefined, errorMessage: undefined, versionNumber: undefined, + bytesUploaded: undefined, + totalBytes: 100, + remainingMs: undefined, }); }); + test('omits byte progress and ETA fields when the treatment is off (kill switch)', () => { + const result = mapToModernizedUploadItem( + buildLegacyItem({ bytesUploaded: 40, totalBytes: 100, remainingMs: 12000 }), + '0', + ); + expect(result.bytesUploaded).toBeUndefined(); + expect(result.totalBytes).toBeUndefined(); + expect(result.remainingMs).toBeUndefined(); + }); + + test('forwards byte progress and ETA fields', () => { + const result = mapToModernizedUploadItem( + buildLegacyItem({ bytesUploaded: 40, totalBytes: 100, remainingMs: 12000 }), + '0', + true, + ); + expect(result.bytesUploaded).toBe(40); + expect(result.totalBytes).toBe(100); + expect(result.remainingMs).toBe(12000); + }); + + test('falls back to size for totalBytes before first progress event', () => { + const result = mapToModernizedUploadItem(buildLegacyItem({ totalBytes: undefined, size: 2048 }), '0', true); + expect(result.totalBytes).toBe(2048); + }); + + test.each([STATUS_STAGED, STATUS_COMPLETE])( + 'reports full bytesUploaded once the item is %s (progress events stop before hitting the total)', + status => { + const result = mapToModernizedUploadItem( + buildLegacyItem({ status, bytesUploaded: 259, totalBytes: 260 }), + '0', + true, + ); + expect(result.bytesUploaded).toBe(260); + expect(result.totalBytes).toBe(260); + }, + ); + + test('does not inflate bytesUploaded while still uploading', () => { + const result = mapToModernizedUploadItem( + buildLegacyItem({ status: STATUS_IN_PROGRESS, bytesUploaded: 259, totalBytes: 260 }), + '0', + true, + ); + expect(result.bytesUploaded).toBe(259); + }); + + test.each([STATUS_STAGED, STATUS_COMPLETE])( + 'drops the ETA once the item is %s (the smoothed estimate lags and looks stale)', + status => { + const result = mapToModernizedUploadItem( + buildLegacyItem({ status, bytesUploaded: 260, totalBytes: 260, remainingMs: 3000 }), + '0', + true, + ); + expect(result.remainingMs).toBeUndefined(); + }, + ); + + test('still forwards the ETA while uploading', () => { + const result = mapToModernizedUploadItem( + buildLegacyItem({ status: STATUS_IN_PROGRESS, remainingMs: 3000 }), + '0', + true, + ); + expect(result.remainingMs).toBe(3000); + }); + test.each([ [STATUS_PENDING, 'pending'], [STATUS_IN_PROGRESS, 'uploading'], @@ -150,4 +222,16 @@ describe('mapToModernizedUploadItems()', () => { const result = mapToModernizedUploadItems([folderA, folderB], '0'); expect(result[0].id).not.toBe(result[1].id); }); + + test('forwards the ETA treatment flag to each item', () => { + const item = buildLegacyItem({ bytesUploaded: 40, totalBytes: 100, remainingMs: 12000 }); + + const disabled = mapToModernizedUploadItems([item], '0'); + expect(disabled[0].bytesUploaded).toBeUndefined(); + expect(disabled[0].remainingMs).toBeUndefined(); + + const enabled = mapToModernizedUploadItems([item], '0', true); + expect(enabled[0].bytesUploaded).toBe(40); + expect(enabled[0].remainingMs).toBe(12000); + }); }); diff --git a/src/elements/content-uploader/utils/__tests__/uploadEta.test.ts b/src/elements/content-uploader/utils/__tests__/uploadEta.test.ts new file mode 100644 index 0000000000..9ba668ea58 --- /dev/null +++ b/src/elements/content-uploader/utils/__tests__/uploadEta.test.ts @@ -0,0 +1,84 @@ +import { updateEta, getRemainingMs, type EtaState } from '../uploadEta'; + +describe('updateEta()', () => { + test('first sample seeds the window with no ETA yet', () => { + const state = updateEta(undefined, 0, 100, 1000); + expect(state).toEqual({ + startMs: 1000, + lastSampleMs: 1000, + times: [1000], + loaded: [0], + etaMs: null, + }); + expect(getRemainingMs(state)).toBeUndefined(); + }); + + test('withholds the ETA during the grace period', () => { + let state = updateEta(undefined, 0, 1000, 0); + state = updateEta(state, 500, 1000, 500); // elapsed 500ms < 1s grace + expect(getRemainingMs(state)).toBeUndefined(); + }); + + test('produces an estimate once the grace period has passed', () => { + let state = updateEta(undefined, 0, 1000, 0); + // elapsed == grace -> the estimate is now surfaced. + // window [0,1000] / [0,200] -> 200 B/s, 800 B left -> raw 4000ms (no prior ETA to smooth against). + state = updateEta(state, 200, 1000, 1000); + expect(getRemainingMs(state)).toBeCloseTo(4000, 5); + // window [0,1000,2000] / [0,200,400] -> 200 B/s, 600 B left -> raw 3000ms. + // Smoothed against 4000: 0.1 * 3000 + 0.9 * 4000 = 3900ms. + state = updateEta(state, 400, 1000, 2000); + expect(getRemainingMs(state)).toBeCloseTo(3900, 5); + }); + + test('smooths the ETA with heavy inertia (does not jump)', () => { + const prev: EtaState = { + startMs: 0, + lastSampleMs: 2000, + times: [0, 2000], + loaded: [0, 2000], + etaMs: 10000, + }; + // Window [0,2000,3000] / [0,2000,3000] -> 1000 B/s, 7000 B left -> raw ETA 7000ms. + // Smoothed: 0.1 * 7000 + 0.9 * 10000 = 9700 (barely moves off the previous 10000). + const next = updateEta(prev, 3000, 10000, 3000); + expect(next.etaMs).toBeCloseTo(9700, 5); + }); + + test('throttles samples that arrive faster than the sampling rate', () => { + const prev: EtaState = { + startMs: 0, + lastSampleMs: 1000, + times: [0, 1000], + loaded: [0, 1000], + etaMs: 5000, + }; + const next = updateEta(prev, 1010, 10000, 1030); // 30ms < 50ms sampling rate + expect(next).toBe(prev); + }); + + test('hides the ETA when the whole window stalls (zero speed)', () => { + const prev: EtaState = { + startMs: 0, + lastSampleMs: 3000, + times: [1000, 3000], + loaded: [5000, 5000], + etaMs: 8000, + }; + const next = updateEta(prev, 5000, 10000, 4000); // no bytes moved across the window + expect(next.etaMs).toBeNull(); + expect(getRemainingMs(next)).toBeUndefined(); + }); +}); + +describe('getRemainingMs()', () => { + const emptyWindow = { startMs: 0, lastSampleMs: 0, times: [], loaded: [] }; + + test('returns undefined when there is no estimate', () => { + expect(getRemainingMs({ ...emptyWindow, etaMs: null })).toBeUndefined(); + }); + + test('returns the smoothed estimate in milliseconds', () => { + expect(getRemainingMs({ ...emptyWindow, etaMs: 12300 })).toBe(12300); + }); +}); diff --git a/src/elements/content-uploader/utils/mapToModernizedUploadItem.ts b/src/elements/content-uploader/utils/mapToModernizedUploadItem.ts index a2e8a28111..ab0ca5650a 100644 --- a/src/elements/content-uploader/utils/mapToModernizedUploadItem.ts +++ b/src/elements/content-uploader/utils/mapToModernizedUploadItem.ts @@ -33,24 +33,50 @@ export function getUploadItemKey(item: LegacyUploadItem | FolderUploadItem, root return `${item.name}_${folderId}_${uploadInitTimestamp}`; } -export function mapToModernizedUploadItem(item: LegacyUploadItem | FolderUploadItem, rootFolderId: string): UploadItem { +export function mapToModernizedUploadItem( + item: LegacyUploadItem | FolderUploadItem, + rootFolderId: string, + isUploadEtaEnabled = false, +): UploadItem { const errorMessage = item.error ? (item.error as { message?: string }).message : undefined; + const fileItem = item as LegacyUploadItem; - return { + const status = STATUS_MAP[item.status] ?? 'pending'; + + const baseItem: UploadItem = { id: getUploadItemKey(item, rootFolderId), name: item.name, extension: item.extension ?? '', progress: item.progress ?? 0, - status: STATUS_MAP[item.status] ?? 'pending', + status, isFolder: item.isFolder, errorMessage, versionNumber: item.boxFile?.version_number, }; + + // Kill switch: when the ETA/byte-progress treatment is off, omit the fields + // entirely so the modernized manager falls back to the plain percentage. + if (!isUploadEtaEnabled) { + return baseItem; + } + + const totalBytes = fileItem.totalBytes ?? item.size; + const isFullyUploaded = status === 'staged' || status === 'complete'; + const bytesUploaded = isFullyUploaded && totalBytes != null ? totalBytes : fileItem.bytesUploaded; + const remainingMs = !isFullyUploaded ? fileItem.remainingMs : undefined; + + return { + ...baseItem, + bytesUploaded, + totalBytes, + remainingMs, + }; } export function mapToModernizedUploadItems( items: Array, rootFolderId: string, + isUploadEtaEnabled = false, ): UploadItem[] { - return items.map(item => mapToModernizedUploadItem(item, rootFolderId)); + return items.map(item => mapToModernizedUploadItem(item, rootFolderId, isUploadEtaEnabled)); } diff --git a/src/elements/content-uploader/utils/uploadEta.ts b/src/elements/content-uploader/utils/uploadEta.ts new file mode 100644 index 0000000000..cdbbaa9e0f --- /dev/null +++ b/src/elements/content-uploader/utils/uploadEta.ts @@ -0,0 +1,80 @@ +/** Window the speed is measured over */ +const SAMPLING_WINDOW_MS = 3000; +/** Minimum spacing between recorded samples */ +const SAMPLING_RATE_MS = 50; +/** How long to wait before surfacing an ETA at all */ +const GRACE_PERIOD_MS = 1000; +/** + * EMA weight kept from the previous smoothed ETA (yt-dlp: 0.9). Higher = more + * inertia = steadier countdown. New raw ETA gets weight `1 - ETA_SMOOTHING`. + */ +const ETA_SMOOTHING = 0.9; + +export interface EtaState { + /** Timestamp of the first sample — used for the grace period. */ + startMs: number; + /** Timestamp of the last recorded sample — used to throttle sampling. */ + lastSampleMs: number; + /** Sample timestamps within the sliding window, oldest first. */ + times: number[]; + /** Loaded-byte counts matching `times`, oldest first. */ + loaded: number[]; + /** EMA(explonential moving average)-smoothed ETA, or `null` when no reliable estimate exists. */ + etaMs: number | null; +} + +export function updateEta(prev: EtaState | undefined, loaded: number, total: number, nowMs: number): EtaState { + if (!prev) { + return { startMs: nowMs, lastSampleMs: nowMs, times: [nowMs], loaded: [loaded], etaMs: null }; + } + + // Throttle: ignore samples that arrive faster than the sampling rate so the + // window is built from evenly spaced points. + if (nowMs - prev.lastSampleMs < SAMPLING_RATE_MS) { + return prev; + } + + const times = [...prev.times, nowMs]; + const loadedSamples = [...prev.loaded, loaded]; + + // Drop samples that have aged out of the trailing window. + const cutoff = nowMs - SAMPLING_WINDOW_MS; + let offset = 0; + while (offset < times.length && times[offset] < cutoff) { + offset += 1; + } + + const base = { + startMs: prev.startMs, + lastSampleMs: nowMs, + times: times.slice(offset), + loaded: loadedSamples.slice(offset), + }; + + // Need at least two points to measure a rate. + if (base.times.length < 2) { + return { ...base, etaMs: null }; + } + + const spanMs = nowMs - base.times[0]; + if (spanMs <= 0) { + return { ...base, etaMs: prev.etaMs }; + } + + const speedBytesPerMs = (loaded - base.loaded[0]) / spanMs; + const elapsedMs = nowMs - prev.startMs; + + // Only estimate once we have positive speed and the warm-up grace has passed. + if (total > 0 && speedBytesPerMs > 0 && elapsedMs >= GRACE_PERIOD_MS) { + const rawEtaMs = Math.max(0, total - loaded) / speedBytesPerMs; + const etaMs = prev.etaMs == null ? rawEtaMs : (1 - ETA_SMOOTHING) * rawEtaMs + ETA_SMOOTHING * prev.etaMs; + return { ...base, etaMs }; + } + + // No reliable estimate right now (warming up, or a full-window stall). + return { ...base, etaMs: null }; +} + +export function getRemainingMs(state: EtaState): number | undefined { + return state.etaMs == null ? undefined : state.etaMs; +} diff --git a/yarn.lock b/yarn.lock index d05cfdda8e..aeea231cdd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1252,10 +1252,10 @@ resolved "https://registry.yarnpkg.com/@box/unified-share-modal/-/unified-share-modal-2.15.16.tgz#49deb182bf1ac7f1ffe4bae713ab12ff2822533e" integrity sha512-Y1bE+JWDQm058lT+AcsfwJqK3WvGAsZj6ro9PLvejJZ1LxzexvOzyQFkM0sL4Kmj/93k2VEKoAJ/onBgheAJoQ== -"@box/uploads-manager@^1.17.2": - version "1.17.2" - resolved "https://registry.yarnpkg.com/@box/uploads-manager/-/uploads-manager-1.17.2.tgz#e7640ca60c0b1eac6fda1ab6dc5d8269a124eb69" - integrity sha512-pVXoPDFmAYx/ErD8+kP3yi4/EOG331tWl4yiO7cEDE1ia5X6mmKGp3va5twJfiqmTmnxBJJZAFEBa4QYb5yV5Q== +"@box/uploads-manager@^2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@box/uploads-manager/-/uploads-manager-2.4.0.tgz#c336bc4baef6265fa5a75d266dedd8784532f1eb" + integrity sha512-8tXfla+FNnKoqLnUSs+CLc2EEIueOJhGvJFhVtnJ8YOKGvoBSC3eiZA9iSScJQ4/OjK0ImqBlCsuLOAReCflfw== "@box/user-selector@^2.1.39": version "2.1.39"