Skip to content
Closed
5 changes: 5 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"@reduxjs/toolkit": "1.3.6",
"classnames": "2.2.6",
"core-js": "3.6.5",
"js-cookie": "2.2.1",
"lodash.camelcase": "^4.3.0",
"prop-types": "15.7.2",
"react": "16.13.1",
Expand Down
47 changes: 39 additions & 8 deletions src/courseware/course/Course.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React from 'react';
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Helmet } from 'react-helmet';
import { useDispatch } from 'react-redux';
import Cookies from 'js-cookie';
import { getConfig } from '@edx/frontend-platform';

import { AlertList } from '../../generic/user-messages';
Expand All @@ -13,8 +14,11 @@ import Sequence from './sequence';
import { CelebrationModal, shouldCelebrateOnSectionLoad } from './celebration';
import ContentTools from './content-tools';
import CourseBreadcrumbs from './CourseBreadcrumbs';
import SidebarNotificationButton from './SidebarNotificationButton';

import CourseSock from '../../generic/course-sock';
import { useModel } from '../../generic/model-store';
import useWindowSize from '../../generic/tabs/useWindowSize';

/** [MM-P2P] Experiment */
import { initCoursewareMMP2P, MMP2PBlockModal } from '../../experiments/mm-p2p';
Expand Down Expand Up @@ -57,6 +61,19 @@ 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
// the browser console and refresh: document.cookie = 'value_prop_cookie=true';
const isCookieSet = Cookies.get('value_prop_cookie') === 'true';

const shouldDisplaySidebarButton = useWindowSize().width > 575;

const [sidebarVisible, setSidebar] = useState(false);
const isSidebarVisible = () => sidebarVisible && setSidebar;
const toggleSidebar = () => {
if (!sidebarVisible) { setSidebar(true); } else { setSidebar(false); }
};

/** [MM-P2P] Experiment */
const MMP2P = initCoursewareMMP2P(courseId, sequenceId, unitId);

Expand All @@ -76,13 +93,23 @@ function Course({
}}
/>
)}
<CourseBreadcrumbs
courseId={courseId}
sectionId={section ? section.id : null}
sequenceId={sequenceId}
//* * [MM-P2P] Experiment */
mmp2p={MMP2P}
/>
<div className="breadcrumb-container">
<CourseBreadcrumbs
courseId={courseId}
sectionId={section ? section.id : null}
sequenceId={sequenceId}
//* * [MM-P2P] Experiment */
mmp2p={MMP2P}
/>

{ shouldDisplaySidebarButton && isCookieSet ? (
<SidebarNotificationButton
toggleSidebar={toggleSidebar}
isSidebarVisible={isSidebarVisible}
/>
) : null}
</div>

