From 521506ad89956c9d6f804824eab82c089d1412b9 Mon Sep 17 00:00:00 2001 From: Oleh Rybak Date: Wed, 24 Jun 2026 17:05:57 +0200 Subject: [PATCH 1/9] feat(content-uploader): implement warning modal for upload items exceeding size limit --- i18n/en-US.properties | 12 ++ .../content-uploader/ContentUploader.tsx | 138 +++++++++++++- .../LargeFileWarningModal.scss | 69 +++++++ .../LargeFileWarningModal.tsx | 142 +++++++++++++++ .../__tests__/ContentUploader.test.js | 168 ++++++++++++++++++ .../__tests__/LargeFileWarningModal.test.tsx | 101 +++++++++++ src/elements/content-uploader/messages.ts | 33 ++++ 7 files changed, 658 insertions(+), 5 deletions(-) create mode 100644 src/elements/content-uploader/LargeFileWarningModal.scss create mode 100644 src/elements/content-uploader/LargeFileWarningModal.tsx create mode 100644 src/elements/content-uploader/__tests__/LargeFileWarningModal.test.tsx diff --git a/i18n/en-US.properties b/i18n/en-US.properties index f6a0cfac0e..f4a7999b83 100644 --- a/i18n/en-US.properties +++ b/i18n/en-US.properties @@ -396,6 +396,18 @@ be.contentUploader.cancelAllUploads.confirmButton = Cancel All be.contentUploader.cancelAllUploads.heading = Cancel all uploads? # Dismiss button for the cancel all uploads modal be.contentUploader.cancelAllUploads.keepUploadingButton = Keep Uploading +# Body text in the warning modal informing the user that one or more files exceed their plan upload size limit. Contains a link to upgrade the plan. +be.contentUploader.largeFileWarning.body = {count, plural, one {This file exceeds} other {These files exceed}} the {maxFileSize} limit on your current plan. Upgrade your plan for larger uploads: +# Label for the button that cancels the upload attempt entirely +be.contentUploader.largeFileWarning.cancelButton = Cancel +# Accessible label for the close button in the large file warning modal +be.contentUploader.largeFileWarning.closeAriaLabel = Close +# Accessible label for the list of oversize files inside the warning modal +be.contentUploader.largeFileWarning.fileListAriaLabel = Files exceeding the upload size limit +# Heading for the warning modal shown when one or more files exceed the upload size limit +be.contentUploader.largeFileWarning.heading = {count, plural, one {File} other {Files}} Can't Be Uploaded +# Label for the button that proceeds with uploading only the files that are within the plan size limit +be.contentUploader.largeFileWarning.uploadTheRestButton = Upload the Rest # Label for copy action. be.copy = Copy # Label for create action. diff --git a/src/elements/content-uploader/ContentUploader.tsx b/src/elements/content-uploader/ContentUploader.tsx index 04af3e3720..2fc8cb91ee 100644 --- a/src/elements/content-uploader/ContentUploader.tsx +++ b/src/elements/content-uploader/ContentUploader.tsx @@ -10,6 +10,7 @@ import { UploadsManager as UploadsManagerBP } from '@box/uploads-manager'; import { TooltipProvider } from '@box/blueprint-web'; import { AxiosRequestConfig, AxiosResponse } from 'axios'; import CancelAllUploadsModal from './CancelAllUploadsModal'; +import LargeFileWarningModal from './LargeFileWarningModal'; import DroppableContent from './DroppableContent'; import Footer from './Footer'; import UploadsManager from './UploadsManager'; @@ -115,6 +116,7 @@ export interface ContentUploaderProps { enableModernizedUploads?: boolean; isExpanded?: boolean; onToggle?: (isExpanded: boolean) => void; + maxFileSize?: number; } type ModernizedPanelState = 'hidden' | 'shown' | 'dismissing'; @@ -122,6 +124,7 @@ type ModernizedPanelState = 'hidden' | 'shown' | 'dismissing'; type State = { errorCode?: string; isCancelAllModalOpen: boolean; + isLargeFileWarningModalOpen: boolean; isUploadsManagerExpanded: boolean; itemIds: Object; items: UploadItem[]; @@ -209,6 +212,7 @@ class ContentUploader extends Component { errorCode: '', itemIds: {}, isCancelAllModalOpen: false, + isLargeFileWarningModalOpen: false, isUploadsManagerExpanded: false, modernizedPanelState: 'hidden', }; @@ -234,7 +238,7 @@ class ContentUploader extends Component { const { files, isPrepopulateFilesEnabled } = this.props; // isPrepopulateFilesEnabled is a prop used to pre-populate files without clicking upload button. if (isPrepopulateFilesEnabled && files && files.length > 0) { - this.addFilesToUploadQueue(files, this.upload); + this.addFilesToUploadQueue(files, this.maybeUpload); } } @@ -277,6 +281,10 @@ class ContentUploader extends Component { item => item.status === STATUS_COMPLETE || item.status === STATUS_CANCELED, ); + if (!hasItemsInUploadQueue && modernizedPanelState === 'shown') { + this.setState({ modernizedPanelState: 'hidden' }); + } + if (hasItemsInUploadQueue && modernizedPanelState === 'hidden') { this.showModernizedPanel(); } @@ -730,7 +738,7 @@ class ContentUploader extends Component { const { view } = this.state; // Automatically start upload if other files are being uploaded if (view === VIEW_UPLOAD_IN_PROGRESS) { - this.upload(); + this.maybeUpload(); } }); }; @@ -1589,6 +1597,103 @@ class ContentUploader extends Component { this.finalizeModernizedDismiss(); }; + /** + * Returns pending upload items that exceed the configured max file size. + * + * @private + * @return {UploadItem[]} + */ + getOversizePendingItems = (): UploadItem[] => { + const { maxFileSize } = this.props; + + if (!maxFileSize) { + return []; + } + + return this.itemsRef.current.filter( + item => item.status === STATUS_PENDING && item.file && item.file.size > maxFileSize, + ); + }; + + /** + * Returns the number of pending upload items that are eligible to upload. + * + * @private + * @return {number} + */ + getEligiblePendingCount = (): number => { + const { maxFileSize } = this.props; + + return this.itemsRef.current.filter(item => { + if (item.status !== STATUS_PENDING) { + return false; + } + + if (!item.file || !maxFileSize) { + return true; + } + + return item.file.size <= maxFileSize; + }).length; + }; + + /** + * Starts upload immediately when no oversize files are present; otherwise opens + * the large file warning modal and waits for user confirmation. + * + * @private + * @return {void} + */ + maybeUpload = (): void => { + const { maxFileSize, enableModernizedUploads } = this.props; + + if (!maxFileSize || !enableModernizedUploads) { + this.upload(); + return; + } + + const oversizeItems = this.getOversizePendingItems(); + + if (oversizeItems.length === 0) { + this.upload(); + return; + } + + if (!this.state.isLargeFileWarningModalOpen) { + this.setState({ isLargeFileWarningModalOpen: true }); + } + }; + + /** + * Removes oversize pending items and starts uploading the remaining eligible items. + * + * @private + * @return {void} + */ + handleLargeFileWarningUploadRest = (): void => { + const oversizeItems = this.getOversizePendingItems(); + + oversizeItems.forEach(item => this.removeFileFromUploadQueue(item)); + + this.setState({ isLargeFileWarningModalOpen: false }, () => { + this.upload(); + }); + }; + + /** + * Cancels the upload attempt by removing all pending items from the queue. + * + * @private + * @return {void} + */ + handleLargeFileWarningCancel = (): void => { + const pendingItems = this.itemsRef.current.filter(item => item.status === STATUS_PENDING); + + pendingItems.forEach(item => this.removeFileFromUploadQueue(item)); + + this.setState({ isLargeFileWarningModalOpen: false }); + }; + /** * Adds file to the upload queue and starts upload immediately * @@ -1600,8 +1705,8 @@ class ContentUploader extends Component { files?: Array, dataTransferItems?: Array, ): void => { - this.addFilesToUploadQueue(files, this.upload); - this.addDataTransferItemsToUploadQueue(dataTransferItems, this.upload); + this.addFilesToUploadQueue(files, this.maybeUpload); + this.addDataTransferItemsToUploadQueue(dataTransferItems, this.maybeUpload); }; /** @@ -1629,8 +1734,16 @@ class ContentUploader extends Component { rootFolderId, theme, useUploadsManager, + maxFileSize, }: ContentUploaderProps = this.props; - const { view, items, errorCode, isCancelAllModalOpen, modernizedPanelState }: State = this.state; + const { + view, + items, + errorCode, + isCancelAllModalOpen, + isLargeFileWarningModalOpen, + modernizedPanelState, + }: State = this.state; const isUploadsManagerExpanded = this.getIsExpanded(); const isEmpty = items.length === 0; const isVisible = !isEmpty || !!isDraggingItemsToUploadsManager; @@ -1638,6 +1751,12 @@ class ContentUploader extends Component { const hasFiles = items.length !== 0; const isLoading = items.some(item => item.status === STATUS_IN_PROGRESS); const isDone = items.every(item => item.status === STATUS_COMPLETE || item.status === STATUS_STAGED); + const oversizePendingItems = this.getOversizePendingItems(); + const oversizeFiles = oversizePendingItems.map(item => ({ + name: item.name, + size: item.file?.size ?? 0, + })); + const eligiblePendingCount = this.getEligiblePendingCount(); const styleClassName = classNames('bcu', className, { 'be-app-element': !useUploadsManager, @@ -1679,6 +1798,15 @@ class ContentUploader extends Component { onConfirm={this.handleCancelAllConfirm} onDismiss={this.handleCancelAllDismiss} /> + ); diff --git a/src/elements/content-uploader/LargeFileWarningModal.scss b/src/elements/content-uploader/LargeFileWarningModal.scss new file mode 100644 index 0000000000..704fdf3691 --- /dev/null +++ b/src/elements/content-uploader/LargeFileWarningModal.scss @@ -0,0 +1,69 @@ +.bcu-large-file-warning-modal { + &-body { + display: flex; + flex-direction: column; + gap: var(--space-4); + } + + &-description { + margin: 0; + } + + &-upgradeLink { + background: none; + border: 0; + color: var(--text-cta-link); + cursor: pointer; + font: inherit; + font-weight: var(--font-weights-bold); + padding: 0; + text-decoration: none; + + &:hover, + &:focus-visible { + color: var(--text-cta-link-hover); + text-decoration: underline; + } + } + + &-fileListContainer { + border: var(--border-1) solid var(--border-card-border); + border-radius: var(--radius-4); + max-height: 140px; + overflow-y: auto; + padding: var(--space-5); + display: flex; + } + + &-fileListInner { + position: relative; + width: 100%; + } + + &-fileListRow { + align-items: center; + column-gap: var(--space-3); + display: flex; + justify-content: space-between; + left: 0; + min-height: 20px; + position: absolute; + top: 0; + width: 100%; + } + + &-fileName { + flex: 1 1 auto; + font-weight: var(--font-weights-semibold); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &-fileSize { + color: var(--text-text-on-light-secondary); + flex: 0 0 auto; + white-space: nowrap; + } +} diff --git a/src/elements/content-uploader/LargeFileWarningModal.tsx b/src/elements/content-uploader/LargeFileWarningModal.tsx new file mode 100644 index 0000000000..e32a46fb19 --- /dev/null +++ b/src/elements/content-uploader/LargeFileWarningModal.tsx @@ -0,0 +1,142 @@ +import * as React from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { Modal } from '@box/blueprint-web'; + +import getFileSize from '../../utils/getFileSize'; +import messages from './messages'; + +import './LargeFileWarningModal.scss'; + +export type OversizeFile = { + name: string; + size: number; +}; + +export interface LargeFileWarningModalProps { + eligibleCount: number; + isOpen: boolean; + maxFileSize: number; + onCancel: () => void; + onConfirm: () => void; + onUpgradeCTAClick?: () => void; + oversizeFiles: ReadonlyArray; +} + +const LargeFileWarningModal = ({ + eligibleCount, + isOpen, + maxFileSize, + onCancel, + onConfirm, + onUpgradeCTAClick, + oversizeFiles, +}: LargeFileWarningModalProps) => { + const intl = useIntl(); + const oversizeCount = oversizeFiles.length; + const maxFileSizeLabel = getFileSize(maxFileSize, intl.locale); + const scrollRef = React.useRef(null); + const rowVirtualizer = useVirtualizer({ + count: oversizeFiles.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 20, + overscan: 1, + gap: 12, + }); + + const handleOpenChange = (open: boolean) => { + if (!open) { + onCancel(); + } + }; + + const renderFileList = () => { + return ( +
+
+ {rowVirtualizer.getVirtualItems().map(item => { + const file = oversizeFiles[item.index]; + + return ( +
+ + {file.name} + + + {getFileSize(file.size, intl.locale)} + +
+ ); + })} +
+
+ ); + }; + + return ( + + + + {intl.formatMessage(messages.largeFileWarningHeading, { count: oversizeCount })} + + + +
+

+ + onUpgradeCTAClick ? ( + + ) : ( + {chunks} + ), + }} + /> +

