feat: requests redux modules

This commit is contained in:
Ben Warzeski
2021-10-20 22:56:41 -04:00
parent fde93273e3
commit de6b0dbb4e
15 changed files with 912 additions and 104 deletions

8
package-lock.json generated
View File

@@ -3849,6 +3849,14 @@
"@testing-library/dom": "^8.0.0"
}
},
"@testing-library/user-event": {
"version": "13.5.0",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz",
"integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==",
"requires": {
"@babel/runtime": "^7.12.5"
}
},
"@tootallnate/once": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",

View File

@@ -36,6 +36,7 @@
"@fortawesome/react-fontawesome": "^0.1.15",
"@redux-beacon/segment": "^1.1.0",
"@reduxjs/toolkit": "^1.6.1",
"@testing-library/user-event": "^13.5.0",
"classnames": "^2.3.1",
"core-js": "3.16.2",
"dompurify": "^2.3.1",

View File

@@ -2,10 +2,12 @@ import { StrictDict } from 'utils';
import app from './app';
import grading from './grading';
import requests from './requests';
import submissions from './submissions';
export default StrictDict({
app,
grading,
requests,
submissions,
});

View File

@@ -0,0 +1,15 @@
import { StrictDict } from 'utils';
import { createActionFactory } from './utils';
export const dataKey = 'requests';
const createAction = createActionFactory(dataKey);
export const startRequest = createAction('startRequest');
export const completeRequest = createAction('completeRequest');
export const failRequest = createAction('failRequest');
export default StrictDict({
startRequest,
completeRequest,
failRequest,
});

View File

@@ -0,0 +1,18 @@
import actions, { dataKey } from './requests';
import { testAction, testActionTypes } from './testUtils';
describe('actions', () => {
describe('action types', () => {
const actionTypes = [
actions.startRequest,
actions.completeRequest,
actions.failRequest,
].map(action => action.toString());
testActionTypes(actionTypes, dataKey);
});
describe('actions provided', () => {
test('startRequest action', () => testAction(actions.startRequest));
test('completeRequest action', () => testAction(actions.completeRequest));
test('failRequest action', () => testAction(actions.failRequest));
});
});

View File

@@ -0,0 +1,18 @@
import { StrictDict } from 'utils';
export const RequestStates = StrictDict({
inactive: 'inactive',
pending: 'pending',
completed: 'completed',
failed: 'failed',
});
export const RequestKeys = StrictDict({
initialize: 'initialize',
fetchSubmission: 'fetchSubmission',
fetchSubmissionStatus: 'fetchSubmissionStatus',
setLock: 'setLock',
prefetchNext: 'prefetchNext',
prefetchPrev: 'prefetchPrev',
submitGrade: 'submitGrade',
});

View File

@@ -2,12 +2,14 @@ import { combineReducers } from 'redux';
import app from './app';
import grading from './grading';
import requests from './requests';
import submissions from './submissions';
/* istanbul ignore next */
const rootReducer = combineReducers({
app,
grading,
requests,
submissions,
});

View File

@@ -0,0 +1,41 @@
import { createReducer } from '@reduxjs/toolkit';
import { RequestStates, RequestKeys } from 'data/constants/requests';
import actions from 'data/actions';
const initialState = {
[RequestKeys.initialize]: { status: RequestStates.inactive },
[RequestKeys.fetchSubmission]: { status: RequestStates.inactive },
[RequestKeys.fetchSubmissionStatus]: { status: RequestStates.inactive },
[RequestKeys.setLock]: { status: RequestStates.inactive },
[RequestKeys.prefetchNext]: { status: RequestStates.inactive },
[RequestKeys.prefetchPrev]: { status: RequestStates.inactive },
[RequestKeys.submitGrade]: { status: RequestStates.inactive },
};
// eslint-disable-next-line no-unused-vars
const app = createReducer(initialState, {
[actions.requests.startRequest]: (state, { payload }) => ({
...state,
[payload]: {
status: RequestStates.pending,
},
}),
[actions.requests.completeRequest]: (state, { payload }) => ({
...state,
[payload.requestKey]: {
status: RequestStates.completed,
response: payload.response,
},
}),
[actions.requests.failRequest]: (state, { payload }) => ({
...state,
[payload.requestKey]: {
status: RequestStates.failed,
error: payload.error,
},
}),
});
export { initialState };
export default app;

View File

