From 3cbbb0272baaeb883d2382518e4f13c0579c9f27 Mon Sep 17 00:00:00 2001
From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com>
Date: Fri, 21 Feb 2025 14:49:52 -0500
Subject: [PATCH] fix: update outline sidebar hooks for plugins (#1586)
* fix: update outline sidebar hooks for plugins
* docs: explain UnitLinkWrapper
---
.../course-outline/CourseOutlineTray.jsx | 28 ++-------
.../components/SidebarSection.jsx | 5 +-
.../components/SidebarSection.test.jsx | 30 ++++++----
.../components/SidebarSequence.jsx | 6 +-
.../components/SidebarSequence.test.jsx | 26 ++++++---
.../course-outline/components/SidebarUnit.jsx | 47 +++------------
.../components/SidebarUnit.test.jsx | 32 +++++++----
.../components/UnitLinkWrapper.tsx | 42 ++++++++++++++
.../sidebar/sidebars/course-outline/hooks.jsx | 57 ++++++++++++++++++-
9 files changed, 172 insertions(+), 101 deletions(-)
create mode 100644 src/courseware/course/sidebar/sidebars/course-outline/components/UnitLinkWrapper.tsx
diff --git a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.jsx b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.jsx
index 83356221..9bf3544d 100644
--- a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.jsx
+++ b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.jsx
@@ -1,6 +1,5 @@
-import { useState, useEffect } from 'react';
+import { useState } 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 {
@@ -9,15 +8,8 @@ import {
} from '@openedx/paragon/icons';
import { useModel } from '@src/generic/model-store';
-import { LOADING, LOADED } from '@src/constants';
+import { LOADING } from '@src/constants';
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';
@@ -28,12 +20,6 @@ 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,
@@ -42,6 +28,10 @@ const CourseOutlineTray = ({ intl }) => {
handleToggleCollapse,
isActiveEntranceExam,
shouldDisplayFullScreen,
+ courseOutlineStatus,
+ activeSequenceId,
+ sections,
+ sequences,
} = useCourseOutlineSidebar();
const {
@@ -87,12 +77,6 @@ const CourseOutlineTray = ({ intl }) => {
);
- useEffect(() => {
- if ((isEnabledSidebar && courseOutlineStatus !== LOADED) || courseOutlineShouldUpdate) {
- dispatch(getCourseOutlineStructure(courseId));
- }
- }, [courseId, isEnabledSidebar, courseOutlineShouldUpdate]);
-
if (!isEnabledSidebar || isActiveEntranceExam || currentSidebar !== ID) {
return null;
}
diff --git a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSection.jsx b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSection.jsx
index 659edf49..15c3b6dd 100644
--- a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSection.jsx
+++ b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSection.jsx
@@ -1,13 +1,12 @@
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 { ChevronRight as ChevronRightIcon } from '@openedx/paragon/icons';
import courseOutlineMessages from '@src/course-home/outline-tab/messages';
-import { getSequenceId } from '@src/courseware/data/selectors';
import CompletionIcon from './CompletionIcon';
+import { useCourseOutlineSidebar } from '../hooks';
const SidebarSection = ({ intl, section, handleSelectSection }) => {
const {
@@ -18,7 +17,7 @@ const SidebarSection = ({ intl, section, handleSelectSection }) => {
completionStat,
} = section;
- const activeSequenceId = useSelector(getSequenceId);
+ const { activeSequenceId } = useCourseOutlineSidebar();
const isActiveSection = sequenceIds.includes(activeSequenceId);
const sectionTitle = (
diff --git a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSection.test.jsx b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSection.test.jsx
index cf3aab7e..8fbaa334 100644
--- a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSection.test.jsx
+++ b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSection.test.jsx
@@ -1,3 +1,4 @@
+import { useMemo } from 'react';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
@@ -5,6 +6,7 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { initializeTestStore } from '@src/setupTest';
import courseOutlineMessages from '@src/course-home/outline-tab/messages';
+import SidebarContext from '../../../SidebarContext';
import SidebarSection from './SidebarSection';
describe('', () => {
@@ -19,17 +21,23 @@ describe('', () => {
section = state.courseware.courseOutline.sections[activeSectionId];
};
- const RootWrapper = (props) => (
-
-
- ,
-
-
- );
+ const RootWrapper = (props) => {
+ const mockData = useMemo(() => ({ toggleSidebar: jest.fn() }), []);
+
+ return (
+
+
+
+
+
+
+
+ );
+ };
beforeEach(() => {
mockHandleSelectSection = jest.fn();
diff --git a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSequence.jsx b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSequence.jsx
index 18d41071..fb9f47c4 100644
--- a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSequence.jsx
+++ b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSequence.jsx
@@ -1,12 +1,11 @@
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 courseOutlineMessages from '@src/course-home/outline-tab/messages';
-import { getCourseOutline, getSequenceId } from '@src/courseware/data/selectors';
+import { useCourseOutlineSidebar } from '../hooks';
import CompletionIcon from './CompletionIcon';
import SidebarUnit from './SidebarUnit';
import { UNIT_ICON_TYPES } from './UnitIcon';
@@ -29,8 +28,7 @@ const SidebarSequence = ({
} = sequence;
const [open, setOpen] = useState(defaultOpen);
- const { units = {} } = useSelector(getCourseOutline);
- const activeSequenceId = useSelector(getSequenceId);
+ const { activeSequenceId, units } = useCourseOutlineSidebar();
const isActiveSequence = id === activeSequenceId;
const sectionTitle = (
diff --git a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSequence.test.jsx b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSequence.test.jsx
index 26a0c722..25f15bb3 100644
--- a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSequence.test.jsx
+++ b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSequence.test.jsx
@@ -6,6 +6,7 @@ import { IntlProvider } from '@edx/frontend-platform/i18n';
import courseOutlineMessages from '@src/course-home/outline-tab/messages';
import { initializeMockApp, initializeTestStore } from '@src/setupTest';
+import SidebarContext from '../../../SidebarContext';
import messages from '../messages';
import SidebarSequence from './SidebarSequence';
@@ -17,6 +18,7 @@ describe('', () => {
let sequence;
let unit;
const sequenceDescription = 'sequence test description';
+ let mockData;
const initTestStore = async (options) => {
store = await initializeTestStore(options);
@@ -27,21 +29,27 @@ describe('', () => {
sequence = state.courseware.courseOutline.sequences[activeSequenceId];
const unitId = sequence.unitIds[0];
unit = state.courseware.courseOutline.units[unitId];
+
+ mockData = {
+ toggleSidebar: jest.fn(),
+ };
};
function renderWithProvider(props = {}) {
const { container } = render(
-
-
-
+
+
+
+
+
,
);
diff --git a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarUnit.jsx b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarUnit.jsx
index 1d34ee63..bf5e9f56 100644
--- a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarUnit.jsx
+++ b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarUnit.jsx
@@ -1,14 +1,10 @@
import PropTypes from 'prop-types';
import classNames from 'classnames';
-import { Link } from 'react-router-dom';
-import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
-import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
-import { checkBlockCompletion } from '@src/courseware/data';
-import { getCourseOutline } from '@src/courseware/data/selectors';
import messages from '../messages';
import UnitIcon, { UNIT_ICON_TYPES } from './UnitIcon';
+import UnitLinkWrapper from './UnitLinkWrapper';
const SidebarUnit = ({
id,
@@ -26,43 +22,18 @@ const SidebarUnit = ({
title,
icon = UNIT_ICON_TYPES.other,
} = unit;
- const dispatch = useDispatch();
- const { sequences = {} } = useSelector(getCourseOutline);
-
- const logEvent = (eventName, widgetPlacement) => {
- const findSequenceByUnitId = (unitId) => Object.values(sequences).find(seq => seq.unitIds.includes(unitId));
- const activeSequence = findSequenceByUnitId(activeUnitId);
- const targetSequence = findSequenceByUnitId(id);
- const payload = {
- id: activeUnitId,
- current_tab: activeSequence.unitIds.indexOf(activeUnitId) + 1,
- tab_count: activeSequence.unitIds.length,
- target_id: id,
- target_tab: targetSequence.unitIds.indexOf(id) + 1,
- widget_placement: widgetPlacement,
- };
-
- if (activeSequence.id !== targetSequence.id) {
- payload.target_tab_count = targetSequence.unitIds.length;
- }
-
- sendTrackEvent(eventName, payload);
- sendTrackingLogEvent(eventName, payload);
- };
-
- const handleClick = () => {
- logEvent('edx.ui.lms.sequence.tab_selected', 'left');
- dispatch(checkBlockCompletion(courseId, sequenceId, activeUnitId));
- };
const iconType = isLocked ? UNIT_ICON_TYPES.lock : icon;
return (
-
@@ -75,7 +46,7 @@ const SidebarUnit = ({
, {intl.formatMessage(complete ? messages.completedUnit : messages.incompleteUnit)}
-
+
);
};
diff --git a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarUnit.test.jsx b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarUnit.test.jsx
index 41776a7e..4c1a430f 100644
--- a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarUnit.test.jsx
+++ b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarUnit.test.jsx
@@ -6,6 +6,7 @@ import { IntlProvider } from '@edx/frontend-platform/i18n';
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { initializeMockApp, initializeTestStore } from '@src/setupTest';
+import SidebarContext from '../../../SidebarContext';
import SidebarUnit from './SidebarUnit';
jest.mock('@edx/frontend-platform/analytics', () => ({
@@ -19,6 +20,7 @@ describe('', () => {
let store = {};
let unit;
let sequenceId;
+ let mockData;
const initTestStore = async (options) => {
store = await initializeTestStore(options);
@@ -26,24 +28,30 @@ describe('', () => {
[sequenceId] = Object.keys(state.courseware.courseOutline.sequences);
const sequence = state.courseware.courseOutline.sequences[sequenceId];
unit = state.courseware.courseOutline.units[sequence.unitIds[0]];
+
+ mockData = {
+ toggleSidebar: jest.fn(),
+ };
};
function renderWithProvider(props = {}) {
const { container } = render(
-
-
-
+
+
+
+
+
,
);
diff --git a/src/courseware/course/sidebar/sidebars/course-outline/components/UnitLinkWrapper.tsx b/src/courseware/course/sidebar/sidebars/course-outline/components/UnitLinkWrapper.tsx
new file mode 100644
index 00000000..26ba67ac
--- /dev/null
+++ b/src/courseware/course/sidebar/sidebars/course-outline/components/UnitLinkWrapper.tsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+
+import { useCourseOutlineSidebar } from '../hooks';
+
+interface Props {
+ courseId: string;
+ sequenceId: string;
+ activeUnitId: string;
+ id: string;
+ children?: React.ReactNode;
+}
+
+/*
+ * UnitLinkWrapper is necessary for unit navigation within the OutlineTrayPlugin.
+ * import { Link } from 'react-router-dom' throws errors inside the plugin
+ * because the package tries to load two versions of 'react-router-dom' or a
+ * route can not be found. This component abstracts the import into a wrapper
+ * component that can be imported into plugins without a render error.
+ */
+
+const UnitLinkWrapper: React.FC = ({
+ sequenceId,
+ activeUnitId,
+ id,
+ courseId,
+ children,
+}) => {
+ const { handleUnitClick } = useCourseOutlineSidebar();
+
+ return (
+ handleUnitClick({ sequenceId, activeUnitId, id })}
+ >
+ {children}
+
+ );
+};
+
+export default UnitLinkWrapper;
diff --git a/src/courseware/course/sidebar/sidebars/course-outline/hooks.jsx b/src/courseware/course/sidebar/sidebars/course-outline/hooks.jsx
index 2156625e..2dc079cd 100644
--- a/src/courseware/course/sidebar/sidebars/course-outline/hooks.jsx
+++ b/src/courseware/course/sidebar/sidebars/course-outline/hooks.jsx
@@ -1,17 +1,32 @@
import { useContext, useEffect, useState } from 'react';
-import { useSelector } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
+import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { useModel } from '@src/generic/model-store';
+import { LOADED } from '@src/constants';
+import { checkBlockCompletion, getCourseOutlineStructure } from '@src/courseware/data/thunks';
import OldSidebarContext from '@src/courseware/course/sidebar/SidebarContext';
import NewSidebarContext from '@src/courseware/course/new-sidebar/SidebarContext';
-import { getCoursewareOutlineSidebarSettings } from '@src/courseware/data/selectors';
+import {
+ getCoursewareOutlineSidebarSettings,
+ getCourseOutlineShouldUpdate,
+ getCourseOutlineStatus,
+ getSequenceId,
+ getCourseOutline,
+} from '@src/courseware/data/selectors';
import { ID } from './constants';
// eslint-disable-next-line import/prefer-default-export
export const useCourseOutlineSidebar = () => {
+ const dispatch = useDispatch();
const isCollapsedOutlineSidebar = window.sessionStorage.getItem('hideCourseOutlineSidebar');
const { enableNavigationSidebar: isEnabledSidebar } = useSelector(getCoursewareOutlineSidebarSettings);
+ const courseOutlineShouldUpdate = useSelector(getCourseOutlineShouldUpdate);
+ const courseOutlineStatus = useSelector(getCourseOutlineStatus);
+ const activeSequenceId = useSelector(getSequenceId);
+ const { sections = {}, sequences = {}, units = {} } = useSelector(getCourseOutline);
+
const { courseId } = useParams();
const course = useModel('coursewareMeta', courseId);
const { isNewDiscussionSidebarViewEnabled } = useModel('courseHomeMeta', courseId);
@@ -44,12 +59,44 @@ export const useCourseOutlineSidebar = () => {
}
};
+ const handleUnitClick = ({ sequenceId, activeUnitId, id }) => {
+ const logEvent = (eventName, widgetPlacement) => {
+ const findSequenceByUnitId = () => Object.values(sequences).find(seq => seq.unitIds.includes(activeUnitId));
+ const activeSequence = findSequenceByUnitId(activeUnitId);
+ const targetSequence = findSequenceByUnitId(id);
+ const payload = {
+ id: activeUnitId,
+ current_tab: activeSequence.unitIds.indexOf(activeUnitId) + 1,
+ tab_count: activeSequence.unitIds.length,
+ target_id: id,
+ target_tab: targetSequence.unitIds.indexOf(id) + 1,
+ widget_placement: widgetPlacement,
+ };
+
+ if (activeSequence.id !== targetSequence.id) {
+ payload.target_tab_count = targetSequence.unitIds.length;
+ }
+
+ sendTrackEvent(eventName, payload);
+ sendTrackingLogEvent(eventName, payload);
+ };
+
+ logEvent('edx.ui.lms.sequence.tab_selected', 'left');
+ dispatch(checkBlockCompletion(courseId, sequenceId, activeUnitId));
+ };
+
useEffect(() => {
if (isOpenSidebar && currentSidebar !== ID) {
toggleSidebar(ID);
}
}, [initialSidebar, unitId]);
+ useEffect(() => {
+ if ((isEnabledSidebar && courseOutlineStatus !== LOADED) || courseOutlineShouldUpdate) {
+ dispatch(getCourseOutlineStructure(courseId));
+ }
+ }, [courseId, isEnabledSidebar, courseOutlineShouldUpdate]);
+
return {
courseId,
unitId,
@@ -60,5 +107,11 @@ export const useCourseOutlineSidebar = () => {
setIsOpen,
handleToggleCollapse,
isActiveEntranceExam,
+ courseOutlineStatus,
+ activeSequenceId,
+ sections,
+ sequences,
+ units,
+ handleUnitClick,
};
};