Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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
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
20 changes: 14 additions & 6 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 All @@ -53,7 +62,6 @@ const SidebarProvider = ({
}, [courseId]);

const toggleSidebar = useCallback((sidebarId) => {
// Switch to new sidebar or hide the current sidebar
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we leave this comment?

Copy link
Author

Choose a reason for hiding this comment

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

fixed

const newSidebar = sidebarId === currentSidebar ? null : sidebarId;
setCurrentSidebar(newSidebar);
setLocalStorage(`sidebar.${courseId}`, newSidebar);
Expand Down
199 changes: 199 additions & 0 deletions src/courseware/course/sidebar/SidebarContextProvider.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { useContext } from 'react';
import { render, screen } from '@testing-library/react';
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we import this from setupTest?

Copy link
Author

Choose a reason for hiding this comment

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

yes, fixed

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 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', () => ({
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',
};

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>,
);
});
});
10 changes: 9 additions & 1 deletion 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 Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import PropTypes from 'prop-types';

import { WIDGETS } from '@src/constants';
import { getLocalStorage, setLocalStorage } from '@src/data/localStorage';
import { getSessionStorage, setSessionStorage } from '@src/data/sessionStorage';
import messages from '../../../messages';
import SidebarTriggerBase from '../../common/TriggerBase';
import SidebarContext from '../../SidebarContext';
Expand All @@ -19,6 +20,8 @@ const NotificationTrigger = ({
courseId,
notificationStatus,
setNotificationStatus,
currentSidebar,
toggleSidebar,
upgradeNotificationCurrentState,
} = useContext(SidebarContext);

Expand All @@ -45,10 +48,30 @@ const NotificationTrigger = ({

useEffect(() => {
UpdateUpgradeNotificationLastSeen();
});
const notificationTrayStatus = getSessionStorage(`notificationTrayStatus.${courseId}`);
const isNotificationTrayOpen = notificationTrayStatus === 'open';

if (isNotificationTrayOpen && !currentSidebar) {
if (toggleSidebar) {
toggleSidebar(ID);
}
}
}, [courseId, currentSidebar, ID]);

const handleClick = () => {
const isNotificationTrayOpen = getSessionStorage(`notificationTrayStatus.${courseId}`) === 'open';

if (isNotificationTrayOpen) {
setSessionStorage(`notificationTrayStatus.${courseId}`, 'closed');
} else {
setSessionStorage(`notificationTrayStatus.${courseId}`, 'open');
}

onClick();
};

return (
<SidebarTriggerBase onClick={onClick} ariaLabel={intl.formatMessage(messages.openNotificationTrigger)}>
<SidebarTriggerBase onClick={handleClick} ariaLabel={intl.formatMessage(messages.openNotificationTrigger)}>
<NotificationIcon status={notificationStatus} notificationColor="bg-danger-500" />
</SidebarTriggerBase>
);
Expand Down
Loading