Skip to content
Draft
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
12 changes: 10 additions & 2 deletions src/common/types/upload.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
// @flow
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 type { Token, BoxItem } from './core';

type UploadStatus =
| typeof STATUS_PENDING
| typeof STATUS_IN_PROGRESS
| typeof STATUS_STAGED
| typeof STATUS_COMPLETE
| typeof STATUS_ERROR;
| typeof STATUS_ERROR
| typeof STATUS_CANCELED;

type FileSystemFileEntry = {
createReader: Function,
Expand Down
1 change: 1 addition & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,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';
Expand Down
96 changes: 91 additions & 5 deletions src/elements/content-uploader/ContentUploader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,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,
Expand Down Expand Up @@ -1157,6 +1158,61 @@ class ContentUploader extends Component<ContentUploaderProps, State> {
});
};

/**
* 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.
*/
handleCancelAllUploads = () => {
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.
*/
handleRetryAllUploads = () => {
const { chunked, isResumableUploadsEnabled } = this.props;
this.itemsRef.current.forEach(item => {
if (item.status !== STATUS_ERROR && item.status !== STATUS_CANCELED) {
return;
}
const { file, api } = item;
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);
} else {
this.resetFile(item);
this.uploadFile(item);
}
});
};

/**
* Expands the upload manager
*
Expand Down Expand Up @@ -1228,10 +1284,38 @@ class ContentUploader extends Component<ContentUploaderProps, State> {
return this.state.items.find(item => getFileId(item.file, rootFolderId) === id);
};

handleModernizedItemAction = (id: string) => {
handleModernizedItemCancel = (id: string) => {
const item = this.findItemByModernizedId(id);
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 = (id: string) => {
const item = this.findItemByModernizedId(id);
if (!item) {
return;
}
const { chunked, isResumableUploadsEnabled, onClickResume, onClickRetry } = this.props;
const { file, api, status } = item;
if (status !== STATUS_ERROR && 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);
}
};

Expand Down Expand Up @@ -1331,9 +1415,11 @@ class ContentUploader extends Component<ContentUploaderProps, State> {
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.handleCancelAllUploads}
onRetryAll={this.handleRetryAllUploads}
/>
</div>
);
Expand Down
119 changes: 112 additions & 7 deletions src/elements/content-uploader/__tests__/ContentUploader.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -815,22 +815,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();
instance.itemsRef.current = [item];

wrapper.find(UploadsManagerBP).prop('onItemCancel')('foo.pdf');

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();
const onClickSpy = jest.spyOn(instance, 'onClick').mockImplementation(() => {});
instance.itemsRef.current = [item];

wrapper.find(UploadsManagerBP).prop('onItemCancel')('foo.pdf');

expect(onClickSpy).toHaveBeenCalledWith(item);
expect(cancelMock).not.toHaveBeenCalled();
expect(item.status).toBe(STATUS_COMPLETE);
});

test('should call removeFileFromUploadQueue when onItemRemove is invoked', () => {
Expand All @@ -855,11 +879,92 @@ describe('elements/content-uploader/ContentUploader', () => {
const wrapper = getWrapper({ enableModernizedUploads: true });
wrapper.setState({ items: [] });
const instance = wrapper.instance();
const onClickSpy = jest.spyOn(instance, 'onClick').mockImplementation(() => {});
instance.itemsRef.current = [];
const cancelMock = jest.fn();

wrapper.find(UploadsManagerBP).prop('onItemCancel')('missing-id');

expect(onClickSpy).not.toHaveBeenCalled();
expect(cancelMock).not.toHaveBeenCalled();
});

test('handleCancelAllUploads 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('handleRetryAllUploads should restart errored and canceled items', () => {
const wrapper = getWrapper({ enableModernizedUploads: true });
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);
});
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
STATUS_STAGED,
STATUS_COMPLETE,
STATUS_ERROR,
STATUS_CANCELED,
} from '../../../../constants';
import { mapToModernizedUploadItem, mapToModernizedUploadItems } from '../mapToModernizedUploadItem';

Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
STATUS_STAGED,
STATUS_COMPLETE,
STATUS_ERROR,
STATUS_CANCELED,
} from '../../../constants';
import { getFileId } from '../../../utils/uploads';
import { UploadItem as LegacyUploadItem } from '../../../common/types/upload';
Expand All @@ -26,6 +27,7 @@ const STATUS_MAP: Record<string, ModernizedStatus> = {
[STATUS_STAGED]: 'staged',
[STATUS_COMPLETE]: 'complete',
[STATUS_ERROR]: 'error',
[STATUS_CANCELED]: 'canceled',
};

export function mapToModernizedUploadItem(item: LegacyUploadItem, rootFolderId: string): ModernizedUploadItem {
Expand Down
Loading