Compare commits

..

1 Commits

Author SHA1 Message Date
SundasNoreen
4e80ca73a0 fix: fixed height issue 2023-07-18 13:27:13 +05:00
65 changed files with 5605 additions and 6230 deletions

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v3
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup Nodejs Env - name: Setup Nodejs Env

View File

@@ -10,4 +10,4 @@ on:
jobs: jobs:
version-check: version-check:
uses: openedx/.github/.github/workflows/lockfile-check.yml@master uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master

View File

@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v3
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup Nodejs Env - name: Setup Nodejs Env

View File

@@ -2,42 +2,22 @@
frontend-component-header frontend-component-header
######################### #########################
|license| |Build Status| |Codecov| |npm_version| |npm_downloads| |semantic-release| |Build Status| |Codecov| |npm_version| |npm_downloads| |license| |semantic-release|
******** ********
Purpose Overview
******** ********
A generic header for Open edX micro-frontend applications. A generic header for Open edX micro-frontend applications.
************ ************
Getting Started
************
Prerequisites
=============
The `devstack`_ is currently recommended as a development environment for your
new MFE. If you start it with ``make dev.up.lms`` that should give you
everything you need as a companion to this frontend.
Note that it is also possible to use `Tutor`_ to develop an MFE. You can refer
to the `relevant tutor-mfe documentation`_ to get started using it.
.. _Devstack: https://github.com/openedx/devstack
.. _Tutor: https://github.com/overhangio/tutor
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
Requirements Requirements
============ ************
This component uses ``@edx/frontend-platform`` services such as i18n, analytics, configuration, and the ``AppContext`` React component, and expects that it has been loaded into a micro-frontend that has been properly initialized via ``@edx/frontend-platform``'s ``initialize`` function. `Please visit the frontend template application to see an example. <https://github.com/openedx/frontend-template-application/blob/master/src/index.jsx>`_ This component uses ``@edx/frontend-platform`` services such as i18n, analytics, configuration, and the ``AppContext`` React component, and expects that it has been loaded into a micro-frontend that has been properly initialized via ``@edx/frontend-platform``'s ``initialize`` function. `Please visit the frontend template application to see an example. <https://github.com/openedx/frontend-template-application/blob/master/src/index.jsx>`_
Environment Variables Environment Variables
==================== =====================
* ``LMS_BASE_URL`` - The URL of the LMS of your Open edX instance. * ``LMS_BASE_URL`` - The URL of the LMS of your Open edX instance.
* ``LOGOUT_URL`` - The URL of the API endpoint which performs a user logout. * ``LOGOUT_URL`` - The URL of the API endpoint which performs a user logout.
@@ -52,8 +32,9 @@ Environment Variables
menu items when truthy. This is intended to be used in micro-frontends like menu items when truthy. This is intended to be used in micro-frontends like
frontend-app-authentication in which these menus are considered distractions from the user's task. frontend-app-authentication in which these menus are considered distractions from the user's task.
************
Installation Installation
============ ************
To install this header into your Open edX micro-frontend, run the following command in your MFE: To install this header into your Open edX micro-frontend, run the following command in your MFE:
@@ -61,33 +42,9 @@ To install this header into your Open edX micro-frontend, run the following comm
This will make the component available to be imported into your application. This will make the component available to be imported into your application.
Cloning and Startup *****
===================
.. code-block::
1. Clone your new repo:
``git clone https://github.com/openedx/frontend-component-header.git``
2. Use node v18.x.
The current version of the micro-frontend build scripts support node 18.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes an .nvmrc file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
3. Install npm dependencies:
``cd frontend-component-header && npm ci``
4. Start the dev server:
``npm start``
Usage Usage
===== *****
This library has the following exports: This library has the following exports:
@@ -101,8 +58,10 @@ Examples
* `An example of component and messages usage. <https://github.com/openedx/frontend-template-application/blob/3355bb3a96232390e9056f35b06ffa8f105ed7ca/src/index.jsx#L21>`_ * `An example of component and messages usage. <https://github.com/openedx/frontend-template-application/blob/3355bb3a96232390e9056f35b06ffa8f105ed7ca/src/index.jsx#L21>`_
* `An example of SCSS file usage. <https://github.com/openedx/frontend-template-application/blob/3cd5485bf387b8c479baf6b02bf59e3061dc3465/src/index.scss#L8>`_ * `An example of SCSS file usage. <https://github.com/openedx/frontend-template-application/blob/3cd5485bf387b8c479baf6b02bf59e3061dc3465/src/index.scss#L8>`_
***********
Development Development
=========== ***********
Install dependencies:: Install dependencies::
@@ -116,63 +75,6 @@ Build a production distribution::
npm run build npm run build
License
=======
The code in this repository is licensed under the AGPLv3 unless otherwise
noted.
Please see `LICENSE <LICENSE>`_ for details.
Contributing
============
Contributions are very welcome. Please read `How To Contribute`_ for details.
.. _How To Contribute: https://openedx.org/r/how-to-contribute
This project is currently accepting all types of contributions, bug fixes,
security fixes, maintenance work, or new features. However, please make sure
to have a discussion about your new feature idea with the maintainers prior to
beginning development to maximize the chances of your change being accepted.
You can start a conversation by creating a new issue on this repo summarizing
your idea.
Getting Help
===========
If you're having trouble, we have discussion forums at
https://discuss.openedx.org where you can connect with others in the community.
Our real-time conversations are on Slack. You can request a `Slack
invitation`_, then join our `community Slack workspace`_. Because this is a
frontend repository, the best place to discuss it would be in the `#wg-frontend
channel`_.
For anything non-trivial, the best path is to open an issue in this repository
with as many details about the issue you are facing as you can provide.
https://github.com/openedx/frontend-component-header/issues
For more information about these options, see the `Getting Help`_ page.
.. _Slack invitation: https://openedx.org/slack
.. _community Slack workspace: https://openedx.slack.com/
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
.. _Getting Help: https://openedx.org/community/connect
The Open edX Code of Conduct
============================
All community members are expected to follow the `Open edX Code of Conduct`_.
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
Reporting Security Issues
=========================
Please do not report security issues in public. Please email security@openedx.org.
.. |Build Status| image:: https://api.travis-ci.com/edx/frontend-component-header.svg?branch=master .. |Build Status| image:: https://api.travis-ci.com/edx/frontend-component-header.svg?branch=master
:target: https://travis-ci.com/edx/frontend-component-header :target: https://travis-ci.com/edx/frontend-component-header
.. |Codecov| image:: https://img.shields.io/codecov/c/github/edx/frontend-component-header .. |Codecov| image:: https://img.shields.io/codecov/c/github/edx/frontend-component-header

View File

@@ -4,7 +4,8 @@ import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { initialize, getConfig, subscribe, APP_READY } from '@edx/frontend-platform'; import { initialize, getConfig, subscribe, APP_READY } from '@edx/frontend-platform';
import { AppContext, AppProvider } from '@edx/frontend-platform/react'; import { AppContext, AppProvider } from '@edx/frontend-platform/react';
import Header from '@edx/frontend-component-header'; // import Header from '@edx/frontend-component-header';
import { LearningHeader as Header } from '@edx/frontend-component-header';
import './index.scss'; import './index.scss';

7772
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -35,40 +35,47 @@
"devDependencies": { "devDependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.2.0", "@edx/brand": "npm:@edx/brand-openedx@1.2.0",
"@edx/browserslist-config": "^1.1.1", "@edx/browserslist-config": "^1.1.1",
"@edx/frontend-build": "12.9.17", "@edx/frontend-build": "12.8.61",
"@edx/frontend-platform": "5.5.4", "@edx/frontend-platform": "4.6.0",
"@edx/reactifex": "^2.1.1", "@edx/reactifex": "^2.1.1",
"@testing-library/dom": "9.3.3", "@testing-library/dom": "9.3.1",
"@testing-library/jest-dom": "5.17.0", "@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "10.4.9", "@testing-library/react": "10.4.9",
"@wojtekmaj/enzyme-adapter-react-17": "0.8.0", "@wojtekmaj/enzyme-adapter-react-17": "0.8.0",
"enzyme": "3.11.0", "enzyme": "3.11.0",
"husky": "8.0.3", "husky": "8.0.3",
"jest": "29.7.0", "jest": "29.6.1",
"jest-chain": "1.1.6", "jest-chain": "1.1.6",
"prop-types": "15.8.1", "prop-types": "15.8.1",
"react": "17.0.2", "react": "17.0.2",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-redux": "7.2.9", "react-redux": "7.2.9",
"react-router-dom": "6.16.0", "react-router-dom": "5.3.4",
"react-test-renderer": "17.0.2", "react-test-renderer": "17.0.2",
"redux": "4.2.1", "redux": "4.2.1",
"redux-saga": "1.2.3" "redux-saga": "1.2.3"
}, },
"dependencies": { "dependencies": {
"@edx/paragon": "21.5.3", "@edx/paragon": "20.45.0",
"@fortawesome/fontawesome-svg-core": "6.4.2", "@fortawesome/fontawesome-svg-core": "6.4.0",
"@fortawesome/free-brands-svg-icons": "6.4.2", "@fortawesome/free-brands-svg-icons": "6.4.0",
"@fortawesome/free-regular-svg-icons": "6.4.2", "@fortawesome/free-regular-svg-icons": "6.4.0",
"@fortawesome/free-solid-svg-icons": "6.4.2", "@fortawesome/free-solid-svg-icons": "6.4.0",
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@reduxjs/toolkit": "1.9.5",
"axios-mock-adapter": "1.21.5", "axios-mock-adapter": "1.21.5",
"babel-polyfill": "6.26.0", "babel-polyfill": "6.26.0",
"classnames": "2.3.2",
"lodash": "4.17.21",
"react-redux": "7.2.9",
"react-responsive": "8.2.0", "react-responsive": "8.2.0",
"react-transition-group": "4.4.5" "react-router-dom": "5.3.4",
"react-transition-group": "4.4.5",
"rosie": "2.1.0",
"timeago.js": "4.0.2"
}, },
"peerDependencies": { "peerDependencies": {
"@edx/frontend-platform": "^4.0.0 || ^5.0.0", "@edx/frontend-platform": "^4.0.0",
"prop-types": "^15.5.10", "prop-types": "^15.5.10",
"react": "^16.9.0 || ^17.0.0", "react": "^16.9.0 || ^17.0.0",
"react-dom": "^16.9.0 || ^17.0.0" "react-dom": "^16.9.0 || ^17.0.0"

View File

@@ -24,7 +24,7 @@
"automerge": true "automerge": true
}, },
{ {
"matchPackagePatterns": ["@edx", "@openedx"], "matchPackagePatterns": ["@edx"],
"matchUpdateTypes": ["minor", "patch"], "matchUpdateTypes": ["minor", "patch"],
"automerge": true "automerge": true
} }

View File

