fix: update outline sidebar hooks for plugins (#1586)

* fix: update outline sidebar hooks for plugins

* docs: explain UnitLinkWrapper
This commit is contained in:
Kristin Aoki
2025-02-21 14:49:52 -05:00
committed by GitHub
parent 911c7658f5
commit 3cbbb0272b
9 changed files with 172 additions and 101 deletions

View File

@@ -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 }) => {
</div>
);
useEffect(() => {
if ((isEnabledSidebar && courseOutlineStatus !== LOADED) || courseOutlineShouldUpdate) {
dispatch(getCourseOutlineStructure(courseId));
}
}, [courseId, isEnabledSidebar, courseOutlineShouldUpdate]);
if (!isEnabledSidebar || isActiveEntranceExam || currentSidebar !== ID) {
return null;
}

View File

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

View File

@@ -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('<SidebarSection />', () => {
@@ -19,17 +21,23 @@ describe('<SidebarSection />', () => {
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>
);
const RootWrapper = (props) => {
const mockData = useMemo(() => ({ toggleSidebar: jest.fn() }), []);
return (
<AppProvider store={store} wrapWithRouter={false}>
<IntlProvider locale="en">
<SidebarContext.Provider value={mockData}>
<SidebarSection
section={section}
handleSelectSection={mockHandleSelectSection}
{...props}
/>
</SidebarContext.Provider>
</IntlProvider>
</AppProvider>
);
};
beforeEach(() => {
mockHandleSelectSection = jest.fn();

View File

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

View File

@@ -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('<SidebarSequence />', () => {
let sequence;
let unit;
const sequenceDescription = 'sequence test description';
let mockData;
const initTestStore = async (options) => {
store = await initializeTestStore(options);
@@ -27,21 +29,27 @@ describe('<SidebarSequence />', () => {
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(
<AppProvider store={store} wrapWithRouter={false}>
<IntlProvider locale="en">
<MemoryRouter>
<SidebarSequence
courseId={courseId}
defaultOpen={false}
sequence={sequence}
activeUnitId={sequence.unitIds[0]}
{...props}
/>
</MemoryRouter>
<SidebarContext.Provider value={{ ...mockData }}>
<MemoryRouter>
<SidebarSequence
courseId={courseId}
defaultOpen={false}
sequence={sequence}
activeUnitId={sequence.unitIds[0]}
{...props}
/>
</MemoryRouter>
</SidebarContext.Provider>
</IntlProvider>
</AppProvider>,
);

View File

@@ -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 (
<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}
<UnitLinkWrapper
{...{
sequenceId,
activeUnitId,
id,
courseId,
}}
>
<div className="col-auto p-0">
<UnitIcon type={iconType} isCompleted={complete} />
@@ -75,7 +46,7 @@ const SidebarUnit = ({
, {intl.formatMessage(complete ? messages.completedUnit : messages.incompleteUnit)}
</span>
</div>
</Link>
</UnitLinkWrapper>
</li>
);
};

View File

@@ -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('<SidebarUnit />', () => {
let store = {};
let unit;
let sequenceId;
let mockData;
const initTestStore = async (options) => {
store = await initializeTestStore(options);
@@ -26,24 +28,30 @@ describe('<SidebarUnit />', () => {
[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(
<AppProvider store={store} wrapWithRouter={false}>
<IntlProvider locale="en">
<MemoryRouter>
<SidebarUnit
isFirst
id={unit.id}
courseId="course123"
sequenceId={sequenceId}
unit={{ ...unit, icon: 'video', isLocked: false }}
isActive={false}
activeUnitId={unit.id}
{...props}
/>
</MemoryRouter>
<SidebarContext.Provider value={{ ...mockData }}>
<MemoryRouter>
<SidebarUnit
isFirst
id={unit.id}
courseId="course123"
sequenceId={sequenceId}
unit={{ ...unit, icon: 'video', isLocked: false }}
isActive={false}
activeUnitId={unit.id}
{...props}
/>
</MemoryRouter>
</SidebarContext.Provider>
</IntlProvider>
</AppProvider>,
);

View File

@@ -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<Props> = ({
sequenceId,
activeUnitId,
id,
courseId,
children,
}) => {
const { handleUnitClick } = useCourseOutlineSidebar();
return (
<Link
to={`/course/${courseId}/${sequenceId}/${id}`}
className="row w-100 m-0 d-flex align-items-center text-gray-700"
onClick={() => handleUnitClick({ sequenceId, activeUnitId, id })}
>
{children}
</Link>
);
};
export default UnitLinkWrapper;

View File

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