From 9cc5b98bbf5d7137f48ff0eaf1137158c8988c65 Mon Sep 17 00:00:00 2001 From: Hleb Kryshyn Date: Wed, 20 May 2026 17:27:03 +0200 Subject: [PATCH] feat(uploads-manager): add Cancel All confirmation modal The Cancel All button on the modernized uploads manager now opens a confirmation dialog instead of canceling immediately. Confirming runs handleCancelAllUploads; dismissing leaves uploads untouched. Modal uses @box/blueprint-web AlertModal with localized copy and danger-styled primary action. --- i18n/en-US.properties | 10 ++++ .../CancelAllUploadsModal.tsx | 39 +++++++++++++++ .../content-uploader/ContentUploader.tsx | 30 +++++++++++- .../__tests__/CancelAllUploadsModal.test.tsx | 48 +++++++++++++++++++ .../__tests__/ContentUploader.test.js | 47 ++++++++++++++++-- src/elements/content-uploader/messages.ts | 31 ++++++++++++ 6 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 src/elements/content-uploader/CancelAllUploadsModal.tsx create mode 100644 src/elements/content-uploader/__tests__/CancelAllUploadsModal.test.tsx create mode 100644 src/elements/content-uploader/messages.ts diff --git a/i18n/en-US.properties b/i18n/en-US.properties index d4c6296ce7..7e265bf90c 100644 --- a/i18n/en-US.properties +++ b/i18n/en-US.properties @@ -386,6 +386,16 @@ be.contentSidebar.editTask.general.title = Modify General Task be.contentSidebar.mentionUserSelectorLoading = Loading users # Accessibility role description for the mention user selector input be.contentSidebar.mentionUserSelectorRoleDescription = Mention a user +# Aria label for the close button on the cancel all uploads modal +be.contentUploader.cancelAllUploadsCloseLabel = Close cancel uploads dialog +# Confirm button for the cancel all uploads modal +be.contentUploader.cancelAllUploadsConfirmButton = Cancel All +# Dismiss button for the cancel all uploads modal +be.contentUploader.cancelAllUploadsKeepButton = Keep Uploading +# Body content for the cancel all uploads confirmation modal +be.contentUploader.cancelAllUploadsModalContent = Files that are still uploading will be canceled. Completed uploads will not be affected. +# Heading for the cancel all uploads confirmation modal +be.contentUploader.cancelAllUploadsModalHeading = Cancel all uploads? # Label for copy action. be.copy = Copy # Label for create action. diff --git a/src/elements/content-uploader/CancelAllUploadsModal.tsx b/src/elements/content-uploader/CancelAllUploadsModal.tsx new file mode 100644 index 0000000000..2c9f029b97 --- /dev/null +++ b/src/elements/content-uploader/CancelAllUploadsModal.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import { useIntl } from 'react-intl'; +import { AlertModal } from '@box/blueprint-web'; +import messages from './messages'; + +export interface CancelAllUploadsModalProps { + isOpen: boolean; + onConfirm: () => void; + onDismiss: () => void; +} + +export function CancelAllUploadsModal({ isOpen, onConfirm, onDismiss }: CancelAllUploadsModalProps) { + const { formatMessage } = useIntl(); + + const handleOpenChange = (open: boolean) => { + if (!open) { + onDismiss(); + } + }; + + return ( + + + {formatMessage(messages.cancelAllUploadsKeepButton)} + + + {formatMessage(messages.cancelAllUploadsConfirmButton)} + + + ); +} + +export default CancelAllUploadsModal; diff --git a/src/elements/content-uploader/ContentUploader.tsx b/src/elements/content-uploader/ContentUploader.tsx index 14e712b915..4b1f6dc07e 100644 --- a/src/elements/content-uploader/ContentUploader.tsx +++ b/src/elements/content-uploader/ContentUploader.tsx @@ -13,6 +13,7 @@ import DroppableContent from './DroppableContent'; import Footer from './Footer'; import UploadsManager from './UploadsManager'; import { mapToModernizedUploadItems } from './utils/mapToModernizedUploadItem'; +import CancelAllUploadsModal from './CancelAllUploadsModal'; import API from '../../api'; import Browser from '../../utils/Browser'; import Internationalize from '../common/Internationalize'; @@ -112,6 +113,7 @@ export interface ContentUploaderProps { type State = { errorCode?: string; + isCancelAllModalOpen: boolean; isUploadsManagerExpanded: boolean; itemIds: Object; items: UploadItem[]; @@ -190,6 +192,7 @@ class ContentUploader extends Component { items: [], errorCode: '', itemIds: {}, + isCancelAllModalOpen: false, isUploadsManagerExpanded: false, }; this.id = uniqueid('bcu_'); @@ -1195,6 +1198,24 @@ class ContentUploader extends Component { * Cancel every pending or in-progress upload at once. Items keep their row * in the list with the canceled status. Only used by the modernized flow. */ + /** + * Open the Cancel All confirmation modal. Wired as the onCancelAll prop + * passed to the modernized uploads manager so the action requires explicit + * confirmation before destroying in-progress uploads. + */ + handleCancelAllRequest = () => { + this.setState({ isCancelAllModalOpen: true }); + }; + + handleCancelAllDismiss = () => { + this.setState({ isCancelAllModalOpen: false }); + }; + + handleCancelAllConfirm = () => { + this.setState({ isCancelAllModalOpen: false }); + this.handleCancelAllUploads(); + }; + handleCancelAllUploads = () => { const cancelable: UploadItem[] = this.itemsRef.current.filter( item => item.status === STATUS_PENDING || item.status === STATUS_IN_PROGRESS, @@ -1411,7 +1432,7 @@ class ContentUploader extends Component { theme, useUploadsManager, }: ContentUploaderProps = this.props; - const { view, items, errorCode, isUploadsManagerExpanded }: State = this.state; + const { view, items, errorCode, isCancelAllModalOpen, isUploadsManagerExpanded }: State = this.state; const isEmpty = items.length === 0; const isVisible = !isEmpty || !!isDraggingItemsToUploadsManager; @@ -1436,9 +1457,14 @@ class ContentUploader extends Component { onItemCancel={this.handleModernizedItemCancel} onItemRetry={this.handleModernizedItemRetry} onItemRemove={this.handleModernizedItemRemove} - onCancelAll={this.handleCancelAllUploads} + onCancelAll={this.handleCancelAllRequest} onRetryAll={this.handleRetryAllUploads} /> + ); } diff --git a/src/elements/content-uploader/__tests__/CancelAllUploadsModal.test.tsx b/src/elements/content-uploader/__tests__/CancelAllUploadsModal.test.tsx new file mode 100644 index 0000000000..b87743a894 --- /dev/null +++ b/src/elements/content-uploader/__tests__/CancelAllUploadsModal.test.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +import { render, screen, userEvent } from '../../../test-utils/testing-library'; +import CancelAllUploadsModal, { type CancelAllUploadsModalProps } from '../CancelAllUploadsModal'; + +const renderModal = (props: Partial = {}) => { + const defaultProps: CancelAllUploadsModalProps = { + isOpen: true, + onConfirm: jest.fn(), + onDismiss: jest.fn(), + ...props, + }; + render(); + return defaultProps; +}; + +describe('elements/content-uploader/CancelAllUploadsModal', () => { + test('renders heading, body, and both action buttons when open', async () => { + renderModal(); + expect(await screen.findByRole('alertdialog')).toBeInTheDocument(); + expect(screen.getByText('Cancel all uploads?')).toBeInTheDocument(); + expect(screen.getByText(/Files that are still uploading will be canceled/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel All' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Keep Uploading' })).toBeInTheDocument(); + }); + + test('calls onConfirm when Cancel All is clicked', async () => { + const props = renderModal(); + const user = userEvent(); + const button = await screen.findByRole('button', { name: 'Cancel All' }); + await user.click(button); + expect(props.onConfirm).toHaveBeenCalledTimes(1); + expect(props.onDismiss).not.toHaveBeenCalled(); + }); + + test('calls onDismiss when Keep Uploading is clicked', async () => { + const props = renderModal(); + const user = userEvent(); + const button = await screen.findByRole('button', { name: 'Keep Uploading' }); + await user.click(button); + expect(props.onDismiss).toHaveBeenCalledTimes(1); + expect(props.onConfirm).not.toHaveBeenCalled(); + }); + + test('does not render dialog when isOpen is false', () => { + renderModal({ isOpen: false }); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); +}); diff --git a/src/elements/content-uploader/__tests__/ContentUploader.test.js b/src/elements/content-uploader/__tests__/ContentUploader.test.js index 49111b8c32..001716ac04 100644 --- a/src/elements/content-uploader/__tests__/ContentUploader.test.js +++ b/src/elements/content-uploader/__tests__/ContentUploader.test.js @@ -887,7 +887,28 @@ describe('elements/content-uploader/ContentUploader', () => { expect(cancelMock).not.toHaveBeenCalled(); }); - test('handleCancelAllUploads should cancel all in-progress and pending items', () => { + test('onCancelAll should open the confirmation modal instead of canceling directly', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + const inProgress = { + name: 'a.pdf', + extension: 'pdf', + progress: 25, + status: STATUS_IN_PROGRESS, + file: { name: 'a.pdf' }, + api: { cancel: jest.fn() }, + }; + wrapper.setState({ items: [inProgress] }); + const instance = wrapper.instance(); + instance.itemsRef.current = [inProgress]; + + wrapper.find(UploadsManagerBP).prop('onCancelAll')(); + + expect(wrapper.state('isCancelAllModalOpen')).toBe(true); + expect(inProgress.status).toBe(STATUS_IN_PROGRESS); + expect(inProgress.api.cancel).not.toHaveBeenCalled(); + }); + + test('handleCancelAllConfirm should cancel all in-progress and pending items and close modal', () => { const wrapper = getWrapper({ enableModernizedUploads: true }); const inProgress = { name: 'a.pdf', @@ -913,12 +934,13 @@ describe('elements/content-uploader/ContentUploader', () => { file: { name: 'c.pdf' }, api: { cancel: jest.fn() }, }; - wrapper.setState({ items: [inProgress, pending, complete] }); + wrapper.setState({ items: [inProgress, pending, complete], isCancelAllModalOpen: true }); const instance = wrapper.instance(); instance.itemsRef.current = [inProgress, pending, complete]; - wrapper.find(UploadsManagerBP).prop('onCancelAll')(); + instance.handleCancelAllConfirm(); + expect(wrapper.state('isCancelAllModalOpen')).toBe(false); expect(inProgress.status).toBe('canceled'); expect(pending.status).toBe('canceled'); expect(complete.status).toBe(STATUS_COMPLETE); @@ -927,6 +949,25 @@ describe('elements/content-uploader/ContentUploader', () => { expect(complete.api.cancel).not.toHaveBeenCalled(); }); + test('handleCancelAllDismiss should close the modal without canceling uploads', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + const inProgress = { + name: 'a.pdf', + status: STATUS_IN_PROGRESS, + file: { name: 'a.pdf' }, + api: { cancel: jest.fn() }, + }; + wrapper.setState({ items: [inProgress], isCancelAllModalOpen: true }); + const instance = wrapper.instance(); + instance.itemsRef.current = [inProgress]; + + instance.handleCancelAllDismiss(); + + expect(wrapper.state('isCancelAllModalOpen')).toBe(false); + expect(inProgress.status).toBe(STATUS_IN_PROGRESS); + expect(inProgress.api.cancel).not.toHaveBeenCalled(); + }); + test('updateViewAndCollection should not fire onComplete when all items are canceled (modernized)', () => { const onComplete = jest.fn(); const wrapper = getWrapper({ diff --git a/src/elements/content-uploader/messages.ts b/src/elements/content-uploader/messages.ts new file mode 100644 index 0000000000..436824d8f8 --- /dev/null +++ b/src/elements/content-uploader/messages.ts @@ -0,0 +1,31 @@ +import { defineMessages } from 'react-intl'; + +const messages = defineMessages({ + cancelAllUploadsModalHeading: { + id: 'be.contentUploader.cancelAllUploadsModalHeading', + defaultMessage: 'Cancel all uploads?', + description: 'Heading for the cancel all uploads confirmation modal', + }, + cancelAllUploadsModalContent: { + id: 'be.contentUploader.cancelAllUploadsModalContent', + defaultMessage: 'Files that are still uploading will be canceled. Completed uploads will not be affected.', + description: 'Body content for the cancel all uploads confirmation modal', + }, + cancelAllUploadsConfirmButton: { + id: 'be.contentUploader.cancelAllUploadsConfirmButton', + defaultMessage: 'Cancel All', + description: 'Confirm button for the cancel all uploads modal', + }, + cancelAllUploadsKeepButton: { + id: 'be.contentUploader.cancelAllUploadsKeepButton', + defaultMessage: 'Keep Uploading', + description: 'Dismiss button for the cancel all uploads modal', + }, + cancelAllUploadsCloseLabel: { + id: 'be.contentUploader.cancelAllUploadsCloseLabel', + defaultMessage: 'Close cancel uploads dialog', + description: 'Aria label for the close button on the cancel all uploads modal', + }, +}); + +export default messages;