feat: added implementation for new sidebar (#1260)

* feat: added implementation for new sidebar

* test: fixed test cases

* refactor: fixed naming convention and combine useeffects

* refactor: improved sidebar UI and renamed sidebar flag

* refactor: remove additional states

* refactor: fixed UI and logic related issue

* refactor: simplified condition

* refactor: toggle sidebar action

* refactor: fixed toggle issues

* refactor: back arrow component

* refactor: changed useeffect position

---------

Co-authored-by: Awais Ansari <awais.ansari63@gmail.com>
This commit is contained in:
sundasnoreen12
2024-01-08 13:40:21 +05:00
committed by GitHub
parent 4dc4725ba1
commit 023f5ac254
28 changed files with 834 additions and 15 deletions

1
.env
View File

@@ -13,6 +13,7 @@ DISCOVERY_API_BASE_URL=''
DISCUSSIONS_MFE_BASE_URL=''
ECOMMERCE_BASE_URL=''
ENABLE_JUMPNAV='true'
ENABLE_NEW_SIDEBAR=''
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=''
EXAMS_BASE_URL=''

View File

@@ -13,6 +13,7 @@ DISCOVERY_API_BASE_URL='http://localhost:18381'
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NEW_SIDEBAR=''
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
EXAMS_BASE_URL=''

View File

@@ -13,6 +13,7 @@ DISCOVERY_API_BASE_URL='http://localhost:18381'
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NEW_SIDEBAR=''
ENABLE_NOTICES=''
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
EXAMS_BASE_URL='http://localhost:18740'

View File

@@ -15,6 +15,8 @@ import ContentTools from './content-tools';
import CourseBreadcrumbs from './CourseBreadcrumbs';
import SidebarProvider from './sidebar/SidebarContextProvider';
import SidebarTriggers from './sidebar/SidebarTriggers';
import NewSidebarProvider from './new-sidebar/SidebarContextProvider';
import NewSidebarTriggers from './new-sidebar/SidebarTriggers';
import { useModel } from '../../generic/model-store';
@@ -34,6 +36,7 @@ const Course = ({
} = useModel('courseHomeMeta', courseId);
const sequence = useModel('sequences', sequenceId);
const section = useModel('sections', sequence ? sequence.sectionId : null);
const enableNewSidebar = getConfig().ENABLE_NEW_SIDEBAR;
const pageTitleBreadCrumbs = [
sequence,
@@ -64,12 +67,14 @@ const Course = ({
));
}, [sequenceId]);
const SidebarProviderComponent = enableNewSidebar === 'true' ? NewSidebarProvider : SidebarProvider;
return (
<SidebarProvider courseId={courseId} unitId={unitId}>
<SidebarProviderComponent courseId={courseId} unitId={unitId}>
<Helmet>
<title>{`${pageTitleBreadCrumbs.join(' | ')} | ${getConfig().SITE_NAME}`}</title>
</Helmet>
<div className="position-relative d-flex align-items-start">
<div className="position-relative d-flex align-items-center mb-4 mt-1">
<CourseBreadcrumbs
courseId={courseId}
sectionId={section ? section.id : null}
@@ -87,7 +92,7 @@ const Course = ({
contentToolsEnabled={course.showCalculator || course.notes.enabled}
unitId={unitId}
/>
<SidebarTriggers />
{enableNewSidebar === 'true' ? <NewSidebarTriggers /> : <SidebarTriggers /> }
</>
)}
</div>
@@ -113,7 +118,7 @@ const Course = ({
onClose={() => setWeeklyGoalCelebrationOpen(false)}
/>
<ContentTools course={course} />
</SidebarProvider>
</SidebarProviderComponent>
);
};

View File

@@ -153,7 +153,7 @@ const CourseBreadcrumbs = ({
}, [courseStatus, sequenceStatus, allSequencesInSections]);
return (
<nav aria-label="breadcrumb" className="my-4 d-inline-block col-sm-10">
<nav aria-label="breadcrumb" className="d-inline-block col-sm-10">
<ol className="list-unstyled d-flex flex-nowrap align-items-center m-0">
<li className="list-unstyled col-auto m-0 p-0">
<Link

View File

@@ -0,0 +1,17 @@
import React, { useContext } from 'react';
import SidebarContext from './SidebarContext';
import { SIDEBARS } from './sidebars';
const Sidebar = () => {
const { currentSidebar } = useContext(SidebarContext);
if (currentSidebar === null) { return null; }
const SidebarToRender = SIDEBARS[currentSidebar].Sidebar;
return (
<SidebarToRender />
);
};
export default Sidebar;

View File

@@ -0,0 +1,5 @@
import React from 'react';
const SidebarContext = React.createContext({});
export default SidebarContext;

View File

@@ -0,0 +1,103 @@
import React, {
useCallback, useEffect, useMemo, useState,
} from 'react';
import PropTypes from 'prop-types';
import isEmpty from 'lodash/isEmpty';
import { breakpoints, useWindowSize } from '@edx/paragon';
import { getLocalStorage, setLocalStorage } from '../../../data/localStorage';
import { useModel } from '../../../generic/model-store';
import WIDGETS from './constants';
import SidebarContext from './SidebarContext';
import { SIDEBARS } from './sidebars';
const SidebarProvider = ({
courseId,
unitId,
children,
}) => {
const shouldDisplayFullScreen = useWindowSize().width < breakpoints.large.minWidth;
const shouldDisplaySidebarOpen = useWindowSize().width > breakpoints.medium.minWidth;
const query = new URLSearchParams(window.location.search);
const initialSidebar = (shouldDisplaySidebarOpen || query.get('sidebar') === 'true')
? SIDEBARS.DISCUSSIONS_NOTIFICATIONS.ID : null;
const [currentSidebar, setCurrentSidebar] = useState(initialSidebar);
const [notificationStatus, setNotificationStatus] = useState(getLocalStorage(`notificationStatus.${courseId}`));
const [hideDiscussionbar, setHideDiscussionbar] = useState(false);
const [hideNotificationbar, setHideNotificationbar] = useState(false);
const [upgradeNotificationCurrentState, setUpgradeNotificationCurrentState] = useState(
getLocalStorage(`upgradeNotificationCurrentState.${courseId}`),
);
const topic = useModel('discussionTopics', unitId);
const { verifiedMode } = useModel('courseHomeMeta', courseId);
const isDiscussionbarAvailable = topic?.id && topic?.enabledInContext;
const isNotificationbarAvailable = !isEmpty(verifiedMode);
const onNotificationSeen = useCallback(() => {
setNotificationStatus('inactive');
setLocalStorage(`notificationStatus.${courseId}`, 'inactive');
}, [courseId]);
useEffect(() => {
setHideDiscussionbar(!isDiscussionbarAvailable);
setHideNotificationbar(!isNotificationbarAvailable);
setCurrentSidebar(SIDEBARS.DISCUSSIONS_NOTIFICATIONS.ID);
}, [unitId, topic]);
useEffect(() => {
if (hideDiscussionbar && hideNotificationbar) {
setCurrentSidebar(null);
}
}, [hideDiscussionbar, hideNotificationbar]);
const toggleSidebar = useCallback((sidebarId = null, widgetId = null) => {
if (widgetId) {
setHideDiscussionbar(prevWidgetId => (widgetId === WIDGETS.DISCUSSIONS ? true : prevWidgetId));
setHideNotificationbar(prevWidgetId => (widgetId === WIDGETS.NOTIFICATIONS ? true : prevWidgetId));
} else {
setCurrentSidebar(prevSidebar => (sidebarId === prevSidebar ? null : sidebarId));
setHideDiscussionbar(!isDiscussionbarAvailable);
setHideNotificationbar(!isNotificationbarAvailable);
}
}, [isDiscussionbarAvailable, isNotificationbarAvailable]);
const contextValue = useMemo(() => ({
toggleSidebar,
onNotificationSeen,
setNotificationStatus,
currentSidebar,
notificationStatus,
upgradeNotificationCurrentState,
setUpgradeNotificationCurrentState,
shouldDisplaySidebarOpen,
shouldDisplayFullScreen,
courseId,
unitId,
hideDiscussionbar,
hideNotificationbar,
isNotificationbarAvailable,
isDiscussionbarAvailable,
}), [courseId, currentSidebar, notificationStatus, onNotificationSeen, shouldDisplayFullScreen,
shouldDisplaySidebarOpen, toggleSidebar, unitId, upgradeNotificationCurrentState, hideDiscussionbar,
hideNotificationbar, isNotificationbarAvailable, isDiscussionbarAvailable]);
return (
<SidebarContext.Provider value={contextValue}>
{children}
</SidebarContext.Provider>
);
};
SidebarProvider.propTypes = {
courseId: PropTypes.string.isRequired,
unitId: PropTypes.string.isRequired,
children: PropTypes.node,
};
SidebarProvider.defaultProps = {
children: null,
};
export default SidebarProvider;

View File

@@ -0,0 +1,21 @@
import React, { useContext } from 'react';
import SidebarContext from './SidebarContext';
import { SIDEBAR_ORDER, SIDEBARS } from './sidebars';
const SidebarTriggers = () => {
const { toggleSidebar } = useContext(SidebarContext);
return (
<div className="d-flex ml-auto">
{SIDEBAR_ORDER.map((sidebarId) => {
const { Trigger } = SIDEBARS[sidebarId];
return (
<Trigger onClick={() => toggleSidebar(sidebarId)} key={sidebarId} />
);
})}
</div>
);
};
export default SidebarTriggers;

View File

@@ -0,0 +1,113 @@
import React, { useCallback, useContext } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, IconButton } from '@edx/paragon';
import { ArrowBackIos, Close } from '@edx/paragon/icons';
import { useEventListener } from '../../../../generic/hooks';
import WIDGETS from '../constants';
import messages from '../messages';
import SidebarContext from '../SidebarContext';
const SidebarBase = ({
title,
ariaLabel,
sidebarId,
className,
children,
showTitleBar,
width,
allowFullHeight,
showBorder,
}) => {
const intl = useIntl();
const {
toggleSidebar,
shouldDisplayFullScreen,
currentSidebar,
} = useContext(SidebarContext);
const receiveMessage = useCallback(({ data }) => {
const { type } = data;
if (type === 'learning.events.sidebar.close') {
toggleSidebar(currentSidebar, WIDGETS.DISCUSSIONS);
}
}, [toggleSidebar]);
useEventListener('message', receiveMessage);
return (
<section
className={classNames('ml-0 ml-lg-4 h-auto align-top', {
'min-vh-100': !shouldDisplayFullScreen && allowFullHeight,
'bg-white m-0 border-0 fixed-top vh-100 rounded-0': shouldDisplayFullScreen,
'd-none': currentSidebar !== sidebarId,
'border border-light-400 rounded-sm': showBorder,
}, className)}
data-testid={`sidebar-${sidebarId}`}
style={{ width: shouldDisplayFullScreen ? '100%' : width }}
aria-label={ariaLabel}
>
{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)}
role="button"
tabIndex="0"
alt={intl.formatMessage(messages.responsiveCloseSidebarTray)}
>
<Icon src={ArrowBackIos} />
<span className="font-weight-bold m-2 d-inline-block">
{intl.formatMessage(messages.responsiveCloseSidebarTray)}
</span>
</div>
)}
{showTitleBar && (
<>
<div className="d-flex align-items-center">
<span className="p-2.5 d-inline-block">{title}</span>
<div className="d-inline-flex mr-2 mt-1.5 ml-auto">
<IconButton
src={Close}
size="sm"
iconAs={Icon}
onClick={() => toggleSidebar(sidebarId)}
alt={intl.formatMessage(messages.closeTrigger)}
className="icon-hover"
/>
</div>
</div>
<div className="py-1 bg-gray-100 border-top border-bottom border-light-400" />
</>
)}
{children}
</section>
);
};
SidebarBase.propTypes = {
title: PropTypes.string.isRequired,
ariaLabel: PropTypes.string.isRequired,
sidebarId: PropTypes.string.isRequired,
className: PropTypes.string,
children: PropTypes.element.isRequired,
showTitleBar: PropTypes.bool,
width: PropTypes.string,
allowFullHeight: PropTypes.bool,
showBorder: PropTypes.bool,
};
SidebarBase.defaultProps = {
width: '50rem',
allowFullHeight: false,
showTitleBar: true,
className: '',
showBorder: true,
};
export default SidebarBase;

