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:
Ben Warzeski
2022-01-10 10:29:13 -05:00
committed by GitHub
parent b0be873722
commit 579ff50c99
35 changed files with 1379 additions and 151 deletions

View File

@@ -15,6 +15,7 @@ export const RequestKeys = StrictDict({
prefetchNext: 'prefetchNext',
prefetchPrev: 'prefetchPrev',
submitGrade: 'submitGrade',
downloadFiles: 'downloadFiles',
});
export const ErrorCodes = StrictDict({

View File

@@ -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 }),

View File

@@ -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 },

View 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,
};

View 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');
});
});
});

View File

@@ -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 }));
}
},
}));

View File

@@ -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()],
]);

View File

@@ -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,
});

View File

@@ -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,

View File

@@ -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,