From 2cb907e7319cd741e52ce529553019ba93430530 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 11 Jan 2024 20:02:54 +0530 Subject: [PATCH 01/11] 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 | 21 +- .../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/GradingPolicyAlert.jsx | 48 ++ .../xblock-status/GradingTypeAndDueDate.jsx | 134 +++++ .../xblock-status/HideAfterDueMessage.jsx | 29 + .../xblock-status/ReleaseStatus.jsx | 65 +++ .../xblock-status/StatusMessages.jsx | 88 +++ .../xblock-status/XBlockStatus.jsx | 122 +++++ .../xblock-status/XBlockStatus.scss | 4 + .../xblock-status/XBlockStatus.test.jsx | 503 ++++++++++++++++++ src/course-outline/xblock-status/messages.js | 78 +++ src/data/constants.js | 1 + 27 files changed, 1181 insertions(+), 57 deletions(-) create mode 100644 src/course-outline/xblock-status/GradingPolicyAlert.jsx create mode 100644 src/course-outline/xblock-status/GradingTypeAndDueDate.jsx create mode 100644 src/course-outline/xblock-status/HideAfterDueMessage.jsx create mode 100644 src/course-outline/xblock-status/ReleaseStatus.jsx create mode 100644 src/course-outline/xblock-status/StatusMessages.jsx 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 fee3dafd3..a76b1f5bb 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -53,6 +53,7 @@ const CourseOutline = ({ courseId }) => { statusBarData, courseActions, sectionsList, + isCustomRelativeDatesActive, isLoading, isReIndexShow, showErrorAlert, @@ -309,6 +310,8 @@ const CourseOutline = ({ courseId }) => { section={section} index={sectionIndex} canMoveItem={canMoveItem(sections)} + isSelfPaced={statusBarData.isSelfPaced} + isCustomRelativeDatesActive={isCustomRelativeDatesActive} savingStatus={savingStatus} onOpenHighlightsModal={handleOpenHighlightsModal} onOpenPublishModal={openPublishModal} @@ -332,6 +335,8 @@ const CourseOutline = ({ courseId }) => { subsection={subsection} index={subsectionIndex} canMoveItem={canMoveItem(section.childInfo.children)} + isSelfPaced={statusBarData.isSelfPaced} + isCustomRelativeDatesActive={isCustomRelativeDatesActive} savingStatus={savingStatus} onOpenPublishModal={openPublishModal} onOpenDeleteModal={openDeleteModal} @@ -356,6 +361,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 3c6572dcf..e9ac37e25 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 a6ba83687..a3ea06d8f 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 b268c430b..78ce08864 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 e14752726..2ce86bb60 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 ebfb71a91..3a3a2bb6c 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 33f7344f0..fd2706f65 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 10088a466..8390b282c 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 4f0222975..00393c48f 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 79d99db93..3bfa080e5 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 c444d9db1..07fe2c1cc 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,18 +176,24 @@ const SectionCard = ({ /> )}
- {explanatoryMessage &&

{explanatoryMessage}

} -
+
+
{isExpanded && (
{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 cf30dfcc2..31d56e18e 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 7032212d9..6d7edf1a5 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/GradingPolicyAlert.jsx b/src/course-outline/xblock-status/GradingPolicyAlert.jsx new file mode 100644 index 000000000..27c33b8f4 --- /dev/null +++ b/src/course-outline/xblock-status/GradingPolicyAlert.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Alert } from '@edx/paragon'; +import { + WarningFilled as WarningIcon, +} from '@edx/paragon/icons'; + +import messages from './messages'; + +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, +}; + +export default GradingPolicyAlert; diff --git a/src/course-outline/xblock-status/GradingTypeAndDueDate.jsx b/src/course-outline/xblock-status/GradingTypeAndDueDate.jsx new file mode 100644 index 000000000..50d5bcb4f --- /dev/null +++ b/src/course-outline/xblock-status/GradingTypeAndDueDate.jsx @@ -0,0 +1,134 @@ +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, + CalendarMonth as CalendarIcon, +} from '@edx/paragon/icons'; + +import messages from './messages'; + +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, +}; + +export default GradingTypeAndDueDate; diff --git a/src/course-outline/xblock-status/HideAfterDueMessage.jsx b/src/course-outline/xblock-status/HideAfterDueMessage.jsx new file mode 100644 index 000000000..6c0dd282d --- /dev/null +++ b/src/course-outline/xblock-status/HideAfterDueMessage.jsx @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Icon } from '@edx/paragon'; +import { + VisibilityOff as HideIcon, +} from '@edx/paragon/icons'; + +import messages from './messages'; + +const HideAfterDueMessage = ({ isSelfPaced }) => { + const intl = useIntl(); + return ( +
+ + + {isSelfPaced + ? intl.formatMessage(messages.hiddenAfterEndDate) + : intl.formatMessage(messages.hiddenAfterDueDate)} + +
+ ); +}; + +HideAfterDueMessage.propTypes = { + isSelfPaced: PropTypes.bool.isRequired, +}; + +export default HideAfterDueMessage; diff --git a/src/course-outline/xblock-status/ReleaseStatus.jsx b/src/course-outline/xblock-status/ReleaseStatus.jsx new file mode 100644 index 000000000..6eed48a6d --- /dev/null +++ b/src/course-outline/xblock-status/ReleaseStatus.jsx @@ -0,0 +1,65 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Icon } from '@edx/paragon'; +import { + AccessTime as ClockIcon, +} from '@edx/paragon/icons'; + +import messages from './messages'; + +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, +}; + +export default ReleaseStatus; diff --git a/src/course-outline/xblock-status/StatusMessages.jsx b/src/course-outline/xblock-status/StatusMessages.jsx new file mode 100644 index 000000000..8e8dd39fc --- /dev/null +++ b/src/course-outline/xblock-status/StatusMessages.jsx @@ -0,0 +1,88 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Icon } from '@edx/paragon'; +import { + Lock as LockIcon, + Groups as GroupsIcon, +} from '@edx/paragon/icons'; + +import messages from './messages'; + +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, +}; + +export default StatusMessages; diff --git a/src/course-outline/xblock-status/XBlockStatus.jsx b/src/course-outline/xblock-status/XBlockStatus.jsx new file mode 100644 index 000000000..4073d3a9d --- /dev/null +++ b/src/course-outline/xblock-status/XBlockStatus.jsx @@ -0,0 +1,122 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { COURSE_BLOCK_NAMES } from '../constants'; +import ReleaseStatus from './ReleaseStatus'; +import GradingPolicyAlert from './GradingPolicyAlert'; +import GradingTypeAndDueDate from './GradingTypeAndDueDate'; +import StatusMessages from './StatusMessages'; +import HideAfterDueMessage from './HideAfterDueMessage'; + +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 000000000..92fa58aca --- /dev/null +++ b/src/course-outline/xblock-status/XBlockStatus.scss @@ -0,0 +1,4 @@ +.grading-mismatch-alert { + font-size: 14px; + font-weight: 400; +} 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 000000000..7ebb74820 --- /dev/null +++ b/src/course-outline/xblock-status/XBlockStatus.test.jsx @@ -0,0 +1,503 @@ +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 { queryByText } = renderComponent({ + blockData: { + ...section, + format: 'Fun', + }, + }); + + // check alert + const alert = queryByText(messages.gradingPolicyMismatchText.defaultMessage); + expect(alert).toBeInTheDocument(); + }); +}); + +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 000000000..33f339762 --- /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 2448504fa..a1b6a906b 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', From 815ddbe94ea8348d3322930cc51cfd6e542f6756 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Thu, 25 Jan 2024 20:21:28 +0530 Subject: [PATCH 02/11] feat: copy & paste units refactor: paste component fix: lint issues and delete unused hook test: add test fix: update api for npm broadcast channel --- package-lock.json | 69 ++++++++++- package.json | 1 + src/constants.js | 2 + src/course-outline/CourseOutline.jsx | 4 + src/course-outline/CourseOutline.scss | 1 + src/course-outline/CourseOutline.test.jsx | 78 ++++++++++++ .../__mocks__/courseOutlineIndex.js | 4 +- src/course-outline/card-header/CardHeader.jsx | 17 +++ src/course-outline/card-header/messages.js | 4 + src/course-outline/data/api.js | 30 +++++ src/course-outline/data/selectors.js | 1 + src/course-outline/data/slice.js | 11 ++ src/course-outline/data/thunk.js | 49 +++++++- src/course-outline/hooks.jsx | 17 +++ .../paste-button/PasteButton.jsx | 115 ++++++++++++++++++ .../paste-button/PasteButton.scss | 20 +++ src/course-outline/paste-button/messages.js | 14 +++ .../subsection-card/SubsectionCard.jsx | 36 ++++-- .../subsection-card/messages.js | 4 + src/course-outline/unit-card/UnitCard.jsx | 11 ++ .../unit-card/UnitCard.test.jsx | 14 +++ src/generic/broadcast-channel/hooks.js | 46 +++++++ 22 files changed, 531 insertions(+), 17 deletions(-) create mode 100644 src/course-outline/paste-button/PasteButton.jsx create mode 100644 src/course-outline/paste-button/PasteButton.scss create mode 100644 src/course-outline/paste-button/messages.js create mode 100644 src/generic/broadcast-channel/hooks.js diff --git a/package-lock.json b/package-lock.json index e397fafa0..144fcb875 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@fortawesome/react-fontawesome": "0.2.0", "@reduxjs/toolkit": "1.9.7", "@tanstack/react-query": "4.36.1", + "broadcast-channel": "^7.0.0", "classnames": "2.2.6", "core-js": "3.8.1", "email-validator": "2.0.4", @@ -2022,18 +2023,20 @@ "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==" }, "node_modules/@babel/runtime": { - "version": "7.21.0", - "license": "MIT", + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.4.tgz", + "integrity": "sha512-2Yv65nlWnWlSpe3fXEyX5i7fx5kIKo4Qbcj+hMO0odwaneFjfXw5fdum+4yL20O0QiaHpia0cYQ9xpNMqrBwHg==", "dependencies": { - "regenerator-runtime": "^0.13.11" + "regenerator-runtime": "^0.14.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/runtime/node_modules/regenerator-runtime": { - "version": "0.13.11", - "license": "MIT" + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/@babel/template": { "version": "7.22.15", @@ -9273,6 +9276,20 @@ "node": ">=8" } }, + "node_modules/broadcast-channel": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-7.0.0.tgz", + "integrity": "sha512-a2tW0Ia1pajcPBOGUF2jXlDnvE9d5/dg6BG9h60OmRUcZVr/veUrU8vEQFwwQIhwG3KVzYwSk3v2nRRGFgQDXQ==", + "dependencies": { + "@babel/runtime": "7.23.4", + "oblivious-set": "1.4.0", + "p-queue": "6.6.2", + "unload": "2.4.1" + }, + "funding": { + "url": "https://github.com/sponsors/pubkey" + } + }, "node_modules/browser-process-hrtime": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", @@ -21972,6 +21989,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oblivious-set": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-1.4.0.tgz", + "integrity": "sha512-szyd0ou0T8nsAqHtprRcP3WidfsN1TnAR5yWXf2mFCEr5ek3LEOkT6EZ/92Xfs74HIdyhG5WkGxIssMU0jBaeg==", + "engines": { + "node": ">=16" + } + }, "node_modules/obuf": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", @@ -22201,6 +22226,21 @@ "node": ">=6" } }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", @@ -22213,6 +22253,17 @@ "node": ">=8" } }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-try": { "version": "2.2.0", "license": "MIT", @@ -27382,6 +27433,14 @@ "node": ">= 10.0.0" } }, + "node_modules/unload": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/unload/-/unload-2.4.1.tgz", + "integrity": "sha512-IViSAm8Z3sRBYA+9wc0fLQmU9Nrxb16rcDmIiR6Y9LJSZzI7QY5QsDhqPpKOjAn0O9/kfK1TfNEMMAGPTIraPw==", + "funding": { + "url": "https://github.com/sponsors/pubkey" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index f0095f9e7..24472c2bf 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@fortawesome/react-fontawesome": "0.2.0", "@reduxjs/toolkit": "1.9.7", "@tanstack/react-query": "4.36.1", + "broadcast-channel": "^7.0.0", "classnames": "2.2.6", "core-js": "3.8.1", "email-validator": "2.0.4", diff --git a/src/constants.js b/src/constants.js index 7c293fe12..cfb427edb 100644 --- a/src/constants.js +++ b/src/constants.js @@ -23,6 +23,8 @@ export const NOTIFICATION_MESSAGES = { saving: 'Saving', duplicating: 'Duplicating', deleting: 'Deleting', + copying: 'Copying', + pasting: 'Pasting', empty: '', }; diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx index a76b1f5bb..020cc0a42 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -95,6 +95,8 @@ const CourseOutline = ({ courseId }) => { handleSubsectionDragAndDrop, handleVideoSharingOptionChange, handleUnitDragAndDrop, + handleCopyToClipboardClick, + handlePasteClipboardClick, } = useCourseOutline({ courseId }); const [sections, setSections] = useState(sectionsList); @@ -349,6 +351,7 @@ const CourseOutline = ({ courseId }) => { section, section.childInfo.children, )} + onPasteClick={handlePasteClipboardClick} > { subsection, subsection.childInfo.children, )} + onCopyToClipboardClick={handleCopyToClipboardClick} /> ))} diff --git a/src/course-outline/CourseOutline.scss b/src/course-outline/CourseOutline.scss index e9ac37e25..a8b51fcfb 100644 --- a/src/course-outline/CourseOutline.scss +++ b/src/course-outline/CourseOutline.scss @@ -10,3 +10,4 @@ @import "./configure-modal/ConfigureModal"; @import "./drag-helper/ConditionalSortableElement"; @import "./xblock-status/XBlockStatus"; +@import "./paste-button/PasteButton"; diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.jsx index ac95308aa..6688b29e9 100644 --- a/src/course-outline/CourseOutline.test.jsx +++ b/src/course-outline/CourseOutline.test.jsx @@ -16,6 +16,7 @@ import { getCourseBlockApiUrl, getCourseItemApiUrl, getXBlockBaseApiUrl, + getClipboardUrl, } from './data/api'; import { RequestStatus } from '../data/constants'; import { @@ -42,6 +43,8 @@ import cardHeaderMessages from './card-header/messages'; import enableHighlightsModalMessages from './enable-highlights-modal/messages'; import statusBarMessages from './status-bar/messages'; import configureModalMessages from './configure-modal/messages'; +import pasteButtonMessages from './paste-button/messages'; +import subsectionMessages from './subsection-card/messages'; let axiosMock; let store; @@ -1337,4 +1340,79 @@ describe('', () => { expect(within(sectionElement).queryByText(section.displayName)).toBeInTheDocument(); }); }); + + it('check whether unit copy & paste option works correctly', async () => { + const { findAllByTestId } = render(); + // get first section -> first subsection -> first unit element + const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; + const [sectionElement] = await findAllByTestId('section-card'); + const [subsection] = section.childInfo.children; + let [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); + await act(async () => fireEvent.click(expandBtn)); + const [unit] = subsection.childInfo.children; + const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); + + const expectedClipboardContent = { + content: { + blockType: 'vertical', + blockTypeDisplay: 'Unit', + created: '2024-01-29T07:58:36.844249Z', + displayName: unit.displayName, + id: 15, + olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/15/olx', + purpose: 'clipboard', + status: 'ready', + userId: 3, + }, + sourceUsageKey: unit.id, + sourceContexttitle: courseOutlineIndexMock.courseStructure.displayName, + sourceEditUrl: unit.studioUrl, + }; + // mock api call + axiosMock + .onPost(getClipboardUrl(), { + usage_key: unit.id, + }).reply(200, expectedClipboardContent); + // check that initialUserClipboard state is empty + const { initialUserClipboard } = store.getState().courseOutline; + expect(initialUserClipboard).toBeUndefined(); + + // find menu button and click on it to open menu + const menu = await within(unitElement).findByTestId('unit-card-header__menu-button'); + await act(async () => fireEvent.click(menu)); + + // move first unit back to second position to test move down option + const copyButton = await within(unitElement).findByText(cardHeaderMessages.menuCopy.defaultMessage); + await act(async () => fireEvent.click(copyButton)); + + // check that initialUserClipboard state is updated + expect(store.getState().courseOutline.initialUserClipboard).toEqual(expectedClipboardContent); + + [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + // find clipboard content label + const clipboardLabel = await within(subsectionElement).findByText( + pasteButtonMessages.clipboardContentLabel.defaultMessage, + ); + await act(async () => fireEvent.mouseOver(clipboardLabel)); + + // find clipboard content popup link + expect( + subsectionElement.querySelector('#vertical-paste-button-overlay'), + ).toHaveAttribute('href', unit.studioUrl); + + // check paste button functionality + // mock api call + axiosMock + .onPost(getXBlockBaseApiUrl(), { + parent_locator: subsection.id, + staged_content: 'clipboard', + }).reply(200, { dummy: 'value' }); + const pasteBtn = await within(subsectionElement).findByText(subsectionMessages.pasteButton.defaultMessage); + await act(async () => fireEvent.click(pasteBtn)); + + [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + const lastUnitElement = (await within(subsectionElement).findAllByTestId('unit-card')).slice(-1)[0]; + expect(lastUnitElement).toHaveTextContent(unit.displayName); + }); }); diff --git a/src/course-outline/__mocks__/courseOutlineIndex.js b/src/course-outline/__mocks__/courseOutlineIndex.js index 0e508c530..2aa188c79 100644 --- a/src/course-outline/__mocks__/courseOutlineIndex.js +++ b/src/course-outline/__mocks__/courseOutlineIndex.js @@ -261,6 +261,7 @@ module.exports = { ancestorHasStaffLock: true, staffOnlyMessage: false, hasPartitionGroupComponents: false, + enableCopyPasteUnits: true, userPartitionInfo: { selectablePartitions: [ { @@ -292,6 +293,7 @@ module.exports = { ancestorHasStaffLock: true, staffOnlyMessage: false, hasPartitionGroupComponents: false, + enableCopyPasteUnits: true, userPartitionInfo: { selectablePartitions: [ { @@ -391,7 +393,7 @@ module.exports = { }, ancestor_has_staff_lock: false, staff_only_message: false, - enable_copy_paste_units: false, + enable_copy_paste_units: true, has_partition_group_components: false, user_partition_info: { selectable_partitions: [ diff --git a/src/course-outline/card-header/CardHeader.jsx b/src/course-outline/card-header/CardHeader.jsx index 6a2837175..7ff6213fc 100644 --- a/src/course-outline/card-header/CardHeader.jsx +++ b/src/course-outline/card-header/CardHeader.jsx @@ -35,9 +35,12 @@ const CardHeader = ({ onClickDuplicate, onClickMoveUp, onClickMoveDown, + onClickCopy, titleComponent, namePrefix, actions, + enableCopyPasteUnits, + isVertical, }) => { const intl = useIntl(); const [searchParams] = useSearchParams(); @@ -126,6 +129,11 @@ const CardHeader = ({ > {intl.formatMessage(messages.menuConfigure)} + {isVertical && enableCopyPasteUnits && ( + + {intl.formatMessage(messages.menuCopy)} + + )} {actions.duplicable && ( `${getApiBaseUrl()}${rein export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`; export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`; export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`; +export const getClipboardUrl = () => `${getApiBaseUrl()}/api/content-staging/v1/clipboard/`; /** * @typedef {Object} courseOutline @@ -412,3 +413,32 @@ export async function setVideoSharingOption(courseId, videoSharingOption) { return data; } + +/** + * Copy block to clipboard + * @param {string} usageKey + * @returns {Promise} +*/ +export async function copyBlockToClipboard(usageKey) { + const { data } = await getAuthenticatedHttpClient() + .post(getClipboardUrl(), { + usage_key: usageKey, + }); + + return camelCaseObject(data); +} + +/** + * Paste block to clipboard + * @param {string} parentLocator + * @returns {Promise} +*/ +export async function pasteBlock(parentLocator) { + const { data } = await getAuthenticatedHttpClient() + .post(getXBlockBaseApiUrl(), { + parent_locator: parentLocator, + staged_content: 'clipboard', + }); + + return data; +} diff --git a/src/course-outline/data/selectors.js b/src/course-outline/data/selectors.js index 3a3a2bb6c..88d9e9a91 100644 --- a/src/course-outline/data/selectors.js +++ b/src/course-outline/data/selectors.js @@ -8,3 +8,4 @@ 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; +export const getInitialUserClipboard = (state) => state.courseOutline.initialUserClipboard; diff --git a/src/course-outline/data/slice.js b/src/course-outline/data/slice.js index fd2706f65..6c2da8988 100644 --- a/src/course-outline/data/slice.js +++ b/src/course-outline/data/slice.js @@ -38,12 +38,19 @@ const slice = createSlice({ childAddable: true, duplicable: true, }, + initialUserClipboard: { + content: {}, + sourceUsageKey: null, + sourceContexttitle: null, + sourceEditUrl: null, + }, }, reducers: { fetchOutlineIndexSuccess: (state, { payload }) => { state.outlineIndexData = payload; state.sectionsList = payload.courseStructure?.childInfo?.children || []; state.isCustomRelativeDatesActive = payload.isCustomRelativeDatesActive; + state.initialUserClipboard = payload.initialUserClipboard; }, updateOutlineIndexLoadingStatus: (state, { payload }) => { state.loadingStatus = { @@ -69,6 +76,9 @@ const slice = createSlice({ ...payload, }; }, + updateClipboardContent: (state, { payload }) => { + state.initialUserClipboard = payload; + }, updateCourseActions: (state, { payload }) => { state.actions = { ...state.actions, @@ -205,6 +215,7 @@ export const { reorderSectionList, reorderSubsectionList, reorderUnitList, + updateClipboardContent, } = slice.actions; export const { diff --git a/src/course-outline/data/thunk.js b/src/course-outline/data/thunk.js index e7bc22509..611aaa2db 100644 --- a/src/course-outline/data/thunk.js +++ b/src/course-outline/data/thunk.js @@ -28,6 +28,8 @@ import { setSectionOrderList, setVideoSharingOption, setCourseItemOrderList, + copyBlockToClipboard, + pasteBlock, } from './api'; import { addSection, @@ -49,6 +51,7 @@ import { reorderSectionList, reorderSubsectionList, reorderUnitList, + updateClipboardContent, } from './slice'; export function fetchCourseOutlineIndexQuery(courseId) { @@ -371,7 +374,7 @@ export function deleteCourseUnitQuery(unitId, subsectionId, sectionId) { function duplicateCourseItemQuery(itemId, parentLocator, duplicateFn) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.duplicating)); try { await duplicateCourseItem(itemId, parentLocator).then(async (result) => { @@ -560,3 +563,47 @@ export function setUnitOrderListQuery(sectionId, subsectionId, unitListIds, rest } }; } + +export function setClipboardContent(usageKey, broadcastClipboard) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.copying)); + + try { + await copyBlockToClipboard(usageKey).then(async (result) => { + const status = result?.content?.status; + if (status === 'ready') { + dispatch(updateClipboardContent(result)); + broadcastClipboard(result); + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + dispatch(hideProcessingNotification()); + } else { + throw new Error(`Unexpected clipboard status "${status}" in successful API response.`); + } + }); + } catch (error) { + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } + }; +} + +export function pasteClipboardContent(parentLocator, sectionId) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.pasting)); + + try { + await pasteBlock(parentLocator).then(async (result) => { + if (result) { + dispatch(fetchCourseSectionQuery(sectionId, true)); + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + dispatch(hideProcessingNotification()); + } + }); + } catch (error) { + dispatch(hideProcessingNotification()); + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } + }; +} diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index 3bfa080e5..a2b04b2d8 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -6,10 +6,12 @@ import { getConfig } from '@edx/frontend-platform'; import { RequestStatus } from '../data/constants'; import { COURSE_BLOCK_NAMES } from './constants'; +import { useBroadcastChannel } from '../generic/broadcast-channel/hooks'; import { setCurrentItem, setCurrentSection, updateSavingStatus, + updateClipboardContent, } from './data/slice'; import { getLoadingStatus, @@ -48,6 +50,8 @@ import { setVideoSharingOptionQuery, setSubsectionOrderListQuery, setUnitOrderListQuery, + setClipboardContent, + pasteClipboardContent, } from './data/thunk'; const useCourseOutline = ({ courseId }) => { @@ -74,6 +78,17 @@ const useCourseOutline = ({ courseId }) => { const [isPublishModalOpen, openPublishModal, closePublishModal] = useToggle(false); const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); + const clipboardBroadcastChannel = useBroadcastChannel('studio_clipboard_channel', (message) => { + dispatch(updateClipboardContent(message)); + }); + + const handleCopyToClipboardClick = (usageKey) => { + dispatch(setClipboardContent(usageKey, clipboardBroadcastChannel.postMessage)); + }; + + const handlePasteClipboardClick = (parentLocator, sectionId) => { + dispatch(pasteClipboardContent(parentLocator, sectionId)); + }; const handleNewSectionSubmit = () => { dispatch(addNewSectionQuery(courseStructure.id)); @@ -289,6 +304,8 @@ const useCourseOutline = ({ courseId }) => { handleSubsectionDragAndDrop, handleVideoSharingOptionChange, handleUnitDragAndDrop, + handleCopyToClipboardClick, + handlePasteClipboardClick, }; }; diff --git a/src/course-outline/paste-button/PasteButton.jsx b/src/course-outline/paste-button/PasteButton.jsx new file mode 100644 index 000000000..d8c9c6689 --- /dev/null +++ b/src/course-outline/paste-button/PasteButton.jsx @@ -0,0 +1,115 @@ +import { useState, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import { + Hyperlink, Icon, Button, OverlayTrigger, +} from '@edx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + FileCopy as PasteIcon, + Question as QuestionIcon, +} from '@edx/paragon/icons'; +import { getInitialUserClipboard } from '../data/selectors'; +import messages from './messages'; + +const PasteButton = ({ + text, + blockType, + onClick, +}) => { + const intl = useIntl(); + const initialUserClipboard = useSelector(getInitialUserClipboard); + const { + content, + sourceContextTitle, + sourceEditUrl, + } = initialUserClipboard || {}; + // Show button only if clipboard has content + const showPasteButton = ( + content?.status === 'ready' + && content?.blockType === blockType + ); + + const [show, setShow] = useState(false); + const handleOnMouseEnter = () => { + setShow(true); + }; + const handleOnMouseLeave = () => { + setShow(false); + }; + const ref = useRef(null); + + if (!showPasteButton) { + return null; + } + + const renderBlockLink = (props) => ( + +
+

+ {content?.displayName}
+ + {content?.blockTypeDisplay} + +