@@ -2,24 +2,38 @@
import React from 'react'; import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import TestRenderer from 'react-test-renderer'; import TestRenderer from 'react-test-renderer';
import { AppContext } from '@edx/frontend-platform/react'; import { AppContext, AppProvider } from '@edx/frontend-platform/react';
import { Context as ResponsiveContext } from 'react-responsive'; import { Context as ResponsiveContext } from 'react-responsive';
import { initializeMockApp } from '@edx/frontend-platform';
import store from './store';
import Header from './index'; import Header from './index';
const HeaderComponent = ({ width, contextValue }) => ( const HeaderComponent = ({ width, contextValue }) => (
<ResponsiveContext.Provider value={width}> <ResponsiveContext.Provider value={width}>
<IntlProvider locale="en" messages={{}}> <IntlProvider locale="en" messages={{}}>
<AppContext.Provider <AppProvider store={store}>
value={contextValue} <AppContext.Provider
> value={contextValue}
<Header /> >
</AppContext.Provider> <Header />
</AppContext.Provider>
</AppProvider>
</IntlProvider> </IntlProvider>
</ResponsiveContext.Provider> </ResponsiveContext.Provider>
); );
describe('<Header />', () => { describe('<Header />', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: '123abc',
username: 'testuser',
administrator: false,
roles: [],
},
});
});
it('renders correctly for anonymous desktop', () => { it('renders correctly for anonymous desktop', () => {
const contextValue = { const contextValue = {
authenticatedUser: null, authenticatedUser: null,

View File

@@ -0,0 +1,79 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import PropTypes from 'prop-types';
import { Icon } from '@edx/paragon';
import * as timeago from 'timeago.js';
import { getIconByType } from './utils';
import { markNotificationsAsRead } from './data/thunks';
import messages from './messages';
import timeLocale from '../common/time-locale';
const NotificationRowItem = ({
id, type, contentUrl, content, courseName, createdAt, lastRead,
}) => {
timeago.register('time-locale', timeLocale);
const intl = useIntl();
const dispatch = useDispatch();
const handleMarkAsRead = useCallback(() => {
dispatch(markNotificationsAsRead(id));
}, [dispatch, id]);
const { icon: iconComponent, class: iconClass } = getIconByType(type);
return (
<a
target="_blank"
className="d-flex mb-2 align-items-center text-decoration-none"
href={contentUrl}
onClick={handleMarkAsRead}
data-testid={`notification-${id}`}
rel="noopener noreferrer"
>
<Icon
src={iconComponent}
className={`${iconClass} mr-4 notification-icon`}
data-testid={`notification-icon-${id}`}
/>
<div className="d-flex w-100" data-testid="notification-contents">
<div className="d-flex align-items-center w-100">
<div className="py-10px w-100 px-0 cursor-pointer">
<span
className="line-height-24 text-gray-700 mb-2 notification-item-content overflow-hidden content"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: content }}
data-testid={`notification-content-${id}`}
/>
<div className="py-0 d-flex">
<span className="font-size-12 text-gray-500 line-height-20">
<span data-testid={`notification-course-${id}`}>{courseName}
</span>
<span className="text-light-700 px-1.5">{intl.formatMessage(messages.fullStop)}</span>
<span data-testid={`notification-created-date-${id}`}> {timeago.format(createdAt, 'time-locale')}
</span>
</span>
</div>
</div>
{!lastRead && (
<div className="d-flex py-1.5 px-1.5 ml-2 cursor-pointer">
<span className="bg-brand-500 rounded unread" data-testid={`unread-notification-${id}`} />
</div>
)}
</div>
</div>
</a>
);
};
NotificationRowItem.propTypes = {
id: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
contentUrl: PropTypes.string.isRequired,
content: PropTypes.node.isRequired,
courseName: PropTypes.string.isRequired,
createdAt: PropTypes.string.isRequired,
lastRead: PropTypes.string.isRequired,
};
export default React.memo(NotificationRowItem);

View File

@@ -0,0 +1,96 @@
import React, { useCallback, useMemo } from 'react';
import { Button, Spinner } from '@edx/paragon';
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import isEmpty from 'lodash/isEmpty';
import messages from './messages';
import NotificationRowItem from './NotificationRowItem';
import { markAllNotificationsAsRead } from './data/thunks';
import {
selectNotificationsByIds, selectPaginationData, selectSelectedAppName, selectNotificationStatus,
} from './data/selectors';
import { splitNotificationsByTime } from './utils';
import { updatePaginationRequest, RequestStatus } from './data/slice';
const NotificationSections = () => {
const intl = useIntl();
const dispatch = useDispatch();
const selectedAppName = useSelector(selectSelectedAppName());
const notificationRequestStatus = useSelector(selectNotificationStatus());
const notifications = useSelector(selectNotificationsByIds(selectedAppName));
const { hasMorePages } = useSelector(selectPaginationData());
const { today = [], earlier = [] } = useMemo(
() => splitNotificationsByTime(notifications),
[notifications],
);
const handleMarkAllAsRead = useCallback(() => {
dispatch(markAllNotificationsAsRead(selectedAppName));
}, [dispatch, selectedAppName]);
const updatePagination = useCallback(() => {
dispatch(updatePaginationRequest());
}, [dispatch]);
const renderNotificationSection = (section, items) => {
if (isEmpty(items)) { return null; }
return (
<div className="pb-2">
<div className="d-flex justify-content-between align-items-center py-10px mb-2">
<span className="text-gray-500 line-height-10">
{section === 'today' && intl.formatMessage(messages.notificationTodayHeading)}
{section === 'earlier' && intl.formatMessage(messages.notificationEarlierHeading)}
</span>
{notifications?.length > 0 && (section === 'earlier' ? today.length === 0 : true) && (
<Button
variant="link"
className="text-info-500 font-size-14 line-height-10 text-decoration-none p-0 border-0"
onClick={handleMarkAllAsRead}
data-testid="mark-all-read"
>
{intl.formatMessage(messages.notificationMarkAsRead)}
</Button>
)}
</div>
{items.map((notification) => (
<NotificationRowItem
key={notification.id}
id={notification.id}
type={notification.type}
contentUrl={notification.contentUrl}
content={notification.content}
courseName={notification.contentContext?.courseName || ''}
createdAt={notification.createdAt}
lastRead={notification.lastRead}
/>
))}
</div>
);
};
return (
<div className="mt-4 px-4" data-testid="notification-tray-section">
{renderNotificationSection('today', today)}
{renderNotificationSection('earlier', earlier)}
{hasMorePages && notificationRequestStatus === RequestStatus.IN_PROGRESS ? (
<div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" />
</div>
) : (hasMorePages && notificationRequestStatus === RequestStatus.SUCCESSFUL
&& (
<Button
variant="primary"
className="w-100 bg-primary-500"
onClick={updatePagination}
data-testid="load-more-notifications"
>
{intl.formatMessage(messages.loadMoreNotifications)}
</Button>
)
)}
</div>
);
};
export default React.memo(NotificationSections);

View File

@@ -0,0 +1,53 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useCallback, useEffect, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Tab, Tabs } from '@edx/paragon';
import NotificationSections from './NotificationSections';
import { fetchNotificationList, markNotificationsAsSeen } from './data/thunks';
import {
selectNotificationTabs, selectNotificationTabsCount, selectPaginationData, selectSelectedAppName,
} from './data/selectors';
import { updateAppNameRequest } from './data/slice';
const NotificationTabs = () => {
const dispatch = useDispatch();
const selectedAppName = useSelector(selectSelectedAppName());
const notificationUnseenCounts = useSelector(selectNotificationTabsCount());
const notificationTabs = useSelector(selectNotificationTabs());
const { currentPage } = useSelector(selectPaginationData());
useEffect(() => {
dispatch(fetchNotificationList({ appName: selectedAppName, page: currentPage }));
if (selectedAppName) { dispatch(markNotificationsAsSeen(selectedAppName)); }
}, [currentPage, selectedAppName]);
const handleActiveTab = useCallback((appName) => {
dispatch(updateAppNameRequest({ appName }));
}, []);
const tabArray = useMemo(() => notificationTabs?.map((appName) => (
<Tab
key={appName}
eventKey={appName}
title={appName}
notification={notificationUnseenCounts[appName]}
tabClassName="pt-0 pb-10px px-2.5 d-flex border-top-0 mb-0 align-items-center line-height-24 text-capitalize"
data-testid={`notification-tab-${appName}`}
>
{appName === selectedAppName && (<NotificationSections />)}
</Tab>
)), [notificationUnseenCounts, selectedAppName, notificationTabs]);
return (
<Tabs
variant="tabs"
defaultActiveKey={selectedAppName}
onSelect={handleActiveTab}
className="px-2.5 text-primary-500"
>
{tabArray}
</Tabs>
);
};
export default React.memo(NotificationTabs);

View File

@@ -0,0 +1 @@
import './notifications.factory';

View File

@@ -0,0 +1,31 @@
import { Factory } from 'rosie';
Factory.define('notificationsCount')
.attr('count', 45)
.attr('countByAppName', {
reminders: 10,
discussion: 20,
grades: 10,
authoring: 5,
})
.attr('showNotificationsTray', true);
Factory.define('notification')
.sequence('id')
.attr('type', 'post')
.sequence('content', ['id'], (idx, notificationId) => `<p><strong>User ${idx}</strong> posts <strong>Hello and welcome to SC0x
${notificationId}!</strong></p>`)
.attr('course_name', 'Supply Chain Analytics')
.sequence('content_url', (idx) => `https://example.com/${idx}`)
.attr('last_read', null)
.attr('last_seen', null)
.sequence('created_at', ['createdDate'], (idx, date) => date);
Factory.define('notificationsList')
.attr('next', null)
.attr('previous', null)
.attr('count', null, 2)
.attr('num_pages', null, 1)
.attr('current_page', null, 1)
.attr('start', null, 0)
.attr('results', ['results'], (results) => results || Factory.buildList('notification', 2, null, { createdDate: new Date().toISOString() }));

View File

@@ -0,0 +1,39 @@
import { getConfig, snakeCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
export const getNotificationsCountApiUrl = () => `${getConfig().LMS_BASE_URL}/api/notifications/count/`;
export const getNotificationsListApiUrl = () => `${getConfig().LMS_BASE_URL}/api/notifications/`;
export const markNotificationsSeenApiUrl = (appName) => `${getConfig().LMS_BASE_URL}/api/notifications/mark-seen/${appName}/`;
export const markNotificationAsReadApiUrl = () => `${getConfig().LMS_BASE_URL}/api/notifications/read/`;
export async function getNotificationsList(appName, page) {
const params = snakeCaseObject({ appName, page });
const { data } = await getAuthenticatedHttpClient().get(getNotificationsListApiUrl(), { params });
return data;
}
export async function getNotificationCounts() {
const { data } = await getAuthenticatedHttpClient().get(getNotificationsCountApiUrl());
return data;
}
export async function markNotificationSeen(appName) {
const { data } = await getAuthenticatedHttpClient().put(`${markNotificationsSeenApiUrl(appName)}`);
return data;
}
export async function markAllNotificationRead(appName) {
const params = snakeCaseObject({ appName });
const { data } = await getAuthenticatedHttpClient().patch(markNotificationAsReadApiUrl(), params);
return data;
}
export async function markNotificationRead(notificationId) {
const params = snakeCaseObject({ notificationId });
const { data } = await getAuthenticatedHttpClient().patch(markNotificationAsReadApiUrl(), params);
return { data, id: notificationId };
}

View File

@@ -0,0 +1,147 @@
import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { initializeMockApp } from '@edx/frontend-platform/testing';
import {
getNotificationsListApiUrl, getNotificationsCountApiUrl, markNotificationAsReadApiUrl, markNotificationsSeenApiUrl,
getNotificationCounts, getNotificationsList, markNotificationSeen, markAllNotificationRead, markNotificationRead,
} from './api';
import './__factories__';
const notificationCountsApiUrl = getNotificationsCountApiUrl();
const notificationsApiUrl = getNotificationsListApiUrl();
const markedAllNotificationsAsSeenApiUrl = markNotificationsSeenApiUrl('discussion');
const markedAllNotificationsAsReadApiUrl = markNotificationAsReadApiUrl();
let axiosMock = null;
describe('Notifications API', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: '123abc',
username: 'testuser',
administrator: false,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
Factory.resetAll();
});
afterEach(() => {
axiosMock.reset();
});
it('Successfully get notification counts for different tabs.', async () => {
axiosMock.onGet(notificationCountsApiUrl).reply(200, (Factory.build('notificationsCount')));
const { count, countByAppName } = await getNotificationCounts();
expect(count).toEqual(45);
expect(countByAppName.reminders).toEqual(10);
expect(countByAppName.discussion).toEqual(20);
expect(countByAppName.grades).toEqual(10);
expect(countByAppName.authoring).toEqual(5);
});
it.each([
{ statusCode: 404, message: 'Failed to get notification counts.' },
{ statusCode: 403, message: 'Denied to get notification counts.' },
])('%s for notification counts API.', async ({ statusCode, message }) => {
axiosMock.onGet(notificationCountsApiUrl).reply(statusCode, { message });
try {
await getNotificationCounts();
} catch (error) {
expect(error.response.status).toEqual(statusCode);
expect(error.response.data.message).toEqual(message);
}
});
it('Successfully get notifications.', async () => {
axiosMock.onGet(notificationsApiUrl).reply(200, (Factory.build('notificationsList')));
const notifications = await getNotificationsList('discussion', 1);
expect(notifications.results).toHaveLength(2);
});
it.each([
{ statusCode: 404, message: 'Failed to get notifications.' },
{ statusCode: 403, message: 'Denied to get notifications.' },
])('%s for notification API.', async ({ statusCode, message }) => {
axiosMock.onGet(notificationsApiUrl).reply(statusCode, { message });
try {
await getNotificationsList('discussion', 1);
} catch (error) {
expect(error.response.status).toEqual(statusCode);
expect(error.response.data.message).toEqual(message);
}
});
it('Successfully marked all notifications as seen for selected app.', async () => {
axiosMock.onPut(markedAllNotificationsAsSeenApiUrl).reply(200, { message: 'Notifications marked seen.' });
const { message } = await markNotificationSeen('discussion');
expect(message).toEqual('Notifications marked seen.');
});
it.each([
{ statusCode: 404, message: 'Failed to mark all notifications as seen for selected app.' },
{ statusCode: 403, message: 'Denied to mark all notifications as seen for selected app.' },
])('%s for notification mark as seen API.', async ({ statusCode, message }) => {
axiosMock.onPut(markedAllNotificationsAsSeenApiUrl).reply(statusCode, { message });
try {
await markNotificationSeen('discussion');
} catch (error) {
expect(error.response.status).toEqual(statusCode);
expect(error.response.data.message).toEqual(message);
}
});
it('Successfully marked all notifications as read for selected app.', async () => {
axiosMock.onPatch(markedAllNotificationsAsReadApiUrl).reply(200, { message: 'Notifications marked read.' });
const { message } = await markAllNotificationRead('discussion');
expect(message).toEqual('Notifications marked read.');
});
it.each([
{ statusCode: 404, message: 'Failed to mark all notifications as read for selected app.' },
{ statusCode: 403, message: 'Denied to mark all notifications as read for selected app.' },
])('%s for notification mark all as read API.', async ({ statusCode, message }) => {
axiosMock.onPatch(markedAllNotificationsAsReadApiUrl).reply(statusCode, { message });
try {
await markAllNotificationRead('discussion');
} catch (error) {
expect(error.response.status).toEqual(statusCode);
expect(error.response.data.message).toEqual(message);
}
});
it('Successfully marked notification as read.', async () => {
axiosMock.onPatch(markedAllNotificationsAsReadApiUrl).reply(200, { message: 'Notification marked read.' });
const { data } = await markNotificationRead(1);
expect(data.message).toEqual('Notification marked read.');
});
it.each([
{ statusCode: 404, message: 'Failed to mark notification as read.' },
{ statusCode: 403, message: 'Denied to mark notification as read.' },
])('%s for notification mark as read API.', async ({ statusCode, message }) => {
axiosMock.onPatch(markedAllNotificationsAsReadApiUrl).reply(statusCode, { message });
try {
await markAllNotificationRead(1);
} catch (error) {
expect(error.response.status).toEqual(statusCode);
expect(error.response.data.message).toEqual(message);
}
});
});

View File

@@ -0,0 +1,11 @@
import { breakpoints, useWindowSize } from '@edx/paragon';
export function useIsOnMediumScreen() {
const windowSize = useWindowSize();
return breakpoints.large.maxWidth > windowSize.width && windowSize.width >= breakpoints.medium.minWidth;
}
export function useIsOnLargeScreen() {
const windowSize = useWindowSize();
return windowSize.width >= breakpoints.extraLarge.minWidth;
}

View File

@@ -0,0 +1 @@
export * from './slice';

View File

@@ -0,0 +1,134 @@
{
"data": [
{
"id": 1,
"type": "post",
"content": "<p><b>SCM_Lead</b> posts <b>Hello and welcome to SC0x!</b></p>",
"course_name": "Supply Chain Analytics",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:46:11.979531Z"
},
{
"id": 2,
"type": "help",
"content": "<p><b>MITx_Learner</b> asked <b>What grade does a student need to get in order to pass the course and earn a certificate?</b></p>",
"course_name": "Supply Chain Analytics",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:36:11.979531Z"
},
{
"id": 3,
"type": "post",
"content": "<p><b>SCM_Lead</b> posts <b>Hello and welcome to SC0x!</b></p>",
"course_name": "Supply Chain Analytics",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:46:11.979531Z"
},
{
"id": 4,
"type": "respond",
"content": "<p><b>MITx_Learner</b> responded <b>Can't find linear regression in section 3 review</b></p>",
"course_name": "Supply Chain Analytics",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:36:11.979531Z"
},
{
"id": 5,
"type": "comment",
"content": "<p><b>MITx_Learner</b> commented on <b>MITx_Expert's</b> response on a post your following <b>Can't find linear regression in section 3 review</b></p>",
"course_name": "Supply Chain Analytics",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:36:11.979531Z"
},
{
"id": 6,
"type": "question",
"content": "<p><b>MITx_Learner</b> commented <b>Examples of quadratic equations in supply chains</b></p>",
"course_name": "Supply Chain Analytics",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:36:11.979531Z"
},
{
"id": 7,
"type": "answer",
"content": "<p><b>MITx_Expert</b> answered <b>Examples of quadratic equations in supply chains</b></p>",
"course_name": "Supply Chain Analytics",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-05T00:36:11.979531Z"
},
{
"id": 8,
"type": "comment",
"content": "<p><b>MITx_Learner</b> commented <b>Examples of quadratic equations in supply chains</b></p>",
"course_name": "Supply Chain Analytics",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:36:11.979531Z"
},
{
"id": 9,
"type": "comment",
"content": "<p><b>MITx_Learner</b> commented on <b>MITx_Expert's</b>what grade does a student need to get in order to pass the course and earn a certificate?</b></p>",
"course_name": "Supply Chain Analytics",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:36:11.979531Z"
},
{
"id": 10,
"type": "comment",
"content": "<p><b>MITx_Learner</b> commented on your response in <b>Convexity of f(x)=1/x , x>1</b></p>",
"course_name": "Supply Chain Analytics",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:36:11.979531Z"
},
{
"id": 11,
"type": "answer",
"content": "<p><b>SCM_Leads</b> response has been marked as answer in your post <b>Quiz in section 3 - Please explain the F-Significance value</b></p>",
"course_name": "Supply Chain Analytics",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:36:11.979531Z"
},
{
"id": 12,
"type": "endorsed",
"content": "<p>Your response has been endorsed in <b>Quiz in section 3 - Please explain the F-Significance value</b></p>",
"course_name": "Supply Chain Analytics",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:36:11.979531Z"
},
{
"id": 13,
"type": "reported",
"content": "<p><b>MITx Learners</b> post has been reported <b>“Here are the exam answers. Question 1 - CSA stands for Compliance Safety Ac...”</b></p>",
"course_name": "Supply Chain Analytics",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:36:11.979531Z"
}
]
}

View File

@@ -0,0 +1,155 @@
import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { initializeMockApp } from '@edx/frontend-platform/testing';
import { initializeStore } from '../../store';
import executeThunk from '../../test-utils';
import mockNotificationsResponse from '../test-utils';
import {
getNotificationsListApiUrl, getNotificationsCountApiUrl, markNotificationAsReadApiUrl, markNotificationsSeenApiUrl,
} from './api';
import {
fetchAppsNotificationCount, fetchNotificationList, markNotificationsAsRead, markAllNotificationsAsRead,
resetNotificationState, markNotificationsAsSeen,
} from './thunks';
import './__factories__';
const notificationCountsApiUrl = getNotificationsCountApiUrl();
const notificationsListApiUrl = getNotificationsListApiUrl();
const markedAllNotificationsAsReadApiUrl = markNotificationAsReadApiUrl();
const markedAllNotificationsAsSeenApiUrl = markNotificationsSeenApiUrl('discussion');
let axiosMock;
let store;
describe('Notification Redux', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: '123abc',
username: 'testuser',
administrator: false,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
Factory.resetAll();
store = initializeStore();
({ store, axiosMock } = await mockNotificationsResponse());
});
afterEach(() => {
axiosMock.reset();
});
it('Successfully loaded initial notification states in the redux.', async () => {
executeThunk(resetNotificationState(), store.dispatch, store.getState);
const { notifications } = store.getState();
expect(notifications.notificationStatus).toEqual('idle');
expect(notifications.appName).toEqual('discussion');
expect(notifications.appsId).toHaveLength(0);
expect(notifications.apps).toEqual({});
expect(notifications.notifications).toEqual({});
expect(notifications.tabsCount).toEqual({});
expect(notifications.showNotificationsTray).toEqual(false);
expect(notifications.pagination).toEqual({});
});
it('Successfully loaded notifications list in the redux.', async () => {
const { notifications: { notifications } } = store.getState();
expect(Object.keys(notifications)).toHaveLength(10);
});
it.each([
{ statusCode: 404, status: 'failed' },
{ statusCode: 403, status: 'denied' },
])('%s to load notifications list in the redux.', async ({ statusCode, status }) => {
axiosMock.onGet(notificationsListApiUrl).reply(statusCode);
await executeThunk(fetchNotificationList({ page: 1 }), store.dispatch, store.getState);
const { notifications: { notificationStatus } } = store.getState();
expect(notificationStatus).toEqual(status);
});
it('Successfully loaded notification counts in the redux.', async () => {
const { notifications: { tabsCount } } = store.getState();
expect(tabsCount.count).toEqual(25);
expect(tabsCount.reminders).toEqual(10);
expect(tabsCount.discussion).toEqual(0);
expect(tabsCount.grades).toEqual(10);
expect(tabsCount.authoring).toEqual(5);
});
it.each([
{ statusCode: 404, status: 'failed' },
{ statusCode: 403, status: 'denied' },
])('%s to load notification counts in the redux.', async ({ statusCode, status }) => {
axiosMock.onGet(notificationCountsApiUrl).reply(statusCode);
await executeThunk(fetchAppsNotificationCount(), store.dispatch, store.getState);
const { notifications: { notificationStatus } } = store.getState();
expect(notificationStatus).toEqual(status);
});
it('Successfully marked all notifications as seen for selected app.', async () => {
axiosMock.onPut(markedAllNotificationsAsSeenApiUrl).reply(200);
await executeThunk(markNotificationsAsSeen('discussion'), store.dispatch, store.getState);
expect(store.getState().notifications.notificationStatus).toEqual('successful');
});
it.each([
{ statusCode: 404, status: 'failed' },
{ statusCode: 403, status: 'denied' },
])('%s to mark all notifications as seen for selected app.', async ({ statusCode, status }) => {
axiosMock.onPut(markedAllNotificationsAsSeenApiUrl).reply(statusCode);
await executeThunk(markNotificationsAsSeen('discussion'), store.dispatch, store.getState);
const { notifications: { notificationStatus } } = store.getState();
expect(notificationStatus).toEqual(status);
});
it('Successfully marked all notifications as read for selected app in the redux.', async () => {
axiosMock.onPatch(markedAllNotificationsAsReadApiUrl).reply(200);
await executeThunk(markAllNotificationsAsRead('discussion'), store.dispatch, store.getState);
const { notifications: { notificationStatus, notifications } } = store.getState();
const firstNotification = Object.values(notifications)[0];
expect(notificationStatus).toEqual('successful');
expect(firstNotification.lastRead).not.toBeNull();
});
it('Successfully marked notification as read in the redux.', async () => {
axiosMock.onPatch(markedAllNotificationsAsReadApiUrl).reply(200);
await executeThunk(markNotificationsAsRead(1), store.dispatch, store.getState);
const { notifications: { notificationStatus, notifications } } = store.getState();
const firstNotification = Object.values(notifications)[0];
expect(notificationStatus).toEqual('successful');
expect(firstNotification.lastRead).not.toBeNull();
});
it.each([
{ statusCode: 404, status: 'failed' },
{ statusCode: 403, status: 'denied' },
])('%s to marked notification as read in the redux.', async ({ statusCode, status }) => {
axiosMock.onPatch(markedAllNotificationsAsReadApiUrl).reply(statusCode);
await executeThunk(markNotificationsAsRead(1), store.dispatch, store.getState);
const { notifications: { notificationStatus } } = store.getState();
expect(notificationStatus).toEqual(status);
});
});

