feat: [FC-0044] Course unit page - Unit switch widget with a New unit creation button (#809)
* feat: added Unit switch widget with a New unit button * refactor: refactoring after review * refactor: changed the variable name
This commit is contained in:
@@ -19,6 +19,7 @@ import { CourseUpdates } from './course-updates';
|
||||
import { CourseUnit } from './course-unit';
|
||||
import CourseExportPage from './export-page/CourseExportPage';
|
||||
import CourseImportPage from './import-page/CourseImportPage';
|
||||
import { DECODED_ROUTES } from './constants';
|
||||
|
||||
/**
|
||||
* As of this writing, these routes are mounted at a path prefixed with the following:
|
||||
@@ -70,10 +71,12 @@ const CourseAuthoringRoutes = () => {
|
||||
path="custom-pages/*"
|
||||
element={<PageWrap><CustomPages courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
<Route
|
||||
path="/container/:blockId"
|
||||
element={<PageWrap><CourseUnit courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
|
||||
<Route
|
||||
path={path}
|
||||
element={<PageWrap><CourseUnit courseId={courseId} /></PageWrap>}
|
||||
/>
|
||||
))}
|
||||
<Route
|
||||
path="editor/course-videos/:blockId"
|
||||
element={getConfig().ENABLE_NEW_EDITOR_PAGES === 'true' ? <PageWrap><VideoSelectorContainer courseId={courseId} /></PageWrap> : null}
|
||||
|
||||
@@ -37,3 +37,10 @@ export const COURSE_CREATOR_STATES = {
|
||||
denied: 'denied',
|
||||
disallowedForThisSite: 'disallowed_for_this_site',
|
||||
};
|
||||
|
||||
export const DECODED_ROUTES = {
|
||||
COURSE_UNIT: [
|
||||
'/container/:blockId/:sequenceId',
|
||||
'/container/:blockId',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -11,19 +11,20 @@ import { RequestStatus } from '../data/constants';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
import ProcessingNotification from '../generic/processing-notification';
|
||||
import InternetConnectionAlert from '../generic/internet-connection-alert';
|
||||
import Loading from '../generic/Loading';
|
||||
import HeaderTitle from './header-title/HeaderTitle';
|
||||
import Breadcrumbs from './breadcrumbs/Breadcrumbs';
|
||||
import HeaderNavigations from './header-navigations/HeaderNavigations';
|
||||
import Sequence from './course-sequence';
|
||||
import { useCourseUnit } from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
import './CourseUnit.scss';
|
||||
|
||||
const CourseUnit = ({ courseId }) => {
|
||||
const { blockId } = useParams();
|
||||
const intl = useIntl();
|
||||
const {
|
||||
isLoading,
|
||||
sequenceId,
|
||||
unitTitle,
|
||||
savingStatus,
|
||||
isTitleEditFormOpen,
|
||||
@@ -42,7 +43,7 @@ const CourseUnit = ({ courseId }) => {
|
||||
} = useSelector(getProcessingNotification);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -73,6 +74,11 @@ const CourseUnit = ({ courseId }) => {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Sequence
|
||||
courseId={courseId}
|
||||
sequenceId={sequenceId}
|
||||
unitId={blockId}
|
||||
/>
|
||||
<Layout
|
||||
lg={[{ span: 9 }, { span: 3 }]}
|
||||
md={[{ span: 9 }, { span: 3 }]}
|
||||
@@ -80,8 +86,10 @@ const CourseUnit = ({ courseId }) => {
|
||||
xs={[{ span: 9 }, { span: 3 }]}
|
||||
xl={[{ span: 9 }, { span: 3 }]}
|
||||
>
|
||||
<Layout.Element />
|
||||
<Layout.Element />
|
||||
<Layout.Element>
|
||||
{/* TODO: Unit content will be added in the following tasks. */}
|
||||
Unit content
|
||||
</Layout.Element>
|
||||
</Layout>
|
||||
</section>
|
||||
</Container>
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
@import "./breadcrumbs/Breadcrumbs";
|
||||
@import "./course-sequence/CourseSequence";
|
||||
|
||||
17
src/course-unit/constants.js
Normal file
17
src/course-unit/constants.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import {
|
||||
BookOpen as BookOpenIcon,
|
||||
Edit as EditIcon,
|
||||
FormatListBulleted as FormatListBulletedIcon,
|
||||
Lock as LockIcon,
|
||||
VideoCamera as VideoCameraIcon,
|
||||
} from '@edx/paragon/icons';
|
||||
|
||||
export const UNIT_ICON_TYPES = ['video', 'other', 'vertical', 'problem', 'lock'];
|
||||
|
||||
export const TYPE_ICONS_MAP = {
|
||||
video: VideoCameraIcon,
|
||||
other: BookOpenIcon,
|
||||
vertical: FormatListBulletedIcon,
|
||||
problem: EditIcon,
|
||||
lock: LockIcon,
|
||||
};
|
||||
83
src/course-unit/course-sequence/CourseSequence.scss
Normal file
83
src/course-unit/course-sequence/CourseSequence.scss
Normal file
@@ -0,0 +1,83 @@
|
||||
.sequence-container {
|
||||
margin-bottom: 1.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sequence-load-failure-msg {
|
||||
max-width: 30em;
|
||||
}
|
||||
|
||||
.sequence-navigation {
|
||||
.btn {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
color: $gray-700;
|
||||
|
||||
&.btn-primary {
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.sequence-navigation-tabs-wrapper {
|
||||
flex-basis: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sequence-navigation-tabs-container {
|
||||
flex: 1 1 100%;
|
||||
// min-width 0 prevents the flex item from overflowing the parent container
|
||||
// https://dev.to/martyhimmel/quick-tip-to-stop-flexbox-from-overflowing-peb
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sequence-navigation-tabs .btn:not(.sequence-navigation-tabs-new-unit-btn) {
|
||||
flex-basis: 100%;
|
||||
min-width: 2rem;
|
||||
}
|
||||
|
||||
.sequence-navigation-dropdown {
|
||||
.dropdown-menu .btn {
|
||||
flex-basis: 100%;
|
||||
min-width: 4rem;
|
||||
|
||||
.unit-title {
|
||||
flex-grow: 1;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
margin: map-get($spacers, 0) $spacer;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&.btn-primary {
|
||||
background-color: $primary-500;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sequence-navigation-prev-btn,
|
||||
.sequence-navigation-next-btn,
|
||||
.sequence-navigation-tabs-new-unit-btn {
|
||||
min-width: 12.5rem;
|
||||
}
|
||||
|
||||
.sequence-navigation-prev-btn,
|
||||
.sequence-navigation-next-btn {
|
||||
@media (max-width: -1 + map-get($grid-breakpoints, "sm")) {
|
||||
min-width: fit-content;
|
||||
padding-top: $spacer;
|
||||
padding-bottom: $spacer;
|
||||
}
|
||||
|
||||
@media (min-width: map-get($grid-breakpoints, "sm")) {
|
||||
padding-left: map-get($spacers, 4\.5);
|
||||
padding-right: map-get($spacers, 4\.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
68
src/course-unit/course-sequence/Sequence.jsx
Normal file
68
src/course-unit/course-sequence/Sequence.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
import classNames from 'classnames';
|
||||
import { breakpoints, useWindowSize } from '@edx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import Loading from '../../generic/Loading';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import SequenceNavigation from './sequence-navigation/SequenceNavigation';
|
||||
import messages from './messages';
|
||||
|
||||
const Sequence = ({
|
||||
courseId,
|
||||
sequenceId,
|
||||
unitId,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { IN_PROGRESS, FAILED, SUCCESSFUL } = RequestStatus;
|
||||
const shouldDisplayNotificationTriggerInSequence = useWindowSize().width < breakpoints.small.minWidth;
|
||||
const { sequenceStatus, sequenceMightBeUnit } = useSelector(state => state.courseUnit);
|
||||
|
||||
const defaultContent = (
|
||||
<div className="sequence-container d-inline-flex flex-row">
|
||||
<div className={classNames('sequence w-100', { 'position-relative': shouldDisplayNotificationTriggerInSequence })}>
|
||||
<SequenceNavigation
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
courseId={courseId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 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 isLoading = sequenceStatus === IN_PROGRESS || (sequenceStatus === FAILED && sequenceMightBeUnit);
|
||||
if (isLoading) {
|
||||
if (!sequenceId) {
|
||||
return (<div>{intl.formatMessage(messages.sequenceNoContent)}</div>);
|
||||
}
|
||||
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (sequenceStatus === SUCCESSFUL) {
|
||||
return defaultContent;
|
||||
}
|
||||
|
||||
// sequence status 'failed' and any other unexpected sequence status.
|
||||
return (
|
||||
<p className="sequence-load-failure-msg text-center py-5 mx-auto">
|
||||
{intl.formatMessage(messages.sequenceLoadFailure)}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
Sequence.propTypes = {
|
||||
unitId: PropTypes.string,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
sequenceId: PropTypes.string,
|
||||
};
|
||||
|
||||
Sequence.defaultProps = {
|
||||
sequenceId: null,
|
||||
unitId: null,
|
||||
};
|
||||
|
||||
export default Sequence;
|
||||
139
src/course-unit/course-sequence/hooks.js
Normal file
139
src/course-unit/course-sequence/hooks.js
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useWindowSize } from '@edx/paragon';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { getCourseSectionVertical, getSequenceStatus, sequenceIdsSelector } from '../data/selectors';
|
||||
|
||||
export function useSequenceNavigationMetadata(currentSequenceId, currentUnitId) {
|
||||
const { SUCCESSFUL } = RequestStatus;
|
||||
const sequenceIds = useSelector(sequenceIdsSelector);
|
||||
const sequenceStatus = useSelector(getSequenceStatus);
|
||||
const { nextUrl, prevUrl } = useSelector(getCourseSectionVertical);
|
||||
const sequence = useModel('sequences', currentSequenceId);
|
||||
const { courseId, status } = useSelector(state => state.courseDetail);
|
||||
|
||||
const isCourseOrSequenceNotSuccessful = status !== SUCCESSFUL || sequenceStatus !== SUCCESSFUL;
|
||||
const areIdsNotValid = !currentSequenceId || !currentUnitId || !sequence.unitIds;
|
||||
const isNotSuccessfulCompletion = isCourseOrSequenceNotSuccessful || areIdsNotValid;
|
||||
|
||||
// If we don't know the sequence and unit yet, then assume no.
|
||||
if (isNotSuccessfulCompletion) {
|
||||
return { isFirstUnit: false, isLastUnit: false };
|
||||
}
|
||||
|
||||
const sequenceIndex = sequenceIds.indexOf(currentSequenceId);
|
||||
const unitIndex = sequence.unitIds.indexOf(currentUnitId);
|
||||
|
||||
const isFirstSequence = sequenceIndex === 0;
|
||||
const isFirstUnitInSequence = unitIndex === 0;
|
||||
const isFirstUnit = isFirstSequence && isFirstUnitInSequence;
|
||||
const isLastSequence = sequenceIndex === sequenceIds.length - 1;
|
||||
const isLastUnitInSequence = unitIndex === sequence.unitIds.length - 1;
|
||||
const isLastUnit = isLastSequence && isLastUnitInSequence;
|
||||
|
||||
const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null;
|
||||
const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null;
|
||||
|
||||
let nextLink;
|
||||
const nextIndex = unitIndex + 1;
|
||||
|
||||
if (nextIndex < sequence.unitIds.length) {
|
||||
const nextUnitId = sequence.unitIds[nextIndex];
|
||||
nextLink = `/course/${courseId}/container/${nextUnitId}/${currentSequenceId}`;
|
||||
} else if (nextSequenceId) {
|
||||
const pathToNextUnit = decodeURIComponent(nextUrl);
|
||||
nextLink = `/course/${courseId}${pathToNextUnit}/${nextSequenceId}`;
|
||||
}
|
||||
|
||||
let previousLink;
|
||||
const previousIndex = unitIndex - 1;
|
||||
|
||||
if (previousIndex >= 0) {
|
||||
const previousUnitId = sequence.unitIds[previousIndex];
|
||||
previousLink = `/course/${courseId}/container/${previousUnitId}/${currentSequenceId}`;
|
||||
} else if (previousSequenceId) {
|
||||
const pathToPreviousUnit = decodeURIComponent(prevUrl);
|
||||
previousLink = `/course/${courseId}${pathToPreviousUnit}/${previousSequenceId}`;
|
||||
}
|
||||
|
||||
return {
|
||||
isFirstUnit, isLastUnit, nextLink, previousLink,
|
||||
};
|
||||
}
|
||||
|
||||
const invisibleStyle = {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
pointerEvents: 'none',
|
||||
visibility: 'hidden',
|
||||
};
|
||||
|
||||
/**
|
||||
* This hook will find the index of the last child of a containing element
|
||||
* that fits within its bounding rectangle. This is done by summing the widths
|
||||
* of the children until they exceed the width of the container.
|
||||
*
|
||||
* The hook returns an array containing:
|
||||
* [indexOfLastVisibleChild, containerElementRef, invisibleStyle, overflowElementRef]
|
||||
*
|
||||
* indexOfLastVisibleChild - the index of the last visible child
|
||||
* containerElementRef - a ref to be added to the containing html node
|
||||
* invisibleStyle - a set of styles to be applied to child of the containing node
|
||||
* if it needs to be hidden. These styles remove the element visually, from
|
||||
* screen readers, and from normal layout flow. But, importantly, these styles
|
||||
* preserve the width of the element, so that future width calculations will
|
||||
* still be accurate.
|
||||
* overflowElementRef - a ref to be added to an html node inside the container
|
||||
* that is likely to be used to contain a "More" type dropdown or other
|
||||
* mechanism to reveal hidden children. The width of this element is always
|
||||
* included when determining which children will fit or not. Usage of this ref
|
||||
* is optional.
|
||||
*/
|
||||
export function useIndexOfLastVisibleChild() {
|
||||
const containerElementRef = useRef(null);
|
||||
const overflowElementRef = useRef(null);
|
||||
const containingRectRef = useRef({});
|
||||
const [indexOfLastVisibleChild, setIndexOfLastVisibleChild] = useState(-1);
|
||||
const windowSize = useWindowSize();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const containingRect = containerElementRef.current.getBoundingClientRect();
|
||||
|
||||
// No-op if the width is unchanged.
|
||||
// (Assumes tabs themselves don't change count or width).
|
||||
if (!containingRect.width === containingRectRef.current.width) {
|
||||
return;
|
||||
}
|
||||
// Update for future comparison
|
||||
containingRectRef.current = containingRect;
|
||||
|
||||
// Get array of child nodes from NodeList form
|
||||
const childNodesArr = Array.prototype.slice.call(containerElementRef.current.children);
|
||||
const { nextIndexOfLastVisibleChild } = childNodesArr
|
||||
// filter out the overflow element
|
||||
.filter(childNode => childNode !== overflowElementRef.current)
|
||||
// sum the widths to find the last visible element's index
|
||||
.reduce((acc, childNode, index) => {
|
||||
// use floor to prevent rounding errors
|
||||
acc.sumWidth += Math.floor(childNode.getBoundingClientRect().width);
|
||||
if (acc.sumWidth <= containingRect.width) {
|
||||
acc.nextIndexOfLastVisibleChild = index;
|
||||
}
|
||||
return acc;
|
||||
}, {
|
||||
// Include the overflow element's width to begin with. Doing this means
|
||||
// sometimes we'll show a dropdown with one item in it when it would fit,
|
||||
// but allowing this case dramatically simplifies the calculations we need
|
||||
// to do above.
|
||||
sumWidth: overflowElementRef.current ? overflowElementRef.current.getBoundingClientRect().width : 0,
|
||||
nextIndexOfLastVisibleChild: -1,
|
||||
});
|
||||
|
||||
setIndexOfLastVisibleChild(nextIndexOfLastVisibleChild);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [windowSize, containerElementRef.current]);
|
||||
|
||||
return [indexOfLastVisibleChild, containerElementRef, invisibleStyle, overflowElementRef];
|
||||
}
|
||||
1
src/course-unit/course-sequence/index.jsx
Normal file
1
src/course-unit/course-sequence/index.jsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Sequence';
|
||||
34
src/course-unit/course-sequence/messages.js
Normal file
34
src/course-unit/course-sequence/messages.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
prevBtnText: {
|
||||
id: 'course-authoring.course-unit.prev-btn-text',
|
||||
defaultMessage: 'Previous',
|
||||
},
|
||||
nextBtnText: {
|
||||
id: 'course-authoring.course-unit.next-btn-text',
|
||||
defaultMessage: 'Next',
|
||||
},
|
||||
newUnitBtnText: {
|
||||
id: 'course-authoring.course-unit.new-unit-btn-text',
|
||||
defaultMessage: 'New unit',
|
||||
},
|
||||
sequenceNavLabelText: {
|
||||
id: 'course-authoring.course-unit.sequence-nav-label-text',
|
||||
defaultMessage: 'Sequence navigation',
|
||||
},
|
||||
sequenceLoadFailure: {
|
||||
id: 'course-authoring.course-unit.sequence.load.failure',
|
||||
defaultMessage: 'There was an error loading this course.',
|
||||
},
|
||||
sequenceNoContent: {
|
||||
id: 'course-authoring.course-unit.sequence.no.content',
|
||||
defaultMessage: 'There is no content here.',
|
||||
},
|
||||
sequenceDropdownTitle: {
|
||||
id: 'course-authoring.course-unit.sequence.navigation.menu',
|
||||
defaultMessage: '{current} of {total}',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
injectIntl, intlShape, isRtl, getLocale,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { Button, useWindowSize, breakpoints } from '@edx/paragon';
|
||||
import {
|
||||
ChevronLeft as ChevronLeftIcon,
|
||||
ChevronRight as ChevronRightIcon,
|
||||
} from '@edx/paragon/icons';
|
||||
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
import { getSequenceStatus } from '../../data/selectors';
|
||||
import { useSequenceNavigationMetadata } from '../hooks';
|
||||
import messages from '../messages';
|
||||
import SequenceNavigationTabs from './SequenceNavigationTabs';
|
||||
|
||||
const SequenceNavigation = ({
|
||||
intl,
|
||||
unitId,
|
||||
sequenceId,
|
||||
className,
|
||||
}) => {
|
||||
const sequenceStatus = useSelector(getSequenceStatus);
|
||||
const {
|
||||
isFirstUnit, isLastUnit, nextLink, previousLink,
|
||||
} = useSequenceNavigationMetadata(sequenceId, unitId);
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
|
||||
const shouldDisplayNotificationTriggerInSequence = useWindowSize().width < breakpoints.small.minWidth;
|
||||
const renderUnitButtons = () => {
|
||||
if (sequence.unitIds?.length === 0 || unitId === null) {
|
||||
return (
|
||||
<div style={{ flexBasis: '100%', minWidth: 0, borderBottom: 'solid 1px #EAEAEA' }} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SequenceNavigationTabs
|
||||
unitIds={sequence.unitIds || []}
|
||||
unitId={unitId}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPreviousButton = () => {
|
||||
const buttonText = intl.formatMessage(messages.prevBtnText);
|
||||
const prevArrow = isRtl(getLocale()) ? ChevronRightIcon : ChevronLeftIcon;
|
||||
|
||||
if (!isFirstUnit) {
|
||||
return (
|
||||
<Button
|
||||
className="sequence-navigation-prev-btn"
|
||||
variant="outline-primary"
|
||||
iconBefore={prevArrow}
|
||||
as={Link}
|
||||
to={previousLink}
|
||||
>
|
||||
{shouldDisplayNotificationTriggerInSequence ? null : buttonText}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderNextButton = () => {
|
||||
const buttonText = intl.formatMessage(messages.nextBtnText);
|
||||
const nextArrow = isRtl(getLocale()) ? ChevronLeftIcon : ChevronRightIcon;
|
||||
|
||||
if (!isLastUnit) {
|
||||
return (
|
||||
<Button
|
||||
className="sequence-navigation-next-btn"
|
||||
variant="outline-primary"
|
||||
iconAfter={nextArrow}
|
||||
as={Link}
|
||||
to={nextLink}
|
||||
>
|
||||
{shouldDisplayNotificationTriggerInSequence ? null : buttonText}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return sequenceStatus === RequestStatus.SUCCESSFUL && (
|
||||
<nav
|
||||
className={classNames('sequence-navigation d-flex', className)}
|
||||
style={{ width: shouldDisplayNotificationTriggerInSequence ? '90%' : null }}
|
||||
>
|
||||
{renderPreviousButton()}
|
||||
{renderUnitButtons()}
|
||||
{renderNextButton()}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
SequenceNavigation.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
unitId: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
sequenceId: PropTypes.string,
|
||||
};
|
||||
|
||||
SequenceNavigation.defaultProps = {
|
||||
sequenceId: null,
|
||||
unitId: null,
|
||||
className: undefined,
|
||||
};
|
||||
|
||||
export default injectIntl(SequenceNavigation);
|
||||
@@ -0,0 +1,40 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { Dropdown } from '@edx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from '../messages';
|
||||
import UnitButton from './UnitButton';
|
||||
|
||||
const SequenceNavigationDropdown = ({ unitId, unitIds }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<Dropdown className="sequence-navigation-dropdown">
|
||||
<Dropdown.Toggle variant="outline-primary" className="w-100">
|
||||
{intl.formatMessage(messages.sequenceDropdownTitle, {
|
||||
current: unitIds.indexOf(unitId) + 1,
|
||||
total: unitIds.length,
|
||||
})}
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="w-100">
|
||||
{unitIds.map(buttonUnitId => (
|
||||
<Dropdown.Item
|
||||
as={UnitButton}
|
||||
className="w-100"
|
||||
isActive={unitId === buttonUnitId}
|
||||
key={buttonUnitId}
|
||||
showTitle
|
||||
unitId={buttonUnitId}
|
||||
/>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
SequenceNavigationDropdown.propTypes = {
|
||||
unitId: PropTypes.string.isRequired,
|
||||
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
};
|
||||
|
||||
export default SequenceNavigationDropdown;
|
||||
@@ -0,0 +1,62 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '@edx/paragon';
|
||||
import { Plus as PlusIcon } from '@edx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useIndexOfLastVisibleChild } from '../hooks';
|
||||
import messages from '../messages';
|
||||
import SequenceNavigationDropdown from './SequenceNavigationDropdown';
|
||||
import UnitButton from './UnitButton';
|
||||
|
||||
const SequenceNavigationTabs = ({ unitIds, unitId }) => {
|
||||
const intl = useIntl();
|
||||
const [
|
||||
indexOfLastVisibleChild,
|
||||
containerRef,
|
||||
invisibleStyle,
|
||||
] = useIndexOfLastVisibleChild();
|
||||
const shouldDisplayDropdown = indexOfLastVisibleChild === -1;
|
||||
|
||||
return (
|
||||
<div className="sequence-navigation-tabs-wrapper">
|
||||
<div className="sequence-navigation-tabs-container d-flex" 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}
|
||||
/>
|
||||
))}
|
||||
{/* TODO: The functionality of the New unit button will be implemented in https://youtrack.raccoongang.com/issue/AXIMST-14 */}
|
||||
<Button
|
||||
className="sequence-navigation-tabs-new-unit-btn disabled"
|
||||
variant="outline-primary"
|
||||
iconBefore={PlusIcon}
|
||||
as={Link}
|
||||
to="/"
|
||||
>
|
||||
{intl.formatMessage(messages.newUnitBtnText)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{shouldDisplayDropdown && (
|
||||
<SequenceNavigationDropdown
|
||||
unitId={unitId}
|
||||
unitIds={unitIds}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SequenceNavigationTabs.propTypes = {
|
||||
unitId: PropTypes.string.isRequired,
|
||||
unitIds: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
};
|
||||
|
||||
export default SequenceNavigationTabs;
|
||||
@@ -0,0 +1,52 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect, useSelector } from 'react-redux';
|
||||
import { Button } from '@edx/paragon';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import UnitIcon from './UnitIcon';
|
||||
|
||||
const UnitButton = ({
|
||||
title, contentType, isActive, unitId, className, showTitle,
|
||||
}) => {
|
||||
const courseId = useSelector(state => state.courseUnit.courseId);
|
||||
const sequenceId = useSelector(state => state.courseUnit.sequenceId);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
variant={isActive ? 'primary' : 'outline-primary'}
|
||||
as={Link}
|
||||
title={title}
|
||||
to={`/course/${courseId}/container/${unitId}/${sequenceId}/`}
|
||||
>
|
||||
<UnitIcon type={contentType} />
|
||||
{showTitle && <span className="unit-title">{title}</span>}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
UnitButton.propTypes = {
|
||||
className: PropTypes.string,
|
||||
contentType: PropTypes.string.isRequired,
|
||||
isActive: PropTypes.bool,
|
||||
showTitle: PropTypes.bool,
|
||||
title: PropTypes.string.isRequired,
|
||||
unitId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
UnitButton.defaultProps = {
|
||||
className: undefined,
|
||||
isActive: false,
|
||||
showTitle: false,
|
||||
};
|
||||
|
||||
const mapStateToProps = (state, props) => {
|
||||
if (props.unitId) {
|
||||
return {
|
||||
...state.models.units[props.unitId],
|
||||
};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(UnitButton);
|
||||
@@ -0,0 +1,17 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { Icon } from '@edx/paragon';
|
||||
import { BookOpen as BookOpenIcon } from '@edx/paragon/icons';
|
||||
|
||||
import { TYPE_ICONS_MAP, UNIT_ICON_TYPES } from '../../constants';
|
||||
|
||||
const UnitIcon = ({ type }) => {
|
||||
const icon = TYPE_ICONS_MAP[type] || BookOpenIcon;
|
||||
|
||||
return <Icon src={icon} screenReaderText={type} />;
|
||||
};
|
||||
|
||||
UnitIcon.propTypes = {
|
||||
type: PropTypes.oneOf(UNIT_ICON_TYPES).isRequired,
|
||||
};
|
||||
|
||||
export default UnitIcon;
|
||||
@@ -2,11 +2,24 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
import {
|
||||
normalizeLearningSequencesData,
|
||||
normalizeSequenceMetadata,
|
||||
normalizeMetadata,
|
||||
normalizeCourseHomeCourseMetadata,
|
||||
appendBrowserTimezoneToUrl,
|
||||
} from './utils';
|
||||
|
||||
export const getCourseUnitApiUrl = (itemId) => `${getApiBaseUrl()}/xblock/container/${itemId}`;
|
||||
const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
const getLmsBaseUrl = () => getConfig().LMS_BASE_URL;
|
||||
|
||||
export const getXBlockBaseApiUrl = (itemId) => `${getApiBaseUrl()}/xblock/${itemId}`;
|
||||
export const getCourseUnitApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/container/${itemId}`;
|
||||
export const getXBlockBaseApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/${itemId}`;
|
||||
export const getCourseSectionVerticalApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container_handler/${itemId}`;
|
||||
export const getSequenceMetadataApiUrl = (sequenceId) => `${getLmsBaseUrl()}/api/courseware/sequence/${sequenceId}`;
|
||||
export const getLearningSequencesOutlineApiUrl = (courseId) => `${getLmsBaseUrl()}/api/learning_sequences/v1/course_outline/${courseId}`;
|
||||
export const getCourseMetadataApiUrl = (courseId) => `${getLmsBaseUrl()}/api/courseware/course/${courseId}`;
|
||||
export const getCourseHomeCourseMetadataApiUrl = (courseId) => `${getLmsBaseUrl()}/api/course_home/course_metadata/${courseId}`;
|
||||
|
||||
/**
|
||||
* Get course unit.
|
||||
@@ -36,3 +49,66 @@ export async function editUnitDisplayName(unitId, displayName) {
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sequence metadata for a given sequence ID.
|
||||
* @param {string} sequenceId - The ID of the sequence for which metadata is requested.
|
||||
* @returns {Promise<Object>} - A Promise that resolves to the normalized sequence metadata.
|
||||
*/
|
||||
export async function getSequenceMetadata(sequenceId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getSequenceMetadataApiUrl(sequenceId), {});
|
||||
|
||||
return normalizeSequenceMetadata(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an object containing course section vertical data.
|
||||
* @param {string} unitId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getCourseSectionVerticalData(unitId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseSectionVerticalApiUrl(unitId));
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the outline of learning sequences for a specific course.
|
||||
* @param {string} courseId - The ID of the course.
|
||||
* @returns {Promise<Object>} A Promise that resolves to the normalized learning sequences outline data.
|
||||
*/
|
||||
export async function getLearningSequencesOutline(courseId) {
|
||||
const { href } = new URL(getLearningSequencesOutlineApiUrl(courseId));
|
||||
const { data } = await getAuthenticatedHttpClient().get(href, {});
|
||||
|
||||
return normalizeLearningSequencesData(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves metadata for a specific course.
|
||||
* @param {string} courseId - The ID of the course.
|
||||
* @returns {Promise<Object>} A Promise that resolves to the normalized course metadata.
|
||||
*/
|
||||
export async function getCourseMetadata(courseId) {
|
||||
let courseMetadataApiUrl = getCourseMetadataApiUrl(courseId);
|
||||
courseMetadataApiUrl = appendBrowserTimezoneToUrl(courseMetadataApiUrl);
|
||||
const metadata = await getAuthenticatedHttpClient().get(courseMetadataApiUrl);
|
||||
|
||||
return normalizeMetadata(metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves metadata for a course's home page.
|
||||
* @param {string} courseId - The ID of the course.
|
||||
* @param {string} rootSlug - The root slug for the course.
|
||||
* @returns {Promise<Object>} A Promise that resolves to the normalized course home page metadata.
|
||||
*/
|
||||
export async function getCourseHomeCourseMetadata(courseId, rootSlug) {
|
||||
let courseHomeCourseMetadataApiUrl = getCourseHomeCourseMetadataApiUrl(courseId);
|
||||
courseHomeCourseMetadataApiUrl = appendBrowserTimezoneToUrl(courseHomeCourseMetadataApiUrl);
|
||||
const { data } = await getAuthenticatedHttpClient().get(courseHomeCourseMetadataApiUrl);
|
||||
|
||||
return normalizeCourseHomeCourseMetadata(data, rootSlug);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
import { createSelector } from '@reduxjs/toolkit';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
export const getCourseUnitData = (state) => state.courseUnit.unit;
|
||||
|
||||
export const getSavingStatus = (state) => state.courseUnit.savingStatus;
|
||||
|
||||
export const getLoadingStatus = (state) => state.courseUnit.loadingStatus;
|
||||
|
||||
export const getSequenceStatus = (state) => state.courseUnit.sequenceStatus;
|
||||
|
||||
export const getCourseSectionVertical = (state) => state.courseUnit.courseSectionVertical;
|
||||
|
||||
export const getCourseStatus = state => state.courseUnit.courseStatus;
|
||||
export const getCoursewareMeta = state => state.models.coursewareMeta;
|
||||
export const getSections = state => state.models.sections;
|
||||
export const getCourseId = state => state.courseDetail.courseId;
|
||||
|
||||
export const sequenceIdsSelector = createSelector(
|
||||
[getCourseStatus, getCoursewareMeta, getSections, getCourseId],
|
||||
(courseStatus, coursewareMeta, sections, courseId) => {
|
||||
if (courseStatus !== RequestStatus.SUCCESSFUL) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sectionIds = coursewareMeta[courseId].sectionIds || [];
|
||||
return sectionIds.flatMap(sectionId => sections[sectionId].sequenceIds);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -9,8 +9,10 @@ const slice = createSlice({
|
||||
savingStatus: '',
|
||||
loadingStatus: {
|
||||
fetchUnitLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
courseSectionVerticalLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
},
|
||||
unit: {},
|
||||
courseSectionVertical: {},
|
||||
},
|
||||
reducers: {
|
||||
fetchCourseItemSuccess: (state, { payload }) => {
|
||||
@@ -25,6 +27,46 @@ const slice = createSlice({
|
||||
updateSavingStatus: (state, { payload }) => {
|
||||
state.savingStatus = payload.status;
|
||||
},
|
||||
fetchSequenceRequest: (state, { payload }) => {
|
||||
state.sequenceId = payload.sequenceId;
|
||||
state.sequenceStatus = RequestStatus.IN_PROGRESS;
|
||||
state.sequenceMightBeUnit = false;
|
||||
},
|
||||
fetchSequenceSuccess: (state, { payload }) => {
|
||||
state.sequenceId = payload.sequenceId;
|
||||
state.sequenceStatus = RequestStatus.SUCCESSFUL;
|
||||
state.sequenceMightBeUnit = false;
|
||||
},
|
||||
fetchSequenceFailure: (state, { payload }) => {
|
||||
state.sequenceId = payload.sequenceId;
|
||||
state.sequenceStatus = RequestStatus.FAILED;
|
||||
state.sequenceMightBeUnit = payload.sequenceMightBeUnit || false;
|
||||
},
|
||||
fetchCourseRequest: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = RequestStatus.IN_PROGRESS;
|
||||
},
|
||||
fetchCourseSuccess: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = RequestStatus.SUCCESSFUL;
|
||||
},
|
||||
fetchCourseFailure: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = RequestStatus.FAILED;
|
||||
},
|
||||
fetchCourseDenied: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = RequestStatus.DENIED;
|
||||
},
|
||||
fetchCourseSectionVerticalDataSuccess: (state, { payload }) => {
|
||||
state.courseSectionVertical = payload;
|
||||
},
|
||||
updateLoadingCourseSectionVerticalDataStatus: (state, { payload }) => {
|
||||
state.loadingStatus = {
|
||||
...state.loadingStatus,
|
||||
courseSectionVerticalLoadingStatus: payload.status,
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -32,6 +74,16 @@ export const {
|
||||
fetchCourseItemSuccess,
|
||||
updateLoadingCourseUnitStatus,
|
||||
updateSavingStatus,
|
||||
updateModel,
|
||||
fetchSequenceRequest,
|
||||
fetchSequenceSuccess,
|
||||
fetchSequenceFailure,
|
||||
fetchCourseRequest,
|
||||
fetchCourseSuccess,
|
||||
fetchCourseFailure,
|
||||
fetchCourseDenied,
|
||||
fetchCourseSectionVerticalDataSuccess,
|
||||
updateLoadingCourseSectionVerticalDataStatus,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
|
||||
@@ -1,14 +1,34 @@
|
||||
import { logError, logInfo } from '@edx/frontend-platform/logging';
|
||||
|
||||
import {
|
||||
hideProcessingNotification,
|
||||
showProcessingNotification,
|
||||
} from '../../generic/processing-notification/data/slice';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { NOTIFICATION_MESSAGES } from '../../constants';
|
||||
import { getCourseUnitData, editUnitDisplayName } from './api';
|
||||
import {
|
||||
addModel, updateModel, updateModels, updateModelsMap, addModelsMap,
|
||||
} from '../../generic/model-store';
|
||||
import {
|
||||
getCourseUnitData,
|
||||
editUnitDisplayName,
|
||||
getSequenceMetadata,
|
||||
getCourseMetadata,
|
||||
getLearningSequencesOutline, getCourseHomeCourseMetadata, getCourseSectionVerticalData,
|
||||
} from './api';
|
||||
import {
|
||||
updateLoadingCourseUnitStatus,
|
||||
fetchCourseItemSuccess,
|
||||
updateSavingStatus,
|
||||
fetchSequenceRequest,
|
||||
fetchSequenceFailure,
|
||||
fetchSequenceSuccess,
|
||||
fetchCourseRequest,
|
||||
fetchCourseSuccess,
|
||||
fetchCourseDenied,
|
||||
fetchCourseFailure,
|
||||
fetchCourseSectionVerticalDataSuccess,
|
||||
updateLoadingCourseSectionVerticalDataStatus,
|
||||
} from './slice';
|
||||
|
||||
export function fetchCourseUnitQuery(courseId) {
|
||||
@@ -27,6 +47,22 @@ export function fetchCourseUnitQuery(courseId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCourseSectionVerticalData(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateLoadingCourseSectionVerticalDataStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const courseSectionVerticalData = await getCourseSectionVerticalData(courseId);
|
||||
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
|
||||
dispatch(updateLoadingCourseSectionVerticalDataStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
dispatch(updateLoadingCourseSectionVerticalDataStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function editCourseItemQuery(itemId, displayName) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
@@ -47,3 +83,131 @@ export function editCourseItemQuery(itemId, displayName) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchSequence(sequenceId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(fetchSequenceRequest({ sequenceId }));
|
||||
try {
|
||||
const { sequence, units } = await getSequenceMetadata(sequenceId);
|
||||
|
||||
if (sequence.blockType !== 'sequential') {
|
||||
// Some other block types (particularly 'chapter') can be returned
|
||||
// by this API. We want to error in that case, since downstream
|
||||
// courseware code is written to render Sequences of Units.
|
||||
logError(
|
||||
`Requested sequence '${sequenceId}' `
|
||||
+ `has block type '${sequence.blockType}'; expected block type 'sequential'.`,
|
||||
);
|
||||
dispatch(fetchSequenceFailure({ sequenceId }));
|
||||
} else {
|
||||
dispatch(updateModel({
|
||||
modelType: 'sequences',
|
||||
model: sequence,
|
||||
}));
|
||||
dispatch(updateModels({
|
||||
modelType: 'units',
|
||||
models: units,
|
||||
}));
|
||||
dispatch(fetchSequenceSuccess({ sequenceId }));
|
||||
}
|
||||
} catch (error) {
|
||||
// Some errors are expected - for example, CoursewareContainer may request sequence metadata for a unit and rely
|
||||
// on the request failing to notice that it actually does have a unit (mostly so it doesn't have to know anything
|
||||
// about the opaque key structure). In such cases, the backend gives us a 422.
|
||||
const sequenceMightBeUnit = error?.response?.status === 422;
|
||||
if (!sequenceMightBeUnit) {
|
||||
logError(error);
|
||||
}
|
||||
dispatch(fetchSequenceFailure({ sequenceId, sequenceMightBeUnit }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCourse(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(fetchCourseRequest({ courseId }));
|
||||
Promise.allSettled([
|
||||
getCourseMetadata(courseId),
|
||||
getLearningSequencesOutline(courseId),
|
||||
getCourseHomeCourseMetadata(courseId, 'courseware'),
|
||||
]).then(([
|
||||
courseMetadataResult,
|
||||
learningSequencesOutlineResult,
|
||||
courseHomeMetadataResult]) => {
|
||||
if (courseMetadataResult.status === 'fulfilled') {
|
||||
dispatch(addModel({
|
||||
modelType: 'coursewareMeta',
|
||||
model: courseMetadataResult.value,
|
||||
}));
|
||||
}
|
||||
|
||||
if (courseHomeMetadataResult.status === 'fulfilled') {
|
||||
dispatch(addModel({
|
||||
modelType: 'courseHomeMeta',
|
||||
model: {
|
||||
id: courseId,
|
||||
...courseHomeMetadataResult.value,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
if (learningSequencesOutlineResult.status === 'fulfilled') {
|
||||
const {
|
||||
courses, sections, sequences,
|
||||
} = learningSequencesOutlineResult.value;
|
||||
|
||||
// This updates the course with a sectionIds array from the Learning Sequence data.
|
||||
dispatch(updateModelsMap({
|
||||
modelType: 'coursewareMeta',
|
||||
modelsMap: courses,
|
||||
}));
|
||||
dispatch(addModelsMap({
|
||||
modelType: 'sections',
|
||||
modelsMap: sections,
|
||||
}));
|
||||
// We update for sequences because the sequence metadata may have come back first.
|
||||
dispatch(updateModelsMap({
|
||||
modelType: 'sequences',
|
||||
modelsMap: sequences,
|
||||
}));
|
||||
}
|
||||
|
||||
const fetchedMetadata = courseMetadataResult.status === 'fulfilled';
|
||||
const fetchedCourseHomeMetadata = courseHomeMetadataResult.status === 'fulfilled';
|
||||
const fetchedOutline = learningSequencesOutlineResult.status === 'fulfilled';
|
||||
|
||||
// Log errors for each request if needed. Outline failures may occur
|
||||
// even if the course metadata request is successful
|
||||
if (!fetchedOutline) {
|
||||
const { response } = learningSequencesOutlineResult.reason;
|
||||
if (response && response.status === 403) {
|
||||
// 403 responses are normal - they happen when the learner is logged out.
|
||||
// We'll redirect them in a moment to the outline tab by calling fetchCourseDenied() below.
|
||||
logInfo(learningSequencesOutlineResult.reason);
|
||||
} else {
|
||||
logError(learningSequencesOutlineResult.reason);
|
||||
}
|
||||
}
|
||||
if (!fetchedMetadata) {
|
||||
logError(courseMetadataResult.reason);
|
||||
}
|
||||
if (!fetchedCourseHomeMetadata) {
|
||||
logError(courseHomeMetadataResult.reason);
|
||||
}
|
||||
if (fetchedMetadata && fetchedCourseHomeMetadata) {
|
||||
if (courseHomeMetadataResult.value.courseAccess.hasAccess && fetchedOutline) {
|
||||
// User has access
|
||||
dispatch(fetchCourseSuccess({ courseId }));
|
||||
return;
|
||||
}
|
||||
// User either doesn't have access or only has partial access
|
||||
// (can't access course blocks)
|
||||
dispatch(fetchCourseDenied({ courseId }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Definitely an error happening
|
||||
dispatch(fetchCourseFailure({ courseId }));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
191
src/course-unit/data/utils.js
Normal file
191
src/course-unit/data/utils.js
Normal file
@@ -0,0 +1,191 @@
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
|
||||
export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
|
||||
// Time offset computation should move down into the HttpClient wrapper to maintain a global time correction reference
|
||||
// Requires 'Access-Control-Expose-Headers: Date' on the server response per https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#access-control-expose-headers
|
||||
|
||||
let timeOffsetMillis = 0;
|
||||
if (headerDate !== undefined) {
|
||||
const headerTime = Date.parse(headerDate);
|
||||
const roundTripMillis = requestTime - responseTime;
|
||||
const localTime = responseTime - (roundTripMillis / 2); // Roughly compensate for transit time
|
||||
timeOffsetMillis = headerTime - localTime;
|
||||
}
|
||||
|
||||
return timeOffsetMillis;
|
||||
}
|
||||
|
||||
export function normalizeMetadata(metadata) {
|
||||
const requestTime = Date.now();
|
||||
const responseTime = requestTime;
|
||||
const { data, headers } = metadata;
|
||||
return {
|
||||
accessExpiration: camelCaseObject(data.access_expiration),
|
||||
canShowUpgradeSock: data.can_show_upgrade_sock,
|
||||
contentTypeGatingEnabled: data.content_type_gating_enabled,
|
||||
courseGoals: camelCaseObject(data.course_goals),
|
||||
id: data.id,
|
||||
title: data.name,
|
||||
offer: camelCaseObject(data.offer),
|
||||
enrollmentStart: data.enrollment_start,
|
||||
enrollmentEnd: data.enrollment_end,
|
||||
end: data.end,
|
||||
start: data.start,
|
||||
enrollmentMode: data.enrollment.mode,
|
||||
isEnrolled: data.enrollment.is_active,
|
||||
license: data.license,
|
||||
userTimezone: data.user_timezone,
|
||||
showCalculator: data.show_calculator,
|
||||
notes: camelCaseObject(data.notes),
|
||||
marketingUrl: data.marketing_url,
|
||||
celebrations: camelCaseObject(data.celebrations),
|
||||
userHasPassingGrade: data.user_has_passing_grade,
|
||||
courseExitPageIsActive: data.course_exit_page_is_active,
|
||||
certificateData: camelCaseObject(data.certificate_data),
|
||||
entranceExamData: camelCaseObject(data.entrance_exam_data),
|
||||
timeOffsetMillis: getTimeOffsetMillis(headers && headers.date, requestTime, responseTime),
|
||||
verifyIdentityUrl: data.verify_identity_url,
|
||||
verificationStatus: data.verification_status,
|
||||
linkedinAddToProfileUrl: data.linkedin_add_to_profile_url,
|
||||
relatedPrograms: camelCaseObject(data.related_programs),
|
||||
userNeedsIntegritySignature: data.user_needs_integrity_signature,
|
||||
canAccessProctoredExams: data.can_access_proctored_exams,
|
||||
learningAssistantEnabled: data.learning_assistant_enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export const appendBrowserTimezoneToUrl = (url) => {
|
||||
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const urlObject = new URL(url);
|
||||
if (browserTimezone) {
|
||||
urlObject.searchParams.append('browser_timezone', browserTimezone);
|
||||
}
|
||||
return urlObject.href;
|
||||
};
|
||||
|
||||
export function normalizeSequenceMetadata(sequence) {
|
||||
return {
|
||||
sequence: {
|
||||
id: sequence.item_id,
|
||||
blockType: sequence.tag,
|
||||
unitIds: sequence.items.map(unit => unit.id),
|
||||
bannerText: sequence.banner_text,
|
||||
format: sequence.format,
|
||||
title: sequence.display_name,
|
||||
/*
|
||||
Example structure of gated_content when prerequisites exist:
|
||||
{
|
||||
prereq_id: 'id of the prereq section',
|
||||
prereq_url: 'unused by this frontend',
|
||||
prereq_section_name: 'Name of the prerequisite section',
|
||||
gated: true,
|
||||
gated_section_name: 'Name of this gated section',
|
||||
*/
|
||||
gatedContent: camelCaseObject(sequence.gated_content),
|
||||
isTimeLimited: sequence.is_time_limited,
|
||||
isProctored: sequence.is_proctored,
|
||||
isHiddenAfterDue: sequence.is_hidden_after_due,
|
||||
// Position comes back from the server 1-indexed. Adjust here.
|
||||
activeUnitIndex: sequence.position ? sequence.position - 1 : 0,
|
||||
saveUnitPosition: sequence.save_position,
|
||||
showCompletion: sequence.show_completion,
|
||||
allowProctoringOptOut: sequence.allow_proctoring_opt_out,
|
||||
},
|
||||
units: sequence.items.map(unit => ({
|
||||
id: unit.id,
|
||||
sequenceId: sequence.item_id,
|
||||
bookmarked: unit.bookmarked,
|
||||
complete: unit.complete,
|
||||
title: unit.page_title,
|
||||
contentType: unit.type,
|
||||
graded: unit.graded,
|
||||
containsContentTypeGatedContent: unit.contains_content_type_gated_content,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeLearningSequencesData(learningSequencesData) {
|
||||
const models = {
|
||||
courses: {},
|
||||
sections: {},
|
||||
sequences: {},
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
function isReleased(block) {
|
||||
// We check whether the backend marks this as accessible because staff users are granted access anyway.
|
||||
// Note that sections don't have the `accessible` field and will just be checking `effective_start`.
|
||||
return block.accessible || !block.effective_start || now >= Date.parse(block.effective_start);
|
||||
}
|
||||
|
||||
// Sequences
|
||||
Object.entries(learningSequencesData.outline.sequences).forEach(([seqId, sequence]) => {
|
||||
if (!isReleased(sequence)) {
|
||||
return; // Don't let the learner see unreleased sequences
|
||||
}
|
||||
|
||||
models.sequences[seqId] = {
|
||||
id: seqId,
|
||||
title: sequence.title,
|
||||
};
|
||||
});
|
||||
|
||||
// Sections
|
||||
learningSequencesData.outline.sections.forEach(section => {
|
||||
// Filter out any ignored sequences (e.g. unreleased sequences)
|
||||
const availableSequenceIds = section.sequence_ids.filter(seqId => seqId in models.sequences);
|
||||
|
||||
// If we are unreleased and already stripped out all our children, just don't show us at all.
|
||||
// (We check both release date and children because children will exist for an unreleased section even for staff,
|
||||
// so we still want to show this section.)
|
||||
if (!isReleased(section) && !availableSequenceIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
models.sections[section.id] = {
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
sequenceIds: availableSequenceIds,
|
||||
courseId: learningSequencesData.course_key,
|
||||
};
|
||||
|
||||
// Add back-references to this section for all child sequences.
|
||||
availableSequenceIds.forEach(childSeqId => {
|
||||
models.sequences[childSeqId].sectionId = section.id;
|
||||
});
|
||||
});
|
||||
|
||||
// Course
|
||||
models.courses[learningSequencesData.course_key] = {
|
||||
id: learningSequencesData.course_key,
|
||||
title: learningSequencesData.title,
|
||||
sectionIds: Object.entries(models.sections).map(([sectionId]) => sectionId),
|
||||
|
||||
// Scan through all the sequences and look for ones that aren't released yet.
|
||||
hasScheduledContent: Object.values(learningSequencesData.outline.sequences).some(seq => !isReleased(seq)),
|
||||
};
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tweak the metadata for consistency
|
||||
* @param metadata the data to normalize
|
||||
* @param rootSlug either 'courseware' or 'outline' depending on the context
|
||||
* @returns {Object} The normalized metadata
|
||||
*/
|
||||
export function normalizeCourseHomeCourseMetadata(metadata, rootSlug) {
|
||||
const data = camelCaseObject(metadata);
|
||||
return {
|
||||
...data,
|
||||
tabs: data.tabs.map(tab => ({
|
||||
// The API uses "courseware" as a slug for both courseware and the outline tab.
|
||||
// If needed, we switch it to "outline" here for
|
||||
// use within the MFE to differentiate between course home and courseware.
|
||||
slug: tab.tabId === 'courseware' ? rootSlug : tab.tabId,
|
||||
title: tab.title,
|
||||
url: tab.url,
|
||||
})),
|
||||
isMasquerading: data.originalUserIsStaff && !data.isStaff,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import {
|
||||
fetchCourseUnitQuery,
|
||||
editCourseItemQuery,
|
||||
fetchSequence,
|
||||
fetchCourse,
|
||||
fetchCourseSectionVerticalData,
|
||||
} from './data/thunk';
|
||||
import {
|
||||
getCourseUnitData,
|
||||
@@ -15,17 +19,19 @@ import {
|
||||
import { updateSavingStatus } from './data/slice';
|
||||
import { getUnitViewLivePath, getUnitPreviewPath } from './utils';
|
||||
|
||||
const useCourseUnit = ({ courseId, blockId }) => {
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const useCourseUnit = ({ courseId, blockId }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { config } = useContext(AppContext);
|
||||
const courseUnit = useSelector(getCourseUnitData);
|
||||
const savingStatus = useSelector(getSavingStatus);
|
||||
const loadingStatus = useSelector(getLoadingStatus);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [isTitleEditFormOpen, toggleTitleEditForm] = useState(false);
|
||||
|
||||
const unitTitle = courseUnit.metadata?.displayName || '';
|
||||
const sequenceId = courseUnit.ancestorInfo?.ancestors[0].id;
|
||||
|
||||
const headerNavigationsActions = {
|
||||
handleViewLive: () => {
|
||||
@@ -54,11 +60,22 @@ const useCourseUnit = ({ courseId, blockId }) => {
|
||||
handleTitleEdit();
|
||||
};
|
||||
|
||||
const handleNavigate = (id) => {
|
||||
if (sequenceId) {
|
||||
navigate(`/course/${courseId}/container/${blockId}/${id}`, { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchCourseUnitQuery(blockId));
|
||||
}, [courseId]);
|
||||
dispatch(fetchCourseSectionVerticalData(blockId));
|
||||
dispatch(fetchSequence(sequenceId));
|
||||
dispatch(fetchCourse(courseId));
|
||||
handleNavigate(sequenceId);
|
||||
}, [courseId, blockId, sequenceId]);
|
||||
|
||||
return {
|
||||
sequenceId,
|
||||
courseUnit,
|
||||
unitTitle,
|
||||
isLoading: loadingStatus.fetchUnitLoadingStatus === RequestStatus.IN_PROGRESS,
|
||||
@@ -70,6 +87,3 @@ const useCourseUnit = ({ courseId, blockId }) => {
|
||||
handleTitleEditSubmit,
|
||||
};
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { useCourseUnit };
|
||||
|
||||
@@ -982,5 +982,12 @@
|
||||
"course-authoring.course-unit.heading.button.edit.alt": "Edit",
|
||||
"course-authoring.course-unit.heading.button.edit.aria-label": "Edit field",
|
||||
"course-authoring.course-unit.heading.button.settings.alt": "Settings",
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again."
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.",
|
||||
"course-authoring.course-unit.prev-btn-text": "Previous",
|
||||
"course-authoring.course-unit.next-btn-text": "Next",
|
||||
"course-authoring.course-unit.new-unit-btn-text": "New unit",
|
||||
"course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation",
|
||||
"course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.",
|
||||
"course-authoring.course-unit.sequence.no.content": "There is no content here.",
|
||||
"course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}"
|
||||
}
|
||||
|
||||
@@ -982,5 +982,12 @@
|
||||
"course-authoring.course-unit.heading.button.edit.alt": "Edit",
|
||||
"course-authoring.course-unit.heading.button.edit.aria-label": "Edit field",
|
||||
"course-authoring.course-unit.heading.button.settings.alt": "Settings",
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again."
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.",
|
||||
"course-authoring.course-unit.prev-btn-text": "Previous",
|
||||
"course-authoring.course-unit.next-btn-text": "Next",
|
||||
"course-authoring.course-unit.new-unit-btn-text": "New unit",
|
||||
"course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation",
|
||||
"course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.",
|
||||
"course-authoring.course-unit.sequence.no.content": "There is no content here.",
|
||||
"course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}"
|
||||
}
|
||||
|
||||
@@ -982,5 +982,12 @@
|
||||
"course-authoring.course-unit.heading.button.edit.alt": "Edit",
|
||||
"course-authoring.course-unit.heading.button.edit.aria-label": "Edit field",
|
||||
"course-authoring.course-unit.heading.button.settings.alt": "Settings",
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again."
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.",
|
||||
"course-authoring.course-unit.prev-btn-text": "Previous",
|
||||
"course-authoring.course-unit.next-btn-text": "Next",
|
||||
"course-authoring.course-unit.new-unit-btn-text": "New unit",
|
||||
"course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation",
|
||||
"course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.",
|
||||
"course-authoring.course-unit.sequence.no.content": "There is no content here.",
|
||||
"course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}"
|
||||
}
|
||||
|
||||
@@ -982,5 +982,12 @@
|
||||
"course-authoring.course-unit.heading.button.edit.alt": "Edit",
|
||||
"course-authoring.course-unit.heading.button.edit.aria-label": "Edit field",
|
||||
"course-authoring.course-unit.heading.button.settings.alt": "Settings",
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again."
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.",
|
||||
"course-authoring.course-unit.prev-btn-text": "Previous",
|
||||
"course-authoring.course-unit.next-btn-text": "Next",
|
||||
"course-authoring.course-unit.new-unit-btn-text": "New unit",
|
||||
"course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation",
|
||||
"course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.",
|
||||
"course-authoring.course-unit.sequence.no.content": "There is no content here.",
|
||||
"course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}"
|
||||
}
|
||||
|
||||
@@ -5,5 +5,12 @@
|
||||
"course-authoring.course-unit.heading.button.edit.alt": "Edit",
|
||||
"course-authoring.course-unit.heading.button.edit.aria-label": "Edit field",
|
||||
"course-authoring.course-unit.heading.button.settings.alt": "Settings",
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again."
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.",
|
||||
"course-authoring.course-unit.prev-btn-text": "Previous",
|
||||
"course-authoring.course-unit.next-btn-text": "Next",
|
||||
"course-authoring.course-unit.new-unit-btn-text": "New unit",
|
||||
"course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation",
|
||||
"course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.",
|
||||
"course-authoring.course-unit.sequence.no.content": "There is no content here.",
|
||||
"course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}"
|
||||
}
|
||||
|
||||
@@ -982,5 +982,12 @@
|
||||
"course-authoring.course-unit.heading.button.edit.alt": "Edit",
|
||||
"course-authoring.course-unit.heading.button.edit.aria-label": "Edit field",
|
||||
"course-authoring.course-unit.heading.button.settings.alt": "Settings",
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again."
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.",
|
||||
"course-authoring.course-unit.prev-btn-text": "Previous",
|
||||
"course-authoring.course-unit.next-btn-text": "Next",
|
||||
"course-authoring.course-unit.new-unit-btn-text": "New unit",
|
||||
"course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation",
|
||||
"course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.",
|
||||
"course-authoring.course-unit.sequence.no.content": "There is no content here.",
|
||||
"course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}"
|
||||
}
|
||||
|
||||
@@ -982,5 +982,12 @@
|
||||
"course-authoring.course-unit.heading.button.edit.alt": "Edit",
|
||||
"course-authoring.course-unit.heading.button.edit.aria-label": "Edit field",
|
||||
"course-authoring.course-unit.heading.button.settings.alt": "Settings",
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again."
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.",
|
||||
"course-authoring.course-unit.prev-btn-text": "Previous",
|
||||
"course-authoring.course-unit.next-btn-text": "Next",
|
||||
"course-authoring.course-unit.new-unit-btn-text": "New unit",
|
||||
"course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation",
|
||||
"course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.",
|
||||
"course-authoring.course-unit.sequence.no.content": "There is no content here.",
|
||||
"course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}"
|
||||
}
|
||||
|
||||
@@ -982,5 +982,12 @@
|
||||
"course-authoring.course-unit.heading.button.edit.alt": "Edit",
|
||||
"course-authoring.course-unit.heading.button.edit.aria-label": "Edit field",
|
||||
"course-authoring.course-unit.heading.button.settings.alt": "Settings",
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again."
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.",
|
||||
"course-authoring.course-unit.prev-btn-text": "Previous",
|
||||
"course-authoring.course-unit.next-btn-text": "Next",
|
||||
"course-authoring.course-unit.new-unit-btn-text": "New unit",
|
||||
"course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation",
|
||||
"course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.",
|
||||
"course-authoring.course-unit.sequence.no.content": "There is no content here.",
|
||||
"course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}"
|
||||
}
|
||||
|
||||
@@ -982,5 +982,12 @@
|
||||
"course-authoring.course-unit.heading.button.edit.alt": "Edit",
|
||||
"course-authoring.course-unit.heading.button.edit.aria-label": "Edit field",
|
||||
"course-authoring.course-unit.heading.button.settings.alt": "Settings",
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again."
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.",
|
||||
"course-authoring.course-unit.prev-btn-text": "Previous",
|
||||
"course-authoring.course-unit.next-btn-text": "Next",
|
||||
"course-authoring.course-unit.new-unit-btn-text": "New unit",
|
||||
"course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation",
|
||||
"course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.",
|
||||
"course-authoring.course-unit.sequence.no.content": "There is no content here.",
|
||||
"course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}"
|
||||
}
|
||||
|
||||
@@ -982,5 +982,12 @@
|
||||
"course-authoring.course-unit.heading.button.edit.alt": "Edit",
|
||||
"course-authoring.course-unit.heading.button.edit.aria-label": "Edit field",
|
||||
"course-authoring.course-unit.heading.button.settings.alt": "Settings",
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again."
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.",
|
||||
"course-authoring.course-unit.prev-btn-text": "Previous",
|
||||
"course-authoring.course-unit.next-btn-text": "Next",
|
||||
"course-authoring.course-unit.new-unit-btn-text": "New unit",
|
||||
"course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation",
|
||||
"course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.",
|
||||
"course-authoring.course-unit.sequence.no.content": "There is no content here.",
|
||||
"course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}"
|
||||
}
|
||||
|
||||
@@ -982,5 +982,12 @@
|
||||
"course-authoring.course-unit.heading.button.edit.alt": "Edit",
|
||||
"course-authoring.course-unit.heading.button.edit.aria-label": "Edit field",
|
||||
"course-authoring.course-unit.heading.button.settings.alt": "Settings",
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again."
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.",
|
||||
"course-authoring.course-unit.prev-btn-text": "Previous",
|
||||
"course-authoring.course-unit.next-btn-text": "Next",
|
||||
"course-authoring.course-unit.new-unit-btn-text": "New unit",
|
||||
"course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation",
|
||||
"course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.",
|
||||
"course-authoring.course-unit.sequence.no.content": "There is no content here.",
|
||||
"course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}"
|
||||
}
|
||||
|
||||
@@ -982,5 +982,12 @@
|
||||
"course-authoring.course-unit.heading.button.edit.alt": "Edit",
|
||||
"course-authoring.course-unit.heading.button.edit.aria-label": "Edit field",
|
||||
"course-authoring.course-unit.heading.button.settings.alt": "Settings",
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again."
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.",
|
||||
"course-authoring.course-unit.prev-btn-text": "Previous",
|
||||
"course-authoring.course-unit.next-btn-text": "Next",
|
||||
"course-authoring.course-unit.new-unit-btn-text": "New unit",
|
||||
"course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation",
|
||||
"course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.",
|
||||
"course-authoring.course-unit.sequence.no.content": "There is no content here.",
|
||||
"course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}"
|
||||
}
|
||||
|
||||
@@ -982,5 +982,12 @@
|
||||
"course-authoring.course-unit.heading.button.edit.alt": "Edit",
|
||||
"course-authoring.course-unit.heading.button.edit.aria-label": "Edit field",
|
||||
"course-authoring.course-unit.heading.button.settings.alt": "Settings",
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again."
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.",
|
||||
"course-authoring.course-unit.prev-btn-text": "Previous",
|
||||
"course-authoring.course-unit.next-btn-text": "Next",
|
||||
"course-authoring.course-unit.new-unit-btn-text": "New unit",
|
||||
"course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation",
|
||||
"course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.",
|
||||
"course-authoring.course-unit.sequence.no.content": "There is no content here.",
|
||||
"course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}"
|
||||
}
|
||||
|
||||
@@ -982,5 +982,12 @@
|
||||
"course-authoring.course-unit.heading.button.edit.alt": "Edit",
|
||||
"course-authoring.course-unit.heading.button.edit.aria-label": "Edit field",
|
||||
"course-authoring.course-unit.heading.button.settings.alt": "Settings",
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again."
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.",
|
||||
"course-authoring.course-unit.prev-btn-text": "Previous",
|
||||
"course-authoring.course-unit.next-btn-text": "Next",
|
||||
"course-authoring.course-unit.new-unit-btn-text": "New unit",
|
||||
"course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation",
|
||||
"course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.",
|
||||
"course-authoring.course-unit.sequence.no.content": "There is no content here.",
|
||||
"course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}"
|
||||
}
|
||||
|
||||
@@ -982,5 +982,12 @@
|
||||
"course-authoring.course-unit.heading.button.edit.alt": "Edit",
|
||||
"course-authoring.course-unit.heading.button.edit.aria-label": "Edit field",
|
||||
"course-authoring.course-unit.heading.button.settings.alt": "Settings",
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again."
|
||||
"course-authoring.course-unit.general.alert.error.description": "Unable to {actionName} {type}. Please try again.",
|
||||
"course-authoring.course-unit.prev-btn-text": "Previous",
|
||||
"course-authoring.course-unit.next-btn-text": "Next",
|
||||
"course-authoring.course-unit.new-unit-btn-text": "New unit",
|
||||
"course-authoring.course-unit.sequence-nav-label-text": "Sequence navigation",
|
||||
"course-authoring.course-unit.sequence.load.failure": "There was an error loading this course.",
|
||||
"course-authoring.course-unit.sequence.no.content": "There is no content here.",
|
||||
"course-authoring.course-unit.sequence.navigation.menu": "{current} of {total}"
|
||||
}
|
||||
|
||||
@@ -22,3 +22,4 @@
|
||||
@import "files-and-videos";
|
||||
@import "content-tags-drawer/TagBubble";
|
||||
@import "course-outline/CourseOutline";
|
||||
@import "course-unit/CourseUnit";
|
||||
|
||||
Reference in New Issue
Block a user