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:
David Joy
2020-03-23 11:31:09 -04:00
committed by GitHub
parent 720594a7cf
commit 9cbb765f8a
78 changed files with 1544 additions and 1625 deletions

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

View File

@@ -0,0 +1,66 @@
import React, { useRef, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import BookmarkButton from '../bookmark/BookmarkButton';
import { useModel } from '../../../model-store';
export default function Unit({
onLoaded,
id,
}) {
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;
if (type === 'plugin.resize') {
setIframeHeight(payload.height);
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
setHasLoaded(true);
if (onLoaded) {
onLoaded();
}
}
}
};
}, []);
return (
<div className="unit">
<h2 className="mb-0 h4">{unit.title}</h2>
<BookmarkButton
unitId={unit.id}
isBookmarked={unit.bookmarked}
isProcessing={unit.bookmarkedUpdateState === 'loading'}
/>
<div className="unit-iframe-wrapper">
<iframe
id="unit-iframe"
title={unit.title}
ref={iframeRef}
src={iframeUrl}
allowFullScreen
height={iframeHeight}
scrolling="no"
referrerPolicy="origin"
/>
</div>
</div>
);
}
Unit.propTypes = {
id: PropTypes.string.isRequired,
onLoaded: PropTypes.func,
};
Unit.defaultProps = {
onLoaded: undefined,
};

View File

@@ -0,0 +1,44 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
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 messages from './messages';
function ContentLock({
intl, courseUsageKey, prereqSectionName, prereqId, sequenceTitle,
}) {
const handleClick = useCallback(() => {
history.replace(`/course/${courseUsageKey}/${prereqId}`);
});
return (
<>
<h3>
<FontAwesomeIcon icon={faLock} />
{' '}
{sequenceTitle}
</h3>
<h4>{intl.formatMessage(messages['learn.contentLock.content.locked'])}</h4>
<p>
{intl.formatMessage(messages['learn.contentLock.complete.prerequisite'], {
prereqSectionName,
})}
</p>
<p>
<Button className="btn-primary" onClick={handleClick}>{intl.formatMessage(messages['learn.contentLock.goToSection'])}</Button>
</p>
</>
);
}
ContentLock.propTypes = {
intl: intlShape.isRequired,
courseUsageKey: PropTypes.string.isRequired,
prereqSectionName: PropTypes.string.isRequired,
prereqId: PropTypes.string.isRequired,
sequenceTitle: PropTypes.string.isRequired,
};
export default injectIntl(ContentLock);

View File

@@ -0,0 +1 @@
export { default } from './ContentLock';

View File

@@ -0,0 +1,21 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'learn.contentLock.content.locked': {
id: 'learn.contentLock.content.locked',
defaultMessage: 'Content Locked',
description: 'Message shown to indicate that a piece of content is unavailable and has a prerequisite.',
},
'learn.contentLock.complete.prerequisite': {
id: 'learn.contentLock.complete.prerequisite',
defaultMessage: "You must complete the prerequisite: '{prereqSectionName}' to access this content.",
description: 'Message shown to indicate which prerequisite the student must complete prior to accessing the locked content. {prereqSectionName} is the name of the prerequisite.',
},
'learn.contentLock.goToSection': {
id: 'learn.contentLock.goToSection',
defaultMessage: 'Go To Prerequisite Section',
description: 'A button users can click that navigates their browser to the prerequisite of this section.',
},
});
export default messages;

View File

@@ -0,0 +1 @@
export { default } from './Sequence';

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

View File

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

View File

@@ -0,0 +1,69 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
import classNames from 'classnames';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons';
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({
unitId,
sequenceId,
className,
onNavigate,
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={previousSequenceHandler} disabled={isFirstUnit}>
<FontAwesomeIcon icon={faChevronLeft} className="mr-2" size="sm" />
<FormattedMessage
defaultMessage="Previous"
id="learn.sequence.navigation.previous.button"
description="The Previous button in the sequence nav"
/>
</Button>
{isLocked ? <UnitButton unitId={unitId} title="" contentType="lock" isActive onClick={() => {}} /> : (
<SequenceNavigationTabs
unitIds={sequence.unitIds}
unitId={unitId}
showCompletion={sequence.showCompletion}
onNavigate={onNavigate}
/>
)}
<Button className="next-btn" onClick={nextSequenceHandler} disabled={isLastUnit}>
<FormattedMessage
defaultMessage="Next"
id="learn.sequence.navigation.next.button"
description="The Next button in the sequence nav"
/>
<FontAwesomeIcon icon={faChevronRight} className="ml-2" size="sm" />
</Button>
</nav>
);
}
SequenceNavigation.propTypes = {
unitId: PropTypes.string.isRequired,
sequenceId: PropTypes.string.isRequired,
className: PropTypes.string,
onNavigate: PropTypes.func.isRequired,
nextSequenceHandler: PropTypes.func.isRequired,
previousSequenceHandler: PropTypes.func.isRequired,
};
SequenceNavigation.defaultProps = {
className: null,
};

View File

