diff --git a/package-lock.json b/package-lock.json index 20b78564..ed895f9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1413,9 +1413,9 @@ } }, "@edx/paragon": { - "version": "13.17.3", - "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-13.17.3.tgz", - "integrity": "sha512-fUjrfNmeWIpEsroK0JuajIBHHh0BIvZTnBusTRqzvl5fFivNuhEdcG33oEZSVvfyRYtCgtnWmSRbvN5vGhjK6g==", + "version": "14.6.0", + "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-14.6.0.tgz", + "integrity": "sha512-q3rnNz+SwYL444Dw/NgQdXEDXi7ocgNe+miNJbgfJz8aB06eu1weQWDmz4IR9S/WALnWZgLb716J2E1qUNvfCQ==", "requires": { "@fortawesome/fontawesome-svg-core": "^1.2.30", "@fortawesome/free-solid-svg-icons": "^5.14.0", @@ -1428,7 +1428,7 @@ "font-awesome": "^4.7.0", "mailto-link": "^1.0.0", "prop-types": "^15.7.2", - "react-bootstrap": "^1.2.2", + "react-bootstrap": "^1.3.0", "react-focus-on": "^3.5.0", "react-popper": "^2.2.4", "react-proptype-conditional-require": "^1.0.4", @@ -2312,9 +2312,9 @@ } }, "@popperjs/core": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.9.1.tgz", - "integrity": "sha512-DvJbbn3dUgMxDnJLH+RZQPnXak1h4ZVYQ7CWiFWjQwBFkVajT4rfw2PdpHLTSTwxrYfnoEXkuBiwkDm6tPMQeA==" + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.9.2.tgz", + "integrity": "sha512-VZMYa7+fXHdwIq1TDhSXoVmSPEGM/aa+6Aiq3nVVJ9bXr24zScr+NlKFKC3iPljA7ho/GAZr+d2jOf5GIRC30Q==" }, "@reduxjs/toolkit": { "version": "1.3.6", @@ -7096,9 +7096,9 @@ } }, "dom-serializer": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.2.0.tgz", - "integrity": "sha512-n6kZFH/KlCrqs/1GHMOd5i2fd/beQHuehKdWvNNffbGHTr/almdhuVvTVFb3V7fglz+nC50fFusu3lY33h12pA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.3.1.tgz", + "integrity": "sha512-Pv2ZluG5ife96udGgEDovOOOA5UELkltfJpnIExPrAk1LTvecolUGn6lIaoLh86d83GiB86CjzciMd9BuRB71Q==", "requires": { "domelementtype": "^2.0.1", "domhandler": "^4.0.0", @@ -7106,11 +7106,11 @@ }, "dependencies": { "domhandler": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.0.0.tgz", - "integrity": "sha512-KPTbnGQ1JeEMQyO1iYXoagsI6so/C96HZiFyByU3T6iAzpXn8EGEvct6unm1ZGoed8ByO2oirxgwxBmqKF9haA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.1.0.tgz", + "integrity": "sha512-/6/kmsGlMY4Tup/nGVutdrK9yQi4YjWVcVeoQmixpzjOUK1U7pQkvAPHBJeUxOgxF0J8f8lwCJSlCfD0V4CMGQ==", "requires": { - "domelementtype": "^2.1.0" + "domelementtype": "^2.2.0" } } } @@ -7122,9 +7122,9 @@ "dev": true }, "domelementtype": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.1.0.tgz", - "integrity": "sha512-LsTgx/L5VpD+Q8lmsXSHW2WpA+eBlZ9HPf3erD1IoPF00/3JKHZ3BknUVA2QGDNu69ZNmyFmCWBSO45XjYKC5w==" + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz", + "integrity": "sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==" }, "domexception": { "version": "2.0.1", @@ -7152,21 +7152,21 @@ } }, "domutils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.5.0.tgz", - "integrity": "sha512-Ho16rzNMOFk2fPwChGh3D2D9OEHAfG19HgmRR2l+WLSsIstNsAYBzePH412bL0y5T44ejABIVfTHQ8nqi/tBCg==", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.5.2.tgz", + "integrity": "sha512-MHTthCb1zj8f1GVfRpeZUbohQf/HdBos0oX5gZcQFepOZPLLRyj6Wn7XS7EMnY7CVpwv8863u2vyE83Hfu28HQ==", "requires": { "dom-serializer": "^1.0.1", - "domelementtype": "^2.0.1", - "domhandler": "^4.0.0" + "domelementtype": "^2.2.0", + "domhandler": "^4.1.0" }, "dependencies": { "domhandler": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.0.0.tgz", - "integrity": "sha512-KPTbnGQ1JeEMQyO1iYXoagsI6so/C96HZiFyByU3T6iAzpXn8EGEvct6unm1ZGoed8ByO2oirxgwxBmqKF9haA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.1.0.tgz", + "integrity": "sha512-/6/kmsGlMY4Tup/nGVutdrK9yQi4YjWVcVeoQmixpzjOUK1U7pQkvAPHBJeUxOgxF0J8f8lwCJSlCfD0V4CMGQ==", "requires": { - "domelementtype": "^2.1.0" + "domelementtype": "^2.2.0" } } } @@ -17754,16 +17754,16 @@ } }, "react-focus-on": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/react-focus-on/-/react-focus-on-3.5.1.tgz", - "integrity": "sha512-6iE56nYNwVU6Pke362TjqRLz/G7DBGnEugkxhPAhpXEZW5og3vhc9qDPlyiHgxoiY9kYTWjdAEFz4nJgSluANg==", + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/react-focus-on/-/react-focus-on-3.5.2.tgz", + "integrity": "sha512-tpPxLqw9tEuElWmcr5jqw/ULfJjdHEnom0nBW9p6y75Zsa0wOfwQNqCHqCoJcHUqSBtKXqTuYduZoSTfTOTdJw==", "requires": { - "aria-hidden": "^1.1.1", - "react-focus-lock": "^2.3.1", - "react-remove-scroll": "^2.4.0", - "react-style-singleton": "^2.1.0", - "use-callback-ref": "^1.2.3", - "use-sidecar": "^1.0.1" + "aria-hidden": "^1.1.2", + "react-focus-lock": "^2.5.0", + "react-remove-scroll": "^2.4.1", + "react-style-singleton": "^2.1.1", + "use-callback-ref": "^1.2.5", + "use-sidecar": "^1.0.5" } }, "react-helmet": { @@ -17815,9 +17815,9 @@ } }, "react-popper": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.2.4.tgz", - "integrity": "sha512-NacOu4zWupdQjVXq02XpTD3yFPSfg5a7fex0wa3uGKVkFK7UN6LvVxgcb+xYr56UCuWiNPMH20tntdVdJRwYew==", + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.2.5.tgz", + "integrity": "sha512-kxGkS80eQGtLl18+uig1UIf9MKixFSyPxglsgLBxlYnyDf65BiY9B3nZSc6C9XUNDgStROB0fMQlTEz1KxGddw==", "requires": { "react-fast-compare": "^3.0.1", "warning": "^4.0.2" @@ -21101,14 +21101,21 @@ "dev": true }, "unbox-primitive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.0.tgz", - "integrity": "sha512-P/51NX+JXyxK/aigg1/ZgyccdAxm5K1+n8+tvqSntjOivPt19gvm1VC49RWYetsiub8WViUchdxl/KWHHB0kzA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz", + "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==", "requires": { "function-bind": "^1.1.1", - "has-bigints": "^1.0.0", - "has-symbols": "^1.0.0", - "which-boxed-primitive": "^1.0.1" + "has-bigints": "^1.0.1", + "has-symbols": "^1.0.2", + "which-boxed-primitive": "^1.0.2" + }, + "dependencies": { + "has-symbols": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz", + "integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==" + } } }, "unbzip2-stream": { diff --git a/package.json b/package.json index 7099a250..796fac79 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "@edx/frontend-component-footer": "10.1.4", "@edx/frontend-enterprise": "4.2.3", "@edx/frontend-platform": "1.8.4", - "@edx/paragon": "13.17.3", + "@edx/paragon": "14.6.0", "@fortawesome/fontawesome-svg-core": "1.2.34", "@fortawesome/free-brands-svg-icons": "5.13.1", "@fortawesome/free-regular-svg-icons": "5.13.1", diff --git a/src/course-home/data/__factories__/progressTabData.factory.js b/src/course-home/data/__factories__/progressTabData.factory.js index 5a508d78..a009ba40 100644 --- a/src/course-home/data/__factories__/progressTabData.factory.js +++ b/src/course-home/data/__factories__/progressTabData.factory.js @@ -11,6 +11,7 @@ Factory.define('progressTabData') locked_count: 0, }, course_grade: { + letter_grade: null, percent: 0, is_passing: false, }, diff --git a/src/course-home/data/api.js b/src/course-home/data/api.js index 4ac3268d..c692bb62 100644 --- a/src/course-home/data/api.js +++ b/src/course-home/data/api.js @@ -131,7 +131,9 @@ export async function getProgressTabData(courseId) { const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/progress/${courseId}`; try { const { data } = await getAuthenticatedHttpClient().get(url); - return camelCaseObject(data); + const camelCasedData = camelCaseObject(data); + camelCasedData.gradingPolicy.gradeRange = data.grading_policy.grade_range; + return camelCasedData; } catch (error) { const { httpErrorStatus } = error && error.customAttributes; if (httpErrorStatus === 404) { diff --git a/src/course-home/progress-tab/ProgressTab.test.jsx b/src/course-home/progress-tab/ProgressTab.test.jsx index 416423cb..327073b9 100644 --- a/src/course-home/progress-tab/ProgressTab.test.jsx +++ b/src/course-home/progress-tab/ProgressTab.test.jsx @@ -5,7 +5,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import MockAdapter from 'axios-mock-adapter'; import { - initializeMockApp, logUnhandledRequests, render, screen, act, + fireEvent, initializeMockApp, logUnhandledRequests, render, screen, act, } from '../../setupTest'; import { appendBrowserTimezoneToUrl, executeThunk } from '../../utils'; import * as thunks from '../data/thunks'; @@ -47,6 +47,117 @@ describe('Progress Tab', () => { logUnhandledRequests(axiosMock); }); + describe('Course Grade', () => { + it('renders Course Grade', async () => { + await fetchAndRender(); + expect(screen.getByText('Grades')).toBeInTheDocument(); + expect(screen.getByText('This represents your weighted grade against the grade needed to pass this course.')).toBeInTheDocument(); + }); + + it('renders correct copy for non-passing', async () => { + await fetchAndRender(); + expect(screen.queryByRole('button', { name: 'Grade range tooltip' })).not.toBeInTheDocument(); + expect(screen.getByText('A weighted grade of 75% is required to pass in this course')).toBeInTheDocument(); + }); + + it('renders correct copy for passing with pass/fail grade range', async () => { + setTabData({ + course_grade: { + is_passing: true, + letter_grade: 'Pass', + percent: 0.9, + }, + }); + await fetchAndRender(); + expect(screen.queryByRole('button', { name: 'Grade range tooltip' })).not.toBeInTheDocument(); + expect(screen.getByText('You’re currently passing this course')).toBeInTheDocument(); + }); + + it('renders correct copy and tooltip for non-passing with letter grade range', async () => { + setTabData({ + course_grade: { + is_passing: false, + letter_grade: null, + percent: 0, + }, + grading_policy: { + assignment_policies: [ + { + num_droppable: 1, + short_label: 'HW', + type: 'Homework', + weight: 1, + }, + ], + grade_range: { + A: 0.9, + B: 0.8, + }, + }, + }); + await fetchAndRender(); + expect(screen.getByRole('button', { name: 'Grade range tooltip' })); + expect(screen.getByText('A weighted grade of 80% is required to pass in this course')).toBeInTheDocument(); + }); + + it('renders correct copy and tooltip for passing with letter grade range', async () => { + setTabData({ + course_grade: { + is_passing: true, + letter_grade: 'B', + percent: 0.85, + }, + grading_policy: { + assignment_policies: [ + { + num_droppable: 1, + short_label: 'HW', + type: 'Homework', + weight: 1, + }, + ], + grade_range: { + A: 0.9, + B: 0.8, + }, + }, + }); + await fetchAndRender(); + expect(screen.getByRole('button', { name: 'Grade range tooltip' })); + expect(await screen.findByText('You’re currently passing this course with a grade of B (80-90%)')).toBeInTheDocument(); + }); + + it('renders tooltip for grade range', async () => { + setTabData({ + course_grade: { + percent: 0, + is_passing: false, + }, + grading_policy: { + assignment_policies: [ + { + num_droppable: 1, + short_label: 'HW', + type: 'Homework', + weight: 1, + }, + ], + grade_range: { + A: 0.9, + B: 0.8, + }, + }, + }); + await fetchAndRender(); + const tooltip = await screen.getByRole('button', { name: 'Grade range tooltip' }); + fireEvent.click(tooltip); + expect(screen.getByText('Grade ranges for this course:')); + expect(screen.getByText('A: 90%-100%')); + expect(screen.getByText('B: 80%-90%')); + expect(screen.getByText('F: <80%')); + }); + }); + describe('Grade Summary', () => { it('renders Grade Summary table when assignment policies are populated', async () => { await fetchAndRender(); diff --git a/src/course-home/progress-tab/grades/course-grade/CourseGrade.jsx b/src/course-home/progress-tab/grades/course-grade/CourseGrade.jsx index 25798c70..ea655fc2 100644 --- a/src/course-home/progress-tab/grades/course-grade/CourseGrade.jsx +++ b/src/course-home/progress-tab/grades/course-grade/CourseGrade.jsx @@ -1,13 +1,52 @@ import React from 'react'; +import { useSelector } from 'react-redux'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; + +import { useModel } from '../../../../generic/model-store'; + +import CourseGradeFooter from './CourseGradeFooter'; +import GradeBar from './GradeBar'; + +import messages from '../messages'; + +function CourseGrade({ intl }) { + const { + courseId, + } = useSelector(state => state.courseHome); + + const { + gradingPolicy: { + gradeRange, + }, + } = useModel('progress', courseId); + + let passingGrade; + if (gradeRange.pass) { + passingGrade = gradeRange.pass * 100; + } else { + passingGrade = Object.entries(gradeRange).pop()[1] * 100; + } + + passingGrade = Number(passingGrade.toFixed(0)); -function CourseGrade() { return ( -
- {/* TODO: AA-721 */} -

Grades

-

This represents your weighted grade against the grade needed to pass this course.

+
+
+
+

{intl.formatMessage(messages.grades)}

+

+ {intl.formatMessage(messages.courseGradeBody)} +

+
+ +
+
); } -export default CourseGrade; +CourseGrade.propTypes = { + intl: intlShape.isRequired, +}; + +export default injectIntl(CourseGrade); diff --git a/src/course-home/progress-tab/grades/course-grade/CourseGradeFooter.jsx b/src/course-home/progress-tab/grades/course-grade/CourseGradeFooter.jsx new file mode 100644 index 00000000..e7dc1c04 --- /dev/null +++ b/src/course-home/progress-tab/grades/course-grade/CourseGradeFooter.jsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import PropTypes from 'prop-types'; + +import { layoutGenerator } from 'react-break'; + +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { CheckCircle, WarningFilled } from '@edx/paragon/icons'; +import { Icon } from '@edx/paragon'; +import { useModel } from '../../../../generic/model-store'; + +import GradeRangeTooltip from './GradeRangeTooltip'; +import messages from '../messages'; + +function CourseGradeFooter({ intl, passingGrade }) { + const { + courseId, + } = useSelector(state => state.courseHome); + + const { + courseGrade: { + isPassing, + letterGrade, + }, + gradingPolicy: { + gradeRange, + }, + } = useModel('progress', courseId); + + const layout = layoutGenerator({ + mobile: 0, + tablet: 768, + }); + + const OnMobile = layout.is('mobile'); + const OnAtLeastTablet = layout.isAtLeast('tablet'); + + const hasLetterGrades = !gradeRange.pass; + let footerText = intl.formatMessage(messages.courseGradeFooterNonPassing, { passingGrade }); + + if (isPassing) { + if (hasLetterGrades) { + const letterGrades = Object.keys(gradeRange); + const gradeIndex = letterGrades.indexOf(letterGrade); + const minGrade = gradeRange[letterGrade] * 100; + const maxGrade = gradeIndex > 0 ? gradeRange[letterGrades[gradeIndex - 1]] * 100 : 100; + + footerText = intl.formatMessage(messages.courseGradeFooterPassingWithGrade, { + letterGrade, + minGrade: minGrade.toFixed(0), + maxGrade: maxGrade.toFixed(0), + }); + } else { + footerText = intl.formatMessage(messages.courseGradeFooterGenericPassing); + } + } + + const footerHtml = ( + <> + + {footerText} + + + {footerText} + + + ); + + return ( +
+
+ {isPassing && ( + + )} + {!isPassing && ( + + )} +
+
+ {!hasLetterGrades && footerHtml} + {hasLetterGrades && ( +
+
+ {footerHtml} +
+ +
+ )} +
+
+ ); +} + +CourseGradeFooter.propTypes = { + intl: intlShape.isRequired, + passingGrade: PropTypes.number.isRequired, +}; + +export default injectIntl(CourseGradeFooter); diff --git a/src/course-home/progress-tab/grades/course-grade/CurrentGradeTooltip.jsx b/src/course-home/progress-tab/grades/course-grade/CurrentGradeTooltip.jsx new file mode 100644 index 00000000..c9706b9c --- /dev/null +++ b/src/course-home/progress-tab/grades/course-grade/CurrentGradeTooltip.jsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; + +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { OverlayTrigger, Popover } from '@edx/paragon'; + +import { useModel } from '../../../../generic/model-store'; + +import messages from '../messages'; + +function CurrentGradeTooltip({ intl }) { + const { + courseId, + } = useSelector(state => state.courseHome); + + const { + courseGrade: { + isPassing, + percent, + }, + } = useModel('progress', courseId); + + const currentGrade = percent * 100; + + return ( + <> + + + {intl.formatMessage(messages.currentGradeLabel)} + + + ); +} + +CurrentGradeTooltip.propTypes = { + intl: intlShape.isRequired, +}; + +export default injectIntl(CurrentGradeTooltip); diff --git a/src/course-home/progress-tab/grades/course-grade/GradeBar.jsx b/src/course-home/progress-tab/grades/course-grade/GradeBar.jsx new file mode 100644 index 00000000..d587498c --- /dev/null +++ b/src/course-home/progress-tab/grades/course-grade/GradeBar.jsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import PropTypes from 'prop-types'; + +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useModel } from '../../../../generic/model-store'; +import CurrentGradeTooltip from './CurrentGradeTooltip'; +import PassingGradeTooltip from './PassingGradeTooltip'; + +import messages from '../messages'; + +function GradeBar({ intl, passingGrade }) { + const { + courseId, + } = useSelector(state => state.courseHome); + + const { + courseGrade: { + isPassing, + percent, + }, + } = useModel('progress', courseId); + + const currentGrade = percent * 100; + + return ( +
+
{intl.formatMessage(messages.courseGradeBarAltText, { currentGrade, passingGrade })}
+ +
+ ); +} + +GradeBar.propTypes = { + intl: intlShape.isRequired, + passingGrade: PropTypes.number.isRequired, +}; + +export default injectIntl(GradeBar); diff --git a/src/course-home/progress-tab/grades/course-grade/GradeBar.scss b/src/course-home/progress-tab/grades/course-grade/GradeBar.scss new file mode 100644 index 00000000..7ae6a616 --- /dev/null +++ b/src/course-home/progress-tab/grades/course-grade/GradeBar.scss @@ -0,0 +1,48 @@ +.grade-bar { + rect { + height: 6px; + } + + .grade-bar__base { + fill: $light-300; + } + + .grade-bar__divider { + fill: $primary-500; + width: 1px; + } + + .grade-bar--passing { + fill: $primary-500; + } + + .grade-bar--current-passing { + fill: $success-500; + } + + .grade-bar--current-non-passing { + fill: $accent-b; + } +} + +#minimum-grade-tooltip { + .arrow::after { + border-bottom-color: $primary-500; + } +} + +#passing-grade-tooltip { + .arrow::after { + border-top-color: $success-500; + } + + background: $success-500; +} + +#non-passing-grade-tooltip { + .arrow::after { + border-top-color: $accent-b; + } + + background: $accent-b; +} diff --git a/src/course-home/progress-tab/grades/course-grade/GradeRangeTooltip.jsx b/src/course-home/progress-tab/grades/course-grade/GradeRangeTooltip.jsx new file mode 100644 index 00000000..c7b96155 --- /dev/null +++ b/src/course-home/progress-tab/grades/course-grade/GradeRangeTooltip.jsx @@ -0,0 +1,79 @@ +import React, { useState } from 'react'; +import { useSelector } from 'react-redux'; +import PropTypes from 'prop-types'; + +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { InfoOutline } from '@edx/paragon/icons'; +import { + Icon, IconButton, OverlayTrigger, Popover, +} from '@edx/paragon'; +import { useModel } from '../../../../generic/model-store'; + +import messages from '../messages'; + +function GradeRangeTooltip({ intl, passingGrade }) { + const { + courseId, + } = useSelector(state => state.courseHome); + + const { + gradingPolicy: { + gradeRange, + }, + } = useModel('progress', courseId); + + const [showTooltip, setShowTooltip] = useState(false); + + const gradeRangeEntries = Object.entries(gradeRange); + + return ( +
+ + + {intl.formatMessage(messages.courseGradeRangeTooltip)} +
    + {gradeRangeEntries.map((entry, index) => { + if (index === 0) { + return ( +
  • + {entry[0]}: {(entry[1] * 100).toFixed(0)}%-100% +
  • + ); + } + const previousGrade = gradeRangeEntries[index - 1]; + return ( +
  • + {entry[0]}: {(entry[1] * 100).toFixed(0)}%-{(previousGrade[1] * 100).toFixed(0)}% +
  • + ); + })} +
  • F: {'<'}{passingGrade}%
  • +
