Compare commits

...

10 Commits

Author SHA1 Message Date
julianajlk
05e299d976 Deactivate notification red dot 2021-06-29 14:35:49 -04:00
julianajlk
ae842b8c24 Rename UpgradeCard to UpgradeNotification 2021-06-28 18:22:03 -04:00
julianajlk
1a5c6a8319 Rename Sidebar to NotificationTray 2021-06-28 18:16:36 -04:00
julianajlk
2f87b762f2 Rename SidebarNotificationButton to NotificationTrigger 2021-06-28 18:16:35 -04:00
julianajlk
01f5c3c95c Update tests 2021-06-28 18:16:34 -04:00
julianajlk
dade755780 Updates after UX review 2021-06-28 18:16:34 -04:00
julianajlk
b618cccb02 Add UpgradeCard to courseware and fix CSS to be usable for course-home and courseware 2021-06-28 18:16:33 -04:00
julianajlk
81f8ff5a13 Add timeOffsetMillis to courseware API 2021-06-28 18:16:33 -04:00
julianajlk
556da6a22f Clean up UpgradeCard SCSS 2021-06-28 18:16:32 -04:00
julianajlk
8291ea5929 Move UpgradeCard to generic directory to be accessible in courseware 2021-06-28 18:16:32 -04:00
27 changed files with 482 additions and 418 deletions

View File

@@ -2,4 +2,4 @@ import './courseHomeMetadata.factory';
import './datesTabData.factory';
import './outlineTabData.factory';
import './progressTabData.factory';
import './upgradeCardData.factory';
import './upgradeNotificationData.factory';

View File

@@ -1,6 +1,6 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
Factory.define('upgradeCardData')
Factory.define('upgradeNotificationData')
.option('host', 'http://localhost:18000')
.option('dateBlocks', [])
.option('offer', null)

View File

