Skip to content

Commit b5e8ae2

Browse files
ArturGasparAgrendalath
authored andcommitted
feat: legacy course navigation
Add an option to enable the legacy course navigation where clicking a breadcrumb leads to the course index page highlighting the selected section.
1 parent 0137868 commit b5e8ae2

File tree

13 files changed

+175
-23
lines changed

13 files changed

+175
-23
lines changed

.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ DISCOVERY_API_BASE_URL=''
1313
DISCUSSIONS_MFE_BASE_URL=''
1414
ECOMMERCE_BASE_URL=''
1515
ENABLE_JUMPNAV='true'
16+
ENABLE_LEGACY_NAV=''
1617
ENABLE_NOTICES=''
1718
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=''
1819
EXAMS_BASE_URL=''

.env.development

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ DISCOVERY_API_BASE_URL='http://localhost:18381'
1313
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
1414
ECOMMERCE_BASE_URL='http://localhost:18130'
1515
ENABLE_JUMPNAV='true'
16+
ENABLE_LEGACY_NAV=''
1617
ENABLE_NOTICES=''
1718
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
1819
EXAMS_BASE_URL='http://localhost:18740'

README.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ ENABLE_JUMPNAV
8080
This feature flag is slated to be removed as jumpnav becomes default. Follow the progress of this ticket here:
8181
https://openedx.atlassian.net/browse/TNL-8678
8282

83+
ENABLE_LEGACY_NAV
84+
Enables the legacy behaviour in the course breadcrumbs, where links lead to
85+
the course index highlighting the selected course section or subsection.
86+
8387
SOCIAL_UTM_MILESTONE_CAMPAIGN
8488
This value is passed as the ``utm_campaign`` parameter for social-share
8589
links when celebrating learning milestones in the course. Optional.

src/course-home/outline-tab/OutlineTab.jsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,15 @@ const OutlineTab = ({ intl }) => {
121121
}
122122
}, [location.search]);
123123

