Initial version of Course Home page (#20)

* refactor: Moving PageLoading up to the top

This way it can be used on both the courseware and outline pages.

* Adding index.js files to data directories, and PropTypes data shapes

- The course-blocks and course-meta data directories now have index files so their exports can be imported from that, rather than reaching into specific files in the directories.
- Also added “shapes” for use in React Components that use PropTypes for the course blocks data structure, and the course metadata data structure.

* Simplifying/refactoring CourseContainer rendering a bit.

* Adding course outline page.

This page is not complete.

- It contains the ‘outline’ itself with links to the Sequences in the course.
- It contains a very basic stab at displaying dates - they’re not even formatted.
- It shows logistration and enrollment alerts for anonymous and unenrolled users.

It does not include any other content in the right-hand sidebar.  It also doesn’t include a welcome message, or perhaps any number of other features on the page.  This is effectively an initial implementation for discovering how much data we’re missing from our APIs.  It should not be used as-is by any means.
This commit is contained in:
David Joy
2020-03-06 13:21:18 -05:00
committed by GitHub
parent 5a3597ac4b
commit 6ba8929c97
16 changed files with 415 additions and 37 deletions

View File

@@ -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 (
<main className="flex-grow-1 d-flex flex-column">
{ isLoaded ? (
{ready ? (
<Course
courseOrg={props.metadata.org}
courseNumber={props.metadata.number}
@@ -80,28 +80,7 @@ CourseContainer.propTypes = {
blocks: PropTypes.objectOf(PropTypes.shape({
id: PropTypes.string,
})),
metadata: PropTypes.shape({
fetchState: PropTypes.string,
org: PropTypes.string,
number: PropTypes.string,
name: PropTypes.string,
userHasAccess: PropTypes.bool,
tabs: PropTypes.arrayOf(PropTypes.shape({
priority: PropTypes.number,
slug: PropTypes.string,
title: PropTypes.string,
type: PropTypes.string,
url: PropTypes.string,
})),
isEnrolled: 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,
}),
}),
metadata: courseMetadataShape,
fetchCourseMetadata: PropTypes.func.isRequired,
fetchCourseBlocks: PropTypes.func.isRequired,
match: PropTypes.shape({

View File

@@ -6,10 +6,10 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { history } from '@edx/frontend-platform';
import messages from '../messages';
import PageLoading from '../PageLoading';
import PageLoading from '../../PageLoading';
import Sequence from '../sequence/Sequence';
import AlertList from '../../user-messages/AlertList';
import { fetchSequenceMetadata, checkBlockCompletion, saveSequencePosition } from '../../data/course-blocks/thunks';
import { fetchSequenceMetadata, checkBlockCompletion, saveSequencePosition } from '../../data/course-blocks';
function SequenceContainer(props) {
const {

View File

@@ -10,7 +10,7 @@ import { Button } from '@edx/paragon';
import Unit from './Unit';
import SequenceNavigation from './SequenceNavigation';
import PageLoading from '../PageLoading';
import PageLoading from '../../PageLoading';
import messages from './messages';
import UserMessagesContext from '../../user-messages/UserMessagesContext';

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { connect } from 'react-redux';
import BookmarkButton from './bookmark/BookmarkButton';
import { addBookmark, removeBookmark } from '../../data/course-blocks/thunks';
import { addBookmark, removeBookmark } from '../../data/course-blocks';
function Unit({
bookmarked,

View File

@@ -0,0 +1,20 @@
export {
getCourseBlocks,
getSequenceMetadata,
updateSequencePosition,
getBlockCompletion,
createBookmark,
deleteBookmark,
} from './api';
export {
reducer,
courseBlocksShape,
} from './slice';
export {
fetchCourseBlocks,
fetchSequenceMetadata,
checkBlockCompletion,
saveSequencePosition,
addBookmark,
removeBookmark,
} from './thunks';

View File

@@ -1,5 +1,6 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import PropTypes from 'prop-types';
const blocksSlice = createSlice({
name: 'blocks',
@@ -132,3 +133,10 @@ export const {
} = blocksSlice.actions;
export const { reducer } = blocksSlice;
export const courseBlocksShape = PropTypes.objectOf(PropTypes.shape({
id: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired,
children: PropTypes.arrayOf(PropTypes.string),
parentId: PropTypes.string,
}));

View File

@@ -0,0 +1,6 @@
export { getCourseMetadata } from './api';
export {
reducer,
courseMetadataShape,
} from './slice';
export { fetchCourseMetadata } from './thunks';

View File

@@ -1,5 +1,6 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import PropTypes from 'prop-types';
const courseMetaSlice = createSlice({
name: 'course-meta',
@@ -12,13 +13,33 @@ const courseMetaSlice = createSlice({
},
fetchCourseMetadataSuccess: (draftState, { payload }) => ({
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,
})),
});

View File

@@ -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, () => {
<UserMessagesProvider>
<Switch>
<Route exact path="/" render={courseLinks} />
<Route path="/outline/:courseUsageKey" component={OutlineContainer} />
<Route
path={[
'/course/:courseUsageKey/:sequenceId/:unitId',

45
src/outline/Chapter.jsx Normal file
View File

@@ -0,0 +1,45 @@
import React from 'react';
import PropTypes from 'prop-types';
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';
export default function Chapter({ id, courseUsageKey, models }) {
const { displayName, children } = models[id];
return (
<Collapsible.Advanced className="collapsible-card mb-2">
<Collapsible.Trigger className="collapsible-trigger d-flex align-items-start">
<Collapsible.Visible whenClosed>
<div style={{ minWidth: '1rem' }}>
<FontAwesomeIcon icon={faChevronRight} />
</div>
</Collapsible.Visible>
<Collapsible.Visible whenOpen>
<div style={{ minWidth: '1rem' }}>
<FontAwesomeIcon icon={faChevronDown} />
</div>
</Collapsible.Visible>
<div className="ml-2 flex-grow-1">{displayName}</div>
</Collapsible.Trigger>
<Collapsible.Body className="collapsible-body">
{children.map((sequenceId) => (
<SequenceLink
key={sequenceId}
id={sequenceId}
courseUsageKey={courseUsageKey}
models={models}
/>
))}
</Collapsible.Body>
</Collapsible.Advanced>
);
}
Chapter.propTypes = {
id: PropTypes.string.isRequired,
courseUsageKey: PropTypes.string.isRequired,
models: courseBlocksShape.isRequired,
};

View File

@@ -0,0 +1,41 @@
import React from 'react';
import PropTypes from 'prop-types';
export default function CourseDates({
start,
end,
enrollmentStart,
enrollmentEnd,
enrollmentMode,
isEnrolled,
}) {
return (
<section>
<h4>Upcoming Dates</h4>
<div><strong>Course Start:</strong><br />{start}</div>
<div><strong>Course End:</strong><br />{end}</div>
<div><strong>Enrollment Start:</strong><br />{enrollmentStart}</div>
<div><strong>Enrollment End:</strong><br />{enrollmentEnd}</div>
<div><strong>Mode:</strong><br />{enrollmentMode}</div>
<div>{isEnrolled ? 'Active Enrollment' : 'Inactive Enrollment'}</div>
</section>
);
}
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,
};

115
src/outline/Outline.jsx Normal file
View File

@@ -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 (
<>
<CourseHeader
courseOrg={courseOrg}
courseNumber={courseNumber}
courseName={courseName}
/>
<main className="d-flex flex-column flex-grow-1">
<div className="container-fluid">
<CourseTabsNavigation tabs={tabs} className="mb-3" activeTabSlug="courseware" />
<AlertList
topic="outline"
className="mb-3"
customAlerts={{
clientEnrollmentAlert: EnrollmentAlert,
clientLogistrationAlert: LogistrationAlert,
}}
/>
</div>
<div className="flex-grow-1">
<div className="container-fluid">
<div className="d-flex justify-content-between mb-3">
<h2>{courseName}</h2>
<Button className="btn-primary" type="button">Resume Course</Button>
</div>
<div className="row">
<div className="col col-8">
{course.children.map((chapterId) => (
<Chapter
key={chapterId}
id={chapterId}
courseUsageKey={courseUsageKey}
models={models}
/>
))}
</div>
<div className="col col-4">
<CourseDates
start={start}
end={end}
enrollmentStart={enrollmentStart}
enrollmentEnd={enrollmentEnd}
enrollmentMode={enrollmentMode}
isEnrolled={isEnrolled}
/>
</div>
</div>
</div>
</div>
</main>
</>
);
}
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,
};

View File

@@ -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 ? (
<Outline
courseOrg={metadata.org}
courseNumber={metadata.number}
courseName={metadata.name}
courseUsageKey={courseUsageKey}
courseId={courseId}
start={metadata.start}
end={metadata.end}
enrollmentStart={metadata.enrollmentStart}
enrollmentEnd={metadata.enrollmentEnd}
enrollmentMode={metadata.enrollmentMode}
isEnrolled={metadata.isEnrolled}
models={models}
tabs={metadata.tabs}
/>
) : (
<PageLoading
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
/>
)}
</>
);
}
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));

View File

@@ -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 (
<div className="ml-4">
<Link to={`/course/${courseUsageKey}/${id}`}>{sequence.displayName}</Link>
</div>
);
}
SequenceLink.propTypes = {
id: PropTypes.string.isRequired,
courseUsageKey: PropTypes.string.isRequired,
models: courseBlocksShape.isRequired,
};

View File

@@ -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: {