View File

@@ -0,0 +1,6 @@
const WIDGETS = {
DISCUSSIONS: 'DISCUSSIONS',
NOTIFICATIONS: 'NOTIFICATIONS',
};
export default WIDGETS;

View File

@@ -0,0 +1,20 @@
import * as React from 'react';
const RightSidebarFilled = (props) => (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 22V2h20v20H2ZM14 4H4v16h10V4Z"
fill="currentColor"
/>
</svg>
);
export default RightSidebarFilled;

View File

@@ -0,0 +1,20 @@
import * as React from 'react';
const RightSidebarOutlined = (props) => (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 2v20h20V2H2Zm18 2h-4v16h4V4ZM4 4h10v16H4V4Z"
fill="currentColor"
/>
</svg>
);
export default RightSidebarOutlined;

View File

@@ -0,0 +1,2 @@
export { default as RightSidebarFilled } from './RightSidebarFilled';
export { default as RightSidebarOutlined } from './RightSidebarOutlined';

View File

@@ -0,0 +1,36 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
discussionsTitle: {
id: 'discussions.sidebar.title',
defaultMessage: 'Discussions',
description: 'Title text for a forum where users are able to discuss course topics',
},
discussionNotificationTray: {
id: 'discussions.notification.tray.container',
defaultMessage: 'Discussion and Notification tray',
description: 'Discussion and Notification tray container',
},
notificationTitle: {
id: 'notification.tray.title',
defaultMessage: 'Notifications',
description: 'Title text displayed for the notification tray',
},
closeTrigger: {
id: 'tray.close.button',
defaultMessage: 'Close tray',
description: 'Button for the learner to close the sidebar',
},
openSidebarTrigger: {
id: 'sidebar.open.button',
defaultMessage: 'Show sidebar tray',
description: 'Button to open the sidebar tray and shows notifications and didcussions',
},
responsiveCloseSidebarTray: {
id: 'responsive.close.sidebar',
defaultMessage: 'Back to course',
description: 'Responsive button to go back to course and close the sidebar tray',
},
});
export default messages;

