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
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import {
fireEvent, getTestStoreIds, initializeTestStore, render, screen,
} from '../../../../setupTest';
import MountCourseQueryHooks from '../../../../tests/MountCourseQueryHooks';
import SidebarContext from '../../sidebar/SidebarContext';
import LockPaywall from './LockPaywall';

jest.mock('@edx/frontend-platform/analytics');

describe('Lock Paywall', () => {
let store;
const mockData = { currentSidebar: null };
const sidebarContextValue = { currentSidebar: null, availableSidebarIds: [] };

beforeAll(async () => {
store = await initializeTestStore();
Expand All @@ -22,10 +24,10 @@ describe('Lock Paywall', () => {
});

const renderPaywall = (props, options) => render(
<>
<SidebarContext.Provider value={sidebarContextValue}>
<MountCourseQueryHooks courseId={props.courseId} />
<LockPaywall {...props} />
</>,
</SidebarContext.Provider>,
options,
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
render, screen, fireEvent, getByText, getTestStoreIds, initializeTestStore,
} from '../../../../setupTest';
import MountCourseQueryHooks from '../../../../tests/MountCourseQueryHooks';
import SidebarContext from '../../sidebar/SidebarContext';
import SequenceNavigation from './SequenceNavigation';
import useIndexOfLastVisibleChild from '../../../../generic/tabs/useIndexOfLastVisibleChild';

Expand Down Expand Up @@ -32,6 +33,8 @@ describe('Sequence Navigation', () => {
};
});

const sidebarContextValue = { currentSidebar: null, availableSidebarIds: [] };

const renderNav = (props = {}, { store } = {}) => {
const sequenceId = props.sequenceId ?? mockData.sequenceId;
return render(
Expand All @@ -40,10 +43,10 @@ describe('Sequence Navigation', () => {
<Route
path="/course/:courseId/:sequenceId/*"
element={(
<>
<SidebarContext.Provider value={sidebarContextValue}>
<MountCourseQueryHooks courseId={courseMetadata.id} />
<SequenceNavigation {...mockData} {...props} />
</>
</SidebarContext.Provider>
)}
/>
</Routes>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getAllByRole } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import { initializeTestStore, render, screen } from '../../../../setupTest';
import SidebarContext from '../../sidebar/SidebarContext';
import SequenceNavigationTabs from './SequenceNavigationTabs';
import useIndexOfLastVisibleChild from '../../../../generic/tabs/useIndexOfLastVisibleChild';

Expand Down Expand Up @@ -40,9 +41,18 @@ describe('Sequence Navigation Tabs', () => {
};
});

const sidebarContextValue = { currentSidebar: null, availableSidebarIds: [] };

const renderTabs = () => render(
<SidebarContext.Provider value={sidebarContextValue}>
<SequenceNavigationTabs {...mockData} />
</SidebarContext.Provider>,
{ wrapWithRouter: true },
);

it('renders unit buttons', () => {
useIndexOfLastVisibleChild.mockReturnValue([0, null, null]);
render(<SequenceNavigationTabs {...mockData} />, { wrapWithRouter: true });
renderTabs();

expect(screen.getAllByRole('link')).toHaveLength(unitBlocks.length);
});
Expand All @@ -51,7 +61,7 @@ describe('Sequence Navigation Tabs', () => {
let container = null;

useIndexOfLastVisibleChild.mockReturnValue([-1, null, null]);
const booyah = render(<SequenceNavigationTabs {...mockData} />, { wrapWithRouter: true });
const booyah = renderTabs();

// wait for links to appear so we aren't testing an empty div
await screen.findAllByRole('link');
Expand Down
39 changes: 21 additions & 18 deletions src/courseware/course/sidebar/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,32 +159,35 @@ _Example with built-in widgets:_
- Manage `currentSidebar` state (shared by both sidebars)
- Handle unit shift logic for RIGHT sidebar panels
- Provide context to both left and right sidebar components
- **Prefetch widget data** after mount via `widget.prefetch` (sync logic re-evaluates availability once data arrives)
- **Mount each widget's `Provider`** around the sidebar children, whether or not the widget is available (sync logic re-evaluates availability once its data arrives)

### Widget Prefetch Lifecycle
### Widget Data Lifecycle

Widgets can define a `prefetch` function in their config to pre-load data that their `isAvailable` or render logic depends on. The framework calls `prefetch` for every enabled widget on mount (and when `courseId` or the widget list changes):
A widget whose `isAvailable` depends on fetched data loads it in its `Provider` as a React Query observer gated with `enabled`. The framework mounts every enabled widget's `Provider` inside `SidebarContext`, so the observer runs before the widget is available and independently of whether its trigger or panel render:

```javascript
// In SidebarContextProvider.jsx
const courseMetaRef = useRef(null);
courseMetaRef.current = { ...coursewareMeta, ...courseHomeMeta };

useEffect(() => {
enabledWidgets.forEach(widget => {
if (widget.prefetch) {
widget.prefetch({ courseId, course: courseMetaRef.current, queryClient });
}
// In SidebarContextProvider.tsx
const renderWithWidgetProviders = useCallback((content) => enabledWidgets
.reduceRight((acc, { Provider }) => (Provider ? <Provider>{acc}</Provider> : acc), content), [enabledWidgets]);

// In widgets/discussions/DiscussionsProvider.tsx
const DiscussionsProvider = ({ children }) => {
const { courseId } = useSidebar();
const tabs = useCourseHomeMeta(courseId, { enabled: false }).data?.tabs;
useQuery({
...discussionTopicsQuery(courseId),
enabled: !!getConfig().DISCUSSIONS_MFE_BASE_URL && hasDiscussionTab(tabs),
});
}, [enabledWidgets, courseId, queryClient]);
return <>{children}</>;
};
```

`courseMetaRef` is updated on every render so the effect always reads the latest `coursewareMeta` + `courseHomeMeta` values without `coursewareMeta`/`courseHomeMeta` being reactive dependencies. This means the effect fires once per `courseId` change rather than on every model reference update.
An observer fetches when it mounts and when its query key changes, not when the metadata it reads re-renders, so one sidebar mount is one request even as `courseHomeMeta` updates (celebration writes, refetches).

**Why prefetch lives in the provider, not in individual components:**
- Starts data loading post-mount so the sync logic can re-evaluate availability once the data arrives
- Individual Trigger/Sidebar components can remain pure render components
- Centralises fetch orchestration in one place
**Why data loading lives in the widget's `Provider`, not in its Trigger/Sidebar components:**
- The trigger is mounted only when the widget is available, and availability depends on the data — a component cannot load the data its own mounting waits for
- Starts loading post-mount so the sync logic can re-evaluate availability once the data arrives
- Individual Trigger/Sidebar components remain pure render components

**Key Logic:**
```javascript
Expand Down
73 changes: 20 additions & 53 deletions src/courseware/course/sidebar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,75 +13,43 @@ Widget implementations:

### Widget Structure

Each widget must provide:

```javascript
{
id: string, // Unique identifier (e.g., 'DISCUSSIONS', 'CUSTOM_TOOL')
priority: number, // Display order (lower = first, default: 50)
Sidebar: ReactComponent, // Main panel component
Trigger: ReactComponent, // Trigger button component
isAvailable: (context) => boolean, // Optional: check if widget should be shown
prefetch: ({ courseId, course, queryClient }) => void, // Optional: pre-load data (runs post-mount; sync logic re-evaluates availability)
enabled: boolean, // Whether widget is enabled
Provider?: ReactComponent, // Optional: React Provider for Panel↔Trigger shared state
}
```
Each widget is a `SidebarWidget`, declared in [`SidebarContext.ts`](SidebarContext.ts).

### The `Provider` field

An optional hook point for widgets that need to share React state between their `Sidebar` and `Trigger` components. The widget owns the full Provider implementation. The framework simply mounts it.
An optional component the widget supplies, taking `children`. `SidebarContextProvider` wraps all children in each registered widget's `Provider` (in reverse-priority order), whether or not the widget is currently available, and the Provider can read `courseId` etc. from `SidebarContext` since it mounts inside it. That gives the widget one component that is mounted for as long as the sidebar is, where it can run hooks and hold state that its `Sidebar` and `Trigger` both read. The two built-in widgets use it for the two things it is for:

`SidebarContextProvider` wraps all children in each registered widget's `Provider` (in reverse-priority order), so both components have access to the same widget-level context. The Provider itself can safely read `courseId` etc. from `SidebarContext` since it mounts inside it.
- **Shared state.** The upgrade widget's `UpgradeWidgetProvider` keeps the seen/unseen status and the upgrade stage in a context of its own, which `UpgradeTrigger` and `UpgradePanel` read.
- **Loading the data `isAvailable` depends on.** A widget's trigger is mounted only once the widget is available, so a fetch the availability check needs cannot live in the trigger. The discussions widget's `DiscussionsProvider` runs one `useQuery`, with the conditions for fetching in `enabled`, and renders its children unchanged. The request goes out once when the sidebar mounts; the first availability check runs before the data arrives, and when the query resolves the framework re-evaluates availability and the trigger appears.

No built-in widgets use this field — it exists as a generic extension point for custom widgets that need cross-component coordination without polluting `SidebarContext`.
```javascript
// widgets/discussions/DiscussionsProvider.tsx
const DiscussionsProvider = ({ children }) => {
const { courseId } = useSidebar();
const tabs = useCourseHomeMeta(courseId, { enabled: false }).data?.tabs;
useQuery({
...discussionTopicsQuery(courseId),
enabled: !!getConfig().DISCUSSIONS_MFE_BASE_URL && hasDiscussionTab(tabs),
});
return <>{children}</>;
};
```

```javascript
// In your widget's widgetConfig.js
export const myWidgetConfig = {
id: 'MY_WIDGET',
Sidebar: MyWidgetPanel,
Trigger: MyWidgetTrigger,
Provider: MyWidgetProvider, // optional — omit if Sidebar/Trigger don't share state
Provider: MyWidgetProvider, // optional — omit if the widget has no shared state and no data to load
isAvailable: ({ course }) => !!course?.someField,
enabled: true,
};
```

### The `prefetch` field

An optional function called by `SidebarContextProvider` after mount (and when `courseId` or the widget list changes). Use it to prefetch a React Query query (via the `queryClient` argument) or otherwise fetch data that `isAvailable`, `Trigger`, or `Sidebar` depend on. The `course` argument always reflects the latest `coursewareMeta` + `courseHomeMeta` values at the time the effect fires. Because this runs post-mount, it does not guarantee the data is present for the initial render-time availability check; widgets that depend on prefetched data may become available after the store updates and the framework sync logic re-evaluates availability.

```javascript
export const myWidgetPrefetch = ({ courseId, course, queryClient }) => {
if (course?.someCondition) {
queryClient.query(myWidgetDataQuery(courseId)).catch(() => {});
}
};

export const myWidgetConfig = {
id: 'MY_WIDGET',
// ...
prefetch: myWidgetPrefetch,
};
```

The `course` object is a merged view of the courseware metadata (`coursewareMeta`) and the course-home metadata.

### Context Object

The `isAvailable` function receives a context object with:

```javascript
{
courseId: string,
unitId: string,
course: object, // Merged coursewareMeta + courseHomeMeta (verifiedMode, enrollmentMode, courseModes, …)
unit: object, // discussionTopics model for the current unit (id, enabledInContext, …)
}
```

Widgets pick whatever they need from `course` or `unit` — the sidebar makes no assumptions about which fields any given widget requires.
The `isAvailable` function receives a `SidebarWidgetContext`, declared in [`SidebarContext.ts`](SidebarContext.ts). Widgets pick whatever they need from its `course` or `unit` — the sidebar makes no assumptions about which fields any given widget requires.

## Adding Widgets

Expand Down Expand Up @@ -123,16 +91,15 @@ export default {
The main panel component that renders when the widget is active. Wrap your content in `SidebarBase` to get the standard close button, fullscreen handling, and show/hide behaviour:

```javascript
import { useContext } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import SidebarBase from '@src/courseware/course/sidebar/common/SidebarBase';
import SidebarContext from '@src/courseware/course/sidebar/SidebarContext';
import { useSidebar } from '@src/courseware/course/sidebar/SidebarContext';

export const ID = 'MY_WIDGET';

const MySidebar = () => {
const intl = useIntl();
const { courseId } = useContext(SidebarContext);
const { courseId } = useSidebar();

return (
<SidebarBase
Expand Down
35 changes: 0 additions & 35 deletions src/courseware/course/sidebar/SidebarContext.js

This file was deleted.

10 changes: 10 additions & 0 deletions src/courseware/course/sidebar/SidebarContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { renderHook } from '@testing-library/react';

import { useSidebar } from './SidebarContext';

describe('useSidebar', () => {
it('throws outside a SidebarProvider', () => {
expect(() => renderHook(() => useSidebar()))
.toThrow('useSidebar must be used within a SidebarProvider');
});
});
55 changes: 55 additions & 0 deletions src/courseware/course/sidebar/SidebarContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {
createContext, useContext, type ComponentType, type ReactNode,
} from 'react';

import type { CourseHomeMeta } from '@src/course-home/data/apiHooks';
import type { CoursewareMeta, DiscussionTopic } from '@src/courseware/data/apiHooks';

export interface SidebarWidgetContext {
courseId: string;
unitId: string;
course: CourseHomeMeta & CoursewareMeta;
unit: Partial<DiscussionTopic>;
}

export interface SidebarWidget {
id: string;
priority: number;
Sidebar: ComponentType;
Trigger: ComponentType<{ onClick: () => void }>;
Provider?: ComponentType<{ children: ReactNode }>;
isAvailable?: (context: SidebarWidgetContext) => boolean;
enabled?: boolean;
}

export interface SidebarRegistryEntry {
ID: string;
Sidebar: ComponentType;
Trigger: ComponentType<{ onClick: () => void }>;
isAvailable?: (context: SidebarWidgetContext) => boolean;
}

export interface SidebarContextValue {
currentSidebar: string | null;
initialSidebar: string | null;
toggleSidebar: (sidebarId: string) => void;
shouldDisplaySidebarOpen: boolean;
shouldDisplayFullScreen: boolean;
courseId: string;
unitId: string;
SIDEBARS: Record<string, SidebarRegistryEntry>;
SIDEBAR_ORDER: string[];
availableSidebarIds: string[];
}

const SidebarContext = createContext<SidebarContextValue | null>(null);

export const useSidebar = (): SidebarContextValue => {
const context = useContext(SidebarContext);
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider');
}
return context;
};

export default SidebarContext;
Loading
Loading