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

@@ -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}
>
<FormattedMessage {...args.label} />
</Button>
@@ -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);

View File

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

View File

@@ -3,6 +3,7 @@
exports[`StartGradingButton component component snapshotes snapshot: graded, confirmOverride (startGrading callback) 1`] = `
<React.Fragment>
<Button
disabled={false}
iconAfter={[MockFunction icons.Highlight]}
onClick={[MockFunction this.handleClick]}
variant="primary"
@@ -30,12 +31,13 @@ exports[`StartGradingButton component component snapshotes snapshot: graded, con
exports[`StartGradingButton component component snapshotes snapshot: inProgress, isOverride, confirmStop (stopGrading callback) 1`] = `
<React.Fragment>
<Button
disabled={false}
iconAfter={[MockFunction icons.Cancel]}
onClick={[MockFunction this.handleClick]}
variant="primary"
>
<FormattedMessage
defaultMessage="Stop grading"
defaultMessage="Stop grading this response"
description="Review pane button text to stop grading"
id="ora-grading.ReviewActions.StartGradingButton.stopGrading"
/>
@@ -56,9 +58,38 @@ exports[`StartGradingButton component component snapshotes snapshot: inProgress,
exports[`StartGradingButton component component snapshotes snapshot: locked (null) 1`] = `null`;
exports[`StartGradingButton component component snapshotes snapshot: ungraded (startGrading callback) 1`] = `
exports[`StartGradingButton component component snapshotes snapshot: pending (disabled) 1`] = `
<React.Fragment>
<Button
disabled={true}
iconAfter={[MockFunction icons.Highlight]}
onClick={[MockFunction this.handleClick]}
variant="primary"
>
<FormattedMessage
defaultMessage="Start grading"
description="Review pane button text to start grading"
id="ora-grading.ReviewActions.StartGradingButton.startGrading"
/>
</Button>
<OverrideGradeConfirmModal
isOpen={false}
onCancel={[MockFunction this.hideConfirmOverrideGrade]}
onConfirm={[MockFunction this.confirmOverrideGrade]}
/>
<StopGradingConfirmModal
isOpen={false}
isOverride={false}
onCancel={[MockFunction this.hideConfirmStopGrading]}
onConfirm={[MockFunction this.confirmStopGrading]}
/>
</React.Fragment>
`;
exports[`StartGradingButton component component snapshotes snapshot: ungraded (startGrading callback) 1`] = `
<React.Fragment>
<Button
disabled={false}
iconAfter={[MockFunction icons.Highlight]}
onClick={[MockFunction this.handleClick]}
variant="primary"

View File

