diff --git a/src/course-home/data/api.js b/src/course-home/data/api.js index 22b6738df4..c94308ac3b 100644 --- a/src/course-home/data/api.js +++ b/src/course-home/data/api.js @@ -137,6 +137,7 @@ export function normalizeOutlineBlocks(courseId, blocks) { resumeBlock: block.resume_block, sequenceIds: block.children || [], hideFromTOC: block.hide_from_toc, + optionalCompletion: block.optional_completion, }; break; @@ -155,6 +156,7 @@ export function normalizeOutlineBlocks(courseId, blocks) { title: block.display_name, hideFromTOC: block.hide_from_toc, navigationDisabled: block.navigation_disabled, + optionalCompletion: block.optional_completion, }; break; diff --git a/src/course-home/outline-tab/Section.jsx b/src/course-home/outline-tab/Section.jsx index 4e013c73cb..b36c158657 100644 --- a/src/course-home/outline-tab/Section.jsx +++ b/src/course-home/outline-tab/Section.jsx @@ -1,7 +1,9 @@ import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { Collapsible, IconButton, Icon } from '@openedx/paragon'; +import { + Badge, Collapsible, IconButton, Icon, +} from '@openedx/paragon'; import { faCheckCircle as fasCheckCircle, faMinus, faPlus } from '@fortawesome/free-solid-svg-icons'; import { faCheckCircle as farCheckCircle } from '@fortawesome/free-regular-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -25,6 +27,7 @@ const Section = ({ sequenceIds, title, hideFromTOC, + optionalCompletion, } = section; const { courseBlocks: { @@ -82,6 +85,11 @@ const Section = ({ )} )} + {optionalCompletion && ( + + {intl.formatMessage(messages.optionalCompletion)} + + )} ); diff --git a/src/course-home/outline-tab/SequenceLink.jsx b/src/course-home/outline-tab/SequenceLink.jsx index 41af780c19..9b5ff6cf97 100644 --- a/src/course-home/outline-tab/SequenceLink.jsx +++ b/src/course-home/outline-tab/SequenceLink.jsx @@ -11,7 +11,7 @@ import { faCheckCircle as fasCheckCircle } from '@fortawesome/free-solid-svg-ico import { faCheckCircle as farCheckCircle } from '@fortawesome/free-regular-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Icon } from '@openedx/paragon'; +import { Badge, Icon } from '@openedx/paragon'; import { Block } from '@openedx/paragon/icons'; import EffortEstimate from '../../shared/effort-estimate'; import { useModel } from '../../generic/model-store'; @@ -31,6 +31,7 @@ const SequenceLink = ({ showLink, title, hideFromTOC, + optionalCompletion, } = sequence; const { userTimezone, @@ -108,12 +109,17 @@ const SequenceLink = ({ /> )} -
+
{displayTitle} , {intl.formatMessage(complete ? messages.completedAssignment : messages.incompleteAssignment)} + {optionalCompletion && ( + + {intl.formatMessage(messages.optionalCompletion)} + + )}
{hideFromTOC && ( diff --git a/src/course-home/outline-tab/messages.js b/src/course-home/outline-tab/messages.js index 46ac3bd2f0..faf5d0dda4 100644 --- a/src/course-home/outline-tab/messages.js +++ b/src/course-home/outline-tab/messages.js @@ -109,6 +109,11 @@ const messages = defineMessages({ defaultMessage: 'Open', description: 'A button to open the given section of the course outline', }, + optionalCompletion: { + id: 'learning.outline.optionalBlock', + defaultMessage: 'Optional', + description: 'Used as a label to indicate that a section or sequence is optional.', + }, proctoringInfoPanel: { id: 'learning.proctoringPanel.header', defaultMessage: 'This course contains proctored exams', diff --git a/src/course-home/progress-tab/course-completion/CompletionDonutChart.jsx b/src/course-home/progress-tab/course-completion/CompletionDonutChart.jsx index 54b6caa9c6..de8ce01f62 100644 --- a/src/course-home/progress-tab/course-completion/CompletionDonutChart.jsx +++ b/src/course-home/progress-tab/course-completion/CompletionDonutChart.jsx @@ -3,6 +3,7 @@ import { useSelector } from 'react-redux'; import { getLocale, injectIntl, intlShape, isRtl, } from '@edx/frontend-platform/i18n'; +import PropTypes from 'prop-types'; import { useModel } from '../../../generic/model-store'; import CompleteDonutSegment from './CompleteDonutSegment'; @@ -10,18 +11,16 @@ import IncompleteDonutSegment from './IncompleteDonutSegment'; import LockedDonutSegment from './LockedDonutSegment'; import messages from './messages'; -const CompletionDonutChart = ({ intl }) => { +const CompletionDonutChart = ({ intl, optional = false }) => { const { courseId, } = useSelector(state => state.courseHome); - const { - completionSummary: { - completeCount, - incompleteCount, - lockedCount, - }, - } = useModel('progress', courseId); + const progress = useModel('progress', courseId); + const completionSummary = progress?.completionSummary || {}; + const completeCount = optional ? completionSummary.optionalCompleteCount : completionSummary.completeCount; + const incompleteCount = optional ? completionSummary.optionalIncompleteCount : completionSummary.incompleteCount; + const lockedCount = optional ? completionSummary.optionalLockedCount : completionSummary.lockedCount; const numTotalUnits = completeCount + incompleteCount + lockedCount; const completePercentage = completeCount ? Number(((completeCount / numTotalUnits) * 100).toFixed(0)) : 0; @@ -30,6 +29,25 @@ const CompletionDonutChart = ({ intl }) => { const isLocaleRtl = isRtl(getLocale()); + if (optional && numTotalUnits === 0) { + return <>; + } + + const optionalTotalUnits = ( + completionSummary.optionalCompleteCount + + completionSummary.optionalIncompleteCount + + completionSummary.optionalLockedCount + ); + + let label; + if (optional) { + label = intl.formatMessage(messages.optionalDonutLabel); + } else if (optionalTotalUnits > 0) { + label = intl.formatMessage(messages.requiredDonutLabel); + } else { + label = intl.formatMessage(messages.donutLabel); + } + return ( <>
+
diff --git a/src/course-home/progress-tab/course-completion/messages.js b/src/course-home/progress-tab/course-completion/messages.js index 08bb8f59db..5273a8fcda 100644 --- a/src/course-home/progress-tab/course-completion/messages.js +++ b/src/course-home/progress-tab/course-completion/messages.js @@ -4,7 +4,17 @@ const messages = defineMessages({ donutLabel: { id: 'progress.completion.donut.label', defaultMessage: 'completed', - description: 'Label text for progress donut chart', + description: 'Label text for progress donut chart when only one donut is shown', + }, + requiredDonutLabel: { + id: 'progress.completion.requiredDonut.label', + defaultMessage: 'required', + description: 'Label text for required progress donut chart when optional content is present', + }, + optionalDonutLabel: { + id: 'progress.completion.optionalDonut.label', + defaultMessage: 'optional', + description: 'Label text for optional progress donut chart', }, completionBody: { id: 'progress.completion.body', diff --git a/src/courseware/course/sequence/Unit/index.jsx b/src/courseware/course/sequence/Unit/index.jsx index e9769917bc..8fcc267360 100644 --- a/src/courseware/course/sequence/Unit/index.jsx +++ b/src/courseware/course/sequence/Unit/index.jsx @@ -29,6 +29,7 @@ const Unit = ({ const unit = useModel(modelKeys.units, id); const isProcessing = unit.bookmarkedUpdateState === 'loading'; const view = authenticatedUser ? views.student : views.public; + const { optionalCompletion } = unit; const getUrl = usePluginsCallback('getIFrameUrl', () => getIFrameUrl({ id, @@ -51,6 +52,11 @@ const Unit = ({ isBookmarked={unit.bookmarked} isProcessing={isProcessing} /> + {optionalCompletion && ( +
+ {formatMessage(messages.optionalCompletionUnitAlert)} +
+ )} { title, sequenceIds, completionStat, + optionalCompletion, } = section; const activeSequenceId = useSelector(getSequenceId); @@ -26,13 +27,18 @@ const SidebarSection = ({ intl, section, handleSelectSection }) => {
-
+
{title} , {intl.formatMessage(complete ? courseOutlineMessages.completedSection : courseOutlineMessages.incompleteSection)} + {optionalCompletion && ( + + {intl.formatMessage(courseOutlineMessages.optionalCompletion)} + + )}
); @@ -65,6 +71,7 @@ SidebarSection.propTypes = { completed: PropTypes.number, total: PropTypes.number, }), + optionalCompletion: PropTypes.bool, }).isRequired, handleSelectSection: PropTypes.func.isRequired, }; diff --git a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSequence.jsx b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSequence.jsx index 18d41071fb..861bc1ff93 100644 --- a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSequence.jsx +++ b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSequence.jsx @@ -3,7 +3,7 @@ import { useSelector } from 'react-redux'; import classNames from 'classnames'; import PropTypes from 'prop-types'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { Collapsible } from '@openedx/paragon'; +import { Badge, Collapsible } from '@openedx/paragon'; import courseOutlineMessages from '@src/course-home/outline-tab/messages'; import { getCourseOutline, getSequenceId } from '@src/courseware/data/selectors'; @@ -26,6 +26,7 @@ const SidebarSequence = ({ unitIds, type, completionStat, + optionalCompletion, } = sequence; const [open, setOpen] = useState(defaultOpen); @@ -38,8 +39,15 @@ const SidebarSequence = ({
-
- {title} +
+ + {title} + {optionalCompletion && ( + + {intl.formatMessage(courseOutlineMessages.optionalCompletion)} + + )} + {specialExamInfo && {specialExamInfo}} , {intl.formatMessage(complete @@ -94,6 +102,7 @@ SidebarSequence.propTypes = { completed: PropTypes.number, total: PropTypes.number, }), + optionalCompletion: PropTypes.bool, }).isRequired, activeUnitId: PropTypes.string.isRequired, }; diff --git a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarUnit.jsx b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarUnit.jsx index c01a575f0a..967ed9c89b 100644 --- a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarUnit.jsx +++ b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarUnit.jsx @@ -4,6 +4,7 @@ import { Link } from 'react-router-dom'; import { useDispatch, useSelector } from 'react-redux'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics'; +import { Badge } from '@openedx/paragon'; import { checkBlockCompletion } from '@src/courseware/data'; import { getCourseOutline } from '@src/courseware/data/selectors'; @@ -24,6 +25,7 @@ const SidebarUnit = ({ }) => { const { complete, + optionalCompletion, title, icon = UNIT_ICON_TYPES.other, } = unit; @@ -74,10 +76,15 @@ const SidebarUnit = ({
-
+
{title} + {optionalCompletion && ( + + {intl.formatMessage(messages.optionalCompletion)} + + )} , {intl.formatMessage(complete ? messages.completedUnit : messages.incompleteUnit)} @@ -97,6 +104,7 @@ SidebarUnit.propTypes = { id: PropTypes.string, title: PropTypes.string, type: PropTypes.string, + optionalCompletion: PropTypes.bool, }).isRequired, isActive: PropTypes.bool.isRequired, isLocked: PropTypes.bool.isRequired, diff --git a/src/courseware/course/sidebar/sidebars/course-outline/messages.js b/src/courseware/course/sidebar/sidebars/course-outline/messages.js index 80eb608d03..24ad2b125a 100644 --- a/src/courseware/course/sidebar/sidebars/course-outline/messages.js +++ b/src/courseware/course/sidebar/sidebars/course-outline/messages.js @@ -26,6 +26,11 @@ const messages = defineMessages({ defaultMessage: 'Incomplete unit', description: 'Text used to describe the gray checkmark icon in front of a unit title', }, + optionalCompletion: { + id: 'learn.sequence.optionalBlock', + defaultMessage: 'Optional', + description: 'Used as a label to indicate that a unit is optional.', + }, }); export default messages; diff --git a/src/courseware/data/utils.js b/src/courseware/data/utils.js index bcd3f0ae8b..408874afc3 100644 --- a/src/courseware/data/utils.js +++ b/src/courseware/data/utils.js @@ -145,6 +145,7 @@ export function normalizeSequenceMetadata(sequence) { contentType: unit.type, graded: unit.graded, containsContentTypeGatedContent: unit.contains_content_type_gated_content, + optionalCompletion: unit.optional_completion, })), }; } @@ -173,6 +174,7 @@ export function normalizeOutlineBlocks(courseId, blocks) { completed: block.completion_stat?.completion, total: block.completion_stat?.completable_children, }, + optionalCompletion: block.optional_completion || false, }; break; @@ -189,6 +191,7 @@ export function normalizeOutlineBlocks(courseId, blocks) { completed: block.completion_stat?.completion, total: block.completion_stat?.completable_children, }, + optionalCompletion: block.optional_completion || false, }; break; @@ -199,6 +202,7 @@ export function normalizeOutlineBlocks(courseId, blocks) { id: block.id, title: block.display_name, type: block.type, + optionalCompletion: block.optional_completion || false, }; break;