Skip to content

Commit

Permalink
fix: update outline sidebar hooks for plugins (#1586)
Browse files Browse the repository at this point in the history
* fix: update outline sidebar hooks for plugins

* docs: explain UnitLinkWrapper
  • Loading branch information
KristinAoki authored Feb 21, 2025
1 parent 911c765 commit 3cbbb02
Show file tree
Hide file tree
Showing 9 changed files with 172 additions and 101 deletions.
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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';
Expand All @@ -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,
Expand All @@ -42,6 +28,10 @@ const CourseOutlineTray = ({ intl }) => {
handleToggleCollapse,
isActiveEntranceExam,
shouldDisplayFullScreen,
courseOutlineStatus,
activeSequenceId,
sections,
sequences,
} = useCourseOutlineSidebar();

const {
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 = (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { useMemo } from 'react';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
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 />', () => {
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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);
Expand All @@ -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>,
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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} />
Expand All @@ -75,7 +46,7 @@ const SidebarUnit = ({
, {intl.formatMessage(complete ? messages.completedUnit : messages.incompleteUnit)}
</span>
</div>
</Link>
</UnitLinkWrapper>
</li>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -19,31 +20,38 @@ describe('<SidebarUnit />', () => {
let store = {};
let unit;
let sequenceId;
let mockData;

const initTestStore = async (options) => {
store = await initializeTestStore(options);
const state = store.getState();
[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>,
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Loading

0 comments on commit 3cbbb02

Please sign in to comment.