Skip to content

Commit 64ac98c

Browse files
authored
download filename, error handling and cache busting (openedx#98)
* feat: handle download error and display them * chore: update test environment for easier single file test * feat: add cache bursting to the download
1 parent 8a80e2a commit 64ac98c

File tree

7 files changed

+166
-53
lines changed

7 files changed

+166
-53
lines changed

jest.config.js

+1
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ module.exports = createConfig('jest', {
1414
'src/postcss.config.js',
1515
],
1616
testTimeout: 120000,
17+
testEnvironment: 'jsdom',
1718
});

src/containers/ReviewModal/ReviewErrors/DownloadErrors.jsx

+18-1
Original file line numberDiff line numberDiff line change
@@ -28,27 +28,44 @@ export class DownloadErrors extends React.Component {
2828
if (!this.props.isFailed) { return null; }
2929
return (
3030
<ReviewError
31-
key="lockFailed"
31+
key="downloadFailed"
3232
headingMessage={messages.downloadFailedHeading}
3333
actions={{
3434
cancel: { onClick: this.cancelAction, message: messages.dismiss },
3535
confirm: { onClick: this.props.downloadFiles, message: messages.retryDownload },
3636
}}
3737
>
3838
<FormattedMessage {...messages.downloadFailedContent} />
39+
<br />
40+
<FormattedMessage {...messages.failedFiles} />
41+
<ul>
42+
{this.props.error.files.map(filename => (
43+
<li key={filename}>{filename}</li>
44+
))}
45+
</ul>
3946
</ReviewError>
4047
);
4148
}
4249
}
50+
51+
DownloadErrors.defaultProps = {
52+
error: {
53+
files: [],
54+
},
55+
};
4356
DownloadErrors.propTypes = {
4457
// redux
4558
clearState: PropTypes.func.isRequired,
4659
isFailed: PropTypes.bool.isRequired,
60+
error: PropTypes.shape({
61+
files: PropTypes.arrayOf(PropTypes.string),
62+
}),
4763
downloadFiles: PropTypes.func.isRequired,
4864
};
4965

5066
export const mapStateToProps = (state) => ({
5167
isFailed: selectors.requests.isFailed(state, { requestKey: RequestKeys.downloadFiles }),
68+
error: selectors.requests.error(state, { requestKey: RequestKeys.downloadFiles }),
5269
});
5370

