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
This commit is contained in:
leangseu-edx
2021-10-08 14:41:09 -04:00
committed by GitHub
parent da410f8423
commit 02b29830cf
24 changed files with 1443 additions and 175 deletions

View File

@@ -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';
/**
* <InfoPopover />
*/
export const InfoPopover = ({ children }) => (
<OverlayTrigger
trigger="focus"
placement="auto"
flip
overlay={
<Popover className="overlay-help-popover">
<PopoverContent>{children}</PopoverContent>
</Popover>
}
>
<IconButton
className="criteria-help-icon"
src={InfoOutline}
alt="criterion info"
iconAs={Icon}
/>
</OverlayTrigger>
);
InfoPopover.defaultProps = {};
InfoPopover.propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
};
export default InfoPopover;

View File

@@ -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 = <div>Children component</div>;
test('snapshot', () => {
expect(shallow(<InfoPopover>{child}</InfoPopover>)).toMatchSnapshot();
});
describe('Component', () => {
let el;
beforeEach(() => {
el = shallow(<InfoPopover>{child}</InfoPopover>);
});
test('Test component render', () => {
expect(el.length).toEqual(1);
expect(el.find('.criteria-help-icon').length).toEqual(1);
});
});
});

View File

@@ -0,0 +1,27 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Info Popover Component snapshot 1`] = `
<OverlayTrigger
flip={true}
overlay={
<Popover
className="overlay-help-popover"
>
<PopoverContent>
<div>
Children component
</div>
</PopoverContent>
</Popover>
}
placement="auto"
trigger="focus"
>
<IconButton
alt="criterion info"
className="criteria-help-icon"
iconAs={[MockFunction Icon]}
src={[MockFunction icons.InfoOutline]}
/>
</OverlayTrigger>
`;

View File

@@ -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
? (
<Form.Control
as="input"
className="criterion-feedback feedback-input"
floatingLabel="Add comments"
value={this.props.value}
onChange={this.onChange}
/>
)
: (
<Form.Text className="feedback-text">{this.props.value}</Form.Text>
);
return (
<Form.Control
as="input"
className="criterion-feedback feedback-input"
floatingLabel={isGrading ? 'Add comments' : 'Comments'}
value={value}
onChange={this.onChange}
disabled={!isGrading}
/>
);
}
}
@@ -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 }),
});

View File

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

View File

@@ -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';
/**
* <OptionInfoPopover />
*/
export const OptionInfoPopover = ({ options }) => (
<OverlayTrigger
trigger="focus"
placement="left"
overlay={(
<Popover className="overlay-help-popover">
<Popover.Content>
{options.map(option => (
<div key={option.name} className="help-popover-option">
<strong>{option.label}</strong><br />
{option.explanation}
</div>
))}
</Popover.Content>
</Popover>
)}
>
<IconButton
className="criteria-help-icon"
onClick={() => {}}
src={InfoOutline}
alt="criterion info"
iconAs={Icon}
/>
</OverlayTrigger>
);
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;

View File

@@ -8,9 +8,9 @@ import actions from 'data/actions';
import selectors from 'data/selectors';
/**
* <GradingCriterion />
* <RadioCriterion />
*/
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 (
<>
<Form.RadioSet
name={config.name}
value={data.selectedOption || ''}
>
{ config.options.map(option => (
<Form.RadioSet name={config.name} value={data.selectedOption || ''}>
{config.options.map((option) => (
<Form.Radio
className="criteria-option"
key={option.name}
value={option.name}
description={`${option.points} points`}
onChange={this.onChange}
disabled={!isGrading}
>
{option.label}
</Form.Radio>
)) }
))}
</Form.RadioSet>
</>
);
}
}
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);

View File

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

View File

@@ -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';
/**
* <ReviewCriterion />
*/
export const ReviewCriterion = ({
config,
// data,
}) => (
export const ReviewCriterion = ({ config }) => (
<div className="review-criterion">
{ config.options.map(option => (
{config.options.map((option) => (
<div key={option.name} className="criteria-option">
<div>
<Form.Label className="option-label">
{option.label}
</Form.Label>
<Form.Label className="option-label">{option.label}</Form.Label>
<FormControlFeedback className="option-points">
{`${option.points} points`}
</FormControlFeedback>
</div>
</div>
)) }
))}
</div>
);
@@ -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);

View File

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

View File

@@ -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`] = `
<Control
as="input"
className="criterion-feedback feedback-input"
disabled={true}
floatingLabel="Comments"
onChange={[MockFunction this.onChange]}
value="some value"
/>
`;
exports[`Criterion Feedback snapshot is grading 1`] = `
<Control
as="input"
className="criterion-feedback feedback-input"
disabled={false}
floatingLabel="Add comments"
onChange={[MockFunction this.onChange]}
value="some value"
/>
`;

