Extensive refactor of application data management. (#32)
* Extensive refactor of application data management. - “course-blocks” and “course-meta” are replaced with “courseware” module. This obscures the difference between the two from the application itself. - a generic “model-store” module is used to store all course, section, sequence, and unit data in a normalized way, agnostic to the metadata vs. blocks APIs. - SequenceContainer has been removed, and it’s work is just done in CourseContainer instead. - UI components are - in general - more responsible for deciding their own behavior during data loading. If they want to show a spinner or nothing, it’s up to their discretion. - The API layer is responsible for normalizing data into a form the app will want to use, prior to putting it into the model store. * Organizing into some more sub-modules. - Bookmarks becomes it’s own module. - SequenceNavigation becomes another one. * More modularization of data directories. - Moving model-store up to the top. - Moving fetchCourse and fetchSequence up to the top-level data directory, since they’re used by both courseware and outline. - Moving getBlockCompletion and updateSequencePosition into the courseware/data directory, since they pertain to that page. * Normalizing on using the word “title” * Using history.replace instead of history.push This fixes TNL-7125 * Allowing sub-components to use hooks and redux This reduces the amount of data we need to pass around, and lets us move some complexity to more natural modules. * Fixing bug where enrollment alert is shown for undefined isEnrolled The enrollment alert would inadvertently be shown if a user navigated from the outline to the course. This was because it interpreted an undefined “isEnrolled” flag as false. Instead, we should wait for the isEnrolled flag to be explicitly true or false. * Organizing modules. - Renaming “outline” to “course-home”. - Moving sequence and sequence-navigation modules under the course module. * Some final application organization and ADR write-ups. * Final refactoring - Favoring passing data by ID and looking it up in the store with useModel. - Moving headers into course-header directory. * Updating ADRs. Splitting model-store information out into its own ADR.
This commit is contained in:
@@ -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 (
|
||||
<main className="flex-grow-1 d-flex flex-column">
|
||||
{(() => {
|
||||
if (ready) {
|
||||
return (
|
||||
<Course
|
||||
courseOrg={props.metadata.org}
|
||||
courseNumber={props.metadata.number}
|
||||
courseName={props.metadata.name}
|
||||
courseUsageKey={courseUsageKey}
|
||||
courseId={courseId}
|
||||
isEnrolled={props.metadata.isEnrolled}
|
||||
isStaff={props.metadata.isStaff}
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
models={models}
|
||||
tabs={props.metadata.tabs}
|
||||
verifiedMode={props.metadata.verifiedMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (metadata.fetchState === 'failed' || models.fetchState === 'failed') {
|
||||
return (
|
||||
<p className="text-center py-5 mx-auto" style={{ maxWidth: '30em' }}>
|
||||
{intl.formatMessage(messages['learn.course.load.failure'])}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
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));
|
||||
199
src/courseware/CoursewareContainer.jsx
Normal file
199
src/courseware/CoursewareContainer.jsx
Normal file
@@ -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 (
|
||||
<main className="flex-grow-1 d-flex flex-column">
|
||||
<Course
|
||||
courseId={courseUsageKey}
|
||||
sequenceId={sequenceId}
|
||||
unitId={routeUnitId}
|
||||
nextSequenceHandler={nextSequenceHandler}
|
||||
previousSequenceHandler={previousSequenceHandler}
|
||||
unitNavigationHandler={unitNavigationHandler}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
CoursewareContainer.propTypes = {
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.shape({
|
||||
courseUsageKey: PropTypes.string.isRequired,
|
||||
sequenceId: PropTypes.string,
|
||||
unitId: PropTypes.string,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
@@ -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 (
|
||||
<>
|
||||
<CourseHeader
|
||||
courseOrg={courseOrg}
|
||||
courseNumber={courseNumber}
|
||||
courseName={courseName}
|
||||
const courseStatus = useSelector(state => state.courseware.courseStatus);
|
||||
|
||||
if (courseStatus === 'loading') {
|
||||
return (
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
|
||||
/>
|
||||
{isStaff && (
|
||||
);
|
||||
}
|
||||
|
||||
if (courseStatus === 'loaded') {
|
||||
const {
|
||||
org, number, title, isStaff, tabs, verifiedMode,
|
||||
} = course;
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
courseOrg={org}
|
||||
courseNumber={number}
|
||||
courseTitle={title}
|
||||
/>
|
||||
{isStaff && (
|
||||
<InstructorToolbar
|
||||
courseUsageKey={courseUsageKey}
|
||||
courseId={courseId}
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
unitId={unit.id}
|
||||
/>
|
||||
)}
|
||||
<CourseTabsNavigation tabs={tabs} activeTabSlug="courseware" />
|
||||
<div className="container-fluid">
|
||||
<AlertList
|
||||
className="my-3"
|
||||
topic="course"
|
||||
customAlerts={{
|
||||
clientEnrollmentAlert: EnrollmentAlert,
|
||||
clientLogistrationAlert: LogistrationAlert,
|
||||
}}
|
||||
/>
|
||||
<CourseBreadcrumbs
|
||||
courseUsageKey={courseUsageKey}
|
||||
courseId={courseId}
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
models={models}
|
||||
/>
|
||||
<AlertList topic="sequence" />
|
||||
</div>
|
||||
<div className="flex-grow-1 d-flex flex-column">
|
||||
<SequenceContainer
|
||||
key={sequenceId}
|
||||
courseUsageKey={courseUsageKey}
|
||||
courseId={courseId}
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
models={models}
|
||||
onNext={nextSequenceHandler}
|
||||
onPrevious={previousSequenceHandler}
|
||||
/>
|
||||
{verifiedMode && <CourseSock verifiedMode={verifiedMode} />}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<CourseTabsNavigation tabs={tabs} activeTabSlug="courseware" />
|
||||
<div className="container-fluid">
|
||||
<AlertList
|
||||
className="my-3"
|
||||
topic="course"
|
||||
customAlerts={{
|
||||
clientEnrollmentAlert: EnrollmentAlert,
|
||||
clientLogistrationAlert: LogistrationAlert,
|
||||
}}
|
||||
/>
|
||||
<CourseBreadcrumbs
|
||||
courseId={courseId}
|
||||
sectionId={section ? section.id : null}
|
||||
sequenceId={sequenceId}
|
||||
/>
|
||||
<AlertList topic="sequence" />
|
||||
</div>
|
||||
<div className="flex-grow-1 d-flex flex-column">
|
||||
<Sequence
|
||||
unitId={unitId}
|
||||
sequenceId={sequenceId}
|
||||
courseUsageKey={courseId}
|
||||
unitNavigationHandler={unitNavigationHandler}
|
||||
nextSequenceHandler={nextSequenceHandler}
|
||||
previousSequenceHandler={previousSequenceHandler}
|
||||
/>
|
||||
{verifiedMode && <CourseSock verifiedMode={verifiedMode} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// courseStatus 'failed' and any other unexpected course status.
|
||||
return (
|
||||
<p className="text-center py-5 mx-auto" style={{ maxWidth: '30em' }}>
|
||||
{intl.formatMessage(messages['learn.course.load.failure'])}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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 (
|
||||
<nav aria-label="breadcrumb" className="my-4">
|
||||
<ol className="list-unstyled d-flex m-0">
|
||||
<CourseBreadcrumb
|
||||
url={`${getConfig().LMS_BASE_URL}/courses/${courseUsageKey}/course/`}
|
||||
url={`${getConfig().LMS_BASE_URL}/courses/${course.id}/course/`}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<FontAwesomeIcon icon={faHome} className="mr-2" />
|
||||
@@ -80,13 +88,12 @@ export default function CourseBreadcrumbs({
|
||||
}
|
||||
|
||||
CourseBreadcrumbs.propTypes = {
|
||||
courseUsageKey: PropTypes.string.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
sequenceId: PropTypes.string.isRequired,
|
||||
models: PropTypes.objectOf(PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
children: PropTypes.arrayOf(PropTypes.string),
|
||||
parentId: PropTypes.string,
|
||||
})).isRequired,
|
||||
sectionId: PropTypes.string,
|
||||
sequenceId: PropTypes.string,
|
||||
};
|
||||
|
||||
CourseBreadcrumbs.defaultProps = {
|
||||
sectionId: null,
|
||||
sequenceId: null,
|
||||
};
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Dropdown } from '@edx/paragon';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
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';
|
||||
|
||||
function LinkedLogo({
|
||||
href,
|
||||
src,
|
||||
alt,
|
||||
...attributes
|
||||
}) {
|
||||
return (
|
||||
<a href={href} {...attributes}>
|
||||
<img className="d-block" src={src} alt={alt} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
LinkedLogo.propTypes = {
|
||||
href: PropTypes.string.isRequired,
|
||||
src: PropTypes.string.isRequired,
|
||||
alt: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default function CourseHeader({
|
||||
courseOrg, courseNumber, courseName,
|
||||
}) {
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
|
||||
return (
|
||||
<header className="course-header">
|
||||
<div className="container-fluid py-2 d-flex align-items-center ">
|
||||
<LinkedLogo
|
||||
className="logo"
|
||||
href={`${getConfig().LMS_BASE_URL}/dashboard`}
|
||||
src={logo}
|
||||
alt={getConfig().SITE_NAME}
|
||||
/>
|
||||
<div className="flex-grow-1 course-title-lockup" style={{ lineHeight: 1 }}>
|
||||
<span className="d-block small m-0">{courseOrg} {courseNumber}</span>
|
||||
<span className="d-block m-0 font-weight-bold course-name">{courseName}</span>
|
||||
</div>
|
||||
|
||||
<Dropdown className="user-dropdown">
|
||||
<Dropdown.Button>
|
||||
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />
|
||||
<span className="d-none d-md-inline">
|
||||
{authenticatedUser.username}
|
||||
</span>
|
||||
</Dropdown.Button>
|
||||
<Dropdown.Menu className="dropdown-menu-right">
|
||||
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}>Dashboard</Dropdown.Item>
|
||||
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/u/${authenticatedUser.username}`}>Profile</Dropdown.Item>
|
||||
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/account/settings`}>Account</Dropdown.Item>
|
||||
<Dropdown.Item href={getConfig().ORDER_HISTORY_URL}>Order History</Dropdown.Item>
|
||||
<Dropdown.Item href={getConfig().LOGOUT_URL}>Sign Out</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
CourseHeader.propTypes = {
|
||||
courseOrg: PropTypes.string.isRequired,
|
||||
courseNumber: PropTypes.string.isRequired,
|
||||
courseName: PropTypes.string.isRequired,
|
||||
};
|
||||
@@ -1,50 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import messages from './messages';
|
||||
import Tabs from '../../tabs/Tabs';
|
||||
|
||||
function CourseTabsNavigation({
|
||||
activeTabSlug, tabs, intl,
|
||||
}) {
|
||||
return (
|
||||
<div className="course-tabs-navigation">
|
||||
<div className="container-fluid">
|
||||
<Tabs
|
||||
className="nav-underline-tabs"
|
||||
aria-label={intl.formatMessage(messages['learn.navigation.course.tabs.label'])}
|
||||
>
|
||||
{tabs.map(({ url, title, slug }) => (
|
||||
<a
|
||||
key={slug}
|
||||
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
|
||||
href={`${getConfig().LMS_BASE_URL}${url}`}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CourseTabsNavigation.propTypes = {
|
||||
activeTabSlug: PropTypes.string,
|
||||
tabs: PropTypes.arrayOf(PropTypes.shape({
|
||||
title: PropTypes.string.isRequired,
|
||||
priority: PropTypes.number.isRequired,
|
||||
slug: PropTypes.string.isRequired,
|
||||
url: PropTypes.string.isRequired,
|
||||
})).isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
CourseTabsNavigation.defaultProps = {
|
||||
activeTabSlug: undefined,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseTabsNavigation);
|
||||
@@ -51,7 +51,7 @@ const mapStateToProps = (state, props) => {
|
||||
return {};
|
||||
}
|
||||
|
||||
const activeUnit = state.courseBlocks.blocks[props.unitId];
|
||||
const activeUnit = state.models.units[props.unitId];
|
||||
return {
|
||||
activeUnitLmsWebUrl: activeUnit.lmsWebUrl,
|
||||
};
|
||||
@@ -1,165 +0,0 @@
|
||||
/* eslint-disable no-plusplus */
|
||||
import React, { useEffect, useCallback, useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
|
||||
import messages from '../messages';
|
||||
import PageLoading from '../../PageLoading';
|
||||
import Sequence from '../sequence/Sequence';
|
||||
import { fetchSequenceMetadata, checkBlockCompletion, saveSequencePosition } from '../../data/course-blocks';
|
||||
import { createSequenceIdList } from '../utils';
|
||||
|
||||
function SequenceContainer(props) {
|
||||
const {
|
||||
courseUsageKey,
|
||||
courseId,
|
||||
sequenceId,
|
||||
unitId,
|
||||
intl,
|
||||
onNext,
|
||||
onPrevious,
|
||||
fetchState,
|
||||
displayName,
|
||||
showCompletion,
|
||||
isTimeLimited,
|
||||
savePosition,
|
||||
bannerText,
|
||||
gatedContent,
|
||||
position,
|
||||
items,
|
||||
lmsWebUrl,
|
||||
models,
|
||||
} = props;
|
||||
const loaded = fetchState === 'loaded';
|
||||
|
||||
const unitIds = useMemo(() => items.map(({ id }) => id), [items]);
|
||||
const sequenceIds = useMemo(() => createSequenceIdList(models, courseId), [models, courseId]);
|
||||
|
||||
useEffect(() => {
|
||||
props.fetchSequenceMetadata(sequenceId);
|
||||
}, [sequenceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (savePosition) {
|
||||
const activeUnitIndex = unitIds.indexOf(unitId);
|
||||
props.saveSequencePosition(courseUsageKey, sequenceId, activeUnitIndex);
|
||||
}
|
||||
}, [unitId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded && !unitId) {
|
||||
// The position may be null, in which case we'll just assume 0.
|
||||
const unitIndex = position || 0;
|
||||
const nextUnitId = unitIds[unitIndex];
|
||||
history.push(`/course/${courseUsageKey}/${sequenceId}/${nextUnitId}`);
|
||||
}
|
||||
}, [loaded, unitId]);
|
||||
|
||||
const handleUnitNavigation = useCallback((nextUnitId) => {
|
||||
props.checkBlockCompletion(courseUsageKey, sequenceId, unitId);
|
||||
history.push(`/course/${courseUsageKey}/${sequenceId}/${nextUnitId}`);
|
||||
}, [courseUsageKey, sequenceId]);
|
||||
|
||||
// Exam redirect
|
||||
useEffect(() => {
|
||||
if (isTimeLimited) {
|
||||
global.location.href = lmsWebUrl;
|
||||
}
|
||||
}, [isTimeLimited]);
|
||||
|
||||
const isLoading = !loaded || !unitId || isTimeLimited;
|
||||
const isFirstUnit = sequenceIds.indexOf(sequenceId) === 0 && unitIds.indexOf(unitId) === 0;
|
||||
const isLastUnit = sequenceIds.indexOf(sequenceId) === sequenceIds.length - 1
|
||||
&& unitIds.indexOf(unitId) === unitIds.length - 1;
|
||||
return (
|
||||
<div className="sequence-container">
|
||||
{isLoading ? (
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
|
||||
/>
|
||||
) : (
|
||||
<Sequence
|
||||
activeUnitId={unitId}
|
||||
bannerText={bannerText}
|
||||
courseUsageKey={courseUsageKey}
|
||||
displayName={displayName}
|
||||
isFirstUnit={isFirstUnit}
|
||||
isGated={gatedContent.gated}
|
||||
isLastUnit={isLastUnit}
|
||||
onNavigateUnit={handleUnitNavigation}
|
||||
onNext={onNext}
|
||||
onPrevious={onPrevious}
|
||||
prerequisite={{
|
||||
id: gatedContent.prereqId,
|
||||
name: gatedContent.gatedSectionName,
|
||||
}}
|
||||
showCompletion={showCompletion}
|
||||
unitIds={unitIds}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
SequenceContainer.propTypes = {
|
||||
onNext: PropTypes.func.isRequired,
|
||||
onPrevious: PropTypes.func.isRequired,
|
||||
courseUsageKey: PropTypes.string.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
sequenceId: PropTypes.string.isRequired,
|
||||
unitId: PropTypes.string,
|
||||
intl: intlShape.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
})),
|
||||
gatedContent: PropTypes.shape({
|
||||
gated: PropTypes.bool,
|
||||
gatedSectionName: PropTypes.string,
|
||||
prereqId: PropTypes.string,
|
||||
}),
|
||||
models: PropTypes.objectOf(PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
children: PropTypes.arrayOf(PropTypes.string),
|
||||
parentId: PropTypes.string,
|
||||
})).isRequired,
|
||||
checkBlockCompletion: PropTypes.func.isRequired,
|
||||
fetchSequenceMetadata: PropTypes.func.isRequired,
|
||||
saveSequencePosition: PropTypes.func.isRequired,
|
||||
savePosition: PropTypes.bool,
|
||||
lmsWebUrl: PropTypes.string,
|
||||
position: PropTypes.number,
|
||||
fetchState: PropTypes.string,
|
||||
displayName: PropTypes.string,
|
||||
showCompletion: PropTypes.bool,
|
||||
isTimeLimited: PropTypes.bool,
|
||||
bannerText: PropTypes.string,
|
||||
};
|
||||
|
||||
SequenceContainer.defaultProps = {
|
||||
unitId: undefined,
|
||||
gatedContent: undefined,
|
||||
showCompletion: false,
|
||||
lmsWebUrl: undefined,
|
||||
position: undefined,
|
||||
fetchState: undefined,
|
||||
displayName: undefined,
|
||||
isTimeLimited: undefined,
|
||||
bannerText: undefined,
|
||||
savePosition: undefined,
|
||||
items: [],
|
||||
};
|
||||
|
||||
export default connect(
|
||||
(state, props) => ({
|
||||
...state.courseBlocks.blocks[props.sequenceId],
|
||||
}),
|
||||
{
|
||||
fetchSequenceMetadata,
|
||||
checkBlockCompletion,
|
||||
saveSequencePosition,
|
||||
},
|
||||
)(injectIntl(SequenceContainer));
|
||||
@@ -1,9 +1,11 @@
|
||||
import React from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { StatefulButton } from '@edx/paragon';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import BookmarkOutlineIcon from './BookmarkOutlineIcon';
|
||||
import BookmarkFilledIcon from './BookmarkFilledIcon';
|
||||
import { removeBookmark, addBookmark } from './data/thunks';
|
||||
|
||||
const addBookmarkLabel = (
|
||||
<FormattedMessage
|
||||
@@ -21,14 +23,25 @@ const hasBookmarkLabel = (
|
||||
/>
|
||||
);
|
||||
|
||||
export default function BookmarkButton({ onClick, isBookmarked, isProcessing }) {
|
||||
export default function BookmarkButton({
|
||||
isBookmarked, isProcessing, unitId,
|
||||
}) {
|
||||
const bookmarkState = isBookmarked ? 'bookmarked' : 'default';
|
||||
const state = isProcessing ? `${bookmarkState}Processing` : bookmarkState;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const toggleBookmark = useCallback(() => {
|
||||
if (isBookmarked) {
|
||||
dispatch(removeBookmark(unitId));
|
||||
} else {
|
||||
dispatch(addBookmark(unitId));
|
||||
}
|
||||
}, [isBookmarked, unitId]);
|
||||
|
||||
return (
|
||||
<StatefulButton
|
||||
className="btn-link px-1 ml-n1 btn-sm"
|
||||
onClick={onClick}
|
||||
onClick={toggleBookmark}
|
||||
state={state}
|
||||
disabledStates={['defaultProcessing', 'bookmarkedProcessing']}
|
||||
labels={{
|
||||
@@ -48,7 +61,11 @@ export default function BookmarkButton({ onClick, isBookmarked, isProcessing })
|
||||
}
|
||||
|
||||
BookmarkButton.propTypes = {
|
||||
onClick: PropTypes.func.isRequired,
|
||||
isBookmarked: PropTypes.bool.isRequired,
|
||||
unitId: PropTypes.string.isRequired,
|
||||
isBookmarked: PropTypes.bool,
|
||||
isProcessing: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
BookmarkButton.defaultProps = {
|
||||
isBookmarked: false,
|
||||
};
|
||||
13
src/courseware/course/bookmark/data/api.js
Normal file
13
src/courseware/course/bookmark/data/api.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
|
||||
const bookmarksBaseUrl = `${getConfig().LMS_BASE_URL}/api/bookmarks/v1/bookmarks/`;
|
||||
|
||||
export async function createBookmark(usageId) {
|
||||
return getAuthenticatedHttpClient().post(bookmarksBaseUrl, { usage_id: usageId });
|
||||
}
|
||||
|
||||
export async function deleteBookmark(usageId) {
|
||||
const { username } = getAuthenticatedUser();
|
||||
return getAuthenticatedHttpClient().delete(`${bookmarksBaseUrl}${username},${usageId}/`);
|
||||
}
|
||||
78
src/courseware/course/bookmark/data/thunks.js
Normal file
78
src/courseware/course/bookmark/data/thunks.js
Normal file
@@ -0,0 +1,78 @@
|
||||
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import {
|
||||
createBookmark,
|
||||
deleteBookmark,
|
||||
} from './api';
|
||||
import { updateModel } from '../../../../model-store';
|
||||
|
||||
export function addBookmark(unitId) {
|
||||
return async (dispatch) => {
|
||||
// Optimistically update the bookmarked flag.
|
||||
dispatch(updateModel({
|
||||
modelType: 'units',
|
||||
model: {
|
||||
id: unitId,
|
||||
bookmarked: true,
|
||||
bookmarkedUpdateState: 'loading',
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
await createBookmark(unitId);
|
||||
dispatch(updateModel({
|
||||
modelType: 'units',
|
||||
model: {
|
||||
id: unitId,
|
||||
bookmarked: true,
|
||||
bookmarkedUpdateState: 'loaded',
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
dispatch(updateModel({
|
||||
modelType: 'units',
|
||||
model: {
|
||||
id: unitId,
|
||||
bookmarked: false,
|
||||
bookmarkedUpdateState: 'failed',
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function removeBookmark(unitId) {
|
||||
return async (dispatch) => {
|
||||
// Optimistically update the bookmarked flag.
|
||||
dispatch(updateModel({
|
||||
modelType: 'units',
|
||||
model: {
|
||||
id: unitId,
|
||||
bookmarked: false,
|
||||
bookmarkedUpdateState: 'loading',
|
||||
},
|
||||
}));
|
||||
try {
|
||||
await deleteBookmark(unitId);
|
||||
dispatch(updateModel({
|
||||
modelType: 'units',
|
||||
model: {
|
||||
id: unitId,
|
||||
bookmarked: false,
|
||||
bookmarkedUpdateState: 'loaded',
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
dispatch(updateModel({
|
||||
modelType: 'units',
|
||||
model: {
|
||||
id: unitId,
|
||||
bookmarked: true,
|
||||
bookmarkedUpdateState: 'failed',
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
3
src/courseware/course/bookmark/index.js
Normal file
3
src/courseware/course/bookmark/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as BookmarkButton } from './BookmarkButton';
|
||||
export { default as BookmarkFilledIcon } from './BookmarkFilledIcon';
|
||||
export { default as BookmarkOutlineIcon } from './BookmarkFilledIcon';
|
||||
1
src/courseware/course/index.js
Normal file
1
src/courseware/course/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Course';
|
||||
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1168px" height="540px" viewBox="0 0 1168 540" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 53.2 (72643) - https://sketchapp.com -->
|
||||
<title>logo</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<g id="logo" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<polygon id="Path" fill="#209FDA" fill-rule="nonzero" points="1166.81993 85.5 1166.81993 2.84217094e-14 953.759925 2.84217094e-14 953.759925 85.5 1002.17993 85.5 915.859925 191.98 829.459925 85.5 878.099925 85.5 878.099925 2.84217094e-14 718.919925 2.84217094e-14 718.919925 95.72 856.479925 265.26 718.919925 434.96 718.919925 452.02 784.499925 452.02 784.499925 539.64 878.099925 539.64 878.099925 452.02 823.919925 452.02 915.919925 338.52 915.939925 338.52 1008.03993 452.02 953.759925 452.02 953.759925 539.64 1166.81993 539.64 1166.81993 452.02 1126.85993 452.02 975.319925 265.26 1121.01993 85.5"></polygon>
|
||||
<polygon id="Path" fill="#026BA4" fill-rule="nonzero" points="664.019925 7.10542736e-15 664.019925 85.5 710.619925 85.5 718.919925 95.72 718.919925 7.10542736e-15"></polygon>
|
||||
<polygon id="Path" fill="#026BA4" fill-rule="nonzero" points="718.919925 452.02 718.919925 434.96 705.079925 452.02 664.019925 452.02 664.019925 539.64 784.499925 539.64 784.499925 452.02"></polygon>
|
||||
<path d="M321.999925,411.86 L397.659925,411.86 C388.805702,433.829527 376.258024,454.122269 360.559925,471.86 C344.364089,454.216816 331.320914,433.921419 321.999925,411.86" id="Path" fill="#78212E" fill-rule="nonzero"></path>
|
||||
<path d="M360.559925,189.28 C338.58337,213.190393 322.501981,241.908137 313.599925,273.14 C317.134915,280.039338 320.007771,287.25831 322.179925,294.7 L397.059925,294.7 C399.306706,287.354671 402.25356,280.242036 405.859925,273.46 C397.464721,242.277678 381.959326,213.464341 360.559925,189.28 Z M322.179925,294.7 C328.784599,317.438017 328.978396,341.558795 322.739925,364.4 L396.399925,364.4 C389.855554,341.597488 390.06397,317.386469 396.999925,294.7 L322.179925,294.7 Z M322.179925,294.7 L308.679925,294.7 C304.690779,317.752715 304.575868,341.309464 308.339925,364.4 L322.739925,364.4 C328.978396,341.558795 328.784599,317.438017 322.179925,294.7 L322.179925,294.7 Z" id="Shape" fill="#78212E" fill-rule="nonzero"></path>
|
||||
<path d="M710.619925,85.5 L664.019925,85.5 L664.019925,0.02 L576.019925,0.02 L576.019925,85.5 L632.859925,85.5 L632.859925,159.2 C598.417874,134.487772 557.04992,121.286425 514.659925,121.48 C456.044663,121.405246 400.107354,146.01621 360.559925,189.28 C381.937732,213.470272 397.422343,242.283149 405.799925,273.46 C426.944121,233.500977 468.451514,208.51034 513.659925,208.52 C581.059925,208.52 632.879925,263.16 632.879925,330.52 L632.879925,331.2 C632.539925,398.28 580.879925,452.56 513.659925,452.56 C468.477451,452.593197 426.976426,427.652566 405.799925,387.74 L405.799925,387.74 C401.869213,380.340239 398.718926,372.551658 396.399925,364.5 L308.399925,364.5 C309.686934,372.450225 311.443338,380.317312 313.659925,388.06 C315.970162,396.190434 318.775397,404.171995 322.059925,411.96 L397.659925,411.96 C388.805702,433.929527 376.258024,454.222269 360.559925,471.96 C400.107354,515.22379 456.044663,539.834754 514.659925,539.76 C571.465111,540.091874 625.745998,516.316729 664.019925,474.34 L664.019925,452.04 L705.059925,452.04 L718.899925,434.96 L718.899925,95.74 L710.619925,85.5 Z M632.879925,501.9 L632.879925,539.74 L664.019925,539.74 L664.019925,474.18 C654.623775,484.469293 644.18821,493.758755 632.879925,501.9 L632.879925,501.9 Z M313.599925,273.14 C311.569597,280.231983 309.927163,287.429316 308.679925,294.7 L322.179925,294.7 C320.007771,287.25831 317.134915,280.039338 313.599925,273.14 L313.599925,273.14 Z" id="Shape" fill="#8A8C8F" fill-rule="nonzero"></path>
|
||||
<path d="M410.399925,294.7 C409.199925,287.5 407.659925,280.4 405.799925,273.46 C402.19356,280.242036 399.246706,287.354671 396.999925,294.7 C390.06397,317.386469 389.855554,341.597488 396.399925,364.4 L410.719925,364.4 C414.264276,341.293291 414.156293,317.77319 410.399925,294.7 L410.399925,294.7 Z M209.059925,121.48 C107.422724,121.487508 20.5081632,194.571683 3.05992537,294.7 L91.3999254,294.7 C107.135726,243.467257 154.465065,208.503753 208.059925,208.52 C252.638644,208.335148 293.496156,233.351373 313.599925,273.14 C322.501981,241.908137 338.58337,213.190393 360.559925,189.28 C322.206855,145.880863 266.976617,121.163964 209.059925,121.48 L209.059925,121.48 Z M297.479925,411.86 C275.077969,437.877726 242.392659,452.761934 208.059925,452.58 C153.691226,452.598435 105.87164,416.63791 90.7999254,364.4 L308.339925,364.4 C304.575868,341.309464 304.690779,317.752715 308.679925,294.7 L3.05992537,294.7 C-0.902504563,317.755068 -1.01739385,341.307372 2.71992537,364.4 L2.71992537,364.4 C19.3292424,465.441984 106.661918,539.594765 209.059925,539.6 C266.986094,539.900862 322.217868,515.161403 360.559925,471.74 C344.364089,454.096816 331.320914,433.801419 321.999925,411.74 L297.479925,411.86 Z" id="Shape" fill="#B72768" fill-rule="nonzero"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.0 KiB |
@@ -1,10 +1,15 @@
|
||||
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',
|
||||
'learn.loading.learning.sequence': {
|
||||
id: 'learn.loading.learning.sequence',
|
||||
defaultMessage: 'Loading learning sequence...',
|
||||
description: 'Message when learning sequence is being loaded',
|
||||
},
|
||||
'learn.course.load.failure': {
|
||||
id: 'learn.course.load.failure',
|
||||
defaultMessage: 'There was an error loading this course.',
|
||||
description: 'Message when a course fails to load',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
199
src/courseware/course/sequence/Sequence.jsx
Normal file
199
src/courseware/course/sequence/Sequence.jsx
Normal file
@@ -0,0 +1,199 @@
|
||||
/* eslint-disable no-use-before-define */
|
||||
import React, {
|
||||
useEffect, useContext, Suspense, useState,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import Unit from './Unit';
|
||||
import { SequenceNavigation, UnitNavigation } from './sequence-navigation';
|
||||
import PageLoading from '../../../PageLoading';
|
||||
import messages from './messages';
|
||||
import UserMessagesContext from '../../../user-messages/UserMessagesContext';
|
||||
import { useModel } from '../../../model-store';
|
||||
|
||||
const ContentLock = React.lazy(() => import('./content-lock'));
|
||||
|
||||
function Sequence({
|
||||
unitId,
|
||||
sequenceId,
|
||||
courseUsageKey,
|
||||
unitNavigationHandler,
|
||||
nextSequenceHandler,
|
||||
previousSequenceHandler,
|
||||
intl,
|
||||
}) {
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const unit = useModel('units', unitId);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
const handleNext = () => {
|
||||
const nextIndex = sequence.unitIds.indexOf(unitId) + 1;
|
||||
if (nextIndex < sequence.unitIds.length) {
|
||||
const newUnitId = sequence.unitIds[nextIndex];
|
||||
handleNavigate(newUnitId);
|
||||
} else {
|
||||
nextSequenceHandler();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
const previousIndex = sequence.unitIds.indexOf(unitId) - 1;
|
||||
if (previousIndex >= 0) {
|
||||
const newUnitId = sequence.unitIds[previousIndex];
|
||||
handleNavigate(newUnitId);
|
||||
} else {
|
||||
previousSequenceHandler();
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigate = (destinationUnitId) => {
|
||||
unitNavigationHandler(destinationUnitId);
|
||||
};
|
||||
|
||||
const logEvent = (eventName, widgetPlacement, targetUnitId) => {
|
||||
// Note: tabs are tracked with a 1-indexed position
|
||||
// as opposed to a 0-index used throughout this MFE
|
||||
const currentIndex = sequence.unitIds.indexOf(unitId);
|
||||
const payload = {
|
||||
current_tab: currentIndex + 1,
|
||||
id: unitId,
|
||||
tab_count: sequence.unitIds.length,
|
||||
widget_placement: widgetPlacement,
|
||||
};
|
||||
if (targetUnitId) {
|
||||
const targetIndex = sequence.unitIds.indexOf(targetUnitId);
|
||||
payload.target_tab = targetIndex + 1;
|
||||
}
|
||||
sendTrackEvent(eventName, payload);
|
||||
};
|
||||
|
||||
const { add, remove } = useContext(UserMessagesContext);
|
||||
useEffect(() => {
|
||||
let id = null;
|
||||
if (sequenceStatus === 'loaded') {
|
||||
if (sequence.bannerText) {
|
||||
id = add({
|
||||
code: null,
|
||||
dismissible: false,
|
||||
text: sequence.bannerText,
|
||||
type: 'info',
|
||||
topic: 'sequence',
|
||||
});
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (id) {
|
||||
remove(id);
|
||||
}
|
||||
};
|
||||
}, [sequenceStatus, sequence]);
|
||||
|
||||
const [unitHasLoaded, setUnitHasLoaded] = useState(false);
|
||||
const handleUnitLoaded = () => {
|
||||
setUnitHasLoaded(true);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (unit) {
|
||||
setUnitHasLoaded(false);
|
||||
}
|
||||
}, [unit]);
|
||||
|
||||
if (sequenceStatus === 'loading') {
|
||||
return (
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const gated = sequence.gatedContent !== undefined && sequence.gatedContent.gated;
|
||||
|
||||
if (sequenceStatus === 'loaded' && unit) {
|
||||
return (
|
||||
<div className="sequence">
|
||||
<SequenceNavigation
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
className="mb-4"
|
||||
nextSequenceHandler={() => {
|
||||
logEvent('edx.ui.lms.sequence.next_selected', 'top');
|
||||
handleNext();
|
||||
}}
|
||||
onNavigate={(destinationUnitId) => {
|
||||
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
|
||||
handleNavigate(destinationUnitId);
|
||||
}}
|
||||
previousSequenceHandler={() => {
|
||||
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
|
||||
handlePrevious();
|
||||
}}
|
||||
/>
|
||||
<div className="unit-container flex-grow-1">
|
||||
{gated && (
|
||||
<Suspense
|
||||
fallback={(
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.content.lock'])}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<ContentLock
|
||||
courseUsageKey={courseUsageKey}
|
||||
sequenceTitle={sequence.title}
|
||||
prereqSectionName={sequence.gatedContent.gatedSectionName}
|
||||
prereqId={sequence.gatedContent.prereqId}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
{!gated && (
|
||||
<Unit
|
||||
key={unitId}
|
||||
id={unitId}
|
||||
onLoaded={handleUnitLoaded}
|
||||
/>
|
||||
)}
|
||||
{unitHasLoaded && (
|
||||
<UnitNavigation
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
onClickPrevious={() => {
|
||||
logEvent('edx.ui.lms.sequence.previous_selected', 'bottom');
|
||||
handlePrevious();
|
||||
}}
|
||||
onClickNext={() => {
|
||||
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
|
||||
handleNext();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// sequence status 'failed' and any other unexpected sequence status.
|
||||
return (
|
||||
<p className="text-center py-5 mx-auto" style={{ maxWidth: '30em' }}>
|
||||
{intl.formatMessage(messages['learn.course.load.failure'])}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
Sequence.propTypes = {
|
||||
unitId: PropTypes.string,
|
||||
sequenceId: PropTypes.string,
|
||||
courseUsageKey: PropTypes.string.isRequired,
|
||||
unitNavigationHandler: PropTypes.func.isRequired,
|
||||
nextSequenceHandler: PropTypes.func.isRequired,
|
||||
previousSequenceHandler: PropTypes.func.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
Sequence.defaultProps = {
|
||||
sequenceId: null,
|
||||
unitId: null,
|
||||
};
|
||||
|
||||
export default injectIntl(Sequence);
|
||||
@@ -1,23 +1,21 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
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';
|
||||
import BookmarkButton from '../bookmark/BookmarkButton';
|
||||
import { useModel } from '../../../model-store';
|
||||
|
||||
function Unit({
|
||||
bookmarked,
|
||||
bookmarkedUpdateState,
|
||||
displayName,
|
||||
export default function Unit({
|
||||
onLoaded,
|
||||
id,
|
||||
...props
|
||||
}) {
|
||||
const iframeRef = useRef(null);
|
||||
const iframeUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}?show_title=0&show_bookmark_button=0`;
|
||||
|
||||
const [iframeHeight, setIframeHeight] = useState(0);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
|
||||
const unit = useModel('units', id);
|
||||
|
||||
useEffect(() => {
|
||||
global.onmessage = (event) => {
|
||||
const { type, payload } = event.data;
|
||||
@@ -34,26 +32,18 @@ function Unit({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleBookmark = () => {
|
||||
if (bookmarked) {
|
||||
props.removeBookmark(id);
|
||||
} else {
|
||||
props.addBookmark(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="unit">
|
||||
<h2 className="mb-0 h4">{displayName}</h2>
|
||||
<h2 className="mb-0 h4">{unit.title}</h2>
|
||||
<BookmarkButton
|
||||
onClick={toggleBookmark}
|
||||
isBookmarked={bookmarked}
|
||||
isProcessing={bookmarkedUpdateState === 'loading'}
|
||||
unitId={unit.id}
|
||||
isBookmarked={unit.bookmarked}
|
||||
isProcessing={unit.bookmarkedUpdateState === 'loading'}
|
||||
/>
|
||||
<div className="unit-iframe-wrapper">
|
||||
<iframe
|
||||
id="unit-iframe"
|
||||
title={displayName}
|
||||
title={unit.title}
|
||||
ref={iframeRef}
|
||||
src={iframeUrl}
|
||||
allowFullScreen
|
||||
@@ -67,24 +57,10 @@ function Unit({
|
||||
}
|
||||
|
||||
Unit.propTypes = {
|
||||
addBookmark: PropTypes.func.isRequired,
|
||||
bookmarked: PropTypes.bool,
|
||||
bookmarkedUpdateState: PropTypes.string,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
removeBookmark: PropTypes.func.isRequired,
|
||||
onLoaded: PropTypes.func,
|
||||
};
|
||||
|
||||
Unit.defaultProps = {
|
||||
bookmarked: false,
|
||||
bookmarkedUpdateState: undefined,
|
||||
onLoaded: undefined,
|
||||
};
|
||||
|
||||
const mapStateToProps = (state, props) => state.courseBlocks.blocks[props.id] || {};
|
||||
|
||||
export default connect(mapStateToProps, {
|
||||
addBookmark,
|
||||
removeBookmark,
|
||||
})(Unit);
|
||||
@@ -9,10 +9,10 @@ import { Button } from '@edx/paragon';
|
||||
import messages from './messages';
|
||||
|
||||
function ContentLock({
|
||||
intl, courseUsageKey, prereqSectionName, prereqId, sectionName,
|
||||
intl, courseUsageKey, prereqSectionName, prereqId, sequenceTitle,
|
||||
}) {
|
||||
const handleClick = useCallback(() => {
|
||||
history.push(`/course/${courseUsageKey}/${prereqId}`);
|
||||
history.replace(`/course/${courseUsageKey}/${prereqId}`);
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -20,7 +20,7 @@ function ContentLock({
|
||||
<h3>
|
||||
<FontAwesomeIcon icon={faLock} />
|
||||
{' '}
|
||||
{sectionName}
|
||||
{sequenceTitle}
|
||||
</h3>
|
||||
<h4>{intl.formatMessage(messages['learn.contentLock.content.locked'])}</h4>
|
||||
<p>
|
||||
@@ -39,6 +39,6 @@ ContentLock.propTypes = {
|
||||
courseUsageKey: PropTypes.string.isRequired,
|
||||
prereqSectionName: PropTypes.string.isRequired,
|
||||
prereqId: PropTypes.string.isRequired,
|
||||
sectionName: PropTypes.string.isRequired,
|
||||
sequenceTitle: PropTypes.string.isRequired,
|
||||
};
|
||||
export default injectIntl(ContentLock);
|
||||
1
src/courseware/course/sequence/index.js
Normal file
1
src/courseware/course/sequence/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Sequence';
|
||||
21
src/courseware/course/sequence/messages.js
Normal file
21
src/courseware/course/sequence/messages.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'learn.loading.content.lock': {
|
||||
id: 'learn.loading.content.lock',
|
||||
defaultMessage: 'Loading locked content messaging...',
|
||||
description: 'Message shown when an interface about locked content is being loaded',
|
||||
},
|
||||
'learn.loading.learning.sequence': {
|
||||
id: 'learn.loading.learning.sequence',
|
||||
defaultMessage: 'Loading learning sequence...',
|
||||
description: 'Message when learning sequence is being loaded',
|
||||
},
|
||||
'learn.course.load.failure': {
|
||||
id: 'learn.course.load.failure',
|
||||
defaultMessage: 'There was an error loading this course.',
|
||||
description: 'Message when a course fails to load',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -8,22 +8,24 @@ import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import UnitButton from './UnitButton';
|
||||
import SequenceNavigationTabs from './SequenceNavigationTabs';
|
||||
import { useSequenceNavigationMetadata } from './hooks';
|
||||
import { useModel } from '../../../../model-store';
|
||||
|
||||
export default function SequenceNavigation({
|
||||
activeUnitId,
|
||||
unitId,
|
||||
sequenceId,
|
||||
className,
|
||||
isFirstUnit,
|
||||
isLastUnit,
|
||||
isLocked,
|
||||
onNavigate,
|
||||
onNext,
|
||||
onPrevious,
|
||||
showCompletion,
|
||||
unitIds,
|
||||
nextSequenceHandler,
|
||||
previousSequenceHandler,
|
||||
}) {
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
|
||||
const isLocked = sequence.gatedContent !== undefined && sequence.gatedContent.gated;
|
||||
|
||||
return (
|
||||
<nav className={classNames('sequence-navigation', className)}>
|
||||
<Button className="previous-btn" onClick={onPrevious} disabled={isFirstUnit}>
|
||||
<Button className="previous-btn" onClick={previousSequenceHandler} disabled={isFirstUnit}>
|
||||
<FontAwesomeIcon icon={faChevronLeft} className="mr-2" size="sm" />
|
||||
<FormattedMessage
|
||||
defaultMessage="Previous"
|
||||
@@ -32,17 +34,16 @@ export default function SequenceNavigation({
|
||||
/>
|
||||
</Button>
|
||||
|
||||
|
||||
{isLocked ? <UnitButton type="lock" isActive /> : (
|
||||
{isLocked ? <UnitButton unitId={unitId} title="" contentType="lock" isActive onClick={() => {}} /> : (
|
||||
<SequenceNavigationTabs
|
||||
unitIds={unitIds}
|
||||
activeUnitId={activeUnitId}
|
||||
showCompletion={showCompletion}
|
||||
unitIds={sequence.unitIds}
|
||||
unitId={unitId}
|
||||
showCompletion={sequence.showCompletion}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button className="next-btn" onClick={onNext} disabled={isLastUnit}>
|
||||
<Button className="next-btn" onClick={nextSequenceHandler} disabled={isLastUnit}>
|
||||
<FormattedMessage
|
||||
defaultMessage="Next"
|
||||
id="learn.sequence.navigation.next.button"
|
||||
@@ -55,16 +56,12 @@ export default function SequenceNavigation({
|
||||
}
|
||||
|
||||
SequenceNavigation.propTypes = {
|
||||
activeUnitId: PropTypes.string.isRequired,
|
||||
unitId: PropTypes.string.isRequired,
|
||||
sequenceId: PropTypes.string.isRequired,
|
||||
className: PropTypes.string,
|
||||
isFirstUnit: PropTypes.bool.isRequired,
|
||||
isLastUnit: PropTypes.bool.isRequired,
|
||||
isLocked: PropTypes.bool.isRequired,
|
||||
onNavigate: PropTypes.func.isRequired,
|
||||
onNext: PropTypes.func.isRequired,
|
||||
onPrevious: PropTypes.func.isRequired,
|
||||
showCompletion: PropTypes.bool.isRequired,
|
||||
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
nextSequenceHandler: PropTypes.func.isRequired,
|
||||
previousSequenceHandler: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
SequenceNavigation.defaultProps = {
|
||||
@@ -6,7 +6,7 @@ import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import UnitButton from './UnitButton';
|
||||
|
||||
export default function SequenceNavigationDropdown({
|
||||
activeUnitId,
|
||||
unitId,
|
||||
onNavigate,
|
||||
showCompletion,
|
||||
unitIds,
|
||||
@@ -19,21 +19,21 @@ export default function SequenceNavigationDropdown({
|
||||
description="The title of the mobile menu for sequence navigation of units"
|
||||
id="learn.course.sequence.navigation.mobile.menu"
|
||||
values={{
|
||||
current: unitIds.indexOf(activeUnitId) + 1,
|
||||
current: unitIds.indexOf(unitId) + 1,
|
||||
total: unitIds.length,
|
||||
}}
|
||||
/>
|
||||
</Dropdown.Button>
|
||||
<Dropdown.Menu className="w-100">
|
||||
{unitIds.map(unitId => (
|
||||
{unitIds.map(buttonUnitId => (
|
||||
<UnitButton
|
||||
className="w-100"
|
||||
isActive={activeUnitId === unitId}
|
||||
key={unitId}
|
||||
isActive={unitId === buttonUnitId}
|
||||
key={buttonUnitId}
|
||||
onClick={onNavigate}
|
||||
showCompletion={showCompletion}
|
||||
showTitle
|
||||
unitId={unitId}
|
||||
unitId={buttonUnitId}
|
||||
/>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
@@ -42,7 +42,7 @@ export default function SequenceNavigationDropdown({
|
||||
}
|
||||
|
||||
SequenceNavigationDropdown.propTypes = {
|
||||
activeUnitId: PropTypes.string.isRequired,
|
||||
unitId: PropTypes.string.isRequired,
|
||||
onNavigate: PropTypes.func.isRequired,
|
||||
showCompletion: PropTypes.bool.isRequired,
|
||||
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
@@ -3,10 +3,10 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import UnitButton from './UnitButton';
|
||||
import SequenceNavigationDropdown from './SequenceNavigationDropdown';
|
||||
import useIndexOfLastVisibleChild from '../../tabs/useIndexOfLastVisibleChild';
|
||||
import useIndexOfLastVisibleChild from '../../../../tabs/useIndexOfLastVisibleChild';
|
||||
|
||||
export default function SequenceNavigationTabs({
|
||||
unitIds, activeUnitId, showCompletion, onNavigate,
|
||||
unitIds, unitId, showCompletion, onNavigate,
|
||||
}) {
|
||||
const [
|
||||
indexOfLastVisibleChild,
|
||||
@@ -22,11 +22,11 @@ export default function SequenceNavigationTabs({
|
||||
className="sequence-navigation-tabs d-flex flex-grow-1"
|
||||
style={shouldDisplayDropdown ? invisibleStyle : null}
|
||||
>
|
||||
{unitIds.map(unitId => (
|
||||
{unitIds.map(buttonUnitId => (
|
||||
<UnitButton
|
||||
key={unitId}
|
||||
unitId={unitId}
|
||||
isActive={activeUnitId === unitId}
|
||||
key={buttonUnitId}
|
||||
unitId={buttonUnitId}
|
||||
isActive={unitId === buttonUnitId}
|
||||
showCompletion={showCompletion}
|
||||
onClick={onNavigate}
|
||||
/>
|
||||
@@ -35,7 +35,7 @@ export default function SequenceNavigationTabs({
|
||||
</div>
|
||||
{shouldDisplayDropdown && (
|
||||
<SequenceNavigationDropdown
|
||||
activeUnitId={activeUnitId}
|
||||
unitId={unitId}
|
||||
onNavigate={onNavigate}
|
||||
showCompletion={showCompletion}
|
||||
unitIds={unitIds}
|
||||
@@ -46,7 +46,7 @@ export default function SequenceNavigationTabs({
|
||||
}
|
||||
|
||||
SequenceNavigationTabs.propTypes = {
|
||||
activeUnitId: PropTypes.string.isRequired,
|
||||
unitId: PropTypes.string.isRequired,
|
||||
onNavigate: PropTypes.func.isRequired,
|
||||
showCompletion: PropTypes.bool.isRequired,
|
||||
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
@@ -6,11 +6,11 @@ import { Button } from '@edx/paragon';
|
||||
|
||||
import UnitIcon from './UnitIcon';
|
||||
import CompleteIcon from './CompleteIcon';
|
||||
import BookmarkFilledIcon from './bookmark/BookmarkFilledIcon';
|
||||
import BookmarkFilledIcon from '../../bookmark/BookmarkFilledIcon';
|
||||
|
||||
function UnitButton({
|
||||
onClick,
|
||||
displayName,
|
||||
title,
|
||||
contentType,
|
||||
isActive,
|
||||
bookmarked,
|
||||
@@ -31,10 +31,10 @@ function UnitButton({
|
||||
complete: showCompletion && complete,
|
||||
}, className)}
|
||||
onClick={handleClick}
|
||||
title={displayName}
|
||||
title={title}
|
||||
>
|
||||
<UnitIcon type={contentType} />
|
||||
{showTitle && <span className="unit-title">{displayName}</span>}
|
||||
{showTitle && <span className="unit-title">{title}</span>}
|
||||
{showCompletion && complete ? <CompleteIcon size="sm" className="text-success ml-2" /> : null}
|
||||
{bookmarked ? (
|
||||
<BookmarkFilledIcon
|
||||
@@ -47,16 +47,16 @@ function UnitButton({
|
||||
}
|
||||
|
||||
UnitButton.propTypes = {
|
||||
unitId: PropTypes.string.isRequired,
|
||||
isActive: PropTypes.bool,
|
||||
bookmarked: PropTypes.bool,
|
||||
complete: PropTypes.bool,
|
||||
showCompletion: PropTypes.bool,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
contentType: PropTypes.string.isRequired,
|
||||
className: PropTypes.string,
|
||||
complete: PropTypes.bool,
|
||||
contentType: PropTypes.string.isRequired,
|
||||
isActive: PropTypes.bool,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
showCompletion: PropTypes.bool,
|
||||
showTitle: PropTypes.bool,
|
||||
title: PropTypes.string.isRequired,
|
||||
unitId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
UnitButton.defaultProps = {
|
||||
@@ -69,7 +69,7 @@ UnitButton.defaultProps = {
|
||||
};
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
...state.courseBlocks.blocks[props.unitId],
|
||||
...state.models.units[props.unitId],
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(UnitButton);
|
||||
@@ -4,15 +4,18 @@ import { Button } from '@edx/paragon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { useSequenceNavigationMetadata } from './hooks';
|
||||
|
||||
export default function UnitNavigation(props) {
|
||||
const {
|
||||
isFirstUnit,
|
||||
isLastUnit,
|
||||
sequenceId,
|
||||
unitId,
|
||||
onClickPrevious,
|
||||
onClickNext,
|
||||
} = props;
|
||||
|
||||
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
|
||||
|
||||
return (
|
||||
<div className="unit-navigation d-flex">
|
||||
<Button
|
||||
@@ -56,13 +59,8 @@ export default function UnitNavigation(props) {
|
||||
}
|
||||
|
||||
UnitNavigation.propTypes = {
|
||||
isFirstUnit: PropTypes.bool,
|
||||
isLastUnit: PropTypes.bool,
|
||||
sequenceId: PropTypes.string.isRequired,
|
||||
unitId: PropTypes.string.isRequired,
|
||||
onClickPrevious: PropTypes.func.isRequired,
|
||||
onClickNext: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
UnitNavigation.defaultProps = {
|
||||
isFirstUnit: false,
|
||||
isLastUnit: false,
|
||||
};
|
||||
24
src/courseware/course/sequence/sequence-navigation/hooks.js
Normal file
24
src/courseware/course/sequence/sequence-navigation/hooks.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useModel } from '../../../../model-store';
|
||||
import { sequenceIdsSelector } from '../../../data/selectors';
|
||||
|
||||
export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId) {
|
||||
const sequenceIds = useSelector(sequenceIdsSelector);
|
||||
const sequence = useModel('sequences', currentSequenceId);
|
||||
const courseStatus = useSelector(state => state.courseware.courseStatus);
|
||||
|
||||
// If we don't know the sequence and unit yet, then assume no.
|
||||
if (courseStatus !== 'loaded' || !currentSequenceId || !currentUnitId) {
|
||||
return { isFirstUnit: false, isLastUnit: false };
|
||||
}
|
||||
const isFirstSequence = sequenceIds.indexOf(currentSequenceId) === 0;
|
||||
const isFirstUnitInSequence = sequence.unitIds.indexOf(currentUnitId) === 0;
|
||||
const isFirstUnit = isFirstSequence && isFirstUnitInSequence;
|
||||
const isLastSequence = sequenceIds.indexOf(currentSequenceId) === sequenceIds.length - 1;
|
||||
const isLastUnitInSequence = sequence.unitIds.indexOf(currentUnitId) === sequence.unitIds.length - 1;
|
||||
const isLastUnit = isLastSequence && isLastUnitInSequence;
|
||||
|
||||
return { isFirstUnit, isLastUnit };
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as SequenceNavigation } from './SequenceNavigation';
|
||||
export { default as UnitNavigation } from './UnitNavigation';
|
||||
47
src/courseware/data/api.js
Normal file
47
src/courseware/data/api.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
const getSequenceXModuleHandlerUrl = (courseUsageKey, sequenceId) => `${getConfig().LMS_BASE_URL}/courses/${courseUsageKey}/xblock/${sequenceId}/handler/xmodule_handler`;
|
||||
|
||||
export async function getBlockCompletion(courseUsageKey, sequenceId, usageKey) {
|
||||
// Post data sent to this endpoint must be url encoded
|
||||
// TODO: Remove the need for this to be the case.
|
||||
// TODO: Ensure this usage of URLSearchParams is working in Internet Explorer
|
||||
const urlEncoded = new URLSearchParams();
|
||||
urlEncoded.append('usage_key', usageKey);
|
||||
const requestConfig = {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
};
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient().post(
|
||||
`${getSequenceXModuleHandlerUrl(courseUsageKey, sequenceId)}/get_completion`,
|
||||
urlEncoded.toString(),
|
||||
requestConfig,
|
||||
);
|
||||
|
||||
if (data.complete) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function updateSequencePosition(courseUsageKey, sequenceId, position) {
|
||||
// Post data sent to this endpoint must be url encoded
|
||||
// TODO: Remove the need for this to be the case.
|
||||
// TODO: Ensure this usage of URLSearchParams is working in Internet Explorer
|
||||
const urlEncoded = new URLSearchParams();
|
||||
// Position is 1-indexed on the server and 0-indexed in this app. Adjust here.
|
||||
urlEncoded.append('position', position + 1);
|
||||
const requestConfig = {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
};
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient().post(
|
||||
`${getSequenceXModuleHandlerUrl(courseUsageKey, sequenceId)}/goto_position`,
|
||||
urlEncoded.toString(),
|
||||
requestConfig,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
20
src/courseware/data/selectors.js
Normal file
20
src/courseware/data/selectors.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export function sequenceIdsSelector(state) {
|
||||
if (state.courseware.courseStatus !== 'loaded') {
|
||||
return [];
|
||||
}
|
||||
const { sectionIds } = state.models.courses[state.courseware.courseUsageKey];
|
||||
let sequenceIds = [];
|
||||
sectionIds.forEach(sectionId => {
|
||||
sequenceIds = [...sequenceIds, ...state.models.sections[sectionId].sequenceIds];
|
||||
});
|
||||
return sequenceIds;
|
||||
}
|
||||
|
||||
export function firstSequenceIdSelector(state) {
|
||||
if (state.courseware.courseStatus !== 'loaded') {
|
||||
return null;
|
||||
}
|
||||
const sectionId = state.models.courses[state.courseware.courseUsageKey].sectionIds[0];
|
||||
return state.models.sections[sectionId].sequenceIds[0];
|
||||
}
|
||||
66
src/courseware/data/thunks.js
Normal file
66
src/courseware/data/thunks.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import {
|
||||
getBlockCompletion,
|
||||
updateSequencePosition,
|
||||
} from './api';
|
||||
import {
|
||||
updateModel,
|
||||
} from '../../model-store';
|
||||
|
||||
export function checkBlockCompletion(courseUsageKey, sequenceId, unitId) {
|
||||
return async (dispatch, getState) => {
|
||||
const { models } = getState();
|
||||
if (models.units[unitId].complete) {
|
||||
return; // do nothing. Things don't get uncompleted after they are completed.
|
||||
}
|
||||
|
||||
try {
|
||||
const isComplete = await getBlockCompletion(courseUsageKey, sequenceId, unitId);
|
||||
dispatch(updateModel({
|
||||
modelType: 'units',
|
||||
model: {
|
||||
id: unitId,
|
||||
complete: isComplete,
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function saveSequencePosition(courseUsageKey, sequenceId, position) {
|
||||
return async (dispatch, getState) => {
|
||||
const { models } = getState();
|
||||
const initialPosition = models.sequences[sequenceId].position;
|
||||
// Optimistically update the position.
|
||||
dispatch(updateModel({
|
||||
modelType: 'sequences',
|
||||
model: {
|
||||
id: sequenceId,
|
||||
position,
|
||||
},
|
||||
}));
|
||||
try {
|
||||
await updateSequencePosition(courseUsageKey, sequenceId, position);
|
||||
// Update again under the assumption that the above call succeeded, since it doesn't return a
|
||||
// meaningful response.
|
||||
dispatch(updateModel({
|
||||
modelType: 'sequences',
|
||||
model: {
|
||||
id: sequenceId,
|
||||
position,
|
||||
},
|
||||
}));
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
dispatch(updateModel({
|
||||
modelType: 'sequences',
|
||||
model: {
|
||||
id: sequenceId,
|
||||
position: initialPosition,
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
1
src/courseware/index.js
Normal file
1
src/courseware/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './CoursewareContainer';
|
||||
@@ -1,21 +1,11 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'learn.loading.learning.sequence': {
|
||||
id: 'learn.loading.learning.sequence',
|
||||
defaultMessage: 'Loading learning sequence...',
|
||||
description: 'Message when learning sequence is being loaded',
|
||||
},
|
||||
'learn.loading.error': {
|
||||
id: 'learn.loading.error',
|
||||
defaultMessage: 'Error: {error}',
|
||||
description: 'Message when learning sequence fails to load',
|
||||
},
|
||||
'learn.course.load.failure': {
|
||||
id: 'learn.course.load.failure',
|
||||
defaultMessage: 'There was an error loading this course.',
|
||||
description: 'Message when a course fails to load',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
/* eslint-disable no-use-before-define */
|
||||
import React, {
|
||||
useEffect, useContext, Suspense, useState,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import Unit from './Unit';
|
||||
import SequenceNavigation from './SequenceNavigation';
|
||||
import PageLoading from '../../PageLoading';
|
||||
import messages from './messages';
|
||||
import UserMessagesContext from '../../user-messages/UserMessagesContext';
|
||||
import UnitNavigation from './UnitNavigation';
|
||||
|
||||
const ContentLock = React.lazy(() => import('./content-lock'));
|
||||
|
||||
function Sequence({
|
||||
activeUnitId,
|
||||
bannerText,
|
||||
courseUsageKey,
|
||||
displayName,
|
||||
intl,
|
||||
isFirstUnit,
|
||||
isGated,
|
||||
isLastUnit,
|
||||
onNavigateUnit,
|
||||
onNext,
|
||||
onPrevious,
|
||||
prerequisite,
|
||||
showCompletion,
|
||||
unitIds,
|
||||
}) {
|
||||
const handleNext = () => {
|
||||
const nextIndex = unitIds.indexOf(activeUnitId) + 1;
|
||||
if (nextIndex < unitIds.length) {
|
||||
const newUnitId = unitIds[nextIndex];
|
||||
handleNavigate(newUnitId);
|
||||
} else {
|
||||
onNext();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
const previousIndex = unitIds.indexOf(activeUnitId) - 1;
|
||||
if (previousIndex >= 0) {
|
||||
const newUnitId = unitIds[previousIndex];
|
||||
handleNavigate(newUnitId);
|
||||
} else {
|
||||
onPrevious();
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigate = (unitId) => {
|
||||
onNavigateUnit(unitId);
|
||||
};
|
||||
|
||||
const logEvent = (eventName, widgetPlacement, targetUnitId) => {
|
||||
// Note: tabs are tracked with a 1-indexed position
|
||||
// as opposed to a 0-index used throughout this MFE
|
||||
const currentIndex = unitIds.indexOf(activeUnitId);
|
||||
const payload = {
|
||||
current_tab: currentIndex + 1,
|
||||
id: activeUnitId,
|
||||
tab_count: unitIds.length,
|
||||
widget_placement: widgetPlacement,
|
||||
};
|
||||
if (targetUnitId) {
|
||||
const targetIndex = unitIds.indexOf(targetUnitId);
|
||||
payload.target_tab = targetIndex + 1;
|
||||
}
|
||||
sendTrackEvent(eventName, payload);
|
||||
};
|
||||
|
||||
const { add, remove } = useContext(UserMessagesContext);
|
||||
useEffect(() => {
|
||||
let id = null;
|
||||
if (bannerText) {
|
||||
id = add({
|
||||
code: null,
|
||||
dismissible: false,
|
||||
text: bannerText,
|
||||
type: 'info',
|
||||
topic: 'sequence',
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
if (id) {
|
||||
remove(id);
|
||||
}
|
||||
};
|
||||
}, [bannerText]);
|
||||
|
||||
const [unitHasLoaded, setUnitHasLoaded] = useState(false);
|
||||
const handleUnitLoaded = () => {
|
||||
setUnitHasLoaded(true);
|
||||
};
|
||||
useEffect(() => {
|
||||
setUnitHasLoaded(false);
|
||||
}, [activeUnitId]);
|
||||
|
||||
return (
|
||||
<div className="sequence">
|
||||
<SequenceNavigation
|
||||
activeUnitId={activeUnitId}
|
||||
className="mb-4"
|
||||
isFirstUnit={isFirstUnit}
|
||||
isLastUnit={isLastUnit}
|
||||
isLocked={isGated}
|
||||
onNext={() => {
|
||||
logEvent('edx.ui.lms.sequence.next_selected', 'top');
|
||||
handleNext();
|
||||
}}
|
||||
onNavigate={(unitId) => {
|
||||
logEvent('edx.ui.lms.sequence.tab_selected', 'top', unitId);
|
||||
handleNavigate(unitId);
|
||||
}}
|
||||
onPrevious={() => {
|
||||
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
|
||||
handlePrevious();
|
||||
}}
|
||||
showCompletion={showCompletion}
|
||||
unitIds={unitIds}
|
||||
/>
|
||||
<div className="unit-container flex-grow-1">
|
||||
{isGated && (
|
||||
<Suspense
|
||||
fallback={(
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.content.lock'])}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<ContentLock
|
||||
courseUsageKey={courseUsageKey}
|
||||
sectionName={displayName}
|
||||
prereqSectionName={prerequisite.name}
|
||||
prereqId={prerequisite.id}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
{!isGated && (
|
||||
<Unit
|
||||
key={activeUnitId}
|
||||
id={activeUnitId}
|
||||
onLoaded={handleUnitLoaded}
|
||||
/>
|
||||
)}
|
||||
{unitHasLoaded && (
|
||||
<UnitNavigation
|
||||
isFirstUnit={isFirstUnit}
|
||||
onClickPrevious={() => {
|
||||
logEvent('edx.ui.lms.sequence.previous_selected', 'bottom');
|
||||
handlePrevious();
|
||||
}}
|
||||
onClickNext={() => {
|
||||
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
|
||||
handleNext();
|
||||
}}
|
||||
isLastUnit={isLastUnit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Sequence.propTypes = {
|
||||
activeUnitId: PropTypes.string.isRequired,
|
||||
bannerText: PropTypes.string,
|
||||
courseUsageKey: PropTypes.string.isRequired,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
isFirstUnit: PropTypes.bool.isRequired,
|
||||
isGated: PropTypes.bool.isRequired,
|
||||
isLastUnit: PropTypes.bool.isRequired,
|
||||
onNavigateUnit: PropTypes.func,
|
||||
onNext: PropTypes.func.isRequired,
|
||||
onPrevious: PropTypes.func.isRequired,
|
||||
showCompletion: PropTypes.bool.isRequired,
|
||||
prerequisite: PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
}).isRequired,
|
||||
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
};
|
||||
|
||||
Sequence.defaultProps = {
|
||||
onNavigateUnit: null,
|
||||
bannerText: undefined,
|
||||
};
|
||||
|
||||
export default injectIntl(Sequence);
|
||||
@@ -1,11 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'learn.loading.content.lock': {
|
||||
id: 'learn.loading.content.lock',
|
||||
defaultMessage: 'Loading locked content messaging...',
|
||||
description: 'Message shown when an interface about locked content is being loaded',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,67 +0,0 @@
|
||||
/* eslint-disable no-plusplus */
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
|
||||
export function createBlocksMap(blocksData) {
|
||||
const blocks = {};
|
||||
const blocksList = Object.values(blocksData);
|
||||
|
||||
// First go through the list and flesh out our blocks map, camelCasing the objects as we go.
|
||||
for (let i = 0; i < blocksList.length; i++) {
|
||||
const block = blocksList[i];
|
||||
blocks[block.id] = camelCaseObject(block);
|
||||
}
|
||||
|
||||
// Next go through the blocksList again - now that we've added them all to the blocks map - and
|
||||
// append a parent ID to every child found in every `children` list, using the blocks map to find
|
||||
// them.
|
||||
for (let i = 0; i < blocksList.length; i++) {
|
||||
const block = blocksList[i];
|
||||
|
||||
if (Array.isArray(block.children)) {
|
||||
for (let j = 0; j < block.children.length; j++) {
|
||||
const childId = block.children[j];
|
||||
const child = blocks[childId];
|
||||
child.parentId = block.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export function createSequenceIdList(blocks, entryPointId, sequences = []) {
|
||||
const block = blocks[entryPointId];
|
||||
if (block.type === 'sequential') {
|
||||
sequences.push(block.id);
|
||||
}
|
||||
if (Array.isArray(block.children)) {
|
||||
for (let i = 0; i < block.children.length; i++) {
|
||||
const childId = block.children[i];
|
||||
createSequenceIdList(blocks, childId, sequences);
|
||||
}
|
||||
}
|
||||
return sequences;
|
||||
}
|
||||
|
||||
export function createUnitIdList(blocks, entryPointId, units = []) {
|
||||
const block = blocks[entryPointId];
|
||||
if (block.type === 'vertical') {
|
||||
units.push(block.id);
|
||||
}
|
||||
if (Array.isArray(block.children)) {
|
||||
for (let i = 0; i < block.children.length; i++) {
|
||||
const childId = block.children[i];
|
||||
createUnitIdList(blocks, childId, units);
|
||||
}
|
||||
}
|
||||
return units;
|
||||
}
|
||||
|
||||
export function findBlockAncestry(blocks, blockId, descendents = []) {
|
||||
const block = blocks[blockId];
|
||||
descendents.unshift(block);
|
||||
if (block.parentId === undefined) {
|
||||
return descendents;
|
||||
}
|
||||
return findBlockAncestry(blocks, block.parentId, descendents);
|
||||
}
|
||||
Reference in New Issue
Block a user