Skip to content

Commit 66f232f

Browse files
feat: add Assessment component
1 parent 7b2919b commit 66f232f

24 files changed

+1407
-0
lines changed
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
@import "~@edx/brand/paragon/variables";
2+
@import "~@edx/paragon/scss/core/core";
3+
@import "~@edx/brand/paragon/overrides";
4+
5+
.criteria-label {
6+
width: 100%;
7+
.criteria-title {
8+
display: inline-block;
9+
max-width: calc(100% - 44px);
10+
color: $primary-500;
11+
font-weight: bold;
12+
vertical-align: top;
13+
}
14+
.esg-help-icon {
15+
float: right;
16+
margin-top: (map-get($spacers, 2) * -1);
17+
margin-right: (map-get($spacers, 2\.5) * -1);
18+
vertical-align: top;
19+
}
20+
}
21+
.criteria-option {
22+
width: 100%;
23+
> div {
24+
display: inline;
25+
width: 100%;
26+
.pgn__form-label {
27+
display: inline-flex;
28+
}
29+
.pgn__form-control-description {
30+
float: right;
31+
}
32+
}
33+
}
34+
35+
.criterion-feedback {
36+
margin-top: 1rem;
37+
}
38+
39+
.popover.overlay-help-popover {
40+
z-index: 4000;
41+
margin-right: map-get($spacers, 1) !important;
42+
.help-popover-option {
43+
margin-bottom: map-get($spacers, 1);
44+
}
45+
}
46+
47+
48+
.assessment-card {
49+
width: 320px !important;
50+
height: fit-content;
51+
max-height: 100%;
52+
margin-left: map-get($spacers, 3);
53+
position: sticky !important;
54+
top: map-get($spacers, 1) * -1;
55+
56+
.assessment-header {
57+
box-shadow: 0 0 0.25rem rgba(0, 0, 0, 0.3) !important;
58+
display: flex;
59+
justify-content: center;
60+
padding: map-get($spacers, 3);
61+
}
62+
63+
.assessment-body {
64+
overflow-y: scroll;
65+
}
66+
67+
.assessment-footer {
68+
box-shadow: 0 0 0.25rem rgba(0, 0, 0, 0.3) !important;
69+
display: flex;
70+
justify-content: center;
71+
padding: map-get($spacers, 3);
72+
}
73+
74+
button.pgn__stateful-btn.pgn__stateful-btn-state-pending {
75+
opacity: .4 !important;
76+
}
77+
}
78+
79+
@include media-breakpoint-down(sm) {
80+
.assessment-card {
81+
margin-left: 0 !important;
82+
}
83+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useContext } from 'react';
2+
import { StrictDict } from '@edx/react-unit-test-utils';
3+
4+
import { useRubricConfig } from 'data/services/lms/hooks/selectors';
5+
import { useSubmitRubric } from 'data/services/lms/hooks/actions';
6+
import { AssessmentContext } from 'components/AssessmentContext';
7+
8+
export const stateKeys = StrictDict({
9+
optionsSelected: 'optionsSelected',
10+
criterionFeedback: 'criterionFeedback',
11+
assessment: 'assessment',
12+
overallFeedback: 'overallFeedback',
13+
});
14+
15+
const useEditableAssessmentData = () => {
16+
const {
17+
currentValue,
18+
formFields,
19+
} = useContext(AssessmentContext);
20+
21+
const { criteria, feedbackConfig } = useRubricConfig();
22+
23+
const submitRubricMutation = useSubmitRubric();
24+
const onSubmit = () => {
25+
submitRubricMutation.mutate(currentValue);
26+
};
27+
28+
return {
29+
criteria,
30+
formFields,
31+
onSubmit,
32+
submitStatus: submitRubricMutation.status,
33+
// overall feedback
34+
overallFeedbackPrompt: feedbackConfig.defaultText,
35+
};
36+
};
37+
38+
export default useEditableAssessmentData;
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import React from 'react';
2+
3+
import { Card, StatefulButton } from '@edx/paragon';
4+
import { useIntl } from '@edx/frontend-platform/i18n';
5+
6+
import { MutationStatus } from 'data/services/lms/constants';
7+
import CriterionContainer from '../components/CriterionContainer';
8+
import OverallFeedback from '../components/OverallFeedback';
9+
10+
import useEditableAssessmentData from './hooks';
11+
import messages from '../messages';
12+
13+
/**
14+
* <Rubric />
15+
*/
16+
const EditableAssessment = () => {
17+
const {
18+
criteria,
19+
formFields,
20+
onSubmit,
21+
submitStatus,
22+
overallFeedbackPrompt,
23+
} = useEditableAssessmentData();
24+
25+
const { formatMessage } = useIntl();
26+
return (
27+
<Card className="rubric-card">
28+
<Card.Section className="rubric-body">
29+
<h3>{formatMessage(messages.rubric)}</h3>
30+
<hr className="m-2.5" />
31+
{criteria.map((criterion) => (
32+
<CriterionContainer
33+
isGrading
34+
{...{
35+
key: criterion.name,
36+
criterion: { ...criterion, ...formFields.criteria[criterion.name] },
37+
}}
38+
/>
39+
))}
40+
<hr />
41+
<OverallFeedback prompt={overallFeedbackPrompt} {...formFields.overallFeedback} />
42+
</Card.Section>
43+
<div className="rubric-footer">
44+
<StatefulButton
45+
onClick={onSubmit}
46+
state={submitStatus}
47+
disabledStates={[MutationStatus.loading, MutationStatus.success]}
48+
labels={{
49+
[MutationStatus.idle]: formatMessage(messages.submitGrade),
50+
[MutationStatus.loading]: formatMessage(messages.submittingGrade),
51+
[MutationStatus.success]: formatMessage(messages.gradeSubmitted),
52+
}}
53+
/>
54+
</div>
55+
</Card>
56+
);
57+
};
58+
59+
EditableAssessment.propTypes = {
60+
61+
};
62+
63+
export default EditableAssessment;
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useRubricConfig } from 'data/services/lms/hooks/selectors';
2+
3+
export const useReadonlyRubricData = ({ assessment }) => {
4+
const { criteria, feedbackConfig } = useRubricConfig();
5+
6+
const criterionData = (criterion) => ({
7+
...criterion,
8+
optionsValue: assessment.optionsSelected[criterion.name],
9+
optionsIsInvalid: false,
10+
feedbackValue: assessment.criterionFeedback[criterion.name],
11+
feedbackIsInvalid: false,
12+
});
13+
14+
return {
15+
criteria: criteria.map(criterionData),
16+
// overall feedback
17+
overallFeedbackPrompt: feedbackConfig.defaultText,
18+
overallFeedback: assessment.overallFeedback,
19+
};
20+
};
21+
22+
export default useReadonlyRubricData;
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import React from 'react';
2+
import PropTypes from 'prop-types';
3+
4+
import { Card } from '@edx/paragon';
5+
import { useIntl } from '@edx/frontend-platform/i18n';
6+
7+
import CriterionContainer from '../components/CriterionContainer';
8+
import { useReadonlyRubricData } from './hooks';
9+
import messages from '../messages';
10+
11+
/**
12+
* <ReadonlyRubric />
13+
*/
14+
const ReadonlyRubric = ({ assessment }) => {
15+
const {
16+
criteria,
17+
overallFeedbackDisabled,
18+
} = useReadonlyRubricData({ assessment });
19+
20+
const { formatMessage } = useIntl();
21+
return (
22+
<Card className="rubric-card">
23+
<Card.Section className="rubric-body">
24+
<h3>{formatMessage(messages.rubric)}</h3>
25+
<hr className="m-2.5" />
26+
{criteria.map((criterion) => (
27+
<CriterionContainer
28+
key={criterion.name}
29+
isGrading={false}
30+
criterion={{
31+
...criterion,
32+
optionsValue: assessment.optionsSelected[criterion.name],
33+
feedbackValue: assessment.criterionFeedback[criterion.name],
34+
}}
35+
/>
36+
))}
37+
{!overallFeedbackDisabled && (
38+
<p>{assessment.overallFeedback}</p>
39+
)}
40+
<hr />
41+
</Card.Section>
42+
</Card>
43+
);
44+
};
45+
46+
ReadonlyRubric.propTypes = {
47+
assessment: PropTypes.shape({
48+
optionsSelected: PropTypes.objectOf(PropTypes.string).isRequired,
49+
criterionFeedback: PropTypes.objectOf(PropTypes.string).isRequired,
50+
overallFeedback: PropTypes.string,
51+
}).isRequired,
52+
};
53+
54+
export default ReadonlyRubric;
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import React from 'react';
2+
import PropTypes from 'prop-types';
3+
4+
import { Form } from '@edx/paragon';
5+
import { useIntl } from '@edx/frontend-platform/i18n';
6+
7+
import { feedbackRequirement } from 'data/services/lms/constants';
8+
9+
import messages from './messages';
10+
11+
/**
12+
* <CriterionFeedback />
13+
*/
14+
const CriterionFeedback = ({ criterion }) => {
15+
const { formatMessage } = useIntl();
16+
17+
let commentMessage = formatMessage(messages.addComments);
18+
if (criterion.feedbackRequired === feedbackRequirement.optional) {
19+
commentMessage += ` ${formatMessage(messages.optional)}`;
20+
}
21+
22+
const { feedbackValue, feedbackIsInvalid, feedbackOnChange } = criterion;
23+
24+
if (
25+
!criterion.feedbackEnabled
26+
|| criterion.feedbackRequired === feedbackRequirement.disabled
27+
) {
28+
return null;
29+
}
30+
31+
return (
32+
<Form.Group isInvalid={feedbackIsInvalid}>
33+
<Form.Control
34+
as="textarea"
35+
className="criterion-feedback feedback-input"
36+
floatingLabel={commentMessage}
37+
value={feedbackValue}
38+
onChange={feedbackOnChange}
39+
/>
40+
{feedbackIsInvalid && (
41+
<Form.Control.Feedback type="invalid" className="feedback-error-msg">
42+
{formatMessage(messages.criterionFeedbackError)}
43+
</Form.Control.Feedback>
44+
)}
45+
</Form.Group>
46+
);
47+
};
48+
49+
CriterionFeedback.propTypes = {
50+
criterion: PropTypes.shape({
51+
feedbackValue: PropTypes.string.isRequired,
52+
feedbackIsInvalid: PropTypes.bool.isRequired,
53+
feedbackOnChange: PropTypes.func.isRequired,
54+
feedbackEnabled: PropTypes.bool.isRequired,
55+
feedbackRequired: PropTypes.oneOf(Object.values(feedbackRequirement)).isRequired,
56+
}).isRequired,
57+
};
58+
59+
export default CriterionFeedback;
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import React from 'react';
2+
import { shallow } from '@edx/react-unit-test-utils';
3+
import { feedbackRequirement } from 'data/services/lms/constants';
4+
5+
import CriterionFeedback from './CriterionFeedback';
6+
7+
describe('<CriterionFeedback />', () => {
8+
const props = {
9+
criterion: {
10+
feedbackValue: 'feedback-1',
11+
feedbackIsInvalid: false,
12+
feedbackOnChange: jest.fn().mockName('feedbackOnChange'),
13+
feedbackEnabled: true,
14+
feedbackRequired: feedbackRequirement.required,
15+
},
16+
};
17+
describe('renders', () => {
18+
test('feedbackEnabled', () => {
19+
const wrapper = shallow(<CriterionFeedback {...props} />);
20+
expect(wrapper.snapshot).toMatchSnapshot();
21+
});
22+
23+
test('feedbackDisabled render empty', () => {
24+
const wrapper = shallow(
25+
<CriterionFeedback
26+
{...props}
27+
criterion={{ ...props.criterion, feedbackEnabled: false }}
28+
/>,
29+
);
30+
expect(wrapper.snapshot).toMatchSnapshot();
31+
expect(wrapper.isEmptyRender()).toBe(true);
32+
});
33+
34+
test('feedbackRequired disabled render empty', () => {
35+
const wrapper = shallow(
36+
<CriterionFeedback
37+
{...props}
38+
criterion={{
39+
...props.criterion,
40+
feedbackRequired: feedbackRequirement.disabled,
41+
}}
42+
/>,
43+
);
44+
expect(wrapper.snapshot).toMatchSnapshot();
45+
expect(wrapper.isEmptyRender()).toBe(true);
46+
});
47+
48+
test('feedbackRequired: optional', () => {
49+
const wrapper = shallow(
50+
<CriterionFeedback
51+
{...props}
52+
criterion={{
53+
...props.criterion,
54+
feedbackRequired: feedbackRequirement.optional,
55+
}}
56+
/>,
57+
);
58+
expect(wrapper.snapshot).toMatchSnapshot();
59+
expect(wrapper.instance.findByType('Form.Control')[0].props.floatingLabel).toContain('Optional');
60+
});
61+
62+
test('feedbackIsInvalid', () => {
63+
const wrapper = shallow(
64+
<CriterionFeedback
65+
{...props}
66+
criterion={{ ...props.criterion, feedbackIsInvalid: true }}
67+
/>,
68+
);
69+
expect(wrapper.snapshot).toMatchSnapshot();
70+
expect(wrapper.instance.findByType('Form.Control.Feedback')[0].props.type).toBe('invalid');
71+
});
72+
});
73+
});

0 commit comments

Comments
 (0)