Parsing course structure and rendering breadcrumbs, subsection nav, and iframe.
This commit is contained in:
@@ -1,116 +1,154 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig, camelCaseObject } from '@edx/frontend-platform';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { Breadcrumb } from '@edx/paragon';
|
||||
|
||||
import PageLoading from './PageLoading';
|
||||
|
||||
import messages from './messages';
|
||||
import SubSectionNavigation from './SubSectionNavigation';
|
||||
|
||||
function useApi(apiFunction, {
|
||||
format = true, keepDataIfFailed = false, loadedIfFailed = false, refreshParams = [],
|
||||
}) {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
async function getCourseBlocks(courseId, username) {
|
||||
const queryParams = Object.entries({
|
||||
course_id: courseId,
|
||||
username,
|
||||
depth: 3,
|
||||
requested_fields: 'children',
|
||||
}).reduce((acc, [key, value]) => (acc === '' ? `?${key}=${value}` : `${acc}&${key}=${value}`), '');
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
apiFunction().then((response) => {
|
||||
const result = format ? camelCaseObject(response.data) : response.data;
|
||||
setData(result);
|
||||
setLoaded(true);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setFailed(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (keepDataIfFailed) {
|
||||
setData(null);
|
||||
}
|
||||
setFailed(true);
|
||||
setLoading(false);
|
||||
if (loadedIfFailed) {
|
||||
setLoaded(true);
|
||||
}
|
||||
setError(e);
|
||||
});
|
||||
}, refreshParams);
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/${queryParams}`, {});
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
loaded,
|
||||
failed,
|
||||
error,
|
||||
};
|
||||
return { models: organizeCourseModels(data.blocks), courseBlockId: data.root };
|
||||
}
|
||||
|
||||
function LearningSequencePage(props) {
|
||||
const iframeRef = useRef(null);
|
||||
function organizeCourseModels(blocksMap) {
|
||||
const models = {};
|
||||
|
||||
const handleResizeIframe = useCallback(() => {
|
||||
// TODO: This won't work because of crossdomain issues. Leaving here for reference once we're
|
||||
// able to have the iFrame content publish resize events through postMessage
|
||||
console.log('**** Resizing iframe...');
|
||||
const iframe = iframeRef.current;
|
||||
const contentHeight = iframe.contentWindow.document.body.scrollHeight;
|
||||
console.log(`**** Height is: ${contentHeight}`);
|
||||
iframe.height = contentHeight + 20;
|
||||
});
|
||||
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
loaded,
|
||||
} = useApi(
|
||||
async () => getAuthenticatedHttpClient().get(`${getConfig().LMS_BASE_URL}/api/courses/v1/blocks/?course_id=${props.match.params.courseId}&username=staff&depth=all&block_types_filter=sequential&requested_fields=children`, {}),
|
||||
{
|
||||
keepDataIfFailed: false,
|
||||
refreshParams: [
|
||||
props.match.params.courseId,
|
||||
props.match.params.blockIndex,
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
console.log(data);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageLoading srMessage={props.intl.formatMessage(messages['learn.loading.learning.sequence'])} />
|
||||
);
|
||||
const blocks = Object.values(blocksMap);
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const block = blocks[i];
|
||||
models[block.id] = camelCaseObject(block);
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="container-fluid">
|
||||
<h1>Learning Sequence Page</h1>
|
||||
{loaded && data.blocks ? (
|
||||
<iframe
|
||||
title="yus"
|
||||
ref={iframeRef}
|
||||
src={Object.values(data.blocks)[parseInt(props.match.params.blockIndex, 10)].studentViewUrl}
|
||||
onLoad={handleResizeIframe}
|
||||
height={500}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
// NOTE: If a child is listed as a child of multiple models, the last one in wins. This does NOT
|
||||
// support multiple parents.
|
||||
const modelValues = Object.values(models);
|
||||
for (let i = 0; i < modelValues.length; i++) {
|
||||
const model = modelValues[i];
|
||||
|
||||
if (Array.isArray(model.children)) {
|
||||
for (let j = 0; j < model.children.length; j++) {
|
||||
const child = models[model.children[j]];
|
||||
child.parentId = model.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
function findFirstLeafChild(models, blockId) {
|
||||
const block = models[blockId];
|
||||
if (Array.isArray(block.children) && block.children.length > 0) {
|
||||
return findFirstLeafChild(models, block.children[0]);
|
||||
}
|
||||
return block;
|
||||
}
|
||||
|
||||
function findBlockAncestry(models, block, descendents = []) {
|
||||
descendents.unshift(block);
|
||||
if (block.parentId === undefined) {
|
||||
return descendents;
|
||||
}
|
||||
return findBlockAncestry(models, models[block.parentId], descendents);
|
||||
}
|
||||
|
||||
class LearningSequencePage extends Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
models: {},
|
||||
courseBlockId: null,
|
||||
loading: true,
|
||||
currentUnitId: null,
|
||||
};
|
||||
|
||||
this.iframeRef = React.createRef();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
getCourseBlocks(this.props.match.params.courseId, this.context.authenticatedUser.username)
|
||||
.then(({ models, courseBlockId }) => {
|
||||
const currentUnit = findFirstLeafChild(models, courseBlockId); // Temporary until we know where the user is in the course.
|
||||
this.setState({
|
||||
models,
|
||||
courseBlockId,
|
||||
loading: false,
|
||||
currentUnitId: currentUnit.id,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
handleUnitChange = (unitId) => {
|
||||
this.setState({
|
||||
currentUnitId: unitId,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.loading) {
|
||||
return (
|
||||
<PageLoading srMessage={this.props.intl.formatMessage(messages['learn.loading.learning.sequence'])} />
|
||||
);
|
||||
}
|
||||
|
||||
const currentUnit = this.state.models[this.state.currentUnitId];
|
||||
|
||||
// TODO: All of this should be put in state or memoized.
|
||||
const course = this.state.models[this.state.courseBlockId];
|
||||
const chapter = this.state.models[course.children[0].id];
|
||||
const subSection = this.state.models[currentUnit.parentId];
|
||||
const ancestry = findBlockAncestry(this.state.models, currentUnit);
|
||||
const breadcrumbLinks = ancestry.slice(0, ancestry.length - 1).map(ancestor => ({ label: ancestor.displayName, url: global.location.href }));
|
||||
|
||||
|
||||
console.log(course, chapter, currentUnit, ancestry);
|
||||
|
||||
return (
|
||||
<main >
|
||||
<div className="container-fluid">
|
||||
<h1>{course.displayName}</h1>
|
||||
<Breadcrumb
|
||||
links={breadcrumbLinks}
|
||||
activeLabel={currentUnit.displayName}
|
||||
spacer={<span>></span>}
|
||||
/>
|
||||
<SubSectionNavigation models={this.state.models} subSection={subSection} unitClickHandler={this.handleUnitChange} />
|
||||
</div>
|
||||
<iframe
|
||||
title="yus"
|
||||
ref={this.iframeRef}
|
||||
src={currentUnit.studentViewUrl}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
LearningSequencePage.contextType = AppContext;
|
||||
|
||||
export default injectIntl(LearningSequencePage);
|
||||
|
||||
LearningSequencePage.propTypes = {
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.shape({
|
||||
courseId: PropTypes.string.isRequired,
|
||||
blockIndex: PropTypes.number.isRequired,
|
||||
blockIndex: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(LearningSequencePage);
|
||||
|
||||
30
src/learning-sequence/SubSectionNavigation.jsx
Normal file
30
src/learning-sequence/SubSectionNavigation.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
export default class SubSectionNavigation extends Component {
|
||||
renderUnits() {
|
||||
return this.props.subSection.children.map((unitId) => {
|
||||
const unit = this.props.models[unitId];
|
||||
return (
|
||||
<Button
|
||||
key={unitId}
|
||||
className="btn-outline-secondary unit-button"
|
||||
onClick={() => this.props.unitClickHandler(unitId)}
|
||||
>
|
||||
{unit.displayName}
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<nav>
|
||||
<Button key="previous" className="btn-outline-primary">Previous</Button>
|
||||
{this.renderUnits()}
|
||||
<Button key="next" className="btn-outline-primary">Next</Button>
|
||||
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user