@@ -0,0 +1,49 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Dropdown } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import UnitButton from './UnitButton';
export default function SequenceNavigationDropdown({
unitId,
onNavigate,
showCompletion,
unitIds,
}) {
return (
<Dropdown className="sequence-navigation-dropdown">
<Dropdown.Button className="dropdown-button font-weight-normal w-100 border-right-0">
<FormattedMessage
defaultMessage="{current} of {total}"
description="The title of the mobile menu for sequence navigation of units"
id="learn.course.sequence.navigation.mobile.menu"
values={{
current: unitIds.indexOf(unitId) + 1,
total: unitIds.length,
}}
/>
</Dropdown.Button>
<Dropdown.Menu className="w-100">
{unitIds.map(buttonUnitId => (
<UnitButton
className="w-100"
isActive={unitId === buttonUnitId}
key={buttonUnitId}
onClick={onNavigate}
showCompletion={showCompletion}
showTitle
unitId={buttonUnitId}
/>
))}
</Dropdown.Menu>
</Dropdown>
);
}
SequenceNavigationDropdown.propTypes = {
unitId: PropTypes.string.isRequired,
onNavigate: PropTypes.func.isRequired,
showCompletion: PropTypes.bool.isRequired,
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
};

View File

@@ -0,0 +1,53 @@
import React from 'react';
import PropTypes from 'prop-types';
import UnitButton from './UnitButton';
import SequenceNavigationDropdown from './SequenceNavigationDropdown';
import useIndexOfLastVisibleChild from '../../../../tabs/useIndexOfLastVisibleChild';
export default function SequenceNavigationTabs({
unitIds, unitId, showCompletion, onNavigate,
}) {
const [
indexOfLastVisibleChild,
containerRef,
invisibleStyle,
] = useIndexOfLastVisibleChild();
const shouldDisplayDropdown = indexOfLastVisibleChild === -1;
return (
<div style={{ flexBasis: '100%', minWidth: 0 }}>
<div className="sequence-navigation-tabs-container" ref={containerRef}>
<div
className="sequence-navigation-tabs d-flex flex-grow-1"
style={shouldDisplayDropdown ? invisibleStyle : null}
>
{unitIds.map(buttonUnitId => (
<UnitButton
key={buttonUnitId}
unitId={buttonUnitId}
isActive={unitId === buttonUnitId}
showCompletion={showCompletion}
onClick={onNavigate}
/>
))}
</div>
</div>
{shouldDisplayDropdown && (
<SequenceNavigationDropdown
unitId={unitId}
onNavigate={onNavigate}
showCompletion={showCompletion}
unitIds={unitIds}
/>
)}
</div>
);
}
SequenceNavigationTabs.propTypes = {
unitId: PropTypes.string.isRequired,
onNavigate: PropTypes.func.isRequired,
showCompletion: PropTypes.bool.isRequired,
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
};

View File

@@ -0,0 +1,75 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import classNames from 'classnames';
import { Button } from '@edx/paragon';
import UnitIcon from './UnitIcon';
import CompleteIcon from './CompleteIcon';
import BookmarkFilledIcon from '../../bookmark/BookmarkFilledIcon';
function UnitButton({
onClick,
title,
contentType,
isActive,
bookmarked,
complete,
showCompletion,
unitId,
className,
showTitle,
}) {
const handleClick = useCallback(() => {
onClick(unitId);
});
return (
<Button
className={classNames({
active: isActive,
complete: showCompletion && complete,
}, className)}
onClick={handleClick}
title={title}
>
<UnitIcon type={contentType} />
{showTitle && <span className="unit-title">{title}</span>}
{showCompletion && complete ? <CompleteIcon size="sm" className="text-success ml-2" /> : null}
{bookmarked ? (
<BookmarkFilledIcon
className="text-primary small position-absolute"
style={{ top: '-3px', right: '5px' }}
/>
) : null}
</Button>
);
}
UnitButton.propTypes = {
bookmarked: PropTypes.bool,
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 = {
className: undefined,
isActive: false,
bookmarked: false,
complete: false,
showTitle: false,
showCompletion: true,
};
const mapStateToProps = (state, props) => ({
...state.models.units[props.unitId],
});
export default connect(mapStateToProps)(UnitButton);

View File

@@ -0,0 +1,37 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faFilm, faBook, faEdit, 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 = faEdit;
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,66 @@
import React from 'react';
import PropTypes from 'prop-types';
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 {
sequenceId,
unitId,
onClickPrevious,
onClickNext,
} = props;
const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId);
return (
<div className="unit-navigation d-flex">
<Button
className="btn-outline-secondary previous-button mr-2"
disabled={isFirstUnit}
onClick={onClickPrevious}
>
<FontAwesomeIcon icon={faChevronLeft} className="mr-2" size="sm" />
<FormattedMessage
id="learn.sequence.navigation.after.unit.previous"
description="The button to go to the previous unit"
defaultMessage="Previous"
/>
</Button>
{isLastUnit ? (
<div className="m-2">
<span role="img" aria-hidden="true">&#129303;</span> {/* This is a hugging face emoji */}
{' '}
<FormattedMessage
id="learn.end.of.course"
description="Message shown to students in place of a 'Next' button when they're at the end of a course."
defaultMessage="You've reached the end of this course!"
/>
</div>
) : (
<Button
className="btn-outline-primary next-button"
onClick={onClickNext}
disabled={isLastUnit}
>
<FormattedMessage
id="learn.sequence.navigation.after.unit.next"
description="The button to go to the next unit"
defaultMessage="Next"
/>
<FontAwesomeIcon icon={faChevronRight} className="ml-2" size="sm" />
</Button>
)}
</div>
);
}
UnitNavigation.propTypes = {
sequenceId: PropTypes.string.isRequired,
unitId: PropTypes.string.isRequired,
onClickPrevious: PropTypes.func.isRequired,
onClickNext: PropTypes.func.isRequired,
};

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

View File

@@ -0,0 +1,2 @@
export { default as SequenceNavigation } from './SequenceNavigation';
export { default as UnitNavigation } from './UnitNavigation';