View File

@@ -0,0 +1,57 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Radio Crition Container snapshot is grading 1`] = `
<React.Fragment>
<RadioSet
name="random name"
value="selected option"
>
<Radio
className="criteria-option"
description="1 points"
disabled={false}
onChange={[MockFunction this.onChange]}
value="option name"
>
this label
</Radio>
<Radio
className="criteria-option"
description="2 points"
disabled={false}
onChange={[MockFunction this.onChange]}
value="option name 2"
>
this label 2
</Radio>
</RadioSet>
</React.Fragment>
`;
exports[`Radio Crition Container snapshot is not grading 1`] = `
<React.Fragment>
<RadioSet
name="random name"
value="selected option"
>
<Radio
className="criteria-option"
description="1 points"
disabled={true}
onChange={[MockFunction this.onChange]}
value="option name"
>
this label
</Radio>
<Radio
className="criteria-option"
description="2 points"
disabled={true}
onChange={[MockFunction this.onChange]}
value="option name 2"
>
this label 2
</Radio>
</RadioSet>
</React.Fragment>
`;

View File

@@ -0,0 +1,42 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Review Crition Container snapshot 1`] = `
<div
className="review-criterion"
>
<div
className="criteria-option"
key="option name"
>
<div>
<Label
className="option-label"
>
this label
</Label>
<FormControlFeedback
className="option-points"
>
1 points
</FormControlFeedback>
</div>
</div>
<div
className="criteria-option"
key="option name 2"
>
<div>
<Label
className="option-label"
>
this label 2
</Label>
<FormControlFeedback
className="option-points"
>
2 points
</FormControlFeedback>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,144 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Criterion Container snapshot is graded and is not grading 1`] = `
<Group>
<Label
className="criteria-label"
>
<span
className="criteria-title"
>
prompt
</span>
<InfoPopover>
<div
className="help-popover-option"
key="option name"
>
<strong>
this label
</strong>
<br />
explaination
</div>
<div
className="help-popover-option"
key="option name 2"
>
<strong>
this label 2
</strong>
<br />
explaination 2
</div>
</InfoPopover>
</Label>
<div
className="rubric-criteria"
>
<RadioCriterion
isGrading={false}
orderNum={1}
/>
</div>
<CriterionFeedback
isGrading={false}
orderNum={1}
/>
</Group>
`;
exports[`Criterion Container snapshot is ungraded and is grading 1`] = `
<Group>
<Label
className="criteria-label"
>
<span
className="criteria-title"
>
prompt
</span>
<InfoPopover>
<div
className="help-popover-option"
key="option name"
>
<strong>
this label
</strong>
<br />
explaination
</div>
<div
className="help-popover-option"
key="option name 2"
>
<strong>
this label 2
</strong>
<br />
explaination 2
</div>
</InfoPopover>
</Label>
<div
className="rubric-criteria"
>
<RadioCriterion
isGrading={true}
orderNum={1}
/>
</div>
<CriterionFeedback
isGrading={true}
orderNum={1}
/>
</Group>
`;
exports[`Criterion Container snapshot is ungraded and is not grading 1`] = `
<Group>
<Label
className="criteria-label"
>
<span
className="criteria-title"
>
prompt
</span>
<InfoPopover>
<div
className="help-popover-option"
key="option name"
>
<strong>
this label
</strong>
<br />
explaination
</div>
<div
className="help-popover-option"
key="option name 2"
>
<strong>
this label 2
</strong>
<br />
explaination 2
</div>
</InfoPopover>
</Label>
<div
className="rubric-criteria"
>
<ReviewCriterion
orderNum={1}
/>
</div>
<CriterionFeedback
isGrading={false}
orderNum={1}
/>
</Group>
`;

View File

