feat: Submit states (#37)

* feat: enable teams support

* feat: submit grade pending behavior

* feat: submit error behavior

* fix: Update src/data/services/lms/fakeData/testUtils.js

Co-authored-by: leangseu-edx <83240113+leangseu-edx@users.noreply.github.com>

Co-authored-by: leangseu-edx <83240113+leangseu-edx@users.noreply.github.com>
This commit is contained in:
Ben Warzeski
2021-12-06 11:02:43 -05:00
committed by GitHub
parent 909516dbc7
commit 91c874e20d
45 changed files with 1148 additions and 230 deletions

View File

@@ -16,3 +16,16 @@ export const RequestKeys = StrictDict({
prefetchPrev: 'prefetchPrev',
submitGrade: 'submitGrade',
});
export const ErrorCodes = StrictDict({
missingParam: 'ERR_MISSING_PARAM',
});
export const ErrorStatuses = StrictDict({
badRequest: 400,
unauthorized: 401,
forbidden: 403,
notFound: 404,
conflict: 409,
serverError: 500,
});

View File

@@ -107,12 +107,11 @@ export const updateGradingData = (state, data) => {
*/
export const updateCriterion = (state, orderNum, data) => {
const entry = state.gradingData[state.current.submissionUUID];
const criteria = [...entry.criteria];
criteria[orderNum] = { ...entry.criteria[orderNum], ...data };
return updateGradingData(state, {
...entry,
criteria: {
...entry.criteria,
[orderNum]: { ...entry.criteria[orderNum], ...data },
},
criteria,
});
};
@@ -196,15 +195,43 @@ const grading = createSlice({
},
};
},
stopGrading: (state) => {
loadStatus: (state, { payload }) => {
const gradingData = { ...state.gradingData };
delete gradingData[state.current.submissionUUID];
return {
...state,
gradeData: {
...state.gradeData,
[state.current.submissionUUID]: { ...payload.gradeData },
},
gradingData,
current: {
...state.current,
gradeStatus: payload.gradeStatus,
lockStatus: payload.lockStatus,
},
};
},
stopGrading: (state, { payload }) => {
const { submissionUUID } = state.current;
const localGradeData = { ...state.localGradeData };
delete localGradeData[state.current.submissionUUID];
delete localGradeData[submissionUUID];
const gradeData = { ...state.gradeData };
let lockStatus = lockStatuses.unlocked;
let { gradeStatus } = state.current;
if (payload) {
const { submissionStatus } = payload;
gradeData[submissionUUID] = submissionStatus.gradeData;
lockStatus = submissionStatus.lockStatus;
gradeStatus = submissionStatus.gradeStatus;
}
return {
...state,
localGradeData,
current: {
...state.current,
lockStatus: lockStatuses.unlocked,
lockStatus,
gradeStatus,
},
};
},

View File

@@ -39,6 +39,10 @@ const requests = createSlice({
error: payload.error,
},
}),
clearRequest: (state, { payload }) => ({
...state,
[payload.requestKey]: {},
}),
},
});

View File

