Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 62 additions & 19 deletions src/courseware/course/sidebar/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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 `<CourseOutline>`, 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 shouldDisplayFullScreen={shouldDisplayFullScreen} onToggleCollapse={handleToggleCollapse} />;
```

**`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.
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> {
courseId: string;
unitId: string;
}

export const CourseOutline = ({
shouldDisplayFullScreen = false,
onToggleCollapse,
}: CourseOutlineProps) => {
const intl = useIntl();
const [selectedSection, setSelectedSection] = useState<string | null>(null);
const [isDisplaySequenceLevel, setDisplaySequenceLevel, setDisplaySectionLevel] = useToggle(true);

const { unitId, courseId } = useParams<CoursePageParams>();
const {
courseId,
unitId,
currentSidebar,
isActiveEntranceExam,
courseOutlineStatus,
activeSequenceId,
sections,
sequences,
shouldDisplayFullScreen,
handleToggleCollapse,
} = useCourseOutlineSidebar();
isActiveEntranceExam,
} = useCourseOutlineData();

const resolvedSectionId = selectedSection
|| Object.keys(sections).find(
Expand All @@ -47,19 +57,16 @@ export const CourseOutline = () => {
setDisplaySequenceLevel();
setSelectedSection(id);
};

const sidebarHeading = (
<CourseOutlineSidebarHeadingSlot
onToggleCollapse={handleToggleCollapse}
onToggleCollapse={onToggleCollapse}
isDisplaySequenceLevel={isDisplaySequenceLevel}
backButton={backButtonTitle ? { title: backButtonTitle, onClick: handleBackToSectionLevel } : undefined}
/>
);

if (isActiveEntranceExam || currentSidebar !== ID) {
if (isActiveEntranceExam) {
return null;
}

if (courseOutlineStatus === LOADING) {
return (
<div className={classNames('outline-sidebar-wrapper', {
Expand Down Expand Up @@ -108,5 +115,3 @@ export const CourseOutline = () => {
</div>
);
};

export default CourseOutline;
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
}

.outline-sidebar-heading-wrapper {
border: 1px solid #d7d3d1;
border: 1px solid var(--pgn-color-light-700);

&.sticky {
position: sticky;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { useCourseOutlineSidebar } from './hooks';
const CourseOutlineTray = () => {
const {
currentSidebar,
shouldDisplayFullScreen,
handleToggleCollapse,
} = useCourseOutlineSidebar();

if (currentSidebar !== ID) {
return null;
}
return <CourseOutline />;
return <CourseOutline shouldDisplayFullScreen={shouldDisplayFullScreen} onToggleCollapse={handleToggleCollapse} />;
};

CourseOutlineTray.ID = ID;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 = (
Expand Down Expand Up @@ -48,13 +48,10 @@ const SidebarSection = ({ section, handleSelectSection }) => {
);

return (
<li className="mb-2 course-sidebar-section">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious about the mb-2 removal here

<li className={classNames('mb-2 course-sidebar-section', { 'active-section': isActiveSection })}>
<Button
variant="tertiary"
className={classNames(
'd-flex align-items-center w-100 px-4 py-3.5 rounded-0 justify-content-start',
{ 'bg-info-100': isActiveSection },

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now applied via SCSS

)}
className="d-flex align-items-center w-100 px-4 py-3.5 rounded-0 justify-content-start"
onClick={() => handleSelectSection(id)}
>
{sectionTitle}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const SidebarUnit = ({
const completeAndEnabled = complete && isCompletionTrackingEnabled;

return (
<li className={classNames({ 'bg-info-100': isActive, 'border-top border-light': !isFirst })}>
<li className={classNames({ 'active-unit bg-info-100': isActive, 'border-top border-light': !isFirst })}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

active-unit doesn't appear to exist.

<UnitLinkWrapper
{...{
sequenceId,
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -26,17 +26,25 @@ const UnitLinkWrapper: React.FC<Props> = ({
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 (
<Link
to={link}
className="row w-100 m-0 d-flex align-items-center text-gray-700"
onClick={() => handleUnitClick({ sequenceId, activeUnitId, id })}
onClick={handleClick}
>
{children}
</Link>
Expand Down
Loading