diff --git a/package-lock.json b/package-lock.json index 9dcf093..a4a374d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b5df41e..d0b9224 100755 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/data/actions/index.js b/src/data/actions/index.js index eaaa4c6..832a1e1 100644 --- a/src/data/actions/index.js +++ b/src/data/actions/index.js @@ -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, }); diff --git a/src/data/actions/requests.js b/src/data/actions/requests.js new file mode 100644 index 0000000..108be74 --- /dev/null +++ b/src/data/actions/requests.js @@ -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, +}); diff --git a/src/data/actions/requests.test.js b/src/data/actions/requests.test.js new file mode 100644 index 0000000..dfa1804 --- /dev/null +++ b/src/data/actions/requests.test.js @@ -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)); + }); +}); diff --git a/src/data/constants/requests.js b/src/data/constants/requests.js new file mode 100644 index 0000000..932e212 --- /dev/null +++ b/src/data/constants/requests.js @@ -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', +}); diff --git a/src/data/reducers/index.js b/src/data/reducers/index.js index 7c8ef05..8051e48 100755 --- a/src/data/reducers/index.js +++ b/src/data/reducers/index.js @@ -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, }); diff --git a/src/data/reducers/requests.js b/src/data/reducers/requests.js new file mode 100644 index 0000000..ed9b056 --- /dev/null +++ b/src/data/reducers/requests.js @@ -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; diff --git a/src/data/reducers/requests.test.js b/src/data/reducers/requests.test.js new file mode 100644 index 0000000..2763b8c --- /dev/null +++ b/src/data/reducers/requests.test.js @@ -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 }, + }); + }); + }); + }); +}); diff --git a/src/data/thunkActions/app.js b/src/data/thunkActions/app.js index 3335e67..4fb413c 100644 --- a/src/data/thunkActions/app.js +++ b/src/data/thunkActions/app.js @@ -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 }); diff --git a/src/data/thunkActions/app.test.js b/src/data/thunkActions/app.test.js index cd3124a..3ffeb9b 100644 --- a/src/data/thunkActions/app.test.js +++ b/src/data/thunkActions/app.test.js @@ -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)], ]); }); }); diff --git a/src/data/thunkActions/grading.js b/src/data/thunkActions/grading.js index a0ecbb7..3fdd326 100644 --- a/src/data/thunkActions/grading.js +++ b/src/data/thunkActions/grading.js @@ -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)); + }, + })); }; /** diff --git a/src/data/thunkActions/grading.test.js b/src/data/thunkActions/grading.test.js new file mode 100644 index 0000000..6cfccc3 --- /dev/null +++ b/src/data/thunkActions/grading.test.js @@ -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)], + ]); + }); + }); +}); diff --git a/src/data/thunkActions/requests.js b/src/data/thunkActions/requests.js new file mode 100644 index 0000000..d59f837 --- /dev/null +++ b/src/data/thunkActions/requests.js @@ -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, +}); diff --git a/src/data/thunkActions/requests.test.js b/src/data/thunkActions/requests.test.js new file mode 100644 index 0000000..2f1be66 --- /dev/null +++ b/src/data/thunkActions/requests.test.js @@ -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), + }, + }); + }); + }); +});