Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,5 @@ module.config.js
src/i18n/messages/

env.config.jsx

webpack.dev-tutor.config.js
26 changes: 25 additions & 1 deletion src/courseware/course/Course.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as celebrationUtils from './celebration/utils';
import { handleNextSectionCelebration } from './celebration';
import Course from './Course';
import setupDiscussionSidebar from './test-utils';
import SidebarProvider from './sidebar/SidebarContextProvider';

jest.mock('@edx/frontend-platform/analytics');
jest.mock('@edx/frontend-lib-special-exams/dist/data/thunks.js', () => ({
Expand All @@ -26,6 +27,10 @@ jest.mock(
},
);

jest.mock('@src/data/sessionStorage', () => ({
getSessionStorage: jest.fn().mockReturnValue(null),
}));

const recordFirstSectionCelebration = jest.fn();
// eslint-disable-next-line no-import-assign
celebrationUtils.recordFirstSectionCelebration = recordFirstSectionCelebration;
Expand Down Expand Up @@ -151,6 +156,13 @@ describe('Course', () => {
it('handles click to open/close discussions sidebar', async () => {
await setupDiscussionSidebar();

render(
<SidebarProvider courseId={mockData.courseId} unitId={mockData.unitId}>
<Course {...mockData} />
</SidebarProvider>,
{ wrapWithRouter: true },
);

waitFor(() => {
expect(screen.getByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
expect(screen.getByTestId('sidebar-DISCUSSIONS')).not.toHaveClass('d-none');
Expand Down Expand Up @@ -180,7 +192,12 @@ describe('Course', () => {

await setupDiscussionSidebar();

const { rerender } = render(<Course {...testData} />, { store: testStore });
const { rerender } = render(
<SidebarProvider courseId={courseId} unitId={testData.unitId}>
<Course {...testData} />
</SidebarProvider>,
{ store: testStore },
);
loadUnit();

waitFor(() => {
Expand All @@ -193,6 +210,13 @@ describe('Course', () => {

it('handles click to open/close notification tray', async () => {
await setupDiscussionSidebar();
render(
<SidebarProvider courseId={mockData.courseId} unitId={mockData.unitId}>
<Course {...mockData} />
</SidebarProvider>,
{ wrapWithRouter: true },
);

waitFor(() => {
const notificationShowButton = screen.findByRole('button', { name: /Show notification tray/i });
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toBeInTheDocument();
Expand Down
19 changes: 14 additions & 5 deletions src/courseware/course/sidebar/SidebarContextProvider.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {

import { useModel } from '@src/generic/model-store';
import { getLocalStorage, setLocalStorage } from '@src/data/localStorage';
import { getSessionStorage } from '@src/data/sessionStorage';

import * as discussionsSidebar from './sidebars/discussions';
import * as notificationsSidebar from './sidebars/notifications';
Expand All @@ -25,12 +26,20 @@ const SidebarProvider = ({
const query = new URLSearchParams(window.location.search);
const isInitiallySidebarOpen = shouldDisplaySidebarOpen || query.get('sidebar') === 'true';

let initialSidebar = shouldDisplayFullScreen ? getLocalStorage(`sidebar.${courseId}`) : null;
if (!shouldDisplayFullScreen && isInitiallySidebarOpen) {
initialSidebar = isUnitHasDiscussionTopics
? SIDEBARS[discussionsSidebar.ID].ID
: verifiedMode && SIDEBARS[notificationsSidebar.ID].ID;
const isNotificationTrayOpen = getSessionStorage(`notificationTrayStatus.${courseId}`) === 'open';

let initialSidebar;
if (isNotificationTrayOpen) {
initialSidebar = SIDEBARS[notificationsSidebar.ID].ID;
} else {
initialSidebar = shouldDisplayFullScreen ? getLocalStorage(`sidebar.${courseId}`) : null;
if (!shouldDisplayFullScreen && isInitiallySidebarOpen) {
initialSidebar = isUnitHasDiscussionTopics
? SIDEBARS[discussionsSidebar.ID].ID
: verifiedMode && SIDEBARS[notificationsSidebar.ID].ID;
}
}

const [currentSidebar, setCurrentSidebar] = useState(initialSidebar);
const [notificationStatus, setNotificationStatus] = useState(getLocalStorage(`notificationStatus.${courseId}`));
const [upgradeNotificationCurrentState, setUpgradeNotificationCurrentState] = useState(getLocalStorage(`upgradeNotificationCurrentState.${courseId}`));
Expand Down
207 changes: 207 additions & 0 deletions src/courseware/course/sidebar/SidebarContextProvider.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { useContext } from 'react';
import userEvent from '@testing-library/user-event';
import { useWindowSize } from '@openedx/paragon';

import { useModel } from '@src/generic/model-store';
import { getLocalStorage, setLocalStorage } from '@src/data/localStorage';
import { getSessionStorage } from '@src/data/sessionStorage';

import { initializeTestStore, render, screen } from '@src/setupTest';
import SidebarProvider from './SidebarContextProvider';
import SidebarContext from './SidebarContext';
import * as discussionsSidebar from './sidebars/discussions';
import * as notificationsSidebar from './sidebars/notifications';

jest.mock('@openedx/paragon', () => ({
...jest.requireActual('@openedx/paragon'),
useWindowSize: jest.fn(),
breakpoints: {
extraLarge: { minWidth: 1200 },
},
}));

jest.mock('@src/generic/model-store', () => {
const actual = jest.requireActual('@src/generic/model-store');
return {
...actual,
useModel: jest.fn(),
};
});

jest.mock('@src/data/localStorage', () => ({
getLocalStorage: jest.fn(),
setLocalStorage: jest.fn(),
}));

jest.mock('@src/data/sessionStorage', () => ({
getSessionStorage: jest.fn(),
}));

jest.mock('./sidebars/discussions', () => ({ ID: 'discussions' }));
jest.mock('./sidebars/notifications', () => ({ ID: 'notifications' }));
jest.mock('./sidebars', () => ({
SIDEBARS: {
discussions: { ID: 'discussions' },
notifications: { ID: 'notifications' },
},
}));

const TestConsumer = () => {
const {
currentSidebar,
toggleSidebar,
onNotificationSeen,
notificationStatus,
} = useContext(SidebarContext);

return (
<div>
<div data-testid="current-sidebar">{currentSidebar || 'none'}</div>
<div data-testid="notification-status">{notificationStatus || 'none'}</div>
<button type="button" onClick={() => toggleSidebar(discussionsSidebar.ID)}>Toggle Discussions</button>
<button type="button" onClick={onNotificationSeen}>See Notifications</button>
</div>
);
};

describe('SidebarContextProvider', () => {
const defaultProps = {
courseId: 'course-v1:test',
unitId: 'unit-1',
};

beforeAll(async () => {
await initializeTestStore();
});

beforeEach(() => {
jest.clearAllMocks();
useWindowSize.mockReturnValue({ width: 1400 });
useModel.mockReturnValue({});
getLocalStorage.mockReturnValue(null);
getSessionStorage.mockReturnValue(null);
});

it('renders without crashing and provides default context', () => {
render(
<SidebarProvider {...defaultProps}>
<TestConsumer />
</SidebarProvider>,
);
expect(screen.getByTestId('current-sidebar')).toHaveTextContent('none');
});

it('initializes with notifications sidebar if notification tray is open in session storage', () => {
getSessionStorage.mockReturnValue('open');

render(
<SidebarProvider {...defaultProps}>
<TestConsumer />
</SidebarProvider>,
);

expect(screen.getByTestId('current-sidebar')).toHaveTextContent(notificationsSidebar.ID);
});

it('loads initial sidebar from local storage on small screens (mobile behavior)', () => {
useWindowSize.mockReturnValue({ width: 800 });
getLocalStorage.mockImplementation((key) => {
if (key === `sidebar.${defaultProps.courseId}`) { return discussionsSidebar.ID; }
return null;
});

render(
<SidebarProvider {...defaultProps}>
<TestConsumer />
</SidebarProvider>,
);

expect(screen.getByTestId('current-sidebar')).toHaveTextContent(discussionsSidebar.ID);
});

it('does not load from local storage on large screens (desktop behavior)', () => {
useWindowSize.mockReturnValue({ width: 1400 });
getLocalStorage.mockReturnValue(discussionsSidebar.ID);

render(
<SidebarProvider {...defaultProps}>
<TestConsumer />
</SidebarProvider>,
);

expect(screen.getByTestId('current-sidebar')).toHaveTextContent('none');
});

it('toggles sidebar open and updates local storage', async () => {
const user = userEvent.setup();
render(
<SidebarProvider {...defaultProps}>
<TestConsumer />
</SidebarProvider>,
);

expect(screen.getByTestId('current-sidebar')).toHaveTextContent('none');

await user.click(screen.getByText('Toggle Discussions'));

expect(screen.getByTestId('current-sidebar')).toHaveTextContent(discussionsSidebar.ID);
expect(setLocalStorage).toHaveBeenCalledWith(`sidebar.${defaultProps.courseId}`, discussionsSidebar.ID);
});

it('toggles sidebar closed (null) if clicking the same sidebar', async () => {
useWindowSize.mockReturnValue({ width: 800 });
getLocalStorage.mockReturnValue(discussionsSidebar.ID);
const user = userEvent.setup();

render(
<SidebarProvider {...defaultProps}>
<TestConsumer />
</SidebarProvider>,
);

expect(screen.getByTestId('current-sidebar')).toHaveTextContent(discussionsSidebar.ID);

await user.click(screen.getByText('Toggle Discussions'));

expect(screen.getByTestId('current-sidebar')).toHaveTextContent('none');
expect(setLocalStorage).toHaveBeenCalledWith(`sidebar.${defaultProps.courseId}`, null);
});

it('updates notification status when seen', async () => {
const user = userEvent.setup();
render(
<SidebarProvider {...defaultProps}>
<TestConsumer />
</SidebarProvider>,
);

await user.click(screen.getByText('See Notifications'));

expect(setLocalStorage).toHaveBeenCalledWith(`notificationStatus.${defaultProps.courseId}`, 'inactive');
expect(screen.getByTestId('notification-status')).toHaveTextContent('inactive');
});

it('updates current sidebar when unitId changes (Effect trigger)', () => {
useWindowSize.mockReturnValue({ width: 800 });
getLocalStorage.mockReturnValue(notificationsSidebar.ID);

const { rerender } = render(
<SidebarProvider {...defaultProps}>
<TestConsumer />
</SidebarProvider>,
);

expect(screen.getByTestId('current-sidebar')).toHaveTextContent(notificationsSidebar.ID);

useModel.mockImplementation((model) => {
if (model === 'discussionTopics') { return { id: 'topic-1', enabledInContext: true }; }
return {};
});

rerender(
<SidebarProvider {...defaultProps} unitId="unit-2">
<TestConsumer />
</SidebarProvider>,
);
});
});
14 changes: 11 additions & 3 deletions src/courseware/course/sidebar/common/SidebarBase.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { ArrowBackIos, Close } from '@openedx/paragon/icons';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import { useCallback, useContext } from 'react';

import { useEventListener } from '@src/generic/hooks';
import { setSessionStorage } from '@src/data/sessionStorage';
import messages from '../../messages';
import SidebarContext from '../SidebarContext';

Expand All @@ -19,6 +21,7 @@ const SidebarBase = ({
}) => {
const intl = useIntl();
const {
courseId,
toggleSidebar,
shouldDisplayFullScreen,
currentSidebar,
Expand All @@ -34,6 +37,11 @@ const SidebarBase = ({

useEventListener('message', receiveMessage);

const handleCloseNotificationTray = () => {
toggleSidebar(null);
setSessionStorage(`notificationTrayStatus.${courseId}`, 'closed');
};

return (
<section
className={classNames('ml-0 border border-light-400 rounded-sm h-auto align-top zindex-0', {
Expand All @@ -49,8 +57,8 @@ const SidebarBase = ({
{shouldDisplayFullScreen ? (
<div
className="pt-2 pb-2.5 border-bottom border-light-400 d-flex align-items-center ml-2"
onClick={() => toggleSidebar(null)}
onKeyDown={() => toggleSidebar(null)}
onClick={handleCloseNotificationTray}
onKeyDown={handleCloseNotificationTray}
role="button"
tabIndex="0"
>
Expand All @@ -72,7 +80,7 @@ const SidebarBase = ({
src={Close}
size="sm"
iconAs={Icon}
onClick={() => toggleSidebar(null)}
onClick={handleCloseNotificationTray}
variant="primary"
alt={intl.formatMessage(messages.closeNotificationTrigger)}
/>
Expand Down
Loading