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;