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.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/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..8a89767cda 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 = ( @@ -48,13 +48,10 @@ const SidebarSection = ({ section, handleSelectSection }) => { ); return ( -
  • +