<AlertList topic="sequence" />
<Sequence
unitId={unitId}
Expand All @@ -91,6 +118,10 @@ function Course({
unitNavigationHandler={unitNavigationHandler}
nextSequenceHandler={nextSequenceHandler}
previousSequenceHandler={previousSequenceHandler}
toggleSidebar={toggleSidebar}
isSidebarVisible={isSidebarVisible}
sidebarVisible={sidebarVisible}
isCookieSet={isCookieSet}
//* * [MM-P2P] Experiment */
mmp2p={MMP2P}
/>
Expand Down
26 changes: 26 additions & 0 deletions src/courseware/course/Course.test.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import { Factory } from 'rosie';
import Cookies from 'js-cookie';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import {
loadUnit, render, screen, waitFor, getByRole, initializeTestStore, fireEvent,
Expand All @@ -19,6 +20,7 @@ describe('Course', () => {
nextSequenceHandler: () => {},
previousSequenceHandler: () => {},
unitNavigationHandler: () => {},
toggleSidebar: () => {},
};

beforeAll(async () => {
Expand Down Expand Up @@ -85,6 +87,30 @@ 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();

const cookieName = 'value_prop_cookie';
Cookies.set = jest.fn();
Cookies.get = jest.fn().mockImplementation(() => cookieName);
const getSpy = jest.spyOn(Cookies, 'get').mockReturnValueOnce('true');

const courseMetadata = Factory.build('courseMetadata');
const testStore = await initializeTestStore({ courseMetadata, excludeFetchSequence: true }, false);
const testData = {
...mockData,
toggleSidebar,
isSidebarVisible,
};
render(<Course {...testData} courseId={courseMetadata.id} />, { store: testStore });

const sidebarOpenButton = screen.getByRole('button', { name: /Show sidebar notification/i });

expect(getSpy).toBeCalledWith(cookieName);
expect(sidebarOpenButton).toBeInTheDocument();
});

it('displays offer and expiration alert', async () => {
const courseMetadata = Factory.build('courseMetadata', {
access_expiration: {
Expand Down
3 changes: 2 additions & 1 deletion src/courseware/course/CourseBreadcrumbs.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useMemo } from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
Expand Down Expand Up @@ -60,7 +61,7 @@ export default function CourseBreadcrumbs({
}, [courseStatus, sequenceStatus]);

return (
<nav aria-label="breadcrumb" className="my-4">
<nav aria-label="breadcrumb" className={classNames('my-4 d-inline-block col-sm-10')}>
<ol className="list-unstyled d-flex m-0">
<CourseBreadcrumb
url={`${getConfig().LMS_BASE_URL}/courses/${course.id}/course/`}
Expand Down
27 changes: 27 additions & 0 deletions src/courseware/course/NotificationIcon.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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 { WatchOutline } from '@edx/paragon/icons';

import messages from './messages';

function NotificationIcon({ intl, status, notificationColor }) {
return (
<>
<Icon src={WatchOutline} className="m-0 m-auto" alt={intl.formatMessage(messages.openSidebarButton)} />
{status === 'active'
? <span className={classNames(notificationColor, 'rounded-circle p-1 position-absolute')} style={{ top: '0.3rem', right: '0.55rem' }} />
: null}
</>
);
}

NotificationIcon.propTypes = {
intl: intlShape.isRequired,
status: PropTypes.string.isRequired,
notificationColor: PropTypes.string.isRequired,
};

export default injectIntl(NotificationIcon);
46 changes: 46 additions & 0 deletions src/courseware/course/Sidebar.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Icon } from '@edx/paragon';
import { ArrowBackIos, Close } from '@edx/paragon/icons';
import './Sidebar.scss';
import messages from './messages';
import useWindowSize from '../../generic/tabs/useWindowSize';

function Sidebar({
intl, toggleSidebar,
}) {
const shouldDisplayFullScreen = useWindowSize().width < 992;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reusable variable that can be used here (and other places where the sizes are hardcoded) so you don't have to hardcode the pixel size? Something like media-breakpoint-down(xs) in edx-platform?

@julianajlk julianajlk Apr 14, 2021

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question, there is a way to add to a SCSS file (ie. $grid-breakpoints), but not sure about here, will take a look how to do it in JSX.

return (
<section className="sidebar-container ml-0 ml-lg-4" 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: add conditional here to display expiration box or display below message */}
<p>{intl.formatMessage(messages.noNotificationsMessage)}</p>
</div>
</section>
);
}

Sidebar.propTypes = {
intl: intlShape.isRequired,
toggleSidebar: PropTypes.func,
};

Sidebar.defaultProps = {
toggleSidebar: null,
};

export default injectIntl(Sidebar);
69 changes: 69 additions & 0 deletions src/courseware/course/Sidebar.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
@import "~@edx/brand/paragon/fonts";
@import "~@edx/brand/paragon/variables";
@import "~@edx/paragon/scss/core/core";
@import "~@edx/brand/paragon/overrides";

.sidebar-container {
border: 1px solid $light-400;
border-radius: 4px;
width: 20rem;
vertical-align: top;

@media (max-width: map-get($grid-breakpoints, 'lg')) {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
width: 100%;
height: 100%;
background-color: white;
margin: 0;
border: none;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you use border: transparent instead of border: none, the border will show up as a white box on Windows High Contrast Mode. Sometimes this is useful. I'm not sure if it would help here. It would also change your box size calculation for the width of the borders.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wittjeff Would it be useful to have the "transparent" border if it's on a full width screen (the sidebar takes up the entire screen)?

border-radius: 0;
}
}

.sidebar-header {
padding: 0.625rem 0;

span {
display: inline-block;
}
}

.close-btn {
float: right;
}

.sidebar-divider {
width: 100.5%;
height: 0.5rem;
background: $gray-100;
border: 1px solid $light-400;
border-left: 0;
}

.sidebar-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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you use the font-weight-bold class from https://paragon-edx.netlify.app/foundations/typography/?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because I have a separate SCSS file, I'm generally choosing not to add utility classes inline but add all CSS to the file to keep it contained. Except for responsive padding/margin, which are simpler to do than adding media queries.

margin-left: 1.2rem;
}
58 changes: 58 additions & 0 deletions src/courseware/course/Sidebar.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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);
});
});
Loading