@@ -11,6 +11,9 @@ export const isPending = ({ status }) => status === RequestStates.pending;
export const isCompleted = ({ status }) => status === RequestStates.completed;
export const isFailed = ({ status }) => status === RequestStates.failed;
export const error = (request) => request.error;
export const errorStatus = (request) => request.error?.response?.status;
export const errorCode = (request) => request.error?.response?.data;
export const data = (request) => request.data;
export const allowNavigation = ({ requests }) => (
@@ -25,5 +28,7 @@ export default StrictDict({
isCompleted: statusSelector(isCompleted),
isFailed: statusSelector(isFailed),
error: statusSelector(error),
errorCode: statusSelector(errorCode),
errorStatus: statusSelector(errorStatus),
data: statusSelector(data),
});

View File

@@ -1,6 +1,7 @@
import { StrictDict } from 'utils';
import { actions, selectors } from 'data/redux';
import { RequestKeys, ErrorStatuses } from 'data/constants/requests';
import * as module from './grading';
import requests from './requests';
@@ -39,10 +40,14 @@ export const loadSelectionForReview = (submissionUUIDs) => (dispatch) => {
export const loadSubmission = () => (dispatch, getState) => {
const submissionUUID = selectors.grading.selected.submissionUUID(getState());
dispatch(actions.requests.clearRequest({ requestKey: RequestKeys.submitGrade }));
dispatch(requests.fetchSubmission({
submissionUUID,
onSuccess: (response) => {
dispatch(actions.grading.loadSubmission({ ...response, submissionUUID }));
if (selectors.grading.selected.isGrading(getState())) {
dispatch(module.startGrading());
}
},
}));
};
@@ -55,12 +60,13 @@ export const loadSubmission = () => (dispatch, getState) => {
* based on the rubric config.
*/
export const startGrading = () => (dispatch, getState) => {
dispatch(actions.requests.clearRequest({ requestKey: RequestKeys.submitGrade }));
dispatch(requests.setLock({
value: true,
submissionUUID: selectors.grading.selected.submissionUUID(getState()),
onSuccess: (response) => {
dispatch(actions.app.setShowRubric(true));
let { gradeData } = response;
let gradeData = selectors.grading.selected.gradeData(getState());
if (!gradeData) {
gradeData = selectors.app.emptyGrade(getState());
}
@@ -74,6 +80,7 @@ export const startGrading = () => (dispatch, getState) => {
* Releases the lock and dispatches stopGrading on success.
*/
export const cancelGrading = () => (dispatch, getState) => {
dispatch(actions.requests.clearRequest({ requestKey: RequestKeys.submitGrade }));
dispatch(requests.setLock({
value: false,
submissionUUID: selectors.grading.selected.submissionUUID(getState()),
@@ -103,8 +110,10 @@ export const submitGrade = () => (dispatch, getState) => {
onSuccess: (response) => {
dispatch(actions.grading.completeGrading(response));
},
onFailure: () => {
// on failure action
onFailure: (error) => {
if (error.response.status === ErrorStatuses.conflict) {
dispatch(actions.grading.stopGrading(error.response.data));
}
},
}));
} else {

View File

@@ -1,4 +1,5 @@
import { actions, selectors } from 'data/redux';
import { RequestKeys } from 'data/constants/requests';
import * as thunkActions from './grading';
jest.mock('./requests', () => ({
@@ -21,8 +22,9 @@ jest.mock('data/redux/grading/selectors', () => ({
doesExist: jest.fn((state) => ({ nextDoesExist: state })),
},
selected: {
submissionUUID: (state) => ({ selectedsubmissionUUID: state }),
gradeData: jest.fn((state) => ({ gradeData: state })),
isGrading: jest.fn((state) => ({ isGrading: state })),
submissionUUID: (state) => ({ selectedsubmissionUUID: state }),
},
}));
@@ -32,13 +34,11 @@ describe('grading thunkActions', () => {
const response = 'test-response';
const objResponse = { response };
let dispatch;
let dispatched;
let actionArgs;
const getState = () => testState;
const getDispatched = (calledAction) => {
calledAction(dispatch, getState);
[[dispatched]] = dispatch.mock.calls;
};
beforeEach(() => {
@@ -48,7 +48,11 @@ describe('grading thunkActions', () => {
describe('loadSubmission', () => {
beforeEach(() => {
getDispatched(thunkActions.loadSubmission());
actionArgs = dispatched.fetchSubmission;
actionArgs = dispatch.mock.calls[1][0].fetchSubmission;
});
test('dispatches clearRequest for submitGrade', () => {
const requestKey = RequestKeys.submitGrade;
expect(dispatch.mock.calls[0]).toEqual([actions.requests.clearRequest({ requestKey })]);
});
test('dispatches fetchSubmission', () => {
expect(actionArgs).not.toEqual(undefined);
@@ -88,7 +92,7 @@ describe('grading thunkActions', () => {
});
});
describe('loadPrev', () => {
test('dispatches actions.grading.loadPrev and then loadSubmission', () => {
test('clears submitGrade status and dispatches actions.grading.loadPrev and then loadSubmission', () => {
thunkActions.loadPrev()(dispatch, getState);
expect(dispatch.mock.calls).toEqual([
[actions.grading.loadPrev()],
@@ -117,13 +121,17 @@ describe('grading thunkActions', () => {
describe('startGrading', () => {
beforeEach(() => {
getDispatched(thunkActions.startGrading());
actionArgs = dispatched.setLock;
actionArgs = dispatch.mock.calls[1][0].setLock;
});
test('dispatches setLock with selected submissionUUID and value: true', () => {
expect(actionArgs).not.toEqual(undefined);
expect(actionArgs.value).toEqual(true);
expect(actionArgs.submissionUUID).toEqual(selectors.grading.selected.submissionUUID(testState));
});
test('dispatches clearRequest for submitGrade', () => {
const requestKey = RequestKeys.submitGrade;
expect(dispatch.mock.calls[0]).toEqual([actions.requests.clearRequest({ requestKey })]);
});
describe('onSuccess', () => {
const gradeData = { some: 'test grade data' };
const startResponse = { other: 'fields', gradeData };
@@ -132,7 +140,12 @@ describe('grading thunkActions', () => {
});
test('dispatches startGrading with selected gradeData if truthy', () => {
actionArgs.onSuccess(startResponse);
expect(dispatch.mock.calls).toContainEqual([actions.grading.startGrading(startResponse)]);
expect(dispatch.mock.calls).toContainEqual([
actions.grading.startGrading({
...startResponse,
gradeData: selectors.grading.selected.gradeData(testState),
}),
]);
expect(dispatch.mock.calls).toContainEqual([actions.app.setShowRubric(true)]);
});
test('dispatches startGrading with empty grade if selected gradeData is not truthy', () => {
@@ -140,6 +153,7 @@ describe('grading thunkActions', () => {
const expected = [
actions.grading.startGrading({ ...startResponse, gradeData: emptyGrade }),
];
selectors.grading.selected.gradeData.mockReturnValue(undefined);
actionArgs.onSuccess({ ...startResponse, gradeData: undefined });
expect(dispatch.mock.calls).toContainEqual(expected);
expect(dispatch.mock.calls).toContainEqual([actions.app.setShowRubric(true)]);
@@ -159,11 +173,15 @@ describe('grading thunkActions', () => {
});
beforeEach(() => {
getDispatched(thunkActions.cancelGrading());
actionArgs = dispatched.setLock;
actionArgs = dispatch.mock.calls[1][0].setLock;
});
afterAll(() => {
thunkActions.stopGrading = stopGrading;
});
test('dispatches clearRequest for submitGrade', () => {
const requestKey = RequestKeys.submitGrade;
expect(dispatch.mock.calls[0]).toEqual([actions.requests.clearRequest({ requestKey })]);
});
test('dispatches setLock with selected submissionUUID and value: false', () => {
expect(actionArgs).not.toEqual(undefined);
expect(actionArgs.value).toEqual(false);

View File

@@ -99,10 +99,10 @@ export const fetchSubmission = ({ submissionUUID, ...rest }) => (dispatch) => {
* @param {[func]} onSuccess - onSuccess method ((response) => { ... })
* @param {[func]} onFailure - onFailure method ((error) => { ... })
*/
export const setLock = ({ submissionUUID, ...rest }) => (dispatch) => {
export const setLock = ({ value, submissionUUID, ...rest }) => (dispatch) => {
dispatch(module.networkRequest({
requestKey: RequestKeys.setLock,
promise: api.lockSubmission(submissionUUID),
promise: value ? api.lockSubmission(submissionUUID) : api.unlockSubmission(submissionUUID),
...rest,
}));
};

View File

@@ -8,7 +8,8 @@ jest.mock('data/services/lms/api', () => ({
fetchSubmissionResponse: (submissionUUID) => ({ fetchSubmissionResponse: submissionUUID }),
fetchSubmissionStatus: (submissionUUID) => ({ fetchSubmissionStatus: submissionUUID }),
fetchSubmission: (submissionUUID) => ({ fetchSubmission: submissionUUID }),
lockSubmission: ({ submissionUUID, value }) => ({ lockSubmission: { submissionUUID, value } }),
lockSubmission: ({ submissionUUID }) => ({ lockSubmission: { submissionUUID } }),
unlockSubmission: ({ submissionUUID }) => ({ unlockSubmission: { submissionUUID } }),
updateGrade: (submissionUUID, gradeData) => ({ updateGrade: { submissionUUID, gradeData } }),
}));
@@ -153,10 +154,10 @@ describe('requests thunkActions module', () => {
},
});
});
describe('setLock', () => {
describe('setLock: true', () => {
testNetworkRequestAction({
action: requests.setLock,
args: { submissionUUID },
args: { submissionUUID, value: true },
expectedString: 'with setLock promise',
expectedData: {
requestKey: RequestKeys.setLock,
@@ -164,6 +165,17 @@ describe('requests thunkActions module', () => {
},
});
});
describe('setLock: false', () => {
testNetworkRequestAction({
action: requests.setLock,
args: { submissionUUID, value: false },
expectedString: 'with setLock promise',
expectedData: {
requestKey: RequestKeys.setLock,
promise: api.unlockSubmission(submissionUUID),
},
});
});
describe('submitGrade', () => {
const gradeData = 'test-grade-data';
testNetworkRequestAction({

View File

@@ -2,7 +2,12 @@ import { StrictDict } from 'utils';
import { locationId } from 'data/constants/app';
import { paramKeys } from './constants';
import urls from './urls';
import { get, post, stringifyUrl } from './utils';
import {
client,
get,
post,
stringifyUrl,
} from './utils';
/*********************************************************************************
* GET Actions
@@ -75,11 +80,8 @@ export const fetchSubmissionResponse = (submissionUUID) => get(
}),
).then(response => response.data);
/* I assume this is the "Start Grading" call, even for if a
* submission is already graded and we are attempting re-lock.
* Assuming the check for if allowed would happen locally first.
/**
* post('api/lock', { ora_location, submissionUUID });
* @param {bool} value - new lock value
* @param {string} submissionUUID
*/
const lockSubmission = (submissionUUID) => post(
@@ -88,12 +90,23 @@ const lockSubmission = (submissionUUID) => post(
[paramKeys.submissionUUID]: submissionUUID,
}),
).then(response => response.data);
/**
* unlockSubmission(submissionUUID)
* @param {string} submissionUUID
*/
const unlockSubmission = (submissionUUID) => client().delete(
stringifyUrl(urls.fetchSubmissionLockUrl, {
[paramKeys.oraLocation]: locationId,
[paramKeys.submissionUUID]: submissionUUID,
}),
).then(response => response.data);
/*
* post('api/updateGrade', { submissionUUID, gradeData })
* @param {object} gradeData - full grading submission data
*/
const updateGrade = (submissionUUID, gradeData) => post(
stringifyUrl(urls.updateSubmissioonGradeUrl, {
stringifyUrl(urls.updateSubmissionGradeUrl, {
[paramKeys.oraLocation]: locationId,
[paramKeys.submissionUUID]: submissionUUID,
}),
@@ -107,4 +120,5 @@ export default StrictDict({
fetchSubmissionStatus,
lockSubmission,
updateGrade,
unlockSubmission,
});

View File

@@ -0,0 +1,73 @@
import { StrictDict } from 'utils';
import { ErrorStatuses, RequestKeys } from 'data/constants/requests';
import { gradeStatuses, lockStatuses } from 'data/services/lms/constants';
import { actions } from 'data/redux';
export const errorData = (status, data = '') => ({
response: {
status,
data,
},
});
export const networkErrorData = errorData(ErrorStatuses.badRequest);
const gradeData = {
overallFeedback: 'was okay',
criteria: [{ feedback: 'did alright', name: 'firstCriterion', selectedOption: 'good' }],
};
export const genTestUtils = ({ dispatch }) => {
const mockStart = (requestKey) => () => {
dispatch(actions.requests.startRequest(requestKey));
};
const mockError = (requestKey, status, data) => () => {
dispatch(actions.requests.failRequest({
requestKey,
error: errorData(status, data),
}));
};
const mockNetworkError = (requestKey) => (
mockError(requestKey, ErrorStatuses.badRequest)
);
return {
init: StrictDict({
start: mockStart(RequestKeys.initialize),
networkError: mockNetworkError(RequestKeys.initialize),
}),
fetch: StrictDict({
start: mockStart(RequestKeys.fetchSubmission),
mockError: mockError(RequestKeys.fetchSubmission, ErrorStatuses.badRequest),
}),
submitGrade: StrictDict({
start: mockStart(RequestKeys.submitGrade),
success: () => {
dispatch(actions.requests.completeRequest({
requestKey: RequestKeys.submitGrade,
response: {
gradeStatus: gradeStatuses.graded,
lockStatus: lockStatuses.unlocked,
gradeData,
},
}));
},
networkError: mockError(RequestKeys.submitGrade, ErrorStatuses.badRequest),
rejectedError: mockError(
RequestKeys.submitGrade,
ErrorStatuses.conflict,
{
submissionStatus: {
gradeStatus: gradeStatuses.ungraded,
lockStatus: lockStatuses.locked,
gradeData,
},
},
),
}),
};
};
export default genTestUtils;

View File

@@ -10,7 +10,7 @@ const oraInitializeUrl = `${baseEsgUrl}initialize`;
const fetchSubmissionUrl = `${baseEsgUrl}submission`;
const fetchSubmissionStatusUrl = `${baseEsgUrl}submission/status`;
const fetchSubmissionLockUrl = `${baseEsgUrl}submission/lock`;
const updateSubmissioonGradeUrl = `${baseEsgUrl}submission/grade`;
const updateSubmissionGradeUrl = `${baseEsgUrl}submission/grade`;
const course = (courseId) => `${baseUrl}/courses/${courseId}`;
@@ -25,7 +25,7 @@ export default StrictDict({
fetchSubmissionUrl,
fetchSubmissionStatusUrl,
fetchSubmissionLockUrl,
updateSubmissioonGradeUrl,
updateSubmissionGradeUrl,
baseUrl,
course,
openResponse,

View File

@@ -15,6 +15,8 @@ export const get = (...args) => getAuthenticatedHttpClient().get(...args);
*/
export const post = (...args) => getAuthenticatedHttpClient().post(...args);
export const client = getAuthenticatedHttpClient;
/**
* stringifyUrl(url, query)
* simple wrapper around queryString.stringifyUrl that sets skip behavior

View File

@@ -3,6 +3,8 @@ import thunkMiddleware from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction';
import { createLogger } from 'redux-logger';
import apiTestUtils from 'data/services/lms/fakeData/testUtils';
import reducer, { actions, selectors } from './redux';
export const createStore = () => {
@@ -22,6 +24,7 @@ export const createStore = () => {
window.store = store;
window.actions = actions;
window.selectors = selectors;
window.apiTestUtils = apiTestUtils(store);
}
return store;