From 1f704136330b776248b3bf398664107babdda4d8 Mon Sep 17 00:00:00 2001 From: "artur.filippovskii" Date: Mon, 10 Nov 2025 14:27:15 +0200 Subject: [PATCH 1/3] feat: add notification tray status to session storage --- src/courseware/course/Course.test.jsx | 4 ++ .../course/sidebar/SidebarContextProvider.jsx | 7 ++- .../sidebar/SidebarContextProvider.test.jsx | 14 ++++++ .../course/sidebar/common/SidebarBase.jsx | 22 ++++++--- .../sidebar/common/SidebarBase.test.jsx | 16 ++++++ src/widgets/upgrade/src/UpgradeTrigger.jsx | 20 +++++++- .../upgrade/src/UpgradeTrigger.test.jsx | 49 +++++++++++++++++++ 7 files changed, 122 insertions(+), 10 deletions(-) diff --git a/src/courseware/course/Course.test.jsx b/src/courseware/course/Course.test.jsx index 1bf61f0eef..89d68dd0e3 100644 --- a/src/courseware/course/Course.test.jsx +++ b/src/courseware/course/Course.test.jsx @@ -34,6 +34,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; diff --git a/src/courseware/course/sidebar/SidebarContextProvider.jsx b/src/courseware/course/sidebar/SidebarContextProvider.jsx index ed9567558b..aff889bc12 100644 --- a/src/courseware/course/sidebar/SidebarContextProvider.jsx +++ b/src/courseware/course/sidebar/SidebarContextProvider.jsx @@ -7,6 +7,8 @@ import { useDispatch } from 'react-redux'; import { useSearchParams } from 'react-router-dom'; import { useModel } from '@src/generic/model-store'; +import { getSessionStorage } from '@src/data/sessionStorage'; +import { WIDGETS } from '@src/constants'; import SidebarContext from './SidebarContext'; import { @@ -68,14 +70,15 @@ const SidebarProvider = ({ }, [getAvailableWidgets]); // Calculate initial sidebar with priority cascade - const initialSidebar = useInitialSidebar({ + const initialSidebarByPriority = useInitialSidebar({ courseId, shouldDisplayFullScreen, isInitiallySidebarOpen, getFirstAvailablePanel, getAvailableWidgets, }); - + const isNotificationTrayOpen = getSessionStorage(`notificationTrayStatus.${courseId}`) === 'open'; + const initialSidebar = isNotificationTrayOpen ? WIDGETS.NOTIFICATIONS : initialSidebarByPriority; const [currentSidebar, setCurrentSidebar] = useState(initialSidebar); // Track if user has manually toggled sidebar within current unit diff --git a/src/courseware/course/sidebar/SidebarContextProvider.test.jsx b/src/courseware/course/sidebar/SidebarContextProvider.test.jsx index 59219ad031..ab58f53e1f 100644 --- a/src/courseware/course/sidebar/SidebarContextProvider.test.jsx +++ b/src/courseware/course/sidebar/SidebarContextProvider.test.jsx @@ -4,6 +4,7 @@ import { render, screen, fireEvent, act, } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; +import { getSessionStorage } from '@src/data/sessionStorage'; import SidebarContext from './SidebarContext'; import SidebarProvider from './SidebarContextProvider'; @@ -20,6 +21,10 @@ jest.mock('@src/generic/model-store', () => ({ }), })); +jest.mock('@src/data/sessionStorage', () => ({ + getSessionStorage: jest.fn(() => null), +})); + jest.mock('@openedx/paragon', () => { const actual = jest.requireActual('@openedx/paragon'); return { @@ -105,6 +110,7 @@ describe('SidebarContextProvider', () => { const storage = jest.requireMock('./utils/storage'); storage.getSidebarId.mockReturnValue(null); storage.isOutlineSidebarCollapsed.mockReturnValue(false); + getSessionStorage.mockReturnValue(null); }); describe('context values provided', () => { @@ -120,6 +126,14 @@ describe('SidebarContextProvider', () => { expect(screen.getByTestId('available-ids').textContent).toBe('DISCUSSIONS,NOTES'); }); + it('opens the legacy notification tray when session storage marks it open', () => { + getSessionStorage.mockReturnValue('open'); + + renderProvider(); + + expect(screen.getByTestId('current-sidebar').textContent).toBe('UPGRADE'); + }); + it('excludes a widget from availableSidebarIds when isAvailable returns false', () => { const { getEnabledWidgets } = jest.requireMock('./defaultWidgets'); getEnabledWidgets.mockReturnValueOnce([ diff --git a/src/courseware/course/sidebar/common/SidebarBase.jsx b/src/courseware/course/sidebar/common/SidebarBase.jsx index d2b6fb19d1..55ac25c353 100644 --- a/src/courseware/course/sidebar/common/SidebarBase.jsx +++ b/src/courseware/course/sidebar/common/SidebarBase.jsx @@ -4,7 +4,10 @@ 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 { WIDGETS } from '@src/constants'; +import { setSessionStorage } from '@src/data/sessionStorage'; import messages from '../../messages'; import SidebarContext from '../SidebarContext'; @@ -19,18 +22,25 @@ const SidebarBase = ({ }) => { const intl = useIntl(); const { + courseId, toggleSidebar, shouldDisplayFullScreen, currentSidebar, } = useContext(SidebarContext); + const handleCloseSidebar = useCallback(() => { + toggleSidebar(null); + if (sidebarId === WIDGETS.NOTIFICATIONS) { + setSessionStorage(`notificationTrayStatus.${courseId}`, 'closed'); + } + }, [courseId, sidebarId, toggleSidebar]); + const receiveMessage = useCallback(({ data }) => { const { type } = data; if (type === 'learning.events.sidebar.close') { - toggleSidebar(null); + handleCloseSidebar(); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sidebarId, toggleSidebar]); + }, [handleCloseSidebar]); useEventListener('message', receiveMessage); @@ -49,8 +59,8 @@ const SidebarBase = ({ {shouldDisplayFullScreen ? (
toggleSidebar(null)} - onKeyDown={() => toggleSidebar(null)} + onClick={handleCloseSidebar} + onKeyDown={handleCloseSidebar} role="button" tabIndex="0" > @@ -71,7 +81,7 @@ const SidebarBase = ({ src={Close} size="sm" iconAs={Icon} - onClick={() => toggleSidebar(null)} + onClick={handleCloseSidebar} variant="primary" alt={intl.formatMessage(messages.closeSidebarTrigger)} /> diff --git a/src/courseware/course/sidebar/common/SidebarBase.test.jsx b/src/courseware/course/sidebar/common/SidebarBase.test.jsx index 7c3b4b777c..4d670d3ee7 100644 --- a/src/courseware/course/sidebar/common/SidebarBase.test.jsx +++ b/src/courseware/course/sidebar/common/SidebarBase.test.jsx @@ -1,12 +1,20 @@ import React from 'react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { render, screen, fireEvent } from '@testing-library/react'; +import { WIDGETS } from '@src/constants'; +import { setSessionStorage } from '@src/data/sessionStorage'; import SidebarContext from '../SidebarContext'; import SidebarBase from './SidebarBase'; const mockToggleSidebar = jest.fn(); +const courseId = 'course-test-123'; + +jest.mock('@src/data/sessionStorage', () => ({ + setSessionStorage: jest.fn(), +})); const defaultContextValue = { + courseId, toggleSidebar: mockToggleSidebar, shouldDisplayFullScreen: false, currentSidebar: 'TEST_SIDEBAR', @@ -82,6 +90,14 @@ describe('SidebarBase', () => { expect(mockToggleSidebar).toHaveBeenCalledWith(null); }); + it('marks the notification tray as closed when closing the upgrade sidebar', () => { + renderSidebarBase({ sidebarId: WIDGETS.NOTIFICATIONS }, { currentSidebar: WIDGETS.NOTIFICATIONS }); + + fireEvent.click(screen.getByRole('button', { name: /close sidebar/i })); + + expect(setSessionStorage).toHaveBeenCalledWith(`notificationTrayStatus.${courseId}`, 'closed'); + }); + it('does not render the back-to-course button', () => { renderSidebarBase(); diff --git a/src/widgets/upgrade/src/UpgradeTrigger.jsx b/src/widgets/upgrade/src/UpgradeTrigger.jsx index 029b1de723..3db419ea5d 100644 --- a/src/widgets/upgrade/src/UpgradeTrigger.jsx +++ b/src/widgets/upgrade/src/UpgradeTrigger.jsx @@ -4,6 +4,7 @@ import PropTypes from 'prop-types'; import SidebarContext from '@src/courseware/course/sidebar/SidebarContext'; import SidebarTriggerBase from '@src/courseware/course/sidebar/common/TriggerBase'; import { getLocalStorage, setLocalStorage } from '@src/data/localStorage'; +import { getSessionStorage, setSessionStorage } from '@src/data/sessionStorage'; import { useUpgradeWidgetContext } from './UpgradeWidgetContext'; import UpgradeIcon from './UpgradeIcon'; import messages from './messages'; @@ -12,13 +13,21 @@ export const ID = 'UPGRADE'; const UpgradeTrigger = ({ onClick }) => { const intl = useIntl(); - const { courseId } = useContext(SidebarContext); + const { courseId, currentSidebar, toggleSidebar } = useContext(SidebarContext); const { upgradeWidgetStatus, setUpgradeWidgetStatus, upgradeCurrentState, } = useUpgradeWidgetContext(); + useEffect(() => { + const isNotificationTrayOpen = getSessionStorage(`notificationTrayStatus.${courseId}`) === 'open'; + + if (isNotificationTrayOpen && !currentSidebar && toggleSidebar) { + toggleSidebar(ID); + } + }, [courseId, currentSidebar, toggleSidebar]); + useEffect(() => { if (!upgradeCurrentState) { return; @@ -30,8 +39,15 @@ const UpgradeTrigger = ({ onClick }) => { } }, [upgradeCurrentState, courseId, setUpgradeWidgetStatus]); + const handleClick = () => { + const isNotificationTrayOpen = getSessionStorage(`notificationTrayStatus.${courseId}`) === 'open'; + + setSessionStorage(`notificationTrayStatus.${courseId}`, isNotificationTrayOpen ? 'closed' : 'open'); + onClick(); + }; + return ( - + ); diff --git a/src/widgets/upgrade/src/UpgradeTrigger.test.jsx b/src/widgets/upgrade/src/UpgradeTrigger.test.jsx index 489093a7fc..ee5736fda8 100644 --- a/src/widgets/upgrade/src/UpgradeTrigger.test.jsx +++ b/src/widgets/upgrade/src/UpgradeTrigger.test.jsx @@ -3,6 +3,7 @@ import { IntlProvider } from '@edx/frontend-platform/i18n'; import { render, screen, fireEvent } from '@testing-library/react'; import SidebarContext from '@src/courseware/course/sidebar/SidebarContext'; import * as localStorageModule from '@src/data/localStorage'; +import * as sessionStorageModule from '@src/data/sessionStorage'; import { UpgradeWidgetProvider } from './UpgradeWidgetContext'; import UpgradeTrigger from './UpgradeTrigger'; @@ -11,6 +12,11 @@ jest.mock('@src/data/localStorage', () => ({ setLocalStorage: jest.fn(), })); +jest.mock('@src/data/sessionStorage', () => ({ + getSessionStorage: jest.fn(() => null), + setSessionStorage: jest.fn(), +})); + const courseId = 'course-test-123'; function renderTrigger(sidebarContextOverrides = {}, localStorageOverride = null) { @@ -35,6 +41,7 @@ function renderTrigger(sidebarContextOverrides = {}, localStorageOverride = null describe('UpgradeTrigger', () => { beforeEach(() => { jest.clearAllMocks(); + sessionStorageModule.getSessionStorage.mockReturnValue(null); }); it('renders a button with the correct aria-label', () => { @@ -66,6 +73,48 @@ describe('UpgradeTrigger', () => { expect(onClick).toHaveBeenCalledTimes(1); }); + it('marks the notification tray as open when the trigger opens the panel', () => { + const onClick = jest.fn(); + render( + + + + + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: /show upgrade panel/i })); + + expect(sessionStorageModule.setSessionStorage).toHaveBeenCalledWith( + `notificationTrayStatus.${courseId}`, + 'open', + ); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('marks the notification tray as closed when the trigger closes the panel', () => { + sessionStorageModule.getSessionStorage.mockReturnValue('open'); + renderTrigger({ currentSidebar: 'UPGRADE' }); + + fireEvent.click(screen.getByRole('button', { name: /show upgrade panel/i })); + + expect(sessionStorageModule.setSessionStorage).toHaveBeenCalledWith( + `notificationTrayStatus.${courseId}`, + 'closed', + ); + }); + + it('reopens the upgrade panel when notification tray status is open', () => { + const toggleSidebar = jest.fn(); + sessionStorageModule.getSessionStorage.mockReturnValue('open'); + + renderTrigger({ currentSidebar: null, toggleSidebar }); + + expect(toggleSidebar).toHaveBeenCalledWith('UPGRADE'); + }); + it('shows status dot when upgradeWidgetStatus is "active"', () => { // Default status from localStorage is null, so provider defaults to "active" renderTrigger(); From 857036fabf7eec5f534d43e78271800d8847ab18 Mon Sep 17 00:00:00 2001 From: "artur.filippovskii" Date: Wed, 26 Nov 2025 14:57:56 +0200 Subject: [PATCH 2/3] fix: fix bug and remove extra code --- webpack.dev-tutor.config.js | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 webpack.dev-tutor.config.js diff --git a/webpack.dev-tutor.config.js b/webpack.dev-tutor.config.js new file mode 100644 index 0000000000..e69de29bb2 From 95a0e4b9038bb98f11a0dd062a53ba5d8d1205a4 Mon Sep 17 00:00:00 2001 From: "artur.filippovskii" Date: Fri, 28 Nov 2025 12:55:22 +0200 Subject: [PATCH 3/3] fix: remove extra file and add him to gitignore --- .gitignore | 2 ++ webpack.dev-tutor.config.js | 0 2 files changed, 2 insertions(+) delete mode 100644 webpack.dev-tutor.config.js 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/webpack.dev-tutor.config.js b/webpack.dev-tutor.config.js deleted file mode 100644 index e69de29bb2..0000000000