Parsing course structure and rendering breadcrumbs, subsection nav, and iframe.

This commit is contained in:
David Joy
2020-01-03 16:36:00 -05:00
parent e525d81f8f
commit 580dc3b5b1
4 changed files with 200 additions and 93 deletions

View File

@@ -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>&gt;</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);

View 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>
);
}
}