diff --git a/src/courseware/PageLoading.jsx b/src/PageLoading.jsx similarity index 100% rename from src/courseware/PageLoading.jsx rename to src/PageLoading.jsx diff --git a/src/courseware/CourseContainer.jsx b/src/courseware/CourseContainer.jsx index 16a0d9d5..cd726f50 100644 --- a/src/courseware/CourseContainer.jsx +++ b/src/courseware/CourseContainer.jsx @@ -3,11 +3,11 @@ 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 } from '../data/course-meta/thunks'; -import { fetchCourseBlocks } from '../data/course-blocks/thunks'; +import { fetchCourseMetadata, courseMetadataShape } from '../data/course-meta'; +import { fetchCourseBlocks } from '../data/course-blocks'; import messages from './messages'; -import PageLoading from './PageLoading'; +import PageLoading from '../PageLoading'; import Course from './course/Course'; function CourseContainer(props) { @@ -24,8 +24,6 @@ function CourseContainer(props) { unitId, } = match.params; - const metadataLoaded = metadata.fetchState === 'loaded'; - useEffect(() => { props.fetchCourseMetadata(courseUsageKey); props.fetchCourseBlocks(courseUsageKey); @@ -41,17 +39,19 @@ function CourseContainer(props) { } }, [courseUsageKey, courseId, sequenceId]); + const metadataLoaded = metadata.fetchState === 'loaded'; useEffect(() => { if (metadataLoaded && !metadata.userHasAccess) { global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${courseUsageKey}/course/`); } }, [metadataLoaded]); - const isLoaded = courseId && sequenceId && metadataLoaded; + // Whether or not the container is ready to render the Course. + const ready = metadataLoaded && courseId && sequenceId; return (
- { isLoaded ? ( + {ready ? ( ({ fetchState: 'loaded', + + /* + * NOTE: If you change the data saved here, + * update the courseMetadataShape below! + */ + + // Course identifiers name: payload.name, number: payload.number, org: payload.org, - tabs: payload.tabs, - userHasAccess: payload.userHasAccess, + + // Enrollment dates + enrollmentStart: payload.enrollmentStart, + enrollmentEnd: payload.enrollmentEnd, + + // Course dates + end: payload.end, + start: payload.start, + + // User access/enrollment status + enrollmentMode: payload.enrollment.mode, isEnrolled: payload.enrollment.isActive, + userHasAccess: payload.userHasAccess, verifiedMode: payload.verifiedMode, + + // Misc + tabs: payload.tabs, }), fetchCourseMetadataFailure: (draftState) => { draftState.fetchState = 'failed'; @@ -33,3 +54,40 @@ export const { } = courseMetaSlice.actions; export const { reducer } = courseMetaSlice; + +export const courseMetadataShape = PropTypes.shape({ + fetchState: PropTypes.string, + // Course identifiers + name: PropTypes.string, + number: PropTypes.string, + org: PropTypes.string, + + // Enrollment dates + enrollmentStart: PropTypes.string, + enrollmentEnd: PropTypes.string, + + // User access/enrollment status + enrollmentMode: PropTypes.string, + isEnrolled: PropTypes.bool, + userHasAccess: PropTypes.bool, + verifiedMode: PropTypes.shape({ + price: PropTypes.number.isRequired, + currency: PropTypes.string.isRequired, + currencySymbol: PropTypes.string.isRequired, + sku: PropTypes.string.isRequired, + upgradeUrl: PropTypes.string.isRequired, + }), + + // Course dates + start: PropTypes.string, + end: PropTypes.string, + + // Misc + tabs: PropTypes.arrayOf(PropTypes.shape({ + priority: PropTypes.number, + slug: PropTypes.string, + title: PropTypes.string, + type: PropTypes.string, + url: PropTypes.string, + })), +}); diff --git a/src/index.jsx b/src/index.jsx index 6cf899e0..fd0e73de 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -18,6 +18,7 @@ import UserMessagesProvider from './user-messages/UserMessagesProvider'; import './index.scss'; import './assets/favicon.ico'; import CourseContainer from './courseware/CourseContainer'; +import OutlineContainer from './outline/OutlineContainer'; import store from './store'; @@ -39,6 +40,7 @@ subscribe(APP_READY, () => { + + + +
+ +
+
+ +
+ +
+
+
{displayName}
+
+ + + {children.map((sequenceId) => ( + + ))} + + + ); +} + +Chapter.propTypes = { + id: PropTypes.string.isRequired, + courseUsageKey: PropTypes.string.isRequired, + models: courseBlocksShape.isRequired, +}; diff --git a/src/outline/CourseDates.jsx b/src/outline/CourseDates.jsx new file mode 100644 index 00000000..8a86a3d0 --- /dev/null +++ b/src/outline/CourseDates.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export default function CourseDates({ + start, + end, + enrollmentStart, + enrollmentEnd, + enrollmentMode, + isEnrolled, +}) { + return ( +
+

Upcoming Dates

+
Course Start:
{start}
+
Course End:
{end}
+
Enrollment Start:
{enrollmentStart}
+
Enrollment End:
{enrollmentEnd}
+
Mode:
{enrollmentMode}
+
{isEnrolled ? 'Active Enrollment' : 'Inactive Enrollment'}
+
+ ); +} + +CourseDates.propTypes = { + start: PropTypes.string, + end: PropTypes.string, + enrollmentStart: PropTypes.string, + enrollmentEnd: PropTypes.string, + enrollmentMode: PropTypes.string, + isEnrolled: PropTypes.bool, +}; + +CourseDates.defaultProps = { + start: null, + end: null, + enrollmentStart: null, + enrollmentEnd: null, + enrollmentMode: null, + isEnrolled: false, +}; diff --git a/src/outline/Outline.jsx b/src/outline/Outline.jsx new file mode 100644 index 00000000..bf9b5cb5 --- /dev/null +++ b/src/outline/Outline.jsx @@ -0,0 +1,115 @@ +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 CourseDates from './CourseDates'; +import { useLogistrationAlert, useEnrollmentAlert } from '../hooks'; +import Chapter from './Chapter'; +import { courseBlocksShape } from '../data/course-blocks'; + +const EnrollmentAlert = React.lazy(() => import('../enrollment-alert')); +const LogistrationAlert = React.lazy(() => import('../logistration-alert')); + +export default function Outline({ + courseOrg, + courseNumber, + courseName, + courseUsageKey, + courseId, + models, + tabs, + start, + end, + enrollmentStart, + enrollmentEnd, + enrollmentMode, + isEnrolled, +}) { + const course = models[courseId]; + + useLogistrationAlert(); + useEnrollmentAlert(isEnrolled); + + return ( + <> + +
+
+ + +
+
+
+
+

{courseName}

+ +
+
+
+ {course.children.map((chapterId) => ( + + ))} +
+
+ +
+
+
+
+
+ + ); +} + +Outline.propTypes = { + courseOrg: PropTypes.string.isRequired, + courseNumber: PropTypes.string.isRequired, + courseName: PropTypes.string.isRequired, + 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/outline/OutlineContainer.jsx b/src/outline/OutlineContainer.jsx new file mode 100644 index 00000000..d56a748d --- /dev/null +++ b/src/outline/OutlineContainer.jsx @@ -0,0 +1,85 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { fetchCourseMetadata, courseMetadataShape } from '../data/course-meta'; +import { fetchCourseBlocks, courseBlocksShape } from '../data/course-blocks'; + +import messages from '../courseware/messages'; +import PageLoading from '../PageLoading'; +import Outline from './Outline'; + +function OutlineContainer(props) { + const { + intl, + match, + courseId, + blocks: models, + metadata, + } = props; + const { courseUsageKey } = match.params; + + useEffect(() => { + props.fetchCourseMetadata(courseUsageKey); + props.fetchCourseBlocks(courseUsageKey); + }, [courseUsageKey]); + + const ready = metadata.fetchState === 'loaded' && courseId; + + return ( + <> + {ready ? ( + + ) : ( + + )} + + ); +} + +OutlineContainer.propTypes = { + intl: intlShape.isRequired, + courseId: PropTypes.string, + blocks: courseBlocksShape, + metadata: courseMetadataShape, + fetchCourseMetadata: PropTypes.func.isRequired, + fetchCourseBlocks: PropTypes.func.isRequired, + match: PropTypes.shape({ + params: PropTypes.shape({ + courseUsageKey: PropTypes.string.isRequired, + }).isRequired, + }).isRequired, +}; + +OutlineContainer.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(OutlineContainer)); diff --git a/src/outline/SequenceLink.jsx b/src/outline/SequenceLink.jsx new file mode 100644 index 00000000..631a5a5b --- /dev/null +++ b/src/outline/SequenceLink.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; +import { courseBlocksShape } from '../data/course-blocks'; + +export default function SequenceLink({ id, courseUsageKey, models }) { + const sequence = models[id]; + return ( +
+ {sequence.displayName} +
+ ); +} + +SequenceLink.propTypes = { + id: PropTypes.string.isRequired, + courseUsageKey: PropTypes.string.isRequired, + models: courseBlocksShape.isRequired, +}; diff --git a/src/store.js b/src/store.js index 0f7dd262..a5bb3fac 100644 --- a/src/store.js +++ b/src/store.js @@ -1,6 +1,6 @@ import { configureStore } from '@reduxjs/toolkit'; -import { reducer as courseReducer } from './data/course-meta/slice'; -import { reducer as courseBlocksReducer } from './data/course-blocks/slice'; +import { reducer as courseReducer } from './data/course-meta'; +import { reducer as courseBlocksReducer } from './data/course-blocks'; const store = configureStore({ reducer: {