diff --git a/.env.development b/.env.development index 03658123df..8f8636436b 100644 --- a/.env.development +++ b/.env.development @@ -37,7 +37,7 @@ ENABLE_NEW_VIDEO_UPLOAD_PAGE = false ENABLE_UNIT_PAGE = true ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN = false ENABLE_TAGGING_TAXONOMY_PAGES = true -ENABLE_NEW_COURSE_OUTLINE_PAGE = true +ENABLE_NEW_COURSE_OUTLINE_PAGE = false BBB_LEARN_MORE_URL='' HOTJAR_APP_ID='' HOTJAR_VERSION=6 diff --git a/package-lock.json b/package-lock.json index cfba25d9d7..0fece87770 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "react-transition-group": "4.4.5", "redux": "4.0.5", "regenerator-runtime": "0.13.7", + "rosie": "^2.1.1", "universal-cookie": "^4.0.4", "uuid": "^3.4.0", "yup": "0.31.1" @@ -24354,6 +24355,14 @@ "rimraf": "bin.js" } }, + "node_modules/rosie": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/rosie/-/rosie-2.1.1.tgz", + "integrity": "sha512-2AXB7WrIZXtKMZ6Q/PlozqPF5nu/x7NEvRJZOblrJuprrPfm5gL8JVvJPj9aaib9F8IUALnLUFhzXrwEtnI5cQ==", + "engines": { + "node": ">=10" + } + }, "node_modules/rst-selector-parser": { "version": "2.2.3", "dev": true, diff --git a/package.json b/package.json index c928b89ba2..530994b58b 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "react-transition-group": "4.4.5", "redux": "4.0.5", "regenerator-runtime": "0.13.7", + "rosie": "^2.1.1", "universal-cookie": "^4.0.4", "uuid": "^3.4.0", "yup": "0.31.1" diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index 90880b1ca9..cec1d4289b 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -1,6 +1,6 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; -import { useSelector } from 'react-redux'; +import { useSelector, useDispatch } from 'react-redux'; import { useParams } from 'react-router-dom'; import { useIntl, injectIntl } from '@edx/frontend-platform/i18n'; import { Container, Layout } from '@edx/paragon'; @@ -13,6 +13,7 @@ import ProcessingNotification from '../generic/processing-notification'; import InternetConnectionAlert from '../generic/internet-connection-alert'; import { getProcessingNotification } from '../generic/processing-notification/data/selectors'; import Sequence from './course-sequence'; +import { fetchSequence, fetchCourse } from './data/thunk'; import { useCourseUnit } from './hooks'; import messages from './messages'; @@ -21,6 +22,7 @@ import './CourseUnit.scss'; const CourseUnit = ({ courseId }) => { const { sequenceId, blockId } = useParams(); const intl = useIntl(); + const dispatch = useDispatch(); const { isLoading, breadcrumbsData, @@ -33,7 +35,7 @@ const CourseUnit = ({ courseId }) => { handleTitleEdit, handleInternetConnectionFailed, } = useCourseUnit({ intl, courseId, blockId }); - // console.log('blockId', blockId); + document.title = getPageHeadTitle('', unitTitle); const { @@ -41,13 +43,10 @@ const CourseUnit = ({ courseId }) => { title: processingNotificationTitle, } = useSelector(getProcessingNotification); - const handleUnitNavigationClick = () => { - console.log('handleUnitNavigationClick'); - }; - - const handleNextSequenceClick = () => {}; - - const handlePreviousSequenceClick = () => {}; + useEffect(() => { + dispatch(fetchSequence(sequenceId)); + dispatch(fetchCourse(courseId)); + }, []); if (isLoading) { // eslint-disable-next-line react/jsx-no-useless-fragment @@ -74,9 +73,6 @@ const CourseUnit = ({ courseId }) => { courseId={courseId} sequenceId={sequenceId} unitId={blockId} - unitNavigationHandler={handleUnitNavigationClick} - nextSequenceHandler={handleNextSequenceClick} - previousSequenceHandler={handlePreviousSequenceClick} /> { - const { sequenceStatus } = useSelector(state => state.courseUnit); - // const course = useModel('coursewareMeta', courseId); const sequence = useModel('sequences', sequenceId); - const dispatch = useDispatch(); - // const unit = useModel('units', unitId); - const globalStore = useSelector(state => state); - // const courseDetails = useModel('courseDetails', courseId); - - const { - isFirstUnit, isLastUnit, nextLink, previousLink, - } = useSequenceNavigationMetadata( - sequenceId, - unitId, - ); - // console.log('isFirstUnit', isFirstUnit); - // console.log('isLastUnit', isLastUnit); - - useEffect(() => { - dispatch(fetchSequence(sequenceId)); - dispatch(fetchCourse(courseId)); - }, []); - console.log('globalStore', globalStore); + const shouldDisplayNotificationTriggerInSequence = useWindowSize().width < breakpoints.small.minWidth; + const sequenceStatus = useSelector(state => state.courseUnit.sequenceStatus); const handleNavigate = (destinationUnitId) => { unitNavigationHandler(destinationUnitId); @@ -71,82 +42,44 @@ const Sequence = ({ } }; - const renderPreviousButton = () => { - const disabled = isFirstUnit; - - return ( - - ); - }; - - const renderNextButton = () => { - const disabled = isLastUnit; - - return ( - - ); - }; + const defaultContent = ( +
+
+ { + handleNext(); + }} + onNavigate={(destinationUnitId) => { + handleNavigate(destinationUnitId); + }} + previousHandler={() => { + handlePrevious(); + }} + /> +
+
+ ); - const renderUnitButtons = () => { - // if (isLocked) { - // return ( - // {}} /> - // ); - // } - if (sequence.unitIds.length === 0) { - return ( -
- ); - } + if (sequenceStatus === 'LOADED') { return ( - { - handleNavigate(destinationUnitId); - }} - /> +
+ {defaultContent} +
); - }; + } - return sequenceStatus === 'LOADED' && ( - - {renderPreviousButton()} - {renderUnitButtons()} - {renderNextButton()} - + // sequence status 'failed' and any other unexpected sequence status. + return ( +

+ failed +

); }; Sequence.propTypes = { - intl: intlShape.isRequired, unitId: PropTypes.string, courseId: PropTypes.string.isRequired, sequenceId: PropTypes.string, diff --git a/src/course-unit/course-sequence/hooks.js b/src/course-unit/course-sequence/hooks.js index 68c7cd6802..757dfddb52 100644 --- a/src/course-unit/course-sequence/hooks.js +++ b/src/course-unit/course-sequence/hooks.js @@ -1,24 +1,12 @@ import { useSelector } from 'react-redux'; +import { useLayoutEffect, useRef, useState } from 'react'; +import { useWindowSize } from '@edx/paragon'; + import { useModel } from '../../generic/model-store'; import { sequenceIdsSelector } from '../data/selectors'; -// eslint-disable-next-line import/prefer-default-export export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId) { - // const sequenceIds = [ - // 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction', - // 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5', - // 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions', - // 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@simulations', - // 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@graded_simulations', - // 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@175e76c4951144a29d46211361266e0e', - // 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@48ecb924d7fe4b66a230137626bfa93e', - // 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@dbe8fc027bcb4fe9afb744d2e8415855', - // 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@6ab9c442501d472c8ed200e367b4edfa', - // 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@workflow', - // ]; - const sequenceIds = useSelector(sequenceIdsSelector); - console.log('sequenceIds >>>>>>>>>>>>>>>>>>>>>>>', sequenceIds); const sequence = useModel('sequences', currentSequenceId); const { courseId, status } = useSelector(state => state.courseDetail); const courseStatus = status; @@ -29,15 +17,14 @@ export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId) return { isFirstUnit: false, isLastUnit: false }; } - const sequenceIndex = sequenceIds.indexOf(currentSequenceId); - // console.log('HELLOOOOOOOOOO', sequenceIndex); - const unitIndex = sequence.unitIds.indexOf(currentUnitId); + 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 isLastUnitInSequence = unitIndex === sequence.unitIds?.length - 1; const isLastUnit = isLastSequence && isLastUnitInSequence; const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null; @@ -48,11 +35,12 @@ export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId) nextLink = `/course/${courseId}/course-end`; } else { const nextIndex = unitIndex + 1; - if (nextIndex < sequence.unitIds.length) { + if (nextIndex < sequence.unitIds?.length) { const nextUnitId = sequence.unitIds[nextIndex]; nextLink = `/course/${courseId}/container/${nextUnitId}/${currentSequenceId}`; } else if (nextSequenceId) { - nextLink = `/course/${courseId}/container/${nextSequenceId}/first`; + const NEXT_URL_FROM_BACKEND = 'container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@867dddb6f55d410caaa9c1eb9c6743ec'; + nextLink = `/course/${courseId}/${NEXT_URL_FROM_BACKEND}/${nextSequenceId}`; } } @@ -62,10 +50,86 @@ export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId) const previousUnitId = sequence.unitIds[previousIndex]; previousLink = `/course/${courseId}/container/${previousUnitId}/${currentSequenceId}`; } else if (previousSequenceId) { - previousLink = `/course/${courseId}/${previousSequenceId}/last`; + const PREV_URL_FROM_BACKEND = 'container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@b74b638511c94ec880b5ef897fe7f2c8'; + previousLink = `/course/${courseId}/${PREV_URL_FROM_BACKEND}/${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/sequence-navigation/SequenceNavigation.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx new file mode 100644 index 0000000000..1252d3ab91 --- /dev/null +++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx @@ -0,0 +1,125 @@ +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, ChevronRight } from '@edx/paragon/icons'; +import { Link } from 'react-router-dom'; +import { useSelector } from 'react-redux'; + +import { useModel } from '../../../generic/model-store'; +import messages from '../messages'; +import { useSequenceNavigationMetadata } from '../hooks'; +import SequenceNavigationTabs from './SequenceNavigationTabs'; + +const SequenceNavigation = ({ + intl, + courseId, + sequenceId, + unitId, + nextHandler, + onNavigate, + previousHandler, + className, +}) => { + const { sequenceStatus } = useSelector(state => state.courseUnit); + 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 disabled = isFirstUnit; + const prevArrow = isRtl(getLocale()) ? ChevronRight : ChevronLeft; + + return ( + + ); + }; + + const renderNextButton = () => { + const buttonText = intl.formatMessage(messages.nextBtnText); + const disabled = isLastUnit; + const nextArrow = isRtl(getLocale()) ? ChevronLeft : ChevronRight; + + return ( + + ); + }; + + return sequenceStatus === 'LOADED' && ( + + ); +}; + +SequenceNavigation.propTypes = { + intl: intlShape.isRequired, + unitId: PropTypes.string, + className: PropTypes.string, + courseId: PropTypes.string.isRequired, + sequenceId: PropTypes.string, + onNavigate: PropTypes.func.isRequired, + nextHandler: PropTypes.func.isRequired, + previousHandler: PropTypes.func.isRequired, +}; + +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 0000000000..df1a234eca --- /dev/null +++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationDropdown.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Dropdown } from '@edx/paragon'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; + +import UnitButton from './UnitButton'; + +const SequenceNavigationDropdown = ({ + unitId, + onNavigate, + unitIds, +}) => ( + + + + + + {unitIds.map(buttonUnitId => ( + + ))} + + +); + +SequenceNavigationDropdown.propTypes = { + unitId: PropTypes.string.isRequired, + onNavigate: PropTypes.func.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 index 1690ac2221..a02d3599af 100644 --- a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx +++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx @@ -1,45 +1,62 @@ -import { Stack, Button } from '@edx/paragon'; +import { Button } from '@edx/paragon'; import { Plus } from '@edx/paragon/icons'; import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; +import { useIndexOfLastVisibleChild } from '../hooks'; +import SequenceNavigationDropdown from './SequenceNavigationDropdown'; + import UnitButton from './UnitButton'; -/* eslint-disable react/prop-types */ -const SequenceNavigationTabs = ({ - unitIds, - unitId, - sequenceId, - courseId, - onNavigate, -}) => { - // console.log('unitId', unitId); - // console.log('unitIds', unitIds); + +const SequenceNavigationTabs = ({ unitIds, unitId, onNavigate }) => { + const [ + indexOfLastVisibleChild, + containerRef, + invisibleStyle, + ] = useIndexOfLastVisibleChild(); + const shouldDisplayDropdown = indexOfLastVisibleChild === -1; return ( - - {unitIds.map(buttonUnitId => ( - +
+
+ {unitIds?.map(buttonUnitId => ( + + ))} + +
+
+ {shouldDisplayDropdown && ( + - ))} - -
+ )} +
); }; SequenceNavigationTabs.propTypes = { - unitIds: PropTypes.array.isRequired, + unitId: PropTypes.string.isRequired, + onNavigate: PropTypes.func.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 index c6da66f884..bcabecead4 100644 --- a/src/course-unit/course-sequence/sequence-navigation/UnitButton.jsx +++ b/src/course-unit/course-sequence/sequence-navigation/UnitButton.jsx @@ -2,24 +2,23 @@ import { useCallback } from 'react'; import PropTypes from 'prop-types'; import { Button } from '@edx/paragon'; import { Link } from 'react-router-dom'; -import classNames from 'classnames'; import { connect, useSelector } from 'react-redux'; import UnitIcon from './UnitIcon'; -/* eslint-disable react/prop-types */ const UnitButton = ({ - isActive, onClick, title, unitId, contentType, + onClick, title, contentType, isActive, unitId, className, showTitle, }) => { const { courseId } = useSelector(state => state.courseDetail); const { sequenceId } = useSelector(state => state.courseUnit); + const handleClick = useCallback(() => { onClick(unitId); }, [onClick, unitId]); return ( ); }; UnitButton.propTypes = { - isActive: PropTypes.bool.isRequired, + className: PropTypes.string, + contentType: PropTypes.string.isRequired, + isActive: PropTypes.bool, + showTitle: PropTypes.bool, + onClick: PropTypes.func.isRequired, + title: PropTypes.string.isRequired, + unitId: PropTypes.string.isRequired, +}; + +UnitButton.defaultProps = { + className: undefined, + isActive: false, + showTitle: false, }; const mapStateToProps = (state, props) => { diff --git a/src/course-unit/course-sequence/sequence-navigation/UnitIcon.jsx b/src/course-unit/course-sequence/sequence-navigation/UnitIcon.jsx index fb1366bb2d..07721dbeed 100644 --- a/src/course-unit/course-sequence/sequence-navigation/UnitIcon.jsx +++ b/src/course-unit/course-sequence/sequence-navigation/UnitIcon.jsx @@ -8,34 +8,43 @@ import { Lock as LockIcon, } from '@edx/paragon/icons'; +export const UNIT_ICON_TYPES = ['video', 'other', 'vertical', 'problem', 'lock']; + const UnitIcon = ({ type }) => { let icon = null; + let srText = null; switch (type) { case 'video': icon = VideoCameraIcon; + srText = type; break; case 'other': icon = BookOpenIcon; + srText = type; break; case 'vertical': icon = FormatListBulletedIcon; + srText = type; break; case 'problem': icon = EditIcon; + srText = type; break; case 'lock': icon = LockIcon; + srText = type; break; default: icon = BookOpenIcon; + srText = type; } - return (); + return (); }; UnitIcon.propTypes = { - type: PropTypes.oneOf(['video', 'other', 'vertical', 'problem', 'lock']).isRequired, + type: PropTypes.oneOf(UNIT_ICON_TYPES).isRequired, }; export default UnitIcon; diff --git a/src/course-unit/course-sequence/sequence-navigation/UnitIcon.test.jsx b/src/course-unit/course-sequence/sequence-navigation/UnitIcon.test.jsx new file mode 100644 index 0000000000..7ef7cd1bf4 --- /dev/null +++ b/src/course-unit/course-sequence/sequence-navigation/UnitIcon.test.jsx @@ -0,0 +1,41 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { Factory } from 'rosie'; + +import { initializeTestStore, render } from '../../../setupTest'; +import UnitIcon from './UnitIcon'; + +describe('Unit Icon', () => { + const types = { + video: 'video', + other: 'other', + vertical: 'vertical', + problem: 'problem', + lock: 'lock', + undefined: 'undefined', + }; + + const courseMetadata = Factory.build('courseMetadata'); + const unitBlocks = Object.keys(types).map(contentType => Factory.build( + 'block', + { id: contentType, type: contentType }, + { courseId: courseMetadata.id }, + )); + + beforeAll(async () => { + await initializeTestStore({ courseMetadata, unitBlocks }); + }); + + unitBlocks.forEach(block => { + it(`renders correct icon for ${block.type} unit`, () => { + // Suppress warning for undefined prop type. + if (block.type === 'undefined') { + jest.spyOn(console, 'error').mockImplementation(() => {}); + } + + const { container, getByText } = render(); + const srOnlyElement = getByText(types[block.type], { selector: '.sr-only' }); + expect(srOnlyElement).toBeInTheDocument(); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/course-unit/data/__factories__/courseMetadata.factory.js b/src/course-unit/data/__factories__/courseMetadata.factory.js new file mode 100644 index 0000000000..508f11026d --- /dev/null +++ b/src/course-unit/data/__factories__/courseMetadata.factory.js @@ -0,0 +1,61 @@ +import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies + +import courseMetadataBase from '../../../shared/data/__factories__/courseMetadataBase.factory'; + +Factory.define('courseMetadata') + .extend(courseMetadataBase) + .option('host', '') + .attrs({ + access_expiration: { + expiration_date: '2032-02-22T05:00:00Z', + }, + content_type_gating_enabled: false, + course_expired_message: null, + course_goals: { + goal_options: [], + selected_goal: { + days_per_week: 1, + subscribed_to_reminders: true, + }, + }, + end: null, + enrollment_start: null, + enrollment_end: null, + name: 'Demonstration Course', + offer_html: null, + short_description: null, + start: '2013-02-05T05:00:00Z', + start_display: 'Feb. 5, 2013', + start_type: 'timestamp', + pacing: 'instructor', + enrollment: { + mode: null, + is_active: null, + }, + show_calculator: false, + license: 'all-rights-reserved', + notes: { + visible: true, + enabled: false, + }, + marketing_url: null, + celebrations: null, + enroll_alert: null, + course_exit_page_is_active: true, + user_has_passing_grade: false, + certificate_data: null, + entrance_exam_data: { + entrance_exam_current_score: 0.0, + entrance_exam_enabled: false, + entrance_exam_id: '', + entrance_exam_minimum_score_pct: 0.65, + entrance_exam_passed: true, + }, + verify_identity_url: null, + verification_status: 'none', + linkedin_add_to_profile_url: null, + related_programs: null, + user_needs_integrity_signature: false, + recommendations: null, + learning_assistant_enabled: null, + }); diff --git a/src/course-unit/data/__factories__/courseRecommendations.factory.js b/src/course-unit/data/__factories__/courseRecommendations.factory.js new file mode 100644 index 0000000000..e252b96ee6 --- /dev/null +++ b/src/course-unit/data/__factories__/courseRecommendations.factory.js @@ -0,0 +1,38 @@ +import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies + +Factory.define('courseRecommendations') + .option('host', '') + .option('numRecs', '', 4) + .sequence('uuid', (i) => `a-uuid-${i}`) + .attr('recommendations', ['numRecs'], (numRecs) => { + const recs = []; + for (let i = 0; i < numRecs; i++) { + recs.push(Factory.build('courseRecommendation')); + } + return recs; + }); + +Factory.define('courseRecommendation') + .sequence('key', (i) => `edX+DemoX${i}`) + .sequence('uuid', (i) => `abcd-${i}`) + .attrs({ + title: 'DemoX', + owners: [ + { + uuid: '', + key: 'edX', + }, + ], + image: { + src: '', + }, + }) + .attr('course_run_keys', ['key'], (key) => ( + [`${key}+1T2021`] + )) + .attr('url_slug', ['key'], (key) => key) + .attr('marketing_url', ['url_slug'], (urlSlug) => `https://www.edx.org/course/${urlSlug}`); + +Factory.define('userEnrollment') + .option('runKey') + .attr('course_details', ['runKey'], (runKey) => (runKey ? { course_id: runKey } : { course_id: 'edX+EnrolledX' })); diff --git a/src/course-unit/data/__factories__/discussionTopics.factory.js b/src/course-unit/data/__factories__/discussionTopics.factory.js new file mode 100644 index 0000000000..1aeb2b1938 --- /dev/null +++ b/src/course-unit/data/__factories__/discussionTopics.factory.js @@ -0,0 +1,23 @@ +/* eslint-disable import/prefer-default-export */ +import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies + +Factory.define('discussionTopic') + .option('topicPrefix', null, '') + .option('courseId', null, 'course-v1:edX+DemoX+Demo_Course') + .sequence('id', ['topicPrefix'], (idx, topicPrefix) => `${topicPrefix}topic-${idx}`) + .sequence('name', ['topicPrefix'], (idx, topicPrefix) => `${topicPrefix}topic ${idx}`) + .sequence( + 'usage_key', + ['id', 'courseId'], + (idx, id, courseId) => `block-v1:${courseId.replace('course-v1:', '')}+type@vertical+block@${id}`, + ) + .attr('enabled_in_context', null, true) + .attr('thread_counts', [], { + discussion: 0, + question: 0, + }); + +// Given a pre-build units state, build topics from it. +export function buildTopicsFromUnits(units) { + return Object.values(units).map(unit => Factory.build('discussionTopic', { usage_key: unit.id })); +} diff --git a/src/course-unit/data/__factories__/index.js b/src/course-unit/data/__factories__/index.js new file mode 100644 index 0000000000..cec03812de --- /dev/null +++ b/src/course-unit/data/__factories__/index.js @@ -0,0 +1,5 @@ +import './courseMetadata.factory'; +import './sequenceMetadata.factory'; +import './courseRecommendations.factory'; +import './learningSequencesOutline.factory'; +import './discussionTopics.factory'; diff --git a/src/course-unit/data/__factories__/learningSequencesOutline.factory.js b/src/course-unit/data/__factories__/learningSequencesOutline.factory.js new file mode 100644 index 0000000000..24bee16cb5 --- /dev/null +++ b/src/course-unit/data/__factories__/learningSequencesOutline.factory.js @@ -0,0 +1,77 @@ +import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies + +Factory.define('learningSequencesOutline') + .attrs({ + title: 'Demo Course', + course_id: 'course-v1:edX+DemoX+Demo_Course', + outline: { + sections: [], + sequences: { + }, + }, + }); + +export function buildEmptyOutline(courseId) { + return Factory.build( + 'learningSequencesOutline', + { + title: 'Demo Course', + course_id: courseId, + outline: { + sections: [], + sequences: { + }, + }, + }, + { courseId }, + ); +} + +// Given courseBlocks (output from buildSimpleCourseBlocks), create a matching +// Learning Sequences API outline (what the REST API would return to us). +// Ideally this method would go away at some point, and we'd use a learning +// sequence factory directly. But this method exists as a bridge-gap from +// when course blocks were always used anyway. Now that they are rarely used, +// this can probably go away. +export function buildOutlineFromBlocks(courseBlocks) { + const sections = {}; + const sequences = {}; + let courseBlock = null; + + Object.values(courseBlocks.blocks).forEach(block => { + if (block.type === 'course') { + courseBlock = block; + } else if (block.type === 'chapter') { + sections[block.id] = { + id: block.id, + title: block.display_name, + start: null, + effective_start: null, + sequence_ids: [...block.children], + }; + } else if (block.type === 'sequential') { + sequences[block.id] = { + id: block.id, + title: block.display_name, + accessible: true, + start: null, + effective_start: null, + }; + } + }); + + const outline = Factory.build( + 'learningSequencesOutline', + { + course_key: courseBlocks.courseId, + title: courseBlocks.title, + outline: { + sections: courseBlock.children.map(sectionId => sections[sectionId]), + sequences, + }, + }, + {}, + ); + + return outline; +} diff --git a/src/course-unit/data/__factories__/sequenceMetadata.factory.js b/src/course-unit/data/__factories__/sequenceMetadata.factory.js new file mode 100644 index 0000000000..58eac947ce --- /dev/null +++ b/src/course-unit/data/__factories__/sequenceMetadata.factory.js @@ -0,0 +1,91 @@ +import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies +import '../../../shared/data/__factories__/block.factory'; +import { buildSimpleCourseBlocks } from '../../../shared/data/__factories__/courseBlocks.factory'; + +Factory.define('sequenceMetadata') + .option('courseId', (courseId) => { + if (courseId) { + return courseId; + } + throw new Error('courseId must be specified for sequenceMetadata factory.'); + }) + // An array of units + .option('unitBlocks', ['courseId'], courseId => ([ + Factory.build( + 'block', + { type: 'vertical' }, + { courseId }, + ), + ])) + .option('sequenceBlock', ['courseId', 'unitBlocks'], (courseId, unitBlocks) => ( + Factory.build( + 'block', + { type: 'sequential', children: unitBlocks.map(unitBlock => unitBlock.id) }, + { courseId }, + ) + )) + .attr('element_id', ['sequenceBlock'], sequenceBlock => sequenceBlock.block_id) + .attr('item_id', ['sequenceBlock'], sequenceBlock => sequenceBlock.id) + .attr('display_name', ['sequenceBlock'], sequenceBlock => sequenceBlock.display_name) + .attr('gated_content', ['sequenceBlock'], sequenceBlock => ({ + gated: false, + prereq_url: null, + prereq_id: `${sequenceBlock.id}-prereq`, + prereq_section_name: `${sequenceBlock.display_name}-prereq`, + gated_section_name: sequenceBlock.display_name, + })) + .attr('items', ['unitBlocks', 'sequenceBlock'], (unitBlocks, sequenceBlock) => unitBlocks.map( + unitBlock => ({ + href: '', + graded: unitBlock.graded, + id: unitBlock.id, + bookmarked: unitBlock.bookmarked || false, + path: `Chapter Display Name > ${sequenceBlock.display_name} > ${unitBlock.display_name}`, + type: unitBlock.type, + complete: unitBlock.complete || null, + content: '', + page_title: unitBlock.display_name, + contains_content_type_gated_content: unitBlock.contains_content_type_gated_content, + }), + )) + .attrs({ + exclude_units: true, + position: null, + next_url: null, + tag: 'sequential', + save_position: true, + prev_url: null, + is_time_limited: false, + is_hidden_after_due: false, + show_completion: true, + banner_text: null, + format: 'Homework', + }); + +/** + * Build a simple course and simple metadata for its sequence. + */ +export default function buildSimpleCourseAndSequenceMetadata(options = {}) { + const courseMetadata = options.courseMetadata || Factory.build('courseMetadata', { + course_access: { + has_access: false, + }, + }); + const courseId = courseMetadata.id; + const simpleCourseBlocks = buildSimpleCourseBlocks(courseId, courseMetadata.name, options); + const { unitBlocks, sequenceBlocks } = simpleCourseBlocks; + const sequenceMetadata = options.sequenceMetadata || sequenceBlocks.map(block => Factory.build( + 'sequenceMetadata', + {}, + { + courseId, unitBlocks, sequenceBlock: block, + }, + )); + // const courseHomeMetadata = options.courseHomeMetadata || Factory.build('courseHomeMetadata'); + return { + ...simpleCourseBlocks, + courseMetadata, + sequenceMetadata, + // courseHomeMetadata, + }; +} diff --git a/src/course-unit/data/api.js b/src/course-unit/data/api.js index 006900b0b8..1eadf14367 100644 --- a/src/course-unit/data/api.js +++ b/src/course-unit/data/api.js @@ -1,36 +1,20 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { + normalizeMetadata, + appendBrowserTimezoneToUrl, + normalizeSequenceMetadata, + normalizeLearningSequencesData, + normalizeCourseHomeCourseMetadata, +} from './utils'; + const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; export const getCourseUnitApiUrl = (itemId) => `${getApiBaseUrl()}/xblock/container/${itemId}`; export const setCourseUnitApiUrl = (itemId) => `${getApiBaseUrl()}/xblock/${itemId}`; -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 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; -}; - /** * Get course unit. * @param {string} unitId @@ -60,47 +44,12 @@ export async function editUnitDisplayName(unitId, displayName) { return data; } -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, - })), - }; -} - +/** + * Retrieves metadata for a specific sequence. + * + * @param {string} sequenceId - The ID of the sequence. + * @returns {Promise} A Promise that resolves to the normalized sequence metadata. + */ export async function getSequenceMetadata(sequenceId) { const { data } = await getAuthenticatedHttpClient() .get(`${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceId}`, {}); @@ -108,70 +57,12 @@ export async function getSequenceMetadata(sequenceId) { return normalizeSequenceMetadata(data); } -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 === 0) { - 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; -} - +/** + * 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 outlineUrl = new URL(`${getConfig().LMS_BASE_URL}/api/learning_sequences/v1/course_outline/${courseId}`); const { data } = await getAuthenticatedHttpClient().get(outlineUrl.href, {}); @@ -179,74 +70,27 @@ export async function getLearningSequencesOutline(courseId) { return normalizeLearningSequencesData(data); } -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, - }; -} - +/** + * 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 url = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`; url = appendBrowserTimezoneToUrl(url); const metadata = await getAuthenticatedHttpClient().get(url); + return normalizeMetadata(metadata); } /** - * 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 + * 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. */ -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, - }; -} - export async function getCourseHomeCourseMetadata(courseId, rootSlug) { let url = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`; url = appendBrowserTimezoneToUrl(url); @@ -254,3 +98,14 @@ export async function getCourseHomeCourseMetadata(courseId, rootSlug) { return normalizeCourseHomeCourseMetadata(data, rootSlug); } + +// const getSequenceHandlerUrl = (courseId, sequenceId) => `${getConfig().LMS_BASE_URL}/courses/${courseId}/xblock/${sequenceId}/handler`; + +// export async function postSequencePosition(courseId, sequenceId, activeUnitIndex) { +// const { data } = await getAuthenticatedHttpClient().post( +// `${getSequenceHandlerUrl(courseId, sequenceId)}/goto_position`, +// // Position is 1-indexed on the server and 0-indexed in this app. Adjust here. +// { position: activeUnitIndex + 1 }, +// ); +// return data; +// } diff --git a/src/course-unit/data/slice.js b/src/course-unit/data/slice.js index b7aa699c30..b2180aec56 100644 --- a/src/course-unit/data/slice.js +++ b/src/course-unit/data/slice.js @@ -1,5 +1,6 @@ /* eslint-disable no-param-reassign */ import { createSlice } from '@reduxjs/toolkit'; + import { RequestStatus } from '../../data/constants'; const slice = createSlice({ diff --git a/src/course-unit/data/utils.js b/src/course-unit/data/utils.js new file mode 100644 index 0000000000..939702b897 --- /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 === 0) { + 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/setupTest.js b/src/setupTest.js index dac2e09566..1689eaf11b 100755 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -2,13 +2,23 @@ import 'core-js/stable'; import 'regenerator-runtime/runtime'; import '@testing-library/jest-dom'; import '@testing-library/jest-dom/extend-expect'; - +import './course-unit/data/__factories__'; +import { configureStore } from '@reduxjs/toolkit'; +import { getConfig, mergeConfig } from '@edx/frontend-platform'; +import { configure as configureLogging } from '@edx/frontend-platform/logging'; +import { configure as configureAuth, getAuthenticatedHttpClient, MockAuthService } from '@edx/frontend-platform/auth'; +import { configure as configureI18n } from '@edx/frontend-platform/i18n'; +import MockAdapter from 'axios-mock-adapter'; /* eslint-disable import/no-extraneous-dependencies */ import Enzyme from 'enzyme'; import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import buildSimpleCourseAndSequenceMetadata from './course-unit/data/__factories__/sequenceMetadata.factory'; +import { buildOutlineFromBlocks } from './course-unit/data/__factories__/learningSequencesOutline.factory'; +import { reducer as modelsReducer } from './generic/model-store'; import 'babel-polyfill'; - -import { mergeConfig } from '@edx/frontend-platform'; +import messages from './i18n'; +import { fetchSequence, fetchCourse } from './course-unit/data/thunk'; +import { reducer as coursewareReducer } from './course-unit/data/slice'; Enzyme.configure({ adapter: new Adapter() }); @@ -54,3 +64,130 @@ mergeConfig({ // Mock the plugins repo so jest will stop complaining about ES6 syntax jest.mock('frontend-components-tinymce-advanced-plugins', () => {}); + +export function logUnhandledRequests(axiosMock) { + axiosMock.onAny().reply((config) => { + // eslint-disable-next-line no-console + console.log(config.method, config.url); + return [200, {}]; + }); +} + +// Helper, that is used to forcibly finalize all promises +// in thunk before running matcher against state. +export const executeThunk = async (thunk, dispatch, getState) => { + await thunk(dispatch, getState); + await new Promise(setImmediate); +}; + +// Utility function for appending the browser timezone to the url +// Can be used on the backend when the user timezone is not set in the user account +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; +}; + +class MockLoggingService { + // eslint-disable-next-line no-console + logInfo = jest.fn(infoString => console.log(infoString)); + + // eslint-disable-next-line no-console + logError = jest.fn(errorString => console.log(errorString)); +} + +export function initializeMockApp() { + mergeConfig({ + CONTACT_URL: process.env.CONTACT_URL || null, + DISCUSSIONS_MFE_BASE_URL: process.env.DISCUSSIONS_MFE_BASE_URL || null, + INSIGHTS_BASE_URL: process.env.INSIGHTS_BASE_URL || null, + STUDIO_BASE_URL: process.env.STUDIO_BASE_URL || null, + TWITTER_URL: process.env.TWITTER_URL || null, + authenticatedUser: { + userId: 'abc123', + username: 'MockUser', + roles: [], + administrator: false, + }, + SUPPORT_URL_ID_VERIFICATION: 'http://example.com', + }); + + const loggingService = configureLogging(MockLoggingService, { + config: getConfig(), + }); + const authService = configureAuth(MockAuthService, { + config: getConfig(), + loggingService, + }); + + // i18n doesn't have a service class to return. + configureI18n({ + config: getConfig(), + loggingService, + messages, + }); + + return { loggingService, authService }; +} + +let globalStore; + +export async function initializeTestStore(options = {}, overrideStore = true) { + const store = configureStore({ + reducer: { + models: modelsReducer, + courseware: coursewareReducer, + // courseHome: courseHomeReducer, + // learningAssistant: learningAssistantReducer, + }, + }); + if (overrideStore) { + globalStore = store; + } + initializeMockApp(); + const axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock.reset(); + + const { + courseBlocks, sequenceBlocks, courseMetadata, sequenceMetadata, courseHomeMetadata, + } = buildSimpleCourseAndSequenceMetadata(options); + + let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseMetadata.id}`; + courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl); + + const learningSequencesUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/learning_sequences/v1/course_outline/*`); + let courseHomeMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseMetadata.id}`; + const discussionConfigUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/*`); + courseHomeMetadataUrl = appendBrowserTimezoneToUrl(courseHomeMetadataUrl); + + const provider = options?.provider || 'legacy'; + + axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata); + axiosMock.onGet(courseHomeMetadataUrl).reply(200, courseHomeMetadata); + axiosMock.onGet(learningSequencesUrlRegExp).reply(200, buildOutlineFromBlocks(courseBlocks)); + axiosMock.onGet(discussionConfigUrl).reply(200, { provider }); + sequenceMetadata.forEach(metadata => { + const sequenceMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/sequence/${metadata.item_id}`; + axiosMock.onGet(sequenceMetadataUrl).reply(200, metadata); + const proctoredExamApiUrl = `${getConfig().LMS_BASE_URL}/api/edx_proctoring/v1/proctored_exam/attempt/course_id/${courseMetadata.id}/content_id/${sequenceMetadata.item_id}?is_learning_mfe=true`; + axiosMock.onGet(proctoredExamApiUrl).reply(200, { exam: {}, active_attempt: {} }); + }); + + logUnhandledRequests(axiosMock); + + // eslint-disable-next-line no-unused-expressions + !options.excludeFetchCourse && await executeThunk(fetchCourse(courseMetadata.id), store.dispatch); + + if (!options.excludeFetchSequence) { + await Promise.all(sequenceBlocks + .map(block => executeThunk(fetchSequence(block.id), store.dispatch))); + } + + return store; +} + +// Re-export everything. +export * from '@testing-library/react'; diff --git a/src/shared/data/__factories__/block.factory.js b/src/shared/data/__factories__/block.factory.js new file mode 100644 index 0000000000..0bda473834 --- /dev/null +++ b/src/shared/data/__factories__/block.factory.js @@ -0,0 +1,59 @@ +import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies + +Factory.define('block') + .option('courseId', 'course-v1:edX+DemoX+Demo_Course') + .option('host', 'http://localhost:18000') + // Generating block_id that is similar to md5 hash, but still deterministic + .sequence('block_id', id => ('abcd'.repeat(8) + id).slice(-32)) + .attrs({ + complete: false, + description: null, + due: null, + graded: false, + icon: null, + showLink: true, + type: 'course', + children: [], + }) + .attr('display_name', ['display_name', 'block_id'], (displayName, blockId) => { + if (displayName) { + return displayName; + } + + return blockId; + }) + .attr( + 'id', + ['id', 'block_id', 'type', 'courseId'], + (id, blockId, type, courseId) => { + if (id) { + return id; + } + + const courseInfo = courseId.split(':')[1]; + + return `block-v1:${courseInfo}+type@${type}+block@${blockId}`; + }, + ) + .attr( + 'student_view_url', + ['student_view_url', 'host', 'id'], + (url, host, id) => { + if (url) { + return url; + } + + return `${host}/xblock/${id}`; + }, + ) + .attr( + 'lms_web_url', + ['lms_web_url', 'host', 'courseId', 'id'], + (url, host, courseId, id) => { + if (url) { + return url; + } + + return `${host}/courses/${courseId}/jump_to/${id}`; + }, + ); diff --git a/src/shared/data/__factories__/courseBlocks.factory.js b/src/shared/data/__factories__/courseBlocks.factory.js new file mode 100644 index 0000000000..842b78e1cd --- /dev/null +++ b/src/shared/data/__factories__/courseBlocks.factory.js @@ -0,0 +1,249 @@ +import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies +import './block.factory'; + +// Most of this file can be removed at some point, now that we rarely use course blocks +// in favor of learning sequences. But for now, these are mostly used to then feed into +// buildOutlineFromBlocks, which is an awkward flow if we don't really care about the +// course blocks themselves. A future cleanup to do. + +// Generates an Array of block IDs, either from a single block or an array of blocks. +const getIds = (attr) => { + const blocks = Array.isArray(attr) ? attr : [attr]; + return blocks.map(block => block.id); +}; + +// Generates an Object in { [block.id]: block } format, either from a single block or an array of blocks. +const getBlocks = (attr) => { + const blocks = Array.isArray(attr) ? attr : [attr]; + // eslint-disable-next-line no-return-assign,no-sequences + return blocks.reduce((acc, block) => (acc[block.id] = block, acc), {}); +}; + +Factory.define('courseBlocks') + .option('courseId', 'course-v1:edX+DemoX+Demo_Course') + .option('units', ['courseId'], courseId => [ + Factory.build( + 'block', + { type: 'vertical' }, + { courseId }, + ), + ]) + .option('sequences', ['courseId', 'units'], (courseId, units) => [ + Factory.build( + 'block', + { type: 'sequential', children: getIds(units) }, + { courseId }, + ), + ]) + .option('sections', ['courseId', 'sequences'], (courseId, sequences) => [ + Factory.build( + 'block', + { type: 'chapter', children: getIds(sequences) }, + { courseId }, + ), + ]) + .option('course', ['courseId', 'sections'], (courseId, sections) => Factory.build( + 'block', + { type: 'course', children: getIds(sections) }, + { courseId }, + )) + .attr( + 'blocks', + ['course', 'sections', 'sequences', 'units'], + (course, sections, sequences, units) => ({ + [course.id]: course, + ...getBlocks(sections), + ...getBlocks(sequences), + ...getBlocks(units), + }), + ) + .attr('root', ['course'], course => course.id); + +/** + * Builds a course with a single chapter, sequence, and unit. + */ +export function buildSimpleCourseBlocks(courseId, title, options = {}) { + const unitBlocks = options.unitBlocks || [Factory.build( + 'block', + { type: 'vertical' }, + { courseId }, + )]; + const sequenceBlocks = options.sequenceBlocks || [Factory.build( + 'block', + { type: 'sequential', children: unitBlocks.map(block => block.id) }, + { courseId }, + )]; + const sectionBlocks = options.sectionBlocks || [Factory.build( + 'block', + { type: 'chapter', children: sequenceBlocks.map(block => block.id) }, + { courseId }, + )]; + const courseBlock = options.courseBlock || Factory.build( + 'block', + { type: 'course', display_name: title, children: sectionBlocks.map(block => block.id) }, + { courseId }, + ); + return { + courseBlocks: options.courseBlocks || Factory.build( + 'courseBlocks', + { + courseId, + hasScheduledContent: options.hasScheduledContent || false, + title, + }, + { + units: unitBlocks, + sequences: sequenceBlocks, + sections: sectionBlocks, + course: courseBlock, + }, + ), + unitBlocks, + sequenceBlocks, + sectionBlocks, + courseBlock, + }; +} + +/** + * Builds a course with a single chapter and sequence, but no units. + */ +export function buildMinimalCourseBlocks(courseId, title, options = {}) { + const sequenceBlocks = options.sequenceBlocks || [Factory.build( + 'block', + { + display_name: 'Title of Sequence', + effort_activities: 2, + effort_time: 15, + type: 'sequential', + }, + { courseId }, + )]; + const sectionBlocks = options.sectionBlocks || [Factory.build( + 'block', + { + type: 'chapter', + display_name: 'Title of Section', + complete: options.complete || false, + resume_block: options.resumeBlock || false, + children: sequenceBlocks.map(block => block.id), + }, + { courseId }, + )]; + const courseBlock = options.courseBlock || Factory.build( + 'block', + { + type: 'course', + display_name: title, + has_scheduled_content: options.hasScheduledContent || false, + children: sectionBlocks.map(block => block.id), + }, + { courseId }, + ); + return { + courseBlocks: options.courseBlocks || Factory.build( + 'courseBlocks', + { courseId }, + { + sequences: sequenceBlocks, + sections: sectionBlocks, + course: courseBlock, + units: [], + }, + ), + unitBlocks: [], + sequenceBlocks, + sectionBlocks, + courseBlock, + }; +} + +/** + * Builds a course with two branches at each node. That is: + * + * Crs + * | + * Sec--------+-------Sec + * | | + * Seq---+---Seq Seq---+---Seq + * | | | | + * U--+--U U--+--U U--+--U U--+--U + * ^ + * + * Each left branch is indexed 0, and each right branch is indexed 1. + * So, the caret in the diagram above is pointing to `unitTree[1][0][1]`, + * whose parent is `sequenceTree[1][0]`, whose parent is `sectionTree[1]`. + */ +export function buildBinaryCourseBlocks(courseId, title) { + const sectionTree = []; + const sequenceTree = [[], []]; + const unitTree = [[[], []], [[], []]]; + [0, 1].forEach(sectionIndex => { + [0, 1].forEach(sequenceIndex => { + [0, 1].forEach(unitIndex => { + unitTree[sectionIndex][sequenceIndex][unitIndex] = Factory.build( + 'block', + { type: 'vertical' }, + { courseId }, + ); + }); + sequenceTree[sectionIndex][sequenceIndex] = Factory.build( + 'block', + { type: 'sequential', children: unitTree[sectionIndex][sequenceIndex].map(block => block.id) }, + { courseId }, + ); + }); + sectionTree[sectionIndex] = Factory.build( + 'block', + { type: 'chapter', children: sequenceTree[sectionIndex].map(block => block.id) }, + { courseId }, + ); + }); + const courseBlock = Factory.build( + 'block', + { type: 'course', display_name: title, children: sectionTree.map(block => block.id) }, + { courseId }, + ); + const sectionBlocks = [ + sectionTree[0], + sectionTree[1], + ]; + const sequenceBlocks = [ + sequenceTree[0][0], + sequenceTree[0][1], + sequenceTree[1][0], + sequenceTree[1][1], + ]; + const unitBlocks = [ + unitTree[0][0][0], + unitTree[0][0][1], + unitTree[0][1][0], + unitTree[0][1][1], + unitTree[1][0][0], + unitTree[1][0][1], + unitTree[1][1][0], + unitTree[1][1][1], + ]; + return { + // Expose blocks as a combined list, lists separated by type, and as + // trees separated by type. The caller can decide which they want to + // work with. + courseBlocks: Factory.build( + 'courseBlocks', + { courseId, title }, + { + units: unitBlocks, + sequences: sequenceBlocks, + sections: sectionBlocks, + course: courseBlock, + }, + ), + unitBlocks, + sequenceBlocks, + sectionBlocks, + courseBlock, + unitTree, + sequenceTree, + sectionTree, + }; +} diff --git a/src/shared/data/__factories__/courseMetadataBase.factory.js b/src/shared/data/__factories__/courseMetadataBase.factory.js new file mode 100644 index 0000000000..2086173301 --- /dev/null +++ b/src/shared/data/__factories__/courseMetadataBase.factory.js @@ -0,0 +1,11 @@ +/* A basic course metadata factory, to be specialized in courseware and course-home., */ + +import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies + +import './tab.factory'; + +export default new Factory() + .option('host') + .attrs({ + id: 'course-v1:edX+DemoX+Demo_Course', + }); diff --git a/src/shared/data/__factories__/index.js b/src/shared/data/__factories__/index.js new file mode 100644 index 0000000000..ab3709c5e6 --- /dev/null +++ b/src/shared/data/__factories__/index.js @@ -0,0 +1,4 @@ +import './block.factory'; +import './courseBlocks.factory'; +import './courseMetadataBase.factory'; +import './tab.factory'; diff --git a/src/shared/data/__factories__/tab.factory.js b/src/shared/data/__factories__/tab.factory.js new file mode 100644 index 0000000000..0868356842 --- /dev/null +++ b/src/shared/data/__factories__/tab.factory.js @@ -0,0 +1,18 @@ +import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies + +Factory.define('tab') + .option('courseId', 'course-v1:edX+DemoX+Demo_Course') + .option('path', 'course/') + .option('host', 'http://localhost:18000') + .attrs({ + title: 'Course', + priority: 0, + slug: 'courseware', + type: 'courseware', + }) + .attr('tab_id', ['slug'], (slug) => slug) + .attr( + 'url', + ['courseId', 'path', 'host'], + (courseId, path, host) => `${host}/courses/${courseId}/${path}`, + );