@@ -0,0 +1,52 @@
import actions from 'data/actions';
import { RequestStates } from 'data/constants/requests';
import requests, { initialState } from './requests';
const testingState = {
...initialState,
arbitraryField: 'arbitrary',
};
describe('requests reducer', () => {
it('has initial state', () => {
expect(requests(undefined, {})).toEqual(initialState);
});
const testValue = 'roll for initiative';
const testKey = 'test-key';
describe('handling actions', () => {
describe('requests.startRequest', () => {
it('adds a pending status for the given key', () => {
expect(requests(
testingState,
actions.requests.startRequest(testKey),
)).toEqual({
...testingState,
[testKey]: { status: RequestStates.pending },
});
});
});
describe('requests.completeRequest', () => {
it('adds a completed status with passed response', () => {
expect(requests(
testingState,
actions.requests.completeRequest({ requestKey: testKey, response: testValue }),
)).toEqual({
...testingState,
[testKey]: { status: RequestStates.completed, response: testValue },
});
});
});
describe('requests.failRequest', () => {
it('adds a failed status with passed error', () => {
expect(requests(
testingState,
actions.requests.failRequest({ requestKey: testKey, error: testValue }),
)).toEqual({
...testingState,
[testKey]: { status: RequestStates.failed, error: testValue },
});
});
});
});
});

View File

@@ -1,19 +1,22 @@
import { StrictDict } from 'utils';
import actions from 'data/actions';
import api from 'data/services/lms/api';
import { locationId } from 'data/constants/app';
import { initializeApp } from './requests';
/**
* initialize the app, loading ora and course metadata from the api, and loading the initial
* submission list data.
*/
export const initialize = () => (dispatch) => (
api.initializeApp(locationId).then((response) => {
dispatch(actions.app.loadOraMetadata(response.oraMetadata));
dispatch(actions.app.loadCourseMetadata(response.courseMetadata));
dispatch(actions.submissions.loadList(response.submissions));
})
);
export const initialize = () => (dispatch) => {
dispatch(initializeApp({
locationId,
onSuccess: (response) => {
dispatch(actions.app.loadOraMetadata(response.oraMetadata));
dispatch(actions.app.loadCourseMetadata(response.courseMetadata));
dispatch(actions.submissions.loadList(response.submissions));
},
}));
};
export default StrictDict({ initialize });

View File

