Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions i18n/en-US.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
39 changes: 39 additions & 0 deletions src/elements/content-uploader/CancelAllUploadsModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AlertModal
open={isOpen}
onOpenChange={handleOpenChange}
heading={formatMessage(messages.cancelAllUploadsModalHeading)}
textContent={formatMessage(messages.cancelAllUploadsModalContent)}
closeButtonAriaLabel={formatMessage(messages.cancelAllUploadsCloseLabel)}
>
<AlertModal.SecondaryButton onClick={onDismiss}>
{formatMessage(messages.cancelAllUploadsKeepButton)}
</AlertModal.SecondaryButton>
<AlertModal.PrimaryButton variant="destructive" onClick={onConfirm}>
{formatMessage(messages.cancelAllUploadsConfirmButton)}
</AlertModal.PrimaryButton>
</AlertModal>
);
}

export default CancelAllUploadsModal;
54 changes: 49 additions & 5 deletions src/elements/content-uploader/ContentUploader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import DroppableContent from './DroppableContent';
import Footer from './Footer';
import UploadsManager from './UploadsManager';
import { getUploadItemKey, mapToModernizedUploadItems } from './utils/mapToModernizedUploadItem';
import CancelAllUploadsModal from './CancelAllUploadsModal';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] imports should follow alphabetical order

