Files
frontend-app-learning/src/courseware/course/sequence/Sequence.jsx
Kristin Aoki d47433ee83 feat: add functionality to see unit draft preview (#1501)
* feat: add functionality to see unit draft preview

* feat: add tests for course link redirects

* fix: course redirect unit to sequnce unit redirect

* fix: test coverage
2024-10-28 10:31:17 -04:00

259 lines
8.5 KiB
JavaScript

/* eslint-disable @typescript-eslint/no-use-before-define */
import { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import {
sendTrackEvent,
sendTrackingLogEvent,
} from '@edx/frontend-platform/analytics';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import SequenceExamWrapper from '@edx/frontend-lib-special-exams';
import { useToggle } from '@openedx/paragon';
import PageLoading from '@src/generic/PageLoading';
import { useModel } from '@src/generic/model-store';
import { useSequenceBannerTextAlert, useSequenceEntranceExamAlert } from '@src/alerts/sequence-alerts/hooks';
import SequenceContainerSlot from '../../../plugin-slots/SequenceContainerSlot';
import { getCoursewareOutlineSidebarSettings } from '../../data/selectors';
import CourseLicense from '../course-license';
import Sidebar from '../sidebar/Sidebar';
import NewSidebar from '../new-sidebar/Sidebar';
import {
Trigger as CourseOutlineTrigger,
Sidebar as CourseOutlineTray,
} from '../sidebar/sidebars/course-outline';
import messages from './messages';
import HiddenAfterDue from './hidden-after-due';
import { SequenceNavigation, UnitNavigation } from './sequence-navigation';
import SequenceContent from './SequenceContent';
const Sequence = ({
unitId,
sequenceId,
courseId,
unitNavigationHandler,
nextSequenceHandler,
previousSequenceHandler,
}) => {
const intl = useIntl();
const [isOpen, open, close] = useToggle();
const {
canAccessProctoredExams,
license,
} = useModel('coursewareMeta', courseId);
const {
isStaff,
originalUserIsStaff,
isNewDiscussionSidebarViewEnabled,
} = useModel('courseHomeMeta', courseId);
const sequence = useModel('sequences', sequenceId);
const unit = useModel('units', unitId);
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
const sequenceMightBeUnit = useSelector(state => state.courseware.sequenceMightBeUnit);
const { enableNavigationSidebar: isEnabledOutlineSidebar } = useSelector(getCoursewareOutlineSidebarSettings);
const handleNext = () => {
const nextIndex = sequence.unitIds.indexOf(unitId) + 1;
const newUnitId = sequence.unitIds[nextIndex];
handleNavigate(newUnitId);
if (nextIndex >= sequence.unitIds.length) {
nextSequenceHandler();
}
};
const handlePrevious = () => {
const previousIndex = sequence.unitIds.indexOf(unitId) - 1;
const newUnitId = sequence.unitIds[previousIndex];
handleNavigate(newUnitId);
if (previousIndex < 0) {
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.length > 0 ? sequence.unitIds.indexOf(unitId) : 0;
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);
sendTrackingLogEvent(eventName, payload);
};
useSequenceBannerTextAlert(sequenceId);
useSequenceEntranceExamAlert(courseId, sequenceId, intl);
useEffect(() => {
function receiveMessage(event) {
const { type } = event.data;
if (type === 'entranceExam.passed') {
// I know this seems (is) intense. It is implemented this way since we need to refetch the underlying
// course blocks that were originally hidden because the Entrance Exam was not passed.
global.location.reload();
}
}
global.addEventListener('message', receiveMessage);
}, []);
const [unitHasLoaded, setUnitHasLoaded] = useState(false);
const handleUnitLoaded = () => {
setUnitHasLoaded(true);
};
// We want hide the unit navigation if we're in the middle of navigating to another unit
// but not if other things about the unit change, like the bookmark status.
// The array property of this useEffect ensures that we only hide the unit navigation
// while navigating to another unit.
useEffect(() => {
if (unit) {
setUnitHasLoaded(false);
}
}, [(unit || {}).id]);
// If sequence might be a unit, we want to keep showing a spinner - the courseware container will redirect us when
// it knows which sequence to actually go to.
const loading = sequenceStatus === 'loading' || (sequenceStatus === 'failed' && sequenceMightBeUnit);
if (loading) {
if (!sequenceId) {
return (<div> {intl.formatMessage(messages.noContent)} </div>);
}
return (
<PageLoading
srMessage={intl.formatMessage(messages.loadingSequence)}
/>
);
}
if (sequenceStatus === 'loaded' && sequence.isHiddenAfterDue) {
// Shouldn't even be here - these sequences are normally stripped out of the navigation.
// But we are here, so render a notice instead of the normal content.
return <HiddenAfterDue courseId={courseId} />;
}
const gated = sequence && sequence.gatedContent !== undefined && sequence.gatedContent.gated;
const renderUnitNavigation = (isAtTop) => (
<UnitNavigation
sequenceId={sequenceId}
unitId={unitId}
isAtTop={isAtTop}
onClickPrevious={() => {
logEvent('edx.ui.lms.sequence.previous_selected', 'bottom');
handlePrevious();
}}
onClickNext={() => {
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
handleNext();
}}
/>
);
const defaultContent = (
<>
<div className="sequence-container d-inline-flex flex-row w-100">
<CourseOutlineTrigger />
<CourseOutlineTray />
<div className="sequence w-100">
{!isEnabledOutlineSidebar && (
<div className="sequence-navigation-container">
<SequenceNavigation
sequenceId={sequenceId}
unitId={unitId}
nextHandler={() => {
logEvent('edx.ui.lms.sequence.next_selected', 'top');
handleNext();
}}
onNavigate={(destinationUnitId) => {
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
handleNavigate(destinationUnitId);
}}
previousHandler={() => {
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
handlePrevious();
}}
{...{
nextSequenceHandler,
handleNavigate,
isOpen,
open,
close,
}}
/>
</div>
)}
<div className="unit-container flex-grow-1 pt-4">
<SequenceContent
courseId={courseId}
gated={gated}
sequenceId={sequenceId}
unitId={unitId}
unitLoadedHandler={handleUnitLoaded}
isStaff={isStaff}
/>
{unitHasLoaded && renderUnitNavigation(false)}
</div>
</div>
{isNewDiscussionSidebarViewEnabled ? <NewSidebar /> : <Sidebar />}
</div>
<SequenceContainerSlot courseId={courseId} unitId={unitId} />
</>
);
if (sequenceStatus === 'loaded') {
return (
<div>
<SequenceExamWrapper
sequence={sequence}
courseId={courseId}
isStaff={isStaff}
originalUserIsStaff={originalUserIsStaff}
canAccessProctoredExams={canAccessProctoredExams}
>
{isEnabledOutlineSidebar && renderUnitNavigation(true)}
{defaultContent}
</SequenceExamWrapper>
<CourseLicense license={license || undefined} />
</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.loadFailure)}
</p>
);
};
Sequence.propTypes = {
unitId: PropTypes.string,
sequenceId: PropTypes.string,
courseId: PropTypes.string.isRequired,
unitNavigationHandler: PropTypes.func.isRequired,
nextSequenceHandler: PropTypes.func.isRequired,
previousSequenceHandler: PropTypes.func.isRequired,
};
Sequence.defaultProps = {
sequenceId: null,
unitId: null,
};
export default Sequence;