From e620bc0f59f3c1b20cda5814a1132b03eff8e440 Mon Sep 17 00:00:00 2001 From: Leangseu Kim Date: Mon, 1 Nov 2021 11:54:09 -0400 Subject: [PATCH] feat: first draft of submit test: update unit test chore: update requested change --- .../CriterionContainer/CriterionFeedback.jsx | 35 +++++++--- .../CriterionFeedback.test.jsx | 37 ++++++++-- .../CriterionContainer/RadioCriterion.jsx | 8 +++ .../RadioCriterion.test.jsx | 26 +++++++ .../CriterionFeedback.test.jsx.snap | 55 ++++++++++----- .../RadioCriterion.test.jsx.snap | 34 ++++++++++ src/containers/CriterionContainer/messages.js | 10 +++ src/containers/Rubric/RubricFeedback.jsx | 22 ++++-- src/containers/Rubric/RubricFeedback.test.jsx | 27 ++++++++ .../RubricFeedback.test.jsx.snap | 41 +++++++++++ .../Rubric/__snapshots__/index.test.jsx.snap | 4 +- src/containers/Rubric/index.jsx | 68 ++++++++++++------- src/containers/Rubric/index.test.jsx | 58 +++++++++++----- src/containers/Rubric/messages.js | 5 ++ src/data/redux/grading/reducer.js | 23 ++++++- src/data/redux/grading/selectors.js | 41 +++++++++++ src/data/redux/thunkActions/grading.js | 24 +++++++ src/data/services/lms/fakeData/ora.js | 42 +++++++++++- src/setupTest.js | 4 +- src/testUtils.js | 24 +++++-- 20 files changed, 498 insertions(+), 90 deletions(-) diff --git a/src/containers/CriterionContainer/CriterionFeedback.jsx b/src/containers/CriterionContainer/CriterionFeedback.jsx index b8b7e7e..d3f50f1 100644 --- a/src/containers/CriterionContainer/CriterionFeedback.jsx +++ b/src/containers/CriterionContainer/CriterionFeedback.jsx @@ -28,19 +28,30 @@ export class CriterionFeedback extends React.Component { translate = (msg) => this.props.intl.formatMessage(msg); render() { - const { config, isGrading, value } = this.props; + const { + config, isGrading, value, valueIsInvalid, + } = this.props; if (config === feedbackRequirement.disabled) { return null; } return ( - + + + {valueIsInvalid && ( + + {this.translate(messages.criterionFeedbackError)} + + )} + ); } } @@ -58,15 +69,19 @@ CriterionFeedback.propTypes = { config: PropTypes.string.isRequired, setValue: PropTypes.func.isRequired, value: PropTypes.string, + valueIsInvalid: PropTypes.bool.isRequired, }; export const mapStateToProps = (state, { orderNum }) => ({ config: selectors.app.rubric.criterionFeedbackConfig(state, { orderNum }), value: selectors.grading.selected.criterionFeedback(state, { orderNum }), + valueIsInvalid: selectors.grading.selected.criterionFeedbackIsInvalid(state, { orderNum }), }); export const mapDispatchToProps = { setValue: actions.grading.setCriterionFeedback, }; -export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(CriterionFeedback)); +export default injectIntl( + connect(mapStateToProps, mapDispatchToProps)(CriterionFeedback), +); diff --git a/src/containers/CriterionContainer/CriterionFeedback.test.jsx b/src/containers/CriterionContainer/CriterionFeedback.test.jsx index b1c3f63..3b4c537 100644 --- a/src/containers/CriterionContainer/CriterionFeedback.test.jsx +++ b/src/containers/CriterionContainer/CriterionFeedback.test.jsx @@ -25,7 +25,7 @@ jest.mock('data/redux/grading/selectors', () => ({ criterionFeedback: jest.fn((...args) => ({ selectedCriterionFeedback: args, })), - gradeStatus: jest.fn((...args) => ({ selectedGradeStatus: args })), + criterionFeedbackIsInvalid: jest.fn((...args) => ({ selectedFeedbackIsInvalid: args })), }, })); @@ -38,6 +38,7 @@ describe('Criterion Feedback', () => { value: 'some value', gradeStatus: gradeStatuses.ungraded, setValue: jest.fn().mockName('this.props.setValue'), + valueIsInvalid: false, }; let el; beforeEach(() => { @@ -57,6 +58,13 @@ describe('Criterion Feedback', () => { expect(el.instance().render()).toMatchSnapshot(); }); + test('feedback value is invalid', () => { + el.setProps({ + valueIsInvalid: true, + }); + expect(el.instance().render()).toMatchSnapshot(); + }); + test('is configure to disabled', () => { el.setProps({ config: feedbackRequirement.disabled, @@ -69,16 +77,28 @@ describe('Criterion Feedback', () => { describe('render', () => { test('is grading (the feedback input is not disabled)', () => { expect(el.isEmptyRender()).toEqual(false); - expect(el.prop('value')).toEqual(props.value); - expect(el.prop('disabled')).toEqual(false); + expect(el.instance().props.value).toEqual(props.value); + const controlEl = el.find('.feedback-input'); + expect(controlEl.prop('disabled')).toEqual(false); + expect(controlEl.prop('value')).toEqual(props.value); }); test('is graded (the input is disabled)', () => { el.setProps({ isGrading: false, gradeStatus: gradeStatuses.graded, }); - expect(el.prop('value')).toEqual(props.value); - expect(el.prop('disabled')).toEqual(true); + expect(el.instance().props.value).toEqual(props.value); + const controlEl = el.find('.feedback-input'); + expect(controlEl.prop('disabled')).toEqual(true); + expect(controlEl.prop('value')).toEqual(props.value); + }); + test('is having invalid feedback (feedback get render)', () => { + el.setProps({ + valueIsInvalid: true, + }); + const feedbackErrorEl = el.find('.feedback-error-msg'); + expect(el.instance().props.valueIsInvalid).toEqual(true); + expect(feedbackErrorEl).toBeDefined(); }); test('is configure to disabled (the input does not get render)', () => { el.setProps({ @@ -108,18 +128,21 @@ describe('Criterion Feedback', () => { beforeEach(() => { mapped = mapStateToProps(testState, ownProps); }); - test('selectors.app.rubric.criterionFeedbackConfig', () => { expect(mapped.config).toEqual( selectors.app.rubric.criterionFeedbackConfig(testState, ownProps), ); }); - test('selector.grading.selected.criterionFeedback', () => { expect(mapped.value).toEqual( selectors.grading.selected.criterionFeedback(testState, ownProps), ); }); + test('selector.grading.selected.criterionFeedbackIsInvalid', () => { + expect(mapped.valueIsInvalid).toEqual( + selectors.grading.selected.criterionFeedbackIsInvalid(testState, ownProps), + ); + }); }); describe('mapDispatchToProps', () => { diff --git a/src/containers/CriterionContainer/RadioCriterion.jsx b/src/containers/CriterionContainer/RadioCriterion.jsx index 746a226..43db992 100644 --- a/src/containers/CriterionContainer/RadioCriterion.jsx +++ b/src/containers/CriterionContainer/RadioCriterion.jsx @@ -30,6 +30,7 @@ export class RadioCriterion extends React.Component { data, intl, isGrading, + radioIsInvalid, } = this.props; return ( <> @@ -46,6 +47,11 @@ export class RadioCriterion extends React.Component { {option.label} ))} + {radioIsInvalid && ( + + {intl.formatMessage(messages.rubricSelectedError)} + + )} ); @@ -84,11 +90,13 @@ RadioCriterion.propTypes = { feedback: PropTypes.string, }), setCriterionOption: PropTypes.func.isRequired, + radioIsInvalid: PropTypes.bool.isRequired, }; export const mapStateToProps = (state, { orderNum }) => ({ config: selectors.app.rubric.criterionConfig(state, { orderNum }), data: selectors.grading.selected.criterionGradeData(state, { orderNum }), + radioIsInvalid: selectors.grading.selected.criterionSelectedIsInvalid(state, { orderNum }), }); export const mapDispatchToProps = { diff --git a/src/containers/CriterionContainer/RadioCriterion.test.jsx b/src/containers/CriterionContainer/RadioCriterion.test.jsx index eee58ad..13abfad 100644 --- a/src/containers/CriterionContainer/RadioCriterion.test.jsx +++ b/src/containers/CriterionContainer/RadioCriterion.test.jsx @@ -21,6 +21,9 @@ jest.mock('data/redux/grading/selectors', () => ({ criterionGradeData: jest.fn((...args) => ({ selectedCriterionGradeData: args, })), + criterionSelectedIsInvalid: jest.fn((...args) => ({ + selectedCriterionSelectedIsInvalid: args, + })), }, })); @@ -55,6 +58,7 @@ describe('Radio Criterion Container', () => { feedback: 'data feedback', }, setCriterionOption: jest.fn().mockName('this.props.setCriterionOption'), + radioIsInvalid: false, }; let el; @@ -73,6 +77,13 @@ describe('Radio Criterion Container', () => { }); expect(el.instance().render()).toMatchSnapshot(); }); + + test('radio contain invalid response', () => { + el.setProps({ + radioIsInvalid: true, + }); + expect(el.instance().render()).toMatchSnapshot(); + }); }); describe('component', () => { @@ -93,6 +104,16 @@ describe('Radio Criterion Container', () => { expect(optionsEl.length).toEqual(props.config.options.length); optionsEl.forEach((optionEl) => expect(optionEl.prop('disabled')).toEqual(true)); }); + + test('radio contain invalid response (error response get render)', () => { + el.setProps({ + radioIsInvalid: true, + }); + expect(el.isEmptyRender()).toEqual(false); + const radioErrorEl = el.find('.feedback-error-msg'); + expect(el.instance().props.radioIsInvalid).toEqual(true); + expect(radioErrorEl).toBeDefined(); + }); }); describe('behavior', () => { @@ -126,6 +147,11 @@ describe('Radio Criterion Container', () => { selectors.grading.selected.criterionGradeData(testState, ownProps), ); }); + test('selectors.grading.selected.criterionSelectedIsInvalid', () => { + expect(mapped.radioIsInvalid).toEqual( + selectors.grading.selected.criterionSelectedIsInvalid(testState, ownProps), + ); + }); }); describe('mapDispatchToProps', () => { diff --git a/src/containers/CriterionContainer/__snapshots__/CriterionFeedback.test.jsx.snap b/src/containers/CriterionContainer/__snapshots__/CriterionFeedback.test.jsx.snap index 4ba98df..30332df 100644 --- a/src/containers/CriterionContainer/__snapshots__/CriterionFeedback.test.jsx.snap +++ b/src/containers/CriterionContainer/__snapshots__/CriterionFeedback.test.jsx.snap @@ -1,25 +1,48 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Criterion Feedback snapshot feedback value is invalid 1`] = ` + + + + The feedback is required + + +`; + exports[`Criterion Feedback snapshot is configure to disabled 1`] = `null`; exports[`Criterion Feedback snapshot is graded 1`] = ` - + + + `; exports[`Criterion Feedback snapshot is grading 1`] = ` - + + + `; diff --git a/src/containers/CriterionContainer/__snapshots__/RadioCriterion.test.jsx.snap b/src/containers/CriterionContainer/__snapshots__/RadioCriterion.test.jsx.snap index b16e6c9..1d6ad07 100644 --- a/src/containers/CriterionContainer/__snapshots__/RadioCriterion.test.jsx.snap +++ b/src/containers/CriterionContainer/__snapshots__/RadioCriterion.test.jsx.snap @@ -55,3 +55,37 @@ exports[`Radio Criterion Container snapshot is not grading 1`] = ` `; + +exports[`Radio Criterion Container snapshot radio contain invalid response 1`] = ` + + + + this label + + + this label 2 + + + Rubric selection is required + + + +`; diff --git a/src/containers/CriterionContainer/messages.js b/src/containers/CriterionContainer/messages.js index 22263cc..d44e7aa 100644 --- a/src/containers/CriterionContainer/messages.js +++ b/src/containers/CriterionContainer/messages.js @@ -16,6 +16,16 @@ const messages = defineMessages({ defaultMessage: '{points} points', description: 'criterion option point value display', }, + rubricSelectedError: { + id: 'ora-grading.RadioCriterion.rubricSelectedError', + defaultMessage: 'Rubric selection is required', + description: 'Error message when rubric radio did not get selected', + }, + criterionFeedbackError: { + id: 'ora-grading.CriterionFeedback.criterionFeedbackError', + defaultMessage: 'The feedback is required', + description: 'Error message when feedback is required', + }, }); export default messages; diff --git a/src/containers/Rubric/RubricFeedback.jsx b/src/containers/Rubric/RubricFeedback.jsx index b580934..dc351b4 100644 --- a/src/containers/Rubric/RubricFeedback.jsx +++ b/src/containers/Rubric/RubricFeedback.jsx @@ -3,7 +3,11 @@ import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { Form } from '@edx/paragon'; -import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { + FormattedMessage, + injectIntl, + intlShape, +} from '@edx/frontend-platform/i18n'; import { feedbackRequirement } from 'data/services/lms/constants'; import { actions, selectors } from 'data/redux'; @@ -32,10 +36,7 @@ export class RubricFeedback extends React.Component { render() { const { - isGrading, - value, - feedbackPrompt, - config, + isGrading, value, feedbackPrompt, config, valueIsInvalid, } = this.props; if (config === feedbackRequirement.disabled) { @@ -59,6 +60,11 @@ export class RubricFeedback extends React.Component { onChange={this.onChange} disabled={!isGrading} /> + {valueIsInvalid && ( + + + + )} ); } @@ -76,12 +82,14 @@ RubricFeedback.propTypes = { isGrading: PropTypes.bool.isRequired, setValue: PropTypes.func.isRequired, value: PropTypes.string, + valueIsInvalid: PropTypes.bool.isRequired, feedbackPrompt: PropTypes.string.isRequired, }; export const mapStateToProps = (state) => ({ isGrading: selectors.app.isGrading(state), value: selectors.grading.selected.overallFeedback(state), + valueIsInvalid: selectors.grading.selected.overallFeedbackIsInvalid(state), config: selectors.app.rubric.feedbackConfig(state), feedbackPrompt: selectors.app.rubric.feedbackPrompt(state), }); @@ -90,4 +98,6 @@ export const mapDispatchToProps = { setValue: actions.grading.setRubricFeedback, }; -export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(RubricFeedback)); +export default injectIntl( + connect(mapStateToProps, mapDispatchToProps)(RubricFeedback), +); diff --git a/src/containers/Rubric/RubricFeedback.test.jsx b/src/containers/Rubric/RubricFeedback.test.jsx index c7feddc..0e6a656 100644 --- a/src/containers/Rubric/RubricFeedback.test.jsx +++ b/src/containers/Rubric/RubricFeedback.test.jsx @@ -33,6 +33,9 @@ jest.mock('data/redux/grading/selectors', () => ({ overallFeedback: jest.fn((...args) => ({ selectedOverallFeedback: args, })), + overallFeedbackIsInvalid: jest.fn((...args) => ({ + selectedOverallFeedbackIsInvalid: args, + })), }, })); @@ -42,6 +45,7 @@ describe('Rubric Feedback component', () => { config: 'config stirng', isGrading: true, value: 'some value', + valueIsInvalid: false, feedbackPrompt: 'feedback prompt', gradeStatus: gradeStatuses.ungraded, setValue: jest.fn().mockName('this.props.setValue'), @@ -64,6 +68,13 @@ describe('Rubric Feedback component', () => { expect(el.instance().render()).toMatchSnapshot(); }); + test('feedback value is invalid', () => { + el.setProps({ + valueIsInvalid: true, + }); + expect(el.instance().render()).toMatchSnapshot(); + }); + test('is configure to disabled', () => { el.setProps({ config: feedbackRequirement.disabled, @@ -91,6 +102,16 @@ describe('Rubric Feedback component', () => { expect(input.prop('disabled')).toEqual(true); expect(input.prop('value')).toEqual(props.value); }); + + test('is having invalid feedback (feedback get render)', () => { + el.setProps({ + valueIsInvalid: true, + }); + const feedbackErrorEl = el.find('.feedback-error-msg'); + expect(el.instance().props.valueIsInvalid).toEqual(true); + expect(feedbackErrorEl).toBeDefined(); + }); + test('is configure to disabled (this input does not get render)', () => { el.setProps({ config: feedbackRequirement.disabled, @@ -133,6 +154,12 @@ describe('Rubric Feedback component', () => { ); }); + test('selectors.grading.selected.overallFeedbackIsInvalid', () => { + expect(mapped.valueIsInvalid).toEqual( + selectors.grading.selected.overallFeedbackIsInvalid(testState), + ); + }); + test('selectors.app.rubric.feedbackPrompt', () => { expect(mapped.feedbackPrompt).toEqual( selectors.app.rubric.feedbackPrompt(testState), diff --git a/src/containers/Rubric/__snapshots__/RubricFeedback.test.jsx.snap b/src/containers/Rubric/__snapshots__/RubricFeedback.test.jsx.snap index b25f2ee..f698267 100644 --- a/src/containers/Rubric/__snapshots__/RubricFeedback.test.jsx.snap +++ b/src/containers/Rubric/__snapshots__/RubricFeedback.test.jsx.snap @@ -1,5 +1,46 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Rubric Feedback component snapshot feedback value is invalid 1`] = ` + + + + + + +
+ feedback prompt +
+
+
+ + + + +
+`; + exports[`Rubric Feedback component snapshot is configure to disabled 1`] = `null`; exports[`Rubric Feedback component snapshot is graded 1`] = ` diff --git a/src/containers/Rubric/__snapshots__/index.test.jsx.snap b/src/containers/Rubric/__snapshots__/index.test.jsx.snap index 7fc53d4..db589fc 100644 --- a/src/containers/Rubric/__snapshots__/index.test.jsx.snap +++ b/src/containers/Rubric/__snapshots__/index.test.jsx.snap @@ -48,7 +48,9 @@ exports[`Rubric Container snapshot is grading 1`] = `
- -
- )} - -); +export class Rubric extends React.Component { + constructor(props) { + super(props); + + this.submitGradeHandler = this.submitGradeHandler.bind(this); + } + + submitGradeHandler() { + this.props.submitGrade(); + } + + render() { + const { isGrading, criteriaIndices } = this.props; + return ( + + +

+
+ {criteriaIndices.map((index) => ( + + ))} +
+ +
+ {isGrading && ( +
+ +
+ )} +
+ ); + } +} Rubric.defaultProps = { criteriaIndices: [], }; Rubric.propTypes = { isGrading: PropTypes.bool.isRequired, criteriaIndices: PropTypes.arrayOf(PropTypes.number), + submitGrade: PropTypes.func.isRequired, }; export const mapStateToProps = (state) => ({ @@ -51,6 +69,8 @@ export const mapStateToProps = (state) => ({ criteriaIndices: selectors.app.rubric.criteriaIndices(state), }); -export const mapDispatchToProps = {}; +export const mapDispatchToProps = { + submitGrade: thunkActions.grading.submitGrade, +}; export default connect(mapStateToProps, mapDispatchToProps)(Rubric); diff --git a/src/containers/Rubric/index.test.jsx b/src/containers/Rubric/index.test.jsx index b8ad5d3..7496ae1 100644 --- a/src/containers/Rubric/index.test.jsx +++ b/src/containers/Rubric/index.test.jsx @@ -1,8 +1,8 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { selectors } from 'data/redux'; -import { Rubric, mapStateToProps } from '.'; +import { selectors, thunkActions } from 'data/redux'; +import { Rubric, mapStateToProps, mapDispatchToProps } from '.'; jest.mock('containers/CriterionContainer', () => 'CriterionContainer'); jest.mock('./RubricFeedback', () => 'RubricFeedback'); @@ -20,10 +20,14 @@ describe('Rubric Container', () => { const props = { isGrading: true, criteriaIndices: [1, 2, 3, 4, 5], + submitGrade: jest.fn().mockName('this.props.submitGrade'), }; let el; beforeEach(() => { el = shallow(); + el.instance().submitGradeHandler = jest + .fn() + .mockName('this.submitGradeHandler'); }); describe('snapshot', () => { test('is grading', () => { @@ -38,24 +42,34 @@ describe('Rubric Container', () => { }); describe('component', () => { - test('is grading (grading footer present)', () => { - expect(el.find('.grading-rubric-footer').length).toEqual(1); - const containers = el.find('CriterionContainer'); - expect(containers.length).toEqual(props.criteriaIndices.length); - containers.forEach((container, i) => { - expect(container.key()).toEqual(String(props.criteriaIndices[i])); + describe('render', () => { + test('is grading (grading footer present)', () => { + expect(el.find('.grading-rubric-footer').length).toEqual(1); + const containers = el.find('CriterionContainer'); + expect(containers.length).toEqual(props.criteriaIndices.length); + containers.forEach((container, i) => { + expect(container.key()).toEqual(String(props.criteriaIndices[i])); + }); + }); + + test('is not grading (no grading footer)', () => { + el.setProps({ + isGrading: false, + }); + expect(el.find('.grading-rubric-footer').length).toEqual(0); + const containers = el.find('CriterionContainer'); + expect(containers.length).toEqual(props.criteriaIndices.length); + containers.forEach((container, i) => { + expect(container.key()).toEqual(String(props.criteriaIndices[i])); + }); }); }); - test('is not grading (no grading footer)', () => { - el.setProps({ - isGrading: false, - }); - expect(el.find('.grading-rubric-footer').length).toEqual(0); - const containers = el.find('CriterionContainer'); - expect(containers.length).toEqual(props.criteriaIndices.length); - containers.forEach((container, i) => { - expect(container.key()).toEqual(String(props.criteriaIndices[i])); + describe('behavior', () => { + test('submitGrade', () => { + el = shallow(); + el.instance().submitGradeHandler(); + expect(props.submitGrade).toBeCalledTimes(1); }); }); }); @@ -76,4 +90,14 @@ describe('Rubric Container', () => { ); }); }); + + describe('mapDispatchToProps', () => { + beforeEach(() => {}); + + test('maps thunkActions.grading.submitGrade to submitGrade prop', () => { + expect(mapDispatchToProps.submitGrade).toEqual( + thunkActions.grading.submitGrade, + ); + }); + }); }); diff --git a/src/containers/Rubric/messages.js b/src/containers/Rubric/messages.js index 9b56321..7c11381 100644 --- a/src/containers/Rubric/messages.js +++ b/src/containers/Rubric/messages.js @@ -26,6 +26,11 @@ const messages = defineMessages({ defaultMessage: 'Comments', description: 'Rubric comments display label', }, + overallFeedbackError: { + id: 'ora-grading.RubricFeedback.error', + defaultMessage: 'The overall feedback is required', + description: 'Error message when feedback input is required', + }, }); export default messages; diff --git a/src/data/redux/grading/reducer.js b/src/data/redux/grading/reducer.js index 60cec85..5d42a7b 100644 --- a/src/data/redux/grading/reducer.js +++ b/src/data/redux/grading/reducer.js @@ -2,7 +2,7 @@ import { createSlice } from '@reduxjs/toolkit'; import { StrictDict } from 'utils'; -import { lockStatuses } from 'data/services/lms/constants'; +import { lockStatuses, feedbackRequirement, gradingStatuses } from 'data/services/lms/constants'; const initialState = { selected: [ @@ -145,6 +145,27 @@ const grading = createSlice({ setCriterionFeedback: (state, { payload: { orderNum, value } }) => ( updateCriterion(state, orderNum, { feedback: value }) ), + validateGrade: (state, { payload: { rubricConfig, gradeData } }) => ( + updateGradeData(state, { + overallFeedbackIsInvalid: rubricConfig.feedback === feedbackRequirement.required + && gradeData.overallFeedback.length === 0, + criteria: rubricConfig.criteria.map((criterion, index) => ({ + ...gradeData.criteria[index], + feedbackIsInvalid: criterion.feedback === feedbackRequirement.required + && gradeData.criteria[index].feedback.length === 0, + selectedIsInvalid: gradeData.criteria[index].selectedOption.length === 0, + })), + }) + ), + completeGrading: (state) => ({ + ...state, + current: { + ...state.current, + gradeData: state.gradeData, + gradeStatus: gradingStatuses.graded, + lockStatus: lockStatuses.unlocked, + }, + }), clearGrade: (state) => { const gradeData = { ...state.gradeData }; delete gradeData[state.current.submissionId]; diff --git a/src/data/redux/grading/selectors.js b/src/data/redux/grading/selectors.js index 3ac48ac..16ff9a0 100644 --- a/src/data/redux/grading/selectors.js +++ b/src/data/redux/grading/selectors.js @@ -147,6 +147,27 @@ selected.overallFeedback = createSelector( (data) => (data ? data.overallFeedback : ''), ); +/** + * Returns rubric-level feedback is invalid + * @return {bool} rubric-level feedback is invalid + */ +selected.overallFeedbackIsInvalid = createSelector( + [module.selected.gradeData], + (data) => data?.overallFeedbackIsInvalid === true, +); + +/** + * Return true is the rubric is valid for submission + * @returns {bool} the rubric is invalid + */ +selected.isValidForSubmit = createSelector( + [module.selected.gradeData], + (data) => !( + data.overallFeedbackIsInvalid + || data.criteria.some(criterion => criterion.selectedIsInvalid || criterion.feedbackIsInvalid) + ), +); + /** * Returns the grade data for the given criterion of the current * selection @@ -169,6 +190,26 @@ selected.criterionFeedback = (state, { orderNum }) => { return data ? data.feedback : ''; }; +/** + * return criterion feedback is invalid + * @param {number} orderNum - criterion index + * @returns {bool} - criterion feedback is invalid + */ +selected.criterionFeedbackIsInvalid = (state, { orderNum }) => { + const data = module.selected.criterionGradeData(state, { orderNum }); + return data?.feedbackIsInvalid === true; +}; + +/** + * return criterion selection is invalid + * @param {number} orderNum - criterion index + * @returns {bool} - criterion feedback is invalid + */ +selected.criterionSelectedIsInvalid = (state, { orderNum }) => { + const data = module.selected.criterionGradeData(state, { orderNum }); + return data?.selectedIsInvalid === true; +}; + /************************************************* * Next/Previous Submission Selectors *************************************************/ diff --git a/src/data/redux/thunkActions/grading.js b/src/data/redux/thunkActions/grading.js index 18d8c53..092a85d 100644 --- a/src/data/redux/thunkActions/grading.js +++ b/src/data/redux/thunkActions/grading.js @@ -163,6 +163,29 @@ export const stopGrading = () => (dispatch) => { dispatch(actions.app.setGrading(false)); }; +export const submitGrade = () => (dispatch, getState) => { + const gradeData = selectors.grading.selected.gradeData(getState()); + const submissionId = selectors.grading.selected.submissionId(getState()); + dispatch(actions.grading.validateGrade({ + rubricConfig: selectors.app.rubric.config(getState()), + gradeData, + })); + + if (selectors.grading.selected.isValidForSubmit(getState())) { + dispatch(requests.submitGrade({ + submissionId, + gradeData, + onSuccess: () => { + dispatch(actions.grading.completeGrading()); + dispatch(actions.app.setGrading(false)); + }, + onFailure: () => { + // on failure action + }, + })); + } +}; + export default StrictDict({ loadSelectionForReview, loadNext, @@ -170,4 +193,5 @@ export default StrictDict({ startGrading, cancelGrading, stopGrading, + submitGrade, }); diff --git a/src/data/services/lms/fakeData/ora.js b/src/data/services/lms/fakeData/ora.js index e0ba114..2dd0df0 100644 --- a/src/data/services/lms/fakeData/ora.js +++ b/src/data/services/lms/fakeData/ora.js @@ -11,7 +11,7 @@ export const name = 'This is the Name of the ORA'; export const type = 'individual'; const rubricConfig = { - feedback: 'optional', + feedback: 'required', feedbackPrompt: 'Grader-facing prompt for submission-level feedback', criteria: [ { @@ -54,6 +54,46 @@ const rubricConfig = { }, ], }, + { + name: 'second criterion', + orderNum: 1, + prompt: 'A criterion prompt', + feedback: 'required', + options: [ + { + orderNum: 0, + name: 'poor', + label: 'Poor', + explanation: 'Includes little information with few or no details or unrelated details. Unsuccessful in attempts to explore any facets of the topic.', + points: 0, + feedback: 'optional', + }, + { + orderNum: 1, + name: 'fair', + label: 'Fair', + explanation: 'Includes little information and few or no details. Explores only one or two facets of the topic.', + points: 1, + feedback: 'optional', + }, + { + orderNum: 2, + name: 'good', + label: 'Good', + explanation: 'Includes sufficient information and supporting details. (Details may not be fully developed; ideas may be listed.) Explores some facets of the topic.', + points: 2, + feedback: 'optional', + }, + { + orderNum: 3, + name: 'excellent', + label: 'Excellent', + explanation: 'Includes in-depth information and exceptional supporting details that are fully developed. Explores all facets of the topic', + points: 3, + feedback: 'optional', + }, + ], + }, ], }; diff --git a/src/setupTest.js b/src/setupTest.js index 14d9729..de3d8b3 100755 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -48,7 +48,9 @@ jest.mock('@edx/paragon', () => jest.requireActual('testUtils').mockNestedCompon Toggle: 'Dropdown.Toggle', }, Form: { - Control: 'Form.Control', + Control: { + Feedback: 'Form.Control.Feedback', + }, Group: 'Form.Group', Label: 'Form.Label', Radio: 'Form.Radio', diff --git a/src/testUtils.js b/src/testUtils.js index 59cb853..b1f346b 100644 --- a/src/testUtils.js +++ b/src/testUtils.js @@ -3,8 +3,10 @@ */ export const formatMessage = (msg, values) => { let message = msg.defaultMessage; - if (values === undefined) { return message; } - Object.keys(values).forEach(key => { + if (values === undefined) { + return message; + } + Object.keys(values).forEach((key) => { // eslint-disable-next-line message = message.replace(`{${key}}`, values[key]); }); @@ -20,14 +22,21 @@ export const formatMessage = (msg, values) => { * @return {func} - mock component with nested children. * * usage: - * mockNestedComponent('Card', { Body: 'Card.Body', ... }); + * mockNestedComponent('Card', { Body: 'Card.Body', Form: { Control: { Feedback: 'Form.Control.Feedback' }}... }); * mockNestedComponent('IconButton', 'IconButton'); */ export const mockNestedComponent = (name, contents) => { - if (typeof contents !== 'object') { return contents; } + if (typeof contents !== 'object') { + return contents; + } const fn = () => name; Object.defineProperty(fn, 'name', { value: name }); - Object.keys(contents).forEach(nestedName => { fn[nestedName] = contents[nestedName]; }); + Object.keys(contents).forEach((nestedName) => { + const value = contents[nestedName]; + fn[nestedName] = typeof value !== 'object' + ? value + : mockNestedComponent(`${name}.${nestedName}`, value); + }); return fn; }; @@ -42,6 +51,9 @@ export const mockNestedComponent = (name, contents) => { * }) */ export const mockNestedComponents = (mapping) => Object.entries(mapping).reduce( - (obj, [name, value]) => ({ ...obj, [name]: mockNestedComponent(name, value) }), + (obj, [name, value]) => ({ + ...obj, + [name]: mockNestedComponent(name, value), + }), {}, );