From 7d6b415cdad4bf07ccbf53d1fb4b503ef0277585 Mon Sep 17 00:00:00 2001 From: "kshitij.sobti" Date: Fri, 17 Jul 2026 23:41:55 +0530 Subject: [PATCH 1/2] refactor: Separate course outline data and sidebar context logic Splits useCourseOutlineSidebar into useCourseOutlineData and useCourseOutlineSidebar for improved separation of responsibilities. Updates components to reflect this change. This allows the course outline to be used outside of a sidebar context. --- src/courseware/course/sidebar/ARCHITECTURE.md | 81 ++++++++++--- .../sidebars/course-outline/CourseOutline.tsx | 43 ++++--- .../course-outline/CourseOutlineTray.tsx | 4 +- .../course-outline/CourseOutlineTrigger.jsx | 4 +- .../sidebar/sidebars/course-outline/README.md | 3 +- .../components/SidebarSection.jsx | 4 +- .../components/SidebarSequence.jsx | 4 +- .../components/UnitLinkWrapper.tsx | 14 ++- .../sidebar/sidebars/course-outline/hooks.js | 108 +++++++++--------- 9 files changed, 161 insertions(+), 104 deletions(-) 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..48dbf71078 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 (
{
); }; - -export default CourseOutline; 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, }; }; From 614ced56fcab8dcbfccdb1a5103f1c9542d5cdc1 Mon Sep 17 00:00:00 2001 From: "kshitij.sobti" Date: Fri, 17 Jul 2026 23:42:48 +0530 Subject: [PATCH 2/2] style: Update course outline sidebar styling and use theme tokens - Replace hardcoded border color (#d7d3d1) with var(--pgn-color-light-700) in CourseOutlineTray.scss for theme compatibility. - Move active-section highlight to a dedicated .active-section class on SidebarSection and add an .active-unit class on SidebarUnit. --- .../sidebars/course-outline/CourseOutlineTray.scss | 8 ++++++-- .../sidebars/course-outline/components/SidebarSection.jsx | 7 ++----- .../sidebars/course-outline/components/SidebarUnit.jsx | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.scss b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.scss index f051d248ea..ab8a2d8f21 100644 --- a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.scss +++ b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.scss @@ -13,7 +13,7 @@ } .outline-sidebar-heading-wrapper { - border: 1px solid #d7d3d1; + border: 1px solid var(--pgn-color-light-700); &.sticky { position: sticky; @@ -29,7 +29,11 @@ .course-sidebar-section { background: var(--pgn-color-white); - border: 1px solid #d7d3d1; + border: 1px solid var(--pgn-color-light-700); + + &.active-section button { + background-color: var(--pgn-color-info-100); + } button { line-height: 1.75rem; 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 dacbe6dfcb..8a89767cda 100644 --- a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSection.jsx +++ b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSection.jsx @@ -48,13 +48,10 @@ const SidebarSection = ({ section, handleSelectSection }) => { ); return ( -
  • +