@@ -28,7 +28,7 @@ const messages = defineMessages({
},
stopGrading: {
id: 'ora-grading.ReviewActions.StartGradingButton.stopGrading',
defaultMessage: 'Stop grading',
defaultMessage: 'Stop grading this response',
description: 'Review pane button text to stop grading',
},
confirmStopOverrideTitle: {

View File

@@ -31,7 +31,7 @@ export const ReviewActions = ({
<StatusBadge className="review-actions-status mr-3" status={gradingStatus} />
)}
<span className="small">
{pointsEarned && (
{pointsPossible && (
<FormattedMessage
{...messages.pointsDisplay}
values={{ pointsEarned, pointsPossible }}

View File

@@ -5,29 +5,40 @@ import { connect } from 'react-redux';
import { Col, Row } from '@edx/paragon';
import { selectors } from 'data/redux';
import { RequestKeys } from 'data/constants/requests';
import ResponseDisplay from 'containers/ResponseDisplay';
import Rubric from 'containers/Rubric';
import ReviewErrors from './ReviewErrors';
/**
* <ReviewContent />
*/
export const ReviewContent = ({ showRubric }) => (
export const ReviewContent = ({ isFailed, isLoaded, showRubric }) => (isLoaded || isFailed) && (
<div className="content-block">
<Row className="flex-nowrap">
<Col><ResponseDisplay /></Col>
{ showRubric && <Rubric /> }
</Row>
<ReviewErrors />
{ isLoaded && (
<Row className="flex-nowrap">
<Col><ResponseDisplay /></Col>
{ showRubric && <Rubric /> }
</Row>
)}
</div>
);
ReviewContent.defaultProps = {
isFailed: false,
isLoaded: false,
showRubric: false,
};
ReviewContent.propTypes = {
isFailed: PropTypes.bool,
isLoaded: PropTypes.bool,
showRubric: PropTypes.bool,
};
export const mapStateToProps = (state) => ({
isFailed: selectors.requests.isFailed(state, { requestKey: RequestKeys.fetchSubmission }),
isLoaded: selectors.requests.isCompleted(state, { requestKey: RequestKeys.fetchSubmission }),
showRubric: selectors.app.showRubric(state),
});

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { shallow } from 'enzyme';
import { selectors } from 'data/redux';
import { RequestKeys } from 'data/constants/requests';
import {
ReviewContent,
mapStateToProps,
@@ -12,21 +13,30 @@ jest.mock('data/redux', () => ({
app: {
showRubric: (...args) => ({ showRubric: args }),
},
requests: {
isCompleted: (...args) => ({ isCompleted: args }),
isFailed: (...args) => ({ isFailed: args }),
},
},
}));
jest.mock('containers/ResponseDisplay', () => 'ResponseDisplay');
jest.mock('containers/Rubric', () => 'Rubric');
jest.useFakeTimers('modern');
jest.mock('./ReviewErrors', () => 'ReviewErrors');
describe('ReviewContent component', () => {
describe('component', () => {
describe('render tests', () => {
test('snapshot (show rubric)', () => {
expect(shallow(<ReviewContent />)).toMatchSnapshot();
test('snapshot: not loaded, no error', () => {
expect(shallow(<ReviewContent />).isEmptyRender()).toEqual(true);
});
test('snapshot (hide rubric)', () => {
expect(shallow(<ReviewContent showRubric />)).toMatchSnapshot();
test('snapshot: show rubric', () => {
expect(shallow(<ReviewContent isLoaded />)).toMatchSnapshot();
});
test('snapshot: hide rubric', () => {
expect(shallow(<ReviewContent isLoaded showRubric />)).toMatchSnapshot();
});
test('snapshot: failed, showRubric (errors only)', () => {
expect(shallow(<ReviewContent showRubric isFailed />)).toMatchSnapshot();
});
});
});
@@ -36,8 +46,15 @@ describe('ReviewContent component', () => {
beforeEach(() => {
mapped = mapStateToProps(testState);
});
const requestKey = RequestKeys.fetchSubmission;
test('showRubric loads from app.showRubric', () => {
expect(mapped.showRubric).toEqual(selectors.app.showRubric(testState));
});
test('isFailed loads from requests.isFailed(fetchSubmission)', () => {
expect(mapped.isFailed).toEqual(selectors.requests.isFailed(testState, { requestKey }));
});
test('isLoadeed loads from requests.isCompleted(fetchSubmission)', () => {
expect(mapped.isLoaded).toEqual(selectors.requests.isCompleted(testState, { requestKey }));
});
});
});

View File

@@ -1,51 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
Alert,
Button,
} from '@edx/paragon';
import { Info } from '@edx/paragon/icons';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { thunkActions } from 'data/redux';
import messages from './messages';
/**
* <ReviewError />
*/
export const ReviewError = ({ reload }) => (
<Alert
variant="danger"
icon={Info}
actions={[
<Button onClick={reload}>
<FormattedMessage {...messages.reloadSubmission} />
</Button>,
]}
>
<Alert.Heading>
<FormattedMessage {...messages.loadErrorHeading} />
</Alert.Heading>
<p>
<FormattedMessage {...messages.loadErrorMessage} />
</p>
</Alert>
);
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);

View File

@@ -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(<ReviewError {...props} />);
});
test('snapshot', () => {
expect(el).toMatchSnapshot();
});
});
});
describe('mapDispatchToProps', () => {
it('loads reload from thunkActions.grading.reloadSubmission', () => {
expect(mapDispatchToProps.reload).toEqual(thunkActions.grading.loadSubmission);
});
});
});

View File

@@ -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';
/**
* <FetchErrors />
*/
export const FetchErrors = ({
isFailed,
reload,
}) => isFailed && (
<ReviewError
key="loadFailed"
actions={{
confirm: {
onClick: reload,
message: messages.reloadSubmission,
},
}}
headingMessage={messages.loadErrorHeading}
>
<FormattedMessage {...messages.loadErrorMessage} />
</ReviewError>
);
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);

View File

@@ -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(<FetchErrors {...props} />).toMatchSnapshot();
});
test('snapshot: with failure', () => {
expect(<FetchErrors {...props} isFailed={false} />).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);
});
});
});

View File

