From f004d0ab3c11ec8a88c39ca2ecdabee4d05d0151 Mon Sep 17 00:00:00 2001 From: Kshitij Sobti Date: Mon, 7 Mar 2022 19:26:05 +0530 Subject: [PATCH] feat: Sidebar refactor and add support for discussions sidebar. (#762) squash!: remove unnecessary styling and migrate to bootstrap and other review feedback --- .env | 1 + .env.development | 1 + .env.test | 1 + src/courseware/CoursewareContainer.test.jsx | 3 + src/courseware/course/Course.jsx | 65 ++-------- src/courseware/course/Course.test.jsx | 14 +-- src/courseware/course/NotificationIcon.scss | 15 --- src/courseware/course/NotificationTray.jsx | 96 --------------- src/courseware/course/NotificationTray.scss | 67 ----------- src/courseware/course/NotificationTrigger.jsx | 51 -------- .../course/NotificationTrigger.scss | 24 ---- src/courseware/course/sequence/Sequence.jsx | 53 +-------- .../course/sequence/Sequence.test.jsx | 26 ++-- .../course/sequence/SequenceContent.jsx | 3 - src/courseware/course/sequence/Unit.jsx | 85 ++++++------- .../sequence/lock-paywall/LockPaywall.jsx | 6 +- src/courseware/course/sidebar/Sidebar.jsx | 18 +++ .../course/sidebar/SidebarContext.js | 5 + .../course/sidebar/SidebarContextProvider.jsx | 72 +++++++++++ .../course/sidebar/SidebarTriggers.jsx | 32 +++++ .../course/sidebar/common/SidebarBase.jsx | 102 ++++++++++++++++ .../course/sidebar/common/TriggerBase.jsx | 30 +++++ .../discussions/DiscussionsSidebar.jsx | 50 ++++++++ .../discussions/DiscussionsTrigger.jsx | 50 ++++++++ .../discussions/DiscussionsTrigger.test.jsx | 70 +++++++++++ .../sidebar/sidebars/discussions/index.js | 2 + .../sidebar/sidebars/discussions/messages.js | 16 +++ .../course/sidebar/sidebars/index.js | 20 ++++ .../notifications}/NotificationIcon.jsx | 29 +++-- .../notifications/NotificationIcon.scss | 4 + .../notifications/NotificationTray.jsx | 72 +++++++++++ .../notifications}/NotificationTray.test.jsx | 112 ++++++++++++------ .../notifications/NotificationTrigger.jsx | 71 +++++++++++ .../NotificationTrigger.test.jsx | 78 ++++++------ .../sidebar/sidebars/notifications/index.js | 2 + .../__factories__/discussionTopics.factory.js | 23 ++++ src/courseware/data/__factories__/index.js | 1 + src/courseware/data/api.js | 17 ++- src/courseware/data/thunks.js | 36 ++++-- src/generic/hooks.js | 21 ++++ src/generic/model-store/slice.js | 35 +++--- .../UpgradeNotification.test.jsx | 2 +- src/index.jsx | 1 + src/index.scss | 4 +- src/product-tours/ProductTours.test.jsx | 2 + src/setupTest.js | 3 + 46 files changed, 954 insertions(+), 537 deletions(-) delete mode 100644 src/courseware/course/NotificationIcon.scss delete mode 100644 src/courseware/course/NotificationTray.jsx delete mode 100644 src/courseware/course/NotificationTray.scss delete mode 100644 src/courseware/course/NotificationTrigger.jsx delete mode 100644 src/courseware/course/NotificationTrigger.scss create mode 100644 src/courseware/course/sidebar/Sidebar.jsx create mode 100644 src/courseware/course/sidebar/SidebarContext.js create mode 100644 src/courseware/course/sidebar/SidebarContextProvider.jsx create mode 100644 src/courseware/course/sidebar/SidebarTriggers.jsx create mode 100644 src/courseware/course/sidebar/common/SidebarBase.jsx create mode 100644 src/courseware/course/sidebar/common/TriggerBase.jsx create mode 100644 src/courseware/course/sidebar/sidebars/discussions/DiscussionsSidebar.jsx create mode 100644 src/courseware/course/sidebar/sidebars/discussions/DiscussionsTrigger.jsx create mode 100644 src/courseware/course/sidebar/sidebars/discussions/DiscussionsTrigger.test.jsx create mode 100644 src/courseware/course/sidebar/sidebars/discussions/index.js create mode 100644 src/courseware/course/sidebar/sidebars/discussions/messages.js create mode 100644 src/courseware/course/sidebar/sidebars/index.js rename src/courseware/course/{ => sidebar/sidebars/notifications}/NotificationIcon.jsx (60%) create mode 100644 src/courseware/course/sidebar/sidebars/notifications/NotificationIcon.scss create mode 100644 src/courseware/course/sidebar/sidebars/notifications/NotificationTray.jsx rename src/courseware/course/{ => sidebar/sidebars/notifications}/NotificationTray.test.jsx (56%) create mode 100644 src/courseware/course/sidebar/sidebars/notifications/NotificationTrigger.jsx rename src/courseware/course/{ => sidebar/sidebars/notifications}/NotificationTrigger.test.jsx (74%) create mode 100644 src/courseware/course/sidebar/sidebars/notifications/index.js create mode 100644 src/courseware/data/__factories__/discussionTopics.factory.js create mode 100644 src/generic/hooks.js diff --git a/.env b/.env index b29e73eb..c882fde3 100644 --- a/.env +++ b/.env @@ -10,6 +10,7 @@ CREDENTIALS_BASE_URL='' CREDIT_HELP_LINK_URL='' CSRF_TOKEN_API_PATH='' DISCOVERY_API_BASE_URL='' +DISCUSSIONS_MFE_BASE_URL='' ECOMMERCE_BASE_URL='' ENABLE_JUMPNAV='true' ENABLE_NOTICES='' diff --git a/.env.development b/.env.development index cb04320b..f1913514 100644 --- a/.env.development +++ b/.env.development @@ -10,6 +10,7 @@ CREDENTIALS_BASE_URL='http://localhost:18150' CREDIT_HELP_LINK_URL='https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_credit_courses.html#keep-track-of-credit-requirements' CSRF_TOKEN_API_PATH='/csrf/api/v1/token' DISCOVERY_API_BASE_URL='http://localhost:18381' +DISCUSSIONS_MFE_BASE_URL='http://localhost:2002' ECOMMERCE_BASE_URL='http://localhost:18130' ENABLE_JUMPNAV='true' ENABLE_NOTICES='' diff --git a/.env.test b/.env.test index 98609638..147c12c1 100644 --- a/.env.test +++ b/.env.test @@ -10,6 +10,7 @@ CREDENTIALS_BASE_URL='http://localhost:18150' CREDIT_HELP_LINK_URL='https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_credit_courses.html#keep-track-of-credit-requirements' CSRF_TOKEN_API_PATH='/csrf/api/v1/token' DISCOVERY_API_BASE_URL='http://localhost:18381' +DISCUSSIONS_MFE_BASE_URL='http://localhost:2002' ECOMMERCE_BASE_URL='http://localhost:18130' ENABLE_JUMPNAV='true' ENABLE_NOTICES='' diff --git a/src/courseware/CoursewareContainer.test.jsx b/src/courseware/CoursewareContainer.test.jsx index 3baa127f..71912ca1 100644 --- a/src/courseware/CoursewareContainer.test.jsx +++ b/src/courseware/CoursewareContainer.test.jsx @@ -147,6 +147,9 @@ describe('CoursewareContainer', () => { const sequenceMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/sequence/${unitBlock.id}`; axiosMock.onGet(sequenceMetadataUrl).reply(422, {}); }); + + const discussionConfigUrl = new RegExp(`${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/*`); + axiosMock.onGet(discussionConfigUrl).reply(200, { provider: 'legacy' }); } async function loadContainer() { diff --git a/src/courseware/course/Course.jsx b/src/courseware/course/Course.jsx index e6d85524..56b25df4 100644 --- a/src/courseware/course/Course.jsx +++ b/src/courseware/course/Course.jsx @@ -12,10 +12,10 @@ import Sequence from './sequence'; import { CelebrationModal, shouldCelebrateOnSectionLoad, WeeklyGoalCelebrationModal } from './celebration'; import ContentTools from './content-tools'; import CourseBreadcrumbs from './CourseBreadcrumbs'; -import NotificationTrigger from './NotificationTrigger'; +import SidebarProvider from './sidebar/SidebarContextProvider'; +import SidebarTriggers from './sidebar/SidebarTriggers'; import { useModel } from '../../generic/model-store'; -import { getLocalStorage, setLocalStorage } from '../../data/localStorage'; import { getSessionStorage, setSessionStorage } from '../../data/sessionStorage'; /** [MM-P2P] Experiment */ @@ -43,7 +43,6 @@ function Course({ const { celebrations, courseGoals, - verifiedMode, } = course; // Below the tabs, above the breadcrumbs alerts (appearing in the order listed here) @@ -57,10 +56,10 @@ function Course({ const [weeklyGoalCelebrationOpen, setWeeklyGoalCelebrationOpen] = useState( celebrations && !celebrations.streakLengthToCelebrate && celebrations.weeklyGoal, ); + const shouldDisplayTriggers = windowWidth >= breakpoints.small.minWidth; const daysPerWeek = courseGoals?.selectedGoal?.daysPerWeek; // Responsive breakpoints for showing the notification button/tray - const shouldDisplayNotificationTriggerInCourse = windowWidth >= breakpoints.small.minWidth; const shouldDisplayNotificationTrayOpenOnLoad = windowWidth > breakpoints.medium.minWidth; // Course specific notification tray open/closed persistance by browser session @@ -73,45 +72,15 @@ function Course({ } } - const [notificationTrayVisible, setNotificationTray] = verifiedMode - && shouldDisplayNotificationTrayOpenOnLoad && getSessionStorage(`notificationTrayStatus.${courseId}`) !== 'closed' ? useState(true) : useState(false); - - const isNotificationTrayVisible = () => notificationTrayVisible && setNotificationTray; - - const toggleNotificationTray = () => { - if (notificationTrayVisible) { setNotificationTray(false); } else { setNotificationTray(true); } - if (getSessionStorage(`notificationTrayStatus.${courseId}`) === 'open') { - setSessionStorage(`notificationTrayStatus.${courseId}`, 'closed'); - } else { - setSessionStorage(`notificationTrayStatus.${courseId}`, 'open'); - } - }; - - if (!getLocalStorage(`notificationStatus.${courseId}`)) { - setLocalStorage(`notificationStatus.${courseId}`, 'active'); // Show red dot on notificationTrigger until seen - } - - if (!getLocalStorage(`upgradeNotificationCurrentState.${courseId}`)) { - setLocalStorage(`upgradeNotificationCurrentState.${courseId}`, 'initialize'); - } - - const [notificationStatus, setNotificationStatus] = useState(getLocalStorage(`notificationStatus.${courseId}`)); - const [upgradeNotificationCurrentState, setupgradeNotificationCurrentState] = useState(getLocalStorage(`upgradeNotificationCurrentState.${courseId}`)); - - const onNotificationSeen = () => { - setNotificationStatus('inactive'); - setLocalStorage(`notificationStatus.${courseId}`, 'inactive'); - }; - /** [MM-P2P] Experiment */ const MMP2P = initCoursewareMMP2P(courseId, sequenceId, unitId); return ( - <> + {`${pageTitleBreadCrumbs.join(' | ')} | ${getConfig().SITE_NAME}`} -
+
- - { shouldDisplayNotificationTriggerInCourse ? ( - - ) : null} + {shouldDisplayTriggers && ( + + )}
@@ -142,14 +103,6 @@ function Course({ unitNavigationHandler={unitNavigationHandler} nextSequenceHandler={nextSequenceHandler} previousSequenceHandler={previousSequenceHandler} - toggleNotificationTray={toggleNotificationTray} - isNotificationTrayVisible={isNotificationTrayVisible} - notificationTrayVisible={notificationTrayVisible} - notificationStatus={notificationStatus} - setNotificationStatus={setNotificationStatus} - onNotificationSeen={onNotificationSeen} - upgradeNotificationCurrentState={upgradeNotificationCurrentState} - setupgradeNotificationCurrentState={setupgradeNotificationCurrentState} //* * [MM-P2P] Experiment */ mmp2p={MMP2P} /> @@ -167,7 +120,7 @@ function Course({ { /** [MM-P2P] Experiment */ } { MMP2P.meta.modalLock && } - + ); } diff --git a/src/courseware/course/Course.test.jsx b/src/courseware/course/Course.test.jsx index 77d19373..d2180367 100644 --- a/src/courseware/course/Course.test.jsx +++ b/src/courseware/course/Course.test.jsx @@ -2,14 +2,13 @@ import React from 'react'; import { Factory } from 'rosie'; import { breakpoints } from '@edx/paragon'; import { - loadUnit, render, screen, waitFor, getByRole, initializeTestStore, fireEvent, + fireEvent, getByRole, initializeTestStore, loadUnit, render, screen, waitFor, } from '../../setupTest'; -import Course from './Course'; import { handleNextSectionCelebration } from './celebration'; import * as celebrationUtils from './celebration/utils'; +import Course from './Course'; jest.mock('@edx/frontend-platform/analytics'); -jest.mock('./NotificationTray', () => () =>
); const recordFirstSectionCelebration = jest.fn(); celebrationUtils.recordFirstSectionCelebration = recordFirstSectionCelebration; @@ -106,11 +105,10 @@ describe('Course', () => { render(); const notificationTrigger = screen.getByRole('button', { name: /Show notification tray/i }); - expect(notificationTrigger).toBeInTheDocument(); - expect(notificationTrigger).toHaveClass('trigger-active'); + expect(notificationTrigger.parentNode).toHaveClass('border-primary-700'); fireEvent.click(notificationTrigger); - expect(notificationTrigger).not.toHaveClass('trigger-active'); + expect(notificationTrigger.parentNode).not.toHaveClass('border-primary-700'); }); it('handles click to open/close notification tray', async () => { @@ -118,10 +116,10 @@ describe('Course', () => { render(); expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"open"'); const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i }); - expect(screen.queryByTestId('NotificationTray')).toBeInTheDocument(); + expect(screen.queryByRole('region', { name: /notification tray/i })).toBeInTheDocument(); fireEvent.click(notificationShowButton); expect(sessionStorage.getItem(`notificationTrayStatus.${mockData.courseId}`)).toBe('"closed"'); - expect(screen.queryByTestId('NotificationTray')).not.toBeInTheDocument(); + expect(screen.queryByRole('region', { name: /notification tray/i })).not.toBeInTheDocument(); }); it('handles reload persisting notification tray status', async () => { diff --git a/src/courseware/course/NotificationIcon.scss b/src/courseware/course/NotificationIcon.scss deleted file mode 100644 index c2671cd0..00000000 --- a/src/courseware/course/NotificationIcon.scss +++ /dev/null @@ -1,15 +0,0 @@ -.icon-container { - position: relative; - display: flex; - align-items: center; - width: 2.4rem; - height: 2rem; -} - -.notification-dot { - position: absolute; - top: 0.3rem; - right: 0.55rem; - border-radius: 50% !important; - padding: 0.25rem !important; -} \ No newline at end of file diff --git a/src/courseware/course/NotificationTray.jsx b/src/courseware/course/NotificationTray.jsx deleted file mode 100644 index 76946d74..00000000 --- a/src/courseware/course/NotificationTray.jsx +++ /dev/null @@ -1,96 +0,0 @@ -import React, { useEffect } from 'react'; -import { useSelector } from 'react-redux'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { - breakpoints, - Icon, - IconButton, - useWindowSize, -} from '@edx/paragon'; -import { ArrowBackIos, Close } from '@edx/paragon/icons'; - -import messages from './messages'; -import { useModel } from '../../generic/model-store'; -import UpgradeNotification from '../../generic/upgrade-notification/UpgradeNotification'; - -function NotificationTray({ - intl, toggleNotificationTray, onNotificationSeen, upgradeNotificationCurrentState, setupgradeNotificationCurrentState, -}) { - const { - courseId, - } = useSelector(state => state.courseware); - - const course = useModel('coursewareMeta', courseId); - - const { - accessExpiration, - contentTypeGatingEnabled, - offer, - org, - timeOffsetMillis, - userTimezone, - verifiedMode, - } = course; - - const shouldDisplayFullScreen = useWindowSize().width < breakpoints.large.minWidth; - - // After three seconds, update notificationSeen (to hide red dot) - useEffect(() => { setTimeout(onNotificationSeen, 3000); }, []); - - return ( -
- {shouldDisplayFullScreen ? ( -
{ toggleNotificationTray(); }} onKeyDown={() => { toggleNotificationTray(); }} role="button" tabIndex="0" alt={intl.formatMessage(messages.responsiveCloseNotificationTray)}> - - {intl.formatMessage(messages.responsiveCloseNotificationTray)} -
- ) : null} -
- {intl.formatMessage(messages.notificationTitle)} - {shouldDisplayFullScreen - ? null - : ( -
- { toggleNotificationTray(); }} variant="primary" alt={intl.formatMessage(messages.closeNotificationTrigger)} /> -
- )} -
-
-
{verifiedMode - ? ( - - ) :

{intl.formatMessage(messages.noNotificationsMessage)}

} -
-
- ); -} - -NotificationTray.propTypes = { - intl: intlShape.isRequired, - toggleNotificationTray: PropTypes.func, - onNotificationSeen: PropTypes.func, - upgradeNotificationCurrentState: PropTypes.string.isRequired, - setupgradeNotificationCurrentState: PropTypes.func.isRequired, -}; - -NotificationTray.defaultProps = { - toggleNotificationTray: null, - onNotificationSeen: null, -}; - -export default injectIntl(NotificationTray); diff --git a/src/courseware/course/NotificationTray.scss b/src/courseware/course/NotificationTray.scss deleted file mode 100644 index abddee9a..00000000 --- a/src/courseware/course/NotificationTray.scss +++ /dev/null @@ -1,67 +0,0 @@ -.notification-tray-container { - border: 1px solid $light-400; - border-radius: 4px; - width: 31rem; - vertical-align: top; - height: 100%; - - @media (max-width: -1 + map-get($grid-breakpoints, 'lg')) { - position: fixed; - top: 0; - bottom: 0; - left: 0; - right: 0; - width: 100%; - background-color: white; - margin: 0; - border: none; - border-radius: 0; - z-index: 1100; - } -} - -.no-notification { - height: 15rem; -} - -.notification-tray-title { - display: inline-block; - padding: 0.625rem 0 0.625rem 1.25rem; -} - -.close-btn { - float: right; - margin-right: 0.5rem; - margin-top: 0.35rem; -} - -.notification-tray-divider { - height: 0.5rem; - background: $gray-100; - border-top: 1px solid $light-400; - border-bottom: 1px solid $light-400; -} - -.notification-tray-content { - padding: 1rem; - font-size: 0.875rem; -} - -.mobile-close-container { - padding-top: 0.5rem; - padding-bottom: 0.75rem; - border-bottom: 1px solid $light-400; - - span { - display: inline-block; - } - svg { - top: 0.4rem; - left: 0.8rem; - } -} - -.mobile-close { - font-weight: 500; - margin-left: 1.2rem; -} \ No newline at end of file diff --git a/src/courseware/course/NotificationTrigger.jsx b/src/courseware/course/NotificationTrigger.jsx deleted file mode 100644 index 8078dce9..00000000 --- a/src/courseware/course/NotificationTrigger.jsx +++ /dev/null @@ -1,51 +0,0 @@ -import React, { useEffect } from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { getLocalStorage, setLocalStorage } from '../../data/localStorage'; - -import NotificationIcon from './NotificationIcon'; -import messages from './messages'; - -function NotificationTrigger({ - courseId, intl, toggleNotificationTray, isNotificationTrayVisible, notificationStatus, setNotificationStatus, - upgradeNotificationCurrentState, -}) { - /* Re-show a red dot beside the notification trigger for each of the 7 UpgradeNotification stages - The upgradeNotificationCurrentState prop will be available after UpgradeNotification mounts. Once available, - compare with the last state they've seen, and if it's different then set dot back to red */ - function UpdateUpgradeNotificationLastSeen() { - if (upgradeNotificationCurrentState) { - if (getLocalStorage(`upgradeNotificationLastSeen.${courseId}`) !== upgradeNotificationCurrentState) { - setNotificationStatus('active'); - setLocalStorage(`notificationStatus.${courseId}`, 'active'); - setLocalStorage(`upgradeNotificationLastSeen.${courseId}`, upgradeNotificationCurrentState); - } - } - } - - useEffect(() => { UpdateUpgradeNotificationLastSeen(); }); - - return ( - - ); -} - -NotificationTrigger.propTypes = { - courseId: PropTypes.string.isRequired, - intl: intlShape.isRequired, - toggleNotificationTray: PropTypes.func.isRequired, - notificationStatus: PropTypes.string.isRequired, - setNotificationStatus: PropTypes.func.isRequired, - isNotificationTrayVisible: PropTypes.func.isRequired, - upgradeNotificationCurrentState: PropTypes.string.isRequired, -}; - -export default injectIntl(NotificationTrigger); diff --git a/src/courseware/course/NotificationTrigger.scss b/src/courseware/course/NotificationTrigger.scss deleted file mode 100644 index a3267a07..00000000 --- a/src/courseware/course/NotificationTrigger.scss +++ /dev/null @@ -1,24 +0,0 @@ -.notification-trigger-btn { - border: 1px solid $light-400; - background: none; - margin-top: 1rem; - - position: absolute; - right: 0; - - @media (max-width: -1 + map-get($grid-breakpoints, 'sm')) { - border: none; - margin: 0.3rem 1.25rem 0 0.25rem; - top: 0.1rem; - right: -3rem; - } -} - -.trigger-active::after { - content: ''; - position: absolute; - border-bottom: 2px solid $primary-700; - right: 0; - left: 0; - bottom: -1px; -} \ No newline at end of file diff --git a/src/courseware/course/sequence/Sequence.jsx b/src/courseware/course/sequence/Sequence.jsx index b5b1b7e7..cc3baad5 100644 --- a/src/courseware/course/sequence/Sequence.jsx +++ b/src/courseware/course/sequence/Sequence.jsx @@ -19,12 +19,12 @@ import { UserMessagesContext, ALERT_TYPES } from '../../../generic/user-messages import { useModel } from '../../../generic/model-store'; import CourseLicense from '../course-license'; +import Sidebar from '../sidebar/Sidebar'; +import SidebarTriggers from '../sidebar/SidebarTriggers'; import messages from './messages'; import HiddenAfterDue from './hidden-after-due'; import { SequenceNavigation, UnitNavigation } from './sequence-navigation'; import SequenceContent from './SequenceContent'; -import NotificationTray from '../NotificationTray'; -import NotificationTrigger from '../NotificationTrigger'; /** [MM-P2P] Experiment */ import { isMobile } from '../../../experiments/mm-p2p/utils'; @@ -38,14 +38,6 @@ function Sequence({ nextSequenceHandler, previousSequenceHandler, intl, - toggleNotificationTray, - notificationTrayVisible, - isNotificationTrayVisible, - notificationStatus, - setNotificationStatus, - onNotificationSeen, - upgradeNotificationCurrentState, - setupgradeNotificationCurrentState, mmp2p, }) { const course = useModel('coursewareMeta', courseId); @@ -159,8 +151,8 @@ function Sequence({ }; const defaultContent = ( -
-
+
+
goToCourseExitPage()} /> - - {shouldDisplayNotificationTriggerInSequence ? ( - - ) : null} + {shouldDisplayNotificationTriggerInSequence && }
@@ -223,16 +204,7 @@ function Sequence({ )}
- {notificationTrayVisible ? ( - - ) : null } + {/** [MM-P2P] Experiment */} {(mmp2p.state.isEnabled && mmp2p.flyover.isVisible) && ( @@ -277,14 +249,6 @@ Sequence.propTypes = { nextSequenceHandler: PropTypes.func.isRequired, previousSequenceHandler: PropTypes.func.isRequired, intl: intlShape.isRequired, - toggleNotificationTray: PropTypes.func, - notificationTrayVisible: PropTypes.bool, - isNotificationTrayVisible: PropTypes.func, - notificationStatus: PropTypes.string.isRequired, - setNotificationStatus: PropTypes.func.isRequired, - onNotificationSeen: PropTypes.func, - upgradeNotificationCurrentState: PropTypes.string.isRequired, - setupgradeNotificationCurrentState: PropTypes.func.isRequired, /** [MM-P2P] Experiment */ mmp2p: PropTypes.shape({ @@ -303,11 +267,6 @@ Sequence.propTypes = { Sequence.defaultProps = { sequenceId: null, unitId: null, - toggleNotificationTray: null, - notificationTrayVisible: null, - isNotificationTrayVisible: null, - onNotificationSeen: null, - /** [MM-P2P] Experiment */ mmp2p: { flyover: { isVisible: false }, diff --git a/src/courseware/course/sequence/Sequence.test.jsx b/src/courseware/course/sequence/Sequence.test.jsx index 611e7fd8..0836c2c9 100644 --- a/src/courseware/course/sequence/Sequence.test.jsx +++ b/src/courseware/course/sequence/Sequence.test.jsx @@ -5,6 +5,7 @@ import { breakpoints } from '@edx/paragon'; import { loadUnit, render, screen, fireEvent, waitFor, initializeTestStore, } from '../../../setupTest'; +import SidebarContext from '../sidebar/SidebarContext'; import Sequence from './Sequence'; import { fetchSequenceFailure } from '../../data/slice'; @@ -386,22 +387,25 @@ describe('Sequence', () => { describe('notification feature', () => { it('renders notification tray in sequence', async () => { - const testData = { - ...mockData, - notificationTrayVisible: true, - }; - render(); + render( + null }} + > + + , + ); expect(await screen.findByText('Notifications')).toBeInTheDocument(); }); it('handles click on notification tray close button', async () => { const toggleNotificationTray = jest.fn(); - const testData = { - ...mockData, - toggleNotificationTray, - notificationTrayVisible: true, - }; - render(); + render( + + + , + ); const notificationCloseIconButton = await screen.findByRole('button', { name: /Close notification tray/i }); fireEvent.click(notificationCloseIconButton); expect(toggleNotificationTray).toHaveBeenCalledTimes(1); diff --git a/src/courseware/course/sequence/SequenceContent.jsx b/src/courseware/course/sequence/SequenceContent.jsx index 110dd0c3..d6eff201 100644 --- a/src/courseware/course/sequence/SequenceContent.jsx +++ b/src/courseware/course/sequence/SequenceContent.jsx @@ -16,7 +16,6 @@ function SequenceContent({ sequenceId, unitId, unitLoadedHandler, - notificationTrayVisible, /** [MM-P2P] Experiment */ mmp2p, }) { @@ -62,7 +61,6 @@ function SequenceContent({ key={unitId} id={unitId} onLoaded={unitLoadedHandler} - notificationTrayVisible={notificationTrayVisible} /** [MM-P2P] Experiment */ mmp2p={mmp2p} /> @@ -75,7 +73,6 @@ SequenceContent.propTypes = { sequenceId: PropTypes.string.isRequired, unitId: PropTypes.string, unitLoadedHandler: PropTypes.func.isRequired, - notificationTrayVisible: PropTypes.bool.isRequired, intl: intlShape.isRequired, /** [MM-P2P] Experiment */ mmp2p: PropTypes.shape({ diff --git a/src/courseware/course/sequence/Unit.jsx b/src/courseware/course/sequence/Unit.jsx index 7ceb4ae6..7da7a817 100644 --- a/src/courseware/course/sequence/Unit.jsx +++ b/src/courseware/course/sequence/Unit.jsx @@ -1,25 +1,21 @@ -import React, { - Suspense, - useContext, - useEffect, - useRef, - useState, - useLayoutEffect, -} from 'react'; -import { useDispatch } from 'react-redux'; -import PropTypes from 'prop-types'; import { getConfig } from '@edx/frontend-platform'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { AppContext, ErrorPage } from '@edx/frontend-platform/react'; import { Modal } from '@edx/paragon'; -import messages from './messages'; -import BookmarkButton from '../bookmark/BookmarkButton'; -import { useModel } from '../../../generic/model-store'; -import PageLoading from '../../../generic/PageLoading'; +import PropTypes from 'prop-types'; +import React, { + Suspense, useCallback, useContext, useEffect, useLayoutEffect, useState, +} from 'react'; +import { useDispatch } from 'react-redux'; import { processEvent } from '../../../course-home/data/thunks'; -import { fetchCourse } from '../../data'; /** [MM-P2P] Experiment */ import { MMP2PLockPaywall } from '../../../experiments/mm-p2p'; +import { useEventListener } from '../../../generic/hooks'; +import { useModel } from '../../../generic/model-store'; +import PageLoading from '../../../generic/PageLoading'; +import { fetchCourse } from '../../data'; +import BookmarkButton from '../bookmark/BookmarkButton'; +import messages from './messages'; const HonorCode = React.lazy(() => import('./honor-code')); const LockPaywall = React.lazy(() => import('./lock-paywall')); @@ -85,7 +81,6 @@ function Unit({ onLoaded, id, intl, - notificationTrayVisible, /** [MM-P2P] Experiment */ mmp2p, }) { @@ -121,40 +116,31 @@ function Unit({ } }, [userNeedsIntegritySignature]); - // We use this ref so that we can hold a reference to the currently active event listener. - const messageEventListenerRef = useRef(null); + const receiveMessage = useCallback(({ data }) => { + const { + type, + payload, + } = data; + if (type === 'plugin.resize') { + setIframeHeight(payload.height); + if (!hasLoaded && iframeHeight === 0 && payload.height > 0) { + setHasLoaded(true); + if (onLoaded) { + onLoaded(); + } + } + } else if (type === 'plugin.modal') { + payload.open = true; + setModalOptions(payload); + } else if (data.offset) { + // We listen for this message from LMS to know when the page needs to + // be scrolled to another location on the page. + window.scrollTo(0, data.offset + document.getElementById('unit-iframe').offsetTop); + } + }, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded]); + useEventListener('message', receiveMessage); useEffect(() => { sendUrlHashToFrame(document.getElementById('unit-iframe')); - function receiveMessage(event) { - const { type, payload } = event.data; - if (type === 'plugin.resize') { - setIframeHeight(payload.height); - if (!hasLoaded && iframeHeight === 0 && payload.height > 0) { - setHasLoaded(true); - if (onLoaded) { - onLoaded(); - } - } - } else if (type === 'plugin.modal') { - payload.open = true; - setModalOptions(payload); - } else if (event.data.offset) { - // We listen for this message from LMS to know when the page needs to - // be scrolled to another location on the page. - window.scrollTo(0, event.data.offset + document.getElementById('unit-iframe').offsetTop); - } - } - // If we currently have an event listener, remove it. - if (messageEventListenerRef.current !== null) { - global.removeEventListener('message', messageEventListenerRef.current); - messageEventListenerRef.current = null; - } - // Now add our new receiveMessage handler as the event listener. - global.addEventListener('message', receiveMessage); - // And then save it to our ref for next time. - messageEventListenerRef.current = receiveMessage; - // When the component finally unmounts, use the ref to remove the correct handler. - return () => global.removeEventListener('message', messageEventListenerRef.current); }, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded]); return ( @@ -174,7 +160,7 @@ function Unit({ /> )} > - + )} { /** [MM-P2P] Experiment */ } @@ -266,7 +252,6 @@ Unit.propTypes = { id: PropTypes.string.isRequired, intl: intlShape.isRequired, onLoaded: PropTypes.func, - notificationTrayVisible: PropTypes.bool.isRequired, /** [MM-P2P] Experiment */ mmp2p: PropTypes.shape({ state: PropTypes.shape({ diff --git a/src/courseware/course/sequence/lock-paywall/LockPaywall.jsx b/src/courseware/course/sequence/lock-paywall/LockPaywall.jsx index 7a452c36..37aacd2a 100644 --- a/src/courseware/course/sequence/lock-paywall/LockPaywall.jsx +++ b/src/courseware/course/sequence/lock-paywall/LockPaywall.jsx @@ -1,10 +1,11 @@ -import React from 'react'; +import React, { useContext } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import { sendTrackEvent } from '@edx/frontend-platform/analytics'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { Alert, breakpoints, useWindowSize } from '@edx/paragon'; import { Locked } from '@edx/paragon/icons'; +import SidebarContext from '../../sidebar/SidebarContext'; import messages from './messages'; import certificateLocked from '../../../../generic/assets/edX_locked_certificate.png'; import { useModel } from '../../../../generic/model-store'; @@ -19,8 +20,8 @@ import { function LockPaywall({ intl, courseId, - notificationTrayVisible, }) { + const { notificationTrayVisible } = useContext(SidebarContext); const course = useModel('coursewareMeta', courseId); const { offer, @@ -115,6 +116,5 @@ function LockPaywall({ LockPaywall.propTypes = { intl: intlShape.isRequired, courseId: PropTypes.string.isRequired, - notificationTrayVisible: PropTypes.bool.isRequired, }; export default injectIntl(LockPaywall); diff --git a/src/courseware/course/sidebar/Sidebar.jsx b/src/courseware/course/sidebar/Sidebar.jsx new file mode 100644 index 00000000..efec70a1 --- /dev/null +++ b/src/courseware/course/sidebar/Sidebar.jsx @@ -0,0 +1,18 @@ +import React, { useContext } from 'react'; +import SidebarContext from './SidebarContext'; +import { SIDEBARS } from './sidebars'; + +function Sidebar() { + const { + currentSidebar, + } = useContext(SidebarContext); + if (!currentSidebar) { + return null; + } + const CurrentSidebar = SIDEBARS[currentSidebar].Sidebar; + return ( + + ); +} + +export default Sidebar; diff --git a/src/courseware/course/sidebar/SidebarContext.js b/src/courseware/course/sidebar/SidebarContext.js new file mode 100644 index 00000000..023b868c --- /dev/null +++ b/src/courseware/course/sidebar/SidebarContext.js @@ -0,0 +1,5 @@ +import React from 'react'; + +const SidebarContext = React.createContext({}); + +export default SidebarContext; diff --git a/src/courseware/course/sidebar/SidebarContextProvider.jsx b/src/courseware/course/sidebar/SidebarContextProvider.jsx new file mode 100644 index 00000000..1e6c93a1 --- /dev/null +++ b/src/courseware/course/sidebar/SidebarContextProvider.jsx @@ -0,0 +1,72 @@ +import { breakpoints, useWindowSize } from '@edx/paragon'; +import PropTypes from 'prop-types'; +import React, { useEffect, useState } from 'react'; + +import { getLocalStorage, setLocalStorage } from '../../../data/localStorage'; +import { getSessionStorage } from '../../../data/sessionStorage'; +import { useModel } from '../../../generic/model-store'; +import SidebarContext from './SidebarContext'; +import { SIDEBARS } from './sidebars'; + +export default function SidebarProvider({ + courseId, + unitId, + children, +}) { + const { verifiedMode } = useModel('coursewareMeta', courseId); + const shouldDisplayFullScreen = useWindowSize().width < breakpoints.large.minWidth; + const shouldDisplaySidebarOpen = useWindowSize().width > breakpoints.medium.minWidth; + const showNotificationsOnLoad = getSessionStorage(`notificationTrayStatus.${courseId}`) !== 'closed'; + const initialSidebar = (verifiedMode && shouldDisplaySidebarOpen && showNotificationsOnLoad) + ? SIDEBARS.NOTIFICATIONS.ID + : null; + const [currentSidebar, setCurrentSidebar] = useState(initialSidebar); + const [notificationStatus, setNotificationStatus] = useState(getLocalStorage(`notificationStatus.${courseId}`)); + const [upgradeNotificationCurrentState, setUpgradeNotificationCurrentState] = useState(getLocalStorage(`upgradeNotificationCurrentState.${courseId}`)); + + useEffect(() => { + // As a one-off set initial sidebar if the verified mode data has just loaded + if (verifiedMode && currentSidebar === null && initialSidebar) { + setCurrentSidebar(initialSidebar); + } + }, [initialSidebar, verifiedMode]); + + const onNotificationSeen = () => { + setNotificationStatus('inactive'); + setLocalStorage(`notificationStatus.${courseId}`, 'inactive'); + }; + + const toggleSidebar = (sidebarId) => { + // Switch to new sidebar or hide the current sidebar + setCurrentSidebar(sidebarId === currentSidebar ? null : sidebarId); + }; + + return ( + + {children} + + ); +} + +SidebarProvider.propTypes = { + courseId: PropTypes.string.isRequired, + unitId: PropTypes.string.isRequired, + children: PropTypes.node, +}; + +SidebarProvider.defaultProps = { + children: null, +}; diff --git a/src/courseware/course/sidebar/SidebarTriggers.jsx b/src/courseware/course/sidebar/SidebarTriggers.jsx new file mode 100644 index 00000000..b578f41b --- /dev/null +++ b/src/courseware/course/sidebar/SidebarTriggers.jsx @@ -0,0 +1,32 @@ +import classNames from 'classnames'; +import React, { useContext } from 'react'; +import SidebarContext from './SidebarContext'; +import { SIDEBAR_ORDER, SIDEBARS } from './sidebars'; + +function SidebarTriggers() { + const { + toggleSidebar, + currentSidebar, + } = useContext(SidebarContext); + return ( +
+ {SIDEBAR_ORDER.map((sidebarId) => { + const { Trigger } = SIDEBARS[sidebarId]; + const isActive = sidebarId === currentSidebar; + return ( +
+ toggleSidebar(sidebarId)} key={sidebarId} /> +
+ ); + })} +
+ ); +} + +SidebarTriggers.propTypes = {}; + +export default SidebarTriggers; diff --git a/src/courseware/course/sidebar/common/SidebarBase.jsx b/src/courseware/course/sidebar/common/SidebarBase.jsx new file mode 100644 index 00000000..41454b63 --- /dev/null +++ b/src/courseware/course/sidebar/common/SidebarBase.jsx @@ -0,0 +1,102 @@ +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { Icon, IconButton } from '@edx/paragon'; +import { ArrowBackIos, Close } from '@edx/paragon/icons'; +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React, { useCallback, useContext } from 'react'; +import { useEventListener } from '../../../../generic/hooks'; +import messages from '../../messages'; +import SidebarContext from '../SidebarContext'; + +function SidebarBase({ + intl, + title, + ariaLabel, + sidebarId, + className, + children, + showTitleBar, + width, +}) { + const { + toggleSidebar, + shouldDisplayFullScreen, + currentSidebar, + } = useContext(SidebarContext); + + const receiveMessage = useCallback(({ data }) => { + const { type } = data; + if (type === 'learning.events.sidebar.close') { + toggleSidebar(null); + } + }, [sidebarId, toggleSidebar]); + + useEventListener('message', receiveMessage); + + return currentSidebar === sidebarId && ( +
+ {shouldDisplayFullScreen ? ( +
toggleSidebar(null)} + onKeyDown={() => toggleSidebar(null)} + role="button" + tabIndex="0" + alt={intl.formatMessage(messages.responsiveCloseNotificationTray)} + > + + + {intl.formatMessage(messages.responsiveCloseNotificationTray)} + +
+ ) : null} + {showTitleBar && ( + <> +
+ {title} + {shouldDisplayFullScreen + ? null + : ( +
+ toggleSidebar(null)} + variant="primary" + alt={intl.formatMessage(messages.closeNotificationTrigger)} + /> +
+ )} +
+
+ + )} + {children} +
+ ); +} + +SidebarBase.propTypes = { + intl: intlShape.isRequired, + title: PropTypes.string.isRequired, + ariaLabel: PropTypes.string.isRequired, + sidebarId: PropTypes.string.isRequired, + className: PropTypes.string.isRequired, + children: PropTypes.element.isRequired, + showTitleBar: PropTypes.bool, + width: PropTypes.string, +}; + +SidebarBase.defaultProps = { + width: '31rem', + showTitleBar: true, +}; + +export default injectIntl(SidebarBase); diff --git a/src/courseware/course/sidebar/common/TriggerBase.jsx b/src/courseware/course/sidebar/common/TriggerBase.jsx new file mode 100644 index 00000000..bf1e9b64 --- /dev/null +++ b/src/courseware/course/sidebar/common/TriggerBase.jsx @@ -0,0 +1,30 @@ +import { injectIntl } from '@edx/frontend-platform/i18n'; +import PropTypes from 'prop-types'; +import React from 'react'; + +function SidebarTriggerBase({ + onClick, + ariaLabel, + children, +}) { + return ( + + ); +} + +SidebarTriggerBase.propTypes = { + onClick: PropTypes.func.isRequired, + ariaLabel: PropTypes.string.isRequired, + children: PropTypes.element.isRequired, +}; + +export default injectIntl(SidebarTriggerBase); diff --git a/src/courseware/course/sidebar/sidebars/discussions/DiscussionsSidebar.jsx b/src/courseware/course/sidebar/sidebars/discussions/DiscussionsSidebar.jsx new file mode 100644 index 00000000..f781e806 --- /dev/null +++ b/src/courseware/course/sidebar/sidebars/discussions/DiscussionsSidebar.jsx @@ -0,0 +1,50 @@ +import { ensureConfig, getConfig } from '@edx/frontend-platform'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import React, { useContext } from 'react'; +import { useModel } from '../../../../../generic/model-store'; +import SidebarBase from '../../common/SidebarBase'; +import SidebarContext from '../../SidebarContext'; +import { ID } from './DiscussionsTrigger'; + +import messages from './messages'; + +ensureConfig(['DISCUSSIONS_MFE_BASE_URL']); + +function DiscussionsSidebar({ intl }) { + const { + unitId, + courseId, + } = useContext(SidebarContext); + const topic = useModel('discussionTopics', unitId); + if (!topic) { + return null; + } + const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/topics/${topic.id}`; + return ( + +