Refactoring to use containers and components

This commit is contained in:
David Joy
2020-01-14 17:11:03 -05:00
parent f756299975
commit 89830af45a
27 changed files with 754 additions and 152 deletions

View File

@@ -31,9 +31,9 @@ subscribe(APP_READY, () => {
path="/"
render={() => <Link to="/course/course-v1%3AedX%2BDemoX%2BDemo_Course/block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc">Visit Demo Course</Link>}
/>
<Route path="/course/:courseId/:subSectionId/:unitId" component={LearningSequencePage} />
<Route path="/course/:courseId/:subSectionId" component={LearningSequencePage} />
<Route path="/course/:courseId" component={LearningSequencePage} />
<Route path="/course/:courseUsageKey/:sequenceId/:unitId" component={LearningSequencePage} />
<Route path="/course/:courseUsageKey/:sequenceId" component={LearningSequencePage} />
<Route path="/course/:courseUsageKey" component={LearningSequencePage} />
</Switch>
<Footer />
</AppProvider>,

View File

@@ -0,0 +1,50 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useLoadCourseStructure } from './data/hooks';
import messages from './messages';
import PageLoading from './PageLoading';
import Course from './course/Course';
import { history } from '@edx/frontend-platform';
function CourseContainer({
courseUsageKey, sequenceId, unitId, intl,
}) {
const { blocks, loaded, courseId } = useLoadCourseStructure(courseUsageKey);
useEffect(() => {
if (!sequenceId) {
// TODO: This will not work right now.
const { activeSequenceId } = blocks[courseId];
history.push(`/course/${courseUsageKey}/${activeSequenceId}`);
}
}, [courseUsageKey, courseId, sequenceId]);
if (!loaded || !sequenceId) {
return (
<PageLoading
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
/>
);
}
return (
<Course
courseUsageKey={courseUsageKey}
courseId={courseId}
sequenceId={sequenceId}
unitId={unitId}
models={blocks}
/>
);
}
CourseContainer.propTypes = {
courseUsageKey: PropTypes.string.isRequired,
sequenceId: PropTypes.string,
unitId: PropTypes.string,
intl: intlShape.isRequired,
};
export default injectIntl(CourseContainer);

View File

@@ -2,45 +2,40 @@ import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import PageLoading from './PageLoading';
import messages from './messages';
import CourseBreadcrumbs from './CourseBreadcrumbs';
import CourseStructureContext from './CourseStructureContext';
import { useLoadCourseStructure, useMissingSubSectionRedirect } from './data/hooks';
import SubSection from './sub-section/SubSection';
import { history } from '@edx/frontend-platform';
import CourseContainer from './CourseContainer';
function LearningSequencePage({ match, intl }) {
const {
courseId,
subSectionId,
courseUsageKey,
sequenceId,
unitId,
} = match.params;
const { blocks, loaded, courseBlockId } = useLoadCourseStructure(courseId);
// const { blocks, loaded, courseId } = useLoadCourseStructure(courseId);
useMissingSubSectionRedirect(loaded, blocks, courseId, courseBlockId, subSectionId);
// useMissingSequenceRedirect(loaded, blocks, courseId, courseId, sequenceId);
return (
<main className="container-fluid d-flex flex-column flex-grow-1">
<CourseStructureContext.Provider value={{
courseId,
courseBlockId,
subSectionId,
unitId,
blocks,
loaded,
}}
>
{!loaded && <PageLoading
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
/>}
<CourseContainer courseUsageKey={courseUsageKey} sequenceId={sequenceId} unitId={unitId} />
// <main className="container-fluid d-flex flex-column flex-grow-1">
// <CourseStructureContext.Provider value={{
// courseId,
// courseId,
// sequenceId,
// unitId,
// blocks,
// loaded,
// }}
// >
// {!loaded && <PageLoading
// srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
// />}
{loaded && unitId && <CourseBreadcrumbs />}
{subSectionId && <SubSection />}
</CourseStructureContext.Provider>
// {loaded && unitId && <CourseBreadcrumbs />}
// {sequenceId && <Sequence />}
// </CourseStructureContext.Provider>
</main>
// </main>
);
}
@@ -49,8 +44,8 @@ export default injectIntl(LearningSequencePage);
LearningSequencePage.propTypes = {
match: PropTypes.shape({
params: PropTypes.shape({
courseId: PropTypes.string.isRequired,
subSectionId: PropTypes.string,
courseUsageKey: PropTypes.string.isRequired,
sequenceId: PropTypes.string,
unitId: PropTypes.string,
}).isRequired,
}).isRequired,

View File

@@ -0,0 +1,77 @@
import React, { useMemo, useCallback } from 'react';
import PropTypes from 'prop-types';
import { getConfig, history } from '@edx/frontend-platform';
import CourseBreadcrumbs from './CourseBreadcrumbs';
import SequenceContainer from './SequenceContainer';
import { createSequenceIdList } from '../data/utils';
export default function Course({
courseUsageKey, courseId, sequenceId, unitId, models,
}) {
const breadcrumbs = useMemo(() => {
const sectionId = models[sequenceId].parentId;
// TODO: Add unit ID back in here if it exists
return [courseId, sectionId, sequenceId].map((nodeId) => {
const node = models[nodeId];
return {
id: node.id,
label: node.displayName,
url: `${getConfig().LMS_BASE_URL}/courses/${courseUsageKey}/course/#${node.id}`,
};
});
}, [courseUsageKey, courseId, sequenceId, unitId, models]);
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}`);
}
});
return (
<main className="container-fluid d-flex flex-column flex-grow-1">
<CourseBreadcrumbs links={breadcrumbs} />
<SequenceContainer
key={sequenceId}
courseUsageKey={courseUsageKey}
courseId={courseId}
sequenceId={sequenceId}
unitId={unitId}
onNext={nextSequenceHandler}
onPrevious={previousSequenceHandler}
/>
</main>
);
}
Course.propTypes = {
courseUsageKey: PropTypes.string.isRequired,
courseId: PropTypes.string.isRequired,
sequenceId: PropTypes.string.isRequired,
unitId: PropTypes.string,
models: PropTypes.objectOf(PropTypes.shape({
id: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired,
})).isRequired,
};
Course.defaultProps = {
sequenceId: null,
unitId: null,
};

View File

@@ -1,36 +1,28 @@
import React, { useContext } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronRight } from '@fortawesome/free-solid-svg-icons';
import { getConfig } from '@edx/frontend-platform';
import CourseStructureContext from './CourseStructureContext';
import { useBlockAncestry } from './data/hooks';
const CourseBreadcrumbs = () => {
const { courseId, unitId } = useContext(CourseStructureContext);
const ancestry = useBlockAncestry(unitId);
const links = ancestry.map(ancestor => ({
id: ancestor.id,
label: ancestor.displayName,
url: `${getConfig().LMS_BASE_URL}/courses/${courseId}/course/#${ancestor.id}`,
}));
export default function CourseBreadcrumbs({ links }) {
return (
<nav aria-label="breadcrumb">
<ol className="list-inline">
{links.map(({ id, url, label }, i) => (
<CourseBreadcrumb key={id} url={url} label={label} last={i === links.length - 1} />
))}
))}
</ol>
</nav>
);
};
}
export default CourseBreadcrumbs;
CourseBreadcrumbs.propTypes = {
links: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
})).isRequired,
};
function CourseBreadcrumb({ url, label, last }) {
return (

View File

@@ -0,0 +1,107 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { history } from '@edx/frontend-platform';
import { useLoadSequenceMetadata } from './hooks';
import messages from '../messages';
import PageLoading from '../PageLoading';
import Sequence from '../sequence/Sequence';
/*
elementId: "edx_introduction"
bannerText: null
displayName: "Demo Course Overview"
items: Array(1)
0:
path: "Introduction > Demo Course Overview > Introduction: Video and Sequences"
href: ""
type: "video"
content: ""
graded: false
pageTitle: "Introduction: Video and Sequences"
bookmarked: false
id: "block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc"
complete: null
__proto__: Object
length: 1
__proto__: Array(0)
savePosition: true
isTimeLimited: false
gatedContent: {gatedSectionName: "Demo Course Overview", prereqUrl: null, prereqSectionName: null, gated: false, prereqId: null}
excludeUnits: true
tag: "sequential"
position: 1
showCompletion: true
itemId: "block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction"
ajaxUrl: "/courses/course-v1:edX+DemoX+Demo_Course/xblock/block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction/handler/xmodule_handler"
nextUrl: null
prevUrl: null
*/
function SequenceContainer({
courseUsageKey, courseId, sequenceId, unitId, intl, onNext, onPrevious,
}) {
const { metadata, loaded, units } = useLoadSequenceMetadata(courseUsageKey, sequenceId);
console.log(units);
useEffect(() => {
if (loaded && !unitId) {
const position = metadata.position - 1;
const nextUnitId = metadata.items[position].id;
history.push(`/course/${courseUsageKey}/${sequenceId}/${nextUnitId}`);
}
}, [loaded, metadata, unitId]);
console.log(metadata);
if (!loaded || !unitId) {
return (
<PageLoading
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
/>
);
}
const {
displayName,
showCompletion,
isTimeLimited,
savePosition,
bannerText,
gatedContent,
} = metadata;
const prerequisite = {
id: gatedContent.prereqId,
name: gatedContent.gatedSectionName,
};
return (
<Sequence
id={sequenceId}
courseUsageKey={courseUsageKey}
courseId={courseId}
unitIds={metadata.items.map(item => item.id)}
units={units}
displayName={displayName}
activeUnitId={unitId}
showCompletion={showCompletion}
isTimeLimited={isTimeLimited}
isGated={gatedContent.gated}
savePosition={savePosition}
bannerText={bannerText}
onNext={onNext}
onPrevious={onPrevious}
onNavigateUnit={() => console.log('hah2')}
prerequisite={prerequisite}
/>
);
}
SequenceContainer.propTypes = {
courseId: PropTypes.string.isRequired,
sequenceId: PropTypes.string.isRequired,
unitId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(SequenceContainer);

View File

@@ -3,17 +3,17 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
/* eslint-disable import/prefer-default-export */
const getSubSectionXModuleHandlerUrl = (courseId, subSectionId) =>
`${getConfig().LMS_BASE_URL}/courses/${courseId}/xblock/${subSectionId}/handler/xmodule_handler`;
const getSequenceXModuleHandlerUrl = (courseUsageKey, sequenceId) =>
`${getConfig().LMS_BASE_URL}/courses/${courseUsageKey}/xblock/${sequenceId}/handler/xmodule_handler`;
export async function getSubSectionMetadata(courseId, subSectionId) {
export async function getSequenceMetadata(courseUsageKey, sequenceId) {
const { data } = await getAuthenticatedHttpClient()
.get(`${getSubSectionXModuleHandlerUrl(courseId, subSectionId)}/metadata`, {});
.get(`${getSequenceXModuleHandlerUrl(courseUsageKey, sequenceId)}/metadata`, {});
return data;
}
export async function saveSubSectionPosition(courseId, subSectionId, position) {
export async function saveSequencePosition(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
@@ -24,7 +24,7 @@ export async function saveSubSectionPosition(courseId, subSectionId, position) {
};
const { data } = await getAuthenticatedHttpClient().post(
`${getSubSectionXModuleHandlerUrl(courseId, subSectionId)}/goto_position`,
`${getSequenceXModuleHandlerUrl(courseUsageKey, sequenceId)}/goto_position`,
urlEncoded.toString(),
requestConfig,
);

View File

@@ -1,24 +1,34 @@
/* eslint-disable no-plusplus */
import { useState, useEffect, useContext } from 'react';
import { camelCaseObject, history } from '@edx/frontend-platform';
import { getSubSectionMetadata, saveSubSectionPosition } from './api';
import CourseStructureContext from '../../CourseStructureContext';
import { getSequenceMetadata, saveSequencePosition } from './api';
import CourseStructureContext from '../CourseStructureContext';
export function useLoadSubSectionMetadata(courseId, subSectionId) {
export function useLoadSequenceMetadata(courseUsageKey, sequenceId) {
const [metadata, setMetadata] = useState(null);
const [units, setUnits] = useState(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
setLoaded(false);
setMetadata(null);
getSubSectionMetadata(courseId, subSectionId).then((data) => {
getSequenceMetadata(courseUsageKey, sequenceId).then((data) => {
const unitsMap = {};
for (let i = 0; i < data.items.length; i++) {
const item = data.items[i];
unitsMap[item.id] = camelCaseObject(item);
}
setMetadata(camelCaseObject(data));
setUnits(unitsMap);
setLoaded(true);
});
}, [courseId, subSectionId]);
}, [courseUsageKey, sequenceId]);
return {
metadata,
units,
loaded,
};
}
@@ -36,15 +46,15 @@ export function useExamRedirect(metadata, blocks) {
/**
* Save the position of current unit the subsection
*/
export function usePersistentUnitPosition(courseId, subSectionId, unitId, subSectionMetadata) {
export function usePersistentUnitPosition(courseUsageKey, sequenceId, unitId, sequenceMetadata) {
useEffect(() => {
// All values must be defined to function
const hasNeededData = courseId && subSectionId && unitId && subSectionMetadata;
const hasNeededData = courseUsageKey && sequenceId && unitId && sequenceMetadata;
if (!hasNeededData) {
return;
}
const { items, savePosition } = subSectionMetadata;
const { items, savePosition } = sequenceMetadata;
// A sub-section can individually specify whether positions should be saved
if (!savePosition) {
@@ -58,18 +68,18 @@ export function usePersistentUnitPosition(courseId, subSectionId, unitId, subSec
// TODO: update the local understanding of the position and
// don't make requests to update the position if they still match?
saveSubSectionPosition(courseId, subSectionId, newPosition);
}, [courseId, subSectionId, unitId, subSectionMetadata]);
saveSequencePosition(courseUsageKey, sequenceId, newPosition);
}, [courseUsageKey, sequenceId, unitId, sequenceMetadata]);
}
export function useMissingUnitRedirect(metadata, loaded) {
const { courseId, subSectionId, unitId } = useContext(CourseStructureContext);
const { courseUsageKey, sequenceId, unitId } = useContext(CourseStructureContext);
useEffect(() => {
if (loaded && metadata.itemId === subSectionId && !unitId) {
if (loaded && metadata.itemId === sequenceId && !unitId) {
// Position comes from the server as a 1-indexed array index. Convert it to 0-indexed.
const position = metadata.position - 1;
const nextUnitId = metadata.items[position].id;
history.push(`/course/${courseId}/${subSectionId}/${nextUnitId}`);
history.push(`/course/${courseUsageKey}/${sequenceId}/${nextUnitId}`);
}
}, [loaded, metadata, unitId]);
}

View File

@@ -13,3 +13,10 @@ export async function getCourseBlocks(courseId, username) {
return data;
}
export async function getCourse(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/courses/v2/courses/${courseId}`;
const { data } = await getAuthenticatedHttpClient().get(url);
return data;
}

View File

@@ -4,7 +4,7 @@ import { AppContext } from '@edx/frontend-platform/react';
import CourseStructureContext from '../CourseStructureContext';
import { getCourseBlocks } from './api';
import { findBlockAncestry, createBlocksMap, createSubSectionIdList, createUnitIdList } from './utils';
import { findBlockAncestry, createBlocksMap, createSequenceIdList, createUnitIdList } from './utils';
export function useBlockAncestry(blockId) {
const { blocks, loaded } = useContext(CourseStructureContext);
@@ -19,63 +19,66 @@ export function useBlockAncestry(blockId) {
}, [blocks, blockId, loaded]);
}
export function useMissingSubSectionRedirect(
export function useMissingSequenceRedirect(
loaded,
blocks,
courseUsageKey,
courseId,
courseBlockId,
subSectionId,
sequenceId,
) {
useEffect(() => {
if (loaded && !subSectionId) {
const course = blocks[courseBlockId];
if (loaded && !sequenceId) {
const course = blocks[courseId];
const nextSectionId = course.children[0];
const nextSection = blocks[nextSectionId];
const nextSubSectionId = nextSection.children[0];
const nextSubSection = blocks[nextSubSectionId];
const nextUnitId = nextSubSection.children[0];
history.push(`/course/${courseId}/${nextSubSectionId}/${nextUnitId}`);
const nextSequenceId = nextSection.children[0];
const nextSequence = blocks[nextSequenceId];
const nextUnitId = nextSequence.children[0];
history.push(`/course/${courseUsageKey}/${nextSequenceId}/${nextUnitId}`);
}
}, [loaded, subSectionId]);
}, [loaded, sequenceId]);
}
export function useLoadCourseStructure(courseId) {
export function useLoadCourseStructure(courseUsageKey) {
const { authenticatedUser } = useContext(AppContext);
const [blocks, setBlocks] = useState(null);
const [loaded, setLoaded] = useState(false);
const [courseBlockId, setCourseBlockId] = useState();
const [courseId, setCourseId] = useState();
useEffect(() => {
setLoaded(false);
getCourseBlocks(courseId, authenticatedUser.username).then((blocksData) => {
getCourseBlocks(courseUsageKey, authenticatedUser.username).then((blocksData) => {
setBlocks(createBlocksMap(blocksData.blocks));
setCourseBlockId(blocksData.root);
setCourseId(blocksData.root);
setLoaded(true);
});
}, [courseId]);
// getCourse(courseUsageKey).then((courseData) => {
// });
}, [courseUsageKey]);
return {
blocks, loaded, courseBlockId,
blocks, loaded, courseId,
};
}
export function useCurrentCourse() {
const { loaded, courseBlockId, blocks } = useContext(CourseStructureContext);
const { loaded, courseId, blocks } = useContext(CourseStructureContext);
return loaded ? blocks[courseBlockId] : null;
return loaded ? blocks[courseId] : null;
}
export function useCurrentSubSection() {
const { loaded, blocks, subSectionId } = useContext(CourseStructureContext);
export function useCurrentSequence() {
const { loaded, blocks, sequenceId } = useContext(CourseStructureContext);
return loaded && subSectionId ? blocks[subSectionId] : null;
return loaded && sequenceId ? blocks[sequenceId] : null;
}
export function useCurrentSection() {
const { loaded, blocks } = useContext(CourseStructureContext);
const subSection = useCurrentSubSection();
return loaded ? blocks[subSection.parentId] : null;
const sequence = useCurrentSequence();
return loaded ? blocks[sequence.parentId] : null;
}
export function useCurrentUnit() {
@@ -86,11 +89,11 @@ export function useCurrentUnit() {
export function useUnitIds() {
const { loaded, blocks, courseBlockId } = useContext(CourseStructureContext);
const { loaded, blocks, courseId } = useContext(CourseStructureContext);
return useMemo(
() => (loaded ? createUnitIdList(blocks, courseBlockId) : []),
[loaded, blocks, courseBlockId],
() => (loaded ? createUnitIdList(blocks, courseId) : []),
[loaded, blocks, courseId],
);
}
@@ -117,20 +120,20 @@ export function useNextUnit() {
return loaded ? blocks[unitIds[currentUnitIndex + 1]] : null;
}
export function useCurrentSubSectionUnits() {
export function useCurrentSequenceUnits() {
const { loaded, blocks } = useContext(CourseStructureContext);
const subSection = useCurrentSubSection();
const sequence = useCurrentSequence();
return loaded ? subSection.children.map(id => blocks[id]) : [];
return loaded ? sequence.children.map(id => blocks[id]) : [];
}
export function useSubSectionIdList() {
const { loaded, blocks, courseBlockId } = useContext(CourseStructureContext);
export function useSequenceIdList() {
const { loaded, blocks, courseId } = useContext(CourseStructureContext);
const subSectionIdList = useMemo(
() => (loaded ? createSubSectionIdList(blocks, courseBlockId) : []),
[blocks, courseBlockId],
const sequenceIdList = useMemo(
() => (loaded ? createSequenceIdList(blocks, courseId) : []),
[blocks, courseId],
);
return subSectionIdList;
return sequenceIdList;
}

View File

@@ -29,18 +29,18 @@ export function createBlocksMap(blocksData) {
return blocks;
}
export function createSubSectionIdList(blocks, entryPointId, subSections = []) {
export function createSequenceIdList(blocks, entryPointId, sequences = []) {
const block = blocks[entryPointId];
if (block.type === 'sequential') {
subSections.push(block.id);
sequences.push(block.id);
}
if (Array.isArray(block.children)) {
for (let i = 0; i < block.children.length; i++) {
const childId = block.children[i];
createSubSectionIdList(blocks, childId, subSections);
createSequenceIdList(blocks, childId, sequences);
}
}
return subSections;
return sequences;
}
export function createUnitIdList(blocks, entryPointId, units = []) {

View File

@@ -0,0 +1,7 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCheckCircle } from '@fortawesome/free-solid-svg-icons';
export default function CompleteIcon(props) {
return <FontAwesomeIcon icon={faCheckCircle} {...props} />;
}

View File

@@ -0,0 +1,173 @@
/* eslint-disable no-use-before-define */
import React, { useState, useEffect, Suspense } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import Unit from './Unit';
import SequenceNavigation from './SequenceNavigation';
import PageLoading from '../PageLoading';
import { getBlockCompletion, saveSequencePosition } from './api';
import messages from './messages';
const ContentLock = React.lazy(() => import('./content-lock'));
function Sequence({
courseUsageKey,
id,
unitIds,
units: initialUnits,
displayName,
showCompletion,
isTimeLimited,
bannerText,
onNext,
onPrevious,
onNavigateUnit,
isGated,
prerequisite,
savePosition,
activeUnitId: initialActiveUnitId,
intl,
}) {
const [units, setUnits] = useState(initialUnits);
const [activeUnitId, setActiveUnitId] = useState(initialActiveUnitId);
const activeUnitIndex = unitIds.indexOf(activeUnitId);
const activeUnit = units[activeUnitId];
const unitsArr = unitIds.map(unitId => ({
...units[unitId],
id: unitId,
isActive: unitId === activeUnitId,
}));
// TODO: Use callback
const updateUnitCompletion = (unitId) => {
// If the unit is already complete, don't check.
if (units[unitId].complete) {
return;
}
getBlockCompletion(courseUsageKey, id, unitId).then((isComplete) => {
if (isComplete) {
setUnits({
...units,
[unitId]: { ...units[unitId], complete: isComplete },
});
}
});
};
const handleNext = () => {
if (activeUnitIndex < unitIds.length - 1) {
handleNavigate(activeUnitIndex + 1);
} else {
onNext();
}
};
const handlePrevious = () => {
if (activeUnitIndex > 0) {
handleNavigate(activeUnitIndex - 1);
} else {
onPrevious();
}
};
const handleNavigate = (unitIndex) => {
const newUnitId = unitIds[unitIndex];
if (showCompletion) {
updateUnitCompletion(activeUnitId);
}
setActiveUnitId(newUnitId);
onNavigateUnit(newUnitId, units[newUnitId]);
};
useEffect(() => {
if (savePosition) {
saveSequencePosition(courseUsageKey, id, activeUnitIndex);
}
}, [activeUnitId]);
return (
<div className="d-flex flex-column flex-grow-1">
<SequenceNavigation
onNext={handleNext}
onNavigate={handleNavigate}
onPrevious={handlePrevious}
units={unitsArr}
isLocked={isGated}
showCompletion={showCompletion}
/>
{isGated ? (
<Suspense fallback={<PageLoading
srMessage={intl.formatMessage(messages['learn.loading.content.lock'])}
/>}
>
<ContentLock
courseUsageKey={courseUsageKey}
sectionName={displayName}
prereqSectionName={prerequisite.name}
prereqId={prerequisite.id}
/>
</Suspense>
) : (
<Unit key={activeUnitId} {...activeUnit} />
)}
</div>
);
}
Sequence.propTypes = {
id: PropTypes.string.isRequired,
courseUsageKey: PropTypes.string.isRequired,
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
units: PropTypes.objectOf(PropTypes.shape({
id: PropTypes.string.isRequired,
complete: PropTypes.bool,
pageTitle: PropTypes.string.isRequired,
})).isRequired,
displayName: PropTypes.string.isRequired,
activeUnitId: PropTypes.string.isRequired,
showCompletion: PropTypes.bool.isRequired,
isTimeLimited: PropTypes.bool.isRequired,
bannerText: PropTypes.string,
onNext: PropTypes.func.isRequired,
onPrevious: PropTypes.func.isRequired,
onNavigateUnit: PropTypes.func.isRequired,
isGated: PropTypes.bool.isRequired,
prerequisite: PropTypes.shape({
name: PropTypes.string,
id: PropTypes.string,
}).isRequired,
savePosition: PropTypes.bool.isRequired,
intl: intlShape.isRequired,
};
Sequence.defaultProps = {
bannerText: null,
};
export default injectIntl(Sequence);
// Sequence.propTypes = {
// id: PropTypes.string.isRequired,
// courseUsageKey: Pro
// unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
// units: PropTypes.objectOf(PropTypes.shape({
// })),
// displayName: PropTypes.string.isRequired,
// activeUnitId: PropTypes.string.isRequired,
// showCompletion: PropTypes.bool.isRequired,
// isTimeLimited: PropTypes.bool.isRequired,
// isGated: PropTypes.bool.isRequired,
// savePosition: PropTypes.bool.isRequired,
// bannerText: PropTypes.string,
// onNext: PropTypes.func.isRequired,
// onPrevious: PropTypes.func.isRequired,
// onNavigateUnit: PropTypes.func.isRequired,
// prerequisite: PropTypes.shape({
// name: PropTypes.string,
// id: PropTypes.string,
// }),
// };

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { Button } from '@edx/paragon';
import UnitButton from './UnitButton';
export default function SequenceNavigation({
onNext,
onPrevious,
onNavigate,
units,
isLocked,
showCompletion,
}) {
const unitButtons = units.map((unit, index) => (
<UnitButton
key={unit.id}
{...unit}
isComplete={showCompletion && unit.complete}
onClick={onNavigate.bind(null, index)}
/>
));
return (
<nav className="flex-grow-0 d-flex w-100 mb-3 btn-group">
<Button className="btn-outline-primary" onClick={onPrevious}>
Previous
</Button>
{isLocked ? <UnitButton type="lock" isActive /> : unitButtons}
<Button className="btn-outline-primary" onClick={onNext}>
Next
</Button>
</nav>
);
}

View File

@@ -0,0 +1,22 @@
import React, { useRef } from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
export default function Unit({ id, pageTitle }) {
const iframeRef = useRef(null);
const iframeUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}`;
return (
<iframe
className="flex-grow-1"
title={pageTitle}
ref={iframeRef}
src={iframeUrl}
/>
);
}
Unit.propTypes = {
id: PropTypes.string.isRequired,
pageTitle: PropTypes.string.isRequired,
};

View File

@@ -0,0 +1,30 @@
import React from 'react';
import classNames from 'classnames';
import { Button } from '@edx/paragon';
import UnitIcon from './UnitIcon';
import CompleteIcon from './CompleteIcon';
export default function UnitButton({
onClick,
pageTitle,
type,
isActive,
isComplete,
}) {
return (
<Button
className={classNames({
active: isActive,
'btn-outline-primary': !isActive,
'btn-outline-secondary': isActive,
})}
disabled={isActive}
onClick={onClick}
title={pageTitle}
>
<UnitIcon type={type} />
{isComplete ? <CompleteIcon className="text-success ml-2" /> : null}
</Button>
);
}

View File

@@ -0,0 +1,35 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFilm, faBook, faPencilAlt, faTasks, faLock } from '@fortawesome/free-solid-svg-icons';
export default function UnitIcon({ type }) {
let icon = null;
switch (type) {
case 'video':
icon = faFilm;
break;
case 'other':
icon = faBook;
break;
case 'vertical':
icon = faTasks;
break;
case 'problem':
icon = faPencilAlt;
break;
case 'lock':
icon = faLock;
break;
default:
icon = faBook;
}
return (
<FontAwesomeIcon icon={icon} />
);
}
UnitIcon.propTypes = {
type: PropTypes.oneOf(['video', 'other', 'vertical', 'problem', 'lock']).isRequired,
};

View File

@@ -0,0 +1,50 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
/* eslint-disable import/prefer-default-export */
const getSequenceXModuleHandlerUrl = (courseUsageKey, sequenceId) =>
`${getConfig().LMS_BASE_URL}/courses/${courseUsageKey}/xblock/${sequenceId}/handler/xmodule_handler`;
export async function saveSequencePosition(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();
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;
}
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;
}

View File

@@ -1,33 +1,28 @@
import React, { useContext, useCallback } from 'react';
import React, { useCallback } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLock } from '@fortawesome/free-solid-svg-icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { history } from '@edx/frontend-platform';
import { Button } from '@edx/paragon';
import SubSectionMetadataContext from '../SubSectionMetadataContext';
import messages from './messages';
import { useCurrentSubSection } from '../../data/hooks';
import CourseStructureContext from '../../CourseStructureContext';
function ContentLock({ intl }) {
const { courseId } = useContext(CourseStructureContext);
const metadata = useContext(SubSectionMetadataContext);
const subSection = useCurrentSubSection();
function ContentLock({
intl, courseUsageKey, prereqSectionName, prereqId, sectionName,
}) {
const handleClick = useCallback(() => {
history.push(`/course/${courseId}/${metadata.gatedContent.prereqId}`);
history.push(`/course/${courseUsageKey}/${prereqId}`);
});
return (
<>
<h3>
<FontAwesomeIcon icon={faLock} />{' '}
{subSection.displayName}
{sectionName}
</h3>
<h4>{intl.formatMessage(messages['learn.contentLock.content.locked'])}</h4>
<p>{intl.formatMessage(messages['learn.contentLock.complete.prerequisite'], {
prereqSectionName: metadata.gatedContent.prereqSectionName,
prereqSectionName,
})}
</p>
<p>
@@ -36,9 +31,7 @@ function ContentLock({ intl }) {
</>
);
}
ContentLock.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(ContentLock);

View File

@@ -0,0 +1,5 @@
import React from 'react';
const SequenceMetadataContext = React.createContext({});
export default SequenceMetadataContext;

View File

@@ -1,31 +1,31 @@
import React, { useContext, Suspense } from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import SubSectionNavigation from './SubSectionNavigation';
import SequenceNavigation from './SequenceNavigation';
import CourseStructureContext from '../CourseStructureContext';
import Unit from './Unit';
import {
useLoadSubSectionMetadata,
useLoadSequenceMetadata,
useExamRedirect,
usePersistentUnitPosition,
useMissingUnitRedirect,
} from './data/hooks';
import SubSectionMetadataContext from './SubSectionMetadataContext';
import SequenceMetadataContext from './SequenceMetadataContext';
import PageLoading from '../PageLoading';
import messages from './messages';
import { useCurrentUnit } from '../data/hooks';
const ContentLock = React.lazy(() => import('./content-lock'));
function SubSection({ intl }) {
function Sequence({ intl }) {
const {
courseId,
subSectionId,
courseUsageKey,
sequenceId,
unitId,
blocks,
} = useContext(CourseStructureContext);
const { metadata, loaded } = useLoadSubSectionMetadata(courseId, subSectionId);
usePersistentUnitPosition(courseId, subSectionId, unitId, metadata);
const { metadata, loaded } = useLoadSequenceMetadata(courseUsageKey, sequenceId);
usePersistentUnitPosition(courseUsageKey, sequenceId, unitId, metadata);
useExamRedirect(metadata, blocks);
@@ -41,9 +41,9 @@ function SubSection({ intl }) {
const isGated = metadata.gatedContent.gated;
return (
<SubSectionMetadataContext.Provider value={metadata}>
<SequenceMetadataContext.Provider value={metadata}>
<section className="d-flex flex-column flex-grow-1">
<SubSectionNavigation />
<SequenceNavigation />
{isGated && (
<Suspense fallback={<PageLoading
srMessage={intl.formatMessage(messages['learn.loading.content.lock'])}
@@ -54,12 +54,12 @@ function SubSection({ intl }) {
)}
{!isGated && <Unit id={unitId} unit={unit} />}
</section>
</SubSectionMetadataContext.Provider>
</SequenceMetadataContext.Provider>
);
}
SubSection.propTypes = {
Sequence.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(SubSection);
export default injectIntl(Sequence);

View File

@@ -1,5 +0,0 @@
import React from 'react';
const SubSectionMetadataContext = React.createContext({});
export default SubSectionMetadataContext;

View File

@@ -5,9 +5,9 @@ import { Button } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFilm, faBook, faPencilAlt, faTasks, faLock } from '@fortawesome/free-solid-svg-icons';
import { usePreviousUnit, useNextUnit, useCurrentSubSectionUnits, useCurrentUnit } from '../data/hooks';
import { usePreviousUnit, useNextUnit, useCurrentSequenceUnits, useCurrentUnit } from '../data/hooks';
import CourseStructureContext from '../CourseStructureContext';
import SubSectionMetadataContext from './SubSectionMetadataContext';
import SequenceMetadataContext from './SequenceMetadataContext';
function UnitIcon({ type }) {
let icon = null;
@@ -40,24 +40,24 @@ UnitIcon.propTypes = {
type: PropTypes.oneOf(['video', 'other', 'vertical', 'problem', 'lock']).isRequired,
};
export default function SubSectionNavigation() {
const { courseId, unitId } = useContext(CourseStructureContext);
export default function SequenceNavigation() {
const { courseUsageKey, unitId } = useContext(CourseStructureContext);
const previousUnit = usePreviousUnit();
const nextUnit = useNextUnit();
const handlePreviousClick = useCallback(() => {
if (previousUnit) {
history.push(`/course/${courseId}/${previousUnit.parentId}/${previousUnit.id}`);
history.push(`/course/${courseUsageKey}/${previousUnit.parentId}/${previousUnit.id}`);
}
});
const handleNextClick = useCallback(() => {
if (nextUnit) {
history.push(`/course/${courseId}/${nextUnit.parentId}/${nextUnit.id}`);
history.push(`/course/${courseUsageKey}/${nextUnit.parentId}/${nextUnit.id}`);
}
});
const handleUnitClick = useCallback((unit) => {
history.push(`/course/${courseId}/${unit.parentId}/${unit.id}`);
history.push(`/course/${courseUsageKey}/${unit.parentId}/${unit.id}`);
});
if (!unitId) {
@@ -87,8 +87,8 @@ export default function SubSectionNavigation() {
function UnitNavigation({ clickHandler }) {
const currentUnit = useCurrentUnit();
const units = useCurrentSubSectionUnits();
const metadata = useContext(SubSectionMetadataContext);
const units = useCurrentSequenceUnits();
const metadata = useContext(SequenceMetadataContext);
const isGated = metadata.gatedContent.gated;