diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index 30c412bd0..bdeffa110 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -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={} /> - } - /> + {DECODED_ROUTES.COURSE_UNIT.map((path) => ( + } + /> + ))} : null} diff --git a/src/constants.js b/src/constants.js index cfb427edb..2b2d394c0 100644 --- a/src/constants.js +++ b/src/constants.js @@ -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', + ], +}; diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index 6a6979704..e7a09a2d0 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -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 ; } return ( @@ -73,6 +74,11 @@ const CourseUnit = ({ courseId }) => { /> )} /> + { xs={[{ span: 9 }, { span: 3 }]} xl={[{ span: 9 }, { span: 3 }]} > - - + + {/* TODO: Unit content will be added in the following tasks. */} + Unit content + diff --git a/src/course-unit/CourseUnit.scss b/src/course-unit/CourseUnit.scss index 82ba56f50..954bbbdac 100644 --- a/src/course-unit/CourseUnit.scss +++ b/src/course-unit/CourseUnit.scss @@ -1 +1,2 @@ @import "./breadcrumbs/Breadcrumbs"; +@import "./course-sequence/CourseSequence"; diff --git a/src/course-unit/constants.js b/src/course-unit/constants.js new file mode 100644 index 000000000..c35d980d1 --- /dev/null +++ b/src/course-unit/constants.js @@ -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, +}; diff --git a/src/course-unit/course-sequence/CourseSequence.scss b/src/course-unit/course-sequence/CourseSequence.scss new file mode 100644 index 000000000..21bf490d1 --- /dev/null +++ b/src/course-unit/course-sequence/CourseSequence.scss @@ -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); + } + } +} diff --git a/src/course-unit/course-sequence/Sequence.jsx b/src/course-unit/course-sequence/Sequence.jsx new file mode 100644 index 000000000..bd251551b --- /dev/null +++ b/src/course-unit/course-sequence/Sequence.jsx @@ -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 = ( +
+
+ +
+
+ ); + + // 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 (
{intl.formatMessage(messages.sequenceNoContent)}
); + } + + return ; + } + + if (sequenceStatus === SUCCESSFUL) { + return defaultContent; + } + + // sequence status 'failed' and any other unexpected sequence status. + return ( +

+ {intl.formatMessage(messages.sequenceLoadFailure)} +