@@ -16,7 +16,7 @@ import genericMessages from '../../generic/messages';
import messages from './messages';
import Section from './Section';
import UpdateGoalSelector from './widgets/UpdateGoalSelector';
import UpgradeCard from './widgets/UpgradeCard';
import UpgradeNotification from '../../generic/upgrade-notification/UpgradeNotification';
import { useAccessExpirationAlertMasquerade } from '../../alerts/access-expiration-alert';
import useCertificateAvailableAlert from './alerts/certificate-status-alert';
import useCourseEndAlert from './alerts/course-end-alert';
@@ -219,7 +219,7 @@ function OutlineTab({ intl }) {
{ MMP2P.state.isEnabled
? <MMP2PFlyover isStatic options={MMP2P} />
: (
<UpgradeCard
<UpgradeNotification
offer={offer}
verifiedMode={verifiedMode}
accessExpiration={accessExpiration}
@@ -228,6 +228,7 @@ function OutlineTab({ intl }) {
timeOffsetMillis={timeOffsetMillis}
courseId={courseId}
org={org}
shouldDisplayBorder
/>
)}
<CourseDates

View File

@@ -1,59 +0,0 @@
.upgrade-card {
border-radius: 0 !important;
}
.upgrade-card-header{
margin: 1.25rem;
}
.upsell-warning{
background-color: $danger-100;
}
.upsell-warning-light{
background-color: $warning-100;
}
.upsell-warning, .upsell-warning-light{
padding-left: 1.25rem;
padding-right: 1.25rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.upgrade-card-ul{
margin-left: 3rem;
padding-top: 0.875rem;
padding-right: 1.25rem;
}
.upgrade-card-li{
left: -2.125rem;
top: 0 !important;
}
.upgrade-card-text{
padding-top: 0.875rem;
padding-right: 1.25rem;
padding-left: 1.25rem;
}
.upgrade-card-button{
margin-left: 1.25rem;
margin-right: 1.25rem;
margin-bottom: 1.25rem;
}
.discount-info {
border-top: 1px solid rgba(0, 0, 0, 0.125);
padding-top: .75rem;
padding-bottom: .75rem;
}
.inline-link-underline {
text-decoration: underline;
}
.upgrade-card .upgrade-card-message a{
color: $primary-500;
}

View File

@@ -14,7 +14,7 @@ import Sequence from './sequence';
import { CelebrationModal, shouldCelebrateOnSectionLoad } from './celebration';
import ContentTools from './content-tools';
import CourseBreadcrumbs from './CourseBreadcrumbs';
import SidebarNotificationButton from './SidebarNotificationButton';
import NotificationTrigger from './NotificationTrigger';
import CourseSock from '../../generic/course-sock';
import { useModel } from '../../generic/model-store';
@@ -61,17 +61,17 @@ function Course({
courseId, sequenceId, unitId, celebrateFirstSection, dispatch, celebrations,
);
// REV-2130 TODO: temporary cookie code that should be removed.
// In order to see the Value Prop sidebar in prod, a cookie should be set in
// REV-2297 TODO: temporary cookie code that should be removed.
// In order to see the Value Prop Notification Tray in prod, a cookie should be set in
// the browser console and refresh: document.cookie = 'value_prop_cookie=true';
const isValuePropCookieSet = Cookies.get('value_prop_cookie') === 'true';
const shouldDisplaySidebarButton = useWindowSize().width >= responsiveBreakpoints.small.minWidth;
const shouldDisplayNotificationTrigger = useWindowSize().width >= responsiveBreakpoints.small.minWidth;
const [sidebarVisible, setSidebar] = useState(false);
const isSidebarVisible = () => sidebarVisible && setSidebar;
const toggleSidebar = () => {
if (sidebarVisible) { setSidebar(false); } else { setSidebar(true); }
const [notificationTrayVisible, setNotificationTray] = useState(true);
const isNotificationTrayVisible = () => notificationTrayVisible && setNotificationTray;
const toggleNotificationTray = () => {
if (notificationTrayVisible) { setNotificationTray(false); } else { setNotificationTray(true); }
};
/** [MM-P2P] Experiment */
@@ -102,10 +102,10 @@ function Course({
mmp2p={MMP2P}
/>
{ isValuePropCookieSet && shouldDisplaySidebarButton ? (
<SidebarNotificationButton
toggleSidebar={toggleSidebar}
isSidebarVisible={isSidebarVisible}
{ isValuePropCookieSet && shouldDisplayNotificationTrigger ? (
<NotificationTrigger
toggleNotificationTray={toggleNotificationTray}
isNotificationTrayVisible={isNotificationTrayVisible}
/>
) : null}
</div>
@@ -118,9 +118,9 @@ function Course({
unitNavigationHandler={unitNavigationHandler}
nextSequenceHandler={nextSequenceHandler}
previousSequenceHandler={previousSequenceHandler}
toggleSidebar={toggleSidebar}
isSidebarVisible={isSidebarVisible}
sidebarVisible={sidebarVisible}
toggleNotificationTray={toggleNotificationTray}
isNotificationTrayVisible={isNotificationTrayVisible}
notificationTrayVisible={notificationTrayVisible}
isValuePropCookieSet={isValuePropCookieSet}
//* * [MM-P2P] Experiment */
mmp2p={MMP2P}

View File

@@ -20,7 +20,7 @@ describe('Course', () => {
nextSequenceHandler: () => {},
previousSequenceHandler: () => {},
unitNavigationHandler: () => {},
toggleSidebar: () => {},
toggleNotificationTray: () => {},
};
beforeAll(async () => {
@@ -87,11 +87,11 @@ describe('Course', () => {
expect(screen.getByRole('button', { name: 'Learn About Verified Certificates' })).toBeInTheDocument();
});
it('displays sidebar notification button', async () => {
const toggleSidebar = jest.fn();
const isSidebarVisible = jest.fn();
it('displays notification trigger', async () => {
const toggleNotificationTray = jest.fn();
const isNotificationTrayVisible = jest.fn();
// REV-2130 TODO: remove cookie related code once temporary value prop cookie code is removed.
// REV-2297 TODO: remove cookie related code once temporary value prop cookie code is removed.
const cookieName = 'value_prop_cookie';
Cookies.set = jest.fn();
Cookies.get = jest.fn().mockImplementation(() => cookieName);
@@ -101,15 +101,15 @@ describe('Course', () => {
const testStore = await initializeTestStore({ courseMetadata, excludeFetchSequence: true }, false);
const testData = {
...mockData,
toggleSidebar,
isSidebarVisible,
toggleNotificationTray,
isNotificationTrayVisible,
};
render(<Course {...testData} courseId={courseMetadata.id} />, { store: testStore });
const sidebarOpenButton = screen.getByRole('button', { name: /Show sidebar notification/i });
const notificationOpenButton = screen.getByRole('button', { name: /Show notification tray/i });
expect(getSpy).toBeCalledWith(cookieName);
expect(sidebarOpenButton).toBeInTheDocument();
expect(notificationOpenButton).toBeInTheDocument();
});
it('displays offer and expiration alert', async () => {

View File

@@ -11,7 +11,7 @@ import messages from './messages';
function NotificationIcon({ intl, status, notificationColor }) {
return (
<div className="icon-container">
<Icon src={WatchOutline} className="m-0 m-auto" alt={intl.formatMessage(messages.openSidebarButton)} />
<Icon src={WatchOutline} className="m-0 m-auto" alt={intl.formatMessage(messages.openNotificationTrigger)} />
{status === 'active'
? <span className={classNames(notificationColor, 'notification-dot')} data-testid="notification-dot" />
: null}

View File

@@ -0,0 +1,78 @@
import React 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 { Icon } from '@edx/paragon';
import { ArrowBackIos, Close } from '@edx/paragon/icons';
import messages from './messages';
import { useModel } from '../../generic/model-store';
import useWindowSize, { responsiveBreakpoints } from '../../generic/tabs/useWindowSize';
import UpgradeNotification from '../../generic/upgrade-notification/UpgradeNotification';
function NotificationTray({
intl, toggleNotificationTray,
}) {
const {
courseId,
} = useSelector(state => state.courseware);
const course = useModel('coursewareMeta', courseId);
const {
accessExpiration,
contentTypeGatingEnabled,
offer,
org,
timeOffsetMillis,
userTimezone,
verifiedMode,
} = course;
const shouldDisplayFullScreen = useWindowSize().width < responsiveBreakpoints.large.minWidth;
return (
<section className={classNames('notification-tray-container ml-0 ml-lg-4', { 'no-notification': !verifiedMode && !shouldDisplayFullScreen })} aria-label={intl.formatMessage(messages.notificationTray)}>
{shouldDisplayFullScreen ? (
<div className="mobile-close-container" onClick={() => { toggleNotificationTray(); }} onKeyDown={() => { toggleNotificationTray(); }} role="button" tabIndex="0" alt={intl.formatMessage(messages.responsiveCloseNotificationTray)}>
<Icon src={ArrowBackIos} />
<span className="mobile-close">{intl.formatMessage(messages.responsiveCloseNotificationTray)}</span>
</div>
) : null}
<div className="notification-tray-header px-3">
<span>{intl.formatMessage(messages.notificationTitle)}</span>
{shouldDisplayFullScreen
? null
: <Icon src={Close} className="close-btn" onClick={() => { toggleNotificationTray(); }} onKeyDown={() => { toggleNotificationTray(); }} role="button" tabIndex="0" alt={intl.formatMessage(messages.closeNotificationTrigger)} />}
</div>
<div className="notification-tray-divider" />
<div>{verifiedMode
? (
<UpgradeNotification
offer={offer}
verifiedMode={verifiedMode}
accessExpiration={accessExpiration}
contentTypeGatingEnabled={contentTypeGatingEnabled}
userTimezone={userTimezone}
timeOffsetMillis={timeOffsetMillis}
courseId={courseId}
org={org}
shouldDisplayBorder={false}
/>
) : <p className="notification-tray-content">{intl.formatMessage(messages.noNotificationsMessage)}</p>}
</div>
</section>
);
}
NotificationTray.propTypes = {
intl: intlShape.isRequired,
toggleNotificationTray: PropTypes.func,
};
NotificationTray.defaultProps = {
toggleNotificationTray: null,
};
export default injectIntl(NotificationTray);

View File

@@ -1,8 +1,9 @@
.sidebar-container {
.notification-tray-container {
border: 1px solid $light-400;
border-radius: 4px;
width: 20rem;
width: 31rem;
vertical-align: top;
height: 100%;
@media (max-width: -1 + map-get($grid-breakpoints, 'lg')) {
position: fixed;
@@ -11,7 +12,6 @@
left: 0;
right: 0;
width: 100%;
height: 100% !important;
background-color: white;
margin: 0;
border: none;
@@ -23,7 +23,7 @@
height: 15rem;
}
.sidebar-header {
.notification-tray-header {
padding: 0.625rem 0;
span {
@@ -35,7 +35,7 @@
float: right;
}
.sidebar-divider {
.notification-tray-divider {
width: 100.5%;
height: 0.5rem;
background: $gray-100;
@@ -43,7 +43,7 @@
border-left: 0;
}
.sidebar-content {
.notification-tray-content {
padding: 1rem;
font-size: 0.875rem;
}

View File

@@ -0,0 +1,87 @@
import React from 'react';
import { Factory } from 'rosie';
import MockAdapter from 'axios-mock-adapter';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { fetchCourse } from '../data';
import {
render, initializeMockApp, screen, fireEvent, waitFor,
} from '../../setupTest';
import initializeStore from '../../store';
import { appendBrowserTimezoneToUrl, executeThunk } from '../../utils';
import NotificationTray from './NotificationTray';
import useWindowSize from '../../generic/tabs/useWindowSize';
initializeMockApp();
jest.mock('../../generic/tabs/useWindowSize');
jest.mock('@edx/frontend-platform/analytics');
describe('NotificationTray', () => {
let mockData;
let axiosMock;
let store;
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const defaultMetadata = Factory.build('courseMetadata', { id: courseId });
let courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${defaultMetadata.id}`;
courseMetadataUrl = appendBrowserTimezoneToUrl(courseMetadataUrl);
function setMetadata(attributes, options) {
const courseMetadata = Factory.build('courseMetadata', { id: courseId, ...attributes }, options);
axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata);
}
async function fetchAndRender(component) {
await executeThunk(fetchCourse(defaultMetadata.id), store.dispatch);
render(component, { store });
}
beforeEach(async () => {
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(courseMetadataUrl).reply(200, defaultMetadata);
mockData = {
toggleNotificationTray: () => {},
};
});
it('renders notification tray', async () => {
useWindowSize.mockReturnValue({ width: 1200, height: 422 });
await fetchAndRender(<NotificationTray />);
expect(screen.getByText('Notifications')).toBeInTheDocument();
expect(screen.queryByText('Back to course')).not.toBeInTheDocument();
});
it('renders upgrade card', async () => {
await fetchAndRender(<NotificationTray />);
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 message if no verified mode', async () => {
setMetadata({ verified_mode: null });
await fetchAndRender(<NotificationTray />);
expect(screen.queryByText('You have no new notifications at this time.')).toBeInTheDocument();
});
it('renders notification tray with full screen "Back to course" at response width', async () => {
useWindowSize.mockReturnValue({ width: 991, height: 422 });
const toggleNotificationTray = jest.fn();
const testData = {
...mockData,
toggleNotificationTray,
};
await fetchAndRender(<NotificationTray {...testData} />);
const responsiveCloseButton = screen.getByRole('button', { name: 'Back to course' });
await waitFor(() => expect(responsiveCloseButton).toBeInTheDocument());
fireEvent.click(responsiveCloseButton);
expect(toggleNotificationTray).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import NotificationIcon from './NotificationIcon';
import messages from './messages';
function NotificationTrigger({ intl, toggleNotificationTray, isNotificationTrayVisible }) {
return (
<button
className={classNames('notification-trigger-btn', { active: isNotificationTrayVisible() })}
type="button"
onClick={() => { toggleNotificationTray(); }}
aria-label={intl.formatMessage(messages.openNotificationTrigger)}
>
{/* REV-2297 TODO: add logic for status "active" if red dot should display */}
<NotificationIcon status="inactive" notificationColor="bg-danger-500" />
</button>
);
}
NotificationTrigger.propTypes = {
intl: intlShape.isRequired,
toggleNotificationTray: PropTypes.func.isRequired,
isNotificationTrayVisible: PropTypes.func.isRequired,
};
export default injectIntl(NotificationTrigger);

View File

@@ -1,4 +1,4 @@
.sidebar-notification-btn {
.notification-trigger-btn {
border: 1px solid $light-400;
background: none;
margin-top: 0.625rem;

View File

@@ -0,0 +1,43 @@
import React from 'react';
import { Factory } from 'rosie';
import {
render, initializeTestStore, screen, fireEvent,
} from '../../setupTest';
import NotificationTrigger from './NotificationTrigger';
describe('Notification Trigger', () => {
let mockData;
const courseMetadata = Factory.build('courseMetadata');
beforeAll(async () => {
await initializeTestStore({ courseMetadata, excludeFetchCourse: true, excludeFetchSequence: true });
mockData = {
toggleNotificationTray: () => {},
isNotificationTrayVisible: () => {},
};
});
it('renders notification trigger with icon', async () => {
const { container } = render(<NotificationTrigger {...mockData} />);
expect(container).toBeInTheDocument();
const buttonIcon = container.querySelectorAll('svg');
expect(buttonIcon).toHaveLength(1);
// REV-2297 TODO: update below test once the status=active or inactive is implemented
// expect(screen.getByTestId('notification-dot')).toBeInTheDocument();
});
it('handles onClick event toggling the notification tray', async () => {
const toggleNotificationTray = jest.fn();
const testData = {
...mockData,
toggleNotificationTray,
};
render(<NotificationTrigger {...testData} />);
const notificationOpenButton = screen.getByRole('button', { name: /Show notification tray/i });
expect(notificationOpenButton).toBeInTheDocument();
fireEvent.click(notificationOpenButton);
expect(toggleNotificationTray).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,51 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Icon } from '@edx/paragon';
import { ArrowBackIos, Close } from '@edx/paragon/icons';
import messages from './messages';
import useWindowSize, { responsiveBreakpoints } from '../../generic/tabs/useWindowSize';
function Sidebar({
intl, toggleSidebar,
}) {
const shouldDisplayFullScreen = useWindowSize().width < responsiveBreakpoints.large.minWidth;
// REV-2130 TODO: temporary variable set to true, should be replaced with
// whether the course can be upgraded (ie. shouldDisplayUpgradeNotification)
const shouldDisplayNoNotification = true;
return (
<section className={classNames('sidebar-container ml-0 ml-lg-4', { 'no-notification': shouldDisplayNoNotification && !shouldDisplayFullScreen })} aria-label={intl.formatMessage(messages.sidebarNotification)}>
{shouldDisplayFullScreen ? (
<div className="mobile-close-container" onClick={() => { toggleSidebar(); }} onKeyDown={() => { toggleSidebar(); }} role="button" tabIndex="0" alt={intl.formatMessage(messages.responsiveCloseSidebar)}>
<Icon src={ArrowBackIos} />
<span className="mobile-close">{intl.formatMessage(messages.responsiveCloseSidebar)}</span>
</div>
) : null}
<div className="sidebar-header px-3">
<span>{intl.formatMessage(messages.notificationTitle)}</span>
{shouldDisplayFullScreen
? null
: <Icon src={Close} className="close-btn" onClick={() => { toggleSidebar(); }} onKeyDown={() => { toggleSidebar(); }} role="button" tabIndex="0" alt={intl.formatMessage(messages.closeSidebarButton)} />}
</div>
<div className="sidebar-divider" />
<div className="sidebar-content">
{/* REV-2130 TODO: replace logic to display upgrade expiration box if condition is true */}
{shouldDisplayNoNotification ? <p>{intl.formatMessage(messages.noNotificationsMessage)}</p> : null}
</div>
</section>
);
}
Sidebar.propTypes = {
intl: intlShape.isRequired,
toggleSidebar: PropTypes.func,
};
Sidebar.defaultProps = {
toggleSidebar: null,
};
export default injectIntl(Sidebar);

View File

@@ -1,58 +0,0 @@
import React from 'react';
import { Factory } from 'rosie';
import {
render, initializeTestStore, screen, fireEvent, waitFor,
} from '../../setupTest';
import Sidebar from './Sidebar';
import useWindowSize from '../../generic/tabs/useWindowSize';
jest.mock('../../generic/tabs/useWindowSize');
describe('Sidebar', () => {
let mockData;
const courseMetadata = Factory.build('courseMetadata');
beforeEach(async () => {
mockData = {
toggleSidebar: () => {},
};
});
beforeAll(async () => {
await initializeTestStore({ courseMetadata, excludeFetchCourse: true, excludeFetchSequence: true });
});
it('renders sidebar', async () => {
useWindowSize.mockReturnValue({ width: 1200, height: 422 });
const { container } = render(<Sidebar {...mockData} />);
expect(container).toBeInTheDocument();
expect(container).toHaveTextContent('Notifications');
expect(container).not.toHaveTextContent('Back to course');
});
it('renders no notifications message', async () => {
// REV-2130 TODO: add conditional if no expiration box/upgradeable
const testData = { ...mockData };
const { container } = render(<Sidebar {...testData} />);
expect(container).toBeInTheDocument();
expect(container).toHaveTextContent('You have no new notifications at this time.');
});
it('renders sidebar with full screen "Back to course" at response width', async () => {
useWindowSize.mockReturnValue({ width: 991, height: 422 });
const toggleSidebar = jest.fn();
const testData = {
...mockData,
toggleSidebar,
};
render(<Sidebar {...testData} />);
const responsiveCloseButton = screen.getByRole('button', { name: 'Back to course' });
await waitFor(() => expect(responsiveCloseButton).toBeInTheDocument());
fireEvent.click(responsiveCloseButton);
expect(toggleSidebar).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,29 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import NotificationIcon from './NotificationIcon';
import messages from './messages';
function SidebarNotificationButton({ intl, toggleSidebar, isSidebarVisible }) {
return (
<button
className={classNames('sidebar-notification-btn', { active: isSidebarVisible() })}
type="button"
onClick={() => { toggleSidebar(); }}
aria-label={intl.formatMessage(messages.openSidebarButton)}
>
{/* REV-2130 TODO: add logic for status "active" if red dot should display */}
<NotificationIcon status="active" notificationColor="bg-danger-500" />
</button>
);
}
SidebarNotificationButton.propTypes = {
intl: intlShape.isRequired,
toggleSidebar: PropTypes.func.isRequired,
isSidebarVisible: PropTypes.func.isRequired,
};
export default injectIntl(SidebarNotificationButton);

View File

@@ -1,43 +0,0 @@
import React from 'react';
import { Factory } from 'rosie';
import {
render, initializeTestStore, screen, fireEvent,
} from '../../setupTest';
import SidebarNotificationButton from './SidebarNotificationButton';
describe('Sidebar Notification Button', () => {
let mockData;
const courseMetadata = Factory.build('courseMetadata');
beforeAll(async () => {
await initializeTestStore({ courseMetadata, excludeFetchCourse: true, excludeFetchSequence: true });
mockData = {
toggleSidebar: () => {},
isSidebarVisible: () => {},
};
});
it('renders sidebar notification button with icon', async () => {
const { container } = render(<SidebarNotificationButton {...mockData} />);
expect(container).toBeInTheDocument();
const buttonIcon = container.querySelectorAll('svg');
expect(buttonIcon).toHaveLength(1);
// REV-2130 TODO: update below test once the status=active or inactive is implemented
expect(screen.getByTestId('notification-dot')).toBeInTheDocument();
});
it('handles onClick event toggling the sidebar', async () => {
const toggleSidebar = jest.fn();
const testData = {
...mockData,
toggleSidebar,
};
render(<SidebarNotificationButton {...testData} />);
const sidebarOpenButton = screen.getByRole('button', { name: /Show sidebar notification/i });
expect(sidebarOpenButton).toBeInTheDocument();
fireEvent.click(sidebarOpenButton);
expect(toggleSidebar).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,33 +1,33 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
sidebarNotification: {
id: 'sidebar.notification.container',
defaultMessage: 'Sidebar notification',
description: 'Sidebar notification section container',
notificationTray: {
id: 'notification.tray.container',
defaultMessage: 'Notification tray',
description: 'Notification tray container',
},
openSidebarButton: {
id: 'sidebar.open.button',
defaultMessage: 'Show sidebar notification',
description: 'Button to open the sidebar and show notifications',
openNotificationTrigger: {
id: 'notification.open.button',
defaultMessage: 'Show notification tray',
description: 'Button to open the notification tray and show notifications',
},
closeSidebarButton: {
id: 'sidebar.close.button',
defaultMessage: 'Close sidebar notification',
description: 'Button for the learner to close the sidebar',
closeNotificationTrigger: {
id: 'notification.close.button',
defaultMessage: 'Close notification tray',
description: 'Button to close the notification tray',
},
responsiveCloseSidebar: {
id: 'sidebar.responsive.close.button',
responsiveCloseNotificationTray: {
id: 'responsive.close.notification',
defaultMessage: 'Back to course',
description: 'Responsive button for the learner to go back to course and close the sidebar',
description: 'Responsive button to go back to course and close the notification tray',
},
notificationTitle: {
id: 'sidebar.notification.title',
id: 'notification.tray.title',
defaultMessage: 'Notifications',
description: 'Title text displayed for sidebar notifications',
description: 'Title text displayed for the notification tray',
},
noNotificationsMessage: {
id: 'sidebar.notification.no.message',
id: 'notification.tray.no.message',
defaultMessage: 'You have no new notifications at this time.',
description: 'Text displayed when the learner has no notifications',
},

View File

@@ -22,8 +22,8 @@ import CourseLicense from '../course-license';
import messages from './messages';
import { SequenceNavigation, UnitNavigation } from './sequence-navigation';
import SequenceContent from './SequenceContent';
import Sidebar from '../Sidebar';
import SidebarNotificationButton from '../SidebarNotificationButton';
import NotificationTray from '../NotificationTray';
import NotificationTrigger from '../NotificationTrigger';
/** [MM-P2P] Experiment */
import { isMobile } from '../../../experiments/mm-p2p/utils';
@@ -37,9 +37,9 @@ function Sequence({
nextSequenceHandler,
previousSequenceHandler,
intl,
toggleSidebar,
sidebarVisible,
isSidebarVisible,
toggleNotificationTray,
notificationTrayVisible,
isNotificationTrayVisible,
isValuePropCookieSet,
mmp2p,
}) {
@@ -49,7 +49,7 @@ function Sequence({
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
const specialExamsEnabledWaffleFlag = useSelector(state => state.courseware.specialExamsEnabledWaffleFlag);
const proctoredExamsEnabledWaffleFlag = useSelector(state => state.courseware.proctoredExamsEnabledWaffleFlag);
const shouldDisplaySidebarButton = useWindowSize().width < responsiveBreakpoints.small.minWidth;
const shouldDisplayNotificationTrigger = useWindowSize().width < responsiveBreakpoints.small.minWidth;
const handleNext = () => {
const nextIndex = sequence.unitIds.indexOf(unitId) + 1;
@@ -167,7 +167,7 @@ function Sequence({
const defaultContent = (
<div className="sequence-container" style={{ display: 'inline-flex', flexDirection: 'row' }}>
<div className={classNames('sequence', { 'position-relative': shouldDisplaySidebarButton })} style={{ width: '100%' }}>
<div className={classNames('sequence', { 'position-relative': shouldDisplayNotificationTrigger })} style={{ width: '100%' }}>
<SequenceNavigation
sequenceId={sequenceId}
unitId={unitId}
@@ -192,10 +192,10 @@ function Sequence({
isValuePropCookieSet={isValuePropCookieSet}
/>
{isValuePropCookieSet && shouldDisplaySidebarButton ? (
<SidebarNotificationButton
toggleSidebar={toggleSidebar}
isSidebarVisible={isSidebarVisible}
{isValuePropCookieSet && shouldDisplayNotificationTrigger ? (
<NotificationTrigger
toggleNotificationTray={toggleNotificationTray}
isNotificationTrayVisible={isNotificationTrayVisible}
/>
) : null}
@@ -226,10 +226,10 @@ function Sequence({
)}
</div>
</div>
{sidebarVisible ? (
<Sidebar
toggleSidebar={toggleSidebar}
sidebarVisible={sidebarVisible}
{isValuePropCookieSet && notificationTrayVisible ? (
<NotificationTray
toggleNotificationTray={toggleNotificationTray}
notificationTrayVisible={notificationTrayVisible}
/>
) : null }
@@ -269,9 +269,9 @@ Sequence.propTypes = {
nextSequenceHandler: PropTypes.func.isRequired,
previousSequenceHandler: PropTypes.func.isRequired,
intl: intlShape.isRequired,
toggleSidebar: PropTypes.func,
sidebarVisible: PropTypes.bool,
isSidebarVisible: PropTypes.func,
toggleNotificationTray: PropTypes.func,
notificationTrayVisible: PropTypes.bool,
isNotificationTrayVisible: PropTypes.func,
isValuePropCookieSet: PropTypes.bool,
/** [MM-P2P] Experiment */
@@ -291,9 +291,9 @@ Sequence.propTypes = {
Sequence.defaultProps = {
sequenceId: null,
unitId: null,
toggleSidebar: null,
sidebarVisible: null,
isSidebarVisible: null,
toggleNotificationTray: null,
notificationTrayVisible: null,
isNotificationTrayVisible: null,
isValuePropCookieSet: null,
/** [MM-P2P] Experiment */

View File

@@ -28,7 +28,7 @@ describe('Sequence', () => {
unitNavigationHandler: () => {},
nextSequenceHandler: () => {},
previousSequenceHandler: () => {},
sidebarVisible: false,
notificationTrayVisible: false,
};
});
@@ -130,10 +130,11 @@ describe('Sequence', () => {
expect(screen.getAllByRole('button', { name: /previous|next/i }).length).toEqual(4);
});
it('renders sidebar in sequence', async () => {
it('renders notification tray in sequence', async () => {
const testData = {
...mockData,
sidebarVisible: true,
notificationTrayVisible: true,
isValuePropCookieSet: true,
};
render(<Sequence {...testData} />);

View File

@@ -40,7 +40,7 @@ function SequenceNavigation({
sequence.gatedContent !== undefined && sequence.gatedContent.gated
) : undefined;
const shouldDisplaySidebarButton = useWindowSize().width < responsiveBreakpoints.small.minWidth;
const shouldDisplayNotificationTrigger = useWindowSize().width < responsiveBreakpoints.small.minWidth;
const renderUnitButtons = () => {
if (isLocked) {
@@ -70,15 +70,15 @@ function SequenceNavigation({
const disabled = isLastUnit && !exitActive;
return (
<Button variant="link" className="next-btn" onClick={buttonOnClick} disabled={disabled} iconAfter={ChevronRight}>
{isValuePropCookieSet && shouldDisplaySidebarButton ? null : buttonText}
{isValuePropCookieSet && shouldDisplayNotificationTrigger ? null : buttonText}
</Button>
);
};
return sequenceStatus === LOADED && (
<nav className={classNames('sequence-navigation', className)} style={{ width: isValuePropCookieSet && shouldDisplaySidebarButton ? '90%' : null }}>
<nav className={classNames('sequence-navigation', className)} style={{ width: isValuePropCookieSet && shouldDisplayNotificationTrigger ? '90%' : null }}>
<Button variant="link" className="previous-btn" onClick={previousSequenceHandler} disabled={isFirstUnit} iconBefore={ChevronLeft}>
{isValuePropCookieSet && shouldDisplaySidebarButton ? null : intl.formatMessage(messages.previousButton)}
{isValuePropCookieSet && shouldDisplayNotificationTrigger ? null : intl.formatMessage(messages.previousButton)}
</Button>
{renderUnitButtons()}
{renderNextButton()}

View File

@@ -1,6 +1,7 @@
import { getConfig, camelCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { logInfo } from '@edx/frontend-platform/logging';
import { getTimeOffsetMillis } from '../../course-home/data/api';
import { appendBrowserTimezoneToUrl } from '../../utils';
export function normalizeBlocks(courseId, blocks) {
@@ -141,51 +142,55 @@ function normalizeTabUrls(id, tabs) {
}
function normalizeMetadata(metadata) {
const requestTime = Date.now();
const responseTime = requestTime;
const { data, headers } = metadata;
return {
accessExpiration: camelCaseObject(metadata.access_expiration),
canShowUpgradeSock: metadata.can_show_upgrade_sock,
contentTypeGatingEnabled: metadata.content_type_gating_enabled,
id: metadata.id,
title: metadata.name,
number: metadata.number,
offer: camelCaseObject(metadata.offer),
org: metadata.org,
enrollmentStart: metadata.enrollment_start,
enrollmentEnd: metadata.enrollment_end,
end: metadata.end,
start: metadata.start,
enrollmentMode: metadata.enrollment.mode,
isEnrolled: metadata.enrollment.is_active,
canLoadCourseware: camelCaseObject(metadata.can_load_courseware),
canViewLegacyCourseware: metadata.can_view_legacy_courseware,
originalUserIsStaff: metadata.original_user_is_staff,
isStaff: metadata.is_staff,
license: metadata.license,
verifiedMode: camelCaseObject(metadata.verified_mode),
tabs: normalizeTabUrls(metadata.id, camelCaseObject(metadata.tabs)),
userTimezone: metadata.user_timezone,
showCalculator: metadata.show_calculator,
notes: camelCaseObject(metadata.notes),
marketingUrl: metadata.marketing_url,
celebrations: camelCaseObject(metadata.celebrations),
userHasPassingGrade: metadata.user_has_passing_grade,
courseExitPageIsActive: metadata.course_exit_page_is_active,
certificateData: camelCaseObject(metadata.certificate_data),
verifyIdentityUrl: metadata.verify_identity_url,
verificationStatus: metadata.verification_status,
linkedinAddToProfileUrl: metadata.linkedin_add_to_profile_url,
relatedPrograms: camelCaseObject(metadata.related_programs),
userNeedsIntegritySignature: metadata.user_needs_integrity_signature,
specialExamsEnabledWaffleFlag: metadata.is_mfe_special_exams_enabled,
proctoredExamsEnabledWaffleFlag: metadata.is_mfe_proctored_exams_enabled,
accessExpiration: camelCaseObject(data.access_expiration),
canShowUpgradeSock: data.can_show_upgrade_sock,
contentTypeGatingEnabled: data.content_type_gating_enabled,
id: data.id,
title: data.name,
number: data.number,
offer: camelCaseObject(data.offer),
org: data.org,
enrollmentStart: data.enrollment_start,
enrollmentEnd: data.enrollment_end,
end: data.end,
start: data.start,
enrollmentMode: data.enrollment.mode,
isEnrolled: data.enrollment.is_active,
canLoadCourseware: camelCaseObject(data.can_load_courseware),
canViewLegacyCourseware: data.can_view_legacy_courseware,
originalUserIsStaff: data.original_user_is_staff,
isStaff: data.is_staff,
license: data.license,
verifiedMode: camelCaseObject(data.verified_mode),
tabs: normalizeTabUrls(data.id, camelCaseObject(data.tabs)),
userTimezone: data.user_timezone,
showCalculator: data.show_calculator,
notes: camelCaseObject(data.notes),
marketingUrl: data.marketing_url,
celebrations: camelCaseObject(data.celebrations),
userHasPassingGrade: data.user_has_passing_grade,
courseExitPageIsActive: data.course_exit_page_is_active,
certificateData: camelCaseObject(data.certificate_data),
timeOffsetMillis: getTimeOffsetMillis(headers && headers.date, requestTime, responseTime),
verifyIdentityUrl: data.verify_identity_url,
verificationStatus: data.verification_status,
linkedinAddToProfileUrl: data.linkedin_add_to_profile_url,
relatedPrograms: camelCaseObject(data.related_programs),
userNeedsIntegritySignature: data.user_needs_integrity_signature,
specialExamsEnabledWaffleFlag: data.is_mfe_special_exams_enabled,
proctoredExamsEnabledWaffleFlag: data.is_mfe_proctored_exams_enabled,
};
}
export async function getCourseMetadata(courseId) {
let url = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`;
url = appendBrowserTimezoneToUrl(url);
const { data } = await getAuthenticatedHttpClient().get(url);
return normalizeMetadata(data);
const metadata = await getAuthenticatedHttpClient().get(url);
return normalizeMetadata(metadata);
}
function normalizeSequenceMetadata(sequence) {

View File

@@ -20,9 +20,11 @@ function UpgradeButton(props) {
return (
<Button
variant={variant}
href={url}
onClick={onClick}
size="lg"
variant={variant}
block
{...rest}
>
<FormattedMessage

View File

@@ -1,5 +1,6 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { faCheck } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -7,32 +8,32 @@ import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/ana
import { FormattedDate, FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import { UpgradeButton } from '../../../generic/upgrade-button';
import { UpgradeButton } from '../upgrade-button';
function UpsellNoFBECardContent() {
const verifiedCertLink = (
<a className="inline-link-underline font-weight-bold" rel="noopener noreferrer" target="_blank" href={`${getConfig().MARKETING_SITE_BASE_URL}/verified-certificate`}>
<FormattedMessage
id="learning.outline.widgets.upgradeCard.verifiedCertLink"
id="learning.generic.upgradeNotification.verifiedCertLink"
defaultMessage="verified certificate"
/>
</a>
);
return (
<ul className="fa-ul upgrade-card-ul pt-0">
<ul className="fa-ul upgrade-notification-ul pt-0">
<li>
<span className="fa-li upgrade-card-li"><FontAwesomeIcon icon={faCheck} /></span>
<span className="fa-li upgrade-notification-li"><FontAwesomeIcon icon={faCheck} /></span>
<FormattedMessage
id="learning.outline.widgets.upgradeCard.verifiedCertMessage"
id="learning.generic.upgradeNotification.verifiedCertMessage"
defaultMessage="Earn a {verifiedCertLink} of completion to showcase on your resume"
values={{ verifiedCertLink }}
/>
</li>
<li>
<span className="fa-li upgrade-card-li"><FontAwesomeIcon icon={faCheck} /></span>
<span className="fa-li upgrade-notification-li"><FontAwesomeIcon icon={faCheck} /></span>
<FormattedMessage
id="learning.outline.widgets.upgradeCard.nonProfitMission"
id="learning.generic.upgradeNotification.noFBE.nonProfitMission"
defaultMessage="Support our {nonProfitMission} at edX"
values={{
nonProfitMission: (
@@ -49,7 +50,7 @@ function UpsellFBEFarAwayCardContent() {
const verifiedCertLink = (
<a className="inline-link-underline font-weight-bold" rel="noopener noreferrer" target="_blank" href={`${getConfig().MARKETING_SITE_BASE_URL}/verified-certificate`}>
<FormattedMessage
id="learning.outline.widgets.upgradeCard.verifiedCertLink"
id="learning.generic.upgradeNotification.verifiedCertLink"
defaultMessage="verified certificate"
/>
</a>
@@ -58,7 +59,7 @@ function UpsellFBEFarAwayCardContent() {
const gradedAssignments = (
<span className="font-weight-bold">
<FormattedMessage
id="learning.outline.widgets.upgradeCard.gradedAssignments"
id="learning.generic.upgradeNotification.gradedAssignments"
defaultMessage="graded assignments"
/>
</span>
@@ -67,7 +68,7 @@ function UpsellFBEFarAwayCardContent() {
const fullAccess = (
<span className="font-weight-bold">
<FormattedMessage
id="learning.upgradeCard.verifiedCertLink"
id="learning.generic.upgradeNotification.verifiedCertLink.fullAccess"
defaultMessage="Full access"
/>
</span>
@@ -76,42 +77,42 @@ function UpsellFBEFarAwayCardContent() {
const nonProfitMission = (
<span className="font-weight-bold">
<FormattedMessage
id="learning.upgradeCard.nonProfitMission"
id="learning.generic.upgradeNotification.FBE.nonProfitMission"
defaultMessage="non-profit mission"
/>
</span>
);
return (
<ul className="fa-ul upgrade-card-ul">
<ul className="fa-ul upgrade-notification-ul">
<li>
<span className="fa-li upgrade-card-li"><FontAwesomeIcon icon={faCheck} /></span>
<span className="fa-li upgrade-notification-li"><FontAwesomeIcon icon={faCheck} /></span>
<FormattedMessage
id="learning.outline.widgets.upgradeCard.verifiedCertMessage"
id="learning.generic.upgradeNotification.verifiedCertMessage"
defaultMessage="Earn a {verifiedCertLink} of completion to showcase on your resume"
values={{ verifiedCertLink }}
/>
</li>
<li>
<span className="fa-li upgrade-card-li"><FontAwesomeIcon icon={faCheck} /></span>
<span className="fa-li upgrade-notification-li"><FontAwesomeIcon icon={faCheck} /></span>
<FormattedMessage
id="learning.outline.widgets.upgradeCard.unlockGraded"
id="learning.generic.upgradeNotification.unlockGraded"
defaultMessage="Unlock your access to all course activities, including {gradedAssignments}"
values={{ gradedAssignments }}
/>
</li>
<li>
<span className="fa-li upgrade-card-li"><FontAwesomeIcon icon={faCheck} /></span>
<span className="fa-li upgrade-notification-li"><FontAwesomeIcon icon={faCheck} /></span>
<FormattedMessage
id="learning.outline.widgets.upgradeCard.fullAccess"
id="learning.generic.upgradeNotification.fullAccess"
defaultMessage="{fullAccess} to course content and materials, even after the course ends"
values={{ fullAccess }}
/>
</li>
<li>
<span className="fa-li upgrade-card-li"><FontAwesomeIcon icon={faCheck} /></span>
<span className="fa-li upgrade-notification-li"><FontAwesomeIcon icon={faCheck} /></span>
<FormattedMessage
id="learning.outline.widgets.upgradeCard.nonProfitMission"
id="learning.generic.upgradeNotification.nonProfitMission"
defaultMessage="Support our {nonProfitMission} at edX"
values={{ nonProfitMission }}
/>
@@ -124,7 +125,7 @@ function UpsellFBESoonCardContent({ accessExpirationDate, timezoneFormatArgs })
const includingAnyProgress = (
<span className="font-weight-bold">
<FormattedMessage
id="learning.upgradeCard.expirationAccessLoss.progress"
id="learning.generic.upgradeNotification.expirationAccessLoss.progress"
defaultMessage="including any progress"
/>
</span>
@@ -143,17 +144,17 @@ function UpsellFBESoonCardContent({ accessExpirationDate, timezoneFormatArgs })
const benefitsOfUpgrading = (
<a className="inline-link-underline font-weight-bold" rel="noopener noreferrer" target="_blank" href="https://support.edx.org/hc/en-us/articles/360013426573-What-are-the-differences-between-audit-free-and-verified-paid-courses-">
<FormattedMessage
id="learning.outline.widgets.upgradeCard.expirationVerifiedCert.benefits"
id="learning.generic.upgradeNotification.expirationVerifiedCert.benefits"
defaultMessage="benefits of upgrading"
/>
</a>
);
return (
<div className="upgrade-card-text">
<div className="upgrade-notification-text">
<p>
<FormattedMessage
id="learning.outline.widgets.upgradeCard.expirationAccessLoss"
id="learning.generic.upgradeNotification.expirationAccessLoss"
defaultMessage="You will lose all access to this course, {includingAnyProgress}, on {date}."
values={{
includingAnyProgress,
@@ -163,7 +164,7 @@ function UpsellFBESoonCardContent({ accessExpirationDate, timezoneFormatArgs })
</p>
<p>
<FormattedMessage
id="learning.outline.widgets.upgradeCard.expirationVerifiedCert"
id="learning.generic.upgradeNotification.expirationVerifiedCert"
defaultMessage="Upgrading your course enables you to pursue a verified certificate and unlocks numerous features. Learn more about the {benefitsOfUpgrading}."
values={{ benefitsOfUpgrading }}
/>
@@ -189,7 +190,7 @@ function ExpirationCountdown({ hoursToExpiration }) {
if (hoursToExpiration >= 24) {
expirationText = (
<FormattedMessage
id="learning.outline.widgets.upgradeCard.expirationDays"
id="learning.generic.upgradeNotification.expirationDays"
defaultMessage={`{dayCount, number} {dayCount, plural,
one {day}
other {days}} left`}
@@ -201,7 +202,7 @@ function ExpirationCountdown({ hoursToExpiration }) {
} else if (hoursToExpiration >= 1) {
expirationText = (
<FormattedMessage
id="learning.outline.widgets.upgradeCard.expirationHours"
id="learning.generic.upgradeNotification.expirationHours"
defaultMessage={`{hourCount, number} {hourCount, plural,
one {hour}
other {hours}} left`}
@@ -213,7 +214,7 @@ function ExpirationCountdown({ hoursToExpiration }) {
} else {
expirationText = (
<FormattedMessage
id="learning.outline.widgets.upgradeCard.expirationMinutes"
id="learning.generic.upgradeNotification.expirationMinutes"
defaultMessage="Less than 1 hour left"
/>
);
@@ -229,7 +230,7 @@ function AccessExpirationDateBanner({ accessExpirationDate, timezoneFormatArgs }
return (
<div className="upsell-warning-light">
<FormattedMessage
id="learning.outline.widgets.upgradeCard.expiration"
id="learning.generic.upgradeNotification.expiration"
defaultMessage="Course access will expire {date}"
values={{
date: (
@@ -258,7 +259,7 @@ AccessExpirationDateBanner.defaultProps = {
timezoneFormatArgs: {},
};
function UpgradeCard({
function UpgradeNotification({
accessExpiration,
contentTypeGatingEnabled,
courseId,
@@ -267,6 +268,7 @@ function UpgradeCard({
timeOffsetMillis,
userTimezone,
verifiedMode,
shouldDisplayBorder,
}) {
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
const correctedTime = new Date(Date.now() + timeOffsetMillis);
@@ -311,12 +313,12 @@ function UpgradeCard({
/*
There are 4 parts that change in the upgrade card:
upgradeCardHeaderText
upgradeNotificationHeaderText
expirationBanner
upsellMessage
offerCode
*/
let upgradeCardHeaderText;
let upgradeNotificationHeaderText;
let expirationBanner;
let upsellMessage;
let offerCode;
@@ -329,7 +331,7 @@ function UpgradeCard({
offerCode = (
<div className="text-center discount-info">
<FormattedMessage
id="learning.outline.widgets.upgradeCard.code"
id="learning.generic.upgradeNotification.code"
defaultMessage="Use code {code} at checkout"
values={{
code: (<span className="font-weight-bold">{offer.code}</span>),
@@ -342,9 +344,9 @@ function UpgradeCard({
if (hoursToAccessExpiration >= (7 * 24)) {
if (offer) { // countdown to the first purchase discount if there is one
const hoursToDiscountExpiration = Math.floor((new Date(offer.expirationDate) - correctedTime) / 1000 / 60 / 60);
upgradeCardHeaderText = (
upgradeNotificationHeaderText = (
<FormattedMessage
id="learning.outline.widgets.upgradeCard.firstTimeLearnerDiscount"
id="learning.generic.upgradeNotification.firstTimeLearnerDiscount"
defaultMessage="{percentage}% First-Time Learner Discount"
values={{
percentage: (offer.percentage),
@@ -353,9 +355,9 @@ function UpgradeCard({
);
expirationBanner = <ExpirationCountdown hoursToExpiration={hoursToDiscountExpiration} />;
} else {
upgradeCardHeaderText = (
upgradeNotificationHeaderText = (
<FormattedMessage
id="learning.outline.widgets.upgradeCard.accessExpiration"
id="learning.generic.upgradeNotification.accessExpiration"
defaultMessage="Upgrade your course today"
/>
);
@@ -368,9 +370,9 @@ function UpgradeCard({
}
upsellMessage = <UpsellFBEFarAwayCardContent />;
} else { // more urgent messaging if there's less than 7 days left to access expiration
upgradeCardHeaderText = (
upgradeNotificationHeaderText = (
<FormattedMessage
id="learning.outline.widgets.upgradeCard.accessExpirationUrgent"
id="learning.generic.upgradeNotification.accessExpirationUrgent"
defaultMessage="Course Access Expiration"
/>
);
@@ -383,9 +385,9 @@ function UpgradeCard({
);
}
} else { // FBE is turned off
upgradeCardHeaderText = (
upgradeNotificationHeaderText = (
<FormattedMessage
id="learning.outline.widgets.upgradeCard.pursueAverifiedCertificate"
id="learning.generic.upgradeNotification.pursueAverifiedCertificate"
defaultMessage="Pursue a verified certificate"
/>
);
@@ -393,26 +395,26 @@ function UpgradeCard({
}
return (
<section className="mb-4 card upgrade-card small">
<h2 className="h5 upgrade-card-header" id="outline-sidebar-upgrade-header">
{upgradeCardHeaderText}
<section className={classNames('upgrade-notification small', { 'card mb-4': shouldDisplayBorder })}>
<h2 className="h5 upgrade-notification-header" id="outline-sidebar-upgrade-header">
{upgradeNotificationHeaderText}
</h2>
{expirationBanner}
<div className="upgrade-card-message">
<div className="upgrade-notification-message">
{upsellMessage}
</div>
<UpgradeButton
offer={offer}
onClick={logClick}
verifiedMode={verifiedMode}
className="upgrade-card-button"
className="upgrade-notification-button"
/>
{offerCode}
</section>
);
}
UpgradeCard.propTypes = {
UpgradeNotification.propTypes = {
courseId: PropTypes.string.isRequired,
org: PropTypes.string.isRequired,
accessExpiration: PropTypes.shape({
@@ -431,15 +433,17 @@ UpgradeCard.propTypes = {
price: PropTypes.number.isRequired,
upgradeUrl: PropTypes.string.isRequired,
}),
shouldDisplayBorder: PropTypes.bool,
};
UpgradeCard.defaultProps = {
UpgradeNotification.defaultProps = {
accessExpiration: null,
contentTypeGatingEnabled: false,
offer: null,
timeOffsetMillis: 0,
userTimezone: null,
verifiedMode: null,
shouldDisplayBorder: null,
};
export default injectIntl(UpgradeCard);
export default injectIntl(UpgradeNotification);

View File

@@ -0,0 +1,54 @@
.upgrade-notification {
border-radius: 0 !important;
}
.upgrade-notification-header {
margin: 1.25rem;
}
.upsell-warning {
background-color: $danger-100;
}
.upsell-warning-light {
background-color: $warning-100;
}
.upsell-warning, .upsell-warning-light {
padding: 0.5rem 1.25rem;
}
.upgrade-notification-ul {
margin-left: 3rem;
padding-top: 0.875rem;
padding-right: 1.25rem;
}
.upgrade-notification-li {
left: -2.125rem;
top: 0 !important;
}
.upgrade-notification-text {
padding: 0.875rem 1.25rem 0 1.25rem;
}
.upgrade-notification-button {
width: 90%;
margin: 0 auto;
margin-bottom: 1.25rem;
}
.discount-info {
border-top: 1px solid $light-400;
padding-top: .75rem;
padding-bottom: .75rem;
}
.inline-link-underline {
text-decoration: underline;
}
.upgrade-notification .upgrade-notification-message a {
color: $primary-500;
}

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { Factory } from 'rosie';
import { initializeMockApp, render, screen } from '../../../setupTest';
import UpgradeCard from './UpgradeCard';
import { initializeMockApp, render, screen } from '../../setupTest';
import UpgradeNotification from './UpgradeNotification';
initializeMockApp();
jest.mock('@edx/frontend-platform/analytics');
@@ -11,10 +11,10 @@ jest
.spyOn(global.Date, 'now')
.mockImplementation(() => dateNow.valueOf());
describe('Upgrade Card', () => {
describe('Upgrade Notification', () => {
function buildAndRender(attributes) {
const upgradeCardData = Factory.build('upgradeCardData', { ...attributes });
render(<UpgradeCard {...upgradeCardData} />);
const upgradeNotificationData = Factory.build('upgradeNotificationData', { ...attributes });
render(<UpgradeNotification {...upgradeNotificationData} />);
}
it('does not render when there is no verified mode', async () => {

View File

@@ -372,15 +372,15 @@
// Import component-specific sass files
@import 'courseware/course/celebration/CelebrationModal.scss';
@import 'courseware/course/Sidebar.scss';
@import 'courseware/course/SidebarNotificationButton.scss';
@import 'courseware/course/NotificationTray.scss';
@import 'courseware/course/NotificationTrigger.scss';
@import 'courseware/course/NotificationIcon.scss';
@import 'shared/streak-celebration/StreakCelebrationModal.scss';
@import 'courseware/course/content-tools/calculator/calculator.scss';
@import 'courseware/course/content-tools/contentTools.scss';
@import 'course-home/dates-tab/Badge.scss';
@import 'course-home/dates-tab/Day.scss';
@import 'course-home/outline-tab/widgets/UpgradeCard.scss';
@import 'generic/upgrade-notification/UpgradeNotification.scss';
@import 'course-home/outline-tab/widgets/ProctoringInfoPanel.scss';
@import 'course-home/progress-tab/course-completion/CompletionDonutChart.scss';
@import 'course-home/progress-tab/grades/course-grade/GradeBar.scss';