import API from '../../api';
import Browser from '../../utils/Browser';
import Internationalize from '../common/Internationalize';
Expand Down Expand Up @@ -113,6 +114,7 @@ export interface ContentUploaderProps {

type State = {
errorCode?: string;
isCancelAllModalOpen: boolean;
isUploadsManagerExpanded: boolean;
itemIds: Object;
items: UploadItem[];
Expand Down Expand Up @@ -191,6 +193,7 @@ class ContentUploader extends Component<ContentUploaderProps, State> {
items: [],
errorCode: '',
itemIds: {},
isCancelAllModalOpen: false,
isUploadsManagerExpanded: false,
};
this.id = uniqueid('bcu_');
Expand Down Expand Up @@ -977,19 +980,31 @@ class ContentUploader extends Component<ContentUploaderProps, State> {
*/
updateViewAndCollection(items: UploadItem[], callback?: () => void) {
const {
enableModernizedUploads,
isPartialUploadEnabled,
isResumableUploadsEnabled,
onComplete,
useUploadsManager,
}: ContentUploaderProps = this.props;
const someUploadIsInProgress = items.some(uploadItem => uploadItem.status !== STATUS_COMPLETE);
// When the modernized flow is on, canceled items are kept in the list
// but treated as terminal so they do not block completion logic. The
// legacy view/state machine sees canceled items as non-COMPLETE which
// would otherwise stall progress and emit a stale success notification.
const isTerminalForModernized = (uploadItem: UploadItem) =>
enableModernizedUploads && uploadItem.status === STATUS_CANCELED;
const someUploadIsInProgress = items.some(
uploadItem => uploadItem.status !== STATUS_COMPLETE && !isTerminalForModernized(uploadItem),
);
const someUploadHasFailed = items.some(uploadItem => uploadItem.status === STATUS_ERROR);
const allItemsArePending = !items.some(uploadItem => uploadItem.status !== STATUS_PENDING);
const noFileIsPendingOrInProgress = items.every(
uploadItem => uploadItem.status !== STATUS_PENDING && uploadItem.status !== STATUS_IN_PROGRESS,
);
const areAllItemsFinished = items.every(
uploadItem => uploadItem.status === STATUS_COMPLETE || uploadItem.status === STATUS_ERROR,
uploadItem =>
uploadItem.status === STATUS_COMPLETE ||
uploadItem.status === STATUS_ERROR ||
isTerminalForModernized(uploadItem),
);
const uploadItemsStatus = isResumableUploadsEnabled ? areAllItemsFinished : noFileIsPendingOrInProgress;

Expand Down Expand Up @@ -1023,7 +1038,13 @@ class ContentUploader extends Component<ContentUploaderProps, State> {
if (this.isAutoExpanded) {
this.resetUploadManagerExpandState();
} // Else manually expanded so don't close
onComplete(items);
// In the modernized flow, suppress the completion notification
// when no item actually finished successfully (e.g. user canceled
// every upload). This prevents a misleading "upload complete" toast.
const hasCompletedItem = items.some(uploadItem => uploadItem.status === STATUS_COMPLETE);
if (!enableModernizedUploads || hasCompletedItem) {
onComplete(items);
}
}

const state: Partial<State> = {
Expand Down Expand Up @@ -1181,6 +1202,24 @@ class ContentUploader extends Component<ContentUploaderProps, State> {
onClickCancel(item);
};

/**
* 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 = () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] This function name is quite misleading because it's not actually handling any (normally network) request. Can we change this to ex. handleCancelAllClick or something implies this is simply opening the modal?

this.setState({ isCancelAllModalOpen: true });
};

handleCancelAllDismiss = () => {
this.setState({ isCancelAllModalOpen: false });
};

handleCancelAllConfirm = () => {
this.setState({ isCancelAllModalOpen: false });
this.handleUploadsManagerCancelAll();
};

/**
* 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.
Expand Down Expand Up @@ -1409,7 +1448,7 @@ class ContentUploader extends Component<ContentUploaderProps, State> {
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;

Expand All @@ -1434,9 +1473,14 @@ class ContentUploader extends Component<ContentUploaderProps, State> {
onItemCancel={this.handleUploadsManagerItemCancel}
onItemRetry={this.handleUploadsManagerItemRetry}
onItemRemove={this.handleUploadsManagerItemRemove}
onCancelAll={this.handleUploadsManagerCancelAll}
onCancelAll={this.handleCancelAllRequest}
onRetryAll={this.handleUploadsManagerRetryAll}
/>
<CancelAllUploadsModal
isOpen={isCancelAllModalOpen}
onConfirm={this.handleCancelAllConfirm}
onDismiss={this.handleCancelAllDismiss}
/>
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CancelAllUploadsModalProps> = {}) => {
const defaultProps: CancelAllUploadsModalProps = {
isOpen: true,
onConfirm: jest.fn(),
onDismiss: jest.fn(),
...props,
};
render(<CancelAllUploadsModal {...defaultProps} />);
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();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Is there a reason why this can not be expected with the literal string and has to be regex?

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();
});
});
103 changes: 100 additions & 3 deletions src/elements/content-uploader/__tests__/ContentUploader.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -986,7 +986,28 @@ describe('elements/content-uploader/ContentUploader', () => {
expect(folderItem.status).toBe(STATUS_CANCELED);
});

test('handleUploadsManagerCancelAll 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',
Expand All @@ -1012,12 +1033,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(STATUS_CANCELED);
expect(pending.status).toBe(STATUS_CANCELED);
expect(complete.status).toBe(STATUS_COMPLETE);
Expand All @@ -1026,6 +1048,81 @@ 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({
enableModernizedUploads: true,
useUploadsManager: true,
onComplete,
});
const instance = wrapper.instance();
const canceled = { status: 'canceled' };
instance.updateViewAndCollection([
{ ...canceled, file: { name: 'a' } },
{ ...canceled, file: { name: 'b' } },
]);
expect(onComplete).not.toHaveBeenCalled();
});

test('updateViewAndCollection should fire onComplete when at least one item completes (modernized)', () => {
const onComplete = jest.fn();
const wrapper = getWrapper({
enableModernizedUploads: true,
useUploadsManager: true,
onComplete,
});
const instance = wrapper.instance();
instance.updateViewAndCollection([
{ status: STATUS_COMPLETE, file: { name: 'a' } },
{ status: 'canceled', file: { name: 'b' } },
]);
expect(onComplete).toHaveBeenCalled();
});

test('updateViewAndCollection should treat canceled items as terminal (modernized)', () => {
const wrapper = getWrapper({
enableModernizedUploads: true,
useUploadsManager: true,
});
const instance = wrapper.instance();
instance.updateViewAndCollection([
{ status: STATUS_COMPLETE, file: { name: 'a' } },
{ status: 'canceled', file: { name: 'b' } },
]);
expect(wrapper.state('view')).not.toBe(VIEW_UPLOAD_IN_PROGRESS);
});

test('updateViewAndCollection should preserve legacy behavior when modernized flag is off', () => {
const onComplete = jest.fn();
const wrapper = getWrapper({
enableModernizedUploads: false,
useUploadsManager: true,
onComplete,
});
const instance = wrapper.instance();
instance.updateViewAndCollection([{ status: STATUS_COMPLETE, file: { name: 'a' } }]);
expect(onComplete).toHaveBeenCalled();
});

test('handleUploadsManagerRetryAll should restart errored and canceled items', () => {
const onClickRetry = jest.fn();
const wrapper = getWrapper({ enableModernizedUploads: true, onClickRetry });
Expand Down
31 changes: 31 additions & 0 deletions src/elements/content-uploader/messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { defineMessages } from 'react-intl';

Copy link
Copy Markdown
Contributor

@reneshen0328 reneshen0328 May 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[question] What's the intended scope of this file?

  • If it's dedicated to CancelAllUploadsModal, the prefix on every key is redundant — heading, body, confirmButton, dismissButton, closeLabel would read cleaner.
  • If it'll hold messages for other modals in content-uploader/, dropping just Modal (already implied) gets you cancelAllUploadsHeading, cancelAllUploadsBody, etc.
  • If it'll hold non-modal messages too, the current cancelAllUploadsModal prefix makes sense as-is.

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: {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name feels slightly unclear what it's trying to do. How about keepUploadingButton?

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;
Loading