@@ -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';
/**
* <CriterionContainer />
*/
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 (
<Form.Group>
<Form.Label className="criteria-label">
<span className="criteria-title">
{config.prompt}
</span>
<OptionInfoPopover options={config.options} />
<span className="criteria-title">{config.prompt}</span>
<InfoPopover>
{config.options.map((option) => (
<div key={option.name} className="help-popover-option">
<strong>{option.label}</strong>
<br />
{option.explanation}
</div>
))}
</InfoPopover>
</Form.Label>
<div className="rubric-criteria">
{
isGrading
? <GradingCriterion orderNum={orderNum} />
: <ReviewCriterion orderNum={orderNum} />
}
{isGrading || gradeStatus === gradeStatuses.graded ? (
<RadioCriterion orderNum={orderNum} isGrading={isGrading} />
) : (
<ReviewCriterion orderNum={orderNum} />
)}
</div>
<CriterionFeedback orderNum={orderNum} />
<CriterionFeedback orderNum={orderNum} isGrading={isGrading} />
</Form.Group>
);
}
}
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);

View File

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

View File

@@ -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';
/**
* <RubricFeedback />
@@ -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 (
<Form.Group>
<Form.Label className="criteria-label">
<span className="criteria-title">Overall comments</span>
<InfoPopover>
<div>{feedbackPrompt}</div>
</InfoPopover>
</Form.Label>
<Form.Control
as="input"
className="rubric-feedback feedback-input"
floatingLabel="Add comments"
value={this.props.value}
floatingLabel={isGrading ? 'Add comments' : 'Comments'}
value={value}
onChange={this.onChange}
disabled={!isGrading}
/>
)
: (
<Form.Text className="feedback-text">{this.props.value}</Form.Text>
);
</Form.Group>
);
}
}
@@ -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 = {

View File

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

View File

@@ -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`] = `
<Group>
<Label
className="criteria-label"
>
<span
className="criteria-title"
>
Overall comments
</span>
<InfoPopover>
<div>
feedback prompt
</div>
</InfoPopover>
</Label>
<Control
as="input"
className="rubric-feedback feedback-input"
disabled={true}
floatingLabel="Comments"
onChange={[MockFunction this.onChange]}
value="some value"
/>
</Group>
`;
exports[`Review Feedback component snapshot is grading 1`] = `
<Group>
<Label
className="criteria-label"
>
<span
className="criteria-title"
>
Overall comments
</span>
<InfoPopover>
<div>
feedback prompt
</div>
</InfoPopover>
</Label>
<Control
as="input"
className="rubric-feedback feedback-input"
disabled={false}
floatingLabel="Add comments"
onChange={[MockFunction this.onChange]}
value="some value"
/>
</Group>
`;

View File

@@ -0,0 +1,92 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Rubric Container snapshot is grading 1`] = `
<Card
className="grading-rubric-card"
>
<Component
className="grading-rubric-body"
>
<h3>
Rubric
</h3>
<hr />
<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 />
<RubricFeedback />
</Component>
<div
className="grading-rubric-footer"
>
<Button>
Submit Grade
</Button>
</div>
</Card>
`;
exports[`Rubric Container snapshot is not grading 1`] = `
<Card
className="grading-rubric-card"
>
<Component
className="grading-rubric-body"
>
<h3>
Rubric
</h3>
<hr />
<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 />
<RubricFeedback />
</Component>
</Card>
`;

View File

@@ -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';
/**
* <Rubric />
*/
export const Rubric = ({
isGrading,
criteriaIndices,
}) => (
export const Rubric = ({ isGrading, criteriaIndices }) => (
<Card className="grading-rubric-card">
<Card.Body className="grading-rubric-body">
<h3>Rubric</h3>
<hr />
{ criteriaIndices.map(index => (
<CriterionContainer isGrading={isGrading} key={index} orderNum={index} />
)) }
{criteriaIndices.map((index) => (
<CriterionContainer
isGrading={isGrading}
key={index}
orderNum={index}
/>
))}
<hr />
<RubricFeedback />
</Card.Body>
{ isGrading && (
{isGrading && (
<div className="grading-rubric-footer">
<Button>Submit Grade</Button>
</div>
@@ -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);

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ export const type = 'individual';
const rubricConfig = {
feedback: 'optional',
feedbackPrompt: 'Grader-facing prompt for submission-level feedback',
criteria: [
{
name: 'firstCriterion',