@@ -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((
<Button key="cancel" onClick={cancel.onClick} variant="outline-primary">
<FormattedMessage {...cancel.message} />
</Button>
));
}
if (confirm) {
actions.push((
<Button key="confirm" onClick={confirm.onClick}>
<FormattedMessage {...confirm.message} />
</Button>
));
}
return (
<Alert
variant="danger"
icon={Info}
actions={actions}
>
<Alert.Heading><FormattedMessage {...headingMessage} /></Alert.Heading>
<p>{children}</p>
</Alert>
);
};
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;

View File

@@ -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';
/**
* <SubmitErrors />
*/
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 (
<ReviewError
actions={{
cancel: { onClick: this.dismissError, message: messages.dismiss },
confirm: props.confirm,
}}
headingMessage={props.headingMessage}
>
<FormattedMessage {...props.contentMessage} />
</ReviewError>
);
}
}
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);

View File

@@ -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(<SubmitErrors {...props} />);
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);
});
});
});

View File

@@ -0,0 +1,15 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`FetchErrors component component snapshots snapshot: no failure 1`] = `
<FetchErrors
isFailed={false}
reload={[MockFunction]}
/>
`;
exports[`FetchErrors component component snapshots snapshot: with failure 1`] = `
<FetchErrors
isFailed={false}
reload={[MockFunction]}
/>
`;

View File

@@ -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`] = `
<ReviewError
actions={
Object {
"cancel": Object {
"message": Object {
"defaultMessage": "Dismiss",
"description": "Dismiss error action button text",
"id": "ora-grading.ReviewModal.dismiss",
},
"onClick": [MockFunction this.dismissError],
},
"confirm": undefined,
}
}
headingMessage={
Object {
"defaultMessage": "Error submitting grade",
"description": "Error Submitting Grade heading text",
"id": "ora-grading.ReviewModal.errorSubmittingGrade.Heading",
}
}
>
<FormattedMessage
defaultMessage="It looks like someone else got here first! Your grade submission has been rejected"
description="Error Submitting Grade content"
id="ora-grading.ReviewModal.errorSubmittingGrade.Content"
/>
</ReviewError>
`;
exports[`SubmitErrors component component snapshots snapshot: with network failure 1`] = `
<ReviewError
actions={
Object {
"cancel": Object {
"message": Object {
"defaultMessage": "Dismiss",
"description": "Dismiss error action button text",
"id": "ora-grading.ReviewModal.dismiss",
},
"onClick": [MockFunction this.dismissError],
},
"confirm": Object {
"message": Object {
"defaultMessage": "Resubmit grate",
"description": "Resubmit grade button after network failure",
"id": "ora-grading.ReviewModal.resubmitGrade",
},
"onClick": [MockFunction],
},
}
}
headingMessage={
Object {
"defaultMessage": "Grade not submitted",
"description": "Grade submission network error heading",
"id": "ora-grading.ReviewModal.gradeNotSubmitted.heading",
}
}
>
<FormattedMessage
defaultMessage="We're sorry, something went wrong when we tried to submit this grade. Please try again."
description="Grade submission network error message"
id="ora-grading.ReviewModal.gradeNotSubmitted.heading"
/>
</ReviewError>
`;

View File

@@ -0,0 +1,8 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ReviewErrors component component snapshot: no failure 1`] = `
<Fragment>
<FetchErrors />
<SubmitErrors />
</Fragment>
`;

View File

@@ -0,0 +1,20 @@
import React from 'react';
import FetchErrors from './FetchErrors';
import SubmitErrors from './SubmitErrors';
/**
* <ReviewErrors />
*/
export const ReviewErrors = () => (
<>
<FetchErrors />
<SubmitErrors />
</>
);
ReviewErrors.defaultProps = {
};
ReviewErrors.propTypes = {
};
export default ReviewErrors;

View File

@@ -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(<ReviewErrors />)).toMatchSnapshot();
});
});
});

View File

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

View File

