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:
Peter Kulko
2024-02-08 17:15:21 +02:00
committed by GitHub
parent 4850302175
commit b234344aab
37 changed files with 1310 additions and 34 deletions

View File

@@ -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}

View File

@@ -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',
],
};

View File

@@ -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>

View File

@@ -1 +1,2 @@
@import "./breadcrumbs/Breadcrumbs";
@import "./course-sequence/CourseSequence";

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

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
},
);

View File

@@ -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 {

View File

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

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

View File

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

View File

@@ -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}"
}

View File

@@ -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}"
}

View File

@@ -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}"
}

View File

@@ -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}"
}

View File

@@ -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}"
}

View File

@@ -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}"
}

View File

@@ -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}"
}

View File

@@ -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}"
}

View File

@@ -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}"
}

View File

@@ -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}"
}

View File

@@ -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}"
}

View File

@@ -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}"
}

View File

@@ -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}"
}

View File

@@ -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}"
}

View File

@@ -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}"
}

View File

@@ -22,3 +22,4 @@
@import "files-and-videos";
@import "content-tags-drawer/TagBubble";
@import "course-outline/CourseOutline";
@import "course-unit/CourseUnit";