View File

@@ -0,0 +1,115 @@
import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { initializeMockApp } from '@edx/frontend-platform/testing';
import { initializeStore } from '../../store';
import mockNotificationsResponse from '../test-utils';
import {
selectNotifications,
selectNotificationsByIds,
selectNotificationStatus,
selectNotificationTabs,
selectNotificationTabsCount,
selectPaginationData,
selectSelectedAppName,
selectSelectedAppNotificationIds,
selectShowNotificationTray,
} from './selectors';
import './__factories__';
let axiosMock;
let store;
describe('Notification Selectors', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: '123abc',
username: 'testuser',
administrator: false,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
Factory.resetAll();
store = initializeStore();
({ store, axiosMock } = await mockNotificationsResponse());
});
afterEach(() => {
axiosMock.reset();
});
it('Should return notification status.', async () => {
const state = store.getState();
const status = selectNotificationStatus()(state);
expect(status).toEqual('successful');
});
it('Should return notification tabs count.', async () => {
const state = store.getState();
const tabsCount = selectNotificationTabsCount()(state);
expect(tabsCount.count).toEqual(25);
expect(tabsCount.reminders).toEqual(10);
expect(tabsCount.discussion).toEqual(0);
expect(tabsCount.grades).toEqual(10);
expect(tabsCount.authoring).toEqual(5);
});
it('Should return notification tabs.', async () => {
const state = store.getState();
const tabs = selectNotificationTabs()(state);
expect(tabs).toHaveLength(4);
});
it('Should return selected app notification ids.', async () => {
const state = store.getState();
const notificationIds = selectSelectedAppNotificationIds('discussion')(state);
expect(notificationIds).toHaveLength(10);
});
it('Should return show notification tray status.', async () => {
const state = store.getState();
const showNotificationTrayStatus = selectShowNotificationTray()(state);
expect(showNotificationTrayStatus).toEqual(true);
});
it('Should return notifications.', async () => {
const state = store.getState();
const notifications = selectNotifications()(state);
expect(Object.keys(notifications)).toHaveLength(10);
});
it('Should return notifications from Ids.', async () => {
const state = store.getState();
const notifications = selectNotificationsByIds('discussion')(state);
expect(notifications).toHaveLength(10);
});
it('Should return selected app name.', async () => {
const state = store.getState();
const appName = selectSelectedAppName()(state);
expect(appName).toEqual('discussion');
});
it('Should return pagination data.', async () => {
const state = store.getState();
const paginationData = selectPaginationData()(state);
expect(paginationData.currentPage).toEqual(1);
expect(paginationData.numPages).toEqual(2);
expect(paginationData.hasMorePages).toEqual(true);
});
});

View File

@@ -0,0 +1,23 @@
import { createSelector } from '@reduxjs/toolkit';
export const selectNotificationStatus = () => state => state.notifications.notificationStatus;
export const selectNotificationTabsCount = () => state => state.notifications.tabsCount;
export const selectNotificationTabs = () => state => state.notifications.appsId;
export const selectSelectedAppNotificationIds = (appName) => state => state.notifications.apps[appName] ?? [];
export const selectShowNotificationTray = () => state => state.notifications.showNotificationsTray;
export const selectNotifications = () => state => state.notifications.notifications;
export const selectNotificationsByIds = (appName) => createSelector(
selectNotifications(),
selectSelectedAppNotificationIds(appName),
(notifications, notificationIds) => notificationIds.map((notificationId) => notifications[notificationId]) || [],
);
export const selectSelectedAppName = () => state => state.notifications.appName;
export const selectPaginationData = () => state => state.notifications.pagination;

View File

@@ -0,0 +1,147 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
export const RequestStatus = {
IDLE: 'idle',
IN_PROGRESS: 'in-progress',
SUCCESSFUL: 'successful',
FAILED: 'failed',
DENIED: 'denied',
};
const initialState = {
notificationStatus: RequestStatus.IDLE,
appName: 'discussion',
appsId: [],
apps: {},
notifications: {},
tabsCount: {},
showNotificationsTray: false,
pagination: {},
};
const slice = createSlice({
name: 'notifications',
initialState,
reducers: {
fetchNotificationDenied: (state) => {
state.notificationStatus = RequestStatus.DENIED;
},
fetchNotificationFailure: (state) => {
state.notificationStatus = RequestStatus.FAILED;
},
fetchNotificationRequest: (state) => {
state.notificationStatus = RequestStatus.IN_PROGRESS;
},
fetchNotificationSuccess: (state, { payload }) => {
const {
newNotificationIds, notificationsKeyValuePair, pagination,
} = payload;
const existingNotificationIds = state.apps[state.appName];
state.apps[state.appName] = Array.from(new Set([...existingNotificationIds, ...newNotificationIds]));
state.notifications = { ...state.notifications, ...notificationsKeyValuePair };
state.tabsCount.count -= state.tabsCount[state.appName];
state.tabsCount[state.appName] = 0;
state.notificationStatus = RequestStatus.SUCCESSFUL;
state.pagination = pagination;
},
fetchNotificationsCountDenied: (state) => {
state.notificationStatus = RequestStatus.DENIED;
},
fetchNotificationsCountFailure: (state) => {
state.notificationStatus = RequestStatus.FAILED;
},
fetchNotificationsCountRequest: (state) => {
state.notificationStatus = RequestStatus.IN_PROGRESS;
},
fetchNotificationsCountSuccess: (state, { payload }) => {
const {
countByAppName, appIds, apps, count, showNotificationsTray,
} = payload;
state.tabsCount = { count, ...countByAppName };
state.appsId = appIds;
state.apps = apps;
state.showNotificationsTray = showNotificationsTray;
state.notificationStatus = RequestStatus.SUCCESSFUL;
},
markNotificationsAsSeenRequest: (state) => {
state.notificationStatus = RequestStatus.IN_PROGRESS;
},
markNotificationsAsSeenSuccess: (state) => {
state.notificationStatus = RequestStatus.SUCCESSFUL;
},
markNotificationsAsSeenDenied: (state) => {
state.notificationStatus = RequestStatus.DENIED;
},
markNotificationsAsSeenFailure: (state) => {
state.notificationStatus = RequestStatus.FAILED;
},
markAllNotificationsAsReadRequest: (state) => {
state.notificationStatus = RequestStatus.IN_PROGRESS;
},
markAllNotificationsAsReadSuccess: (state) => {
const updatedNotifications = Object.fromEntries(
Object.entries(state.notifications).map(([key, notification]) => [
key, { ...notification, lastRead: new Date().toISOString() },
]),
);
state.notifications = updatedNotifications;
state.notificationStatus = RequestStatus.SUCCESSFUL;
},
markAllNotificationsAsReadDenied: (state) => {
state.notificationStatus = RequestStatus.DENIED;
},
markAllNotificationsAsReadFailure: (state) => {
state.notificationStatus = RequestStatus.FAILED;
},
markNotificationsAsReadRequest: (state) => {
state.notificationStatus = RequestStatus.IN_PROGRESS;
},
markNotificationsAsReadSuccess: (state, { payload }) => {
const date = new Date().toISOString();
state.notifications[payload.id] = { ...state.notifications[payload.id], lastRead: date };
state.notificationStatus = RequestStatus.SUCCESSFUL;
},
markNotificationsAsReadDenied: (state) => {
state.notificationStatus = RequestStatus.DENIED;
},
markNotificationsAsReadFailure: (state) => {
state.notificationStatus = RequestStatus.FAILED;
},
resetNotificationStateRequest: () => initialState,
updateAppNameRequest: (state, { payload }) => {
state.appName = payload.appName;
state.pagination.currentPage = 1;
},
updatePaginationRequest: (state) => {
state.pagination.currentPage += 1;
},
},
});
export const {
fetchNotificationDenied,
fetchNotificationFailure,
fetchNotificationRequest,
fetchNotificationSuccess,
fetchNotificationsCountDenied,
fetchNotificationsCountFailure,
fetchNotificationsCountRequest,
fetchNotificationsCountSuccess,
markNotificationsAsSeenRequest,
markNotificationsAsSeenSuccess,
markNotificationsAsSeenFailure,
markNotificationsAsSeenDenied,
markAllNotificationsAsReadDenied,
markAllNotificationsAsReadRequest,
markAllNotificationsAsReadSuccess,
markAllNotificationsAsReadFailure,
markNotificationsAsReadDenied,
markNotificationsAsReadRequest,
markNotificationsAsReadSuccess,
markNotificationsAsReadFailure,
resetNotificationStateRequest,
updateAppNameRequest,
updatePaginationRequest,
} = slice.actions;
export const notificationsReducer = slice.reducer;

View File

@@ -0,0 +1,135 @@
import { camelCaseObject } from '@edx/frontend-platform';
import {
fetchNotificationSuccess,
fetchNotificationRequest,
fetchNotificationFailure,
fetchNotificationDenied,
fetchNotificationsCountFailure,
fetchNotificationsCountRequest,
fetchNotificationsCountSuccess,
fetchNotificationsCountDenied,
markNotificationsAsSeenRequest,
markNotificationsAsSeenSuccess,
markNotificationsAsSeenFailure,
markNotificationsAsSeenDenied,
markNotificationsAsReadDenied,
resetNotificationStateRequest,
markAllNotificationsAsReadRequest,
markAllNotificationsAsReadSuccess,
markAllNotificationsAsReadFailure,
markAllNotificationsAsReadDenied,
markNotificationsAsReadRequest,
markNotificationsAsReadSuccess,
markNotificationsAsReadFailure,
} from './slice';
import {
getNotificationsList, getNotificationCounts, markNotificationSeen, markAllNotificationRead, markNotificationRead,
} from './api';
import { getHttpErrorStatus } from '../utils';
const normalizeNotificationCounts = ({ countByAppName, count, showNotificationsTray }) => {
const appIds = Object.keys(countByAppName);
const apps = appIds.reduce((acc, appId) => { acc[appId] = []; return acc; }, {});
return {
countByAppName, appIds, apps, count, showNotificationsTray,
};
};
const normalizeNotifications = (data) => {
const newNotificationIds = data.results.map(notification => notification.id.toString());
const notificationsKeyValuePair = data.results.reduce((acc, obj) => { acc[obj.id] = obj; return acc; }, {});
const pagination = {
numPages: data.numPages,
currentPage: data.currentPage,
hasMorePages: !!data.next,
};
return {
newNotificationIds, notificationsKeyValuePair, pagination,
};
};
export const fetchNotificationList = ({ appName, page }) => (
async (dispatch) => {
try {
dispatch(fetchNotificationRequest({ appName }));
const data = await getNotificationsList(appName, page);
const normalisedData = normalizeNotifications((camelCaseObject(data)));
dispatch(fetchNotificationSuccess({ ...normalisedData }));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {
dispatch(fetchNotificationDenied(appName));
} else {
dispatch(fetchNotificationFailure(appName));
}
}
}
);
export const fetchAppsNotificationCount = () => (
async (dispatch) => {
try {
dispatch(fetchNotificationsCountRequest());
const data = await getNotificationCounts();
const normalisedData = normalizeNotificationCounts((camelCaseObject(data)));
dispatch(fetchNotificationsCountSuccess({ ...normalisedData }));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {
dispatch(fetchNotificationsCountDenied());
} else {
dispatch(fetchNotificationsCountFailure());
}
}
}
);
export const markAllNotificationsAsRead = (appName) => (
async (dispatch) => {
try {
dispatch(markAllNotificationsAsReadRequest({ appName }));
const data = await markAllNotificationRead(appName);
dispatch(markAllNotificationsAsReadSuccess(camelCaseObject(data)));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {
dispatch(markAllNotificationsAsReadDenied());
} else {
dispatch(markAllNotificationsAsReadFailure());
}
}
}
);
export const markNotificationsAsRead = (notificationId) => (
async (dispatch) => {
try {
dispatch(markNotificationsAsReadRequest({ notificationId }));
const data = await markNotificationRead(notificationId);
dispatch(markNotificationsAsReadSuccess(camelCaseObject(data)));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {
dispatch(markNotificationsAsReadDenied());
} else {
dispatch(markNotificationsAsReadFailure());
}
}
}
);
export const markNotificationsAsSeen = (appName) => (
async (dispatch) => {
try {
dispatch(markNotificationsAsSeenRequest({ appName }));
const data = await markNotificationSeen(appName);
dispatch(markNotificationsAsSeenSuccess(camelCaseObject(data)));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {
dispatch(markNotificationsAsSeenDenied());
} else {
dispatch(markNotificationsAsSeenFailure());
}
}
}
);
export const resetNotificationState = () => (
async (dispatch) => { dispatch(resetNotificationStateRequest()); }
);

111
src/Notifications/index.jsx Normal file
View File