@@ -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`] = `
<div
className="content-block"
>
<ReviewErrors />
</div>
`;
exports[`ReviewContent component component render tests snapshot: hide rubric 1`] = `
<div
className="content-block"
>
<ReviewErrors />
<Row
className="flex-nowrap"
>
@@ -15,10 +24,11 @@ exports[`ReviewContent component component render tests snapshot (hide rubric) 1
</div>
`;
exports[`ReviewContent component component render tests snapshot (show rubric) 1`] = `
exports[`ReviewContent component component render tests snapshot: show rubric 1`] = `
<div
className="content-block"
>
<ReviewErrors />
<Row
className="flex-nowrap"
>

View File

@@ -1,35 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ReviewError component component render tests snapshot 1`] = `
<Alert
actions={
Array [
<Button
onClick={[MockFunction]}
>
<FormattedMessage
defaultMessage="Reload submission"
description="Reload button text in case of network failure"
id="ora-grading.ReviewModal.reloadSubmission"
/>
</Button>,
]
}
variant="danger"
>
<Alert.Heading>
<FormattedMessage
defaultMessage="Error loading submissions"
description="Submission response load failure alert header"
id="ora-grading.ReviewModal.loadErrorHeading"
/>
</Alert.Heading>
<p>
<FormattedMessage
defaultMessage="An error occurred while loading this submission. Try reloading this submission."
description="Submission response load failure alert message"
id="ora-grading.ReviewModal.loadErrorMessage1"
/>
</p>
</Alert>
`;

View File

@@ -30,9 +30,7 @@ exports[`ReviewModal component component snapshots error 1`] = `
onClose={[MockFunction this.onClose]}
title="test-ora-name"
>
<React.Fragment>
<ReviewError />
</React.Fragment>
<ReviewContent />
</FullscreenModal>
`;
@@ -45,7 +43,7 @@ exports[`ReviewModal component component snapshots loading 1`] = `
onClose={[MockFunction this.onClose]}
title="test-ora-name"
>
<React.Fragment />
<ReviewContent />
<LoadingMessage
message={
Object {
@@ -67,8 +65,6 @@ exports[`ReviewModal component component snapshots success 1`] = `
onClose={[MockFunction this.onClose]}
title="test-ora-name"
>
<React.Fragment>
<ReviewContent />
</React.Fragment>
<ReviewContent />
</FullscreenModal>
`;

View File

@@ -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 (
<FullscreenModal
title={this.props.oraName}
@@ -43,19 +42,15 @@ export class ReviewModal extends React.Component {
className="review-modal"
modalBodyClassName="review-modal-body"
>
{isOpen && (
<>
{isLoaded && <ReviewContent />}
{hasError && <ReviewError />}
</>
)}
{isOpen && <ReviewContent />}
{/* even if the modal is closed, in case we want to add transitions later */}
{!(isLoaded || hasError) && <LoadingMessage message={messages.loadingResponse} />}
{!(isLoaded || errorStatus) && <LoadingMessage message={messages.loadingResponse} />}
</FullscreenModal>
);
}
}
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 = {

View File

@@ -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: (<div>some text</div>) },
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', () => {

View File

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

View File

@@ -69,4 +69,8 @@
justify-content: center;
padding: map-get($spacers, 3);
}
button.pgn__stateful-btn.pgn__stateful-btn-state-pending {
opacity: .4 !important;
}
}

View File