+ + {intl.formatMessage(messages.clipboardContentFromLabel)} + {sourceContextTitle} + +
+
+ ); + + return ( + <> + + +
+ + {intl.formatMessage(messages.clipboardContentLabel)} +
+
+ + ); +}; + +PasteButton.propTypes = { + text: PropTypes.string.isRequired, + blockType: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired, +}; + +export default PasteButton; diff --git a/src/course-outline/paste-button/PasteButton.scss b/src/course-outline/paste-button/PasteButton.scss new file mode 100644 index 000000000..04d449181 --- /dev/null +++ b/src/course-outline/paste-button/PasteButton.scss @@ -0,0 +1,20 @@ +// adds bottom arrow to popup link +.popup-link { + position: relative; + + &::after { + content: ""; + position: absolute; + top: 100%; + left: 50%; + width: 0; + height: 0; + border-top: solid .5rem white; + border-left: solid .5rem transparent; + border-right: solid .5rem transparent; + } +} + +.cursor-help { + cursor: help !important; +} diff --git a/src/course-outline/paste-button/messages.js b/src/course-outline/paste-button/messages.js new file mode 100644 index 000000000..0576b500f --- /dev/null +++ b/src/course-outline/paste-button/messages.js @@ -0,0 +1,14 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + clipboardContentFromLabel: { + id: 'course-authoring.course-outline.paste-button.whats-in-clipboard.from-label', + defaultMessage: 'From: ', + }, + clipboardContentLabel: { + id: 'course-authoring.course-outline.paste-button.whats-in-clipboard.label', + defaultMessage: 'What\'s in my clipboard?', + }, +}); + +export default messages; diff --git a/src/course-outline/subsection-card/SubsectionCard.jsx b/src/course-outline/subsection-card/SubsectionCard.jsx index b3a652f8f..8de54ec27 100644 --- a/src/course-outline/subsection-card/SubsectionCard.jsx +++ b/src/course-outline/subsection-card/SubsectionCard.jsx @@ -9,11 +9,13 @@ import classNames from 'classnames'; import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice'; import { RequestStatus } from '../../data/constants'; +import { COURSE_BLOCK_NAMES } from '../constants'; 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 PasteButton from '../paste-button/PasteButton'; import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils'; import messages from './messages'; @@ -33,6 +35,7 @@ const SubsectionCard = ({ onNewUnitSubmit, onOrderChange, onOpenConfigureModal, + onPasteClick, }) => { const currentRef = useRef(null); const intl = useIntl(); @@ -51,6 +54,7 @@ const SubsectionCard = ({ visibilityState, actions: subsectionActions, isHeaderVisible = true, + enableCopyPasteUnits = false, } = subsection; // re-create actions object for customizations @@ -95,6 +99,7 @@ const SubsectionCard = ({ }; const handleNewButtonClick = () => onNewUnitSubmit(id); + const handlePasteButtonClick = () => onPasteClick(id, section.id); const titleComponent = ( {children} {actions.childAddable && ( - + <> + + {enableCopyPasteUnits && ( + + )} + )} )} @@ -218,6 +232,7 @@ SubsectionCard.propTypes = { hasChanges: PropTypes.bool.isRequired, visibilityState: PropTypes.string.isRequired, shouldScroll: PropTypes.bool, + enableCopyPasteUnits: PropTypes.bool, actions: PropTypes.shape({ deletable: PropTypes.bool.isRequired, draggable: PropTypes.bool.isRequired, @@ -239,6 +254,7 @@ SubsectionCard.propTypes = { canMoveItem: PropTypes.func.isRequired, onOrderChange: PropTypes.func.isRequired, onOpenConfigureModal: PropTypes.func.isRequired, + onPasteClick: PropTypes.func.isRequired, }; export default SubsectionCard; diff --git a/src/course-outline/subsection-card/messages.js b/src/course-outline/subsection-card/messages.js index 90ca407b1..b4a0b5661 100644 --- a/src/course-outline/subsection-card/messages.js +++ b/src/course-outline/subsection-card/messages.js @@ -5,6 +5,10 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.subsection.button.new-unit', defaultMessage: 'New unit', }, + pasteButton: { + id: 'course-authoring.course-outline.subsection.button.new-unit', + defaultMessage: 'Paste unit', + }, }); export default messages; diff --git a/src/course-outline/unit-card/UnitCard.jsx b/src/course-outline/unit-card/UnitCard.jsx index 1601b54e4..1cb95f718 100644 --- a/src/course-outline/unit-card/UnitCard.jsx +++ b/src/course-outline/unit-card/UnitCard.jsx @@ -28,6 +28,7 @@ const UnitCard = ({ onDuplicateSubmit, getTitleLink, onOrderChange, + onCopyToClipboardClick, }) => { const currentRef = useRef(null); const dispatch = useDispatch(); @@ -42,6 +43,7 @@ const UnitCard = ({ visibilityState, actions: unitActions, isHeaderVisible = true, + enableCopyPasteUnits = false, } = unit; // re-create actions object for customizations @@ -80,6 +82,10 @@ const UnitCard = ({ onOrderChange(index, index + 1); }; + const handleCopyClick = () => { + onCopyToClipboardClick(unit.id); + }; + const titleComponent = (
', () => { expect(within(element).queryByTestId('unit-card-header__menu-duplicate-button')).not.toBeInTheDocument(); expect(within(element).queryByTestId('unit-card-header__menu-delete-button')).not.toBeInTheDocument(); }); + + it('shows copy option based on enableCopyPasteUnits flag', async () => { + const { findByTestId } = renderComponent({ + unit: { + ...unit, + enableCopyPasteUnits: true, + }, + }); + const element = await findByTestId('unit-card'); + const menu = await within(element).findByTestId('unit-card-header__menu-button'); + await act(async () => fireEvent.click(menu)); + expect(within(element).queryByText(cardMessages.menuCopy.defaultMessage)).toBeInTheDocument(); + }); }); diff --git a/src/generic/broadcast-channel/hooks.js b/src/generic/broadcast-channel/hooks.js new file mode 100644 index 000000000..230c153d1 --- /dev/null +++ b/src/generic/broadcast-channel/hooks.js @@ -0,0 +1,46 @@ +import { + useCallback, useEffect, useMemo, useRef, +} from 'react'; +import { BroadcastChannel } from 'broadcast-channel'; + +const channelInstances = {}; + +export const getSingletonChannel = (name) => { + if (!channelInstances[name]) { + channelInstances[name] = new BroadcastChannel(name); + } + return channelInstances[name]; +}; + +export const useBroadcastChannel = (channelName, onMessageReceived) => { + const channel = useMemo(() => getSingletonChannel(channelName), [channelName]); + const isSubscribed = useRef(false); + + useEffect(() => { + if (!isSubscribed.current || process.env.NODE_ENV !== 'development') { + // BroadcastChannel api from npm has minor difference from native BroadcastChannel + // Native BroadcastChannel passes event to onmessage callback and to + // access data we need to use `event.data`, but npm BroadcastChannel + // directly passes data as seen below + channel.onmessage = (data) => onMessageReceived(data); + } + return () => { + if (isSubscribed.current || process.env.NODE_ENV !== 'development') { + channel.close(); + isSubscribed.current = true; + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const postMessage = useCallback( + (message) => { + channel?.postMessage(message); + }, + [channel], + ); + + return { + postMessage, + }; +}; From 4850302175853bd9a2edec41b99fdcd9be77836e Mon Sep 17 00:00:00 2001 From: "Adolfo R. Brandes" Date: Tue, 6 Feb 2024 14:28:24 -0300 Subject: [PATCH 03/11] fix: Runtime config support for feature flags This makes sure the following feature flags work with dynamic runtime configuration: * ENABLE_NEW_EDITOR_PAGES * ENABLE_UNIT_PAGE * ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN * ENABLE_TAGGING_TAXONOMY_PAGES We also remove flags from the `.env*` files that are no longer in use. --- .env | 8 +++----- .env.development | 6 +++--- .env.test | 8 +++----- src/CourseAuthoringRoutes.jsx | 7 ++++--- src/course-outline/hooks.jsx | 4 ++-- src/header/utils.js | 3 ++- src/header/utils.test.js | 10 +++++++++- src/index.jsx | 8 ++++++-- 8 files changed, 32 insertions(+), 22 deletions(-) diff --git a/.env b/.env index d5cb397aa..7c027d771 100644 --- a/.env +++ b/.env @@ -32,11 +32,9 @@ ENABLE_ACCESSIBILITY_PAGE=false ENABLE_PROGRESS_GRAPH_SETTINGS=false ENABLE_TEAM_TYPE_SETTING=false ENABLE_NEW_EDITOR_PAGES=true -ENABLE_NEW_COURSE_OUTLINE_PAGE = false -ENABLE_NEW_VIDEO_UPLOAD_PAGE = false -ENABLE_UNIT_PAGE = false -ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = false -ENABLE_TAGGING_TAXONOMY_PAGES = false +ENABLE_UNIT_PAGE=false +ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false +ENABLE_TAGGING_TAXONOMY_PAGES=false BBB_LEARN_MORE_URL='' HOTJAR_APP_ID='' HOTJAR_VERSION=6 diff --git a/.env.development b/.env.development index bb636463c..31c11a560 100644 --- a/.env.development +++ b/.env.development @@ -33,9 +33,9 @@ ENABLE_ACCESSIBILITY_PAGE=false ENABLE_PROGRESS_GRAPH_SETTINGS=false ENABLE_TEAM_TYPE_SETTING=false ENABLE_NEW_EDITOR_PAGES=true -ENABLE_NEW_VIDEO_UPLOAD_PAGE = false -ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = false -ENABLE_TAGGING_TAXONOMY_PAGES = true +ENABLE_UNIT_PAGE=false +ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=false +ENABLE_TAGGING_TAXONOMY_PAGES=true BBB_LEARN_MORE_URL='' HOTJAR_APP_ID='' HOTJAR_VERSION=6 diff --git a/.env.test b/.env.test index 0689cddb3..6a6b7d4dd 100644 --- a/.env.test +++ b/.env.test @@ -30,10 +30,8 @@ USER_INFO_COOKIE_NAME='edx-user-info' ENABLE_PROGRESS_GRAPH_SETTINGS=false ENABLE_TEAM_TYPE_SETTING=false ENABLE_NEW_EDITOR_PAGES=true -ENABLE_NEW_COURSE_OUTLINE_PAGE = true -ENABLE_NEW_VIDEO_UPLOAD_PAGE = true -ENABLE_UNIT_PAGE = true -ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = true -ENABLE_TAGGING_TAXONOMY_PAGES = true +ENABLE_UNIT_PAGE=true +ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true +ENABLE_TAGGING_TAXONOMY_PAGES=true BBB_LEARN_MORE_URL='' INVITE_STUDENTS_EMAIL_TO="someone@domain.com" diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index f5531eafa..30c412bd0 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -2,6 +2,7 @@ import React from 'react'; import { Navigate, Routes, Route, useParams, } from 'react-router-dom'; +import { getConfig } from '@edx/frontend-platform'; import { PageWrap } from '@edx/frontend-platform/react'; import CourseAuthoringPage from './CourseAuthoringPage'; import { PagesAndResources } from './pages-and-resources'; @@ -55,7 +56,7 @@ const CourseAuthoringRoutes = () => { /> : null} + element={getConfig().ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true' ? : null} /> { /> : null} + element={getConfig().ENABLE_NEW_EDITOR_PAGES === 'true' ? : null} /> : null} + element={getConfig().ENABLE_NEW_EDITOR_PAGES === 'true' ? : null} /> { }; const getUnitUrl = (locator) => { - if (process.env.ENABLE_UNIT_PAGE === 'true') { + if (getConfig().ENABLE_UNIT_PAGE === 'true') { return `/course/container/${locator}`; } return `${getConfig().STUDIO_BASE_URL}/container/${locator}`; @@ -107,7 +107,7 @@ const useCourseOutline = ({ courseId }) => { const openUnitPage = (locator) => { const url = getUnitUrl(locator); - if (process.env.ENABLE_UNIT_PAGE === 'true') { + if (getConfig().ENABLE_UNIT_PAGE === 'true') { navigate(url); } else { window.location.assign(url); diff --git a/src/header/utils.js b/src/header/utils.js index bc3e7644a..e019b3214 100644 --- a/src/header/utils.js +++ b/src/header/utils.js @@ -1,3 +1,4 @@ +import { getConfig } from '@edx/frontend-platform'; import { getPagePath } from '../utils'; import messages from './messages'; @@ -20,7 +21,7 @@ export const getContentMenuItems = ({ studioBaseUrl, courseId, intl }) => { title: intl.formatMessage(messages['header.links.filesAndUploads']), }, ]; - if (process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true') { + if (getConfig().ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true') { items.push({ href: `${studioBaseUrl}/videos/${courseId}`, title: intl.formatMessage(messages['header.links.videoUploads']), diff --git a/src/header/utils.test.js b/src/header/utils.test.js index 3f7a35ba7..35072db88 100644 --- a/src/header/utils.test.js +++ b/src/header/utils.test.js @@ -1,3 +1,4 @@ +import { getConfig, setConfig } from '@edx/frontend-platform'; import { getContentMenuItems } from './utils'; const props = { @@ -11,11 +12,18 @@ const props = { describe('header utils', () => { describe('getContentMenuItems', () => { it('should include Video Uploads option', () => { + setConfig({ + ...getConfig(), + ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'true', + }); const actualItems = getContentMenuItems(props); expect(actualItems).toHaveLength(5); }); it('should not include Video Uploads option', () => { - process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = 'false'; + setConfig({ + ...getConfig(), + ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'false', + }); const actualItems = getContentMenuItems(props); expect(actualItems).toHaveLength(4); }); diff --git a/src/index.jsx b/src/index.jsx index 962c5d228..a309fa1e4 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -53,7 +53,7 @@ const App = () => { } /> } /> } /> - {process.env.ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && ( + {getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && ( <> {/* TODO: remove this redirect once Studio's link is updated */} } /> @@ -114,8 +114,12 @@ initialize({ STUDIO_SHORT_NAME: process.env.STUDIO_SHORT_NAME || null, TERMS_OF_SERVICE_URL: process.env.TERMS_OF_SERVICE_URL || null, PRIVACY_POLICY_URL: process.env.PRIVACY_POLICY_URL || null, - SHOW_ACCESSIBILITY_PAGE: process.env.SHOW_ACCESSIBILITY_PAGE || false, + SHOW_ACCESSIBILITY_PAGE: process.env.SHOW_ACCESSIBILITY_PAGE || 'false', NOTIFICATION_FEEDBACK_URL: process.env.NOTIFICATION_FEEDBACK_URL || null, + ENABLE_NEW_EDITOR_PAGES: process.env.ENABLE_NEW_EDITOR_PAGES || 'false', + ENABLE_UNIT_PAGE: process.env.ENABLE_UNIT_PAGE || 'false', + ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN || 'false', + ENABLE_TAGGING_TAXONOMY_PAGES: process.env.ENABLE_TAGGING_TAXONOMY_PAGES || 'false', }, 'CourseAuthoringConfig'); }, }, From b234344aab26b8502c4f8a945361fa470be42a54 Mon Sep 17 00:00:00 2001 From: Peter Kulko <93188219+PKulkoRaccoonGang@users.noreply.github.com> Date: Thu, 8 Feb 2024 17:15:21 +0200 Subject: [PATCH 04/11] feat: [FC-0044] Course unit page - Unit switch widget with a New unit creation button (#809) * feat: added Unit switch widget with a New unit button * refactor: refactoring after review * refactor: changed the variable name --- src/CourseAuthoringRoutes.jsx | 11 +- src/constants.js | 7 + src/course-unit/CourseUnit.jsx | 18 +- src/course-unit/CourseUnit.scss | 1 + src/course-unit/constants.js | 17 ++ .../course-sequence/CourseSequence.scss | 83 ++++++++ src/course-unit/course-sequence/Sequence.jsx | 68 +++++++ src/course-unit/course-sequence/hooks.js | 139 +++++++++++++ src/course-unit/course-sequence/index.jsx | 1 + src/course-unit/course-sequence/messages.js | 34 ++++ .../SequenceNavigation.jsx | 116 +++++++++++ .../SequenceNavigationDropdown.jsx | 40 ++++ .../SequenceNavigationTabs.jsx | 62 ++++++ .../sequence-navigation/UnitButton.jsx | 52 +++++ .../sequence-navigation/UnitIcon.jsx | 17 ++ src/course-unit/data/api.js | 82 +++++++- src/course-unit/data/selectors.js | 25 +++ src/course-unit/data/slice.js | 52 +++++ src/course-unit/data/thunk.js | 166 ++++++++++++++- src/course-unit/data/utils.js | 191 ++++++++++++++++++ src/course-unit/hooks.jsx | 26 ++- src/i18n/messages/ar.json | 9 +- src/i18n/messages/de.json | 9 +- src/i18n/messages/de_DE.json | 9 +- src/i18n/messages/es_419.json | 9 +- src/i18n/messages/fa_IR.json | 9 +- src/i18n/messages/fr.json | 9 +- src/i18n/messages/fr_CA.json | 9 +- src/i18n/messages/hi.json | 9 +- src/i18n/messages/it.json | 9 +- src/i18n/messages/it_IT.json | 9 +- src/i18n/messages/pt.json | 9 +- src/i18n/messages/pt_PT.json | 9 +- src/i18n/messages/ru.json | 9 +- src/i18n/messages/uk.json | 9 +- src/i18n/messages/zh_CN.json | 9 +- src/index.scss | 1 + 37 files changed, 1310 insertions(+), 34 deletions(-) create mode 100644 src/course-unit/constants.js create mode 100644 src/course-unit/course-sequence/CourseSequence.scss create mode 100644 src/course-unit/course-sequence/Sequence.jsx create mode 100644 src/course-unit/course-sequence/hooks.js create mode 100644 src/course-unit/course-sequence/index.jsx create mode 100644 src/course-unit/course-sequence/messages.js create mode 100644 src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx create mode 100644 src/course-unit/course-sequence/sequence-navigation/SequenceNavigationDropdown.jsx create mode 100644 src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx create mode 100644 src/course-unit/course-sequence/sequence-navigation/UnitButton.jsx create mode 100644 src/course-unit/course-sequence/sequence-navigation/UnitIcon.jsx create mode 100644 src/course-unit/data/utils.js diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index 30c412bd0..bdeffa110 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -19,6 +19,7 @@ import { CourseUpdates } from './course-updates'; import { CourseUnit } from './course-unit'; import CourseExportPage from './export-page/CourseExportPage'; import CourseImportPage from './import-page/CourseImportPage'; +import { DECODED_ROUTES } from './constants'; /** * As of this writing, these routes are mounted at a path prefixed with the following: @@ -70,10 +71,12 @@ const CourseAuthoringRoutes = () => { path="custom-pages/*" element={} /> - } - /> + {DECODED_ROUTES.COURSE_UNIT.map((path) => ( + } + /> + ))} : null} diff --git a/src/constants.js b/src/constants.js index cfb427edb..2b2d394c0 100644 --- a/src/constants.js +++ b/src/constants.js @@ -37,3 +37,10 @@ export const COURSE_CREATOR_STATES = { denied: 'denied', disallowedForThisSite: 'disallowed_for_this_site', }; + +export const DECODED_ROUTES = { + COURSE_UNIT: [ + '/container/:blockId/:sequenceId', + '/container/:blockId', + ], +}; diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index 6a6979704..e7a09a2d0 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -11,19 +11,20 @@ import { RequestStatus } from '../data/constants'; import getPageHeadTitle from '../generic/utils'; import ProcessingNotification from '../generic/processing-notification'; import InternetConnectionAlert from '../generic/internet-connection-alert'; +import Loading from '../generic/Loading'; import HeaderTitle from './header-title/HeaderTitle'; import Breadcrumbs from './breadcrumbs/Breadcrumbs'; import HeaderNavigations from './header-navigations/HeaderNavigations'; +import Sequence from './course-sequence'; import { useCourseUnit } from './hooks'; import messages from './messages'; -import './CourseUnit.scss'; - const CourseUnit = ({ courseId }) => { const { blockId } = useParams(); const intl = useIntl(); const { isLoading, + sequenceId, unitTitle, savingStatus, isTitleEditFormOpen, @@ -42,7 +43,7 @@ const CourseUnit = ({ courseId }) => { } = useSelector(getProcessingNotification); if (isLoading) { - return null; + return ; } return ( @@ -73,6 +74,11 @@ const CourseUnit = ({ courseId }) => { /> )} /> + { xs={[{ span: 9 }, { span: 3 }]} xl={[{ span: 9 }, { span: 3 }]} > - - + + {/* TODO: Unit content will be added in the following tasks. */} + Unit content + diff --git a/src/course-unit/CourseUnit.scss b/src/course-unit/CourseUnit.scss index 82ba56f50..954bbbdac 100644 --- a/src/course-unit/CourseUnit.scss +++ b/src/course-unit/CourseUnit.scss @@ -1 +1,2 @@ @import "./breadcrumbs/Breadcrumbs"; +@import "./course-sequence/CourseSequence"; diff --git a/src/course-unit/constants.js b/src/course-unit/constants.js new file mode 100644 index 000000000..c35d980d1 --- /dev/null +++ b/src/course-unit/constants.js @@ -0,0 +1,17 @@ +import { + BookOpen as BookOpenIcon, + Edit as EditIcon, + FormatListBulleted as FormatListBulletedIcon, + Lock as LockIcon, + VideoCamera as VideoCameraIcon, +} from '@edx/paragon/icons'; + +export const UNIT_ICON_TYPES = ['video', 'other', 'vertical', 'problem', 'lock']; + +export const TYPE_ICONS_MAP = { + video: VideoCameraIcon, + other: BookOpenIcon, + vertical: FormatListBulletedIcon, + problem: EditIcon, + lock: LockIcon, +}; diff --git a/src/course-unit/course-sequence/CourseSequence.scss b/src/course-unit/course-sequence/CourseSequence.scss new file mode 100644 index 000000000..21bf490d1 --- /dev/null +++ b/src/course-unit/course-sequence/CourseSequence.scss @@ -0,0 +1,83 @@ +.sequence-container { + margin-bottom: 1.75rem; + width: 100%; +} + +.sequence-load-failure-msg { + max-width: 30em; +} + +.sequence-navigation { + .btn { + flex-grow: 1; + position: relative; + white-space: nowrap; + color: $gray-700; + + &.btn-primary { + color: $white; + } + + &:focus { + z-index: 1; + } + } + + .sequence-navigation-tabs-wrapper { + flex-basis: 100%; + min-width: 0; + } + + .sequence-navigation-tabs-container { + flex: 1 1 100%; + // min-width 0 prevents the flex item from overflowing the parent container + // https://dev.to/martyhimmel/quick-tip-to-stop-flexbox-from-overflowing-peb + min-width: 0; + } + + .sequence-navigation-tabs .btn:not(.sequence-navigation-tabs-new-unit-btn) { + flex-basis: 100%; + min-width: 2rem; + } + + .sequence-navigation-dropdown { + .dropdown-menu .btn { + flex-basis: 100%; + min-width: 4rem; + + .unit-title { + flex-grow: 1; + text-align: left; + overflow: hidden; + min-width: 0; + margin: map-get($spacers, 0) $spacer; + text-overflow: ellipsis; + } + + &.btn-primary { + background-color: $primary-500; + color: $white; + } + } + } + + .sequence-navigation-prev-btn, + .sequence-navigation-next-btn, + .sequence-navigation-tabs-new-unit-btn { + min-width: 12.5rem; + } + + .sequence-navigation-prev-btn, + .sequence-navigation-next-btn { + @media (max-width: -1 + map-get($grid-breakpoints, "sm")) { + min-width: fit-content; + padding-top: $spacer; + padding-bottom: $spacer; + } + + @media (min-width: map-get($grid-breakpoints, "sm")) { + padding-left: map-get($spacers, 4\.5); + padding-right: map-get($spacers, 4\.5); + } + } +} diff --git a/src/course-unit/course-sequence/Sequence.jsx b/src/course-unit/course-sequence/Sequence.jsx new file mode 100644 index 000000000..bd251551b --- /dev/null +++ b/src/course-unit/course-sequence/Sequence.jsx @@ -0,0 +1,68 @@ +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import classNames from 'classnames'; +import { breakpoints, useWindowSize } from '@edx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import Loading from '../../generic/Loading'; +import { RequestStatus } from '../../data/constants'; +import SequenceNavigation from './sequence-navigation/SequenceNavigation'; +import messages from './messages'; + +const Sequence = ({ + courseId, + sequenceId, + unitId, +}) => { + const intl = useIntl(); + const { IN_PROGRESS, FAILED, SUCCESSFUL } = RequestStatus; + const shouldDisplayNotificationTriggerInSequence = useWindowSize().width < breakpoints.small.minWidth; + const { sequenceStatus, sequenceMightBeUnit } = useSelector(state => state.courseUnit); + + const defaultContent = ( +
+
+ +
+
+ ); + + // If sequence might be a unit, we want to keep showing a spinner - the courseware container will redirect us when + // it knows which sequence to actually go to. + const isLoading = sequenceStatus === IN_PROGRESS || (sequenceStatus === FAILED && sequenceMightBeUnit); + if (isLoading) { + if (!sequenceId) { + return (
{intl.formatMessage(messages.sequenceNoContent)}
); + } + + return ; + } + + if (sequenceStatus === SUCCESSFUL) { + return defaultContent; + } + + // sequence status 'failed' and any other unexpected sequence status. + return ( +

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

+ ); +}; + +Sequence.propTypes = { + unitId: PropTypes.string, + courseId: PropTypes.string.isRequired, + sequenceId: PropTypes.string, +}; + +Sequence.defaultProps = { + sequenceId: null, + unitId: null, +}; + +export default Sequence; diff --git a/src/course-unit/course-sequence/hooks.js b/src/course-unit/course-sequence/hooks.js new file mode 100644 index 000000000..f7864b645 --- /dev/null +++ b/src/course-unit/course-sequence/hooks.js @@ -0,0 +1,139 @@ +import { useSelector } from 'react-redux'; +import { useLayoutEffect, useRef, useState } from 'react'; +import { useWindowSize } from '@edx/paragon'; + +import { useModel } from '../../generic/model-store'; +import { RequestStatus } from '../../data/constants'; +import { getCourseSectionVertical, getSequenceStatus, sequenceIdsSelector } from '../data/selectors'; + +export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId) { + const { SUCCESSFUL } = RequestStatus; + const sequenceIds = useSelector(sequenceIdsSelector); + const sequenceStatus = useSelector(getSequenceStatus); + const { nextUrl, prevUrl } = useSelector(getCourseSectionVertical); + const sequence = useModel('sequences', currentSequenceId); + const { courseId, status } = useSelector(state => state.courseDetail); + + const isCourseOrSequenceNotSuccessful = status !== SUCCESSFUL || sequenceStatus !== SUCCESSFUL; + const areIdsNotValid = !currentSequenceId || !currentUnitId || !sequence.unitIds; + const isNotSuccessfulCompletion = isCourseOrSequenceNotSuccessful || areIdsNotValid; + + // If we don't know the sequence and unit yet, then assume no. + if (isNotSuccessfulCompletion) { + return { isFirstUnit: false, isLastUnit: false }; + } + + const sequenceIndex = sequenceIds.indexOf(currentSequenceId); + const unitIndex = sequence.unitIds.indexOf(currentUnitId); + + const isFirstSequence = sequenceIndex === 0; + const isFirstUnitInSequence = unitIndex === 0; + const isFirstUnit = isFirstSequence && isFirstUnitInSequence; + const isLastSequence = sequenceIndex === sequenceIds.length - 1; + const isLastUnitInSequence = unitIndex === sequence.unitIds.length - 1; + const isLastUnit = isLastSequence && isLastUnitInSequence; + + const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null; + const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null; + + let nextLink; + const nextIndex = unitIndex + 1; + + if (nextIndex < sequence.unitIds.length) { + const nextUnitId = sequence.unitIds[nextIndex]; + nextLink = `/course/${courseId}/container/${nextUnitId}/${currentSequenceId}`; + } else if (nextSequenceId) { + const pathToNextUnit = decodeURIComponent(nextUrl); + nextLink = `/course/${courseId}${pathToNextUnit}/${nextSequenceId}`; + } + + let previousLink; + const previousIndex = unitIndex - 1; + + if (previousIndex >= 0) { + const previousUnitId = sequence.unitIds[previousIndex]; + previousLink = `/course/${courseId}/container/${previousUnitId}/${currentSequenceId}`; + } else if (previousSequenceId) { + const pathToPreviousUnit = decodeURIComponent(prevUrl); + previousLink = `/course/${courseId}${pathToPreviousUnit}/${previousSequenceId}`; + } + + return { + isFirstUnit, isLastUnit, nextLink, previousLink, + }; +} + +const invisibleStyle = { + position: 'absolute', + left: 0, + pointerEvents: 'none', + visibility: 'hidden', +}; + +/** + * This hook will find the index of the last child of a containing element + * that fits within its bounding rectangle. This is done by summing the widths + * of the children until they exceed the width of the container. + * + * The hook returns an array containing: + * [indexOfLastVisibleChild, containerElementRef, invisibleStyle, overflowElementRef] + * + * indexOfLastVisibleChild - the index of the last visible child + * containerElementRef - a ref to be added to the containing html node + * invisibleStyle - a set of styles to be applied to child of the containing node + * if it needs to be hidden. These styles remove the element visually, from + * screen readers, and from normal layout flow. But, importantly, these styles + * preserve the width of the element, so that future width calculations will + * still be accurate. + * overflowElementRef - a ref to be added to an html node inside the container + * that is likely to be used to contain a "More" type dropdown or other + * mechanism to reveal hidden children. The width of this element is always + * included when determining which children will fit or not. Usage of this ref + * is optional. + */ +export function useIndexOfLastVisibleChild() { + const containerElementRef = useRef(null); + const overflowElementRef = useRef(null); + const containingRectRef = useRef({}); + const [indexOfLastVisibleChild, setIndexOfLastVisibleChild] = useState(-1); + const windowSize = useWindowSize(); + + useLayoutEffect(() => { + const containingRect = containerElementRef.current.getBoundingClientRect(); + + // No-op if the width is unchanged. + // (Assumes tabs themselves don't change count or width). + if (!containingRect.width === containingRectRef.current.width) { + return; + } + // Update for future comparison + containingRectRef.current = containingRect; + + // Get array of child nodes from NodeList form + const childNodesArr = Array.prototype.slice.call(containerElementRef.current.children); + const { nextIndexOfLastVisibleChild } = childNodesArr + // filter out the overflow element + .filter(childNode => childNode !== overflowElementRef.current) + // sum the widths to find the last visible element's index + .reduce((acc, childNode, index) => { + // use floor to prevent rounding errors + acc.sumWidth += Math.floor(childNode.getBoundingClientRect().width); + if (acc.sumWidth <= containingRect.width) { + acc.nextIndexOfLastVisibleChild = index; + } + return acc; + }, { + // Include the overflow element's width to begin with. Doing this means + // sometimes we'll show a dropdown with one item in it when it would fit, + // but allowing this case dramatically simplifies the calculations we need + // to do above. + sumWidth: overflowElementRef.current ? overflowElementRef.current.getBoundingClientRect().width : 0, + nextIndexOfLastVisibleChild: -1, + }); + + setIndexOfLastVisibleChild(nextIndexOfLastVisibleChild); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [windowSize, containerElementRef.current]); + + return [indexOfLastVisibleChild, containerElementRef, invisibleStyle, overflowElementRef]; +} diff --git a/src/course-unit/course-sequence/index.jsx b/src/course-unit/course-sequence/index.jsx new file mode 100644 index 000000000..285eab20b --- /dev/null +++ b/src/course-unit/course-sequence/index.jsx @@ -0,0 +1 @@ +export { default } from './Sequence'; diff --git a/src/course-unit/course-sequence/messages.js b/src/course-unit/course-sequence/messages.js new file mode 100644 index 000000000..7c3378707 --- /dev/null +++ b/src/course-unit/course-sequence/messages.js @@ -0,0 +1,34 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + prevBtnText: { + id: 'course-authoring.course-unit.prev-btn-text', + defaultMessage: 'Previous', + }, + nextBtnText: { + id: 'course-authoring.course-unit.next-btn-text', + defaultMessage: 'Next', + }, + newUnitBtnText: { + id: 'course-authoring.course-unit.new-unit-btn-text', + defaultMessage: 'New unit', + }, + sequenceNavLabelText: { + id: 'course-authoring.course-unit.sequence-nav-label-text', + defaultMessage: 'Sequence navigation', + }, + sequenceLoadFailure: { + id: 'course-authoring.course-unit.sequence.load.failure', + defaultMessage: 'There was an error loading this course.', + }, + sequenceNoContent: { + id: 'course-authoring.course-unit.sequence.no.content', + defaultMessage: 'There is no content here.', + }, + sequenceDropdownTitle: { + id: 'course-authoring.course-unit.sequence.navigation.menu', + defaultMessage: '{current} of {total}', + }, +}); + +export default messages; diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx new file mode 100644 index 000000000..63e6cfd3d --- /dev/null +++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx @@ -0,0 +1,116 @@ +import { Link } from 'react-router-dom'; +import { useSelector } from 'react-redux'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { + injectIntl, intlShape, isRtl, getLocale, +} from '@edx/frontend-platform/i18n'; +import { Button, useWindowSize, breakpoints } from '@edx/paragon'; +import { + ChevronLeft as ChevronLeftIcon, + ChevronRight as ChevronRightIcon, +} from '@edx/paragon/icons'; + +import { useModel } from '../../../generic/model-store'; +import { RequestStatus } from '../../../data/constants'; +import { getSequenceStatus } from '../../data/selectors'; +import { useSequenceNavigationMetadata } from '../hooks'; +import messages from '../messages'; +import SequenceNavigationTabs from './SequenceNavigationTabs'; + +const SequenceNavigation = ({ + intl, + unitId, + sequenceId, + className, +}) => { + const sequenceStatus = useSelector(getSequenceStatus); + const { + isFirstUnit, isLastUnit, nextLink, previousLink, + } = useSequenceNavigationMetadata(sequenceId, unitId); + const sequence = useModel('sequences', sequenceId); + + const shouldDisplayNotificationTriggerInSequence = useWindowSize().width < breakpoints.small.minWidth; + const renderUnitButtons = () => { + if (sequence.unitIds?.length === 0 || unitId === null) { + return ( +
+ ); + } + + return ( + + ); + }; + + const renderPreviousButton = () => { + const buttonText = intl.formatMessage(messages.prevBtnText); + const prevArrow = isRtl(getLocale()) ? ChevronRightIcon : ChevronLeftIcon; + + if (!isFirstUnit) { + return ( + + ); + } + + return null; + }; + + const renderNextButton = () => { + const buttonText = intl.formatMessage(messages.nextBtnText); + const nextArrow = isRtl(getLocale()) ? ChevronLeftIcon : ChevronRightIcon; + + if (!isLastUnit) { + return ( + + ); + } + + return null; + }; + + return sequenceStatus === RequestStatus.SUCCESSFUL && ( + + ); +}; + +SequenceNavigation.propTypes = { + intl: intlShape.isRequired, + unitId: PropTypes.string, + className: PropTypes.string, + sequenceId: PropTypes.string, +}; + +SequenceNavigation.defaultProps = { + sequenceId: null, + unitId: null, + className: undefined, +}; + +export default injectIntl(SequenceNavigation); diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationDropdown.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationDropdown.jsx new file mode 100644 index 000000000..e052d2c5c --- /dev/null +++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationDropdown.jsx @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import { Dropdown } from '@edx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from '../messages'; +import UnitButton from './UnitButton'; + +const SequenceNavigationDropdown = ({ unitId, unitIds }) => { + const intl = useIntl(); + + return ( + + + {intl.formatMessage(messages.sequenceDropdownTitle, { + current: unitIds.indexOf(unitId) + 1, + total: unitIds.length, + })} + + + {unitIds.map(buttonUnitId => ( + + ))} + + + ); +}; + +SequenceNavigationDropdown.propTypes = { + unitId: PropTypes.string.isRequired, + unitIds: PropTypes.arrayOf(PropTypes.string).isRequired, +}; + +export default SequenceNavigationDropdown; diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx new file mode 100644 index 000000000..1badb8536 --- /dev/null +++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx @@ -0,0 +1,62 @@ +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; +import { Button } from '@edx/paragon'; +import { Plus as PlusIcon } from '@edx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { useIndexOfLastVisibleChild } from '../hooks'; +import messages from '../messages'; +import SequenceNavigationDropdown from './SequenceNavigationDropdown'; +import UnitButton from './UnitButton'; + +const SequenceNavigationTabs = ({ unitIds, unitId }) => { + const intl = useIntl(); + const [ + indexOfLastVisibleChild, + containerRef, + invisibleStyle, + ] = useIndexOfLastVisibleChild(); + const shouldDisplayDropdown = indexOfLastVisibleChild === -1; + + return ( +
+
+
+ {unitIds.map((buttonUnitId) => ( + + ))} + {/* TODO: The functionality of the New unit button will be implemented in https://youtrack.raccoongang.com/issue/AXIMST-14 */} + +
+
+ {shouldDisplayDropdown && ( + + )} +
+ ); +}; + +SequenceNavigationTabs.propTypes = { + unitId: PropTypes.string.isRequired, + unitIds: PropTypes.arrayOf(PropTypes.string).isRequired, +}; + +export default SequenceNavigationTabs; diff --git a/src/course-unit/course-sequence/sequence-navigation/UnitButton.jsx b/src/course-unit/course-sequence/sequence-navigation/UnitButton.jsx new file mode 100644 index 000000000..9cf8eb627 --- /dev/null +++ b/src/course-unit/course-sequence/sequence-navigation/UnitButton.jsx @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types'; +import { connect, useSelector } from 'react-redux'; +import { Button } from '@edx/paragon'; +import { Link } from 'react-router-dom'; + +import UnitIcon from './UnitIcon'; + +const UnitButton = ({ + title, contentType, isActive, unitId, className, showTitle, +}) => { + const courseId = useSelector(state => state.courseUnit.courseId); + const sequenceId = useSelector(state => state.courseUnit.sequenceId); + + return ( + + ); +}; + +UnitButton.propTypes = { + className: PropTypes.string, + contentType: PropTypes.string.isRequired, + isActive: PropTypes.bool, + showTitle: PropTypes.bool, + title: PropTypes.string.isRequired, + unitId: PropTypes.string.isRequired, +}; + +UnitButton.defaultProps = { + className: undefined, + isActive: false, + showTitle: false, +}; + +const mapStateToProps = (state, props) => { + if (props.unitId) { + return { + ...state.models.units[props.unitId], + }; + } + return {}; +}; + +export default connect(mapStateToProps)(UnitButton); diff --git a/src/course-unit/course-sequence/sequence-navigation/UnitIcon.jsx b/src/course-unit/course-sequence/sequence-navigation/UnitIcon.jsx new file mode 100644 index 000000000..7bcee2d86 --- /dev/null +++ b/src/course-unit/course-sequence/sequence-navigation/UnitIcon.jsx @@ -0,0 +1,17 @@ +import PropTypes from 'prop-types'; +import { Icon } from '@edx/paragon'; +import { BookOpen as BookOpenIcon } from '@edx/paragon/icons'; + +import { TYPE_ICONS_MAP, UNIT_ICON_TYPES } from '../../constants'; + +const UnitIcon = ({ type }) => { + const icon = TYPE_ICONS_MAP[type] || BookOpenIcon; + + return ; +}; + +UnitIcon.propTypes = { + type: PropTypes.oneOf(UNIT_ICON_TYPES).isRequired, +}; + +export default UnitIcon; diff --git a/src/course-unit/data/api.js b/src/course-unit/data/api.js index feded71ea..47be48869 100644 --- a/src/course-unit/data/api.js +++ b/src/course-unit/data/api.js @@ -2,11 +2,24 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; +import { + normalizeLearningSequencesData, + normalizeSequenceMetadata, + normalizeMetadata, + normalizeCourseHomeCourseMetadata, + appendBrowserTimezoneToUrl, +} from './utils'; -export const getCourseUnitApiUrl = (itemId) => `${getApiBaseUrl()}/xblock/container/${itemId}`; +const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL; +const getLmsBaseUrl = () => getConfig().LMS_BASE_URL; -export const getXBlockBaseApiUrl = (itemId) => `${getApiBaseUrl()}/xblock/${itemId}`; +export const getCourseUnitApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/container/${itemId}`; +export const getXBlockBaseApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/${itemId}`; +export const getCourseSectionVerticalApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container_handler/${itemId}`; +export const getSequenceMetadataApiUrl = (sequenceId) => `${getLmsBaseUrl()}/api/courseware/sequence/${sequenceId}`; +export const getLearningSequencesOutlineApiUrl = (courseId) => `${getLmsBaseUrl()}/api/learning_sequences/v1/course_outline/${courseId}`; +export const getCourseMetadataApiUrl = (courseId) => `${getLmsBaseUrl()}/api/courseware/course/${courseId}`; +export const getCourseHomeCourseMetadataApiUrl = (courseId) => `${getLmsBaseUrl()}/api/course_home/course_metadata/${courseId}`; /** * Get course unit. @@ -36,3 +49,66 @@ export async function editUnitDisplayName(unitId, displayName) { return data; } + +/** + * Get sequence metadata for a given sequence ID. + * @param {string} sequenceId - The ID of the sequence for which metadata is requested. + * @returns {Promise} - A Promise that resolves to the normalized sequence metadata. + */ +export async function getSequenceMetadata(sequenceId) { + const { data } = await getAuthenticatedHttpClient() + .get(getSequenceMetadataApiUrl(sequenceId), {}); + + return normalizeSequenceMetadata(data); +} + +/** + * Get an object containing course section vertical data. + * @param {string} unitId + * @returns {Promise} + */ +export async function getCourseSectionVerticalData(unitId) { + const { data } = await getAuthenticatedHttpClient() + .get(getCourseSectionVerticalApiUrl(unitId)); + + return camelCaseObject(data); +} + +/** + * Retrieves the outline of learning sequences for a specific course. + * @param {string} courseId - The ID of the course. + * @returns {Promise} A Promise that resolves to the normalized learning sequences outline data. + */ +export async function getLearningSequencesOutline(courseId) { + const { href } = new URL(getLearningSequencesOutlineApiUrl(courseId)); + const { data } = await getAuthenticatedHttpClient().get(href, {}); + + return normalizeLearningSequencesData(data); +} + +/** + * Retrieves metadata for a specific course. + * @param {string} courseId - The ID of the course. + * @returns {Promise} A Promise that resolves to the normalized course metadata. + */ +export async function getCourseMetadata(courseId) { + let courseMetadataApiUrl = getCourseMetadataApiUrl(courseId); + courseMetadataApiUrl = appendBrowserTimezoneToUrl(courseMetadataApiUrl); + const metadata = await getAuthenticatedHttpClient().get(courseMetadataApiUrl); + + return normalizeMetadata(metadata); +} + +/** + * Retrieves metadata for a course's home page. + * @param {string} courseId - The ID of the course. + * @param {string} rootSlug - The root slug for the course. + * @returns {Promise} A Promise that resolves to the normalized course home page metadata. + */ +export async function getCourseHomeCourseMetadata(courseId, rootSlug) { + let courseHomeCourseMetadataApiUrl = getCourseHomeCourseMetadataApiUrl(courseId); + courseHomeCourseMetadataApiUrl = appendBrowserTimezoneToUrl(courseHomeCourseMetadataApiUrl); + const { data } = await getAuthenticatedHttpClient().get(courseHomeCourseMetadataApiUrl); + + return normalizeCourseHomeCourseMetadata(data, rootSlug); +} diff --git a/src/course-unit/data/selectors.js b/src/course-unit/data/selectors.js index c6c449d17..8955c228e 100644 --- a/src/course-unit/data/selectors.js +++ b/src/course-unit/data/selectors.js @@ -1,5 +1,30 @@ +import { createSelector } from '@reduxjs/toolkit'; + +import { RequestStatus } from '../../data/constants'; + export const getCourseUnitData = (state) => state.courseUnit.unit; export const getSavingStatus = (state) => state.courseUnit.savingStatus; export const getLoadingStatus = (state) => state.courseUnit.loadingStatus; + +export const getSequenceStatus = (state) => state.courseUnit.sequenceStatus; + +export const getCourseSectionVertical = (state) => state.courseUnit.courseSectionVertical; + +export const getCourseStatus = state => state.courseUnit.courseStatus; +export const getCoursewareMeta = state => state.models.coursewareMeta; +export const getSections = state => state.models.sections; +export const getCourseId = state => state.courseDetail.courseId; + +export const sequenceIdsSelector = createSelector( + [getCourseStatus, getCoursewareMeta, getSections, getCourseId], + (courseStatus, coursewareMeta, sections, courseId) => { + if (courseStatus !== RequestStatus.SUCCESSFUL) { + return []; + } + + const sectionIds = coursewareMeta[courseId].sectionIds || []; + return sectionIds.flatMap(sectionId => sections[sectionId].sequenceIds); + }, +); diff --git a/src/course-unit/data/slice.js b/src/course-unit/data/slice.js index 6763fbd0b..c80fa02b3 100644 --- a/src/course-unit/data/slice.js +++ b/src/course-unit/data/slice.js @@ -9,8 +9,10 @@ const slice = createSlice({ savingStatus: '', loadingStatus: { fetchUnitLoadingStatus: RequestStatus.IN_PROGRESS, + courseSectionVerticalLoadingStatus: RequestStatus.IN_PROGRESS, }, unit: {}, + courseSectionVertical: {}, }, reducers: { fetchCourseItemSuccess: (state, { payload }) => { @@ -25,6 +27,46 @@ const slice = createSlice({ updateSavingStatus: (state, { payload }) => { state.savingStatus = payload.status; }, + fetchSequenceRequest: (state, { payload }) => { + state.sequenceId = payload.sequenceId; + state.sequenceStatus = RequestStatus.IN_PROGRESS; + state.sequenceMightBeUnit = false; + }, + fetchSequenceSuccess: (state, { payload }) => { + state.sequenceId = payload.sequenceId; + state.sequenceStatus = RequestStatus.SUCCESSFUL; + state.sequenceMightBeUnit = false; + }, + fetchSequenceFailure: (state, { payload }) => { + state.sequenceId = payload.sequenceId; + state.sequenceStatus = RequestStatus.FAILED; + state.sequenceMightBeUnit = payload.sequenceMightBeUnit || false; + }, + fetchCourseRequest: (state, { payload }) => { + state.courseId = payload.courseId; + state.courseStatus = RequestStatus.IN_PROGRESS; + }, + fetchCourseSuccess: (state, { payload }) => { + state.courseId = payload.courseId; + state.courseStatus = RequestStatus.SUCCESSFUL; + }, + fetchCourseFailure: (state, { payload }) => { + state.courseId = payload.courseId; + state.courseStatus = RequestStatus.FAILED; + }, + fetchCourseDenied: (state, { payload }) => { + state.courseId = payload.courseId; + state.courseStatus = RequestStatus.DENIED; + }, + fetchCourseSectionVerticalDataSuccess: (state, { payload }) => { + state.courseSectionVertical = payload; + }, + updateLoadingCourseSectionVerticalDataStatus: (state, { payload }) => { + state.loadingStatus = { + ...state.loadingStatus, + courseSectionVerticalLoadingStatus: payload.status, + }; + }, }, }); @@ -32,6 +74,16 @@ export const { fetchCourseItemSuccess, updateLoadingCourseUnitStatus, updateSavingStatus, + updateModel, + fetchSequenceRequest, + fetchSequenceSuccess, + fetchSequenceFailure, + fetchCourseRequest, + fetchCourseSuccess, + fetchCourseFailure, + fetchCourseDenied, + fetchCourseSectionVerticalDataSuccess, + updateLoadingCourseSectionVerticalDataStatus, } = slice.actions; export const { diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js index 0c684e88d..883c53b99 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -1,14 +1,34 @@ +import { logError, logInfo } from '@edx/frontend-platform/logging'; + import { hideProcessingNotification, showProcessingNotification, } from '../../generic/processing-notification/data/slice'; import { RequestStatus } from '../../data/constants'; import { NOTIFICATION_MESSAGES } from '../../constants'; -import { getCourseUnitData, editUnitDisplayName } from './api'; +import { + addModel, updateModel, updateModels, updateModelsMap, addModelsMap, +} from '../../generic/model-store'; +import { + getCourseUnitData, + editUnitDisplayName, + getSequenceMetadata, + getCourseMetadata, + getLearningSequencesOutline, getCourseHomeCourseMetadata, getCourseSectionVerticalData, +} from './api'; import { updateLoadingCourseUnitStatus, fetchCourseItemSuccess, updateSavingStatus, + fetchSequenceRequest, + fetchSequenceFailure, + fetchSequenceSuccess, + fetchCourseRequest, + fetchCourseSuccess, + fetchCourseDenied, + fetchCourseFailure, + fetchCourseSectionVerticalDataSuccess, + updateLoadingCourseSectionVerticalDataStatus, } from './slice'; export function fetchCourseUnitQuery(courseId) { @@ -27,6 +47,22 @@ export function fetchCourseUnitQuery(courseId) { }; } +export function fetchCourseSectionVerticalData(courseId) { + return async (dispatch) => { + dispatch(updateLoadingCourseSectionVerticalDataStatus({ status: RequestStatus.IN_PROGRESS })); + + try { + const courseSectionVerticalData = await getCourseSectionVerticalData(courseId); + dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData)); + dispatch(updateLoadingCourseSectionVerticalDataStatus({ status: RequestStatus.SUCCESSFUL })); + return true; + } catch (error) { + dispatch(updateLoadingCourseSectionVerticalDataStatus({ status: RequestStatus.FAILED })); + return false; + } + }; +} + export function editCourseItemQuery(itemId, displayName) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); @@ -47,3 +83,131 @@ export function editCourseItemQuery(itemId, displayName) { } }; } + +export function fetchSequence(sequenceId) { + return async (dispatch) => { + dispatch(fetchSequenceRequest({ sequenceId })); + try { + const { sequence, units } = await getSequenceMetadata(sequenceId); + + if (sequence.blockType !== 'sequential') { + // Some other block types (particularly 'chapter') can be returned + // by this API. We want to error in that case, since downstream + // courseware code is written to render Sequences of Units. + logError( + `Requested sequence '${sequenceId}' ` + + `has block type '${sequence.blockType}'; expected block type 'sequential'.`, + ); + dispatch(fetchSequenceFailure({ sequenceId })); + } else { + dispatch(updateModel({ + modelType: 'sequences', + model: sequence, + })); + dispatch(updateModels({ + modelType: 'units', + models: units, + })); + dispatch(fetchSequenceSuccess({ sequenceId })); + } + } catch (error) { + // Some errors are expected - for example, CoursewareContainer may request sequence metadata for a unit and rely + // on the request failing to notice that it actually does have a unit (mostly so it doesn't have to know anything + // about the opaque key structure). In such cases, the backend gives us a 422. + const sequenceMightBeUnit = error?.response?.status === 422; + if (!sequenceMightBeUnit) { + logError(error); + } + dispatch(fetchSequenceFailure({ sequenceId, sequenceMightBeUnit })); + } + }; +} + +export function fetchCourse(courseId) { + return async (dispatch) => { + dispatch(fetchCourseRequest({ courseId })); + Promise.allSettled([ + getCourseMetadata(courseId), + getLearningSequencesOutline(courseId), + getCourseHomeCourseMetadata(courseId, 'courseware'), + ]).then(([ + courseMetadataResult, + learningSequencesOutlineResult, + courseHomeMetadataResult]) => { + if (courseMetadataResult.status === 'fulfilled') { + dispatch(addModel({ + modelType: 'coursewareMeta', + model: courseMetadataResult.value, + })); + } + + if (courseHomeMetadataResult.status === 'fulfilled') { + dispatch(addModel({ + modelType: 'courseHomeMeta', + model: { + id: courseId, + ...courseHomeMetadataResult.value, + }, + })); + } + + if (learningSequencesOutlineResult.status === 'fulfilled') { + const { + courses, sections, sequences, + } = learningSequencesOutlineResult.value; + + // This updates the course with a sectionIds array from the Learning Sequence data. + dispatch(updateModelsMap({ + modelType: 'coursewareMeta', + modelsMap: courses, + })); + dispatch(addModelsMap({ + modelType: 'sections', + modelsMap: sections, + })); + // We update for sequences because the sequence metadata may have come back first. + dispatch(updateModelsMap({ + modelType: 'sequences', + modelsMap: sequences, + })); + } + + const fetchedMetadata = courseMetadataResult.status === 'fulfilled'; + const fetchedCourseHomeMetadata = courseHomeMetadataResult.status === 'fulfilled'; + const fetchedOutline = learningSequencesOutlineResult.status === 'fulfilled'; + + // Log errors for each request if needed. Outline failures may occur + // even if the course metadata request is successful + if (!fetchedOutline) { + const { response } = learningSequencesOutlineResult.reason; + if (response && response.status === 403) { + // 403 responses are normal - they happen when the learner is logged out. + // We'll redirect them in a moment to the outline tab by calling fetchCourseDenied() below. + logInfo(learningSequencesOutlineResult.reason); + } else { + logError(learningSequencesOutlineResult.reason); + } + } + if (!fetchedMetadata) { + logError(courseMetadataResult.reason); + } + if (!fetchedCourseHomeMetadata) { + logError(courseHomeMetadataResult.reason); + } + if (fetchedMetadata && fetchedCourseHomeMetadata) { + if (courseHomeMetadataResult.value.courseAccess.hasAccess && fetchedOutline) { + // User has access + dispatch(fetchCourseSuccess({ courseId })); + return; + } + // User either doesn't have access or only has partial access + // (can't access course blocks) + dispatch(fetchCourseDenied({ courseId })); + return; + } + + // Definitely an error happening + dispatch(fetchCourseFailure({ courseId })); + }); + }; +} diff --git a/src/course-unit/data/utils.js b/src/course-unit/data/utils.js new file mode 100644 index 000000000..b3f77f407 --- /dev/null +++ b/src/course-unit/data/utils.js @@ -0,0 +1,191 @@ +import { camelCaseObject } from '@edx/frontend-platform'; + +export function getTimeOffsetMillis(headerDate, requestTime, responseTime) { + // Time offset computation should move down into the HttpClient wrapper to maintain a global time correction reference + // Requires 'Access-Control-Expose-Headers: Date' on the server response per https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#access-control-expose-headers + + let timeOffsetMillis = 0; + if (headerDate !== undefined) { + const headerTime = Date.parse(headerDate); + const roundTripMillis = requestTime - responseTime; + const localTime = responseTime - (roundTripMillis / 2); // Roughly compensate for transit time + timeOffsetMillis = headerTime - localTime; + } + + return timeOffsetMillis; +} + +export function normalizeMetadata(metadata) { + const requestTime = Date.now(); + const responseTime = requestTime; + const { data, headers } = metadata; + return { + accessExpiration: camelCaseObject(data.access_expiration), + canShowUpgradeSock: data.can_show_upgrade_sock, + contentTypeGatingEnabled: data.content_type_gating_enabled, + courseGoals: camelCaseObject(data.course_goals), + id: data.id, + title: data.name, + offer: camelCaseObject(data.offer), + enrollmentStart: data.enrollment_start, + enrollmentEnd: data.enrollment_end, + end: data.end, + start: data.start, + enrollmentMode: data.enrollment.mode, + isEnrolled: data.enrollment.is_active, + license: data.license, + userTimezone: data.user_timezone, + showCalculator: data.show_calculator, + notes: camelCaseObject(data.notes), + marketingUrl: data.marketing_url, + celebrations: camelCaseObject(data.celebrations), + userHasPassingGrade: data.user_has_passing_grade, + courseExitPageIsActive: data.course_exit_page_is_active, + certificateData: camelCaseObject(data.certificate_data), + entranceExamData: camelCaseObject(data.entrance_exam_data), + timeOffsetMillis: getTimeOffsetMillis(headers && headers.date, requestTime, responseTime), + verifyIdentityUrl: data.verify_identity_url, + verificationStatus: data.verification_status, + linkedinAddToProfileUrl: data.linkedin_add_to_profile_url, + relatedPrograms: camelCaseObject(data.related_programs), + userNeedsIntegritySignature: data.user_needs_integrity_signature, + canAccessProctoredExams: data.can_access_proctored_exams, + learningAssistantEnabled: data.learning_assistant_enabled, + }; +} + +export const appendBrowserTimezoneToUrl = (url) => { + const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const urlObject = new URL(url); + if (browserTimezone) { + urlObject.searchParams.append('browser_timezone', browserTimezone); + } + return urlObject.href; +}; + +export function normalizeSequenceMetadata(sequence) { + return { + sequence: { + id: sequence.item_id, + blockType: sequence.tag, + unitIds: sequence.items.map(unit => unit.id), + bannerText: sequence.banner_text, + format: sequence.format, + title: sequence.display_name, + /* + Example structure of gated_content when prerequisites exist: + { + prereq_id: 'id of the prereq section', + prereq_url: 'unused by this frontend', + prereq_section_name: 'Name of the prerequisite section', + gated: true, + gated_section_name: 'Name of this gated section', + */ + gatedContent: camelCaseObject(sequence.gated_content), + isTimeLimited: sequence.is_time_limited, + isProctored: sequence.is_proctored, + isHiddenAfterDue: sequence.is_hidden_after_due, + // Position comes back from the server 1-indexed. Adjust here. + activeUnitIndex: sequence.position ? sequence.position - 1 : 0, + saveUnitPosition: sequence.save_position, + showCompletion: sequence.show_completion, + allowProctoringOptOut: sequence.allow_proctoring_opt_out, + }, + units: sequence.items.map(unit => ({ + id: unit.id, + sequenceId: sequence.item_id, + bookmarked: unit.bookmarked, + complete: unit.complete, + title: unit.page_title, + contentType: unit.type, + graded: unit.graded, + containsContentTypeGatedContent: unit.contains_content_type_gated_content, + })), + }; +} + +export function normalizeLearningSequencesData(learningSequencesData) { + const models = { + courses: {}, + sections: {}, + sequences: {}, + }; + + const now = new Date(); + function isReleased(block) { + // We check whether the backend marks this as accessible because staff users are granted access anyway. + // Note that sections don't have the `accessible` field and will just be checking `effective_start`. + return block.accessible || !block.effective_start || now >= Date.parse(block.effective_start); + } + + // Sequences + Object.entries(learningSequencesData.outline.sequences).forEach(([seqId, sequence]) => { + if (!isReleased(sequence)) { + return; // Don't let the learner see unreleased sequences + } + + models.sequences[seqId] = { + id: seqId, + title: sequence.title, + }; + }); + + // Sections + learningSequencesData.outline.sections.forEach(section => { + // Filter out any ignored sequences (e.g. unreleased sequences) + const availableSequenceIds = section.sequence_ids.filter(seqId => seqId in models.sequences); + + // If we are unreleased and already stripped out all our children, just don't show us at all. + // (We check both release date and children because children will exist for an unreleased section even for staff, + // so we still want to show this section.) + if (!isReleased(section) && !availableSequenceIds.length) { + return; + } + + models.sections[section.id] = { + id: section.id, + title: section.title, + sequenceIds: availableSequenceIds, + courseId: learningSequencesData.course_key, + }; + + // Add back-references to this section for all child sequences. + availableSequenceIds.forEach(childSeqId => { + models.sequences[childSeqId].sectionId = section.id; + }); + }); + + // Course + models.courses[learningSequencesData.course_key] = { + id: learningSequencesData.course_key, + title: learningSequencesData.title, + sectionIds: Object.entries(models.sections).map(([sectionId]) => sectionId), + + // Scan through all the sequences and look for ones that aren't released yet. + hasScheduledContent: Object.values(learningSequencesData.outline.sequences).some(seq => !isReleased(seq)), + }; + + return models; +} + +/** + * Tweak the metadata for consistency + * @param metadata the data to normalize + * @param rootSlug either 'courseware' or 'outline' depending on the context + * @returns {Object} The normalized metadata + */ +export function normalizeCourseHomeCourseMetadata(metadata, rootSlug) { + const data = camelCaseObject(metadata); + return { + ...data, + tabs: data.tabs.map(tab => ({ + // The API uses "courseware" as a slug for both courseware and the outline tab. + // If needed, we switch it to "outline" here for + // use within the MFE to differentiate between course home and courseware. + slug: tab.tabId === 'courseware' ? rootSlug : tab.tabId, + title: tab.title, + url: tab.url, + })), + isMasquerading: data.originalUserIsStaff && !data.isStaff, + }; +} diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index 98e4fc9a8..3ecb42a28 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -1,11 +1,15 @@ import { useContext, useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { AppContext } from '@edx/frontend-platform/react'; +import { useNavigate } from 'react-router-dom'; import { RequestStatus } from '../data/constants'; import { fetchCourseUnitQuery, editCourseItemQuery, + fetchSequence, + fetchCourse, + fetchCourseSectionVerticalData, } from './data/thunk'; import { getCourseUnitData, @@ -15,17 +19,19 @@ import { import { updateSavingStatus } from './data/slice'; import { getUnitViewLivePath, getUnitPreviewPath } from './utils'; -const useCourseUnit = ({ courseId, blockId }) => { +// eslint-disable-next-line import/prefer-default-export +export const useCourseUnit = ({ courseId, blockId }) => { const dispatch = useDispatch(); const { config } = useContext(AppContext); const courseUnit = useSelector(getCourseUnitData); const savingStatus = useSelector(getSavingStatus); const loadingStatus = useSelector(getLoadingStatus); - + const navigate = useNavigate(); const [isTitleEditFormOpen, toggleTitleEditForm] = useState(false); const unitTitle = courseUnit.metadata?.displayName || ''; + const sequenceId = courseUnit.ancestorInfo?.ancestors[0].id; const headerNavigationsActions = { handleViewLive: () => { @@ -54,11 +60,22 @@ const useCourseUnit = ({ courseId, blockId }) => { handleTitleEdit(); }; + const handleNavigate = (id) => { + if (sequenceId) { + navigate(`/course/${courseId}/container/${blockId}/${id}`, { replace: true }); + } + }; + useEffect(() => { dispatch(fetchCourseUnitQuery(blockId)); - }, [courseId]); + dispatch(fetchCourseSectionVerticalData(blockId)); + dispatch(fetchSequence(sequenceId)); + dispatch(fetchCourse(courseId)); + handleNavigate(sequenceId); + }, [courseId, blockId, sequenceId]); return { + sequenceId, courseUnit, unitTitle, isLoading: loadingStatus.fetchUnitLoadingStatus === RequestStatus.IN_PROGRESS, @@ -70,6 +87,3 @@ const useCourseUnit = ({ courseId, blockId }) => { handleTitleEditSubmit, }; }; - -// eslint-disable-next-line import/prefer-default-export -export { useCourseUnit }; diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index c4d728a39..393a9019a 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -982,5 +982,12 @@ "course-authoring.course-unit.heading.button.edit.alt": "Edit", "course-authoring.course-unit.heading.button.edit.aria-label": "Edit field", "course-authoring.course-unit.heading.button.settings.alt": "Settings", - "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again." + "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.", + "course-authoring.course-unit.prev-btn-text": "Previous", + "course-authoring.course-unit.next-btn-text": "Next", + "course-authoring.course-unit.new-unit-btn-text": "New unit", + "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", + "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", + "course-authoring.course-unit.sequence.no.content": "There is no content here.", + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" } diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 4c8578fcf..09c7da1eb 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -982,5 +982,12 @@ "course-authoring.course-unit.heading.button.edit.alt": "Edit", "course-authoring.course-unit.heading.button.edit.aria-label": "Edit field", "course-authoring.course-unit.heading.button.settings.alt": "Settings", - "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again." + "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.", + "course-authoring.course-unit.prev-btn-text": "Previous", + "course-authoring.course-unit.next-btn-text": "Next", + "course-authoring.course-unit.new-unit-btn-text": "New unit", + "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", + "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", + "course-authoring.course-unit.sequence.no.content": "There is no content here.", + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" } diff --git a/src/i18n/messages/de_DE.json b/src/i18n/messages/de_DE.json index d69cba440..0286fe7a0 100644 --- a/src/i18n/messages/de_DE.json +++ b/src/i18n/messages/de_DE.json @@ -982,5 +982,12 @@ "course-authoring.course-unit.heading.button.edit.alt": "Edit", "course-authoring.course-unit.heading.button.edit.aria-label": "Edit field", "course-authoring.course-unit.heading.button.settings.alt": "Settings", - "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again." + "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.", + "course-authoring.course-unit.prev-btn-text": "Previous", + "course-authoring.course-unit.next-btn-text": "Next", + "course-authoring.course-unit.new-unit-btn-text": "New unit", + "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", + "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", + "course-authoring.course-unit.sequence.no.content": "There is no content here.", + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" } diff --git a/src/i18n/messages/es_419.json b/src/i18n/messages/es_419.json index 1111d4cae..c59fa6849 100644 --- a/src/i18n/messages/es_419.json +++ b/src/i18n/messages/es_419.json @@ -982,5 +982,12 @@ "course-authoring.course-unit.heading.button.edit.alt": "Edit", "course-authoring.course-unit.heading.button.edit.aria-label": "Edit field", "course-authoring.course-unit.heading.button.settings.alt": "Settings", - "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again." + "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.", + "course-authoring.course-unit.prev-btn-text": "Previous", + "course-authoring.course-unit.next-btn-text": "Next", + "course-authoring.course-unit.new-unit-btn-text": "New unit", + "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", + "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", + "course-authoring.course-unit.sequence.no.content": "There is no content here.", + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" } diff --git a/src/i18n/messages/fa_IR.json b/src/i18n/messages/fa_IR.json index 3c5865975..a8ef072f6 100644 --- a/src/i18n/messages/fa_IR.json +++ b/src/i18n/messages/fa_IR.json @@ -5,5 +5,12 @@ "course-authoring.course-unit.heading.button.edit.alt": "Edit", "course-authoring.course-unit.heading.button.edit.aria-label": "Edit field", "course-authoring.course-unit.heading.button.settings.alt": "Settings", - "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again." + "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.", + "course-authoring.course-unit.prev-btn-text": "Previous", + "course-authoring.course-unit.next-btn-text": "Next", + "course-authoring.course-unit.new-unit-btn-text": "New unit", + "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", + "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", + "course-authoring.course-unit.sequence.no.content": "There is no content here.", + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" } diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 094d6af15..e91037042 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -982,5 +982,12 @@ "course-authoring.course-unit.heading.button.edit.alt": "Edit", "course-authoring.course-unit.heading.button.edit.aria-label": "Edit field", "course-authoring.course-unit.heading.button.settings.alt": "Settings", - "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again." + "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.", + "course-authoring.course-unit.prev-btn-text": "Previous", + "course-authoring.course-unit.next-btn-text": "Next", + "course-authoring.course-unit.new-unit-btn-text": "New unit", + "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", + "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", + "course-authoring.course-unit.sequence.no.content": "There is no content here.", + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" } diff --git a/src/i18n/messages/fr_CA.json b/src/i18n/messages/fr_CA.json index 8ed7611d8..f3251fbbb 100644 --- a/src/i18n/messages/fr_CA.json +++ b/src/i18n/messages/fr_CA.json @@ -982,5 +982,12 @@ "course-authoring.course-unit.heading.button.edit.alt": "Edit", "course-authoring.course-unit.heading.button.edit.aria-label": "Edit field", "course-authoring.course-unit.heading.button.settings.alt": "Settings", - "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again." + "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.", + "course-authoring.course-unit.prev-btn-text": "Previous", + "course-authoring.course-unit.next-btn-text": "Next", + "course-authoring.course-unit.new-unit-btn-text": "New unit", + "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", + "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", + "course-authoring.course-unit.sequence.no.content": "There is no content here.", + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" } diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 4c8578fcf..09c7da1eb 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -982,5 +982,12 @@ "course-authoring.course-unit.heading.button.edit.alt": "Edit", "course-authoring.course-unit.heading.button.edit.aria-label": "Edit field", "course-authoring.course-unit.heading.button.settings.alt": "Settings", - "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again." + "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.", + "course-authoring.course-unit.prev-btn-text": "Previous", + "course-authoring.course-unit.next-btn-text": "Next", + "course-authoring.course-unit.new-unit-btn-text": "New unit", + "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", + "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", + "course-authoring.course-unit.sequence.no.content": "There is no content here.", + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" } diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 4c8578fcf..09c7da1eb 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -982,5 +982,12 @@ "course-authoring.course-unit.heading.button.edit.alt": "Edit", "course-authoring.course-unit.heading.button.edit.aria-label": "Edit field", "course-authoring.course-unit.heading.button.settings.alt": "Settings", - "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again." + "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.", + "course-authoring.course-unit.prev-btn-text": "Previous", + "course-authoring.course-unit.next-btn-text": "Next", + "course-authoring.course-unit.new-unit-btn-text": "New unit", + "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", + "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", + "course-authoring.course-unit.sequence.no.content": "There is no content here.", + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" } diff --git a/src/i18n/messages/it_IT.json b/src/i18n/messages/it_IT.json index 46998e7cc..21f4b2660 100644 --- a/src/i18n/messages/it_IT.json +++ b/src/i18n/messages/it_IT.json @@ -982,5 +982,12 @@ "course-authoring.course-unit.heading.button.edit.alt": "Edit", "course-authoring.course-unit.heading.button.edit.aria-label": "Edit field", "course-authoring.course-unit.heading.button.settings.alt": "Settings", - "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again." + "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.", + "course-authoring.course-unit.prev-btn-text": "Previous", + "course-authoring.course-unit.next-btn-text": "Next", + "course-authoring.course-unit.new-unit-btn-text": "New unit", + "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", + "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", + "course-authoring.course-unit.sequence.no.content": "There is no content here.", + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" } diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 4c8578fcf..09c7da1eb 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -982,5 +982,12 @@ "course-authoring.course-unit.heading.button.edit.alt": "Edit", "course-authoring.course-unit.heading.button.edit.aria-label": "Edit field", "course-authoring.course-unit.heading.button.settings.alt": "Settings", - "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again." + "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.", + "course-authoring.course-unit.prev-btn-text": "Previous", + "course-authoring.course-unit.next-btn-text": "Next", + "course-authoring.course-unit.new-unit-btn-text": "New unit", + "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", + "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", + "course-authoring.course-unit.sequence.no.content": "There is no content here.", + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" } diff --git a/src/i18n/messages/pt_PT.json b/src/i18n/messages/pt_PT.json index 52eea7d8e..b447c987f 100644 --- a/src/i18n/messages/pt_PT.json +++ b/src/i18n/messages/pt_PT.json @@ -982,5 +982,12 @@ "course-authoring.course-unit.heading.button.edit.alt": "Edit", "course-authoring.course-unit.heading.button.edit.aria-label": "Edit field", "course-authoring.course-unit.heading.button.settings.alt": "Settings", - "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again." + "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.", + "course-authoring.course-unit.prev-btn-text": "Previous", + "course-authoring.course-unit.next-btn-text": "Next", + "course-authoring.course-unit.new-unit-btn-text": "New unit", + "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", + "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", + "course-authoring.course-unit.sequence.no.content": "There is no content here.", + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" } diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 4c8578fcf..09c7da1eb 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -982,5 +982,12 @@ "course-authoring.course-unit.heading.button.edit.alt": "Edit", "course-authoring.course-unit.heading.button.edit.aria-label": "Edit field", "course-authoring.course-unit.heading.button.settings.alt": "Settings", - "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again." + "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.", + "course-authoring.course-unit.prev-btn-text": "Previous", + "course-authoring.course-unit.next-btn-text": "Next", + "course-authoring.course-unit.new-unit-btn-text": "New unit", + "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", + "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", + "course-authoring.course-unit.sequence.no.content": "There is no content here.", + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" } diff --git a/src/i18n/messages/uk.json b/src/i18n/messages/uk.json index 4c8578fcf..09c7da1eb 100644 --- a/src/i18n/messages/uk.json +++ b/src/i18n/messages/uk.json @@ -982,5 +982,12 @@ "course-authoring.course-unit.heading.button.edit.alt": "Edit", "course-authoring.course-unit.heading.button.edit.aria-label": "Edit field", "course-authoring.course-unit.heading.button.settings.alt": "Settings", - "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again." + "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.", + "course-authoring.course-unit.prev-btn-text": "Previous", + "course-authoring.course-unit.next-btn-text": "Next", + "course-authoring.course-unit.new-unit-btn-text": "New unit", + "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", + "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", + "course-authoring.course-unit.sequence.no.content": "There is no content here.", + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" } diff --git a/src/i18n/messages/zh_CN.json b/src/i18n/messages/zh_CN.json index 4c8578fcf..09c7da1eb 100644 --- a/src/i18n/messages/zh_CN.json +++ b/src/i18n/messages/zh_CN.json @@ -982,5 +982,12 @@ "course-authoring.course-unit.heading.button.edit.alt": "Edit", "course-authoring.course-unit.heading.button.edit.aria-label": "Edit field", "course-authoring.course-unit.heading.button.settings.alt": "Settings", - "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again." + "course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.", + "course-authoring.course-unit.prev-btn-text": "Previous", + "course-authoring.course-unit.next-btn-text": "Next", + "course-authoring.course-unit.new-unit-btn-text": "New unit", + "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", + "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", + "course-authoring.course-unit.sequence.no.content": "There is no content here.", + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" } diff --git a/src/index.scss b/src/index.scss index 2a3dc0679..75704d4e2 100755 --- a/src/index.scss +++ b/src/index.scss @@ -22,3 +22,4 @@ @import "files-and-videos"; @import "content-tags-drawer/TagBubble"; @import "course-outline/CourseOutline"; +@import "course-unit/CourseUnit"; From a318c322b240cfd79548386786d49737c8795e4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Thu, 8 Feb 2024 13:18:07 -0300 Subject: [PATCH 05/11] fix: revert code due to wrong merge conflict resolution (#824) --- src/content-tags-drawer/data/api.js | 6 +++--- src/content-tags-drawer/data/api.test.js | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/content-tags-drawer/data/api.js b/src/content-tags-drawer/data/api.js index d5a4f3de1..28bd7a36c 100644 --- a/src/content-tags-drawer/data/api.js +++ b/src/content-tags-drawer/data/api.js @@ -75,8 +75,8 @@ export async function getContentData(contentId) { * @returns {Promise} */ export async function updateContentTaxonomyTags(contentId, taxonomyId, tags) { - let url = getContentTaxonomyTagsApiUrl(contentId); - url = `${url}&taxonomy=${taxonomyId}`; - const { data } = await getAuthenticatedHttpClient().put(url, { tags }); + const url = getContentTaxonomyTagsApiUrl(contentId); + const params = { taxonomy: taxonomyId }; + const { data } = await getAuthenticatedHttpClient().put(url, { tags }, { params }); return camelCaseObject(data[contentId]); } diff --git a/src/content-tags-drawer/data/api.test.js b/src/content-tags-drawer/data/api.test.js index 229e6daee..9fa88dcb7 100644 --- a/src/content-tags-drawer/data/api.test.js +++ b/src/content-tags-drawer/data/api.test.js @@ -110,10 +110,10 @@ describe('content tags drawer api calls', () => { const contentId = 'block-v1:SampleTaxonomyOrg1+STC1+2023_1+type@vertical+block@aaf8b8eb86b54281aeeab12499d2cb0b'; const taxonomyId = 3; const tags = ['flat taxonomy tag 100', 'flat taxonomy tag 3856']; - axiosMock.onPut(`${getContentTaxonomyTagsApiUrl(contentId)}&taxonomy=${taxonomyId}`).reply(200, updateContentTaxonomyTagsMock); + axiosMock.onPut(`${getContentTaxonomyTagsApiUrl(contentId)}`).reply(200, updateContentTaxonomyTagsMock); const result = await updateContentTaxonomyTags(contentId, taxonomyId, tags); - expect(axiosMock.history.put[0].url).toEqual(`${getContentTaxonomyTagsApiUrl(contentId)}&taxonomy=${taxonomyId}`); + expect(axiosMock.history.put[0].url).toEqual(`${getContentTaxonomyTagsApiUrl(contentId)}`); expect(result).toEqual(updateContentTaxonomyTagsMock[contentId]); }); }); From 3938015aaa843e746b0fb466c655d334785fc461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Ch=C3=A1vez?= Date: Thu, 8 Feb 2024 22:57:17 -0500 Subject: [PATCH 06/11] feat: Add export ID on Taxonomy details and import new Taxonomy (#814) Adds a new prompt on the import new taxonomy workflow to enter the export_id, and adds the export_id on the Taxonomy page details. Implements modular-learning#183 '[Tagging] An "Export ID" identifies each Taxonomy' --- src/taxonomy/data/types.mjs | 1 + .../__mocks__/taxonomyImportMock.js | 1 + src/taxonomy/import-tags/data/api.js | 3 ++- src/taxonomy/import-tags/data/api.test.jsx | 2 +- src/taxonomy/import-tags/data/utils.js | 27 ++++++++++++++++++- src/taxonomy/import-tags/messages.js | 12 +++++++++ .../TaxonomyDetailSideCard.jsx | 4 +++ src/taxonomy/taxonomy-detail/messages.js | 4 +++ 8 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/taxonomy/data/types.mjs b/src/taxonomy/data/types.mjs index 6609a342a..8947c0e43 100644 --- a/src/taxonomy/data/types.mjs +++ b/src/taxonomy/data/types.mjs @@ -5,6 +5,7 @@ * @property {number} id * @property {string} name * @property {string} description + * @property {string} exportId * @property {boolean} enabled * @property {boolean} allowMultiple * @property {boolean} allowFreeText diff --git a/src/taxonomy/import-tags/__mocks__/taxonomyImportMock.js b/src/taxonomy/import-tags/__mocks__/taxonomyImportMock.js index 9db45b4a5..655c4ee08 100644 --- a/src/taxonomy/import-tags/__mocks__/taxonomyImportMock.js +++ b/src/taxonomy/import-tags/__mocks__/taxonomyImportMock.js @@ -1,4 +1,5 @@ export default { name: 'Taxonomy name', + exportId: 'taxonomy_export_id', description: 'Taxonomy description', }; diff --git a/src/taxonomy/import-tags/data/api.js b/src/taxonomy/import-tags/data/api.js index a31abace6..124d329ec 100644 --- a/src/taxonomy/import-tags/data/api.js +++ b/src/taxonomy/import-tags/data/api.js @@ -35,10 +35,11 @@ export const getTagsPlanImportApiUrl = (taxonomyId) => new URL( * @param {File} file * @returns {Promise} */ -export async function importNewTaxonomy(taxonomyName, taxonomyDescription, file) { +export async function importNewTaxonomy(taxonomyName, taxonomyExportId, taxonomyDescription, file) { // ToDo: transform this to use react-query like useImportTags const formData = new FormData(); formData.append('taxonomy_name', taxonomyName); + formData.append('taxonomy_export_id', taxonomyExportId); formData.append('taxonomy_description', taxonomyDescription); formData.append('file', file); diff --git a/src/taxonomy/import-tags/data/api.test.jsx b/src/taxonomy/import-tags/data/api.test.jsx index 6461e6808..9e4c35017 100644 --- a/src/taxonomy/import-tags/data/api.test.jsx +++ b/src/taxonomy/import-tags/data/api.test.jsx @@ -51,7 +51,7 @@ describe('import taxonomy api calls', () => { it('should call import new taxonomy', async () => { axiosMock.onPost(getTaxonomyImportNewApiUrl()).reply(201, taxonomyImportMock); - const result = await importNewTaxonomy('Taxonomy name', 'Taxonomy description'); + const result = await importNewTaxonomy('Taxonomy name', 'taxonomy_export_id', 'Taxonomy description'); expect(axiosMock.history.post[0].url).toEqual(getTaxonomyImportNewApiUrl()); expect(result).toEqual(taxonomyImportMock); diff --git a/src/taxonomy/import-tags/data/utils.js b/src/taxonomy/import-tags/data/utils.js index 71377ac72..8b06858d7 100644 --- a/src/taxonomy/import-tags/data/utils.js +++ b/src/taxonomy/import-tags/data/utils.js @@ -64,6 +64,26 @@ export const importTaxonomy = async (intl) => { // eslint-disable-line import/pr return taxonomyName; }; + const getTaxonomyExportId = () => { + let taxonomyExportId = null; + const validationRegex = /^[\p{L}\w\-.]+$/u; + while (!taxonomyExportId) { + taxonomyExportId = prompt(intl.formatMessage(messages.promptTaxonomyExportId)); + + if (taxonomyExportId == null) { + break; + } + + if (!taxonomyExportId) { + alert(intl.formatMessage(messages.promptTaxonomyExportIdRequired)); + } else if (!validationRegex.test(taxonomyExportId)) { + alert(intl.formatMessage(messages.promptTaxonomyExportIdInvalid)); + taxonomyExportId = null; + } + } + return taxonomyExportId; + }; + const getTaxonomyDescription = () => prompt(intl.formatMessage(messages.promptTaxonomyDescription)); const file = await selectFile(); @@ -77,12 +97,17 @@ export const importTaxonomy = async (intl) => { // eslint-disable-line import/pr return; } + const taxonomyExportId = getTaxonomyExportId(); + if (taxonomyExportId == null) { + return; + } + const taxonomyDescription = getTaxonomyDescription(); if (taxonomyDescription == null) { return; } - importNewTaxonomy(taxonomyName, taxonomyDescription, file) + importNewTaxonomy(taxonomyName, taxonomyExportId, taxonomyDescription, file) .then(() => { alert(intl.formatMessage(messages.importTaxonomySuccess)); }) diff --git a/src/taxonomy/import-tags/messages.js b/src/taxonomy/import-tags/messages.js index 05e4eb738..6fa09f44a 100644 --- a/src/taxonomy/import-tags/messages.js +++ b/src/taxonomy/import-tags/messages.js @@ -92,6 +92,18 @@ const messages = defineMessages({ id: 'course-authoring.import-tags.prompt.taxonomy-name.required', defaultMessage: 'You must enter a name for the new taxonomy', }, + promptTaxonomyExportId: { + id: 'course-authoring.import-tags.prompt.taxonomy-export-id', + defaultMessage: "Enter a Export ID for the new taxonomy. Should only contain alphanumeric characters or '_' '-' '.'", + }, + promptTaxonomyExportIdRequired: { + id: 'course-authoring.import-tags.prompt.taxonomy-export-id.required', + defaultMessage: 'You must enter an Export ID for the new taxonomy.', + }, + promptTaxonomyExportIdInvalid: { + id: 'course-authoring.import-tags.prompt.taxonomy-export-id.invalid', + defaultMessage: "Invalid Export ID. Should only contain alphanumeric characters or '_' '-' '.'", + }, promptTaxonomyDescription: { id: 'course-authoring.import-tags.prompt.taxonomy-description', defaultMessage: 'Enter a description for the new taxonomy', diff --git a/src/taxonomy/taxonomy-detail/TaxonomyDetailSideCard.jsx b/src/taxonomy/taxonomy-detail/TaxonomyDetailSideCard.jsx index e4eec9cb6..19ee4e5b2 100644 --- a/src/taxonomy/taxonomy-detail/TaxonomyDetailSideCard.jsx +++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailSideCard.jsx @@ -18,6 +18,9 @@ const TaxonomyDetailSideCard = ({ taxonomy }) => { {taxonomy.description} + + {taxonomy.exportId} + ); }; @@ -25,6 +28,7 @@ const TaxonomyDetailSideCard = ({ taxonomy }) => { TaxonomyDetailSideCard.propTypes = { taxonomy: PropTypes.shape({ name: PropTypes.string.isRequired, + exportId: PropTypes.string.isRequired, description: PropTypes.string.isRequired, }).isRequired, }; diff --git a/src/taxonomy/taxonomy-detail/messages.js b/src/taxonomy/taxonomy-detail/messages.js index e8ac8851d..922474216 100644 --- a/src/taxonomy/taxonomy-detail/messages.js +++ b/src/taxonomy/taxonomy-detail/messages.js @@ -14,6 +14,10 @@ const messages = defineMessages({ id: 'course-authoring.taxonomy-detail.side-card.description', defaultMessage: 'Description', }, + taxonomyDetailsExportID: { + id: 'course-authoring.taxonomy-detail.side-card.exportID', + defaultMessage: 'Export ID', + }, }); export default messages; From 1555e9f88e0ec35ee6d03720447748265525b365 Mon Sep 17 00:00:00 2001 From: Ihor Romaniuk Date: Fri, 9 Feb 2024 20:27:00 +0100 Subject: [PATCH 07/11] feat: [FC-0044] Unit page - add new component section (#828) * feat: Course unit - add new component section * feat: Course unit - make Discussion and Drag-and-Drop button functional * feat: Course unit - make Problem button functional * feat: Unit page - make Video button functional --- src/constants.js | 1 + src/course-unit/CourseUnit.jsx | 9 +- src/course-unit/CourseUnit.scss | 1 + src/course-unit/CourseUnit.test.jsx | 65 + .../__mocks__/courseCreateXblock.js | 4 + .../__mocks__/courseSectionVertical.js | 1418 +++++++++++++++++ src/course-unit/__mocks__/index.js | 3 +- .../add-component/AddComponent.jsx | 64 + .../add-component/AddComponent.scss | 12 + .../add-component/AddComponent.test.jsx | 165 ++ .../add-component/ComponentIcon.jsx | 17 + src/course-unit/add-component/messages.js | 14 + src/course-unit/constants.js | 29 + src/course-unit/data/api.js | 13 + src/course-unit/data/slice.js | 7 + src/course-unit/data/thunk.js | 32 +- src/course-unit/hooks.jsx | 6 + src/i18n/messages/ar.json | 4 +- src/i18n/messages/de.json | 4 +- src/i18n/messages/de_DE.json | 4 +- src/i18n/messages/es_419.json | 4 +- src/i18n/messages/fa_IR.json | 4 +- src/i18n/messages/fr.json | 4 +- src/i18n/messages/fr_CA.json | 4 +- src/i18n/messages/hi.json | 4 +- src/i18n/messages/it.json | 4 +- src/i18n/messages/it_IT.json | 4 +- src/i18n/messages/pt.json | 4 +- src/i18n/messages/pt_PT.json | 4 +- src/i18n/messages/ru.json | 4 +- src/i18n/messages/uk.json | 4 +- src/i18n/messages/zh_CN.json | 4 +- src/utils.js | 21 +- src/utils.test.js | 46 +- 34 files changed, 1966 insertions(+), 21 deletions(-) create mode 100644 src/course-unit/__mocks__/courseCreateXblock.js create mode 100644 src/course-unit/__mocks__/courseSectionVertical.js create mode 100644 src/course-unit/add-component/AddComponent.jsx create mode 100644 src/course-unit/add-component/AddComponent.scss create mode 100644 src/course-unit/add-component/AddComponent.test.jsx create mode 100644 src/course-unit/add-component/ComponentIcon.jsx create mode 100644 src/course-unit/add-component/messages.js diff --git a/src/constants.js b/src/constants.js index 2b2d394c0..eb1b17b37 100644 --- a/src/constants.js +++ b/src/constants.js @@ -20,6 +20,7 @@ export const BADGE_STATES = { }; export const NOTIFICATION_MESSAGES = { + adding: 'Adding', saving: 'Saving', duplicating: 'Duplicating', deleting: 'Deleting', diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index e7a09a2d0..b532fe0be 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -12,6 +12,7 @@ import getPageHeadTitle from '../generic/utils'; import ProcessingNotification from '../generic/processing-notification'; import InternetConnectionAlert from '../generic/internet-connection-alert'; import Loading from '../generic/Loading'; +import AddComponent from './add-component/AddComponent'; import HeaderTitle from './header-title/HeaderTitle'; import Breadcrumbs from './breadcrumbs/Breadcrumbs'; import HeaderNavigations from './header-navigations/HeaderNavigations'; @@ -33,6 +34,7 @@ const CourseUnit = ({ courseId }) => { headerNavigationsActions, handleTitleEdit, handleInternetConnectionFailed, + handleCreateNewCourseXblock, } = useCourseUnit({ courseId, blockId }); document.title = getPageHeadTitle('', unitTitle); @@ -87,9 +89,12 @@ const CourseUnit = ({ courseId }) => { xl={[{ span: 9 }, { span: 3 }]} > - {/* TODO: Unit content will be added in the following tasks. */} - Unit content + + diff --git a/src/course-unit/CourseUnit.scss b/src/course-unit/CourseUnit.scss index 954bbbdac..d3264d89f 100644 --- a/src/course-unit/CourseUnit.scss +++ b/src/course-unit/CourseUnit.scss @@ -1,2 +1,3 @@ @import "./breadcrumbs/Breadcrumbs"; @import "./course-sequence/CourseSequence"; +@import "./add-component/AddComponent"; diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index bc4e1ad9a..2019dfe6f 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -9,14 +9,19 @@ import { getConfig, initializeMockApp } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { + getCourseSectionVerticalApiUrl, getCourseUnitApiUrl, getXBlockBaseApiUrl, + postXBlockBaseApiUrl, } from './data/api'; import { + fetchCourseSectionVerticalData, fetchCourseUnitQuery, } from './data/thunk'; import initializeStore from '../store'; import { + courseCreateXblockMock, + courseSectionVerticalMock, courseUnitIndexMock, } from './__mocks__'; import { executeThunk } from '../utils'; @@ -24,6 +29,7 @@ import CourseUnit from './CourseUnit'; import headerNavigationsMessages from './header-navigations/messages'; import headerTitleMessages from './header-title/messages'; import { getUnitPreviewPath, getUnitViewLivePath } from './utils'; +import messages from './add-component/messages'; let axiosMock; let store; @@ -32,10 +38,12 @@ const sectionId = 'graded_interactions'; const subsectionId = '19a30717eff543078a5d94ae9d6c18a5'; const blockId = '567890'; const unitDisplayName = courseUnitIndexMock.metadata.display_name; +const mockedUsedNavigate = jest.fn(); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useParams: () => ({ blockId }), + useNavigate: () => mockedUsedNavigate, })); const RootWrapper = () => ( @@ -63,6 +71,10 @@ describe('', () => { .onGet(getCourseUnitApiUrl(courseId)) .reply(200, courseUnitIndexMock); await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, courseSectionVerticalMock); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); }); it('render CourseUnit component correctly', async () => { @@ -146,4 +158,57 @@ describe('', () => { expect(titleEditField).not.toBeInTheDocument(); expect(await findByText(newDisplayName)).toBeInTheDocument(); }); + + it('doesn\'t handle creating xblock and displays an error message', async () => { + const { courseKey, locator } = courseCreateXblockMock; + axiosMock + .onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId })) + .reply(500, {}); + const { getByRole } = render(); + + await waitFor(() => { + const videoButton = getByRole('button', { + name: new RegExp(`${messages.buttonText.defaultMessage} Video`, 'i'), + }); + + userEvent.click(videoButton); + expect(mockedUsedNavigate).not.toHaveBeenCalledWith(`/course/${courseKey}/editor/video/${locator}`); + }); + }); + + it('handle creating Problem xblock and navigate to editor page', async () => { + const { courseKey, locator } = courseCreateXblockMock; + axiosMock + .onPost(postXBlockBaseApiUrl({ type: 'problem', category: 'problem', parentLocator: blockId })) + .reply(200, courseCreateXblockMock); + const { getByRole } = render(); + + await waitFor(() => { + const problemButton = getByRole('button', { + name: new RegExp(`${messages.buttonText.defaultMessage} Problem`, 'i'), + }); + + userEvent.click(problemButton); + expect(mockedUsedNavigate).toHaveBeenCalled(); + expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseKey}/editor/problem/${locator}`); + }); + }); + + it('handles creating Video xblock and navigates to editor page', async () => { + const { courseKey, locator } = courseCreateXblockMock; + axiosMock + .onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId })) + .reply(200, courseCreateXblockMock); + const { getByRole } = render(); + + await waitFor(() => { + const videoButton = getByRole('button', { + name: new RegExp(`${messages.buttonText.defaultMessage} Video`, 'i'), + }); + + userEvent.click(videoButton); + expect(mockedUsedNavigate).toHaveBeenCalled(); + expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseKey}/editor/video/${locator}`); + }); + }); }); diff --git a/src/course-unit/__mocks__/courseCreateXblock.js b/src/course-unit/__mocks__/courseCreateXblock.js new file mode 100644 index 000000000..7da6d4906 --- /dev/null +++ b/src/course-unit/__mocks__/courseCreateXblock.js @@ -0,0 +1,4 @@ +module.exports = { + locator: 'block-v1:edX+L153+3T2023+type@drag-and-drop-v2+block@dc52e3cf8e6145e39ba5c1ff4888db4b', + courseKey: 'course-v1:edX+L153+3T2023', +}; diff --git a/src/course-unit/__mocks__/courseSectionVertical.js b/src/course-unit/__mocks__/courseSectionVertical.js new file mode 100644 index 000000000..fdae7cdd5 --- /dev/null +++ b/src/course-unit/__mocks__/courseSectionVertical.js @@ -0,0 +1,1418 @@ +module.exports = { + language_code: 'en', + action: 'view', + xblock: { + display_name: 'Getting Started', + display_type: 'Unit', + category: 'vertical', + }, + is_unit_page: true, + is_collapsible: false, + position: 1, + prev_url: '%2Fcontainer%2Fblock-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40vertical%2Bblock%40vertical_0270f6de40fc', + next_url: '%2Fcontainer%2Fblock-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40vertical%2Bblock%404f6c1b4e316a419ab5b6bf30e6c708e9', + new_unit_category: 'vertical', + outline_url: '/course/course-v1:edX+DemoX+Demo_Course?format=concise', + ancestor_xblocks: [ + { + children: [ + { + url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40d8a6192ade314473a78242dfeedfbf5b', + display_name: 'Introduction 2', + }, + { + url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40interactive_demonstrations', + display_name: 'Example Week 1: Getting Started', + }, + { + url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40graded_interactions', + display_name: 'Example Week 2: Get Interactive', + }, + { + url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40social_integration', + display_name: 'Example Week 3: Be Social', + }, + { + url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%401414ffd5143b4b508f739b563ab468b7', + display_name: 'About Exams and Certificates', + }, + ], + title: 'Example Week 1: Getting Started', + is_last: false, + }, + { + children: [ + { + url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%4019a30717eff543078a5d94ae9d6c18a5', + display_name: 'Lesson 1 - Getting Started', + }, + { + url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40basic_questions', + display_name: 'Homework - Question Styles', + }, + ], + title: 'Lesson 1 - Getting Started', + is_last: true, + }, + ], + component_templates: [ + { + type: 'advanced', + templates: [ + { + display_name: 'Annotation', + category: 'annotatable', + boilerplate_name: null, + hinted: false, + tab: 'common', + support_level: true, + }, + { + display_name: 'Video', + category: 'videoalpha', + boilerplate_name: null, + hinted: false, + tab: 'common', + support_level: true, + }, + ], + display_name: 'Advanced', + support_legend: { + show_legend: false, + allow_unsupported_xblocks: false, + documentation_label: 'Your Platform Name Here Support Levels:', + }, + }, + { + type: 'discussion', + templates: [ + { + display_name: 'Discussion', + category: 'discussion', + boilerplate_name: null, + hinted: false, + tab: 'advanced', + support_level: true, + }, + ], + display_name: 'Discussion', + support_legend: { + show_legend: false, + allow_unsupported_xblocks: false, + documentation_label: 'Your Platform Name Here Support Levels:', + }, + }, + { + type: 'library', + templates: [ + { + display_name: 'Randomized Content Block', + category: 'library_content', + boilerplate_name: null, + hinted: false, + tab: 'common', + support_level: true, + }, + ], + display_name: 'Library Content', + support_legend: { + show_legend: false, + allow_unsupported_xblocks: false, + documentation_label: 'Your Platform Name Here Support Levels:', + }, + }, + { + type: 'html', + templates: [ + { + display_name: 'Text', + category: 'html', + boilerplate_name: null, + hinted: false, + tab: 'advanced', + support_level: true, + }, + { + display_name: 'Raw HTML', + category: 'html', + boilerplate_name: 'raw.yaml', + hinted: false, + tab: 'advanced', + support_level: true, + }, + { + display_name: 'Zooming Image Tool', + category: 'html', + boilerplate_name: 'zooming_image.yaml', + hinted: false, + tab: 'advanced', + support_level: true, + }, + { + display_name: 'IFrame Tool', + category: 'html', + boilerplate_name: 'iframe.yaml', + hinted: false, + tab: 'advanced', + support_level: true, + }, + { + display_name: 'Anonymous User ID', + category: 'html', + boilerplate_name: 'anon_user_id.yaml', + hinted: false, + tab: 'advanced', + support_level: true, + }, + { + display_name: 'Announcement', + category: 'html', + boilerplate_name: 'announcement.yaml', + hinted: false, + tab: 'advanced', + support_level: true, + }, + ], + display_name: 'Text', + support_legend: { + show_legend: false, + allow_unsupported_xblocks: false, + documentation_label: 'Your Platform Name Here Support Levels:', + }, + }, + { + type: 'openassessment', + templates: [ + { + display_name: 'Peer Assessment Only', + category: 'openassessment', + boilerplate_name: 'peer-assessment', + hinted: false, + tab: 'advanced', + support_level: true, + }, + { + display_name: 'Self Assessment Only', + category: 'openassessment', + boilerplate_name: 'self-assessment', + hinted: false, + tab: 'advanced', + support_level: true, + }, + { + display_name: 'Staff Assessment Only', + category: 'openassessment', + boilerplate_name: 'staff-assessment', + hinted: false, + tab: 'advanced', + support_level: true, + }, + { + display_name: 'Self Assessment to Peer Assessment', + category: 'openassessment', + boilerplate_name: 'self-to-peer', + hinted: false, + tab: 'advanced', + support_level: true, + }, + { + display_name: 'Self Assessment to Staff Assessment', + category: 'openassessment', + boilerplate_name: 'self-to-staff', + hinted: false, + tab: 'advanced', + support_level: true, + }, + ], + display_name: 'Open Response', + support_legend: { + show_legend: false, + allow_unsupported_xblocks: false, + documentation_label: 'Your Platform Name Here Support Levels:', + }, + }, + { + type: 'problem', + templates: [ + { + display_name: 'Blank Common Problem', + category: 'problem', + boilerplate_name: 'blank_common.yaml', + hinted: false, + tab: 'common', + support_level: true, + }, + ], + display_name: 'Problem', + support_legend: { + show_legend: false, + allow_unsupported_xblocks: false, + documentation_label: 'Your Platform Name Here Support Levels:', + }, + }, + { + type: 'video', + templates: [ + { + display_name: 'Video', + category: 'video', + boilerplate_name: null, + hinted: false, + tab: 'advanced', + support_level: true, + }, + ], + display_name: 'Video', + support_legend: { + show_legend: false, + allow_unsupported_xblocks: false, + documentation_label: 'Your Platform Name Here Support Levels:', + }, + }, + { + type: 'drag-and-drop-v2', + templates: [ + { + display_name: 'Drag and Drop', + category: 'drag-and-drop-v2', + boilerplate_name: null, + hinted: false, + tab: 'advanced', + support_level: true, + }, + ], + display_name: 'Drag and Drop', + support_legend: { + show_legend: false, + allow_unsupported_xblocks: false, + documentation_label: 'Your Platform Name Here Support Levels:', + }, + }, + ], + xblock_info: { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@867dddb6f55d410caaa9c1eb9c6743ec', + display_name: 'Getting Started', + category: 'vertical', + has_children: true, + edited_on: 'Jan 04, 2024 at 10:32 UTC', + published: true, + published_on: 'Dec 28, 2023 at 10:00 UTC', + studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@867dddb6f55d410caaa9c1eb9c6743ec', + released_to_students: true, + release_date: 'Feb 05, 2013 at 05:00 UTC', + visibility_state: 'needs_attention', + has_explicit_staff_lock: false, + start: '2013-02-05T05:00:00Z', + graded: false, + due_date: '', + due: null, + relative_weeks_due: null, + format: null, + course_graders: [ + 'Homework', + 'Exam', + ], + has_changes: true, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatory_message: null, + group_access: {}, + user_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + show_correctness: 'always', + discussion_enabled: true, + ancestor_info: { + ancestors: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5', + display_name: 'Lesson 1 - Getting Started', + category: 'sequential', + has_children: true, + edited_on: 'Jan 04, 2024 at 10:32 UTC', + published: true, + published_on: 'Dec 28, 2023 at 10:00 UTC', + studio_url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%4019a30717eff543078a5d94ae9d6c18a5', + released_to_students: true, + release_date: 'Feb 05, 2013 at 05:00 UTC', + visibility_state: 'needs_attention', + has_explicit_staff_lock: false, + start: '2013-02-05T05:00:00Z', + graded: false, + due_date: '', + due: null, + relative_weeks_due: null, + format: null, + course_graders: [ + 'Homework', + 'Exam', + ], + has_changes: null, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatory_message: null, + group_access: {}, + user_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + show_correctness: 'always', + hide_after_due: false, + is_proctored_exam: false, + was_exam_ever_linked_with_external: false, + online_proctoring_rules: '', + is_practice_exam: false, + is_onboarding_exam: false, + is_time_limited: false, + exam_review_rules: '', + default_time_limit_minutes: null, + proctoring_exam_configuration_link: null, + supports_onboarding: false, + show_review_rules: true, + child_info: { + category: 'vertical', + display_name: 'Unit', + children: [ + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@867dddb6f55d410caaa9c1eb9c6743ec', + display_name: 'Getting Started', + category: 'vertical', + has_children: true, + edited_on: 'Jan 04, 2024 at 10:32 UTC', + published: true, + published_on: 'Dec 28, 2023 at 10:00 UTC', + studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@867dddb6f55d410caaa9c1eb9c6743ec', + released_to_students: true, + release_date: 'Feb 05, 2013 at 05:00 UTC', + visibility_state: 'needs_attention', + has_explicit_staff_lock: false, + start: '2013-02-05T05:00:00Z', + graded: false, + due_date: '', + due: null, + relative_weeks_due: null, + format: null, + course_graders: [ + 'Homework', + 'Exam', + ], + has_changes: true, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatory_message: null, + group_access: {}, + user_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + show_correctness: 'always', + discussion_enabled: true, + ancestor_has_staff_lock: false, + user_partition_info: { + selectable_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selected_partition_index: -1, + selected_groups_label: '', + }, + enable_copy_paste_units: false, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4f6c1b4e316a419ab5b6bf30e6c708e9', + display_name: 'Working with Videos', + category: 'vertical', + has_children: true, + edited_on: 'Dec 28, 2023 at 10:00 UTC', + published: true, + published_on: 'Dec 28, 2023 at 10:00 UTC', + studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@4f6c1b4e316a419ab5b6bf30e6c708e9', + released_to_students: true, + release_date: 'Feb 05, 2013 at 05:00 UTC', + visibility_state: 'live', + has_explicit_staff_lock: false, + start: '2013-02-05T05:00:00Z', + graded: false, + due_date: '', + due: null, + relative_weeks_due: null, + format: null, + course_graders: [ + 'Homework', + 'Exam', + ], + has_changes: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatory_message: null, + group_access: {}, + user_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + show_correctness: 'always', + discussion_enabled: true, + ancestor_has_staff_lock: false, + user_partition_info: { + selectable_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selected_partition_index: -1, + selected_groups_label: '', + }, + enable_copy_paste_units: false, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@3dc16db8d14842e38324e95d4030b8a0', + display_name: 'Videos on edX', + category: 'vertical', + has_children: true, + edited_on: 'Dec 28, 2023 at 10:00 UTC', + published: true, + published_on: 'Dec 28, 2023 at 10:00 UTC', + studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@3dc16db8d14842e38324e95d4030b8a0', + released_to_students: true, + release_date: 'Feb 05, 2013 at 05:00 UTC', + visibility_state: 'live', + has_explicit_staff_lock: false, + start: '2013-02-05T05:00:00Z', + graded: false, + due_date: '', + due: null, + relative_weeks_due: null, + format: null, + course_graders: [ + 'Homework', + 'Exam', + ], + has_changes: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatory_message: null, + group_access: {}, + user_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + show_correctness: 'always', + discussion_enabled: true, + ancestor_has_staff_lock: false, + user_partition_info: { + selectable_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selected_partition_index: -1, + selected_groups_label: '', + }, + enable_copy_paste_units: false, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4a1bba2a403f40bca5ec245e945b0d76', + display_name: 'Video Demonstrations', + category: 'vertical', + has_children: true, + edited_on: 'Dec 28, 2023 at 10:00 UTC', + published: true, + published_on: 'Dec 28, 2023 at 10:00 UTC', + studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@4a1bba2a403f40bca5ec245e945b0d76', + released_to_students: true, + release_date: 'Feb 05, 2013 at 05:00 UTC', + visibility_state: 'live', + has_explicit_staff_lock: false, + start: '2013-02-05T05:00:00Z', + graded: false, + due_date: '', + due: null, + relative_weeks_due: null, + format: null, + course_graders: [ + 'Homework', + 'Exam', + ], + has_changes: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatory_message: null, + group_access: {}, + user_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + show_correctness: 'always', + discussion_enabled: true, + ancestor_has_staff_lock: false, + user_partition_info: { + selectable_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selected_partition_index: -1, + selected_groups_label: '', + }, + enable_copy_paste_units: false, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@256f17a44983429fb1a60802203ee4e0', + display_name: 'Video Presentation Styles', + category: 'vertical', + has_children: true, + edited_on: 'Dec 28, 2023 at 10:00 UTC', + published: true, + published_on: 'Dec 28, 2023 at 10:00 UTC', + studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@256f17a44983429fb1a60802203ee4e0', + released_to_students: true, + release_date: 'Feb 05, 2013 at 05:00 UTC', + visibility_state: 'live', + has_explicit_staff_lock: false, + start: '2013-02-05T05:00:00Z', + graded: false, + due_date: '', + due: null, + relative_weeks_due: null, + format: null, + course_graders: [ + 'Homework', + 'Exam', + ], + has_changes: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatory_message: null, + group_access: {}, + user_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + show_correctness: 'always', + discussion_enabled: true, + ancestor_has_staff_lock: false, + user_partition_info: { + selectable_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selected_partition_index: -1, + selected_groups_label: '', + }, + enable_copy_paste_units: false, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@e3601c0abee6427d8c17e6d6f8fdddd1', + display_name: 'Interactive Questions', + category: 'vertical', + has_children: true, + edited_on: 'Dec 28, 2023 at 10:00 UTC', + published: true, + published_on: 'Dec 28, 2023 at 10:00 UTC', + studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@e3601c0abee6427d8c17e6d6f8fdddd1', + released_to_students: true, + release_date: 'Feb 05, 2013 at 05:00 UTC', + visibility_state: 'live', + has_explicit_staff_lock: false, + start: '2013-02-05T05:00:00Z', + graded: false, + due_date: '', + due: null, + relative_weeks_due: null, + format: null, + course_graders: [ + 'Homework', + 'Exam', + ], + has_changes: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatory_message: null, + group_access: {}, + user_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + show_correctness: 'always', + discussion_enabled: true, + ancestor_has_staff_lock: false, + user_partition_info: { + selectable_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selected_partition_index: -1, + selected_groups_label: '', + }, + enable_copy_paste_units: false, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@a79d59cd72034188a71d388f4954a606', + display_name: 'Exciting Labs and Tools', + category: 'vertical', + has_children: true, + edited_on: 'Dec 28, 2023 at 10:00 UTC', + published: true, + published_on: 'Dec 28, 2023 at 10:00 UTC', + studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@a79d59cd72034188a71d388f4954a606', + released_to_students: true, + release_date: 'Feb 05, 2013 at 05:00 UTC', + visibility_state: 'live', + has_explicit_staff_lock: false, + start: '2013-02-05T05:00:00Z', + graded: false, + due_date: '', + due: null, + relative_weeks_due: null, + format: null, + course_graders: [ + 'Homework', + 'Exam', + ], + has_changes: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatory_message: null, + group_access: {}, + user_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + show_correctness: 'always', + discussion_enabled: true, + ancestor_has_staff_lock: false, + user_partition_info: { + selectable_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selected_partition_index: -1, + selected_groups_label: '', + }, + enable_copy_paste_units: false, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@134df56c516a4a0dbb24dd5facef746e', + display_name: 'Reading Assignments', + category: 'vertical', + has_children: true, + edited_on: 'Dec 28, 2023 at 10:00 UTC', + published: true, + published_on: 'Dec 28, 2023 at 10:00 UTC', + studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@134df56c516a4a0dbb24dd5facef746e', + released_to_students: true, + release_date: 'Feb 05, 2013 at 05:00 UTC', + visibility_state: 'live', + has_explicit_staff_lock: false, + start: '2013-02-05T05:00:00Z', + graded: false, + due_date: '', + due: null, + relative_weeks_due: null, + format: null, + course_graders: [ + 'Homework', + 'Exam', + ], + has_changes: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatory_message: null, + group_access: {}, + user_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + show_correctness: 'always', + discussion_enabled: true, + ancestor_has_staff_lock: false, + user_partition_info: { + selectable_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selected_partition_index: -1, + selected_groups_label: '', + }, + enable_copy_paste_units: false, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@d91b9e5d8bc64d57a1332d06bf2f2193', + display_name: 'When Are Your Exams? ', + category: 'vertical', + has_children: true, + edited_on: 'Dec 28, 2023 at 10:00 UTC', + published: true, + published_on: 'Dec 28, 2023 at 10:00 UTC', + studio_url: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@d91b9e5d8bc64d57a1332d06bf2f2193', + released_to_students: true, + release_date: 'Feb 05, 2013 at 05:00 UTC', + visibility_state: 'live', + has_explicit_staff_lock: false, + start: '2013-02-05T05:00:00Z', + graded: false, + due_date: '', + due: null, + relative_weeks_due: null, + format: null, + course_graders: [ + 'Homework', + 'Exam', + ], + has_changes: false, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatory_message: null, + group_access: {}, + user_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + show_correctness: 'always', + discussion_enabled: true, + ancestor_has_staff_lock: false, + user_partition_info: { + selectable_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selected_partition_index: -1, + selected_groups_label: '', + }, + enable_copy_paste_units: false, + }, + ], + }, + ancestor_has_staff_lock: false, + user_partition_info: { + selectable_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selected_partition_index: -1, + selected_groups_label: '', + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations', + display_name: 'Example Week 1: Getting Started', + category: 'chapter', + has_children: true, + edited_on: 'Jan 04, 2024 at 10:32 UTC', + published: true, + published_on: 'Dec 28, 2023 at 10:00 UTC', + studio_url: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40interactive_demonstrations', + released_to_students: true, + release_date: 'Feb 05, 2013 at 05:00 UTC', + visibility_state: 'live', + has_explicit_staff_lock: false, + start: '2013-02-05T05:00:00Z', + graded: false, + due_date: '', + due: null, + relative_weeks_due: null, + format: null, + course_graders: [ + 'Homework', + 'Exam', + ], + has_changes: null, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatory_message: null, + group_access: {}, + user_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + show_correctness: 'always', + highlights: [], + highlights_enabled: true, + highlights_preview_only: false, + highlights_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages', + ancestor_has_staff_lock: false, + user_partition_info: { + selectable_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selected_partition_index: -1, + selected_groups_label: '', + }, + }, + { + id: 'block-v1:edX+DemoX+Demo_Course+type@course+block@course', + display_name: 'Demonstration Course', + category: 'course', + has_children: true, + unit_level_discussions: false, + edited_on: 'Jan 08, 2024 at 16:39 UTC', + published: true, + published_on: 'Jan 08, 2024 at 16:39 UTC', + studio_url: '/course/course-v1:edX+DemoX+Demo_Course', + released_to_students: true, + release_date: 'Feb 05, 2013 at 05:00 UTC', + visibility_state: null, + has_explicit_staff_lock: false, + start: '2013-02-05T05:00:00Z', + graded: false, + due_date: '', + due: null, + relative_weeks_due: null, + format: null, + course_graders: [ + 'Homework', + 'Exam', + ], + has_changes: null, + actions: { + deletable: true, + draggable: true, + childAddable: true, + duplicable: true, + }, + explanatory_message: null, + group_access: {}, + user_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + show_correctness: 'always', + highlights_enabled_for_messaging: false, + highlights_enabled: true, + highlights_preview_only: false, + highlights_doc_url: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages', + enable_proctored_exams: false, + create_zendesk_tickets: true, + enable_timed_exams: true, + ancestor_has_staff_lock: false, + user_partition_info: { + selectable_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selected_partition_index: -1, + selected_groups_label: '', + }, + }, + ], + }, + ancestor_has_staff_lock: false, + user_partition_info: { + selectable_partitions: [ + { + id: 50, + name: 'Enrollment Track Groups', + scheme: 'enrollment_track', + groups: [ + { + id: 2, + name: 'Verified Certificate', + selected: false, + deleted: false, + }, + { + id: 1, + name: 'Audit', + selected: false, + deleted: false, + }, + ], + }, + ], + selected_partition_index: -1, + selected_groups_label: '', + }, + enable_copy_paste_units: false, + edited_by: 'edx', + published_by: null, + currently_visible_to_students: true, + has_partition_group_components: false, + release_date_from: 'Section "Example Week 1: Getting Started"', + staff_lock_from: null, + }, + draft_preview_link: '//preview.localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@vertical+block@867dddb6f55d410caaa9c1eb9c6743ec', + published_preview_link: '//localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@vertical+block@867dddb6f55d410caaa9c1eb9c6743ec', + show_unit_tags: false, + user_clipboard: { + content: null, + source_usage_key: '', + source_context_title: '', + source_edit_url: '', + }, + is_fullwidth_content: false, + assets_url: '/assets/course-v1:edX+DemoX+Demo_Course/', + unit_block_id: '867dddb6f55d410caaa9c1eb9c6743ec', + subsection_location: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5', +}; diff --git a/src/course-unit/__mocks__/index.js b/src/course-unit/__mocks__/index.js index ebf520684..a6f44a81d 100644 --- a/src/course-unit/__mocks__/index.js +++ b/src/course-unit/__mocks__/index.js @@ -1,2 +1,3 @@ -/* eslint-disable import/prefer-default-export */ export { default as courseUnitIndexMock } from './courseUnitIndex'; +export { default as courseSectionVerticalMock } from './courseSectionVertical'; +export { default as courseCreateXblockMock } from './courseCreateXblock'; diff --git a/src/course-unit/add-component/AddComponent.jsx b/src/course-unit/add-component/AddComponent.jsx new file mode 100644 index 000000000..dd24e4223 --- /dev/null +++ b/src/course-unit/add-component/AddComponent.jsx @@ -0,0 +1,64 @@ +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Button } from '@edx/paragon'; + +import { getCourseSectionVertical } from '../data/selectors'; +import { COMPONENT_ICON_TYPES } from '../constants'; +import ComponentIcon from './ComponentIcon'; +import messages from './messages'; + +const AddComponent = ({ blockId, handleCreateNewCourseXblock }) => { + const navigate = useNavigate(); + const intl = useIntl(); + const { componentTemplates } = useSelector(getCourseSectionVertical); + + const handleCreateNewXblock = (type) => () => { + switch (type) { + case COMPONENT_ICON_TYPES.discussion: + case COMPONENT_ICON_TYPES.dragAndDrop: + handleCreateNewCourseXblock({ type, parentLocator: blockId }); + break; + case COMPONENT_ICON_TYPES.problem: + case COMPONENT_ICON_TYPES.video: + handleCreateNewCourseXblock({ type, parentLocator: blockId }, ({ courseKey, locator }) => { + navigate(`/course/${courseKey}/editor/${type}/${locator}`); + }); + break; + default: + } + }; + + if (!Object.keys(componentTemplates).length) { + return null; + } + + return ( +
+
{intl.formatMessage(messages.title)}
+
    + {Object.keys(componentTemplates).map((component) => ( +
  • + +
  • + ))} +
+
+ ); +}; + +AddComponent.propTypes = { + blockId: PropTypes.string.isRequired, + handleCreateNewCourseXblock: PropTypes.func.isRequired, +}; + +export default AddComponent; diff --git a/src/course-unit/add-component/AddComponent.scss b/src/course-unit/add-component/AddComponent.scss new file mode 100644 index 000000000..aba0a04e1 --- /dev/null +++ b/src/course-unit/add-component/AddComponent.scss @@ -0,0 +1,12 @@ +.course-unit { + .new-component-type { + gap: .75rem; + } + + .add-component-button { + @include pgn-box-shadow(1, "down"); + + width: 11.63rem; + height: 6.875rem; + } +} diff --git a/src/course-unit/add-component/AddComponent.test.jsx b/src/course-unit/add-component/AddComponent.test.jsx new file mode 100644 index 000000000..44befb33d --- /dev/null +++ b/src/course-unit/add-component/AddComponent.test.jsx @@ -0,0 +1,165 @@ +import MockAdapter from 'axios-mock-adapter'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { initializeMockApp } from '@edx/frontend-platform'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import initializeStore from '../../store'; +import { executeThunk } from '../../utils'; +import { fetchCourseSectionVerticalData } from '../data/thunk'; +import { getCourseSectionVerticalApiUrl } from '../data/api'; +import { courseSectionVerticalMock } from '../__mocks__'; +import AddComponent from './AddComponent'; +import messages from './messages'; + +let store; +let axiosMock; +const blockId = '123'; +const handleCreateNewCourseXblockMock = jest.fn(); + +const renderComponent = (props) => render( + + + + + , +); + +describe('', () => { + beforeEach(async () => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, courseSectionVerticalMock); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + }); + + it('render AddComponent component correctly', () => { + const { getByRole } = renderComponent(); + const componentTemplates = courseSectionVerticalMock.component_templates; + + expect(getByRole('heading', { name: messages.title.defaultMessage })).toBeInTheDocument(); + Object.keys(componentTemplates).map((component) => ( + expect(getByRole('button', { + name: new RegExp(`${messages.buttonText.defaultMessage} ${componentTemplates[component].display_name}`, 'i'), + })).toBeInTheDocument() + )); + }); + + it('doesn\'t render AddComponent component when there aren\'t componentTemplates', async () => { + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + component_templates: [], + }); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + + const { queryByRole } = renderComponent(); + + expect(queryByRole('heading', { name: messages.title.defaultMessage })).not.toBeInTheDocument(); + }); + + it('does\'t call handleCreateNewCourseXblock with custom component create button is clicked', async () => { + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + component_templates: [ + { + type: 'custom', + templates: [], + display_name: 'Custom', + support_legend: {}, + }, + ], + }); + await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + + const { getByRole } = renderComponent(); + + const customComponentButton = getByRole('button', { + name: new RegExp(`${messages.buttonText.defaultMessage} Custom`, 'i'), + }); + + userEvent.click(customComponentButton); + expect(handleCreateNewCourseXblockMock).not.toHaveBeenCalled(); + }); + + it('calls handleCreateNewCourseXblock with correct parameters when Discussion xblock create button is clicked', () => { + const { getByRole } = renderComponent(); + + const discussionButton = getByRole('button', { + name: new RegExp(`${messages.buttonText.defaultMessage} Discussion`, 'i'), + }); + + userEvent.click(discussionButton); + expect(handleCreateNewCourseXblockMock).toHaveBeenCalled(); + expect(handleCreateNewCourseXblockMock).toHaveBeenCalledWith({ + parentLocator: '123', + type: 'discussion', + }); + }); + + it('calls handleCreateNewCourseXblock with correct parameters when Drag-and-Drop xblock create button is clicked', () => { + const { getByRole } = renderComponent(); + + const discussionButton = getByRole('button', { + name: new RegExp(`${messages.buttonText.defaultMessage} Drag and Drop`, 'i'), + }); + + userEvent.click(discussionButton); + expect(handleCreateNewCourseXblockMock).toHaveBeenCalled(); + expect(handleCreateNewCourseXblockMock).toHaveBeenCalledWith({ + parentLocator: '123', + type: 'drag-and-drop-v2', + }); + }); + + it('calls handleCreateNewCourseXblock with correct parameters when Problem xblock create button is clicked', () => { + const { getByRole } = renderComponent(); + + const discussionButton = getByRole('button', { + name: new RegExp(`${messages.buttonText.defaultMessage} Problem`, 'i'), + }); + + userEvent.click(discussionButton); + expect(handleCreateNewCourseXblockMock).toHaveBeenCalled(); + expect(handleCreateNewCourseXblockMock).toHaveBeenCalledWith({ + parentLocator: '123', + type: 'problem', + }, expect.any(Function)); + }); + + it('calls handleCreateNewCourseXblock with correct parameters when Video xblock create button is clicked', () => { + const { getByRole } = renderComponent(); + + const discussionButton = getByRole('button', { + name: new RegExp(`${messages.buttonText.defaultMessage} Video`, 'i'), + }); + + userEvent.click(discussionButton); + expect(handleCreateNewCourseXblockMock).toHaveBeenCalled(); + expect(handleCreateNewCourseXblockMock).toHaveBeenCalledWith({ + parentLocator: '123', + type: 'video', + }, expect.any(Function)); + }); +}); diff --git a/src/course-unit/add-component/ComponentIcon.jsx b/src/course-unit/add-component/ComponentIcon.jsx new file mode 100644 index 000000000..a1de9ac11 --- /dev/null +++ b/src/course-unit/add-component/ComponentIcon.jsx @@ -0,0 +1,17 @@ +import PropTypes from 'prop-types'; +import { Icon } from '@edx/paragon'; +import { EditNote as EditNoteIcon } from '@edx/paragon/icons'; + +import { COMPONENT_TYPE_ICON_MAP, COMPONENT_ICON_TYPES } from '../constants'; + +const ComponentIcon = ({ type }) => { + const icon = COMPONENT_TYPE_ICON_MAP[type] || EditNoteIcon; + + return ; +}; + +ComponentIcon.propTypes = { + type: PropTypes.oneOf(Object.values(COMPONENT_ICON_TYPES)).isRequired, +}; + +export default ComponentIcon; diff --git a/src/course-unit/add-component/messages.js b/src/course-unit/add-component/messages.js new file mode 100644 index 000000000..94e6bf483 --- /dev/null +++ b/src/course-unit/add-component/messages.js @@ -0,0 +1,14 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + title: { + id: 'course-authoring.course-unit.add.component.title', + defaultMessage: 'Add a new component', + }, + buttonText: { + id: 'course-authoring.course-unit.add.component.button.text', + defaultMessage: 'Add Component:', + }, +}); + +export default messages; diff --git a/src/course-unit/constants.js b/src/course-unit/constants.js index c35d980d1..7427db238 100644 --- a/src/course-unit/constants.js +++ b/src/course-unit/constants.js @@ -1,13 +1,31 @@ import { + BackHand as BackHandIcon, BookOpen as BookOpenIcon, Edit as EditIcon, + EditNote as EditNoteIcon, FormatListBulleted as FormatListBulletedIcon, + HelpOutline as HelpOutlineIcon, + LibraryAdd as LibraryIcon, Lock as LockIcon, + QuestionAnswerOutline as QuestionAnswerOutlineIcon, + Science as ScienceIcon, + TextFields as TextFieldsIcon, VideoCamera as VideoCameraIcon, } from '@edx/paragon/icons'; export const UNIT_ICON_TYPES = ['video', 'other', 'vertical', 'problem', 'lock']; +export const COMPONENT_ICON_TYPES = { + advanced: 'advanced', + discussion: 'discussion', + library: 'library', + html: 'html', + openassessment: 'openassessment', + problem: 'problem', + video: 'video', + dragAndDrop: 'drag-and-drop-v2', +}; + export const TYPE_ICONS_MAP = { video: VideoCameraIcon, other: BookOpenIcon, @@ -15,3 +33,14 @@ export const TYPE_ICONS_MAP = { problem: EditIcon, lock: LockIcon, }; + +export const COMPONENT_TYPE_ICON_MAP = { + [COMPONENT_ICON_TYPES.advanced]: ScienceIcon, + [COMPONENT_ICON_TYPES.discussion]: QuestionAnswerOutlineIcon, + [COMPONENT_ICON_TYPES.library]: LibraryIcon, + [COMPONENT_ICON_TYPES.html]: TextFieldsIcon, + [COMPONENT_ICON_TYPES.openassessment]: EditNoteIcon, + [COMPONENT_ICON_TYPES.problem]: HelpOutlineIcon, + [COMPONENT_ICON_TYPES.video]: VideoCameraIcon, + [COMPONENT_ICON_TYPES.dragAndDrop]: BackHandIcon, +}; diff --git a/src/course-unit/data/api.js b/src/course-unit/data/api.js index 47be48869..6352c121d 100644 --- a/src/course-unit/data/api.js +++ b/src/course-unit/data/api.js @@ -14,6 +14,7 @@ const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL; const getLmsBaseUrl = () => getConfig().LMS_BASE_URL; export const getCourseUnitApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/container/${itemId}`; +export const postXBlockBaseApiUrl = () => `${getStudioBaseUrl()}/xblock/`; export const getXBlockBaseApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/${itemId}`; export const getCourseSectionVerticalApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container_handler/${itemId}`; export const getSequenceMetadataApiUrl = (sequenceId) => `${getLmsBaseUrl()}/api/courseware/sequence/${sequenceId}`; @@ -112,3 +113,15 @@ export async function getCourseHomeCourseMetadata(courseId, rootSlug) { return normalizeCourseHomeCourseMetadata(data, rootSlug); } + +export async function createCourseXblock({ type, category, parentLocator }) { + const body = { + type, + category: category || type, + parent_locator: parentLocator, + }; + const { data } = await getAuthenticatedHttpClient() + .post(postXBlockBaseApiUrl(), body); + + return data; +} diff --git a/src/course-unit/data/slice.js b/src/course-unit/data/slice.js index c80fa02b3..870870402 100644 --- a/src/course-unit/data/slice.js +++ b/src/course-unit/data/slice.js @@ -67,6 +67,12 @@ const slice = createSlice({ courseSectionVerticalLoadingStatus: payload.status, }; }, + updateLoadingCourseXblockStatus: (state, { payload }) => { + state.loadingStatus = { + ...state.loadingStatus, + createUnitXblockLoadingStatus: payload.status, + }; + }, }, }); @@ -84,6 +90,7 @@ export const { fetchCourseDenied, fetchCourseSectionVerticalDataSuccess, updateLoadingCourseSectionVerticalDataStatus, + updateLoadingCourseXblockStatus, } = slice.actions; export const { diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js index 883c53b99..6b3cc611f 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -14,7 +14,10 @@ import { editUnitDisplayName, getSequenceMetadata, getCourseMetadata, - getLearningSequencesOutline, getCourseHomeCourseMetadata, getCourseSectionVerticalData, + getLearningSequencesOutline, + getCourseHomeCourseMetadata, + getCourseSectionVerticalData, + createCourseXblock, } from './api'; import { updateLoadingCourseUnitStatus, @@ -29,6 +32,7 @@ import { fetchCourseFailure, fetchCourseSectionVerticalDataSuccess, updateLoadingCourseSectionVerticalDataStatus, + updateLoadingCourseXblockStatus, } from './slice'; export function fetchCourseUnitQuery(courseId) { @@ -211,3 +215,29 @@ export function fetchCourse(courseId) { }); }; } + +export function createNewCourseXblock(body, callback) { + return async (dispatch) => { + dispatch(updateLoadingCourseXblockStatus({ status: RequestStatus.IN_PROGRESS })); + dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.adding)); + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + + try { + await createCourseXblock(body).then(async (result) => { + if (result) { + // ToDo: implement fetching (update) xblocks after success creating + dispatch(hideProcessingNotification()); + dispatch(updateLoadingCourseXblockStatus({ status: RequestStatus.SUCCESSFUL })); + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + if (callback) { + callback(result); + } + } + }); + } catch (error) { + dispatch(hideProcessingNotification()); + dispatch(updateLoadingCourseXblockStatus({ status: RequestStatus.FAILED })); + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } + }; +} diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index 3ecb42a28..905be41d7 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom'; import { RequestStatus } from '../data/constants'; import { + createNewCourseXblock, fetchCourseUnitQuery, editCourseItemQuery, fetchSequence, @@ -66,6 +67,10 @@ export const useCourseUnit = ({ courseId, blockId }) => { } }; + const handleCreateNewCourseXblock = (body, callback) => ( + dispatch(createNewCourseXblock(body, callback)) + ); + useEffect(() => { dispatch(fetchCourseUnitQuery(blockId)); dispatch(fetchCourseSectionVerticalData(blockId)); @@ -85,5 +90,6 @@ export const useCourseUnit = ({ courseId, blockId }) => { headerNavigationsActions, handleTitleEdit, handleTitleEditSubmit, + handleCreateNewCourseXblock, }; }; diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 393a9019a..0c24ccbd7 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -989,5 +989,7 @@ "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", "course-authoring.course-unit.sequence.no.content": "There is no content here.", - "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}", + "course-authoring.course-unit.add.component.title": "Add a new component", + "course-authoring.course-unit.add.component.button.text": "Add Component:" } diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 09c7da1eb..2500a1440 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -989,5 +989,7 @@ "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", "course-authoring.course-unit.sequence.no.content": "There is no content here.", - "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}", + "course-authoring.course-unit.add.component.title": "Add a new component", + "course-authoring.course-unit.add.component.button.text": "Add Component:" } diff --git a/src/i18n/messages/de_DE.json b/src/i18n/messages/de_DE.json index 0286fe7a0..de185d707 100644 --- a/src/i18n/messages/de_DE.json +++ b/src/i18n/messages/de_DE.json @@ -989,5 +989,7 @@ "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", "course-authoring.course-unit.sequence.no.content": "There is no content here.", - "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}", + "course-authoring.course-unit.add.component.title": "Add a new component", + "course-authoring.course-unit.add.component.button.text": "Add Component:" } diff --git a/src/i18n/messages/es_419.json b/src/i18n/messages/es_419.json index c59fa6849..7999fe94a 100644 --- a/src/i18n/messages/es_419.json +++ b/src/i18n/messages/es_419.json @@ -989,5 +989,7 @@ "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", "course-authoring.course-unit.sequence.no.content": "There is no content here.", - "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}", + "course-authoring.course-unit.add.component.title": "Add a new component", + "course-authoring.course-unit.add.component.button.text": "Add Component:" } diff --git a/src/i18n/messages/fa_IR.json b/src/i18n/messages/fa_IR.json index a8ef072f6..bafa9a9fb 100644 --- a/src/i18n/messages/fa_IR.json +++ b/src/i18n/messages/fa_IR.json @@ -12,5 +12,7 @@ "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", "course-authoring.course-unit.sequence.no.content": "There is no content here.", - "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}", + "course-authoring.course-unit.add.component.title": "Add a new component", + "course-authoring.course-unit.add.component.button.text": "Add Component:" } diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index e91037042..da68eb671 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -989,5 +989,7 @@ "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", "course-authoring.course-unit.sequence.no.content": "There is no content here.", - "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}", + "course-authoring.course-unit.add.component.title": "Add a new component", + "course-authoring.course-unit.add.component.button.text": "Add Component:" } diff --git a/src/i18n/messages/fr_CA.json b/src/i18n/messages/fr_CA.json index f3251fbbb..ca1e8b415 100644 --- a/src/i18n/messages/fr_CA.json +++ b/src/i18n/messages/fr_CA.json @@ -989,5 +989,7 @@ "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", "course-authoring.course-unit.sequence.no.content": "There is no content here.", - "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}", + "course-authoring.course-unit.add.component.title": "Add a new component", + "course-authoring.course-unit.add.component.button.text": "Add Component:" } diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 09c7da1eb..2500a1440 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -989,5 +989,7 @@ "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", "course-authoring.course-unit.sequence.no.content": "There is no content here.", - "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}", + "course-authoring.course-unit.add.component.title": "Add a new component", + "course-authoring.course-unit.add.component.button.text": "Add Component:" } diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 09c7da1eb..2500a1440 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -989,5 +989,7 @@ "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", "course-authoring.course-unit.sequence.no.content": "There is no content here.", - "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}", + "course-authoring.course-unit.add.component.title": "Add a new component", + "course-authoring.course-unit.add.component.button.text": "Add Component:" } diff --git a/src/i18n/messages/it_IT.json b/src/i18n/messages/it_IT.json index 21f4b2660..333f18723 100644 --- a/src/i18n/messages/it_IT.json +++ b/src/i18n/messages/it_IT.json @@ -989,5 +989,7 @@ "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", "course-authoring.course-unit.sequence.no.content": "There is no content here.", - "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}", + "course-authoring.course-unit.add.component.title": "Add a new component", + "course-authoring.course-unit.add.component.button.text": "Add Component:" } diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 09c7da1eb..2500a1440 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -989,5 +989,7 @@ "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", "course-authoring.course-unit.sequence.no.content": "There is no content here.", - "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}", + "course-authoring.course-unit.add.component.title": "Add a new component", + "course-authoring.course-unit.add.component.button.text": "Add Component:" } diff --git a/src/i18n/messages/pt_PT.json b/src/i18n/messages/pt_PT.json index b447c987f..c281896c8 100644 --- a/src/i18n/messages/pt_PT.json +++ b/src/i18n/messages/pt_PT.json @@ -989,5 +989,7 @@ "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", "course-authoring.course-unit.sequence.no.content": "There is no content here.", - "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}", + "course-authoring.course-unit.add.component.title": "Add a new component", + "course-authoring.course-unit.add.component.button.text": "Add Component:" } diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 09c7da1eb..2500a1440 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -989,5 +989,7 @@ "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", "course-authoring.course-unit.sequence.no.content": "There is no content here.", - "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}", + "course-authoring.course-unit.add.component.title": "Add a new component", + "course-authoring.course-unit.add.component.button.text": "Add Component:" } diff --git a/src/i18n/messages/uk.json b/src/i18n/messages/uk.json index 09c7da1eb..2500a1440 100644 --- a/src/i18n/messages/uk.json +++ b/src/i18n/messages/uk.json @@ -989,5 +989,7 @@ "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", "course-authoring.course-unit.sequence.no.content": "There is no content here.", - "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}", + "course-authoring.course-unit.add.component.title": "Add a new component", + "course-authoring.course-unit.add.component.button.text": "Add Component:" } diff --git a/src/i18n/messages/zh_CN.json b/src/i18n/messages/zh_CN.json index 09c7da1eb..2500a1440 100644 --- a/src/i18n/messages/zh_CN.json +++ b/src/i18n/messages/zh_CN.json @@ -989,5 +989,7 @@ "course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation", "course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.", "course-authoring.course-unit.sequence.no.content": "There is no content here.", - "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}" + "course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}", + "course-authoring.course-unit.add.component.title": "Add a new component", + "course-authoring.course-unit.add.component.button.text": "Add Component:" } diff --git a/src/utils.js b/src/utils.js index 8178e754e..fa6f30337 100644 --- a/src/utils.js +++ b/src/utils.js @@ -4,7 +4,7 @@ import { useMediaQuery } from 'react-responsive'; import * as Yup from 'yup'; import { snakeCase } from 'lodash/string'; import moment from 'moment'; -import { getConfig } from '@edx/frontend-platform'; +import { getConfig, getPath } from '@edx/frontend-platform'; import { RequestStatus } from './data/constants'; import { getCourseAppSettingValue, getLoadingStatus } from './pages-and-resources/data/selectors'; @@ -268,3 +268,22 @@ export const getFileSizeToClosestByte = (fileSize) => { const fileSizeFixedDecimal = Number.parseFloat(size).toFixed(2); return `${fileSizeFixedDecimal} ${units[divides]}`; }; + +/** + * Create a correct inner path depend on config PUBLIC_PATH. + * @param {string} checkPath - the internal route path that is validated + * @returns {string} - the correct internal route path + */ +export const createCorrectInternalRoute = (checkPath) => { + let basePath = getPath(getConfig().PUBLIC_PATH); + + if (basePath.endsWith('/')) { + basePath = basePath.slice(0, -1); + } + + if (!checkPath.startsWith(basePath)) { + return `${basePath}${checkPath}`; + } + + return checkPath; +}; diff --git a/src/utils.test.js b/src/utils.test.js index 3c0ddabf7..e4aada849 100644 --- a/src/utils.test.js +++ b/src/utils.test.js @@ -1,4 +1,12 @@ -import { getFileSizeToClosestByte } from './utils'; +import { getConfig, getPath } from '@edx/frontend-platform'; + +import { getFileSizeToClosestByte, createCorrectInternalRoute } from './utils'; + +jest.mock('@edx/frontend-platform', () => ({ + getConfig: jest.fn(), + ensureConfig: jest.fn(), + getPath: jest.fn(), +})); describe('FilesAndUploads utils', () => { describe('getFileSizeToClosestByte', () => { @@ -33,4 +41,40 @@ describe('FilesAndUploads utils', () => { expect(expectedSize).toEqual(actualSize); }); }); + describe('createCorrectInternalRoute', () => { + beforeEach(() => { + getConfig.mockReset(); + getPath.mockReset(); + }); + + it('returns the correct internal route when checkPath is not prefixed with basePath', () => { + getConfig.mockReturnValue({ PUBLIC_PATH: 'example.com' }); + getPath.mockReturnValue('/'); + + const checkPath = '/some/path'; + const result = createCorrectInternalRoute(checkPath); + + expect(result).toBe('/some/path'); + }); + + it('returns the input checkPath when it is already prefixed with basePath', () => { + getConfig.mockReturnValue({ PUBLIC_PATH: 'example.com' }); + getPath.mockReturnValue('/course-authoring'); + + const checkPath = '/course-authoring/some/path'; + const result = createCorrectInternalRoute(checkPath); + + expect(result).toBe('/course-authoring/some/path'); + }); + + it('handles basePath ending with a slash correctly', () => { + getConfig.mockReturnValue({ PUBLIC_PATH: 'example.com/' }); + getPath.mockReturnValue('/course-authoring/'); + + const checkPath = '/some/path'; + const result = createCorrectInternalRoute(checkPath); + + expect(result).toBe('/course-authoring/some/path'); + }); + }); }); From 60c1a0343cd03cd7b82c4028e43a4bafc312133e Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 14 Feb 2024 03:02:32 +0530 Subject: [PATCH 08/11] feat: proctoring & prerequisite settings and page alerts (#816) * feat: add proctoring exam link to actions * feat: prerequisite settings in advanced tab * refactor: use formik for configuration modal in outline * feat: proctoring exam settings in subsection configuration test: prereq & proctoring settings * feat: outline alerts test: outline page alerts * refactor: replace highlights badge with bubble * feat: discussion badge in outline * refactor: status bar style and date format * Fix spacing between checklist and highlights button * Fix title alignment in status bar * Align learn more link to center with respect to button * Update start date display in local format * fix: unit url * refactor: redesign item header * move status to end of card * move edit icon next to title * make it visible on hover * test: improve coverage * refactor: update messages and alert colors --- .gitignore | 3 + src/course-outline/CourseOutline.jsx | 31 +- src/course-outline/CourseOutline.test.jsx | 598 ++++++++++++++++-- .../__mocks__/courseOutlineIndex.js | 16 +- .../card-header/BaseTitleWithStatusBadge.jsx | 43 -- src/course-outline/card-header/CardHeader.jsx | 59 +- .../card-header/CardHeader.scss | 32 +- .../card-header/CardHeader.test.jsx | 110 ++-- src/course-outline/card-header/CardStatus.jsx | 40 ++ .../card-header/StatusBadge.jsx | 42 ++ .../card-header/TitleButton.jsx | 16 +- src/course-outline/card-header/TitleLink.jsx | 12 +- src/course-outline/card-header/messages.js | 12 + .../configure-modal/AdvancedTab.jsx | 210 +++++- .../configure-modal/BasicTab.jsx | 66 +- .../configure-modal/ConfigureModal.jsx | 339 +++++----- .../configure-modal/ConfigureModal.scss | 10 +- .../configure-modal/PrereqSettings.jsx | 117 ++++ .../configure-modal/UnitTab.jsx | 90 +-- .../configure-modal/VisibilityTab.jsx | 60 +- .../configure-modal/messages.js | 98 ++- src/course-outline/data/api.js | 64 +- src/course-outline/data/selectors.js | 1 + src/course-outline/data/slice.js | 2 + src/course-outline/data/thunk.js | 47 +- src/course-outline/hooks.jsx | 30 +- src/course-outline/messages.js | 4 - src/course-outline/page-alerts/PageAlerts.jsx | 283 +++++++++ .../page-alerts/PageAlerts.test.jsx | 155 +++++ src/course-outline/page-alerts/messages.js | 62 ++ .../section-card/SectionCard.jsx | 16 +- .../section-card/SectionCard.scss | 8 - src/course-outline/status-bar/StatusBar.jsx | 61 +- .../status-bar/StatusBar.test.jsx | 6 +- .../subsection-card/SubsectionCard.jsx | 14 +- .../subsection-card/SubsectionCard.scss | 4 - src/course-outline/unit-card/UnitCard.jsx | 31 +- src/course-outline/unit-card/UnitCard.scss | 4 - src/generic/AlertProctoringError.jsx | 5 +- src/generic/FormikControl.jsx | 5 +- 40 files changed, 2195 insertions(+), 611 deletions(-) delete mode 100644 src/course-outline/card-header/BaseTitleWithStatusBadge.jsx create mode 100644 src/course-outline/card-header/CardStatus.jsx create mode 100644 src/course-outline/card-header/StatusBadge.jsx create mode 100644 src/course-outline/configure-modal/PrereqSettings.jsx create mode 100644 src/course-outline/page-alerts/PageAlerts.jsx create mode 100644 src/course-outline/page-alerts/PageAlerts.test.jsx create mode 100644 src/course-outline/page-alerts/messages.js diff --git a/.gitignore b/.gitignore index 7ba8a0e7f..9770f7309 100755 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ temp/babel-plugin-react-intl /temp /.vscode /module.config.js + +# Local environment overrides +.env.private diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx index 020cc0a42..429d0e410 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -15,10 +15,7 @@ import { Warning as WarningIcon, } from '@edx/paragon/icons'; import { useSelector } from 'react-redux'; -import { - DraggableList, - ErrorAlert, -} from '@edx/frontend-lib-content-components'; +import { DraggableList } from '@edx/frontend-lib-content-components'; import { arrayMove } from '@dnd-kit/sortable'; import { LoadingSpinner } from '../generic/Loading'; @@ -41,6 +38,7 @@ import EmptyPlaceholder from './empty-placeholder/EmptyPlaceholder'; import PublishModal from './publish-modal/PublishModal'; import ConfigureModal from './configure-modal/ConfigureModal'; import DeleteModal from './delete-modal/DeleteModal'; +import PageAlerts from './page-alerts/PageAlerts'; import { useCourseOutline } from './hooks'; import messages from './messages'; @@ -97,6 +95,15 @@ const CourseOutline = ({ courseId }) => { handleUnitDragAndDrop, handleCopyToClipboardClick, handlePasteClipboardClick, + notificationDismissUrl, + discussionsSettings, + discussionsIncontextFeedbackUrl, + discussionsIncontextLearnmoreUrl, + deprecatedBlocksInfo, + proctoringErrors, + mfeProctoredExamSettingsUrl, + handleDismissNotification, + advanceSettingsUrl, } = useCourseOutline({ courseId }); const [sections, setSections] = useState(sectionsList); @@ -250,9 +257,18 @@ const CourseOutline = ({ courseId }) => {
- - {intl.formatMessage(messages.alertFailedGeneric, { actionName: 'save', type: 'changes' })} - + {showSuccessAlert ? ( { subsection.childInfo.children, )} onCopyToClipboardClick={handleCopyToClipboardClick} + discussionsSettings={discussionsSettings} /> ))} diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.jsx index 6688b29e9..3c2dc7649 100644 --- a/src/course-outline/CourseOutline.test.jsx +++ b/src/course-outline/CourseOutline.test.jsx @@ -1,11 +1,12 @@ import { - act, render, waitFor, cleanup, fireEvent, within, + act, render, waitFor, fireEvent, within, } 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 { cloneDeep } from 'lodash'; import { getCourseBestPracticesApiUrl, @@ -45,6 +46,7 @@ import statusBarMessages from './status-bar/messages'; import configureModalMessages from './configure-modal/messages'; import pasteButtonMessages from './paste-button/messages'; import subsectionMessages from './subsection-card/messages'; +import pageAlertMessages from './page-alerts/messages'; let axiosMock; let store; @@ -69,6 +71,13 @@ jest.mock('../help-urls/hooks', () => ({ }), })); +jest.mock('@edx/frontend-platform/i18n', () => ({ + ...jest.requireActual('@edx/frontend-platform/i18n'), + useIntl: () => ({ + formatMessage: (message) => message.defaultMessage, + }), +})); + const RootWrapper = () => ( @@ -118,7 +127,7 @@ describe('', () => { }); it('check video sharing option udpates correctly', async () => { - const { findByTestId } = render(); + const { findByLabelText } = render(); axiosMock .onPost(getCourseBlockApiUrl(courseId), { @@ -127,13 +136,10 @@ describe('', () => { }, }) .reply(200); - const optionDropdownWrapper = await findByTestId('video-sharing-wrapper'); - const optionDropdown = await within(optionDropdownWrapper).findByRole('button'); - await act(async () => fireEvent.click(optionDropdown)); - const allOffOption = await within(optionDropdownWrapper).findByText( - statusBarMessages.videoSharingAllOffText.defaultMessage, + const optionDropdown = await findByLabelText(statusBarMessages.videoSharingTitle.defaultMessage); + await act( + async () => fireEvent.change(optionDropdown, { target: { value: VIDEO_SHARING_OPTIONS.allOff } }), ); - await act(async () => fireEvent.click(allOffOption)); expect(axiosMock.history.post.length).toBe(1); expect(axiosMock.history.post[0].data).toBe(JSON.stringify({ @@ -144,7 +150,7 @@ describe('', () => { }); it('check video sharing option shows error on failure', async () => { - const { findByTestId, queryByRole } = render(); + const { findByLabelText, queryByRole } = render(); axiosMock .onPost(getCourseBlockApiUrl(courseId), { @@ -153,13 +159,10 @@ describe('', () => { }, }) .reply(500); - const optionDropdownWrapper = await findByTestId('video-sharing-wrapper'); - const optionDropdown = await within(optionDropdownWrapper).findByRole('button'); - await act(async () => fireEvent.click(optionDropdown)); - const allOffOption = await within(optionDropdownWrapper).findByText( - statusBarMessages.videoSharingAllOffText.defaultMessage, + const optionDropdown = await findByLabelText(statusBarMessages.videoSharingTitle.defaultMessage); + await act( + async () => fireEvent.change(optionDropdown, { target: { value: VIDEO_SHARING_OPTIONS.allOff } }), ); - await act(async () => fireEvent.click(allOffOption)); expect(axiosMock.history.post.length).toBe(1); expect(axiosMock.history.post[0].data).toBe(JSON.stringify({ @@ -168,7 +171,10 @@ describe('', () => { }, })); - expect(queryByRole('alert')).toBeInTheDocument(); + const alertElement = queryByRole('alert'); + expect(alertElement).toHaveTextContent( + pageAlertMessages.alertFailedGeneric.defaultMessage, + ); }); it('render error alert after failed reindex correctly', async () => { @@ -337,7 +343,6 @@ describe('', () => { }); it('render CourseOutline component without sections correctly', async () => { - cleanup(); axiosMock .onGet(getCourseOutlineIndexApiUrl(courseId)) .reply(200, courseOutlineIndexWithoutSections); @@ -349,6 +354,25 @@ describe('', () => { }); }); + it('render configuration alerts and check dismiss query', async () => { + axiosMock + .onGet(getCourseOutlineIndexApiUrl(courseId)) + .reply(200, { + ...courseOutlineIndexMock, + notificationDismissUrl: '/some/url', + }); + + const { findByRole } = render(); + expect(await findByRole('alert')).toBeInTheDocument(); + const dismissBtn = await findByRole('button', { name: 'Dismiss' }); + axiosMock + .onDelete('/some/url') + .reply(204); + fireEvent.click(dismissBtn); + + expect(axiosMock.history.delete.length).toBe(1); + }); + it('check edit title works for section, subsection and unit', async () => { const { findAllByTestId } = render(); const checkEditTitle = async (section, element, item, newName, elementName) => { @@ -545,7 +569,7 @@ describe('', () => { const checkPublishBtn = async (item, element, elementName) => { expect( - await within(element).findByTestId(`${elementName}-card-header__badge-status`), + (await within(element).getAllByRole('status'))[0], `Failed for ${elementName}!`, ).toHaveTextContent(cardHeaderMessages.statusBadgeDraft.defaultMessage); @@ -601,7 +625,7 @@ describe('', () => { await act(async () => fireEvent.click(confirmButton)); expect( - await within(element).findByTestId(`${elementName}-card-header__badge-status`), + (await within(element).getAllByRole('status'))[0], `Failed for ${elementName}!`, ).toHaveTextContent(cardHeaderMessages.statusBadgePublishedNotLive.defaultMessage); }; @@ -672,43 +696,43 @@ describe('', () => { findAllByTestId, findByTestId, } = render(); - const section = courseOutlineIndexMock.courseStructure.childInfo.children[0]; - const subsection = section.childInfo.children[0]; - const newReleaseDate = '2025-08-10T05:00:00Z'; - const newGraderType = 'Homework'; - const newDue = '2025-09-10T00:00:00Z'; - const isTimeLimited = true; - const defaultTimeLimitMinutes = 3270; + const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]); + const [subsection] = section.childInfo.children; + const expectedRequestData = { + publish: 'republish', + graderType: 'Homework', + isPrereq: false, + prereqMinScore: 100, + prereqMinCompletion: 100, + metadata: { + visible_to_staff_only: null, + due: '2025-09-10T05:00:00Z', + hide_after_due: true, + show_correctness: 'always', + is_practice_exam: false, + is_time_limited: true, + is_proctored_enabled: false, + exam_review_rules: '', + default_time_limit_minutes: 3270, + is_onboarding_exam: false, + start: '2025-08-10T00:00:00Z', + }, + }; axiosMock - .onPost(getCourseItemApiUrl(subsection.id), { - publish: 'republish', - graderType: newGraderType, - metadata: { - visible_to_staff_only: null, - due: newDue, - hide_after_due: false, - show_correctness: 'always', - is_practice_exam: false, - is_time_limited: isTimeLimited, - exam_review_rules: '', - is_proctored_enabled: false, - default_time_limit_minutes: defaultTimeLimitMinutes, - is_onboarding_exam: false, - start: newReleaseDate, - }, - }) + .onPost(getCourseItemApiUrl(subsection.id), expectedRequestData) .reply(200, { dummy: 'value' }); const [currentSection] = await findAllByTestId('section-card'); const [firstSubsection] = await within(currentSection).findAllByTestId('subsection-card'); const subsectionDropdownButton = await within(firstSubsection).findByTestId('subsection-card-header__menu-button'); - subsection.start = newReleaseDate; - subsection.due = newDue; - subsection.format = newGraderType; - subsection.isTimeLimited = isTimeLimited; - subsection.defaultTimeLimitMinutes = defaultTimeLimitMinutes; + subsection.start = expectedRequestData.metadata.start; + subsection.due = expectedRequestData.metadata.due; + subsection.format = expectedRequestData.graderType; + subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited; + subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes; + subsection.hideAfterDue = expectedRequestData.metadata.hideAfterDue; section.childInfo.children[0] = subsection; axiosMock .onGet(getXBlockApiUrl(section.id)) @@ -720,15 +744,25 @@ describe('', () => { // update fields let configureModal = await findByTestId('configure-modal'); - expect(await within(configureModal).findByText(newGraderType)).toBeInTheDocument(); + expect(await within(configureModal).findByText(expectedRequestData.graderType)).toBeInTheDocument(); let releaseDateStack = await within(configureModal).findByTestId('release-date-stack'); let releaseDatePicker = await within(releaseDateStack).findByPlaceholderText('MM/DD/YYYY'); fireEvent.change(releaseDatePicker, { target: { value: '08/10/2025' } }); + let releaseDateTimePicker = await within(releaseDateStack).findByPlaceholderText('HH:MM'); + fireEvent.change(releaseDateTimePicker, { target: { value: '00:00' } }); let dueDateStack = await within(configureModal).findByTestId('due-date-stack'); let dueDatePicker = await within(dueDateStack).findByPlaceholderText('MM/DD/YYYY'); fireEvent.change(dueDatePicker, { target: { value: '09/10/2025' } }); + let dueDateTimePicker = await within(dueDateStack).findByPlaceholderText('HH:MM'); + fireEvent.change(dueDateTimePicker, { target: { value: '05:00' } }); let graderTypeDropdown = await within(configureModal).findByTestId('grader-type-select'); - fireEvent.change(graderTypeDropdown, { target: { value: newGraderType } }); + fireEvent.change(graderTypeDropdown, { target: { value: expectedRequestData.graderType } }); + + // visibility tab + const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage }); + fireEvent.click(visibilityTab); + const visibilityRadioButtons = await within(configureModal).findAllByRole('radio'); + fireEvent.click(visibilityRadioButtons[1]); let advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }); fireEvent.click(advancedTab); @@ -742,23 +776,7 @@ describe('', () => { // verify request expect(axiosMock.history.post.length).toBe(1); - expect(axiosMock.history.post[0].data).toBe(JSON.stringify({ - publish: 'republish', - graderType: newGraderType, - metadata: { - visible_to_staff_only: null, - due: newDue, - hide_after_due: false, - show_correctness: 'always', - is_practice_exam: false, - is_time_limited: isTimeLimited, - exam_review_rules: '', - is_proctored_enabled: false, - default_time_limit_minutes: defaultTimeLimitMinutes, - is_onboarding_exam: false, - start: newReleaseDate, - }, - })); + expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData)); // reopen modal and check values await act(async () => fireEvent.click(subsectionDropdownButton)); @@ -768,11 +786,15 @@ describe('', () => { releaseDateStack = await within(configureModal).findByTestId('release-date-stack'); releaseDatePicker = await within(releaseDateStack).findByPlaceholderText('MM/DD/YYYY'); expect(releaseDatePicker).toHaveValue('08/10/2025'); + releaseDateTimePicker = await within(releaseDateStack).findByPlaceholderText('HH:MM'); + expect(releaseDateTimePicker).toHaveValue('00:00'); dueDateStack = await await within(configureModal).findByTestId('due-date-stack'); dueDatePicker = await within(dueDateStack).findByPlaceholderText('MM/DD/YYYY'); expect(dueDatePicker).toHaveValue('09/10/2025'); + dueDateTimePicker = await within(dueDateStack).findByPlaceholderText('HH:MM'); + expect(dueDateTimePicker).toHaveValue('05:00'); graderTypeDropdown = await within(configureModal).findByTestId('grader-type-select'); - expect(graderTypeDropdown).toHaveValue(newGraderType); + expect(graderTypeDropdown).toHaveValue(expectedRequestData.graderType); advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }); fireEvent.click(advancedTab); @@ -784,6 +806,444 @@ describe('', () => { expect(hours).toHaveValue('54:30'); }); + it('check prereq and proctoring settings in configure modal for subsection', async () => { + const { + findAllByTestId, + findByTestId, + } = render(); + const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]); + const [subsection, secondSubsection] = section.childInfo.children; + const expectedRequestData = { + publish: 'republish', + graderType: 'notgraded', + isPrereq: true, + prereqUsageKey: secondSubsection.id, + prereqMinScore: 80, + prereqMinCompletion: 90, + metadata: { + visible_to_staff_only: true, + due: '', + hide_after_due: false, + show_correctness: 'always', + is_practice_exam: false, + is_time_limited: true, + is_proctored_enabled: true, + exam_review_rules: 'some rules for proctored exams', + default_time_limit_minutes: 30, + is_onboarding_exam: false, + start: '1970-01-01T05:00:00Z', + }, + }; + + axiosMock + .onPost(getCourseItemApiUrl(subsection.id), expectedRequestData) + .reply(200, { dummy: 'value' }); + + const [currentSection] = await findAllByTestId('section-card'); + const [firstSubsection] = await within(currentSection).findAllByTestId('subsection-card'); + const subsectionDropdownButton = await within(firstSubsection).findByTestId('subsection-card-header__menu-button'); + + subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited; + subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes; + subsection.isProctoredExam = expectedRequestData.metadata.is_proctored_enabled; + subsection.isPracticeExam = expectedRequestData.metadata.is_practice_exam; + subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam; + subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules; + subsection.isPrereq = expectedRequestData.isPrereq; + subsection.prereq = expectedRequestData.prereqUsageKey; + subsection.prereqMinScore = expectedRequestData.prereqMinScore; + subsection.prereqMinCompletion = expectedRequestData.prereqMinCompletion; + section.childInfo.children[0] = subsection; + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, section); + + fireEvent.click(subsectionDropdownButton); + const configureBtn = await within(firstSubsection).findByTestId('subsection-card-header__menu-configure-button'); + fireEvent.click(configureBtn); + + // update fields + let configureModal = await findByTestId('configure-modal'); + let advancedTab = await within(configureModal).findByRole( + 'tab', + { name: configureModalMessages.advancedTabTitle.defaultMessage }, + ); + + // visibility tab + const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage }); + fireEvent.click(visibilityTab); + const visibilityRadioButtons = await within(configureModal).findAllByRole('radio'); + fireEvent.click(visibilityRadioButtons[2]); + + fireEvent.click(advancedTab); + let radioButtons = await within(configureModal).findAllByRole('radio'); + fireEvent.click(radioButtons[2]); + let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper'); + let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM'); + fireEvent.change(hours, { target: { value: '00:30' } }); + // select a prerequisite + const prereqSelect = await within(configureModal).findByRole('combobox'); + fireEvent.change(prereqSelect, { target: { value: expectedRequestData.prereqUsageKey } }); + + // update minimum score and completion percentage + let prereqMinScoreInput = await within(configureModal).findByLabelText( + configureModalMessages.minScoreLabel.defaultMessage, + ); + fireEvent.change(prereqMinScoreInput, { target: { value: expectedRequestData.prereqMinScore } }); + let prereqMinCompletionInput = await within(configureModal).findByLabelText( + configureModalMessages.minCompletionLabel.defaultMessage, + ); + fireEvent.change(prereqMinCompletionInput, { target: { value: expectedRequestData.prereqMinCompletion } }); + + // enable this subsection to be used as prerequisite by other subsections + let prereqCheckbox = await within(configureModal).findByLabelText( + configureModalMessages.prereqCheckboxLabel.defaultMessage, + ); + fireEvent.click(prereqCheckbox); + + // fill some rules for proctored exams + let examsRulesInput = await within(configureModal).findByLabelText( + configureModalMessages.reviewRulesLabel.defaultMessage, + ); + fireEvent.change(examsRulesInput, { target: { value: expectedRequestData.metadata.exam_review_rules } }); + + const saveButton = await within(configureModal).findByTestId('configure-save-button'); + await act(async () => fireEvent.click(saveButton)); + + // verify request + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData)); + + // reopen modal and check values + await act(async () => fireEvent.click(subsectionDropdownButton)); + await act(async () => fireEvent.click(configureBtn)); + + configureModal = await findByTestId('configure-modal'); + advancedTab = await within(configureModal).findByRole('tab', { + name: configureModalMessages.advancedTabTitle.defaultMessage, + }); + fireEvent.click(advancedTab); + radioButtons = await within(configureModal).findAllByRole('radio'); + expect(radioButtons[0]).toHaveProperty('checked', false); + expect(radioButtons[1]).toHaveProperty('checked', false); + expect(radioButtons[2]).toHaveProperty('checked', true); + hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper'); + hours = await within(hoursWrapper).findByPlaceholderText('HH:MM'); + expect(hours).toHaveValue('00:30'); + prereqCheckbox = await within(configureModal).findByLabelText( + configureModalMessages.prereqCheckboxLabel.defaultMessage, + ); + expect(prereqCheckbox).toBeChecked(); + const prereqSelectOption = await within(configureModal).findByRole('option', { selected: true }); + expect(prereqSelectOption).toHaveAttribute('value', expectedRequestData.prereqUsageKey); + examsRulesInput = await within(configureModal).findByLabelText( + configureModalMessages.reviewRulesLabel.defaultMessage, + ); + expect(examsRulesInput).toHaveTextContent(expectedRequestData.metadata.exam_review_rules); + + prereqMinScoreInput = await within(configureModal).findByLabelText( + configureModalMessages.minScoreLabel.defaultMessage, + ); + expect(prereqMinScoreInput).toHaveAttribute('value', `${expectedRequestData.prereqMinScore}`); + prereqMinCompletionInput = await within(configureModal).findByLabelText( + configureModalMessages.minCompletionLabel.defaultMessage, + ); + expect(prereqMinCompletionInput).toHaveAttribute('value', `${expectedRequestData.prereqMinCompletion}`); + }); + + it('check practice proctoring settings in configure modal', async () => { + const { + findAllByTestId, + findByTestId, + } = render(); + const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]); + const [subsection] = section.childInfo.children; + const expectedRequestData = { + publish: 'republish', + graderType: 'notgraded', + isPrereq: false, + prereqMinScore: 100, + prereqMinCompletion: 100, + metadata: { + visible_to_staff_only: null, + due: '', + hide_after_due: false, + show_correctness: 'never', + is_practice_exam: true, + is_time_limited: true, + is_proctored_enabled: true, + exam_review_rules: '', + default_time_limit_minutes: 30, + is_onboarding_exam: false, + start: '1970-01-01T05:00:00Z', + }, + }; + + axiosMock + .onPost(getCourseItemApiUrl(subsection.id), expectedRequestData) + .reply(200, { dummy: 'value' }); + + const [currentSection] = await findAllByTestId('section-card'); + const [firstSubsection] = await within(currentSection).findAllByTestId('subsection-card'); + const subsectionDropdownButton = await within(firstSubsection).findByTestId('subsection-card-header__menu-button'); + + subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited; + subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes; + subsection.isProctoredExam = expectedRequestData.metadata.is_proctored_enabled; + subsection.isPracticeExam = expectedRequestData.metadata.is_practice_exam; + subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam; + subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules; + section.childInfo.children[0] = subsection; + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, section); + + fireEvent.click(subsectionDropdownButton); + const configureBtn = await within(firstSubsection).findByTestId('subsection-card-header__menu-configure-button'); + fireEvent.click(configureBtn); + + // update fields + let configureModal = await findByTestId('configure-modal'); + let advancedTab = await within(configureModal).findByRole( + 'tab', + { name: configureModalMessages.advancedTabTitle.defaultMessage }, + ); + // visibility tab + const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage }); + fireEvent.click(visibilityTab); + const visibilityRadioButtons = await within(configureModal).findAllByRole('radio'); + fireEvent.click(visibilityRadioButtons[4]); + + // advancedTab + fireEvent.click(advancedTab); + let radioButtons = await within(configureModal).findAllByRole('radio'); + fireEvent.click(radioButtons[3]); + let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper'); + let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM'); + fireEvent.change(hours, { target: { value: '00:30' } }); + + // rules box should not be visible + expect(within(configureModal).queryByLabelText( + configureModalMessages.reviewRulesLabel.defaultMessage, + )).not.toBeInTheDocument(); + + const saveButton = await within(configureModal).findByTestId('configure-save-button'); + await act(async () => fireEvent.click(saveButton)); + + // verify request + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData)); + + // reopen modal and check values + await act(async () => fireEvent.click(subsectionDropdownButton)); + await act(async () => fireEvent.click(configureBtn)); + + configureModal = await findByTestId('configure-modal'); + advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }); + fireEvent.click(advancedTab); + radioButtons = await within(configureModal).findAllByRole('radio'); + expect(radioButtons[0]).toHaveProperty('checked', false); + expect(radioButtons[1]).toHaveProperty('checked', false); + expect(radioButtons[2]).toHaveProperty('checked', false); + expect(radioButtons[3]).toHaveProperty('checked', true); + hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper'); + hours = await within(hoursWrapper).findByPlaceholderText('HH:MM'); + expect(hours).toHaveValue('00:30'); + }); + + it('check onboarding proctoring settings in configure modal', async () => { + const { + findAllByTestId, + findByTestId, + } = render(); + const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]); + const [, subsection] = section.childInfo.children; + const expectedRequestData = { + publish: 'republish', + graderType: 'notgraded', + isPrereq: true, + prereqMinScore: 100, + prereqMinCompletion: 100, + metadata: { + visible_to_staff_only: null, + due: '', + hide_after_due: false, + show_correctness: 'past_due', + is_practice_exam: false, + is_time_limited: true, + is_proctored_enabled: true, + exam_review_rules: '', + default_time_limit_minutes: 30, + is_onboarding_exam: true, + start: '2013-02-05T05:00:00Z', + }, + }; + + axiosMock + .onPost(getCourseItemApiUrl(subsection.id), expectedRequestData) + .reply(200, { dummy: 'value' }); + + const [currentSection] = await findAllByTestId('section-card'); + const [, secondSubsection] = await within(currentSection).findAllByTestId('subsection-card'); + const subsectionDropdownButton = await within(secondSubsection).findByTestId('subsection-card-header__menu-button'); + + subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited; + subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes; + subsection.isProctoredExam = expectedRequestData.metadata.is_proctored_enabled; + subsection.isPracticeExam = expectedRequestData.metadata.is_practice_exam; + subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam; + subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules; + section.childInfo.children[1] = subsection; + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, section); + + fireEvent.click(subsectionDropdownButton); + const configureBtn = await within(secondSubsection).findByTestId('subsection-card-header__menu-configure-button'); + fireEvent.click(configureBtn); + + // update fields + let configureModal = await findByTestId('configure-modal'); + // visibility tab + const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage }); + fireEvent.click(visibilityTab); + const visibilityRadioButtons = await within(configureModal).findAllByRole('radio'); + fireEvent.click(visibilityRadioButtons[5]); + + // advancedTab + let advancedTab = await within(configureModal).findByRole( + 'tab', + { name: configureModalMessages.advancedTabTitle.defaultMessage }, + ); + fireEvent.click(advancedTab); + let radioButtons = await within(configureModal).findAllByRole('radio'); + fireEvent.click(radioButtons[3]); + let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper'); + let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM'); + fireEvent.change(hours, { target: { value: '00:30' } }); + + // rules box should not be visible + expect(within(configureModal).queryByLabelText( + configureModalMessages.reviewRulesLabel.defaultMessage, + )).not.toBeInTheDocument(); + + const saveButton = await within(configureModal).findByTestId('configure-save-button'); + await act(async () => fireEvent.click(saveButton)); + + // verify request + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData)); + + // reopen modal and check values + await act(async () => fireEvent.click(subsectionDropdownButton)); + await act(async () => fireEvent.click(configureBtn)); + + configureModal = await findByTestId('configure-modal'); + advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }); + fireEvent.click(advancedTab); + radioButtons = await within(configureModal).findAllByRole('radio'); + expect(radioButtons[0]).toHaveProperty('checked', false); + expect(radioButtons[1]).toHaveProperty('checked', false); + expect(radioButtons[2]).toHaveProperty('checked', false); + expect(radioButtons[3]).toHaveProperty('checked', true); + hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper'); + hours = await within(hoursWrapper).findByPlaceholderText('HH:MM'); + expect(hours).toHaveValue('00:30'); + }); + + it('check no special exam setting in configure modal', async () => { + const { + findAllByTestId, + findByTestId, + } = render(); + const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[1]); + const [subsection] = section.childInfo.children; + const expectedRequestData = { + publish: 'republish', + graderType: 'notgraded', + prereqMinScore: 100, + prereqMinCompletion: 100, + metadata: { + visible_to_staff_only: null, + due: '', + hide_after_due: false, + show_correctness: 'always', + is_practice_exam: false, + is_time_limited: false, + is_proctored_enabled: false, + exam_review_rules: '', + default_time_limit_minutes: 0, + is_onboarding_exam: false, + start: '1970-01-01T05:00:00Z', + }, + }; + + axiosMock + .onPost(getCourseItemApiUrl(subsection.id), expectedRequestData) + .reply(200, { dummy: 'value' }); + + const [, currentSection] = await findAllByTestId('section-card'); + const [subsectionElement] = await within(currentSection).findAllByTestId('subsection-card'); + const subsectionDropdownButton = await within(subsectionElement).findByTestId('subsection-card-header__menu-button'); + + subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited; + subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes; + subsection.isProctoredExam = expectedRequestData.metadata.is_proctored_enabled; + subsection.isPracticeExam = expectedRequestData.metadata.is_practice_exam; + subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam; + subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules; + section.childInfo.children[0] = subsection; + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, section); + + fireEvent.click(subsectionDropdownButton); + const configureBtn = await within(subsectionElement).findByTestId('subsection-card-header__menu-configure-button'); + fireEvent.click(configureBtn); + + // update fields + let configureModal = await findByTestId('configure-modal'); + + // advancedTab + let advancedTab = await within(configureModal).findByRole( + 'tab', + { name: configureModalMessages.advancedTabTitle.defaultMessage }, + ); + fireEvent.click(advancedTab); + let radioButtons = await within(configureModal).findAllByRole('radio'); + fireEvent.click(radioButtons[0]); + + // time box should not be visible + expect(within(configureModal).queryByLabelText( + configureModalMessages.timeAllotted.defaultMessage, + )).not.toBeInTheDocument(); + + // rules box should not be visible + expect(within(configureModal).queryByLabelText( + configureModalMessages.reviewRulesLabel.defaultMessage, + )).not.toBeInTheDocument(); + + const saveButton = await within(configureModal).findByTestId('configure-save-button'); + await act(async () => fireEvent.click(saveButton)); + + // verify request + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].data).toBe(JSON.stringify(expectedRequestData)); + + // reopen modal and check values + await act(async () => fireEvent.click(subsectionDropdownButton)); + await act(async () => fireEvent.click(configureBtn)); + + configureModal = await findByTestId('configure-modal'); + advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage }); + fireEvent.click(advancedTab); + radioButtons = await within(configureModal).findAllByRole('radio'); + expect(radioButtons[0]).toHaveProperty('checked', true); + expect(radioButtons[1]).toHaveProperty('checked', false); + expect(radioButtons[2]).toHaveProperty('checked', false); + expect(radioButtons[3]).toHaveProperty('checked', false); + }); + it('check configure modal for unit', async () => { const { findAllByTestId, findByTestId } = render(); const section = courseOutlineIndexMock.courseStructure.childInfo.children[0]; @@ -839,6 +1299,7 @@ describe('', () => { }, ], selectedPartitionIndex: 0, + selectedGroupsLabel: '', }; subsection.childInfo.children[0] = unit; section.childInfo.children[0] = subsection; @@ -1308,7 +1769,6 @@ describe('', () => { }); it('check that drag handle is not visible for non-draggable sections', async () => { - cleanup(); axiosMock .onGet(getCourseOutlineIndexApiUrl(courseId)) .reply(200, { diff --git a/src/course-outline/__mocks__/courseOutlineIndex.js b/src/course-outline/__mocks__/courseOutlineIndex.js index 2aa188c79..0969e5717 100644 --- a/src/course-outline/__mocks__/courseOutlineIndex.js +++ b/src/course-outline/__mocks__/courseOutlineIndex.js @@ -61,7 +61,7 @@ module.exports = { highlightsEnabled: true, highlightsPreviewOnly: false, highlightsDocUrl: 'http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_sections.html#set-section-highlights-for-weekly-course-highlight-messages', - enableProctoredExams: false, + enableProctoredExams: true, createZendeskTickets: true, enableTimedExams: true, childInfo: { @@ -152,6 +152,11 @@ module.exports = { due: null, relativeWeeksDue: null, format: null, + isPrereq: false, + prereqs: [{ + blockDisplayName: 'Sample Subsection', + blockUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@7f75de8dcc261249250b71925f49810f', + }], courseGraders: [ 'Homework', 'Exam', @@ -381,10 +386,11 @@ module.exports = { is_practice_exam: false, is_onboarding_exam: false, is_time_limited: false, + isPrereq: true, exam_review_rules: '', default_time_limit_minutes: null, proctoring_exam_configuration_link: null, - supports_onboarding: false, + supports_onboarding: true, show_review_rules: true, child_info: { category: 'vertical', @@ -571,12 +577,12 @@ module.exports = { ], showCorrectness: 'always', hideAfterDue: false, - isProctoredExam: false, + isProctoredExam: true, wasExamEverLinkedWithExternal: false, onlineProctoringRules: '', isPracticeExam: false, isOnboardingExam: false, - isTimeLimited: false, + isTimeLimited: true, examReviewRules: '', defaultTimeLimitMinutes: null, proctoringExamConfigurationLink: null, @@ -3050,7 +3056,7 @@ module.exports = { languageCode: 'en', lmsLink: '//localhost:18000/courses/course-v1:edx+101+y76/jump_to/block-v1:edx+101+y76+type@course+block@course', mfeProctoredExamSettingsUrl: '', - notificationDismissUrl: '/course_notifications/course-v1:edx+101+y76/2', + notificationDismissUrl: '', proctoringErrors: [], reindexLink: '/course/course-v1:edx+101+y76/search_reindex', rerunNotificationId: 2, diff --git a/src/course-outline/card-header/BaseTitleWithStatusBadge.jsx b/src/course-outline/card-header/BaseTitleWithStatusBadge.jsx deleted file mode 100644 index e1cc504f6..000000000 --- a/src/course-outline/card-header/BaseTitleWithStatusBadge.jsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { Icon, Truncate } from '@edx/paragon'; -import classNames from 'classnames'; -import { ITEM_BADGE_STATUS } from '../constants'; -import { getItemStatusBadgeContent } from '../utils'; -import messages from './messages'; - -const BaseTitleWithStatusBadge = ({ - title, - status, - namePrefix, -}) => { - const intl = useIntl(); - const { badgeTitle, badgeIcon } = getItemStatusBadgeContent(status, messages, intl); - - return ( - <> - {title} - {badgeTitle && ( -
- {badgeIcon && ( - - )} - {badgeTitle} -
- )} - - ); -}; - -BaseTitleWithStatusBadge.propTypes = { - title: PropTypes.string.isRequired, - status: PropTypes.string.isRequired, - namePrefix: PropTypes.string.isRequired, -}; - -export default BaseTitleWithStatusBadge; diff --git a/src/course-outline/card-header/CardHeader.jsx b/src/course-outline/card-header/CardHeader.jsx index 7ff6213fc..0a749e7a8 100644 --- a/src/course-outline/card-header/CardHeader.jsx +++ b/src/course-outline/card-header/CardHeader.jsx @@ -5,6 +5,7 @@ import { useSearchParams } from 'react-router-dom'; import { Dropdown, Form, + Hyperlink, Icon, IconButton, } from '@edx/paragon'; @@ -16,6 +17,7 @@ import { import { useEscapeClick } from '../../hooks'; import { ITEM_BADGE_STATUS } from '../constants'; import { scrollToElement } from '../utils'; +import CardStatus from './CardStatus'; import messages from './messages'; const CardHeader = ({ @@ -41,6 +43,11 @@ const CardHeader = ({ actions, enableCopyPasteUnits, isVertical, + isSequential, + proctoringExamConfigurationLink, + discussionEnabled, + discussionsSettings, + parentInfo, }) => { const intl = useIntl(); const [searchParams] = useSearchParams(); @@ -61,6 +68,17 @@ const CardHeader = ({ } }, []); + const showDiscussionsEnabledBadge = ( + isVertical + && !parentInfo?.isTimeLimited + && discussionEnabled + && discussionsSettings?.providerType === 'openedx' + && ( + discussionsSettings?.enableGradedUnits + || (!discussionsSettings?.enableGradedUnits && !parentInfo.graded) + ) + ); + useEscapeClick({ onEscape: () => { setTitleValue(title); @@ -76,7 +94,7 @@ const CardHeader = ({ ref={cardHeaderRef} > {isFormOpen ? ( - + e && e.focus()} @@ -94,16 +112,20 @@ const CardHeader = ({ /> ) : ( - titleComponent - )} -
- {!isFormOpen && ( + <> + {titleComponent} + + )} +
+ {(isVertical || isSequential) && ( + )} + {isSequential && proctoringExamConfigurationLink && ( + + {intl.formatMessage(messages.menuProctoringLinkText)} + + )} { const titleComponent = ( - - + /> ); return render( @@ -80,9 +76,8 @@ describe('', () => { const { findByText, findByTestId, queryByTestId } = renderComponent(); expect(await findByText(cardHeaderProps.title)).toBeInTheDocument(); - expect(await findByTestId('section-card-header__expanded-btn')).toBeInTheDocument(); - expect(await findByTestId('section-card-header__badge-status')).toBeInTheDocument(); - expect(await findByTestId('section-card-header__menu')).toBeInTheDocument(); + expect(await findByTestId('subsection-card-header__expanded-btn')).toBeInTheDocument(); + expect(await findByTestId('subsection-card-header__menu')).toBeInTheDocument(); await waitFor(() => { expect(queryByTestId('edit field')).not.toBeInTheDocument(); }); @@ -120,25 +115,25 @@ describe('', () => { expect(await findByText(messages.statusBadgeDraft.defaultMessage)).toBeInTheDocument(); }); - it('check publish menu item is disabled when section status is live or published not live and it has no changes', async () => { + it('check publish menu item is disabled when subsection status is live or published not live and it has no changes', async () => { const { findByText, findByTestId } = renderComponent({ ...cardHeaderProps, status: ITEM_BADGE_STATUS.publishedNotLive, }); - const menuButton = await findByTestId('section-card-header__menu-button'); + const menuButton = await findByTestId('subsection-card-header__menu-button'); fireEvent.click(menuButton); expect(await findByText(messages.menuPublish.defaultMessage)).toHaveAttribute('aria-disabled', 'true'); }); - it('check publish menu item is enabled when section status is live or published not live and it has changes', async () => { + it('check publish menu item is enabled when subsection status is live or published not live and it has changes', async () => { const { findByText, findByTestId } = renderComponent({ ...cardHeaderProps, status: ITEM_BADGE_STATUS.publishedNotLive, hasChanges: true, }); - const menuButton = await findByTestId('section-card-header__menu-button'); + const menuButton = await findByTestId('subsection-card-header__menu-button'); fireEvent.click(menuButton); expect(await findByText(messages.menuPublish.defaultMessage)).not.toHaveAttribute('aria-disabled'); }); @@ -146,7 +141,7 @@ describe('', () => { it('calls handleExpanded when button is clicked', async () => { const { findByTestId } = renderComponent(); - const expandButton = await findByTestId('section-card-header__expanded-btn'); + const expandButton = await findByTestId('subsection-card-header__expanded-btn'); fireEvent.click(expandButton); expect(onExpandMock).toHaveBeenCalled(); }); @@ -154,11 +149,9 @@ describe('', () => { it('calls onClickMenuButton when menu is clicked', async () => { const { findByTestId } = renderComponent(); - const menuButton = await findByTestId('section-card-header__menu-button'); - fireEvent.click(menuButton); - waitFor(() => { - expect(onClickMenuButtonMock).toHaveBeenCalled(); - }); + const menuButton = await findByTestId('subsection-card-header__menu-button'); + await act(async () => fireEvent.click(menuButton)); + expect(onClickMenuButtonMock).toHaveBeenCalled(); }); it('calls onClickPublish when item is clicked', async () => { @@ -167,24 +160,20 @@ describe('', () => { status: ITEM_BADGE_STATUS.draft, }); - const menuButton = await findByTestId('section-card-header__menu-button'); + const menuButton = await findByTestId('subsection-card-header__menu-button'); fireEvent.click(menuButton); const publishMenuItem = await findByText(messages.menuPublish.defaultMessage); - fireEvent.click(publishMenuItem); - waitFor(() => { - expect(onClickPublishMock).toHaveBeenCalled(); - }); + await act(async () => fireEvent.click(publishMenuItem)); + expect(onClickPublishMock).toHaveBeenCalled(); }); it('calls onClickEdit when the button is clicked', async () => { const { findByTestId } = renderComponent(); - const editButton = await findByTestId('section-edit-button'); - fireEvent.click(editButton); - waitFor(() => { - expect(onClickEditMock).toHaveBeenCalled(); - }); + const editButton = await findByTestId('subsection-edit-button'); + await act(async () => fireEvent.click(editButton)); + expect(onClickEditMock).toHaveBeenCalled(); }); it('check is field visible when isFormOpen is true', async () => { @@ -193,9 +182,9 @@ describe('', () => { isFormOpen: true, }); - expect(await findByTestId('section-edit-field')).toBeInTheDocument(); + expect(await findByTestId('subsection-edit-field')).toBeInTheDocument(); waitFor(() => { - expect(queryByTestId('section-card-header__expanded-btn')).not.toBeInTheDocument(); + expect(queryByTestId('subsection-card-header__expanded-btn')).not.toBeInTheDocument(); expect(queryByTestId('edit-button')).not.toBeInTheDocument(); }); }); @@ -207,32 +196,59 @@ describe('', () => { isDisabledEditField: true, }); - expect(await findByTestId('section-edit-field')).toBeDisabled(); + expect(await findByTestId('subsection-edit-field')).toBeDisabled(); }); it('calls onClickDelete when item is clicked', async () => { const { findByText, findByTestId } = renderComponent(); - const menuButton = await findByTestId('section-card-header__menu-button'); - fireEvent.click(menuButton); - + const menuButton = await findByTestId('subsection-card-header__menu-button'); + await act(async () => fireEvent.click(menuButton)); const deleteMenuItem = await findByText(messages.menuDelete.defaultMessage); - fireEvent.click(deleteMenuItem); - waitFor(() => { - expect(onClickDeleteMock).toHaveBeenCalledTimes(1); - }); + await act(async () => fireEvent.click(deleteMenuItem)); + expect(onClickDeleteMock).toHaveBeenCalledTimes(1); }); it('calls onClickDuplicate when item is clicked', async () => { const { findByText, findByTestId } = renderComponent(); - const menuButton = await findByTestId('section-card-header__menu-button'); + const menuButton = await findByTestId('subsection-card-header__menu-button'); fireEvent.click(menuButton); const duplicateMenuItem = await findByText(messages.menuDuplicate.defaultMessage); fireEvent.click(duplicateMenuItem); - waitFor(() => { - expect(onClickDuplicateMock).toHaveBeenCalled(); + await act(async () => fireEvent.click(duplicateMenuItem)); + expect(onClickDuplicateMock).toHaveBeenCalled(); + }); + + it('check if proctoringExamConfigurationLink is visible', async () => { + const { findByText, findByTestId } = renderComponent({ + ...cardHeaderProps, + proctoringExamConfigurationLink: 'https://localhost:8000/', + isSequential: true, }); + + const menuButton = await findByTestId('subsection-card-header__menu-button'); + await act(async () => fireEvent.click(menuButton)); + + expect(await findByText(messages.menuProctoringLinkText.defaultMessage)).toBeInTheDocument(); + }); + + it('check if discussion enabled badge is visible', async () => { + const { queryByText } = renderComponent({ + ...cardHeaderProps, + isVertical: true, + discussionEnabled: true, + discussionsSettings: { + providerType: 'openedx', + enableGradedUnits: true, + }, + parentInfo: { + isTimeLimited: false, + graded: false, + }, + }); + + expect(queryByText(messages.discussionEnabledBadgeText.defaultMessage)).toBeInTheDocument(); }); }); diff --git a/src/course-outline/card-header/CardStatus.jsx b/src/course-outline/card-header/CardStatus.jsx new file mode 100644 index 000000000..b5dc3560b --- /dev/null +++ b/src/course-outline/card-header/CardStatus.jsx @@ -0,0 +1,40 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import classNames from 'classnames'; +import { ITEM_BADGE_STATUS } from '../constants'; +import { getItemStatusBadgeContent } from '../utils'; +import messages from './messages'; +import StatusBadge from './StatusBadge'; + +const CardStatus = ({ + status, + showDiscussionsEnabledBadge, +}) => { + const intl = useIntl(); + const { badgeTitle, badgeIcon } = getItemStatusBadgeContent(status, messages, intl); + + return ( + <> + {showDiscussionsEnabledBadge && ( + + )} + {badgeTitle && ( + + )} + + ); +}; + +CardStatus.propTypes = { + status: PropTypes.string.isRequired, + showDiscussionsEnabledBadge: PropTypes.bool.isRequired, +}; + +export default CardStatus; diff --git a/src/course-outline/card-header/StatusBadge.jsx b/src/course-outline/card-header/StatusBadge.jsx new file mode 100644 index 000000000..ead29c990 --- /dev/null +++ b/src/course-outline/card-header/StatusBadge.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Icon } from '@edx/paragon'; + +const StatusBadge = ({ + text, + icon, + iconClassName, +}) => { + if (text) { + return ( +
+ {icon && ( + + )} + {text} +
+ ); + } + return null; +}; + +StatusBadge.defaultProps = { + text: '', + icon: '', + iconClassName: '', +}; + +StatusBadge.propTypes = { + text: PropTypes.string, + icon: PropTypes.string, + iconClassName: PropTypes.string, +}; + +export default StatusBadge; diff --git a/src/course-outline/card-header/TitleButton.jsx b/src/course-outline/card-header/TitleButton.jsx index 44e891a41..8f17b0e5f 100644 --- a/src/course-outline/card-header/TitleButton.jsx +++ b/src/course-outline/card-header/TitleButton.jsx @@ -4,6 +4,7 @@ import { Button, OverlayTrigger, Tooltip, + Truncate, } from '@edx/paragon'; import { ArrowDropDown as ArrowDownIcon, @@ -12,21 +13,20 @@ import { import messages from './messages'; const TitleButton = ({ + title, isExpanded, onTitleClick, namePrefix, - children, }) => { const intl = useIntl(); const titleTooltipMessage = intl.formatMessage(messages.expandTooltip); return ( {titleTooltipMessage} @@ -39,21 +39,17 @@ const TitleButton = ({ className="item-card-header__title-btn" onClick={onTitleClick} > - {children} + {title} ); }; -TitleButton.defaultProps = { - children: null, -}; - TitleButton.propTypes = { + title: PropTypes.string.isRequired, isExpanded: PropTypes.bool.isRequired, onTitleClick: PropTypes.func.isRequired, namePrefix: PropTypes.string.isRequired, - children: PropTypes.node, }; export default TitleButton; diff --git a/src/course-outline/card-header/TitleLink.jsx b/src/course-outline/card-header/TitleLink.jsx index 4a27d11cd..bc7c93d93 100644 --- a/src/course-outline/card-header/TitleLink.jsx +++ b/src/course-outline/card-header/TitleLink.jsx @@ -1,11 +1,11 @@ import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; -import { Button } from '@edx/paragon'; +import { Button, Truncate } from '@edx/paragon'; const TitleLink = ({ + title, titleLink, namePrefix, - children, }) => ( ); -TitleLink.defaultProps = { - children: null, -}; - TitleLink.propTypes = { + title: PropTypes.string.isRequired, titleLink: PropTypes.string.isRequired, namePrefix: PropTypes.string.isRequired, - children: PropTypes.node, }; export default TitleLink; diff --git a/src/course-outline/card-header/messages.js b/src/course-outline/card-header/messages.js index 654d8fe49..d9f250970 100644 --- a/src/course-outline/card-header/messages.js +++ b/src/course-outline/card-header/messages.js @@ -61,6 +61,18 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.card.menu.delete', defaultMessage: 'Copy to clipboard', }, + menuProctoringLinkText: { + id: 'course-authoring.course-outline.card.menu.proctoring-settings', + defaultMessage: 'Proctoring settings', + }, + proctoringLinkTooltip: { + id: 'course-authoring.course-outline.card.menu.proctoring-settings-tooltip', + defaultMessage: 'Proctoring settings', + }, + discussionEnabledBadgeText: { + id: 'course-authoring.course-outline.card.badge.discussionEnabled', + defaultMessage: 'Discussions enabled', + }, }); export default messages; diff --git a/src/course-outline/configure-modal/AdvancedTab.jsx b/src/course-outline/configure-modal/AdvancedTab.jsx index 4a8c9771a..c67ee8847 100644 --- a/src/course-outline/configure-modal/AdvancedTab.jsx +++ b/src/course-outline/configure-modal/AdvancedTab.jsx @@ -1,16 +1,49 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import moment from 'moment'; -import { Form } from '@edx/paragon'; +import { Alert, Form, Hyperlink } from '@edx/paragon'; +import { + Warning as WarningIcon, +} from '@edx/paragon/icons'; import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; +import PrereqSettings from './PrereqSettings'; + const AdvancedTab = ({ - isTimeLimited, - setIsTimeLimited, - defaultTimeLimit, - setDefaultTimeLimit, + values, + setFieldValue, + prereqs, + releasedToStudents, + wasExamEverLinkedWithExternal, + enableProctoredExams, + supportsOnboarding, + wasProctoredExam, + showReviewRules, + onlineProctoringRules, }) => { + const { + isTimeLimited, + isProctoredExam, + isOnboardingExam, + isPracticeExam, + defaultTimeLimitMinutes, + examReviewRules, + } = values; + let examTypeValue = 'none'; + + if (isTimeLimited && isProctoredExam) { + if (isOnboardingExam) { + examTypeValue = 'onboardingExam'; + } else if (isPracticeExam) { + examTypeValue = 'practiceExam'; + } else { + examTypeValue = 'proctoredExam'; + } + } else if (isTimeLimited) { + examTypeValue = 'timed'; + } + const formatHour = (hour) => { const hh = Math.floor(hour / 60); const mm = hour % 60; @@ -31,14 +64,35 @@ const AdvancedTab = ({ return `${hhs}:${mms}`; }; - const [timeLimit, setTimeLimit] = useState(formatHour(defaultTimeLimit)); + const [timeLimit, setTimeLimit] = useState(formatHour(defaultTimeLimitMinutes)); + const showReviewRulesDiv = showReviewRules && isProctoredExam && !isPracticeExam && !isOnboardingExam; const handleChange = (e) => { if (e.target.value === 'timed') { - setIsTimeLimited(true); + setFieldValue('isTimeLimited', true); + setFieldValue('isOnboardingExam', false); + setFieldValue('isPracticeExam', false); + setFieldValue('isProctoredExam', false); + } else if (e.target.value === 'onboardingExam') { + setFieldValue('isOnboardingExam', true); + setFieldValue('isProctoredExam', true); + setFieldValue('isTimeLimited', true); + setFieldValue('isPracticeExam', false); + } else if (e.target.value === 'practiceExam') { + setFieldValue('isPracticeExam', true); + setFieldValue('isProctoredExam', true); + setFieldValue('isTimeLimited', true); + setFieldValue('isOnboardingExam', false); + } else if (e.target.value === 'proctoredExam') { + setFieldValue('isProctoredExam', true); + setFieldValue('isTimeLimited', true); + setFieldValue('isOnboardingExam', false); + setFieldValue('isPracticeExam', false); } else { - setDefaultTimeLimit(null); - setIsTimeLimited(false); + setFieldValue('isTimeLimited', false); + setFieldValue('isOnboardingExam', false); + setFieldValue('isPracticeExam', false); + setFieldValue('isProctoredExam', false); } }; @@ -48,11 +102,29 @@ const AdvancedTab = ({ value = value.trim(); if (value && valid) { const minutes = moment.duration(value).asMinutes(); - setDefaultTimeLimit(minutes); + setFieldValue('defaultTimeLimitMinutes', minutes); } setTimeLimit(value); }; + const renderAlerts = () => { + const proctoredExamLockedIn = releasedToStudents && wasExamEverLinkedWithExternal; + return ( + <> + {proctoredExamLockedIn && !wasProctoredExam && ( + + + + )} + {proctoredExamLockedIn && wasProctoredExam && ( + + + + )} + + ); + }; + return ( <>
@@ -60,15 +132,47 @@ const AdvancedTab = ({ + {renderAlerts()} - + } + controlClassName="mw-1-25rem" + > - + {enableProctoredExams && ( + <> + } + controlClassName="mw-1-25rem" + > + + + {supportsOnboarding ? ( + } + value="onboardingExam" + controlClassName="mw-1-25rem" + > + + + ) : ( + } + > + + + )} + + )} { isTimeLimited && (
@@ -86,15 +190,87 @@ const AdvancedTab = ({
)} + { showReviewRulesDiv && ( +
+ + + + + setFieldValue('examReviewRules', e.target.value)} + value={examReviewRules} + as="textarea" + rows="3" + /> + + + { onlineProctoringRules ? ( + + + + ), + }} + /> + ) : ( + + )} + +
+ )} + ); }; +AdvancedTab.defaultProps = { + prereqs: [], + wasExamEverLinkedWithExternal: false, + enableProctoredExams: false, + supportsOnboarding: false, + wasProctoredExam: false, + showReviewRules: false, + onlineProctoringRules: '', +}; + AdvancedTab.propTypes = { - isTimeLimited: PropTypes.bool.isRequired, - setIsTimeLimited: PropTypes.func.isRequired, - defaultTimeLimit: PropTypes.number.isRequired, - setDefaultTimeLimit: PropTypes.func.isRequired, + values: PropTypes.shape({ + isTimeLimited: PropTypes.bool.isRequired, + defaultTimeLimitMinutes: PropTypes.number, + isPrereq: PropTypes.bool, + prereqUsageKey: PropTypes.string, + prereqMinScore: PropTypes.number, + prereqMinCompletion: PropTypes.number, + isProctoredExam: PropTypes.bool, + isPracticeExam: PropTypes.bool, + isOnboardingExam: PropTypes.bool, + examReviewRules: PropTypes.string, + }).isRequired, + setFieldValue: PropTypes.func.isRequired, + prereqs: PropTypes.arrayOf(PropTypes.shape({ + blockUsageKey: PropTypes.string.isRequired, + blockDisplayName: PropTypes.string.isRequired, + })), + releasedToStudents: PropTypes.bool.isRequired, + wasExamEverLinkedWithExternal: PropTypes.bool, + enableProctoredExams: PropTypes.bool, + supportsOnboarding: PropTypes.bool, + wasProctoredExam: PropTypes.bool, + showReviewRules: PropTypes.bool, + onlineProctoringRules: PropTypes.string, }; export default injectIntl(AdvancedTab); diff --git a/src/course-outline/configure-modal/BasicTab.jsx b/src/course-outline/configure-modal/BasicTab.jsx index 2f8a526dd..233010ade 100644 --- a/src/course-outline/configure-modal/BasicTab.jsx +++ b/src/course-outline/configure-modal/BasicTab.jsx @@ -6,18 +6,20 @@ import messages from './messages'; import { DatepickerControl, DATEPICKER_TYPES } from '../../generic/datepicker-control'; const BasicTab = ({ - releaseDate, - setReleaseDate, - isSubsection, - graderType, + values, + setFieldValue, courseGraders, - setGraderType, - dueDate, - setDueDate, + isSubsection, }) => { const intl = useIntl(); - const onChangeGraderType = (e) => setGraderType(e.target.value); + const { + releaseDate, + graderType, + dueDate, + } = values; + + const onChangeGraderType = (e) => setFieldValue('graderType', e.target.value); const createOptions = () => courseGraders.map((option) => ( @@ -34,14 +36,14 @@ const BasicTab = ({ value={releaseDate} label={intl.formatMessage(messages.releaseDate)} controlName="state-date" - onChange={setReleaseDate} + onChange={(val) => setFieldValue('releaseDate', val)} /> setFieldValue('releaseDate', val)} />
@@ -50,16 +52,20 @@ const BasicTab = ({

- - onChangeGraderType(value)} - data-testid="grader-type-select" - > - - {createOptions()} - + + + + + {createOptions()} + +
setFieldValue('dueDate', val)} data-testid="due-date-picker" /> setFieldValue('dueDate', val)} />
@@ -87,18 +93,14 @@ const BasicTab = ({ }; BasicTab.propTypes = { - releaseDate: PropTypes.string.isRequired, - setReleaseDate: PropTypes.func.isRequired, isSubsection: PropTypes.bool.isRequired, - graderType: PropTypes.string.isRequired, - setGraderType: PropTypes.func.isRequired, - dueDate: PropTypes.string, - setDueDate: PropTypes.func.isRequired, + values: PropTypes.shape({ + releaseDate: PropTypes.string.isRequired, + graderType: PropTypes.string.isRequired, + dueDate: PropTypes.string, + }).isRequired, courseGraders: PropTypes.arrayOf(PropTypes.string).isRequired, -}; - -BasicTab.defaultProps = { - dueDate: null, + setFieldValue: PropTypes.func.isRequired, }; export default injectIntl(BasicTab); diff --git a/src/course-outline/configure-modal/ConfigureModal.jsx b/src/course-outline/configure-modal/ConfigureModal.jsx index 5772faf20..4838949dc 100644 --- a/src/course-outline/configure-modal/ConfigureModal.jsx +++ b/src/course-outline/configure-modal/ConfigureModal.jsx @@ -1,20 +1,22 @@ /* eslint-disable import/named */ -import React, { useEffect, useState } from 'react'; +import React from 'react'; +import * as Yup from 'yup'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import { ModalDialog, Button, ActionRow, + Form, Tab, Tabs, - useCheckboxSetValues, } from '@edx/paragon'; import { useSelector } from 'react-redux'; +import { Formik } from 'formik'; import { VisibilityTypes } from '../../data/constants'; import { COURSE_BLOCK_NAMES } from '../constants'; -import { getCurrentItem } from '../data/selectors'; +import { getCurrentItem, getProctoredExamsFlag } from '../data/selectors'; import messages from './messages'; import BasicTab from './BasicTab'; import VisibilityTab from './VisibilityTab'; @@ -41,27 +43,26 @@ const ConfigureModal = ({ format, userPartitionInfo, ancestorHasStaffLock, + isPrereq, + prereqs, + prereq, + prereqMinScore, + prereqMinCompletion, + releasedToStudents, + wasExamEverLinkedWithExternal, + isProctoredExam, + isOnboardingExam, + isPracticeExam, + examReviewRules, + supportsOnboarding, + showReviewRules, + onlineProctoringRules, } = useSelector(getCurrentItem); + const enableProctoredExams = useSelector(getProctoredExamsFlag); - const [releaseDate, setReleaseDate] = useState(sectionStartDate); - const [isVisibleToStaffOnly, setIsVisibleToStaffOnly] = useState(visibilityState === VisibilityTypes.STAFF_ONLY); - - const [saveButtonDisabled, setSaveButtonDisabled] = useState(true); - const [graderType, setGraderType] = useState(format == null ? 'Not Graded' : format); - const [dueDateState, setDueDateState] = useState(due == null ? '' : due); - const [isTimeLimitedState, setIsTimeLimitedState] = useState(false); - const [defaultTimeLimitMin, setDefaultTimeLimitMin] = useState(defaultTimeLimitMinutes); - const [hideAfterDueState, setHideAfterDueState] = useState(hideAfterDue === undefined ? false : hideAfterDue); - const [showCorrectnessState, setShowCorrectnessState] = useState(false); - const isSubsection = category === COURSE_BLOCK_NAMES.sequential.id; - - /* TODO: The use of these useEffects needs to be updated to use Formik, please see, - * https://github.com/open-craft/frontend-app-course-authoring/pull/22#discussion_r1435957797 as reference. */ - // by default it is -1 i.e. accessible to all learners & staff - const [selectedPartitionIndex, setSelectedPartitionIndex] = useState(userPartitionInfo?.selectedPartitionIndex); const getSelectedGroups = () => { - if (selectedPartitionIndex >= 0) { - return userPartitionInfo?.selectablePartitions[selectedPartitionIndex] + if (userPartitionInfo?.selectedPartitionIndex >= 0) { + return userPartitionInfo?.selectablePartitions[userPartitionInfo?.selectedPartitionIndex] ?.groups .filter(({ selected }) => selected) .map(({ id }) => `${id}`) @@ -70,140 +71,126 @@ const ConfigureModal = ({ return []; }; - const [selectedGroups, { add, remove, set }] = useCheckboxSetValues([]); + const defaultPrereqScore = (val) => { + if (val === null || val === undefined) { + return 100; + } + return parseFloat(val); + }; - useEffect(() => { - setSelectedPartitionIndex(userPartitionInfo?.selectedPartitionIndex); - }, [userPartitionInfo]); + const initialValues = { + releaseDate: sectionStartDate, + isVisibleToStaffOnly: visibilityState === VisibilityTypes.STAFF_ONLY, + saveButtonDisabled: true, + graderType: format == null ? 'notgraded' : format, + dueDate: due == null ? '' : due, + isTimeLimited, + isProctoredExam, + isOnboardingExam, + isPracticeExam, + examReviewRules, + defaultTimeLimitMinutes, + hideAfterDue: hideAfterDue === undefined ? false : hideAfterDue, + showCorrectness, + isPrereq, + prereqUsageKey: prereq, + prereqMinScore: defaultPrereqScore(prereqMinScore), + prereqMinCompletion: defaultPrereqScore(prereqMinCompletion), + // by default it is -1 i.e. accessible to all learners & staff + selectedPartitionIndex: userPartitionInfo?.selectedPartitionIndex, + selectedGroups: getSelectedGroups(), + }; - useEffect(() => { - set(getSelectedGroups()); - }, [selectedPartitionIndex, userPartitionInfo]); + const validationSchema = Yup.object().shape({ + isTimeLimited: Yup.boolean(), + isProctoredExam: Yup.boolean(), + isPracticeExam: Yup.boolean(), + isOnboardingExam: Yup.boolean(), + examReviewRules: Yup.string(), + defaultTimeLimitMinutes: Yup.number().nullable(true), + hideAfterDueState: Yup.boolean(), + showCorrectness: Yup.string().required(), + isPrereq: Yup.boolean(), + prereqUsageKey: Yup.string().nullable(true), + prereqMinScore: Yup.number().min( + 0, + intl.formatMessage(messages.minScoreError), + ).max( + 100, + intl.formatMessage(messages.minScoreError), + ).nullable(true), + prereqMinCompletion: Yup.number().min( + 0, + intl.formatMessage(messages.minScoreError), + ).max( + 100, + intl.formatMessage(messages.minScoreError), + ).nullable(true), + selectedPartitionIndex: Yup.number().integer(), + selectedGroups: Yup.array().of(Yup.string()), + }); - useEffect(() => { - setReleaseDate(sectionStartDate); - }, [sectionStartDate]); + const isSubsection = category === COURSE_BLOCK_NAMES.sequential.id; - useEffect(() => { - setGraderType(format == null ? 'Not Graded' : format); - }, [format]); - - useEffect(() => { - setDueDateState(due == null ? '' : due); - }, [due]); - - useEffect(() => { - setIsTimeLimitedState(isTimeLimited); - }, [isTimeLimited]); - - useEffect(() => { - setDefaultTimeLimitMin(defaultTimeLimitMinutes); - }, [defaultTimeLimitMinutes]); - - useEffect(() => { - setHideAfterDueState(hideAfterDue === undefined ? false : hideAfterDue); - }, [hideAfterDue]); - - useEffect(() => { - setShowCorrectnessState(showCorrectness); - }, [showCorrectness]); - - useEffect(() => { - setIsVisibleToStaffOnly(visibilityState === VisibilityTypes.STAFF_ONLY); - }, [visibilityState]); - - useEffect(() => { - const visibilityUnchanged = isVisibleToStaffOnly === (visibilityState === VisibilityTypes.STAFF_ONLY); - const graderTypeUnchanged = graderType === (format == null ? 'Not Graded' : format); - const dueDateUnchanged = dueDateState === (due == null ? '' : due); - const hideAfterDueUnchanged = hideAfterDueState === (hideAfterDue === undefined ? false : hideAfterDue); - const selectedGroupsUnchanged = selectedGroups.sort().join(',') === getSelectedGroups().sort().join(','); - // handle the case of unrestricting access - const accessRestrictionUnchanged = selectedPartitionIndex !== -1 - || userPartitionInfo?.selectedPartitionIndex === -1; - setSaveButtonDisabled( - visibilityUnchanged - && releaseDate === sectionStartDate - && dueDateUnchanged - && isTimeLimitedState === isTimeLimited - && defaultTimeLimitMin === defaultTimeLimitMinutes - && hideAfterDueUnchanged - && showCorrectnessState === showCorrectness - && graderTypeUnchanged - && selectedGroupsUnchanged - && accessRestrictionUnchanged, - ); - }, [ - releaseDate, - isVisibleToStaffOnly, - dueDateState, - isTimeLimitedState, - defaultTimeLimitMin, - hideAfterDueState, - showCorrectnessState, - graderType, - selectedGroups, - ]); - - const handleSave = () => { + const handleSave = (data) => { const groupAccess = {}; switch (category) { case COURSE_BLOCK_NAMES.chapter.id: - onConfigureSubmit(isVisibleToStaffOnly, releaseDate); + onConfigureSubmit(data.isVisibleToStaffOnly, data.releaseDate); break; case COURSE_BLOCK_NAMES.sequential.id: onConfigureSubmit( - isVisibleToStaffOnly, - releaseDate, - graderType === 'Not Graded' ? 'notgraded' : graderType, - dueDateState, - isTimeLimitedState, - defaultTimeLimitMin, - hideAfterDueState, - showCorrectnessState, + data.isVisibleToStaffOnly, + data.releaseDate, + data.graderType, + data.dueDate, + data.isTimeLimited, + data.isProctoredExam, + data.isOnboardingExam, + data.isPracticeExam, + data.examReviewRules, + data.isTimeLimited ? data.defaultTimeLimitMinutes : 0, + data.hideAfterDue, + data.showCorrectness, + data.isPrereq, + data.prereqUsageKey, + data.prereqMinScore, + data.prereqMinCompletion, ); break; case COURSE_BLOCK_NAMES.vertical.id: // groupAccess should be {partitionId: [group1, group2]} or {} if selectedPartitionIndex === -1 - if (selectedPartitionIndex >= 0) { - const partitionId = userPartitionInfo.selectablePartitions[selectedPartitionIndex].id; - groupAccess[partitionId] = selectedGroups.map(g => parseInt(g, 10)); + if (data.selectedPartitionIndex >= 0) { + const partitionId = userPartitionInfo.selectablePartitions[data.selectedPartitionIndex].id; + groupAccess[partitionId] = data.selectedGroups.map(g => parseInt(g, 10)); } - onConfigureSubmit(isVisibleToStaffOnly, groupAccess); + onConfigureSubmit(data.isVisibleToStaffOnly, groupAccess); break; default: break; } }; - const renderModalBody = () => { + const renderModalBody = (values, setFieldValue) => { switch (category) { case COURSE_BLOCK_NAMES.chapter.id: return ( @@ -213,35 +200,33 @@ const ConfigureModal = ({ @@ -249,15 +234,10 @@ const ConfigureModal = ({ case COURSE_BLOCK_NAMES.vertical.id: return ( ); default: @@ -266,36 +246,51 @@ const ConfigureModal = ({ }; return ( - isOpen && ( - -
- - - {intl.formatMessage(messages.title, { title: displayName })} - - - - {renderModalBody(category)} - - - - - {intl.formatMessage(messages.cancelButton)} - - - - -
-
- ) + +
+ + + {intl.formatMessage(messages.title, { title: displayName })} + + + + {({ + values, handleSubmit, dirty, isValid, setFieldValue, + }) => ( + <> + + + {renderModalBody(values, setFieldValue)} + + + + + + {intl.formatMessage(messages.cancelButton)} + + + + + + )} + +
+
); }; diff --git a/src/course-outline/configure-modal/ConfigureModal.scss b/src/course-outline/configure-modal/ConfigureModal.scss index 3b8d04a9b..9a0eb6474 100644 --- a/src/course-outline/configure-modal/ConfigureModal.scss +++ b/src/course-outline/configure-modal/ConfigureModal.scss @@ -1,8 +1,14 @@ .configure-modal { - max-width: 33.6875rem; - .configure-modal__header { padding-top: 1.5rem; position: static; } + + .w-7rem { + width: 7.2rem; + } + + .mw-1-25rem { + min-width: 1.25rem; + } } diff --git a/src/course-outline/configure-modal/PrereqSettings.jsx b/src/course-outline/configure-modal/PrereqSettings.jsx new file mode 100644 index 000000000..e7170e503 --- /dev/null +++ b/src/course-outline/configure-modal/PrereqSettings.jsx @@ -0,0 +1,117 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Form } from '@edx/paragon'; +import { FormattedMessage, injectIntl, useIntl } from '@edx/frontend-platform/i18n'; +import messages from './messages'; + +import FormikControl from '../../generic/FormikControl'; + +const PrereqSettings = ({ + values, + setFieldValue, + prereqs, +}) => { + const intl = useIntl(); + const { + isPrereq, + prereqUsageKey, + prereqMinScore, + prereqMinCompletion, + } = values; + + if (isPrereq === null || isPrereq === undefined) { + return null; + } + + const handleSelectChange = (e) => { + setFieldValue('prereqUsageKey', e.target.value); + }; + + const prereqSelectionForm = () => ( + <> +
+
+
+ + + + {intl.formatMessage(messages.prerequisiteSelectLabel)} + + + + {prereqs.map((prereqOption) => ( + + ))} + + + {prereqUsageKey && ( + <> + {intl.formatMessage(messages.minScoreLabel)}} + controlClassName="text-right" + controlClasses="w-7rem" + type="number" + trailingElement="%" + /> + {intl.formatMessage(messages.minCompletionLabel)}} + controlClassName="text-right" + controlClasses="w-7rem" + type="number" + trailingElement="%" + /> + + )} + + + ); + + const handleCheckboxChange = e => setFieldValue('isPrereq', e.target.checked); + + return ( + <> + {prereqs.length > 0 && prereqSelectionForm()} +
+
+ + + + + ); +}; + +PrereqSettings.defaultProps = { + prereqs: [], +}; + +PrereqSettings.propTypes = { + values: PropTypes.shape({ + isPrereq: PropTypes.bool, + prereqUsageKey: PropTypes.string, + prereqMinScore: PropTypes.number, + prereqMinCompletion: PropTypes.number, + }).isRequired, + prereqs: PropTypes.arrayOf(PropTypes.shape({ + blockUsageKey: PropTypes.string.isRequired, + blockDisplayName: PropTypes.string.isRequired, + })), + setFieldValue: PropTypes.func.isRequired, +}; + +export default injectIntl(PrereqSettings); diff --git a/src/course-outline/configure-modal/UnitTab.jsx b/src/course-outline/configure-modal/UnitTab.jsx index 452a4c672..83b85e68e 100644 --- a/src/course-outline/configure-modal/UnitTab.jsx +++ b/src/course-outline/configure-modal/UnitTab.jsx @@ -1,36 +1,33 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Alert, Form } from '@edx/paragon'; -import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { + FormattedMessage, injectIntl, useIntl, +} from '@edx/frontend-platform/i18n'; +import { Field } from 'formik'; + import messages from './messages'; const UnitTab = ({ - intl, - isVisibleToStaffOnly, - setIsVisibleToStaffOnly, + values, + setFieldValue, showWarning, userPartitionInfo, - setSelectedPartitionIndex, - selectedPartitionIndex, - selectedGroups, - add, - remove, }) => { + const intl = useIntl(); + const { + isVisibleToStaffOnly, + selectedPartitionIndex, + } = values; + const handleChange = (e) => { - setIsVisibleToStaffOnly(e.target.checked); + setFieldValue('isVisibleToStaffOnly', e.target.checked); }; const handleSelect = (e) => { - setSelectedPartitionIndex(parseInt(e.target.value, 10)); + setFieldValue('selectedPartitionIndex', parseInt(e.target.value, 10)); }; - const handleCheckBoxChange = e => { - if (e.target.checked) { - add(e.target.value); - } else { - remove(e.target.value); - } - }; return ( <>

@@ -39,7 +36,7 @@ const UnitTab = ({ {showWarning && ( - + )} @@ -71,16 +68,33 @@ const UnitTab = ({ {selectedPartitionIndex >= 0 && userPartitionInfo.selectablePartitions.length && ( - + - - {userPartitionInfo.selectablePartitions[selectedPartitionIndex].groups.map((group) => ({group.name}))} - + {userPartitionInfo.selectablePartitions[selectedPartitionIndex].groups.map((group) => ( + + + + {group.name} + + + ))} +
)} @@ -90,9 +104,14 @@ const UnitTab = ({ }; UnitTab.propTypes = { - intl: intlShape.isRequired, - isVisibleToStaffOnly: PropTypes.bool.isRequired, - setIsVisibleToStaffOnly: PropTypes.func.isRequired, + values: PropTypes.shape({ + isVisibleToStaffOnly: PropTypes.bool.isRequired, + selectedPartitionIndex: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + ]).isRequired, + }).isRequired, + setFieldValue: PropTypes.func.isRequired, showWarning: PropTypes.bool.isRequired, userPartitionInfo: PropTypes.shape({ selectablePartitions: PropTypes.arrayOf(PropTypes.shape({ @@ -106,20 +125,9 @@ UnitTab.propTypes = { name: PropTypes.string.isRequired, scheme: PropTypes.string.isRequired, }).isRequired).isRequired, - selectedGroupsLabel: PropTypes.string.isRequired, + selectedGroupsLabel: PropTypes.string, selectedPartitionIndex: PropTypes.number.isRequired, }).isRequired, - setSelectedPartitionIndex: PropTypes.func.isRequired, - selectedPartitionIndex: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.number, - ]).isRequired, - selectedGroups: PropTypes.arrayOf(PropTypes.oneOfType([ - PropTypes.string, - PropTypes.number, - ])).isRequired, - add: PropTypes.func.isRequired, - remove: PropTypes.func.isRequired, }; export default injectIntl(UnitTab); diff --git a/src/course-outline/configure-modal/VisibilityTab.jsx b/src/course-outline/configure-modal/VisibilityTab.jsx index 9051ccece..7aa587ded 100644 --- a/src/course-outline/configure-modal/VisibilityTab.jsx +++ b/src/course-outline/configure-modal/VisibilityTab.jsx @@ -6,20 +6,23 @@ import messages from './messages'; import { COURSE_BLOCK_NAMES } from '../constants'; const VisibilityTab = ({ + values, + setFieldValue, category, - isVisibleToStaffOnly, - setIsVisibleToStaffOnly, showWarning, isSubsection, - hideAfterDue, - setHideAfterDue, - showCorrectness, - setShowCorrectness, }) => { const intl = useIntl(); const visibilityTitle = COURSE_BLOCK_NAMES[category]?.name; + + const { + isVisibleToStaffOnly, + hideAfterDue, + showCorrectness, + } = values; + const handleChange = (e) => { - setIsVisibleToStaffOnly(e.target.checked); + setFieldValue('isVisibleToStaffOnly', e.target.checked); }; const getVisibilityValue = () => { @@ -35,19 +38,19 @@ const VisibilityTab = ({ const visibilityChanged = (e) => { const selected = e.target.value; if (selected === 'hide') { - setIsVisibleToStaffOnly(true); - setHideAfterDue(false); + setFieldValue('isVisibleToStaffOnly', true); + setFieldValue('hideAfterDue', false); } else if (selected === 'hideDue') { - setIsVisibleToStaffOnly(false); - setHideAfterDue(true); + setFieldValue('isVisibleToStaffOnly', false); + setFieldValue('hideAfterDue', true); } else { - setIsVisibleToStaffOnly(false); - setHideAfterDue(false); + setFieldValue('isVisibleToStaffOnly', false); + setFieldValue('hideAfterDue', false); } }; const correctnessChanged = (e) => { - setShowCorrectness(e.target.value); + setFieldValue('showCorrectness', e.target.value); }; return ( @@ -77,6 +80,11 @@ const VisibilityTab = ({ + {showWarning && ( + + + + )}
) } - {showWarning && ( - <> -
- - - - - + {showWarning && !isSubsection && ( + + + )} ); }; VisibilityTab.propTypes = { + values: PropTypes.shape({ + isVisibleToStaffOnly: PropTypes.bool.isRequired, + hideAfterDue: PropTypes.bool.isRequired, + showCorrectness: PropTypes.string.isRequired, + }).isRequired, + setFieldValue: PropTypes.func.isRequired, category: PropTypes.string.isRequired, - isVisibleToStaffOnly: PropTypes.bool.isRequired, showWarning: PropTypes.bool.isRequired, - setIsVisibleToStaffOnly: PropTypes.func.isRequired, isSubsection: PropTypes.bool.isRequired, - hideAfterDue: PropTypes.bool.isRequired, - setHideAfterDue: PropTypes.func.isRequired, - showCorrectness: PropTypes.string.isRequired, - setShowCorrectness: PropTypes.func.isRequired, }; export default injectIntl(VisibilityTab); diff --git a/src/course-outline/configure-modal/messages.js b/src/course-outline/configure-modal/messages.js index 13d1d3964..316cbc0fb 100644 --- a/src/course-outline/configure-modal/messages.js +++ b/src/course-outline/configure-modal/messages.js @@ -9,6 +9,10 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.configure-modal.basic-tab.title', defaultMessage: 'Basic', }, + notGradedTypeOption: { + id: 'course-authoring.course-outline.configure-modal.basic-tab.notGradedTypeOption', + defaultMessage: 'Not Graded', + }, releaseDateAndTime: { id: 'course-authoring.course-outline.configure-modal.basic-tab.release-date-and-time', defaultMessage: 'Release Date and Time', @@ -43,12 +47,16 @@ const messages = defineMessages({ }, sectionVisibilityWarning: { id: 'course-authoring.course-outline.configure-modal.visibility-tab.section-visibility-warning', - defaultMessage: 'If you make this section visible to learners, learners will be able to see its content after the release date has passed and you have published the unit. Only units that are explicitly hidden from learners will remain hidden after you clear this option for the section.', + defaultMessage: 'If you make this section visible to learners, learners will be able to see its content after the release date has passed and you have published the section. Only units that are explicitly hidden from learners will remain hidden after you clear this option for the section.', }, unitVisibilityWarning: { id: 'course-authoring.course-outline.configure-modal.unit-tab.unit-visibility-warning', defaultMessage: 'If the unit was previously published and released to learners, any changes you made to the unit when it was hidden will now be visible to learners.', }, + subsectionVisibilityWarning: { + id: 'course-authoring.course-outline.configure-modal.unit-tab.subsection-visibility-warning', + defaultMessage: 'If you select an option other than "Hide entire subsection", published units in this subsection will become available to learners unless they are explicitly hidden.', + }, unitSelectGroup: { id: 'course-authoring.course-outline.configure-modal.unit-tab.unit-select-group', defaultMessage: 'Select one or more groups:', @@ -157,6 +165,30 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.configure-modal.advanced-tab.timed-description', defaultMessage: 'Use a timed exam to limit the time learners can spend on problems in this subsection. Learners must submit answers before the time expires. You can allow additional time for individual learners through the instructor Dashboard.', }, + proctoredExam: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.proctoredExam', + defaultMessage: 'Proctored', + }, + proctoredExamDescription: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.timed-description', + defaultMessage: 'Proctored exams are timed and they record video of each learner taking the exam. The videos are then reviewed to ensure that learners follow all examination rules. Please note that setting this exam as proctored will change the visibility settings to "Hide content after due date."', + }, + onboardingExam: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.onboardingExam', + defaultMessage: 'Onboarding', + }, + onboardingExamDescription: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.timed-description', + defaultMessage: 'Use Onboarding to introduce learners to proctoring, verify their identity, and create an onboarding profile. Learners must complete the onboarding profile step prior to taking a proctored exam. Profile reviews take 2+ business days.', + }, + practiceExam: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.practiceExam', + defaultMessage: 'Practice proctored', + }, + practiceExamDescription: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.timed-description', + defaultMessage: 'Use a practice proctored exam to introduce learners to the proctoring tools and processes. Results of a practice exam do not affect a learner\'s grade.', + }, advancedTabTitle: { id: 'course-authoring.course-outline.configure-modal.advanced-tab.title', defaultMessage: 'Advanced', @@ -169,6 +201,70 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.configure-modal.advanced-tab.time-limit-description', defaultMessage: 'Select a time allotment for the exam. If it is over 24 hours, type in the amount of time. You can grant individual learners extra time to complete the exam through the Instructor Dashboard.', }, + prereqTitle: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.prereqTitle', + defaultMessage: 'Use as a Prerequisite', + }, + prereqCheckboxLabel: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.prereqCheckboxLabel', + defaultMessage: 'Make this subsection available as a prerequisite to other content', + }, + limitAccessTitle: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.limitAccessTitle', + defaultMessage: 'Limit access', + }, + limitAccessDescription: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.limitAccessDescription', + defaultMessage: 'Select a prerequisite subsection and enter a minimum score percentage and minimum completion percentage to limit access to this subsection. Allowed values are 0-100', + }, + noPrerequisiteOption: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.noPrerequisiteOption', + defaultMessage: 'No prerequisite', + }, + prerequisiteSelectLabel: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.prerequisiteSelectLabel', + defaultMessage: 'Prerequisite:', + }, + minScoreLabel: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.minScoreLabel', + defaultMessage: 'Minimum score:', + }, + minCompletionLabel: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.minCompletionLabel', + defaultMessage: 'Minimum completion:', + }, + minScoreError: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.minScoreError', + defaultMessage: 'The minimum score percentage must be a whole number between 0 and 100.', + }, + minCompletionError: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.minCompletionError', + defaultMessage: 'The minimum completion percentage must be a whole number between 0 and 100.', + }, + proctoredExamLockedAndisNotProctoredExamAlert: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.proctoredExamLockedAndisNotProctoredExamAlert', + defaultMessage: 'This subsection was released to learners as a proctored exam, but was reverted back to a basic or timed exam. You may not configure it as a proctored exam now. Contact edX Support for assistance.', + }, + proctoredExamLockedAndisProctoredExamAlert: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.proctoredExamLockedAndisProctoredExamAlert', + defaultMessage: 'This proctored exam has been released to learners. You may not convert it to another type of special exam. You may revert this subsection back to being a basic exam by selecting \'None\', or a timed exam, but you will NOT be able to configure it as a proctored exam in the future.', + }, + reviewRulesLabel: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.reviewRulesLabel', + defaultMessage: 'Review rules', + }, + reviewRulesDescription: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.reviewRulesDescription', + defaultMessage: 'Specify any rules or rule exceptions that the proctoring review team should enforce when reviewing the videos. For example, you could specify that calculators are allowed. These specified rules are visible to learners before the learners start the exam.', + }, + reviewRulesDescriptionWithLink: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.reviewRulesDescriptionWithLink', + defaultMessage: 'Specify any rules or rule exceptions that the proctoring review team should enforce when reviewing the videos. For example, you could specify that calculators are allowed. These specified rules are visible to learners before the learners start the exam, along with the {hyperlink}.', + }, + reviewRulesDescriptionLinkText: { + id: 'course-authoring.course-outline.configure-modal.advanced-tab.reviewRulesDescriptionLinkText', + defaultMessage: 'general proctored exam rules', + }, }); export default messages; diff --git a/src/course-outline/data/api.js b/src/course-outline/data/api.js index d6de77a1c..3c2e03808 100644 --- a/src/course-outline/data/api.js +++ b/src/course-outline/data/api.js @@ -243,11 +243,19 @@ export async function configureCourseSection(sectionId, isVisibleToStaffOnly, st * @param {string} isVisibleToStaffOnly * @param {string} releaseDate * @param {string} graderType - * @param {string} dueDateState - * @param {string} isTimeLimitedState - * @param {string} defaultTimeLimitMin - * @param {string} hideAfterDueState - * @param {string} showCorrectnessState + * @param {string} dueDate + * @param {boolean} isProctoredExam, + * @param {boolean} isOnboardingExam, + * @param {boolean} isPracticeExam, + * @param {string} examReviewRules, + * @param {boolean} isTimeLimited + * @param {number} defaultTimeLimitMin + * @param {string} hideAfterDue + * @param {string} showCorrectness + * @param {boolean} isPrereq, + * @param {string} prereqUsageKey, + * @param {number} prereqMinScore, + * @param {number} prereqMinCompletion, * @returns {Promise} */ export async function configureCourseSubsection( @@ -255,28 +263,40 @@ export async function configureCourseSubsection( isVisibleToStaffOnly, releaseDate, graderType, - dueDateState, - isTimeLimitedState, + dueDate, + isTimeLimited, + isProctoredExam, + isOnboardingExam, + isPracticeExam, + examReviewRules, defaultTimeLimitMin, - hideAfterDueState, - showCorrectnessState, + hideAfterDue, + showCorrectness, + isPrereq, + prereqUsageKey, + prereqMinScore, + prereqMinCompletion, ) { const { data } = await getAuthenticatedHttpClient() .post(getCourseItemApiUrl(itemId), { publish: 'republish', graderType, + isPrereq, + prereqUsageKey, + prereqMinScore, + prereqMinCompletion, metadata: { // The backend expects metadata.visible_to_staff_only to either true or null visible_to_staff_only: isVisibleToStaffOnly ? true : null, - due: dueDateState, - hide_after_due: hideAfterDueState, - show_correctness: showCorrectnessState, - is_practice_exam: false, - is_time_limited: isTimeLimitedState, - exam_review_rules: '', - is_proctored_enabled: false, + due: dueDate, + hide_after_due: hideAfterDue, + show_correctness: showCorrectness, + is_practice_exam: isPracticeExam, + is_time_limited: isTimeLimited, + is_proctored_enabled: isProctoredExam || isPracticeExam || isOnboardingExam, + exam_review_rules: examReviewRules, default_time_limit_minutes: defaultTimeLimitMin, - is_onboarding_exam: false, + is_onboarding_exam: isOnboardingExam, start: releaseDate, }, }); @@ -442,3 +462,13 @@ export async function pasteBlock(parentLocator) { return data; } + +/** + * Dismiss notification + * @param {string} url + * @returns void +*/ +export async function dismissNotification(url) { + await getAuthenticatedHttpClient() + .delete(url); +} diff --git a/src/course-outline/data/selectors.js b/src/course-outline/data/selectors.js index 88d9e9a91..fcf1b4881 100644 --- a/src/course-outline/data/selectors.js +++ b/src/course-outline/data/selectors.js @@ -9,3 +9,4 @@ export const getCurrentSubsection = (state) => state.courseOutline.currentSubsec export const getCourseActions = (state) => state.courseOutline.actions; export const getCustomRelativeDatesActiveFlag = (state) => state.courseOutline.isCustomRelativeDatesActive; export const getInitialUserClipboard = (state) => state.courseOutline.initialUserClipboard; +export const getProctoredExamsFlag = (state) => state.courseOutline.enableProctoredExams; diff --git a/src/course-outline/data/slice.js b/src/course-outline/data/slice.js index 6c2da8988..0e8a3d342 100644 --- a/src/course-outline/data/slice.js +++ b/src/course-outline/data/slice.js @@ -44,6 +44,7 @@ const slice = createSlice({ sourceContexttitle: null, sourceEditUrl: null, }, + enableProctoredExams: false, }, reducers: { fetchOutlineIndexSuccess: (state, { payload }) => { @@ -51,6 +52,7 @@ const slice = createSlice({ state.sectionsList = payload.courseStructure?.childInfo?.children || []; state.isCustomRelativeDatesActive = payload.isCustomRelativeDatesActive; state.initialUserClipboard = payload.initialUserClipboard; + state.enableProctoredExams = payload.courseStructure?.enableProctoredExams; }, updateOutlineIndexLoadingStatus: (state, { payload }) => { state.loadingStatus = { diff --git a/src/course-outline/data/thunk.js b/src/course-outline/data/thunk.js index 611aaa2db..f5f4bd392 100644 --- a/src/course-outline/data/thunk.js +++ b/src/course-outline/data/thunk.js @@ -30,6 +30,7 @@ import { setCourseItemOrderList, copyBlockToClipboard, pasteBlock, + dismissNotification, } from './api'; import { addSection, @@ -261,11 +262,19 @@ export function configureCourseSubsectionQuery( isVisibleToStaffOnly, releaseDate, graderType, - dueDateState, - isTimeLimitedState, + dueDate, + isTimeLimited, + isProctoredExam, + isOnboardingExam, + isPracticeExam, + examReviewRules, defaultTimeLimitMin, - hideAfterDueState, - showCorrectnessState, + hideAfterDue, + showCorrectness, + isPrereq, + prereqUsageKey, + prereqMinScore, + prereqMinCompletion, ) { return async (dispatch) => { dispatch(configureCourseItemQuery( @@ -275,11 +284,19 @@ export function configureCourseSubsectionQuery( isVisibleToStaffOnly, releaseDate, graderType, - dueDateState, - isTimeLimitedState, + dueDate, + isTimeLimited, + isProctoredExam, + isOnboardingExam, + isPracticeExam, + examReviewRules, defaultTimeLimitMin, - hideAfterDueState, - showCorrectnessState, + hideAfterDue, + showCorrectness, + isPrereq, + prereqUsageKey, + prereqMinScore, + prereqMinCompletion, ), )); }; @@ -607,3 +624,17 @@ export function pasteClipboardContent(parentLocator, sectionId) { } }; } + +export function dismissNotificationQuery(url) { + return async (dispatch) => { + dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); + + try { + await dismissNotification(url).then(async () => { + dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); + }); + } catch (error) { + dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } + }; +} diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index 40528f83d..ead67142a 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -52,13 +52,26 @@ import { setUnitOrderListQuery, setClipboardContent, pasteClipboardContent, + dismissNotificationQuery, } from './data/thunk'; const useCourseOutline = ({ courseId }) => { const dispatch = useDispatch(); const navigate = useNavigate(); - const { reindexLink, courseStructure, lmsLink } = useSelector(getOutlineIndexData); + const { + reindexLink, + courseStructure, + lmsLink, + notificationDismissUrl, + discussionsSettings, + discussionsIncontextFeedbackUrl, + discussionsIncontextLearnmoreUrl, + deprecatedBlocksInfo, + proctoringErrors, + mfeProctoredExamSettingsUrl, + advanceSettingsUrl, + } = useSelector(getOutlineIndexData); const { outlineIndexLoadingStatus, reIndexLoadingStatus } = useSelector(getLoadingStatus); const statusBarData = useSelector(getStatusBarData); const savingStatus = useSelector(getSavingStatus); @@ -100,7 +113,7 @@ const useCourseOutline = ({ courseId }) => { const getUnitUrl = (locator) => { if (getConfig().ENABLE_UNIT_PAGE === 'true') { - return `/course/container/${locator}`; + return `/course/${courseId}/container/${locator}`; } return `${getConfig().STUDIO_BASE_URL}/container/${locator}`; }; @@ -239,6 +252,10 @@ const useCourseOutline = ({ courseId }) => { dispatch(setUnitOrderListQuery(sectionId, subsectionId, unitListIds, restoreCallback)); }; + const handleDismissNotification = () => { + dispatch(dismissNotificationQuery(notificationDismissUrl)); + }; + useEffect(() => { dispatch(fetchCourseOutlineIndexQuery(courseId)); dispatch(fetchCourseBestPracticesQuery({ courseId })); @@ -306,6 +323,15 @@ const useCourseOutline = ({ courseId }) => { handleUnitDragAndDrop, handleCopyToClipboardClick, handlePasteClipboardClick, + notificationDismissUrl, + discussionsSettings, + discussionsIncontextFeedbackUrl, + discussionsIncontextLearnmoreUrl, + deprecatedBlocksInfo, + proctoringErrors, + mfeProctoredExamSettingsUrl, + handleDismissNotification, + advanceSettingsUrl, }; }; diff --git a/src/course-outline/messages.js b/src/course-outline/messages.js index b1ff8cde6..b0851f423 100644 --- a/src/course-outline/messages.js +++ b/src/course-outline/messages.js @@ -33,10 +33,6 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.section-list.button.new-section', defaultMessage: 'New section', }, - alertFailedGeneric: { - id: 'course-authoring.course-outline.general.alert.error.description', - defaultMessage: 'Unable to {actionName} {type}. Please try again.', - }, }); export default messages; diff --git a/src/course-outline/page-alerts/PageAlerts.jsx b/src/course-outline/page-alerts/PageAlerts.jsx new file mode 100644 index 000000000..bc882b101 --- /dev/null +++ b/src/course-outline/page-alerts/PageAlerts.jsx @@ -0,0 +1,283 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { getConfig } from '@edx/frontend-platform'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { ErrorAlert } from '@edx/frontend-lib-content-components'; +import { + Campaign as CampaignIcon, + InfoOutline as InfoOutlineIcon, + Warning as WarningIcon, +} from '@edx/paragon/icons'; +import { Alert, Button, Hyperlink } from '@edx/paragon'; + +import { RequestStatus } from '../../data/constants'; +import AlertMessage from '../../generic/alert-message'; +import AlertProctoringError from '../../generic/AlertProctoringError'; +import messages from './messages'; +import advancedSettingsMessages from '../../advanced-settings/messages'; + +const PageAlerts = ({ + notificationDismissUrl, + handleDismissNotification, + discussionsSettings, + discussionsIncontextFeedbackUrl, + discussionsIncontextLearnmoreUrl, + deprecatedBlocksInfo, + proctoringErrors, + mfeProctoredExamSettingsUrl, + advanceSettingsUrl, + savingStatus, +}) => { + const intl = useIntl(); + const studioBaseUrl = getConfig().STUDIO_BASE_URL; + const [showConfigAlert, setShowConfigAlert] = useState(true); + const [showDiscussionAlert, setShowDiscussionAlert] = useState(true); + + const configurationErrors = () => { + if (!notificationDismissUrl) { + return null; + } + + const onDismiss = () => { + setShowConfigAlert(false); + handleDismissNotification(); + }; + + return ( + + ); + }; + + const discussionNotification = () => { + const { providerType } = discussionsSettings || {}; + if (providerType !== 'openedx') { + return null; + } + + const onDismiss = () => { + setShowDiscussionAlert(false); + }; + + return ( + + {intl.formatMessage(messages.discussionNotificationLearnMore)} + , + ]} + > +
+ {intl.formatMessage(messages.discussionNotificationText, { + platformName: process.env.SITE_NAME, + })} +
+ + {intl.formatMessage(messages.discussionNotificationFeedback)} + +
+ ); + }; + + const deprecationWarning = () => { + const { + blocks, + deprecatedEnabledBlockTypes, + } = deprecatedBlocksInfo || {}; + + if (blocks?.length > 0 || deprecatedEnabledBlockTypes?.length > 0) { + return ( + + + {intl.formatMessage(messages.deprecationWarningTitle)} + + {blocks?.length > 0 && ( + <> +
+ {intl.formatMessage(messages.deprecationWarningBlocksText)} +
+
    + {blocks.map(([parentUrl, name]) => ( +
  • + + {name || intl.formatMessage(messages.deprecatedComponentName)} + +
  • + ))} +
+ + )} + {deprecatedEnabledBlockTypes?.length > 0 && ( + <> +
+ + + + ), + }} + /> +
+
    + {deprecatedEnabledBlockTypes.map((name) => ( +
  • + {name} +
  • + ))} +
+ + )} +
+ ); + } + + return null; + }; + + const proctoringAlerts = () => { + if (proctoringErrors?.length > 0) { + return ( + + ); + } + return null; + }; + + return ( + <> + {configurationErrors()} + {discussionNotification()} + {deprecationWarning()} + {proctoringAlerts()} + + {intl.formatMessage(messages.alertFailedGeneric, { actionName: 'save', type: 'changes' })} + + + ); +}; + +PageAlerts.defaultProps = { + notificationDismissUrl: '', + handleDismissNotification: null, + discussionsSettings: {}, + discussionsIncontextFeedbackUrl: '', + discussionsIncontextLearnmoreUrl: '', + deprecatedBlocksInfo: {}, + proctoringErrors: [], + mfeProctoredExamSettingsUrl: '', + advanceSettingsUrl: '', + savingStatus: '', +}; + +PageAlerts.propTypes = { + notificationDismissUrl: PropTypes.string, + handleDismissNotification: PropTypes.func, + discussionsSettings: PropTypes.shape({ + providerType: PropTypes.string, + }), + discussionsIncontextFeedbackUrl: PropTypes.string, + discussionsIncontextLearnmoreUrl: PropTypes.string, + deprecatedBlocksInfo: PropTypes.shape({ + blocks: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)), + deprecatedEnabledBlockTypes: PropTypes.arrayOf(PropTypes.string), + advanceSettingsUrl: PropTypes.string, + }), + proctoringErrors: PropTypes.arrayOf(PropTypes.shape({ + key: PropTypes.string, + message: PropTypes.string, + model: PropTypes.shape({ + deprecated: PropTypes.bool, + displayName: PropTypes.string, + help: PropTypes.string, + hideOnEnabledPublisher: PropTypes.bool, + }), + value: PropTypes.string, + })), + mfeProctoredExamSettingsUrl: PropTypes.string, + advanceSettingsUrl: PropTypes.string, + savingStatus: PropTypes.string, +}; + +export default PageAlerts; diff --git a/src/course-outline/page-alerts/PageAlerts.test.jsx b/src/course-outline/page-alerts/PageAlerts.test.jsx new file mode 100644 index 000000000..c40cafd08 --- /dev/null +++ b/src/course-outline/page-alerts/PageAlerts.test.jsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { act, render, fireEvent } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { initializeMockApp, getConfig } from '@edx/frontend-platform'; + +import PageAlerts from './PageAlerts'; +import messages from './messages'; +import initializeStore from '../../store'; + +jest.mock('@edx/frontend-platform/i18n', () => ({ + ...jest.requireActual('@edx/frontend-platform/i18n'), + useIntl: () => ({ + formatMessage: (message) => message.defaultMessage, + }), +})); + +let store; +const handleDismissNotification = jest.fn(); + +const pageAlertsData = { + notificationDismissUrl: '', + handleDismissNotification: null, + discussionsSettings: {}, + discussionsIncontextFeedbackUrl: '', + discussionsIncontextLearnmoreUrl: '', + deprecatedBlocksInfo: {}, + proctoringErrors: [], + mfeProctoredExamSettingsUrl: '', + advanceSettingsUrl: '', + savingStatus: '', +}; + +const renderComponent = (props) => render( + + + + + , +); + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + }); + + it('renders null when no alerts are present', () => { + const { container } = renderComponent(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders configuration alerts', async () => { + const { queryByText } = renderComponent({ + ...pageAlertsData, + notificationDismissUrl: 'some-url', + handleDismissNotification, + }); + + expect(queryByText(messages.configurationErrorTitle.defaultMessage)).toBeInTheDocument(); + const dismissBtn = queryByText('Dismiss'); + await act(async () => fireEvent.click(dismissBtn)); + + expect(handleDismissNotification).toBeCalled(); + }); + + it('renders discussion alerts', async () => { + const { queryByText } = renderComponent({ + ...pageAlertsData, + discussionsSettings: { + providerType: 'openedx', + }, + discussionsIncontextFeedbackUrl: 'some-feedback-url', + discussionsIncontextLearnmoreUrl: 'some-learn-more-url', + }); + + expect(queryByText(messages.discussionNotificationText.defaultMessage)).toBeInTheDocument(); + const learnMoreBtn = queryByText(messages.discussionNotificationLearnMore.defaultMessage); + expect(learnMoreBtn).toBeInTheDocument(); + expect(learnMoreBtn).toHaveAttribute('href', 'some-learn-more-url'); + + const feedbackLink = queryByText(messages.discussionNotificationFeedback.defaultMessage); + expect(feedbackLink).toBeInTheDocument(); + expect(feedbackLink).toHaveAttribute('href', 'some-feedback-url'); + }); + + it('renders deprecation warning alerts', async () => { + const { queryByText } = renderComponent({ + ...pageAlertsData, + deprecatedBlocksInfo: { + blocks: [['url1', 'block1'], ['url2']], + deprecatedEnabledBlockTypes: ['lti', 'video'], + advanceSettingsUrl: '/some-url', + }, + }); + + expect(queryByText(messages.deprecationWarningTitle.defaultMessage)).toBeInTheDocument(); + expect(queryByText(messages.deprecationWarningBlocksText.defaultMessage)).toBeInTheDocument(); + expect(queryByText('block1')).toHaveAttribute('href', 'url1'); + expect(queryByText(messages.deprecatedComponentName.defaultMessage)).toHaveAttribute('href', 'url2'); + + const feedbackLink = queryByText(messages.advancedSettingLinkText.defaultMessage); + expect(feedbackLink).toBeInTheDocument(); + expect(feedbackLink).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}/some-url`); + expect(queryByText('lti')).toBeInTheDocument(); + expect(queryByText('video')).toBeInTheDocument(); + }); + + it('renders proctoring alerts with mfe settings link', async () => { + const { queryByText } = renderComponent({ + ...pageAlertsData, + mfeProctoredExamSettingsUrl: 'mfe-url', + proctoringErrors: [ + { key: '1', model: { displayName: 'error 1' }, message: 'message 1' }, + { key: '2', model: { displayName: 'error 2' }, message: 'message 2' }, + ], + }); + + expect(queryByText('error 1')).toBeInTheDocument(); + expect(queryByText('error 2')).toBeInTheDocument(); + expect(queryByText('message 1')).toBeInTheDocument(); + expect(queryByText('message 2')).toBeInTheDocument(); + expect(queryByText(messages.proctoredSettingsLinkText.defaultMessage)).toHaveAttribute('href', 'mfe-url'); + }); + + it('renders proctoring alerts without mfe settings link', async () => { + const { queryByText } = renderComponent({ + ...pageAlertsData, + advanceSettingsUrl: '/some-url', + proctoringErrors: [ + { key: '1', model: { displayName: 'error 1' }, message: 'message 1' }, + { key: '2', model: { displayName: 'error 2' }, message: 'message 2' }, + ], + }); + + expect(queryByText('error 1')).toBeInTheDocument(); + expect(queryByText('error 2')).toBeInTheDocument(); + expect(queryByText('message 1')).toBeInTheDocument(); + expect(queryByText('message 2')).toBeInTheDocument(); + expect(queryByText(messages.advancedSettingLinkText.defaultMessage)).toHaveAttribute( + 'href', + `${getConfig().STUDIO_BASE_URL}/some-url`, + ); + }); +}); diff --git a/src/course-outline/page-alerts/messages.js b/src/course-outline/page-alerts/messages.js new file mode 100644 index 000000000..5964dbdf1 --- /dev/null +++ b/src/course-outline/page-alerts/messages.js @@ -0,0 +1,62 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + configurationErrorTitle: { + id: 'course-authoring.course-outline.page-alerts.configurationErrorTitle', + defaultMessage: 'This course was created as a re-run. Some manual configuration is needed.', + }, + configurationErrorText: { + id: 'course-authoring.course-outline.page-alerts.configurationErrorText', + defaultMessage: 'No course content is currently visible, and no learners are enrolled. Be sure to review and reset all dates, including the Course Start Date; set up the course team; review course updates and other assets for dated material; and seed the discussions and wiki.', + }, + discussionNotificationText: { + id: 'course-authoring.course-outline.page-alerts.discussionNotificationText', + defaultMessage: 'This course run is using an upgraded version of {platformName} discussion forum. In order to display the discussions sidebar, discussions xBlocks will no longer be visible to learners.', + }, + discussionNotificationLearnMore: { + id: 'course-authoring.course-outline.page-alerts.discussionNotificationLearnMore', + defaultMessage: 'Learn more', + }, + discussionNotificationFeedback: { + id: 'course-authoring.course-outline.page-alerts.discussionNotificationLearnMore', + defaultMessage: 'Share feedback', + }, + deprecationWarningTitle: { + id: 'course-authoring.course-outline.page-alerts.deprecationWarningTitle', + defaultMessage: 'This course uses features that are no longer supported.', + }, + deprecationWarningBlocksText: { + id: 'course-authoring.course-outline.page-alerts.deprecationWarningBlocksText', + defaultMessage: 'You must delete or replace the following components.', + }, + deprecationWarningDeprecatedBlockText: { + id: 'course-authoring.course-outline.page-alerts.deprecationWarningDeprecatedBlockText', + defaultMessage: 'To avoid errors, {platformName} strongly recommends that you remove unsupported features from the course advanced settings. To do this, go to the {hyperlink}, locate the "Advanced Module List" setting, and then delete the following modules from the list.', + }, + advancedSettingLinkText: { + id: 'course-authoring.course-outline.page-alerts.advancedSettingLinkText', + defaultMessage: 'Advanced Settings page', + }, + deprecatedComponentName: { + id: 'course-authoring.course-outline.page-alerts.deprecatedComponentName', + defaultMessage: 'Deprecated Component', + }, + proctoringErrorTitle: { + id: 'course-authoring.course-outline.page-alerts.proctoringErrorTitle', + defaultMessage: 'This course has proctored exam settings that are incomplete or invalid.', + }, + proctoringErrorText: { + id: 'course-authoring.course-outline.page-alerts.proctoringErrorText', + defaultMessage: 'To update these settings go to the {hyperlink}.', + }, + proctoredSettingsLinkText: { + id: 'course-authoring.course-outline.page-alerts.proctoredSettingsLinkText', + defaultMessage: 'Proctored Exam Settings page', + }, + alertFailedGeneric: { + id: 'course-authoring.course-outline.page-alert.generic-error.description', + defaultMessage: 'Unable to {actionName} {type}. Please try again.', + }, +}); + +export default messages; diff --git a/src/course-outline/section-card/SectionCard.jsx b/src/course-outline/section-card/SectionCard.jsx index 07fe2c1cc..8d2f2b173 100644 --- a/src/course-outline/section-card/SectionCard.jsx +++ b/src/course-outline/section-card/SectionCard.jsx @@ -4,14 +4,13 @@ import React, { import PropTypes from 'prop-types'; import { useDispatch } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Badge, Button, useToggle } from '@edx/paragon'; +import { Bubble, Button, useToggle } from '@edx/paragon'; import { Add as IconAdd } from '@edx/paragon/icons'; import classNames from 'classnames'; import { setCurrentItem, setCurrentSection } from '../data/slice'; import { RequestStatus } from '../../data/constants'; 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'; @@ -123,16 +122,11 @@ const SectionCard = ({ const titleComponent = ( - - + /> ); const isDraggable = actions.draggable && (actions.allowMoveUp || actions.allowMoveDown); @@ -183,9 +177,9 @@ const SectionCard = ({ variant="tertiary" onClick={handleOpenHighlightsModal} > - + {highlights.length} - +