+ {renderFileList()} +
+
+
+ + + {intl.formatMessage(messages.largeFileWarningCancelButton)} + + + {intl.formatMessage(messages.largeFileWarningUploadTheRestButton)} + + + +
+
+ ); +}; + +export default LargeFileWarningModal; diff --git a/src/elements/content-uploader/__tests__/ContentUploader.test.js b/src/elements/content-uploader/__tests__/ContentUploader.test.js index 18277aab46..93a4f5846e 100644 --- a/src/elements/content-uploader/__tests__/ContentUploader.test.js +++ b/src/elements/content-uploader/__tests__/ContentUploader.test.js @@ -1697,4 +1697,172 @@ describe('elements/content-uploader/ContentUploader', () => { expect(instance.modernizedDismissTimer).toBeNull(); }); }); + + describe('maybeUpload()', () => { + const makeFileWithSize = (name, size) => new File([new Uint8Array(size)], name, { type: 'text/plain' }); + + const makePendingFileItem = (name, size) => ({ + api: {}, + extension: 'txt', + file: makeFileWithSize(name, size), + name, + progress: 0, + size, + status: STATUS_PENDING, + }); + + test('should upload immediately when maxFileSize is not configured', () => { + const wrapper = getWrapper(); + const instance = wrapper.instance(); + instance.upload = jest.fn(); + instance.itemsRef.current = [makePendingFileItem('large.txt', 200)]; + + instance.maybeUpload(); + + expect(instance.upload).toHaveBeenCalledTimes(1); + expect(wrapper.state('isLargeFileWarningModalOpen')).toBe(false); + }); + + test('should upload immediately when all pending files are within maxFileSize', () => { + const wrapper = getWrapper({ maxFileSize: 100 }); + const instance = wrapper.instance(); + instance.upload = jest.fn(); + instance.itemsRef.current = [makePendingFileItem('small.txt', 50)]; + + instance.maybeUpload(); + + expect(instance.upload).toHaveBeenCalledTimes(1); + expect(wrapper.state('isLargeFileWarningModalOpen')).toBe(false); + }); + + test('should open the large file warning modal when any pending file exceeds maxFileSize', () => { + const wrapper = getWrapper({ enableModernizedUploads: true, maxFileSize: 100 }); + const instance = wrapper.instance(); + instance.upload = jest.fn(); + instance.itemsRef.current = [makePendingFileItem('small.txt', 50), makePendingFileItem('large.txt', 200)]; + + instance.maybeUpload(); + + expect(instance.upload).not.toHaveBeenCalled(); + expect(wrapper.state('isLargeFileWarningModalOpen')).toBe(true); + }); + }); + + describe('addToQueue()', () => { + const makeFileWithSize = (name, size) => new File([new Uint8Array(size)], name, { type: 'text/plain' }); + + test('should not auto-upload pending items when adding a batch with oversize files during an in-progress upload', () => { + const wrapper = getWrapper({ enableModernizedUploads: true, maxFileSize: 100 }); + const instance = wrapper.instance(); + const inProgressItem = { + api: {}, + extension: 'txt', + file: makeFileWithSize('uploading.txt', 50), + name: 'uploading.txt', + progress: 50, + size: 50, + status: STATUS_IN_PROGRESS, + }; + const oversizeItem = { + api: {}, + extension: 'txt', + file: makeFileWithSize('large.txt', 200), + name: 'large.txt', + progress: 0, + size: 200, + status: STATUS_PENDING, + }; + const eligibleItem = { + api: {}, + extension: 'txt', + file: makeFileWithSize('small.txt', 50), + name: 'small.txt', + progress: 0, + size: 50, + status: STATUS_PENDING, + }; + + instance.itemsRef.current = [inProgressItem]; + wrapper.setState({ view: VIEW_UPLOAD_IN_PROGRESS }); + instance.upload = jest.fn(); + + instance.addToQueue([oversizeItem, eligibleItem], instance.maybeUpload); + + expect(wrapper.state('isLargeFileWarningModalOpen')).toBe(true); + expect(instance.upload).not.toHaveBeenCalled(); + expect(eligibleItem.status).toBe(STATUS_PENDING); + }); + }); + + describe('handleLargeFileWarningUploadRest()', () => { + test('should remove oversize pending items and start upload', () => { + const wrapper = getWrapper({ maxFileSize: 100 }); + const instance = wrapper.instance(); + const oversizeItem = { + api: {}, + extension: 'txt', + file: new File([new Uint8Array(200)], 'large.txt', { type: 'text/plain' }), + name: 'large.txt', + progress: 0, + size: 200, + status: STATUS_PENDING, + }; + const eligibleItem = { + api: {}, + extension: 'txt', + file: new File([new Uint8Array(50)], 'small.txt', { type: 'text/plain' }), + name: 'small.txt', + progress: 0, + size: 50, + status: STATUS_PENDING, + }; + + instance.itemsRef.current = [oversizeItem, eligibleItem]; + instance.removeFileFromUploadQueue = jest.fn(); + instance.upload = jest.fn(); + wrapper.setState({ isLargeFileWarningModalOpen: true }); + + instance.handleLargeFileWarningUploadRest(); + + expect(instance.removeFileFromUploadQueue).toHaveBeenCalledWith(oversizeItem); + expect(instance.removeFileFromUploadQueue).not.toHaveBeenCalledWith(eligibleItem); + expect(wrapper.state('isLargeFileWarningModalOpen')).toBe(false); + expect(instance.upload).toHaveBeenCalledTimes(1); + }); + }); + + describe('handleLargeFileWarningCancel()', () => { + test('should remove all pending items and close the modal', () => { + const wrapper = getWrapper({ maxFileSize: 100 }); + const instance = wrapper.instance(); + const oversizeItem = { + api: {}, + extension: 'txt', + file: new File(['contents'], 'large.txt', { type: 'text/plain' }), + name: 'large.txt', + progress: 0, + size: 200, + status: STATUS_PENDING, + }; + const eligibleItem = { + api: {}, + extension: 'txt', + file: new File(['contents'], 'small.txt', { type: 'text/plain' }), + name: 'small.txt', + progress: 0, + size: 50, + status: STATUS_PENDING, + }; + + instance.itemsRef.current = [oversizeItem, eligibleItem]; + instance.removeFileFromUploadQueue = jest.fn(); + wrapper.setState({ isLargeFileWarningModalOpen: true }); + + instance.handleLargeFileWarningCancel(); + + expect(instance.removeFileFromUploadQueue).toHaveBeenCalledWith(oversizeItem); + expect(instance.removeFileFromUploadQueue).toHaveBeenCalledWith(eligibleItem); + expect(wrapper.state('isLargeFileWarningModalOpen')).toBe(false); + }); + }); }); diff --git a/src/elements/content-uploader/__tests__/LargeFileWarningModal.test.tsx b/src/elements/content-uploader/__tests__/LargeFileWarningModal.test.tsx new file mode 100644 index 0000000000..25e6f13135 --- /dev/null +++ b/src/elements/content-uploader/__tests__/LargeFileWarningModal.test.tsx @@ -0,0 +1,101 @@ +import * as React from 'react'; +import { render, screen, userEvent } from '../../../test-utils/testing-library'; +import LargeFileWarningModal, { type LargeFileWarningModalProps } from '../LargeFileWarningModal'; + +const FILE_LIST_CONTAINER_CLASS = 'bcu-large-file-warning-modal-fileListContainer'; +const originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight'); +const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth'); + +beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + get(this: HTMLElement) { + if (this.classList?.contains(FILE_LIST_CONTAINER_CLASS)) { + return 140; + } + + return originalOffsetHeight?.get?.call(this) ?? 0; + }, + }); + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + get(this: HTMLElement) { + if (this.classList?.contains(FILE_LIST_CONTAINER_CLASS)) { + return 300; + } + + return originalOffsetWidth?.get?.call(this) ?? 0; + }, + }); +}); + +afterAll(() => { + if (originalOffsetHeight) { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', originalOffsetHeight); + } + + if (originalOffsetWidth) { + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', originalOffsetWidth); + } +}); + +const renderModal = (props: Partial = {}) => { + const defaultProps: LargeFileWarningModalProps = { + eligibleCount: 1, + isOpen: true, + maxFileSize: 100, + onCancel: jest.fn(), + onConfirm: jest.fn(), + oversizeFiles: [{ name: 'large-file.txt', size: 200 }], + ...props, + }; + render(); + return defaultProps; +}; + +describe('elements/content-uploader/LargeFileWarningModal', () => { + test('renders singular heading and oversize file details when one file is oversize', async () => { + renderModal(); + expect(await screen.findByText("File Can't Be Uploaded")).toBeInTheDocument(); + expect(screen.getByText('large-file.txt')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Upload the Rest' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + + test('renders plural heading when multiple files are oversize', async () => { + renderModal({ + oversizeFiles: [ + { name: 'large-a.txt', size: 200 }, + { name: 'large-b.txt', size: 300 }, + ], + }); + expect(await screen.findByText("Files Can't Be Uploaded")).toBeInTheDocument(); + }); + + test('disables Upload the Rest when no eligible files remain', async () => { + renderModal({ eligibleCount: 0 }); + expect(await screen.findByRole('button', { name: 'Upload the Rest' })).toBeDisabled(); + }); + + test('calls onConfirm when Upload the Rest is clicked', async () => { + const props = renderModal(); + const user = userEvent(); + await user.click(await screen.findByRole('button', { name: 'Upload the Rest' })); + expect(props.onConfirm).toHaveBeenCalledTimes(1); + }); + + test('calls onCancel when Cancel is clicked', async () => { + const props = renderModal(); + const user = userEvent(); + await user.click(await screen.findByRole('button', { name: 'Cancel' })); + expect(props.onCancel).toHaveBeenCalledTimes(1); + }); + + test('calls onUpgradeCTAClick when upgrade link is clicked', async () => { + const onUpgradeCTAClick = jest.fn(); + renderModal({ onUpgradeCTAClick }); + const user = userEvent(); + await user.click(await screen.findByRole('button', { name: 'Upgrade your plan' })); + expect(onUpgradeCTAClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/elements/content-uploader/messages.ts b/src/elements/content-uploader/messages.ts index 89f241b92a..bd04c86f75 100644 --- a/src/elements/content-uploader/messages.ts +++ b/src/elements/content-uploader/messages.ts @@ -26,6 +26,39 @@ const messages = defineMessages({ defaultMessage: 'Close cancel uploads dialog', description: 'Aria label for the close button on the cancel all uploads modal', }, + largeFileWarningHeading: { + id: 'be.contentUploader.largeFileWarning.heading', + defaultMessage: "{count, plural, one {File} other {Files}} Can't Be Uploaded", + description: 'Heading for the warning modal shown when one or more files exceed the upload size limit', + }, + largeFileWarningBody: { + id: 'be.contentUploader.largeFileWarning.body', + defaultMessage: + '{count, plural, one {This file exceeds} other {These files exceed}} the {maxFileSize} limit on your current plan. Upgrade your plan for larger uploads:', + description: + 'Body text in the warning modal informing the user that one or more files exceed their plan upload size limit. Contains a link to upgrade the plan.', + }, + largeFileWarningCancelButton: { + id: 'be.contentUploader.largeFileWarning.cancelButton', + defaultMessage: 'Cancel', + description: 'Label for the button that cancels the upload attempt entirely', + }, + largeFileWarningUploadTheRestButton: { + id: 'be.contentUploader.largeFileWarning.uploadTheRestButton', + defaultMessage: 'Upload the Rest', + description: + 'Label for the button that proceeds with uploading only the files that are within the plan size limit', + }, + largeFileWarningCloseAriaLabel: { + id: 'be.contentUploader.largeFileWarning.closeAriaLabel', + defaultMessage: 'Close', + description: 'Accessible label for the close button in the large file warning modal', + }, + largeFileWarningFileListAriaLabel: { + id: 'be.contentUploader.largeFileWarning.fileListAriaLabel', + defaultMessage: 'Files exceeding the upload size limit', + description: 'Accessible label for the list of oversize files inside the warning modal', + }, }); export default messages; From 870328127739741b3424e3d8ef05c79100d92f5e Mon Sep 17 00:00:00 2001 From: Oleh Rybak Date: Wed, 24 Jun 2026 17:19:10 +0200 Subject: [PATCH 2/9] fix: close modal on upgrade click --- .../content-uploader/LargeFileWarningModal.tsx | 11 +++++++---- .../__tests__/LargeFileWarningModal.test.tsx | 6 ++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/elements/content-uploader/LargeFileWarningModal.tsx b/src/elements/content-uploader/LargeFileWarningModal.tsx index e32a46fb19..23812b5ac6 100644 --- a/src/elements/content-uploader/LargeFileWarningModal.tsx +++ b/src/elements/content-uploader/LargeFileWarningModal.tsx @@ -50,6 +50,12 @@ const LargeFileWarningModal = ({ } }; + const handleUpgradeCTAClick = (e: React.MouseEvent) => { + e.preventDefault(); + onUpgradeCTAClick?.(); + onCancel(); + }; + const renderFileList = () => { return (
{ - event.preventDefault(); - onUpgradeCTAClick(); - }} + onClick={handleUpgradeCTAClick} type="button" > {chunks} diff --git a/src/elements/content-uploader/__tests__/LargeFileWarningModal.test.tsx b/src/elements/content-uploader/__tests__/LargeFileWarningModal.test.tsx index 25e6f13135..5acf521223 100644 --- a/src/elements/content-uploader/__tests__/LargeFileWarningModal.test.tsx +++ b/src/elements/content-uploader/__tests__/LargeFileWarningModal.test.tsx @@ -91,11 +91,13 @@ describe('elements/content-uploader/LargeFileWarningModal', () => { expect(props.onCancel).toHaveBeenCalledTimes(1); }); - test('calls onUpgradeCTAClick when upgrade link is clicked', async () => { + test('calls onUpgradeCTAClick and onCancel when upgrade link is clicked', async () => { const onUpgradeCTAClick = jest.fn(); - renderModal({ onUpgradeCTAClick }); + const onCancel = jest.fn(); + renderModal({ onUpgradeCTAClick, onCancel }); const user = userEvent(); await user.click(await screen.findByRole('button', { name: 'Upgrade your plan' })); expect(onUpgradeCTAClick).toHaveBeenCalledTimes(1); + expect(onCancel).toHaveBeenCalledTimes(1); }); }); From cdb134eca1139f74e85aec2b2085f5b1b3fb2958 Mon Sep 17 00:00:00 2001 From: Oleh Rybak Date: Wed, 24 Jun 2026 17:38:00 +0200 Subject: [PATCH 3/9] fix: fix undefined file size --- src/elements/content-uploader/LargeFileWarningModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/elements/content-uploader/LargeFileWarningModal.tsx b/src/elements/content-uploader/LargeFileWarningModal.tsx index 23812b5ac6..eddc05d45d 100644 --- a/src/elements/content-uploader/LargeFileWarningModal.tsx +++ b/src/elements/content-uploader/LargeFileWarningModal.tsx @@ -16,7 +16,7 @@ export type OversizeFile = { export interface LargeFileWarningModalProps { eligibleCount: number; isOpen: boolean; - maxFileSize: number; + maxFileSize?: number; onCancel: () => void; onConfirm: () => void; onUpgradeCTAClick?: () => void; @@ -34,7 +34,7 @@ const LargeFileWarningModal = ({ }: LargeFileWarningModalProps) => { const intl = useIntl(); const oversizeCount = oversizeFiles.length; - const maxFileSizeLabel = getFileSize(maxFileSize, intl.locale); + const maxFileSizeLabel = maxFileSize ? getFileSize(maxFileSize, intl.locale) : null; const scrollRef = React.useRef(null); const rowVirtualizer = useVirtualizer({ count: oversizeFiles.length, From a55236f1a68d04ed12f4b01728e05394c1968ad5 Mon Sep 17 00:00:00 2001 From: Oleh Rybak Date: Wed, 24 Jun 2026 18:21:02 +0200 Subject: [PATCH 4/9] fix: skip auto upload after removing item from the queue --- src/elements/content-uploader/ContentUploader.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/elements/content-uploader/ContentUploader.tsx b/src/elements/content-uploader/ContentUploader.tsx index 2fc8cb91ee..628b0580cb 100644 --- a/src/elements/content-uploader/ContentUploader.tsx +++ b/src/elements/content-uploader/ContentUploader.tsx @@ -789,7 +789,7 @@ class ContentUploader extends Component { * @param {UploadItem} item - Item to remove * @return {void} */ - removeFileFromUploadQueue = (item: UploadItem) => { + removeFileFromUploadQueue = (item: UploadItem, skipAutoUpload: boolean = false) => { const { onCancel, useUploadsManager } = this.props; // Clear any error errorCode in footer this.setState({ errorCode: '' }); @@ -833,7 +833,7 @@ class ContentUploader extends Component { } const { view } = this.state; - if (view === VIEW_UPLOAD_IN_PROGRESS) { + if (view === VIEW_UPLOAD_IN_PROGRESS && !skipAutoUpload) { this.upload(); } }); @@ -1673,7 +1673,7 @@ class ContentUploader extends Component { handleLargeFileWarningUploadRest = (): void => { const oversizeItems = this.getOversizePendingItems(); - oversizeItems.forEach(item => this.removeFileFromUploadQueue(item)); + oversizeItems.forEach(item => this.removeFileFromUploadQueue(item, true)); this.setState({ isLargeFileWarningModalOpen: false }, () => { this.upload(); @@ -1689,7 +1689,7 @@ class ContentUploader extends Component { handleLargeFileWarningCancel = (): void => { const pendingItems = this.itemsRef.current.filter(item => item.status === STATUS_PENDING); - pendingItems.forEach(item => this.removeFileFromUploadQueue(item)); + pendingItems.forEach(item => this.removeFileFromUploadQueue(item, true)); this.setState({ isLargeFileWarningModalOpen: false }); }; From 35ce9d9ac669ee78ec429b544940670ef582beb7 Mon Sep 17 00:00:00 2001 From: Oleh Rybak Date: Wed, 24 Jun 2026 18:28:01 +0200 Subject: [PATCH 5/9] fix: fix tests --- .../content-uploader/__tests__/ContentUploader.test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/elements/content-uploader/__tests__/ContentUploader.test.js b/src/elements/content-uploader/__tests__/ContentUploader.test.js index 93a4f5846e..dcdb7ea845 100644 --- a/src/elements/content-uploader/__tests__/ContentUploader.test.js +++ b/src/elements/content-uploader/__tests__/ContentUploader.test.js @@ -1824,8 +1824,8 @@ describe('elements/content-uploader/ContentUploader', () => { instance.handleLargeFileWarningUploadRest(); - expect(instance.removeFileFromUploadQueue).toHaveBeenCalledWith(oversizeItem); - expect(instance.removeFileFromUploadQueue).not.toHaveBeenCalledWith(eligibleItem); + expect(instance.removeFileFromUploadQueue).toHaveBeenCalledWith(oversizeItem, true); + expect(instance.removeFileFromUploadQueue).not.toHaveBeenCalledWith(eligibleItem, true); expect(wrapper.state('isLargeFileWarningModalOpen')).toBe(false); expect(instance.upload).toHaveBeenCalledTimes(1); }); @@ -1860,8 +1860,8 @@ describe('elements/content-uploader/ContentUploader', () => { instance.handleLargeFileWarningCancel(); - expect(instance.removeFileFromUploadQueue).toHaveBeenCalledWith(oversizeItem); - expect(instance.removeFileFromUploadQueue).toHaveBeenCalledWith(eligibleItem); + expect(instance.removeFileFromUploadQueue).toHaveBeenCalledWith(oversizeItem, true); + expect(instance.removeFileFromUploadQueue).toHaveBeenCalledWith(eligibleItem, true); expect(wrapper.state('isLargeFileWarningModalOpen')).toBe(false); }); }); From 555c1d9037457edfc6da9cdd2bb3d94cc1577c84 Mon Sep 17 00:00:00 2001 From: Oleh Rybak Date: Thu, 25 Jun 2026 10:18:00 +0200 Subject: [PATCH 6/9] feat: refactor upload method --- .../content-uploader/ContentUploader.tsx | 44 ++++++------------- .../__tests__/ContentUploader.test.js | 26 +++++------ 2 files changed, 26 insertions(+), 44 deletions(-) diff --git a/src/elements/content-uploader/ContentUploader.tsx b/src/elements/content-uploader/ContentUploader.tsx index 628b0580cb..aa0417a349 100644 --- a/src/elements/content-uploader/ContentUploader.tsx +++ b/src/elements/content-uploader/ContentUploader.tsx @@ -238,7 +238,7 @@ class ContentUploader extends Component { const { files, isPrepopulateFilesEnabled } = this.props; // isPrepopulateFilesEnabled is a prop used to pre-populate files without clicking upload button. if (isPrepopulateFilesEnabled && files && files.length > 0) { - this.addFilesToUploadQueue(files, this.maybeUpload); + this.addFilesToUploadQueue(files, this.upload); } } @@ -738,7 +738,7 @@ class ContentUploader extends Component { const { view } = this.state; // Automatically start upload if other files are being uploaded if (view === VIEW_UPLOAD_IN_PROGRESS) { - this.maybeUpload(); + this.upload(); } }); }; @@ -864,6 +864,15 @@ class ContentUploader extends Component { * @return {void} */ upload = () => { + const { maxFileSize, enableModernizedUploads } = this.props; + + if (maxFileSize && enableModernizedUploads && this.getOversizePendingItems().length > 0) { + if (!this.state.isLargeFileWarningModalOpen) { + this.setState({ isLargeFileWarningModalOpen: true }); + } + return; + } + this.itemsRef.current.forEach(uploadItem => { if (uploadItem.status === STATUS_PENDING) { this.uploadFile(uploadItem); @@ -1637,33 +1646,6 @@ class ContentUploader extends Component { }).length; }; - /** - * Starts upload immediately when no oversize files are present; otherwise opens - * the large file warning modal and waits for user confirmation. - * - * @private - * @return {void} - */ - maybeUpload = (): void => { - const { maxFileSize, enableModernizedUploads } = this.props; - - if (!maxFileSize || !enableModernizedUploads) { - this.upload(); - return; - } - - const oversizeItems = this.getOversizePendingItems(); - - if (oversizeItems.length === 0) { - this.upload(); - return; - } - - if (!this.state.isLargeFileWarningModalOpen) { - this.setState({ isLargeFileWarningModalOpen: true }); - } - }; - /** * Removes oversize pending items and starts uploading the remaining eligible items. * @@ -1705,8 +1687,8 @@ class ContentUploader extends Component { files?: Array, dataTransferItems?: Array, ): void => { - this.addFilesToUploadQueue(files, this.maybeUpload); - this.addDataTransferItemsToUploadQueue(dataTransferItems, this.maybeUpload); + this.addFilesToUploadQueue(files, this.upload); + this.addDataTransferItemsToUploadQueue(dataTransferItems, this.upload); }; /** diff --git a/src/elements/content-uploader/__tests__/ContentUploader.test.js b/src/elements/content-uploader/__tests__/ContentUploader.test.js index dcdb7ea845..30bca6b7ba 100644 --- a/src/elements/content-uploader/__tests__/ContentUploader.test.js +++ b/src/elements/content-uploader/__tests__/ContentUploader.test.js @@ -1698,7 +1698,7 @@ describe('elements/content-uploader/ContentUploader', () => { }); }); - describe('maybeUpload()', () => { + describe('upload() large-file gate', () => { const makeFileWithSize = (name, size) => new File([new Uint8Array(size)], name, { type: 'text/plain' }); const makePendingFileItem = (name, size) => ({ @@ -1714,36 +1714,36 @@ describe('elements/content-uploader/ContentUploader', () => { test('should upload immediately when maxFileSize is not configured', () => { const wrapper = getWrapper(); const instance = wrapper.instance(); - instance.upload = jest.fn(); + instance.uploadFile = jest.fn(); instance.itemsRef.current = [makePendingFileItem('large.txt', 200)]; - instance.maybeUpload(); + instance.upload(); - expect(instance.upload).toHaveBeenCalledTimes(1); + expect(instance.uploadFile).toHaveBeenCalledTimes(1); expect(wrapper.state('isLargeFileWarningModalOpen')).toBe(false); }); test('should upload immediately when all pending files are within maxFileSize', () => { const wrapper = getWrapper({ maxFileSize: 100 }); const instance = wrapper.instance(); - instance.upload = jest.fn(); + instance.uploadFile = jest.fn(); instance.itemsRef.current = [makePendingFileItem('small.txt', 50)]; - instance.maybeUpload(); + instance.upload(); - expect(instance.upload).toHaveBeenCalledTimes(1); + expect(instance.uploadFile).toHaveBeenCalledTimes(1); expect(wrapper.state('isLargeFileWarningModalOpen')).toBe(false); }); test('should open the large file warning modal when any pending file exceeds maxFileSize', () => { const wrapper = getWrapper({ enableModernizedUploads: true, maxFileSize: 100 }); const instance = wrapper.instance(); - instance.upload = jest.fn(); + instance.uploadFile = jest.fn(); instance.itemsRef.current = [makePendingFileItem('small.txt', 50), makePendingFileItem('large.txt', 200)]; - instance.maybeUpload(); + instance.upload(); - expect(instance.upload).not.toHaveBeenCalled(); + expect(instance.uploadFile).not.toHaveBeenCalled(); expect(wrapper.state('isLargeFileWarningModalOpen')).toBe(true); }); }); @@ -1784,12 +1784,12 @@ describe('elements/content-uploader/ContentUploader', () => { instance.itemsRef.current = [inProgressItem]; wrapper.setState({ view: VIEW_UPLOAD_IN_PROGRESS }); - instance.upload = jest.fn(); + instance.uploadFile = jest.fn(); - instance.addToQueue([oversizeItem, eligibleItem], instance.maybeUpload); + instance.addToQueue([oversizeItem, eligibleItem], instance.upload); expect(wrapper.state('isLargeFileWarningModalOpen')).toBe(true); - expect(instance.upload).not.toHaveBeenCalled(); + expect(instance.uploadFile).not.toHaveBeenCalled(); expect(eligibleItem.status).toBe(STATUS_PENDING); }); }); From 31c45c167cda30abd7f930d643bc237373b99d7f Mon Sep 17 00:00:00 2001 From: Oleh Rybak Date: Thu, 25 Jun 2026 10:37:14 +0200 Subject: [PATCH 7/9] fix: add an additional gate for enabling upgrade modal --- .../content-uploader/ContentUploader.tsx | 11 ++++++-- .../__tests__/ContentUploader.test.js | 28 +++++++++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/elements/content-uploader/ContentUploader.tsx b/src/elements/content-uploader/ContentUploader.tsx index aa0417a349..d13b387926 100644 --- a/src/elements/content-uploader/ContentUploader.tsx +++ b/src/elements/content-uploader/ContentUploader.tsx @@ -114,6 +114,7 @@ export interface ContentUploaderProps { uploadHost: string; useUploadsManager?: boolean; enableModernizedUploads?: boolean; + isUpgradeModalEnabled?: boolean; isExpanded?: boolean; onToggle?: (isExpanded: boolean) => void; maxFileSize?: number; @@ -195,6 +196,7 @@ class ContentUploader extends Component { uploadHost: DEFAULT_HOSTNAME_UPLOAD, useUploadsManager: false, enableModernizedUploads: false, + isUpgradeModalEnabled: false, }; /** @@ -864,9 +866,14 @@ class ContentUploader extends Component { * @return {void} */ upload = () => { - const { maxFileSize, enableModernizedUploads } = this.props; + const { enableModernizedUploads, isUpgradeModalEnabled, maxFileSize } = this.props; - if (maxFileSize && enableModernizedUploads && this.getOversizePendingItems().length > 0) { + if ( + maxFileSize && + enableModernizedUploads && + isUpgradeModalEnabled && + this.getOversizePendingItems().length > 0 + ) { if (!this.state.isLargeFileWarningModalOpen) { this.setState({ isLargeFileWarningModalOpen: true }); } diff --git a/src/elements/content-uploader/__tests__/ContentUploader.test.js b/src/elements/content-uploader/__tests__/ContentUploader.test.js index 30bca6b7ba..8130c4840c 100644 --- a/src/elements/content-uploader/__tests__/ContentUploader.test.js +++ b/src/elements/content-uploader/__tests__/ContentUploader.test.js @@ -1736,7 +1736,11 @@ describe('elements/content-uploader/ContentUploader', () => { }); test('should open the large file warning modal when any pending file exceeds maxFileSize', () => { - const wrapper = getWrapper({ enableModernizedUploads: true, maxFileSize: 100 }); + const wrapper = getWrapper({ + enableModernizedUploads: true, + isUpgradeModalEnabled: true, + maxFileSize: 100, + }); const instance = wrapper.instance(); instance.uploadFile = jest.fn(); instance.itemsRef.current = [makePendingFileItem('small.txt', 50), makePendingFileItem('large.txt', 200)]; @@ -1746,13 +1750,33 @@ describe('elements/content-uploader/ContentUploader', () => { expect(instance.uploadFile).not.toHaveBeenCalled(); expect(wrapper.state('isLargeFileWarningModalOpen')).toBe(true); }); + + test('should NOT open the modal when isUpgradeModalEnabled is false even with oversize files', () => { + const wrapper = getWrapper({ + enableModernizedUploads: true, + isUpgradeModalEnabled: false, + maxFileSize: 100, + }); + const instance = wrapper.instance(); + instance.uploadFile = jest.fn(); + instance.itemsRef.current = [makePendingFileItem('large.txt', 200)]; + + instance.upload(); + + expect(instance.uploadFile).toHaveBeenCalledTimes(1); + expect(wrapper.state('isLargeFileWarningModalOpen')).toBe(false); + }); }); describe('addToQueue()', () => { const makeFileWithSize = (name, size) => new File([new Uint8Array(size)], name, { type: 'text/plain' }); test('should not auto-upload pending items when adding a batch with oversize files during an in-progress upload', () => { - const wrapper = getWrapper({ enableModernizedUploads: true, maxFileSize: 100 }); + const wrapper = getWrapper({ + enableModernizedUploads: true, + isUpgradeModalEnabled: true, + maxFileSize: 100, + }); const instance = wrapper.instance(); const inProgressItem = { api: {}, From 2f0d3007278b01ecb41266ab74804ebcf84ac91f Mon Sep 17 00:00:00 2001 From: Oleh Rybak Date: Thu, 25 Jun 2026 15:26:26 +0200 Subject: [PATCH 8/9] feat: add bulk removal from queue for files --- .../content-uploader/ContentUploader.tsx | 49 ++++++++++++++++--- .../__tests__/ContentUploader.test.js | 42 ++++++++++------ 2 files changed, 70 insertions(+), 21 deletions(-) diff --git a/src/elements/content-uploader/ContentUploader.tsx b/src/elements/content-uploader/ContentUploader.tsx index d13b387926..f982683924 100644 --- a/src/elements/content-uploader/ContentUploader.tsx +++ b/src/elements/content-uploader/ContentUploader.tsx @@ -791,7 +791,7 @@ class ContentUploader extends Component { * @param {UploadItem} item - Item to remove * @return {void} */ - removeFileFromUploadQueue = (item: UploadItem, skipAutoUpload: boolean = false) => { + removeFileFromUploadQueue = (item: UploadItem) => { const { onCancel, useUploadsManager } = this.props; // Clear any error errorCode in footer this.setState({ errorCode: '' }); @@ -835,12 +835,51 @@ class ContentUploader extends Component { } const { view } = this.state; - if (view === VIEW_UPLOAD_IN_PROGRESS && !skipAutoUpload) { + if (view === VIEW_UPLOAD_IN_PROGRESS) { this.upload(); } }); }; + /** + * Removes multiple items from the upload queue in a single batch update. + * + * @param {UploadItem[]} itemsToRemove - Items to remove + * @return {void} + */ + removeItemsFromUploadQueue = (itemsToRemove: UploadItem[]): void => { + if (itemsToRemove.length === 0) { + return; + } + + const { onCancel, rootFolderId } = this.props; + + itemsToRemove.forEach(item => { + item.api.cancel(); + + if (item.file) { + const simpleFileId = item.file.name; + const fileWithOptions = item.options ? { file: item.file, options: item.options } : item.file; + const fullFileId = getFileId(fileWithOptions, rootFolderId); + + delete this.itemIdsRef.current[simpleFileId]; + delete this.itemIdsRef.current[fullFileId]; + } else if (item.dedupeKey) { + delete this.itemIdsRef.current[item.dedupeKey]; + } + }); + + const preservedItems = this.itemsRef.current.filter(item => !itemsToRemove.includes(item)); + + onCancel(itemsToRemove); + + this.setState({ + errorCode: '', + itemIds: { ...this.itemIdsRef.current }, + }); + this.updateViewAndCollection(preservedItems); + }; + /** * Aborts uploads in progress and clears upload list. * @@ -1660,9 +1699,7 @@ class ContentUploader extends Component { * @return {void} */ handleLargeFileWarningUploadRest = (): void => { - const oversizeItems = this.getOversizePendingItems(); - - oversizeItems.forEach(item => this.removeFileFromUploadQueue(item, true)); + this.removeItemsFromUploadQueue(this.getOversizePendingItems()); this.setState({ isLargeFileWarningModalOpen: false }, () => { this.upload(); @@ -1678,7 +1715,7 @@ class ContentUploader extends Component { handleLargeFileWarningCancel = (): void => { const pendingItems = this.itemsRef.current.filter(item => item.status === STATUS_PENDING); - pendingItems.forEach(item => this.removeFileFromUploadQueue(item, true)); + this.removeItemsFromUploadQueue(pendingItems); this.setState({ isLargeFileWarningModalOpen: false }); }; diff --git a/src/elements/content-uploader/__tests__/ContentUploader.test.js b/src/elements/content-uploader/__tests__/ContentUploader.test.js index 8130c4840c..599f6198b6 100644 --- a/src/elements/content-uploader/__tests__/ContentUploader.test.js +++ b/src/elements/content-uploader/__tests__/ContentUploader.test.js @@ -1823,7 +1823,7 @@ describe('elements/content-uploader/ContentUploader', () => { const wrapper = getWrapper({ maxFileSize: 100 }); const instance = wrapper.instance(); const oversizeItem = { - api: {}, + api: { cancel: jest.fn() }, extension: 'txt', file: new File([new Uint8Array(200)], 'large.txt', { type: 'text/plain' }), name: 'large.txt', @@ -1832,7 +1832,7 @@ describe('elements/content-uploader/ContentUploader', () => { status: STATUS_PENDING, }; const eligibleItem = { - api: {}, + api: { cancel: jest.fn() }, extension: 'txt', file: new File([new Uint8Array(50)], 'small.txt', { type: 'text/plain' }), name: 'small.txt', @@ -1842,14 +1842,17 @@ describe('elements/content-uploader/ContentUploader', () => { }; instance.itemsRef.current = [oversizeItem, eligibleItem]; - instance.removeFileFromUploadQueue = jest.fn(); instance.upload = jest.fn(); - wrapper.setState({ isLargeFileWarningModalOpen: true }); + wrapper.setState({ + isLargeFileWarningModalOpen: true, + items: [oversizeItem, eligibleItem], + }); instance.handleLargeFileWarningUploadRest(); - expect(instance.removeFileFromUploadQueue).toHaveBeenCalledWith(oversizeItem, true); - expect(instance.removeFileFromUploadQueue).not.toHaveBeenCalledWith(eligibleItem, true); + expect(oversizeItem.api.cancel).toHaveBeenCalledTimes(1); + expect(eligibleItem.api.cancel).not.toHaveBeenCalled(); + expect(wrapper.state('items')).toEqual([eligibleItem]); expect(wrapper.state('isLargeFileWarningModalOpen')).toBe(false); expect(instance.upload).toHaveBeenCalledTimes(1); }); @@ -1857,21 +1860,22 @@ describe('elements/content-uploader/ContentUploader', () => { describe('handleLargeFileWarningCancel()', () => { test('should remove all pending items and close the modal', () => { - const wrapper = getWrapper({ maxFileSize: 100 }); + const onCancel = jest.fn(); + const wrapper = getWrapper({ maxFileSize: 100, onCancel, rootFolderId: '0' }); const instance = wrapper.instance(); const oversizeItem = { - api: {}, + api: { cancel: jest.fn() }, extension: 'txt', - file: new File(['contents'], 'large.txt', { type: 'text/plain' }), + file: new File([new Uint8Array(200)], 'large.txt', { type: 'text/plain' }), name: 'large.txt', progress: 0, size: 200, status: STATUS_PENDING, }; const eligibleItem = { - api: {}, + api: { cancel: jest.fn() }, extension: 'txt', - file: new File(['contents'], 'small.txt', { type: 'text/plain' }), + file: new File([new Uint8Array(50)], 'small.txt', { type: 'text/plain' }), name: 'small.txt', progress: 0, size: 50, @@ -1879,13 +1883,21 @@ describe('elements/content-uploader/ContentUploader', () => { }; instance.itemsRef.current = [oversizeItem, eligibleItem]; - instance.removeFileFromUploadQueue = jest.fn(); - wrapper.setState({ isLargeFileWarningModalOpen: true }); + instance.itemIdsRef.current = { 'large.txt': true, 'small.txt': true }; + wrapper.setState({ + isLargeFileWarningModalOpen: true, + items: [oversizeItem, eligibleItem], + }); instance.handleLargeFileWarningCancel(); - expect(instance.removeFileFromUploadQueue).toHaveBeenCalledWith(oversizeItem, true); - expect(instance.removeFileFromUploadQueue).toHaveBeenCalledWith(eligibleItem, true); + expect(oversizeItem.api.cancel).toHaveBeenCalledTimes(1); + expect(eligibleItem.api.cancel).toHaveBeenCalledTimes(1); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onCancel).toHaveBeenCalledWith([oversizeItem, eligibleItem]); + expect(instance.itemIdsRef.current['large.txt']).toBeUndefined(); + expect(instance.itemIdsRef.current['small.txt']).toBeUndefined(); + expect(wrapper.state('items')).toEqual([]); expect(wrapper.state('isLargeFileWarningModalOpen')).toBe(false); }); }); From ef9a8340aa54fc4de00f0a18df3753e8b323a324 Mon Sep 17 00:00:00 2001 From: Oleh Rybak Date: Fri, 26 Jun 2026 17:33:59 +0200 Subject: [PATCH 9/9] test: refactor the modal test --- .../__tests__/LargeFileWarningModal.test.tsx | 50 ++++++------------- 1 file changed, 14 insertions(+), 36 deletions(-) diff --git a/src/elements/content-uploader/__tests__/LargeFileWarningModal.test.tsx b/src/elements/content-uploader/__tests__/LargeFileWarningModal.test.tsx index 5acf521223..a634970940 100644 --- a/src/elements/content-uploader/__tests__/LargeFileWarningModal.test.tsx +++ b/src/elements/content-uploader/__tests__/LargeFileWarningModal.test.tsx @@ -2,42 +2,20 @@ import * as React from 'react'; import { render, screen, userEvent } from '../../../test-utils/testing-library'; import LargeFileWarningModal, { type LargeFileWarningModalProps } from '../LargeFileWarningModal'; -const FILE_LIST_CONTAINER_CLASS = 'bcu-large-file-warning-modal-fileListContainer'; -const originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight'); -const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth'); - -beforeAll(() => { - Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { - configurable: true, - get(this: HTMLElement) { - if (this.classList?.contains(FILE_LIST_CONTAINER_CLASS)) { - return 140; - } - - return originalOffsetHeight?.get?.call(this) ?? 0; - }, - }); - Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { - configurable: true, - get(this: HTMLElement) { - if (this.classList?.contains(FILE_LIST_CONTAINER_CLASS)) { - return 300; - } - - return originalOffsetWidth?.get?.call(this) ?? 0; - }, - }); -}); - -afterAll(() => { - if (originalOffsetHeight) { - Object.defineProperty(HTMLElement.prototype, 'offsetHeight', originalOffsetHeight); - } - - if (originalOffsetWidth) { - Object.defineProperty(HTMLElement.prototype, 'offsetWidth', originalOffsetWidth); - } -}); +jest.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getTotalSize: () => count * 20, + getVirtualItems: () => + Array.from({ length: count }, (_, index) => ({ + index, + key: index, + start: index * 20, + end: (index + 1) * 20, + size: 20, + lane: 0, + })), + }), +})); const renderModal = (props: Partial = {}) => { const defaultProps: LargeFileWarningModalProps = {