From 55e030d05a83d2e5a8082319da047f1597630929 Mon Sep 17 00:00:00 2001 From: Hleb Kryshyn Date: Thu, 28 May 2026 14:38:40 +0200 Subject: [PATCH 1/3] feat(content-uploader): add Cancel All and Retry All handlers behind enableModernizedUploads flag --- src/common/types/upload.js | 9 +- src/constants.js | 1 + .../content-uploader/ContentUploader.tsx | 113 +++++++- .../__tests__/ContentUploader.test.js | 270 +++++++++++++++++- .../tests/ContentUploader-visual.stories.js | 168 ++++++++++- .../mapToModernizedUploadItem.test.ts | 2 + .../utils/mapToModernizedUploadItem.ts | 10 +- 7 files changed, 545 insertions(+), 28 deletions(-) diff --git a/src/common/types/upload.js b/src/common/types/upload.js index b512e30b55..90f31e67ed 100644 --- a/src/common/types/upload.js +++ b/src/common/types/upload.js @@ -1,13 +1,8 @@ // @flow -import { STATUS_PENDING, STATUS_IN_PROGRESS, STATUS_STAGED, STATUS_COMPLETE, STATUS_ERROR } from '../../constants'; import type { Token, BoxItem } from './core'; -type UploadStatus = - | typeof STATUS_PENDING - | typeof STATUS_IN_PROGRESS - | typeof STATUS_STAGED - | typeof STATUS_COMPLETE - | typeof STATUS_ERROR; +// TODO: replace with `UploadItemStatus` from @box/uploads-manager once 'inprogress' is aligned to 'uploading'. +type UploadStatus = 'pending' | 'inprogress' | 'staged' | 'complete' | 'error' | 'canceled'; type FileSystemFileEntry = { createReader: Function, diff --git a/src/constants.js b/src/constants.js index 218f306964..ba71f07d1d 100644 --- a/src/constants.js +++ b/src/constants.js @@ -250,6 +250,7 @@ export const CLIENT_VERSION = __VERSION__; /* ---------------------- Statuses -------------------------- */ export const STATUS_ACCEPTED: 'accepted' = 'accepted'; +export const STATUS_CANCELED: 'canceled' = 'canceled'; export const STATUS_COMPLETE: 'complete' = 'complete'; export const STATUS_ERROR: 'error' = 'error'; export const STATUS_INACTIVE: 'inactive' = 'inactive'; diff --git a/src/elements/content-uploader/ContentUploader.tsx b/src/elements/content-uploader/ContentUploader.tsx index 9df47ab4f1..7d54840ee7 100644 --- a/src/elements/content-uploader/ContentUploader.tsx +++ b/src/elements/content-uploader/ContentUploader.tsx @@ -42,6 +42,7 @@ import { DEFAULT_HOSTNAME_UPLOAD, ERROR_CODE_ITEM_NAME_IN_USE, ERROR_CODE_UPLOAD_FILE_LIMIT, + STATUS_CANCELED, STATUS_COMPLETE, STATUS_ERROR, STATUS_IN_PROGRESS, @@ -1168,6 +1169,71 @@ class ContentUploader extends Component { }); }; + /** + * Mark a single in-progress or pending item as canceled without removing + * it from the queue. Used by the modernized uploads manager flow so that + * canceled items remain visible in the list. + */ + markItemCanceled = (item: UploadItem) => { + const { onClickCancel } = this.props; + const { api } = item; + if (api && typeof api.cancel === 'function') { + api.cancel(); + } + item.status = STATUS_CANCELED; + onClickCancel(item); + }; + + /** + * 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. + */ + handleModernizedCancelAll = () => { + const cancelable: UploadItem[] = this.itemsRef.current.filter( + item => item.status === STATUS_PENDING || item.status === STATUS_IN_PROGRESS, + ); + cancelable.forEach(item => this.markItemCanceled(item)); + if (cancelable.length > 0) { + const { onCancel } = this.props; + onCancel(cancelable); + this.updateViewAndCollection([...this.itemsRef.current]); + } + }; + + /** + * Retry every errored or canceled item. Resumable items (with a sessionId) + * are resumed; everything else is restarted via resetFile + uploadFile. + * Only used by the modernized flow. + */ + handleModernizedRetryAll = () => { + const { chunked, isResumableUploadsEnabled, onClickCancel, onClickResume, onClickRetry } = this.props; + // Snapshot since removeFileFromUploadQueue mutates itemsRef.current. + const targets = this.itemsRef.current.filter( + item => item.status === STATUS_ERROR || item.status === STATUS_CANCELED, + ); + targets.forEach(item => { + const { file, api, error } = item; + // Name-in-use cannot be resolved by retrying — drop the item like the legacy onClick path. + if (error?.code === ERROR_CODE_ITEM_NAME_IN_USE) { + this.removeFileFromUploadQueue(item); + onClickCancel(item); + return; + } + const isChunkedUpload = + chunked && !item.isFolder && file.size > CHUNKED_UPLOAD_MIN_SIZE_BYTES && isMultiputSupported(); + const isResumable = isResumableUploadsEnabled && isChunkedUpload && api?.sessionId; + if (isResumable) { + item.bytesUploadedOnLastResume = api.totalUploadedBytes; + this.resumeFile(item); + onClickResume(item); + } else { + this.resetFile(item); + this.uploadFile(item); + onClickRetry(item); + } + }); + }; + /** * Expands the upload manager * @@ -1239,13 +1305,44 @@ class ContentUploader extends Component { return this.state.items.find(item => getUploadItemKey(item, rootFolderId) === key); }; - handleModernizedItemAction = (key: string) => { + handleModernizedItemCancel = (key: string) => { const item = this.findItemByUploadKey(key); - if (item) { - this.onClick(item); + if (!item) { + return; + } + if (item.status === STATUS_PENDING || item.status === STATUS_IN_PROGRESS) { + this.markItemCanceled(item); + this.updateViewAndCollection([...this.itemsRef.current]); + } + }; + + handleModernizedItemRetry = (key: string) => { + const item = this.findItemByUploadKey(key); + if (!item) { + return; + } + const { chunked, isResumableUploadsEnabled, onClickCancel, onClickResume, onClickRetry } = this.props; + const { file, api, error, status } = item; + if (status !== STATUS_ERROR && status !== STATUS_CANCELED) { + return; + } + // Name-in-use cannot be resolved by retrying — drop the item like the legacy onClick path. + if (error?.code === ERROR_CODE_ITEM_NAME_IN_USE) { + this.removeFileFromUploadQueue(item); + onClickCancel(item); + return; + } + const isChunkedUpload = + chunked && !item.isFolder && file.size > CHUNKED_UPLOAD_MIN_SIZE_BYTES && isMultiputSupported(); + const isResumable = isResumableUploadsEnabled && isChunkedUpload && api && api.sessionId; + if (isResumable) { + item.bytesUploadedOnLastResume = api.totalUploadedBytes; + this.resumeFile(item); + onClickResume(item); } else { - // eslint-disable-next-line no-console - console.warn(`ContentUploader: no upload item found for key "${key}" on action.`); + this.resetFile(item); + this.uploadFile(item); + onClickRetry(item); } }; @@ -1348,9 +1445,11 @@ class ContentUploader extends Component { items={mapToModernizedUploadItems(items, rootFolderId)} isExpanded={isUploadsManagerExpanded} onToggle={this.toggleUploadsManager} - onItemCancel={this.handleModernizedItemAction} - onItemRetry={this.handleModernizedItemAction} + onItemCancel={this.handleModernizedItemCancel} + onItemRetry={this.handleModernizedItemRetry} onItemRemove={this.handleModernizedItemRemove} + onCancelAll={this.handleModernizedCancelAll} + onRetryAll={this.handleModernizedRetryAll} /> ); diff --git a/src/elements/content-uploader/__tests__/ContentUploader.test.js b/src/elements/content-uploader/__tests__/ContentUploader.test.js index 2658b7414c..a78f87ea63 100644 --- a/src/elements/content-uploader/__tests__/ContentUploader.test.js +++ b/src/elements/content-uploader/__tests__/ContentUploader.test.js @@ -8,6 +8,7 @@ import Footer from '../Footer'; import UploadsManager from '../UploadsManager'; import DroppableContent from '../DroppableContent'; import { + ERROR_CODE_ITEM_NAME_IN_USE, STATUS_PENDING, STATUS_IN_PROGRESS, STATUS_STAGED, @@ -862,22 +863,46 @@ describe('elements/content-uploader/ContentUploader', () => { expect(wrapper.find(UploadsManagerBP).prop('isExpanded')).toBe(true); }); - test('should call onClick when onItemCancel is invoked', () => { + test('should mark in-progress item as canceled when onItemCancel is invoked', () => { const wrapper = getWrapper({ enableModernizedUploads: true }); + const cancelMock = jest.fn(); const item = { name: 'foo.pdf', extension: 'pdf', - progress: 0, - status: STATUS_PENDING, + progress: 50, + status: STATUS_IN_PROGRESS, file: { name: 'foo.pdf' }, + api: { cancel: cancelMock }, }; wrapper.setState({ items: [item] }); const instance = wrapper.instance(); - const onClickSpy = jest.spyOn(instance, 'onClick').mockImplementation(() => {}); + instance.itemsRef.current = [item]; wrapper.find(UploadsManagerBP).prop('onItemCancel')('foo.pdf'); - expect(onClickSpy).toHaveBeenCalledWith(item); + expect(cancelMock).toHaveBeenCalled(); + expect(item.status).toBe('canceled'); + }); + + test('should ignore onItemCancel for already-completed items', () => { + const wrapper = getWrapper({ enableModernizedUploads: true }); + const cancelMock = jest.fn(); + const item = { + name: 'foo.pdf', + extension: 'pdf', + progress: 100, + status: STATUS_COMPLETE, + file: { name: 'foo.pdf' }, + api: { cancel: cancelMock }, + }; + wrapper.setState({ items: [item] }); + const instance = wrapper.instance(); + instance.itemsRef.current = [item]; + + wrapper.find(UploadsManagerBP).prop('onItemCancel')('foo.pdf'); + + expect(cancelMock).not.toHaveBeenCalled(); + expect(item.status).toBe(STATUS_COMPLETE); }); test('should call removeFileFromUploadQueue when onItemRemove is invoked', () => { @@ -899,14 +924,27 @@ describe('elements/content-uploader/ContentUploader', () => { }); test('should no-op when modernized id does not match any item', () => { - const wrapper = getWrapper({ enableModernizedUploads: true }); - wrapper.setState({ items: [] }); + const onClickCancel = jest.fn(); + const wrapper = getWrapper({ enableModernizedUploads: true, onClickCancel }); + const item = { + name: 'foo.pdf', + extension: 'pdf', + progress: 50, + status: STATUS_IN_PROGRESS, + file: { name: 'foo.pdf' }, + api: { cancel: jest.fn() }, + }; + wrapper.setState({ items: [item] }); const instance = wrapper.instance(); - const onClickSpy = jest.spyOn(instance, 'onClick').mockImplementation(() => {}); + instance.itemsRef.current = [item]; + const markSpy = jest.spyOn(instance, 'markItemCanceled'); wrapper.find(UploadsManagerBP).prop('onItemCancel')('missing-id'); - expect(onClickSpy).not.toHaveBeenCalled(); + expect(markSpy).not.toHaveBeenCalled(); + expect(item.api.cancel).not.toHaveBeenCalled(); + expect(onClickCancel).not.toHaveBeenCalled(); + expect(item.status).toBe(STATUS_IN_PROGRESS); }); test('should not crash when state contains a folder item without a file', () => { @@ -935,16 +973,224 @@ describe('elements/content-uploader/ContentUploader', () => { progress: 0, status: STATUS_PENDING, isFolder: true, - api: {}, + api: { cancel: jest.fn() }, }; wrapper.setState({ items: [folderItem] }); const instance = wrapper.instance(); - const onClickSpy = jest.spyOn(instance, 'onClick').mockImplementation(() => {}); + instance.itemsRef.current = [folderItem]; const folderId = wrapper.find(UploadsManagerBP).prop('items')[0].id; wrapper.find(UploadsManagerBP).prop('onItemCancel')(folderId); - expect(onClickSpy).toHaveBeenCalledWith(folderItem); + expect(folderItem.status).toBe('canceled'); + }); + + test('handleModernizedCancelAll should cancel all in-progress and pending items', () => { + 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() }, + }; + const pending = { + name: 'b.pdf', + extension: 'pdf', + progress: 0, + status: STATUS_PENDING, + file: { name: 'b.pdf' }, + api: { cancel: jest.fn() }, + }; + const complete = { + name: 'c.pdf', + extension: 'pdf', + progress: 100, + status: STATUS_COMPLETE, + file: { name: 'c.pdf' }, + api: { cancel: jest.fn() }, + }; + wrapper.setState({ items: [inProgress, pending, complete] }); + const instance = wrapper.instance(); + instance.itemsRef.current = [inProgress, pending, complete]; + + wrapper.find(UploadsManagerBP).prop('onCancelAll')(); + + expect(inProgress.status).toBe('canceled'); + expect(pending.status).toBe('canceled'); + expect(complete.status).toBe(STATUS_COMPLETE); + expect(inProgress.api.cancel).toHaveBeenCalled(); + expect(pending.api.cancel).toHaveBeenCalled(); + expect(complete.api.cancel).not.toHaveBeenCalled(); + }); + + test('handleModernizedRetryAll should restart errored and canceled items', () => { + const onClickRetry = jest.fn(); + const wrapper = getWrapper({ enableModernizedUploads: true, onClickRetry }); + const errored = { + name: 'a.pdf', + extension: 'pdf', + progress: 0, + status: STATUS_ERROR, + file: { name: 'a.pdf', size: 100 }, + api: {}, + isFolder: false, + }; + const canceled = { + name: 'b.pdf', + extension: 'pdf', + progress: 0, + status: 'canceled', + file: { name: 'b.pdf', size: 100 }, + api: {}, + isFolder: false, + }; + const complete = { + name: 'c.pdf', + extension: 'pdf', + progress: 100, + status: STATUS_COMPLETE, + file: { name: 'c.pdf', size: 100 }, + api: {}, + }; + wrapper.setState({ items: [errored, canceled, complete] }); + const instance = wrapper.instance(); + instance.itemsRef.current = [errored, canceled, complete]; + const resetSpy = jest.spyOn(instance, 'resetFile').mockImplementation(() => {}); + const uploadFileSpy = jest.spyOn(instance, 'uploadFile').mockImplementation(() => {}); + + wrapper.find(UploadsManagerBP).prop('onRetryAll')(); + + expect(resetSpy).toHaveBeenCalledTimes(2); + expect(uploadFileSpy).toHaveBeenCalledTimes(2); + expect(onClickRetry).toHaveBeenCalledTimes(2); + expect(onClickRetry).toHaveBeenCalledWith(errored); + expect(onClickRetry).toHaveBeenCalledWith(canceled); + }); + + test('handleModernizedRetryAll should call onClickResume for resumable chunked items', () => { + const onClickResume = jest.fn(); + const wrapper = getWrapper({ + enableModernizedUploads: true, + chunked: true, + isResumableUploadsEnabled: true, + onClickResume, + }); + const resumable = { + name: 'big.bin', + extension: 'bin', + progress: 25, + status: STATUS_ERROR, + file: { name: 'big.bin', size: CHUNKED_UPLOAD_MIN_SIZE_BYTES + 1 }, + api: { sessionId: 'sess-1', totalUploadedBytes: 1024 }, + isFolder: false, + }; + wrapper.setState({ items: [resumable] }); + const instance = wrapper.instance(); + instance.itemsRef.current = [resumable]; + jest.spyOn(UploaderUtils, 'isMultiputSupported').mockReturnValue(true); + const resumeSpy = jest.spyOn(instance, 'resumeFile').mockImplementation(() => {}); + + wrapper.find(UploadsManagerBP).prop('onRetryAll')(); + + expect(resumeSpy).toHaveBeenCalledWith(resumable); + expect(onClickResume).toHaveBeenCalledWith(resumable); + expect(resumable.bytesUploadedOnLastResume).toBe(1024); + }); + + test('handleModernizedRetryAll should drop name-in-use items instead of retrying', () => { + const onClickCancel = jest.fn(); + const onCancel = jest.fn(); + const wrapper = getWrapper({ enableModernizedUploads: true, onClickCancel, onCancel }); + const conflict = { + name: 'dup.pdf', + extension: 'pdf', + progress: 0, + status: STATUS_ERROR, + error: { code: ERROR_CODE_ITEM_NAME_IN_USE }, + file: { name: 'dup.pdf', size: 100 }, + api: { cancel: jest.fn() }, + isFolder: false, + }; + const ok = { + name: 'ok.pdf', + extension: 'pdf', + progress: 0, + status: STATUS_ERROR, + file: { name: 'ok.pdf', size: 100 }, + api: {}, + isFolder: false, + }; + wrapper.setState({ items: [conflict, ok] }); + const instance = wrapper.instance(); + instance.itemsRef.current = [conflict, ok]; + const resetSpy = jest.spyOn(instance, 'resetFile').mockImplementation(() => {}); + const uploadFileSpy = jest.spyOn(instance, 'uploadFile').mockImplementation(() => {}); + + wrapper.find(UploadsManagerBP).prop('onRetryAll')(); + + expect(onClickCancel).toHaveBeenCalledWith(conflict); + expect(conflict.api.cancel).toHaveBeenCalled(); + expect(instance.itemsRef.current).not.toContain(conflict); + expect(resetSpy).toHaveBeenCalledTimes(1); + expect(resetSpy).toHaveBeenCalledWith(ok); + expect(uploadFileSpy).toHaveBeenCalledWith(ok); + }); + + test('handleModernizedItemRetry should fire onClickRetry for non-resumable items', () => { + const onClickRetry = jest.fn(); + const wrapper = getWrapper({ enableModernizedUploads: true, onClickRetry }); + const item = { + name: 'a.pdf', + extension: 'pdf', + progress: 0, + status: STATUS_ERROR, + file: { name: 'a.pdf', size: 100 }, + api: {}, + isFolder: false, + }; + wrapper.setState({ items: [item] }); + const instance = wrapper.instance(); + instance.itemsRef.current = [item]; + const resetSpy = jest.spyOn(instance, 'resetFile').mockImplementation(() => {}); + const uploadFileSpy = jest.spyOn(instance, 'uploadFile').mockImplementation(() => {}); + + const { id } = wrapper.find(UploadsManagerBP).prop('items')[0]; + wrapper.find(UploadsManagerBP).prop('onItemRetry')(id); + + expect(resetSpy).toHaveBeenCalledWith(item); + expect(uploadFileSpy).toHaveBeenCalledWith(item); + expect(onClickRetry).toHaveBeenCalledWith(item); + }); + + test('handleModernizedItemRetry should drop name-in-use items instead of retrying', () => { + const onClickCancel = jest.fn(); + const wrapper = getWrapper({ enableModernizedUploads: true, onClickCancel }); + const item = { + name: 'dup.pdf', + extension: 'pdf', + progress: 0, + status: STATUS_ERROR, + error: { code: ERROR_CODE_ITEM_NAME_IN_USE }, + file: { name: 'dup.pdf', size: 100 }, + api: { cancel: jest.fn() }, + isFolder: false, + }; + wrapper.setState({ items: [item] }); + const instance = wrapper.instance(); + instance.itemsRef.current = [item]; + const resetSpy = jest.spyOn(instance, 'resetFile').mockImplementation(() => {}); + const uploadFileSpy = jest.spyOn(instance, 'uploadFile').mockImplementation(() => {}); + + const { id } = wrapper.find(UploadsManagerBP).prop('items')[0]; + wrapper.find(UploadsManagerBP).prop('onItemRetry')(id); + + expect(onClickCancel).toHaveBeenCalledWith(item); + expect(item.api.cancel).toHaveBeenCalled(); + expect(instance.itemsRef.current).not.toContain(item); + expect(resetSpy).not.toHaveBeenCalled(); + expect(uploadFileSpy).not.toHaveBeenCalled(); }); }); }); diff --git a/src/elements/content-uploader/stories/tests/ContentUploader-visual.stories.js b/src/elements/content-uploader/stories/tests/ContentUploader-visual.stories.js index 2867a86bec..2cf27dabce 100644 --- a/src/elements/content-uploader/stories/tests/ContentUploader-visual.stories.js +++ b/src/elements/content-uploader/stories/tests/ContentUploader-visual.stories.js @@ -1,5 +1,5 @@ import { expect, userEvent, waitFor, within } from 'storybook/test'; -import { http, HttpResponse } from 'msw'; +import { delay, http, HttpResponse } from 'msw'; import ContentUploader from '../../ContentUploader'; import { SLEEP_TIMEOUT } from '../../../../utils/storybook'; @@ -166,6 +166,172 @@ export const successfulMultipleUpload = { }, }; +const modernizedArgs = { + enableModernizedUploads: true, + isPrepopulateFilesEnabled: true, + // useUploadsManager gates expandUploadsManager(), so the modernized panel cannot auto-expand without it. + useUploadsManager: true, +}; + +const expandModernizedPanel = async canvas => { + const expandButton = canvas.queryByLabelText('Expand uploads panel'); + if (expandButton) { + await userEvent.click(expandButton); + await waitFor(() => { + expect(canvas.getByLabelText('Collapse uploads panel')).toBeInTheDocument(); + }); + } +}; + +const modernizedStallingHandlers = [ + http.options(`${DEFAULT_HOSTNAME_API}/2.0/files/content`, () => HttpResponse.json({})), + http.post(`${DEFAULT_HOSTNAME_UPLOAD}/api/2.0/files/content`, async () => { + await delay('infinite'); + return HttpResponse.json({}); + }), +]; + +const modernizedFailingHandlers = [ + http.options(`${DEFAULT_HOSTNAME_API}/2.0/files/content`, () => HttpResponse.json({})), + http.post(`${DEFAULT_HOSTNAME_UPLOAD}/api/2.0/files/content`, () => + HttpResponse.json({ message: 'Upload failed' }, { status: 500 }), + ), +]; + +export const modernizedSingleUpload = { + args: { + ...modernizedArgs, + files: [singleFile], + }, + parameters: { + msw: { handlers: modernizedStallingHandlers }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expandModernizedPanel(canvas); + + await waitFor(() => { + expect(canvas.getByText('upload_file.txt')).toBeInTheDocument(); + }); + expect(canvas.getByText('Uploading')).toBeInTheDocument(); + }, +}; + +export const modernizedCancelAll = { + args: { + ...modernizedArgs, + files: multipleFiles, + }, + parameters: { + msw: { handlers: modernizedStallingHandlers }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expandModernizedPanel(canvas); + + await waitFor(() => { + expect(canvas.getByText('upload_file_1.txt')).toBeInTheDocument(); + expect(canvas.getByText('upload_file_2.txt')).toBeInTheDocument(); + expect(canvas.getByText('upload_file_3.txt')).toBeInTheDocument(); + }); + + const cancelAllButton = canvas.getByLabelText('Cancel All'); + await userEvent.click(cancelAllButton); + + await waitFor(() => { + expect(canvas.getByText('Uploads Canceled')).toBeInTheDocument(); + }); + expect(canvas.getByText('upload_file_1.txt')).toBeInTheDocument(); + expect(canvas.getByText('upload_file_2.txt')).toBeInTheDocument(); + expect(canvas.getByText('upload_file_3.txt')).toBeInTheDocument(); + expect(canvas.getAllByText('Canceled')).toHaveLength(3); + }, +}; + +export const modernizedRetryAll = { + args: { + ...modernizedArgs, + files: multipleFiles, + }, + parameters: { + msw: { handlers: modernizedFailingHandlers }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await waitFor( + () => { + expect(canvas.getByText('Uploads Failed')).toBeInTheDocument(); + }, + { timeout: SLEEP_TIMEOUT }, + ); + + await expandModernizedPanel(canvas); + + await waitFor(() => { + expect(canvas.getByText('upload_file_1.txt')).toBeInTheDocument(); + }); + expect(canvas.getByText('upload_file_2.txt')).toBeInTheDocument(); + expect(canvas.getByText('upload_file_3.txt')).toBeInTheDocument(); + + const retryAllButton = canvas.getByLabelText('Retry All'); + await userEvent.click(retryAllButton); + + expect(canvas.getByText('upload_file_1.txt')).toBeInTheDocument(); + expect(canvas.getByText('upload_file_2.txt')).toBeInTheDocument(); + expect(canvas.getByText('upload_file_3.txt')).toBeInTheDocument(); + }, +}; + +export const modernizedItemCancel = { + args: { + ...modernizedArgs, + files: multipleFiles, + }, + parameters: { + msw: { handlers: modernizedStallingHandlers }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expandModernizedPanel(canvas); + + await waitFor(() => { + expect(canvas.getByText('upload_file_2.txt')).toBeInTheDocument(); + }); + + const cancelButton = canvas.getByLabelText('Cancel upload for upload_file_2.txt'); + await userEvent.click(cancelButton); + + await waitFor(() => { + expect(canvas.getByText('Canceled')).toBeInTheDocument(); + }); + expect(canvas.getByText('upload_file_1.txt')).toBeInTheDocument(); + expect(canvas.getByText('upload_file_2.txt')).toBeInTheDocument(); + expect(canvas.getByText('upload_file_3.txt')).toBeInTheDocument(); + }, +}; + +export const modernizedToggleExpand = { + args: modernizedArgs, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const itemList = canvas.getByTestId('uploads-manager-item-list-container'); + expect(itemList).toHaveAttribute('inert'); + + const expandButton = canvas.getByLabelText('Expand uploads panel'); + await userEvent.click(expandButton); + + await waitFor(() => { + expect(canvas.getByLabelText('Collapse uploads panel')).toBeInTheDocument(); + }); + expect(itemList).not.toHaveAttribute('inert'); + }, +}; + export default { title: 'Elements/ContentUploader/tests/visual', component: ContentUploader, diff --git a/src/elements/content-uploader/utils/__tests__/mapToModernizedUploadItem.test.ts b/src/elements/content-uploader/utils/__tests__/mapToModernizedUploadItem.test.ts index 3376be23a1..cc5ac9aef4 100644 --- a/src/elements/content-uploader/utils/__tests__/mapToModernizedUploadItem.test.ts +++ b/src/elements/content-uploader/utils/__tests__/mapToModernizedUploadItem.test.ts @@ -4,6 +4,7 @@ import { STATUS_STAGED, STATUS_COMPLETE, STATUS_ERROR, + STATUS_CANCELED, } from '../../../../constants'; import { mapToModernizedUploadItem, mapToModernizedUploadItems } from '../mapToModernizedUploadItem'; @@ -38,6 +39,7 @@ describe('mapToModernizedUploadItem()', () => { [STATUS_STAGED, 'staged'], [STATUS_COMPLETE, 'complete'], [STATUS_ERROR, 'error'], + [STATUS_CANCELED, 'canceled'], ])('maps legacy status %s to modernized %s', (legacy, modernized) => { const result = mapToModernizedUploadItem(buildLegacyItem({ status: legacy }), '0'); expect(result.status).toBe(modernized); diff --git a/src/elements/content-uploader/utils/mapToModernizedUploadItem.ts b/src/elements/content-uploader/utils/mapToModernizedUploadItem.ts index 3fd0badbdb..95d4d3f6bc 100644 --- a/src/elements/content-uploader/utils/mapToModernizedUploadItem.ts +++ b/src/elements/content-uploader/utils/mapToModernizedUploadItem.ts @@ -1,5 +1,12 @@ import type { UploadItem, UploadItemStatus } from '@box/uploads-manager'; -import { STATUS_PENDING, STATUS_IN_PROGRESS, STATUS_STAGED, STATUS_COMPLETE, STATUS_ERROR } from '../../../constants'; +import { + STATUS_PENDING, + STATUS_IN_PROGRESS, + STATUS_STAGED, + STATUS_COMPLETE, + STATUS_ERROR, + STATUS_CANCELED, +} from '../../../constants'; import { getFileId } from '../../../utils/uploads'; import { UploadItem as LegacyUploadItem, FolderUploadItem } from '../../../common/types/upload'; @@ -9,6 +16,7 @@ const STATUS_MAP: Record = { [STATUS_STAGED]: 'staged', [STATUS_COMPLETE]: 'complete', [STATUS_ERROR]: 'error', + [STATUS_CANCELED]: 'canceled', }; export function getUploadItemKey(item: LegacyUploadItem | FolderUploadItem, rootFolderId: string): string { From 79474623f185ae550986ce1b4c0b84bea12b1196 Mon Sep 17 00:00:00 2001 From: Hleb Kryshyn Date: Thu, 28 May 2026 15:55:48 +0200 Subject: [PATCH 2/3] refactor(content-uploader): address PR feedback on modernized upload handlers - Restore typeof STATUS_* union, add STATUS_CANCELED to keep type/constant linked - Rename handleModernized* -> handleUploadsManager* for clarity - Extract retryUploadsManagerItem helper shared by RetryAll and per-item retry - Drop unreachable empty-cancelable guard in handleUploadsManagerCancelAll - Drop dead typeof api.cancel function check in markItemCanceled - Align item handlers to warn-and-return on missing key - Replace inline status strings with constants in tests - Remove modernized VRTs already covered by shared feature tests --- i18n/en-US.properties | 10 ++ src/common/types/upload.js | 16 +- .../content-uploader/ContentUploader.tsx | 116 ++++++------ .../__tests__/ContentUploader.test.js | 23 +-- .../tests/ContentUploader-visual.stories.js | 168 +----------------- 5 files changed, 89 insertions(+), 244 deletions(-) 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/common/types/upload.js b/src/common/types/upload.js index 90f31e67ed..15d710ad81 100644 --- a/src/common/types/upload.js +++ b/src/common/types/upload.js @@ -1,8 +1,22 @@ // @flow +import { + STATUS_PENDING, + STATUS_IN_PROGRESS, + STATUS_STAGED, + STATUS_COMPLETE, + STATUS_ERROR, + STATUS_CANCELED, +} from '../../constants'; import type { Token, BoxItem } from './core'; // TODO: replace with `UploadItemStatus` from @box/uploads-manager once 'inprogress' is aligned to 'uploading'. -type UploadStatus = 'pending' | 'inprogress' | 'staged' | 'complete' | 'error' | 'canceled'; +type UploadStatus = + | typeof STATUS_PENDING + | typeof STATUS_IN_PROGRESS + | typeof STATUS_STAGED + | typeof STATUS_COMPLETE + | typeof STATUS_ERROR + | typeof STATUS_CANCELED; type FileSystemFileEntry = { createReader: Function, diff --git a/src/elements/content-uploader/ContentUploader.tsx b/src/elements/content-uploader/ContentUploader.tsx index 7d54840ee7..4dabc98f51 100644 --- a/src/elements/content-uploader/ContentUploader.tsx +++ b/src/elements/content-uploader/ContentUploader.tsx @@ -1176,10 +1176,7 @@ class ContentUploader extends Component { */ markItemCanceled = (item: UploadItem) => { const { onClickCancel } = this.props; - const { api } = item; - if (api && typeof api.cancel === 'function') { - api.cancel(); - } + item.api.cancel(); item.status = STATUS_CANCELED; onClickCancel(item); }; @@ -1188,16 +1185,14 @@ 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. */ - handleModernizedCancelAll = () => { + handleUploadsManagerCancelAll = () => { + const { onCancel } = this.props; const cancelable: UploadItem[] = this.itemsRef.current.filter( item => item.status === STATUS_PENDING || item.status === STATUS_IN_PROGRESS, ); cancelable.forEach(item => this.markItemCanceled(item)); - if (cancelable.length > 0) { - const { onCancel } = this.props; - onCancel(cancelable); - this.updateViewAndCollection([...this.itemsRef.current]); - } + onCancel(cancelable); + this.updateViewAndCollection([...this.itemsRef.current]); }; /** @@ -1205,33 +1200,39 @@ class ContentUploader extends Component { * are resumed; everything else is restarted via resetFile + uploadFile. * Only used by the modernized flow. */ - handleModernizedRetryAll = () => { - const { chunked, isResumableUploadsEnabled, onClickCancel, onClickResume, onClickRetry } = this.props; + handleUploadsManagerRetryAll = () => { // Snapshot since removeFileFromUploadQueue mutates itemsRef.current. const targets = this.itemsRef.current.filter( item => item.status === STATUS_ERROR || item.status === STATUS_CANCELED, ); - targets.forEach(item => { - const { file, api, error } = item; - // Name-in-use cannot be resolved by retrying — drop the item like the legacy onClick path. - if (error?.code === ERROR_CODE_ITEM_NAME_IN_USE) { - this.removeFileFromUploadQueue(item); - onClickCancel(item); - return; - } - const isChunkedUpload = - chunked && !item.isFolder && file.size > CHUNKED_UPLOAD_MIN_SIZE_BYTES && isMultiputSupported(); - const isResumable = isResumableUploadsEnabled && isChunkedUpload && api?.sessionId; - if (isResumable) { - item.bytesUploadedOnLastResume = api.totalUploadedBytes; - this.resumeFile(item); - onClickResume(item); - } else { - this.resetFile(item); - this.uploadFile(item); - onClickRetry(item); - } - }); + targets.forEach(item => this.retryUploadsManagerItem(item)); + }; + + /** + * Retry a single errored or canceled item: drop name-in-use, resume resumable + * chunked uploads, otherwise restart from scratch. Shared by RetryAll and per-item retry. + */ + retryUploadsManagerItem = (item: UploadItem) => { + const { chunked, isResumableUploadsEnabled, onClickCancel, onClickResume, onClickRetry } = this.props; + const { file, api, error } = item; + // Name-in-use cannot be resolved by retrying — drop the item like the legacy onClick path. + if (error?.code === ERROR_CODE_ITEM_NAME_IN_USE) { + this.removeFileFromUploadQueue(item); + onClickCancel(item); + return; + } + const isChunkedUpload = + chunked && !item.isFolder && file.size > CHUNKED_UPLOAD_MIN_SIZE_BYTES && isMultiputSupported(); + const isResumable = isResumableUploadsEnabled && isChunkedUpload && api?.sessionId; + if (isResumable) { + item.bytesUploadedOnLastResume = api.totalUploadedBytes; + this.resumeFile(item); + onClickResume(item); + } else { + this.resetFile(item); + this.uploadFile(item); + onClickRetry(item); + } }; /** @@ -1305,9 +1306,11 @@ class ContentUploader extends Component { return this.state.items.find(item => getUploadItemKey(item, rootFolderId) === key); }; - handleModernizedItemCancel = (key: string) => { + handleUploadsManagerItemCancel = (key: string) => { const item = this.findItemByUploadKey(key); if (!item) { + // eslint-disable-next-line no-console + console.warn(`ContentUploader: no upload item found for key "${key}" on cancel.`); return; } if (item.status === STATUS_PENDING || item.status === STATUS_IN_PROGRESS) { @@ -1316,44 +1319,27 @@ class ContentUploader extends Component { } }; - handleModernizedItemRetry = (key: string) => { + handleUploadsManagerItemRetry = (key: string) => { const item = this.findItemByUploadKey(key); if (!item) { + // eslint-disable-next-line no-console + console.warn(`ContentUploader: no upload item found for key "${key}" on retry.`); return; } - const { chunked, isResumableUploadsEnabled, onClickCancel, onClickResume, onClickRetry } = this.props; - const { file, api, error, status } = item; - if (status !== STATUS_ERROR && status !== STATUS_CANCELED) { - return; - } - // Name-in-use cannot be resolved by retrying — drop the item like the legacy onClick path. - if (error?.code === ERROR_CODE_ITEM_NAME_IN_USE) { - this.removeFileFromUploadQueue(item); - onClickCancel(item); + if (item.status !== STATUS_ERROR && item.status !== STATUS_CANCELED) { return; } - const isChunkedUpload = - chunked && !item.isFolder && file.size > CHUNKED_UPLOAD_MIN_SIZE_BYTES && isMultiputSupported(); - const isResumable = isResumableUploadsEnabled && isChunkedUpload && api && api.sessionId; - if (isResumable) { - item.bytesUploadedOnLastResume = api.totalUploadedBytes; - this.resumeFile(item); - onClickResume(item); - } else { - this.resetFile(item); - this.uploadFile(item); - onClickRetry(item); - } + this.retryUploadsManagerItem(item); }; - handleModernizedItemRemove = (key: string) => { + handleUploadsManagerItemRemove = (key: string) => { const item = this.findItemByUploadKey(key); - if (item) { - this.removeFileFromUploadQueue(item); - } else { + if (!item) { // eslint-disable-next-line no-console console.warn(`ContentUploader: no upload item found for key "${key}" on remove.`); + return; } + this.removeFileFromUploadQueue(item); }; /** @@ -1445,11 +1431,11 @@ class ContentUploader extends Component { items={mapToModernizedUploadItems(items, rootFolderId)} isExpanded={isUploadsManagerExpanded} onToggle={this.toggleUploadsManager} - onItemCancel={this.handleModernizedItemCancel} - onItemRetry={this.handleModernizedItemRetry} - onItemRemove={this.handleModernizedItemRemove} - onCancelAll={this.handleModernizedCancelAll} - onRetryAll={this.handleModernizedRetryAll} + onItemCancel={this.handleUploadsManagerItemCancel} + onItemRetry={this.handleUploadsManagerItemRetry} + onItemRemove={this.handleUploadsManagerItemRemove} + onCancelAll={this.handleUploadsManagerCancelAll} + onRetryAll={this.handleUploadsManagerRetryAll} /> ); diff --git a/src/elements/content-uploader/__tests__/ContentUploader.test.js b/src/elements/content-uploader/__tests__/ContentUploader.test.js index a78f87ea63..4682cdf902 100644 --- a/src/elements/content-uploader/__tests__/ContentUploader.test.js +++ b/src/elements/content-uploader/__tests__/ContentUploader.test.js @@ -14,6 +14,7 @@ import { STATUS_STAGED, STATUS_COMPLETE, STATUS_ERROR, + STATUS_CANCELED, VIEW_ERROR, VIEW_UPLOAD_EMPTY, VIEW_UPLOAD_IN_PROGRESS, @@ -881,7 +882,7 @@ describe('elements/content-uploader/ContentUploader', () => { wrapper.find(UploadsManagerBP).prop('onItemCancel')('foo.pdf'); expect(cancelMock).toHaveBeenCalled(); - expect(item.status).toBe('canceled'); + expect(item.status).toBe(STATUS_CANCELED); }); test('should ignore onItemCancel for already-completed items', () => { @@ -982,10 +983,10 @@ describe('elements/content-uploader/ContentUploader', () => { const folderId = wrapper.find(UploadsManagerBP).prop('items')[0].id; wrapper.find(UploadsManagerBP).prop('onItemCancel')(folderId); - expect(folderItem.status).toBe('canceled'); + expect(folderItem.status).toBe(STATUS_CANCELED); }); - test('handleModernizedCancelAll should cancel all in-progress and pending items', () => { + test('handleUploadsManagerCancelAll should cancel all in-progress and pending items', () => { const wrapper = getWrapper({ enableModernizedUploads: true }); const inProgress = { name: 'a.pdf', @@ -1017,15 +1018,15 @@ describe('elements/content-uploader/ContentUploader', () => { wrapper.find(UploadsManagerBP).prop('onCancelAll')(); - expect(inProgress.status).toBe('canceled'); - expect(pending.status).toBe('canceled'); + expect(inProgress.status).toBe(STATUS_CANCELED); + expect(pending.status).toBe(STATUS_CANCELED); expect(complete.status).toBe(STATUS_COMPLETE); expect(inProgress.api.cancel).toHaveBeenCalled(); expect(pending.api.cancel).toHaveBeenCalled(); expect(complete.api.cancel).not.toHaveBeenCalled(); }); - test('handleModernizedRetryAll should restart errored and canceled items', () => { + test('handleUploadsManagerRetryAll should restart errored and canceled items', () => { const onClickRetry = jest.fn(); const wrapper = getWrapper({ enableModernizedUploads: true, onClickRetry }); const errored = { @@ -1041,7 +1042,7 @@ describe('elements/content-uploader/ContentUploader', () => { name: 'b.pdf', extension: 'pdf', progress: 0, - status: 'canceled', + status: STATUS_CANCELED, file: { name: 'b.pdf', size: 100 }, api: {}, isFolder: false, @@ -1069,7 +1070,7 @@ describe('elements/content-uploader/ContentUploader', () => { expect(onClickRetry).toHaveBeenCalledWith(canceled); }); - test('handleModernizedRetryAll should call onClickResume for resumable chunked items', () => { + test('handleUploadsManagerRetryAll should call onClickResume for resumable chunked items', () => { const onClickResume = jest.fn(); const wrapper = getWrapper({ enableModernizedUploads: true, @@ -1099,7 +1100,7 @@ describe('elements/content-uploader/ContentUploader', () => { expect(resumable.bytesUploadedOnLastResume).toBe(1024); }); - test('handleModernizedRetryAll should drop name-in-use items instead of retrying', () => { + test('handleUploadsManagerRetryAll should drop name-in-use items instead of retrying', () => { const onClickCancel = jest.fn(); const onCancel = jest.fn(); const wrapper = getWrapper({ enableModernizedUploads: true, onClickCancel, onCancel }); @@ -1138,7 +1139,7 @@ describe('elements/content-uploader/ContentUploader', () => { expect(uploadFileSpy).toHaveBeenCalledWith(ok); }); - test('handleModernizedItemRetry should fire onClickRetry for non-resumable items', () => { + test('handleUploadsManagerItemRetry should fire onClickRetry for non-resumable items', () => { const onClickRetry = jest.fn(); const wrapper = getWrapper({ enableModernizedUploads: true, onClickRetry }); const item = { @@ -1164,7 +1165,7 @@ describe('elements/content-uploader/ContentUploader', () => { expect(onClickRetry).toHaveBeenCalledWith(item); }); - test('handleModernizedItemRetry should drop name-in-use items instead of retrying', () => { + test('handleUploadsManagerItemRetry should drop name-in-use items instead of retrying', () => { const onClickCancel = jest.fn(); const wrapper = getWrapper({ enableModernizedUploads: true, onClickCancel }); const item = { diff --git a/src/elements/content-uploader/stories/tests/ContentUploader-visual.stories.js b/src/elements/content-uploader/stories/tests/ContentUploader-visual.stories.js index 2cf27dabce..2867a86bec 100644 --- a/src/elements/content-uploader/stories/tests/ContentUploader-visual.stories.js +++ b/src/elements/content-uploader/stories/tests/ContentUploader-visual.stories.js @@ -1,5 +1,5 @@ import { expect, userEvent, waitFor, within } from 'storybook/test'; -import { delay, http, HttpResponse } from 'msw'; +import { http, HttpResponse } from 'msw'; import ContentUploader from '../../ContentUploader'; import { SLEEP_TIMEOUT } from '../../../../utils/storybook'; @@ -166,172 +166,6 @@ export const successfulMultipleUpload = { }, }; -const modernizedArgs = { - enableModernizedUploads: true, - isPrepopulateFilesEnabled: true, - // useUploadsManager gates expandUploadsManager(), so the modernized panel cannot auto-expand without it. - useUploadsManager: true, -}; - -const expandModernizedPanel = async canvas => { - const expandButton = canvas.queryByLabelText('Expand uploads panel'); - if (expandButton) { - await userEvent.click(expandButton); - await waitFor(() => { - expect(canvas.getByLabelText('Collapse uploads panel')).toBeInTheDocument(); - }); - } -}; - -const modernizedStallingHandlers = [ - http.options(`${DEFAULT_HOSTNAME_API}/2.0/files/content`, () => HttpResponse.json({})), - http.post(`${DEFAULT_HOSTNAME_UPLOAD}/api/2.0/files/content`, async () => { - await delay('infinite'); - return HttpResponse.json({}); - }), -]; - -const modernizedFailingHandlers = [ - http.options(`${DEFAULT_HOSTNAME_API}/2.0/files/content`, () => HttpResponse.json({})), - http.post(`${DEFAULT_HOSTNAME_UPLOAD}/api/2.0/files/content`, () => - HttpResponse.json({ message: 'Upload failed' }, { status: 500 }), - ), -]; - -export const modernizedSingleUpload = { - args: { - ...modernizedArgs, - files: [singleFile], - }, - parameters: { - msw: { handlers: modernizedStallingHandlers }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await expandModernizedPanel(canvas); - - await waitFor(() => { - expect(canvas.getByText('upload_file.txt')).toBeInTheDocument(); - }); - expect(canvas.getByText('Uploading')).toBeInTheDocument(); - }, -}; - -export const modernizedCancelAll = { - args: { - ...modernizedArgs, - files: multipleFiles, - }, - parameters: { - msw: { handlers: modernizedStallingHandlers }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await expandModernizedPanel(canvas); - - await waitFor(() => { - expect(canvas.getByText('upload_file_1.txt')).toBeInTheDocument(); - expect(canvas.getByText('upload_file_2.txt')).toBeInTheDocument(); - expect(canvas.getByText('upload_file_3.txt')).toBeInTheDocument(); - }); - - const cancelAllButton = canvas.getByLabelText('Cancel All'); - await userEvent.click(cancelAllButton); - - await waitFor(() => { - expect(canvas.getByText('Uploads Canceled')).toBeInTheDocument(); - }); - expect(canvas.getByText('upload_file_1.txt')).toBeInTheDocument(); - expect(canvas.getByText('upload_file_2.txt')).toBeInTheDocument(); - expect(canvas.getByText('upload_file_3.txt')).toBeInTheDocument(); - expect(canvas.getAllByText('Canceled')).toHaveLength(3); - }, -}; - -export const modernizedRetryAll = { - args: { - ...modernizedArgs, - files: multipleFiles, - }, - parameters: { - msw: { handlers: modernizedFailingHandlers }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await waitFor( - () => { - expect(canvas.getByText('Uploads Failed')).toBeInTheDocument(); - }, - { timeout: SLEEP_TIMEOUT }, - ); - - await expandModernizedPanel(canvas); - - await waitFor(() => { - expect(canvas.getByText('upload_file_1.txt')).toBeInTheDocument(); - }); - expect(canvas.getByText('upload_file_2.txt')).toBeInTheDocument(); - expect(canvas.getByText('upload_file_3.txt')).toBeInTheDocument(); - - const retryAllButton = canvas.getByLabelText('Retry All'); - await userEvent.click(retryAllButton); - - expect(canvas.getByText('upload_file_1.txt')).toBeInTheDocument(); - expect(canvas.getByText('upload_file_2.txt')).toBeInTheDocument(); - expect(canvas.getByText('upload_file_3.txt')).toBeInTheDocument(); - }, -}; - -export const modernizedItemCancel = { - args: { - ...modernizedArgs, - files: multipleFiles, - }, - parameters: { - msw: { handlers: modernizedStallingHandlers }, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await expandModernizedPanel(canvas); - - await waitFor(() => { - expect(canvas.getByText('upload_file_2.txt')).toBeInTheDocument(); - }); - - const cancelButton = canvas.getByLabelText('Cancel upload for upload_file_2.txt'); - await userEvent.click(cancelButton); - - await waitFor(() => { - expect(canvas.getByText('Canceled')).toBeInTheDocument(); - }); - expect(canvas.getByText('upload_file_1.txt')).toBeInTheDocument(); - expect(canvas.getByText('upload_file_2.txt')).toBeInTheDocument(); - expect(canvas.getByText('upload_file_3.txt')).toBeInTheDocument(); - }, -}; - -export const modernizedToggleExpand = { - args: modernizedArgs, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - const itemList = canvas.getByTestId('uploads-manager-item-list-container'); - expect(itemList).toHaveAttribute('inert'); - - const expandButton = canvas.getByLabelText('Expand uploads panel'); - await userEvent.click(expandButton); - - await waitFor(() => { - expect(canvas.getByLabelText('Collapse uploads panel')).toBeInTheDocument(); - }); - expect(itemList).not.toHaveAttribute('inert'); - }, -}; - export default { title: 'Elements/ContentUploader/tests/visual', component: ContentUploader, From 514e1b104caec9f02ff1f42e4e80beb9f3ec939b Mon Sep 17 00:00:00 2001 From: Hleb Kryshyn Date: Thu, 28 May 2026 18:12:50 +0200 Subject: [PATCH 3/3] chore(content-uploader): drop orphan cancelAllUploads i18n keys These keys had no defineMessages source on this branch, so the CI i18n rebuild dropped them and failed the generated-files check. --- i18n/en-US.properties | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/i18n/en-US.properties b/i18n/en-US.properties index 7e265bf90c..d4c6296ce7 100644 --- a/i18n/en-US.properties +++ b/i18n/en-US.properties @@ -386,16 +386,6 @@ 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.