{messages.sectionHighlightsBadge.defaultMessage}

diff --git a/src/course-outline/section-card/SectionCard.scss b/src/course-outline/section-card/SectionCard.scss index 25e3c688c..6eae48677 100644 --- a/src/course-outline/section-card/SectionCard.scss +++ b/src/course-outline/section-card/SectionCard.scss @@ -13,14 +13,6 @@ color: $headings-color; } - - .highlights-badge { - width: 1.5rem; - height: 1.5rem; - border-radius: 1.375rem; - font-size: 1rem; - } - .section-card__content { margin-left: 1.7rem; } diff --git a/src/course-outline/status-bar/StatusBar.jsx b/src/course-outline/status-bar/StatusBar.jsx index 4779b3a70..3ff28209f 100644 --- a/src/course-outline/status-bar/StatusBar.jsx +++ b/src/course-outline/status-bar/StatusBar.jsx @@ -1,8 +1,9 @@ import React, { useContext } from 'react'; +import moment from 'moment/moment'; import PropTypes from 'prop-types'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { FormattedDate, useIntl } from '@edx/frontend-platform/i18n'; import { - Button, Hyperlink, SelectMenu, MenuItem, Stack, + Button, Hyperlink, Form, Stack, } from '@edx/paragon'; import { AppContext } from '@edx/frontend-platform/react'; @@ -37,6 +38,7 @@ const StatusBar = ({ totalCourseBestPracticesChecks, } = checklist; + const courseReleaseDateObj = moment.utc(courseReleaseDate, 'MMM DD, YYYY at HH:mm UTC', true); const checkListTitle = `${completedCourseLaunchChecks + completedCourseBestPracticesChecks}/${totalCourseLaunchChecks + totalCourseBestPracticesChecks}`; const checklistDestination = () => new URL(`checklists/${courseId}`, config.STUDIO_BASE_URL).href; const scheduleDestination = () => new URL(`settings/details/${courseId}#schedule`, config.STUDIO_BASE_URL).href; @@ -52,18 +54,27 @@ const StatusBar = ({ } return ( - -
+ +
{intl.formatMessage(messages.startDateTitle)}
- {courseReleaseDate} + {courseReleaseDateObj.isValid() ? ( + + ) : courseReleaseDate}
-
+
{intl.formatMessage(messages.pacingTypeTitle)}
{isSelfPaced @@ -71,7 +82,7 @@ const StatusBar = ({ : intl.formatMessage(messages.pacingTypeInstructorPaced)}
-
+
{intl.formatMessage(messages.checklistTitle)}
-
+
{intl.formatMessage(messages.highlightEmailsTitle)}
-
+
{highlightsEnabledForMessaging ? ( {intl.formatMessage(messages.highlightEmailsEnabled)} @@ -104,26 +115,31 @@ const StatusBar = ({
{videoSharingEnabled && ( -
-
{intl.formatMessage(messages.videoSharingTitle)}
-
- + {intl.formatMessage(messages.videoSharingTitle)} + +
+ handleVideoSharingOptionChange(e.target.value)} + > {Object.values(VIDEO_SHARING_OPTIONS).map((option) => ( - handleVideoSharingOptionChange(option)} > {getVideoSharingOptionText(option, messages, intl)} - + ))} - +
-
+ + )} ); diff --git a/src/course-outline/status-bar/StatusBar.test.jsx b/src/course-outline/status-bar/StatusBar.test.jsx index 9b17be01b..5891720be 100644 --- a/src/course-outline/status-bar/StatusBar.test.jsx +++ b/src/course-outline/status-bar/StatusBar.test.jsx @@ -74,10 +74,10 @@ describe('', () => { }); it('renders StatusBar component correctly', () => { - const { queryByTestId, getByText } = renderComponent(); + const { getByText } = renderComponent(); expect(getByText(messages.startDateTitle.defaultMessage)).toBeInTheDocument(); - expect(getByText(statusBarData.courseReleaseDate)).toBeInTheDocument(); + expect(getByText('Feb 05, 2013, 5:00 AM')).toBeInTheDocument(); expect(getByText(messages.pacingTypeTitle.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.pacingTypeSelfPaced.defaultMessage)).toBeInTheDocument(); @@ -88,7 +88,7 @@ describe('', () => { expect(getByText(messages.highlightEmailsTitle.defaultMessage)).toBeInTheDocument(); expect(getByText(messages.highlightEmailsEnabled.defaultMessage)).toBeInTheDocument(); - expect(queryByTestId('video-sharing-wrapper')).toBeInTheDocument(); + expect(getByText(messages.videoSharingTitle.defaultMessage)).toBeInTheDocument(); }); it('renders StatusBar when isSelfPaced is false', () => { diff --git a/src/course-outline/subsection-card/SubsectionCard.jsx b/src/course-outline/subsection-card/SubsectionCard.jsx index 8de54ec27..9a40e130f 100644 --- a/src/course-outline/subsection-card/SubsectionCard.jsx +++ b/src/course-outline/subsection-card/SubsectionCard.jsx @@ -11,7 +11,6 @@ import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data import { RequestStatus } from '../../data/constants'; import { COURSE_BLOCK_NAMES } from '../constants'; 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'; @@ -55,6 +54,7 @@ const SubsectionCard = ({ actions: subsectionActions, isHeaderVisible = true, enableCopyPasteUnits = false, + proctoringExamConfigurationLink, } = subsection; // re-create actions object for customizations @@ -103,16 +103,11 @@ const SubsectionCard = ({ const titleComponent = ( - - + /> ); useEffect(() => { @@ -168,6 +163,8 @@ const SubsectionCard = ({ titleComponent={titleComponent} namePrefix={namePrefix} actions={actions} + proctoringExamConfigurationLink={proctoringExamConfigurationLink} + isSequential />
{ const currentRef = useRef(null); const dispatch = useDispatch(); @@ -44,6 +44,7 @@ const UnitCard = ({ actions: unitActions, isHeaderVisible = true, enableCopyPasteUnits = false, + discussionEnabled, } = unit; // re-create actions object for customizations @@ -52,6 +53,11 @@ const UnitCard = ({ actions.allowMoveUp = canMoveItem(index, -1); actions.allowMoveDown = canMoveItem(index, 1); + const parentInfo = { + graded: subsection.graded, + isTimeLimited: subsection.isTimeLimited, + }; + const unitStatus = getItemStatus({ published, visibilityState, @@ -88,15 +94,10 @@ const UnitCard = ({ const titleComponent = ( - - + /> ); useEffect(() => { @@ -157,6 +158,9 @@ const UnitCard = ({ isVertical enableCopyPasteUnits={enableCopyPasteUnits} onClickCopy={handleCopyClick} + discussionEnabled={discussionEnabled} + discussionsSettings={discussionsSettings} + parentInfo={parentInfo} />
( +const AlertProctoringError = ({ proctoringErrorsData, children, ...props }) => (
    + {children} {proctoringErrorsData.map(({ key, model, message }) => (
  • {model.displayName} @@ -17,6 +18,7 @@ const AlertProctoringError = ({ proctoringErrorsData, ...props }) => ( AlertProctoringError.propTypes = { variant: PropTypes.string, + children: PropTypes.node, proctoringErrorsData: PropTypes.arrayOf(PropTypes.shape({ key: PropTypes.string, message: PropTypes.string, @@ -32,6 +34,7 @@ AlertProctoringError.propTypes = { AlertProctoringError.defaultProps = { variant: 'danger', + children: null, }; export default AlertProctoringError; diff --git a/src/generic/FormikControl.jsx b/src/generic/FormikControl.jsx index 981e3be02..f75c4833a 100644 --- a/src/generic/FormikControl.jsx +++ b/src/generic/FormikControl.jsx @@ -10,6 +10,7 @@ const FormikControl = ({ label, help, className, + controlClasses, ...params }) => { const { @@ -25,7 +26,7 @@ const FormikControl = ({ , label: <>, className: '', + controlClasses: 'pb-2', }; export default FormikControl; From 51c5f9c4dced25f4b6af3070edcc95d6a58fe7cc Mon Sep 17 00:00:00 2001 From: Ihor Romaniuk Date: Wed, 14 Feb 2024 19:38:54 +0100 Subject: [PATCH 09/11] refactor: Unit page - refactoring breadcrumbs, view live and preview links buttons (#827) --- .env | 1 - .env.development | 1 - .env.test | 1 - src/course-unit/CourseUnit.jsx | 4 +- src/course-unit/CourseUnit.test.jsx | 16 ++-- src/course-unit/breadcrumbs/Breadcrumbs.jsx | 83 ++++++------------- src/course-unit/breadcrumbs/Breadcrumbs.scss | 4 + .../breadcrumbs/Breadcrumbs.test.jsx | 38 ++------- src/course-unit/hooks.jsx | 13 ++- src/course-unit/utils.jsx | 23 ----- src/index.jsx | 1 - src/setupTest.js | 1 - 12 files changed, 53 insertions(+), 133 deletions(-) delete mode 100644 src/course-unit/utils.jsx diff --git a/.env b/.env index 7c027d771..d0bb7ec0d 100644 --- a/.env +++ b/.env @@ -9,7 +9,6 @@ EXAMS_BASE_URL='' FAVICON_URL='' LANGUAGE_PREFERENCE_COOKIE_NAME='' LMS_BASE_URL='' -PREVIEW_BASE_URL='' LEARNING_BASE_URL='' LOGIN_URL='' LOGO_TRADEMARK_URL='' diff --git a/.env.development b/.env.development index 31c11a560..045c52f2d 100644 --- a/.env.development +++ b/.env.development @@ -42,4 +42,3 @@ HOTJAR_VERSION=6 HOTJAR_DEBUG=true INVITE_STUDENTS_EMAIL_TO="someone@domain.com" AI_TRANSLATIONS_BASE_URL='http://localhost:18760' -PREVIEW_BASE_URL='http://preview.localhost:18000' diff --git a/.env.test b/.env.test index 6a6b7d4dd..67ad2994b 100644 --- a/.env.test +++ b/.env.test @@ -8,7 +8,6 @@ EXAMS_BASE_URL= FAVICON_URL='https://edx-cdn.org/v3/default/favicon.ico' LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference' LMS_BASE_URL='http://localhost:18000' -PREVIEW_BASE_URL='http://preview.localhost:18000' LEARNING_BASE_URL='http://localhost:2000' LOGIN_URL='http://localhost:18000/login' LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index b532fe0be..28d3675f0 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -66,9 +66,7 @@ const CourseUnit = ({ courseId }) => { /> )} breadcrumbs={( - + )} headerActions={( ', () => { const { open } = window; window.open = jest.fn(); const { getByRole } = render(); + const { + draft_preview_link: draftPreviewLink, + published_preview_link: publishedPreviewLink, + } = courseSectionVerticalMock; await waitFor(() => { const viewLiveButton = getByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage }); userEvent.click(viewLiveButton); expect(window.open).toHaveBeenCalled(); - const VIEW_LIVE_LINK = getConfig().LMS_BASE_URL + getUnitViewLivePath(courseId, blockId); - expect(window.open).toHaveBeenCalledWith(VIEW_LIVE_LINK, '_blank'); + expect(window.open).toHaveBeenCalledWith(publishedPreviewLink, '_blank'); const previewButton = getByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage }); userEvent.click(previewButton); expect(window.open).toHaveBeenCalled(); - // eslint-disable-next-line max-len - const PREVIEW_LINK = getConfig().PREVIEW_BASE_URL + getUnitPreviewPath(courseId, sectionId, subsectionId, blockId); - expect(window.open).toHaveBeenCalledWith(PREVIEW_LINK, '_blank'); + expect(window.open).toHaveBeenCalledWith(draftPreviewLink, '_blank'); }); window.open = open; diff --git a/src/course-unit/breadcrumbs/Breadcrumbs.jsx b/src/course-unit/breadcrumbs/Breadcrumbs.jsx index ff09ce98e..17e995364 100644 --- a/src/course-unit/breadcrumbs/Breadcrumbs.jsx +++ b/src/course-unit/breadcrumbs/Breadcrumbs.jsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Dropdown, Icon } from '@edx/paragon'; @@ -7,34 +6,14 @@ import { ChevronRight as ChevronRightIcon, } from '@edx/paragon/icons'; -import { useCourseOutline } from '../../course-outline/hooks'; -import { getCourseUnitData } from '../data/selectors'; +import { createCorrectInternalRoute } from '../../utils'; +import { getCourseSectionVertical } from '../data/selectors'; import messages from './messages'; -const Breadcrumbs = ({ courseId }) => { +const Breadcrumbs = () => { const intl = useIntl(); - const { ancestorInfo } = useSelector(getCourseUnitData); - const { sectionsList, isLoading: isLoadingCourseOutline } = useCourseOutline({ courseId }); - const activeCourseSectionInfo = sectionsList.find((block) => block.id === ancestorInfo?.ancestors[1]?.id); - - const breadcrumbs = { - section: { - id: ancestorInfo?.ancestors[1]?.id, - displayName: ancestorInfo?.ancestors[1]?.displayName, - dropdownItems: sectionsList, - }, - subsection: { - id: ancestorInfo?.ancestors[0]?.id, - displayName: ancestorInfo?.ancestors[0]?.displayName, - dropdownItems: activeCourseSectionInfo?.childInfo.children || [], - }, - }; - - const getLoadingPlaceholder = () => ( -
    - {intl.formatMessage(messages.loading)} -
    - ); + const { ancestorXblocks } = useSelector(getCourseSectionVertical); + const [section, subsection] = ancestorXblocks ?? []; return (