feat: [FC-0056] create course outline sidebar (#1375)

This commit is contained in:
Ihor Romaniuk
2024-05-07 18:02:06 +02:00
committed by GitHub
parent 796bbef10b
commit 1c3610e9af
44 changed files with 1893 additions and 369 deletions

15
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
#course-sidebar {
@media (max-width: -1 + map-get($grid-breakpoints, "lg")) {
overflow-y: scroll;
padding: 0 .625rem !important;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export const ID = 'COURSE_OUTLINE';

View File

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

View File

@@ -0,0 +1,3 @@
export { default as Sidebar } from './CourseOutlineTray';
export { default as Trigger } from './CourseOutlineTrigger';
export { ID } from './constants';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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