+
+ + )} + > + setShowTooltip(!showTooltip)} + onBlur={() => setShowTooltip(false)} + alt={intl.formatMessage(messages.gradeRangeTooltipAlt)} + className="mt-n1" + src={InfoOutline} + iconAs={Icon} + /> +
+
+ ); +} + +GradeRangeTooltip.propTypes = { + intl: intlShape.isRequired, + passingGrade: PropTypes.number.isRequired, +}; + +export default injectIntl(GradeRangeTooltip); diff --git a/src/course-home/progress-tab/grades/course-grade/PassingGradeTooltip.jsx b/src/course-home/progress-tab/grades/course-grade/PassingGradeTooltip.jsx new file mode 100644 index 00000000..8422545f --- /dev/null +++ b/src/course-home/progress-tab/grades/course-grade/PassingGradeTooltip.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { OverlayTrigger, Popover } from '@edx/paragon'; + +import messages from '../messages'; + +function PassingGradeTooltip({ intl, passingGrade }) { + return ( + <> + + + + {intl.formatMessage(messages.passingGradeLabel)} + + + ); +} + +PassingGradeTooltip.propTypes = { + intl: intlShape.isRequired, + passingGrade: PropTypes.number.isRequired, +}; + +export default injectIntl(PassingGradeTooltip); diff --git a/src/course-home/progress-tab/grades/grade-summary/GradeSummaryHeader.jsx b/src/course-home/progress-tab/grades/grade-summary/GradeSummaryHeader.jsx index d5416b9d..6b912367 100644 --- a/src/course-home/progress-tab/grades/grade-summary/GradeSummaryHeader.jsx +++ b/src/course-home/progress-tab/grades/grade-summary/GradeSummaryHeader.jsx @@ -1,27 +1,39 @@ -import React from 'react'; +import React, { useState } from 'react'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { Icon, OverlayTrigger, Popover } from '@edx/paragon'; +import { + Icon, IconButton, OverlayTrigger, Popover, +} from '@edx/paragon'; import { InfoOutline } from '@edx/paragon/icons'; import messages from '../messages'; function GradeSummaryHeader({ intl }) { + const [showTooltip, setShowTooltip] = useState(false); return (

{intl.formatMessage(messages.gradeSummary)}

- {intl.formatMessage(messages.gradeSummaryTooltip)} + {intl.formatMessage(messages.gradeSummaryTooltipBody)} )} > - + { setShowTooltip(!showTooltip); }} + onBlur={() => { setShowTooltip(false); }} + alt={intl.formatMessage(messages.gradeSummaryTooltipAlt)} + src={InfoOutline} + iconAs={Icon} + className="mb-3" + style={{ height: '1rem', width: '1rem' }} + />
); diff --git a/src/course-home/progress-tab/grades/messages.js b/src/course-home/progress-tab/grades/messages.js index ddaca03c..468f2fd8 100644 --- a/src/course-home/progress-tab/grades/messages.js +++ b/src/course-home/progress-tab/grades/messages.js @@ -9,10 +9,38 @@ const messages = defineMessages({ id: 'progress.footnotes.backToContent', defaultMessage: 'Back to content', }, + courseGradeBody: { + id: 'progress.courseGrade.body', + defaultMessage: 'This represents your weighted grade against the grade needed to pass this course.', + }, + courseGradeBarAltText: { + id: 'progress.courseGrade.gradeBar.altText', + defaultMessage: 'Your current grade is {currentGrade}%. A weighted grade of {passingGrade}% is required to pass in this course.', + }, + courseGradeFooterGenericPassing: { + id: 'progress.courseGrade.footer.generic.passing', + defaultMessage: 'You’re currently passing this course', + }, + courseGradeFooterNonPassing: { + id: 'progress.courseGrade.footer.nonPassing', + defaultMessage: 'A weighted grade of {passingGrade}% is required to pass in this course', + }, + courseGradeFooterPassingWithGrade: { + id: 'progress.courseGrade.footer.passing', + defaultMessage: 'You’re currently passing this course with a grade of {letterGrade} ({minGrade}-{maxGrade}%)', + }, + courseGradeRangeTooltip: { + id: 'progress.courseGrade.gradeRange.tooltip', + defaultMessage: 'Grade ranges for this course:', + }, courseOutline: { id: 'progress.courseOutline', defaultMessage: 'Course Outline', }, + currentGradeLabel: { + id: 'progress.courseGrade.label.currentGrade', + defaultMessage: 'Your current grade', + }, detailedGrades: { id: 'progress.detailedGrades', defaultMessage: 'Detailed grades', @@ -25,16 +53,32 @@ const messages = defineMessages({ id: 'progress.footnotes.title', defaultMessage: 'Grade summary footnotes', }, + grades: { + id: 'progress.courseGrade.grades', + defaultMessage: 'Grades', + }, + gradeRangeTooltipAlt: { + id: 'progress.courseGrade.gradeRange.Tooltip', + defaultMessage: 'Grade range tooltip', + }, gradeSummary: { id: 'progress.gradeSummary', defaultMessage: 'Grade summary', }, - gradeSummaryTooltip: { - id: 'progress.gradeSummary.tooltip', + gradeSummaryTooltipAlt: { + id: 'progress.gradeSummary.tooltip.alt', + defaultMessage: 'Grade summary tooltip', + }, + gradeSummaryTooltipBody: { + id: 'progress.gradeSummary.tooltip.body', defaultMessage: "Your course assignment's weight is determined by your instructor. " + 'By multiplying your score by the weight for that assignment type, your weighted grade is calculated. ' + "Your weighted grade is what's used to determine if you pass the course.", }, + passingGradeLabel: { + id: 'progress.courseGrade.label.passingGrade', + defaultMessage: 'Passing grade', + }, score: { id: 'progress.score', defaultMessage: 'Score', diff --git a/src/index.scss b/src/index.scss index 8bfcfc35..239eae51 100755 --- a/src/index.scss +++ b/src/index.scss @@ -370,6 +370,7 @@ @import 'course-home/outline-tab/widgets/UpgradeCard.scss'; @import 'course-home/outline-tab/widgets/ProctoringInfoPanel.scss'; @import 'course-home/progress-tab/course-completion/CompletionDonutChart.scss'; +@import 'course-home/progress-tab/grades/course-grade/GradeBar.scss'; @import 'courseware/course/course-exit/CourseRecommendationsExp/course_recommendations.exp'; /** [MM-P2P] Experiment */ diff --git a/src/setupTest.js b/src/setupTest.js index 5b22c3e8..7ae18abf 100755 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -40,16 +40,22 @@ window.getComputedStyle = jest.fn(() => ({ // run into `TypeError: window.matchMedia is not a function`. This avoids that for all of our tests now. Object.defineProperty(window, 'matchMedia', { writable: true, - value: jest.fn().mockImplementation(query => ({ - matches: false, - media: query, - onchange: null, - addListener: jest.fn(), // deprecated - removeListener: jest.fn(), // deprecated - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn(), - })), + value: jest.fn().mockImplementation(query => { + // Returns true given a mediaQuery for a screen size greater than 768px (this exact query is what react-break sends) + // Without this, if we hardcode `matches` to either true or false, either all or none of the breakpoints match, + // respectively. + const matches = !!(query === 'screen and (min-width: 768px)'); + return { + matches, + media: query, + onchange: null, + addListener: jest.fn(), // deprecated + removeListener: jest.fn(), // deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + }; + }), }); export const authenticatedUser = {