+ ); +}; + +Sequence.propTypes = { + unitId: PropTypes.string, + courseId: PropTypes.string.isRequired, + sequenceId: PropTypes.string, +}; + +Sequence.defaultProps = { + sequenceId: null, + unitId: null, +}; + +export default Sequence; diff --git a/src/course-unit/course-sequence/hooks.js b/src/course-unit/course-sequence/hooks.js new file mode 100644 index 000000000..f7864b645 --- /dev/null +++ b/src/course-unit/course-sequence/hooks.js @@ -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]; +} diff --git a/src/course-unit/course-sequence/index.jsx b/src/course-unit/course-sequence/index.jsx new file mode 100644 index 000000000..285eab20b --- /dev/null +++ b/src/course-unit/course-sequence/index.jsx @@ -0,0 +1 @@ +export { default } from './Sequence'; diff --git a/src/course-unit/course-sequence/messages.js b/src/course-unit/course-sequence/messages.js new file mode 100644 index 000000000..7c3378707 --- /dev/null +++ b/src/course-unit/course-sequence/messages.js @@ -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; diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx new file mode 100644 index 000000000..63e6cfd3d --- /dev/null +++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigation.jsx @@ -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 ( +
+ ); + } + + return ( + + ); + }; + + const renderPreviousButton = () => { + const buttonText = intl.formatMessage(messages.prevBtnText); + const prevArrow = isRtl(getLocale()) ? ChevronRightIcon : ChevronLeftIcon; + + if (!isFirstUnit) { + return ( + + ); + } + + return null; + }; + + const renderNextButton = () => { + const buttonText = intl.formatMessage(messages.nextBtnText); + const nextArrow = isRtl(getLocale()) ? ChevronLeftIcon : ChevronRightIcon; + + if (!isLastUnit) { + return ( + + ); + } + + return null; + }; + + return sequenceStatus === RequestStatus.SUCCESSFUL && ( + + ); +}; + +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); diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationDropdown.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationDropdown.jsx new file mode 100644 index 000000000..e052d2c5c --- /dev/null +++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationDropdown.jsx @@ -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 ( + + + {intl.formatMessage(messages.sequenceDropdownTitle, { + current: unitIds.indexOf(unitId) + 1, + total: unitIds.length, + })} + + + {unitIds.map(buttonUnitId => ( + + ))} + + + ); +}; + +SequenceNavigationDropdown.propTypes = { + unitId: PropTypes.string.isRequired, + unitIds: PropTypes.arrayOf(PropTypes.string).isRequired, +}; + +export default SequenceNavigationDropdown; diff --git a/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx new file mode 100644 index 000000000..1badb8536 --- /dev/null +++ b/src/course-unit/course-sequence/sequence-navigation/SequenceNavigationTabs.jsx @@ -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 ( +
+
+
+ {unitIds.map((buttonUnitId) => ( + + ))} + {/* TODO: The functionality of the New unit button will be implemented in https://youtrack.raccoongang.com/issue/AXIMST-14 */} + +
+
+ {shouldDisplayDropdown && ( + + )} +
+ ); +}; + +SequenceNavigationTabs.propTypes = { + unitId: PropTypes.string.isRequired, + unitIds: PropTypes.arrayOf(PropTypes.string).isRequired, +}; + +export default SequenceNavigationTabs; diff --git a/src/course-unit/course-sequence/sequence-navigation/UnitButton.jsx b/src/course-unit/course-sequence/sequence-navigation/UnitButton.jsx new file mode 100644 index 000000000..9cf8eb627 --- /dev/null +++ b/src/course-unit/course-sequence/sequence-navigation/UnitButton.jsx @@ -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 ( + + ); +}; + +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); diff --git a/src/course-unit/course-sequence/sequence-navigation/UnitIcon.jsx b/src/course-unit/course-sequence/sequence-navigation/UnitIcon.jsx new file mode 100644 index 000000000..7bcee2d86 --- /dev/null +++ b/src/course-unit/course-sequence/sequence-navigation/UnitIcon.jsx @@ -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 ; +}; + +UnitIcon.propTypes = { + type: PropTypes.oneOf(UNIT_ICON_TYPES).isRequired, +}; + +export default UnitIcon; diff --git a/src/course-unit/data/api.js b/src/course-unit/data/api.js index feded71ea..47be48869 100644 --- a/src/course-unit/data/api.js +++ b/src/course-unit/data/api.js @@ -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} - 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} + */ +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} 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} 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} 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); +} diff --git a/src/course-unit/data/selectors.js b/src/course-unit/data/selectors.js index c6c449d17..8955c228e 100644 --- a/src/course-unit/data/selectors.js +++ b/src/course-unit/data/selectors.js @@ -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); + }, +); diff --git a/src/course-unit/data/slice.js b/src/course-unit/data/slice.js index 6763fbd0b..c80fa02b3 100644 --- a/src/course-unit/data/slice.js +++ b/src/course-unit/data/slice.js @@ -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 { diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js index 0c684e88d..883c53b99 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -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 })); + }); + }; +} diff --git a/src/course-unit/data/utils.js b/src/course-unit/data/utils.js new file mode 100644 index 000000000..b3f77f407 --- /dev/null +++ b/src/course-unit/data/utils.js @@ -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, + }; +} diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index 98e4fc9a8..3ecb42a28 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -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 }; diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index c4d728a39..393a9019a 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -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}" } diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 4c8578fcf..09c7da1eb 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -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}" } diff --git a/src/i18n/messages/de_DE.json b/src/i18n/messages/de_DE.json index d69cba440..0286fe7a0 100644 --- a/src/i18n/messages/de_DE.json +++ b/src/i18n/messages/de_DE.json @@ -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}" } diff --git a/src/i18n/messages/es_419.json b/src/i18n/messages/es_419.json index 1111d4cae..c59fa6849 100644 --- a/src/i18n/messages/es_419.json +++ b/src/i18n/messages/es_419.json @@ -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}" } diff --git a/src/i18n/messages/fa_IR.json b/src/i18n/messages/fa_IR.json index 3c5865975..a8ef072f6 100644 --- a/src/i18n/messages/fa_IR.json +++ b/src/i18n/messages/fa_IR.json @@ -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}" } diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 094d6af15..e91037042 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -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}" } diff --git a/src/i18n/messages/fr_CA.json b/src/i18n/messages/fr_CA.json index 8ed7611d8..f3251fbbb 100644 --- a/src/i18n/messages/fr_CA.json +++ b/src/i18n/messages/fr_CA.json @@ -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}" } diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 4c8578fcf..09c7da1eb 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -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}" } diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 4c8578fcf..09c7da1eb 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -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}" } diff --git a/src/i18n/messages/it_IT.json b/src/i18n/messages/it_IT.json index 46998e7cc..21f4b2660 100644 --- a/src/i18n/messages/it_IT.json +++ b/src/i18n/messages/it_IT.json @@ -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}" } diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 4c8578fcf..09c7da1eb 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -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}" } diff --git a/src/i18n/messages/pt_PT.json b/src/i18n/messages/pt_PT.json index 52eea7d8e..b447c987f 100644 --- a/src/i18n/messages/pt_PT.json +++ b/src/i18n/messages/pt_PT.json @@ -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}" } diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 4c8578fcf..09c7da1eb 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -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}" } diff --git a/src/i18n/messages/uk.json b/src/i18n/messages/uk.json index 4c8578fcf..09c7da1eb 100644 --- a/src/i18n/messages/uk.json +++ b/src/i18n/messages/uk.json @@ -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}" } diff --git a/src/i18n/messages/zh_CN.json b/src/i18n/messages/zh_CN.json index 4c8578fcf..09c7da1eb 100644 --- a/src/i18n/messages/zh_CN.json +++ b/src/i18n/messages/zh_CN.json @@ -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}" } diff --git a/src/index.scss b/src/index.scss index 2a3dc0679..75704d4e2 100755 --- a/src/index.scss +++ b/src/index.scss @@ -22,3 +22,4 @@ @import "files-and-videos"; @import "content-tags-drawer/TagBubble"; @import "course-outline/CourseOutline"; +@import "course-unit/CourseUnit";