feat: [FC-0056] create course outline sidebar (#1375)
This commit is contained in:
15
package-lock.json
generated
15
package-lock.json
generated
@@ -31,7 +31,7 @@
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||
"@fortawesome/react-fontawesome": "^0.1.4",
|
||||
"@openedx/frontend-plugin-framework": "^1.0.2",
|
||||
"@openedx/paragon": "^22.1.1",
|
||||
"@openedx/paragon": "^22.3.0",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@reduxjs/toolkit": "1.8.1",
|
||||
"classnames": "2.3.2",
|
||||
@@ -5076,16 +5076,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@openedx/paragon": {
|
||||
"version": "22.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-22.2.1.tgz",
|
||||
"integrity": "sha512-Dd7PzvHwNnUokqbFkuOpugJZ9dHaUBOcYwqAA2aMoN7tgi4xEZWsfDFyP1+se2UPuR7NvNGammEesLAwGQ0Ylw==",
|
||||
"workspaces": [
|
||||
"example",
|
||||
"component-generator",
|
||||
"www",
|
||||
"icons",
|
||||
"dependent-usage-analyzer"
|
||||
],
|
||||
"version": "22.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-22.3.0.tgz",
|
||||
"integrity": "sha512-tyPD14nNHfNPUzlbtspiBYFoGtrYa5+ANAVLA5ZXV1Oqunw4Etf8VMTj0DMII+BlZixBpc3gFuVHNbQBNd42Pw==",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.1.1",
|
||||
"@fortawesome/react-fontawesome": "^0.1.18",
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||
"@fortawesome/react-fontawesome": "^0.1.4",
|
||||
"@openedx/frontend-plugin-framework": "^1.0.2",
|
||||
"@openedx/paragon": "^22.1.1",
|
||||
"@openedx/paragon": "^22.3.0",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@reduxjs/toolkit": "1.8.1",
|
||||
"classnames": "2.3.2",
|
||||
|
||||
@@ -14,7 +14,11 @@ Object {
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseOutline": Object {},
|
||||
"courseOutlineShouldUpdate": false,
|
||||
"courseOutlineStatus": "loading",
|
||||
"courseStatus": "loading",
|
||||
"coursewareOutlineSidebarSettings": Object {},
|
||||
"sequenceId": null,
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
@@ -402,7 +406,11 @@ Object {
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseOutline": Object {},
|
||||
"courseOutlineShouldUpdate": false,
|
||||
"courseOutlineStatus": "loading",
|
||||
"courseStatus": "loading",
|
||||
"coursewareOutlineSidebarSettings": Object {},
|
||||
"sequenceId": null,
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
@@ -671,7 +679,11 @@ Object {
|
||||
},
|
||||
"courseware": Object {
|
||||
"courseId": null,
|
||||
"courseOutline": Object {},
|
||||
"courseOutlineShouldUpdate": false,
|
||||
"courseOutlineStatus": "loading",
|
||||
"courseStatus": "loading",
|
||||
"coursewareOutlineSidebarSettings": Object {},
|
||||
"sequenceId": null,
|
||||
"sequenceMightBeUnit": false,
|
||||
"sequenceStatus": "loading",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
@@ -96,7 +95,7 @@ const SequenceLink = ({
|
||||
icon={fasCheckCircle}
|
||||
fixedWidth
|
||||
className="float-left text-success mt-1"
|
||||
aria-hidden="true"
|
||||
aria-hidden={complete}
|
||||
title={intl.formatMessage(messages.completedAssignment)}
|
||||
/>
|
||||
) : (
|
||||
@@ -104,7 +103,7 @@ const SequenceLink = ({
|
||||
icon={farCheckCircle}
|
||||
fixedWidth
|
||||
className="float-left text-gray-400 mt-1"
|
||||
aria-hidden="true"
|
||||
aria-hidden={complete}
|
||||
title={intl.formatMessage(messages.incompleteAssignment)}
|
||||
/>
|
||||
)}
|
||||
@@ -118,14 +117,14 @@ const SequenceLink = ({
|
||||
</div>
|
||||
</div>
|
||||
{hideFromTOC && (
|
||||
<div className="row w-100 my-2 mx-4 pl-3">
|
||||
<span className="small d-flex">
|
||||
<Icon className="mr-2" src={Block} data-testid="hide-from-toc-sequence-link-icon" />
|
||||
<span data-testid="hide-from-toc-sequence-link-text">
|
||||
{intl.formatMessage(messages.hiddenSequenceLink)}
|
||||
<div className="row w-100 my-2 mx-4 pl-3">
|
||||
<span className="small d-flex">
|
||||
<Icon className="mr-2" src={Block} data-testid="hide-from-toc-sequence-link-icon" />
|
||||
<span data-testid="hide-from-toc-sequence-link-text">
|
||||
{intl.formatMessage(messages.hiddenSequenceLink)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="row w-100 m-0 ml-3 pl-3">
|
||||
<small className="text-body pl-2">
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { breakpoints, useWindowSize } from '@openedx/paragon';
|
||||
|
||||
import { AlertList } from '../../generic/user-messages';
|
||||
|
||||
import Sequence from './sequence';
|
||||
|
||||
import { CelebrationModal, shouldCelebrateOnSectionLoad, WeeklyGoalCelebrationModal } from './celebration';
|
||||
import { AlertList } from '@src/generic/user-messages';
|
||||
import { useModel } from '@src/generic/model-store';
|
||||
import { getCoursewareOutlineSidebarSettings } from '../data/selectors';
|
||||
import { Trigger as CourseOutlineTrigger } from './sidebar/sidebars/course-outline';
|
||||
import Chat from './chat/Chat';
|
||||
import ContentTools from './content-tools';
|
||||
import CourseBreadcrumbs from './CourseBreadcrumbs';
|
||||
import SidebarProvider from './sidebar/SidebarContextProvider';
|
||||
import SidebarTriggers from './sidebar/SidebarTriggers';
|
||||
import NewSidebarProvider from './new-sidebar/SidebarContextProvider';
|
||||
import NewSidebarTriggers from './new-sidebar/SidebarTriggers';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import { CelebrationModal, shouldCelebrateOnSectionLoad, WeeklyGoalCelebrationModal } from './celebration';
|
||||
import CourseBreadcrumbs from './CourseBreadcrumbs';
|
||||
import ContentTools from './content-tools';
|
||||
import Sequence from './sequence';
|
||||
|
||||
const Course = ({
|
||||
courseId,
|
||||
@@ -37,7 +36,8 @@ const Course = ({
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const section = useModel('sections', sequence ? sequence.sectionId : null);
|
||||
const navigationDisabled = sequence?.navigationDisabled ?? false;
|
||||
const { enableNavigationSidebar } = useSelector(getCoursewareOutlineSidebarSettings);
|
||||
const navigationDisabled = enableNavigationSidebar || (sequence?.navigationDisabled ?? false);
|
||||
|
||||
const pageTitleBreadCrumbs = [
|
||||
sequence,
|
||||
@@ -54,7 +54,6 @@ const Course = ({
|
||||
const [weeklyGoalCelebrationOpen, setWeeklyGoalCelebrationOpen] = useState(
|
||||
celebrations && !celebrations.streakLengthToCelebrate && celebrations.weeklyGoal,
|
||||
);
|
||||
const shouldDisplayTriggers = windowWidth >= breakpoints.small.minWidth;
|
||||
const shouldDisplayChat = windowWidth >= breakpoints.medium.minWidth;
|
||||
const daysPerWeek = course?.courseGoals?.selectedGoal?.daysPerWeek;
|
||||
|
||||
@@ -76,7 +75,7 @@ const Course = ({
|
||||
<Helmet>
|
||||
<title>{`${pageTitleBreadCrumbs.join(' | ')} | ${getConfig().SITE_NAME}`}</title>
|
||||
</Helmet>
|
||||
<div className="position-relative d-flex align-items-center mb-4 mt-1">
|
||||
<div className="position-relative d-flex align-items-xl-center mb-4 mt-1 flex-column flex-xl-row">
|
||||
{navigationDisabled || (
|
||||
<>
|
||||
<CourseBreadcrumbs
|
||||
@@ -100,11 +99,10 @@ const Course = ({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{shouldDisplayTriggers && (
|
||||
<>
|
||||
{isNewDiscussionSidebarViewEnabled ? <NewSidebarTriggers /> : <SidebarTriggers /> }
|
||||
</>
|
||||
)}
|
||||
<div className="w-100 d-flex align-items-center">
|
||||
<CourseOutlineTrigger isMobileView />
|
||||
{isNewDiscussionSidebarViewEnabled ? <NewSidebarTriggers /> : <SidebarTriggers /> }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertList topic="sequence" />
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Factory } from 'rosie';
|
||||
import { breakpoints } from '@openedx/paragon';
|
||||
|
||||
import {
|
||||
act, fireEvent, getByRole, initializeTestStore, loadUnit, render, screen, waitFor,
|
||||
fireEvent, getByRole, initializeTestStore, loadUnit, render, screen, waitFor,
|
||||
} from '../../setupTest';
|
||||
import * as celebrationUtils from './celebration/utils';
|
||||
import { handleNextSectionCelebration } from './celebration';
|
||||
@@ -59,7 +59,7 @@ describe('Course', () => {
|
||||
|
||||
it('loads learning sequence', async () => {
|
||||
render(<Course {...mockData} />, { wrapWithRouter: true });
|
||||
expect(screen.getByRole('navigation', { name: 'breadcrumb' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('navigation', { name: 'breadcrumb' })).not.toBeInTheDocument();
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
@@ -142,27 +142,32 @@ describe('Course', () => {
|
||||
|
||||
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
|
||||
expect(notificationTrigger).toBeInTheDocument();
|
||||
expect(notificationTrigger.parentNode).not.toHaveClass('mt-3', { exact: true });
|
||||
expect(notificationTrigger.parentNode).not.toHaveClass('sidebar-active', { exact: true });
|
||||
fireEvent.click(notificationTrigger);
|
||||
expect(notificationTrigger.parentNode).toHaveClass('mt-3');
|
||||
expect(notificationTrigger.parentNode).toHaveClass('sidebar-active');
|
||||
});
|
||||
|
||||
it('handles click to open/close discussions sidebar', async () => {
|
||||
await setupDiscussionSidebar();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('sidebar-DISCUSSIONS')).not.toHaveClass('d-none');
|
||||
});
|
||||
|
||||
const discussionsTrigger = await screen.getByRole('button', { name: /Show discussions tray/i });
|
||||
const discussionsSideBar = await waitFor(() => screen.findByTestId('sidebar-DISCUSSIONS'));
|
||||
expect(discussionsTrigger).toBeInTheDocument();
|
||||
fireEvent.click(discussionsTrigger);
|
||||
|
||||
expect(discussionsSideBar).not.toHaveClass('d-none');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(discussionsTrigger);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('sidebar-DISCUSSIONS')).not.toBeInTheDocument();
|
||||
});
|
||||
await expect(discussionsSideBar).toHaveClass('d-none');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(discussionsTrigger);
|
||||
fireEvent.click(discussionsTrigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
|
||||
});
|
||||
await expect(discussionsSideBar).not.toHaveClass('d-none');
|
||||
});
|
||||
|
||||
it('displays discussions sidebar when unit changes', async () => {
|
||||
@@ -192,8 +197,9 @@ describe('Course', () => {
|
||||
it('handles click to open/close notification tray', async () => {
|
||||
await setupDiscussionSidebar();
|
||||
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).toHaveClass('d-none');
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toBeInTheDocument();
|
||||
fireEvent.click(notificationShowButton);
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toHaveClass('d-none');
|
||||
});
|
||||
|
||||
@@ -204,7 +210,9 @@ describe('Course', () => {
|
||||
{ type: 'vertical' },
|
||||
{ courseId: courseMetadata.id },
|
||||
));
|
||||
const testStore = await initializeTestStore({ courseMetadata, unitBlocks }, false);
|
||||
const testStore = await initializeTestStore({
|
||||
courseMetadata, unitBlocks, enableNavigationSidebar: { enable_navigation_sidebar: false },
|
||||
}, false);
|
||||
const { courseware, models } = testStore.getState();
|
||||
const { courseId, sequenceId } = courseware;
|
||||
const testData = {
|
||||
|
||||
@@ -154,7 +154,7 @@ const CourseBreadcrumbs = ({
|
||||
}, [courseStatus, sequenceStatus, allSequencesInSections]);
|
||||
|
||||
return (
|
||||
<nav aria-label="breadcrumb" className="d-inline-block col-sm-10">
|
||||
<nav aria-label="breadcrumb" className="d-inline-block col-sm-10 mb-3">
|
||||
<ol className="list-unstyled d-flex flex-nowrap align-items-center m-0">
|
||||
<li className="list-unstyled col-auto m-0 p-0">
|
||||
<Link
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
/* eslint-disable no-use-before-define */
|
||||
import React, {
|
||||
useEffect, useState,
|
||||
} from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import {
|
||||
sendTrackEvent,
|
||||
@@ -12,18 +9,19 @@ import {
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useSelector } from 'react-redux';
|
||||
import SequenceExamWrapper from '@edx/frontend-lib-special-exams';
|
||||
import { breakpoints, useWindowSize } from '@openedx/paragon';
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
import PageLoading from '../../../generic/PageLoading';
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import { useSequenceBannerTextAlert, useSequenceEntranceExamAlert } from '../../../alerts/sequence-alerts/hooks';
|
||||
import PageLoading from '@src/generic/PageLoading';
|
||||
import { useModel } from '@src/generic/model-store';
|
||||
import { useSequenceBannerTextAlert, useSequenceEntranceExamAlert } from '@src/alerts/sequence-alerts/hooks';
|
||||
|
||||
import CourseLicense from '../course-license';
|
||||
import Sidebar from '../sidebar/Sidebar';
|
||||
import NewSidebar from '../new-sidebar/Sidebar';
|
||||
import SidebarTriggers from '../sidebar/SidebarTriggers';
|
||||
import NewSidebarTriggers from '../new-sidebar/SidebarTriggers';
|
||||
import {
|
||||
Trigger as CourseOutlineTrigger,
|
||||
Sidebar as CourseOutlineTray,
|
||||
} from '../sidebar/sidebars/course-outline';
|
||||
import messages from './messages';
|
||||
import HiddenAfterDue from './hidden-after-due';
|
||||
import { SequenceNavigation, UnitNavigation } from './sequence-navigation';
|
||||
@@ -51,24 +49,23 @@ const Sequence = ({
|
||||
const unit = useModel('units', unitId);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
const sequenceMightBeUnit = useSelector(state => state.courseware.sequenceMightBeUnit);
|
||||
const shouldDisplayNotificationTriggerInSequence = useWindowSize().width < breakpoints.small.minWidth;
|
||||
|
||||
const handleNext = () => {
|
||||
const nextIndex = sequence.unitIds.indexOf(unitId) + 1;
|
||||
if (nextIndex < sequence.unitIds.length) {
|
||||
const newUnitId = sequence.unitIds[nextIndex];
|
||||
handleNavigate(newUnitId);
|
||||
} else {
|
||||
const newUnitId = sequence.unitIds[nextIndex];
|
||||
handleNavigate(newUnitId);
|
||||
|
||||
if (nextIndex >= sequence.unitIds.length) {
|
||||
nextSequenceHandler();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
const previousIndex = sequence.unitIds.indexOf(unitId) - 1;
|
||||
if (previousIndex >= 0) {
|
||||
const newUnitId = sequence.unitIds[previousIndex];
|
||||
handleNavigate(newUnitId);
|
||||
} else {
|
||||
const newUnitId = sequence.unitIds[previousIndex];
|
||||
handleNavigate(newUnitId);
|
||||
|
||||
if (previousIndex < 0) {
|
||||
previousSequenceHandler();
|
||||
}
|
||||
};
|
||||
@@ -150,7 +147,9 @@ const Sequence = ({
|
||||
const defaultContent = (
|
||||
<>
|
||||
<div className="sequence-container d-inline-flex flex-row w-100">
|
||||
<div className={classNames('sequence w-100', { 'position-relative': shouldDisplayNotificationTriggerInSequence })}>
|
||||
<CourseOutlineTrigger />
|
||||
<CourseOutlineTray />
|
||||
<div className="sequence w-100">
|
||||
<div className="sequence-navigation-container">
|
||||
<SequenceNavigation
|
||||
sequenceId={sequenceId}
|
||||
@@ -169,9 +168,6 @@ const Sequence = ({
|
||||
handlePrevious();
|
||||
}}
|
||||
/>
|
||||
{shouldDisplayNotificationTriggerInSequence && (
|
||||
isNewDiscussionSidebarViewEnabled ? <NewSidebarTriggers /> : <SidebarTriggers />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="unit-container flex-grow-1">
|
||||
@@ -183,18 +179,18 @@ const Sequence = ({
|
||||
unitLoadedHandler={handleUnitLoaded}
|
||||
/>
|
||||
{unitHasLoaded && (
|
||||
<UnitNavigation
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
onClickPrevious={() => {
|
||||
logEvent('edx.ui.lms.sequence.previous_selected', 'bottom');
|
||||
handlePrevious();
|
||||
}}
|
||||
onClickNext={() => {
|
||||
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
|
||||
handleNext();
|
||||
}}
|
||||
/>
|
||||
<UnitNavigation
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
onClickPrevious={() => {
|
||||
logEvent('edx.ui.lms.sequence.previous_selected', 'bottom');
|
||||
handlePrevious();
|
||||
}}
|
||||
onClickNext={() => {
|
||||
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
|
||||
handleNext();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@ jest.mock('@edx/frontend-lib-special-exams/dist/data/thunks.js', () => ({
|
||||
|
||||
describe('Sequence', () => {
|
||||
let mockData;
|
||||
let defaultContextValue;
|
||||
const courseMetadata = Factory.build('courseMetadata');
|
||||
const unitBlocks = Array.from({ length: 3 }).map(() => Factory.build(
|
||||
'block',
|
||||
@@ -38,12 +39,29 @@ describe('Sequence', () => {
|
||||
toggleNotificationTray: () => {},
|
||||
setNotificationStatus: () => {},
|
||||
};
|
||||
defaultContextValue = { courseId: mockData.courseId, currentSidebar: null, toggleSidebar: jest.fn() };
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
global.innerWidth = breakpoints.extraLarge.minWidth;
|
||||
});
|
||||
|
||||
const SidebarWrapper = ({ contextValue = defaultContextValue, overrideData = {} }) => (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<Sequence {...({ ...mockData, ...overrideData })} />
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
|
||||
SidebarWrapper.defaultProps = {
|
||||
contextValue: defaultContextValue,
|
||||
overrideData: {},
|
||||
};
|
||||
|
||||
SidebarWrapper.propTypes = {
|
||||
contextValue: PropTypes.shape({}),
|
||||
overrideData: PropTypes.shape({}),
|
||||
};
|
||||
|
||||
it('renders correctly without data', async () => {
|
||||
const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false);
|
||||
render(
|
||||
@@ -77,7 +95,7 @@ describe('Sequence', () => {
|
||||
courseMetadata, unitBlocks, sequenceBlocks, sequenceMetadata,
|
||||
}, false);
|
||||
const { container } = render(
|
||||
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
|
||||
<SidebarWrapper overrideData={{ sequenceId: sequenceBlocks[0].id }} />,
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
);
|
||||
|
||||
@@ -134,9 +152,9 @@ describe('Sequence', () => {
|
||||
});
|
||||
|
||||
it('handles loading unit', async () => {
|
||||
render(<Sequence {...mockData} />, { wrapWithRouter: true });
|
||||
render(<SidebarWrapper />, { wrapWithRouter: true });
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
// `Previous`, `Bookmark` and `Close Tray` buttons
|
||||
// `Previous`, `Prerequisite` and `Close Tray` buttons.
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3);
|
||||
// Renders `Next` button plus one button for each unit.
|
||||
expect(screen.getAllByRole('link')).toHaveLength(1 + unitBlocks.length);
|
||||
@@ -174,13 +192,13 @@ describe('Sequence', () => {
|
||||
sequenceId: sequenceBlocks[1].id,
|
||||
previousSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
render(<SidebarWrapper overrideData={testData} />, { store: testStore, wrapWithRouter: true });
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
|
||||
const sequencePreviousButton = screen.getByRole('link', { name: /previous/i });
|
||||
fireEvent.click(sequencePreviousButton);
|
||||
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(1);
|
||||
expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.previous_selected', {
|
||||
current_tab: 1,
|
||||
id: testData.unitId,
|
||||
@@ -194,8 +212,8 @@ describe('Sequence', () => {
|
||||
.filter(button => button !== sequencePreviousButton)[0];
|
||||
fireEvent.click(unitPreviousButton);
|
||||
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(3);
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(3, 'edx.ui.lms.sequence.previous_selected', {
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.sequence.previous_selected', {
|
||||
current_tab: 1,
|
||||
id: testData.unitId,
|
||||
tab_count: unitBlocks.length,
|
||||
@@ -210,7 +228,7 @@ describe('Sequence', () => {
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
nextSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
render(<SidebarWrapper overrideData={testData} />, { store: testStore, wrapWithRouter: true });
|
||||
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
|
||||
|
||||
const sequenceNextButton = screen.getByRole('link', { name: /next/i });
|
||||
@@ -229,8 +247,8 @@ describe('Sequence', () => {
|
||||
.filter(button => button !== sequenceNextButton)[0];
|
||||
fireEvent.click(unitNextButton);
|
||||
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(3);
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(3, 'edx.ui.lms.sequence.next_selected', {
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.sequence.next_selected', {
|
||||
current_tab: unitBlocks.length,
|
||||
id: testData.unitId,
|
||||
tab_count: unitBlocks.length,
|
||||
@@ -248,7 +266,7 @@ describe('Sequence', () => {
|
||||
previousSequenceHandler: jest.fn(),
|
||||
nextSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
render(<SidebarWrapper overrideData={testData} />, { store: testStore, wrapWithRouter: true });
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: /previous/i }));
|
||||
@@ -261,7 +279,7 @@ describe('Sequence', () => {
|
||||
// Therefore the next unit will still be `the initial one + 1`.
|
||||
expect(testData.unitNavigationHandler).toHaveBeenNthCalledWith(2, unitBlocks[unitNumber + 1].id);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(3);
|
||||
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('handles the `Previous` buttons for the first unit in the first sequence', async () => {
|
||||
@@ -272,7 +290,7 @@ describe('Sequence', () => {
|
||||
unitNavigationHandler: jest.fn(),
|
||||
previousSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
render(<SidebarWrapper overrideData={testData} />, { store: testStore, wrapWithRouter: true });
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
|
||||
@@ -280,7 +298,7 @@ describe('Sequence', () => {
|
||||
|
||||
expect(testData.previousSequenceHandler).not.toHaveBeenCalled();
|
||||
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
|
||||
expect(sendTrackEvent).toHaveBeenCalled();
|
||||
expect(sendTrackEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles the `Next` buttons for the last unit in the last sequence', async () => {
|
||||
@@ -291,7 +309,7 @@ describe('Sequence', () => {
|
||||
unitNavigationHandler: jest.fn(),
|
||||
nextSequenceHandler: jest.fn(),
|
||||
};
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
render(<SidebarWrapper overrideData={testData} />, { store: testStore, wrapWithRouter: true });
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
|
||||
@@ -299,7 +317,7 @@ describe('Sequence', () => {
|
||||
|
||||
expect(testData.nextSequenceHandler).not.toHaveBeenCalled();
|
||||
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
|
||||
expect(sendTrackEvent).toHaveBeenCalled();
|
||||
expect(sendTrackEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles the navigation buttons for empty sequence', async () => {
|
||||
@@ -333,51 +351,37 @@ describe('Sequence', () => {
|
||||
nextSequenceHandler: jest.fn(),
|
||||
};
|
||||
|
||||
render(<Sequence {...testData} />, { store: innerTestStore, wrapWithRouter: true });
|
||||
render(<SidebarWrapper overrideData={testData} />, { store: innerTestStore, wrapWithRouter: true });
|
||||
loadUnit();
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
|
||||
|
||||
screen.getAllByRole('link', { name: /previous/i }).forEach(button => fireEvent.click(button));
|
||||
expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2);
|
||||
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
|
||||
expect(testData.unitNavigationHandler).toHaveBeenCalledTimes(2);
|
||||
|
||||
screen.getAllByRole('link', { name: /next/i }).forEach(button => fireEvent.click(button));
|
||||
expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2);
|
||||
expect(testData.unitNavigationHandler).not.toHaveBeenCalled();
|
||||
expect(testData.unitNavigationHandler).toHaveBeenCalledTimes(4);
|
||||
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(1, 'edx.ui.course.upgrade.old_sidebar.notifications', {
|
||||
course_end: undefined,
|
||||
course_modes: undefined,
|
||||
course_start: undefined,
|
||||
courserun_key: undefined,
|
||||
enrollment_end: undefined,
|
||||
enrollment_mode: undefined,
|
||||
enrollment_start: undefined,
|
||||
is_upgrade_notification_visible: false,
|
||||
name: 'Old Sidebar Notification Tray',
|
||||
org_key: undefined,
|
||||
username: undefined,
|
||||
verification_status: undefined,
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(1, 'edx.ui.lms.sequence.previous_selected', {
|
||||
current_tab: 1,
|
||||
id: testData.unitId,
|
||||
tab_count: 0,
|
||||
widget_placement: 'top',
|
||||
});
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.sequence.previous_selected', {
|
||||
current_tab: 1,
|
||||
id: testData.unitId,
|
||||
tab_count: 0,
|
||||
widget_placement: 'top',
|
||||
});
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(3, 'edx.ui.lms.sequence.previous_selected', {
|
||||
current_tab: 1,
|
||||
id: testData.unitId,
|
||||
tab_count: 0,
|
||||
widget_placement: 'bottom',
|
||||
});
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(4, 'edx.ui.lms.sequence.next_selected', {
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(3, 'edx.ui.lms.sequence.next_selected', {
|
||||
current_tab: 1,
|
||||
id: testData.unitId,
|
||||
tab_count: 0,
|
||||
widget_placement: 'top',
|
||||
});
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(5, 'edx.ui.lms.sequence.next_selected', {
|
||||
expect(sendTrackEvent).toHaveBeenNthCalledWith(4, 'edx.ui.lms.sequence.next_selected', {
|
||||
current_tab: 1,
|
||||
id: testData.unitId,
|
||||
tab_count: 0,
|
||||
@@ -395,7 +399,7 @@ describe('Sequence', () => {
|
||||
sequenceId: sequenceBlocks[0].id,
|
||||
unitNavigationHandler: jest.fn(),
|
||||
};
|
||||
render(<Sequence {...testData} />, { store: testStore, wrapWithRouter: true });
|
||||
render(<SidebarWrapper overrideData={testData} />, { store: testStore, wrapWithRouter: true });
|
||||
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: targetUnit.display_name }));
|
||||
@@ -410,16 +414,6 @@ describe('Sequence', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const SidebarWrapper = ({ contextValue }) => (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<Sequence {...mockData} />
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
|
||||
SidebarWrapper.propTypes = {
|
||||
contextValue: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
describe('notification feature', () => {
|
||||
it('renders notification tray in sequence', async () => {
|
||||
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: () => null }} />, { wrapWithRouter: true });
|
||||
@@ -431,7 +425,7 @@ describe('Sequence', () => {
|
||||
render(<SidebarWrapper contextValue={{ courseId: mockData.courseId, currentSidebar: 'NOTIFICATIONS', toggleSidebar: toggleNotificationTray }} />, { wrapWithRouter: true });
|
||||
const notificationCloseIconButton = await screen.findByRole('button', { name: /Close notification tray/i });
|
||||
fireEvent.click(notificationCloseIconButton);
|
||||
expect(toggleNotificationTray).toHaveBeenCalledTimes(1);
|
||||
expect(toggleNotificationTray).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not render notification tray in sequence by default if in responsive view', async () => {
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import React from 'react';
|
||||
import { SIDEBAR_ORDER, SIDEBARS } from './sidebars';
|
||||
import { useContext } from 'react';
|
||||
|
||||
const Sidebar = () => (
|
||||
<>
|
||||
{
|
||||
SIDEBAR_ORDER.map((sideBarId) => {
|
||||
const SidebarToRender = SIDEBARS[sideBarId].Sidebar;
|
||||
return <SidebarToRender key={sideBarId} />;
|
||||
})
|
||||
}
|
||||
</>
|
||||
);
|
||||
import SidebarContext from './SidebarContext';
|
||||
import { SIDEBARS } from './sidebars';
|
||||
|
||||
const Sidebar = () => {
|
||||
const { currentSidebar } = useContext(SidebarContext);
|
||||
|
||||
if (!currentSidebar || !SIDEBARS[currentSidebar]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const SidebarToRender = SIDEBARS[currentSidebar].Sidebar;
|
||||
|
||||
return (
|
||||
<SidebarToRender />
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { breakpoints, useWindowSize } from '@openedx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, {
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
useEffect, useState, useMemo, useCallback,
|
||||
} from 'react';
|
||||
|
||||
import { useModel } from '../../../generic/model-store';
|
||||
import { getLocalStorage, setLocalStorage } from '../../../data/localStorage';
|
||||
import { useModel } from '@src/generic/model-store';
|
||||
import { getLocalStorage, setLocalStorage } from '@src/data/localStorage';
|
||||
import { getCoursewareOutlineSidebarSettings } from '../../data/selectors';
|
||||
|
||||
import * as discussionsSidebar from './sidebars/discussions';
|
||||
import * as notificationsSidebar from './sidebars/notifications';
|
||||
import SidebarContext from './SidebarContext';
|
||||
import { SIDEBARS } from './sidebars';
|
||||
|
||||
@@ -16,18 +20,35 @@ const SidebarProvider = ({
|
||||
children,
|
||||
}) => {
|
||||
const { verifiedMode } = useModel('courseHomeMeta', courseId);
|
||||
const shouldDisplayFullScreen = useWindowSize().width < breakpoints.large.minWidth;
|
||||
const shouldDisplaySidebarOpen = useWindowSize().width > breakpoints.medium.minWidth;
|
||||
const topic = useModel('discussionTopics', unitId);
|
||||
const isUnitHasDiscussionTopics = topic?.id && topic?.enabledInContext;
|
||||
const shouldDisplayFullScreen = useWindowSize().width < breakpoints.extraLarge.minWidth;
|
||||
const shouldDisplaySidebarOpen = useWindowSize().width > breakpoints.extraLarge.minWidth;
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const initialSidebar = (shouldDisplaySidebarOpen || query.get('sidebar') === 'true') ? SIDEBARS.DISCUSSIONS.ID : null;
|
||||
const { alwaysOpenAuxiliarySidebar } = useSelector(getCoursewareOutlineSidebarSettings);
|
||||
const isInitiallySidebarOpen = shouldDisplaySidebarOpen || query.get('sidebar') === 'true';
|
||||
|
||||
let initialSidebar = null;
|
||||
if (isInitiallySidebarOpen && alwaysOpenAuxiliarySidebar) {
|
||||
initialSidebar = isUnitHasDiscussionTopics
|
||||
? SIDEBARS[discussionsSidebar.ID].ID
|
||||
: verifiedMode && SIDEBARS[notificationsSidebar.ID].ID;
|
||||
}
|
||||
const [currentSidebar, setCurrentSidebar] = useState(initialSidebar);
|
||||
const [notificationStatus, setNotificationStatus] = useState(getLocalStorage(`notificationStatus.${courseId}`));
|
||||
const [upgradeNotificationCurrentState, setUpgradeNotificationCurrentState] = useState(getLocalStorage(`upgradeNotificationCurrentState.${courseId}`));
|
||||
|
||||
useEffect(() => {
|
||||
// if the user hasn't purchased the course, show the notifications sidebar
|
||||
setCurrentSidebar(verifiedMode ? SIDEBARS.NOTIFICATIONS.ID : SIDEBARS.DISCUSSIONS.ID);
|
||||
}, [unitId]);
|
||||
if (initialSidebar && currentSidebar !== initialSidebar) {
|
||||
setCurrentSidebar(initialSidebar);
|
||||
}
|
||||
}, [unitId, topic]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialSidebar) {
|
||||
setCurrentSidebar(initialSidebar);
|
||||
}
|
||||
}, [shouldDisplaySidebarOpen]);
|
||||
|
||||
const onNotificationSeen = useCallback(() => {
|
||||
setNotificationStatus('inactive');
|
||||
@@ -40,6 +61,7 @@ const SidebarProvider = ({
|
||||
}, [currentSidebar]);
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
initialSidebar,
|
||||
toggleSidebar,
|
||||
onNotificationSeen,
|
||||
setNotificationStatus,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useContext } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import React, { useContext } from 'react';
|
||||
import { breakpoints, useWindowSize } from '@openedx/paragon';
|
||||
import SidebarContext from './SidebarContext';
|
||||
import { SIDEBAR_ORDER, SIDEBARS } from './sidebars';
|
||||
@@ -19,7 +19,7 @@ const SidebarTriggers = () => {
|
||||
const isActive = sidebarId === currentSidebar;
|
||||
return (
|
||||
<div
|
||||
className={classNames({ 'mt-3': !isMobileView, 'border-primary-700': isActive })}
|
||||
className={classNames({ 'ml-1': !isMobileView, 'border-primary-700 sidebar-active': isActive })}
|
||||
style={{ borderBottom: isActive ? '2px solid' : null }}
|
||||
key={sidebarId}
|
||||
>
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Icon, IconButton } from '@openedx/paragon';
|
||||
import { ArrowBackIos, Close } from '@openedx/paragon/icons';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
import { useEventListener } from '../../../../generic/hooks';
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { useEventListener } from '@src/generic/hooks';
|
||||
import messages from '../../messages';
|
||||
import SidebarContext from '../SidebarContext';
|
||||
|
||||
@@ -36,14 +36,15 @@ const SidebarBase = ({
|
||||
|
||||
return (
|
||||
<section
|
||||
className={classNames('ml-0 ml-lg-4 border border-light-400 rounded-sm h-auto align-top zindex-0', {
|
||||
className={classNames('ml-0 border border-light-400 rounded-sm h-auto align-top zindex-0', {
|
||||
'bg-white m-0 border-0 fixed-top vh-100 rounded-0': shouldDisplayFullScreen,
|
||||
'min-vh-100': !shouldDisplayFullScreen,
|
||||
'd-none': currentSidebar !== sidebarId,
|
||||
}, className)}
|
||||
data-testid={`sidebar-${sidebarId}`}
|
||||
style={{ width: shouldDisplayFullScreen ? '100%' : width }}
|
||||
style={{ minWidth: shouldDisplayFullScreen ? '100%' : width }}
|
||||
aria-label={ariaLabel}
|
||||
id="course-sidebar"
|
||||
>
|
||||
{shouldDisplayFullScreen ? (
|
||||
<div
|
||||
@@ -52,7 +53,6 @@ const SidebarBase = ({
|
||||
onKeyDown={() => toggleSidebar(null)}
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
alt={intl.formatMessage(messages.responsiveCloseNotificationTray)}
|
||||
>
|
||||
<Icon src={ArrowBackIos} />
|
||||
<span className="font-weight-bold m-2 d-inline-block">
|
||||
@@ -62,12 +62,12 @@ const SidebarBase = ({
|
||||
) : null}
|
||||
{showTitleBar && (
|
||||
<>
|
||||
<div className="d-flex align-items-center">
|
||||
<span className="p-2.5 d-inline-block">{title}</span>
|
||||
<div className="d-flex align-items-center mb-2">
|
||||
<strong className="p-2.5 d-inline-block course-sidebar-title">{title}</strong>
|
||||
{shouldDisplayFullScreen
|
||||
? null
|
||||
: (
|
||||
<div className="d-inline-flex mr-2 mt-1.5 ml-auto">
|
||||
<div className="d-inline-flex mr-2 ml-auto">
|
||||
<IconButton
|
||||
src={Close}
|
||||
size="sm"
|
||||
@@ -79,7 +79,6 @@ const SidebarBase = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="py-1 bg-gray-100 border-top border-bottom border-light-400" />
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
@@ -92,16 +91,15 @@ SidebarBase.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
ariaLabel: PropTypes.string.isRequired,
|
||||
sidebarId: PropTypes.string.isRequired,
|
||||
className: PropTypes.string,
|
||||
className: PropTypes.string.isRequired,
|
||||
children: PropTypes.element.isRequired,
|
||||
showTitleBar: PropTypes.bool,
|
||||
width: PropTypes.string,
|
||||
};
|
||||
|
||||
SidebarBase.defaultProps = {
|
||||
width: '31rem',
|
||||
width: '410px',
|
||||
showTitleBar: true,
|
||||
className: '',
|
||||
};
|
||||
|
||||
export default injectIntl(SidebarBase);
|
||||
|
||||
6
src/courseware/course/sidebar/common/SidebarBase.scss
Normal file
6
src/courseware/course/sidebar/common/SidebarBase.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
#course-sidebar {
|
||||
@media (max-width: -1 + map-get($grid-breakpoints, "lg")) {
|
||||
overflow-y: scroll;
|
||||
padding: 0 .625rem !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Button, useToggle, IconButton } from '@openedx/paragon';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
MenuOpen as MenuOpenIcon,
|
||||
ChevronLeft as ChevronLeftIcon,
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
import { useModel } from '@src/generic/model-store';
|
||||
import { LOADING, LOADED } from '@src/course-home/data/slice';
|
||||
import PageLoading from '@src/generic/PageLoading';
|
||||
import {
|
||||
getSequenceId,
|
||||
getCourseOutline,
|
||||
getCourseOutlineStatus,
|
||||
getCourseOutlineShouldUpdate,
|
||||
} from '../../../../data/selectors';
|
||||
import { getCourseOutlineStructure } from '../../../../data/thunks';
|
||||
import SidebarSection from './components/SidebarSection';
|
||||
import SidebarSequence from './components/SidebarSequence';
|
||||
import { ID } from './constants';
|
||||
import { useCourseOutlineSidebar } from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
const CourseOutlineTray = ({ intl }) => {
|
||||
const [selectedSection, setSelectedSection] = useState(null);
|
||||
const [isDisplaySequenceLevel, setDisplaySequenceLevel, setDisplaySectionLevel] = useToggle(true);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const activeSequenceId = useSelector(getSequenceId);
|
||||
const { sections = {}, sequences = {} } = useSelector(getCourseOutline);
|
||||
const courseOutlineStatus = useSelector(getCourseOutlineStatus);
|
||||
const courseOutlineShouldUpdate = useSelector(getCourseOutlineShouldUpdate);
|
||||
|
||||
const {
|
||||
courseId,
|
||||
unitId,
|
||||
isEnabledSidebar,
|
||||
currentSidebar,
|
||||
handleToggleCollapse,
|
||||
isActiveEntranceExam,
|
||||
shouldDisplayFullScreen,
|
||||
} = useCourseOutlineSidebar();
|
||||
|
||||
const {
|
||||
sectionId: activeSectionId,
|
||||
} = useModel('sequences', activeSequenceId);
|
||||
|
||||
const sectionsIds = Object.keys(sections);
|
||||
const sequenceIds = sections[selectedSection || activeSectionId]?.sequenceIds || [];
|
||||
const backButtonTitle = sections[selectedSection || activeSectionId]?.title;
|
||||
|
||||
const handleBackToSectionLevel = () => {
|
||||
setDisplaySectionLevel();
|
||||
setSelectedSection(null);
|
||||
};
|
||||
|
||||
const handleSelectSection = (id) => {
|
||||
setDisplaySequenceLevel();
|
||||
setSelectedSection(id);
|
||||
};
|
||||
|
||||
const sidebarHeading = (
|
||||
<div className="outline-sidebar-heading-wrapper sticky d-flex justify-content-between align-items-center bg-light-200 p-2.5 pl-4">
|
||||
{isDisplaySequenceLevel && backButtonTitle ? (
|
||||
<Button
|
||||
variant="link"
|
||||
iconBefore={ChevronLeftIcon}
|
||||
className="outline-sidebar-heading p-0 mb-0 text-left text-dark-500"
|
||||
onClick={handleBackToSectionLevel}
|
||||
>
|
||||
{backButtonTitle}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="outline-sidebar-heading mb-0 h4 text-dark-500">
|
||||
{intl.formatMessage(messages.courseOutlineTitle)}
|
||||
</span>
|
||||
)}
|
||||
<IconButton
|
||||
alt={intl.formatMessage(messages.toggleCourseOutlineTrigger)}
|
||||
className="outline-sidebar-toggle-btn flex-shrink-0 text-dark bg-light-200"
|
||||
iconAs={MenuOpenIcon}
|
||||
onClick={handleToggleCollapse}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if ((isEnabledSidebar && courseOutlineStatus !== LOADED) || courseOutlineShouldUpdate) {
|
||||
dispatch(getCourseOutlineStructure(courseId));
|
||||
}
|
||||
}, [courseId, isEnabledSidebar, courseOutlineShouldUpdate]);
|
||||
|
||||
if (!isEnabledSidebar || isActiveEntranceExam || currentSidebar !== ID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (courseOutlineStatus === LOADING) {
|
||||
return (
|
||||
<div className={classNames('outline-sidebar-wrapper', {
|
||||
'flex-shrink-0 mr-4 h-auto': !shouldDisplayFullScreen,
|
||||
'bg-white m-0 fixed-top w-100 vh-100': shouldDisplayFullScreen,
|
||||
})}
|
||||
>
|
||||
<section className="outline-sidebar w-100">
|
||||
{sidebarHeading}
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages.loading)}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('outline-sidebar-wrapper', {
|
||||
'flex-shrink-0 mr-4 h-auto': !shouldDisplayFullScreen,
|
||||
'bg-white m-0 fixed-top w-100 vh-100': shouldDisplayFullScreen,
|
||||
})}
|
||||
>
|
||||
<section className="outline-sidebar w-100">
|
||||
{sidebarHeading}
|
||||
<ol id="outline-sidebar-outline" className="list-unstyled">
|
||||
{isDisplaySequenceLevel
|
||||
? sequenceIds.map((sequenceId) => (
|
||||
<SidebarSequence
|
||||
key={sequenceId}
|
||||
courseId={courseId}
|
||||
sequence={sequences[sequenceId]}
|
||||
defaultOpen={sequenceId === activeSequenceId}
|
||||
activeUnitId={unitId}
|
||||
/>
|
||||
))
|
||||
: sectionsIds.map((sectionId) => (
|
||||
<SidebarSection
|
||||
key={sectionId}
|
||||
courseId={courseId}
|
||||
section={sections[sectionId]}
|
||||
handleSelectSection={handleSelectSection}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CourseOutlineTray.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
CourseOutlineTray.ID = ID;
|
||||
|
||||
export default injectIntl(CourseOutlineTray);
|
||||
@@ -0,0 +1,104 @@
|
||||
.outline-sidebar-wrapper {
|
||||
width: 32.125rem;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.outline-sidebar {
|
||||
@media (min-width: map-get($grid-breakpoints, "xl")) {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.outline-sidebar-heading-wrapper {
|
||||
border: 1px solid #d7d3d1;
|
||||
align-self: flex-start;
|
||||
|
||||
&.sticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.outline-sidebar-heading {
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
}
|
||||
|
||||
.course-sidebar-section {
|
||||
background: $white;
|
||||
border: 1px solid #d7d3d1;
|
||||
|
||||
button {
|
||||
line-height: 1.75rem;
|
||||
|
||||
&.focus::before,
|
||||
&:focus::before {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.outline-sidebar-toggle-btn {
|
||||
font-size: 1.5rem;
|
||||
|
||||
.collapsed & {
|
||||
transform: scale(-1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#outline-sidebar-outline {
|
||||
margin-top: -1px;
|
||||
|
||||
@media (min-width: map-get($grid-breakpoints, "xl")) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
|
||||
.collapsible-trigger {
|
||||
border-radius: 0;
|
||||
padding: map-get($spacers, 3\.5) map-get($spacers, 4) map-get($spacers, 3\.5) map-get($spacers, 5);
|
||||
|
||||
@media (max-width: -1 + map-get($grid-breakpoints, "sm")) {
|
||||
padding-left: map-get($spacers, 4);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $light-500;
|
||||
}
|
||||
|
||||
.collapsible-icon {
|
||||
margin-inline-start: initial;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child .pgn_collapsible {
|
||||
@extend .mb-0;
|
||||
}
|
||||
}
|
||||
|
||||
.collapsible-body {
|
||||
padding: 0;
|
||||
|
||||
ol li > a {
|
||||
padding: map-get($spacers, 3\.5) map-get($spacers, 4) map-get($spacers, 3\.5) map-get($spacers, 5\.5);
|
||||
|
||||
@media (max-width: -1 + map-get($grid-breakpoints, "sm")) {
|
||||
padding-left: map-get($spacers, 4\.5);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
background-color: $light-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { initializeTestStore } from '@src/setupTest';
|
||||
import courseOutlineMessages from '@src/course-home/outline-tab/messages';
|
||||
import SidebarContext from '../../SidebarContext';
|
||||
import CourseOutlineTray from './CourseOutlineTray';
|
||||
import { ID as outlineSidebarId } from './constants';
|
||||
import messages from './messages';
|
||||
|
||||
describe('<CourseOutlineTray />', () => {
|
||||
let store;
|
||||
let section = {};
|
||||
let sequence = {};
|
||||
let unit;
|
||||
let unitId;
|
||||
let courseId;
|
||||
let mockData;
|
||||
|
||||
const initTestStore = async (options) => {
|
||||
store = await initializeTestStore(options);
|
||||
const state = store.getState();
|
||||
courseId = state.courseware.courseId;
|
||||
[unitId] = Object.keys(state.models.units);
|
||||
|
||||
if (Object.keys(state.courseware.courseOutline).length) {
|
||||
const [activeSequenceId] = Object.keys(state.courseware.courseOutline.sequences);
|
||||
sequence = state.courseware.courseOutline.sequences[activeSequenceId];
|
||||
const activeSectionId = Object.keys(state.courseware.courseOutline.sections)[0];
|
||||
section = state.courseware.courseOutline.sections[activeSectionId];
|
||||
[unitId] = sequence.unitIds;
|
||||
unit = state.courseware.courseOutline.units[unitId];
|
||||
}
|
||||
|
||||
mockData = {
|
||||
courseId,
|
||||
unitId,
|
||||
currentSidebar: outlineSidebarId,
|
||||
toggleSidebar: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
function renderWithProvider(testData = {}) {
|
||||
const { container } = render(
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<IntlProvider locale="en">
|
||||
<SidebarContext.Provider value={{ ...mockData, ...testData }}>
|
||||
<MemoryRouter>
|
||||
<CourseOutlineTray />
|
||||
</MemoryRouter>
|
||||
</SidebarContext.Provider>
|
||||
</IntlProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
return container;
|
||||
}
|
||||
|
||||
it('renders correctly when course outline is loading', async () => {
|
||||
await initTestStore({ excludeFetchOutlineSidebar: true });
|
||||
renderWithProvider();
|
||||
|
||||
expect(screen.getByText(messages.loading.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.courseOutlineTitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Course Outline' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('doesn\'t render when outline sidebar is disabled', async () => {
|
||||
await initTestStore({ enableNavigationSidebar: { enable_navigation_sidebar: false } });
|
||||
renderWithProvider();
|
||||
|
||||
await expect(screen.queryByText(messages.loading.defaultMessage)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: section.title })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: messages.toggleCourseOutlineTrigger.defaultMessage })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correctly when course outline is loaded', async () => {
|
||||
await initTestStore();
|
||||
renderWithProvider();
|
||||
|
||||
await expect(screen.queryByText(messages.loading.defaultMessage)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: section.title })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: messages.toggleCourseOutlineTrigger.defaultMessage })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: `${sequence.title} , ${courseOutlineMessages.incompleteAssignment.defaultMessage}` })).toBeInTheDocument();
|
||||
expect(screen.getByText(unit.title)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('collapses sidebar correctly when toggle button is clicked', async () => {
|
||||
const mockToggleSidebar = jest.fn();
|
||||
await initTestStore();
|
||||
renderWithProvider({ toggleSidebar: mockToggleSidebar });
|
||||
|
||||
const collapseBtn = screen.getByRole('button', { name: messages.toggleCourseOutlineTrigger.defaultMessage });
|
||||
const sidebarBackBtn = screen.queryByRole('button', { name: section.title });
|
||||
expect(sidebarBackBtn).toBeInTheDocument();
|
||||
expect(collapseBtn).toBeInTheDocument();
|
||||
|
||||
userEvent.click(collapseBtn);
|
||||
expect(mockToggleSidebar).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('navigates to section or sequence level correctly on click by back/section button', async () => {
|
||||
await initTestStore();
|
||||
renderWithProvider();
|
||||
|
||||
const sidebarBackBtn = screen.queryByRole('button', { name: section.title });
|
||||
expect(sidebarBackBtn).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: `${sequence.title} , ${courseOutlineMessages.incompleteAssignment.defaultMessage}` })).toBeInTheDocument();
|
||||
|
||||
userEvent.click(sidebarBackBtn);
|
||||
expect(sidebarBackBtn).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(messages.courseOutlineTitle.defaultMessage)).toBeInTheDocument();
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: `${section.title} , ${courseOutlineMessages.incompleteSection.defaultMessage}` }));
|
||||
expect(screen.queryByRole('button', { name: section.title })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { IconButton } from '@openedx/paragon';
|
||||
import { MenuOpen as MenuOpenIcon } from '@openedx/paragon/icons';
|
||||
|
||||
import { useCourseOutlineSidebar } from './hooks';
|
||||
import { ID } from './constants';
|
||||
import messages from './messages';
|
||||
|
||||
const CourseOutlineTrigger = ({ intl, isMobileView }) => {
|
||||
const {
|
||||
currentSidebar,
|
||||
shouldDisplayFullScreen,
|
||||
handleToggleCollapse,
|
||||
isActiveEntranceExam,
|
||||
isEnabledSidebar,
|
||||
} = useCourseOutlineSidebar();
|
||||
|
||||
const isDisplayForDesktopView = !isMobileView && !shouldDisplayFullScreen && currentSidebar !== ID;
|
||||
const isDisplayForMobileView = isMobileView && shouldDisplayFullScreen;
|
||||
|
||||
if ((!isDisplayForDesktopView && !isDisplayForMobileView) || !isEnabledSidebar || isActiveEntranceExam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('outline-sidebar-heading-wrapper bg-light-200 collapsed', {
|
||||
'flex-shrink-0 mr-4 p-2.5 sticky': isDisplayForDesktopView,
|
||||
'p-0': isDisplayForMobileView,
|
||||
})}
|
||||
>
|
||||
<IconButton
|
||||
alt={intl.formatMessage(messages.toggleCourseOutlineTrigger)}
|
||||
className="outline-sidebar-toggle-btn flex-shrink-0 text-dark bg-light-200 rounded-0"
|
||||
iconAs={MenuOpenIcon}
|
||||
onClick={handleToggleCollapse}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CourseOutlineTrigger.defaultProps = {
|
||||
isMobileView: false,
|
||||
};
|
||||
|
||||
CourseOutlineTrigger.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
isMobileView: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseOutlineTrigger);
|
||||
@@ -0,0 +1,109 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { initializeTestStore } from '@src/setupTest';
|
||||
import SidebarContext from '../../SidebarContext';
|
||||
import { ID as discussionSidebarId } from '../discussions/DiscussionsTrigger';
|
||||
import CourseOutlineTrigger from './CourseOutlineTrigger';
|
||||
import { ID as outlineSidebarId } from './constants';
|
||||
import messages from './messages';
|
||||
|
||||
describe('<CourseOutlineTrigger />', () => {
|
||||
let mockData;
|
||||
let courseId;
|
||||
let unitId;
|
||||
let store;
|
||||
|
||||
const initTestStore = async (options) => {
|
||||
store = await initializeTestStore(options);
|
||||
const state = store.getState();
|
||||
courseId = state.courseware.courseId;
|
||||
[unitId] = Object.keys(state.models.units);
|
||||
|
||||
mockData = {
|
||||
courseId,
|
||||
unitId,
|
||||
currentSidebar: discussionSidebarId,
|
||||
};
|
||||
};
|
||||
|
||||
function renderWithProvider(testData = {}, props = {}) {
|
||||
const { container } = render(
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<IntlProvider locale="en">
|
||||
<SidebarContext.Provider value={{ ...mockData, ...testData }}>
|
||||
<CourseOutlineTrigger {...props} />
|
||||
</SidebarContext.Provider>
|
||||
</IntlProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
return container;
|
||||
}
|
||||
|
||||
it('renders correctly for desktop when sidebar is enabled', async () => {
|
||||
const mockToggleSidebar = jest.fn();
|
||||
await initTestStore({ enableNavigationSidebar: { enable_navigation_sidebar: true } });
|
||||
renderWithProvider({ toggleSidebar: mockToggleSidebar }, { isMobileView: false });
|
||||
|
||||
const toggleButton = await screen.getByRole('button', {
|
||||
name: messages.toggleCourseOutlineTrigger.defaultMessage,
|
||||
});
|
||||
expect(toggleButton).toBeInTheDocument();
|
||||
|
||||
userEvent.click(toggleButton);
|
||||
|
||||
expect(mockToggleSidebar).toHaveBeenCalled();
|
||||
expect(mockToggleSidebar).toHaveBeenCalledWith(outlineSidebarId);
|
||||
});
|
||||
|
||||
it('renders correctly for mobile when sidebar is enabled', async () => {
|
||||
const mockToggleSidebar = jest.fn();
|
||||
await initTestStore({ enableNavigationSidebar: { enable_navigation_sidebar: true } });
|
||||
renderWithProvider({
|
||||
toggleSidebar: mockToggleSidebar,
|
||||
shouldDisplayFullScreen: true,
|
||||
}, { isMobileView: true });
|
||||
|
||||
const toggleButton = await screen.getByRole('button', {
|
||||
name: messages.toggleCourseOutlineTrigger.defaultMessage,
|
||||
});
|
||||
expect(toggleButton).toBeInTheDocument();
|
||||
|
||||
userEvent.click(toggleButton);
|
||||
|
||||
expect(mockToggleSidebar).toHaveBeenCalled();
|
||||
expect(mockToggleSidebar).toHaveBeenCalledWith(outlineSidebarId);
|
||||
});
|
||||
|
||||
it('changes current sidebar value on click', async () => {
|
||||
const mockToggleSidebar = jest.fn();
|
||||
await initTestStore({ enableNavigationSidebar: { enable_navigation_sidebar: true } });
|
||||
renderWithProvider({
|
||||
toggleSidebar: mockToggleSidebar,
|
||||
shouldDisplayFullScreen: true,
|
||||
currentSidebar: outlineSidebarId,
|
||||
}, { isMobileView: true });
|
||||
|
||||
const toggleButton = await screen.getByRole('button', {
|
||||
name: messages.toggleCourseOutlineTrigger.defaultMessage,
|
||||
});
|
||||
expect(toggleButton).toBeInTheDocument();
|
||||
|
||||
userEvent.click(toggleButton);
|
||||
|
||||
expect(mockToggleSidebar).toHaveBeenCalledTimes(1);
|
||||
expect(mockToggleSidebar).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('does not render when isEnabled is false', async () => {
|
||||
await initTestStore({ enableNavigationSidebar: { enable_navigation_sidebar: false } });
|
||||
renderWithProvider({}, { isMobileView: false });
|
||||
|
||||
const toggleButton = await screen.queryByRole('button', {
|
||||
name: messages.toggleCourseOutlineTrigger.defaultMessage,
|
||||
});
|
||||
expect(toggleButton).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Icon } from '@openedx/paragon';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
ChevronRight as ChevronRightIcon,
|
||||
LmsCompletionSolid as LmsCompletionSolidIcon,
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
import courseOutlineMessages from '@src/course-home/outline-tab/messages';
|
||||
import { getSequenceId } from '@src/courseware/data/selectors';
|
||||
|
||||
const SidebarSection = ({ intl, section, handleSelectSection }) => {
|
||||
const {
|
||||
id,
|
||||
complete,
|
||||
title,
|
||||
sequenceIds,
|
||||
} = section;
|
||||
|
||||
const activeSequenceId = useSelector(getSequenceId);
|
||||
const isActiveSection = sequenceIds.includes(activeSequenceId);
|
||||
|
||||
const sectionTitle = (
|
||||
<>
|
||||
<div className="col-auto p-0">
|
||||
{complete ? <CheckCircleIcon className="text-success" /> : <LmsCompletionSolidIcon className="text-gray-300" />}
|
||||
</div>
|
||||
<div className="col-10 ml-3 p-0 flex-grow-1 text-dark-500 text-left text-break">
|
||||
{title}
|
||||
<span className="sr-only">
|
||||
, {intl.formatMessage(complete
|
||||
? courseOutlineMessages.completedSection
|
||||
: courseOutlineMessages.incompleteSection)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<li className="mb-2 course-sidebar-section">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className={classNames(
|
||||
'd-flex align-items-center w-100 px-4 py-3.5 rounded-0 justify-content-start',
|
||||
{ 'bg-info-100': isActiveSection },
|
||||
)}
|
||||
onClick={() => handleSelectSection(id)}
|
||||
>
|
||||
{sectionTitle}
|
||||
<Icon src={ChevronRightIcon} />
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
SidebarSection.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
section: PropTypes.shape({
|
||||
complete: PropTypes.bool,
|
||||
id: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
sequenceIds: PropTypes.arrayOf(PropTypes.string),
|
||||
}).isRequired,
|
||||
handleSelectSection: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(SidebarSection);
|
||||
@@ -0,0 +1,67 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { initializeTestStore } from '@src/setupTest';
|
||||
import courseOutlineMessages from '@src/course-home/outline-tab/messages';
|
||||
import SidebarSection from './SidebarSection';
|
||||
|
||||
describe('<SidebarSection />', () => {
|
||||
let mockHandleSelectSection;
|
||||
let store;
|
||||
let section;
|
||||
|
||||
const initTestStore = async (options) => {
|
||||
store = await initializeTestStore(options);
|
||||
const state = store.getState();
|
||||
const [activeSectionId] = Object.keys(state.courseware.courseOutline.sections);
|
||||
section = state.courseware.courseOutline.sections[activeSectionId];
|
||||
};
|
||||
|
||||
const RootWrapper = (props) => (
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<IntlProvider locale="en">
|
||||
<SidebarSection
|
||||
section={section}
|
||||
handleSelectSection={mockHandleSelectSection}
|
||||
{...props}
|
||||
/>,
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockHandleSelectSection = jest.fn();
|
||||
});
|
||||
|
||||
it('renders correctly when section is incomplete', async () => {
|
||||
await initTestStore();
|
||||
const { getByText, container } = render(<RootWrapper />);
|
||||
|
||||
expect(getByText(section.title)).toBeInTheDocument();
|
||||
expect(screen.getByText(`, ${courseOutlineMessages.incompleteSection.defaultMessage}`)).toBeInTheDocument();
|
||||
expect(container.querySelector('.text-success')).not.toBeInTheDocument();
|
||||
|
||||
const button = getByText(section.title);
|
||||
userEvent.click(button);
|
||||
expect(mockHandleSelectSection).toHaveBeenCalledTimes(1);
|
||||
expect(mockHandleSelectSection).toHaveBeenCalledWith(section.id);
|
||||
});
|
||||
|
||||
it('renders correctly when section is complete', async () => {
|
||||
await initTestStore();
|
||||
const { getByText, container } = render(
|
||||
<RootWrapper section={{ ...section, complete: true }} />,
|
||||
);
|
||||
|
||||
expect(getByText(section.title)).toBeInTheDocument();
|
||||
expect(screen.getByText(`, ${courseOutlineMessages.completedSection.defaultMessage}`)).toBeInTheDocument();
|
||||
expect(container.querySelector('.text-success')).toBeInTheDocument();
|
||||
|
||||
const button = getByText(section.title);
|
||||
userEvent.click(button);
|
||||
expect(mockHandleSelectSection).toHaveBeenCalledTimes(1);
|
||||
expect(mockHandleSelectSection).toHaveBeenCalledWith(section.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Collapsible } from '@openedx/paragon';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
LmsCompletionSolid as LmsCompletionSolidIcon,
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
import courseOutlineMessages from '@src/course-home/outline-tab/messages';
|
||||
import { getCourseOutline, getSequenceId } from '@src/courseware/data/selectors';
|
||||
import SidebarUnit from './SidebarUnit';
|
||||
import { UNIT_ICON_TYPES } from './UnitIcon';
|
||||
|
||||
const SidebarSequence = ({
|
||||
intl,
|
||||
courseId,
|
||||
defaultOpen,
|
||||
sequence,
|
||||
activeUnitId,
|
||||
}) => {
|
||||
const {
|
||||
id,
|
||||
complete,
|
||||
title,
|
||||
specialExamInfo,
|
||||
unitIds,
|
||||
type,
|
||||
} = sequence;
|
||||
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const { units = {} } = useSelector(getCourseOutline);
|
||||
const activeSequenceId = useSelector(getSequenceId);
|
||||
const isActiveSequence = id === activeSequenceId;
|
||||
|
||||
const sectionTitle = (
|
||||
<>
|
||||
<div className="col-auto p-0" style={{ fontSize: '1.1rem' }}>
|
||||
{complete ? <CheckCircleIcon className="text-success" /> : <LmsCompletionSolidIcon className="text-gray-300" />}
|
||||
</div>
|
||||
<div className="col-9 d-flex flex-column flex-grow-1 ml-3 mr-auto p-0 text-left">
|
||||
<span className="align-middle text-dark-500">{title}</span>
|
||||
{specialExamInfo && <span className="align-middle small text-muted">{specialExamInfo}</span>}
|
||||
<span className="sr-only">
|
||||
, {intl.formatMessage(complete
|
||||
? courseOutlineMessages.completedAssignment
|
||||
: courseOutlineMessages.incompleteAssignment)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Collapsible
|
||||
className={classNames('mb-2', { 'active-section': isActiveSequence, 'bg-info-100': isActiveSequence && !open })}
|
||||
styling="card-lg text-break"
|
||||
title={sectionTitle}
|
||||
open={open}
|
||||
onToggle={() => setOpen(!open)}
|
||||
>
|
||||
<ol className="list-unstyled">
|
||||
{unitIds.map((unitId, index) => (
|
||||
<SidebarUnit
|
||||
key={unitId}
|
||||
id={unitId}
|
||||
courseId={courseId}
|
||||
sequenceId={id}
|
||||
unit={units[unitId]}
|
||||
isActive={activeUnitId === unitId}
|
||||
activeUnitId={activeUnitId}
|
||||
isFirst={index === 0}
|
||||
isLocked={type === UNIT_ICON_TYPES.lock}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</Collapsible>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
SidebarSequence.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
defaultOpen: PropTypes.bool.isRequired,
|
||||
sequence: PropTypes.shape({
|
||||
complete: PropTypes.bool,
|
||||
id: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
type: PropTypes.string,
|
||||
specialExamInfo: PropTypes.string,
|
||||
unitIds: PropTypes.arrayOf(PropTypes.string),
|
||||
}).isRequired,
|
||||
activeUnitId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(SidebarSequence);
|
||||
@@ -0,0 +1,84 @@
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import courseOutlineMessages from '@src/course-home/outline-tab/messages';
|
||||
import { initializeMockApp, initializeTestStore } from '@src/setupTest';
|
||||
import SidebarSequence from './SidebarSequence';
|
||||
import messages from '../messages';
|
||||
|
||||
initializeMockApp();
|
||||
|
||||
describe('<SidebarSequence />', () => {
|
||||
let courseId;
|
||||
let store;
|
||||
let sequence;
|
||||
let unit;
|
||||
const sequenceDescription = 'sequence test description';
|
||||
|
||||
const initTestStore = async (options) => {
|
||||
store = await initializeTestStore(options);
|
||||
const state = store.getState();
|
||||
courseId = state.courseware.courseId;
|
||||
let activeSequenceId = '';
|
||||
[activeSequenceId] = Object.keys(state.courseware.courseOutline.sequences);
|
||||
sequence = state.courseware.courseOutline.sequences[activeSequenceId];
|
||||
const unitId = sequence.unitIds[0];
|
||||
unit = state.courseware.courseOutline.units[unitId];
|
||||
};
|
||||
|
||||
function renderWithProvider(props = {}) {
|
||||
const { container } = render(
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<IntlProvider locale="en">
|
||||
<MemoryRouter>
|
||||
<SidebarSequence
|
||||
courseId={courseId}
|
||||
defaultOpen={false}
|
||||
sequence={sequence}
|
||||
activeUnitId={sequence.unitIds[0]}
|
||||
{...props}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</IntlProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
return container;
|
||||
}
|
||||
|
||||
it('renders correctly when sequence is collapsed and incomplete', async () => {
|
||||
await initTestStore();
|
||||
renderWithProvider();
|
||||
|
||||
expect(screen.getByText(sequence.title)).toBeInTheDocument();
|
||||
expect(screen.queryByText(sequenceDescription)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(`, ${courseOutlineMessages.incompleteAssignment.defaultMessage}`)).toBeInTheDocument();
|
||||
expect(screen.queryByText(unit.title)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correctly when sequence is not collapsed and complete', async () => {
|
||||
await initTestStore();
|
||||
renderWithProvider({
|
||||
defaultOpen: true,
|
||||
sequence: {
|
||||
...sequence,
|
||||
specialExamInfo: sequenceDescription,
|
||||
complete: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByText(sequence.title)).toBeInTheDocument();
|
||||
expect(screen.getByText(sequenceDescription)).toBeInTheDocument();
|
||||
expect(screen.getByText(`, ${courseOutlineMessages.completedAssignment.defaultMessage}`)).toBeInTheDocument();
|
||||
expect(screen.getByText(unit.title)).toBeInTheDocument();
|
||||
expect(screen.getByText(`, ${messages.incompleteUnit.defaultMessage}`)).toBeInTheDocument();
|
||||
|
||||
userEvent.click(screen.getByText(sequence.title));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(unit.title)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(`, ${messages.incompleteUnit.defaultMessage}`)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { checkBlockCompletion } from '@src/courseware/data';
|
||||
import UnitIcon, { UNIT_ICON_TYPES } from './UnitIcon';
|
||||
import messages from '../messages';
|
||||
|
||||
const SidebarUnit = ({
|
||||
id,
|
||||
intl,
|
||||
courseId,
|
||||
sequenceId,
|
||||
isFirst,
|
||||
unit,
|
||||
isActive,
|
||||
isLocked,
|
||||
activeUnitId,
|
||||
}) => {
|
||||
const {
|
||||
complete,
|
||||
title,
|
||||
icon = UNIT_ICON_TYPES.other,
|
||||
} = unit;
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleClick = () => {
|
||||
dispatch(checkBlockCompletion(courseId, sequenceId, activeUnitId));
|
||||
};
|
||||
|
||||
const iconType = isLocked ? UNIT_ICON_TYPES.lock : icon;
|
||||
|
||||
return (
|
||||
<li className={classNames({ 'bg-info-100': isActive, 'border-top border-light': !isFirst })}>
|
||||
<Link
|
||||
to={`/course/${courseId}/${sequenceId}/${id}`}
|
||||
className="row w-100 m-0 d-flex align-items-center text-gray-700"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="col-auto p-0">
|
||||
<UnitIcon type={iconType} isCompleted={complete} />
|
||||
</div>
|
||||
<div className="col-10 p-0 ml-3 text-break">
|
||||
<span className="align-middle">
|
||||
{title}
|
||||
</span>
|
||||
<span className="sr-only">
|
||||
, {intl.formatMessage(complete ? messages.completedUnit : messages.incompleteUnit)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
SidebarUnit.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
isFirst: PropTypes.bool.isRequired,
|
||||
unit: PropTypes.shape({
|
||||
complete: PropTypes.bool,
|
||||
icon: PropTypes.string,
|
||||
id: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
type: PropTypes.string,
|
||||
}).isRequired,
|
||||
isActive: PropTypes.bool.isRequired,
|
||||
isLocked: PropTypes.bool.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
sequenceId: PropTypes.string.isRequired,
|
||||
activeUnitId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(SidebarUnit);
|
||||
@@ -0,0 +1,81 @@
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { initializeMockApp, initializeTestStore } from '@src/setupTest';
|
||||
import SidebarUnit from './SidebarUnit';
|
||||
|
||||
initializeMockApp();
|
||||
|
||||
describe('<SidebarUnit />', () => {
|
||||
let store = {};
|
||||
let unit;
|
||||
let sequenceId;
|
||||
|
||||
const initTestStore = async (options) => {
|
||||
store = await initializeTestStore(options);
|
||||
const state = store.getState();
|
||||
[sequenceId] = Object.keys(state.courseware.courseOutline.sequences);
|
||||
const sequence = state.courseware.courseOutline.sequences[sequenceId];
|
||||
unit = state.courseware.courseOutline.units[sequence.unitIds[0]];
|
||||
};
|
||||
|
||||
function renderWithProvider(props = {}) {
|
||||
const { container } = render(
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<IntlProvider locale="en">
|
||||
<MemoryRouter>
|
||||
<SidebarUnit
|
||||
isFirst
|
||||
id="unit1"
|
||||
courseId="course123"
|
||||
sequenceId={sequenceId}
|
||||
unit={{ ...unit, icon: 'video', isLocked: false }}
|
||||
isActive={false}
|
||||
{...props}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</IntlProvider>
|
||||
</AppProvider>,
|
||||
);
|
||||
return container;
|
||||
}
|
||||
|
||||
it('renders correctly when unit is incomplete', async () => {
|
||||
await initTestStore();
|
||||
const container = renderWithProvider();
|
||||
|
||||
expect(screen.getByText(unit.title)).toBeInTheDocument();
|
||||
expect(container.querySelector('.text-success')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correctly when unit is complete', async () => {
|
||||
await initTestStore();
|
||||
const container = renderWithProvider({ unit: { ...unit, complete: true } });
|
||||
|
||||
expect(screen.getByText(unit.title)).toBeInTheDocument();
|
||||
expect(container.querySelector('.text-success')).toBeInTheDocument();
|
||||
expect(container.querySelector('.border-top')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correctly when unit is not first and icon is not set', async () => {
|
||||
await initTestStore();
|
||||
const container = renderWithProvider({
|
||||
isFirst: false,
|
||||
unit: { ...unit, icon: null },
|
||||
});
|
||||
|
||||
expect(screen.getByText(unit.title)).toBeInTheDocument();
|
||||
expect(container.querySelector('.border-top')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correctly when unit is locked', async () => {
|
||||
await initTestStore();
|
||||
renderWithProvider({
|
||||
unit: { ...unit, isLocked: true },
|
||||
});
|
||||
|
||||
expect(screen.getByText(unit.title)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
Locked as LockedIcon,
|
||||
Article as ArticleIcon,
|
||||
LmsBook as LmsBookIcon,
|
||||
LmsBookComplete as LmsBookCompleteIcon,
|
||||
LmsEditSquare as LmsEditSquareIcon,
|
||||
LmsEditSquareComplete as LmsEditSquareCompleteIcon,
|
||||
LmsVideocam as LmsVideocamIcon,
|
||||
LmsVideocamComplete as LmsVideocamCompleteIcon,
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
export const UNIT_ICON_TYPES = {
|
||||
video: 'video',
|
||||
problem: 'problem',
|
||||
vertical: 'vertical',
|
||||
lock: 'lock',
|
||||
other: 'other',
|
||||
};
|
||||
|
||||
const UnitIcon = ({ type, isCompleted, ...props }) => {
|
||||
const iconMap = {
|
||||
[UNIT_ICON_TYPES.video]: {
|
||||
default: LmsVideocamIcon,
|
||||
complete: LmsVideocamCompleteIcon,
|
||||
},
|
||||
[UNIT_ICON_TYPES.problem]: {
|
||||
default: LmsEditSquareIcon,
|
||||
complete: LmsEditSquareCompleteIcon,
|
||||
},
|
||||
[UNIT_ICON_TYPES.vertical]: ArticleIcon,
|
||||
[UNIT_ICON_TYPES.lock]: LockedIcon,
|
||||
[UNIT_ICON_TYPES.other]: {
|
||||
default: LmsBookIcon,
|
||||
complete: LmsBookCompleteIcon,
|
||||
},
|
||||
};
|
||||
|
||||
let Icon = iconMap[type || UNIT_ICON_TYPES.other];
|
||||
|
||||
if (typeof Icon === 'object') {
|
||||
Icon = iconMap[type || UNIT_ICON_TYPES.other]?.[isCompleted ? 'complete' : 'default'];
|
||||
}
|
||||
|
||||
return (
|
||||
<Icon {...props} className={classNames({ 'text-success': isCompleted, 'text-gray-300': !isCompleted })} />
|
||||
);
|
||||
};
|
||||
|
||||
UnitIcon.propTypes = {
|
||||
type: PropTypes.oneOf(Object.keys(UNIT_ICON_TYPES)).isRequired,
|
||||
isCompleted: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default UnitIcon;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import UnitIcon, { UNIT_ICON_TYPES } from './UnitIcon';
|
||||
|
||||
describe('<UnitIcon />', () => {
|
||||
Object.keys(UNIT_ICON_TYPES).forEach((type) => {
|
||||
it(`renders default ${type} icon correctly`, () => {
|
||||
const { container } = render(<UnitIcon type={type} isCompleted={false} />);
|
||||
const icon = container.querySelector('svg');
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(icon).not.toHaveClass('text-success');
|
||||
});
|
||||
|
||||
it(`renders default ${type} completed icon correctly`, () => {
|
||||
const { container } = render(<UnitIcon type={type} isCompleted />);
|
||||
const icon = container.querySelector('svg');
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(icon).toHaveClass('text-success');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const ID = 'COURSE_OUTLINE';
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useModel } from '@src/generic/model-store';
|
||||
import SidebarContext from '@src/courseware/course/sidebar/SidebarContext';
|
||||
import { getCoursewareOutlineSidebarSettings } from '@src/courseware/data/selectors';
|
||||
import { ID } from './constants';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const useCourseOutlineSidebar = () => {
|
||||
const isCollapsedOutlineSidebar = window.sessionStorage.getItem('hideCourseOutlineSidebar');
|
||||
const { enableNavigationSidebar: isEnabledSidebar } = useSelector(getCoursewareOutlineSidebarSettings);
|
||||
const {
|
||||
unitId,
|
||||
courseId,
|
||||
initialSidebar,
|
||||
currentSidebar,
|
||||
toggleSidebar,
|
||||
shouldDisplayFullScreen,
|
||||
} = useContext(SidebarContext);
|
||||
|
||||
const isOpenSidebar = !initialSidebar && isEnabledSidebar && !isCollapsedOutlineSidebar;
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
const {
|
||||
entranceExamEnabled,
|
||||
entranceExamPassed,
|
||||
} = course.entranceExamData || {};
|
||||
const isActiveEntranceExam = entranceExamEnabled && !entranceExamPassed;
|
||||
|
||||
const handleToggleCollapse = () => {
|
||||
if (currentSidebar === ID) {
|
||||
toggleSidebar(null);
|
||||
window.sessionStorage.setItem('hideCourseOutlineSidebar', 'true');
|
||||
} else {
|
||||
toggleSidebar(ID);
|
||||
window.sessionStorage.removeItem('hideCourseOutlineSidebar');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpenSidebar && currentSidebar !== ID) {
|
||||
toggleSidebar(ID);
|
||||
}
|
||||
}, [initialSidebar, unitId]);
|
||||
|
||||
return {
|
||||
courseId,
|
||||
unitId,
|
||||
currentSidebar,
|
||||
shouldDisplayFullScreen,
|
||||
isEnabledSidebar,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
handleToggleCollapse,
|
||||
isActiveEntranceExam,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as Sidebar } from './CourseOutlineTray';
|
||||
export { default as Trigger } from './CourseOutlineTrigger';
|
||||
export { ID } from './constants';
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
loading: {
|
||||
id: 'courseOutline.loading',
|
||||
defaultMessage: 'Loading...',
|
||||
description: 'Screen reader text to use on the spinner while the sidebar is loading.',
|
||||
},
|
||||
toggleCourseOutlineTrigger: {
|
||||
id: 'courseOutline.toggle.button',
|
||||
defaultMessage: 'Toggle course outline tray',
|
||||
description: 'Button for the learner to toggle the sidebar',
|
||||
},
|
||||
courseOutlineTitle: {
|
||||
id: 'courseOutline.tray.title',
|
||||
defaultMessage: 'Course Outline',
|
||||
description: 'Title text displayed for the course outline tray',
|
||||
},
|
||||
completedUnit: {
|
||||
id: 'courseOutline.completedUnit',
|
||||
defaultMessage: 'Completed unit',
|
||||
description: 'Text used to describe the green checkmark icon in front of a unit title',
|
||||
},
|
||||
incompleteUnit: {
|
||||
id: 'courseOutline.incompleteUnit',
|
||||
defaultMessage: 'Incomplete unit',
|
||||
description: 'Text used to describe the gray checkmark icon in front of a unit title',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useContext } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import React, { useContext } from 'react';
|
||||
import { useModel } from '../../../../../generic/model-store';
|
||||
|
||||
import { useModel } from '@src/generic/model-store';
|
||||
import SidebarBase from '../../common/SidebarBase';
|
||||
import SidebarContext from '../../SidebarContext';
|
||||
import { ID } from './DiscussionsTrigger';
|
||||
@@ -14,6 +16,7 @@ const DiscussionsSidebar = ({ intl }) => {
|
||||
const {
|
||||
unitId,
|
||||
courseId,
|
||||
shouldDisplayFullScreen,
|
||||
} = useContext(SidebarContext);
|
||||
const topic = useModel('discussionTopics', unitId);
|
||||
const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/category/${unitId}`;
|
||||
@@ -27,8 +30,11 @@ const DiscussionsSidebar = ({ intl }) => {
|
||||
title={intl.formatMessage(messages.discussionsTitle)}
|
||||
ariaLabel={intl.formatMessage(messages.discussionsTitle)}
|
||||
sidebarId={ID}
|
||||
width="50rem"
|
||||
width="45rem"
|
||||
showTitleBar={false}
|
||||
className={classNames({
|
||||
'ml-4': !shouldDisplayFullScreen,
|
||||
})}
|
||||
>
|
||||
<iframe
|
||||
src={`${discussionsUrl}?inContextSidebar`}
|
||||
|
||||
@@ -3,14 +3,14 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import { QuestionAnswer } from '@openedx/paragon/icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useContext, useEffect, useMemo } from 'react';
|
||||
import { useContext, useEffect, useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useModel } from '../../../../../generic/model-store';
|
||||
import { useModel } from '@src/generic/model-store';
|
||||
import { WIDGETS } from '@src/constants';
|
||||
import { getCourseDiscussionTopics } from '../../../../data/thunks';
|
||||
import SidebarTriggerBase from '../../common/TriggerBase';
|
||||
import SidebarContext from '../../SidebarContext';
|
||||
import messages from './messages';
|
||||
import { WIDGETS } from '../../../../../constants';
|
||||
|
||||
ensureConfig(['DISCUSSIONS_MFE_BASE_URL']);
|
||||
export const ID = WIDGETS.DISCUSSIONS;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import classNames from 'classnames';
|
||||
import React, { useContext, useEffect, useMemo } from 'react';
|
||||
import { useContext, useEffect, useMemo } from 'react';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { useModel } from '../../../../../generic/model-store';
|
||||
import UpgradeNotification from '../../../../../generic/upgrade-notification/UpgradeNotification';
|
||||
import { useModel } from '@src/generic/model-store';
|
||||
import UpgradeNotification from '@src/generic/upgrade-notification/UpgradeNotification';
|
||||
|
||||
import messages from '../../../messages';
|
||||
import SidebarBase from '../../common/SidebarBase';
|
||||
@@ -70,8 +70,10 @@ const NotificationTray = ({ intl }) => {
|
||||
title={intl.formatMessage(messages.notificationTitle)}
|
||||
ariaLabel={intl.formatMessage(messages.notificationTray)}
|
||||
sidebarId={ID}
|
||||
width="50rem"
|
||||
className={classNames({ 'h-100': !verifiedMode && !shouldDisplayFullScreen })}
|
||||
className={classNames({
|
||||
'h-100': !verifiedMode && !shouldDisplayFullScreen,
|
||||
'ml-4': !shouldDisplayFullScreen,
|
||||
})}
|
||||
>
|
||||
<div>{verifiedMode
|
||||
? (
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useContext, useEffect } from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import { getLocalStorage, setLocalStorage } from '../../../../../data/localStorage';
|
||||
|
||||
import { WIDGETS } from '@src/constants';
|
||||
import { getLocalStorage, setLocalStorage } from '@src/data/localStorage';
|
||||
import messages from '../../../messages';
|
||||
import SidebarTriggerBase from '../../common/TriggerBase';
|
||||
import SidebarContext from '../../SidebarContext';
|
||||
import { WIDGETS } from '../../../../../constants';
|
||||
import NotificationIcon from './NotificationIcon';
|
||||
|
||||
export const ID = WIDGETS.NOTIFICATIONS;
|
||||
|
||||
@@ -4,9 +4,10 @@ import { getConfig, snakeCaseObject } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { breakpoints } from '@openedx/paragon';
|
||||
import { initializeTestStore, render } from '../../setupTest';
|
||||
import { executeThunk } from '@src/utils';
|
||||
import { initializeTestStore, render } from '@src/setupTest';
|
||||
import SidebarContext from '@src/courseware/course/sidebar/SidebarContext';
|
||||
import { buildTopicsFromUnits } from '../data/__factories__/discussionTopics.factory';
|
||||
import { executeThunk } from '../../utils';
|
||||
import * as thunks from '../data/thunks';
|
||||
import Course from './Course';
|
||||
|
||||
@@ -26,7 +27,7 @@ const setupDiscussionSidebar = async (HomeMetaParams) => {
|
||||
sequenceId,
|
||||
unitId: Object.values(models.units)[0].id,
|
||||
});
|
||||
global.innerWidth = breakpoints.extraLarge.minWidth;
|
||||
global.innerWidth = breakpoints.extraExtraLarge.minWidth;
|
||||
|
||||
const courseHomeMetadata = Factory.build('courseHomeMetadata', { ...snakeCaseObject(params) });
|
||||
const testStore = await initializeTestStore({ provider: 'openedx', courseHomeMetadata });
|
||||
@@ -42,8 +43,14 @@ const setupDiscussionSidebar = async (HomeMetaParams) => {
|
||||
mockData.unitId = firstUnitId;
|
||||
const [firstSequenceId] = Object.keys(state.models.sequences);
|
||||
mockData.sequenceId = firstSequenceId;
|
||||
const contextValue = { courseId: mockData.courseId, currentSidebar: null, toggleSidebar: jest.fn() };
|
||||
|
||||
const wrapper = await render(<Course {...mockData} />, { store: testStore, wrapWithRouter: true });
|
||||
const wrapper = await render(
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<Course {...mockData} />
|
||||
</SidebarContext.Provider>,
|
||||
{ store: testStore, wrapWithRouter: true },
|
||||
);
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,71 +1,9 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { getTimeOffsetMillis } from '../../course-home/data/api';
|
||||
import { appendBrowserTimezoneToUrl } from '../../utils';
|
||||
|
||||
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 === 0) {
|
||||
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;
|
||||
}
|
||||
import {
|
||||
normalizeLearningSequencesData, normalizeMetadata, normalizeOutlineBlocks, normalizeSequenceMetadata,
|
||||
} from './utils';
|
||||
|
||||
// Do not add further calls to this API - we don't like making use of the modulestore if we can help it
|
||||
export async function getSequenceForUnitDeprecated(courseId, unitId) {
|
||||
@@ -87,46 +25,6 @@ export async function getLearningSequencesOutline(courseId) {
|
||||
return normalizeLearningSequencesData(data);
|
||||
}
|
||||
|
||||
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),
|
||||
language: data.language,
|
||||
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 async function getCourseMetadata(courseId) {
|
||||
let url = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`;
|
||||
url = appendBrowserTimezoneToUrl(url);
|
||||
@@ -134,48 +32,6 @@ export async function getCourseMetadata(courseId) {
|
||||
return normalizeMetadata(metadata);
|
||||
}
|
||||
|
||||
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,
|
||||
navigationDisabled: sequence.navigation_disabled,
|
||||
},
|
||||
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 async function getSequenceMetadata(sequenceId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(`${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceId}`, {});
|
||||
@@ -230,3 +86,30 @@ export async function getCourseTopics(courseId) {
|
||||
.get(`${getConfig().LMS_BASE_URL}/api/discussion/v2/course_topics/${courseId}`);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get course outline structure for the courseware navigation sidebar.
|
||||
* @param {string} courseId - The unique identifier for the course.
|
||||
* @returns {Promise<{units: {}, sequences: {}, sections: {}}|null>}
|
||||
*/
|
||||
export async function getCourseOutline(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(`${getConfig().LMS_BASE_URL}/api/course_home/v1/navigation/${courseId}`);
|
||||
|
||||
return data.blocks ? normalizeOutlineBlocks(courseId, data.blocks) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get waffle flag value that enable courseware outline sidebar and always open auxiliary sidebar.
|
||||
* @param {string} courseId - The unique identifier for the course.
|
||||
* @returns {Promise<{enable_navigation_sidebar: boolean, enable_navigation_sidebar: boolean}>} - The object
|
||||
* of boolean values of enabling of the outline sidebar and is always open auxiliary sidebar.
|
||||
*/
|
||||
export async function getCoursewareOutlineSidebarToggles(courseId) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/courseware-navigation-sidebar/toggles/`);
|
||||
const { data } = await getAuthenticatedHttpClient().get(url.href);
|
||||
return {
|
||||
enable_navigation_sidebar: data.enable_navigation_sidebar || false,
|
||||
always_open_auxiliary_sidebar: data.always_open_auxiliary_sidebar || false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import * as thunks from './thunks';
|
||||
import { FAILED, LOADING } from './slice';
|
||||
|
||||
import { appendBrowserTimezoneToUrl, executeThunk } from '../../utils';
|
||||
|
||||
@@ -43,6 +44,7 @@ describe('Data layer integration tests', () => {
|
||||
const sequenceUrl = `${sequenceBaseUrl}/${sequenceMetadata.item_id}`;
|
||||
const sequenceId = sequenceBlocks[0].id;
|
||||
const unitId = unitBlocks[0].id;
|
||||
const coursewareSidebarSettingsUrl = `${getConfig().LMS_BASE_URL}/courses/${courseId}/courseware-navigation-sidebar/toggles/`;
|
||||
|
||||
let store;
|
||||
|
||||
@@ -57,13 +59,20 @@ describe('Data layer integration tests', () => {
|
||||
it('Should fail to fetch course and blocks if request error happens', async () => {
|
||||
axiosMock.onGet(courseUrl).networkError();
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).networkError();
|
||||
axiosMock.onGet(coursewareSidebarSettingsUrl).networkError();
|
||||
|
||||
await executeThunk(thunks.fetchCourse(courseId), store.dispatch);
|
||||
|
||||
expect(loggingService.logError).toHaveBeenCalled();
|
||||
expect(store.getState().courseware).toEqual(expect.objectContaining({
|
||||
courseId,
|
||||
courseStatus: 'failed',
|
||||
courseOutline: {},
|
||||
courseStatus: FAILED,
|
||||
coursewareOutlineSidebarSettings: {},
|
||||
courseOutlineStatus: LOADING,
|
||||
sequenceId: null,
|
||||
sequenceMightBeUnit: false,
|
||||
sequenceStatus: LOADING,
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -101,6 +110,10 @@ describe('Data layer integration tests', () => {
|
||||
axiosMock.onGet(courseHomeMetadataUrl).reply(200, courseHomeMetadata);
|
||||
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(200, buildOutlineFromBlocks(courseBlocks));
|
||||
axiosMock.onGet(coursewareSidebarSettingsUrl).reply(200, {
|
||||
enable_navigation_sidebar: true,
|
||||
always_open_auxiliary_sidebar: true,
|
||||
});
|
||||
|
||||
await executeThunk(thunks.fetchCourse(courseId), store.dispatch);
|
||||
|
||||
@@ -110,6 +123,10 @@ describe('Data layer integration tests', () => {
|
||||
expect(state.courseware.courseId).toEqual(courseId);
|
||||
expect(state.courseware.sequenceStatus).toEqual('loading');
|
||||
expect(state.courseware.sequenceId).toEqual(null);
|
||||
expect(state.courseware.coursewareOutlineSidebarSettings).toEqual({
|
||||
enableNavigationSidebar: true,
|
||||
alwaysOpenAuxiliarySidebar: true,
|
||||
});
|
||||
|
||||
// check that at least one key camel cased, thus course data normalized
|
||||
expect(state.models.coursewareMeta[courseId].marketingUrl).not.toBeUndefined();
|
||||
@@ -121,6 +138,10 @@ describe('Data layer integration tests', () => {
|
||||
axiosMock.onGet(courseHomeMetadataUrl).reply(200, courseHomeMetadata);
|
||||
axiosMock.onGet(courseUrl).reply(200, courseMetadata);
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(200, simpleOutline);
|
||||
axiosMock.onGet(coursewareSidebarSettingsUrl).reply(200, {
|
||||
enable_navigation_sidebar: false,
|
||||
always_open_auxiliary_sidebar: false,
|
||||
});
|
||||
|
||||
await executeThunk(thunks.fetchCourse(courseId), store.dispatch);
|
||||
|
||||
@@ -130,6 +151,10 @@ describe('Data layer integration tests', () => {
|
||||
expect(state.courseware.courseId).toEqual(courseId);
|
||||
expect(state.courseware.sequenceStatus).toEqual('loading');
|
||||
expect(state.courseware.sequenceId).toEqual(null);
|
||||
expect(state.courseware.coursewareOutlineSidebarSettings).toEqual({
|
||||
enableNavigationSidebar: false,
|
||||
alwaysOpenAuxiliarySidebar: false,
|
||||
});
|
||||
|
||||
// check that at least one key camel cased, thus course data normalized
|
||||
expect(state.models.coursewareMeta[courseId].marketingUrl).not.toBeUndefined();
|
||||
@@ -254,31 +279,74 @@ describe('Data layer integration tests', () => {
|
||||
});
|
||||
|
||||
describe('Test checkBlockCompletion', () => {
|
||||
const getCourseOutlineURL = `${getConfig().LMS_BASE_URL}/api/course_home/v1/navigation/${courseId}`;
|
||||
const getCompletionURL = `${getConfig().LMS_BASE_URL}/courses/${courseId}/xblock/${sequenceId}/handler/get_completion`;
|
||||
|
||||
it('Should fail to check completion and log error', async () => {
|
||||
axiosMock.onPost(getCompletionURL).networkError();
|
||||
axiosMock.onGet(getCourseOutlineURL).networkError();
|
||||
|
||||
await executeThunk(
|
||||
thunks.checkBlockCompletion(courseId, sequenceId, unitId),
|
||||
store.dispatch,
|
||||
store.getState,
|
||||
);
|
||||
await executeThunk(
|
||||
thunks.getCourseOutlineStructure(courseId, sequenceId, unitId),
|
||||
store.dispatch,
|
||||
store.getState,
|
||||
);
|
||||
|
||||
expect(loggingService.logError).toHaveBeenCalled();
|
||||
expect(axiosMock.history.post[0].url).toEqual(getCompletionURL);
|
||||
});
|
||||
|
||||
it('Should update complete field of unit model', async () => {
|
||||
it('Should update complete field of unit model and course outline', async () => {
|
||||
axiosMock.onPost(getCompletionURL).reply(201, { complete: true });
|
||||
axiosMock.onGet(getCourseOutlineURL).reply(201, {
|
||||
...courseBlocks,
|
||||
...sequenceBlocks,
|
||||
...unitBlocks,
|
||||
});
|
||||
|
||||
await executeThunk(
|
||||
thunks.checkBlockCompletion(courseId, sequenceId, unitId),
|
||||
store.dispatch,
|
||||
store.getState,
|
||||
);
|
||||
await executeThunk(thunks.getCourseOutlineStructure(courseId), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().models.units[unitId].complete).toBeTruthy();
|
||||
const [unit] = Object.values(store.getState().courseware.courseOutline.units);
|
||||
const [sequence] = Object.values(store.getState().courseware.courseOutline.sequences);
|
||||
const [section] = Object.values(store.getState().courseware.courseOutline.sections);
|
||||
|
||||
expect(unit.complete).not.toBeTruthy();
|
||||
expect(sequence.complete).not.toBeTruthy();
|
||||
expect(section.complete).not.toBeTruthy();
|
||||
|
||||
await executeThunk(thunks.checkBlockCompletion(courseId, sequenceId, unit.id), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().models.units[unit.id].complete).toBeTruthy();
|
||||
expect(store.getState().courseware.courseOutline.units[unit.id].complete).toBeTruthy();
|
||||
expect(store.getState().courseware.courseOutline.sequences[sequence.id].complete).toBeTruthy();
|
||||
expect(store.getState().courseware.courseOutline.sections[section.id].complete).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Shouldn\'t update complete field if complete is false', async () => {
|
||||
axiosMock.onPost(getCompletionURL).reply(201, { complete: false });
|
||||
axiosMock.onGet(getCourseOutlineURL).reply(201, {
|
||||
...courseBlocks,
|
||||
...sequenceBlocks,
|
||||
...unitBlocks,
|
||||
});
|
||||
|
||||
await executeThunk(thunks.getCourseOutlineStructure(courseId), store.dispatch, store.getState);
|
||||
|
||||
const [unit] = Object.values(store.getState().courseware.courseOutline.units);
|
||||
const [sequence] = Object.values(store.getState().courseware.courseOutline.sequences);
|
||||
const [section] = Object.values(store.getState().courseware.courseOutline.sections);
|
||||
|
||||
await executeThunk(thunks.checkBlockCompletion(courseId, sequenceId, unit.id), store.dispatch, store.getState);
|
||||
|
||||
expect(store.getState().models.units[unit.id].complete).not.toBeTruthy();
|
||||
expect(store.getState().courseware.courseOutline.units[unit.id].complete).not.toBeTruthy();
|
||||
expect(store.getState().courseware.courseOutline.sequences[sequence.id].complete).not.toBeTruthy();
|
||||
expect(store.getState().courseware.courseOutline.sections[section.id].complete).not.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { LOADED } from './slice';
|
||||
|
||||
export function sequenceIdsSelector(state) {
|
||||
if (state.courseware.courseStatus !== 'loaded') {
|
||||
if (state.courseware.courseStatus !== LOADED) {
|
||||
return [];
|
||||
}
|
||||
const { sectionIds = [] } = state.models.coursewareMeta[state.courseware.courseId];
|
||||
|
||||
const sequenceIds = sectionIds
|
||||
return sectionIds
|
||||
.flatMap(sectionId => state.models.sections[sectionId].sequenceIds);
|
||||
|
||||
return sequenceIds;
|
||||
}
|
||||
|
||||
export const getSequenceId = state => state.courseware.sequenceId;
|
||||
|
||||
export const getCourseOutline = state => state.courseware.courseOutline;
|
||||
|
||||
export const getCourseOutlineStatus = state => state.courseware.courseOutlineStatus;
|
||||
|
||||
export const getCoursewareOutlineSidebarSettings = state => state.courseware.coursewareOutlineSidebarSettings;
|
||||
|
||||
export const getCourseOutlineShouldUpdate = state => state.courseware.courseOutlineShouldUpdate;
|
||||
|
||||
@@ -9,11 +9,15 @@ export const DENIED = 'denied';
|
||||
const slice = createSlice({
|
||||
name: 'courseware',
|
||||
initialState: {
|
||||
courseStatus: 'loading',
|
||||
courseId: null,
|
||||
sequenceStatus: 'loading',
|
||||
courseStatus: LOADING,
|
||||
sequenceId: null,
|
||||
sequenceMightBeUnit: false,
|
||||
sequenceStatus: LOADING,
|
||||
courseOutline: {},
|
||||
coursewareOutlineSidebarSettings: {},
|
||||
courseOutlineStatus: LOADING,
|
||||
courseOutlineShouldUpdate: false,
|
||||
},
|
||||
reducers: {
|
||||
fetchCourseRequest: (state, { payload }) => {
|
||||
@@ -47,6 +51,59 @@ const slice = createSlice({
|
||||
state.sequenceStatus = FAILED;
|
||||
state.sequenceMightBeUnit = payload.sequenceMightBeUnit || false;
|
||||
},
|
||||
fetchCourseOutlineRequest: (state) => {
|
||||
state.courseOutline = {};
|
||||
state.courseOutlineStatus = LOADING;
|
||||
},
|
||||
fetchCourseOutlineSuccess: (state, { payload }) => {
|
||||
state.courseOutline = payload.courseOutline;
|
||||
state.courseOutlineStatus = LOADED;
|
||||
state.courseOutlineShouldUpdate = false;
|
||||
},
|
||||
fetchCourseOutlineFailure: (state) => {
|
||||
state.courseOutline = {};
|
||||
state.courseOutlineStatus = FAILED;
|
||||
},
|
||||
setCoursewareOutlineSidebarToggles: (state, { payload }) => {
|
||||
state.coursewareOutlineSidebarSettings = payload;
|
||||
},
|
||||
updateCourseOutlineCompletion: (state, { payload }) => {
|
||||
const { unitId, isComplete: isUnitComplete } = payload;
|
||||
if (!isUnitComplete) {
|
||||
return state;
|
||||
}
|
||||
|
||||
state.courseOutline.units[unitId].complete = true;
|
||||
|
||||
const sequenceId = Object.keys(state.courseOutline.sequences)
|
||||
.find(id => state.courseOutline.sequences[id].unitIds.includes(unitId));
|
||||
const sequenceUnits = state.courseOutline.sequences[sequenceId].unitIds;
|
||||
const isAllUnitsAreComplete = sequenceUnits.every((id) => state.courseOutline.units[id].complete);
|
||||
|
||||
if (isAllUnitsAreComplete) {
|
||||
state.courseOutline.sequences[sequenceId].complete = true;
|
||||
}
|
||||
|
||||
const sectionId = Object.keys(state.courseOutline.sections)
|
||||
.find(id => state.courseOutline.sections[id].sequenceIds.includes(sequenceId));
|
||||
const sectionSequences = state.courseOutline.sections[sectionId].sequenceIds;
|
||||
const isAllSequencesAreComplete = sectionSequences.every((id) => state.courseOutline.sequences[id].complete);
|
||||
const hasLockedSequence = sectionSequences.some((id) => state.courseOutline.sequences[id].type === 'lock');
|
||||
|
||||
// This block of code checks whether all units in the current sequence are complete
|
||||
// and if the parent section has a locked (prerequisites) sequence. If both conditions
|
||||
// are met, it switches the state of the 'courseOutlineShouldUpdate' flag to true,
|
||||
// indicating that the sidebar outline structure needs to be refetched.
|
||||
if (isAllUnitsAreComplete && hasLockedSequence) {
|
||||
state.courseOutlineShouldUpdate = true;
|
||||
}
|
||||
|
||||
if (isAllSequencesAreComplete) {
|
||||
state.courseOutline.sections[sectionId].complete = true;
|
||||
}
|
||||
|
||||
return state;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -61,6 +118,11 @@ export const {
|
||||
fetchCourseRecommendationsRequest,
|
||||
fetchCourseRecommendationsSuccess,
|
||||
fetchCourseRecommendationsFailure,
|
||||
fetchCourseOutlineRequest,
|
||||
fetchCourseOutlineSuccess,
|
||||
fetchCourseOutlineFailure,
|
||||
setCoursewareOutlineSidebarToggles,
|
||||
updateCourseOutlineCompletion,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
getBlockCompletion,
|
||||
getCourseDiscussionConfig,
|
||||
getCourseMetadata,
|
||||
getCourseOutline,
|
||||
getCourseTopics,
|
||||
getCoursewareOutlineSidebarToggles,
|
||||
getLearningSequencesOutline,
|
||||
getSequenceMetadata,
|
||||
postIntegritySignature,
|
||||
@@ -21,6 +23,11 @@ import {
|
||||
fetchSequenceFailure,
|
||||
fetchSequenceRequest,
|
||||
fetchSequenceSuccess,
|
||||
fetchCourseOutlineRequest,
|
||||
fetchCourseOutlineSuccess,
|
||||
fetchCourseOutlineFailure,
|
||||
setCoursewareOutlineSidebarToggles,
|
||||
updateCourseOutlineCompletion,
|
||||
} from './slice';
|
||||
|
||||
export function fetchCourse(courseId) {
|
||||
@@ -30,18 +37,25 @@ export function fetchCourse(courseId) {
|
||||
getCourseMetadata(courseId),
|
||||
getLearningSequencesOutline(courseId),
|
||||
getCourseHomeCourseMetadata(courseId, 'courseware'),
|
||||
getCoursewareOutlineSidebarToggles(courseId),
|
||||
]).then(([
|
||||
courseMetadataResult,
|
||||
learningSequencesOutlineResult,
|
||||
courseHomeMetadataResult]) => {
|
||||
if (courseMetadataResult.status === 'fulfilled') {
|
||||
courseHomeMetadataResult,
|
||||
coursewareOutlineSidebarTogglesResult]) => {
|
||||
const fetchedMetadata = courseMetadataResult.status === 'fulfilled';
|
||||
const fetchedCourseHomeMetadata = courseHomeMetadataResult.status === 'fulfilled';
|
||||
const fetchedOutline = learningSequencesOutlineResult.status === 'fulfilled';
|
||||
const fetchedCoursewareOutlineSidebarTogglesResult = coursewareOutlineSidebarTogglesResult.status === 'fulfilled';
|
||||
|
||||
if (fetchedMetadata) {
|
||||
dispatch(addModel({
|
||||
modelType: 'coursewareMeta',
|
||||
model: courseMetadataResult.value,
|
||||
}));
|
||||
}
|
||||
|
||||
if (courseHomeMetadataResult.status === 'fulfilled') {
|
||||
if (fetchedCourseHomeMetadata) {
|
||||
dispatch(addModel({
|
||||
modelType: 'courseHomeMeta',
|
||||
model: {
|
||||
@@ -51,7 +65,7 @@ export function fetchCourse(courseId) {
|
||||
}));
|
||||
}
|
||||
|
||||
if (learningSequencesOutlineResult.status === 'fulfilled') {
|
||||
if (fetchedOutline) {
|
||||
const {
|
||||
courses, sections, sequences,
|
||||
} = learningSequencesOutlineResult.value;
|
||||
@@ -72,9 +86,13 @@ export function fetchCourse(courseId) {
|
||||
}));
|
||||
}
|
||||
|
||||
const fetchedMetadata = courseMetadataResult.status === 'fulfilled';
|
||||
const fetchedCourseHomeMetadata = courseHomeMetadataResult.status === 'fulfilled';
|
||||
const fetchedOutline = learningSequencesOutlineResult.status === 'fulfilled';
|
||||
if (fetchedCoursewareOutlineSidebarTogglesResult) {
|
||||
const {
|
||||
enable_navigation_sidebar: enableNavigationSidebar,
|
||||
always_open_auxiliary_sidebar: alwaysOpenAuxiliarySidebar,
|
||||
} = coursewareOutlineSidebarTogglesResult.value;
|
||||
dispatch(setCoursewareOutlineSidebarToggles({ enableNavigationSidebar, alwaysOpenAuxiliarySidebar }));
|
||||
}
|
||||
|
||||
// Log errors for each request if needed. Outline failures may occur
|
||||
// even if the course metadata request is successful
|
||||
@@ -94,6 +112,9 @@ export function fetchCourse(courseId) {
|
||||
if (!fetchedCourseHomeMetadata) {
|
||||
logError(courseHomeMetadataResult.reason);
|
||||
}
|
||||
if (!fetchedCoursewareOutlineSidebarTogglesResult) {
|
||||
logError(fetchedCoursewareOutlineSidebarTogglesResult.reason);
|
||||
}
|
||||
if (fetchedMetadata && fetchedCourseHomeMetadata) {
|
||||
if (courseHomeMetadataResult.value.courseAccess.hasAccess && fetchedOutline) {
|
||||
// User has access
|
||||
@@ -153,7 +174,7 @@ export function fetchSequence(sequenceId) {
|
||||
export function checkBlockCompletion(courseId, sequenceId, unitId) {
|
||||
return async (dispatch, getState) => {
|
||||
const { models } = getState();
|
||||
if (models.units[unitId].complete) {
|
||||
if (models.units[unitId]?.complete) {
|
||||
return {}; // do nothing. Things don't get uncompleted after they are completed.
|
||||
}
|
||||
|
||||
@@ -166,6 +187,7 @@ export function checkBlockCompletion(courseId, sequenceId, unitId) {
|
||||
complete: isComplete,
|
||||
},
|
||||
}));
|
||||
dispatch(updateCourseOutlineCompletion({ sequenceId, unitId, isComplete }));
|
||||
return isComplete;
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
@@ -251,3 +273,16 @@ export function getCourseDiscussionTopics(courseId) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getCourseOutlineStructure(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(fetchCourseOutlineRequest());
|
||||
try {
|
||||
const courseOutline = await getCourseOutline(courseId);
|
||||
dispatch(fetchCourseOutlineSuccess({ courseOutline }));
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
dispatch(fetchCourseOutlineFailure());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
203
src/courseware/data/utils.js
Normal file
203
src/courseware/data/utils.js
Normal file
@@ -0,0 +1,203 @@
|
||||
import { logInfo } from '@edx/frontend-platform/logging';
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
|
||||
import { getTimeOffsetMillis } from '../../course-home/data/api';
|
||||
|
||||
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 === 0) {
|
||||
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;
|
||||
}
|
||||
|
||||
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),
|
||||
language: data.language,
|
||||
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 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,
|
||||
navigationDisabled: sequence.navigation_disabled,
|
||||
},
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes outline blocks for a given course.
|
||||
* @param {string} courseId - The unique identifier for the course.
|
||||
* @param {Object} blocks - An object containing different blocks of the course outline.
|
||||
* @returns {Object} - An object with normalized sections, sequences, and units.
|
||||
*/
|
||||
export function normalizeOutlineBlocks(courseId, blocks) {
|
||||
const models = {
|
||||
sections: {},
|
||||
sequences: {},
|
||||
units: {},
|
||||
};
|
||||
Object.values(blocks).forEach(block => {
|
||||
switch (block.type) {
|
||||
case 'chapter':
|
||||
models.sections[block.id] = {
|
||||
complete: block.complete,
|
||||
id: block.id,
|
||||
title: block.display_name,
|
||||
sequenceIds: block.children || [],
|
||||
};
|
||||
break;
|
||||
|
||||
case 'sequential':
|
||||
case 'lock':
|
||||
models.sequences[block.id] = {
|
||||
complete: block.complete,
|
||||
id: block.id,
|
||||
title: block.display_name,
|
||||
type: block.type,
|
||||
specialExamInfo: block.special_exam_info,
|
||||
unitIds: block.children || [],
|
||||
};
|
||||
break;
|
||||
|
||||
case 'vertical':
|
||||
models.units[block.id] = {
|
||||
complete: block.complete,
|
||||
icon: block.icon,
|
||||
id: block.id,
|
||||
title: block.display_name,
|
||||
type: block.type,
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
logInfo(`Unexpected course block type: ${block.type} with ID ${block.id}. Expected block types are course, chapter, and sequential.`);
|
||||
}
|
||||
});
|
||||
|
||||
return models;
|
||||
}
|
||||
@@ -104,6 +104,10 @@
|
||||
margin: -1px -1px 0;
|
||||
}
|
||||
|
||||
@media (max-width: -1 + map-get($grid-breakpoints, "sm")) {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex-grow: 1;
|
||||
display: inline-flex;
|
||||
@@ -412,10 +416,12 @@
|
||||
@import "generic/upgrade-notification/UpgradeNotification.scss";
|
||||
@import "generic/upsell-bullets/UpsellBullets.scss";
|
||||
@import "course-home/outline-tab/widgets/ProctoringInfoPanel.scss";
|
||||
@import "src/course-home/outline-tab/widgets/FlagButton.scss";
|
||||
@import "course-home/outline-tab/widgets/FlagButton.scss";
|
||||
@import "course-home/progress-tab/course-completion/CompletionDonutChart.scss";
|
||||
@import "course-home/progress-tab/grades/course-grade/GradeBar.scss";
|
||||
@import "courseware/course/course-exit/CourseRecommendations";
|
||||
@import "product-tours/newUserCourseHomeTour/NewUserCourseHomeTourModal.scss";
|
||||
@import "course-home/courseware-search/courseware-search.scss";
|
||||
@import "course-tabs/course-tabs-navigation.scss";
|
||||
@import "courseware/course/sidebar/common/SidebarBase.scss";
|
||||
@import "courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.scss";
|
||||
|
||||
@@ -25,6 +25,7 @@ import { UserMessagesProvider } from './generic/user-messages';
|
||||
|
||||
import messages from './i18n';
|
||||
import { fetchCourse, fetchSequence } from './courseware/data';
|
||||
import { getCourseOutlineStructure } from './courseware/data/thunks';
|
||||
import { appendBrowserTimezoneToUrl, executeThunk } from './utils';
|
||||
import buildSimpleCourseAndSequenceMetadata from './courseware/data/__factories__/sequenceMetadata.factory';
|
||||
import { buildOutlineFromBlocks } from './courseware/data/__factories__/learningSequencesOutline.factory';
|
||||
@@ -152,7 +153,7 @@ export async function initializeTestStore(options = {}, overrideStore = true) {
|
||||
axiosMock.reset();
|
||||
|
||||
const {
|
||||
courseBlocks, sequenceBlocks, courseMetadata, sequenceMetadata, courseHomeMetadata,
|
||||
courseBlocks, sequenceBlocks, unitBlocks, courseMetadata, sequenceMetadata, courseHomeMetadata,
|
||||
} = buildSimpleCourseAndSequenceMetadata(options);
|
||||
|
||||
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseMetadata.id}`;
|
||||
@@ -161,14 +162,29 @@ export async function initializeTestStore(options = {}, overrideStore = true) {
|
||||
const learningSequencesUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/learning_sequences/v1/course_outline/*`);
|
||||
let courseHomeMetadataUrl = `${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseMetadata.id}`;
|
||||
const discussionConfigUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/*`);
|
||||
const coursewareSidebarSettingsUrl = `${getConfig().LMS_BASE_URL}/courses/${courseMetadata.id}/courseware-navigation-sidebar/toggles/`;
|
||||
const outlineSidebarUrl = `${getConfig().LMS_BASE_URL}/api/course_home/v1/navigation/${courseMetadata.id}`;
|
||||
courseHomeMetadataUrl = appendBrowserTimezoneToUrl(courseHomeMetadataUrl);
|
||||
|
||||
const provider = options?.provider || 'legacy';
|
||||
const enableNavigationSidebar = options.enableNavigationSidebar || { enable_navigation_sidebar: true };
|
||||
const alwaysOpenAuxiliarySidebar = options.alwaysOpenAuxiliarySidebar || { always_open_auxiliary_sidebar: true };
|
||||
|
||||
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
|
||||
axiosMock.onGet(courseHomeMetadataUrl).reply(200, courseHomeMetadata);
|
||||
axiosMock.onGet(learningSequencesUrlRegExp).reply(200, buildOutlineFromBlocks(courseBlocks));
|
||||
axiosMock.onGet(discussionConfigUrl).reply(200, { provider });
|
||||
axiosMock.onGet(coursewareSidebarSettingsUrl).reply(200, {
|
||||
...enableNavigationSidebar,
|
||||
...alwaysOpenAuxiliarySidebar,
|
||||
});
|
||||
|
||||
axiosMock.onGet(outlineSidebarUrl).reply(200, {
|
||||
...courseBlocks,
|
||||
...sequenceBlocks,
|
||||
...unitBlocks,
|
||||
});
|
||||
|
||||
sequenceMetadata.forEach(metadata => {
|
||||
const sequenceMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/sequence/${metadata.item_id}`;
|
||||
axiosMock.onGet(sequenceMetadataUrl).reply(200, metadata);
|
||||
@@ -181,6 +197,12 @@ export async function initializeTestStore(options = {}, overrideStore = true) {
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
!options.excludeFetchCourse && await executeThunk(fetchCourse(courseMetadata.id), store.dispatch);
|
||||
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
!options.excludeFetchOutlineSidebar && await executeThunk(
|
||||
getCourseOutlineStructure(courseMetadata.id),
|
||||
store.dispatch,
|
||||
);
|
||||
|
||||
if (!options.excludeFetchSequence) {
|
||||
await Promise.all(sequenceBlocks
|
||||
.map(block => executeThunk(fetchSequence(block.id), store.dispatch)));
|
||||
|
||||
Reference in New Issue
Block a user