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