Skip to content

Commit d76c0cc

Browse files
feat: [FC-0056] courseware sidebar enhancement (openedx#1398)
- Display section and sequence progress - Add tracking event to the unit button - Hide the horizontal unit navigation with enabled sidebar navigation
1 parent 1c3610e commit d76c0cc

21 files changed

+288
-74
lines changed

src/courseware/course/sequence/Sequence.jsx

Lines changed: 41 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import PageLoading from '@src/generic/PageLoading';
1515
import { useModel } from '@src/generic/model-store';
1616
import { useSequenceBannerTextAlert, useSequenceEntranceExamAlert } from '@src/alerts/sequence-alerts/hooks';
1717

18+
import { getCoursewareOutlineSidebarSettings } from '../../data/selectors';
1819
import CourseLicense from '../course-license';
1920
import Sidebar from '../sidebar/Sidebar';
2021
import NewSidebar from '../new-sidebar/Sidebar';
@@ -49,6 +50,7 @@ const Sequence = ({
4950
const unit = useModel('units', unitId);
5051
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
5152
const sequenceMightBeUnit = useSelector(state => state.courseware.sequenceMightBeUnit);
53+
const { enableNavigationSidebar: isEnabledOutlineSidebar } = useSelector(getCoursewareOutlineSidebarSettings);
5254

5355
const handleNext = () => {
5456
const nextIndex = sequence.unitIds.indexOf(unitId) + 1;
@@ -144,54 +146,58 @@ const Sequence = ({
144146

145147
const gated = sequence && sequence.gatedContent !== undefined && sequence.gatedContent.gated;
146148

149+
const renderUnitNavigation = (isAtTop) => (
150+
<UnitNavigation
151+
sequenceId={sequenceId}
152+
unitId={unitId}
153+
isAtTop={isAtTop}
154+
onClickPrevious={() => {
155+
logEvent('edx.ui.lms.sequence.previous_selected', 'bottom');
156+
handlePrevious();
157+
}}
158+
onClickNext={() => {
159+
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
160+
handleNext();
161+
}}
162+
/>
163+
);
164+
147165
const defaultContent = (
148166
<>
149167
<div className="sequence-container d-inline-flex flex-row w-100">
150168
<CourseOutlineTrigger />
151169
<CourseOutlineTray />
152170
<div className="sequence w-100">
153-
<div className="sequence-navigation-container">
154-
<SequenceNavigation
155-
sequenceId={sequenceId}
156-
unitId={unitId}
157-
className="mb-4"
158-
nextHandler={() => {
159-
logEvent('edx.ui.lms.sequence.next_selected', 'top');
160-
handleNext();
161-
}}
162-
onNavigate={(destinationUnitId) => {
163-
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
164-
handleNavigate(destinationUnitId);
165-
}}
166-
previousHandler={() => {
167-
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
168-
handlePrevious();
169-
}}
170-
/>
171-
</div>
171+
{!isEnabledOutlineSidebar && (
172+
<div className="sequence-navigation-container">
173+
<SequenceNavigation
174+
sequenceId={sequenceId}
175+
unitId={unitId}
176+
nextHandler={() => {
177+
logEvent('edx.ui.lms.sequence.next_selected', 'top');
178+
handleNext();
179+
}}
180+
onNavigate={(destinationUnitId) => {
181+
logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId);
182+
handleNavigate(destinationUnitId);
183+
}}
184+
previousHandler={() => {
185+
logEvent('edx.ui.lms.sequence.previous_selected', 'top');
186+
handlePrevious();
187+
}}
188+
/>
189+
</div>
190+
)}
172191

173-
<div className="unit-container flex-grow-1">
192+
<div className="unit-container flex-grow-1 pt-4">
174193
<SequenceContent
175194
courseId={courseId}
176195
gated={gated}
177196
sequenceId={sequenceId}
178197
unitId={unitId}
179198
unitLoadedHandler={handleUnitLoaded}
180199
/>
181-
{unitHasLoaded && (
182-
<UnitNavigation
183-
sequenceId={sequenceId}
184-
unitId={unitId}
185-
onClickPrevious={() => {
186-
logEvent('edx.ui.lms.sequence.previous_selected', 'bottom');
187-
handlePrevious();
188-
}}
189-
onClickNext={() => {
190-
logEvent('edx.ui.lms.sequence.next_selected', 'bottom');
191-
handleNext();
192-
}}
193-
/>
194-
)}
200+
{unitHasLoaded && renderUnitNavigation(false)}
195201
</div>
196202
</div>
197203
{isNewDiscussionSidebarViewEnabled ? <NewSidebar /> : <Sidebar />}
@@ -216,6 +222,7 @@ const Sequence = ({
216222
originalUserIsStaff={originalUserIsStaff}
217223
canAccessProctoredExams={canAccessProctoredExams}
218224
>
225+
{isEnabledOutlineSidebar && renderUnitNavigation(true)}
219226
{defaultContent}
220227
</SequenceExamWrapper>
221228
<CourseLicense license={license || undefined} />

src/courseware/course/sequence/Sequence.test.jsx

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import React from 'react';
21
import PropTypes from 'prop-types';
32
import { Factory } from 'rosie';
43
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
@@ -25,6 +24,7 @@ describe('Sequence', () => {
2524
{ type: 'vertical' },
2625
{ courseId: courseMetadata.id },
2726
));
27+
const enableNavigationSidebar = { enable_navigation_sidebar: false };
2828

2929
beforeAll(async () => {
3030
const store = await initializeTestStore({ courseMetadata, unitBlocks });
@@ -92,7 +92,11 @@ describe('Sequence', () => {
9292
{ courseId: courseMetadata.id, unitBlocks, sequenceBlock: sequenceBlocks[0] },
9393
)];
9494
const testStore = await initializeTestStore({
95-
courseMetadata, unitBlocks, sequenceBlocks, sequenceMetadata,
95+
courseMetadata,
96+
unitBlocks,
97+
sequenceBlocks,
98+
sequenceMetadata,
99+
enableNavigationSidebar: { enable_navigation_sidebar: true },
96100
}, false);
97101
const { container } = render(
98102
<SidebarWrapper overrideData={{ sequenceId: sequenceBlocks[0].id }} />,
@@ -102,8 +106,8 @@ describe('Sequence', () => {
102106
await waitFor(() => expect(screen.queryByText('Loading locked content messaging...')).toBeInTheDocument());
103107
// `Previous`, `Prerequisite` and `Close Tray` buttons.
104108
expect(screen.getAllByRole('button').length).toEqual(3);
105-
// `Active` and `Next` buttons.
106-
expect(screen.getAllByRole('link').length).toEqual(2);
109+
// `Next` button.
110+
expect(screen.getAllByRole('link').length).toEqual(1);
107111

108112
expect(screen.getByText('Content Locked')).toBeInTheDocument();
109113
const unitContainer = container.querySelector('.unit-container');
@@ -125,7 +129,7 @@ describe('Sequence', () => {
125129
{ courseId: courseMetadata.id, unitBlocks, sequenceBlock: sequenceBlocks[0] },
126130
)];
127131
const testStore = await initializeTestStore({
128-
courseMetadata, unitBlocks, sequenceBlocks, sequenceMetadata,
132+
courseMetadata, unitBlocks, sequenceBlocks, sequenceMetadata, enableNavigationSidebar,
129133
}, false);
130134
render(
131135
<Sequence {...mockData} {...{ sequenceId: sequenceBlocks[0].id }} />,
@@ -156,14 +160,16 @@ describe('Sequence', () => {
156160
expect(await screen.findByText('Loading learning sequence...')).toBeInTheDocument();
157161
// `Previous`, `Prerequisite` and `Close Tray` buttons.
158162
expect(screen.getAllByRole('button')).toHaveLength(3);
159-
// Renders `Next` button plus one button for each unit.
160-
expect(screen.getAllByRole('link')).toHaveLength(1 + unitBlocks.length);
163+
// Renders `Next` button.
164+
expect(screen.getAllByRole('link')).toHaveLength(1);
161165

162166
loadUnit();
163167
await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument());
164168
// At this point there will be 2 `Previous` and 2 `Next` buttons.
165169
expect(screen.getAllByRole('button', { name: /previous/i }).length).toEqual(2);
166170
expect(screen.getAllByRole('link', { name: /next/i }).length).toEqual(2);
171+
// Renders two `Next` buttons for top and bottom unit navigations.
172+
expect(screen.getAllByRole('link')).toHaveLength(2);
167173
});
168174

169175
describe('sequence and unit navigation buttons', () => {
@@ -179,7 +185,9 @@ describe('Sequence', () => {
179185
)];
180186

181187
beforeAll(async () => {
182-
testStore = await initializeTestStore({ courseMetadata, unitBlocks, sequenceBlocks }, false);
188+
testStore = await initializeTestStore({
189+
courseMetadata, unitBlocks, sequenceBlocks, enableNavigationSidebar,
190+
}, false);
183191
});
184192

185193
beforeEach(() => {
@@ -340,7 +348,11 @@ describe('Sequence', () => {
340348
{ courseId: courseMetadata.id, unitBlocks: block.children.length ? unitBlocks : [], sequenceBlock: block },
341349
));
342350
const innerTestStore = await initializeTestStore({
343-
courseMetadata, unitBlocks, sequenceBlocks: testSequenceBlocks, sequenceMetadata: testSequenceMetadata,
351+
courseMetadata,
352+
unitBlocks,
353+
sequenceBlocks: testSequenceBlocks,
354+
sequenceMetadata: testSequenceMetadata,
355+
enableNavigationSidebar,
344356
}, false);
345357
const testData = {
346358
...mockData,

src/courseware/course/sequence/sequence-navigation/UnitNavigation.jsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react';
1+
import classNames from 'classnames';
22
import { Link } from 'react-router-dom';
33
import PropTypes from 'prop-types';
44
import { Button } from '@openedx/paragon';
@@ -21,6 +21,7 @@ const UnitNavigation = ({
2121
unitId,
2222
onClickPrevious,
2323
onClickNext,
24+
isAtTop,
2425
}) => {
2526
const {
2627
isFirstUnit, isLastUnit, nextLink, previousLink,
@@ -33,7 +34,7 @@ const UnitNavigation = ({
3334
return (
3435
<Button
3536
variant="outline-secondary"
36-
className="previous-button mr-2 d-flex align-items-center justify-content-center"
37+
className="previous-button mr-sm-2 d-flex align-items-center justify-content-center"
3738
disabled={disabled}
3839
onClick={onClickPrevious}
3940
as={disabled ? undefined : Link}
@@ -68,7 +69,7 @@ const UnitNavigation = ({
6869
};
6970

7071
return (
71-
<div className="unit-navigation d-flex">
72+
<div className={classNames('unit-navigation d-flex', { 'top-unit-navigation mb-3 w-100': isAtTop })}>
7273
{renderPreviousButton()}
7374
{renderNextButton()}
7475
</div>
@@ -81,10 +82,12 @@ UnitNavigation.propTypes = {
8182
unitId: PropTypes.string,
8283
onClickPrevious: PropTypes.func.isRequired,
8384
onClickNext: PropTypes.func.isRequired,
85+
isAtTop: PropTypes.bool,
8486
};
8587

8688
UnitNavigation.defaultProps = {
8789
unitId: null,
90+
isAtTop: false,
8891
};
8992

9093
export default injectIntl(UnitNavigation);

src/courseware/course/sidebar/SidebarTriggers.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const SidebarTriggers = () => {
2020
return (
2121
<div
2222
className={classNames({ 'ml-1': !isMobileView, 'border-primary-700 sidebar-active': isActive })}
23-
style={{ borderBottom: isActive ? '2px solid' : null }}
23+
style={{ borderBottom: '2px solid', borderColor: isActive ? 'inherit' : 'transparent' }}
2424
key={sidebarId}
2525
>
2626
<Trigger onClick={() => toggleSidebar(sidebarId)} key={sidebarId} />

src/courseware/course/sidebar/common/SidebarBase.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ const SidebarBase = ({
3838
<section
3939
className={classNames('ml-0 border border-light-400 rounded-sm h-auto align-top zindex-0', {
4040
'bg-white m-0 border-0 fixed-top vh-100 rounded-0': shouldDisplayFullScreen,
41-
'min-vh-100': !shouldDisplayFullScreen,
41+
'align-self-start': !shouldDisplayFullScreen,
4242
'd-none': currentSidebar !== sidebarId,
4343
}, className)}
4444
data-testid={`sidebar-${sidebarId}`}

src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ const CourseOutlineTray = ({ intl }) => {
6363
};
6464

6565
const sidebarHeading = (
66-
<div className="outline-sidebar-heading-wrapper sticky d-flex justify-content-between align-items-center bg-light-200 p-2.5 pl-4">
66+
<div className="outline-sidebar-heading-wrapper sticky d-flex justify-content-between align-self-start align-items-center bg-light-200 p-2.5 pl-4">
6767
{isDisplaySequenceLevel && backButtonTitle ? (
6868
<Button
6969
variant="link"

src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.scss

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616

1717
.outline-sidebar-heading-wrapper {
1818
border: 1px solid #d7d3d1;
19-
align-self: flex-start;
2019

2120
&.sticky {
2221
position: sticky;

src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTrigger.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ const CourseOutlineTrigger = ({ intl, isMobileView }) => {
2525
}
2626

2727
return (
28-
<div className={classNames('outline-sidebar-heading-wrapper bg-light-200 collapsed', {
29-
'flex-shrink-0 mr-4 p-2.5 sticky': isDisplayForDesktopView,
28+
<div className={classNames('outline-sidebar-heading-wrapper bg-light-200 collapsed align-self-start', {
29+
'flex-shrink-0 mr-4 p-2.5': isDisplayForDesktopView,
3030
'p-0': isDisplayForMobileView,
3131
})}
3232
>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import PropTypes from 'prop-types';
2+
import {
3+
CheckCircle as CheckCircleIcon,
4+
LmsCompletionSolid as LmsCompletionSolidIcon,
5+
} from '@openedx/paragon/icons';
6+
7+
import { DashedCircleIcon } from '../icons';
8+
9+
const CompletionIcon = ({ completionStat: { completed = 0, total = 0 } }) => {
10+
const percentage = total !== 0 ? Math.min((completed / total) * 100, 100) : 0;
11+
const remainder = 100 - percentage;
12+
13+
switch (true) {
14+
case !completed:
15+
return <LmsCompletionSolidIcon className="text-gray-300" data-testid="completion-solid-icon" />;
16+
case completed === total:
17+
return <CheckCircleIcon className="text-success" data-testid="check-circle-icon" />;
18+
default:
19+
return <DashedCircleIcon percentage={percentage} remainder={remainder} data-testid="dashed-circle-icon" />;
20+
}
21+
};
22+
23+
CompletionIcon.propTypes = {
24+
completionStat: PropTypes.shape({
25+
completed: PropTypes.number,
26+
total: PropTypes.number,
27+
}).isRequired,
28+
};
29+
30+
export default CompletionIcon;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { render, screen } from '@testing-library/react';
2+
3+
import CompletionIcon from './CompletionIcon';
4+
5+
describe('CompletionIcon', () => {
6+
it('renders check circle icon when completion is equal to total', () => {
7+
const completionStat = { completed: 5, total: 5 };
8+
render(<CompletionIcon completionStat={completionStat} />);
9+
expect(screen.getByTestId('check-circle-icon')).toBeInTheDocument();
10+
});
11+
12+
it('renders dashed circle icon when completion is between 0 and total', () => {
13+
const completionStat = { completed: 2, total: 5 };
14+
render(<CompletionIcon completionStat={completionStat} />);
15+
expect(screen.getByTestId('dashed-circle-icon')).toBeInTheDocument();
16+
});
17+
18+
it('renders completion solid icon when completion is 0', () => {
19+
const completionStat = { completed: 0, total: 5 };
20+
render(<CompletionIcon completionStat={completionStat} />);
21+
expect(screen.getByTestId('completion-solid-icon')).toBeInTheDocument();
22+
});
23+
});

0 commit comments

Comments
 (0)