View File

@@ -0,0 +1,31 @@
import React, { useContext } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import SidebarBase from '../../common/SidebarBase';
import messages from '../../messages';
import SidebarContext from '../../SidebarContext';
import DiscussionsSidebar from './discussions/DiscussionsWidget';
import NotificationTray from './notifications/NotificationsWidget';
import { ID } from './DiscussionsNotificationsTrigger';
const DiscussionsNotificationsSidebar = () => {
const intl = useIntl();
const { hideNotificationbar } = useContext(SidebarContext);
return (
<SidebarBase
ariaLabel={intl.formatMessage(messages.discussionNotificationTray)}
sidebarId={ID}
className="d-flex flex-column flex-fill"
showTitleBar={false}
showBorder={false}
>
<NotificationTray />
{!hideNotificationbar && <div className="my-1.5" />}
<DiscussionsSidebar />
</SidebarBase>
);
};
export default DiscussionsNotificationsSidebar;

View File

@@ -0,0 +1,97 @@
import React, { useContext, useEffect, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, IconButton } from '@edx/paragon';
import { getLocalStorage, setLocalStorage } from '../../../../../data/localStorage';
import { getSessionStorage, setSessionStorage } from '../../../../../data/sessionStorage';
import { useModel } from '../../../../../generic/model-store';
import { getCourseDiscussionTopics } from '../../../../data/thunks';
import { RightSidebarFilled, RightSidebarOutlined } from '../../icons';
import messages from '../../messages';
import SidebarContext from '../../SidebarContext';
export const ID = 'DISCUSSIONS_NOTIFICATIONS';
const DiscussionsNotificationsTrigger = ({ onClick }) => {
const {
courseId,
currentSidebar,
setNotificationStatus,
upgradeNotificationCurrentState,
isNotificationbarAvailable,
isDiscussionbarAvailable,
} = useContext(SidebarContext);
const dispatch = useDispatch();
const intl = useIntl();
const { tabs } = useModel('courseHomeMeta', courseId);
const baseUrl = getConfig().DISCUSSIONS_MFE_BASE_URL;
const edxProvider = useMemo(
() => tabs?.find(tab => tab.slug === 'discussion'),
[tabs],
);
useEffect(() => {
if (baseUrl && edxProvider) {
dispatch(getCourseDiscussionTopics(courseId));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [courseId, baseUrl, edxProvider]);
/* 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);
}
}
}
if (!getLocalStorage(`notificationStatus.${courseId}`)) {
setLocalStorage(`notificationStatus.${courseId}`, 'active'); // Show red dot on notificationTrigger until seen
}
if (!getLocalStorage(`upgradeNotificationCurrentState.${courseId}`)) {
setLocalStorage(`upgradeNotificationCurrentState.${courseId}`, 'initialize');
}
useEffect(() => {
updateUpgradeNotificationLastSeen();
});
const handleClick = () => {
if (getSessionStorage(`notificationTrayStatus.${courseId}`) === 'open') {
setSessionStorage(`notificationTrayStatus.${courseId}`, 'closed');
} else {
setSessionStorage(`notificationTrayStatus.${courseId}`, 'open');
}
onClick();
};
if (!isDiscussionbarAvailable && !isNotificationbarAvailable) { return null; }
return (
<IconButton
src={currentSidebar ? RightSidebarFilled : RightSidebarOutlined}
iconAs={Icon}
onClick={handleClick}
alt={intl.formatMessage(messages.openSidebarTrigger)}
className="icon-hover"
/>
);
};
DiscussionsNotificationsTrigger.propTypes = {
onClick: PropTypes.func.isRequired,
};
export default DiscussionsNotificationsTrigger;

View File

@@ -0,0 +1,35 @@
import React, { useContext } from 'react';
import { ensureConfig, getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import classNames from 'classnames';
import SidebarContext from '../../../SidebarContext';
import messages from '../../../messages';
ensureConfig(['DISCUSSIONS_MFE_BASE_URL']);
const DiscussionsWidget = () => {
const intl = useIntl();
const {
unitId,
courseId,
hideDiscussionbar,
isDiscussionbarAvailable,
shouldDisplayFullScreen,
} = useContext(SidebarContext);
const discussionsUrl = `${getConfig().DISCUSSIONS_MFE_BASE_URL}/${courseId}/category/${unitId}`;
if (hideDiscussionbar || !isDiscussionbarAvailable) { return null; }
return (
<iframe
src={`${discussionsUrl}?inContextSidebar`}
className={classNames('d-flex w-100 flex-fill border border-light-400 rounded-sm', { 'vh-100': !shouldDisplayFullScreen })}
title={intl.formatMessage(messages.discussionsTitle)}
allow="clipboard-write"
loading="lazy"
/>
);
};
export default DiscussionsWidget;

View File

@@ -0,0 +1,74 @@
import React from 'react';
import MockAdapter from 'axios-mock-adapter';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
initializeMockApp, initializeTestStore, render, screen,
} from '../../../../../../setupTest';
import { executeThunk } from '../../../../../../utils';
import { buildTopicsFromUnits } from '../../../../../data/__factories__/discussionTopics.factory';
import { getCourseDiscussionTopics } from '../../../../../data/thunks';
import SidebarContext from '../../../SidebarContext';
import DiscussionsWidget from './DiscussionsWidget';
initializeMockApp();
describe('DiscussionsWidget', () => {
let axiosMock;
let mockData;
let courseId;
let unitId;
beforeEach(async () => {
const store = await initializeTestStore({
excludeFetchCourse: false,
excludeFetchSequence: false,
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const state = store.getState();
courseId = state.courseware.courseId;
[unitId] = Object.keys(state.models.units);
mockData = {
courseId,
unitId,
currentSidebar: 'NEWSIDEBAR',
hideDiscussionbar: false,
isDiscussionbarAvailable: true,
};
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/${courseId}`).reply(
200,
{
provider: 'openedx',
},
);
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/discussion/v2/course_topics/${courseId}`)
.reply(200, buildTopicsFromUnits(state.models.units));
await executeThunk(getCourseDiscussionTopics(courseId), store.dispatch);
});
function renderWithProvider(testData = {}) {
const { container } = render(
<SidebarContext.Provider value={{ ...mockData, ...testData }}>
<DiscussionsWidget />
</SidebarContext.Provider>,
);
return container;
}
it('should show up if unit discussions associated with it', async () => {
renderWithProvider();
expect(screen.queryByTitle('Discussions')).toBeInTheDocument();
expect(screen.queryByTitle('Discussions'))
.toHaveAttribute('src', `http://localhost:2002/${courseId}/category/${unitId}?inContextSidebar`);
});
it('should show nothing if unit has no discussions associated with it', async () => {
renderWithProvider({ isDiscussionbarAvailable: false });
expect(screen.queryByTitle('Discussions')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,2 @@
export { default as Sidebar } from './DiscussionsNotificationsSidebar';
export { default as Trigger, ID } from './DiscussionsNotificationsTrigger';

View File

@@ -0,0 +1,62 @@
import React, { useContext, useEffect } from 'react';
import { useModel } from '../../../../../../generic/model-store';
import UpgradeNotification from '../../../../../../generic/upgrade-notification/UpgradeNotification';
import WIDGETS from '../../../constants';
import SidebarContext from '../../../SidebarContext';
const NotificationsWidget = () => {
const {
courseId,
onNotificationSeen,
upgradeNotificationCurrentState,
setUpgradeNotificationCurrentState,
hideNotificationbar,
toggleSidebar,
isNotificationbarAvailable,
currentSidebar,
} = useContext(SidebarContext);
const course = useModel('coursewareMeta', courseId);
const {
accessExpiration,
contentTypeGatingEnabled,
marketingUrl,
offer,
timeOffsetMillis,
userTimezone,
} = course;
const {
org,
verifiedMode,
} = useModel('courseHomeMeta', courseId);
// After three seconds, update notificationSeen (to hide red dot)
useEffect(() => { setTimeout(onNotificationSeen, 3000); }, []);
if (hideNotificationbar || !isNotificationbarAvailable) { return null; }
return (
<div className="border border-light-400 rounded-sm">
<UpgradeNotification
offer={offer}
verifiedMode={verifiedMode}
accessExpiration={accessExpiration}
contentTypeGatingEnabled={contentTypeGatingEnabled}
marketingUrl={marketingUrl}
upsellPageName="in_course"
userTimezone={userTimezone}
shouldDisplayBorder={false}
timeOffsetMillis={timeOffsetMillis}
courseId={courseId}
org={org}
upgradeNotificationCurrentState={upgradeNotificationCurrentState}
setupgradeNotificationCurrentState={setUpgradeNotificationCurrentState}
toggleSidebar={() => toggleSidebar(currentSidebar, WIDGETS.NOTIFICATIONS)}
/>
</div>
);
};
export default NotificationsWidget;

View File

@@ -0,0 +1,112 @@
/* eslint-disable react/jsx-no-constructed-context-values */
import React from 'react';
import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { breakpoints } from '@edx/paragon';
import { initializeMockApp, render, screen } from '../../../../../../setupTest';
import initializeStore from '../../../../../../store';
import { appendBrowserTimezoneToUrl, executeThunk } from '../../../../../../utils';
import { fetchCourse } from '../../../../../data';
import SidebarContext from '../../../SidebarContext';
import NotificationsWidget from './NotificationsWidget';
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
describe('NotificationsWidget', () => {
let axiosMock;
let store;
const ID = 'NEWSIDEBAR';
const defaultMetadata = Factory.build('courseMetadata');
const courseId = defaultMetadata.id;
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${defaultMetadata.id}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
const courseHomeMetadata = Factory.build('courseHomeMetadata');
const courseHomeMetadataUrl = appendBrowserTimezoneToUrl(`${getConfig().LMS_BASE_URL}/api/course_home/course_metadata/${courseId}`);
function setMetadata(attributes, options) {
const updatedCourseHomeMetadata = Factory.build('courseHomeMetadata', attributes, options);
axiosMock.onGet(courseHomeMetadataUrl).reply(200, updatedCourseHomeMetadata);
}
async function fetchAndRender(component) {
await executeThunk(fetchCourse(defaultMetadata.id), store.dispatch);
render(component, { store });
}
beforeEach(async () => {
global.innerWidth = breakpoints.large.minWidth;
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
axiosMock.onGet(courseHomeMetadataUrl).reply(200, courseHomeMetadata);
});
it('renders upgrade card', async () => {
await fetchAndRender(
<SidebarContext.Provider value={{
currentSidebar: ID,
courseId,
hideNotificationbar: false,
isNotificationbarAvailable: true,
}}
>
<NotificationsWidget />
</SidebarContext.Provider>,
);
const UpgradeNotification = document.querySelector('.upgrade-notification');
expect(UpgradeNotification)
.toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Upgrade for $149' }))
.toBeInTheDocument();
expect(screen.queryByText('You have no new notifications at this time.'))
.not
.toBeInTheDocument();
});
it('renders no notifications bar if no verified mode', async () => {
setMetadata({ verified_mode: null });
await fetchAndRender(
<SidebarContext.Provider value={{
currentSidebar: ID,
courseId,
hideNotificationbar: true,
isNotificationbarAvailable: false,
}}
>
<NotificationsWidget />
</SidebarContext.Provider>,
);
expect(screen.queryByText('Notifications'))
.not
.toBeInTheDocument();
});
it('marks notification as seen 3 seconds later', async () => {
jest.useFakeTimers();
const onNotificationSeen = jest.fn();
await fetchAndRender(
<SidebarContext.Provider value={{
currentSidebar: ID,
courseId,
onNotificationSeen,
hideNotificationbar: false,
isNotificationbarAvailable: true,
}}
>
<NotificationsWidget />
</SidebarContext.Provider>,
);
expect(onNotificationSeen).toHaveBeenCalledTimes(0);
jest.advanceTimersByTime(3000);
expect(onNotificationSeen).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,13 @@
import * as discussionsNotifications from './discussions-notifications';
export const SIDEBARS = {
[discussionsNotifications.ID]: {
ID: discussionsNotifications.ID,
Sidebar: discussionsNotifications.Sidebar,
Trigger: discussionsNotifications.Trigger,
},
};
export const SIDEBAR_ORDER = [
discussionsNotifications.ID,
];

View File

@@ -2,13 +2,14 @@
import React, {
useEffect, useState,
} from 'react';
import { getConfig } from '@edx/frontend-platform';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import {
sendTrackEvent,
sendTrackingLogEvent,
} from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import SequenceExamWrapper from '@edx/frontend-lib-special-exams';
import { breakpoints, useWindowSize } from '@edx/paragon';
@@ -19,7 +20,9 @@ import { useSequenceBannerTextAlert, useSequenceEntranceExamAlert } from '../../
import CourseLicense from '../course-license';
import Sidebar from '../sidebar/Sidebar';
import NewSidebar from '../new-sidebar/Sidebar';
import SidebarTriggers from '../sidebar/SidebarTriggers';
import NewSidebarTriggers from '../new-sidebar/SidebarTriggers';
import messages from './messages';
import HiddenAfterDue from './hidden-after-due';
import { SequenceNavigation, UnitNavigation } from './sequence-navigation';
@@ -32,8 +35,8 @@ const Sequence = ({
unitNavigationHandler,
nextSequenceHandler,
previousSequenceHandler,
intl,
}) => {
const intl = useIntl();
const course = useModel('coursewareMeta', courseId);
const {
isStaff,
@@ -44,6 +47,7 @@ const Sequence = ({
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
const sequenceMightBeUnit = useSelector(state => state.courseware.sequenceMightBeUnit);
const shouldDisplayNotificationTriggerInSequence = useWindowSize().width < breakpoints.small.minWidth;
const enableNewSidebar = getConfig().ENABLE_NEW_SIDEBAR;
const handleNext = () => {
const nextIndex = sequence.unitIds.indexOf(unitId) + 1;
@@ -159,7 +163,9 @@ const Sequence = ({
handlePrevious();
}}
/>
{shouldDisplayNotificationTriggerInSequence && <SidebarTriggers />}
{shouldDisplayNotificationTriggerInSequence && (
enableNewSidebar === 'true' ? <NewSidebarTriggers /> : <SidebarTriggers />
)}
<div className="unit-container flex-grow-1">
<SequenceContent
@@ -185,7 +191,7 @@ const Sequence = ({
)}
</div>
</div>
<Sidebar />
{enableNewSidebar === 'true' ? <NewSidebar /> : <Sidebar />}
</div>
);
@@ -221,7 +227,6 @@ Sequence.propTypes = {
unitNavigationHandler: PropTypes.func.isRequired,
nextSequenceHandler: PropTypes.func.isRequired,
previousSequenceHandler: PropTypes.func.isRequired,
intl: intlShape.isRequired,
};
Sequence.defaultProps = {
@@ -229,4 +234,4 @@ Sequence.defaultProps = {
unitId: null,
};
export default injectIntl(Sequence);
export default Sequence;

View File

@@ -1,9 +1,12 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import {
useIntl, FormattedDate, FormattedMessage, injectIntl,
} from '@edx/frontend-platform/i18n';
import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { FormattedDate, FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { Button, Icon, IconButton } from '@edx/paragon';
import { Close } from '@edx/paragon/icons';
import { setLocalStorage } from '../../data/localStorage';
import { UpgradeButton } from '../upgrade-button';
import {
@@ -12,6 +15,7 @@ import {
FullAccessBullet,
SupportMissionBullet,
} from '../upsell-bullets/UpsellBullets';
import messages from '../messages';
const UpsellNoFBECardContent = () => (
<ul className="fa-ul upgrade-notification-ul pt-0">
@@ -122,7 +126,7 @@ const ExpirationCountdown = ({
expirationText = (
<FormattedMessage
id="learning.generic.upgradeNotification.expirationDays"
defaultMessage={`{dayCount, number} {dayCount, plural,
defaultMessage={`{dayCount, number} {dayCount, plural,
one {day}
other {days}} left`}
values={{
@@ -283,7 +287,9 @@ const UpgradeNotification = ({
upsellPageName,
userTimezone,
verifiedMode,
toggleSidebar,
}) => {
const intl = useIntl();
const dateNow = Date.now();
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const correctedTime = new Date(dateNow + timeOffsetMillis);
@@ -483,8 +489,25 @@ const UpgradeNotification = ({
return (
<section className={classNames('upgrade-notification small', { 'card mb-4': shouldDisplayBorder })}>
<div id="courseHome-upgradeNotification">
<h2 className="h5 upgrade-notification-header" id="outline-sidebar-upgrade-header">
<h2
className={classNames('h5 upgrade-notification-header', {
'd-flex align-items-center mr-2 ml-4 my-1.5 font-size-18': !!toggleSidebar,
})}
id="outline-sidebar-upgrade-header"
>
{upgradeNotificationHeaderText}
{!!toggleSidebar && (
<div className="d-inline-flex ml-auto">
<IconButton
src={Close}
size="sm"
iconAs={Icon}
onClick={toggleSidebar}
className="icon-hover"
alt={intl.formatMessage(messages.close)}
/>
</div>
)}
</h2>
{expirationBanner}
<div className="upgrade-notification-message">
@@ -512,6 +535,7 @@ UpgradeNotification.propTypes = {
percentage: PropTypes.number,
code: PropTypes.string,
}),
toggleSidebar: PropTypes.func,
shouldDisplayBorder: PropTypes.bool,
setupgradeNotificationCurrentState: PropTypes.func,
timeOffsetMillis: PropTypes.number,
@@ -534,6 +558,7 @@ UpgradeNotification.defaultProps = {
timeOffsetMillis: 0,
userTimezone: null,
verifiedMode: null,
toggleSidebar: null,
};
export default injectIntl(UpgradeNotification);

View File

@@ -41,3 +41,7 @@
padding-top: .75rem;
padding-bottom: .75rem;
}
.font-size-18 {
font-size: 18px !important;
}

View File

@@ -154,6 +154,7 @@ initialize({
DISCUSSIONS_MFE_BASE_URL: process.env.DISCUSSIONS_MFE_BASE_URL || null,
ENTERPRISE_LEARNER_PORTAL_HOSTNAME: process.env.ENTERPRISE_LEARNER_PORTAL_HOSTNAME || null,
ENABLE_JUMPNAV: process.env.ENABLE_JUMPNAV || null,
ENABLE_NEW_SIDEBAR: process.env.ENABLE_NEW_SIDEBAR || null,
ENABLE_NOTICES: process.env.ENABLE_NOTICES || null,
INSIGHTS_BASE_URL: process.env.INSIGHTS_BASE_URL || null,
SEARCH_CATALOG_URL: process.env.SEARCH_CATALOG_URL || null,

View File

@@ -361,6 +361,13 @@
box-shadow: 0 .0625rem .125rem rgba(0, 0, 0, .2) !important;
}
.icon-hover {
&:hover {
color: $primary-500 !important;
background-color: $light-300 !important;
}
}
// Import component-specific sass files
@import "courseware/course/celebration/CelebrationModal.scss";
@import "courseware/course/sidebar/sidebars/notifications/NotificationIcon.scss";