From 9a6ae9a7545e8e94cd310606b798c144c4f0c5fa Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 11 Jan 2024 20:02:54 +0530 Subject: [PATCH] feat: xblock status component feat: add custom relative dates flag to state refactor: add gated status type refactor: alert style feat: add status text to units test: add tests fix: lint issues refactor: break up xblock status component fix: selector for isCustomRelativeDatesActive fix: prereq default value --- src/course-outline/CourseOutline.jsx | 7 + src/course-outline/CourseOutline.scss | 1 + .../card-header/CardHeader.scss | 1 - src/course-outline/card-header/messages.js | 4 + src/course-outline/constants.js | 1 + src/course-outline/data/selectors.js | 1 + src/course-outline/data/slice.js | 2 + .../ConditionalSortableElement.jsx | 10 +- .../ConditionalSortableElement.scss | 3 - src/course-outline/hooks.jsx | 3 + .../section-card/SectionCard.jsx | 19 +- .../section-card/SectionCard.scss | 27 +- .../subsection-card/SubsectionCard.jsx | 54 +- .../subsection-card/SubsectionCard.scss | 4 + src/course-outline/unit-card/UnitCard.jsx | 12 + src/course-outline/unit-card/UnitCard.scss | 4 - src/course-outline/utils.jsx | 11 + .../xblock-status/XBlockStatus.jsx | 440 +++++++++++++++ .../xblock-status/XBlockStatus.scss | 6 + .../xblock-status/XBlockStatus.test.jsx | 504 ++++++++++++++++++ src/course-outline/xblock-status/messages.js | 78 +++ src/data/constants.js | 1 + 22 files changed, 1137 insertions(+), 56 deletions(-) create mode 100644 src/course-outline/xblock-status/XBlockStatus.jsx create mode 100644 src/course-outline/xblock-status/XBlockStatus.scss create mode 100644 src/course-outline/xblock-status/XBlockStatus.test.jsx create mode 100644 src/course-outline/xblock-status/messages.js diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx index 073a96bf39..25c6564bce 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -55,6 +55,7 @@ const CourseOutline = ({ courseId }) => { statusBarData, courseActions, sectionsList, + isCustomRelativeDatesActive, isLoading, isReIndexShow, showErrorAlert, @@ -311,6 +312,8 @@ const CourseOutline = ({ courseId }) => { section={section} index={sectionIndex} canMoveItem={canMoveItem(sections)} + isSelfPaced={statusBarData.isSelfPaced} + isCustomRelativeDatesActive={isCustomRelativeDatesActive} savingStatus={savingStatus} onOpenHighlightsModal={handleOpenHighlightsModal} onOpenPublishModal={openPublishModal} @@ -334,6 +337,8 @@ const CourseOutline = ({ courseId }) => { subsection={subsection} index={subsectionIndex} canMoveItem={canMoveItem(section.childInfo.children)} + isSelfPaced={statusBarData.isSelfPaced} + isCustomRelativeDatesActive={isCustomRelativeDatesActive} savingStatus={savingStatus} onOpenPublishModal={openPublishModal} onOpenDeleteModal={openDeleteModal} @@ -358,6 +363,8 @@ const CourseOutline = ({ courseId }) => { unit={unit} subsection={subsection} section={section} + isSelfPaced={statusBarData.isSelfPaced} + isCustomRelativeDatesActive={isCustomRelativeDatesActive} index={unitIndex} canMoveItem={canMoveItem(subsection.childInfo.children)} savingStatus={savingStatus} diff --git a/src/course-outline/CourseOutline.scss b/src/course-outline/CourseOutline.scss index 3c6572dcfd..e9ac37e25b 100644 --- a/src/course-outline/CourseOutline.scss +++ b/src/course-outline/CourseOutline.scss @@ -9,3 +9,4 @@ @import "./publish-modal/PublishModal"; @import "./configure-modal/ConfigureModal"; @import "./drag-helper/ConditionalSortableElement"; +@import "./xblock-status/XBlockStatus"; diff --git a/src/course-outline/card-header/CardHeader.scss b/src/course-outline/card-header/CardHeader.scss index a6ba83687a..a3ea06d8f1 100644 --- a/src/course-outline/card-header/CardHeader.scss +++ b/src/course-outline/card-header/CardHeader.scss @@ -1,7 +1,6 @@ .item-card-header { display: flex; align-items: center; - margin-right: -.5rem; .item-card-header__title-btn { justify-content: flex-start; diff --git a/src/course-outline/card-header/messages.js b/src/course-outline/card-header/messages.js index b268c430b7..78ce088642 100644 --- a/src/course-outline/card-header/messages.js +++ b/src/course-outline/card-header/messages.js @@ -9,6 +9,10 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.card.status-badge.live', defaultMessage: 'Live', }, + statusBadgeGated: { + id: 'course-authoring.course-outline.card.status-badge.gated', + defaultMessage: 'Gated', + }, statusBadgePublishedNotLive: { id: 'course-authoring.course-outline.card.status-badge.published-not-live', defaultMessage: 'Published not live', diff --git a/src/course-outline/constants.js b/src/course-outline/constants.js index e147527262..2ce86bb602 100644 --- a/src/course-outline/constants.js +++ b/src/course-outline/constants.js @@ -1,5 +1,6 @@ export const ITEM_BADGE_STATUS = /** @type {const} */ ({ live: 'live', + gated: 'gated', publishedNotLive: 'published_not_live', unpublishedChanges: 'unpublished_changes', staffOnly: 'staff_only', diff --git a/src/course-outline/data/selectors.js b/src/course-outline/data/selectors.js index ebfb71a91b..3a3a2bb6c7 100644 --- a/src/course-outline/data/selectors.js +++ b/src/course-outline/data/selectors.js @@ -7,3 +7,4 @@ export const getCurrentItem = (state) => state.courseOutline.currentItem; export const getCurrentSection = (state) => state.courseOutline.currentSection; export const getCurrentSubsection = (state) => state.courseOutline.currentSubsection; export const getCourseActions = (state) => state.courseOutline.actions; +export const getCustomRelativeDatesActiveFlag = (state) => state.courseOutline.isCustomRelativeDatesActive; diff --git a/src/course-outline/data/slice.js b/src/course-outline/data/slice.js index 33f7344f02..fd2706f656 100644 --- a/src/course-outline/data/slice.js +++ b/src/course-outline/data/slice.js @@ -28,6 +28,7 @@ const slice = createSlice({ videoSharingOptions: VIDEO_SHARING_OPTIONS.perVideo, }, sectionsList: [], + isCustomRelativeDatesActive: false, currentSection: {}, currentSubsection: {}, currentItem: {}, @@ -42,6 +43,7 @@ const slice = createSlice({ fetchOutlineIndexSuccess: (state, { payload }) => { state.outlineIndexData = payload; state.sectionsList = payload.courseStructure?.childInfo?.children || []; + state.isCustomRelativeDatesActive = payload.isCustomRelativeDatesActive; }, updateOutlineIndexLoadingStatus: (state, { payload }) => { state.loadingStatus = { diff --git a/src/course-outline/drag-helper/ConditionalSortableElement.jsx b/src/course-outline/drag-helper/ConditionalSortableElement.jsx index 10088a4667..8390b282cd 100644 --- a/src/course-outline/drag-helper/ConditionalSortableElement.jsx +++ b/src/course-outline/drag-helper/ConditionalSortableElement.jsx @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Row } from '@edx/paragon'; +import { Col, Row } from '@edx/paragon'; import { SortableItem } from '@edx/frontend-lib-content-components'; const ConditionalSortableElement = ({ @@ -24,9 +24,9 @@ const ConditionalSortableElement = ({ id={id} componentStyle={style} > -
+ {children} -
+ ); } @@ -36,7 +36,9 @@ const ConditionalSortableElement = ({ style={style} className="mx-0" > - {children} + + {children} + ); }; diff --git a/src/course-outline/drag-helper/ConditionalSortableElement.scss b/src/course-outline/drag-helper/ConditionalSortableElement.scss index 4f0222975b..00393c48f1 100644 --- a/src/course-outline/drag-helper/ConditionalSortableElement.scss +++ b/src/course-outline/drag-helper/ConditionalSortableElement.scss @@ -1,7 +1,4 @@ .extend-margin { - display: flex; - flex-grow: 1; - .item-children { margin-right: -2.75rem; } diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index 6604357129..7754733dcf 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -21,6 +21,7 @@ import { getCurrentItem, getCurrentSection, getCurrentSubsection, + getCustomRelativeDatesActiveFlag, } from './data/selectors'; import { addNewSectionQuery, @@ -62,6 +63,7 @@ const useCourseOutline = ({ courseId }) => { const currentItem = useSelector(getCurrentItem); const currentSection = useSelector(getCurrentSection); const currentSubsection = useSelector(getCurrentSubsection); + const isCustomRelativeDatesActive = useSelector(getCustomRelativeDatesActiveFlag); const [isEnableHighlightsModalOpen, openEnableHighlightsModal, closeEnableHighlightsModal] = useToggle(false); const [isSectionsExpanded, setSectionsExpanded] = useState(true); @@ -242,6 +244,7 @@ const useCourseOutline = ({ courseId }) => { courseActions, savingStatus, sectionsList, + isCustomRelativeDatesActive, isLoading: outlineIndexLoadingStatus === RequestStatus.IN_PROGRESS, isReIndexShow: Boolean(reindexLink), showSuccessAlert, diff --git a/src/course-outline/section-card/SectionCard.jsx b/src/course-outline/section-card/SectionCard.jsx index 9787c0a823..4e81318d49 100644 --- a/src/course-outline/section-card/SectionCard.jsx +++ b/src/course-outline/section-card/SectionCard.jsx @@ -14,11 +14,14 @@ import CardHeader from '../card-header/CardHeader'; import BaseTitleWithStatusBadge from '../card-header/BaseTitleWithStatusBadge'; import ConditionalSortableElement from '../drag-helper/ConditionalSortableElement'; import TitleButton from '../card-header/TitleButton'; +import XBlockStatus from '../xblock-status/XBlockStatus'; import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils'; import messages from './messages'; const SectionCard = ({ section, + isSelfPaced, + isCustomRelativeDatesActive, children, index, canMoveItem, @@ -60,7 +63,6 @@ const SectionCard = ({ highlights, actions: sectionActions, isHeaderVisible = true, - explanatoryMessage = '', } = section; // re-create actions object for customizations @@ -174,15 +176,21 @@ const SectionCard = ({ /> )}
- {explanatoryMessage &&

{explanatoryMessage}

} +
@@ -226,7 +234,6 @@ SectionCard.propTypes = { visibilityState: PropTypes.string.isRequired, highlights: PropTypes.arrayOf(PropTypes.string).isRequired, shouldScroll: PropTypes.bool, - explanatoryMessage: PropTypes.string, actions: PropTypes.shape({ deletable: PropTypes.bool.isRequired, draggable: PropTypes.bool.isRequired, @@ -235,6 +242,8 @@ SectionCard.propTypes = { }).isRequired, isHeaderVisible: PropTypes.bool, }).isRequired, + isSelfPaced: PropTypes.bool.isRequired, + isCustomRelativeDatesActive: PropTypes.bool.isRequired, children: PropTypes.node, onOpenHighlightsModal: PropTypes.func.isRequired, onOpenPublishModal: PropTypes.func.isRequired, diff --git a/src/course-outline/section-card/SectionCard.scss b/src/course-outline/section-card/SectionCard.scss index 6309d22c00..25e3c688c2 100644 --- a/src/course-outline/section-card/SectionCard.scss +++ b/src/course-outline/section-card/SectionCard.scss @@ -13,26 +13,15 @@ color: $headings-color; } - .section-card__highlights { - display: flex; - align-items: center; - gap: .5rem; - padding: 0; - background: transparent; - &::before { - display: none; - } + .highlights-badge { + width: 1.5rem; + height: 1.5rem; + border-radius: 1.375rem; + font-size: 1rem; + } - .highlights-badge { - display: flex; - justify-content: center; - align-items: center; - width: 1.75rem; - height: 1.75rem; - border-radius: 1.375rem; - font-size: 1.125rem; - font-weight: 700; - } + .section-card__content { + margin-left: 1.7rem; } } diff --git a/src/course-outline/subsection-card/SubsectionCard.jsx b/src/course-outline/subsection-card/SubsectionCard.jsx index 32ad90074d..f081c9ea4f 100644 --- a/src/course-outline/subsection-card/SubsectionCard.jsx +++ b/src/course-outline/subsection-card/SubsectionCard.jsx @@ -12,12 +12,15 @@ import CardHeader from '../card-header/CardHeader'; import BaseTitleWithStatusBadge from '../card-header/BaseTitleWithStatusBadge'; import ConditionalSortableElement from '../drag-helper/ConditionalSortableElement'; import TitleButton from '../card-header/TitleButton'; +import XBlockStatus from '../xblock-status/XBlockStatus'; import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils'; import messages from './messages'; const SubsectionCard = ({ section, subsection, + isSelfPaced, + isCustomRelativeDatesActive, children, index, canMoveItem, @@ -136,26 +139,35 @@ const SubsectionCard = ({ >
{isHeaderVisible && ( - + <> + +
+ +
+ )} {isExpanded && (
+
+ +
); @@ -193,6 +203,8 @@ UnitCard.propTypes = { index: PropTypes.number.isRequired, canMoveItem: PropTypes.func.isRequired, onOrderChange: PropTypes.func.isRequired, + isSelfPaced: PropTypes.bool.isRequired, + isCustomRelativeDatesActive: PropTypes.bool.isRequired, }; export default UnitCard; diff --git a/src/course-outline/unit-card/UnitCard.scss b/src/course-outline/unit-card/UnitCard.scss index cf30dfcc2a..31d56e18e9 100644 --- a/src/course-outline/unit-card/UnitCard.scss +++ b/src/course-outline/unit-card/UnitCard.scss @@ -1,10 +1,6 @@ .unit-card { flex-grow: 1; - .unit-card__content { - margin: $spacer; - } - .item-card-header__badge-status { background: $light-100; } diff --git a/src/course-outline/utils.jsx b/src/course-outline/utils.jsx index 7032212d94..6d7edf1a59 100644 --- a/src/course-outline/utils.jsx +++ b/src/course-outline/utils.jsx @@ -21,6 +21,8 @@ const getItemStatus = ({ switch (true) { case visibilityState === VisibilityTypes.STAFF_ONLY: return ITEM_BADGE_STATUS.staffOnly; + case visibilityState === VisibilityTypes.GATED: + return ITEM_BADGE_STATUS.gated; case visibilityState === VisibilityTypes.LIVE: return ITEM_BADGE_STATUS.live; case published && !hasChanges: @@ -42,6 +44,11 @@ const getItemStatus = ({ */ const getItemStatusBadgeContent = (status, messages, intl) => { switch (status) { + case ITEM_BADGE_STATUS.gated: + return { + badgeTitle: intl.formatMessage(messages.statusBadgeGated), + badgeIcon: LockIcon, + }; case ITEM_BADGE_STATUS.live: return { badgeTitle: intl.formatMessage(messages.statusBadgeLive), @@ -92,6 +99,10 @@ const getItemStatusBorder = (status) => { return { borderLeft: '5px solid #0D7D4D', }; + case ITEM_BADGE_STATUS.gated: + return { + borderLeft: '5px solid #000000', + }; case ITEM_BADGE_STATUS.staffOnly: return { borderLeft: '5px solid #000000', diff --git a/src/course-outline/xblock-status/XBlockStatus.jsx b/src/course-outline/xblock-status/XBlockStatus.jsx new file mode 100644 index 0000000000..1b3e9431f5 --- /dev/null +++ b/src/course-outline/xblock-status/XBlockStatus.jsx @@ -0,0 +1,440 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Icon } from '@edx/paragon'; +import { + Check as CheckIcon, + AccessTime as ClockIcon, + CalendarMonth as CalendarIcon, + VisibilityOff as HideIcon, + Lock as LockIcon, + Groups as GroupsIcon, + WarningFilled as WarningIcon, +} from '@edx/paragon/icons'; + +import messages from './messages'; +import { COURSE_BLOCK_NAMES } from '../constants'; + +const ReleaseStatus = ({ + isInstructorPaced, + explanatoryMessage, + releaseDate, + releasedToStudents, +}) => { + const intl = useIntl(); + + const explanatoryMessageDiv = () => ( + + {explanatoryMessage} + + ); + + let releaseLabel = messages.unscheduledLabel; + if (releasedToStudents) { + releaseLabel = messages.releasedLabel; + } else if (releaseDate) { + releaseLabel = messages.scheduledLabel; + } + + const releaseStatusDiv = () => ( +
+ + {intl.formatMessage(messages.releaseStatusScreenReaderTitle)} + + + {intl.formatMessage(releaseLabel)} + {releaseDate && releaseDate} +
+ ); + + if (explanatoryMessage) { + return explanatoryMessageDiv(); + } + + if (isInstructorPaced) { + return releaseStatusDiv(); + } + + return null; +}; + +ReleaseStatus.defaultProps = { + explanatoryMessage: '', +}; + +ReleaseStatus.propTypes = { + isInstructorPaced: PropTypes.bool.isRequired, + explanatoryMessage: PropTypes.string, + releaseDate: PropTypes.string.isRequired, + releasedToStudents: PropTypes.bool.isRequired, +}; + +const GradingTypeAndDueDate = ({ + isSelfPaced, + isInstructorPaced, + isCustomRelativeDatesActive, + isTimeLimited, + isProctoredExam, + isOnboardingExam, + isPracticeExam, + graded, + gradingType, + dueDate, + relativeWeeksDue, +}) => { + const intl = useIntl(); + const showRelativeWeeks = isSelfPaced && isCustomRelativeDatesActive && relativeWeeksDue; + + let examValue = ''; + if (isProctoredExam) { + if (isOnboardingExam) { + examValue = messages.onboardingExam; + } else if (isPracticeExam) { + examValue = messages.practiceProctoredExam; + } else { + examValue = messages.proctoredExam; + } + } else { + examValue = messages.timedExam; + } + + const gradingTypeDiv = () => ( +
+ + {intl.formatMessage(messages.gradedAsScreenReaderLabel)} + + + + {gradingType || intl.formatMessage(messages.ungradedText)} + +
+ ); + + const dueDateDiv = () => { + if (dueDate && isInstructorPaced) { + return ( +
+ {intl.formatMessage(messages.dueLabel)} {dueDate} +
+ ); + } + return null; + }; + + const selfPacedRelativeDueWeeksDiv = () => ( +
+ + + {intl.formatMessage(messages.customDueDateLabel, { relativeWeeksDue })} + +
+ ); + + if (isTimeLimited) { + return ( + <> +
+ {gradingTypeDiv()} - + {intl.formatMessage(examValue)} + + {intl.formatMessage(examValue)} + + {dueDateDiv()} +
+ {showRelativeWeeks && (selfPacedRelativeDueWeeksDiv())} + + ); + } if ((dueDate && !isSelfPaced) || graded) { + return ( + <> +
+ {gradingTypeDiv()} + {dueDateDiv()} +
+ {showRelativeWeeks && (selfPacedRelativeDueWeeksDiv())} + + ); + } if (showRelativeWeeks) { + return ( + <> + {gradingTypeDiv()} + {selfPacedRelativeDueWeeksDiv()} + + ); + } + return null; +}; + +GradingTypeAndDueDate.defaultProps = { + isCustomRelativeDatesActive: false, + isTimeLimited: false, + isProctoredExam: false, + isOnboardingExam: false, + isPracticeExam: false, + graded: false, + gradingType: '', + dueDate: '', + relativeWeeksDue: null, +}; + +GradingTypeAndDueDate.propTypes = { + isInstructorPaced: PropTypes.bool.isRequired, + isSelfPaced: PropTypes.bool.isRequired, + isCustomRelativeDatesActive: PropTypes.bool, + isTimeLimited: PropTypes.bool, + isProctoredExam: PropTypes.bool, + isOnboardingExam: PropTypes.bool, + isPracticeExam: PropTypes.bool, + graded: PropTypes.bool, + gradingType: PropTypes.string, + dueDate: PropTypes.string, + relativeWeeksDue: PropTypes.number, +}; + +const HideAfterDueMessage = ({ isSelfPaced }) => { + const intl = useIntl(); + return ( +
+ + + {isSelfPaced + ? intl.formatMessage(messages.hiddenAfterEndDate) + : intl.formatMessage(messages.hiddenAfterDueDate)} + +
+ ); +}; + +HideAfterDueMessage.propTypes = { + isSelfPaced: PropTypes.bool.isRequired, +}; + +const StatusMessages = ({ + isVertical, + staffOnlyMessage, + prereq, + prereqs, + userPartitionInfo, + hasPartitionGroupComponents, +}) => { + const intl = useIntl(); + const statusMessages = []; + + if (prereq) { + let prereqDisplayName = ''; + prereqs.forEach((block) => { + if (block.blockUsageKey === prereq) { + prereqDisplayName = block.blockDisplayName; + } + }); + statusMessages.push({ + icon: LockIcon, + text: intl.formatMessage(messages.prerequisiteLabel, { prereqDisplayName }), + }); + } + + if (!staffOnlyMessage && isVertical) { + const { selectedPartitionIndex, selectedGroupsLabel } = userPartitionInfo; + if (selectedPartitionIndex !== -1 && !Number.isNaN(selectedPartitionIndex)) { + statusMessages.push({ + icon: GroupsIcon, + text: intl.formatMessage(messages.restrictedUnitAccess, { selectedGroupsLabel }), + }); + } else if (hasPartitionGroupComponents) { + statusMessages.push({ + icon: GroupsIcon, + text: intl.formatMessage(messages.restrictedUnitAccessToSomeContent), + }); + } + } + + if (statusMessages.length > 0) { + return ( +
+ {statusMessages.map(({ icon, text }) => ( +
+ + {text} +
+ ))} +
+ ); + } + return null; +}; + +StatusMessages.defaultProps = { + staffOnlyMessage: false, + prereq: '', + prereqs: [], + userPartitionInfo: {}, +}; + +StatusMessages.propTypes = { + isVertical: PropTypes.bool.isRequired, + staffOnlyMessage: PropTypes.bool, + prereq: PropTypes.string, + prereqs: PropTypes.arrayOf(PropTypes.shape({ + blockUsageKey: PropTypes.string.isRequired, + blockDisplayName: PropTypes.string.isRequired, + })), + userPartitionInfo: PropTypes.shape({ + selectedPartitionIndex: PropTypes.number.isRequired, + selectedGroupsLabel: PropTypes.string.isRequired, + }), + hasPartitionGroupComponents: PropTypes.bool.isRequired, +}; + +const GradingPolicyAlert = ({ + graded, + gradingType, + courseGraders, +}) => { + const intl = useIntl(); + + let gradingPolicyMismatch = false; + if (graded) { + if (gradingType) { + gradingPolicyMismatch = ( + courseGraders.filter((cg) => cg.toLowerCase() === gradingType.toLowerCase()) + ).length === 0; + } + } + + if (gradingPolicyMismatch) { + return ( +
+ + {intl.formatMessage(messages.gradingPolicyMismatchText, { gradingType })} +
+ ); + } + return null; +}; + +GradingPolicyAlert.defaultProps = { + graded: false, + gradingType: '', +}; + +GradingPolicyAlert.propTypes = { + graded: PropTypes.bool, + gradingType: PropTypes.string, + courseGraders: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired, +}; + +const XBlockStatus = ({ + isSelfPaced, + isCustomRelativeDatesActive, + blockData, +}) => { + const { + category, + explanatoryMessage, + releasedToStudents, + releaseDate, + isProctoredExam, + isOnboardingExam, + isPracticeExam, + prereq, + prereqs, + staffOnlyMessage, + userPartitionInfo, + hasPartitionGroupComponents, + format: gradingType, + dueDate, + relativeWeeksDue, + isTimeLimited, + graded, + courseGraders, + hideAfterDue, + } = blockData; + + const isInstructorPaced = !isSelfPaced; + const isVertical = category === COURSE_BLOCK_NAMES.vertical.id; + + return ( +
+ {!isVertical && ( + + )} + {!isVertical && ( + + )} + {hideAfterDue && ( + + )} + + +
+ ); +}; + +XBlockStatus.defaultProps = { + isCustomRelativeDatesActive: false, +}; + +XBlockStatus.propTypes = { + isSelfPaced: PropTypes.bool.isRequired, + isCustomRelativeDatesActive: PropTypes.bool, + blockData: PropTypes.shape({ + category: PropTypes.string.isRequired, + explanatoryMessage: PropTypes.string, + releasedToStudents: PropTypes.bool.isRequired, + releaseDate: PropTypes.string.isRequired, + isProctoredExam: PropTypes.bool, + isOnboardingExam: PropTypes.bool, + isPracticeExam: PropTypes.bool, + prereq: PropTypes.string, + prereqs: PropTypes.arrayOf(PropTypes.shape({ + blockUsageKey: PropTypes.string.isRequired, + blockDisplayName: PropTypes.string.isRequired, + })), + staffOnlyMessage: PropTypes.bool, + userPartitionInfo: PropTypes.shape({ + selectedPartitionIndex: PropTypes.number.isRequired, + selectedGroupsLabel: PropTypes.string.isRequired, + }), + hasPartitionGroupComponents: PropTypes.bool.isRequired, + format: PropTypes.string, + dueDate: PropTypes.string, + relativeWeeksDue: PropTypes.number, + isTimeLimited: PropTypes.bool, + graded: PropTypes.bool, + courseGraders: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired, + hideAfterDue: PropTypes.bool, + }).isRequired, +}; + +export default XBlockStatus; diff --git a/src/course-outline/xblock-status/XBlockStatus.scss b/src/course-outline/xblock-status/XBlockStatus.scss new file mode 100644 index 0000000000..32c093f857 --- /dev/null +++ b/src/course-outline/xblock-status/XBlockStatus.scss @@ -0,0 +1,6 @@ +.grading-mismatch-alert { + background: #FFFADB; + font-size: 14px; + font-weight: 400; + color: #454545; +} diff --git a/src/course-outline/xblock-status/XBlockStatus.test.jsx b/src/course-outline/xblock-status/XBlockStatus.test.jsx new file mode 100644 index 0000000000..e3b5ab9340 --- /dev/null +++ b/src/course-outline/xblock-status/XBlockStatus.test.jsx @@ -0,0 +1,504 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { initializeMockApp } from '@edx/frontend-platform'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import initializeStore from '../../store'; +import XBlockStatus from './XBlockStatus'; +import messages from './messages'; + +// eslint-disable-next-line no-unused-vars +let axiosMock; +let store; + +jest.mock('@edx/frontend-platform/i18n', () => ({ + ...jest.requireActual('@edx/frontend-platform/i18n'), + useIntl: () => ({ + formatMessage: (message) => message.defaultMessage, + }), +})); + +const section = { + id: '123', + displayName: 'Section Name', + published: true, + visibilityState: 'live', + hasChanges: false, + highlights: ['highlight 1', 'highlight 2'], + category: 'chapter', + explanatoryMessage: '', + releasedToStudents: true, + releaseDate: 'Feb 05, 2013 at 01:00 UTC', + isProctoredExam: false, + isOnboardingExam: false, + isPracticeExam: false, + staffOnlyMessage: false, + userPartitionInfo: { + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + hasPartitionGroupComponents: false, + format: 'Homework', + dueDate: 'Dec 28, 2023 at 22:00 UTC', + isTimeLimited: true, + graded: true, + courseGraders: ['Homework'], + hideAfterDue: true, +}; + +const renderComponent = (props) => render( + + + + , + , +); + +describe(' for Instructor paced Section', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + it('render XBlockStatus with explanatoryMessage', () => { + const { queryByTestId } = renderComponent({ + blockData: { + ...section, + explanatoryMessage: 'some explanatory message', + }, + }); + + expect(queryByTestId('explanatory-message-span')).toBeInTheDocument(); + // when explanatory message is displayed, release date should not be visible + expect(queryByTestId('release-status-div')).not.toBeInTheDocument(); + }); + + it('renders XBlockStatus with release status, grading type, due date etc.', () => { + const { queryByTestId } = renderComponent({ blockData: section }); + + expect(queryByTestId('explanatory-message-span')).not.toBeInTheDocument(); + // when explanatory message is not displayed, release date should be visible + const releaseStatusDiv = queryByTestId('release-status-div'); + expect(releaseStatusDiv).toBeInTheDocument(); + expect(releaseStatusDiv).toHaveTextContent( + `${messages.releasedLabel.defaultMessage}${section.releaseDate}`, + ); + + // check grading type + const gradingTypeDiv = queryByTestId('grading-type-div'); + expect(gradingTypeDiv).toBeInTheDocument(); + expect(gradingTypeDiv).toHaveTextContent(section.format); + // check exam value label + const examValue = queryByTestId('exam-value-span'); + expect(examValue).toBeInTheDocument(); + expect(examValue).toHaveTextContent(messages.timedExam.defaultMessage); + // check due date div + const dueDateDiv = queryByTestId('due-date-div'); + expect(dueDateDiv).toBeInTheDocument(); + expect(dueDateDiv).toHaveTextContent( + `${messages.dueLabel.defaultMessage} ${section.dueDate}`, + ); + // self paced weeks should not be visible as + // isSelfPaced is false as well as isCustomRelativeDatesActive is false + expect(queryByTestId('self-paced-relative-due-weeks-div')).not.toBeInTheDocument(); + + // check hide after due date message + const hideAfterDueMessage = queryByTestId('hide-after-due-message'); + expect(hideAfterDueMessage).toBeInTheDocument(); + expect(hideAfterDueMessage).toHaveTextContent(messages.hiddenAfterDueDate.defaultMessage); + + // check status messages + const statusDiv = queryByTestId('status-messages-div'); + expect(statusDiv).not.toBeInTheDocument(); + }); +}); + +describe(' for self paced Section', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + it('renders XBlockStatus with grading type, due weeks etc.', () => { + const { queryByTestId } = renderComponent({ + isSelfPaced: true, + isCustomRelativeDatesActive: true, + blockData: { + ...section, + relativeWeeksDue: 2, + }, + }); + + // both explanatoryMessage & releaseStatusDiv should not be visible + expect(queryByTestId('explanatory-message-span')).not.toBeInTheDocument(); + expect(queryByTestId('release-status-div')).not.toBeInTheDocument(); + + // check grading type + const gradingTypeDiv = queryByTestId('grading-type-div'); + expect(gradingTypeDiv).toBeInTheDocument(); + expect(gradingTypeDiv).toHaveTextContent(section.format); + // due date should not be visible for self paced courses. + expect(queryByTestId('due-date-div')).not.toBeInTheDocument(); + // check selfPacedRelativeDueWeeksDiv + const selfPacedRelativeDueWeeksDiv = queryByTestId('self-paced-relative-due-weeks-div'); + expect(selfPacedRelativeDueWeeksDiv).toBeInTheDocument(); + expect(selfPacedRelativeDueWeeksDiv).toHaveTextContent( + messages.customDueDateLabel.defaultMessage, + ); + + // check hide after due date message + const hideAfterDueMessage = queryByTestId('hide-after-due-message'); + expect(hideAfterDueMessage).toBeInTheDocument(); + expect(hideAfterDueMessage).toHaveTextContent(messages.hiddenAfterEndDate.defaultMessage); + + // check status messages + expect(queryByTestId('status-messages-div')).not.toBeInTheDocument(); + }); + + it('renders XBlockStatus with grading mismatch alert', () => { + const { queryByTestId } = renderComponent({ + blockData: { + ...section, + format: 'Fun', + }, + }); + + // check alert + const alert = queryByTestId('grading-mismatch-alert'); + expect(alert).toBeInTheDocument(); + expect(alert).toHaveTextContent(messages.gradingPolicyMismatchText.defaultMessage); + }); +}); + +const subsection = { + id: '123', + displayName: 'Subsection Name', + published: true, + visibilityState: 'live', + hasChanges: false, + highlights: ['highlight 1', 'highlight 2'], + category: 'sequential', + explanatoryMessage: '', + releasedToStudents: false, + releaseDate: 'Feb 05, 2025 at 01:00 UTC', + isProctoredExam: false, + isOnboardingExam: false, + isPracticeExam: false, + prereq: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@dbe8fc0', + prereqs: [ + { + blockDisplayName: 'Find your study buddy', + blockUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@dbe8fc0', + }, + { + blockDisplayName: 'Something else', + blockUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@sdafyrb', + }, + ], + staffOnlyMessage: false, + userPartitionInfo: { + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + hasPartitionGroupComponents: false, + format: 'Homework', + dueDate: 'Dec 28, 2023 at 22:00 UTC', + isTimeLimited: true, + graded: true, + courseGraders: ['Homework'], + hideAfterDue: true, +}; + +describe(' for Instructor paced Subsection', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + it('renders XBlockStatus with release status, grading type, due date etc.', () => { + const { queryByTestId } = renderComponent({ blockData: subsection }); + + expect(queryByTestId('explanatory-message-span')).not.toBeInTheDocument(); + // when explanatory message is not displayed, release date should be visible + const releaseStatusDiv = queryByTestId('release-status-div'); + expect(releaseStatusDiv).toBeInTheDocument(); + expect(releaseStatusDiv).toHaveTextContent( + `${messages.scheduledLabel.defaultMessage}${subsection.releaseDate}`, + ); + + // check grading type + const gradingTypeDiv = queryByTestId('grading-type-div'); + expect(gradingTypeDiv).toBeInTheDocument(); + expect(gradingTypeDiv).toHaveTextContent(subsection.format); + // check exam value label + const examValue = queryByTestId('exam-value-span'); + expect(examValue).toBeInTheDocument(); + expect(examValue).toHaveTextContent(messages.timedExam.defaultMessage); + // check due date div + const dueDateDiv = queryByTestId('due-date-div'); + expect(dueDateDiv).toBeInTheDocument(); + expect(dueDateDiv).toHaveTextContent( + `${messages.dueLabel.defaultMessage} ${subsection.dueDate}`, + ); + // self paced weeks should not be visible as + // isSelfPaced is false as well as isCustomRelativeDatesActive is false + expect(queryByTestId('self-paced-relative-due-weeks-div')).not.toBeInTheDocument(); + + // check hide after due date message + const hideAfterDueMessage = queryByTestId('hide-after-due-message'); + expect(hideAfterDueMessage).toBeInTheDocument(); + expect(hideAfterDueMessage).toHaveTextContent(messages.hiddenAfterDueDate.defaultMessage); + + // check status messages + const statusDiv = queryByTestId('status-messages-div'); + expect(statusDiv).toBeInTheDocument(); + expect(statusDiv).toHaveTextContent(messages.prerequisiteLabel.defaultMessage); + }); + + it('renders XBlockStatus with proctored exam info', () => { + const { queryByTestId } = renderComponent({ + blockData: { + ...subsection, + isProctoredExam: true, + isOnboardingExam: false, + isPracticeExam: false, + }, + }); + + // check exam value label + const examValue = queryByTestId('exam-value-span'); + expect(examValue).toBeInTheDocument(); + expect(examValue).toHaveTextContent(messages.proctoredExam.defaultMessage); + }); + + it('renders XBlockStatus with practice proctored exam info', () => { + const { queryByTestId } = renderComponent({ + blockData: { + ...subsection, + isProctoredExam: true, + isOnboardingExam: false, + isPracticeExam: true, + }, + }); + + // check exam value label + const examValue = queryByTestId('exam-value-span'); + expect(examValue).toBeInTheDocument(); + expect(examValue).toHaveTextContent(messages.practiceProctoredExam.defaultMessage); + }); + + it('renders XBlockStatus with onboarding exam info', () => { + const { queryByTestId } = renderComponent({ + blockData: { + ...subsection, + isProctoredExam: true, + isOnboardingExam: true, + isPracticeExam: false, + }, + }); + + // check exam value label + const examValue = queryByTestId('exam-value-span'); + expect(examValue).toBeInTheDocument(); + expect(examValue).toHaveTextContent(messages.onboardingExam.defaultMessage); + }); + + it('renders XBlockStatus correctly for graded but not time limited subsection', () => { + const { queryByTestId } = renderComponent({ + blockData: { + ...subsection, + isTimeLimited: false, + graded: true, + }, + }); + + // check grading type + const gradingTypeDiv = queryByTestId('grading-type-div'); + expect(gradingTypeDiv).toBeInTheDocument(); + expect(gradingTypeDiv).toHaveTextContent(subsection.format); + // exam value label should not be visible + expect(queryByTestId('exam-value-span')).not.toBeInTheDocument(); + // check due date div + const dueDateDiv = queryByTestId('due-date-div'); + expect(dueDateDiv).toBeInTheDocument(); + expect(dueDateDiv).toHaveTextContent( + `${messages.dueLabel.defaultMessage} ${subsection.dueDate}`, + ); + // self paced weeks should not be visible as + // isSelfPaced is false as well as isCustomRelativeDatesActive is false + expect(queryByTestId('self-paced-relative-due-weeks-div')).not.toBeInTheDocument(); + }); +}); + +describe(' for self paced Subsection', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + it('renders XBlockStatus with grading type, due weeks etc.', () => { + const { queryByTestId } = renderComponent({ + isSelfPaced: true, + isCustomRelativeDatesActive: true, + blockData: { + ...subsection, + relativeWeeksDue: 2, + }, + }); + + // both explanatoryMessage & releaseStatusDiv should not be visible + expect(queryByTestId('explanatory-message-span')).not.toBeInTheDocument(); + expect(queryByTestId('release-status-div')).not.toBeInTheDocument(); + + // check grading type + const gradingTypeDiv = queryByTestId('grading-type-div'); + expect(gradingTypeDiv).toBeInTheDocument(); + expect(gradingTypeDiv).toHaveTextContent(subsection.format); + // due date should not be visible for self paced courses. + expect(queryByTestId('due-date-div')).not.toBeInTheDocument(); + // check selfPacedRelativeDueWeeksDiv + const selfPacedRelativeDueWeeksDiv = queryByTestId('self-paced-relative-due-weeks-div'); + expect(selfPacedRelativeDueWeeksDiv).toBeInTheDocument(); + expect(selfPacedRelativeDueWeeksDiv).toHaveTextContent( + messages.customDueDateLabel.defaultMessage, + ); + + // check hide after due date message + const hideAfterDueMessage = queryByTestId('hide-after-due-message'); + expect(hideAfterDueMessage).toBeInTheDocument(); + expect(hideAfterDueMessage).toHaveTextContent(messages.hiddenAfterEndDate.defaultMessage); + + // check status messages + const statusDiv = queryByTestId('status-messages-div'); + expect(statusDiv).toBeInTheDocument(); + expect(statusDiv).toHaveTextContent(messages.prerequisiteLabel.defaultMessage); + }); +}); + +const unit = { + id: '123', + displayName: 'Unit Name', + published: true, + visibilityState: 'live', + hasChanges: false, + highlights: ['highlight 1', 'highlight 2'], + category: 'vertical', + explanatoryMessage: '', + releasedToStudents: true, + releaseDate: 'Feb 05, 2013 at 01:00 UTC', + isProctoredExam: false, + isOnboardingExam: false, + isPracticeExam: false, + staffOnlyMessage: false, + userPartitionInfo: { + selectedPartitionIndex: 1, + selectedGroupsLabel: 'Some label', + }, + hasPartitionGroupComponents: false, + format: 'Homework', + dueDate: 'Dec 28, 2023 at 22:00 UTC', + isTimeLimited: true, + graded: true, + courseGraders: ['Homework'], +}; + +describe(' for unit', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + it('renders XBlockStatus with status messages', () => { + const { queryByTestId } = renderComponent({ blockData: unit }); + + expect(queryByTestId('explanatory-message-span')).not.toBeInTheDocument(); + expect(queryByTestId('release-status-div')).not.toBeInTheDocument(); + + // grading type should not be visible + expect(queryByTestId('grading-type-div')).not.toBeInTheDocument(); + // due date should not be visible + expect(queryByTestId('due-date-div')).not.toBeInTheDocument(); + + // self paced weeks should not be visible for units + expect(queryByTestId('self-paced-relative-due-weeks-div')).not.toBeInTheDocument(); + + // check hide after due date message + // hide after due date message should not be visible as the flag is set to false + expect(queryByTestId('hide-after-due-message')).not.toBeInTheDocument(); + + // check status messages for partition info + const statusDiv = queryByTestId('status-messages-div'); + expect(statusDiv).toBeInTheDocument(); + expect(statusDiv).toHaveTextContent(messages.restrictedUnitAccess.defaultMessage); + }); + + it('renders XBlockStatus with status messages', () => { + const { queryByTestId } = renderComponent({ + blockData: { + ...unit, + hasPartitionGroupComponents: true, + userPartitionInfo: { + selectedPartitionIndex: -1, + selectedGroupsLabel: '', + }, + }, + }); + + // check status messages for partition info + const statusDiv = queryByTestId('status-messages-div'); + expect(statusDiv).toBeInTheDocument(); + expect(statusDiv).toHaveTextContent(messages.restrictedUnitAccessToSomeContent.defaultMessage); + }); +}); diff --git a/src/course-outline/xblock-status/messages.js b/src/course-outline/xblock-status/messages.js new file mode 100644 index 0000000000..33f3397624 --- /dev/null +++ b/src/course-outline/xblock-status/messages.js @@ -0,0 +1,78 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + unscheduledLabel: { + id: 'course-authoring.course-outline.xblock-status.unscheduled.label', + defaultMessage: 'Unscheduled', + }, + releasedLabel: { + id: 'course-authoring.course-outline.xblock-status.released.label', + defaultMessage: 'Released: ', + }, + scheduledLabel: { + id: 'course-authoring.course-outline.xblock-status.scheduled.label', + defaultMessage: 'Scheduled: ', + }, + onboardingExam: { + id: 'course-authoring.course-outline.xblock-status.onboardingExam.value', + defaultMessage: 'Onboarding Exam', + }, + practiceProctoredExam: { + id: 'course-authoring.course-outline.xblock-status.practiceProctoredExam.value', + defaultMessage: 'Practice proctored Exam', + }, + proctoredExam: { + id: 'course-authoring.course-outline.xblock-status.proctoredExam.value', + defaultMessage: 'Proctored Exam', + }, + timedExam: { + id: 'course-authoring.course-outline.xblock-status.timedExam.value', + defaultMessage: 'Timed Exam', + }, + releaseStatusScreenReaderTitle: { + id: 'course-authoring.course-outline.xblock-status.releaseStatusScreenReader.title', + defaultMessage: 'Release Status: ', + }, + gradedAsScreenReaderLabel: { + id: 'course-authoring.course-outline.xblock-status.gradedAsScreenReader.label', + defaultMessage: 'Graded as: ', + }, + ungradedText: { + id: 'course-authoring.course-outline.xblock-status.ungraded.text', + defaultMessage: 'Ungraded', + }, + dueLabel: { + id: 'course-authoring.course-outline.xblock-status.due.label', + defaultMessage: 'Due:', + }, + customDueDateLabel: { + id: 'course-authoring.course-outline.xblock-status.custom-due-date.label', + defaultMessage: 'Custom due date: {relativeWeeksDue, plural, one {# week} other {# weeks}} from enrollment', + }, + prerequisiteLabel: { + id: 'course-authoring.course-outline.xblock-status.prerequisite.label', + defaultMessage: 'Prerequisite: {prereqDisplayName}', + }, + restrictedUnitAccess: { + id: 'course-authoring.course-outline.xblock-status.restrictedUnitAccess.text', + defaultMessage: 'Access to this unit is restricted to: {selectedGroupsLabel}', + }, + restrictedUnitAccessToSomeContent: { + id: 'course-authoring.course-outline.xblock-status.restrictedUnitAccessToSomeContent.text', + defaultMessage: 'Access to some content in this unit is restricted to specific groups of learners', + }, + gradingPolicyMismatchText: { + id: 'course-authoring.course-outline.xblock-status.gradingPolicyMismatch.text', + defaultMessage: 'This subsection is configured as "{gradingType}", which doesn\'t exist in the current grading policy.', + }, + hiddenAfterEndDate: { + id: 'course-authoring.course-outline.xblock-status.hiddenAfterEndDate.text', + defaultMessage: 'Subsection is hidden after course end date', + }, + hiddenAfterDueDate: { + id: 'course-authoring.course-outline.xblock-status.hiddenAfterDueDate.text', + defaultMessage: 'Subsection is hidden after due date', + }, +}); + +export default messages; diff --git a/src/data/constants.js b/src/data/constants.js index 2448504fa5..a1b6a906b0 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -45,6 +45,7 @@ export const DivisionSchemes = /** @type {const} */ ({ }); export const VisibilityTypes = /** @type {const} */ ({ + GATED: 'gated', LIVE: 'live', STAFF_ONLY: 'staff_only', HIDE_AFTER_DUE: 'hide_after_due',