feat: Zip download (#41)
* integration tests through navigation * more tests * download-demo * feat: file download and tests * fix: update manifest styling
This commit is contained in:
@@ -15,6 +15,7 @@ export const RequestKeys = StrictDict({
|
||||
prefetchNext: 'prefetchNext',
|
||||
prefetchPrev: 'prefetchPrev',
|
||||
submitGrade: 'submitGrade',
|
||||
downloadFiles: 'downloadFiles',
|
||||
});
|
||||
|
||||
export const ErrorCodes = StrictDict({
|
||||
|
||||
@@ -16,7 +16,6 @@ const initialState = {
|
||||
},
|
||||
showReview: false,
|
||||
showRubric: false,
|
||||
isGrading: false,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
@@ -29,7 +28,6 @@ const app = createSlice({
|
||||
setShowReview: (state, { payload }) => ({
|
||||
...state,
|
||||
showReview: payload,
|
||||
isReview: state.isGrading && payload, // stop grading when closing review window
|
||||
showRubric: state.showRubric && payload, // Hide rubric when closing review window
|
||||
}),
|
||||
setShowRubric: (state, { payload }) => ({ ...state, showRubric: payload }),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { RequestStates, RequestKeys } from 'data/constants/requests';
|
||||
|
||||
const initialState = {
|
||||
[RequestKeys.initialize]: { status: RequestStates.inactive },
|
||||
[RequestKeys.downloadFiles]: { status: RequestStates.inactive },
|
||||
[RequestKeys.fetchSubmission]: { status: RequestStates.inactive },
|
||||
[RequestKeys.fetchSubmissionStatus]: { status: RequestStates.inactive },
|
||||
[RequestKeys.setLock]: { status: RequestStates.inactive },
|
||||
|
||||
78
src/data/redux/thunkActions/download.js
Normal file
78
src/data/redux/thunkActions/download.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import JSZip from 'jszip';
|
||||
import FileSaver from 'file-saver';
|
||||
|
||||
import { RequestKeys } from 'data/constants/requests';
|
||||
import { selectors } from 'data/redux';
|
||||
|
||||
import { networkRequest } from './requests';
|
||||
import * as module from './download';
|
||||
|
||||
/**
|
||||
* Generate a manifest file content based on files object
|
||||
* @param {obj[]} files - list of file entries with downloadUrl, name, and description
|
||||
* @return {string} - manifest text file content.
|
||||
*/
|
||||
export const genManifest = (files) => files.map(
|
||||
(file) => `Filename: ${file.name}\nDescription: ${file.description}`,
|
||||
).join('\n\n');
|
||||
|
||||
/**
|
||||
* Returns the zip filename
|
||||
* @return {string} - zip download file name
|
||||
*/
|
||||
export const zipFileName = () => {
|
||||
const currentDate = new Date().getTime();
|
||||
return `ora-files-download-${currentDate}.zip`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Zip the blob output of a set of files with a manifest file.
|
||||
* @param {obj[]} files - list of file entries with downloadUrl, name, and description
|
||||
* @param {blob[]} blobs - file content blobs
|
||||
* @return {Promise} - zip async process promise.
|
||||
*/
|
||||
export const zipFiles = (files, blobs) => {
|
||||
const zip = new JSZip();
|
||||
zip.file('manifest.txt', module.genManifest(files));
|
||||
blobs.forEach((blob, i) => zip.file(files[i].name, blob));
|
||||
return zip.generateAsync({ type: 'blob' }).then(
|
||||
zipFile => FileSaver.saveAs(zipFile, module.zipFileName()),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Download a file and return its blob is successful, or null if not.
|
||||
* @param {obj} file - file entry with downloadUrl
|
||||
* @return {blob} - file blob or null
|
||||
*/
|
||||
export const downloadFile = (file) => fetch(file.downloadUrl).then(resp => (
|
||||
resp.ok ? resp.blob() : null
|
||||
));
|
||||
|
||||
/**
|
||||
* Download blobs given file objects. Returns a promise map.
|
||||
* @param {obj[]} files - list of file entries with downloadUrl, name, and description
|
||||
* @return {Promise[]} - Promise map of download attempts (null for failed fetches)
|
||||
*/
|
||||
export const downloadBlobs = (files) => Promise.all(files.map(module.downloadFile));
|
||||
|
||||
/**
|
||||
* Download all files for the selected submission as a zip file.
|
||||
* Throw error and do not download zip if any of the files fail to fetch.
|
||||
*/
|
||||
export const downloadFiles = () => (dispatch, getState) => {
|
||||
const { files } = selectors.grading.selected.response(getState());
|
||||
dispatch(networkRequest({
|
||||
requestKey: RequestKeys.downloadFiles,
|
||||
promise: module.downloadBlobs(files).then(blobs => {
|
||||
if (blobs.some(blob => blob === null)) {
|
||||
throw Error('Fetch Failed');
|
||||
}
|
||||
module.zipFiles(files, blobs);
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
export default {
|
||||
downloadFiles,
|
||||
};
|
||||
128
src/data/redux/thunkActions/download.test.js
Normal file
128
src/data/redux/thunkActions/download.test.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import JSZip from 'jszip';
|
||||
import FileSaver from 'file-saver';
|
||||
|
||||
import { selectors } from 'data/redux';
|
||||
import { RequestKeys } from 'data/constants/requests';
|
||||
import * as download from './download';
|
||||
|
||||
jest.mock('file-saver', () => ({
|
||||
saveAs: jest.fn(),
|
||||
}));
|
||||
jest.mock('jszip', () => {
|
||||
const file = jest.fn();
|
||||
const zipFile = 'test zip output';
|
||||
const generateAsync = jest.fn(() => new Promise((resolve) => resolve(zipFile)));
|
||||
return function zip() {
|
||||
return { file, zipFile, generateAsync };
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('./requests', () => ({
|
||||
networkRequest: (args) => ({ networkRequest: args }),
|
||||
}));
|
||||
|
||||
jest.mock('data/redux/grading/selectors', () => ({
|
||||
selected: {
|
||||
response: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
describe('download thunkActions', () => {
|
||||
const testState = { some: 'testy-state' };
|
||||
const mockFile = (name) => ({
|
||||
downloadUrl: `home/${name}`,
|
||||
name,
|
||||
description: `${name} description`,
|
||||
});
|
||||
const files = [mockFile('test-file1.jpg'), mockFile('test-file2.pdf')];
|
||||
const blobs = ['blob1', 'blob2'];
|
||||
const response = { files };
|
||||
let dispatch;
|
||||
const getState = () => testState;
|
||||
describe('genManifest', () => {
|
||||
test('returns a list of strings with filename and description for each file', () => {
|
||||
expect(download.genManifest(response.files)).toEqual([
|
||||
`Filename: ${files[0].name}\nDescription: ${files[0].description}`,
|
||||
`Filename: ${files[1].name}\nDescription: ${files[1].description}`,
|
||||
].join('\n\n'));
|
||||
});
|
||||
});
|
||||
describe('zipFileName', () => {
|
||||
// add tests when name is more nailed down
|
||||
});
|
||||
describe('zipFiles', () => {
|
||||
test('zips files and manifest', () => {
|
||||
const mockZip = new JSZip();
|
||||
const mockFilename = 'mock-filename';
|
||||
module.genManifest = (testFiles) => ({ genManifest: testFiles });
|
||||
download.zipFileName = () => mockFilename;
|
||||
return download.zipFiles(files, blobs).then(() => {
|
||||
expect(mockZip.file.mock.calls).toEqual([
|
||||
['manifest.txt', download.genManifest(files)],
|
||||
[files[0].name, blobs[0]],
|
||||
[files[1].name, blobs[1]],
|
||||
]);
|
||||
expect(mockZip.generateAsync).toHaveBeenCalledWith({ type: 'blob' });
|
||||
expect(FileSaver.saveAs).toHaveBeenCalledWith(mockZip.zipFile, mockFilename);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadFile', () => {
|
||||
let fetch;
|
||||
const blob = 'test-blob';
|
||||
beforeEach(() => {
|
||||
fetch = window.fetch;
|
||||
window.fetch = jest.fn();
|
||||
});
|
||||
afterEach(() => {
|
||||
window.fetch = fetch;
|
||||
});
|
||||
it('returns blob output if successful', () => {
|
||||
window.fetch.mockReturnValue(new Promise(resolve => resolve({ ok: true, blob: () => blob })));
|
||||
return download.downloadFile(files[0]).then(val => expect(val).toEqual(blob));
|
||||
});
|
||||
it('returns null if not successful', () => {
|
||||
window.fetch.mockReturnValue(new Promise(resolve => resolve({ ok: false })));
|
||||
return download.downloadFile(files[0]).then(val => expect(val).toEqual(null));
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadBlobs', () => {
|
||||
it('returns a joing promise mapping all files to download action', async () => {
|
||||
download.downloadFile = (file) => new Promise(resolve => resolve(file.name));
|
||||
const responses = await download.downloadBlobs(files);
|
||||
expect(responses).toEqual(files.map(file => file.name));
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadFiles', () => {
|
||||
beforeEach(() => {
|
||||
dispatch = jest.fn();
|
||||
selectors.grading.selected.response = () => ({ files });
|
||||
module.zipFiles = jest.fn();
|
||||
});
|
||||
it('dispatches network request with downloadFiles key', () => {
|
||||
module.downloadBlobs = () => new Promise(resolve => resolve(blobs));
|
||||
download.downloadFiles()(dispatch, getState);
|
||||
const { networkRequest } = dispatch.mock.calls[0][0];
|
||||
expect(networkRequest.requestKey).toEqual(RequestKeys.downloadFiles);
|
||||
});
|
||||
it('dispatches network request for downloadFiles, zipping output of downloadBlobs', () => {
|
||||
module.downloadBlobs = () => new Promise(resolve => resolve(blobs));
|
||||
download.downloadFiles()(dispatch, getState);
|
||||
const { networkRequest } = dispatch.mock.calls[0][0];
|
||||
networkRequest.promise.then(() => {
|
||||
expect(module.zipFile).toHaveBeenCalledWith(files, blobs);
|
||||
});
|
||||
});
|
||||
it('throws an error on failure', () => {
|
||||
module.downloadBlobs = () => new Promise((resolve, reject) => reject());
|
||||
download.downloadFiles()(dispatch, getState);
|
||||
const { networkRequest } = dispatch.mock.calls[0][0];
|
||||
expect(networkRequest.promise).rejects.toThrow('Fetch failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import requests from './requests';
|
||||
* If the new index has a next submission available, preload its response.
|
||||
*/
|
||||
export const loadNext = () => (dispatch) => {
|
||||
dispatch(actions.requests.clearRequest({ requestKey: RequestKeys.downloadFiles }));
|
||||
dispatch(actions.grading.loadNext());
|
||||
dispatch(module.loadSubmission());
|
||||
};
|
||||
@@ -22,6 +23,7 @@ export const loadNext = () => (dispatch) => {
|
||||
* If the new index has a previous submission available, preload its response.
|
||||
*/
|
||||
export const loadPrev = () => (dispatch) => {
|
||||
dispatch(actions.requests.clearRequest({ requestKey: RequestKeys.downloadFiles }));
|
||||
dispatch(actions.grading.loadPrev());
|
||||
dispatch(module.loadSubmission());
|
||||
};
|
||||
@@ -46,7 +48,13 @@ export const loadSubmission = () => (dispatch, getState) => {
|
||||
onSuccess: (response) => {
|
||||
dispatch(actions.grading.loadSubmission({ ...response, submissionUUID }));
|
||||
if (selectors.grading.selected.isGrading(getState())) {
|
||||
dispatch(module.startGrading());
|
||||
dispatch(actions.app.setShowRubric(true));
|
||||
let gradeData = selectors.grading.selected.gradeData(getState());
|
||||
if (!gradeData) {
|
||||
gradeData = selectors.app.emptyGrade(getState());
|
||||
}
|
||||
const lockStatus = selectors.grading.selected.lockStatus(getState());
|
||||
dispatch(actions.grading.startGrading({ lockStatus, gradeData }));
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -25,6 +25,7 @@ jest.mock('data/redux/grading/selectors', () => ({
|
||||
gradeData: jest.fn((state) => ({ gradeData: state })),
|
||||
isGrading: jest.fn((state) => ({ isGrading: state })),
|
||||
submissionUUID: (state) => ({ selectedsubmissionUUID: state }),
|
||||
lockStatus: (state) => ({ lockStatus: state }),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -86,6 +87,7 @@ describe('grading thunkActions', () => {
|
||||
test('dispatches actions.grading.loadNext and then loadSubmission', () => {
|
||||
thunkActions.loadNext()(dispatch, getState);
|
||||
expect(dispatch.mock.calls).toEqual([
|
||||
[actions.requests.clearRequest({ requestKey: RequestKeys.downloadFiles })],
|
||||
[actions.grading.loadNext()],
|
||||
[thunkActions.loadSubmission()],
|
||||
]);
|
||||
@@ -95,6 +97,7 @@ describe('grading thunkActions', () => {
|
||||
test('clears submitGrade status and dispatches actions.grading.loadPrev and then loadSubmission', () => {
|
||||
thunkActions.loadPrev()(dispatch, getState);
|
||||
expect(dispatch.mock.calls).toEqual([
|
||||
[actions.requests.clearRequest({ requestKey: RequestKeys.downloadFiles })],
|
||||
[actions.grading.loadPrev()],
|
||||
[thunkActions.loadSubmission()],
|
||||
]);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { StrictDict } from 'utils';
|
||||
|
||||
import app from './app';
|
||||
import download from './download';
|
||||
import grading from './grading';
|
||||
|
||||
export default StrictDict({
|
||||
app,
|
||||
download,
|
||||
grading,
|
||||
});
|
||||
|
||||
@@ -48,21 +48,6 @@ export const initializeApp = ({ locationId, ...rest }) => (dispatch) => {
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracked fetchSubmissionResponse api method.
|
||||
* Tracked either prefetchNext or prefetchPrev request key.
|
||||
* @param {string} submissionUUID - target submission id
|
||||
* @param {string} requestKey - identifying request key.
|
||||
* @param {[func]} onSuccess - onSuccess method ((response) => { ... })
|
||||
* @param {[func]} onFailure - onFailure method ((error) => { ... })
|
||||
*/
|
||||
export const fetchSubmissionResponse = ({ submissionUUID, ...rest }) => (dispatch) => {
|
||||
dispatch(module.networkRequest({
|
||||
promise: api.fetchSubmissionResponse(submissionUUID),
|
||||
...rest,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracked fetchSubmissionStatus api method.
|
||||
* Tracked to the `fetchSubmissinStatus` request key.
|
||||
@@ -124,7 +109,6 @@ export const submitGrade = ({ submissionUUID, gradeData, ...rest }) => (dispatch
|
||||
|
||||
export default StrictDict({
|
||||
fetchSubmission,
|
||||
fetchSubmissionResponse,
|
||||
fetchSubmissionStatus,
|
||||
setLock,
|
||||
submitGrade,
|
||||
|
||||
@@ -5,7 +5,6 @@ import * as requests from './requests';
|
||||
|
||||
jest.mock('data/services/lms/api', () => ({
|
||||
initializeApp: (locationId) => ({ initializeApp: locationId }),
|
||||
fetchSubmissionResponse: (submissionUUID) => ({ fetchSubmissionResponse: submissionUUID }),
|
||||
fetchSubmissionStatus: (submissionUUID) => ({ fetchSubmissionStatus: submissionUUID }),
|
||||
fetchSubmission: (submissionUUID) => ({ fetchSubmission: submissionUUID }),
|
||||
lockSubmission: ({ submissionUUID }) => ({ lockSubmission: { submissionUUID } }),
|
||||
@@ -120,18 +119,6 @@ describe('requests thunkActions module', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
describe('fetchSubmissionResponse', () => {
|
||||
const requestKey = 'test-request-key';
|
||||
testNetworkRequestAction({
|
||||
action: requests.fetchSubmissionResponse,
|
||||
args: { submissionUUID, requestKey },
|
||||
expectedString: 'with fetchSubmissionResponse promise',
|
||||
expectedData: {
|
||||
requestKey,
|
||||
promise: api.fetchSubmissionResponse(submissionUUID),
|
||||
},
|
||||
});
|
||||
});
|
||||
describe('fetchSubmissionStatus', () => {
|
||||
testNetworkRequestAction({
|
||||
action: requests.fetchSubmissionStatus,
|
||||
|
||||
Reference in New Issue
Block a user