diff --git a/docs/decisions/0002-courseware-component-hierarchy.md b/docs/decisions/0002-courseware-component-hierarchy.md deleted file mode 100644 index 281b8bd9..00000000 --- a/docs/decisions/0002-courseware-component-hierarchy.md +++ /dev/null @@ -1,21 +0,0 @@ -# Courseware app structure - -Currently we have hierarchical courses - they contain sections, subsections, units, and components. - -We need data to power each level. - -We've made decisions that we're going to re-fetch data at the subsection level under the assumption that - -At any given level, you have the following structure: - -Parent - Container - Child - Context - - The container belongs to the parent module, and is an opportunity for the parent to decide to load more data necessary to load the Child. If the parent has what it needs, it may not use a Container. The Child has an props-only interface. It does _not_ use contexts or redux from the Parent. The child may decide to use a Context internally if that's convenient, but that's a decision independent of anything above the Child in the hierarchy. - - -This app uses a "model store" - a normalized representation of our API data. This data is kept in an Object with entity IDs as keys, and the entities as values. This allows the application to quickly look up data in the map using only a key. It also means that if the same entity is used in multiple places, there's only one actual representation of it in the client - anyone who wants to use it effectively maintains a reference to it via it's ID. - -There are a few kinds of data in the model store. Information from the blocks API - courses, chapters, sequences, and units - are stored together by ID. Into this, we merge course, sequence, and unit metadata from the courses and sequence metadata APIs. diff --git a/docs/decisions/0002-courseware-page-decisions.md b/docs/decisions/0002-courseware-page-decisions.md new file mode 100644 index 00000000..e79c0d54 --- /dev/null +++ b/docs/decisions/0002-courseware-page-decisions.md @@ -0,0 +1,50 @@ +# Courseware Page Decisions + +## Courseware data loading + +Today we have strictly hierarchical courses - a course contains sections, which contain sequences, which contain units, which contain components. + +In creating the courseware pages of this app, we needed to choose how often we fetch data from the server. If we fetch it once and try to get the whole course, including all the data we need in its entire hierarchy, then the request will take 30+ seconds and be a horrible UX. If we try to fetch too granularly, we risk making hundreds of calls to the LMS, incuring both request overhead and common server-side processing that needs to occur for each of those requests. + +Instead, we've chosen to load data via the following: + +- The course blocks API (/api/courses/v2/blocks) for getting the overall structure of the course (limited data on the whole hierarchy) +- The course metadata API (/api/courseware/course) for detailed top-level data, such as dates, enrollment status, info for tabs across the top of the page, etc. +- The sequence metadata API (/api/courseware/sequence) for detailed information on a sequence, such as which unit to display, any banner messages, whether or not the sequence has a prerequisite, if it's an exam, etc. +- The xblock endpoint (http://localhost:18000/xblock/:block_id) which renders HTML for an xBlock by ID, used to render Unit contents. This HTML is loaded into the application via an iFrame. + +These APIs aren't perfect for our usage, but they're getting the job done for now. They weren't built for our purposes and thus load more information than we strictly need, and aren't as performant as we'd like. Future milestones of the application may rely on new, more performant APIs (possibly BFFs) + +## Unit iframing + +We determined, as part of our project discovery, that in order to deliver value to users sooner, we would iframe in content of units. This allowed us to avoid rebuilding the UI for unit/component xblocks in the micro-frontend, which is a daunting task. It also allows existing custom xblocks to continue to work for now, as they wouldn't have to be re-written. + +A future iteration of the project may go back and pull the unit rendering into the MFE. + +## Strictly hierarchical courses + +We've also made the assumption that courses are strictly hierarchical - a given section, sequence, or unit doesn't have multiple parents. This is important, as it allows us to navigate the tree in the client in a deterministic way. If we need to find out who the parent section of a sequence is, there's only one answer to that question. + +## Determining which sequences and units to show + +The courseware URL scheme: + +`/course/:courseId(/:sequenceId(/:unitId))` + +Sequence ID and unit ID are optional. + +Today, if the URL only specifies the course ID, we need to pick a sequence to show. We do this by picking the first sequence of the course (as dictated by the course blocks API) and update the URL to match. _After_ the URL has been updated, the application will attempt to load that sequence. + +Similarly, if the URL doesn't contain a unit ID, we use the `position` field of the sequence to determine which unit we want to display from that sequence. If the position isn't specified in the sequence, we choose the first unit of the sequence. After determining which unit to display, we update the URL to match. After the URL is updated, the application will attempt to load that unit via an iFrame. + +## "Container" components vs. display components + +This application makes use of a few "container" components at the top level - CoursewareContainer and CourseHomeContainer. + +The point of these containers is to introduce a layer of abstraction between the UI representation of the pages and the way their data was loaded, as described above. + +We don't want our Course.jsx component to be intimately aware - for example - that it's data is loaded via two separate APIs that are then merged together. That's not useful information - it just needs to know where it's data is and if it's loaded. Furthermore, this layer of abstraction lets us normalize field names between the various APIs to let our MFE code be more consistent and readable. This normalization is done in the src/data/api.js layer. + +## Navigation + +Course navigation in a hierarchical course happens primarily via the "sequence navigation". This component lets users navigate to the next and previous unit in the course, and also select specific units within the sequence directly. The next and previous buttons (SequenceNavigation and UnitNavigation) delegate decision making up the tree to CoursewareContainer. This is an intentional separation of concerns which should allow different CoursewareContainer-like components to make different decisions about what it means to go to the "next" or "previous" sequence. This is in support of future course types such as "pathway" courses and adaptive learning sequences. There is no actual code written for these course types, but it felt like a good separation of concerns. diff --git a/docs/decisions/0003-course-home-decisions.md b/docs/decisions/0003-course-home-decisions.md new file mode 100644 index 00000000..28148add --- /dev/null +++ b/docs/decisions/0003-course-home-decisions.md @@ -0,0 +1,7 @@ +# Course Home Decisions + +The course home page is not complete as of this writing. + +It was added to the MFE as a proof of concept for the Engagement theme's Always Available squad, as they were intending to do some work in the legacy course home page in the LMS, and we wanted to understand whether it would be more easily done in this application. + +It uses the same APIs as the courseware page, for the most part. This may not always be the case, but it is for now. Differing API shapes may be faster for both pages. diff --git a/docs/decisions/0004-model-store.md b/docs/decisions/0004-model-store.md new file mode 100644 index 00000000..61a3d4b7 --- /dev/null +++ b/docs/decisions/0004-model-store.md @@ -0,0 +1,7 @@ +## Model Store + +Because we have a variety of models in this app (course, section, sequence, unit), we use a set of generic 'model store' reducers in redux to manage this data. Once loaded from the APIs, the data is put into the model store by type and by ID, which allows us to quickly access it in the application. Furthermore, any sub-trees of model children (like "items" in the sequence metadata API) are flattened out and stored by ID in the model-store, and their arrays replaced by arrays of IDs. This is a recommended way to store data in redux as documented here: + +https://redux.js.org/faq/organizing-state#how-do-i-organize-nested-or-duplicate-data-in-my-state + +(As an additional data point, djoy has stored data in this format in multiple projects over the years and found it to be very effective) diff --git a/docs/xblock-links.md b/docs/xblock-links.md deleted file mode 100644 index bb0aa292..00000000 --- a/docs/xblock-links.md +++ /dev/null @@ -1,30 +0,0 @@ -# Perf test courses - -These courses have some large xblocks and small ones. One course has many sequences, the other has fewer. - -## Big course: course-v1:MITx+CTL.SC0x+3T2016 - -- MFE URL: https://learning.edx.org/course/course-v1%3AMITx%2BCTL.SC0x%2B3T2016/0 -- URL: https://courses.edx.org/courses/course-v1:MITx+CTL.SC0x+3T2016/course/ - -### Small xblock -- ID: block-v1:MITx+CTL.SC0x+3T2016+type@vertical+block@0586b59f1cf74e3c982f0b9070e7ad33 -- URL: https://courses.edx.org/courses/course-v1:MITx+CTL.SC0x+3T2016/courseware/6a31d02d958e45a398d8a5f1592bdd78/b1ede7bf43c248e19894040718443750/1?activate_block_id=block-v1%3AMITx%2BCTL.SC0x%2B3T2016%2Btype%40vertical%2Bblock%400586b59f1cf74e3c982f0b9070e7ad33 - -### Big xblock -- ID: block-v1:MITx+CTL.SC0x+3T2016+type@vertical+block@84d6e785f548431a9e82e58d2df4e971 -- URL: https://courses.edx.org/courses/course-v1:MITx+CTL.SC0x+3T2016/courseware/b77abc02967e401ca615b23dacf8d115/4913db3e36f14ccd8c98c374b9dae809/2?activate_block_id=block-v1%3AMITx%2BCTL.SC0x%2B3T2016%2Btype%40vertical%2Bblock%4084d6e785f548431a9e82e58d2df4e971 - -## Small course: course-v1:edX+DevSec101+3T2018 - -- URL: https://courses.edx.org/courses/course-v1:edX+DevSec101+3T2018/course/ -- MFE URL: https://learning.edx.org/course/course-v1%3AedX%2BDevSec101%2B3T2018/0 - -### Small xblock -- ID: block-v1:edX+DevSec101+3T2018+type@vertical+block@931f96d1822a4fe5b521fcda19245dca -- URL: https://courses.edx.org/courses/course-v1:edX+DevSec101+3T2018/courseware/ee898e64bd174e4aba4c07cd2673e5d3/1a37309647814ab8b333c7a17d50abc4/1?activate_block_id=block-v1%3AedX%2BDevSec101%2B3T2018%2Btype%40vertical%2Bblock%40931f96d1822a4fe5b521fcda19245dca - -### Big-ish xblock - -- ID: block-v1:edX+DevSec101+3T2018+type@vertical+block@d88210fbc2b74ceab167a52def04e2a0 -- URL: https://courses.edx.org/courses/course-v1:edX+DevSec101+3T2018/courseware/b0e2c2b78b5d49308e1454604a255403/38c7049bc8e44d309ab3bdb7f54ae6ae/2?activate_block_id=block-v1%3AedX%2BDevSec101%2B3T2018%2Btype%40vertical%2Bblock%40d88210fbc2b74ceab167a52def04e2a0 diff --git a/src/courseware/course/CourseTabsNavigation.jsx b/src/course-header/CourseTabsNavigation.jsx similarity index 97% rename from src/courseware/course/CourseTabsNavigation.jsx rename to src/course-header/CourseTabsNavigation.jsx index ce0da856..63d1bdf8 100644 --- a/src/courseware/course/CourseTabsNavigation.jsx +++ b/src/course-header/CourseTabsNavigation.jsx @@ -5,7 +5,7 @@ import { getConfig } from '@edx/frontend-platform'; import classNames from 'classnames'; import messages from './messages'; -import Tabs from '../../tabs/Tabs'; +import Tabs from '../tabs/Tabs'; function CourseTabsNavigation({ activeTabSlug, tabs, intl, diff --git a/src/courseware/course/CourseHeader.jsx b/src/course-header/Header.jsx similarity index 89% rename from src/courseware/course/CourseHeader.jsx rename to src/course-header/Header.jsx index d8329c59..b213e051 100644 --- a/src/courseware/course/CourseHeader.jsx +++ b/src/course-header/Header.jsx @@ -7,7 +7,7 @@ import { AppContext } from '@edx/frontend-platform/react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faUserCircle } from '@fortawesome/free-solid-svg-icons'; -import logo from './logo.svg'; +import logo from './assets/logo.svg'; function LinkedLogo({ href, @@ -28,8 +28,8 @@ LinkedLogo.propTypes = { alt: PropTypes.string.isRequired, }; -export default function CourseHeader({ - courseOrg, courseNumber, courseName, +export default function Header({ + courseOrg, courseNumber, courseTitle, }) { const { authenticatedUser } = useContext(AppContext); @@ -44,7 +44,7 @@ export default function CourseHeader({ />
{courseOrg} {courseNumber} - {courseName} + {courseTitle}
@@ -67,8 +67,8 @@ export default function CourseHeader({ ); } -CourseHeader.propTypes = { +Header.propTypes = { courseOrg: PropTypes.string.isRequired, courseNumber: PropTypes.string.isRequired, - courseName: PropTypes.string.isRequired, + courseTitle: PropTypes.string.isRequired, }; diff --git a/src/courseware/course/logo.svg b/src/course-header/assets/logo.svg similarity index 100% rename from src/courseware/course/logo.svg rename to src/course-header/assets/logo.svg diff --git a/src/course-header/index.js b/src/course-header/index.js new file mode 100644 index 00000000..8839c6ce --- /dev/null +++ b/src/course-header/index.js @@ -0,0 +1,2 @@ +export { default as Header } from './Header'; +export { default as CourseTabsNavigation } from './CourseTabsNavigation'; diff --git a/src/course-header/messages.js b/src/course-header/messages.js new file mode 100644 index 00000000..defda275 --- /dev/null +++ b/src/course-header/messages.js @@ -0,0 +1,11 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + 'learn.navigation.course.tabs.label': { + id: 'learn.navigation.course.tabs.label', + defaultMessage: 'Course Material', + description: 'The accessible label for course tabs navigation', + }, +}); + +export default messages; diff --git a/src/outline/CourseDates.jsx b/src/course-home/CourseDates.jsx similarity index 100% rename from src/outline/CourseDates.jsx rename to src/course-home/CourseDates.jsx diff --git a/src/outline/Outline.jsx b/src/course-home/CourseHome.jsx similarity index 51% rename from src/outline/Outline.jsx rename to src/course-home/CourseHome.jsx index bf9b5cb5..984c36ac 100644 --- a/src/outline/Outline.jsx +++ b/src/course-home/CourseHome.jsx @@ -1,45 +1,45 @@ import React from 'react'; import PropTypes from 'prop-types'; - import { Button } from '@edx/paragon'; import AlertList from '../user-messages/AlertList'; -import CourseHeader from '../courseware/course/CourseHeader'; -import CourseTabsNavigation from '../courseware/course/CourseTabsNavigation'; +import { Header, CourseTabsNavigation } from '../course-header'; +import { useLogistrationAlert } from '../logistration-alert'; +import { useEnrollmentAlert } from '../enrollment-alert'; + import CourseDates from './CourseDates'; -import { useLogistrationAlert, useEnrollmentAlert } from '../hooks'; -import Chapter from './Chapter'; -import { courseBlocksShape } from '../data/course-blocks'; +import Section from './Section'; +import { useModel } from '../model-store'; const EnrollmentAlert = React.lazy(() => import('../enrollment-alert')); const LogistrationAlert = React.lazy(() => import('../logistration-alert')); -export default function Outline({ - courseOrg, - courseNumber, - courseName, +export default function CourseHome({ courseUsageKey, - courseId, - models, - tabs, - start, - end, - enrollmentStart, - enrollmentEnd, - enrollmentMode, - isEnrolled, }) { - const course = models[courseId]; - useLogistrationAlert(); - useEnrollmentAlert(isEnrolled); + useEnrollmentAlert(courseUsageKey); + + const { + org, + number, + title, + start, + end, + enrollmentStart, + enrollmentEnd, + enrollmentMode, + isEnrolled, + tabs, + sectionIds, + } = useModel('courses', courseUsageKey); return ( <> -
@@ -56,17 +56,16 @@ export default function Outline({
-

{courseName}

+

{title}

- {course.children.map((chapterId) => ( - ( +
))}
@@ -88,28 +87,6 @@ export default function Outline({ ); } -Outline.propTypes = { - courseOrg: PropTypes.string.isRequired, - courseNumber: PropTypes.string.isRequired, - courseName: PropTypes.string.isRequired, +CourseHome.propTypes = { courseUsageKey: PropTypes.string.isRequired, - courseId: PropTypes.string.isRequired, - start: PropTypes.string.isRequired, - end: PropTypes.string.isRequired, - enrollmentStart: PropTypes.string.isRequired, - enrollmentEnd: PropTypes.string.isRequired, - enrollmentMode: PropTypes.string.isRequired, - isEnrolled: PropTypes.bool, - models: courseBlocksShape.isRequired, - tabs: PropTypes.arrayOf(PropTypes.shape({ - slug: PropTypes.string.isRequired, - priority: PropTypes.number.isRequired, - title: PropTypes.string.isRequired, - type: PropTypes.string.isRequired, - url: PropTypes.string.isRequired, - })).isRequired, -}; - -Outline.defaultProps = { - isEnrolled: false, }; diff --git a/src/course-home/CourseHomeContainer.jsx b/src/course-home/CourseHomeContainer.jsx new file mode 100644 index 00000000..c2a0b344 --- /dev/null +++ b/src/course-home/CourseHomeContainer.jsx @@ -0,0 +1,54 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; +import PageLoading from '../PageLoading'; +import CourseHome from './CourseHome'; +import { fetchCourse } from '../data'; + +function CourseHomeContainer(props) { + const { + intl, + match, + } = props; + + const dispatch = useDispatch(); + useEffect(() => { + // The courseUsageKey from the URL is the course we WANT to load. + dispatch(fetchCourse(match.params.courseUsageKey)); + }, [match.params.courseUsageKey]); + + // The courseUsageKey from the store is the course we HAVE loaded. If the URL changes, + // we don't want the application to adjust to it until it has actually loaded the new data. + const { + courseUsageKey, + courseStatus, + } = useSelector(state => state.courseware); + + return ( + <> + {courseStatus === 'loaded' ? ( + + ) : ( + + )} + + ); +} + +CourseHomeContainer.propTypes = { + intl: intlShape.isRequired, + match: PropTypes.shape({ + params: PropTypes.shape({ + courseUsageKey: PropTypes.string.isRequired, + }).isRequired, + }).isRequired, +}; + +export default injectIntl(CourseHomeContainer); diff --git a/src/outline/Chapter.jsx b/src/course-home/Section.jsx similarity index 76% rename from src/outline/Chapter.jsx rename to src/course-home/Section.jsx index 7112309a..d6676fd5 100644 --- a/src/outline/Chapter.jsx +++ b/src/course-home/Section.jsx @@ -4,10 +4,11 @@ import { Collapsible } from '@edx/paragon'; import { faChevronRight, faChevronDown } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import SequenceLink from './SequenceLink'; -import { courseBlocksShape } from '../data/course-blocks'; +import { useModel } from '../model-store'; -export default function Chapter({ id, courseUsageKey, models }) { - const { displayName, children } = models[id]; +export default function Section({ id, courseUsageKey }) { + const section = useModel('sections', id); + const { title, sequenceIds } = section; return ( @@ -21,16 +22,15 @@ export default function Chapter({ id, courseUsageKey, models }) {
-
{displayName}
+
{title}
- {children.map((sequenceId) => ( + {sequenceIds.map((sequenceId) => ( ))} @@ -38,8 +38,7 @@ export default function Chapter({ id, courseUsageKey, models }) { ); } -Chapter.propTypes = { +Section.propTypes = { id: PropTypes.string.isRequired, courseUsageKey: PropTypes.string.isRequired, - models: courseBlocksShape.isRequired, }; diff --git a/src/course-home/SequenceLink.jsx b/src/course-home/SequenceLink.jsx new file mode 100644 index 00000000..7ae7ccb2 --- /dev/null +++ b/src/course-home/SequenceLink.jsx @@ -0,0 +1,18 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; +import { useModel } from '../model-store'; + +export default function SequenceLink({ id, courseUsageKey }) { + const sequence = useModel('sequences', id); + return ( +
+ {sequence.title} +
+ ); +} + +SequenceLink.propTypes = { + id: PropTypes.string.isRequired, + courseUsageKey: PropTypes.string.isRequired, +}; diff --git a/src/course-home/index.js b/src/course-home/index.js new file mode 100644 index 00000000..5a034d17 --- /dev/null +++ b/src/course-home/index.js @@ -0,0 +1 @@ +export { default } from './CourseHomeContainer'; diff --git a/src/course-home/messages.js b/src/course-home/messages.js new file mode 100644 index 00000000..a5d2a02b --- /dev/null +++ b/src/course-home/messages.js @@ -0,0 +1,11 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + 'learn.loading.outline': { + id: 'learn.loading.learning.sequence', + defaultMessage: 'Loading learning sequence...', + description: 'Message when learning sequence is being loaded', + }, +}); + +export default messages; diff --git a/src/courseware/CourseContainer.jsx b/src/courseware/CourseContainer.jsx deleted file mode 100644 index 051c54ac..00000000 --- a/src/courseware/CourseContainer.jsx +++ /dev/null @@ -1,125 +0,0 @@ -import React, { useEffect } from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { history, getConfig } from '@edx/frontend-platform'; -import { fetchCourseMetadata, courseMetadataShape } from '../data/course-meta'; -import { fetchCourseBlocks } from '../data/course-blocks'; - -import messages from './messages'; -import PageLoading from '../PageLoading'; -import Course from './course/Course'; - -function CourseContainer(props) { - const { - intl, - match, - courseId, - blocks: models, - metadata, - } = props; - const { - courseUsageKey, - sequenceId, - unitId, - } = match.params; - - useEffect(() => { - props.fetchCourseMetadata(courseUsageKey); - props.fetchCourseBlocks(courseUsageKey); - }, [courseUsageKey]); - - useEffect(() => { - if (courseId && !sequenceId) { - // TODO: This is temporary until we get an actual activeSequenceId into the course model data. - const course = models[courseId]; - const chapter = models[course.children[0]]; - const activeSequenceId = chapter.children[0]; - history.push(`/course/${courseUsageKey}/${activeSequenceId}`); - } - }, [courseUsageKey, courseId, sequenceId]); - - const metadataLoaded = metadata.fetchState === 'loaded'; - useEffect(() => { - if (metadataLoaded && !metadata.userHasAccess) { - global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${courseUsageKey}/course/`); - } - }, [metadataLoaded]); - - // Whether or not the container is ready to render the Course. - const ready = metadataLoaded && courseId && sequenceId; - - return ( -
- {(() => { - if (ready) { - return ( - - ); - } - - if (metadata.fetchState === 'failed' || models.fetchState === 'failed') { - return ( -

- {intl.formatMessage(messages['learn.course.load.failure'])} -

- ); - } - - return ( - - ); - })()} -
- ); -} - -CourseContainer.propTypes = { - intl: intlShape.isRequired, - courseId: PropTypes.string, - blocks: PropTypes.objectOf(PropTypes.shape({ - id: PropTypes.string, - })), - metadata: courseMetadataShape, - fetchCourseMetadata: PropTypes.func.isRequired, - fetchCourseBlocks: PropTypes.func.isRequired, - match: PropTypes.shape({ - params: PropTypes.shape({ - courseUsageKey: PropTypes.string.isRequired, - sequenceId: PropTypes.string, - unitId: PropTypes.string, - }).isRequired, - }).isRequired, -}; - -CourseContainer.defaultProps = { - blocks: {}, - metadata: undefined, - courseId: undefined, -}; - -const mapStateToProps = state => ({ - courseId: state.courseBlocks.root, - metadata: state.courseMeta, - blocks: state.courseBlocks.blocks, -}); - -export default connect(mapStateToProps, { - fetchCourseMetadata, - fetchCourseBlocks, -})(injectIntl(CourseContainer)); diff --git a/src/courseware/CoursewareContainer.jsx b/src/courseware/CoursewareContainer.jsx new file mode 100644 index 00000000..bbe0cb60 --- /dev/null +++ b/src/courseware/CoursewareContainer.jsx @@ -0,0 +1,199 @@ +import React, { useEffect, useCallback } from 'react'; +import PropTypes from 'prop-types'; +import { useSelector, useDispatch } from 'react-redux'; +import { history, getConfig } from '@edx/frontend-platform'; + +import { useRouteMatch } from 'react-router'; +import { + fetchCourse, + fetchSequence, +} from '../data'; +import { + checkBlockCompletion, + saveSequencePosition, +} from './data/thunks'; +import { useModel } from '../model-store'; + +import Course from './course'; + +import { sequenceIdsSelector, firstSequenceIdSelector } from './data/selectors'; + +function useUnitNavigationHandler(courseUsageKey, sequenceId, unitId) { + const dispatch = useDispatch(); + return useCallback((nextUnitId) => { + dispatch(checkBlockCompletion(courseUsageKey, sequenceId, unitId)); + history.replace(`/course/${courseUsageKey}/${sequenceId}/${nextUnitId}`); + }, [courseUsageKey, sequenceId]); +} + +function usePreviousSequence(sequenceId) { + const sequenceIds = useSelector(sequenceIdsSelector); + const sequences = useSelector(state => state.models.sequences); + if (!sequenceId || sequenceIds.length === 0) { + return null; + } + const sequenceIndex = sequenceIds.indexOf(sequenceId); + const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null; + return previousSequenceId !== null ? sequences[previousSequenceId] : null; +} + +function useNextSequence(sequenceId) { + const sequenceIds = useSelector(sequenceIdsSelector); + const sequences = useSelector(state => state.models.sequences); + if (!sequenceId || sequenceIds.length === 0) { + return null; + } + const sequenceIndex = sequenceIds.indexOf(sequenceId); + const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null; + return nextSequenceId !== null ? sequences[nextSequenceId] : null; +} + + +function useNextSequenceHandler(courseUsageKey, sequenceId) { + const nextSequence = useNextSequence(sequenceId); + const courseStatus = useSelector(state => state.courseware.courseStatus); + const sequenceStatus = useSelector(state => state.courseware.sequenceStatus); + return useCallback(() => { + if (nextSequence !== null) { + const nextUnitId = nextSequence.unitIds[0]; + history.replace(`/course/${courseUsageKey}/${nextSequence.id}/${nextUnitId}`); + } + }, [courseStatus, sequenceStatus, sequenceId]); +} + +function usePreviousSequenceHandler(courseUsageKey, sequenceId) { + const previousSequence = usePreviousSequence(sequenceId); + const courseStatus = useSelector(state => state.courseware.courseStatus); + const sequenceStatus = useSelector(state => state.courseware.sequenceStatus); + return useCallback(() => { + if (previousSequence !== null) { + const previousUnitId = previousSequence.unitIds[previousSequence.unitIds.length - 1]; + history.replace(`/course/${courseUsageKey}/${previousSequence.id}/${previousUnitId}`); + } + }, [courseStatus, sequenceStatus, sequenceId]); +} + +function useExamRedirect(sequenceId) { + const sequence = useModel('sequences', sequenceId); + const sequenceStatus = useSelector(state => state.courseware.sequenceStatus); + useEffect(() => { + if (sequenceStatus === 'loaded' && sequence.isTimeLimited) { + global.location.assign(sequence.lmsWebUrl); + } + }, [sequenceStatus, sequence]); +} + +function useContentRedirect(courseStatus, sequenceStatus) { + const match = useRouteMatch(); + const { courseUsageKey, sequenceId, unitId } = match.params; + const sequence = useModel('sequences', sequenceId); + const firstSequenceId = useSelector(firstSequenceIdSelector); + useEffect(() => { + if (courseStatus === 'loaded' && !sequenceId) { + history.replace(`/course/${courseUsageKey}/${firstSequenceId}`); + } + }, [courseStatus, sequenceId]); + + useEffect(() => { + if (sequenceStatus === 'loaded' && sequenceId && !unitId) { + // The position may be null, in which case we'll just assume 0. + const unitIndex = sequence.position || 0; + const nextUnitId = sequence.unitIds[unitIndex]; + history.replace(`/course/${courseUsageKey}/${sequence.id}/${nextUnitId}`); + } + }, [sequenceStatus]); +} + +function useSavedSequencePosition(courseUsageKey, sequenceId, unitId) { + const dispatch = useDispatch(); + const sequence = useModel('sequences', sequenceId); + const sequenceStatus = useSelector(state => state.courseware.sequenceStatus); + useEffect(() => { + if (sequenceStatus === 'loaded' && sequence.savePosition) { + const activeUnitIndex = sequence.unitIds.indexOf(unitId); + dispatch(saveSequencePosition(courseUsageKey, sequenceId, activeUnitIndex)); + } + }, [unitId]); +} + +/** + * Redirects the user away from the app if they don't have access to view this course. + * + * @param {*} courseStatus + * @param {*} course + */ +function useAccessDeniedRedirect(courseStatus, courseId) { + const course = useModel('courses', courseId); + useEffect(() => { + if (courseStatus === 'loaded' && !course.userHasAccess) { + global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${course.id}/course/`); + } + }, [courseStatus, course]); +} + +export default function CoursewareContainer() { + const { params } = useRouteMatch(); + const { + courseUsageKey: routeCourseUsageKey, + sequenceId: routeSequenceId, + unitId: routeUnitId, + } = params; + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(fetchCourse(routeCourseUsageKey)); + }, [routeCourseUsageKey]); + + useEffect(() => { + if (routeSequenceId) { + dispatch(fetchSequence(routeSequenceId)); + } + }, [routeSequenceId]); + + // The courseUsageKey and sequenceId in the store are the entities we currently have loaded. + // We get these two IDs from the store because until fetchCourse and fetchSequence below have + // finished their work, the IDs in the URL are not representative of what we should actually show. + // This is important particularly when switching sequences. Until a new sequence is fully loaded, + // there's information that we don't have yet - if we use the URL's sequence ID to tell the app + // which sequence is loaded, we'll instantly try to pull it out of the store and use it, before + // the sequenceStatus flag has even switched back to "loading", which will put our app into an + // invalid state. + const { + courseUsageKey, + sequenceId, + courseStatus, + sequenceStatus, + } = useSelector(state => state.courseware); + + const nextSequenceHandler = useNextSequenceHandler(courseUsageKey, sequenceId); + const previousSequenceHandler = usePreviousSequenceHandler(courseUsageKey, sequenceId); + const unitNavigationHandler = useUnitNavigationHandler(courseUsageKey, sequenceId, routeUnitId); + + useAccessDeniedRedirect(courseStatus, courseUsageKey); + useContentRedirect(courseStatus, sequenceStatus); + useExamRedirect(sequenceId); + useSavedSequencePosition(courseUsageKey, sequenceId, routeUnitId); + + return ( +
+ +
+ ); +} + +CoursewareContainer.propTypes = { + match: PropTypes.shape({ + params: PropTypes.shape({ + courseUsageKey: PropTypes.string.isRequired, + sequenceId: PropTypes.string, + unitId: PropTypes.string, + }).isRequired, + }).isRequired, +}; diff --git a/src/courseware/course/Course.jsx b/src/courseware/course/Course.jsx index 294caa75..55aceb17 100644 --- a/src/courseware/course/Course.jsx +++ b/src/courseware/course/Course.jsx @@ -1,146 +1,122 @@ -import React, { useCallback } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; -import { history } from '@edx/frontend-platform'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; + +import { useSelector } from 'react-redux'; +import AlertList from '../../user-messages/AlertList'; +import { useLogistrationAlert } from '../../logistration-alert'; +import { useEnrollmentAlert } from '../../enrollment-alert'; +import PageLoading from '../../PageLoading'; + +import InstructorToolbar from './InstructorToolbar'; +import Sequence from './sequence'; import CourseBreadcrumbs from './CourseBreadcrumbs'; -import SequenceContainer from './SequenceContainer'; -import { createSequenceIdList } from '../utils'; -import AlertList from '../../user-messages/AlertList'; -import CourseHeader from './CourseHeader'; +import { Header, CourseTabsNavigation } from '../../course-header'; import CourseSock from './course-sock'; -import CourseTabsNavigation from './CourseTabsNavigation'; -import InstructorToolbar from '../InstructorToolbar'; -import { useLogistrationAlert, useEnrollmentAlert } from '../../hooks'; +import messages from './messages'; +import { useModel } from '../../model-store'; const EnrollmentAlert = React.lazy(() => import('../../enrollment-alert')); const LogistrationAlert = React.lazy(() => import('../../logistration-alert')); - -export default function Course({ +function Course({ courseId, - courseNumber, - courseName, - courseOrg, - courseUsageKey, - isEnrolled, - isStaff, - models, sequenceId, - tabs, unitId, - verifiedMode, + nextSequenceHandler, + previousSequenceHandler, + unitNavigationHandler, + intl, }) { - const nextSequenceHandler = useCallback(() => { - const sequenceIds = createSequenceIdList(models, courseId); - const currentIndex = sequenceIds.indexOf(sequenceId); - if (currentIndex < sequenceIds.length - 1) { - const nextSequenceId = sequenceIds[currentIndex + 1]; - const nextSequence = models[nextSequenceId]; - const nextUnitId = nextSequence.children[0]; - history.push(`/course/${courseUsageKey}/${nextSequenceId}/${nextUnitId}`); - } - }); - - const previousSequenceHandler = useCallback(() => { - const sequenceIds = createSequenceIdList(models, courseId); - const currentIndex = sequenceIds.indexOf(sequenceId); - if (currentIndex > 0) { - const previousSequenceId = sequenceIds[currentIndex - 1]; - const previousSequence = models[previousSequenceId]; - const previousUnitId = previousSequence.children[previousSequence.children.length - 1]; - history.push(`/course/${courseUsageKey}/${previousSequenceId}/${previousUnitId}`); - } - }); + const course = useModel('courses', courseId); + const sequence = useModel('sequences', sequenceId); + const section = useModel('sections', sequence ? sequence.sectionId : null); + const unit = useModel('units', unitId); useLogistrationAlert(); - useEnrollmentAlert(isEnrolled); + useEnrollmentAlert(courseId); - return ( - <> - state.courseware.courseStatus); + + if (courseStatus === 'loading') { + return ( + - {isStaff && ( + ); + } + + if (courseStatus === 'loaded') { + const { + org, number, title, isStaff, tabs, verifiedMode, + } = course; + return ( + <> +
+ {isStaff && ( - )} - -
- - - -
-
- - {verifiedMode && } -
- + )} + +
+ + + +
+
+ + {verifiedMode && } +
+ + ); + } + + // courseStatus 'failed' and any other unexpected course status. + return ( +

+ {intl.formatMessage(messages['learn.course.load.failure'])} +

); } Course.propTypes = { - courseOrg: PropTypes.string.isRequired, - courseNumber: PropTypes.string.isRequired, - courseName: PropTypes.string.isRequired, - courseUsageKey: PropTypes.string.isRequired, - courseId: PropTypes.string.isRequired, - sequenceId: PropTypes.string.isRequired, + courseId: PropTypes.string, + sequenceId: PropTypes.string, unitId: PropTypes.string, - isEnrolled: PropTypes.bool, - isStaff: PropTypes.bool, - models: PropTypes.objectOf(PropTypes.shape({ - id: PropTypes.string.isRequired, - displayName: PropTypes.string.isRequired, - children: PropTypes.arrayOf(PropTypes.string), - parentId: PropTypes.string, - })).isRequired, - tabs: PropTypes.arrayOf(PropTypes.shape({ - slug: PropTypes.string.isRequired, - priority: PropTypes.number.isRequired, - title: PropTypes.string.isRequired, - type: PropTypes.string.isRequired, - url: PropTypes.string.isRequired, - })).isRequired, - verifiedMode: PropTypes.shape({ - price: PropTypes.number.isRequired, - currency: PropTypes.string.isRequired, - currencySymbol: PropTypes.string, - sku: PropTypes.string.isRequired, - upgradeUrl: PropTypes.string.isRequired, - }), + nextSequenceHandler: PropTypes.func.isRequired, + previousSequenceHandler: PropTypes.func.isRequired, + unitNavigationHandler: PropTypes.func.isRequired, + intl: intlShape.isRequired, }; Course.defaultProps = { - unitId: undefined, - isEnrolled: false, - isStaff: false, - verifiedMode: null, + courseId: null, + sequenceId: null, + unitId: null, }; + +export default injectIntl(Course); diff --git a/src/courseware/course/CourseBreadcrumbs.jsx b/src/courseware/course/CourseBreadcrumbs.jsx index 2db5ab50..2750b1cb 100644 --- a/src/courseware/course/CourseBreadcrumbs.jsx +++ b/src/courseware/course/CourseBreadcrumbs.jsx @@ -4,6 +4,8 @@ import { getConfig } from '@edx/frontend-platform'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faHome } from '@fortawesome/free-solid-svg-icons'; +import { useSelector } from 'react-redux'; +import { useModel } from '../../model-store'; function CourseBreadcrumb({ url, children, withSeparator, ...attrs @@ -30,27 +32,33 @@ CourseBreadcrumb.defaultProps = { withSeparator: false, }; - export default function CourseBreadcrumbs({ - courseUsageKey, courseId, sequenceId, models, + courseId, + sectionId, + sequenceId, }) { + const course = useModel('courses', courseId); + const sequence = useModel('sequences', sequenceId); + const section = useModel('sections', sectionId); + const courseStatus = useSelector(state => state.courseware.courseStatus); + const sequenceStatus = useSelector(state => state.courseware.sequenceStatus); + const links = useMemo(() => { - const sectionId = models[sequenceId].parentId; - return [sectionId, sequenceId].map((nodeId) => { - const node = models[nodeId]; - return { + if (courseStatus === 'loaded' && sequenceStatus === 'loaded') { + return [section, sequence].map((node) => ({ id: node.id, - label: node.displayName, - url: `${getConfig().LMS_BASE_URL}/courses/${courseUsageKey}/course/#${node.id}`, - }; - }); - }, [courseUsageKey, courseId, sequenceId, models]); + label: node.title, + url: `${getConfig().LMS_BASE_URL}/courses/${course.id}/course/#${node.id}`, + })); + } + return []; + }, [courseStatus, sequenceStatus]); return (