@@ -0,0 +1,111 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, {
useCallback, useEffect, useRef, useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import classNames from 'classnames';
import {
Badge, Icon, IconButton, OverlayTrigger, Popover,
} from '@edx/paragon';
import { NotificationsNone, Settings } from '@edx/paragon/icons';
import { selectNotificationTabsCount } from './data/selectors';
import { resetNotificationState } from './data/thunks';
import { useIsOnLargeScreen, useIsOnMediumScreen } from './data/hook';
import NotificationTabs from './NotificationTabs';
import messages from './messages';
const Notifications = () => {
const intl = useIntl();
const dispatch = useDispatch();
const popoverRef = useRef(null);
const buttonRef = useRef(null);
const [enableNotificationTray, setEnableNotificationTray] = useState(false);
const notificationCounts = useSelector(selectNotificationTabsCount());
const isOnMediumScreen = useIsOnMediumScreen();
const isOnLargeScreen = useIsOnLargeScreen();
const hideNotificationTray = useCallback(() => {
setEnableNotificationTray(prevState => !prevState);
}, []);
const handleClickOutsideNotificationTray = useCallback((event) => {
if (!popoverRef.current?.contains(event.target) && !buttonRef.current?.contains(event.target)) {
setEnableNotificationTray(false);
}
}, []);
useEffect(() => {
document.addEventListener('mousedown', handleClickOutsideNotificationTray);
return () => {
document.removeEventListener('mousedown', handleClickOutsideNotificationTray);
dispatch(resetNotificationState());
};
}, []);
const viewPortHeight = window.innerHeight;
const headerHeight = document.getElementsByClassName('learning-header');
let notificationBarHeight = 0;
if (headerHeight.length > 0) {
notificationBarHeight = viewPortHeight - headerHeight[0].clientHeight;
}
return (
<OverlayTrigger
trigger="click"
key="bottom"
placement="bottom"
id="notificationTray"
show={enableNotificationTray}
overlay={(
<Popover
id="notificationTray"
style={{ height: `${notificationBarHeight}px` }}
data-testid="notification-tray"
className={classNames('overflow-auto rounded-0 border-0', {
'w-100': !isOnMediumScreen && !isOnLargeScreen,
'medium-screen': isOnMediumScreen,
'large-screen': isOnLargeScreen,
})}
>
<div ref={popoverRef}>
<Popover.Title as="h2" className="d-flex justify-content-between p-0 m-4 border-0 text-primary-500 font-size-18 line-height-24">
{intl.formatMessage(messages.notificationTitle)}
<Icon src={Settings} className="icon-size-20" data-testid="setting-icon" />
</Popover.Title>
<Popover.Content className="notification-content p-0">
<NotificationTabs />
</Popover.Content>
</div>
</Popover>
)}
>
<div ref={buttonRef}>
<IconButton
isActive={enableNotificationTray}
alt="notification bell icon"
onClick={hideNotificationTray}
src={NotificationsNone}
iconAs={Icon}
variant="light"
iconClassNames="text-primary-500"
className="ml-4 mr-1 my-3 notification-button"
data-testid="notification-bell-icon"
/>
{notificationCounts?.count > 0 && (
<Badge
pill
variant="danger"
className="font-weight-normal px-1 notification-badge"
data-testid="notification-count"
>
{notificationCounts.count}
</Badge>
)}
</div>
</OverlayTrigger>
);
};
export default Notifications;

View File

@@ -0,0 +1,109 @@
import React from 'react';
import {
act, fireEvent, render, screen, waitFor,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { Context as ResponsiveContext } from 'react-responsive';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppContext, AppProvider } from '@edx/frontend-platform/react';
import AuthenticatedUserDropdown from '../learning-header/AuthenticatedUserDropdown';
import { initializeStore } from '../store';
import executeThunk from '../test-utils';
import { getNotificationsCountApiUrl } from './data/api';
import { fetchAppsNotificationCount } from './data/thunks';
import './data/__factories__';
const notificationCountsApiUrl = getNotificationsCountApiUrl();
let axiosMock;
let store;
function renderComponent() {
render(
<ResponsiveContext.Provider>
<IntlProvider locale="en" messages={{}}>
<AppProvider store={store}>
<AppContext.Provider>
<AuthenticatedUserDropdown />
</AppContext.Provider>
</AppProvider>
</IntlProvider>
</ResponsiveContext.Provider>,
);
}
describe('Notification test cases.', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: '123abc',
username: 'testuser',
administrator: false,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
Factory.resetAll();
store = initializeStore();
});
async function setupMockNotificationCountResponse(count = 45, showNotificationsTray = true) {
axiosMock.onGet(notificationCountsApiUrl)
.reply(200, (Factory.build('notificationsCount', { count, showNotificationsTray })));
await executeThunk(fetchAppsNotificationCount(), store.dispatch, store.getState);
}
it('Successfully showed bell icon and unseen count on it if unseen count is greater then 0.', async () => {
await setupMockNotificationCountResponse();
renderComponent();
const bellIcon = screen.queryByTestId('notification-bell-icon');
const notificationCount = screen.queryByTestId('notification-count');
expect(bellIcon).toBeInTheDocument();
expect(notificationCount).toBeInTheDocument();
expect(screen.queryByText(45)).toBeInTheDocument();
});
it('Successfully showed bell icon and hide unseen count tag when unseen count is zero.', async () => {
await setupMockNotificationCountResponse(0);
renderComponent();
const bellIcon = screen.queryByTestId('notification-bell-icon');
const notificationCount = screen.queryByTestId('notification-count');
expect(bellIcon).toBeInTheDocument();
expect(notificationCount).not.toBeInTheDocument();
});
it('Successfully hides bell icon when showNotificationsTray is false.', async () => {
await setupMockNotificationCountResponse(45, false);
renderComponent();
const bellIcon = screen.queryByTestId('notification-bell-icon');
expect(bellIcon).not.toBeInTheDocument();
});
it('Successfully viewed setting icon and show/hide notification tray by clicking on the bell icon .', async () => {
await setupMockNotificationCountResponse();
renderComponent();
const bellIcon = screen.queryByTestId('notification-bell-icon');
await act(async () => { fireEvent.click(bellIcon); });
expect(screen.queryByTestId('notification-tray')).toBeInTheDocument();
expect(screen.queryByTestId('setting-icon')).toBeInTheDocument();
await act(async () => { fireEvent.click(bellIcon); });
await waitFor(() => expect(screen.queryByTestId('notification-tray')).not.toBeInTheDocument());
});
});

View File

@@ -0,0 +1,36 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
notificationTitle: {
id: 'notification.title',
defaultMessage: 'Notifications',
description: 'Notifications',
},
notificationTodayHeading: {
id: 'notification.today.heading',
defaultMessage: 'Last 24 hours',
description: 'Today Notifications',
},
notificationEarlierHeading: {
id: 'notification.earlier.heading',
defaultMessage: 'Earlier',
description: 'Earlier Notifications',
},
notificationMarkAsRead: {
id: 'notification.mark.as.read',
defaultMessage: 'Mark all as read',
description: 'Mark all Notifications as read',
},
fullStop: {
id: 'notification.fullStop',
defaultMessage: '•',
description: 'Fullstop shown to users to indicate who edited a post.',
},
loadMoreNotifications: {
id: 'notification.load.more.notifications',
defaultMessage: 'Load more notifications',
description: 'Load more button to load more notifications',
},
});
export default messages;

View File

@@ -0,0 +1,87 @@
import React from 'react';
import {
act, fireEvent, render, screen,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { Context as ResponsiveContext } from 'react-responsive';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppContext, AppProvider } from '@edx/frontend-platform/react';
import AuthenticatedUserDropdown from '../learning-header/AuthenticatedUserDropdown';
import { initializeStore } from '../store';
import { markNotificationAsReadApiUrl } from './data/api';
import mockNotificationsResponse from './test-utils';
import './data/__factories__';
const markedNotificationAsReadApiUrl = markNotificationAsReadApiUrl();
let axiosMock;
let store;
function renderComponent() {
render(
<ResponsiveContext.Provider>
<IntlProvider locale="en" messages={{}}>
<AppProvider store={store}>
<AppContext.Provider>
<AuthenticatedUserDropdown />
</AppContext.Provider>
</AppProvider>
</IntlProvider>
</ResponsiveContext.Provider>,
);
}
describe('Notification row item test cases.', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
Factory.resetAll();
store = initializeStore();
({ store, axiosMock } = await mockNotificationsResponse());
});
it(
'Successfully viewed notification icon, notification context, unread , course name and notification time.',
async () => {
renderComponent();
const bellIcon = screen.queryByTestId('notification-bell-icon');
await act(async () => { fireEvent.click(bellIcon); });
expect(screen.queryByTestId('notification-icon-1')).toBeInTheDocument();
expect(screen.queryByTestId('notification-content-1')).toBeInTheDocument();
expect(screen.queryByTestId('notification-course-1')).toBeInTheDocument();
expect(screen.queryByTestId('notification-created-date-1')).toBeInTheDocument();
expect(screen.queryByTestId('unread-notification-1')).toBeInTheDocument();
},
);
it('Successfully marked notification as read.', async () => {
axiosMock.onPatch(markedNotificationAsReadApiUrl).reply(200, { message: 'Notification marked read.' });
renderComponent();
const bellIcon = screen.queryByTestId('notification-bell-icon');
await act(async () => { fireEvent.click(bellIcon); });
const notification = screen.queryByTestId('notification-1');
await act(async () => { fireEvent.click(notification); });
expect(screen.queryByTestId('unread-notification-1')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,106 @@
import React from 'react';
import {
act, fireEvent, render, screen, within,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { Context as ResponsiveContext } from 'react-responsive';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppContext, AppProvider } from '@edx/frontend-platform/react';
import AuthenticatedUserDropdown from '../learning-header/AuthenticatedUserDropdown';
import { initializeStore } from '../store';
import { markNotificationAsReadApiUrl, markNotificationsSeenApiUrl, getNotificationsListApiUrl } from './data/api';
import mockNotificationsResponse from './test-utils';
import { markNotificationsAsSeen, fetchNotificationList } from './data/thunks';
import executeThunk from '../test-utils';
import './data/__factories__';
const markedAllNotificationsAsReadApiUrl = markNotificationAsReadApiUrl();
let axiosMock;
let store;
function renderComponent() {
render(
<ResponsiveContext.Provider>
<IntlProvider locale="en" messages={{}}>
<AppProvider store={store}>
<AppContext.Provider>
<AuthenticatedUserDropdown />
</AppContext.Provider>
</AppProvider>
</IntlProvider>
</ResponsiveContext.Provider>,
);
}
describe('Notification sections test cases.', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
Factory.resetAll();
store = initializeStore();
({ store, axiosMock } = await mockNotificationsResponse());
});
it('Successfully viewed last 24 hours and earlier section along with mark all as read label.', async () => {
renderComponent();
const bellIcon = screen.queryByTestId('notification-bell-icon');
await act(async () => { fireEvent.click(bellIcon); });
const notificationTraySection = screen.queryByTestId('notification-tray-section');
expect(within(notificationTraySection).queryByText('Last 24 hours')).toBeInTheDocument();
expect(within(notificationTraySection).queryByText('Earlier')).toBeInTheDocument();
expect(within(notificationTraySection).queryByText('Mark all as read')).toBeInTheDocument();
});
it('Successfully marked all notifications as read, removing the unread status.', async () => {
axiosMock.onPatch(markedAllNotificationsAsReadApiUrl).reply(200, { message: 'Notifications marked read.' });
renderComponent();
const bellIcon = screen.queryByTestId('notification-bell-icon');
await act(async () => { fireEvent.click(bellIcon); });
const markAllReadButton = screen.queryByTestId('mark-all-read');
expect(screen.queryByTestId('unread-notification-1')).toBeInTheDocument();
await act(async () => { fireEvent.click(markAllReadButton); });
expect(screen.queryByTestId('unread-notification-1')).not.toBeInTheDocument();
});
it('Successfully load more notifications by clicking on load more notification button.', async () => {
axiosMock.onPut(markNotificationsSeenApiUrl('discussion')).reply(200);
await executeThunk(markNotificationsAsSeen('discussions'), store.dispatch, store.getState);
renderComponent();
const bellIcon = screen.queryByTestId('notification-bell-icon');
await act(async () => { fireEvent.click(bellIcon); });
expect(screen.queryAllByTestId('notification-contents')).toHaveLength(10);
const loadMoreButton = screen.queryByTestId('load-more-notifications');
axiosMock.onGet(getNotificationsListApiUrl()).reply(
200,
(Factory.build('notificationsList', { num_pages: 2, current_page: 2 })),
);
await executeThunk(fetchNotificationList({ appName: 'discussion', page: 2 }), store.dispatch, store.getState);
await act(async () => { fireEvent.click(loadMoreButton); });
expect(screen.queryAllByTestId('notification-contents')).toHaveLength(12);
});
});

View File

