diff --git a/.gitignore b/.gitignore
index 3fc643913a..fc47888426 100755
--- a/.gitignore
+++ b/.gitignore
@@ -30,3 +30,5 @@ module.config.js
src/i18n/messages/
env.config.jsx
+
+webpack.dev-tutor.config.js
diff --git a/src/courseware/course/Course.test.jsx b/src/courseware/course/Course.test.jsx
index 744bebe548..e092c5ecd6 100644
--- a/src/courseware/course/Course.test.jsx
+++ b/src/courseware/course/Course.test.jsx
@@ -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', () => ({
@@ -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;
@@ -151,6 +156,13 @@ describe('Course', () => {
it('handles click to open/close discussions sidebar', async () => {
await setupDiscussionSidebar();
+ render(
+
+
+ ,
+ { wrapWithRouter: true },
+ );
+
waitFor(() => {
expect(screen.getByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
expect(screen.getByTestId('sidebar-DISCUSSIONS')).not.toHaveClass('d-none');
@@ -180,7 +192,12 @@ describe('Course', () => {
await setupDiscussionSidebar();
- const { rerender } = render(, { store: testStore });
+ const { rerender } = render(
+
+
+ ,
+ { store: testStore },
+ );
loadUnit();
waitFor(() => {
@@ -193,6 +210,13 @@ describe('Course', () => {
it('handles click to open/close notification tray', async () => {
await setupDiscussionSidebar();
+ render(
+
+
+ ,
+ { wrapWithRouter: true },
+ );
+
waitFor(() => {
const notificationShowButton = screen.findByRole('button', { name: /Show notification tray/i });
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toBeInTheDocument();
diff --git a/src/courseware/course/sidebar/SidebarContextProvider.jsx b/src/courseware/course/sidebar/SidebarContextProvider.jsx
index 9b3b824d53..1d74988be8 100644
--- a/src/courseware/course/sidebar/SidebarContextProvider.jsx
+++ b/src/courseware/course/sidebar/SidebarContextProvider.jsx
@@ -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';
@@ -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}`));
diff --git a/src/courseware/course/sidebar/SidebarContextProvider.test.jsx b/src/courseware/course/sidebar/SidebarContextProvider.test.jsx
new file mode 100644
index 0000000000..bc45e5aeb7
--- /dev/null
+++ b/src/courseware/course/sidebar/SidebarContextProvider.test.jsx
@@ -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 (
+
+
{currentSidebar || 'none'}
+
{notificationStatus || 'none'}
+
+
+
+ );
+};
+
+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(
+
+
+ ,
+ );
+ expect(screen.getByTestId('current-sidebar')).toHaveTextContent('none');
+ });
+
+ it('initializes with notifications sidebar if notification tray is open in session storage', () => {
+ getSessionStorage.mockReturnValue('open');
+
+ render(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('current-sidebar')).toHaveTextContent('none');
+ });
+
+ it('toggles sidebar open and updates local storage', async () => {
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('current-sidebar')).toHaveTextContent(notificationsSidebar.ID);
+
+ useModel.mockImplementation((model) => {
+ if (model === 'discussionTopics') { return { id: 'topic-1', enabledInContext: true }; }
+ return {};
+ });
+
+ rerender(
+
+
+ ,
+ );
+ });
+});
diff --git a/src/courseware/course/sidebar/common/SidebarBase.jsx b/src/courseware/course/sidebar/common/SidebarBase.jsx
index 87775ea1c3..9c26d6bdd7 100644
--- a/src/courseware/course/sidebar/common/SidebarBase.jsx
+++ b/src/courseware/course/sidebar/common/SidebarBase.jsx
@@ -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';
@@ -19,6 +21,7 @@ const SidebarBase = ({
}) => {
const intl = useIntl();
const {
+ courseId,
toggleSidebar,
shouldDisplayFullScreen,
currentSidebar,
@@ -34,6 +37,11 @@ const SidebarBase = ({
useEventListener('message', receiveMessage);
+ const handleCloseNotificationTray = () => {
+ toggleSidebar(null);
+ setSessionStorage(`notificationTrayStatus.${courseId}`, 'closed');
+ };
+
return (
toggleSidebar(null)}
- onKeyDown={() => toggleSidebar(null)}
+ onClick={handleCloseNotificationTray}
+ onKeyDown={handleCloseNotificationTray}
role="button"
tabIndex="0"
>
@@ -72,7 +80,7 @@ const SidebarBase = ({
src={Close}
size="sm"
iconAs={Icon}
- onClick={() => toggleSidebar(null)}
+ onClick={handleCloseNotificationTray}
variant="primary"
alt={intl.formatMessage(messages.closeNotificationTrigger)}
/>
diff --git a/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.jsx b/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.jsx
index 4b2037be5b..f1c57a2d40 100644
--- a/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.jsx
+++ b/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.jsx
@@ -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';
@@ -19,6 +20,8 @@ const NotificationTrigger = ({
courseId,
notificationStatus,
setNotificationStatus,
+ currentSidebar,
+ toggleSidebar,
upgradeNotificationCurrentState,
} = useContext(SidebarContext);
@@ -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 (
-
+
);
diff --git a/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.test.jsx b/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.test.jsx
index c6c264bec3..82eadb0782 100644
--- a/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.test.jsx
+++ b/src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.test.jsx
@@ -1,16 +1,26 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Factory } from 'rosie';
+import { getLocalStorage, setLocalStorage } from '@src/data/localStorage';
+import { getSessionStorage, setSessionStorage } from '@src/data/sessionStorage';
import {
fireEvent, initializeTestStore, render, screen,
} from '../../../../../setupTest';
import SidebarContext from '../../SidebarContext';
-import NotificationTrigger from './NotificationTrigger';
+import NotificationTrigger, { ID } from './NotificationTrigger';
+
+jest.mock('@src/data/localStorage', () => ({
+ getLocalStorage: jest.fn(),
+ setLocalStorage: jest.fn(),
+}));
+
+jest.mock('@src/data/sessionStorage', () => ({
+ getSessionStorage: jest.fn(),
+ setSessionStorage: jest.fn(),
+}));
describe('Notification Trigger', () => {
let mockData;
- let getItemSpy;
- let setItemSpy;
const courseMetadata = Factory.build('courseMetadata');
beforeEach(async () => {
@@ -19,22 +29,20 @@ describe('Notification Trigger', () => {
excludeFetchCourse: true,
excludeFetchSequence: true,
});
+
+ jest.clearAllMocks();
+
mockData = {
courseId: courseMetadata.id,
- toggleNotificationTray: () => {},
- isNotificationTrayVisible: () => {},
+ toggleSidebar: jest.fn(),
+ currentSidebar: null,
notificationStatus: 'inactive',
- setNotificationStatus: () => {},
+ setNotificationStatus: jest.fn(),
upgradeNotificationCurrentState: 'FPDdaysLeft',
};
- // Jest does not support calls to localStorage, spying on localStorage's prototype directly instead
- getItemSpy = jest.spyOn(Object.getPrototypeOf(window.localStorage), 'getItem');
- setItemSpy = jest.spyOn(Object.getPrototypeOf(window.localStorage), 'setItem');
- });
- afterAll(() => {
- getItemSpy.mockRestore();
- setItemSpy.mockRestore();
+ getLocalStorage.mockReturnValue(null);
+ getSessionStorage.mockReturnValue('closed');
});
const SidebarWrapper = ({ contextValue, onClick }) => (
@@ -48,8 +56,7 @@ describe('Notification Trigger', () => {
onClick: PropTypes.func.isRequired,
};
- function renderWithProvider(data, onClick = () => {
- }) {
+ function renderWithProvider(data, onClick = () => {}) {
const { container } = render();
return container;
}
@@ -58,14 +65,19 @@ describe('Notification Trigger', () => {
const toggleNotificationTray = jest.fn();
const testData = {
...mockData,
- toggleNotificationTray,
+ toggleSidebar: toggleNotificationTray,
};
- renderWithProvider(testData, toggleNotificationTray);
+ const onClickProp = jest.fn();
+
+ renderWithProvider(testData, onClickProp);
const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i });
expect(notificationTrigger).toBeInTheDocument();
+
fireEvent.click(notificationTrigger);
- expect(toggleNotificationTray).toHaveBeenCalledTimes(1);
+
+ expect(onClickProp).toHaveBeenCalledTimes(1);
+ expect(setSessionStorage).toHaveBeenCalledWith(`notificationTrayStatus.${mockData.courseId}`, 'open');
});
it('renders notification trigger icon with red dot when notificationStatus is active', async () => {
@@ -77,61 +89,81 @@ describe('Notification Trigger', () => {
});
it('renders notification trigger icon WITHOUT red dot within the same phase', async () => {
+ getLocalStorage.mockReturnValue('sameState');
+
const container = renderWithProvider({
upgradeNotificationLastSeen: 'sameState',
upgradeNotificationCurrentState: 'sameState',
});
- expect(container)
- .toBeInTheDocument();
- expect(localStorage.getItem)
- .toHaveBeenCalledWith(`upgradeNotificationLastSeen.${mockData.courseId}`);
- expect(localStorage.getItem(`upgradeNotificationLastSeen.${mockData.courseId}`))
- .toBe('"sameState"');
+
+ expect(container).toBeInTheDocument();
+
+ expect(getLocalStorage).toHaveBeenCalledWith(`upgradeNotificationLastSeen.${mockData.courseId}`);
+
const buttonIcon = container.querySelectorAll('svg');
- expect(buttonIcon)
- .toHaveLength(1);
- expect(screen.queryByRole('notification-dot'))
- .not
- .toBeInTheDocument();
+ expect(buttonIcon).toHaveLength(1);
+ expect(screen.queryByRole('notification-dot')).not.toBeInTheDocument();
});
- // Rendering NotificationTrigger has the effect of calling UpdateUpgradeNotificationLastSeen(),
- // if upgradeNotificationLastSeen is different than upgradeNotificationCurrentState
- // it should update localStorage accordingly
it('makes the right updates when rendering a new phase from an UpgradeNotification change (before -> after)', async () => {
+ getLocalStorage.mockImplementation((key) => {
+ if (key.includes('upgradeNotificationLastSeen')) { return 'before'; }
+ return null;
+ });
+
const container = renderWithProvider({
- upgradeNotificationLastSeen: 'before',
upgradeNotificationCurrentState: 'after',
});
- expect(container).toBeInTheDocument();
- // verify localStorage get/set are called with correct arguments
- expect(localStorage.getItem).toHaveBeenCalledWith(`upgradeNotificationLastSeen.${mockData.courseId}`);
- expect(localStorage.setItem).toHaveBeenCalledWith(`notificationStatus.${mockData.courseId}`, '"active"');
- expect(localStorage.setItem).toHaveBeenCalledWith(`upgradeNotificationLastSeen.${mockData.courseId}`, '"after"');
+ expect(container).toBeInTheDocument();
- // verify localStorage is updated accordingly
- expect(localStorage.getItem(`upgradeNotificationLastSeen.${mockData.courseId}`)).toBe('"after"');
- expect(localStorage.getItem(`notificationStatus.${mockData.courseId}`)).toBe('"active"');
+ expect(getLocalStorage).toHaveBeenCalledWith(`upgradeNotificationLastSeen.${mockData.courseId}`);
+ expect(setLocalStorage).toHaveBeenCalledWith(`notificationStatus.${mockData.courseId}`, 'active');
+ expect(setLocalStorage).toHaveBeenCalledWith(`upgradeNotificationLastSeen.${mockData.courseId}`, 'after');
});
it('handles localStorage from a different course', async () => {
- const courseMetadataSecondCourse = Factory.build('courseMetadata', { id: 'second_id' });
- // set localStorage for a different course before rendering NotificationTrigger
- localStorage.setItem(`upgradeNotificationLastSeen.${courseMetadataSecondCourse.id}`, '"accessDateView"');
- localStorage.setItem(`notificationStatus.${courseMetadataSecondCourse.id}`, '"inactive"');
+ getLocalStorage.mockImplementation((key) => {
+ if (key === `upgradeNotificationLastSeen.${mockData.courseId}`) { return 'before'; }
+ return 'accessDateView';
+ });
const container = renderWithProvider({
- upgradeNotificationLastSeen: 'before',
upgradeNotificationCurrentState: 'after',
});
+
expect(container).toBeInTheDocument();
- // Verify localStorage was updated for the original course
- expect(localStorage.getItem(`upgradeNotificationLastSeen.${mockData.courseId}`)).toBe('"after"');
- expect(localStorage.getItem(`notificationStatus.${mockData.courseId}`)).toBe('"active"');
- // Verify the second course localStorage was not changed
- expect(localStorage.getItem(`upgradeNotificationLastSeen.${courseMetadataSecondCourse.id}`)).toBe('"accessDateView"');
- expect(localStorage.getItem(`notificationStatus.${courseMetadataSecondCourse.id}`)).toBe('"inactive"');
+ expect(setLocalStorage).toHaveBeenCalledWith(`upgradeNotificationLastSeen.${mockData.courseId}`, 'after');
+ expect(setLocalStorage).toHaveBeenCalledWith(`notificationStatus.${mockData.courseId}`, 'active');
+
+ expect(setLocalStorage).not.toHaveBeenCalledWith(expect.stringContaining('second_id'), expect.anything());
+ });
+
+ it('initializes default localStorage values if they are missing', () => {
+ getLocalStorage.mockReturnValue(null);
+
+ renderWithProvider({});
+
+ expect(setLocalStorage).toHaveBeenCalledWith(`notificationStatus.${mockData.courseId}`, 'active');
+ expect(setLocalStorage).toHaveBeenCalledWith(`upgradeNotificationCurrentState.${mockData.courseId}`, 'initialize');
+ });
+
+ it('automatically opens sidebar if notification tray is open in session and sidebar is closed', () => {
+ getSessionStorage.mockReturnValue('open');
+ const contextData = { currentSidebar: null, toggleSidebar: jest.fn() };
+
+ renderWithProvider(contextData);
+
+ expect(contextData.toggleSidebar).toHaveBeenCalledWith(ID);
+ });
+
+ it('does NOT automatically open sidebar if it is already open (even if tray is open)', () => {
+ getSessionStorage.mockReturnValue('open');
+ const contextData = { currentSidebar: 'discussions', toggleSidebar: jest.fn() };
+
+ renderWithProvider(contextData);
+
+ expect(contextData.toggleSidebar).not.toHaveBeenCalled();
});
});