diff --git a/.env b/.env index b0129d312a..30547f1633 100644 --- a/.env +++ b/.env @@ -6,6 +6,7 @@ NODE_ENV='production' ACCESS_TOKEN_COOKIE_NAME='' APP_ID='learning' BASE_URL='' +COMPLETION_PERCENTAGE_PRECISION='' CONTACT_URL='' CREDENTIALS_BASE_URL='' CREDIT_HELP_LINK_URL='' diff --git a/.env.development b/.env.development index 026d03eeeb..7d51ea8580 100644 --- a/.env.development +++ b/.env.development @@ -6,6 +6,7 @@ NODE_ENV='development' ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' APP_ID='learning' BASE_URL='http://localhost:2000' +COMPLETION_PERCENTAGE_PRECISION='' CONTACT_URL='http://localhost:18000/contact' CREDENTIALS_BASE_URL='http://localhost:18150' CREDIT_HELP_LINK_URL='https://help.edx.org/edxlearner/s/article/Can-I-receive-college-credit-or-credit-hours-for-my-course' diff --git a/.env.test b/.env.test index bc8511cb21..7997446678 100644 --- a/.env.test +++ b/.env.test @@ -6,6 +6,7 @@ NODE_ENV='test' ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' APP_ID='learning' BASE_URL='http://localhost:2000' +COMPLETION_PERCENTAGE_PRECISION='' CONTACT_URL='http://localhost:18000/contact' CREDENTIALS_BASE_URL='http://localhost:18150' CREDIT_HELP_LINK_URL='https://help.edx.org/edxlearner/s/article/Can-I-receive-college-credit-or-credit-hours-for-my-course' diff --git a/README.rst b/README.rst index 4080246780..335139ca97 100644 --- a/README.rst +++ b/README.rst @@ -148,6 +148,10 @@ as documented in the Open edX Developer Guide under The learning micro-frontend also supports the following additional variables: +COMPLETION_PERCENTAGE_PRECISION + The number of decimal places used when rounding the percentages shown in the + completion donut chart on the progress tab. Defaults to ``0``. + CREDIT_HELP_LINK_URL A link to resources to help explain what course credit is and how to earn it. diff --git a/src/course-home/progress-tab/course-completion/CompletionDonutChart.jsx b/src/course-home/progress-tab/course-completion/CompletionDonutChart.jsx index bc47deb50f..355306b737 100644 --- a/src/course-home/progress-tab/course-completion/CompletionDonutChart.jsx +++ b/src/course-home/progress-tab/course-completion/CompletionDonutChart.jsx @@ -1,3 +1,4 @@ +import { getConfig } from '@edx/frontend-platform'; import { getLocale, isRtl, useIntl } from '@edx/frontend-platform/i18n'; import { useProgressData } from '../hooks'; @@ -17,10 +18,14 @@ const CompletionDonutChart = () => { }, } = useProgressData(); + const precision = Number(getConfig().COMPLETION_PERCENTAGE_PRECISION) || 0; + const toPercentage = (count, total) => Number(((count / total) * 100).toFixed(precision)); + const formatPercentage = (percentage) => intl.formatNumber(percentage, { maximumFractionDigits: precision }); + const numTotalUnits = completeCount + incompleteCount + lockedCount; - const completePercentage = completeCount ? Number(((completeCount / numTotalUnits) * 100).toFixed(0)) : 0; - const lockedPercentage = lockedCount ? Number(((lockedCount / numTotalUnits) * 100).toFixed(0)) : 0; - const incompletePercentage = 100 - completePercentage - lockedPercentage; + const completePercentage = completeCount ? toPercentage(completeCount, numTotalUnits) : 0; + const lockedPercentage = lockedCount ? toPercentage(lockedCount, numTotalUnits) : 0; + const incompletePercentage = Number((100 - completePercentage - lockedPercentage).toFixed(precision)); const isLocaleRtl = isRtl(getLocale()); @@ -33,7 +38,7 @@ const CompletionDonutChart = () => { - {completePercentage}{isLocaleRtl && '\u200f'}% + {formatPercentage(completePercentage)}{isLocaleRtl && '\u200f'}% {intl.formatMessage(messages.donutLabel)} @@ -44,11 +49,11 @@ const CompletionDonutChart = () => {
- {intl.formatMessage(messages.percentComplete, { percent: completePercentage })} - {intl.formatMessage(messages.percentIncomplete, { percent: incompletePercentage })} + {intl.formatMessage(messages.percentComplete, { percent: formatPercentage(completePercentage) })} + {intl.formatMessage(messages.percentIncomplete, { percent: formatPercentage(incompletePercentage) })} {lockedPercentage > 0 && ( <> - {intl.formatMessage(messages.percentLocked, { percent: lockedPercentage })} + {intl.formatMessage(messages.percentLocked, { percent: formatPercentage(lockedPercentage) })} )}
diff --git a/src/course-home/progress-tab/course-completion/CompletionDonutChart.test.jsx b/src/course-home/progress-tab/course-completion/CompletionDonutChart.test.jsx new file mode 100644 index 0000000000..f4732cd6e6 --- /dev/null +++ b/src/course-home/progress-tab/course-completion/CompletionDonutChart.test.jsx @@ -0,0 +1,106 @@ +import { mergeConfig } from '@edx/frontend-platform'; + +import { initializeMockApp, render } from '../../../setupTest'; +import { useProgressData } from '../hooks'; +import CompletionDonutChart from './CompletionDonutChart'; + +jest.mock('../hooks', () => ({ + useProgressData: jest.fn(), +})); + +describe('CompletionDonutChart', () => { + const setPrecision = (COMPLETION_PERCENTAGE_PRECISION) => { + mergeConfig({ COMPLETION_PERCENTAGE_PRECISION }); + }; + + beforeAll(async () => { + initializeMockApp(); + }); + + beforeEach(() => { + // 4 complete, 7 incomplete and 1 locked unit out of 12, so that none of the + // percentages can be represented exactly. + useProgressData.mockReturnValue({ + completionSummary: { + completeCount: 4, + incompleteCount: 7, + lockedCount: 1, + }, + }); + }); + + afterEach(() => { + setPrecision(0); + }); + + // The length of a donut segment is the first value of its `stroke-dasharray`. + const getSegmentLength = (container, segmentClass) => ( + container.querySelector(`.${segmentClass}`)?.getAttribute('stroke-dasharray').split(' ')[0] + ); + + const renderChart = () => { + const { container } = render(); + return { + donutPercentage: container.querySelector('.donut-chart-number').textContent, + screenReaderText: container.querySelector('.sr-only').textContent, + completeSegment: getSegmentLength(container, 'complete-stroke'), + incompleteSegment: getSegmentLength(container, 'incomplete-stroke'), + lockedSegment: getSegmentLength(container, 'locked-stroke'), + }; + }; + + it('rounds the percentages to whole numbers by default', () => { + const { donutPercentage, screenReaderText } = renderChart(); + + expect(donutPercentage).toEqual('33%'); + expect(screenReaderText).toContain('You have completed 33% of content in this course.'); + expect(screenReaderText).toContain('You have not completed 59% of content in this course'); + expect(screenReaderText).toContain('8% of content in this course is locked'); + }); + + it('rounds the percentages to the configured precision', () => { + setPrecision(2); + const { donutPercentage, screenReaderText } = renderChart(); + + expect(donutPercentage).toEqual('33.33%'); + expect(screenReaderText).toContain('You have completed 33.33% of content in this course.'); + expect(screenReaderText).toContain('You have not completed 58.34% of content in this course'); + expect(screenReaderText).toContain('8.33% of content in this course is locked'); + }); + + it('draws segments that add up to 100% without floating point residue', () => { + setPrecision(2); + const { completeSegment, incompleteSegment, lockedSegment } = renderChart(); + + // 100 - 33.33 - 8.33 evaluates to 58.339999999999996 before rounding. + expect([completeSegment, incompleteSegment, lockedSegment]).toEqual(['33.33', '58.34', '8.33']); + }); + + it('falls back to whole numbers for a non-numeric precision', () => { + setPrecision('two'); + const { donutPercentage } = renderChart(); + + expect(donutPercentage).toEqual('33%'); + }); + + it('omits the segments for units that are not present', () => { + useProgressData.mockReturnValue({ + completionSummary: { + completeCount: 0, + incompleteCount: 3, + lockedCount: 0, + }, + }); + setPrecision(2); + const { + donutPercentage, screenReaderText, completeSegment, incompleteSegment, lockedSegment, + } = renderChart(); + + expect(donutPercentage).toEqual('0%'); + expect(screenReaderText).toContain('You have not completed 100% of content in this course'); + expect(screenReaderText).not.toContain('is locked'); + expect(completeSegment).toBeUndefined(); + expect(incompleteSegment).toEqual('100'); + expect(lockedSegment).toBeUndefined(); + }); +}); diff --git a/src/index.jsx b/src/index.jsx index e73043499b..c2ea86585a 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -156,6 +156,7 @@ initialize({ config: () => { /* istanbul ignore next */ mergeConfig({ + COMPLETION_PERCENTAGE_PRECISION: process.env.COMPLETION_PERCENTAGE_PRECISION || 0, CONTACT_URL: process.env.CONTACT_URL || null, CREDENTIALS_BASE_URL: process.env.CREDENTIALS_BASE_URL || null, CREDIT_HELP_LINK_URL: process.env.CREDIT_HELP_LINK_URL || null,