diff --git a/src/containers/ReviewActions/components/StartGradingButton.jsx b/src/containers/ReviewActions/components/StartGradingButton.jsx index 4e26f96..ce40ea4 100644 --- a/src/containers/ReviewActions/components/StartGradingButton.jsx +++ b/src/containers/ReviewActions/components/StartGradingButton.jsx @@ -7,6 +7,7 @@ import { Cancel, Highlight } from '@edx/paragon/icons'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { selectors, thunkActions } from 'data/redux'; +import { RequestKeys } from 'data/constants/requests'; import { gradingStatuses as statuses } from 'data/services/lms/constants'; import StopGradingConfirmModal from './StopGradingConfirmModal'; @@ -93,6 +94,7 @@ export class StartGradingButton extends React.Component { variant="primary" iconAfter={args.iconAfter} onClick={this.handleClick} + disabled={this.props.isPending} > @@ -117,16 +119,18 @@ StartGradingButton.propTypes = { gradingStatus: PropTypes.string.isRequired, startGrading: PropTypes.func.isRequired, stopGrading: PropTypes.func.isRequired, + isPending: PropTypes.bool.isRequired, }; export const mapStateToProps = (state) => ({ + isPending: selectors.requests.isPending(state, { requestKey: RequestKeys.submitGrade }), gradeStatus: selectors.grading.selected.gradeStatus(state), gradingStatus: selectors.grading.selected.gradingStatus(state), }); export const mapDispatchToProps = { startGrading: thunkActions.grading.startGrading, - stopGrading: thunkActions.grading.stopGrading, + stopGrading: thunkActions.grading.cancelGrading, }; export default connect(mapStateToProps, mapDispatchToProps)(StartGradingButton); diff --git a/src/containers/ReviewActions/components/StartGradingButton.test.jsx b/src/containers/ReviewActions/components/StartGradingButton.test.jsx index ce16f58..6afef81 100644 --- a/src/containers/ReviewActions/components/StartGradingButton.test.jsx +++ b/src/containers/ReviewActions/components/StartGradingButton.test.jsx @@ -2,6 +2,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { selectors, thunkActions } from 'data/redux'; +import { RequestKeys } from 'data/constants/requests'; import { gradingStatuses as statuses } from 'data/services/lms/constants'; import { @@ -10,10 +11,21 @@ import { mapDispatchToProps, } from './StartGradingButton'; -jest.mock('data/redux/grading/selectors', () => ({ - selected: { - gradeStatus: (state) => ({ gradeStatus: state }), - gradingStatus: (state) => ({ gradingStatus: state }), +jest.mock('data/redux', () => ({ + selectors: { + grading: { + selected: { + gradeStatus: (state) => ({ gradeStatus: state }), + gradingStatus: (state) => ({ gradingStatus: state }), + }, + }, + requests: { isPending: (...args) => ({ isPending: args }) }, + }, + thunkActions: { + grading: { + startGrading: jest.fn(), + stopGrading: jest.fn(), + }, }, })); jest.mock('./OverrideGradeConfirmModal', () => 'OverrideGradeConfirmModal'); @@ -23,7 +35,9 @@ let el; describe('StartGradingButton component', () => { describe('component', () => { - const props = {}; + const props = { + isPending: false, + }; beforeEach(() => { props.startGrading = jest.fn().mockName('this.props.startGrading'); props.stopGrading = jest.fn().mockName('this.props.stopGrading'); @@ -55,6 +69,11 @@ describe('StartGradingButton component', () => { test('snapshot: ungraded (startGrading callback)', () => { expect(mockedEl(statuses.ungraded).instance().render()).toMatchSnapshot(); }); + test('snapshot: pending (disabled)', () => { + el = mockedEl(statuses.ungraded); + el.setProps({ isPending: true }); + expect(el.instance().render()).toMatchSnapshot(); + }); test('snapshot: graded, confirmOverride (startGrading callback)', () => { el = mockedEl(statuses.graded); el.setState({ showConfirmOverrideGrade: true }); @@ -73,6 +92,14 @@ describe('StartGradingButton component', () => { beforeEach(() => { mapped = mapStateToProps(testState); }); + test('isPending loads from requests.isPending(submitGrade)', () => { + expect(mapped.isPending).toEqual( + selectors.requests.isPending( + testState, + { requestKey: RequestKeys.submitGrade }, + ), + ); + }); test('gradeStatus loads from grading.selected.gradeStatus', () => { expect(mapped.gradeStatus).toEqual(selectors.grading.selected.gradeStatus(testState)); }); @@ -84,8 +111,8 @@ describe('StartGradingButton component', () => { it('loads startGrading from thunkActions.grading.stargGrading', () => { expect(mapDispatchToProps.startGrading).toEqual(thunkActions.grading.startGrading); }); - it('loads stopGrading from thunkActions.grading.stopGrading', () => { - expect(mapDispatchToProps.stopGrading).toEqual(thunkActions.grading.stopGrading); + it('loads stopGrading from thunkActions.grading.cancelGrading', () => { + expect(mapDispatchToProps.stopGrading).toEqual(thunkActions.grading.cancelGrading); }); }); }); diff --git a/src/containers/ReviewActions/components/__snapshots__/StartGradingButton.test.jsx.snap b/src/containers/ReviewActions/components/__snapshots__/StartGradingButton.test.jsx.snap index 1b5a5d6..be5f631 100644 --- a/src/containers/ReviewActions/components/__snapshots__/StartGradingButton.test.jsx.snap +++ b/src/containers/ReviewActions/components/__snapshots__/StartGradingButton.test.jsx.snap @@ -3,6 +3,7 @@ exports[`StartGradingButton component component snapshotes snapshot: graded, confirmOverride (startGrading callback) 1`] = ` + + + +`; + +exports[`StartGradingButton component component snapshotes snapshot: ungraded (startGrading callback) 1`] = ` + + , - ]} - > - - - -

- -

- -); -ReviewError.defaultProps = { -}; -ReviewError.propTypes = { - // redux - reload: PropTypes.func.isRequired, -}; - -export const mapStateToProps = () => ({ -}); - -export const mapDispatchToProps = { - reload: thunkActions.grading.loadSubmission, -}; - -export default connect(mapStateToProps, mapDispatchToProps)(ReviewError); diff --git a/src/containers/ReviewModal/ReviewError.test.jsx b/src/containers/ReviewModal/ReviewError.test.jsx deleted file mode 100644 index 461fcd5..0000000 --- a/src/containers/ReviewModal/ReviewError.test.jsx +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import { shallow } from 'enzyme'; - -import { thunkActions } from 'data/redux'; - -import { - ReviewError, - mapDispatchToProps, -} from './ReviewError'; - -let el; -jest.useFakeTimers('modern'); - -describe('ReviewError component', () => { - const props = {}; - describe('component', () => { - beforeEach(() => { - props.reload = jest.fn(); - }); - describe('render tests', () => { - beforeEach(() => { - el = shallow(); - }); - test('snapshot', () => { - expect(el).toMatchSnapshot(); - }); - }); - }); - describe('mapDispatchToProps', () => { - it('loads reload from thunkActions.grading.reloadSubmission', () => { - expect(mapDispatchToProps.reload).toEqual(thunkActions.grading.loadSubmission); - }); - }); -}); diff --git a/src/containers/ReviewModal/ReviewErrors/FetchErrors.jsx b/src/containers/ReviewModal/ReviewErrors/FetchErrors.jsx new file mode 100644 index 0000000..42ea069 --- /dev/null +++ b/src/containers/ReviewModal/ReviewErrors/FetchErrors.jsx @@ -0,0 +1,50 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; + +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +import { selectors, thunkActions } from 'data/redux'; +import { RequestKeys } from 'data/constants/requests'; + +import messages from '../messages'; + +import ReviewError from './ReviewError'; + +/** + * + */ +export const FetchErrors = ({ + isFailed, + reload, +}) => isFailed && ( + + + +); +FetchErrors.defaultProps = { +}; +FetchErrors.propTypes = { + // redux + isFailed: PropTypes.bool.isRequired, + reload: PropTypes.func.isRequired, +}; + +export const mapStateToProps = (state) => ({ + isFailed: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchSubmission }), +}); + +export const mapDispatchToProps = { + reload: thunkActions.grading.loadSubmission, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(FetchErrors); diff --git a/src/containers/ReviewModal/ReviewErrors/FetchErrors.test.jsx b/src/containers/ReviewModal/ReviewErrors/FetchErrors.test.jsx new file mode 100644 index 0000000..d264a12 --- /dev/null +++ b/src/containers/ReviewModal/ReviewErrors/FetchErrors.test.jsx @@ -0,0 +1,61 @@ +import React from 'react'; + +import { selectors, thunkActions } from 'data/redux'; +import { RequestKeys } from 'data/constants/requests'; + +import { + FetchErrors, + mapStateToProps, + mapDispatchToProps, +} from './FetchErrors'; + +jest.mock('data/redux', () => ({ + selectors: { + requests: { + isFailed: (...args) => ({ isFailed: args }), + }, + }, + thunkActions: { + grading: { + loadSubmission: jest.fn(), + }, + }, +})); + +jest.mock('./ReviewError', () => 'ReviewError'); + +const requestKey = RequestKeys.fetchSubmission; + +describe('FetchErrors component', () => { + const props = { + isFailed: false, + }; + describe('component', () => { + beforeEach(() => { + props.reload = jest.fn(); + }); + describe('snapshots', () => { + test('snapshot: no failure', () => { + expect().toMatchSnapshot(); + }); + test('snapshot: with failure', () => { + expect().toMatchSnapshot(); + }); + }); + }); + describe('mapStateToProps', () => { + let mapped; + const testState = { some: 'test-state' }; + beforeEach(() => { + mapped = mapStateToProps(testState); + }); + test('isFailed loads from requests.isFailed(fetchSubmission)', () => { + expect(mapped.isFailed).toEqual(selectors.requests.isFailed(testState, { requestKey })); + }); + }); + describe('mapDispatchToProps', () => { + it('loads reload from thunkActions.grading.loadSubmission', () => { + expect(mapDispatchToProps.reload).toEqual(thunkActions.grading.loadSubmission); + }); + }); +}); diff --git a/src/containers/ReviewModal/ReviewErrors/ReviewError.jsx b/src/containers/ReviewModal/ReviewErrors/ReviewError.jsx new file mode 100644 index 0000000..050756a --- /dev/null +++ b/src/containers/ReviewModal/ReviewErrors/ReviewError.jsx @@ -0,0 +1,62 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Alert, Button } from '@edx/paragon'; +import { Info } from '@edx/paragon/icons'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +const messageShape = PropTypes.shape({ + id: PropTypes.string, + defaultMessage: PropTypes.string, +}); + +const ReviewError = ({ + actions: { + cancel, + confirm, + }, + headingMessage, + children, +}) => { + const actions = []; + if (cancel) { + actions.push(( + + )); + } + if (confirm) { + actions.push(( + + )); + } + return ( + + +

{children}

+
+ ); +}; +ReviewError.propTypes = { + actions: PropTypes.shape({ + cancel: PropTypes.shape({ + onClick: PropTypes.func, + message: messageShape, + }), + confirm: PropTypes.shape({ + onClick: PropTypes.func, + message: messageShape, + }), + }).isRequired, + headingMessage: messageShape.isRequired, + children: PropTypes.node.isRequired, +}; + +export default ReviewError; diff --git a/src/containers/ReviewModal/ReviewErrors/SubmitErrors.jsx b/src/containers/ReviewModal/ReviewErrors/SubmitErrors.jsx new file mode 100644 index 0000000..5199068 --- /dev/null +++ b/src/containers/ReviewModal/ReviewErrors/SubmitErrors.jsx @@ -0,0 +1,92 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; + +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +import { actions, selectors, thunkActions } from 'data/redux'; +import { RequestKeys, ErrorStatuses } from 'data/constants/requests'; + +import messages from './messages'; + +import ReviewError from './ReviewError'; + +/** + * + */ +export class SubmitErrors extends React.Component { + constructor(props) { + super(props); + this.dismissError = this.dismissError.bind(this); + } + + get gradeNotSubmitted() { + return { + confirm: { onClick: this.props.resubmit, message: messages.resubmitGrade }, + headingMessage: messages.gradeNotSubmittedHeading, + contentMessage: messages.gradeNotSubmittedContent, + }; + } + + get errorSubmittingGrade() { + return { + headingMessage: messages.errorSubmittingGradeHeading, + contentMessage: messages.errorSubmittingGradeContent, + }; + } + + get errorProps() { + if (this.props.errorStatus === ErrorStatuses.badRequest) { + return this.gradeNotSubmitted; + } + if (this.props.errorStatus === ErrorStatuses.conflict) { + return this.errorSubmittingGrade; + } + // TODO: Network-Log an error here for unhandled error type + return this.gradeNotSubmitted; + } + + dismissError() { + this.props.clearRequest({ requestKey: RequestKeys.submitGrade }); + } + + render() { + if (!this.props.errorStatus) { + return null; + } + const props = this.errorProps; + + return ( + + + + ); + } +} +SubmitErrors.defaultProps = { + errorStatus: undefined, +}; +SubmitErrors.propTypes = { + // redux + clearRequest: PropTypes.func.isRequired, + errorStatus: PropTypes.number, + resubmit: PropTypes.func.isRequired, +}; + +const requestKey = RequestKeys.submitGrade; +export const mapStateToProps = (state) => ({ + errorStatus: selectors.requests.errorStatus(state, { requestKey }), +}); + +export const mapDispatchToProps = { + clearRequest: actions.requests.clearRequest, + resubmit: thunkActions.grading.submitGrade, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(SubmitErrors); diff --git a/src/containers/ReviewModal/ReviewErrors/SubmitErrors.test.jsx b/src/containers/ReviewModal/ReviewErrors/SubmitErrors.test.jsx new file mode 100644 index 0000000..b93194e --- /dev/null +++ b/src/containers/ReviewModal/ReviewErrors/SubmitErrors.test.jsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { actions, selectors, thunkActions } from 'data/redux'; +import { ErrorStatuses, RequestKeys } from 'data/constants/requests'; + +import { + SubmitErrors, + mapStateToProps, + mapDispatchToProps, +} from './SubmitErrors'; + +jest.mock('data/redux', () => ({ + actions: { + requests: { + clearRequest: jest.fn().mockName('actions.requests.clearRequest'), + }, + }, + selectors: { + requests: { + errorStatus: (...args) => ({ errorStatus: args }), + }, + }, + thunkActions: { + grading: { + submitGrade: jest.fn().mockName('thunkActions.grading.submitGrade'), + }, + }, +})); + +let el; +jest.mock('./ReviewError', () => 'ReviewError'); + +const requestKey = RequestKeys.submitGrade; + +describe('SubmitErrors component', () => { + const props = {}; + describe('component', () => { + beforeEach(() => { + props.resubmit = jest.fn(); + props.clearRequest = jest.fn(); + el = shallow(); + el.instance().dismissError = jest.fn().mockName('this.dismissError'); + }); + describe('snapshots', () => { + test('snapshot: no failure', () => { + expect(el.instance().render()).toMatchSnapshot(); + }); + test('snapshot: with network failure', () => { + el.setProps({ errorStatus: ErrorStatuses.badRequest }); + expect(el.instance().render()).toMatchSnapshot(); + }); + test('snapshot: with conflict failure', () => { + el.setProps({ errorStatus: ErrorStatuses.conflict }); + expect(el.instance().render()).toMatchSnapshot(); + }); + }); + }); + describe('mapStateToProps', () => { + let mapped; + const testState = { some: 'test-state' }; + beforeEach(() => { + mapped = mapStateToProps(testState); + }); + test('errorStatus loads from requests.errorStatus(fetchSubmission)', () => { + expect(mapped.errorStatus).toEqual( + selectors.requests.errorStatus(testState, { requestKey }), + ); + }); + }); + describe('mapDispatchToProps', () => { + it('loads clearRequest from actions.requests.clearRequest', () => { + expect(mapDispatchToProps.clearRequest).toEqual(actions.requests.clearRequest); + }); + it('loads resubmit from thunkActions.grading.submitGrade', () => { + expect(mapDispatchToProps.resubmit).toEqual(thunkActions.grading.submitGrade); + }); + }); +}); diff --git a/src/containers/ReviewModal/ReviewErrors/__snapshots__/FetchErrors.test.jsx.snap b/src/containers/ReviewModal/ReviewErrors/__snapshots__/FetchErrors.test.jsx.snap new file mode 100644 index 0000000..abb0d11 --- /dev/null +++ b/src/containers/ReviewModal/ReviewErrors/__snapshots__/FetchErrors.test.jsx.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FetchErrors component component snapshots snapshot: no failure 1`] = ` + +`; + +exports[`FetchErrors component component snapshots snapshot: with failure 1`] = ` + +`; diff --git a/src/containers/ReviewModal/ReviewErrors/__snapshots__/SubmitErrors.test.jsx.snap b/src/containers/ReviewModal/ReviewErrors/__snapshots__/SubmitErrors.test.jsx.snap new file mode 100644 index 0000000..ee0c3d5 --- /dev/null +++ b/src/containers/ReviewModal/ReviewErrors/__snapshots__/SubmitErrors.test.jsx.snap @@ -0,0 +1,72 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SubmitErrors component component snapshots snapshot: no failure 1`] = `null`; + +exports[`SubmitErrors component component snapshots snapshot: with conflict failure 1`] = ` + + + +`; + +exports[`SubmitErrors component component snapshots snapshot: with network failure 1`] = ` + + + +`; diff --git a/src/containers/ReviewModal/ReviewErrors/__snapshots__/index.test.jsx.snap b/src/containers/ReviewModal/ReviewErrors/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000..4bf64fd --- /dev/null +++ b/src/containers/ReviewModal/ReviewErrors/__snapshots__/index.test.jsx.snap @@ -0,0 +1,8 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ReviewErrors component component snapshot: no failure 1`] = ` + + + + +`; diff --git a/src/containers/ReviewModal/ReviewErrors/index.jsx b/src/containers/ReviewModal/ReviewErrors/index.jsx new file mode 100644 index 0000000..d0852e0 --- /dev/null +++ b/src/containers/ReviewModal/ReviewErrors/index.jsx @@ -0,0 +1,20 @@ +import React from 'react'; + +import FetchErrors from './FetchErrors'; +import SubmitErrors from './SubmitErrors'; + +/** + * + */ +export const ReviewErrors = () => ( + <> + + + +); +ReviewErrors.defaultProps = { +}; +ReviewErrors.propTypes = { +}; + +export default ReviewErrors; diff --git a/src/containers/ReviewModal/ReviewErrors/index.test.jsx b/src/containers/ReviewModal/ReviewErrors/index.test.jsx new file mode 100644 index 0000000..4e11d18 --- /dev/null +++ b/src/containers/ReviewModal/ReviewErrors/index.test.jsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { ReviewErrors } from '.'; + +jest.mock('./FetchErrors', () => 'FetchErrors'); +jest.mock('./SubmitErrors', () => 'SubmitErrors'); + +describe('ReviewErrors component', () => { + describe('component', () => { + test('snapshot: no failure', () => { + expect(shallow()).toMatchSnapshot(); + }); + }); +}); diff --git a/src/containers/ReviewModal/ReviewErrors/messages.js b/src/containers/ReviewModal/ReviewErrors/messages.js new file mode 100644 index 0000000..56b165d --- /dev/null +++ b/src/containers/ReviewModal/ReviewErrors/messages.js @@ -0,0 +1,53 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; +import { StrictDict } from 'utils'; + +const messages = defineMessages({ + loadErrorHeading: { + id: 'ora-grading.ReviewModal.loadErrorHeading', + defaultMessage: 'Error loading submissions', + description: 'Submission response load failure alert header', + }, + loadErrorMessage: { + id: 'ora-grading.ReviewModal.loadErrorMessage1', + defaultMessage: 'An error occurred while loading this submission. Try reloading this submission.', + description: 'Submission response load failure alert message', + }, + reloadSubmission: { + id: 'ora-grading.ReviewModal.reloadSubmission', + defaultMessage: 'Reload submission', + description: 'Reload button text in case of network failure', + }, + gradeNotSubmittedHeading: { + id: 'ora-grading.ReviewModal.gradeNotSubmitted.heading', + defaultMessage: 'Grade not submitted', + description: 'Grade submission network error heading', + }, + gradeNotSubmittedContent: { + id: 'ora-grading.ReviewModal.gradeNotSubmitted.heading', + defaultMessage: "We're sorry, something went wrong when we tried to submit this grade. Please try again.", + description: 'Grade submission network error message', + }, + resubmitGrade: { + id: 'ora-grading.ReviewModal.resubmitGrade', + defaultMessage: 'Resubmit grate', + description: 'Resubmit grade button after network failure', + }, + dismiss: { + id: 'ora-grading.ReviewModal.dismiss', + defaultMessage: 'Dismiss', + description: 'Dismiss error action button text', + }, + errorSubmittingGradeHeading: { + id: 'ora-grading.ReviewModal.errorSubmittingGrade.Heading', + defaultMessage: 'Error submitting grade', + description: 'Error Submitting Grade heading text', + }, + errorSubmittingGradeContent: { + id: 'ora-grading.ReviewModal.errorSubmittingGrade.Content', + defaultMessage: 'It looks like someone else got here first! Your grade submission has been rejected', + description: 'Error Submitting Grade content', + }, + +}); + +export default StrictDict(messages); diff --git a/src/containers/ReviewModal/__snapshots__/ReviewContent.test.jsx.snap b/src/containers/ReviewModal/__snapshots__/ReviewContent.test.jsx.snap index 86b6e90..40b1883 100644 --- a/src/containers/ReviewModal/__snapshots__/ReviewContent.test.jsx.snap +++ b/src/containers/ReviewModal/__snapshots__/ReviewContent.test.jsx.snap @@ -1,9 +1,18 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ReviewContent component component render tests snapshot (hide rubric) 1`] = ` +exports[`ReviewContent component component render tests snapshot: failed, showRubric (errors only) 1`] = `
+ +
+`; + +exports[`ReviewContent component component render tests snapshot: hide rubric 1`] = ` +
+ @@ -15,10 +24,11 @@ exports[`ReviewContent component component render tests snapshot (hide rubric) 1
`; -exports[`ReviewContent component component render tests snapshot (show rubric) 1`] = ` +exports[`ReviewContent component component render tests snapshot: show rubric 1`] = `
+ diff --git a/src/containers/ReviewModal/__snapshots__/ReviewError.test.jsx.snap b/src/containers/ReviewModal/__snapshots__/ReviewError.test.jsx.snap deleted file mode 100644 index 336df69..0000000 --- a/src/containers/ReviewModal/__snapshots__/ReviewError.test.jsx.snap +++ /dev/null @@ -1,35 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ReviewError component component render tests snapshot 1`] = ` - - - , - ] - } - variant="danger" -> - - - -

- -

-
-`; diff --git a/src/containers/ReviewModal/__snapshots__/index.test.jsx.snap b/src/containers/ReviewModal/__snapshots__/index.test.jsx.snap index a7bf68e..f83858d 100644 --- a/src/containers/ReviewModal/__snapshots__/index.test.jsx.snap +++ b/src/containers/ReviewModal/__snapshots__/index.test.jsx.snap @@ -30,9 +30,7 @@ exports[`ReviewModal component component snapshots error 1`] = ` onClose={[MockFunction this.onClose]} title="test-ora-name" > - - - + `; @@ -45,7 +43,7 @@ exports[`ReviewModal component component snapshots loading 1`] = ` onClose={[MockFunction this.onClose]} title="test-ora-name" > - + - - - + `; diff --git a/src/containers/ReviewModal/index.jsx b/src/containers/ReviewModal/index.jsx index 192b162..079b555 100644 --- a/src/containers/ReviewModal/index.jsx +++ b/src/containers/ReviewModal/index.jsx @@ -9,7 +9,6 @@ import { RequestKeys } from 'data/constants/requests'; import LoadingMessage from 'components/LoadingMessage'; import ReviewActions from 'containers/ReviewActions'; -import ReviewError from './ReviewError'; import ReviewContent from './ReviewContent'; import messages from './messages'; @@ -29,11 +28,11 @@ export class ReviewModal extends React.Component { } get isLoading() { - return !(this.props.hasError || this.props.isLoaded); + return !(this.props.errorStatus || this.props.isLoaded); } render() { - const { isOpen, isLoaded, hasError } = this.props; + const { isOpen, isLoaded, errorStatus } = this.props; return ( - {isOpen && ( - <> - {isLoaded && } - {hasError && } - - )} + {isOpen && } {/* even if the modal is closed, in case we want to add transitions later */} - {!(isLoaded || hasError) && } + {!(isLoaded || errorStatus) && } ); } } ReviewModal.defaultProps = { + errorStatus: null, response: null, }; ReviewModal.propTypes = { @@ -66,7 +61,7 @@ ReviewModal.propTypes = { }), setShowReview: PropTypes.func.isRequired, isLoaded: PropTypes.bool.isRequired, - hasError: PropTypes.bool.isRequired, + errorStatus: PropTypes.number, }; export const mapStateToProps = (state) => ({ @@ -74,7 +69,7 @@ export const mapStateToProps = (state) => ({ oraName: selectors.app.ora.name(state), response: selectors.grading.selected.response(state), isLoaded: selectors.requests.isCompleted(state, { requestKey: RequestKeys.fetchSubmission }), - hasError: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchSubmission }), + errorStatus: selectors.requests.errorStatus(state, { requestKey: RequestKeys.fetchSubmission }), }); export const mapDispatchToProps = { diff --git a/src/containers/ReviewModal/index.test.jsx b/src/containers/ReviewModal/index.test.jsx index 1c3f70f..38c8418 100644 --- a/src/containers/ReviewModal/index.test.jsx +++ b/src/containers/ReviewModal/index.test.jsx @@ -24,7 +24,7 @@ jest.mock('data/redux', () => ({ }, requests: { isCompleted: (...args) => ({ isCompleted: args }), - isFailed: (...args) => ({ isFailed: args }), + errorStatus: (...args) => ({ errorStatus: args }), }, }, actions: { @@ -35,7 +35,6 @@ jest.mock('data/redux', () => ({ })); jest.mock('containers/ReviewActions', () => 'ReviewActions'); -jest.mock('./ReviewError', () => 'ReviewError'); jest.mock('./ReviewContent', () => 'ReviewContent'); jest.mock('components/LoadingMessage', () => 'LoadingMessage'); @@ -48,7 +47,6 @@ describe('ReviewModal component', () => { response: { text: (
some text
) }, showRubric: false, isLoaded: false, - hasError: false, }; describe('component', () => { beforeEach(() => { @@ -69,7 +67,7 @@ describe('ReviewModal component', () => { expect(render()).toMatchSnapshot(); }); test('error', () => { - el.setProps({ isOpen: true, hasError: true }); + el.setProps({ isOpen: true, errorStatus: 200 }); expect(render()).toMatchSnapshot(); }); test('success', () => { @@ -96,8 +94,8 @@ describe('ReviewModal component', () => { test('isLoaded loads from requests.isCompleted(fetchSubmission)', () => { expect(mapped.isLoaded).toEqual(selectors.requests.isCompleted(testState, { requestKey })); }); - test('hasError loads from requests.isFailed(fetchSubmission)', () => { - expect(mapped.hasError).toEqual(selectors.requests.isFailed(testState, { requestKey })); + test('errorStatus loads from requests.errorStatus(fetchSubmission)', () => { + expect(mapped.errorStatus).toEqual(selectors.requests.errorStatus(testState, { requestKey })); }); }); describe('mapDispatchToProps', () => { diff --git a/src/containers/ReviewModal/messages.js b/src/containers/ReviewModal/messages.js index ddc8a99..d342185 100644 --- a/src/containers/ReviewModal/messages.js +++ b/src/containers/ReviewModal/messages.js @@ -1,4 +1,5 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; +import { StrictDict } from 'utils'; const messages = defineMessages({ loadErrorHeading: { @@ -21,6 +22,26 @@ const messages = defineMessages({ defaultMessage: 'Loading response', description: 'loading text for submission response review screen', }, + gradeNotSubmittedHeading: { + id: 'ora-grading.ReviewModal.gradeNotSubmitted.heading', + defaultMessage: 'Grade not submitted', + description: 'Grade submission network error heading', + }, + gradeNotSubmittedContent: { + id: 'ora-grading.ReviewModal.gradeNotSubmitted.heading', + defaultMessage: "We're sorry, something went wrong when we tried to submit this grade. Please try again.", + description: 'Grade submission network error message', + }, + resubmitGrade: { + id: 'ora-grading.ReviewModal.resubmitGrade', + defaultMessage: 'Resubmit grate', + description: 'Resubmit grade button after network failure', + }, + dismiss: { + id: 'ora-grading.ReviewModal.dismiss', + defaultMessage: 'Dismiss', + description: 'Dismiss error action button text', + }, }); -export default messages; +export default StrictDict(messages); diff --git a/src/containers/Rubric/Rubric.scss b/src/containers/Rubric/Rubric.scss index fd30b07..63f53ee 100644 --- a/src/containers/Rubric/Rubric.scss +++ b/src/containers/Rubric/Rubric.scss @@ -69,4 +69,8 @@ justify-content: center; padding: map-get($spacers, 3); } + + button.pgn__stateful-btn.pgn__stateful-btn-state-pending { + opacity: .4 !important; + } } diff --git a/src/containers/Rubric/__snapshots__/index.test.jsx.snap b/src/containers/Rubric/__snapshots__/index.test.jsx.snap index db589fc..1fad1cb 100644 --- a/src/containers/Rubric/__snapshots__/index.test.jsx.snap +++ b/src/containers/Rubric/__snapshots__/index.test.jsx.snap @@ -19,27 +19,22 @@ exports[`Rubric Container snapshot is grading 1`] = ` />
@@ -48,15 +43,109 @@ exports[`Rubric Container snapshot is grading 1`] = `
-
+ +`; + +exports[`Rubric Container snapshot is grading, submit pending 1`] = ` + + +

- +

+
+ + + + + +
+ +
+
+ , + "default": , + "pending": , + } + } + onClick={[MockFunction this.submitGradeHandler]} + state="pending" + />
`; @@ -80,27 +169,22 @@ exports[`Rubric Container snapshot is not grading 1`] = ` />
@@ -108,3 +192,78 @@ exports[`Rubric Container snapshot is not grading 1`] = ` `; + +exports[`Rubric Container snapshot submit completed 1`] = ` + + +

+ +

+
+ + + + + +
+ +
+
+ , + "default": , + "pending": , + } + } + onClick={[MockFunction this.submitGradeHandler]} + state="complete" + /> +
+
+`; diff --git a/src/containers/Rubric/index.jsx b/src/containers/Rubric/index.jsx index 5f048ae..4c2a8ac 100644 --- a/src/containers/Rubric/index.jsx +++ b/src/containers/Rubric/index.jsx @@ -2,10 +2,12 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import { Card, Button } from '@edx/paragon'; +import { Card, StatefulButton } from '@edx/paragon'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { StrictDict } from 'utils'; import { selectors, thunkActions } from 'data/redux'; +import { RequestKeys } from 'data/constants/requests'; import CriterionContainer from 'containers/CriterionContainer'; import RubricFeedback from './RubricFeedback'; @@ -13,6 +15,13 @@ import messages from './messages'; import './Rubric.scss'; +const ButtonStates = StrictDict({ + default: 'default', + pending: 'pending', + complete: 'complete', + error: 'error', +}); + /** * */ @@ -23,6 +32,16 @@ export class Rubric extends React.Component { this.submitGradeHandler = this.submitGradeHandler.bind(this); } + get submitButtonState() { + if (this.props.isPending) { + return ButtonStates.pending; + } + if (this.props.isCompleted) { + return ButtonStates.complete; + } + return ButtonStates.default; + } + submitGradeHandler() { this.props.submitGrade(); } @@ -44,11 +63,18 @@ export class Rubric extends React.Component {
- {isGrading && ( + {(isGrading || this.props.isCompleted) && (
- + , + [ButtonStates.pending]: , + [ButtonStates.complete]: , + }} + />
)} @@ -59,13 +85,19 @@ Rubric.defaultProps = { criteriaIndices: [], }; Rubric.propTypes = { + isCompleted: PropTypes.bool.isRequired, isGrading: PropTypes.bool.isRequired, + isPending: PropTypes.bool.isRequired, criteriaIndices: PropTypes.arrayOf(PropTypes.number), submitGrade: PropTypes.func.isRequired, }; +const requestKey = RequestKeys.submitGrade; + export const mapStateToProps = (state) => ({ + isCompleted: selectors.requests.isCompleted(state, { requestKey }), isGrading: selectors.grading.selected.isGrading(state), + isPending: selectors.requests.isPending(state, { requestKey }), criteriaIndices: selectors.app.rubric.criteriaIndices(state), }); diff --git a/src/containers/Rubric/index.test.jsx b/src/containers/Rubric/index.test.jsx index 6bdc617..da7c19d 100644 --- a/src/containers/Rubric/index.test.jsx +++ b/src/containers/Rubric/index.test.jsx @@ -18,9 +18,13 @@ jest.mock('data/redux', () => ({ }, grading: { selected: { - isGrading: jest.fn((...args) => ({ isGragrding: args })), + isGrading: jest.fn((...args) => ({ isGrading: args })), }, }, + requests: { + isCompleted: jest.fn((...args) => ({ isCompleted: args })), + isPending: jest.fn((...args) => ({ isPending: args })), + }, }, thunkActions: { grading: { @@ -31,6 +35,8 @@ jest.mock('data/redux', () => ({ describe('Rubric Container', () => { const props = { + isCompleted: false, + isPending: false, isGrading: true, criteriaIndices: [1, 2, 3, 4, 5], submitGrade: jest.fn().mockName('this.props.submitGrade'), @@ -43,14 +49,23 @@ describe('Rubric Container', () => { .mockName('this.submitGradeHandler'); }); describe('snapshot', () => { + beforeEach(() => { + el.instance().submitGradeHandler = jest.fn().mockName('this.submitGradeHandler'); + }); test('is grading', () => { - expect(el).toMatchSnapshot(); + expect(el.instance().render()).toMatchSnapshot(); }); test('is not grading', () => { - el.setProps({ - isGrading: false, - }); - expect(el).toMatchSnapshot(); + el.setProps({ isGrading: false }); + expect(el.instance().render()).toMatchSnapshot(); + }); + test('is grading, submit pending', () => { + el.setProps({ isPending: true }); + expect(el.instance().render()).toMatchSnapshot(); + }); + test('submit completed', () => { + el.setProps({ isCompleted: true, isGrading: false }); + expect(el.instance().render()).toMatchSnapshot(); }); }); diff --git a/src/containers/Rubric/messages.js b/src/containers/Rubric/messages.js index 7c11381..b9561d9 100644 --- a/src/containers/Rubric/messages.js +++ b/src/containers/Rubric/messages.js @@ -1,6 +1,11 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ + gradeSubmitted: { + id: 'ora-grading.Rubric.gradeSubmitted', + defaultMessage: 'Grade Submitted', + description: 'Submit Grade button text after successful submission', + }, rubric: { id: 'ora-grading.Rubric.rubric', defaultMessage: 'Rubric', @@ -11,6 +16,11 @@ const messages = defineMessages({ defaultMessage: 'Submit grade', description: 'Submit Grade button text', }, + submittingGrade: { + id: 'ora-grading.Rubric.submittingGrade', + defaultMessage: 'Submitting grade', + description: 'Submit Grade button text while submitting', + }, overallComments: { id: 'ora-grading.Rubric.overallComments', defaultMessage: 'Overall comments', diff --git a/src/data/constants/requests.js b/src/data/constants/requests.js index 932e212..5a1d2c6 100644 --- a/src/data/constants/requests.js +++ b/src/data/constants/requests.js @@ -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, +}); diff --git a/src/data/redux/grading/reducer.js b/src/data/redux/grading/reducer.js index b6279f8..efd0b72 100644 --- a/src/data/redux/grading/reducer.js +++ b/src/data/redux/grading/reducer.js @@ -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, }, }; }, diff --git a/src/data/redux/requests/reducer.js b/src/data/redux/requests/reducer.js index 709eccb..4e7b068 100644 --- a/src/data/redux/requests/reducer.js +++ b/src/data/redux/requests/reducer.js @@ -39,6 +39,10 @@ const requests = createSlice({ error: payload.error, }, }), + clearRequest: (state, { payload }) => ({ + ...state, + [payload.requestKey]: {}, + }), }, }); diff --git a/src/data/redux/requests/selectors.js b/src/data/redux/requests/selectors.js index 15f8266..9fa746e 100644 --- a/src/data/redux/requests/selectors.js +++ b/src/data/redux/requests/selectors.js @@ -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), }); diff --git a/src/data/redux/thunkActions/grading.js b/src/data/redux/thunkActions/grading.js index 17b5937..585eda7 100644 --- a/src/data/redux/thunkActions/grading.js +++ b/src/data/redux/thunkActions/grading.js @@ -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 { diff --git a/src/data/redux/thunkActions/grading.test.js b/src/data/redux/thunkActions/grading.test.js index a13199b..854398e 100644 --- a/src/data/redux/thunkActions/grading.test.js +++ b/src/data/redux/thunkActions/grading.test.js @@ -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); diff --git a/src/data/redux/thunkActions/requests.js b/src/data/redux/thunkActions/requests.js index fdf16e2..b860b72 100644 --- a/src/data/redux/thunkActions/requests.js +++ b/src/data/redux/thunkActions/requests.js @@ -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, })); }; diff --git a/src/data/redux/thunkActions/requests.test.js b/src/data/redux/thunkActions/requests.test.js index 3ace9d0..2ba777b 100644 --- a/src/data/redux/thunkActions/requests.test.js +++ b/src/data/redux/thunkActions/requests.test.js @@ -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({ diff --git a/src/data/services/lms/api.js b/src/data/services/lms/api.js index 748a88c..6d54606 100644 --- a/src/data/services/lms/api.js +++ b/src/data/services/lms/api.js @@ -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, }); diff --git a/src/data/services/lms/fakeData/testUtils.js b/src/data/services/lms/fakeData/testUtils.js new file mode 100644 index 0000000..55529ec --- /dev/null +++ b/src/data/services/lms/fakeData/testUtils.js @@ -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; diff --git a/src/data/services/lms/urls.js b/src/data/services/lms/urls.js index 9980487..51ab06d 100644 --- a/src/data/services/lms/urls.js +++ b/src/data/services/lms/urls.js @@ -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, diff --git a/src/data/services/lms/utils.js b/src/data/services/lms/utils.js index 9e01ee4..4b0dff5 100644 --- a/src/data/services/lms/utils.js +++ b/src/data/services/lms/utils.js @@ -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 diff --git a/src/data/store.js b/src/data/store.js index 4e73ca3..2098947 100755 --- a/src/data/store.js +++ b/src/data/store.js @@ -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; diff --git a/src/setupTest.js b/src/setupTest.js index 426a51e..41daf94 100755 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -71,6 +71,7 @@ jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedCompon Content: 'Popover.Content', }, Row: 'Row', + StatefulButton: 'StatefulButton', TextFilter: 'TextFilter', }));