5471
export const mapDispatchToProps = {

src/containers/ReviewModal/ReviewErrors/DownloadErrors.test.jsx

+17-2
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ let el;
1414

1515
jest.mock('data/redux', () => ({
1616
selectors: {
17-
requests: { isFailed: (...args) => ({ isFailed: args }) },
17+
requests: {
18+
isFailed: (...args) => ({ isFailed: args }),
19+
error: (...args) => ({ error: args }),
20+
},
1821
},
1922
actions: {
2023
requests: { clearRequest: jest.fn() },
@@ -28,6 +31,9 @@ jest.mock('./ReviewError', () => 'ReviewError');
2831
describe('DownloadErrors component', () => {
2932
const props = {
3033
isFailed: false,
34+
error: {
35+
files: [],
36+
},
3137
};
3238
describe('component', () => {
3339
beforeEach(() => {
@@ -40,7 +46,12 @@ describe('DownloadErrors component', () => {
4046
el.instance().cancelAction = jest.fn().mockName('this.cancelAction');
4147
});
4248
test('failed: show error', () => {
43-
el.setProps({ isFailed: true });
49+
el.setProps({
50+
isFailed: true,
51+
error: {
52+
files: ['file-1-failed.error', 'file-2.failed'],
53+
},
54+
});
4455
expect(el.instance().render()).toMatchSnapshot();
4556
expect(el.isEmptyRender()).toEqual(false);
4657
});
@@ -68,6 +79,10 @@ describe('DownloadErrors component', () => {
6879
const requestKey = RequestKeys.downloadFiles;
6980
expect(mapped.isFailed).toEqual(selectors.requests.isFailed(testState, { requestKey }));
7081
});
82+
test('error loads from requests.error(downloadFiles)', () => {
83+
const requestKey = RequestKeys.downloadFiles;
84+
expect(mapped.error).toEqual(selectors.requests.error(testState, { requestKey }));
85+
});
7186
});
7287
describe('mapDispatchToProps', () => {
7388
it('loads clearState from actions.requests.clearRequest', () => {

src/containers/ReviewModal/ReviewErrors/__snapshots__/DownloadErrors.test.jsx.snap

+14
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,20 @@ exports[`DownloadErrors component component snapshots failed: show error 1`] = `
3434
description="Failed download error content"
3535
id="ora-grading.ReviewModal.errorDownloadFailedContent"
3636
/>
37+
<br />
38+
<FormattedMessage
39+
defaultMessage="Failed files:"
40+
description="List header for file download failure alert"
41+
id="ora-grading.ReviewModal.errorDownloadFailedFiles"
42+
/>
43+
<ul>
44+
<li>
45+
file-1-failed.error
46+
</li>
47+
<li>
48+
file-2.failed
49+
</li>
50+
</ul>
3751
</ReviewError>
3852
`;
3953

src/containers/ReviewModal/ReviewErrors/messages.js

+5
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ const messages = defineMessages({
8282
defaultMessage: 'Retry download',
8383
description: 'Failed download retry button text',
8484
},
85+
failedFiles: {
86+
id: 'ora-grading.ReviewModal.errorDownloadFailedFiles',
87+
defaultMessage: 'Failed files:',
88+
description: 'List header for file download failure alert',
89+
},
8590
});
8691

8792
export default StrictDict(messages);

src/data/redux/thunkActions/download.js

+50-25
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import * as zip from '@zip.js/zip.js';
22
import FileSaver from 'file-saver';
33

4-
import { StrictDict } from 'utils';
54
import { RequestKeys } from 'data/constants/requests';
65
import { selectors } from 'data/redux';
6+
import { locationId } from 'data/constants/app';
7+
import { stringifyUrl } from 'data/services/lms/utils';
78

89
import { networkRequest } from './requests';
910
import * as module from './download';
1011

11-
export const ERRORS = StrictDict({
12-
fetchFailed: 'Fetch failed',
12+
export const DownloadException = (files) => ({
13+
files,
14+
name: 'DownloadException',
1315
});
1416

1517
/**
@@ -21,22 +23,13 @@ export const genManifest = (files) => files.map(
2123
(file) => `Filename: ${file.name}\nDescription: ${file.description}\nSize: ${file.size}`,
2224
).join('\n\n');
2325

24-
/**
25-
* Returns the zip filename
26-
* @return {string} - zip download file name
27-
*/
28-
export const zipFileName = () => {
29-
const currentDate = new Date().getTime();
30-
return `ora-files-download-${currentDate}.zip`;
31-
};
32-
3326
/**
3427
* Zip the blob output of a set of files with a manifest file.
3528
* @param {obj[]} files - list of file entries with downloadUrl, name, and description
3629
* @param {blob[]} blobs - file content blobs
3730
* @return {Promise} - zip async process promise.
3831
*/
39-
export const zipFiles = async (files, blobs) => {
32+
export const zipFiles = async (files, blobs, username) => {
4033
const zipWriter = new zip.ZipWriter(new zip.BlobWriter('application/zip'));
4134
await zipWriter.add('manifest.txt', new zip.TextReader(module.genManifest(files)));
4235

@@ -50,39 +43,71 @@ export const zipFiles = async (files, blobs) => {
5043
}
5144

5245
const zipFile = await zipWriter.close();
53-
FileSaver.saveAs(zipFile, module.zipFileName());
46+
const zipName = `${username}-${locationId}.zip`;
47+
FileSaver.saveAs(zipFile, zipName);
5448
};
5549

50+
/**
51+
* generate url with additional timestamp for cache busting.
52+
* This is implemented for fixing issue with the browser not
53+
* allowing the user to fetch the same url as the image tag.
54+
* @param {string} url
55+
* @returns {string}
56+
*/
57+
export const getTimeStampUrl = (url) => stringifyUrl(url, {
58+
ora_grading_download_timestamp: new Date().getTime(),
59+
});
60+
5661
/**
5762
* Download a file and return its blob is successful, or null if not.
5863
* @param {obj} file - file entry with downloadUrl
59-
* @return {blob} - file blob or null
64+
* @return {Promise} - file blob or null
6065
*/
61-
export const downloadFile = (file) => fetch(file.downloadUrl).then(resp => (
62-
resp.ok ? resp.blob() : null
63-
));
66+
export const downloadFile = (file) => fetch(
67+
module.getTimeStampUrl(file.downloadUrl),
68+
).then((response) => {
69+
if (!response.ok) {
70+
// This is necessary because some of the error such as 404 does not throw.
71+
// Due to that inconsistency, I have decide to share catch statement like this.
72+
throw new Error(response.statusText);
73+
}
74+
return response.blob();
75+
});
6476

6577
/**
6678
* Download blobs given file objects. Returns a promise map.
6779
* @param {obj[]} files - list of file entries with downloadUrl, name, and description
6880
* @return {Promise[]} - Promise map of download attempts (null for failed fetches)
6981
*/
70-
export const downloadBlobs = (files) => Promise.all(files.map(module.downloadFile));
82+
export const downloadBlobs = async (files) => {
83+
const blobs = [];
84+
const errors = [];
85+
86+
// eslint-disable-next-line no-restricted-syntax
87+
for (const file of files) {
88+
try {
89+
// eslint-disable-next-line no-await-in-loop
90+
blobs.push(await module.downloadFile(file));
91+
} catch (error) {
92+
errors.push(file.name);
93+
}
94+
}
95+
if (errors.length) {
96+
throw DownloadException(errors);
97+
}
98+
return blobs;
99+
};
71100

72101
/**
73102
* Download all files for the selected submission as a zip file.
74103
* Throw error and do not download zip if any of the files fail to fetch.
75104
*/
76105
export const downloadFiles = () => (dispatch, getState) => {
77106
const { files } = selectors.grading.selected.response(getState());
107+
const username = selectors.grading.selected.username(getState());
78108
dispatch(networkRequest({
79109
requestKey: RequestKeys.downloadFiles,
80-
promise: module.downloadBlobs(files).then(blobs => {
81-
if (blobs.some(blob => blob === null)) {
82-
throw Error(ERRORS.fetchFailed);
83-
}
84-
return module.zipFiles(files, blobs);
85-
}),
110+
promise: module.downloadBlobs(files).then(blobs => module.zipFiles(files, blobs, username)),
86111
}));
87112
};
88113

0 commit comments

Comments
 (0)