@@ -19,27 +19,22 @@ exports[`Rubric Container snapshot is grading 1`] = `
/>
<CriterionContainer
isGrading={true}
key="1"
orderNum={1}
/>
<CriterionContainer
isGrading={true}
key="2"
orderNum={2}
/>
<CriterionContainer
isGrading={true}
key="3"
orderNum={3}
/>
<CriterionContainer
isGrading={true}
key="4"
orderNum={4}
/>
<CriterionContainer
isGrading={true}
key="5"
orderNum={5}
/>
<hr />
@@ -48,15 +43,109 @@ exports[`Rubric Container snapshot is grading 1`] = `
<div
className="grading-rubric-footer"
>
<Button
onClick={[Function]}
>
<StatefulButton
disabledStates={
Array [
"pending",
]
}
labels={
Object {
"complete": <FormattedMessage
defaultMessage="Grade Submitted"
description="Submit Grade button text after successful submission"
id="ora-grading.Rubric.gradeSubmitted"
/>,
"default": <FormattedMessage
defaultMessage="Submit grade"
description="Submit Grade button text"
id="ora-grading.Rubric.submitGrade"
/>,
"pending": <FormattedMessage
defaultMessage="Submitting grade"
description="Submit Grade button text while submitting"
id="ora-grading.Rubric.submittingGrade"
/>,
}
}
onClick={[MockFunction this.submitGradeHandler]}
state="default"
/>
</div>
</Card>
`;
exports[`Rubric Container snapshot is grading, submit pending 1`] = `
<Card
className="grading-rubric-card"
>
<Card.Body
className="grading-rubric-body"
>
<h3>
<FormattedMessage
defaultMessage="Submit grade"
description="Submit Grade button text"
id="ora-grading.Rubric.submitGrade"
defaultMessage="Rubric"
description="Rubric interface label"
id="ora-grading.Rubric.rubric"
/>
</Button>
</h3>
<hr
className="m-2.5"
/>
<CriterionContainer
isGrading={true}
orderNum={1}
/>
<CriterionContainer
isGrading={true}
orderNum={2}
/>
<CriterionContainer
isGrading={true}
orderNum={3}
/>
<CriterionContainer
isGrading={true}
orderNum={4}
/>
<CriterionContainer
isGrading={true}
orderNum={5}
/>
<hr />
<RubricFeedback />
</Card.Body>
<div
className="grading-rubric-footer"
>
<StatefulButton
disabledStates={
Array [
"pending",
]
}
labels={
Object {
"complete": <FormattedMessage
defaultMessage="Grade Submitted"
description="Submit Grade button text after successful submission"
id="ora-grading.Rubric.gradeSubmitted"
/>,
"default": <FormattedMessage
defaultMessage="Submit grade"
description="Submit Grade button text"
id="ora-grading.Rubric.submitGrade"
/>,
"pending": <FormattedMessage
defaultMessage="Submitting grade"
description="Submit Grade button text while submitting"
id="ora-grading.Rubric.submittingGrade"
/>,
}
}
onClick={[MockFunction this.submitGradeHandler]}
state="pending"
/>
</div>
</Card>
`;
@@ -80,27 +169,22 @@ exports[`Rubric Container snapshot is not grading 1`] = `
/>
<CriterionContainer
isGrading={false}
key="1"
orderNum={1}
/>
<CriterionContainer
isGrading={false}
key="2"
orderNum={2}
/>
<CriterionContainer
isGrading={false}
key="3"
orderNum={3}
/>
<CriterionContainer
isGrading={false}
key="4"
orderNum={4}
/>
<CriterionContainer
isGrading={false}
key="5"
orderNum={5}
/>
<hr />
@@ -108,3 +192,78 @@ exports[`Rubric Container snapshot is not grading 1`] = `
</Card.Body>
</Card>
`;
exports[`Rubric Container snapshot submit completed 1`] = `
<Card
className="grading-rubric-card"
>
<Card.Body
className="grading-rubric-body"
>
<h3>
<FormattedMessage
defaultMessage="Rubric"
description="Rubric interface label"
id="ora-grading.Rubric.rubric"
/>
</h3>
<hr
className="m-2.5"
/>
<CriterionContainer
isGrading={false}
orderNum={1}
/>
<CriterionContainer
isGrading={false}
orderNum={2}
/>
<CriterionContainer
isGrading={false}
orderNum={3}
/>
<CriterionContainer
isGrading={false}
orderNum={4}
/>
<CriterionContainer
isGrading={false}
orderNum={5}
/>
<hr />
<RubricFeedback />
</Card.Body>
<div
className="grading-rubric-footer"
>
<StatefulButton
disabledStates={
Array [
"pending",
]
}
labels={
Object {
"complete": <FormattedMessage
defaultMessage="Grade Submitted"
description="Submit Grade button text after successful submission"
id="ora-grading.Rubric.gradeSubmitted"
/>,
"default": <FormattedMessage
defaultMessage="Submit grade"
description="Submit Grade button text"
id="ora-grading.Rubric.submitGrade"
/>,
"pending": <FormattedMessage
defaultMessage="Submitting grade"
description="Submit Grade button text while submitting"
id="ora-grading.Rubric.submittingGrade"
/>,
}
}
onClick={[MockFunction this.submitGradeHandler]}
state="complete"
/>
</div>
</Card>
`;

View File

@@ -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',
});
/**
* <Rubric />
*/
@@ -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 {
<hr />
<RubricFeedback />
</Card.Body>
{isGrading && (
{(isGrading || this.props.isCompleted) && (
<div className="grading-rubric-footer">
<Button onClick={this.submitGradeHandler}>
<FormattedMessage {...messages.submitGrade} />
</Button>
<StatefulButton
onClick={this.submitGradeHandler}
state={this.submitButtonState}
disabledStates={[ButtonStates.pending]}
labels={{
[ButtonStates.default]: <FormattedMessage {...messages.submitGrade} />,
[ButtonStates.pending]: <FormattedMessage {...messages.submittingGrade} />,
[ButtonStates.complete]: <FormattedMessage {...messages.gradeSubmitted} />,
}}
/>
</div>
)}
</Card>
@@ -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),
});

View File

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

View File

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

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;

View File

@@ -71,6 +71,7 @@ jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedCompon
Content: 'Popover.Content',
},
Row: 'Row',
StatefulButton: 'StatefulButton',
TextFilter: 'TextFilter',
}));