diff --git a/src/courseware/course/sidebar/ARCHITECTURE.md b/src/courseware/course/sidebar/ARCHITECTURE.md index 852411f96d..e6ab852f7f 100644 --- a/src/courseware/course/sidebar/ARCHITECTURE.md +++ b/src/courseware/course/sidebar/ARCHITECTURE.md @@ -8,9 +8,9 @@ The Learning MFE uses a **two-sidebar system** where a left sidebar (Course Outl ### LEFT SIDEBAR (Course Outline) - **Location**: Left side of screen, adjacent to course content -- **Component**: `CourseOutlineTray` (rendered via `CourseOutlineSidebarSlot`) +- **Component**: `CourseOutlineTray` (rendered via `CourseOutlineSidebarSlot`). This is now a thin wrapper (`CourseOutlineTray.tsx`) that gates on sidebar state and renders the presentational `CourseOutline` (`CourseOutline.tsx`). - **Purpose**: Navigation - displays course structure, sequences, and units -- **State Management**: Uses `useCourseOutlineSidebar()` hook +- **State Management**: Split across two hooks — `useCourseOutlineData()` (course data + unit-click tracking) and `useCourseOutlineSidebar()` (sidebar open/collapse state) - **ID**: `WIDGETS.COURSE_OUTLINE` - **Rendering**: Only renders when `currentSidebar === 'COURSE_OUTLINE'` - **Trigger**: `CourseOutlineTrigger` (separate location in mobile/desktop toolbar) @@ -200,22 +200,36 @@ if (!firstAvailable) { } ``` -### useCourseOutlineSidebar Hook +### Course Outline Hooks (`hooks.js`) + +The former single `useCourseOutlineSidebar` hook is split into two, separating +course data from sidebar context so the outline can be reused outside a sidebar: + +#### `useCourseOutlineData()` + +**Responsibilities:** +- Provide course outline data from Redux: `sections`, `sequences`, `units`, `courseOutlineStatus`, `activeSequenceId`, `sequenceStatus` +- Provide `isEnabledCompletionTracking` and `isActiveEntranceExam` +- Provide `handleUnitClick` (analytics + `checkBlockCompletion`) and trigger the outline-structure load effect +- Reads Redux + `useParams` only — **does not** read `SidebarContext`, so it is reusable outside a sidebar + +#### `useCourseOutlineSidebar()` **Responsibilities:** -- Detect when RIGHT sidebar has no available panels (`!initialSidebar`) -- Auto-open Course Outline as fallback (unless manually collapsed) -- Handle Course Outline specific interactions (unit clicks, toggle, resize) +- Expose sidebar context state: `currentSidebar`, `shouldDisplayFullScreen`, `handleToggleCollapse` +- Handle the resize → collapse behaviour when the viewport drops below the desktop breakpoint **Key Logic:** ```javascript -const isOpenSidebar = !initialSidebar && !isCollapsedOutlineSidebar; - -useEffect(() => { - if (isOpenSidebar && currentSidebar !== ID) { - toggleSidebar('COURSE_OUTLINE'); - } -}, [initialSidebar, unitId]); +useLayoutEffect(() => { + const handleResize = () => { + if (currentSidebar === ID && global.innerWidth < breakpoints.large.maxWidth) { + collapseSidebar(); + } + }; + global.addEventListener('resize', handleResize); + return () => global.removeEventListener('resize', handleResize); +}, [currentSidebar]); ``` ### Sidebar.jsx (RIGHT Sidebar Renderer) @@ -231,15 +245,44 @@ if (!currentSidebar || !SIDEBARS || !SIDEBARS[currentSidebar]) { } ``` -### CourseOutlineTray.jsx (LEFT Sidebar Renderer) +### CourseOutlineTray.tsx / CourseOutline.tsx (LEFT Sidebar Renderer) -**Responsibilities:** -- Render course outline navigation -- Only show when `currentSidebar === 'COURSE_OUTLINE'` +The renderer is split into a sidebar-aware wrapper and a presentational component: + +**`CourseOutlineTray.tsx` (wrapper):** +- Reads `useCourseOutlineSidebar()` +- Only renders when `currentSidebar === 'COURSE_OUTLINE'`, otherwise `null` +- Renders ``, passing `shouldDisplayFullScreen` and `onToggleCollapse` +- Still carries `CourseOutlineTray.ID` and is the component registered in the slot -**Key Logic:** ```javascript -if (isActiveEntranceExam || currentSidebar !== ID) { +if (currentSidebar !== ID) { return null; } +return ; ``` + +**`CourseOutline.tsx` (presentational):** +- Reads course data via `useCourseOutlineData()` + `useParams()` +- Returns `null` only when `isActiveEntranceExam` +- Renders the heading through `CourseOutlineSidebarHeadingSlot` (see Extension Points below) + +### Course Outline Extension Points (Plugin Slots) + +The refactor extracts the heading and completion icon into standalone components +exposed through plugin slots, so operators can customise them via `env.config.jsx`: + +- **`CourseOutlineSidebarHeadingSlot`** — ID `org.openedx.frontend.learning.course_outline_sidebar_heading.v1`. Wraps the extracted `components/CourseOutlineHeading.tsx`. Props: `isDisplaySequenceLevel`, `backButton?`, `onToggleCollapse?`. See `src/plugin-slots/CourseOutlineSidebarHeadingSlot/README.md`. +- **`CourseOutlineSidebarCompletionIconSlot`** — ID `org.openedx.frontend.learning.course_outline_sidebar_completion_icon.v1`. Wraps `CompletionIcon.tsx` (now TypeScript, exporting `CompletionIconProps`). Consumed by **both** `SidebarSection` and `SidebarSequence`; the `variant` prop (`'section' | 'sequence'`) tells a plugin which location it is rendering, and `active` reflects whether that section/sequence is current. See `src/plugin-slots/CourseOutlineSidebarCompletionIconSlot/README.md`. + +The mobile "collapse the outline after selecting a unit" behaviour now lives in +`components/UnitLinkWrapper.tsx`, which consumes both hooks (data for the click +handler, sidebar for `shouldDisplayFullScreen`/`handleToggleCollapse`). + +### Styling + +The outline sidebar styling (`CourseOutlineTray.scss`) uses Paragon theme tokens +rather than hard-coded colours — e.g. borders use `var(--pgn-color-light-700)`. +The active-section highlight is driven by an `.active-section` class on +`.course-sidebar-section` (`background-color: var(--pgn-color-info-100)`) instead +of an inline `bg-info-100` class on the button. diff --git a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutline.tsx b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutline.tsx index c06966f524..2afa990a0a 100644 --- a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutline.tsx +++ b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutline.tsx @@ -2,33 +2,43 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { useToggle } from '@openedx/paragon'; import { LOADING } from '@src/constants'; +import { + useCourseOutlineData, +} from '@src/courseware/course/sidebar/sidebars/course-outline/hooks'; import PageLoading from '@src/generic/PageLoading'; import { CourseOutlineSidebarHeadingSlot } from '@src/plugin-slots/CourseOutlineSidebarHeadingSlot'; import classNames from 'classnames'; import { useState } from 'react'; -import SidebarSection from './components/SidebarSection'; +import { useParams } from 'react-router-dom'; import SidebarSequence from './components/SidebarSequence'; -import { ID } from './constants'; -import { useCourseOutlineSidebar } from './hooks'; +import SidebarSection from './components/SidebarSection'; import messages from './messages'; -export const CourseOutline = () => { +interface CourseOutlineProps { + shouldDisplayFullScreen?: boolean; + onToggleCollapse?: () => void; +} + +interface CoursePageParams extends Record { + courseId: string; + unitId: string; +} + +export const CourseOutline = ({ + shouldDisplayFullScreen = false, + onToggleCollapse, +}: CourseOutlineProps) => { const intl = useIntl(); const [selectedSection, setSelectedSection] = useState(null); const [isDisplaySequenceLevel, setDisplaySequenceLevel, setDisplaySectionLevel] = useToggle(true); - + const { unitId, courseId } = useParams(); const { - courseId, - unitId, - currentSidebar, - isActiveEntranceExam, courseOutlineStatus, activeSequenceId, sections, sequences, - shouldDisplayFullScreen, - handleToggleCollapse, - } = useCourseOutlineSidebar(); + isActiveEntranceExam, + } = useCourseOutlineData(); const resolvedSectionId = selectedSection || Object.keys(sections).find( @@ -47,19 +57,16 @@ export const CourseOutline = () => { setDisplaySequenceLevel(); setSelectedSection(id); }; - const sidebarHeading = ( ); - - if (isActiveEntranceExam || currentSidebar !== ID) { + if (isActiveEntranceExam) { return null; } - if (courseOutlineStatus === LOADING) { return (
{ ? sequenceIds.map((sequenceId: string) => ( )) : sectionsIds.map((sectionId) => ( @@ -108,5 +115,3 @@ export const CourseOutline = () => {
); }; - -export default CourseOutline; diff --git a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.test.jsx b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.test.jsx index 653a9bc8d5..2cb0ff63a4 100644 --- a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.test.jsx +++ b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.test.jsx @@ -1,4 +1,5 @@ import { MemoryRouter } from 'react-router-dom'; +import { Routes, Route } from 'react-router'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { AppProvider } from '@edx/frontend-platform/react'; @@ -18,6 +19,7 @@ describe('', () => { let unit; let unitId; let courseId; + let sequenceId; let mockData; const initTestStore = async (options) => { @@ -27,8 +29,8 @@ describe('', () => { [unitId] = Object.keys(state.models.units); if (Object.keys(state.courseware.courseOutline).length) { - const [activeSequenceId] = Object.keys(state.courseware.courseOutline.sequences); - sequence = state.courseware.courseOutline.sequences[activeSequenceId]; + [sequenceId] = Object.keys(state.courseware.courseOutline.sequences); + sequence = state.courseware.courseOutline.sequences[sequenceId]; const activeSectionId = Object.keys(state.courseware.courseOutline.sections)[0]; section = state.courseware.courseOutline.sections[activeSectionId]; [unitId] = sequence.unitIds; @@ -36,20 +38,21 @@ describe('', () => { } mockData = { - courseId, - unitId, currentSidebar: outlineSidebarId, toggleSidebar: jest.fn(), }; }; function renderWithProvider(testData = {}) { + const path = `/course/${courseId}/${sequenceId}/${unitId}`; const { container } = render( - - + + + } /> + @@ -124,4 +127,14 @@ describe('', () => { await user.click(screen.getByRole('button', { name: new RegExp(`${section.title} , ${courseOutlineMessages.incompleteSection.defaultMessage}`) })); expect(screen.queryByRole('button', { name: section.title })).toBeInTheDocument(); }); + + it('highlights the unit matching teh current unitId', async () => { + await initTestStore(); + renderWithProvider(); + // Check that there is only one link with the highlight class and it's + // the one with the active unit title. + const highlightedItems = screen.getAllByRole('listitem').filter(li => li.classList.contains('bg-info-100')); + expect(highlightedItems).toHaveLength(1); + expect(highlightedItems[0]).toHaveTextContent(unit.title); + }); }); diff --git a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.tsx b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.tsx index f35775a092..88f30fcfbe 100644 --- a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.tsx +++ b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.tsx @@ -5,12 +5,14 @@ import { useCourseOutlineSidebar } from './hooks'; const CourseOutlineTray = () => { const { currentSidebar, + shouldDisplayFullScreen, + handleToggleCollapse, } = useCourseOutlineSidebar(); if (currentSidebar !== ID) { return null; } - return ; + return ; }; CourseOutlineTray.ID = ID; diff --git a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTrigger.jsx b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTrigger.jsx index abccd14aed..3d6039598f 100644 --- a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTrigger.jsx +++ b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTrigger.jsx @@ -4,17 +4,17 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { IconButton } from '@openedx/paragon'; import { MenuOpen as MenuOpenIcon } from '@openedx/paragon/icons'; -import { useCourseOutlineSidebar } from './hooks'; +import { useCourseOutlineData, useCourseOutlineSidebar } from './hooks'; import { ID } from './constants'; import messages from './messages'; const CourseOutlineTrigger = ({ isMobileView }) => { const intl = useIntl(); + const { isActiveEntranceExam } = useCourseOutlineData(); const { currentSidebar, shouldDisplayFullScreen, handleToggleCollapse, - isActiveEntranceExam, } = useCourseOutlineSidebar(); const isDisplayForDesktopView = !isMobileView && !shouldDisplayFullScreen && currentSidebar !== ID; diff --git a/src/courseware/course/sidebar/sidebars/course-outline/README.md b/src/courseware/course/sidebar/sidebars/course-outline/README.md index 3f9bcdde34..b10183a7d5 100644 --- a/src/courseware/course/sidebar/sidebars/course-outline/README.md +++ b/src/courseware/course/sidebar/sidebars/course-outline/README.md @@ -29,4 +29,5 @@ Unlike right-sidebar widgets, the course outline is **not** registered in the `S | `CourseOutlineTray` | Main tray panel component | | `CourseOutlineTrigger` | Collapse/expand trigger button | | `ID` | Widget sentinel ID: `'COURSE_OUTLINE'` | -| `useCourseOutlineSidebar` | Hook providing all tray state and handlers | +| `useCourseOutlineData` | Hook providing course outline data (sections, sequences, units, status) and unit-click tracking; usable outside a sidebar context | +| `useCourseOutlineSidebar` | Hook providing sidebar open/collapse state and handlers (reads `SidebarContext`) | 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 5e7a30879a..dacbe6dfcb 100644 --- a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSection.jsx +++ b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSection.jsx @@ -8,7 +8,7 @@ import { Button, Icon } from '@openedx/paragon'; import { ChevronRight as ChevronRightIcon } from '@openedx/paragon/icons'; import courseOutlineMessages from '@src/course-home/outline-tab/messages'; -import { useCourseOutlineSidebar } from '../hooks'; +import { useCourseOutlineData } from '../hooks'; const SidebarSection = ({ section, handleSelectSection }) => { const intl = useIntl(); @@ -20,7 +20,7 @@ const SidebarSection = ({ section, handleSelectSection }) => { completionStat, } = section; - const { activeSequenceId, isEnabledCompletionTracking } = useCourseOutlineSidebar(); + const { activeSequenceId, isEnabledCompletionTracking } = useCourseOutlineData(); const isActiveSection = sequenceIds.includes(activeSequenceId); const sectionTitle = ( 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 fa0f16435c..ddf9852759 100644 --- a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSequence.jsx +++ b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSequence.jsx @@ -8,7 +8,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Collapsible } from '@openedx/paragon'; import courseOutlineMessages from '@src/course-home/outline-tab/messages'; -import { useCourseOutlineSidebar } from '../hooks'; +import { useCourseOutlineData } from '../hooks'; import SidebarUnit from './SidebarUnit'; import { UNIT_ICON_TYPES } from './UnitIcon'; @@ -30,7 +30,7 @@ const SidebarSequence = ({ } = sequence; const [open, setOpen] = useState(defaultOpen); - const { activeSequenceId, units, isEnabledCompletionTracking } = useCourseOutlineSidebar(); + const { activeSequenceId, units, isEnabledCompletionTracking } = useCourseOutlineData(); const isActiveSequence = id === activeSequenceId; const sectionTitle = ( diff --git a/src/courseware/course/sidebar/sidebars/course-outline/components/UnitLinkWrapper.tsx b/src/courseware/course/sidebar/sidebars/course-outline/components/UnitLinkWrapper.tsx index 771eb5f7d5..17b80e39eb 100644 --- a/src/courseware/course/sidebar/sidebars/course-outline/components/UnitLinkWrapper.tsx +++ b/src/courseware/course/sidebar/sidebars/course-outline/components/UnitLinkWrapper.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Link, useLocation } from 'react-router-dom'; -import { useCourseOutlineSidebar } from '../hooks'; +import { useCourseOutlineData, useCourseOutlineSidebar } from '../hooks'; interface Props { courseId: string; @@ -26,17 +26,25 @@ const UnitLinkWrapper: React.FC = ({ courseId, children, }) => { - const { handleUnitClick } = useCourseOutlineSidebar(); + const { handleUnitClick } = useCourseOutlineData(); + const { shouldDisplayFullScreen, handleToggleCollapse } = useCourseOutlineSidebar(); const { pathname } = useLocation(); const isPreview = pathname.startsWith('/preview'); const baseUrl = `/course/${courseId}/${sequenceId}/${id}`; const link = isPreview ? `/preview${baseUrl}` : baseUrl; + const handleClick = React.useCallback(() => { + // Hide the sidebar after selecting a unit on a mobile device. + if (shouldDisplayFullScreen) { + handleToggleCollapse(); + } + handleUnitClick({ sequenceId, activeUnitId, id }); + }, [handleUnitClick, sequenceId, activeUnitId, id, shouldDisplayFullScreen, handleToggleCollapse]); return ( handleUnitClick({ sequenceId, activeUnitId, id })} + onClick={handleClick} > {children} diff --git a/src/courseware/course/sidebar/sidebars/course-outline/hooks.js b/src/courseware/course/sidebar/sidebars/course-outline/hooks.js index 6b3ca556fc..d1588fbc00 100644 --- a/src/courseware/course/sidebar/sidebars/course-outline/hooks.js +++ b/src/courseware/course/sidebar/sidebars/course-outline/hooks.js @@ -1,27 +1,26 @@ -import { - useContext, useEffect, useLayoutEffect, useState, -} from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { useParams } from 'react-router-dom'; import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics'; import { breakpoints } from '@openedx/paragon'; - -import { useModel } from '@src/generic/model-store'; import { LOADED } from '@src/constants'; -import { checkBlockCompletion, getCourseOutlineStructure } from '@src/courseware/data/thunks'; import { - getCoursewareOutlineSidebarSettings, + getCourseOutline, getCourseOutlineShouldUpdate, getCourseOutlineStatus, + getCoursewareOutlineSidebarSettings, getSequenceId, - getCourseOutline, getSequenceStatus, } from '@src/courseware/data/selectors'; +import { checkBlockCompletion, getCourseOutlineStructure } from '@src/courseware/data/thunks'; + +import { useModel } from '@src/generic/model-store'; +import { + useContext, useEffect, useLayoutEffect, +} from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useParams } from 'react-router-dom'; import SidebarContext from '../../SidebarContext'; import { ID } from './constants'; -// eslint-disable-next-line import/prefer-default-export -export const useCourseOutlineSidebar = () => { +export const useCourseOutlineData = () => { const dispatch = useDispatch(); const { enableCompletionTracking: isEnabledCompletionTracking, @@ -30,41 +29,26 @@ export const useCourseOutlineSidebar = () => { const courseOutlineStatus = useSelector(getCourseOutlineStatus); const sequenceStatus = useSelector(getSequenceStatus); const activeSequenceId = useSelector(getSequenceId); - const { sections = {}, sequences = {}, units = {} } = useSelector(getCourseOutline); + const { + sections = {}, + sequences = {}, + units = {}, + } = useSelector(getCourseOutline); const { courseId } = useParams(); const course = useModel('coursewareMeta', courseId); - const { - unitId, - currentSidebar, - toggleSidebar, - shouldDisplayFullScreen, - } = useContext(SidebarContext); - - // Course outline state is now fully controlled by SidebarContextProvider - // This component only renders when currentSidebar === 'COURSE_OUTLINE' - const [isOpen, setIsOpen] = useState(true); - const { entranceExamEnabled, entranceExamPassed, } = course.entranceExamData || {}; const isActiveEntranceExam = entranceExamEnabled && !entranceExamPassed; - const collapseSidebar = () => { - toggleSidebar(null); - }; - - const handleToggleCollapse = () => { - if (currentSidebar === ID) { - collapseSidebar(); - } else { - toggleSidebar(ID); - } - }; - - const handleUnitClick = ({ sequenceId, activeUnitId, id }) => { + const handleUnitClick = ({ + sequenceId, + activeUnitId, + id, + }) => { const logEvent = (eventName, widgetPlacement) => { const findSequenceByUnitId = () => Object.values(sequences).find(seq => seq.unitIds.includes(activeUnitId)); const activeSequence = findSequenceByUnitId(activeUnitId); @@ -88,11 +72,6 @@ export const useCourseOutlineSidebar = () => { logEvent('edx.ui.lms.sequence.tab_selected', 'left'); dispatch(checkBlockCompletion(courseId, sequenceId, activeUnitId)); - - // Hide the sidebar after selecting a unit on a mobile device. - if (shouldDisplayFullScreen) { - handleToggleCollapse(); - } }; // Load course outline structure when needed @@ -102,6 +81,38 @@ export const useCourseOutlineSidebar = () => { } }, [courseId, courseOutlineShouldUpdate]); + return { + isEnabledCompletionTracking, + isActiveEntranceExam, + courseOutlineStatus, + activeSequenceId, + sections, + sequences, + units, + handleUnitClick, + sequenceStatus, + }; +}; + +export const useCourseOutlineSidebar = () => { + const { + currentSidebar, + toggleSidebar, + shouldDisplayFullScreen, + } = useContext(SidebarContext); + + const collapseSidebar = () => { + toggleSidebar(null); + }; + + const handleToggleCollapse = () => { + if (currentSidebar === ID) { + collapseSidebar(); + } else { + toggleSidebar(ID); + } + }; + // Collapse sidebar if screen resized to a width that displays the sidebar automatically useLayoutEffect(() => { const handleResize = () => { @@ -118,21 +129,8 @@ export const useCourseOutlineSidebar = () => { }, [currentSidebar]); return { - courseId, - unitId, currentSidebar, shouldDisplayFullScreen, - isEnabledCompletionTracking, - isOpen, - setIsOpen, handleToggleCollapse, - isActiveEntranceExam, - courseOutlineStatus, - activeSequenceId, - sections, - sequences, - units, - handleUnitClick, - sequenceStatus, }; };