@@ -1,51 +1,43 @@
import api from 'data/services/lms/api';
import { locationId } from 'data/constants/app';
import actions from 'data/actions';
import thunkActions from './app';
jest.mock('data/services/lms/api', () => {
const response = {
oraMetadata: { some: 'ora-metadata' },
courseMetadata: { some: 'course-metadata' },
submissions: { some: 'submissions' },
};
return {
response,
initializeApp: jest.fn(() => new Promise((resolve) => resolve(response))),
};
});
jest.mock('./requests', () => ({
initializeApp: (args) => ({ initializeApp: args }),
}));
jest.mock('data/constants/app', () => ({
locationId: 'fake-location-id',
}));
jest.mock('data/actions', () => ({
app: {
loadOraMetadata: (data) => ({ loadOraMetadata: data }),
loadCourseMetadata: (data) => ({ loadCourseMetadata: data }),
},
submissions: {
loadList: (data) => ({ loadList: data }),
},
}));
describe('app thunkActions', () => {
let dispatch;
let dispatchedAction;
beforeEach(() => {
dispatch = jest.fn((action) => ({ dispatch: action }));
});
describe('initialize', () => {
beforeEach(() => {
thunkActions.initialize()(dispatch);
[[dispatchedAction]] = dispatch.mock.calls;
});
test('it is called with location id from constants/app', () => {
expect(api.initializeApp).toHaveBeenCalledWith(locationId);
it('dispatches initializeApp with locationId and onSuccess', () => {
expect(dispatchedAction.initializeApp.locationId).toEqual(locationId);
expect(typeof dispatchedAction.initializeApp.onSuccess).toEqual('function');
});
describe('on success', () => {
test('loads oraMetadata, courseMetadata and list data', () => {
dispatch.mockClear();
const response = {
oraMetadata: { some: 'ora-metadata' },
courseMetadata: { some: 'course-metadata' },
submissions: { some: 'submissions' },
};
dispatchedAction.initializeApp.onSuccess(response);
expect(dispatch.mock.calls).toEqual([
[actions.app.loadOraMetadata(api.response.oraMetadata)],
[actions.app.loadCourseMetadata(api.response.courseMetadata)],
[actions.submissions.loadList(api.response.submissions)],
[actions.app.loadOraMetadata(response.oraMetadata)],
[actions.app.loadCourseMetadata(response.courseMetadata)],
[actions.submissions.loadList(response.submissions)],
]);
});
});

View File

@@ -1,32 +1,67 @@
import { StrictDict } from 'utils';
import { RequestKeys } from 'data/constants/requests';
import actions from 'data/actions';
import selectors from 'data/selectors';
import api from 'data/services/lms/api';
import { gradingStatuses as statuses } from 'data/services/lms/constants';
import * as module from './grading';
import requests from './requests';
/**
* Prefetch the "next" submission in the selected queue. Only fetches the response info.
*/
export const prefetchNext = () => (dispatch, getState) => (
api.fetchSubmissionResponse(
selectors.grading.next.submissionId(getState()),
).then((response) => {
dispatch(actions.grading.preloadNext(response));
})
);
export const prefetchNext = () => (dispatch, getState) => {
dispatch(requests.fetchSubmissionResponse({
requestKey: RequestKeys.prefetchNext,
submissionId: selectors.grading.next.submissionId(getState()),
onSuccess: (response) => {
dispatch(actions.grading.preloadNext(response));
},
}));
};
/**
* Prefetch the "previous" submission in the selected queue. Only fetches the response info.
*/
export const prefetchPrev = () => (dispatch, getState) => (
api.fetchSubmissionResponse(
selectors.grading.prev.submissionId(getState()),
).then((response) => {
dispatch(actions.grading.preloadPrev(response));
})
);
export const prefetchPrev = () => (dispatch, getState) => {
dispatch(requests.fetchSubmissionResponse({
requestKey: RequestKeys.prefetchPrev,
submissionId: selectors.grading.prev.submissionId(getState()),
onSuccess: (response) => {
dispatch(actions.grading.preloadPrev(response));
},
}));
};
/**
* Fetch the target neighbor submission's status, start grading if in progress,
* dispatches load action with the response (injecting submissionId). If hasNeighbor,
* also dispatches the prefetchAction to pre-fetch the new neighbor's response.
* @param {string} submissionId - target submission id
* @param {action} loadAction - redux action/thunkAction to load the submission status
* @param {bool} hasNeighbor - is there a new neighbor to be pre-fetched?
* @param {action} prefetchAction - redux action/thunkAction to prefetch the new
* neighbor's response.
*/
export const fetchNeighbor = ({
submissionId,
loadAction,
hasNeighbor,
prefetchAction,
}) => (dispatch) => {
dispatch(requests.fetchSubmissionStatus({
submissionId,
onSuccess: (response) => {
if (response.lockStatus === statuses.inProgress) {
dispatch(module.startGrading());
} else {
dispatch(module.stopGrading());
}
dispatch(loadAction({ ...response, submissionId }));
if (hasNeighbor) { dispatch(prefetchAction()); }
},
}));
};
/**
* Fetches the current status for the "next" submission in the selected queue,
@@ -34,19 +69,12 @@ export const prefetchPrev = () => (dispatch, getState) => (
* If the new index has a next submission available, preload its response.
*/
export const loadNext = () => (dispatch, getState) => {
const nextId = selectors.grading.next.submissionId(getState());
return api.fetchSubmissionStatus(nextId).then((response) => {
console.log({ loadNext: response });
dispatch(actions.grading.loadNext({ ...response, submissionId: nextId }));
if (response.lockStatus === statuses.inProgress) {
dispatch(module.startGrading());
} else {
dispatch(actions.app.setGrading(false));
}
if (selectors.grading.next.doesExist(getState())) {
dispatch(module.prefetchNext());
}
});
dispatch(module.fetchNeighbor({
loadAction: actions.grading.loadNext,
hasNeighbor: selectors.grading.next.doesExist(getState()),
prefetchAction: module.prefetchNext,
submissionId: selectors.grading.next.submissionId(getState()),
}));
};
/**
@@ -55,18 +83,12 @@ export const loadNext = () => (dispatch, getState) => {
* If the new index has a previous submission available, preload its response.
*/
export const loadPrev = () => (dispatch, getState) => {
const prevId = selectors.grading.prev.submissionId(getState());
return api.fetchSubmissionStatus(prevId).then((response) => {
dispatch(actions.grading.loadPrev({ ...response, submissionId: prevId }));
if (response.gradeStatus === statuses.inProgress) {
dispatch(module.startGrading());
} else {
dispatch(actions.app.setGrading(false));
}
if (selectors.grading.prev.doesExist(getState())) {
dispatch(module.prefetchPrev());
}
});
dispatch(module.fetchNeighbor({
loadAction: actions.grading.loadPrev,
hasNeighbor: selectors.grading.prev.doesExist(getState()),
prefetchAction: module.prefetchPrev,
submissionId: selectors.grading.prev.submissionId(getState()),
}));
};
/**
@@ -76,22 +98,23 @@ export const loadPrev = () => (dispatch, getState) => {
* @param {string[]} submissionIds - ordered list of submissionIds for selected submissions
*/
export const loadSelectionForReview = (submissionIds) => (dispatch, getState) => {
dispatch(actions.grading.updateSelection(submissionIds));
return api.fetchSubmission(
selectors.grading.selected.submissionId(getState()),
).then((response) => {
dispatch(actions.grading.loadSubmission({
...response,
submissionId: submissionIds[0],
}));
dispatch(actions.app.setShowReview(true));
if (selectors.grading.next.doesExist(getState())) {
dispatch(prefetchNext());
}
if (selectors.grading.prev.doesExist(getState())) {
dispatch(prefetchPrev());
}
});
dispatch(requests.fetchSubmission({
submissionId: submissionIds[0],
onSuccess: (response) => {
dispatch(actions.grading.updateSelection(submissionIds));
dispatch(actions.grading.loadSubmission({
...response,
submissionId: submissionIds[0],
}));
dispatch(actions.app.setShowReview(true));
if (selectors.grading.next.doesExist(getState())) {
dispatch(module.prefetchNext());
}
if (selectors.grading.prev.doesExist(getState())) {
dispatch(module.prefetchPrev());
}
},
}));
};
/**
@@ -102,20 +125,18 @@ export const loadSelectionForReview = (submissionIds) => (dispatch, getState) =>
* based on the rubric config.
*/
export const startGrading = () => (dispatch, getState) => {
console.log('start grading');
return api.lockSubmission(
selectors.grading.selected.submissionId(getState()),
).then(() => {
console.log('succeed at locking');
dispatch(actions.app.setGrading(true));
let gradeData = selectors.grading.selected.gradeData(getState());
if (gradeData === undefined) {
gradeData = selectors.app.emptyGrade(getState());
}
dispatch(actions.grading.startGrading(gradeData));
}).catch((error) => {
console.log({ error });
});
dispatch(requests.setLock({
value: true,
submissionId: selectors.grading.selected.submissionId(getState()),
onSuccess: () => {
dispatch(actions.app.setGrading(true));
let gradeData = selectors.grading.selected.gradeData(getState());
if (!gradeData) {
gradeData = selectors.app.emptyGrade(getState());
}
dispatch(actions.grading.startGrading(gradeData));
},
}));
};
/**

View File

@@ -0,0 +1,325 @@
import { RequestKeys } from 'data/constants/requests';
import { gradingStatuses } from 'data/services/lms/constants';
import actions from 'data/actions';
import selectors from 'data/selectors';
import * as thunkActions from './grading';
jest.mock('./requests', () => ({
fetchSubmission: (args) => ({ fetchSubmission: args }),
fetchSubmissionResponse: (args) => ({ fetchSubmissionResponse: args }),
fetchSubmissionStatus: (args) => ({ fetchSubmissionStatus: args }),
setLock: (args) => ({ setLock: args }),
submitGrade: (args) => ({ submitGrade: args }),
}));
jest.mock('data/selectors', () => ({
__esModule: true,
default: {
app: {
emptyGrade: (state) => ({ emptyGrade: state }),
},
grading: {
prev: {
submissionId: (state) => ({ prevSubmissionId: state }),
doesExist: jest.fn((state) => ({ prevDoesExist: state })),
},
next: {
submissionId: (state) => ({ prevSubmissionId: state }),
doesExist: jest.fn((state) => ({ nextDoesExist: state })),
},
selected: {
submissionId: (state) => ({ selectedSubmissionId: state }),
gradeData: jest.fn((state) => ({ gradeData: state })),
},
},
},
}));
describe('grading thunkActions', () => {
const testState = { some: 'testy-state' };
const submissionId = 'test-submission-id';
const response = 'test-response';
let dispatch;
let dispatched;
let actionArgs;
const getState = () => testState;
const getDispatched = (calledAction) => {
calledAction(dispatch, getState);
[[dispatched]] = dispatch.mock.calls;
};
beforeEach(() => {
dispatch = jest.fn((action) => ({ dispatch: action }));
});
describe('prefetchNext', () => {
beforeEach(() => {
getDispatched(thunkActions.prefetchNext());
actionArgs = dispatched.fetchSubmissionResponse;
});
it('dispatches fetchSubmissionResponse with prefetchNext key and nextSubmissionId', () => {
expect(actionArgs).not.toEqual(undefined);
expect(actionArgs.requestKey).toEqual(RequestKeys.prefetchNext);
expect(actionArgs.submissionId).toEqual(selectors.grading.prev.submissionId(testState));
});
describe('on success', () => {
test('dispatches preloadNext', () => {
dispatch.mockClear();
actionArgs.onSuccess(response);
expect(dispatch.mock.calls).toEqual([
[actions.grading.preloadNext(response)],
]);
});
});
});
describe('prefetchPrev', () => {
beforeEach(() => {
getDispatched(thunkActions.prefetchPrev());
actionArgs = dispatched.fetchSubmissionResponse;
});
it('dispatches fetchSubmissionResponse with prefetchPrev key and next submissionId', () => {
expect(actionArgs).not.toEqual(undefined);
expect(actionArgs.requestKey).toEqual(RequestKeys.prefetchPrev);
expect(actionArgs.submissionId).toEqual(selectors.grading.next.submissionId(testState));
});
describe('on success', () => {
test('dispatches preloadPrev', () => {
dispatch.mockClear();
actionArgs.onSuccess(response);
expect(dispatch.mock.calls).toEqual([
[actions.grading.preloadPrev(response)],
]);
});
});
});
describe('fetchNeighbor', () => {
let startGrading;
let stopGrading;
const loadAction = actions.grading.loadNext;
const prefetchAction = actions.grading.loadNext;
const submitAction = (hasNeighbor) => {
getDispatched(thunkActions.fetchNeighbor({
submissionId,
loadAction,
hasNeighbor,
prefetchAction,
}));
actionArgs = dispatched.fetchSubmissionStatus;
};
beforeAll(() => {
startGrading = thunkActions.startGrading;
thunkActions.startGrading = () => 'startGrading';
stopGrading = thunkActions.stopGrading;
thunkActions.stopGrading = () => 'stopGrading';
});
afterAll(() => {
thunkActions.startGrading = startGrading;
thunkActions.stopGrading = stopGrading;
});
it('calls fetchSubmissionStatus with submissionId', () => {
submitAction(false);
expect(actionArgs).not.toEqual(undefined);
expect(actionArgs.submissionId).toEqual(submissionId);
});
describe('onSuccess', () => {
it('dispatches startGrading if lockStatus is in progress', () => {
submitAction(false);
dispatch.mockClear();
actionArgs.onSuccess({ lockStatus: gradingStatuses.inProgress });
expect(dispatch.mock.calls[0]).toEqual([thunkActions.startGrading()]);
});
it('dispatches stopGrading if lockStatus is not in progress', () => {
submitAction(false);
dispatch.mockClear();
actionArgs.onSuccess({ lockStatus: 'other status' });
expect(dispatch.mock.calls[0]).toEqual([thunkActions.stopGrading()]);
});
});
});
describe('fetchNeigbor inheritors', () => {
let fetchNeighbor;
beforeAll(() => {
fetchNeighbor = thunkActions.fetchNeighbor;
thunkActions.fetchNeighbor = args => ({ fetchNeighbor: args });
});
afterAll(() => {
thunkActions.fetchNeighbor = fetchNeighbor;
});
describe('loadNext', () => {
beforeEach(() => {
getDispatched(thunkActions.loadNext());
actionArgs = dispatched.fetchNeighbor;
});
test('dispatches fetchNeighbor', () => {
expect(actionArgs).not.toEqual(undefined);
});
describe('fetchNeighbor args', () => {
const selGroup = selectors.grading.next;
test('loadAction: actions.grading.loadNext', () => {
expect(actionArgs.loadAction).toEqual(actions.grading.loadNext);
});
test('prefetchAction: module.prefetchNext', () => {
expect(actionArgs.prefetchAction).toEqual(thunkActions.prefetchNext);
});
test('hasNeighbor: selectors.grading.next.doesExist', () => {
expect(actionArgs.hasNeighbor).toEqual(selGroup.doesExist(testState));
});
test('submissionId: selectors.grading.next.submissionId', () => {
expect(actionArgs.submissionId).toEqual(selGroup.submissionId(testState));
});
});
});
describe('loadPrev', () => {
beforeEach(() => {
getDispatched(thunkActions.loadPrev());
actionArgs = dispatched.fetchNeighbor;
});
test('dispatches fetchNeighbor', () => {
expect(actionArgs).not.toEqual(undefined);
});
describe('fetchNeighbor args', () => {
const selGroup = selectors.grading.prev;
test('loadAction: actions.grading.loadPrev', () => {
expect(actionArgs.loadAction).toEqual(actions.grading.loadPrev);
});
test('prefetchAction: module.prefetchPrev', () => {
expect(actionArgs.prefetchAction).toEqual(thunkActions.prefetchPrev);
});
test('hasNeighbor: selectors.grading.prev.doesExist', () => {
expect(actionArgs.hasNeighbor).toEqual(selGroup.doesExist(testState));
});
test('submissionId: selectors.grading.prev.submissionId', () => {
expect(actionArgs.submissionId).toEqual(selGroup.submissionId(testState));
});
});
});
});
describe('loadSelectionForReview', () => {
const submissionIds = [
'submission-id-0',
'submission-id-1',
'submission-id-2',
'submission-id-3',
];
let prefetchPrev;
let prefetchNext;
beforeAll(() => {
prefetchNext = thunkActions.prefetchNext;
prefetchPrev = thunkActions.prefetchPrev;
thunkActions.prefetchNext = () => 'prefetch next';
thunkActions.prefetchPrev = () => 'prefetch prev';
});
afterAll(() => {
thunkActions.prefetchNext = prefetchNext;
thunkActions.prefetchPrev = prefetchPrev;
});
beforeEach(() => {
getDispatched(thunkActions.loadSelectionForReview(submissionIds));
actionArgs = dispatched.fetchSubmission;
});
it('dispatches fetchSubmission with first submissionId', () => {
expect(actionArgs).not.toEqual(undefined);
expect(actionArgs.submissionId).toEqual(submissionIds[0]);
});
describe('onSuccess', () => {
beforeEach(() => {
dispatch.mockClear();
actionArgs.onSuccess(response);
});
it('dispatches updateSelection with passed submissionIds', () => {
expect(dispatch.mock.calls).toContainEqual(
[actions.grading.updateSelection(submissionIds)],
);
});
it('dispatches actions.grading.loadSubmission with response and first submission id', () => {
expect(dispatch.mock.calls).toContainEqual(
[actions.grading.loadSubmission({ ...response, submissionId: submissionIds[0] })],
);
});
it('dispatches app setShowReview(true)', () => {
expect(dispatch.mock.calls).toContainEqual(
[actions.app.setShowReview(true)],
);
});
it('dispatches prefetchNext iff selectors.grading.next.doesExist', () => {
// default configured to be truthy
expect(dispatch.mock.calls).toContainEqual(
[thunkActions.prefetchNext()],
);
selectors.grading.next.doesExist.mockReturnValue(false);
dispatch.mockClear();
actionArgs.onSuccess(response);
expect(dispatch.mock.calls).not.toContainEqual(
[thunkActions.prefetchNext()],
);
});
it('dispatches prefetchPrev iff selectors.grading.prev.doesExist', () => {
// default configured to be truthy
expect(dispatch.mock.calls).toContainEqual(
[thunkActions.prefetchPrev()],
);
selectors.grading.prev.doesExist.mockReturnValue(false);
dispatch.mockClear();
actionArgs.onSuccess(response);
expect(dispatch.mock.calls).not.toContainEqual(
[thunkActions.prefetchPrev()],
);
});
});
});
describe('startGrading', () => {
beforeEach(() => {
getDispatched(thunkActions.startGrading());
actionArgs = dispatched.setLock;
});
test('dispatches setLock with selected submissionId and value: true', () => {
expect(actionArgs).not.toEqual(undefined);
expect(actionArgs.value).toEqual(true);
expect(actionArgs.submissionId).toEqual(selectors.grading.selected.submissionId(testState));
});
describe('onSuccess', () => {
beforeEach(() => {
dispatch.mockClear();
});
test('dispatches app.setGrading(true)', () => {
actionArgs.onSuccess();
expect(dispatch.mock.calls).toContainEqual([actions.app.setGrading(true)]);
});
test('dispatches startGrading with selected gradeData if truthy', () => {
actionArgs.onSuccess();
const gradeData = selectors.grading.selected.gradeData(testState);
expect(dispatch.mock.calls).toContainEqual([actions.grading.startGrading(gradeData)]);
});
test('dispatches startGrading with empty grade if selected gradeData is not truthy', () => {
const emptyGrade = selectors.app.emptyGrade(testState);
selectors.grading.selected.gradeData.mockReturnValueOnce(null);
actionArgs.onSuccess();
expect(dispatch.mock.calls).toContainEqual([actions.grading.startGrading(emptyGrade)]);
dispatch.mockClear();
selectors.grading.selected.gradeData.mockReturnValueOnce(undefined);
actionArgs.onSuccess();
expect(dispatch.mock.calls).toContainEqual([actions.grading.startGrading(emptyGrade)]);
});
});
});
describe('stopGrading', () => {
it('dispatches grading.clearGrade and app.setGrading(false)', () => {
thunkActions.stopGrading()(dispatch);
expect(dispatch.mock.calls).toEqual([
[actions.grading.clearGrade()],
[actions.app.setGrading(false)],
]);
});
});
});

View File

@@ -0,0 +1,129 @@
import { StrictDict } from 'utils';
import { RequestKeys } from 'data/constants/requests';
import actions from 'data/actions';
import api from 'data/services/lms/api';
import * as module from './requests';
/**
* Wrapper around a network request promise, that sends actions to the redux store to
* track the state of that promise.
* Tracks the promise by requestKey, and sends an action when it is started, succeeds, or
* fails. It also accepts onSuccess and onFailure methods to be called with the output
* of failure or success of the promise.
* @param {string} requestKey - request tracking identifier
* @param {Promise} promise - api event promise
* @param {[func]} onSuccess - onSuccess method ((response) => { ... })
* @param {[func]} onFailure - onFailure method ((error) => { ... })
*/
export const networkRequest = ({
requestKey,
promise,
onSuccess,
onFailure,
}) => (dispatch) => {
dispatch(actions.requests.startRequest(requestKey));
return promise.then((response) => {
dispatch(actions.requests.completeRequest({ requestKey, response }));
if (onSuccess) { onSuccess(response); }
}).catch((error) => {
dispatch(actions.requests.failRequest({ requestKey, error }));
if (onFailure) { onFailure(error); }
});
};
/**
* Tracked initializeApp api method.
* Tracked to the `initialize` request key.
* @param {string} locationId - ora location id
* @param {[func]} onSuccess - onSuccess method ((response) => { ... })
* @param {[func]} onFailure - onFailure method ((error) => { ... })
*/
export const initializeApp = ({ locationId, ...rest }) => (dispatch) => {
dispatch(module.networkRequest({
requestKey: RequestKeys.initialize,
promise: api.initializeApp(locationId),
...rest,
}));
};
/**
* Tracked fetchSubmissionResponse api method.
* Tracked either prefetchNext or prefetchPrev request key.
* @param {string} submissionId - target submission id
* @param {[func]} onSuccess - onSuccess method ((response) => { ... })
* @param {[func]} onFailure - onFailure method ((error) => { ... })
*/
export const fetchSubmissionResponse = ({ submissionId, ...rest }) => (dispatch) => {
dispatch(module.networkRequest({
promise: api.fetchSubmissionResponse(submissionId),
...rest,
}));
};
/**
* Tracked fetchSubmissionStatus api method.
* Tracked to the `fetchSubmissinStatus` request key.
* @param {string} submissionId - target submission id
* @param {[func]} onSuccess - onSuccess method ((response) => { ... })
* @param {[func]} onFailure - onFailure method ((error) => { ... })
*/
export const fetchSubmissionStatus = ({ submissionId, ...rest }) => (dispatch) => {
dispatch(module.networkRequest({
requestKey: RequestKeys.fetchSubmissionStatus,
promise: api.fetchSubmissionStatus(submissionId),
...rest,
}));
};
/**
* Tracked initializeApp api method. tracked to the `initialize` request key.
* @param {string} submissionId - target submission id
* @param {[func]} onSuccess - onSuccess method ((response) => { ... })
* @param {[func]} onFailure - onFailure method ((error) => { ... })
*/
export const fetchSubmission = ({ submissionId, ...rest }) => (dispatch) => {
dispatch(module.networkRequest({
requestKey: RequestKeys.fetchSubmission,
promise: api.fetchSubmission(submissionId),
...rest,
}));
};
/**
* Tracked initializeApp api method. tracked to the `initialize` request key.
* @param {string} submissionId - target submission id
* @param {bool} value - requested lock value
* @param {[func]} onSuccess - onSuccess method ((response) => { ... })
* @param {[func]} onFailure - onFailure method ((error) => { ... })
*/
export const setLock = ({ submissionId, value, ...rest }) => (dispatch) => {
dispatch(module.networkRequest({
requestKey: RequestKeys.setLock,
promise: api.lockSubmission({ submissionId, value }),
...rest,
}));
};
/**
* Tracked initializeApp api method. tracked to the `initialize` request key.
* @param {string} submissionId - target submission id
* @param {obj} gradeData - grade data object
* @param {[func]} onSuccess - onSuccess method ((response) => { ... })
* @param {[func]} onFailure - onFailure method ((error) => { ... })
*/
export const submitGrade = ({ submissionId, gradeData, ...rest }) => (dispatch) => {
dispatch(module.networkRequest({
requestKey: RequestKeys.submitGrade,
promise: api.updateGrade(submissionId, gradeData),
...rest,
}));
};
export default StrictDict({
fetchSubmission,
fetchSubmissionResponse,
fetchSubmissionStatus,
setLock,
submitGrade,
});

View File

@@ -0,0 +1,181 @@
import actions from 'data/actions';
import { RequestKeys } from 'data/constants/requests';
import api from 'data/services/lms/api';
import * as requests from './requests';
jest.mock('data/services/lms/api', () => ({
initializeApp: (locationId) => ({ initializeApp: locationId }),
fetchSubmissionResponse: (submissionId) => ({ fetchSubmissionResponse: submissionId }),
fetchSubmissionStatus: (submissionId) => ({ fetchSubmissionStatus: submissionId }),
fetchSubmission: (submissionId) => ({ fetchSubmission: submissionId }),
lockSubmission: ({ submissionId, value }) => ({ lockSubmission: { submissionId, value } }),
updateGrade: (submissionId, gradeData) => ({ updateGrade: { submissionId, gradeData } }),
}));
let dispatch;
let onSuccess;
let onFailure;
describe('requests thunkActions module', () => {
beforeEach(() => {
dispatch = jest.fn();
onSuccess = jest.fn();
onFailure = jest.fn();
});
describe('networkRequest', () => {
const requestKey = 'test-request';
const testData = { some: 'test data' };
let resolveFn;
let rejectFn;
beforeEach(() => {
onSuccess = jest.fn();
onFailure = jest.fn();
requests.networkRequest({
requestKey,
promise: new Promise((resolve, reject) => {
resolveFn = resolve;
rejectFn = reject;
}),
onSuccess,
onFailure,
})(dispatch);
});
test('calls startRequest action with requestKey', async () => {
expect(dispatch.mock.calls).toEqual([[actions.requests.startRequest(requestKey)]]);
});
describe('on success', () => {
beforeEach(async () => {
await resolveFn(testData);
});
it('dispatches completeRequest', async () => {
expect(dispatch.mock.calls).toEqual([
[actions.requests.startRequest(requestKey)],
[actions.requests.completeRequest({ requestKey, response: testData })],
]);
});
it('calls onSuccess with response', async () => {
expect(onSuccess).toHaveBeenCalledWith(testData);
expect(onFailure).not.toHaveBeenCalled();
});
});
describe('on failure', () => {
beforeEach(async () => {
await rejectFn(testData);
});
test('dispatches completeRequest', async () => {
expect(dispatch.mock.calls).toEqual([
[actions.requests.startRequest(requestKey)],
[actions.requests.failRequest({ requestKey, error: testData })],
]);
});
test('calls onSuccess with response', async () => {
expect(onFailure).toHaveBeenCalledWith(testData);
expect(onSuccess).not.toHaveBeenCalled();
});
});
});
const testNetworkRequestAction = ({
action,
args,
expectedData,
expectedString,
}) => {
let dispatchedAction;
beforeEach(() => {
action({ ...args, onSuccess, onFailure })(dispatch);
[[dispatchedAction]] = dispatch.mock.calls;
});
it('dispatches networkRequest', () => {
expect(dispatchedAction.networkRequest).not.toEqual(undefined);
});
test('forwards onSuccess and onFailure', () => {
expect(dispatchedAction.networkRequest.onSuccess).toEqual(onSuccess);
expect(dispatchedAction.networkRequest.onFailure).toEqual(onFailure);
});
test(expectedString, () => {
expect(dispatchedAction.networkRequest).toEqual({
...expectedData,
onSuccess,
onFailure,
});
});
};
describe('network request actions', () => {
const submissionId = 'test-submission-id';
const locationId = 'test-location-id';
beforeEach(() => {
requests.networkRequest = jest.fn(args => ({ networkRequest: args }));
});
describe('initializeApp', () => {
testNetworkRequestAction({
action: requests.initializeApp,
args: { locationId },
expectedString: 'with initialize key, initializeApp promise',
expectedData: {
requestKey: RequestKeys.initialize,
promise: api.initializeApp(locationId),
},
});
});
describe('fetchSubmissionResponse', () => {
const requestKey = 'test-request-key';
testNetworkRequestAction({
action: requests.fetchSubmissionResponse,
args: { submissionId, requestKey },
expectedString: 'with fetchSubmissionResponse promise',
expectedData: {
requestKey,
promise: api.fetchSubmissionResponse(submissionId),
},
});
});
describe('fetchSubmissionStatus', () => {
testNetworkRequestAction({
action: requests.fetchSubmissionStatus,
args: { submissionId },
expectedString: 'with fetchSubmissionStatus promise',
expectedData: {
requestKey: RequestKeys.fetchSubmissionStatus,
promise: api.fetchSubmissionStatus(submissionId),
},
});
});
describe('fetchSubmission', () => {
testNetworkRequestAction({
action: requests.fetchSubmission,
args: { submissionId },
expectedString: 'with fetchSubmission promise',
expectedData: {
requestKey: RequestKeys.fetchSubmission,
promise: api.fetchSubmission(submissionId),
},
});
});
describe('setLock', () => {
const lockValue = 'test-lock-value';
testNetworkRequestAction({
action: requests.setLock,
args: { submissionId, value: lockValue },
expectedString: 'with setLock promise',
expectedData: {
requestKey: RequestKeys.setLock,
promise: api.lockSubmission({ submissionId, value: lockValue }),
},
});
});
describe('submitGrade', () => {
const gradeData = 'test-grade-data';
testNetworkRequestAction({
action: requests.submitGrade,
args: { submissionId, gradeData },
expectedString: 'with submitGrade promise',
expectedData: {
requestKey: RequestKeys.submitGrade,
promise: api.updateGrade(submissionId, gradeData),
},
});
});
});
});