124+
// A section or subsection is selected by its id being the location hash part.
125+
// location.hash will contain an initial # sign, so remove it here.
126+
const hashValue = location.hash.substring(1);
127+
// Represents whether section is either selected or contains selected
128+
// subsection and thus should be expanded by default.
129+
const selectedSectionId = rootCourseId && courses[rootCourseId].sectionIds.find((sectionId) => (
130+
(hashValue === sectionId) || sections[sectionId].sequenceIds.includes(hashValue)
131+
));
132+
124133
return (
125134
<>
126135
<div data-learner-type={learnerType} className="row w-100 mx-0 my-3 justify-content-between">
@@ -171,7 +180,11 @@ const OutlineTab = ({ intl }) => {
171180
<Section
172181
key={sectionId}
173182
courseId={courseId}
174-
defaultOpen={sections[sectionId].resumeBlock}
183+
defaultOpen={
184+
(selectedSectionId)
185+
? sectionId === selectedSectionId
186+
: sections[sectionId].resumeBlock
187+
}
175188
expand={expandAll}
176189
section={sections[sectionId]}
177190
/>

src/course-home/outline-tab/OutlineTab.test.jsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,16 @@ describe('Outline Tab', () => {
8181
});
8282

8383
describe('Course Outline', () => {
84+
const { scrollIntoView } = window.HTMLElement.prototype;
85+
86+
beforeEach(() => {
87+
window.HTMLElement.prototype.scrollIntoView = jest.fn();
88+
});
89+
90+
afterEach(() => {
91+
window.HTMLElement.prototype.scrollIntoView = scrollIntoView;
92+
});
93+
8494
it('displays link to start course', async () => {
8595
await fetchAndRender();
8696
expect(screen.getByRole('link', { name: messages.start.defaultMessage })).toBeInTheDocument();
@@ -107,6 +117,28 @@ describe('Outline Tab', () => {
107117
expect(expandedSectionNode).toHaveAttribute('aria-expanded', 'true');
108118
});
109119

120+
it('expands and scrolls to selected section', async () => {
121+
const { courseBlocks, sectionBlocks } = await buildMinimalCourseBlocks(courseId, 'Title');
122+
setTabData({
123+
course_blocks: { blocks: courseBlocks.blocks },
124+
});
125+
await fetchAndRender(`http://localhost/#${sectionBlocks[0].id}`);
126+
const expandedSectionNode = screen.getByRole('button', { name: /Title of Section/ });
127+
expect(expandedSectionNode).toHaveAttribute('aria-expanded', 'true');
128+
expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
129+
});
130+
131+
it('expands and scrolls to section that contains selected subsection', async () => {
132+
const { courseBlocks, sequenceBlocks } = await buildMinimalCourseBlocks(courseId, 'Title');
133+
setTabData({
134+
course_blocks: { blocks: courseBlocks.blocks },
135+
});
136+
await fetchAndRender(`http://localhost/#${sequenceBlocks[0].id}`);
137+
const expandedSectionNode = screen.getByRole('button', { name: /Title of Section/ });
138+
expect(expandedSectionNode).toHaveAttribute('aria-expanded', 'true');
139+
expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
140+
});
141+
110142
it('handles expand/collapse all button click', async () => {
111143
await fetchAndRender();
112144
// Button renders as "Expand All"

src/course-home/outline-tab/Section.jsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import React, { useEffect, useState } from 'react';
22
import PropTypes from 'prop-types';
3+
import classNames from 'classnames';
4+
import { useLocation } from 'react-router-dom';
35
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
46
import { Collapsible, IconButton } from '@edx/paragon';
57
import { faCheckCircle as fasCheckCircle, faMinus, faPlus } from '@fortawesome/free-solid-svg-icons';
@@ -10,7 +12,9 @@ import SequenceLink from './SequenceLink';
1012
import { useModel } from '../../generic/model-store';
1113

1214
import genericMessages from '../../generic/messages';
15+
import { useScrollTo } from './hooks';
1316
import messages from './messages';
17+
import './Section.scss';
1418

1519
const Section = ({
1620
courseId,
@@ -29,6 +33,10 @@ const Section = ({
2933
sequences,
3034
},
3135
} = useModel('outline', courseId);
36+
// A section or subsection is selected by its id being the location hash part.
37+
// location.hash will contain an initial # sign, so remove it here.
38+
const hashValue = useLocation().hash.substring(1);
39+
const selected = hashValue === section.id;
3240

3341
const [open, setOpen] = useState(defaultOpen);
3442

@@ -41,6 +49,8 @@ const Section = ({
4149
// eslint-disable-next-line react-hooks/exhaustive-deps
4250
}, []);
4351

52+
const sectionRef = useScrollTo(selected);
53+
4454
const sectionTitle = (
4555
<div className="row w-100 m-0">
4656
<div className="col-auto p-0">
@@ -72,9 +82,9 @@ const Section = ({
7282
);
7383

7484
return (
75-
<li>
85+
<li ref={sectionRef}>
7686
<Collapsible
77-
className="mb-2"
87+
className={classNames('mb-2 section', { 'section-selected': selected })}
7888
styling="card-lg"
7989
title={sectionTitle}
8090
open={open}
@@ -104,6 +114,8 @@ const Section = ({
104114
courseId={courseId}
105115
sequence={sequences[sequenceId]}
106116
first={index === 0}
117+
last={index === (sequenceIds.length - 1)}
118+
selected={hashValue === sequenceId}
107119
/>
108120
))}
109121
</ol>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
@import "~@edx/brand/paragon/variables";
2+
@import "~@edx/paragon/scss/core/core";
3+
@import "~@edx/brand/paragon/overrides";
4+
5+
.section .collapsible-body {
6+
/* Internal SequenceLink components will have padding instead so when
7+
* highlighted the highlighting reaches the top and/or bottom of the
8+
* collapsible body. */
9+
padding-top: 0;
10+
padding-bottom: 0;
11+
}
12+
13+
.section-selected > .collapsible-trigger {
14+
background-color: $light-300;
15+
}

src/course-home/outline-tab/SequenceLink.jsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,18 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
1414

1515
import EffortEstimate from '../../shared/effort-estimate';
1616
import { useModel } from '../../generic/model-store';
17+
import { useScrollTo } from './hooks';
1718
import messages from './messages';
19+
import './SequenceLink.scss';
1820

1921
const SequenceLink = ({
2022
id,
2123
intl,
2224
courseId,
2325
first,
26+
last,
2427
sequence,
28+
selected,
2529
}) => {
2630
const {
2731
complete,
@@ -39,6 +43,8 @@ const SequenceLink = ({
3943
const coursewareUrl = <Link to={`/course/${courseId}/${id}`}>{title}</Link>;
4044
const displayTitle = showLink ? coursewareUrl : title;
4145

46+
const sequenceLinkRef = useScrollTo(selected);
47+
4248
const dueDateMessage = (
4349
<FormattedMessage
4450
id="learning.outline.sequence-due-date-set"
@@ -84,8 +90,18 @@ const SequenceLink = ({
8490
);
8591

8692
return (
87-
<li>
88-
<div className={classNames('', { 'mt-2 pt-2 border-top border-light': !first })}>
93+
<li
94+
ref={sequenceLinkRef}
95+
className={classNames('', { 'sequence-link-selected': selected })}
96+
>
97+
<div
98+
className={classNames('', {
99+
'pt-2 border-top border-light': !first,
100+
'pt-2.5': first,
101+
'pb-2': !last,
102+
'pb-2.5': last,
103+
})}
104+
>
89105
<div className="row w-100 m-0">
90106
<div className="col-auto p-0">
91107
{complete ? (
@@ -129,7 +145,9 @@ SequenceLink.propTypes = {
129145
intl: intlShape.isRequired,
130146
courseId: PropTypes.string.isRequired,
131147
first: PropTypes.bool.isRequired,
148+
last: PropTypes.bool.isRequired,
132149
sequence: PropTypes.shape().isRequired,
150+
selected: PropTypes.bool.isRequired,
133151
};
134152

135153
export default injectIntl(SequenceLink);
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
@import "~@edx/brand/paragon/variables";
2+
@import "~@edx/paragon/scss/core/core";
3+
@import "~@edx/brand/paragon/overrides";
4+
5+
.sequence-link-selected {
6+
background-color: $light-300;
7+
}

src/course-home/outline-tab/hooks.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/* eslint-disable import/prefer-default-export */
2+
3+
import { useEffect, useRef, useState } from 'react';
4+
5+
function useScrollTo(shouldScroll) {
6+
const ref = useRef(null);
7+
const [scrolled, setScrolled] = useState(false);
8+
9+
useEffect(() => {
10+
if (shouldScroll && !scrolled) {
11+
setScrolled(true);
12+
ref.current.scrollIntoView({ behavior: 'smooth' });
13+
}
14+
}, [shouldScroll, scrolled]);
15+
16+
return ref;
17+
}
18+
19+
export {
20+
useScrollTo,
21+
};

0 commit comments

Comments
 (0)