@@ -0,0 +1,91 @@
import React from 'react';
import {
act, fireEvent, render, screen, within,
} from '@testing-library/react';
import { Context as ResponsiveContext } from 'react-responsive';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppContext, AppProvider } from '@edx/frontend-platform/react';
import AuthenticatedUserDropdown from '../learning-header/AuthenticatedUserDropdown';
import { initializeStore } from '../store';
import mockNotificationsResponse from './test-utils';
import './data/__factories__';
let store;
function renderComponent() {
render(
<ResponsiveContext.Provider>
<IntlProvider locale="en" messages={{}}>
<AppProvider store={store}>
<AppContext.Provider>
<AuthenticatedUserDropdown />
</AppContext.Provider>
</AppProvider>
</IntlProvider>
</ResponsiveContext.Provider>,
);
}
describe('Notification Tabs test cases.', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: '123abc',
username: 'testuser',
administrator: false,
roles: [],
},
});
Factory.resetAll();
store = initializeStore();
({ store } = await mockNotificationsResponse());
});
it('Notification tabs displayed with default discussion tab selected and no unseen counts.', async () => {
renderComponent();
const bellIcon = screen.queryByTestId('notification-bell-icon');
await act(async () => { fireEvent.click(bellIcon); });
const tabs = screen.queryAllByRole('tab');
const selectedTab = tabs.find(tab => tab.getAttribute('aria-selected') === 'true');
expect(tabs.length).toEqual(5);
expect(within(selectedTab).queryByText('discussion')).toBeInTheDocument();
expect(within(selectedTab).queryByRole('status')).not.toBeInTheDocument();
});
it('Successfully showed unseen counts for unselected tabs.', async () => {
renderComponent();
const bellIcon = screen.queryByTestId('notification-bell-icon');
await act(async () => { fireEvent.click(bellIcon); });
const tabs = screen.getAllByRole('tab');
expect(within(tabs[0]).queryByRole('status')).toBeInTheDocument();
});
it('Successfully selected reminder tab.', async () => {
renderComponent();
const bellIcon = screen.queryByTestId('notification-bell-icon');
await act(async () => { fireEvent.click(bellIcon); });
const notificationTab = screen.getAllByRole('tab');
await act(async () => { fireEvent.click(notificationTab[0], { dataset: { rbEventKey: 'reminders' } }); });
const tabs = screen.queryAllByRole('tab');
const selectedTab = tabs.find(tab => tab.getAttribute('aria-selected') === 'true');
expect(within(selectedTab).queryByText('reminders')).toBeInTheDocument();
expect(within(selectedTab).queryByRole('status')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,32 @@
import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { initializeStore } from '../store';
import executeThunk from '../test-utils';
import { getNotificationsListApiUrl, getNotificationsCountApiUrl } from './data/api';
import { fetchAppsNotificationCount, fetchNotificationList } from './data/thunks';
import './data/__factories__';
const notificationCountsApiUrl = getNotificationsCountApiUrl();
const notificationsApiUrl = getNotificationsListApiUrl();
export default async function mockNotificationsResponse() {
const store = initializeStore();
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
const notifications = (Factory.buildList('notification', 8, null, { createdDate: new Date().toISOString() }).concat(
Factory.buildList('notification', 2, null, { createdDate: '2023-06-01T00:46:11.979531Z' }),
));
axiosMock.onGet(notificationCountsApiUrl).reply(200, (Factory.build('notificationsCount')));
axiosMock.onGet(notificationsApiUrl).reply(200, (Factory.build('notificationsList', {
results: notifications,
num_pages: 2,
next: `${notificationsApiUrl}?app_name=discussion&page=2`,
})));
await executeThunk(fetchAppsNotificationCount(), store.dispatch, store.getState);
await executeThunk(fetchNotificationList({ appName: 'discussion', page: 1 }), store.dispatch, store.getState);
return { store, axiosMock };
}

View File

@@ -0,0 +1,52 @@
import {
CheckCircle, HelpOutline, QuestionAnswerOutline, Verified, Report, EditOutline, ThumbUpOutline, PostOutline,
} from '@edx/paragon/icons';
/**
* Get HTTP Error status from generic error.
* @param error Generic caught error.
* @returns {number|null}
*/
export const getHttpErrorStatus = error => error?.customAttributes?.httpErrorStatus ?? error?.response?.status;
export const splitNotificationsByTime = (notificationList) => {
let splittedData = [];
if (notificationList.length > 0) {
const currentTime = Date.now();
const twentyFourHoursAgo = currentTime - (24 * 60 * 60 * 1000);
splittedData = notificationList.reduce(
(result, notification) => {
if (notification) {
const objectTime = new Date(notification.createdAt).getTime();
if (objectTime >= twentyFourHoursAgo && objectTime <= currentTime) {
result.today.push(notification);
} else {
result.earlier.push(notification);
}
}
return result;
},
{ today: [], earlier: [] },
);
}
const { today, earlier } = splittedData;
return { today, earlier };
};
export const getIconByType = (type) => {
const iconMap = {
post: { icon: PostOutline, class: 'text-primary-500' },
help: { icon: HelpOutline, class: 'text-primary-500' },
respond: { icon: QuestionAnswerOutline, class: 'text-primary-500' },
comment: { icon: QuestionAnswerOutline, class: 'text-primary-500' },
question: { icon: QuestionAnswerOutline, class: 'text-primary-500' },
answer: { icon: CheckCircle, class: 'text-success' },
endorsed: { icon: Verified, class: 'text-primary-500' },
reported: { icon: Report, class: 'text-danger-500' },
postLiked: { icon: ThumbUpOutline, class: 'text-primary-500' },
commentLiked: { icon: ThumbUpOutline, class: 'text-primary-500' },
edited: { icon: EditOutline, class: 'text-primary-500' },
};
return iconMap[type] || { icon: PostOutline, class: 'text-primary-500' };
};

200
src/StudioHeader.jsx Normal file
View File

@@ -0,0 +1,200 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
import {
APP_CONFIG_INITIALIZED,
ensureConfig,
getConfig,
mergeConfig,
subscribe,
} from '@edx/frontend-platform';
import { ActionRow } from '@edx/paragon';
import { Menu, MenuTrigger, MenuContent } from './Menu';
import Avatar from './Avatar';
import { LinkedLogo, Logo } from './Logo';
import { CaretIcon } from './Icons';
import messages from './Header.messages';
ensureConfig([
'STUDIO_BASE_URL',
'LOGOUT_URL',
'LOGIN_URL',
'SITE_NAME',
'LOGO_URL',
'ORDER_HISTORY_URL',
], 'StudioHeader component');
subscribe(APP_CONFIG_INITIALIZED, () => {
mergeConfig({
AUTHN_MINIMAL_HEADER: !!process.env.AUTHN_MINIMAL_HEADER,
}, 'StudioHeader additional config');
});
class StudioDesktopHeaderBase extends React.Component {
constructor(props) { // eslint-disable-line no-useless-constructor
super(props);
}
renderUserMenu() {
const {
userMenu,
avatar,
username,
intl,
} = this.props;
return (
<Menu transitionClassName="menu-dropdown" transitionTimeout={250}>
<MenuTrigger
tag="button"
aria-label={intl.formatMessage(messages['header.label.account.menu.for'], { username })}
className="btn btn-outline-primary d-inline-flex align-items-center pl-2 pr-3"
>
<Avatar size="1.5em" src={avatar} alt="" className="mr-2" />
{username} <CaretIcon role="img" aria-hidden focusable="false" />
</MenuTrigger>
<MenuContent className="mb-0 dropdown-menu show dropdown-menu-right pin-right shadow py-2">
{userMenu.map(({ type, href, content }) => (
<a className={`dropdown-${type}`} key={`${type}-${content}`} href={href}>{content}</a>
))}
</MenuContent>
</Menu>
);
}
renderLoggedOutItems() {
const { loggedOutItems } = this.props;
return loggedOutItems.map((item, i, arr) => (
<a
key={`${item.type}-${item.content}`}
className={i < arr.length - 1 ? 'btn mr-2 btn-link' : 'btn mr-2 btn-outline-primary'}
href={item.href}
>
{item.content}
</a>
));
}
render() {
const {
logo,
logoAltText,
logoDestination,
loggedIn,
intl,
actionRowContent,
} = this.props;
const logoProps = { src: logo, alt: logoAltText, href: logoDestination };
const logoClasses = getConfig().AUTHN_MINIMAL_HEADER ? 'mw-100' : null;
return (
<header className="site-header-desktop">
<a className="nav-skip sr-only sr-only-focusable" href="#main">{intl.formatMessage(messages['header.label.skip.nav'])}</a>
<div className={`container-fluid ${logoClasses}`}>
<div className="nav-container position-relative d-flex align-items-center">
{logoDestination === null ? <Logo className="logo" src={logo} alt={logoAltText} /> : <LinkedLogo className="logo" {...logoProps} />}
<ActionRow>
{actionRowContent}
<nav
aria-label={intl.formatMessage(messages['header.label.secondary.nav'])}
className="nav secondary-menu-container align-items-center ml-auto"
>
{loggedIn ? this.renderUserMenu() : this.renderLoggedOutItems()}
</nav>
</ActionRow>
</div>
</div>
</header>
);
}
}
StudioDesktopHeaderBase.propTypes = {
userMenu: PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.oneOf(['item', 'menu']),
href: PropTypes.string,
content: PropTypes.string,
})),
loggedOutItems: PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.oneOf(['item', 'menu']),
href: PropTypes.string,
content: PropTypes.string,
})),
logo: PropTypes.string,
logoAltText: PropTypes.string,
logoDestination: PropTypes.string,
avatar: PropTypes.string,
username: PropTypes.string,
loggedIn: PropTypes.bool,
actionRowContent: PropTypes.element,
// i18n
intl: intlShape.isRequired,
};
StudioDesktopHeaderBase.defaultProps = {
userMenu: [],
loggedOutItems: [],
logo: null,
logoAltText: null,
logoDestination: null,
avatar: null,
username: null,
loggedIn: false,
actionRowContent: null,
};
const StudioDesktopHeader = injectIntl(StudioDesktopHeaderBase);
const StudioHeader = ({ intl, actionRowContent }) => {
const { authenticatedUser, config } = useContext(AppContext);
const userMenu = authenticatedUser === null ? [] : [
{
type: 'item',
href: `${config.STUDIO_BASE_URL}`,
content: intl.formatMessage(messages['header.user.menu.studio.home']),
},
{
type: 'item',
href: `${config.STUDIO_BASE_URL}/maintenance`,
content: intl.formatMessage(messages['header.user.menu.studio.maintenance']),
},
{
type: 'item',
href: config.LOGOUT_URL,
content: intl.formatMessage(messages['header.user.menu.logout']),
},
];
const props = {
logo: config.LOGO_URL,
logoAltText: config.SITE_NAME,
logoDestination: config.STUDIO_BASE_URL,
loggedIn: authenticatedUser !== null,
username: authenticatedUser !== null ? authenticatedUser.username : null,
avatar: authenticatedUser !== null ? authenticatedUser.avatar : null,
actionRowContent,
userMenu,
loggedOutItems: [],
};
return <StudioDesktopHeader {...props} />;
};
StudioHeader.propTypes = {
intl: intlShape.isRequired,
actionRowContent: PropTypes.element,
};
StudioHeader.defaultProps = {
// eslint-disable-next-line react/jsx-no-useless-fragment
actionRowContent: <></>,
};
export default injectIntl(StudioHeader);

108
src/StudioHeader.test.jsx Normal file
View File

