From 02b29830cf1dae875fa64f2b07b414ad57a9ae77 Mon Sep 17 00:00:00 2001 From: leangseu-edx <83240113+leangseu-edx@users.noreply.github.com> Date: Fri, 8 Oct 2021 14:41:09 -0400 Subject: [PATCH] Rubric feedback (#9) * add feedbackPrompt to selector * create info popover component and radio criterion * update criterion container to use new component instead * remove unused components * update rubric feedback to use gradeState * update to test for bound method * update info popover and the test * implement review criterion * remove gradeStatus from rubric feedback and criterion feedback * use gradeStatus to show review criterion * update selector testing naming --- src/components/InfoPopover.jsx | 45 +++++ src/components/InfoPopover.test.jsx | 34 ++++ .../__snapshots__/InfoPopover.test.jsx.snap | 27 +++ .../CriterionContainer/CriterionFeedback.jsx | 34 ++-- .../CriterionFeedback.test.jsx | 142 ++++++++++++++++ .../CriterionContainer/OptionInfoPopover.jsx | 55 ------ ...radingCriterion.jsx => RadioCriterion.jsx} | 39 ++--- .../RadioCriterion.test.jsx | 153 +++++++++++++++++ .../CriterionContainer/ReviewCriterion.jsx | 35 ++-- .../ReviewCriterion.test.jsx | 110 ++++++++++++ .../CriterionFeedback.test.jsx.snap | 25 +++ .../RadioCriterion.test.jsx.snap | 57 +++++++ .../ReviewCriterion.test.jsx.snap | 42 +++++ .../__snapshots__/index.test.jsx.snap | 144 ++++++++++++++++ src/containers/CriterionContainer/index.jsx | 73 ++++---- .../CriterionContainer/index.test.jsx | 144 ++++++++++++++++ src/containers/Rubric/RubricFeedback.jsx | 27 ++- src/containers/Rubric/RubricFeedback.test.jsx | 160 ++++++++++++++++++ .../RubricFeedback.test.jsx.snap | 57 +++++++ .../Rubric/__snapshots__/index.test.jsx.snap | 92 ++++++++++ src/containers/Rubric/index.jsx | 25 ++- src/containers/Rubric/index.test.jsx | 91 ++++++++++ src/data/selectors/app.js | 6 + src/data/services/lms/fakeData/ora.js | 1 + 24 files changed, 1443 insertions(+), 175 deletions(-) create mode 100644 src/components/InfoPopover.jsx create mode 100644 src/components/InfoPopover.test.jsx create mode 100644 src/components/__snapshots__/InfoPopover.test.jsx.snap create mode 100644 src/containers/CriterionContainer/CriterionFeedback.test.jsx delete mode 100644 src/containers/CriterionContainer/OptionInfoPopover.jsx rename src/containers/CriterionContainer/{GradingCriterion.jsx => RadioCriterion.jsx} (68%) create mode 100644 src/containers/CriterionContainer/RadioCriterion.test.jsx create mode 100644 src/containers/CriterionContainer/ReviewCriterion.test.jsx create mode 100644 src/containers/CriterionContainer/__snapshots__/CriterionFeedback.test.jsx.snap create mode 100644 src/containers/CriterionContainer/__snapshots__/RadioCriterion.test.jsx.snap create mode 100644 src/containers/CriterionContainer/__snapshots__/ReviewCriterion.test.jsx.snap create mode 100644 src/containers/CriterionContainer/__snapshots__/index.test.jsx.snap create mode 100644 src/containers/CriterionContainer/index.test.jsx create mode 100644 src/containers/Rubric/RubricFeedback.test.jsx create mode 100644 src/containers/Rubric/__snapshots__/RubricFeedback.test.jsx.snap create mode 100644 src/containers/Rubric/__snapshots__/index.test.jsx.snap create mode 100644 src/containers/Rubric/index.test.jsx diff --git a/src/components/InfoPopover.jsx b/src/components/InfoPopover.jsx new file mode 100644 index 0000000..9a6e4c1 --- /dev/null +++ b/src/components/InfoPopover.jsx @@ -0,0 +1,45 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { + OverlayTrigger, + Popover, + Icon, + IconButton, + PopoverContent, +} from '@edx/paragon'; +import { InfoOutline } from '@edx/paragon/icons'; + +/** + * + */ +export const InfoPopover = ({ children }) => ( + + {children} + + } + > + + +); + +InfoPopover.defaultProps = {}; + +InfoPopover.propTypes = { + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node, + ]).isRequired, +}; + +export default InfoPopover; diff --git a/src/components/InfoPopover.test.jsx b/src/components/InfoPopover.test.jsx new file mode 100644 index 0000000..02b6958 --- /dev/null +++ b/src/components/InfoPopover.test.jsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import InfoPopover from './InfoPopover'; + +jest.mock('@edx/paragon', () => ({ + OverlayTrigger: () => 'OverlayTrigger', + Icon: jest.fn().mockName('Icon'), + IconButton: () => 'IconButton', + Popover: () => 'Popover', + PopoverContent: () => 'PopoverContent', +})); + +jest.mock('@edx/paragon/icons', () => ({ + InfoOutline: jest.fn().mockName('icons.InfoOutline'), +})); + +describe('Info Popover Component', () => { + const child =
Children component
; + test('snapshot', () => { + expect(shallow({child})).toMatchSnapshot(); + }); + + describe('Component', () => { + let el; + beforeEach(() => { + el = shallow({child}); + }); + test('Test component render', () => { + expect(el.length).toEqual(1); + expect(el.find('.criteria-help-icon').length).toEqual(1); + }); + }); +}); diff --git a/src/components/__snapshots__/InfoPopover.test.jsx.snap b/src/components/__snapshots__/InfoPopover.test.jsx.snap new file mode 100644 index 0000000..d1ae88f --- /dev/null +++ b/src/components/__snapshots__/InfoPopover.test.jsx.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Info Popover Component snapshot 1`] = ` + + +
+ Children component +
+
+ + } + placement="auto" + trigger="focus" +> + +
+`; diff --git a/src/containers/CriterionContainer/CriterionFeedback.jsx b/src/containers/CriterionContainer/CriterionFeedback.jsx index 375eb77..2deb4b9 100644 --- a/src/containers/CriterionContainer/CriterionFeedback.jsx +++ b/src/containers/CriterionContainer/CriterionFeedback.jsx @@ -18,26 +18,27 @@ export class CriterionFeedback extends React.Component { } onChange(event) { - this.props.setValue({ value: event.target.value, orderNum: this.props.orderNum }); + this.props.setValue({ + value: event.target.value, + orderNum: this.props.orderNum, + }); } render() { - if (this.props.config === feedbackRequirement.disabled) { + const { config, isGrading, value } = this.props; + if (config === feedbackRequirement.disabled) { return null; } - return this.props.isGrading - ? ( - - ) - : ( - {this.props.value} - ); + return ( + + ); } } @@ -47,15 +48,14 @@ CriterionFeedback.defaultProps = { CriterionFeedback.propTypes = { orderNum: PropTypes.number.isRequired, + isGrading: PropTypes.bool.isRequired, // redux config: PropTypes.string.isRequired, - isGrading: PropTypes.bool.isRequired, setValue: PropTypes.func.isRequired, value: PropTypes.string, }; export const mapStateToProps = (state, { orderNum }) => ({ - isGrading: selectors.app.isGrading(state), config: selectors.app.rubric.criterionFeedbackConfig(state, { orderNum }), value: selectors.grading.selected.criterionFeedback(state, { orderNum }), }); diff --git a/src/containers/CriterionContainer/CriterionFeedback.test.jsx b/src/containers/CriterionContainer/CriterionFeedback.test.jsx new file mode 100644 index 0000000..820ebfe --- /dev/null +++ b/src/containers/CriterionContainer/CriterionFeedback.test.jsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import actions from 'data/actions'; +import selectors from 'data/selectors'; +import { + CriterionFeedback, + mapStateToProps, + mapDispatchToProps, +} from './CriterionFeedback'; +import { + feedbackRequirement, + gradeStatuses, +} from 'data/services/lms/constants'; + +jest.mock('@edx/paragon', () => ({ + Form: { + Control: () => 'Form.Control', + }, +})); + +jest.mock('data/selectors', () => ({ + __esModule: true, + default: { + app: { + rubric: { + criterionFeedbackConfig: jest.fn((...args) => ({ + rubricCriterionFeedbackConfig: args, + })), + }, + }, + grading: { + selected: { + criterionFeedback: jest.fn((...args) => ({ + selectedCriterionFeedback: args, + })), + gradeStatus: jest.fn((...args) => ({ selectedGradeStatus: args })), + }, + }, + }, +})); + +describe('Criterion Feedback', () => { + const props = { + orderNum: 1, + config: 'config string', + isGrading: true, + value: 'some value', + gradeStatus: gradeStatuses.ungraded, + setValue: jest.fn().mockName('this.props.setValue'), + }; + let el; + beforeEach(() => { + el = shallow(); + el.instance().onChange = jest.fn().mockName('this.onChange'); + }); + describe('snapshot', () => { + test('is grading', () => { + expect(el.instance().render()).toMatchSnapshot(); + }); + + test('is graded', () => { + el.setProps({ + isGrading: false, + gradeStatus: gradeStatuses.graded, + }); + expect(el.instance().render()).toMatchSnapshot(); + }); + + test('is configure to disabled', () => { + el.setProps({ + config: feedbackRequirement.disabled, + }); + expect(el.instance().render()).toMatchSnapshot(); + }); + }); + + describe('component', () => { + 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); + }); + 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); + }); + test('is configure to disabled (the input does not get render)', () => { + el.setProps({ + config: feedbackRequirement.disabled, + }); + expect(el.isEmptyRender()).toEqual(true); + }); + }); + + describe('behavior', () => { + test('onChange call set value', () => { + el = shallow(); + el.instance().onChange({ + target: { + value: 'some value', + }, + }); + expect(props.setValue).toBeCalledTimes(1); + }); + }); + }); + + describe('mapStateToProps', () => { + const testState = { abitaryState: 'some data' }; + const ownProps = { orderNum: props.orderNum }; + let mapped; + 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), + ); + }); + }); + + describe('mapDispatchToProps', () => { + test('maps actions.grading.setCriterionFeedback to setValue prop', () => { + expect(mapDispatchToProps.setValue).toEqual( + actions.grading.setCriterionFeedback, + ); + }); + }); +}); diff --git a/src/containers/CriterionContainer/OptionInfoPopover.jsx b/src/containers/CriterionContainer/OptionInfoPopover.jsx deleted file mode 100644 index e23694f..0000000 --- a/src/containers/CriterionContainer/OptionInfoPopover.jsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -import { - OverlayTrigger, - Popover, - Icon, - IconButton, -} from '@edx/paragon'; -import { InfoOutline } from '@edx/paragon/icons'; - -/** - * - */ -export const OptionInfoPopover = ({ options }) => ( - - - {options.map(option => ( -
- {option.label}
- {option.explanation} -
- ))} -
- - )} - > - {}} - src={InfoOutline} - alt="criterion info" - iconAs={Icon} - /> -
-); - -OptionInfoPopover.defaultProps = { -}; - -OptionInfoPopover.propTypes = { - // redux - options: PropTypes.arrayOf(PropTypes.shape({ - explanation: PropTypes.string, - label: PropTypes.string, - name: PropTypes.string, - points: PropTypes.number, - })).isRequired, -}; - -export default OptionInfoPopover; diff --git a/src/containers/CriterionContainer/GradingCriterion.jsx b/src/containers/CriterionContainer/RadioCriterion.jsx similarity index 68% rename from src/containers/CriterionContainer/GradingCriterion.jsx rename to src/containers/CriterionContainer/RadioCriterion.jsx index 70590a2..cea8c33 100644 --- a/src/containers/CriterionContainer/GradingCriterion.jsx +++ b/src/containers/CriterionContainer/RadioCriterion.jsx @@ -8,9 +8,9 @@ import actions from 'data/actions'; import selectors from 'data/selectors'; /** - * + * */ -export class GradingCriterion extends React.Component { +export class RadioCriterion extends React.Component { constructor(props) { super(props); this.onChange = this.onChange.bind(this); @@ -24,51 +24,52 @@ export class GradingCriterion extends React.Component { } render() { - const { config, data } = this.props; + const { config, data, isGrading } = this.props; return ( <> - - { config.options.map(option => ( + + {config.options.map((option) => ( {option.label} - )) } + ))} ); } } -GradingCriterion.defaultProps = { +RadioCriterion.defaultProps = { data: { selectedOption: '', feedback: '', }, }; -GradingCriterion.propTypes = { +RadioCriterion.propTypes = { orderNum: PropTypes.number.isRequired, + isGrading: PropTypes.bool.isRequired, // redux config: PropTypes.shape({ prompt: PropTypes.string, name: PropTypes.string, feedback: PropTypes.string, - options: PropTypes.arrayOf(PropTypes.shape({ - explanation: PropTypes.string, - feedback: PropTypes.string, - label: PropTypes.string, - name: PropTypes.string, - points: PropTypes.number, - })), + options: PropTypes.arrayOf( + PropTypes.shape({ + explanation: PropTypes.string, + feedback: PropTypes.string, + label: PropTypes.string, + name: PropTypes.string, + points: PropTypes.number, + }), + ), }).isRequired, data: PropTypes.shape({ selectedOption: PropTypes.string, @@ -86,4 +87,4 @@ export const mapDispatchToProps = { setCriterionOption: actions.grading.setCriterionOption, }; -export default connect(mapStateToProps, mapDispatchToProps)(GradingCriterion); +export default connect(mapStateToProps, mapDispatchToProps)(RadioCriterion); diff --git a/src/containers/CriterionContainer/RadioCriterion.test.jsx b/src/containers/CriterionContainer/RadioCriterion.test.jsx new file mode 100644 index 0000000..de6ae13 --- /dev/null +++ b/src/containers/CriterionContainer/RadioCriterion.test.jsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import actions from 'data/actions'; +import selectors from 'data/selectors'; +import { + RadioCriterion, + mapDispatchToProps, + mapStateToProps, +} from './RadioCriterion'; + +jest.mock('@edx/paragon', () => ({ + Form: { + RadioSet: () => 'Form.RadioSet', + Radio: () => 'Form.Radio', + }, +})); + +jest.mock('data/selectors', () => ({ + __esModule: true, + default: { + app: { + rubric: { + criterionConfig: jest.fn((...args) => ({ + rubricCriterionConfig: args, + })), + }, + }, + grading: { + selected: { + criterionGradeData: jest.fn((...args) => ({ + selectedCriterionGradeData: args, + })), + }, + }, + }, +})); + +describe('Radio Crition Container', () => { + const props = { + orderNum: 1, + isGrading: true, + config: { + prompt: 'prompt', + name: 'random name', + feedback: 'feedback mock', + options: [ + { + explanation: 'explaination', + feedback: 'option feedback', + label: 'this label', + name: 'option name', + points: 1, + }, + { + explanation: 'explaination 2', + feedback: 'option feedback 2', + label: 'this label 2', + name: 'option name 2', + points: 2, + }, + ], + }, + data: { + selectedOption: 'selected option', + feedback: 'data feedback', + }, + setCriterionOption: jest.fn().mockName('this.props.setCriterionOption'), + }; + + let el; + beforeEach(() => { + el = shallow(); + el.instance().onChange = jest.fn().mockName('this.onChange'); + }); + describe('snapshot', () => { + test('is grading', () => { + expect(el.instance().render()).toMatchSnapshot(); + }); + + test('is not grading', () => { + el.setProps({ + isGrading: false, + }); + expect(el.instance().render()).toMatchSnapshot(); + }); + }); + + describe('component', () => { + describe('rendering', () => { + test('is grading (all options are not disabled)', () => { + expect(el.isEmptyRender()).toEqual(false); + const optionsEl = el.find('.criteria-option'); + expect(optionsEl.length).toEqual(props.config.options.length); + optionsEl.forEach((optionEl) => + expect(optionEl.prop('disabled')).toEqual(false), + ); + }); + + test('is not grading (all options are disabled)', () => { + el.setProps({ + isGrading: false, + }); + expect(el.isEmptyRender()).toEqual(false); + const optionsEl = el.find('.criteria-option'); + expect(optionsEl.length).toEqual(props.config.options.length); + optionsEl.forEach((optionEl) => + expect(optionEl.prop('disabled')).toEqual(true), + ); + }); + }); + + describe('behavior', () => { + test('onChange call set crition option', () => { + el = shallow(); + el.instance().onChange({ + target: { + value: 'some value', + }, + }); + expect(props.setCriterionOption).toBeCalledTimes(1); + }); + }); + }); + + describe('mapStateToProps', () => { + const testState = { arbitary: 'some data' }; + const ownProps = { orderNum: props.orderNum }; + let mapped; + beforeEach(() => { + mapped = mapStateToProps(testState, ownProps); + }); + test('selectors.app.rubric.criterionConfig', () => { + expect(mapped.config).toEqual( + selectors.app.rubric.criterionConfig(testState, ownProps), + ); + }); + + test('selectors.grading.selected.criterionGradeData', () => { + expect(mapped.data).toEqual( + selectors.grading.selected.criterionGradeData(testState, ownProps), + ); + }); + }); + + describe('mapDispatchToProps', () => { + test('maps actions.grading.setCriterionFeedback to setValue prop', () => { + expect(mapDispatchToProps.setCriterionOption).toEqual( + actions.grading.setCriterionOption, + ); + }); + }); +}); diff --git a/src/containers/CriterionContainer/ReviewCriterion.jsx b/src/containers/CriterionContainer/ReviewCriterion.jsx index 4fd3839..5721867 100644 --- a/src/containers/CriterionContainer/ReviewCriterion.jsx +++ b/src/containers/CriterionContainer/ReviewCriterion.jsx @@ -2,33 +2,25 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import { - Form, - FormControlFeedback, -} from '@edx/paragon'; +import { Form, FormControlFeedback } from '@edx/paragon'; import selectors from 'data/selectors'; /** * */ -export const ReviewCriterion = ({ - config, - // data, -}) => ( +export const ReviewCriterion = ({ config }) => (
- { config.options.map(option => ( + {config.options.map((option) => (
- - {option.label} - + {option.label} {`${option.points} points`}
- )) } + ))}
); @@ -40,12 +32,14 @@ ReviewCriterion.propTypes = { config: PropTypes.shape({ prompt: PropTypes.string, feedback: PropTypes.string, - options: PropTypes.arrayOf(PropTypes.shape({ - explanation: PropTypes.string, - label: PropTypes.string, - name: PropTypes.string, - points: PropTypes.number, - })), + options: PropTypes.arrayOf( + PropTypes.shape({ + explanation: PropTypes.string, + label: PropTypes.string, + name: PropTypes.string, + points: PropTypes.number, + }), + ), }).isRequired, data: PropTypes.shape({ selectedOption: PropTypes.string, @@ -58,7 +52,6 @@ export const mapStateToProps = (state, { orderNum }) => ({ data: selectors.grading.selected.criterionGradeData(state, { orderNum }), }); -export const mapDispatchToProps = { -}; +export const mapDispatchToProps = {}; export default connect(mapStateToProps, mapDispatchToProps)(ReviewCriterion); diff --git a/src/containers/CriterionContainer/ReviewCriterion.test.jsx b/src/containers/CriterionContainer/ReviewCriterion.test.jsx new file mode 100644 index 0000000..171b713 --- /dev/null +++ b/src/containers/CriterionContainer/ReviewCriterion.test.jsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import actions from 'data/actions'; +import selectors from 'data/selectors'; +import { ReviewCriterion, mapStateToProps } from './ReviewCriterion'; + +jest.mock('@edx/paragon', () => ({ + Form: { + Label: () => 'Form.Label', + }, + FormControlFeedback: () => 'FormControlFeedback', +})); + +jest.mock('data/selectors', () => ({ + __esModule: true, + default: { + app: { + rubric: { + criterionConfig: jest.fn((...args) => ({ + rubricCriterionConfig: args, + })), + }, + }, + grading: { + selected: { + criterionGradeData: jest.fn((...args) => ({ + selectedCriterionGradeData: args, + })), + }, + }, + }, +})); + +describe('Review Crition Container', () => { + const props = { + orderNum: 1, + config: { + prompt: 'prompt', + name: 'random name', + feedback: 'feedback mock', + options: [ + { + explanation: 'explaination', + feedback: 'option feedback', + label: 'this label', + name: 'option name', + points: 1, + }, + { + explanation: 'explaination 2', + feedback: 'option feedback 2', + label: 'this label 2', + name: 'option name 2', + points: 2, + }, + ], + }, + data: { + selectedOption: 'selected option', + feedback: 'data feedback', + }, + }; + + let el; + beforeEach(() => { + el = shallow(); + }); + test('snapshot', () => { + expect(el).toMatchSnapshot(); + }); + + describe('component', () => { + test('rendering (everything show up)', () => { + expect(el.isEmptyRender()).toEqual(false); + const optionsEl = el.find('.criteria-option'); + expect(optionsEl.length).toEqual(props.config.options.length); + optionsEl.forEach((optionEl, i) => { + let option = props.config.options[i]; + expect(optionEl.key()).toEqual(option.name); + expect(optionEl.find('.option-label').childAt(0).text()).toEqual( + option.label, + ); + expect(optionEl.find('.option-points').childAt(0).text()).toContain( + String(option.points), + ); + }); + }); + }); + + describe('mapStateToProps', () => { + const testState = { arbitary: 'some data' }; + const ownProps = { orderNum: props.orderNum }; + let mapped; + beforeEach(() => { + mapped = mapStateToProps(testState, ownProps); + }); + test('selectors.app.rubric.criterionConfig', () => { + expect(mapped.config).toEqual( + selectors.app.rubric.criterionConfig(testState, ownProps), + ); + }); + + test('selectors.grading.selected.criterionGradeData', () => { + expect(mapped.data).toEqual( + selectors.grading.selected.criterionGradeData(testState, ownProps), + ); + }); + }); +}); diff --git a/src/containers/CriterionContainer/__snapshots__/CriterionFeedback.test.jsx.snap b/src/containers/CriterionContainer/__snapshots__/CriterionFeedback.test.jsx.snap new file mode 100644 index 0000000..74bc88d --- /dev/null +++ b/src/containers/CriterionContainer/__snapshots__/CriterionFeedback.test.jsx.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +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 new file mode 100644 index 0000000..eae402b --- /dev/null +++ b/src/containers/CriterionContainer/__snapshots__/RadioCriterion.test.jsx.snap @@ -0,0 +1,57 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Radio Crition Container snapshot is grading 1`] = ` + + + + this label + + + this label 2 + + + +`; + +exports[`Radio Crition Container snapshot is not grading 1`] = ` + + + + this label + + + this label 2 + + + +`; diff --git a/src/containers/CriterionContainer/__snapshots__/ReviewCriterion.test.jsx.snap b/src/containers/CriterionContainer/__snapshots__/ReviewCriterion.test.jsx.snap new file mode 100644 index 0000000..3ea05db --- /dev/null +++ b/src/containers/CriterionContainer/__snapshots__/ReviewCriterion.test.jsx.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Review Crition Container snapshot 1`] = ` +
+
+
+ + + 1 points + +
+
+
+
+ + + 2 points + +
+
+
+`; diff --git a/src/containers/CriterionContainer/__snapshots__/index.test.jsx.snap b/src/containers/CriterionContainer/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000..fce0421 --- /dev/null +++ b/src/containers/CriterionContainer/__snapshots__/index.test.jsx.snap @@ -0,0 +1,144 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Criterion Container snapshot is graded and is not grading 1`] = ` + + +
+ +
+ +
+`; + +exports[`Criterion Container snapshot is ungraded and is grading 1`] = ` + + +
+ +
+ +
+`; + +exports[`Criterion Container snapshot is ungraded and is not grading 1`] = ` + + +
+ +
+ +
+`; diff --git a/src/containers/CriterionContainer/index.jsx b/src/containers/CriterionContainer/index.jsx index 4593c2b..ad29114 100644 --- a/src/containers/CriterionContainer/index.jsx +++ b/src/containers/CriterionContainer/index.jsx @@ -4,56 +4,48 @@ import { connect } from 'react-redux'; import { Form } from '@edx/paragon'; -import actions from 'data/actions'; import selectors from 'data/selectors'; -import GradingCriterion from './GradingCriterion'; -import ReviewCriterion from './ReviewCriterion'; +import InfoPopover from 'components/InfoPopover'; +import RadioCriterion from './RadioCriterion'; import CriterionFeedback from './CriterionFeedback'; -import OptionInfoPopover from './OptionInfoPopover'; +import ReviewCriterion from './ReviewCriterion'; +import { gradeStatuses } from 'data/services/lms/constants'; /** * */ export class CriterionContainer extends React.Component { - constructor(props) { - super(props); - this.handleFeedbackUpdate = this.handleFeedbackUpdate.bind(this); - } - - handleFeedbackUpdate(event) { - this.props.setFeedback({ orderNum: this.props.orderNum, value: event.target.value }); - } - render() { - const { - config, - isGrading, - orderNum, - } = this.props; + const { config, isGrading, orderNum, gradeStatus } = this.props; return ( - - {config.prompt} - - + {config.prompt} + + {config.options.map((option) => ( +
+ {option.label} +
+ {option.explanation} +
+ ))} +
- { - isGrading - ? - : - } + {isGrading || gradeStatus === gradeStatuses.graded ? ( + + ) : ( + + )}
- +
); } } -CriterionContainer.defaultProps = { -}; +CriterionContainer.defaultProps = {}; CriterionContainer.propTypes = { isGrading: PropTypes.bool.isRequired, @@ -62,22 +54,23 @@ CriterionContainer.propTypes = { config: PropTypes.shape({ prompt: PropTypes.string, feedback: PropTypes.string, - options: PropTypes.arrayOf(PropTypes.shape({ - explanation: PropTypes.string, - label: PropTypes.string, - name: PropTypes.string, - points: PropTypes.number, - })), + options: PropTypes.arrayOf( + PropTypes.shape({ + explanation: PropTypes.string, + label: PropTypes.string, + name: PropTypes.string, + points: PropTypes.number, + }), + ), }).isRequired, - setFeedback: PropTypes.func.isRequired, + gradeStatus: PropTypes.oneOf(Object.values(gradeStatuses)).isRequired, }; export const mapStateToProps = (state, { orderNum }) => ({ config: selectors.app.rubric.criterionConfig(state, { orderNum }), + gradeStatus: selectors.grading.selected.gradeStatus(state), }); -export const mapDispatchToProps = { - setFeedback: actions.grading.setCriterionFeedback, -}; +export const mapDispatchToProps = {}; export default connect(mapStateToProps, mapDispatchToProps)(CriterionContainer); diff --git a/src/containers/CriterionContainer/index.test.jsx b/src/containers/CriterionContainer/index.test.jsx new file mode 100644 index 0000000..d5cc0e9 --- /dev/null +++ b/src/containers/CriterionContainer/index.test.jsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import selectors from 'data/selectors'; +import { CriterionContainer, mapStateToProps } from '.'; +import { gradeStatuses } from 'data/services/lms/constants'; + +jest.mock('components/InfoPopover', () => 'InfoPopover'); +jest.mock('./RadioCriterion', () => 'RadioCriterion'); +jest.mock('./CriterionFeedback', () => 'CriterionFeedback'); +jest.mock('./ReviewCriterion', () => 'ReviewCriterion'); + +jest.mock('@edx/paragon', () => ({ + Form: { + Group: () => 'Form.Group', + Label: () => 'Form.Label', + }, +})); + +jest.mock('data/selectors', () => ({ + __esModule: true, + default: { + app: { + rubric: { + criterionConfig: jest.fn((...args) => ({ + rubricCriterionConfig: args, + })), + }, + }, + grading: { + selected: { + gradeStatus: jest.fn((...args) => ({ selectedGradeStatus: args })), + }, + }, + }, +})); + +describe('Criterion Container', () => { + const props = { + isGrading: true, + orderNum: 1, + config: { + prompt: 'prompt', + name: 'random name', + feedback: 'feedback mock', + options: [ + { + explanation: 'explaination', + feedback: 'option feedback', + label: 'this label', + name: 'option name', + points: 2, + }, + { + explanation: 'explaination 2', + feedback: 'option feedback 2', + label: 'this label 2', + name: 'option name 2', + points: 1, + }, + ], + }, + gradeStatus: gradeStatuses.ungraded, + }; + let el; + beforeEach(() => { + el = shallow(); + }); + + describe('snapshot', () => { + test('is ungraded and is grading', () => { + expect(el).toMatchSnapshot(); + }); + + test('is ungraded and is not grading', () => { + el.setProps({ + isGrading: false, + }); + expect(el).toMatchSnapshot(); + }); + + test('is graded and is not grading', () => { + el.setProps({ + isGrading: false, + gradeStatus: gradeStatuses.graded, + }); + expect(el).toMatchSnapshot(); + }); + }); + + describe('component', () => { + test('rendering and all of the option show up', () => { + expect(el.isEmptyRender()).toEqual(false); + const optionsEl = el.find('.help-popover-option'); + expect(optionsEl.length).toEqual(props.config.options.length); + optionsEl.forEach((optionEl, i) => { + expect(optionEl.key()).toEqual(props.config.options[i].name); + expect(optionEl.text()).toContain(props.config.options[i].explanation); + }); + }); + + test('is ungraded and is grading (Radio criterion get render)', () => { + const rubricCritera = el.find('.rubric-criteria'); + expect(rubricCritera.children(0).name()).toEqual('RadioCriterion'); + }); + + test('is ungraded and is not grading (Review criterion get render)', () => { + el.setProps({ + isGrading: false, + }); + const rubricCritera = el.find('.rubric-criteria'); + expect(rubricCritera.children(0).name()).toEqual('ReviewCriterion'); + }); + + test('is graded and is not grading (Radio criterion get render)', () => { + el.setProps({ + isGrading: false, + gradeStatus: gradeStatuses.graded, + }); + const rubricCritera = el.find('.rubric-criteria'); + expect(rubricCritera.children(0).name()).toEqual('RadioCriterion'); + }); + }); + + describe('mapStateToProps', () => { + const testState = { abitaryState: 'some data' }; + const ownProps = { orderNum: props.orderNum }; + let mapped; + beforeEach(() => { + mapped = mapStateToProps(testState, ownProps); + }); + test('selectors.app.rubric.criterionConfig', () => { + expect(mapped.config).toEqual( + selectors.app.rubric.criterionConfig(testState, ownProps), + ); + }); + + test('selectors.grading.selected.gradeStatus', () => { + expect(mapped.gradeStatus).toEqual( + selectors.grading.selected.gradeStatus(testState), + ); + }); + }); +}); diff --git a/src/containers/Rubric/RubricFeedback.jsx b/src/containers/Rubric/RubricFeedback.jsx index e87d372..e4cd57f 100644 --- a/src/containers/Rubric/RubricFeedback.jsx +++ b/src/containers/Rubric/RubricFeedback.jsx @@ -7,6 +7,7 @@ import { Form } from '@edx/paragon'; import { feedbackRequirement } from 'data/services/lms/constants'; import actions from 'data/actions'; import selectors from 'data/selectors'; +import InfoPopover from 'components/InfoPopover'; /** * @@ -22,22 +23,28 @@ export class RubricFeedback extends React.Component { } render() { - if (this.props.config === feedbackRequirement.disabled) { + const { isGrading, value, feedbackPrompt, config } = this.props; + if (config === feedbackRequirement.disabled) { return null; } - return this.props.isGrading - ? ( + return ( + + + Overall comments + +
{feedbackPrompt}
+
+
- ) - : ( - {this.props.value} - ); +
+ ); } } @@ -51,12 +58,14 @@ RubricFeedback.propTypes = { isGrading: PropTypes.bool.isRequired, setValue: PropTypes.func.isRequired, value: PropTypes.string, + feedbackPrompt: PropTypes.string.isRequired, }; export const mapStateToProps = (state) => ({ isGrading: selectors.app.isGrading(state), value: selectors.grading.selected.overallFeedback(state), config: selectors.app.rubric.feedbackConfig(state), + feedbackPrompt: selectors.app.rubric.feedbackPrompt(state), }); export const mapDispatchToProps = { diff --git a/src/containers/Rubric/RubricFeedback.test.jsx b/src/containers/Rubric/RubricFeedback.test.jsx new file mode 100644 index 0000000..3dadadc --- /dev/null +++ b/src/containers/Rubric/RubricFeedback.test.jsx @@ -0,0 +1,160 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import actions from 'data/actions'; +import selectors from 'data/selectors'; +import { + RubricFeedback, + mapDispatchToProps, + mapStateToProps, +} from './RubricFeedback'; +import { + feedbackRequirement, + gradeStatuses, +} from 'data/services/lms/constants'; + +jest.mock('components/InfoPopover', () => 'InfoPopover'); + +jest.mock('@edx/paragon', () => ({ + Form: { + Group: () => 'Form.Group', + Label: () => 'Form.Label', + Control: () => 'Form.Control', + }, +})); + +jest.mock('data/selectors', () => ({ + __esModule: true, + default: { + app: { + isGrading: jest.fn((...args) => ({ isGragrding: args })), + rubric: { + feedbackConfig: jest.fn((...args) => ({ + rubricFeedbackConfig: args, + })), + feedbackPrompt: jest.fn((...args) => ({ + rubricFeedbackPrompt: args, + })), + }, + }, + grading: { + selected: { + overallFeedback: jest.fn((...args) => ({ + selectedOverallFeedback: args, + })), + }, + }, + }, +})); + +describe('Review Feedback component', () => { + const props = { + config: 'config stirng', + isGrading: true, + value: 'some value', + feedbackPrompt: 'feedback prompt', + gradeStatus: gradeStatuses.ungraded, + setValue: jest.fn().mockName('this.props.setValue'), + }; + + let el; + beforeEach(() => { + el = shallow(); + el.instance().onChange = jest.fn().mockName('this.onChange'); + }); + describe('snapshot', () => { + test('is grading', () => { + expect(el.instance().render()).toMatchSnapshot(); + }); + test('is graded', () => { + el.setProps({ + isGrading: false, + gradeStatus: gradeStatuses.graded, + }); + expect(el.instance().render()).toMatchSnapshot(); + }); + + test('is configure to disabled', () => { + el.setProps({ + config: feedbackRequirement.disabled, + }); + expect(el.instance().render()).toMatchSnapshot(); + }); + }); + + describe('component', () => { + describe('render', () => { + test('is grading (everything show up and the input is editable)', () => { + expect(el.isEmptyRender()).toEqual(false); + const input = el.find('.rubric-feedback.feedback-input'); + expect(input.prop('disabled')).toEqual(false); + expect(input.prop('value')).toEqual(props.value); + }); + + test('is graded (the input are disabled)', () => { + el.setProps({ + isGrading: false, + gradeStatus: gradeStatuses.graded, + }); + expect(el.isEmptyRender()).toEqual(false); + const input = el.find('.rubric-feedback.feedback-input'); + expect(input.prop('disabled')).toEqual(true); + expect(input.prop('value')).toEqual(props.value); + }); + test('is configure to disabled (this input does not get render)', () => { + el.setProps({ + config: feedbackRequirement.disabled, + }); + expect(el.isEmptyRender()).toEqual(true); + }); + }); + describe('behavior', () => { + test('onChange set value', () => { + el = shallow(); + el.instance().onChange({ + target: { + value: 'some value', + }, + }); + expect(props.setValue).toBeCalledTimes(1); + }); + }); + }); + + describe('mapStateToProps', () => { + const testState = { abitaryState: 'some data' }; + let mapped; + beforeEach(() => { + mapped = mapStateToProps(testState); + }); + test('selectors.app.isGrading', () => { + expect(mapped.isGrading).toEqual(selectors.app.isGrading(testState)); + }); + + test('selectors.app.rubricFeedbackConfig', () => { + expect(mapped.config).toEqual( + selectors.app.rubric.feedbackConfig(testState), + ); + }); + + test('selectors.grading.selected.overallFeedback', () => { + expect(mapped.value).toEqual( + selectors.grading.selected.overallFeedback(testState), + ); + }); + + test('selectors.app.rubric.feedbackPrompt', () => { + expect(mapped.feedbackPrompt).toEqual( + selectors.app.rubric.feedbackPrompt(testState), + ); + }); + }); + + describe('mapDispatchToProps', () => { + test('maps actions.grading.setRubricFeedback to setValue prop', () => { + expect(mapDispatchToProps.setValue).toEqual( + actions.grading.setRubricFeedback, + ); + }); + }); +}); diff --git a/src/containers/Rubric/__snapshots__/RubricFeedback.test.jsx.snap b/src/containers/Rubric/__snapshots__/RubricFeedback.test.jsx.snap new file mode 100644 index 0000000..c021a2b --- /dev/null +++ b/src/containers/Rubric/__snapshots__/RubricFeedback.test.jsx.snap @@ -0,0 +1,57 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Review Feedback component snapshot is configure to disabled 1`] = `null`; + +exports[`Review Feedback component snapshot is graded 1`] = ` + + + + +`; + +exports[`Review Feedback component snapshot is grading 1`] = ` + + + + +`; diff --git a/src/containers/Rubric/__snapshots__/index.test.jsx.snap b/src/containers/Rubric/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000..16b1da3 --- /dev/null +++ b/src/containers/Rubric/__snapshots__/index.test.jsx.snap @@ -0,0 +1,92 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Rubric Container snapshot is grading 1`] = ` + + +

+ Rubric +

+
+ + + + + +
+ +
+
+ +
+
+`; + +exports[`Rubric Container snapshot is not grading 1`] = ` + + +

+ Rubric +

+
+ + + + + +
+ +
+
+`; diff --git a/src/containers/Rubric/index.jsx b/src/containers/Rubric/index.jsx index f1b3665..55859c7 100644 --- a/src/containers/Rubric/index.jsx +++ b/src/containers/Rubric/index.jsx @@ -2,10 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import { - Card, - Button, -} from '@edx/paragon'; +import { Card, Button } from '@edx/paragon'; import selectors from 'data/selectors'; @@ -17,21 +14,22 @@ import './Rubric.scss'; /** * */ -export const Rubric = ({ - isGrading, - criteriaIndices, -}) => ( +export const Rubric = ({ isGrading, criteriaIndices }) => (

Rubric


- { criteriaIndices.map(index => ( - - )) } + {criteriaIndices.map((index) => ( + + ))}
- { isGrading && ( + {isGrading && (
@@ -51,7 +49,6 @@ export const mapStateToProps = (state) => ({ criteriaIndices: selectors.app.rubric.criteriaIndices(state), }); -export const mapDispatchToProps = { -}; +export const mapDispatchToProps = {}; export default connect(mapStateToProps, mapDispatchToProps)(Rubric); diff --git a/src/containers/Rubric/index.test.jsx b/src/containers/Rubric/index.test.jsx new file mode 100644 index 0000000..f8a894e --- /dev/null +++ b/src/containers/Rubric/index.test.jsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import selectors from 'data/selectors'; +import { Rubric, mapStateToProps } from '.'; + +jest.mock('containers/CriterionContainer', () => 'CriterionContainer'); +jest.mock('./RubricFeedback', () => 'RubricFeedback'); + +jest.mock('@edx/paragon', () => { + const Card = () => 'Card'; + Card.Body = () => 'Card.Body'; + const Button = () => 'Button'; + return { Button, Card }; +}); + +jest.mock('data/selectors', () => ({ + __esModule: true, + default: { + app: { + isGrading: jest.fn((...args) => ({ isGragrding: args })), + rubric: { + criteriaIndices: jest.fn((...args) => ({ + rubricCriteriaIndices: args, + })), + }, + }, + }, +})); + +describe('Rubric Container', () => { + const props = { + isGrading: true, + criteriaIndices: [1, 2, 3, 4, 5], + }; + let el; + beforeEach(() => { + el = shallow(); + }); + describe('snapshot', () => { + test('is grading', () => { + expect(el).toMatchSnapshot(); + }); + test('is not grading', () => { + el.setProps({ + isGrading: false, + }); + expect(el).toMatchSnapshot(); + }); + }); + + 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])); + }); + }); + + 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('mapStateToProps', () => { + const testState = { abitaryState: 'some data' }; + let mapped; + beforeEach(() => { + mapped = mapStateToProps(testState); + }); + test('selectors.app.isGrading', () => { + expect(mapped.isGrading).toEqual(selectors.app.isGrading(testState)); + }); + + test('selectors.app.rubric.criteriaIndices', () => { + expect(mapped.criteriaIndices).toEqual( + selectors.app.rubric.criteriaIndices(testState), + ); + }); + }); +}); diff --git a/src/data/selectors/app.js b/src/data/selectors/app.js index 2d18113..01118b6 100644 --- a/src/data/selectors/app.js +++ b/src/data/selectors/app.js @@ -71,6 +71,12 @@ rubric.hasConfig = rubricConfigSelector(config => config !== undefined); */ rubric.feedbackConfig = rubricConfigSelector(config => config.feedback); +/** + * Return the criteria feedbase prompt + * @return {string} - criteria feedback prompt + */ +rubric.feedbackPrompt = rubricConfigSelector(config => config.feedbackPrompt); + /** * Returns a list of rubric criterion config objects for the ORA * @return {obj[]} - array of criterion config objects diff --git a/src/data/services/lms/fakeData/ora.js b/src/data/services/lms/fakeData/ora.js index 31f522b..9fd62f3 100644 --- a/src/data/services/lms/fakeData/ora.js +++ b/src/data/services/lms/fakeData/ora.js @@ -12,6 +12,7 @@ export const type = 'individual'; const rubricConfig = { feedback: 'optional', + feedbackPrompt: 'Grader-facing prompt for submission-level feedback', criteria: [ { name: 'firstCriterion',