fix: update outline sidebar hooks for plugins (#1586)
* fix: update outline sidebar hooks for plugins * docs: explain UnitLinkWrapper
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user