Compare commits
155 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a229c34535 | ||
|
|
5d7b4fecf4 | ||
|
|
f04130a7c6 | ||
|
|
cb7774b325 | ||
|
|
3e4eb21d8c | ||
|
|
a346dccd4c | ||
|
|
c64a201072 | ||
|
|
6496642643 | ||
|
|
a6c36654b4 | ||
|
|
ae5253c822 | ||
|
|
e44001e945 | ||
|
|
e07cf665a4 | ||
|
|
8213ee7460 | ||
|
|
8a7d6eecdf | ||
|
|
a2497eeb22 | ||
|
|
a703abad76 | ||
|
|
3f4d987d12 | ||
|
|
45e551ea44 | ||
|
|
a0d7fd7cf2 | ||
|
|
97d1bdedfb | ||
|
|
4351a09c9f | ||
|
|
9647a74507 | ||
|
|
9cef77349f | ||
|
|
ad7c42bcf9 | ||
|
|
266386fe24 | ||
|
|
f42ee37e16 | ||
|
|
354f9fdc38 | ||
|
|
85b07acfb5 | ||
|
|
3e647f7394 | ||
|
|
848b0f37b9 | ||
|
|
818b3800aa | ||
|
|
63e47bc45a | ||
|
|
7ba5371f69 | ||
|
|
9f0c286897 | ||
|
|
4cc5b91d6d | ||
|
|
3d75a72f0c | ||
|
|
0541dc194e | ||
|
|
b92127fd12 | ||
|
|
b2b9f3fa00 | ||
|
|
b9b6282b4b | ||
|
|
8606585978 | ||
|
|
159072779f | ||
|
|
de843d330d | ||
|
|
d554de89ca | ||
|
|
72be96c230 | ||
|
|
564f34a7c6 | ||
|
|
ab15b3d2bf | ||
|
|
59db41c61e | ||
|
|
0415c00353 | ||
|
|
9d01c074e0 | ||
|
|
83c5b0258f | ||
|
|
45246ad5ee | ||
|
|
487b2590bd | ||
|
|
6cab3f3f3e | ||
|
|
e3c8ec027e | ||
|
|
1e899c1c48 | ||
|
|
370b193df3 | ||
|
|
58c34abd66 | ||
|
|
c9942c1552 | ||
|
|
432dbb5e6b | ||
|
|
02748fab13 | ||
|
|
3a5506c646 | ||
|
|
10619ceb5e | ||
|
|
2694492a7c | ||
|
|
be7d0d97e4 | ||
|
|
e6aa4be4f6 | ||
|
|
f2c236c828 | ||
|
|
db912e6dae | ||
|
|
abb08be08e | ||
|
|
6c6ccc7f20 | ||
|
|
2494ad2b57 | ||
|
|
3b2a2bfa95 | ||
|
|
30b91791e3 | ||
|
|
08592aeec7 | ||
|
|
c1d143ace2 | ||
|
|
54a879aec2 | ||
|
|
b5b37f1d64 | ||
|
|
36be99ace0 | ||
|
|
222bc19bd0 | ||
|
|
3d827e64ea | ||
|
|
8a247abd6a | ||
|
|
a6943fbaeb | ||
|
|
19292cd5b6 | ||
|
|
b6374a5c05 | ||
|
|
f6a4036b49 | ||
|
|
12a845ad33 | ||
|
|
a4a7456726 | ||
|
|
6fdf73fed3 | ||
|
|
32b8079744 | ||
|
|
b9f7fe74c8 | ||
|
|
5a259a76df | ||
|
|
d2ad5ee2a4 | ||
|
|
67fefe814b | ||
|
|
9a5b1fa5e7 | ||
|
|
55f21aeeaa | ||
|
|
fc24e61a1c | ||
|
|
bace8286fd | ||
|
|
665653e9a5 | ||
|
|
0cc2282c44 | ||
|
|
455ffd345c | ||
|
|
dde02a0739 | ||
|
|
81cb72f10b | ||
|
|
e285a91408 | ||
|
|
50142adb85 | ||
|
|
7281804fbd | ||
|
|
b446534992 | ||
|
|
3adc305aec | ||
|
|
65446ce9c3 | ||
|
|
f3637b5624 | ||
|
|
3c1d2152aa | ||
|
|
48b22ea41e | ||
|
|
ed76e40862 | ||
|
|
d5b07cc38f | ||
|
|
b9509bb890 | ||
|
|
c87a1049c8 | ||
|
|
e0c22e781d | ||
|
|
f1605d1f27 | ||
|
|
5d7826c26c | ||
|
|
5d075b0cdc | ||
|
|
1a86685f11 | ||
|
|
e31597509c | ||
|
|
c79c137fd6 | ||
|
|
76f735ed39 | ||
|
|
2298791aeb | ||
|
|
9f48ccc66b | ||
|
|
398479d2e7 | ||
|
|
3d6d815373 | ||
|
|
287bf50a46 | ||
|
|
677e872320 | ||
|
|
d8ecfd6fa3 | ||
|
|
9a6074868b | ||
|
|
077ecf38a4 | ||
|
|
7d6aa276ec | ||
|
|
115e69fad3 | ||
|
|
3e04f76c81 | ||
|
|
83eeb88eab | ||
|
|
a05570ed37 | ||
|
|
058a846978 | ||
|
|
04cd95824e | ||
|
|
678b95f60d | ||
|
|
599f513624 | ||
|
|
e8ce471f6f | ||
|
|
4e727748af | ||
|
|
79c5bbff68 | ||
|
|
a95b600a00 | ||
|
|
464e952a1e | ||
|
|
018ca18a4e | ||
|
|
4b89e7561f | ||
|
|
da08e721c2 | ||
|
|
d037e96a0c | ||
|
|
050dd30d8f | ||
|
|
3dd41030d3 | ||
|
|
27228f093d | ||
|
|
83f3241e26 | ||
|
|
e153aefc13 |
@@ -1,4 +1,4 @@
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
|
||||
module.exports = createConfig('eslint');
|
||||
|
||||
13
.github/workflows/ci.yml
vendored
@@ -11,15 +11,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
- name: Setup Nodejs
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VER }}
|
||||
node-version-file: '.nvmrc'
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Validate package-lock.json changes
|
||||
@@ -33,4 +31,7 @@ jobs:
|
||||
- name: i18n_extract
|
||||
run: npm run i18n_extract
|
||||
- name: Coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: true
|
||||
|
||||
10
.github/workflows/release.yml
vendored
@@ -3,19 +3,20 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- alpha
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VER }}
|
||||
- name: Install dependencies
|
||||
@@ -29,7 +30,10 @@ jobs:
|
||||
- name: i18n_extract
|
||||
run: npm run i18n_extract
|
||||
- name: Coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Release
|
||||
|
||||
1
.gitignore
vendored
@@ -9,3 +9,4 @@ module.config.js
|
||||
.idea/
|
||||
|
||||
.vscode
|
||||
src/i18n/messages
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"branch": "master",
|
||||
"branches": [
|
||||
"master",
|
||||
{name: "alpha", prerelease: true}
|
||||
],
|
||||
"tagFormat": "v${version}",
|
||||
"verifyConditions": [
|
||||
"@semantic-release/npm",
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[o:open-edx:p:edx-platform:r:frontend-component-header]
|
||||
file_filter = src/i18n/messages/<lang>.json
|
||||
source_file = src/i18n/transifex_input.json
|
||||
source_lang = en
|
||||
type = KEYVALUEJSON
|
||||
|
||||
22
Makefile
@@ -1,12 +1,9 @@
|
||||
export TRANSIFEX_RESOURCE = frontend-component-header
|
||||
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fr_CA"
|
||||
|
||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||
i18n = ./src/i18n
|
||||
transifex_input = $(i18n)/transifex_input.json
|
||||
|
||||
# This directory must match .babelrc .
|
||||
transifex_temp = ./temp/babel-plugin-react-intl
|
||||
transifex_temp = ./temp/babel-plugin-formatjs
|
||||
|
||||
build:
|
||||
rm -rf ./dist
|
||||
@@ -17,7 +14,7 @@ build:
|
||||
@rm -rf dist/__mocks__
|
||||
|
||||
requirements:
|
||||
npm install
|
||||
npm ci
|
||||
|
||||
i18n.extract:
|
||||
# Pulling display strings from .jsx files into .json files...
|
||||
@@ -35,21 +32,6 @@ detect_changed_source_translations:
|
||||
# Checking for changed translations...
|
||||
git diff --exit-code $(i18n)
|
||||
|
||||
# Pushes translations to Transifex. You must run make extract_translations first.
|
||||
push_translations:
|
||||
# Pushing strings to Transifex...
|
||||
tx push -s
|
||||
# Fetching hashes from Transifex...
|
||||
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
|
||||
# Writing out comments to file...
|
||||
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
|
||||
# Pushing comments to Transifex...
|
||||
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
|
||||
|
||||
# Pulls translations from Transifex.
|
||||
pull_translations:
|
||||
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
|
||||
|
||||
# This target is used by Travis.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
# Checking for package-lock.json changes...
|
||||
|
||||
128
README.rst
@@ -2,22 +2,42 @@
|
||||
frontend-component-header
|
||||
#########################
|
||||
|
||||
|Build Status| |Codecov| |npm_version| |npm_downloads| |license| |semantic-release|
|
||||
|license| |Build Status| |Codecov| |npm_version| |npm_downloads| |semantic-release|
|
||||
|
||||
********
|
||||
Overview
|
||||
Purpose
|
||||
********
|
||||
|
||||
A generic header for Open edX micro-frontend applications.
|
||||
|
||||
************
|
||||
Requirements
|
||||
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
|
||||
============
|
||||
|
||||
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
|
||||
=====================
|
||||
====================
|
||||
|
||||
* ``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.
|
||||
@@ -32,9 +52,8 @@ Environment Variables
|
||||
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.
|
||||
|
||||
************
|
||||
Installation
|
||||
************
|
||||
============
|
||||
|
||||
To install this header into your Open edX micro-frontend, run the following command in your MFE:
|
||||
|
||||
@@ -42,9 +61,33 @@ 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.
|
||||
|
||||
*****
|
||||
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
|
||||
*****
|
||||
=====
|
||||
|
||||
This library has the following exports:
|
||||
|
||||
@@ -52,16 +95,20 @@ This library has the following exports:
|
||||
* ``messages``: Internationalization messages suitable for use with `@edx/frontend-platform/i18n <https://edx.github.io/frontend-platform/module-Internationalization.html>`_
|
||||
* ``dist/index.scss``: A SASS file which contains style information for the component. It should be imported into the micro-frontend's own SCSS file.
|
||||
|
||||
Plugins
|
||||
-------
|
||||
This can be customized using `Frontend Plugin Framework <https://github.com/openedx/frontend-plugin-framework>`_.
|
||||
|
||||
The parts of this that can be customized in that manner are documented `here </src/plugin-slots>`_.
|
||||
|
||||
Examples
|
||||
========
|
||||
|
||||
* `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>`_
|
||||
|
||||
|
||||
***********
|
||||
Development
|
||||
***********
|
||||
===========
|
||||
|
||||
Install dependencies::
|
||||
|
||||
@@ -75,6 +122,63 @@ Build a production distribution::
|
||||
|
||||
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
|
||||
:target: https://travis-ci.com/edx/frontend-component-header
|
||||
.. |Codecov| image:: https://img.shields.io/codecov/c/github/edx/frontend-component-header
|
||||
@@ -86,4 +190,4 @@ Build a production distribution::
|
||||
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-component-header.svg
|
||||
:target: @edx/frontend-component-header
|
||||
.. |semantic-release| image:: https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg
|
||||
:target: https://github.com/semantic-release/semantic-release
|
||||
:target: https://github.com/semantic-release/semantic-release
|
||||
@@ -1,3 +1,3 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
|
||||
module.exports = createConfig('babel-preserve-modules');
|
||||
|
||||
BIN
docs/images/desktop_header.png
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
docs/images/mobile_main_menu.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
docs/images/mobile_user_menu.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
111
docs/using_custom_header.rst
Normal file
@@ -0,0 +1,111 @@
|
||||
.. title:: Custom Header Component Documentation
|
||||
|
||||
Custom Header Component
|
||||
=======================
|
||||
|
||||
Overview
|
||||
--------
|
||||
|
||||
The ``Header`` component is used to display a header with a provided ``mainMenuItems``,
|
||||
``secondaryMenuItems``, and ``userMenuItems`` props. If props are provided, the component will use them; otherwise,
|
||||
if any of the props ``(mainMenuItems, secondaryMenuItems, userMenuItems)`` are not provided, default
|
||||
items will be displayed. This component provides flexibility in customization, making it suitable for a wide
|
||||
range of applications.
|
||||
|
||||
Props Details
|
||||
-------------
|
||||
|
||||
The `Header` component accepts the following **optional** props for customization:
|
||||
|
||||
``mainMenuItems``
|
||||
*****************
|
||||
|
||||
The main menu items is a list of menu items objects. On desktop screens, these items are displayed on the left side next to the logo icon.
|
||||
On mobile screens, the main menu is displayed as a dropdown menu triggered by a hamburger icon. The main menu dropdown appears below the logo when opened.
|
||||
|
||||
Example:
|
||||
::
|
||||
|
||||
[
|
||||
{ type: 'item', href: '/courses', content: 'Courses', isActive: true },
|
||||
{ type: 'item', href: '/programs', content: 'Programs' },
|
||||
{ type: 'item', href: '/discover', content: 'Discover New', disabled, true },
|
||||
{
|
||||
type: 'submenu',
|
||||
content: 'Sub Menu Item',
|
||||
submenuContent: (
|
||||
<>
|
||||
<div className="mb-1"><a rel="noopener" href="#">Submenu item 1</a></div>
|
||||
<div className="mb-1"><a rel="noopener" href="#">Submenu item 2</a></div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
**Submenu Implementation**
|
||||
|
||||
To implement a submenu, set the type to ``submenu`` and provide a ``submenuContent`` property.
|
||||
The submenuContent should be a React component (as shown in above example) that can be rendered.
|
||||
|
||||
**Note:**
|
||||
|
||||
- The ``type`` should be ``item`` or ``submenu``. If type is ``submenu``, it should contain ``submenuContent`` instead of ``href``.
|
||||
|
||||
- If any item is to be disabled, we can pass optional ``disabled: true`` in that item object and
|
||||
|
||||
- If any item is to be active, we can pass optional ``isActive: true`` in that item object
|
||||
|
||||
secondaryMenuItems
|
||||
******************
|
||||
|
||||
The secondary menu items has same structure as ``mainMenuItems``. On desktop screen, these items are displayed on the right of header just before the userMenu avatar and on mobile screen,
|
||||
these items are displayed below the mainMenu items in dropdown.
|
||||
|
||||
Example:
|
||||
::
|
||||
|
||||
[
|
||||
{ type: 'item', href: '/help', content: 'Help' },
|
||||
]
|
||||
|
||||
userMenuItems
|
||||
*************
|
||||
|
||||
The user menu items is list of objects. On desktop screens, these items are displayed as a dropdown menu on the most right side of the header. The dropdown is opened by clicking on the avatar icon, which is typically located at the far right of the header.
|
||||
On mobile screens, the user menu is also displayed as a dropdown menu, appearing under the avatar icon.
|
||||
|
||||
Each object represents a group in the user menu. Each object contains the ``heading`` and
|
||||
list of menu items to be displayed in that group. Heading is optional and will be displayed only if passed. There can
|
||||
be multiple groups. For a normal user menu, a single group can be passed with empty heading.
|
||||
|
||||
Example:
|
||||
::
|
||||
|
||||
[
|
||||
{
|
||||
heading: '',
|
||||
items: [
|
||||
{ type: 'item', href: '/profile', content: 'Profile' },
|
||||
{ type: 'item', href: '/logout', content: 'Logout' }
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
Screenshots
|
||||
***********
|
||||
|
||||
Desktop:
|
||||
|
||||
.. image:: ./images/desktop_header.png
|
||||
|
||||
Mobile:
|
||||
|
||||
.. image:: ./images/mobile_main_menu.png
|
||||
.. image:: ./images/mobile_user_menu.png
|
||||
|
||||
Some Important Notes
|
||||
--------------------
|
||||
|
||||
- Intl formatted strings should be passed in content attribute.
|
||||
- Only menu items in the main menu can be disabled.
|
||||
- Menu items in the main menu and user menu can have ``isActive`` prop.
|
||||
@@ -4,10 +4,10 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { initialize, getConfig, subscribe, APP_READY } from '@edx/frontend-platform';
|
||||
import { AppContext, AppProvider } from '@edx/frontend-platform/react';
|
||||
// import Header from '@edx/frontend-component-header';
|
||||
import { LearningHeader as Header } from '@edx/frontend-component-header';
|
||||
import Header from '@edx/frontend-component-header';
|
||||
|
||||
import './index.scss';
|
||||
import StudioHeader from '../src/studio-header/StudioHeader';
|
||||
|
||||
subscribe(APP_READY, () => {
|
||||
ReactDOM.render(
|
||||
@@ -33,7 +33,35 @@ subscribe(APP_READY, () => {
|
||||
}}>
|
||||
<Header />
|
||||
</AppContext.Provider>
|
||||
<h5 className="mt-2">Logged in state</h5>
|
||||
<h5 className="mt-2 mb-5">Logged in state</h5>
|
||||
<AppContext.Provider value={{
|
||||
authenticatedUser: {
|
||||
userId: '123abc',
|
||||
username: 'testuser',
|
||||
roles: [],
|
||||
administrator: false,
|
||||
},
|
||||
config: getConfig(),
|
||||
}}>
|
||||
<StudioHeader
|
||||
number="run123"
|
||||
org="testX"
|
||||
title="Course Name"
|
||||
isHiddenMainMenu={false}
|
||||
mainMenuDropdowns={[
|
||||
{
|
||||
id: 'content-dropdown',
|
||||
buttonTitle: 'Content',
|
||||
items: [{
|
||||
href: '#',
|
||||
title: 'Outline',
|
||||
}],
|
||||
},
|
||||
]}
|
||||
outlineLink="#"
|
||||
/>
|
||||
</AppContext.Provider>
|
||||
<h5 className="mt-2">Logged in state for Studio header</h5>
|
||||
</AppProvider>,
|
||||
document.getElementById('root'),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@import "@edx/brand/paragon/fonts";
|
||||
@import "@edx/brand/paragon/variables";
|
||||
@import "@edx/paragon/scss/core/core";
|
||||
@import "@openedx/paragon/scss/core/core";
|
||||
@import "@edx/brand/paragon/overrides";
|
||||
|
||||
@import "@edx/frontend-component-header/index";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
|
||||
module.exports = createConfig('jest', {
|
||||
setupFilesAfterEnv: [
|
||||
|
||||
18046
package-lock.json
generated
50
package.json
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "make build",
|
||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
||||
"i18n_extract": "fedx-scripts formatjs extract",
|
||||
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
|
||||
"snapshot": "fedx-scripts jest --updateSnapshot",
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
@@ -33,51 +33,47 @@
|
||||
},
|
||||
"homepage": "https://github.com/openedx/frontend-component-header#readme",
|
||||
"devDependencies": {
|
||||
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/browserslist-config": "^1.1.1",
|
||||
"@edx/frontend-build": "12.8.61",
|
||||
"@edx/frontend-platform": "4.6.0",
|
||||
"@edx/frontend-platform": "8.1.2",
|
||||
"@edx/reactifex": "^2.1.1",
|
||||
"@testing-library/dom": "9.3.1",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@openedx/frontend-build": "14.1.5",
|
||||
"@openedx/paragon": "22.9.0",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
"@testing-library/jest-dom": "5.17.0",
|
||||
"@testing-library/react": "10.4.9",
|
||||
"@wojtekmaj/enzyme-adapter-react-17": "0.8.0",
|
||||
"enzyme": "3.11.0",
|
||||
"husky": "8.0.3",
|
||||
"jest": "29.6.1",
|
||||
"jest": "29.7.0",
|
||||
"jest-chain": "1.1.6",
|
||||
"prop-types": "15.8.1",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react-redux": "7.2.9",
|
||||
"react-router-dom": "5.3.4",
|
||||
"react-router-dom": "6.28.0",
|
||||
"react-test-renderer": "17.0.2",
|
||||
"redux": "4.2.1",
|
||||
"redux-saga": "1.2.3"
|
||||
"redux-saga": "1.3.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/paragon": "20.45.0",
|
||||
"@fortawesome/fontawesome-svg-core": "6.4.0",
|
||||
"@fortawesome/free-brands-svg-icons": "6.4.0",
|
||||
"@fortawesome/free-regular-svg-icons": "6.4.0",
|
||||
"@fortawesome/free-solid-svg-icons": "6.4.0",
|
||||
"@fortawesome/fontawesome-svg-core": "6.6.0",
|
||||
"@fortawesome/free-brands-svg-icons": "6.6.0",
|
||||
"@fortawesome/free-regular-svg-icons": "6.6.0",
|
||||
"@fortawesome/free-solid-svg-icons": "6.6.0",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@reduxjs/toolkit": "1.9.5",
|
||||
"axios-mock-adapter": "1.21.5",
|
||||
"@openedx/frontend-plugin-framework": "^1.3.0",
|
||||
"axios-mock-adapter": "1.22.0",
|
||||
"babel-polyfill": "6.26.0",
|
||||
"classnames": "2.3.2",
|
||||
"lodash": "4.17.21",
|
||||
"react-redux": "7.2.9",
|
||||
"classnames": "^2.5.1",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"react-responsive": "8.2.0",
|
||||
"react-router-dom": "5.3.4",
|
||||
"react-transition-group": "4.4.5",
|
||||
"rosie": "2.1.0",
|
||||
"timeago.js": "4.0.2"
|
||||
"react-transition-group": "4.4.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-platform": "^4.0.0",
|
||||
"@edx/frontend-platform": "^7.0.0 || ^8.0.0",
|
||||
"@openedx/paragon": ">= 21.5.7 < 23.0.0",
|
||||
"prop-types": "^15.5.10",
|
||||
"react": "^16.9.0 || ^17.0.0",
|
||||
"react-dom": "^16.9.0 || ^17.0.0"
|
||||
"react-dom": "^16.9.0 || ^17.0.0",
|
||||
"react-router-dom": "^6.14.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": ["@edx"],
|
||||
"matchPackagePatterns": ["@edx", "@openedx"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": true
|
||||
}
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
// Local Components
|
||||
import { Menu, MenuTrigger, MenuContent } from './Menu';
|
||||
import Avatar from './Avatar';
|
||||
import { LinkedLogo, Logo } from './Logo';
|
||||
|
||||
// i18n
|
||||
import messages from './Header.messages';
|
||||
|
||||
// Assets
|
||||
import { CaretIcon } from './Icons';
|
||||
|
||||
class DesktopHeader extends React.Component {
|
||||
constructor(props) { // eslint-disable-line no-useless-constructor
|
||||
super(props);
|
||||
}
|
||||
|
||||
renderMainMenu() {
|
||||
const { mainMenu } = this.props;
|
||||
|
||||
// Nodes are accepted as a prop
|
||||
if (!Array.isArray(mainMenu)) {
|
||||
return mainMenu;
|
||||
}
|
||||
|
||||
return mainMenu.map((menuItem) => {
|
||||
const {
|
||||
type,
|
||||
href,
|
||||
content,
|
||||
submenuContent,
|
||||
} = menuItem;
|
||||
|
||||
if (type === 'item') {
|
||||
return (
|
||||
<a key={`${type}-${content}`} className="nav-link" href={href}>{content}</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu key={`${type}-${content}`} tag="div" className="nav-item" respondToPointerEvents>
|
||||
<MenuTrigger tag="a" className="nav-link d-inline-flex align-items-center" href={href}>
|
||||
{content} <CaretIcon role="img" aria-hidden focusable="false" />
|
||||
</MenuTrigger>
|
||||
<MenuContent className="pin-left pin-right shadow py-2">
|
||||
{submenuContent}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Renders an optional App Menu for
|
||||
renderAppMenu() {
|
||||
const { appMenu } = this.props;
|
||||
const { content: appMenuContent, menuItems } = appMenu;
|
||||
return (
|
||||
<Menu transitionClassName="menu-dropdown" transitionTimeout={250}>
|
||||
<MenuTrigger tag="a" className="nav-link d-inline-flex align-items-center">
|
||||
{appMenuContent} <CaretIcon role="img" aria-hidden focusable="false" />
|
||||
</MenuTrigger>
|
||||
<MenuContent className="mb-0 dropdown-menu show dropdown-menu-right pin-right shadow py-2">
|
||||
{menuItems.map(({ type, href, content }) => (
|
||||
<a className={`dropdown-${type}`} key={`${type}-${content}`} href={href}>{content}</a>
|
||||
))}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
appMenu,
|
||||
} = 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} />}
|
||||
<nav
|
||||
aria-label={intl.formatMessage(messages['header.label.main.nav'])}
|
||||
className="nav main-nav"
|
||||
>
|
||||
{this.renderMainMenu()}
|
||||
</nav>
|
||||
{appMenu ? (
|
||||
<nav
|
||||
aria-label={intl.formatMessage(messages['header.label.app.nav'])}
|
||||
className="nav app-nav"
|
||||
>
|
||||
{this.renderAppMenu()}
|
||||
</nav>
|
||||
) : null}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DesktopHeader.propTypes = {
|
||||
mainMenu: PropTypes.oneOfType([
|
||||
PropTypes.node,
|
||||
PropTypes.array,
|
||||
]),
|
||||
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,
|
||||
|
||||
// i18n
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
// appMenu
|
||||
appMenu: PropTypes.shape(
|
||||
{
|
||||
content: PropTypes.string,
|
||||
menuItems: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
type: PropTypes.string,
|
||||
href: PropTypes.string,
|
||||
content: PropTypes.string,
|
||||
}),
|
||||
),
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
DesktopHeader.defaultProps = {
|
||||
mainMenu: [],
|
||||
userMenu: [],
|
||||
loggedOutItems: [],
|
||||
logo: null,
|
||||
logoAltText: null,
|
||||
logoDestination: null,
|
||||
avatar: null,
|
||||
username: null,
|
||||
loggedIn: false,
|
||||
appMenu: null,
|
||||
};
|
||||
|
||||
export default injectIntl(DesktopHeader);
|
||||
123
src/Header.jsx
@@ -10,8 +10,9 @@ import {
|
||||
subscribe,
|
||||
} from '@edx/frontend-platform';
|
||||
|
||||
import DesktopHeader from './DesktopHeader';
|
||||
import MobileHeader from './MobileHeader';
|
||||
import PropTypes from 'prop-types';
|
||||
import DesktopHeaderSlot from './plugin-slots/DesktopHeaderSlot';
|
||||
import MobileHeaderSlot from './plugin-slots/MobileHeaderSlot';
|
||||
|
||||
import messages from './Header.messages';
|
||||
|
||||
@@ -30,50 +31,68 @@ subscribe(APP_CONFIG_INITIALIZED, () => {
|
||||
}, 'Header additional config');
|
||||
});
|
||||
|
||||
const Header = ({ intl }) => {
|
||||
/**
|
||||
* Header component for the application.
|
||||
* Displays a header with the provided main menu, secondary menu, and user menu when the user is authenticated.
|
||||
* If any of the props (mainMenuItems, secondaryMenuItems, userMenuItems) are not provided, default
|
||||
* items are displayed.
|
||||
* For more details on how to use this component, please refer to this document:
|
||||
* https://github.com/openedx/frontend-component-header/blob/master/docs/using_custom_header.rst
|
||||
*
|
||||
* @param {list} mainMenuItems - The list of main menu items to display.
|
||||
* See the documentation for the structure of main menu item.
|
||||
* @param {list} secondaryMenuItems - The list of secondary menu items to display.
|
||||
* See the documentation for the structure of secondary menu item.
|
||||
* @param {list} userMenuItems - The list of user menu items to display.
|
||||
* See the documentation for the structure of user menu item.
|
||||
*/
|
||||
const Header = ({
|
||||
intl, mainMenuItems, secondaryMenuItems, userMenuItems,
|
||||
}) => {
|
||||
const { authenticatedUser, config } = useContext(AppContext);
|
||||
|
||||
const mainMenu = [
|
||||
const defaultMainMenu = [
|
||||
{
|
||||
type: 'item',
|
||||
href: `${config.LMS_BASE_URL}/dashboard`,
|
||||
content: intl.formatMessage(messages['header.links.courses']),
|
||||
},
|
||||
];
|
||||
const defaultUserMenu = authenticatedUser === null ? [] : [{
|
||||
heading: '',
|
||||
items: [
|
||||
{
|
||||
type: 'item',
|
||||
href: `${config.LMS_BASE_URL}/dashboard`,
|
||||
content: intl.formatMessage(messages['header.user.menu.dashboard']),
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
href: `${config.ACCOUNT_PROFILE_URL}/u/${authenticatedUser.username}`,
|
||||
content: intl.formatMessage(messages['header.user.menu.profile']),
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
href: config.ACCOUNT_SETTINGS_URL,
|
||||
content: intl.formatMessage(messages['header.user.menu.account.settings']),
|
||||
},
|
||||
// Users should only see Order History if have a ORDER_HISTORY_URL define in the environment.
|
||||
...(config.ORDER_HISTORY_URL ? [{
|
||||
type: 'item',
|
||||
href: config.ORDER_HISTORY_URL,
|
||||
content: intl.formatMessage(messages['header.user.menu.order.history']),
|
||||
}] : []),
|
||||
{
|
||||
type: 'item',
|
||||
href: config.LOGOUT_URL,
|
||||
content: intl.formatMessage(messages['header.user.menu.logout']),
|
||||
},
|
||||
],
|
||||
}];
|
||||
|
||||
const orderHistoryItem = {
|
||||
type: 'item',
|
||||
href: config.ORDER_HISTORY_URL,
|
||||
content: intl.formatMessage(messages['header.user.menu.order.history']),
|
||||
};
|
||||
|
||||
const userMenu = authenticatedUser === null ? [] : [
|
||||
{
|
||||
type: 'item',
|
||||
href: `${config.LMS_BASE_URL}/dashboard`,
|
||||
content: intl.formatMessage(messages['header.user.menu.dashboard']),
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
href: `${config.ACCOUNT_PROFILE_URL}/u/${authenticatedUser.username}`,
|
||||
content: intl.formatMessage(messages['header.user.menu.profile']),
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
href: config.ACCOUNT_SETTINGS_URL,
|
||||
content: intl.formatMessage(messages['header.user.menu.account.settings']),
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
href: config.LOGOUT_URL,
|
||||
content: intl.formatMessage(messages['header.user.menu.logout']),
|
||||
},
|
||||
];
|
||||
|
||||
// Users should only see Order History if have a ORDER_HISTORY_URL define in the environment.
|
||||
if (config.ORDER_HISTORY_URL) {
|
||||
userMenu.splice(-1, 0, orderHistoryItem);
|
||||
}
|
||||
const mainMenu = mainMenuItems || defaultMainMenu;
|
||||
const secondaryMenu = secondaryMenuItems || [];
|
||||
const userMenu = authenticatedUser === null ? [] : userMenuItems || defaultUserMenu;
|
||||
|
||||
const loggedOutItems = [
|
||||
{
|
||||
@@ -96,24 +115,48 @@ const Header = ({ intl }) => {
|
||||
username: authenticatedUser !== null ? authenticatedUser.username : null,
|
||||
avatar: authenticatedUser !== null ? authenticatedUser.avatar : null,
|
||||
mainMenu: getConfig().AUTHN_MINIMAL_HEADER ? [] : mainMenu,
|
||||
secondaryMenu: getConfig().AUTHN_MINIMAL_HEADER ? [] : secondaryMenu,
|
||||
userMenu: getConfig().AUTHN_MINIMAL_HEADER ? [] : userMenu,
|
||||
loggedOutItems: getConfig().AUTHN_MINIMAL_HEADER ? [] : loggedOutItems,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Responsive maxWidth={768}>
|
||||
<MobileHeader {...props} />
|
||||
<Responsive maxWidth={769}>
|
||||
<MobileHeaderSlot props={props} />
|
||||
</Responsive>
|
||||
<Responsive minWidth={769}>
|
||||
<DesktopHeader {...props} />
|
||||
<DesktopHeaderSlot props={props} />
|
||||
</Responsive>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Header.defaultProps = {
|
||||
mainMenuItems: null,
|
||||
secondaryMenuItems: null,
|
||||
userMenuItems: null,
|
||||
};
|
||||
|
||||
Header.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
mainMenuItems: PropTypes.oneOfType([
|
||||
PropTypes.node,
|
||||
PropTypes.array,
|
||||
]),
|
||||
secondaryMenuItems: PropTypes.oneOfType([
|
||||
PropTypes.node,
|
||||
PropTypes.array,
|
||||
]),
|
||||
userMenuItems: PropTypes.arrayOf(PropTypes.shape({
|
||||
heading: PropTypes.string,
|
||||
items: PropTypes.arrayOf(PropTypes.shape({
|
||||
type: PropTypes.oneOf(['item', 'menu']),
|
||||
href: PropTypes.string,
|
||||
content: PropTypes.string,
|
||||
isActive: PropTypes.bool,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
export default injectIntl(Header);
|
||||
|
||||
@@ -61,11 +61,6 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Studio Home',
|
||||
description: 'Link to the Studio Home',
|
||||
},
|
||||
'header.user.menu.studio.maintenance': {
|
||||
id: 'header.user.menu.studio.maintenance',
|
||||
defaultMessage: 'Maintenance',
|
||||
description: 'Link to the Studio Maintenance',
|
||||
},
|
||||
'header.label.account.nav': {
|
||||
id: 'header.label.account.nav',
|
||||
defaultMessage: 'Account',
|
||||
|
||||
@@ -2,38 +2,24 @@
|
||||
import React from 'react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import TestRenderer from 'react-test-renderer';
|
||||
import { AppContext, AppProvider } from '@edx/frontend-platform/react';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { Context as ResponsiveContext } from 'react-responsive';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import store from './store';
|
||||
|
||||
import Header from './index';
|
||||
|
||||
const HeaderComponent = ({ width, contextValue }) => (
|
||||
<ResponsiveContext.Provider value={width}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<AppProvider store={store}>
|
||||
<AppContext.Provider
|
||||
value={contextValue}
|
||||
>
|
||||
<Header />
|
||||
</AppContext.Provider>
|
||||
</AppProvider>
|
||||
<AppContext.Provider
|
||||
value={contextValue}
|
||||
>
|
||||
<Header />
|
||||
</AppContext.Provider>
|
||||
</IntlProvider>
|
||||
</ResponsiveContext.Provider>
|
||||
);
|
||||
|
||||
describe('<Header />', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: '123abc',
|
||||
username: 'testuser',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
it('renders correctly for anonymous desktop', () => {
|
||||
const contextValue = {
|
||||
authenticatedUser: null,
|
||||
|
||||
18
src/Logo.jsx
@@ -1,31 +1,23 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const Logo = ({ src, alt, ...attributes }) => (
|
||||
<img src={src} alt={alt} {...attributes} />
|
||||
);
|
||||
|
||||
Logo.propTypes = {
|
||||
src: PropTypes.string.isRequired,
|
||||
alt: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
const LinkedLogo = ({
|
||||
const Logo = ({
|
||||
href,
|
||||
src,
|
||||
alt,
|
||||
...attributes
|
||||
}) => (
|
||||
<a href={href} {...attributes}>
|
||||
<a href={href} className="logo" {...attributes}>
|
||||
<img className="d-block" src={src} alt={alt} />
|
||||
</a>
|
||||
);
|
||||
|
||||
LinkedLogo.propTypes = {
|
||||
export const logoDataShape = {
|
||||
href: PropTypes.string.isRequired,
|
||||
src: PropTypes.string.isRequired,
|
||||
alt: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export { LinkedLogo, Logo };
|
||||
Logo.propTypes = logoDataShape;
|
||||
|
||||
export default Logo;
|
||||
|
||||
@@ -14,7 +14,8 @@ MenuTrigger.defaultProps = {
|
||||
tag: 'div',
|
||||
className: null,
|
||||
};
|
||||
const MenuTriggerType = <MenuTrigger />.type;
|
||||
const MenuTriggerComp = <MenuTrigger />;
|
||||
const MenuTriggerType = MenuTriggerComp.type;
|
||||
|
||||
const MenuContent = ({ tag, className, ...attributes }) => React.createElement(tag, {
|
||||
className: ['menu-content', className].join(' '),
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
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 { Link } from 'react-router-dom';
|
||||
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 (
|
||||
<Link
|
||||
target="_blank"
|
||||
className="d-flex mb-2 align-items-center text-decoration-none"
|
||||
to={contentUrl}
|
||||
onClick={handleMarkAsRead}
|
||||
data-testid={`notification-${id}`}
|
||||
>
|
||||
<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>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -1,96 +0,0 @@
|
||||
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.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);
|
||||
@@ -1,53 +0,0 @@
|
||||
/* 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);
|
||||
@@ -1 +0,0 @@
|
||||
import './notifications.factory';
|
||||
@@ -1,31 +0,0 @@
|
||||
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() }));
|
||||
@@ -1,39 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './slice';
|
||||
@@ -1,134 +0,0 @@
|
||||
{
|
||||
"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_Lead’s</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 Learner’s</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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
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;
|
||||
@@ -1,147 +0,0 @@
|
||||
/* 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;
|
||||
@@ -1,135 +0,0 @@
|
||||
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()); }
|
||||
);
|
||||
@@ -1,103 +0,0 @@
|
||||
/* 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());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<OverlayTrigger
|
||||
trigger="click"
|
||||
key="bottom"
|
||||
placement="bottom"
|
||||
id="notificationTray"
|
||||
show={enableNotificationTray}
|
||||
overlay={(
|
||||
<Popover
|
||||
id="notificationTray"
|
||||
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;
|
||||
@@ -1,109 +0,0 @@
|
||||
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());
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
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;
|
||||
@@ -1,87 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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' };
|
||||
};
|
||||
@@ -1,200 +0,0 @@
|
||||
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);
|
||||
@@ -1,108 +0,0 @@
|
||||
/* 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();
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ exports[`<Header /> renders correctly for anonymous desktop 1`] = `
|
||||
<a
|
||||
className="nav-link"
|
||||
href="http://localhost:18000/dashboard"
|
||||
onClick={null}
|
||||
>
|
||||
Courses
|
||||
</a>
|
||||
@@ -93,7 +94,7 @@ exports[`<Header /> renders correctly for anonymous mobile 1`] = `
|
||||
height="24px"
|
||||
role="img"
|
||||
style={
|
||||
Object {
|
||||
{
|
||||
"height": "1.5rem",
|
||||
"width": "1.5rem",
|
||||
}
|
||||
@@ -163,7 +164,7 @@ exports[`<Header /> renders correctly for anonymous mobile 1`] = `
|
||||
<span
|
||||
className="avatar overflow-hidden d-inline-flex rounded-circle null"
|
||||
style={
|
||||
Object {
|
||||
{
|
||||
"height": "1.5rem",
|
||||
"width": "1.5rem",
|
||||
}
|
||||
@@ -175,7 +176,7 @@ exports[`<Header /> renders correctly for anonymous mobile 1`] = `
|
||||
height="24px"
|
||||
role="img"
|
||||
style={
|
||||
Object {
|
||||
{
|
||||
"height": "1.5rem",
|
||||
"width": "1.5rem",
|
||||
}
|
||||
@@ -229,6 +230,7 @@ exports[`<Header /> renders correctly for authenticated desktop 1`] = `
|
||||
<a
|
||||
className="nav-link"
|
||||
href="http://localhost:18000/dashboard"
|
||||
onClick={null}
|
||||
>
|
||||
Courses
|
||||
</a>
|
||||
@@ -253,7 +255,7 @@ exports[`<Header /> renders correctly for authenticated desktop 1`] = `
|
||||
<span
|
||||
className="avatar overflow-hidden d-inline-flex rounded-circle mr-2"
|
||||
style={
|
||||
Object {
|
||||
{
|
||||
"height": "1.5em",
|
||||
"width": "1.5em",
|
||||
}
|
||||
@@ -265,7 +267,7 @@ exports[`<Header /> renders correctly for authenticated desktop 1`] = `
|
||||
height="24px"
|
||||
role="img"
|
||||
style={
|
||||
Object {
|
||||
{
|
||||
"height": "1.5em",
|
||||
"width": "1.5em",
|
||||
}
|
||||
@@ -339,7 +341,7 @@ exports[`<Header /> renders correctly for authenticated mobile 1`] = `
|
||||
height="24px"
|
||||
role="img"
|
||||
style={
|
||||
Object {
|
||||
{
|
||||
"height": "1.5rem",
|
||||
"width": "1.5rem",
|
||||
}
|
||||
@@ -409,7 +411,7 @@ exports[`<Header /> renders correctly for authenticated mobile 1`] = `
|
||||
<span
|
||||
className="avatar overflow-hidden d-inline-flex rounded-circle null"
|
||||
style={
|
||||
Object {
|
||||
{
|
||||
"height": "1.5rem",
|
||||
"width": "1.5rem",
|
||||
}
|
||||
@@ -421,7 +423,7 @@ exports[`<Header /> renders correctly for authenticated mobile 1`] = `
|
||||
height="24px"
|
||||
role="img"
|
||||
style={
|
||||
Object {
|
||||
{
|
||||
"height": "1.5rem",
|
||||
"width": "1.5rem",
|
||||
}
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
// 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>
|
||||
`;
|
||||
@@ -1,18 +0,0 @@
|
||||
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];
|
||||
}
|
||||
153
src/desktop-header/DesktopHeader.jsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
// Local Components
|
||||
import { Menu, MenuTrigger, MenuContent } from '../Menu';
|
||||
import Avatar from '../Avatar';
|
||||
import LogoSlot from '../plugin-slots/LogoSlot';
|
||||
import DesktopLoggedOutItemsSlot from '../plugin-slots/DesktopLoggedOutItemsSlot';
|
||||
import { desktopLoggedOutItemsDataShape } from './DesktopLoggedOutItems';
|
||||
import DesktopMainMenuSlot from '../plugin-slots/DesktopMainMenuSlot';
|
||||
import { desktopHeaderMainOrSecondaryMenuDataShape } from './DesktopHeaderMainOrSecondaryMenu';
|
||||
import DesktopSecondaryMenuSlot from '../plugin-slots/DesktopSecondaryMenuSlot';
|
||||
import DesktopUserMenuSlot from '../plugin-slots/DesktopUserMenuSlot';
|
||||
import { desktopUserMenuDataShape } from './DesktopHeaderUserMenu';
|
||||
|
||||
// i18n
|
||||
import messages from '../Header.messages';
|
||||
|
||||
// Assets
|
||||
import { CaretIcon } from '../Icons';
|
||||
|
||||
class DesktopHeader extends React.Component {
|
||||
constructor(props) { // eslint-disable-line no-useless-constructor
|
||||
super(props);
|
||||
}
|
||||
|
||||
renderMainMenu() {
|
||||
const { mainMenu } = this.props;
|
||||
return <DesktopMainMenuSlot menu={mainMenu} />;
|
||||
}
|
||||
|
||||
renderSecondaryMenu() {
|
||||
const { secondaryMenu } = this.props;
|
||||
return <DesktopSecondaryMenuSlot menu={secondaryMenu} />;
|
||||
}
|
||||
|
||||
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">
|
||||
<DesktopUserMenuSlot menu={userMenu} />
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
renderLoggedOutItems() {
|
||||
const { loggedOutItems } = this.props;
|
||||
return <DesktopLoggedOutItemsSlot items={loggedOutItems} />;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
logo,
|
||||
logoAltText,
|
||||
logoDestination,
|
||||
loggedIn,
|
||||
intl,
|
||||
} = 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">
|
||||
<LogoSlot {...logoProps} />
|
||||
<nav
|
||||
aria-label={intl.formatMessage(messages['header.label.main.nav'])}
|
||||
className="nav main-nav"
|
||||
>
|
||||
{this.renderMainMenu()}
|
||||
</nav>
|
||||
<nav
|
||||
aria-label={intl.formatMessage(messages['header.label.secondary.nav'])}
|
||||
className="nav secondary-menu-container align-items-center ml-auto"
|
||||
>
|
||||
{loggedIn
|
||||
? (
|
||||
<>
|
||||
{this.renderSecondaryMenu()}
|
||||
{this.renderUserMenu()}
|
||||
</>
|
||||
) : this.renderLoggedOutItems()}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const desktopHeaderDataShape = {
|
||||
mainMenu: desktopHeaderMainOrSecondaryMenuDataShape,
|
||||
secondaryMenu: desktopHeaderMainOrSecondaryMenuDataShape,
|
||||
userMenu: desktopUserMenuDataShape,
|
||||
loggedOutItems: desktopLoggedOutItemsDataShape,
|
||||
logo: PropTypes.string,
|
||||
logoAltText: PropTypes.string,
|
||||
logoDestination: PropTypes.string,
|
||||
avatar: PropTypes.string,
|
||||
username: PropTypes.string,
|
||||
loggedIn: PropTypes.bool,
|
||||
};
|
||||
|
||||
DesktopHeader.propTypes = {
|
||||
mainMenu: desktopHeaderDataShape.mainMenu,
|
||||
secondaryMenu: desktopHeaderDataShape.secondaryMenumainMenu,
|
||||
userMenu: desktopHeaderDataShape.userMenumainMenu,
|
||||
loggedOutItems: desktopHeaderDataShape.loggedOutItemsmainMenu,
|
||||
logo: desktopHeaderDataShape.logomainMenu,
|
||||
logoAltText: desktopHeaderDataShape.logoAltTextmainMenu,
|
||||
logoDestination: desktopHeaderDataShape.logoDestinationmainMenu,
|
||||
avatar: desktopHeaderDataShape.avatarmainMenu,
|
||||
username: desktopHeaderDataShape.usernamemainMenu,
|
||||
loggedIn: desktopHeaderDataShape.loggedInmainMenu,
|
||||
|
||||
// i18n
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
DesktopHeader.defaultProps = {
|
||||
mainMenu: [],
|
||||
secondaryMenu: [],
|
||||
userMenu: [],
|
||||
loggedOutItems: [],
|
||||
logo: null,
|
||||
logoAltText: null,
|
||||
logoDestination: null,
|
||||
avatar: null,
|
||||
username: null,
|
||||
loggedIn: false,
|
||||
};
|
||||
|
||||
export default injectIntl(DesktopHeader);
|
||||
59
src/desktop-header/DesktopHeaderMainOrSecondaryMenu.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Menu, MenuTrigger, MenuContent } from '../Menu';
|
||||
import { CaretIcon } from '../Icons';
|
||||
|
||||
const DesktopHeaderMainOrSecondaryMenu = ({ menu }) => {
|
||||
// Nodes are accepted as a prop
|
||||
if (!Array.isArray(menu)) {
|
||||
return menu;
|
||||
}
|
||||
|
||||
return menu.map((menuItem) => {
|
||||
const {
|
||||
type,
|
||||
href,
|
||||
content,
|
||||
submenuContent,
|
||||
disabled,
|
||||
isActive,
|
||||
onClick,
|
||||
} = menuItem;
|
||||
|
||||
if (type === 'item') {
|
||||
return (
|
||||
<a
|
||||
key={`${type}-${content}`}
|
||||
className={`nav-link${disabled ? ' disabled' : ''}${isActive ? ' active' : ''}`}
|
||||
href={href}
|
||||
onClick={onClick || null}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu key={`${type}-${content}`} tag="div" className="nav-item" respondToPointerEvents>
|
||||
<MenuTrigger onClick={onClick || null} tag="a" className="nav-link d-inline-flex align-items-center" href={href}>
|
||||
{content} <CaretIcon role="img" aria-hidden focusable="false" />
|
||||
</MenuTrigger>
|
||||
<MenuContent className="pin-left pin-right shadow py-2">
|
||||
{submenuContent}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const desktopHeaderMainOrSecondaryMenuDataShape = PropTypes.oneOfType([
|
||||
PropTypes.node,
|
||||
PropTypes.array,
|
||||
]);
|
||||
|
||||
DesktopHeaderMainOrSecondaryMenu.propTypes = {
|
||||
menu: desktopHeaderMainOrSecondaryMenuDataShape,
|
||||
};
|
||||
|
||||
export default DesktopHeaderMainOrSecondaryMenu;
|
||||
39
src/desktop-header/DesktopHeaderUserMenu.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const DesktopHeaderUserMenu = ({ menu }) => menu.map((group, index) => (
|
||||
// eslint-disable-next-line react/jsx-no-comment-textnodes,react/no-array-index-key
|
||||
<React.Fragment key={index}>
|
||||
{group.heading && <div className="dropdown-header" role="heading" aria-level="1">{group.heading}</div>}
|
||||
{group.items.map(({
|
||||
type, content, href, disabled, isActive, onClick,
|
||||
}) => (
|
||||
<a
|
||||
className={`dropdown-${type}${isActive ? ' active' : ''}${disabled ? ' disabled' : ''}`}
|
||||
key={`${type}-${content}`}
|
||||
href={href}
|
||||
onClick={onClick || null}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
))}
|
||||
{index < menu.length - 1 && <div className="dropdown-divider" role="separator" />}
|
||||
</React.Fragment>
|
||||
));
|
||||
|
||||
export const desktopUserMenuDataShape = PropTypes.arrayOf(PropTypes.shape({
|
||||
heading: PropTypes.string,
|
||||
items: PropTypes.arrayOf(PropTypes.shape({
|
||||
type: PropTypes.oneOf(['item', 'menu']),
|
||||
href: PropTypes.string,
|
||||
content: PropTypes.string,
|
||||
isActive: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
})),
|
||||
}));
|
||||
|
||||
DesktopHeaderUserMenu.propTypes = {
|
||||
menu: desktopUserMenuDataShape,
|
||||
};
|
||||
|
||||
export default DesktopHeaderUserMenu;
|
||||
24
src/desktop-header/DesktopLoggedOutItems.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const DesktopLoggedOutItems = ({ items }) => items.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>
|
||||
));
|
||||
|
||||
export const desktopLoggedOutItemsDataShape = PropTypes.arrayOf(PropTypes.shape({
|
||||
type: PropTypes.oneOf(['item', 'menu']),
|
||||
href: PropTypes.string,
|
||||
content: PropTypes.string,
|
||||
}));
|
||||
|
||||
DesktopLoggedOutItems.propTypes = {
|
||||
items: desktopLoggedOutItemsDataShape,
|
||||
};
|
||||
|
||||
export default DesktopLoggedOutItems;
|
||||
@@ -1,28 +1 @@
|
||||
import arMessages from './messages/ar.json';
|
||||
import frMessages from './messages/fr.json';
|
||||
import es419Messages from './messages/es_419.json';
|
||||
import zhcnMessages from './messages/zh_CN.json';
|
||||
import ptMessages from './messages/pt.json';
|
||||
import itMessages from './messages/it.json';
|
||||
import ukMessages from './messages/uk.json';
|
||||
import deMessages from './messages/de.json';
|
||||
import ruMessages from './messages/ru.json';
|
||||
import hiMessages from './messages/hi.json';
|
||||
import frCAMessages from './messages/fr_CA.json';
|
||||
// no need to import en messages-- they are in the defaultMessage field
|
||||
|
||||
const messages = {
|
||||
ar: arMessages,
|
||||
'es-419': es419Messages,
|
||||
fr: frMessages,
|
||||
'zh-cn': zhcnMessages,
|
||||
pt: ptMessages,
|
||||
it: itMessages,
|
||||
de: deMessages,
|
||||
hi: hiMessages,
|
||||
'fr-ca': frCAMessages,
|
||||
ru: ruMessages,
|
||||
uk: ukMessages,
|
||||
};
|
||||
|
||||
export default messages;
|
||||
export default {};
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"general.register.sentenceCase": "التسجيل",
|
||||
"general.signIn.sentenceCase": "تسجيل الدخول",
|
||||
"header.links.courses": "المساقات",
|
||||
"header.links.programs": "البرامج",
|
||||
"header.links.content.search": "اكتشف الجديد",
|
||||
"header.links.schools": "المدارس و الشركاء",
|
||||
"header.user.menu.dashboard": "لوحة المعلومات",
|
||||
"header.user.menu.profile": "الملف الشخصي",
|
||||
"header.user.menu.account.settings": "الحساب",
|
||||
"header.user.menu.order.history": "سجل الطلبيات",
|
||||
"header.user.menu.logout": "تسجيل الخروج",
|
||||
"header.user.menu.login": "تسجيل الدخول",
|
||||
"header.user.menu.register": "التسجيل",
|
||||
"header.user.menu.studio.home": "صفحة الاستوديو الرئيسية",
|
||||
"header.user.menu.studio.maintenance": "الصيانة",
|
||||
"header.label.account.nav": "الحساب",
|
||||
"header.label.account.menu": "قائمة الحساب",
|
||||
"header.label.account.menu.for": "قائمة حساب المستخدم {username}",
|
||||
"header.label.main.nav": "القا|مة الرئيسية",
|
||||
"header.label.main.menu": "القائمة الرئيسية",
|
||||
"header.label.main.header": "الرئيسية",
|
||||
"header.label.secondary.nav": "القائمة الثانوية",
|
||||
"header.label.skip.nav": "التخطي إلى المحتوى الرئيسي",
|
||||
"header.label.app.nav": "تطبيق",
|
||||
"header.menu.dashboard.label": "لوحة المعلومات",
|
||||
"header.help.label": "المساعدة",
|
||||
"header.menu.profile.label": "الملف الشخصي",
|
||||
"header.menu.account.label": "الحساب",
|
||||
"header.menu.orderHistory.label": "سجل الطلبيات",
|
||||
"header.navigation.skipNavLink": "التخطي إلى المحتوى الرئيسي",
|
||||
"header.menu.signOut.label": "تسجيل الخروج",
|
||||
"notification.title": "Notifications",
|
||||
"notification.today.heading": "Last 24 hours",
|
||||
"notification.earlier.heading": "Earlier",
|
||||
"notification.mark.as.read": "Mark all as read",
|
||||
"notification.fullStop": "•",
|
||||
"notification.load.more.notifications": "Load more notifications"
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"general.register.sentenceCase": "Register",
|
||||
"general.signIn.sentenceCase": "Sign in",
|
||||
"header.links.courses": "Courses",
|
||||
"header.links.programs": "Programs",
|
||||
"header.links.content.search": "Discover New",
|
||||
"header.links.schools": "Schools & Partners",
|
||||
"header.user.menu.dashboard": "Dashboard",
|
||||
"header.user.menu.profile": "Profile",
|
||||
"header.user.menu.account.settings": "Account",
|
||||
"header.user.menu.order.history": "Order History",
|
||||
"header.user.menu.logout": "Logout",
|
||||
"header.user.menu.login": "Login",
|
||||
"header.user.menu.register": "Sign Up",
|
||||
"header.user.menu.studio.home": "Studio Home",
|
||||
"header.user.menu.studio.maintenance": "Maintenance",
|
||||
"header.label.account.nav": "Account",
|
||||
"header.label.account.menu": "Account Menu",
|
||||
"header.label.account.menu.for": "Account menu for {username}",
|
||||
"header.label.main.nav": "Main",
|
||||
"header.label.main.menu": "Main Menu",
|
||||
"header.label.main.header": "Main",
|
||||
"header.label.secondary.nav": "Secondary",
|
||||
"header.label.skip.nav": "Skip to main content",
|
||||
"header.label.app.nav": "App",
|
||||
"header.menu.dashboard.label": "Dashboard",
|
||||
"header.help.label": "Help",
|
||||
"header.menu.profile.label": "Profile",
|
||||
"header.menu.account.label": "Account",
|
||||
"header.menu.orderHistory.label": "Order History",
|
||||
"header.navigation.skipNavLink": "Skip to main content.",
|
||||
"header.menu.signOut.label": "Sign Out",
|
||||
"notification.title": "Notifications",
|
||||
"notification.today.heading": "Last 24 hours",
|
||||
"notification.earlier.heading": "Earlier",
|
||||
"notification.mark.as.read": "Mark all as read",
|
||||
"notification.fullStop": "•",
|
||||
"notification.load.more.notifications": "Load more notifications"
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"general.register.sentenceCase": "Registrarse",
|
||||
"general.signIn.sentenceCase": "Iniciar sesión",
|
||||
"header.links.courses": "Cursos",
|
||||
"header.links.programs": "Programas",
|
||||
"header.links.content.search": "Encontrar nuevo",
|
||||
"header.links.schools": "Escuelas y Socios",
|
||||
"header.user.menu.dashboard": "Panel de Control",
|
||||
"header.user.menu.profile": "Perfil",
|
||||
"header.user.menu.account.settings": "Cuenta",
|
||||
"header.user.menu.order.history": "Historial de órdenes",
|
||||
"header.user.menu.logout": "Cerrar sesión",
|
||||
"header.user.menu.login": "Login",
|
||||
"header.user.menu.register": "Registrarse",
|
||||
"header.user.menu.studio.home": "Inicio Studio",
|
||||
"header.user.menu.studio.maintenance": "Mantenimiento",
|
||||
"header.label.account.nav": "Cuenta",
|
||||
"header.label.account.menu": "Menú de la cuenta",
|
||||
"header.label.account.menu.for": "Menú de la cuenta para {username}",
|
||||
"header.label.main.nav": "Principal",
|
||||
"header.label.main.menu": "Menú Principal",
|
||||
"header.label.main.header": "Principal",
|
||||
"header.label.secondary.nav": "Secondary",
|
||||
"header.label.skip.nav": "Ir al contenido principal",
|
||||
"header.label.app.nav": "Aplicación",
|
||||
"header.menu.dashboard.label": "Panel de Control",
|
||||
"header.help.label": "Ayuda",
|
||||
"header.menu.profile.label": "Perfil",
|
||||
"header.menu.account.label": "Cuenta",
|
||||
"header.menu.orderHistory.label": "Historial de órdenes",
|
||||
"header.navigation.skipNavLink": "Dirígete al contenido principal.",
|
||||
"header.menu.signOut.label": "Cerrar sesión",
|
||||
"notification.title": "Notificaciones",
|
||||
"notification.today.heading": "Últimas 24 horas",
|
||||
"notification.earlier.heading": "Más temprano",
|
||||
"notification.mark.as.read": "Marcar todo como leído",
|
||||
"notification.fullStop": "•",
|
||||
"notification.load.more.notifications": "Cargar más notificaciones"
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"general.register.sentenceCase": "S'inscrire",
|
||||
"general.signIn.sentenceCase": "Connectez-vous",
|
||||
"header.links.courses": "Cours",
|
||||
"header.links.programs": "Programmes",
|
||||
"header.links.content.search": "Explorer les cours",
|
||||
"header.links.schools": "Écoles et partenaires",
|
||||
"header.user.menu.dashboard": "Tableau de bord",
|
||||
"header.user.menu.profile": "Profil",
|
||||
"header.user.menu.account.settings": "Compte",
|
||||
"header.user.menu.order.history": "Historique des commandes",
|
||||
"header.user.menu.logout": "Déconnexion",
|
||||
"header.user.menu.login": "Connexion",
|
||||
"header.user.menu.register": "S'inscrire",
|
||||
"header.user.menu.studio.home": "Accueil Studio",
|
||||
"header.user.menu.studio.maintenance": "Maintenance",
|
||||
"header.label.account.nav": "Compte",
|
||||
"header.label.account.menu": "Menu du compte",
|
||||
"header.label.account.menu.for": "Menu du compte pour {username}",
|
||||
"header.label.main.nav": "Principal",
|
||||
"header.label.main.menu": "Menu Principal",
|
||||
"header.label.main.header": "Principal",
|
||||
"header.label.secondary.nav": "Secondaire",
|
||||
"header.label.skip.nav": "Passer au contenu principal",
|
||||
"header.label.app.nav": "Application",
|
||||
"header.menu.dashboard.label": "Tableau de bord",
|
||||
"header.help.label": "Aide",
|
||||
"header.menu.profile.label": "Profil",
|
||||
"header.menu.account.label": "Compte",
|
||||
"header.menu.orderHistory.label": "Historique des commandes",
|
||||
"header.navigation.skipNavLink": "Passer au contenu principal",
|
||||
"header.menu.signOut.label": "Se déconnecter",
|
||||
"notification.title": "Notifications",
|
||||
"notification.today.heading": "Last 24 hours",
|
||||
"notification.earlier.heading": "Earlier",
|
||||
"notification.mark.as.read": "Mark all as read",
|
||||
"notification.fullStop": "•",
|
||||
"notification.load.more.notifications": "Load more notifications"
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"general.register.sentenceCase": "Inscription",
|
||||
"general.signIn.sentenceCase": "Connexion",
|
||||
"header.links.courses": "Cours",
|
||||
"header.links.programs": "Programmes",
|
||||
"header.links.content.search": "Découvrir les nouveautés",
|
||||
"header.links.schools": "Écoles et Partenaires",
|
||||
"header.user.menu.dashboard": "Tableau de bord",
|
||||
"header.user.menu.profile": "Profil",
|
||||
"header.user.menu.account.settings": "Compte",
|
||||
"header.user.menu.order.history": "Historique des commandes",
|
||||
"header.user.menu.logout": "Déconnexion",
|
||||
"header.user.menu.login": "Connexion",
|
||||
"header.user.menu.register": "S'inscrire",
|
||||
"header.user.menu.studio.home": "Accueil Studio",
|
||||
"header.user.menu.studio.maintenance": "Entretien",
|
||||
"header.label.account.nav": "Compte",
|
||||
"header.label.account.menu": "Menu de compte",
|
||||
"header.label.account.menu.for": "Menu de compte pour {username}",
|
||||
"header.label.main.nav": "Principal",
|
||||
"header.label.main.menu": "Menu principal",
|
||||
"header.label.main.header": "Principal",
|
||||
"header.label.secondary.nav": "Secondaire",
|
||||
"header.label.skip.nav": "Passer au contenu de cette vue",
|
||||
"header.label.app.nav": "Application",
|
||||
"header.menu.dashboard.label": "Tableau de bord",
|
||||
"header.help.label": "Aide",
|
||||
"header.menu.profile.label": "Profil",
|
||||
"header.menu.account.label": "Compte",
|
||||
"header.menu.orderHistory.label": "Historique des commandes",
|
||||
"header.navigation.skipNavLink": "Passer au contenu principal.",
|
||||
"header.menu.signOut.label": "Se déconnecter",
|
||||
"notification.title": "Notifications",
|
||||
"notification.today.heading": "Dernières 24 heures",
|
||||
"notification.earlier.heading": "Plus tôt",
|
||||
"notification.mark.as.read": "tout marquer comme lu",
|
||||
"notification.fullStop": "•",
|
||||
"notification.load.more.notifications": "Charger plus de notifications"
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"general.register.sentenceCase": "Register",
|
||||
"general.signIn.sentenceCase": "Sign in",
|
||||
"header.links.courses": "Courses",
|
||||
"header.links.programs": "Programs",
|
||||
"header.links.content.search": "Discover New",
|
||||
"header.links.schools": "Schools & Partners",
|
||||
"header.user.menu.dashboard": "Dashboard",
|
||||
"header.user.menu.profile": "Profile",
|
||||
"header.user.menu.account.settings": "Account",
|
||||
"header.user.menu.order.history": "Order History",
|
||||
"header.user.menu.logout": "Logout",
|
||||
"header.user.menu.login": "Login",
|
||||
"header.user.menu.register": "Sign Up",
|
||||
"header.user.menu.studio.home": "Studio Home",
|
||||
"header.user.menu.studio.maintenance": "Maintenance",
|
||||
"header.label.account.nav": "Account",
|
||||
"header.label.account.menu": "Account Menu",
|
||||
"header.label.account.menu.for": "Account menu for {username}",
|
||||
"header.label.main.nav": "Main",
|
||||
"header.label.main.menu": "Main Menu",
|
||||
"header.label.main.header": "Main",
|
||||
"header.label.secondary.nav": "Secondary",
|
||||
"header.label.skip.nav": "Skip to main content",
|
||||
"header.label.app.nav": "App",
|
||||
"header.menu.dashboard.label": "Dashboard",
|
||||
"header.help.label": "Help",
|
||||
"header.menu.profile.label": "Profile",
|
||||
"header.menu.account.label": "Account",
|
||||
"header.menu.orderHistory.label": "Order History",
|
||||
"header.navigation.skipNavLink": "Skip to main content.",
|
||||
"header.menu.signOut.label": "Sign Out",
|
||||
"notification.title": "Notifications",
|
||||
"notification.today.heading": "Last 24 hours",
|
||||
"notification.earlier.heading": "Earlier",
|
||||
"notification.mark.as.read": "Mark all as read",
|
||||
"notification.fullStop": "•",
|
||||
"notification.load.more.notifications": "Load more notifications"
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"general.register.sentenceCase": "Register",
|
||||
"general.signIn.sentenceCase": "Sign in",
|
||||
"header.links.courses": "Courses",
|
||||
"header.links.programs": "Programs",
|
||||
"header.links.content.search": "Discover New",
|
||||
"header.links.schools": "Schools & Partners",
|
||||
"header.user.menu.dashboard": "Dashboard",
|
||||
"header.user.menu.profile": "Profile",
|
||||
"header.user.menu.account.settings": "Account",
|
||||
"header.user.menu.order.history": "Order History",
|
||||
"header.user.menu.logout": "Logout",
|
||||
"header.user.menu.login": "Login",
|
||||
"header.user.menu.register": "Sign Up",
|
||||
"header.user.menu.studio.home": "Studio Home",
|
||||
"header.user.menu.studio.maintenance": "Maintenance",
|
||||
"header.label.account.nav": "Account",
|
||||
"header.label.account.menu": "Account Menu",
|
||||
"header.label.account.menu.for": "Account menu for {username}",
|
||||
"header.label.main.nav": "Main",
|
||||
"header.label.main.menu": "Main Menu",
|
||||
"header.label.main.header": "Main",
|
||||
"header.label.secondary.nav": "Secondary",
|
||||
"header.label.skip.nav": "Skip to main content",
|
||||
"header.label.app.nav": "App",
|
||||
"header.menu.dashboard.label": "Dashboard",
|
||||
"header.help.label": "Help",
|
||||
"header.menu.profile.label": "Profile",
|
||||
"header.menu.account.label": "Account",
|
||||
"header.menu.orderHistory.label": "Order History",
|
||||
"header.navigation.skipNavLink": "Skip to main content.",
|
||||
"header.menu.signOut.label": "Sign Out",
|
||||
"notification.title": "Notifications",
|
||||
"notification.today.heading": "Last 24 hours",
|
||||
"notification.earlier.heading": "Earlier",
|
||||
"notification.mark.as.read": "Mark all as read",
|
||||
"notification.fullStop": "•",
|
||||
"notification.load.more.notifications": "Load more notifications"
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"general.register.sentenceCase": "Register",
|
||||
"general.signIn.sentenceCase": "Sign in",
|
||||
"header.links.courses": "Courses",
|
||||
"header.links.programs": "Programs",
|
||||
"header.links.content.search": "Discover New",
|
||||
"header.links.schools": "Schools & Partners",
|
||||
"header.user.menu.dashboard": "Dashboard",
|
||||
"header.user.menu.profile": "Profile",
|
||||
"header.user.menu.account.settings": "Account",
|
||||
"header.user.menu.order.history": "Order History",
|
||||
"header.user.menu.logout": "Logout",
|
||||
"header.user.menu.login": "Login",
|
||||
"header.user.menu.register": "Sign Up",
|
||||
"header.user.menu.studio.home": "Studio Home",
|
||||
"header.user.menu.studio.maintenance": "Maintenance",
|
||||
"header.label.account.nav": "Account",
|
||||
"header.label.account.menu": "Account Menu",
|
||||
"header.label.account.menu.for": "Account menu for {username}",
|
||||
"header.label.main.nav": "Main",
|
||||
"header.label.main.menu": "Main Menu",
|
||||
"header.label.main.header": "Main",
|
||||
"header.label.secondary.nav": "Secondary",
|
||||
"header.label.skip.nav": "Skip to main content",
|
||||
"header.label.app.nav": "App",
|
||||
"header.menu.dashboard.label": "Dashboard",
|
||||
"header.help.label": "Help",
|
||||
"header.menu.profile.label": "Profile",
|
||||
"header.menu.account.label": "Account",
|
||||
"header.menu.orderHistory.label": "Order History",
|
||||
"header.navigation.skipNavLink": "Skip to main content.",
|
||||
"header.menu.signOut.label": "Sign Out",
|
||||
"notification.title": "Notifications",
|
||||
"notification.today.heading": "Last 24 hours",
|
||||
"notification.earlier.heading": "Earlier",
|
||||
"notification.mark.as.read": "Mark all as read",
|
||||
"notification.fullStop": "•",
|
||||
"notification.load.more.notifications": "Load more notifications"
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"general.register.sentenceCase": "Register",
|
||||
"general.signIn.sentenceCase": "Sign in",
|
||||
"header.links.courses": "Courses",
|
||||
"header.links.programs": "Programs",
|
||||
"header.links.content.search": "Discover New",
|
||||
"header.links.schools": "Schools & Partners",
|
||||
"header.user.menu.dashboard": "Dashboard",
|
||||
"header.user.menu.profile": "Profile",
|
||||
"header.user.menu.account.settings": "Account",
|
||||
"header.user.menu.order.history": "Order History",
|
||||
"header.user.menu.logout": "Logout",
|
||||
"header.user.menu.login": "Login",
|
||||
"header.user.menu.register": "Sign Up",
|
||||
"header.user.menu.studio.home": "Studio Home",
|
||||
"header.user.menu.studio.maintenance": "Maintenance",
|
||||
"header.label.account.nav": "Account",
|
||||
"header.label.account.menu": "Account Menu",
|
||||
"header.label.account.menu.for": "Account menu for {username}",
|
||||
"header.label.main.nav": "Main",
|
||||
"header.label.main.menu": "Main Menu",
|
||||
"header.label.main.header": "Main",
|
||||
"header.label.secondary.nav": "Secondary",
|
||||
"header.label.skip.nav": "Skip to main content",
|
||||
"header.label.app.nav": "App",
|
||||
"header.menu.dashboard.label": "Dashboard",
|
||||
"header.help.label": "Help",
|
||||
"header.menu.profile.label": "Profile",
|
||||
"header.menu.account.label": "Account",
|
||||
"header.menu.orderHistory.label": "Order History",
|
||||
"header.navigation.skipNavLink": "Skip to main content.",
|
||||
"header.menu.signOut.label": "Sign Out",
|
||||
"notification.title": "Notifications",
|
||||
"notification.today.heading": "Last 24 hours",
|
||||
"notification.earlier.heading": "Earlier",
|
||||
"notification.mark.as.read": "Mark all as read",
|
||||
"notification.fullStop": "•",
|
||||
"notification.load.more.notifications": "Load more notifications"
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"general.register.sentenceCase": "Register",
|
||||
"general.signIn.sentenceCase": "Увійти",
|
||||
"header.links.courses": "Курси",
|
||||
"header.links.programs": "Програми",
|
||||
"header.links.content.search": "Discover New",
|
||||
"header.links.schools": "Schools & Partners",
|
||||
"header.user.menu.dashboard": "Dashboard",
|
||||
"header.user.menu.profile": "Profile",
|
||||
"header.user.menu.account.settings": "Account",
|
||||
"header.user.menu.order.history": "Order History",
|
||||
"header.user.menu.logout": "Logout",
|
||||
"header.user.menu.login": "Login",
|
||||
"header.user.menu.register": "Sign Up",
|
||||
"header.user.menu.studio.home": "Studio Home",
|
||||
"header.user.menu.studio.maintenance": "Maintenance",
|
||||
"header.label.account.nav": "Account",
|
||||
"header.label.account.menu": "Меню облікового запису",
|
||||
"header.label.account.menu.for": "Меню облікового запису для {username}",
|
||||
"header.label.main.nav": "Main",
|
||||
"header.label.main.menu": "Main Menu",
|
||||
"header.label.main.header": "Main",
|
||||
"header.label.secondary.nav": "Secondary",
|
||||
"header.label.skip.nav": "Перейти до головного змісту",
|
||||
"header.label.app.nav": "App",
|
||||
"header.menu.dashboard.label": "Dashboard",
|
||||
"header.help.label": "Help",
|
||||
"header.menu.profile.label": "Profile",
|
||||
"header.menu.account.label": "Account",
|
||||
"header.menu.orderHistory.label": "Order History",
|
||||
"header.navigation.skipNavLink": "Перейти до головного змісту.",
|
||||
"header.menu.signOut.label": "Sign Out",
|
||||
"notification.title": "Notifications",
|
||||
"notification.today.heading": "Last 24 hours",
|
||||
"notification.earlier.heading": "Earlier",
|
||||
"notification.mark.as.read": "Mark all as read",
|
||||
"notification.fullStop": "•",
|
||||
"notification.load.more.notifications": "Load more notifications"
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"general.register.sentenceCase": "Register",
|
||||
"general.signIn.sentenceCase": "Sign in",
|
||||
"header.links.courses": "Courses",
|
||||
"header.links.programs": "Programs",
|
||||
"header.links.content.search": "Discover New",
|
||||
"header.links.schools": "Schools & Partners",
|
||||
"header.user.menu.dashboard": "Dashboard",
|
||||
"header.user.menu.profile": "Profile",
|
||||
"header.user.menu.account.settings": "Account",
|
||||
"header.user.menu.order.history": "Order History",
|
||||
"header.user.menu.logout": "Logout",
|
||||
"header.user.menu.login": "Login",
|
||||
"header.user.menu.register": "Sign Up",
|
||||
"header.user.menu.studio.home": "Studio Home",
|
||||
"header.user.menu.studio.maintenance": "Maintenance",
|
||||
"header.label.account.nav": "Account",
|
||||
"header.label.account.menu": "Account Menu",
|
||||
"header.label.account.menu.for": "Account menu for {username}",
|
||||
"header.label.main.nav": "Main",
|
||||
"header.label.main.menu": "Main Menu",
|
||||
"header.label.main.header": "Main",
|
||||
"header.label.secondary.nav": "Secondary",
|
||||
"header.label.skip.nav": "Skip to main content",
|
||||
"header.label.app.nav": "App",
|
||||
"header.menu.dashboard.label": "Dashboard",
|
||||
"header.help.label": "Help",
|
||||
"header.menu.profile.label": "Profile",
|
||||
"header.menu.account.label": "Account",
|
||||
"header.menu.orderHistory.label": "Order History",
|
||||
"header.navigation.skipNavLink": "Skip to main content.",
|
||||
"header.menu.signOut.label": "Sign Out",
|
||||
"notification.title": "Notifications",
|
||||
"notification.today.heading": "Last 24 hours",
|
||||
"notification.earlier.heading": "Earlier",
|
||||
"notification.mark.as.read": "Mark all as read",
|
||||
"notification.fullStop": "•",
|
||||
"notification.load.more.notifications": "Load more notifications"
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import Header from './Header';
|
||||
import LearningHeader from './learning-header/LearningHeader';
|
||||
import messages from './i18n/index';
|
||||
import StudioHeader from './StudioHeader';
|
||||
import StudioHeader from './studio-header';
|
||||
|
||||
export { LearningHeader, messages, StudioHeader };
|
||||
|
||||
|
||||
153
src/index.scss
@@ -1,11 +1,9 @@
|
||||
$spacer: 1rem;
|
||||
$blue: #007db8;
|
||||
$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 './studio-header/StudioHeader.scss';
|
||||
|
||||
.dropdown-item a {
|
||||
text-decoration: none;
|
||||
@@ -45,9 +43,9 @@ $white: #fff;
|
||||
.user-dropdown {
|
||||
.btn {
|
||||
height: 3rem;
|
||||
@media (max-width: -1 + map-get($grid-breakpoints, "sm")) {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
// @media (max-width: -1 + map-get($grid-breakpoints, "sm")) {
|
||||
// padding: 0 0.5rem;
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,6 +88,15 @@ $white: #fff;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
.secondary-menu-container {
|
||||
.nav-link:hover,
|
||||
.nav-link:focus,
|
||||
.nav-link.active,
|
||||
.expanded .nav-link {
|
||||
background: $component-active-bg;
|
||||
color: $component-active-color;
|
||||
}
|
||||
}
|
||||
.main-nav {
|
||||
.nav-link {
|
||||
padding: 1.125rem 1rem;
|
||||
@@ -121,135 +128,3 @@ $white: #fff;
|
||||
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 {
|
||||
max-height: calc(100% - 68px);
|
||||
min-height: 1220px;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,27 +3,25 @@ import React from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@edx/paragon';
|
||||
import LearningLoggedOutItemsSlot from '../plugin-slots/LearningLoggedOutItemsSlot';
|
||||
|
||||
import genericMessages from '../generic/messages';
|
||||
|
||||
const AnonymousUserMenu = ({ intl }) => (
|
||||
<div>
|
||||
<Button
|
||||
className="mr-3"
|
||||
variant="outline-primary"
|
||||
href={`${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`}
|
||||
>
|
||||
{intl.formatMessage(genericMessages.registerSentenceCase)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
href={`${getLoginRedirectUrl(global.location.href)}`}
|
||||
>
|
||||
{intl.formatMessage(genericMessages.signInSentenceCase)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
const AnonymousUserMenu = ({ intl }) => {
|
||||
const buttonsInfo = [
|
||||
{
|
||||
message: intl.formatMessage(genericMessages.registerSentenceCase),
|
||||
href: `${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`,
|
||||
},
|
||||
{
|
||||
message: intl.formatMessage(genericMessages.signInSentenceCase),
|
||||
href: getLoginRedirectUrl(global.location.href),
|
||||
variant: 'primary',
|
||||
},
|
||||
];
|
||||
|
||||
return <LearningLoggedOutItemsSlot buttonsInfo={buttonsInfo} />;
|
||||
};
|
||||
|
||||
AnonymousUserMenu.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
|
||||
@@ -1,67 +1,52 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
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 { Dropdown } from '@openedx/paragon';
|
||||
|
||||
import LearningUserMenuSlot from '../plugin-slots/LearningUserMenuSlot';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
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 = (
|
||||
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}>
|
||||
{intl.formatMessage(messages.dashboard)}
|
||||
</Dropdown.Item>
|
||||
);
|
||||
const dropdownItems = [
|
||||
{
|
||||
message: intl.formatMessage(messages.dashboard),
|
||||
href: `${getConfig().LMS_BASE_URL}/dashboard`,
|
||||
},
|
||||
{
|
||||
message: intl.formatMessage(messages.profile),
|
||||
href: `${getConfig().ACCOUNT_PROFILE_URL}/u/${username}`,
|
||||
},
|
||||
{
|
||||
message: intl.formatMessage(messages.account),
|
||||
href: getConfig().ACCOUNT_SETTINGS_URL,
|
||||
},
|
||||
...(getConfig().ORDER_HISTORY_URL ? [{
|
||||
message: intl.formatMessage(messages.orderHistory),
|
||||
href: getConfig().ORDER_HISTORY_URL,
|
||||
}] : []),
|
||||
{
|
||||
message: intl.formatMessage(messages.signOut),
|
||||
href: getConfig().LOGOUT_URL,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<a className="text-gray-700" href={`${getConfig().SUPPORT_URL}`}>{intl.formatMessage(messages.help)}</a>
|
||||
{showNotificationsTray && <Notifications />}
|
||||
<Dropdown className="user-dropdown ml-3">
|
||||
<Dropdown.Toggle variant="outline-primary">
|
||||
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />
|
||||
<span data-hj-suppress className="d-none d-md-inline">
|
||||
{username}
|
||||
</span>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="dropdown-menu-right">
|
||||
{dashboardMenuItem}
|
||||
<Dropdown.Item href={`${getConfig().ACCOUNT_PROFILE_URL}/u/${username}`}>
|
||||
{intl.formatMessage(messages.profile)}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item href={getConfig().ACCOUNT_SETTINGS_URL}>
|
||||
{intl.formatMessage(messages.account)}
|
||||
</Dropdown.Item>
|
||||
{ getConfig().ORDER_HISTORY_URL && (
|
||||
<Dropdown.Item href={getConfig().ORDER_HISTORY_URL}>
|
||||
{intl.formatMessage(messages.orderHistory)}
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
<Dropdown.Item href={getConfig().LOGOUT_URL}>
|
||||
{intl.formatMessage(messages.signOut)}
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</>
|
||||
<Dropdown className="user-dropdown ml-3">
|
||||
<Dropdown.Toggle variant="outline-primary">
|
||||
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />
|
||||
<span data-hj-suppress className="d-none d-md-inline">
|
||||
{username}
|
||||
</span>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="dropdown-menu-right">
|
||||
<LearningUserMenuSlot items={dropdownItems} />
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,29 +2,15 @@ import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { AppContext, AppProvider } from '@edx/frontend-platform/react';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
|
||||
import AnonymousUserMenu from './AnonymousUserMenu';
|
||||
import AuthenticatedUserDropdown from './AuthenticatedUserDropdown';
|
||||
import LogoSlot from '../plugin-slots/LogoSlot';
|
||||
import CourseInfoSlot from '../plugin-slots/CourseInfoSlot';
|
||||
import { courseInfoDataShape } from './LearningHeaderCourseInfo';
|
||||
import messages from './messages';
|
||||
import store from '../store';
|
||||
|
||||
const LinkedLogo = ({
|
||||
href,
|
||||
src,
|
||||
alt,
|
||||
...attributes
|
||||
}) => (
|
||||
<a href={href} {...attributes}>
|
||||
<img className="d-block" src={src} alt={alt} />
|
||||
</a>
|
||||
);
|
||||
|
||||
LinkedLogo.propTypes = {
|
||||
href: PropTypes.string.isRequired,
|
||||
src: PropTypes.string.isRequired,
|
||||
alt: PropTypes.string.isRequired,
|
||||
};
|
||||
import LearningHelpSlot from '../plugin-slots/LearningHelpSlot';
|
||||
|
||||
const LearningHeader = ({
|
||||
courseOrg, courseNumber, courseTitle, intl, showUserDropdown,
|
||||
@@ -32,8 +18,7 @@ const LearningHeader = ({
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
|
||||
const headerLogo = (
|
||||
<LinkedLogo
|
||||
className="logo"
|
||||
<LogoSlot
|
||||
href={`${getConfig().LMS_BASE_URL}/dashboard`}
|
||||
src={getConfig().LOGO_URL}
|
||||
alt={getConfig().SITE_NAME}
|
||||
@@ -41,33 +26,33 @@ const LearningHeader = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<AppProvider store={store}>
|
||||
<header className="learning-header">
|
||||
<a className="sr-only sr-only-focusable" href="#main-content">{intl.formatMessage(messages.skipNavLink)}</a>
|
||||
<div className="container-xl py-2 d-flex align-items-center">
|
||||
{headerLogo}
|
||||
<div className="flex-grow-1 course-title-lockup" style={{ lineHeight: 1 }}>
|
||||
<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 />
|
||||
)}
|
||||
<header className="learning-header">
|
||||
<a className="sr-only sr-only-focusable" href="#main-content">{intl.formatMessage(messages.skipNavLink)}</a>
|
||||
<div className="container-xl py-2 d-flex align-items-center">
|
||||
{headerLogo}
|
||||
<div className="flex-grow-1 course-title-lockup d-flex" style={{ lineHeight: 1 }}>
|
||||
<CourseInfoSlot courseOrg={courseOrg} courseNumber={courseNumber} courseTitle={courseTitle} />
|
||||
</div>
|
||||
</header>
|
||||
</AppProvider>
|
||||
{showUserDropdown && authenticatedUser && (
|
||||
<>
|
||||
<LearningHelpSlot />
|
||||
<AuthenticatedUserDropdown
|
||||
username={authenticatedUser.username}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{showUserDropdown && !authenticatedUser && (
|
||||
<AnonymousUserMenu />
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
LearningHeader.propTypes = {
|
||||
courseOrg: PropTypes.string,
|
||||
courseNumber: PropTypes.string,
|
||||
courseTitle: PropTypes.string,
|
||||
courseOrg: courseInfoDataShape.courseOrg,
|
||||
courseNumber: courseInfoDataShape.courseNumber,
|
||||
courseTitle: courseInfoDataShape.courseTitle,
|
||||
intl: intlShape.isRequired,
|
||||
showUserDropdown: PropTypes.bool,
|
||||
};
|
||||
|
||||
23
src/learning-header/LearningHeaderCourseInfo.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const LearningHeaderCourseInfo = ({
|
||||
courseOrg,
|
||||
courseNumber,
|
||||
courseTitle,
|
||||
}) => (
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<span className="d-block small m-0">{courseOrg} {courseNumber}</span>
|
||||
<span className="d-block m-0 font-weight-bold course-title">{courseTitle}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const courseInfoDataShape = {
|
||||
courseOrg: PropTypes.string,
|
||||
courseNumber: PropTypes.string,
|
||||
courseTitle: PropTypes.string,
|
||||
};
|
||||
|
||||
LearningHeaderCourseInfo.propTypes = courseInfoDataShape;
|
||||
|
||||
export default LearningHeaderCourseInfo;
|
||||
14
src/learning-header/LearningHeaderHelpLink.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import messages from './messages';
|
||||
|
||||
const LearningHeaderHelpLink = () => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<a className="text-gray-700" href={`${getConfig().SUPPORT_URL}`}>{intl.formatMessage(messages.help)}</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default LearningHeaderHelpLink;
|
||||
21
src/learning-header/LearningHeaderUserMenuItems.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Dropdown } from '@openedx/paragon';
|
||||
|
||||
const LearningHeaderUserMenuItems = ({ items }) => items.map((item) => (
|
||||
<Dropdown.Item href={item.href}>
|
||||
{item.message}
|
||||
</Dropdown.Item>
|
||||
));
|
||||
|
||||
export const learningHeaderUserMenuDataShape = {
|
||||
items: PropTypes.arrayOf(PropTypes.shape({
|
||||
message: PropTypes.string,
|
||||
href: PropTypes.string,
|
||||
})),
|
||||
};
|
||||
|
||||
LearningHeaderUserMenuItems.propTypes = learningHeaderUserMenuDataShape;
|
||||
|
||||
export default LearningHeaderUserMenuItems;
|
||||
26
src/learning-header/LearningLoggedOutButtons.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Button } from '@openedx/paragon';
|
||||
|
||||
const LearningLoggedOutButtons = ({ buttonsInfo }) => buttonsInfo.map(buttonInfo => (
|
||||
<Button
|
||||
className="ml-3"
|
||||
variant={buttonInfo.variant ?? 'outline-primary'}
|
||||
href={buttonInfo.href}
|
||||
>
|
||||
{buttonInfo.message}
|
||||
</Button>
|
||||
));
|
||||
|
||||
export const learningHeaderLoggedOutItemsDataShape = {
|
||||
buttonsInfo: PropTypes.arrayOf(PropTypes.shape({
|
||||
message: PropTypes.string,
|
||||
href: PropTypes.string,
|
||||
variant: PropTypes.string,
|
||||
})),
|
||||
};
|
||||
|
||||
LearningLoggedOutButtons.propTypes = learningHeaderLoggedOutItemsDataShape;
|
||||
|
||||
export default LearningLoggedOutButtons;
|
||||
@@ -4,15 +4,21 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
// Local Components
|
||||
import { Menu, MenuTrigger, MenuContent } from './Menu';
|
||||
import Avatar from './Avatar';
|
||||
import { LinkedLogo, Logo } from './Logo';
|
||||
import { Menu, MenuTrigger, MenuContent } from '../Menu';
|
||||
import Avatar from '../Avatar';
|
||||
import LogoSlot from '../plugin-slots/LogoSlot';
|
||||
import MobileLoggedOutItemsSlot from '../plugin-slots/MobileLoggedOutItemsSlot';
|
||||
import { mobileHeaderLoggedOutItemsDataShape } from './MobileLoggedOutItems';
|
||||
import MobileMainMenuSlot from '../plugin-slots/MobileMainMenuSlot';
|
||||
import { mobileHeaderMainMenuDataShape } from './MobileHeaderMainMenu';
|
||||
import MobileUserMenuSlot from '../plugin-slots/MobileUserMenuSlot';
|
||||
import { mobileHeaderUserMenuDataShape } from './MobileHeaderUserMenu';
|
||||
|
||||
// i18n
|
||||
import messages from './Header.messages';
|
||||
import messages from '../Header.messages';
|
||||
|
||||
// Assets
|
||||
import { MenuIcon } from './Icons';
|
||||
import { MenuIcon } from '../Icons';
|
||||
|
||||
class MobileHeader extends React.Component {
|
||||
constructor(props) { // eslint-disable-line no-useless-constructor
|
||||
@@ -20,65 +26,18 @@ class MobileHeader extends React.Component {
|
||||
}
|
||||
|
||||
renderMainMenu() {
|
||||
const { mainMenu } = this.props;
|
||||
|
||||
// Nodes are accepted as a prop
|
||||
if (!Array.isArray(mainMenu)) {
|
||||
return mainMenu;
|
||||
}
|
||||
|
||||
return mainMenu.map((menuItem) => {
|
||||
const {
|
||||
type,
|
||||
href,
|
||||
content,
|
||||
submenuContent,
|
||||
} = menuItem;
|
||||
|
||||
if (type === 'item') {
|
||||
return (
|
||||
<a key={`${type}-${content}`} className="nav-link" href={href}>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu key={`${type}-${content}`} tag="div" className="nav-item">
|
||||
<MenuTrigger tag="a" role="button" tabIndex="0" className="nav-link">
|
||||
{content}
|
||||
</MenuTrigger>
|
||||
<MenuContent className="position-static pin-left pin-right py-2">
|
||||
{submenuContent}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
);
|
||||
});
|
||||
const { mainMenu, secondaryMenu } = this.props;
|
||||
return <MobileMainMenuSlot menu={[...mainMenu, ...secondaryMenu]} />;
|
||||
}
|
||||
|
||||
renderUserMenuItems() {
|
||||
const { userMenu } = this.props;
|
||||
|
||||
return userMenu.map(({ type, href, content }) => (
|
||||
<li className="nav-item" key={`${type}-${content}`}>
|
||||
<a className="nav-link" href={href}>{content}</a>
|
||||
</li>
|
||||
));
|
||||
return <MobileUserMenuSlot menu={userMenu} />;
|
||||
}
|
||||
|
||||
renderLoggedOutItems() {
|
||||
const { loggedOutItems } = this.props;
|
||||
|
||||
return loggedOutItems.map(({ type, href, content }, i, arr) => (
|
||||
<li className="nav-item px-3 my-2" key={`${type}-${content}`}>
|
||||
<a
|
||||
className={i < arr.length - 1 ? 'btn btn-block btn-outline-primary' : 'btn btn-block btn-primary'}
|
||||
href={href}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
</li>
|
||||
));
|
||||
return <MobileLoggedOutItemsSlot items={loggedOutItems} />;
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -128,7 +87,7 @@ class MobileHeader extends React.Component {
|
||||
</div>
|
||||
) : null}
|
||||
<div className={`w-100 d-flex ${logoClasses}`}>
|
||||
{ logoDestination === null ? <Logo className="logo" src={logo} alt={logoAltText} /> : <LinkedLogo className="logo" {...logoProps} itemType="http://schema.org/Organization" />}
|
||||
<LogoSlot {...logoProps} itemType="http://schema.org/Organization" />
|
||||
</div>
|
||||
{userMenu.length > 0 || loggedOutItems.length > 0 ? (
|
||||
<div className="w-100 d-flex justify-content-end align-items-center">
|
||||
@@ -152,22 +111,11 @@ class MobileHeader extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
MobileHeader.propTypes = {
|
||||
mainMenu: PropTypes.oneOfType([
|
||||
PropTypes.node,
|
||||
PropTypes.array,
|
||||
]),
|
||||
|
||||
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,
|
||||
})),
|
||||
export const mobileHeaderDataShape = {
|
||||
mainMenu: mobileHeaderMainMenuDataShape,
|
||||
secondaryMenu: mobileHeaderMainMenuDataShape,
|
||||
userMenu: mobileHeaderUserMenuDataShape,
|
||||
loggedOutItems: mobileHeaderLoggedOutItemsDataShape,
|
||||
logo: PropTypes.string,
|
||||
logoAltText: PropTypes.string,
|
||||
logoDestination: PropTypes.string,
|
||||
@@ -175,6 +123,20 @@ MobileHeader.propTypes = {
|
||||
username: PropTypes.string,
|
||||
loggedIn: PropTypes.bool,
|
||||
stickyOnMobile: PropTypes.bool,
|
||||
};
|
||||
|
||||
MobileHeader.propTypes = {
|
||||
mainMenu: mobileHeaderDataShape.mainMenu,
|
||||
secondaryMenu: mobileHeaderDataShape.secondaryMenu,
|
||||
userMenu: mobileHeaderDataShape.userMenu,
|
||||
loggedOutItems: mobileHeaderDataShape.loggedOutItems,
|
||||
logo: mobileHeaderDataShape.logo,
|
||||
logoAltText: mobileHeaderDataShape.logoAltText,
|
||||
logoDestination: mobileHeaderDataShape.logoDestination,
|
||||
avatar: mobileHeaderDataShape.avatar,
|
||||
username: mobileHeaderDataShape.username,
|
||||
loggedIn: mobileHeaderDataShape.loggedIn,
|
||||
stickyOnMobile: mobileHeaderDataShape.stickyOnMobile,
|
||||
|
||||
// i18n
|
||||
intl: intlShape.isRequired,
|
||||
@@ -182,6 +144,7 @@ MobileHeader.propTypes = {
|
||||
|
||||
MobileHeader.defaultProps = {
|
||||
mainMenu: [],
|
||||
secondaryMenu: [],
|
||||
userMenu: [],
|
||||
loggedOutItems: [],
|
||||
logo: null,
|
||||
58
src/mobile-header/MobileHeaderMainMenu.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Menu, MenuTrigger, MenuContent } from '../Menu';
|
||||
|
||||
const MobileHeaderMainMenu = ({ menu }) => {
|
||||
// Nodes are accepted as a prop
|
||||
if (!Array.isArray(menu)) {
|
||||
return menu;
|
||||
}
|
||||
|
||||
return menu.map((menuItem) => {
|
||||
const {
|
||||
type,
|
||||
href,
|
||||
content,
|
||||
submenuContent,
|
||||
disabled,
|
||||
isActive,
|
||||
onClick,
|
||||
} = menuItem;
|
||||
|
||||
if (type === 'item') {
|
||||
return (
|
||||
<a
|
||||
key={`${type}-${content}`}
|
||||
className={`nav-link${disabled ? ' disabled' : ''}${isActive ? ' active' : ''}`}
|
||||
href={href}
|
||||
onClick={onClick || null}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu key={`${type}-${content}`} tag="div" className="nav-item">
|
||||
<MenuTrigger onClick={onClick || null} tag="a" role="button" tabIndex="0" className="nav-link">
|
||||
{content}
|
||||
</MenuTrigger>
|
||||
<MenuContent className="position-static pin-left pin-right py-2">
|
||||
{submenuContent}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const mobileHeaderMainMenuDataShape = PropTypes.oneOfType([
|
||||
PropTypes.node,
|
||||
PropTypes.array,
|
||||
]);
|
||||
|
||||
MobileHeaderMainMenu.propTypes = {
|
||||
menu: mobileHeaderMainMenuDataShape,
|
||||
};
|
||||
|
||||
export default MobileHeaderMainMenu;
|
||||
35
src/mobile-header/MobileHeaderUserMenu.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const MobileHeaderUserMenu = ({ menu }) => menu.map((group) => (
|
||||
group.items.map(({
|
||||
type, content, href, disabled, isActive, onClick,
|
||||
}) => (
|
||||
<li className="nav-item" key={`${type}-${content}`}>
|
||||
<a
|
||||
className={`nav-link${isActive ? ' active' : ''}${disabled ? ' disabled' : ''}`}
|
||||
href={href}
|
||||
onClick={onClick || null}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
));
|
||||
|
||||
export const mobileHeaderUserMenuDataShape = PropTypes.arrayOf(PropTypes.shape({
|
||||
heading: PropTypes.string,
|
||||
items: PropTypes.arrayOf(PropTypes.shape({
|
||||
type: PropTypes.oneOf(['item', 'menu']),
|
||||
href: PropTypes.string,
|
||||
content: PropTypes.string,
|
||||
isActive: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
})),
|
||||
}));
|
||||
|
||||
MobileHeaderUserMenu.propTypes = {
|
||||
menu: mobileHeaderUserMenuDataShape,
|
||||
};
|
||||
|
||||
export default MobileHeaderUserMenu;
|
||||
25
src/mobile-header/MobileLoggedOutItems.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const MobileLoggedOutItems = ({ items }) => items.map(({ type, href, content }, i, arr) => (
|
||||
<li className="nav-item px-3 my-2" key={`${type}-${content}`}>
|
||||
<a
|
||||
className={i < arr.length - 1 ? 'btn btn-block btn-outline-primary' : 'btn btn-block btn-primary'}
|
||||
href={href}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
</li>
|
||||
));
|
||||
|
||||
export const mobileHeaderLoggedOutItemsDataShape = PropTypes.arrayOf(PropTypes.shape({
|
||||
type: PropTypes.oneOf(['item', 'menu']),
|
||||
href: PropTypes.string,
|
||||
content: PropTypes.string,
|
||||
}));
|
||||
|
||||
MobileLoggedOutItems.propTypes = {
|
||||
menu: mobileHeaderLoggedOutItemsDataShape,
|
||||
};
|
||||
|
||||
export default MobileLoggedOutItems;
|
||||
125
src/plugin-slots/CourseInfoSlot/README.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Course Info Slot
|
||||
|
||||
### Slot ID: `course_info_slot`
|
||||
|
||||
## Description
|
||||
|
||||
This slot is used to replace/modify/hide the course info.
|
||||
|
||||
## Examples
|
||||
|
||||
### Replace Course Title
|
||||
|
||||
The following `env.config.jsx` will replace the course title.
|
||||
|
||||

|
||||
|
||||
```jsx
|
||||
import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
const replaceCourseTitle = ( widget ) => {
|
||||
widget.content.courseTitle = "Custom Course Title";
|
||||
return widget;
|
||||
};
|
||||
|
||||
const config = {
|
||||
pluginSlots: {
|
||||
course_info_slot: {
|
||||
keepDefault: true,
|
||||
plugins: [
|
||||
{
|
||||
op: PLUGIN_OPERATIONS.Modify,
|
||||
widgetId: 'default_contents',
|
||||
fn: replaceCourseTitle,
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
### Replace Course Info with Custom Component
|
||||
|
||||
The following `env.config.jsx` will replace the course info entirely (in this case with a centered 🗺️ `h1`)
|
||||
|
||||

|
||||
|
||||
```jsx
|
||||
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
const config = {
|
||||
pluginSlots: {
|
||||
course_info_slot: {
|
||||
keepDefault: false,
|
||||
plugins: [
|
||||
{
|
||||
op: PLUGIN_OPERATIONS.Insert,
|
||||
widget: {
|
||||
id: 'custom_course_info_component',
|
||||
type: DIRECT_PLUGIN,
|
||||
RenderWidget: () => (
|
||||
<h1 style={{textAlign: 'center'}}>🗺️</h1>
|
||||
),
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
### Add Custom Components before and after Course Info
|
||||
|
||||
The following `env.config.jsx` will place custom components before and after the course info (in this case centered `h1`s with 🌜 and 🌛).
|
||||
|
||||

|
||||
|
||||
```jsx
|
||||
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
const config = {
|
||||
pluginSlots: {
|
||||
course_info_slot: {
|
||||
keepDefault: true,
|
||||
plugins: [
|
||||
{
|
||||
op: PLUGIN_OPERATIONS.Insert,
|
||||
widget: {
|
||||
id: 'custom_before_course_info_component',
|
||||
type: DIRECT_PLUGIN,
|
||||
priority: 10,
|
||||
RenderWidget: () => (
|
||||
<h3 style={{
|
||||
marginTop: 'auto',
|
||||
marginBottom: 'auto',
|
||||
marginRight: '0.5rem',
|
||||
}}>🌜</h3>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
op: PLUGIN_OPERATIONS.Insert,
|
||||
widget: {
|
||||
id: 'custom_after_course_info_component',
|
||||
type: DIRECT_PLUGIN,
|
||||
priority: 90,
|
||||
RenderWidget: () => (
|
||||
<h3 style={{
|
||||
marginTop: 'auto',
|
||||
marginBottom: 'auto',
|
||||
marginLeft: '0.5rem',
|
||||
}}>🌛</h3>
|
||||
),
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 13 KiB |
BIN
src/plugin-slots/CourseInfoSlot/images/replace_course_title.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
28
src/plugin-slots/CourseInfoSlot/index.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
import LearningHeaderCourseInfo, { courseInfoDataShape } from '../../learning-header/LearningHeaderCourseInfo';
|
||||
|
||||
const CourseInfoSlot = ({
|
||||
courseOrg,
|
||||
courseNumber,
|
||||
courseTitle,
|
||||
...attributes
|
||||
}) => (
|
||||
<PluginSlot
|
||||
id="course_info_slot"
|
||||
slotOptions={{
|
||||
mergeProps: true,
|
||||
}}
|
||||
>
|
||||
<LearningHeaderCourseInfo
|
||||
courseOrg={courseOrg}
|
||||
courseNumber={courseNumber}
|
||||
courseTitle={courseTitle}
|
||||
{...attributes}
|
||||
/>
|
||||
</PluginSlot>
|
||||
);
|
||||
|
||||
CourseInfoSlot.propTypes = courseInfoDataShape;
|
||||
|
||||
export default CourseInfoSlot;
|
||||
41
src/plugin-slots/DesktopHeaderSlot/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Desktop Header Slot
|
||||
|
||||
### Slot ID: `desktop_header_slot`
|
||||
|
||||
## Description
|
||||
|
||||
This slot is used to replace/modify/hide the entire desktop header.
|
||||
|
||||
## Examples
|
||||
|
||||
### Custom Component
|
||||
|
||||
The following `env.config.jsx` will replace the desktop header entirely (in this case with a centered 🗺️ `h1`)
|
||||
|
||||

|
||||
|
||||
```jsx
|
||||
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
const config = {
|
||||
pluginSlots: {
|
||||
desktop_header_slot: {
|
||||
keepDefault: false,
|
||||
plugins: [
|
||||
{
|
||||
op: PLUGIN_OPERATIONS.Insert,
|
||||
widget: {
|
||||
id: 'custom_desktop_header_component',
|
||||
type: DIRECT_PLUGIN,
|
||||
RenderWidget: () => (
|
||||
<h1 style={{textAlign: 'center'}}>🗺️</h1>
|
||||
),
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
After Width: | Height: | Size: 27 KiB |
20
src/plugin-slots/DesktopHeaderSlot/index.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
import DesktopHeader, { desktopHeaderDataShape } from '../../desktop-header/DesktopHeader';
|
||||
|
||||
const DesktopHeaderSlot = ({
|
||||
props,
|
||||
}) => (
|
||||
<PluginSlot
|
||||
id="desktop_header_slot"
|
||||
slotOptions={{
|
||||
mergeProps: true,
|
||||
}}
|
||||
>
|
||||
<DesktopHeader {...props} />
|
||||
</PluginSlot>
|
||||
);
|
||||
|
||||
DesktopHeaderSlot.propTypes = desktopHeaderDataShape;
|
||||
|
||||
export default DesktopHeaderSlot;
|
||||
134
src/plugin-slots/DesktopLoggedOutItemsSlot/README.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Desktop Logged Out Items Slot
|
||||
|
||||
### Slot ID: `desktop_logged_out_items_slot`
|
||||
|
||||
## Description
|
||||
|
||||
This slot is used to replace/modify/hide the items shown on desktop when the user is logged out.
|
||||
|
||||
## Examples
|
||||
|
||||
### Modify Items
|
||||
|
||||
The following `env.config.jsx` will modify the items shown on desktop when the user is logged out.
|
||||
|
||||

|
||||
|
||||
```jsx
|
||||
import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
const modifyLoggedOutItems = ( widget ) => {
|
||||
widget.content.items = [
|
||||
{
|
||||
type: 'item',
|
||||
href: 'https://openedx.org/',
|
||||
content: 'openedx.org',
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
href: 'https://docs.openedx.org/en/latest/',
|
||||
content: 'Documentation',
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
href: 'https://discuss.openedx.org/',
|
||||
content: 'Forums',
|
||||
}
|
||||
];
|
||||
return widget;
|
||||
};
|
||||
|
||||
const config = {
|
||||
pluginSlots: {
|
||||
desktop_logged_out_items_slot: {
|
||||
keepDefault: true,
|
||||
plugins: [
|
||||
{
|
||||
op: PLUGIN_OPERATIONS.Modify,
|
||||
widgetId: 'default_contents',
|
||||
fn: modifyLoggedOutItems,
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
### Replace with Custom Component
|
||||
|
||||
The following `env.config.jsx` will replace the items shown on desktop when the user is logged out entirely (in this case with a centered 🗺️ `h1`)
|
||||
|
||||

|
||||
|
||||
```jsx
|
||||
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
const config = {
|
||||
pluginSlots: {
|
||||
desktop_logged_out_items_slot: {
|
||||
keepDefault: false,
|
||||
plugins: [
|
||||
{
|
||||
op: PLUGIN_OPERATIONS.Insert,
|
||||
widget: {
|
||||
id: 'custom_logged_out_items_component',
|
||||
type: DIRECT_PLUGIN,
|
||||
RenderWidget: () => (
|
||||
<h1 style={{textAlign: 'center'}}>🗺️</h1>
|
||||
),
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
### Add Custom Components before and after
|
||||
|
||||
The following `env.config.jsx` will place custom components before and after the items shown on desktop when the user is logged out (in this case centered `h1`s with 🌜 and 🌛).
|
||||
|
||||

|
||||
|
||||
```jsx
|
||||
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
const config = {
|
||||
pluginSlots: {
|
||||
desktop_logged_out_items_slot: {
|
||||
keepDefault: true,
|
||||
plugins: [
|
||||
{
|
||||
op: PLUGIN_OPERATIONS.Insert,
|
||||
widget: {
|
||||
id: 'custom_before_logged_out_items_component',
|
||||
type: DIRECT_PLUGIN,
|
||||
priority: 10,
|
||||
RenderWidget: () => (
|
||||
<h1 style={{textAlign: 'center'}}>🌜</h1>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
op: PLUGIN_OPERATIONS.Insert,
|
||||
widget: {
|
||||
id: 'custom_after_logged_out_items_component',
|
||||
type: DIRECT_PLUGIN,
|
||||
priority: 90,
|
||||
RenderWidget: () => (
|
||||
<h1 style={{textAlign: 'center'}}>🌛</h1>
|
||||
),
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
22
src/plugin-slots/DesktopLoggedOutItemsSlot/index.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
import DesktopLoggedOutItems, { desktopLoggedOutItemsDataShape } from '../../desktop-header/DesktopLoggedOutItems';
|
||||
|
||||
const DesktopLoggedOutItemsSlot = ({
|
||||
items,
|
||||
}) => (
|
||||
<PluginSlot
|
||||
id="desktop_logged_out_items_slot"
|
||||
slotOptions={{
|
||||
mergeProps: true,
|
||||
}}
|
||||
>
|
||||
<DesktopLoggedOutItems items={items} />
|
||||
</PluginSlot>
|
||||
);
|
||||
|
||||
DesktopLoggedOutItemsSlot.propTypes = {
|
||||
items: desktopLoggedOutItemsDataShape,
|
||||
};
|
||||
|
||||
export default DesktopLoggedOutItemsSlot;
|
||||
134
src/plugin-slots/DesktopMainMenuSlot/README.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Desktop Main Menu Slot
|
||||
|
||||
### Slot ID: `desktop_main_menu_slot`
|
||||
|
||||
## Description
|
||||
|
||||
This slot is used to replace/modify/hide the desktop main menu.
|
||||
|
||||
## Examples
|
||||
|
||||
### Modify Items
|
||||
|
||||
The following `env.config.jsx` will modify the items in the desktop main menu.
|
||||
|
||||

|
||||
|
||||
```jsx
|
||||
import { PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
const modifyMainMenu = ( widget ) => {
|
||||
widget.content.menu = [
|
||||
{
|
||||
type: 'item',
|
||||
href: 'https://openedx.org/',
|
||||
content: 'openedx.org',
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
href: 'https://docs.openedx.org/en/latest/',
|
||||
content: 'Documentation',
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
href: 'https://discuss.openedx.org/',
|
||||
content: 'Forums',
|
||||
}
|
||||
];
|
||||
return widget;
|
||||
};
|
||||
|
||||
const config = {
|
||||
pluginSlots: {
|
||||
desktop_main_menu_slot: {
|
||||
keepDefault: true,
|
||||
plugins: [
|
||||
{
|
||||
op: PLUGIN_OPERATIONS.Modify,
|
||||
widgetId: 'default_contents',
|
||||
fn: modifyMainMenu,
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
### Replace Menu with Custom Component
|
||||
|
||||
The following `env.config.jsx` will replace the desktop main menu entirely (in this case with a centered 🗺️ `h1`)
|
||||
|
||||

|
||||
|
||||
```jsx
|
||||
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
const config = {
|
||||
pluginSlots: {
|
||||
desktop_main_menu_slot: {
|
||||
keepDefault: false,
|
||||
plugins: [
|
||||
{
|
||||
op: PLUGIN_OPERATIONS.Insert,
|
||||
widget: {
|
||||
id: 'custom_main_menu_component',
|
||||
type: DIRECT_PLUGIN,
|
||||
RenderWidget: () => (
|
||||
<h1 style={{textAlign: 'center'}}>🗺️</h1>
|
||||
),
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
### Add Custom Components before and after Menu
|
||||
|
||||
The following `env.config.jsx` will place custom components before and after the desktop main menu (in this case centered `h1`s with 🌜 and 🌛).
|
||||
|
||||

|
||||
|
||||
```jsx
|
||||
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
const config = {
|
||||
pluginSlots: {
|
||||
desktop_main_menu_slot: {
|
||||
keepDefault: true,
|
||||
plugins: [
|
||||
{
|
||||
op: PLUGIN_OPERATIONS.Insert,
|
||||
widget: {
|
||||
id: 'custom_before_main_menu_component',
|
||||
type: DIRECT_PLUGIN,
|
||||
priority: 10,
|
||||
RenderWidget: () => (
|
||||
<h1 style={{textAlign: 'center'}}>🌜</h1>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
op: PLUGIN_OPERATIONS.Insert,
|
||||
widget: {
|
||||
id: 'custom_after_main_menu_component',
|
||||
type: DIRECT_PLUGIN,
|
||||
priority: 90,
|
||||
RenderWidget: () => (
|
||||
<h1 style={{textAlign: 'center'}}>🌛</h1>
|
||||
),
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 10 KiB |