@@ -0,0 +1,108 @@
/* eslint-disable react/prop-types */
import React, { useMemo } from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import TestRenderer from 'react-test-renderer';
import { Link } from 'react-router-dom';
import { AppContext } from '@edx/frontend-platform/react';
import {
ActionRow,
Button,
Dropdown,
} from '@edx/paragon';
import { StudioHeader } from './index';
const StudioHeaderComponent = ({ contextValue, appMenu = null, mainMenu = [] }) => (
<IntlProvider locale="en" messages={{}}>
<AppContext.Provider
value={contextValue}
>
<StudioHeader appMenu={appMenu} mainMenu={mainMenu} />
</AppContext.Provider>
</IntlProvider>
);
const StudioHeaderContext = ({ actionRowContent = null }) => {
const headerContextValue = useMemo(() => ({
authenticatedUser: {
userId: 'abc123',
username: 'edX',
roles: [],
administrator: false,
},
config: {
STUDIO_BASE_URL: process.env.STUDIO_BASE_URL,
SITE_NAME: process.env.SITE_NAME,
LOGIN_URL: process.env.LOGIN_URL,
LOGOUT_URL: process.env.LOGOUT_URL,
LOGO_URL: process.env.LOGO_URL,
},
}), []);
return (
<IntlProvider locale="en" messages={{}}>
<AppContext.Provider
value={headerContextValue}
>
<StudioHeader actionRowContent={actionRowContent} />
</AppContext.Provider>
</IntlProvider>
);
};
describe('<StudioHeader />', () => {
it('renders correctly', () => {
const contextValue = {
authenticatedUser: {
userId: 'abc123',
username: 'edX',
roles: [],
administrator: false,
},
config: {
STUDIO_BASE_URL: process.env.STUDIO_BASE_URL,
SITE_NAME: process.env.SITE_NAME,
LOGIN_URL: process.env.LOGIN_URL,
LOGOUT_URL: process.env.LOGOUT_URL,
LOGO_URL: process.env.LOGO_URL,
},
};
const component = <StudioHeaderComponent contextValue={contextValue} />;
const wrapper = TestRenderer.create(component);
expect(wrapper.toJSON()).toMatchSnapshot();
});
it('renders correctly with optional action row content', () => {
const actionRowContent = (
<>
<Dropdown>
<Dropdown.Toggle variant="outline-primary" id="library-header-menu-dropdown">
Settings
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item as={Link} to="#">Dropdown Item 1</Dropdown.Item>
<Dropdown.Item as={Link} to="#">Dropdown Item 2</Dropdown.Item>
<Dropdown.Item as={Link} to="#">Dropdown Item 3</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
<ActionRow.Spacer />
<Button
variant="tertiary"
href="#"
rel="noopener noreferrer"
target="_blank"
title="Help Button"
>Help
</Button>
</>
);
const component = <StudioHeaderContext actionRowContent={actionRowContent} />;
const wrapper = TestRenderer.create(component);
expect(wrapper.toJSON()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,226 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<StudioHeader /> renders correctly 1`] = `
<header
className="site-header-desktop"
>
<a
className="nav-skip sr-only sr-only-focusable"
href="#main"
>
Skip to main content
</a>
<div
className="container-fluid null"
>
<div
className="nav-container position-relative d-flex align-items-center"
>
<img
alt="edX"
className="logo"
src="https://edx-cdn.org/v3/default/logo.svg"
/>
<div
className="pgn__action-row"
>
<nav
aria-label="Secondary"
className="nav secondary-menu-container align-items-center ml-auto"
>
<div
className="menu null"
onKeyDown={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
<button
aria-expanded={false}
aria-haspopup="menu"
aria-label="Account menu for edX"
className="menu-trigger btn btn-outline-primary d-inline-flex align-items-center pl-2 pr-3"
onClick={[Function]}
>
<span
className="avatar overflow-hidden d-inline-flex rounded-circle mr-2"
style={
Object {
"height": "1.5em",
"width": "1.5em",
}
}
>
<svg
aria-hidden={true}
focusable="false"
height="24px"
role="img"
style={
Object {
"height": "1.5em",
"width": "1.5em",
}
}
version="1.1"
viewBox="0 0 24 24"
width="24px"
>
<path
d="M4.10255106,18.1351061 C4.7170266,16.0581859 8.01891846,14.4720277 12,14.4720277 C15.9810815,14.4720277 19.2829734,16.0581859 19.8974489,18.1351061 C21.215206,16.4412566 22,14.3122775 22,12 C22,6.4771525 17.5228475,2 12,2 C6.4771525,2 2,6.4771525 2,12 C2,14.3122775 2.78479405,16.4412566 4.10255106,18.1351061 Z M12,24 C5.372583,24 0,18.627417 0,12 C0,5.372583 5.372583,0 12,0 C18.627417,0 24,5.372583 24,12 C24,18.627417 18.627417,24 12,24 Z M12,13 C9.790861,13 8,11.209139 8,9 C8,6.790861 9.790861,5 12,5 C14.209139,5 16,6.790861 16,9 C16,11.209139 14.209139,13 12,13 Z"
fill="currentColor"
/>
</svg>
</span>
edX
<svg
aria-hidden={true}
focusable="false"
height="16px"
role="img"
version="1.1"
viewBox="0 0 16 16"
width="16px"
>
<path
d="M7,4 L7,8 L11,8 L11,10 L5,10 L5,4 L7,4 Z"
fill="currentColor"
transform="translate(8.000000, 7.000000) rotate(-45.000000) translate(-8.000000, -7.000000) "
/>
</svg>
</button>
</div>
</nav>
</div>
</div>
</div>
</header>
`;
exports[`<StudioHeader /> renders correctly with optional action row content 1`] = `
<header
className="site-header-desktop"
>
<a
className="nav-skip sr-only sr-only-focusable"
href="#main"
>
Skip to main content
</a>
<div
className="container-fluid null"
>
<div
className="nav-container position-relative d-flex align-items-center"
>
<img
alt="edX"
className="logo"
src="https://edx-cdn.org/v3/default/logo.svg"
/>
<div
className="pgn__action-row"
>
<div
className="pgn__dropdown pgn__dropdown-light dropdown"
data-testid="dropdown"
>
<button
aria-expanded={false}
aria-haspopup={true}
className="dropdown-toggle btn btn-outline-primary"
disabled={false}
id="library-header-menu-dropdown"
onClick={[Function]}
type="button"
>
Settings
</button>
</div>
<span
className="pgn__action-row-spacer"
/>
<a
className="btn btn-tertiary"
href="#"
onClick={[Function]}
onKeyDown={[Function]}
rel="noopener noreferrer"
role="button"
target="_blank"
title="Help Button"
>
Help
</a>
<nav
aria-label="Secondary"
className="nav secondary-menu-container align-items-center ml-auto"
>
<div
className="menu null"
onKeyDown={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
<button
aria-expanded={false}
aria-haspopup="menu"
aria-label="Account menu for edX"
className="menu-trigger btn btn-outline-primary d-inline-flex align-items-center pl-2 pr-3"
onClick={[Function]}
>
<span
className="avatar overflow-hidden d-inline-flex rounded-circle mr-2"
style={
Object {
"height": "1.5em",
"width": "1.5em",
}
}
>
<svg
aria-hidden={true}
focusable="false"
height="24px"
role="img"
style={
Object {
"height": "1.5em",
"width": "1.5em",
}
}
version="1.1"
viewBox="0 0 24 24"
width="24px"
>
<path
d="M4.10255106,18.1351061 C4.7170266,16.0581859 8.01891846,14.4720277 12,14.4720277 C15.9810815,14.4720277 19.2829734,16.0581859 19.8974489,18.1351061 C21.215206,16.4412566 22,14.3122775 22,12 C22,6.4771525 17.5228475,2 12,2 C6.4771525,2 2,6.4771525 2,12 C2,14.3122775 2.78479405,16.4412566 4.10255106,18.1351061 Z M12,24 C5.372583,24 0,18.627417 0,12 C0,5.372583 5.372583,0 12,0 C18.627417,0 24,5.372583 24,12 C24,18.627417 18.627417,24 12,24 Z M12,13 C9.790861,13 8,11.209139 8,9 C8,6.790861 9.790861,5 12,5 C14.209139,5 16,6.790861 16,9 C16,11.209139 14.209139,13 12,13 Z"
fill="currentColor"
/>
</svg>
</span>
edX
<svg
aria-hidden={true}
focusable="false"
height="16px"
role="img"
version="1.1"
viewBox="0 0 16 16"
width="16px"
>
<path
d="M7,4 L7,8 L11,8 L11,10 L5,10 L5,4 L7,4 Z"
fill="currentColor"
transform="translate(8.000000, 7.000000) rotate(-45.000000) translate(-8.000000, -7.000000) "
/>
</svg>
</button>
</div>
</nav>
</div>
</div>
</div>
</header>
`;

18
src/common/time-locale.js Normal file
View File

@@ -0,0 +1,18 @@
export default function timeLocale(number, index) {
return [
['just now', 'right now'],
['%ss', 'in %s seconds'],
['1m', 'in 1 minute'],
['%sm', 'in %s minutes'],
['1h', 'in 1 hour'],
['%sh', 'in %s hours'],
['1d', 'in 1 day'],
['%sd', 'in %s days'],
['1w', 'in 1 week'],
['%sw', 'in %s weeks'],
['4w', 'in 1 month'],
[`${number * 4}w`, 'in %s months'],
['1y', 'in 1 year'],
['%sy', 'in %s years'],
][index];
}

View File

@@ -30,27 +30,10 @@
"header.menu.orderHistory.label": "سجل الطلبيات", "header.menu.orderHistory.label": "سجل الطلبيات",
"header.navigation.skipNavLink": "التخطي إلى المحتوى الرئيسي", "header.navigation.skipNavLink": "التخطي إلى المحتوى الرئيسي",
"header.menu.signOut.label": "تسجيل الخروج", "header.menu.signOut.label": "تسجيل الخروج",
"header.links.content": "Content", "notification.title": "Notifications",
"header.links.settings": "Settings", "notification.today.heading": "Last 24 hours",
"header.links.content.tools": "Tools", "notification.earlier.heading": "Earlier",
"header.links.outline": "Outline", "notification.mark.as.read": "Mark all as read",
"header.links.updates": "Updates", "notification.fullStop": "",
"header.links.pages": "Pages & Resources", "notification.load.more.notifications": "Load more notifications"
"header.links.filesAndUploads": "Files & Uploads",
"header.links.textbooks": "Textbooks",
"header.links.videoUploads": "Video Uploads",
"header.links.scheduleAndDetails": "Schedule & Details",
"header.links.grading": "Grading",
"header.links.courseTeam": "Course Team",
"header.links.groupConfigurations": "Group Configurations",
"header.links.proctoredExamSettings": "Proctored Exam Settings",
"header.links.advancedSettings": "Advanced Settings",
"header.links.certificates": "Certificates",
"header.links.publisher": "Publisher",
"header.links.import": "Import",
"header.links.export": "Export",
"header.links.checklists": "Checklists",
"header.user.menu.studio": "Studio Home",
"header.user.menu.maintenance": "Maintenance",
"header.label.courseOutline": "Back to course outline in Studio"
} }

View File

@@ -30,27 +30,10 @@
"header.menu.orderHistory.label": "Order History", "header.menu.orderHistory.label": "Order History",
"header.navigation.skipNavLink": "Skip to main content.", "header.navigation.skipNavLink": "Skip to main content.",
"header.menu.signOut.label": "Sign Out", "header.menu.signOut.label": "Sign Out",
"header.links.content": "Content", "notification.title": "Notifications",
"header.links.settings": "Settings", "notification.today.heading": "Last 24 hours",
"header.links.content.tools": "Tools", "notification.earlier.heading": "Earlier",
"header.links.outline": "Outline", "notification.mark.as.read": "Mark all as read",
"header.links.updates": "Updates", "notification.fullStop": "",
"header.links.pages": "Pages & Resources", "notification.load.more.notifications": "Load more notifications"
"header.links.filesAndUploads": "Files & Uploads",
"header.links.textbooks": "Textbooks",
"header.links.videoUploads": "Video Uploads",
"header.links.scheduleAndDetails": "Schedule & Details",
"header.links.grading": "Grading",
"header.links.courseTeam": "Course Team",
"header.links.groupConfigurations": "Group Configurations",
"header.links.proctoredExamSettings": "Proctored Exam Settings",
"header.links.advancedSettings": "Advanced Settings",
"header.links.certificates": "Certificates",
"header.links.publisher": "Publisher",
"header.links.import": "Import",
"header.links.export": "Export",
"header.links.checklists": "Checklists",
"header.user.menu.studio": "Studio Home",
"header.user.menu.maintenance": "Maintenance",
"header.label.courseOutline": "Back to course outline in Studio"
} }

View File

@@ -30,27 +30,10 @@
"header.menu.orderHistory.label": "Historial de órdenes", "header.menu.orderHistory.label": "Historial de órdenes",
"header.navigation.skipNavLink": "Dirígete al contenido principal.", "header.navigation.skipNavLink": "Dirígete al contenido principal.",
"header.menu.signOut.label": "Cerrar sesión", "header.menu.signOut.label": "Cerrar sesión",
"header.links.content": "Contenido", "notification.title": "Notificaciones",
"header.links.settings": "Configuración", "notification.today.heading": "Últimas 24 horas",
"header.links.content.tools": "Herramientas", "notification.earlier.heading": "Más temprano",
"header.links.outline": "Estructura", "notification.mark.as.read": "Marcar todo como leído",
"header.links.updates": "Actualizaciones", "notification.fullStop": "•",
"header.links.pages": "Páginas & Recursos", "notification.load.more.notifications": "Cargar más notificaciones"
"header.links.filesAndUploads": "Administración de archivos",
"header.links.textbooks": "Libros de texto",
"header.links.videoUploads": "Carga de videos",
"header.links.scheduleAndDetails": "Calendario y detalles",
"header.links.grading": "Calificaciones",
"header.links.courseTeam": "Equipo del curso",
"header.links.groupConfigurations": "Configuraciones de Grupo",
"header.links.proctoredExamSettings": "Configuración de Exámenes Supervisados",
"header.links.advancedSettings": "Configuración avanzada",
"header.links.certificates": "Certificados",
"header.links.publisher": "Publisher",
"header.links.import": "Importar",
"header.links.export": "Exportar",
"header.links.checklists": "Listas de chequeo",
"header.user.menu.studio": "Inicio Studio",
"header.user.menu.maintenance": "Mantenimiento",
"header.label.courseOutline": "Volver al esquema del curso en Studio"
} }

View File

@@ -30,27 +30,10 @@
"header.menu.orderHistory.label": "Historique des commandes", "header.menu.orderHistory.label": "Historique des commandes",
"header.navigation.skipNavLink": "Passer au contenu principal", "header.navigation.skipNavLink": "Passer au contenu principal",
"header.menu.signOut.label": "Se déconnecter", "header.menu.signOut.label": "Se déconnecter",
"header.links.content": "Content", "notification.title": "Notifications",
"header.links.settings": "Settings", "notification.today.heading": "Last 24 hours",
"header.links.content.tools": "Tools", "notification.earlier.heading": "Earlier",
"header.links.outline": "Outline", "notification.mark.as.read": "Mark all as read",
"header.links.updates": "Updates", "notification.fullStop": "",
"header.links.pages": "Pages & Resources", "notification.load.more.notifications": "Load more notifications"
"header.links.filesAndUploads": "Files & Uploads",
"header.links.textbooks": "Textbooks",
"header.links.videoUploads": "Video Uploads",
"header.links.scheduleAndDetails": "Schedule & Details",
"header.links.grading": "Grading",
"header.links.courseTeam": "Course Team",
"header.links.groupConfigurations": "Group Configurations",
"header.links.proctoredExamSettings": "Proctored Exam Settings",
"header.links.advancedSettings": "Advanced Settings",
"header.links.certificates": "Certificates",
"header.links.publisher": "Publisher",
"header.links.import": "Import",
"header.links.export": "Export",
"header.links.checklists": "Checklists",
"header.user.menu.studio": "Studio Home",
"header.user.menu.maintenance": "Maintenance",
"header.label.courseOutline": "Back to course outline in Studio"
} }

View File

@@ -30,27 +30,10 @@
"header.menu.orderHistory.label": "Historique des commandes", "header.menu.orderHistory.label": "Historique des commandes",
"header.navigation.skipNavLink": "Passer au contenu principal.", "header.navigation.skipNavLink": "Passer au contenu principal.",
"header.menu.signOut.label": "Se déconnecter", "header.menu.signOut.label": "Se déconnecter",
"header.links.content": "Contenu", "notification.title": "Notifications",
"header.links.settings": "Paramètres", "notification.today.heading": "Dernières 24 heures",
"header.links.content.tools": "Outils", "notification.earlier.heading": "Plus tôt",
"header.links.outline": "Plan de cours", "notification.mark.as.read": "tout marquer comme lu",
"header.links.updates": "Annonces", "notification.fullStop": "•",
"header.links.pages": "Pages et ressources", "notification.load.more.notifications": "Charger plus de notifications"
"header.links.filesAndUploads": "Fichiers et téléversements",
"header.links.textbooks": "Manuels",
"header.links.videoUploads": "Téléversements des vidéos",
"header.links.scheduleAndDetails": "Dates et détails",
"header.links.grading": "Évaluation",
"header.links.courseTeam": "Équipe de cours",
"header.links.groupConfigurations": "Configuration des groupes",
"header.links.proctoredExamSettings": "Paramètres d'examen surveillé",
"header.links.advancedSettings": "Paramètres avancés",
"header.links.certificates": "Attestations",
"header.links.publisher": "Éditeur",
"header.links.import": "Importer",
"header.links.export": "Exporter",
"header.links.checklists": "Listes de contrôle",
"header.user.menu.studio": "Accueil Studio",
"header.user.menu.maintenance": "Entretien",
"header.label.courseOutline": "Retour au plan de cours dans Studio"
} }

View File

@@ -30,27 +30,10 @@
"header.menu.orderHistory.label": "Order History", "header.menu.orderHistory.label": "Order History",
"header.navigation.skipNavLink": "Skip to main content.", "header.navigation.skipNavLink": "Skip to main content.",
"header.menu.signOut.label": "Sign Out", "header.menu.signOut.label": "Sign Out",
"header.links.content": "Content", "notification.title": "Notifications",
"header.links.settings": "Settings", "notification.today.heading": "Last 24 hours",
"header.links.content.tools": "Tools", "notification.earlier.heading": "Earlier",
"header.links.outline": "Outline", "notification.mark.as.read": "Mark all as read",
"header.links.updates": "Updates", "notification.fullStop": "",
"header.links.pages": "Pages & Resources", "notification.load.more.notifications": "Load more notifications"
"header.links.filesAndUploads": "Files & Uploads",
"header.links.textbooks": "Textbooks",
"header.links.videoUploads": "Video Uploads",
"header.links.scheduleAndDetails": "Schedule & Details",
"header.links.grading": "Grading",
"header.links.courseTeam": "Course Team",
"header.links.groupConfigurations": "Group Configurations",
"header.links.proctoredExamSettings": "Proctored Exam Settings",
"header.links.advancedSettings": "Advanced Settings",
"header.links.certificates": "Certificates",
"header.links.publisher": "Publisher",
"header.links.import": "Import",
"header.links.export": "Export",
"header.links.checklists": "Checklists",
"header.user.menu.studio": "Studio Home",
"header.user.menu.maintenance": "Maintenance",
"header.label.courseOutline": "Back to course outline in Studio"
} }

View File

@@ -30,27 +30,10 @@
"header.menu.orderHistory.label": "Order History", "header.menu.orderHistory.label": "Order History",
"header.navigation.skipNavLink": "Skip to main content.", "header.navigation.skipNavLink": "Skip to main content.",
"header.menu.signOut.label": "Sign Out", "header.menu.signOut.label": "Sign Out",
"header.links.content": "Content", "notification.title": "Notifications",
"header.links.settings": "Settings", "notification.today.heading": "Last 24 hours",
"header.links.content.tools": "Tools", "notification.earlier.heading": "Earlier",
"header.links.outline": "Outline", "notification.mark.as.read": "Mark all as read",
"header.links.updates": "Updates", "notification.fullStop": "",
"header.links.pages": "Pages & Resources", "notification.load.more.notifications": "Load more notifications"
"header.links.filesAndUploads": "Files & Uploads",
"header.links.textbooks": "Textbooks",
"header.links.videoUploads": "Video Uploads",
"header.links.scheduleAndDetails": "Schedule & Details",
"header.links.grading": "Grading",
"header.links.courseTeam": "Course Team",
"header.links.groupConfigurations": "Group Configurations",
"header.links.proctoredExamSettings": "Proctored Exam Settings",
"header.links.advancedSettings": "Advanced Settings",
"header.links.certificates": "Certificates",
"header.links.publisher": "Publisher",
"header.links.import": "Import",
"header.links.export": "Export",
"header.links.checklists": "Checklists",
"header.user.menu.studio": "Studio Home",
"header.user.menu.maintenance": "Maintenance",
"header.label.courseOutline": "Back to course outline in Studio"
} }

View File

@@ -30,27 +30,10 @@
"header.menu.orderHistory.label": "Order History", "header.menu.orderHistory.label": "Order History",
"header.navigation.skipNavLink": "Skip to main content.", "header.navigation.skipNavLink": "Skip to main content.",
"header.menu.signOut.label": "Sign Out", "header.menu.signOut.label": "Sign Out",
"header.links.content": "Content", "notification.title": "Notifications",
"header.links.settings": "Settings", "notification.today.heading": "Last 24 hours",
"header.links.content.tools": "Tools", "notification.earlier.heading": "Earlier",
"header.links.outline": "Outline", "notification.mark.as.read": "Mark all as read",
"header.links.updates": "Updates", "notification.fullStop": "",
"header.links.pages": "Pages & Resources", "notification.load.more.notifications": "Load more notifications"
"header.links.filesAndUploads": "Files & Uploads",
"header.links.textbooks": "Textbooks",
"header.links.videoUploads": "Video Uploads",
"header.links.scheduleAndDetails": "Schedule & Details",
"header.links.grading": "Grading",
"header.links.courseTeam": "Course Team",
"header.links.groupConfigurations": "Group Configurations",
"header.links.proctoredExamSettings": "Proctored Exam Settings",
"header.links.advancedSettings": "Advanced Settings",
"header.links.certificates": "Certificates",
"header.links.publisher": "Publisher",
"header.links.import": "Import",
"header.links.export": "Export",
"header.links.checklists": "Checklists",
"header.user.menu.studio": "Studio Home",
"header.user.menu.maintenance": "Maintenance",
"header.label.courseOutline": "Back to course outline in Studio"
} }

View File

@@ -30,27 +30,10 @@
"header.menu.orderHistory.label": "Order History", "header.menu.orderHistory.label": "Order History",
"header.navigation.skipNavLink": "Skip to main content.", "header.navigation.skipNavLink": "Skip to main content.",
"header.menu.signOut.label": "Sign Out", "header.menu.signOut.label": "Sign Out",
"header.links.content": "Content", "notification.title": "Notifications",
"header.links.settings": "Settings", "notification.today.heading": "Last 24 hours",
"header.links.content.tools": "Tools", "notification.earlier.heading": "Earlier",
"header.links.outline": "Outline", "notification.mark.as.read": "Mark all as read",
"header.links.updates": "Updates", "notification.fullStop": "",
"header.links.pages": "Pages & Resources", "notification.load.more.notifications": "Load more notifications"
"header.links.filesAndUploads": "Files & Uploads",
"header.links.textbooks": "Textbooks",
"header.links.videoUploads": "Video Uploads",
"header.links.scheduleAndDetails": "Schedule & Details",
"header.links.grading": "Grading",
"header.links.courseTeam": "Course Team",
"header.links.groupConfigurations": "Group Configurations",
"header.links.proctoredExamSettings": "Proctored Exam Settings",
"header.links.advancedSettings": "Advanced Settings",
"header.links.certificates": "Certificates",
"header.links.publisher": "Publisher",
"header.links.import": "Import",
"header.links.export": "Export",
"header.links.checklists": "Checklists",
"header.user.menu.studio": "Studio Home",
"header.user.menu.maintenance": "Maintenance",
"header.label.courseOutline": "Back to course outline in Studio"
} }

View File

@@ -30,27 +30,10 @@
"header.menu.orderHistory.label": "Order History", "header.menu.orderHistory.label": "Order History",
"header.navigation.skipNavLink": "Перейти до головного змісту.", "header.navigation.skipNavLink": "Перейти до головного змісту.",
"header.menu.signOut.label": "Sign Out", "header.menu.signOut.label": "Sign Out",
"header.links.content": "Content", "notification.title": "Notifications",
"header.links.settings": "Settings", "notification.today.heading": "Last 24 hours",
"header.links.content.tools": "Tools", "notification.earlier.heading": "Earlier",
"header.links.outline": "Outline", "notification.mark.as.read": "Mark all as read",
"header.links.updates": "Updates", "notification.fullStop": "",
"header.links.pages": "Pages & Resources", "notification.load.more.notifications": "Load more notifications"
"header.links.filesAndUploads": "Files & Uploads",
"header.links.textbooks": "Textbooks",
"header.links.videoUploads": "Video Uploads",
"header.links.scheduleAndDetails": "Schedule & Details",
"header.links.grading": "Grading",
"header.links.courseTeam": "Course Team",
"header.links.groupConfigurations": "Group Configurations",
"header.links.proctoredExamSettings": "Proctored Exam Settings",
"header.links.advancedSettings": "Advanced Settings",
"header.links.certificates": "Certificates",
"header.links.publisher": "Publisher",
"header.links.import": "Import",
"header.links.export": "Export",
"header.links.checklists": "Checklists",
"header.user.menu.studio": "Studio Home",
"header.user.menu.maintenance": "Maintenance",
"header.label.courseOutline": "Back to course outline in Studio"
} }

View File

@@ -30,27 +30,10 @@
"header.menu.orderHistory.label": "Order History", "header.menu.orderHistory.label": "Order History",
"header.navigation.skipNavLink": "Skip to main content.", "header.navigation.skipNavLink": "Skip to main content.",
"header.menu.signOut.label": "Sign Out", "header.menu.signOut.label": "Sign Out",
"header.links.content": "Content", "notification.title": "Notifications",
"header.links.settings": "Settings", "notification.today.heading": "Last 24 hours",
"header.links.content.tools": "Tools", "notification.earlier.heading": "Earlier",
"header.links.outline": "Outline", "notification.mark.as.read": "Mark all as read",
"header.links.updates": "Updates", "notification.fullStop": "",
"header.links.pages": "Pages & Resources", "notification.load.more.notifications": "Load more notifications"
"header.links.filesAndUploads": "Files & Uploads",
"header.links.textbooks": "Textbooks",
"header.links.videoUploads": "Video Uploads",
"header.links.scheduleAndDetails": "Schedule & Details",
"header.links.grading": "Grading",
"header.links.courseTeam": "Course Team",
"header.links.groupConfigurations": "Group Configurations",
"header.links.proctoredExamSettings": "Proctored Exam Settings",
"header.links.advancedSettings": "Advanced Settings",
"header.links.certificates": "Certificates",
"header.links.publisher": "Publisher",
"header.links.import": "Import",
"header.links.export": "Export",
"header.links.checklists": "Checklists",
"header.user.menu.studio": "Studio Home",
"header.user.menu.maintenance": "Maintenance",
"header.label.courseOutline": "Back to course outline in Studio"
} }

View File

@@ -1,7 +1,7 @@
import Header from './Header'; import Header from './Header';
import LearningHeader from './learning-header/LearningHeader'; import LearningHeader from './learning-header/LearningHeader';
import messages from './i18n/index'; import messages from './i18n/index';
import StudioHeader from './studio-header'; import StudioHeader from './StudioHeader';
export { LearningHeader, messages, StudioHeader }; export { LearningHeader, messages, StudioHeader };

View File

@@ -1,9 +1,11 @@
$spacer: 1rem; $spacer: 1rem;
$blue: #007db8; $blue: #007db8;
$white: #fff; $white: #fff;
@import "@edx/brand/paragon/fonts.scss";
@import "@edx/brand/paragon/variables.scss";
@import "@edx/paragon/scss/core/core.scss";
@import "@edx/brand/paragon/overrides.scss";
@import './Menu/menu.scss'; @import './Menu/menu.scss';
@import './studio-header/header.scss';
.dropdown-item a { .dropdown-item a {
text-decoration: none; text-decoration: none;
@@ -119,3 +121,133 @@ $white: #fff;
border-radius: $rounded-pill; border-radius: $rounded-pill;
} }
} }
.content {
strong {
color: #00262B !important;
font-weight: 500 !important;
}
}
.font-size-18 {
font-size: 18px !important;
}
.font-size-12 {
font-size: 12px;
}
.font-size-14 {
font-size: 14px;
}
.py-10px {
padding-top: 10px;
padding-bottom: 10px;
}
.pb-10px {
padding-bottom: 10px;
}
.line-height-24 {
line-height: 24px;
}
.line-height-20 {
line-height: 20px;
}
.line-height-10 {
line-height: 10px !important;
}
.icon-size-20 {
width: 20px !important;
height: 20px !important;
}
.cursor-pointer {
cursor: pointer;
}
.notification-button {
width: 36px;
height: 36px;
}
.notification-icon{
height: 23.33px !important;
width: 23.33px !important;
}
.notification-badge {
position: absolute;
margin-top: 18px;
margin-left: -21px;
border: 2px solid #FFFFFF;
font-size: 9px !important;
}
.popover {
filter: none;
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.15), 0px 2px 8px rgba(0, 0, 0, 0.15);
&.medium-screen {
min-width: 24.313rem;
}
&.large-screen {
min-width: 34.313rem;
}
.dropdown-toggle::after {
display: none;
}
.expandable {
position: relative !important;
margin-left: 4px;
padding: 2px 5px;
border-radius: 10rem;
font-size: 9px;
}
.dropdown-toggle {
font-size: 14px;
padding-top: 0px !important;
padding-bottom: 12px !important;
div {
min-height: 6px !important;
min-width: 6px !important;
}
}
.dropdown-item {
font-size: 14px;
font-weight: 500;
}
.notification-content {
.notification-item-content {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
p {
margin-bottom: 0px;
}
b {
color: #00262B;
}
}
.unread {
height: 10px;
width: 10px;
}
}
}

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useEffect } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -6,10 +6,26 @@ import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Dropdown } from '@edx/paragon'; import { Dropdown } from '@edx/paragon';
import { useSelector, useDispatch } from 'react-redux';
import Notifications from '../Notifications';
import { selectShowNotificationTray, selectNotificationStatus } from '../Notifications/data/selectors';
import { fetchAppsNotificationCount } from '../Notifications/data/thunks';
import { RequestStatus } from '../Notifications/data/slice';
import messages from './messages'; import messages from './messages';
const AuthenticatedUserDropdown = ({ intl, username }) => { const AuthenticatedUserDropdown = ({ intl, username }) => {
const showNotificationsTray = useSelector(selectShowNotificationTray());
const notificationStatus = useSelector(selectNotificationStatus());
const dispatch = useDispatch();
useEffect(() => {
if (notificationStatus === RequestStatus.IDLE) {
dispatch(fetchAppsNotificationCount());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const dashboardMenuItem = ( const dashboardMenuItem = (
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}> <Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}>
{intl.formatMessage(messages.dashboard)} {intl.formatMessage(messages.dashboard)}
@@ -19,6 +35,7 @@ const AuthenticatedUserDropdown = ({ intl, username }) => {
return ( return (
<> <>
<a className="text-gray-700" href={`${getConfig().SUPPORT_URL}`}>{intl.formatMessage(messages.help)}</a> <a className="text-gray-700" href={`${getConfig().SUPPORT_URL}`}>{intl.formatMessage(messages.help)}</a>
{showNotificationsTray && <Notifications />}
<Dropdown className="user-dropdown ml-3"> <Dropdown className="user-dropdown ml-3">
<Dropdown.Toggle variant="outline-primary"> <Dropdown.Toggle variant="outline-primary">
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" /> <FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />

View File

@@ -2,11 +2,12 @@ import React, { useContext } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react'; import { AppContext, AppProvider } from '@edx/frontend-platform/react';
import AnonymousUserMenu from './AnonymousUserMenu'; import AnonymousUserMenu from './AnonymousUserMenu';
import AuthenticatedUserDropdown from './AuthenticatedUserDropdown'; import AuthenticatedUserDropdown from './AuthenticatedUserDropdown';
import messages from './messages'; import messages from './messages';
import store from '../store';
const LinkedLogo = ({ const LinkedLogo = ({
href, href,
@@ -40,24 +41,26 @@ const LearningHeader = ({
); );
return ( return (
<header className="learning-header"> <AppProvider store={store}>
<a className="sr-only sr-only-focusable" href="#main-content">{intl.formatMessage(messages.skipNavLink)}</a> <header className="learning-header">
<div className="container-xl py-2 d-flex align-items-center"> <a className="sr-only sr-only-focusable" href="#main-content">{intl.formatMessage(messages.skipNavLink)}</a>
{headerLogo} <div className="container-xl py-2 d-flex align-items-center">
<div className="flex-grow-1 course-title-lockup" style={{ lineHeight: 1 }}> {headerLogo}
<span className="d-block small m-0">{courseOrg} {courseNumber}</span> <div className="flex-grow-1 course-title-lockup" style={{ lineHeight: 1 }}>
<span className="d-block m-0 font-weight-bold course-title">{courseTitle}</span> <span className="d-block small m-0">{courseOrg} {courseNumber}</span>
<span className="d-block m-0 font-weight-bold course-title">{courseTitle}</span>
</div>
{showUserDropdown && authenticatedUser && (
<AuthenticatedUserDropdown
username={authenticatedUser.username}
/>
)}
{showUserDropdown && !authenticatedUser && (
<AnonymousUserMenu />
)}
</div> </div>
{showUserDropdown && authenticatedUser && ( </header>
<AuthenticatedUserDropdown </AppProvider>
username={authenticatedUser.username}
/>
)}
{showUserDropdown && !authenticatedUser && (
<AnonymousUserMenu />
)}
</div>
</header>
); );
}; };

16
src/store.js Normal file
View File

@@ -0,0 +1,16 @@
import { configureStore } from '@reduxjs/toolkit';
import { notificationsReducer } from './Notifications/data';
export function initializeStore(preloadedState = undefined) {
return configureStore({
reducer: {
notifications: notificationsReducer,
},
preloadedState,
});
}
const store = initializeStore();
export default store;

View File

@@ -1,24 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
const BrandNav = ({
studioBaseUrl,
logo,
logoAltText,
}) => (
<a href={studioBaseUrl}>
<img
src={logo}
alt={logoAltText}
className="d-block logo"
/>
</a>
);
BrandNav.propTypes = {
studioBaseUrl: PropTypes.string.isRequired,
logo: PropTypes.string.isRequired,
logoAltText: PropTypes.string.isRequired,
};
export default BrandNav;

View File

@@ -1,54 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
OverlayTrigger,
Tooltip,
} from '@edx/paragon';
import messages from './messages';
const CourseLockUp = ({
outlineLink,
org,
number,
title,
// injected
intl,
}) => (
<OverlayTrigger
placement="bottom"
overlay={(
<Tooltip id="course-lock-up">
{title}
</Tooltip>
)}
>
<a
className="course-title-lockup w-25 mr-2"
href={outlineLink}
aria-label={intl.formatMessage(messages['header.label.courseOutline'])}
data-testid="course-lock-up-block"
>
<span className="d-block small m-0 text-gray-800" data-testid="course-org-number">{org} {number}</span>
<span className="d-block m-0 font-weight-bold text-gray-800" data-testid="course-title">{title}</span>
</a>
</OverlayTrigger>
);
CourseLockUp.propTypes = {
number: PropTypes.string,
org: PropTypes.string,
title: PropTypes.string,
outlineLink: PropTypes.string,
// injected
intl: intlShape.isRequired,
};
CourseLockUp.defaultProps = {
number: null,
org: null,
title: null,
outlineLink: null,
};
export default injectIntl(CourseLockUp);

View File

@@ -1,155 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
ActionRow,
Button,
Container,
Nav,
Row,
} from '@edx/paragon';
import { Close, MenuIcon } from '@edx/paragon/icons';
import CourseLockUp from './CourseLockUp';
import UserMenu from './UserMenu';
import BrandNav from './BrandNav';
import NavDropdownMenu from './NavDropdownMenu';
const HeaderBody = ({
logo,
logoAltText,
number,
org,
title,
username,
isAdmin,
studioBaseUrl,
logoutUrl,
authenticatedUserAvatar,
isMobile,
setModalPopupTarget,
toggleModalPopup,
isModalPopupOpen,
isHiddenMainMenu,
mainMenuDropdowns,
outlineLink,
}) => {
const renderBrandNav = (
<BrandNav
{...{
studioBaseUrl,
logo,
logoAltText,
}}
/>
);
return (
<Container size="xl" className="px-4">
<ActionRow as="header">
{isHiddenMainMenu ? (
<Row className="flex-nowrap ml-4">
{renderBrandNav}
</Row>
) : (
<>
{isMobile ? (
<Button
ref={setModalPopupTarget}
className="d-inline-flex align-items-center"
variant="tertiary"
onClick={toggleModalPopup}
iconBefore={isModalPopupOpen ? Close : MenuIcon}
data-testid="mobile-menu-button"
>
Menu
</Button>
) : (
<Row className="flex-nowrap m-0">
{renderBrandNav}
<CourseLockUp
{...{
outlineLink,
number,
org,
title,
}}
/>
</Row>
)}
{isMobile ? (
<>
<ActionRow.Spacer />
{renderBrandNav}
</>
) : (
<Nav data-testid="desktop-menu" className="ml-4">
{mainMenuDropdowns.map(dropdown => {
const { id, buttonTitle, items } = dropdown;
return (
<NavDropdownMenu {...{ id, buttonTitle, items }} />
);
})}
</Nav>
)}
</>
)}
<ActionRow.Spacer />
<Nav>
<UserMenu
{...{
username,
studioBaseUrl,
logoutUrl,
authenticatedUserAvatar,
isAdmin,
}}
/>
</Nav>
</ActionRow>
</Container>
);
};
HeaderBody.propTypes = {
studioBaseUrl: PropTypes.string.isRequired,
logoutUrl: PropTypes.string.isRequired,
setModalPopupTarget: PropTypes.func.isRequired,
toggleModalPopup: PropTypes.func.isRequired,
isModalPopupOpen: PropTypes.bool.isRequired,
number: PropTypes.string,
org: PropTypes.string,
title: PropTypes.string,
logo: PropTypes.string,
logoAltText: PropTypes.string,
authenticatedUserAvatar: PropTypes.string,
username: PropTypes.string,
isAdmin: PropTypes.bool,
isMobile: PropTypes.bool,
isHiddenMainMenu: PropTypes.bool,
mainMenuDropdowns: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
buttonTitle: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.shape({
href: PropTypes.string,
title: PropTypes.string,
})),
})),
outlineLink: PropTypes.string,
};
HeaderBody.defaultProps = {
logo: null,
logoAltText: null,
number: '',
org: '',
title: '',
authenticatedUserAvatar: null,
username: null,
isAdmin: false,
isMobile: false,
isHiddenMainMenu: false,
mainMenuDropdowns: [],
outlineLink: null,
};
export default HeaderBody;

View File

@@ -1,76 +0,0 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { useToggle, ModalPopup } from '@edx/paragon';
import HeaderBody from './HeaderBody';
import MobileMenu from './MobileMenu';
const MobileHeader = ({
mainMenuDropdowns,
...props
}) => {
const [isOpen, , close, toggle] = useToggle(false);
const [target, setTarget] = useState(null);
return (
<>
<HeaderBody
{...props}
isMobile
setModalPopupTarget={setTarget}
toggleModalPopup={toggle}
isModalPopupOpen={isOpen}
/>
<ModalPopup
hasArrow
placement="bottom"
positionRef={target}
isOpen={isOpen}
onClose={close}
onEscapeKey={close}
className="mobile-menu-container"
>
<MobileMenu {...{ mainMenuDropdowns }} />
</ModalPopup>
</>
);
};
MobileHeader.propTypes = {
studioBaseUrl: PropTypes.string.isRequired,
logoutUrl: PropTypes.string.isRequired,
setModalPopupTarget: PropTypes.func.isRequired,
toggleModalPopup: PropTypes.func.isRequired,
isModalPopupOpen: PropTypes.bool.isRequired,
number: PropTypes.string,
org: PropTypes.string,
title: PropTypes.string,
logo: PropTypes.string,
logoAltText: PropTypes.string,
authenticatedUserAvatar: PropTypes.string,
username: PropTypes.string,
isAdmin: PropTypes.bool,
mainMenuDropdowns: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
buttonTitle: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.shape({
href: PropTypes.string,
title: PropTypes.string,
})),
})),
outlineLink: PropTypes.string,
};
MobileHeader.defaultProps = {
logo: null,
logoAltText: null,
number: null,
org: null,
title: null,
authenticatedUserAvatar: null,
username: null,
isAdmin: false,
mainMenuDropdowns: [],
outlineLink: null,
};
export default MobileHeader;

View File

@@ -1,51 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Collapsible } from '@edx/paragon';
const MobileMenu = ({
mainMenuDropdowns,
}) => (
<div
className="ml-4 p-2 bg-light-100 border border-gray-200 small rounded"
data-testid="mobile-menu"
>
<div>
{mainMenuDropdowns.map(dropdown => {
const { id, buttonTitle, items } = dropdown;
return (
<Collapsible
className="border-light-100"
title={buttonTitle}
key={id}
>
<ul className="p-0" style={{ listStyleType: 'none' }}>
{items.map(item => (
<li className="mobile-menu-item">
<a href={item.href}>
{item.title}
</a>
</li>
))}
</ul>
</Collapsible>
);
})}
</div>
</div>
);
MobileMenu.propTypes = {
mainMenuDropdowns: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
buttonTitle: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.shape({
href: PropTypes.string,
title: PropTypes.string,
})),
})),
};
MobileMenu.defaultProps = {
mainMenuDropdowns: [],
};
export default MobileMenu;

View File

@@ -1,38 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
Dropdown,
DropdownButton,
} from '@edx/paragon';
const NavDropdownMenu = ({
id,
buttonTitle,
items,
}) => (
<DropdownButton
id={id}
title={buttonTitle}
variant="tertiary"
>
{items.map(item => (
<Dropdown.Item
href={item.href}
className="small"
>
{item.title}
</Dropdown.Item>
))}
</DropdownButton>
);
NavDropdownMenu.propTypes = {
id: PropTypes.string.isRequired,
buttonTitle: PropTypes.string.isRequired,
items: PropTypes.arrayOf(PropTypes.shape({
href: PropTypes.string,
title: PropTypes.string,
})).isRequired,
};
export default NavDropdownMenu;

View File

@@ -1,74 +0,0 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import Responsive from 'react-responsive';
import { AppContext } from '@edx/frontend-platform/react';
import { ensureConfig } from '@edx/frontend-platform';
import MobileHeader from './MobileHeader';
import HeaderBody from './HeaderBody';
ensureConfig([
'STUDIO_BASE_URL',
'SITE_NAME',
'LOGOUT_URL',
'LOGIN_URL',
'LOGO_URL',
], 'Studio Header component');
const StudioHeader = ({
number, org, title, isHiddenMainMenu, mainMenuDropdowns, outlineLink,
}) => {
const { authenticatedUser, config } = useContext(AppContext);
const props = {
logo: config.LOGO_URL,
logoAltText: `Studio ${config.SITE_NAME}`,
number,
org,
title,
username: authenticatedUser?.username,
isAdmin: authenticatedUser?.administrator,
authenticatedUserAvatar: authenticatedUser?.avatar,
studioBaseUrl: config.STUDIO_BASE_URL,
logoutUrl: config.LOGOUT_URL,
isHiddenMainMenu,
mainMenuDropdowns,
outlineLink,
};
return (
<>
<Responsive maxWidth={768}>
<MobileHeader {...props} />
</Responsive>
<Responsive minWidth={769}>
<HeaderBody {...props} />
</Responsive>
</>
);
};
StudioHeader.propTypes = {
number: PropTypes.string,
org: PropTypes.string,
title: PropTypes.string.isRequired,
isHiddenMainMenu: PropTypes.bool,
mainMenuDropdowns: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
buttonTitle: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.shape({
href: PropTypes.string,
title: PropTypes.string,
})),
})),
outlineLink: PropTypes.string,
};
StudioHeader.defaultProps = {
number: '',
org: '',
isHiddenMainMenu: false,
mainMenuDropdowns: [],
outlineLink: null,
};
export default StudioHeader;

View File

@@ -1,197 +0,0 @@
/* eslint-disable react/prop-types */
import React, { useMemo } from 'react';
import {
render,
fireEvent,
waitFor,
} from '@testing-library/react';
import { AppContext } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { Context as ResponsiveContext } from 'react-responsive';
import StudioHeader from './StudioHeader';
import messages from './messages';
const authenticatedUser = {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
avatar: '/imges/test.png',
};
let currentUser;
let screenWidth = 1280;
const RootWrapper = ({
...props
}) => {
const appContextValue = useMemo(() => ({
authenticatedUser: currentUser,
config: {
LOGOUT_URL: process.env.LOGOUT_URL,
LOGO_URL: process.env.LOGO_URL,
SITE_NAME: process.env.SITE_NAME,
STUDIO_BASE_URL: process.env.STUDIO_BASE_URL,
LOGIN_URL: process.env.LOGIN_URL,
},
}), []);
const responsiveContextValue = useMemo(() => ({ width: screenWidth }), []);
return (
// eslint-disable-next-line react/jsx-no-constructed-context-values, react/prop-types
<IntlProvider locale="en">
<AppContext.Provider value={appContextValue}>
<ResponsiveContext.Provider value={responsiveContextValue}>
<StudioHeader
{...props}
/>
</ResponsiveContext.Provider>
</AppContext.Provider>
</IntlProvider>
);
};
const props = {
number: '123',
org: 'Ed',
title: 'test',
mainMenuDropdowns: [
{
id: 'testId',
buttonTitle: 'test',
items: [
{
title: 'link',
href: '#',
},
],
},
],
outlineLink: 'tEsTLInK',
};
describe('Header', () => {
beforeEach(() => {
jest.clearAllMocks();
currentUser = authenticatedUser;
});
describe('desktop', () => {
it('course lock up should be visible', () => {
const { getByTestId } = render(<RootWrapper {...props} />);
const courseLockUpBlock = getByTestId('course-lock-up-block');
expect(courseLockUpBlock).toBeVisible();
});
it('mobile menu should not be visible', () => {
const { queryByTestId } = render(<RootWrapper {...props} />);
const mobileMenuButton = queryByTestId('mobile-menu-button');
expect(mobileMenuButton).toBeNull();
});
it('desktop menu should be visible', () => {
const { getByTestId } = render(<RootWrapper {...props} />);
const desktopMenu = getByTestId('desktop-menu');
expect(desktopMenu).toBeVisible();
});
it('should render one dropdown', async () => {
const { getAllByRole, getByText } = render(<RootWrapper {...props} />);
const dropdownMenu = getAllByRole('button')[0];
expect(dropdownMenu).toBeVisible();
await waitFor(() => fireEvent.click(dropdownMenu));
const dropdownOption = getByText('link');
expect(dropdownOption).toBeVisible();
});
it('maintenance should not be in user menu', async () => {
currentUser = { ...authenticatedUser, administrator: false };
const { getAllByRole, queryByText } = render(<RootWrapper {...props} />);
const userMenu = getAllByRole('button')[1];
await waitFor(() => fireEvent.click(userMenu));
const maintenanceButton = queryByText(messages['header.user.menu.maintenance'].defaultMessage);
expect(maintenanceButton).toBeNull();
});
it('user menu should use avatar icon', async () => {
currentUser = { ...authenticatedUser, avatar: null };
const { getByTestId } = render(<RootWrapper {...props} />);
const avatarIcon = getByTestId('avatar-icon');
expect(avatarIcon).toBeVisible();
});
it('should hide nav items if prop isHiddenMainMenu true', async () => {
const initialProps = { ...props, isHiddenMainMenu: true };
const { queryByTestId } = render(<RootWrapper {...initialProps} />);
const desktopMenu = queryByTestId('desktop-menu');
const mobileMenuButton = queryByTestId('mobile-menu-button');
expect(mobileMenuButton).toBeNull();
expect(desktopMenu).toBeNull();
});
});
describe('mobile', () => {
beforeEach(() => { screenWidth = 500; });
it('course lock up should not be visible', async () => {
const { queryByTestId } = render(<RootWrapper {...props} />);
const courseLockUpBlock = queryByTestId('course-lock-up-block');
expect(courseLockUpBlock).toBeNull();
});
it('mobile menu should be visible', async () => {
const { getByTestId } = render(<RootWrapper {...props} />);
const mobileMenuButton = getByTestId('mobile-menu-button');
expect(mobileMenuButton).toBeVisible();
await waitFor(() => fireEvent.click(mobileMenuButton));
const mobileMenu = getByTestId('mobile-menu');
expect(mobileMenu).toBeVisible();
});
it('desktop menu should not be visible', () => {
const { queryByTestId } = render(<RootWrapper {...props} />);
const desktopMenu = queryByTestId('desktop-menu');
expect(desktopMenu).toBeNull();
});
it('maintenance should be in user menu', async () => {
const { getAllByRole, getByText } = render(<RootWrapper {...props} />);
const userMenu = getAllByRole('button')[1];
await waitFor(() => fireEvent.click(userMenu));
const maintenanceButton = getByText(messages['header.user.menu.maintenance'].defaultMessage);
expect(maintenanceButton).toBeVisible();
});
it('user menu should use avatar image', async () => {
const { getByTestId } = render(<RootWrapper {...props} />);
const avatarImage = getByTestId('avatar-image');
expect(avatarImage).toBeVisible();
});
it('should hide nav items if prop isHiddenMainMenu true', async () => {
const initialProps = { ...props, isHiddenMainMenu: true };
const { queryByTestId } = render(<RootWrapper {...initialProps} />);
const desktopMenu = queryByTestId('desktop-menu');
const mobileMenuButton = queryByTestId('mobile-menu-button');
expect(mobileMenuButton).toBeNull();
expect(desktopMenu).toBeNull();
});
});
});

View File

@@ -1,69 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Avatar,
} from '@edx/paragon';
import NavDropdownMenu from './NavDropdownMenu';
import getUserMenuItems from './utils';
const UserMenu = ({
username,
studioBaseUrl,
logoutUrl,
authenticatedUserAvatar,
isMobile,
isAdmin,
// injected
intl,
}) => {
const avatar = authenticatedUserAvatar ? (
<img
className="d-block w-100 h-100"
src={authenticatedUserAvatar}
alt={username}
data-testid="avatar-image"
/>
) : (
<Avatar
size="sm"
className="mr-2"
alt={username}
data-testid="avatar-icon"
/>
);
const title = isMobile ? avatar : <>{avatar}{username}</>;
return (
<NavDropdownMenu
buttonTitle={title}
id="user-dropdown-menu"
items={getUserMenuItems({
studioBaseUrl,
logoutUrl,
intl,
isAdmin,
})}
/>
);
};
UserMenu.propTypes = {
username: PropTypes.string,
studioBaseUrl: PropTypes.string.isRequired,
logoutUrl: PropTypes.string.isRequired,
authenticatedUserAvatar: PropTypes.string,
isMobile: PropTypes.bool,
isAdmin: PropTypes.bool,
// injected
intl: intlShape.isRequired,
};
UserMenu.defaultProps = {
isMobile: false,
isAdmin: false,
authenticatedUserAvatar: null,
username: null,
};
export default injectIntl(UserMenu);

View File

@@ -1,64 +0,0 @@
// This SCSS was partly copied from edx/frontend-app-support-tools/src/support-header/index.scss.
$spacer: 1rem;
$white: #FFFFFF;
.btn-tertiary:hover {
color: white;
background-color: #00262B;
}
.course-title-lockup {
@media only screen and (max-width: 768px) {
padding-left: .5rem;
max-width: 70%;
}
@media only screen and (min-width: 769px) {
padding: .5rem;
padding-right: $spacer;
border-right: 1px solid #E5E5E5;
min-width: 70%;
}
overflow: hidden;
span {
color: #333333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.375rem;
}
}
.site-header-mobile,
.site-header-desktop {
position: relative;
z-index: 1000;
}
.site-header-mobile {img {
height: 1.5rem;
}
}
.site-header-desktop {
height: 3.75rem;
box-shadow: 0 1px 0 0 rgb(0 0 0 / .1);
background: $white;
.logo {
display: block;
box-sizing: content-box;
position: relative;
top: -.05em;
height: 1.75rem;
padding: $spacer 0;
margin-right: $spacer;
img {
display: block;
height: 100%;
}
}
}

View File

@@ -1,3 +0,0 @@
import StudioHeader from './StudioHeader';
export default StudioHeader;

View File

@@ -1,156 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'header.links.content': {
id: 'header.links.content',
defaultMessage: 'Content',
description: 'Label for Content menu trigger',
},
'header.links.settings': {
id: 'header.links.settings',
defaultMessage: 'Settings',
description: 'Label for Settings menu trigger',
},
'header.links.tools': {
id: 'header.links.content.tools',
defaultMessage: 'Tools',
description: 'Label for Tools menu trigger',
},
'header.links.outline': {
id: 'header.links.outline',
defaultMessage: 'Outline',
description: 'Link to Studio Outline page',
},
'header.links.updates': {
id: 'header.links.updates',
defaultMessage: 'Updates',
description: 'Link to Studio Updates page',
},
'header.links.pages': {
id: 'header.links.pages',
defaultMessage: 'Pages & Resources',
description: 'Link to Studio Pages page',
},
'header.links.filesAndUploads': {
id: 'header.links.filesAndUploads',
defaultMessage: 'Files & Uploads',
description: 'Link to Studio Files & Uploads page',
},
'header.links.textbooks': {
id: 'header.links.textbooks',
defaultMessage: 'Textbooks',
description: 'Link to Studio Textbooks page',
},
'header.links.videoUploads': {
id: 'header.links.videoUploads',
defaultMessage: 'Video Uploads',
description: 'Link to Studio Video Uploads page',
},
'header.links.scheduleAndDetails': {
id: 'header.links.scheduleAndDetails',
defaultMessage: 'Schedule & Details',
description: 'Link to Studio Schedule & Details page',
},
'header.links.grading': {
id: 'header.links.grading',
defaultMessage: 'Grading',
description: 'Link to Studio Grading page',
},
'header.links.courseTeam': {
id: 'header.links.courseTeam',
defaultMessage: 'Course Team',
description: 'Link to Studio Course Team page',
},
'header.links.groupConfigurations': {
id: 'header.links.groupConfigurations',
defaultMessage: 'Group Configurations',
description: 'Link to Studio Group Configurations page',
},
'header.links.proctoredExamSettings': {
id: 'header.links.proctoredExamSettings',
defaultMessage: 'Proctored Exam Settings',
description: 'Link to Studio Proctored Exam Settings page',
},
'header.links.advancedSettings': {
id: 'header.links.advancedSettings',
defaultMessage: 'Advanced Settings',
description: 'Link to Studio Advanced Settings page',
},
'header.links.certificates': {
id: 'header.links.certificates',
defaultMessage: 'Certificates',
description: 'Link to Studio Certificates page',
},
'header.links.publisher': {
id: 'header.links.publisher',
defaultMessage: 'Publisher',
description: 'Link to Publisher',
},
'header.links.import': {
id: 'header.links.import',
defaultMessage: 'Import',
description: 'Link to Studio Import page',
},
'header.links.export': {
id: 'header.links.export',
defaultMessage: 'Export',
description: 'Link to Studio Export page',
},
'header.links.checklists': {
id: 'header.links.checklists',
defaultMessage: 'Checklists',
description: 'Link to Studio Checklists page',
},
'header.user.menu.studio': {
id: 'header.user.menu.studio',
defaultMessage: 'Studio Home',
description: 'Link to Studio Home',
},
'header.user.menu.maintenance': {
id: 'header.user.menu.maintenance',
defaultMessage: 'Maintenance',
description: 'Link to the Studio maintenance page',
},
'header.user.menu.logout': {
id: 'header.user.menu.logout',
defaultMessage: 'Logout',
description: 'Logout link',
},
'header.label.account.menu': {
id: 'header.label.account.menu',
defaultMessage: 'Account Menu',
description: 'The aria label for the account menu trigger',
},
'header.label.account.menu.for': {
id: 'header.label.account.menu.for',
defaultMessage: 'Account menu for {username}',
description: 'The aria label for the account menu trigger when the username is displayed in it',
},
'header.label.main.nav': {
id: 'header.label.main.nav',
defaultMessage: 'Main',
description: 'The aria label for the main menu nav',
},
'header.label.main.menu': {
id: 'header.label.main.menu',
defaultMessage: 'Main Menu',
description: 'The aria label for the main menu trigger',
},
'header.label.main.header': {
id: 'header.label.main.header',
defaultMessage: 'Main',
description: 'The aria label for the main header',
},
'header.label.secondary.nav': {
id: 'header.label.secondary.nav',
defaultMessage: 'Secondary',
description: 'The aria label for the seconary nav',
},
'header.label.courseOutline': {
id: 'header.label.courseOutline',
defaultMessage: 'Back to course outline in Studio',
description: 'The aria label for the link back to the Studio Course Outline',
},
});
export default messages;

View File

@@ -1,36 +0,0 @@
import messages from './messages';
const getUserMenuItems = ({
studioBaseUrl,
logoutUrl,
intl,
isAdmin,
}) => {
let items = [
{
href: `${studioBaseUrl}}`,
title: intl.formatMessage(messages['header.user.menu.studio']),
}, {
href: `${logoutUrl}`,
title: intl.formatMessage(messages['header.user.menu.logout']),
},
];
if (isAdmin) {
items = [
{
href: `${studioBaseUrl}}`,
title: intl.formatMessage(messages['header.user.menu.studio']),
}, {
href: `${studioBaseUrl}/maintenance`,
title: intl.formatMessage(messages['header.user.menu.maintenance']),
}, {
href: `${logoutUrl}`,
title: intl.formatMessage(messages['header.user.menu.logout']),
},
];
}
return items;
};
export default getUserMenuItems;