diff --git a/package.json b/package.json
index 2ab74102..d6627b01 100644
--- a/package.json
+++ b/package.json
@@ -46,6 +46,7 @@
"@reduxjs/toolkit": "^1.2.3",
"classnames": "^2.2.6",
"core-js": "^3.6.2",
+ "lodash.memoize": "^4.1.2",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-break": "^1.3.2",
diff --git a/src/courseware/CoursewareContainer.jsx b/src/courseware/CoursewareContainer.jsx
index 2a5f223d..c0edca7b 100644
--- a/src/courseware/CoursewareContainer.jsx
+++ b/src/courseware/CoursewareContainer.jsx
@@ -1,88 +1,144 @@
-import React, { useEffect, useCallback } from 'react';
+import React, { Component } from 'react';
import PropTypes from 'prop-types';
-import { useSelector, useDispatch } from 'react-redux';
+import { connect } from 'react-redux';
import { history } from '@edx/frontend-platform';
import { getLocale } from '@edx/frontend-platform/i18n';
-import { useRouteMatch, Redirect } from 'react-router';
-
-import { useModel } from '../generic/model-store';
-import { TabPage } from '../tab-page';
+import { Redirect } from 'react-router';
+import memoize from 'lodash.memoize';
+import { createSelector } from '@reduxjs/toolkit';
import {
+ checkBlockCompletion,
fetchCourse,
fetchSequence,
- checkBlockCompletion,
- saveSequencePosition,
getResumeBlock,
- sequenceIdsSelector,
- firstSequenceIdSelector,
+ saveSequencePosition,
} from './data';
+import { TabPage } from '../tab-page';
+
import Course from './course';
-import { handleNextSectionCelebration } from './course/celebration';
-function useUnitNavigationHandler(courseId, sequenceId, unitId) {
- const dispatch = useDispatch();
- return useCallback((nextUnitId) => {
- dispatch(checkBlockCompletion(courseId, sequenceId, unitId));
+const checkExamRedirect = memoize((sequenceStatus, sequence) => {
+ if (sequenceStatus === 'loaded') {
+ if (sequence.isTimeLimited && sequence.lmsWebUrl !== undefined) {
+ global.location.assign(sequence.lmsWebUrl);
+ }
+ }
+});
+
+const checkResumeRedirect = memoize((courseStatus, courseId, sequenceId, firstSequenceId) => {
+ if (courseStatus === 'loaded' && !sequenceId) {
+ // Note that getResumeBlock is just an API call, not a redux thunk.
+ getResumeBlock(courseId).then((data) => {
+ // This is a replace because we don't want this change saved in the browser's history.
+ if (data.sectionId && data.unitId) {
+ history.replace(`/course/${courseId}/${data.sectionId}/${data.unitId}`);
+ } else if (firstSequenceId) {
+ history.replace(`/course/${courseId}/${firstSequenceId}`);
+ }
+ });
+ }
+});
+
+const checkContentRedirect = memoize((courseId, sequenceStatus, sequenceId, sequence, unitId) => {
+ if (sequenceStatus === 'loaded' && sequenceId && !unitId) {
+ if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) {
+ const nextUnitId = sequence.unitIds[sequence.activeUnitIndex];
+ // This is a replace because we don't want this change saved in the browser's history.
+ history.replace(`/course/${courseId}/${sequence.id}/${nextUnitId}`);
+ }
+ }
+});
+
+class CoursewareContainer extends Component {
+ checkSaveSequencePosition = memoize((courseId, sequenceStatus, sequenceId, sequence, unitId) => {
+ if (sequenceStatus === 'loaded' && sequence.savePosition) {
+ const activeUnitIndex = sequence.unitIds.indexOf(unitId);
+ this.props.saveSequencePosition(courseId, sequenceId, activeUnitIndex);
+ }
+ });
+
+ checkFetchCourse = memoize((courseId) => {
+ this.props.fetchCourse(courseId);
+ });
+
+ checkFetchSequence = memoize((sequenceId) => {
+ if (sequenceId) {
+ this.props.fetchSequence(sequenceId);
+ }
+ });
+
+ componentDidMount() {
+ const {
+ match: {
+ params: {
+ courseId: routeCourseId,
+ sequenceId: routeSequenceId,
+ },
+ },
+ } = this.props;
+ // Load data whenever the course or sequence ID changes.
+ this.checkFetchCourse(routeCourseId);
+ this.checkFetchSequence(routeSequenceId);
+ }
+
+ componentDidUpdate() {
+ const {
+ courseId,
+ sequenceId,
+ unitId,
+ courseStatus,
+ sequenceStatus,
+ sequence,
+ firstSequenceId,
+ match: {
+ params: {
+ courseId: routeCourseId,
+ sequenceId: routeSequenceId,
+ },
+ },
+ } = this.props;
+
+ // Load data whenever the course or sequence ID changes.
+ this.checkFetchCourse(routeCourseId);
+ this.checkFetchSequence(routeSequenceId);
+
+ // Redirect to the legacy experience for exams.
+ checkExamRedirect(sequenceStatus, sequence);
+
+ // Determine if we need to redirect because our URL is incomplete.
+ checkContentRedirect(courseId, sequenceStatus, sequenceId, sequence, unitId);
+
+ // Determine if we can resume where we left off.
+ checkResumeRedirect(courseStatus, courseId, sequenceId, firstSequenceId);
+
+ // Check if we should save our sequence position.
+ this.checkSaveSequencePosition(courseId, sequenceStatus, sequenceId, sequence, unitId);
+ }
+
+ handleUnitNavigationClick = (nextUnitId) => {
+ const {
+ courseId, sequenceId, unitId,
+ } = this.props;
+ this.props.checkBlockCompletion(courseId, sequenceId, unitId);
history.push(`/course/${courseId}/${sequenceId}/${nextUnitId}`);
- }, [courseId, sequenceId, unitId]);
-}
-
-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(courseId, sequenceId) {
- const course = useModel('courses', courseId);
- const sequence = useModel('sequences', sequenceId);
- const nextSequence = useNextSequence(sequenceId);
- const courseStatus = useSelector(state => state.courseware.courseStatus);
- const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
- return useCallback(() => {
+ handleNextSequenceClick = () => {
+ const { nextSequence, courseId } = this.props;
if (nextSequence !== null) {
- let nextUnitId = null;
if (nextSequence.unitIds.length > 0) {
- [nextUnitId] = nextSequence.unitIds;
+ const nextUnitId = nextSequence.unitIds[0];
history.push(`/course/${courseId}/${nextSequence.id}/${nextUnitId}`);
} else {
// Some sequences have no units. This will show a blank page with prev/next buttons.
history.push(`/course/${courseId}/${nextSequence.id}`);
}
-
- // TODO: Consider publishing an event on sequence navigation which the celebration modal can
- // subscribe to. That'd prevent us from having celebration-specific code here in this
- // handler.
- const celebrateFirstSection = course && course.celebrations && course.celebrations.firstSection;
- if (celebrateFirstSection && sequence.sectionId !== nextSequence.sectionId) {
- handleNextSectionCelebration(sequenceId, nextSequence.id, nextUnitId);
- }
}
- }, [courseStatus, sequenceStatus, sequenceId]);
-}
+ }
-function usePreviousSequenceHandler(courseId, sequenceId) {
- const previousSequence = usePreviousSequence(sequenceId);
- const courseStatus = useSelector(state => state.courseware.courseStatus);
- const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
- return useCallback(() => {
+ handlePreviousSequenceClick = () => {
+ const { previousSequence, courseId } = this.props;
if (previousSequence !== null) {
if (previousSequence.unitIds.length > 0) {
const previousUnitId = previousSequence.unitIds[previousSequence.unitIds.length - 1];
@@ -92,141 +148,83 @@ function usePreviousSequenceHandler(courseId, sequenceId) {
history.push(`/course/${courseId}/${previousSequence.id}`);
}
}
- }, [courseStatus, sequenceStatus, sequenceId]);
-}
+ }
-function useExamRedirect(sequenceId) {
- const sequence = useModel('sequences', sequenceId);
- const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
- useEffect(() => {
- if (sequenceStatus === 'loaded' && sequence.isTimeLimited && sequence.lmsWebUrl !== undefined) {
- global.location.assign(sequence.lmsWebUrl);
- }
- }, [sequenceStatus, sequence]);
-}
-
-function useContentRedirect(courseStatus, sequenceStatus) {
- const match = useRouteMatch();
- const { courseId, sequenceId, unitId } = match.params;
- const sequence = useModel('sequences', sequenceId);
- const firstSequenceId = useSelector(firstSequenceIdSelector);
- useEffect(() => {
- if (courseStatus === 'loaded' && !sequenceId) {
- getResumeBlock(courseId).then((data) => {
- // This is a replace because we don't want this change saved in the browser's history.
- if (data.sectionId && data.unitId) {
- history.replace(`/course/${courseId}/${data.sectionId}/${data.unitId}`);
- } else if (firstSequenceId) {
- history.replace(`/course/${courseId}/${firstSequenceId}`);
- }
- });
- }
- }, [courseStatus, sequenceId]);
-
- useEffect(() => {
- if (sequenceStatus === 'loaded' && sequenceId && !unitId) {
- if (sequence.unitIds !== undefined && sequence.unitIds.length > 0) {
- const nextUnitId = sequence.unitIds[sequence.activeUnitIndex];
- // This is a replace because we don't want this change saved in the browser's history.
- history.replace(`/course/${courseId}/${sequence.id}/${nextUnitId}`);
- }
- }
- }, [sequenceStatus, sequenceId, unitId]);
-}
-
-function useSavedSequencePosition(courseId, 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(courseId, sequenceId, activeUnitIndex));
- }
- }, [unitId]);
-}
-
-export default function CoursewareContainer() {
- const { params } = useRouteMatch();
- const {
- courseId: routeCourseUsageKey,
- sequenceId: routeSequenceId,
- unitId: routeUnitId,
- } = params;
- const dispatch = useDispatch();
-
- useEffect(() => {
- dispatch(fetchCourse(routeCourseUsageKey));
- }, [routeCourseUsageKey]);
-
- useEffect(() => {
- if (routeSequenceId) {
- dispatch(fetchSequence(routeSequenceId));
- }
- }, [routeSequenceId]);
-
- // The courseId 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 above 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 {
- courseId,
- sequenceId,
- courseStatus,
- sequenceStatus,
- } = useSelector(state => state.courseware);
-
- const nextSequenceHandler = useNextSequenceHandler(courseId, sequenceId);
- const previousSequenceHandler = usePreviousSequenceHandler(courseId, sequenceId);
- const unitNavigationHandler = useUnitNavigationHandler(courseId, sequenceId, routeUnitId);
-
- useContentRedirect(courseStatus, sequenceStatus);
- useExamRedirect(sequenceId);
- useSavedSequencePosition(courseId, sequenceId, routeUnitId);
-
- const course = useModel('courses', courseId);
-
- if (courseStatus === 'denied') {
+ renderDenied() {
+ const { courseId, course } = this.props;
+ let url = `/redirect/course-home/${courseId}`;
switch (course.canLoadCourseware.errorCode) {
case 'audit_expired':
- return ;
+ url = `/redirect/dashboard?access_response_error=${course.canLoadCourseware.additionalContextUserMessage}`;
+ break;
case 'course_not_started':
// eslint-disable-next-line no-case-declarations
const startDate = (new Intl.DateTimeFormat(getLocale())).format(new Date(course.start));
- return ;
+ url = `/redirect/dashboard?notlive=${startDate}`;
+ break;
case 'survey_required': // TODO: Redirect to the course survey
case 'unfulfilled_milestones':
- return ;
+ url = '/redirect/dashboard';
+ break;
case 'authentication_required':
case 'enrollment_required':
default:
- return ;
}
+ return (
+
+ );
}
- return (
-
-
-
- );
+ courseStatus={courseStatus}
+ >
+
+
+ );
+ }
}
+const sequenceShape = PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
+ isTimeLimited: PropTypes.bool,
+ lmsWebUrl: PropTypes.string,
+});
+
+const courseShape = PropTypes.shape({
+ canLoadCourseware: PropTypes.shape({
+ errorCode: PropTypes.string,
+ additionalContextUserMessage: PropTypes.string,
+ }).isRequired,
+});
+
CoursewareContainer.propTypes = {
match: PropTypes.shape({
params: PropTypes.shape({
@@ -235,4 +233,126 @@ CoursewareContainer.propTypes = {
unitId: PropTypes.string,
}).isRequired,
}).isRequired,
+ courseId: PropTypes.string,
+ sequenceId: PropTypes.string,
+ firstSequenceId: PropTypes.string,
+ unitId: PropTypes.string,
+ courseStatus: PropTypes.oneOf(['loaded', 'loading', 'failed', 'denied']).isRequired,
+ sequenceStatus: PropTypes.oneOf(['loaded', 'loading', 'failed']).isRequired,
+ nextSequence: sequenceShape,
+ previousSequence: sequenceShape,
+ course: courseShape,
+ sequence: sequenceShape,
+ saveSequencePosition: PropTypes.func.isRequired,
+ checkBlockCompletion: PropTypes.func.isRequired,
+ fetchCourse: PropTypes.func.isRequired,
+ fetchSequence: PropTypes.func.isRequired,
};
+
+CoursewareContainer.defaultProps = {
+ courseId: null,
+ sequenceId: null,
+ firstSequenceId: null,
+ unitId: null,
+ nextSequence: null,
+ previousSequence: null,
+ course: null,
+ sequence: null,
+};
+
+const currentCourseSelector = createSelector(
+ (state) => state.models.courses || {},
+ (state) => state.courseware.courseId,
+ (coursesById, courseId) => (coursesById[courseId] ? coursesById[courseId] : null),
+);
+
+const currentSequenceSelector = createSelector(
+ (state) => state.models.sequences || {},
+ (state) => state.courseware.sequenceId,
+ (sequencesById, sequenceId) => (sequencesById[sequenceId] ? sequencesById[sequenceId] : null),
+);
+
+const sequenceIdsSelector = createSelector(
+ (state) => state.courseware.courseStatus,
+ currentCourseSelector,
+ (state) => state.models.sections,
+ (courseStatus, course, sectionsById) => {
+ if (courseStatus !== 'loaded') {
+ return [];
+ }
+ const { sectionIds = [] } = course;
+ return sectionIds.flatMap(sectionId => sectionsById[sectionId].sequenceIds);
+ },
+);
+
+const previousSequenceSelector = createSelector(
+ sequenceIdsSelector,
+ (state) => state.models.sequences || {},
+ (state) => state.courseware.sequenceId,
+ (sequenceIds, sequencesById, sequenceId) => {
+ if (!sequenceId || sequenceIds.length === 0) {
+ return null;
+ }
+ const sequenceIndex = sequenceIds.indexOf(sequenceId);
+ const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null;
+ return previousSequenceId !== null ? sequencesById[previousSequenceId] : null;
+ },
+);
+
+const nextSequenceSelector = createSelector(
+ sequenceIdsSelector,
+ (state) => state.models.sequences || {},
+ (state) => state.courseware.sequenceId,
+ (sequenceIds, sequencesById, sequenceId) => {
+ 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 ? sequencesById[nextSequenceId] : null;
+ },
+);
+
+const firstSequenceIdSelector = createSelector(
+ (state) => state.courseware.courseStatus,
+ currentCourseSelector,
+ (state) => state.models.sections || {},
+ (courseStatus, course, sectionsById) => {
+ if (courseStatus !== 'loaded') {
+ return null;
+ }
+ const { sectionIds = [] } = course;
+
+ if (sectionIds.length === 0) {
+ return null;
+ }
+
+ return sectionsById[sectionIds[0]].sequenceIds[0];
+ },
+);
+
+const mapStateToProps = (state) => {
+ const {
+ courseId, sequenceId, unitId, courseStatus, sequenceStatus,
+ } = state.courseware;
+
+ return {
+ courseId,
+ sequenceId,
+ unitId,
+ courseStatus,
+ sequenceStatus,
+ course: currentCourseSelector(state),
+ sequence: currentSequenceSelector(state),
+ previousSequence: previousSequenceSelector(state),
+ nextSequence: nextSequenceSelector(state),
+ firstSequenceId: firstSequenceIdSelector(state),
+ };
+};
+
+export default connect(mapStateToProps, {
+ checkBlockCompletion,
+ saveSequencePosition,
+ fetchCourse,
+ fetchSequence,
+})(CoursewareContainer);
diff --git a/src/courseware/data/index.js b/src/courseware/data/index.js
index 9dbada10..d5974bfa 100644
--- a/src/courseware/data/index.js
+++ b/src/courseware/data/index.js
@@ -9,6 +9,5 @@ export {
} from './api';
export {
sequenceIdsSelector,
- firstSequenceIdSelector,
} from './selectors';
export { reducer } from './slice';
diff --git a/src/courseware/data/selectors.js b/src/courseware/data/selectors.js
index 73514b13..bec708aa 100644
--- a/src/courseware/data/selectors.js
+++ b/src/courseware/data/selectors.js
@@ -10,16 +10,3 @@ export function sequenceIdsSelector(state) {
return sequenceIds;
}
-
-export function firstSequenceIdSelector(state) {
- if (state.courseware.courseStatus !== 'loaded') {
- return null;
- }
- const { sectionIds = [] } = state.models.courses[state.courseware.courseId];
-
- if (sectionIds.length === 0) {
- return null;
- }
-
- return state.models.sections[sectionIds[0]].sequenceIds[0];
-}