Compare commits

..

121 Commits

Author SHA1 Message Date
Bilal Qamar
83c5b0258f feat: Updated frontend-build to bump jest version to v29 (#369)
* refactor: updated frontend-build to bump jest version to v29

* refactor: resolved failing tests

* refactor: updated frontend-build & failing snapshots

* refactor: updated snapshots

* refactor: updated frontend-build to alpha, updated snapshots

* fix: fixed peer deps issues

* refactor: updated frontend-build to the version which has eslint alpha synced with master

* refactor: updated eslintrc

* refactor: updated ci workflow

* feat: updated frontend-build and frontend-platform major versions

* refactor: updated package-lock

* refactor: updated peerDependency as per suggestion

---------

Co-authored-by: Muhammad Abdullah Waheed <abdullah.waheed@arbisoft.com>
2024-04-23 13:57:10 +05:00
renovate[bot]
45246ad5ee chore(deps): update dependency @openedx/paragon to v22.2.2 2024-04-22 05:14:02 +00:00
renovate[bot]
487b2590bd chore(deps): update dependency @edx/frontend-platform to v7.1.4 2024-04-22 05:13:44 +00:00
Syed Sajjad Hussain Shah
6cab3f3f3e Merge pull request #466 from openedx/sajjad/VAN-1823-custom-header
feat: enable header to accept custom menus
2024-04-19 11:36:03 +05:00
renovate[bot]
e3c8ec027e chore(deps): update dependency @testing-library/dom to v10 2024-04-15 07:39:31 +00:00
renovate[bot]
1e899c1c48 chore(deps): update dependency react-router-dom to v6.22.3 2024-04-15 04:27:37 +00:00
renovate[bot]
370b193df3 fix(deps): update font awesome to v6.5.2 2024-04-08 06:51:52 +00:00
renovate[bot]
58c34abd66 chore(deps): update dependency @openedx/frontend-build to v13.1.4 2024-04-08 06:51:23 +00:00
Rômulo Penido
c9942c1552 feat: add search menu button to header (#474) 2024-04-03 14:36:39 -04:00
renovate[bot]
432dbb5e6b chore(deps): update dependency @openedx/paragon to v22.2.1 2024-04-01 07:23:00 +00:00
renovate[bot]
02748fab13 chore(deps): update dependency @edx/brand to v1.2.3 2024-04-01 07:22:25 +00:00
renovate[bot]
3a5506c646 chore(deps): update dependency @openedx/frontend-build to v13.1.0 2024-04-01 07:18:24 +00:00
renovate[bot]
10619ceb5e chore(deps): update dependency @openedx/paragon to v22.1.1 2024-03-25 15:49:02 +00:00
renovate[bot]
2694492a7c chore(deps): update dependency @edx/frontend-platform to v7.1.3 2024-03-25 13:34:02 +00:00
renovate[bot]
be7d0d97e4 chore(deps): update dependency @openedx/frontend-build to v13.0.29 2024-03-25 10:26:14 +00:00
Syed Sajjad Hussain Shah
e6aa4be4f6 fix: update docs 2024-03-25 12:49:25 +05:00
Syed Sajjad Hussain Shah
f2c236c828 fix: remove logo URL 2024-03-25 12:49:25 +05:00
Syed Sajjad Hussain Shah
db912e6dae feat: enable header to accept custom menus 2024-03-25 12:49:25 +05:00
Samir Sabri
abb08be08e feat!: remove Transifex calls for OEP-58 (#416) 2024-03-22 13:08:10 -04:00
Bilal Qamar
6c6ccc7f20 chore(deps): update frontend-platform to v7 (#469)
* chore(deps): update frontend-platform to v7

* refactor: updated eslintrc
2024-03-22 11:34:40 +05:00
Adolfo R. Brandes
2494ad2b57 build: start releasing alpha packages 2024-03-20 13:09:30 -03:00
Jenkins
3b2a2bfa95 chore(i18n): update translations 2024-01-28 15:31:28 -05:00
renovate[bot]
30b91791e3 chore(deps): update dependency react-router-dom to v6.21.3 2024-01-22 09:41:11 +00:00
Brian Smith
08592aeec7 fix: update paragon dep to support v22 (#460) 2024-01-19 11:30:44 -05:00
Brian Smith
c1d143ace2 fix: update frontend-platform peer dependency (#459)
This updates the `frontend-platform` peer dependency to require a version that has paragon in the `openedx` scope as a peer dependency, as opposed to the `edx` scope
2024-01-18 12:59:14 -05:00
Brian Smith
54a879aec2 chore!: move paragon to peer dependency using @openedx scope (#456)
BREAKING CHANGE: consuming applications must now provide `paragon` from the `@openedx` scope
2024-01-16 11:06:27 -05:00
renovate[bot]
b5b37f1d64 chore(deps): update dependency @testing-library/dom to v9.3.4 2024-01-15 13:19:09 +00:00
renovate[bot]
36be99ace0 chore(deps): update dependency @openedx/frontend-build to v13.0.27 2024-01-15 11:42:22 +00:00
dependabot[bot]
222bc19bd0 chore(deps): bump follow-redirects from 1.15.2 to 1.15.4 (#454)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.2 to 1.15.4.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.2...v1.15.4)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-11 17:14:11 +05:00
renovate[bot]
3d827e64ea chore(deps): update dependency redux-saga to v1.3.0 (#453)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-08 15:40:28 +05:00
renovate[bot]
8a247abd6a fix(deps): update dependency @openedx/paragon to v21.13.0 2024-01-01 12:25:49 +00:00
renovate[bot]
a6943fbaeb chore(deps): update dependency @edx/frontend-platform to v6.2.0 2024-01-01 10:17:53 +00:00
Mashal Malik
19292cd5b6 refactor: replace @edx/paragon with @openedx/paragon (#446)
* refactor: replace @edx/paragon with @openedx/paragon

* fix: replaced frontend-build to @openedx to fix jest issues

* refactor: removed old unused package

---------

Co-authored-by: Abdullah Waheed <abdullah.waheed@arbisoft.com>
2024-01-01 13:08:15 +05:00
Syed Ali Abbas Zaidi
b6374a5c05 feat: migrate enzyme with RTL (#439)
Co-authored-by: Abdullah Waheed <abdullah.waheed@arbisoft.com>
2023-12-25 16:23:33 +05:00
renovate[bot]
f6a4036b49 fix(deps): update font awesome to v6.5.1 2023-12-25 09:29:05 +00:00
renovate[bot]
12a845ad33 chore(deps): update dependency react-router-dom to v6.21.1 2023-12-25 09:07:12 +00:00
renovate[bot]
a4a7456726 chore(deps): update dependency react-router-dom to v6.21.0 2023-12-18 13:48:34 +00:00
renovate[bot]
6fdf73fed3 chore(deps): update dependency @edx/frontend-build to v13.0.14 2023-12-18 10:46:30 +00:00
renovate[bot]
32b8079744 chore(deps): update dependency @edx/frontend-platform to v6.1.2 2023-12-11 12:36:07 +00:00
renovate[bot]
b9f7fe74c8 chore(deps): update dependency @edx/frontend-build to v13.0.12 2023-12-11 10:39:07 +00:00
dependabot[bot]
5a259a76df chore(deps-dev): bump @adobe/css-tools from 4.3.1 to 4.3.2 (#440)
Bumps [@adobe/css-tools](https://github.com/adobe/css-tools) from 4.3.1 to 4.3.2.
- [Changelog](https://github.com/adobe/css-tools/blob/main/History.md)
- [Commits](https://github.com/adobe/css-tools/commits)

---
updated-dependencies:
- dependency-name: "@adobe/css-tools"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-06 13:10:03 +05:00
Jenkins
d2ad5ee2a4 chore(i18n): update translations 2023-12-03 15:31:05 -05:00
renovate[bot]
67fefe814b chore(deps): update dependency react-router-dom to v6.20.0 2023-11-27 11:37:21 +00:00
renovate[bot]
9a5b1fa5e7 chore(deps): update dependency react-router-dom to v6.19.0 2023-11-20 13:25:44 +00:00
renovate[bot]
55f21aeeaa chore(deps): update dependency @edx/frontend-build to v13.0.8 2023-11-20 09:19:45 +00:00
Jenkins
fc24e61a1c chore(i18n): update translations 2023-11-19 15:31:00 -05:00
Kristin Aoki
bace8286fd fix: studio dropdown hover styles (#433) 2023-11-16 11:18:00 -05:00
renovate[bot]
665653e9a5 chore(deps): update actions/setup-node action to v4 (#429)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-16 11:46:31 +05:00
Bilal Qamar
0cc2282c44 feat: bumped frontend-platform version (#432) 2023-11-15 17:21:06 +05:00
Bilal Qamar
455ffd345c Revert "chore: bumped frontend-platform version (#430)" (#431)
This reverts commit dde02a0739.
2023-11-15 17:11:01 +05:00
Bilal Qamar
dde02a0739 chore: bumped frontend-platform version (#430) 2023-11-15 16:51:34 +05:00
renovate[bot]
81cb72f10b chore(deps): update dependency @edx/frontend-build to v13.0.5 2023-11-13 10:40:46 +00:00
Kristin Aoki
e285a91408 fix: admin home link in user menu (#427) 2023-11-07 12:17:41 -05:00
Kristin Aoki
50142adb85 Merge pull request #426 from openedx/KristinAoki/fix-broken-studio-home-link
fix: broken studio home link
2023-11-06 10:17:33 -05:00
KristinAoki
7281804fbd fix: broken studio home link 2023-11-06 09:13:01 -05:00
renovate[bot]
b446534992 chore(deps): update dependency react-router-dom to v6.18.0 2023-11-06 12:07:03 +00:00
renovate[bot]
3adc305aec chore(deps): update dependency @edx/frontend-build to v13.0.4 2023-11-06 11:36:45 +00:00
Jenkins
65446ce9c3 chore(i18n): update translations 2023-11-05 15:32:11 -05:00
renovate[bot]
f3637b5624 fix(deps): update dependency axios-mock-adapter to v1.22.0 2023-10-30 12:47:59 +00:00
Muhammad Abdullah Waheed
3c1d2152aa feat: babel-plugin-react-intl to babel-plugin-formatjs migration (#404)
* feat: babel-plugin-react-intl to babel-plugin-formatjs migration

* fix: upgraded frontend-build to fix security issue

* fix: upgraded frontend-build to fix security issue

* fix: upgraded frontend build again

* refactor: upgraded to latest frontend-build and resolved vulnerabilities
2023-10-30 15:17:10 +05:00
renovate[bot]
48b22ea41e chore(deps): update dependency react-router-dom to v6.17.0 2023-10-30 10:09:57 +00:00
renovate[bot]
ed76e40862 fix(deps): update dependency @edx/paragon to v21.5.6 2023-10-30 07:33:58 +00:00
Bilal Qamar
d5b07cc38f chore: bumped frontend-platform version (#417) 2023-10-24 13:54:00 +05:00
Feanil Patel
b9509bb890 chore: Update to the new version of brand-openedx in the new scope. (#414)
Part of https://github.com/openedx/axim-engineering/issues/23

This updates the `@edx/brand` alias to point to the `brand-openedx` package at
the `openedx` scope. This does not impact imports because this package is used
via an alias.
2023-10-20 17:18:23 -04:00
dependabot[bot]
c87a1049c8 chore(deps-dev): bump @babel/traverse from 7.22.5 to 7.23.2 (#413)
Bumps [@babel/traverse](https://github.com/babel/babel/tree/HEAD/packages/babel-traverse) from 7.22.5 to 7.23.2.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.23.2/packages/babel-traverse)

---
updated-dependencies:
- dependency-name: "@babel/traverse"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-10-18 16:52:03 +05:00
Bilal Qamar
e0c22e781d feat: bumped frontend-platform to v6 (#412) 2023-10-18 16:05:45 +05:00
renovate[bot]
f1605d1f27 fix(deps): update dependency @edx/paragon to v21.5.3 2023-10-16 13:15:31 +00:00
renovate[bot]
5d7826c26c chore(deps): update dependency @edx/frontend-platform to v5.5.4 2023-10-16 09:30:30 +00:00
renovate[bot]
5d075b0cdc chore(deps): update actions/checkout action to v4 (#395)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-12 12:10:30 +05:00
renovate[bot]
1a86685f11 fix(deps): update dependency @edx/paragon to v21.3.1 2023-10-09 11:14:33 +00:00
renovate[bot]
e31597509c chore(deps): update dependency @edx/frontend-platform to v5.5.1 2023-10-09 11:11:49 +00:00
Jenkins
c79c137fd6 chore(i18n): update translations 2023-10-08 16:31:21 -04:00
Mashal Malik
76f735ed39 refactor: updated README file to reflect template changes (#380)
* refactor: update README File

* refactor: update README file

* refactor: update readMe file

* refactor: update readMe file

* refactor: update readMe file

* refactor: update readMe file

* refactor: update readMe file

* refactor: update readMe file

* refactor: update readMe file

* refactor: update readMe file

* refactor: update readMe file

* refactor: update readMe file

* refactor: update readMe file

* refactor: update readMe file

* refactor: update readMe file

* refactor: update readMe file

* refactor: update readMe file

* refactor: replace npm install with npm ci
2023-10-04 12:29:28 +05:00
Kristin Aoki
2298791aeb feat: update studio header to be more accessible 2023-10-02 11:37:48 -04:00
KristinAoki
9f48ccc66b feat: update studio header to be more accessible 2023-10-02 10:05:22 -04:00
Mashal Malik
398479d2e7 refactor: add @openedx in renovate automate configuration (#400) 2023-10-02 18:58:01 +05:00
renovate[bot]
3d6d815373 chore(deps): update dependency react-router-dom to v6.16.0 2023-10-02 12:47:21 +00:00
renovate[bot]
287bf50a46 chore(deps): update dependency @edx/frontend-build to v12.9.17 2023-10-02 10:28:13 +00:00
renovate[bot]
677e872320 chore(deps): update dependency jest to v29.7.0 2023-09-25 13:14:25 +00:00
renovate[bot]
d8ecfd6fa3 chore(deps): update dependency @edx/frontend-platform to v5.4.0 2023-09-25 10:21:00 +00:00
renovate[bot]
9a6074868b fix(deps): update dependency @edx/paragon to v21 (#393)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-25 11:31:13 +05:00
renovate[bot]
077ecf38a4 chore(deps): update dependency @testing-library/dom to v9.3.3 2023-09-18 12:39:04 +00:00
renovate[bot]
7d6aa276ec chore(deps): update dependency @edx/frontend-platform to v5.3.2 2023-09-18 11:31:49 +00:00
renovate[bot]
115e69fad3 chore(deps): update dependency @edx/frontend-platform to v5.3.1 2023-09-07 16:42:05 +00:00
Mashal Malik
3e04f76c81 refactor: update lock file version (#391) 2023-08-31 11:29:46 +05:00
renovate[bot]
83eeb88eab chore(deps): update dependency jest to v29.6.4 2023-08-28 06:07:32 +00:00
renovate[bot]
a05570ed37 chore(deps): update dependency react-router-dom to v6.15.0 2023-08-21 09:53:30 +00:00
renovate[bot]
058a846978 chore(deps): update dependency @edx/frontend-build to v12.9.10 2023-08-21 08:26:22 +00:00
renovate[bot]
04cd95824e chore(deps): update dependency @edx/frontend-platform to v5.1.0 2023-08-18 20:03:31 +00:00
Syed Ali Abbas Zaidi
678b95f60d feat: upgrade react router to v6 (#316)
* feat: upgrade react router to v6

* chore: update peer deps
2023-08-16 16:46:49 +05:00
renovate[bot]
599f513624 fix(deps): update dependency @edx/paragon to v20.46.2 2023-08-14 10:18:32 +00:00
renovate[bot]
e8ce471f6f fix(deps): update font awesome to v6.4.2 2023-08-14 06:15:41 +00:00
renovate[bot]
4e727748af chore(deps): update dependency @edx/frontend-platform to v4.6.1 2023-08-07 10:37:18 +00:00
renovate[bot]
79c5bbff68 chore(deps): update dependency @edx/frontend-build to v12.9.4 2023-08-07 08:25:09 +00:00
Jenkins
a95b600a00 chore(i18n): update translations 2023-08-06 16:31:03 -04:00
Awais Ansari
464e952a1e Merge pull request #379 from openedx/aansari/INF-993
feat: deprecate notification tray in learning header
2023-08-02 14:35:38 +05:00
Awais Ansari
018ca18a4e feat: deprecate notification tray in learning header 2023-08-01 18:50:12 +05:00
renovate[bot]
4b89e7561f chore(deps): update dependency jest to v29.6.2 2023-07-31 09:53:25 +00:00
renovate[bot]
da08e721c2 chore(deps): update dependency @edx/frontend-build to v12.9.3 2023-07-31 07:34:58 +00:00
renovate[bot]
d037e96a0c chore(deps): update dependency @testing-library/jest-dom to v5.17.0 2023-07-24 12:16:59 +00:00
renovate[bot]
050dd30d8f chore(deps): update dependency @edx/frontend-build to v12.9.2 2023-07-24 09:55:32 +00:00
renovate[bot]
3dd41030d3 fix(deps): update dependency @edx/paragon to v20.45.5 2023-07-24 06:29:20 +00:00
Awais Ansari
27228f093d Merge pull request #370 from openedx/Ayesha/INF-951
fix: clicking on notification redirecting to correct URL
2023-07-14 13:04:49 +05:00
Muhammad Adeel Tajamul
83f3241e26 fix: course name was not visible in notification (#371) 2023-07-14 08:53:02 +05:00
ayeshoali
e153aefc13 fix: clicking on notification redirecting to correct URL 2023-07-13 15:48:35 +05:00
Bilal Qamar
936c8714b7 fix: updated frontend-build to resolve word-wrap ReDoS vulnerability (#368) 2023-07-11 17:31:29 +05:00
renovate[bot]
6b18f28933 chore(deps): update dependency jest to v29.6.1 2023-07-10 10:41:00 +00:00
ayesha waris
0ce451cfd2 feat: integrated notifications tray with backend apis (#363)
* feat: integrated notifications tray with backend apis

* test: updates test cases

* refactor: loader added and resolves minor nits

* test: fixes test cases related to pagination

* refactor: moved pagination to normalised data
2023-07-10 13:18:44 +05:00
renovate[bot]
42e831a693 chore(deps): update dependency @edx/frontend-build to v12.8.60 2023-07-10 07:38:50 +00:00
Jenkins
9af7eaf587 chore(i18n): update translations 2023-07-09 16:30:57 -04:00
Bilal Qamar
3fbbde1044 fix: updated frontend-build to bump eslint version (#364) 2023-07-06 18:14:00 +05:00
Bilal Qamar
c3f0282be4 feat: update react & react-dom to v17 (#346)
* feat: update react & react-dom to v17

* build: update paragon version

* build: update lock file

* refactor: updated frontend-platform

---------

Co-authored-by: mashal-m <mashal.malik@arbisoft.com>
2023-07-05 12:04:53 +05:00
sundasnoreen12
ceb9c13542 Merge pull request #360 from openedx/sundas/INF-917
test: added test cases for UI components
2023-07-03 23:20:05 -07:00
SundasNoreen
7229bff3ff refactor: added mock funtion for notification count api 2023-07-03 16:30:03 +05:00
renovate[bot]
0695d4f400 fix(deps): update font awesome to v6.4.0 2023-07-03 08:13:31 +00:00
SundasNoreen
de8783c708 refactor: fixed review points 2023-06-27 16:28:58 +05:00
renovate[bot]
e2540bc3a0 fix(deps): update dependency @edx/paragon to v20.45.0 2023-06-26 19:55:23 +00:00
renovate[bot]
ffb5a765e2 chore(deps): update dependency @edx/reactifex to v2.2.0 2023-06-26 14:58:49 +00:00
renovate[bot]
952e543217 fix(deps): update dependency axios-mock-adapter to v1.21.5 2023-06-26 11:48:04 +00:00
SundasNoreen
0e6a272f2b test: added test cases for UI components 2023-06-26 13:39:09 +05:00
renovate[bot]
45a1da9f5e chore(deps): update dependency @edx/frontend-build to v12.8.57 2023-06-26 06:43:40 +00:00
81 changed files with 6967 additions and 15569 deletions

View File

@@ -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');

View File

@@ -11,13 +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 }}
- name: Install dependencies

View File

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

View File

@@ -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

1
.gitignore vendored
View File

@@ -9,3 +9,4 @@ module.config.js
.idea/
.vscode
src/i18n/messages

View File

@@ -1,5 +1,8 @@
{
"branch": "master",
"branches": [
"master",
{name: "alpha", prerelease: true}
],
"tagFormat": "v${version}",
"verifyConditions": [
"@semantic-release/npm",

View File

@@ -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

View File

@@ -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...

View File

@@ -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:
@@ -58,10 +101,8 @@ 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 +116,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 +184,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

View File

@@ -1,3 +1,3 @@
const { createConfig } = require('@edx/frontend-build');
const { createConfig } = require('@openedx/frontend-build');
module.exports = createConfig('babel-preserve-modules');

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View 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.

View File

@@ -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'),
);

View File

@@ -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";

View File

@@ -1,4 +1,4 @@
const { createConfig } = require('@edx/frontend-build');
const { createConfig } = require('@openedx/frontend-build');
module.exports = createConfig('jest', {
setupFilesAfterEnv: [

18298
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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,48 +33,42 @@
},
"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.54",
"@edx/frontend-platform": "4.5.1",
"@edx/frontend-platform": "8.0.0",
"@edx/reactifex": "^2.1.1",
"@testing-library/dom": "9.3.1",
"@testing-library/jest-dom": "5.16.5",
"@openedx/frontend-build": "14.0.3",
"@openedx/paragon": "22.2.2",
"@testing-library/dom": "10.0.0",
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "10.4.9",
"enzyme": "3.11.0",
"enzyme-adapter-react-16": "1.15.7",
"husky": "8.0.3",
"jest": "29.5.0",
"jest": "29.7.0",
"jest-chain": "1.1.6",
"prop-types": "15.8.1",
"react": "16.14.0",
"react-dom": "16.14.0",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-redux": "7.2.9",
"react-test-renderer": "16.14.0",
"react-router-dom": "6.22.3",
"react-test-renderer": "17.0.2",
"redux": "4.2.1",
"redux-saga": "1.2.3"
"redux-saga": "1.3.0"
},
"dependencies": {
"@edx/paragon": "20.44.0",
"@fortawesome/fontawesome-svg-core": "6.3.0",
"@fortawesome/free-brands-svg-icons": "6.3.0",
"@fortawesome/free-regular-svg-icons": "6.3.0",
"@fortawesome/free-solid-svg-icons": "6.3.0",
"@fortawesome/fontawesome-svg-core": "6.5.2",
"@fortawesome/free-brands-svg-icons": "6.5.2",
"@fortawesome/free-regular-svg-icons": "6.5.2",
"@fortawesome/free-solid-svg-icons": "6.5.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"@reduxjs/toolkit": "1.9.5",
"axios-mock-adapter": "1.21.4",
"axios-mock-adapter": "1.22.0",
"babel-polyfill": "6.26.0",
"classnames": "2.3.2",
"lodash": "4.17.21",
"react-redux": "7.2.9",
"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"

View File

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

View File

@@ -19,25 +19,31 @@ class DesktopHeader extends React.Component {
super(props);
}
renderMainMenu() {
const { mainMenu } = this.props;
renderMenu(menu) {
// Nodes are accepted as a prop
if (!Array.isArray(mainMenu)) {
return mainMenu;
if (!Array.isArray(menu)) {
return menu;
}
return mainMenu.map((menuItem) => {
return menu.map((menuItem) => {
const {
type,
href,
content,
submenuContent,
disabled,
isActive,
} = menuItem;
if (type === 'item') {
return (
<a key={`${type}-${content}`} className="nav-link" href={href}>{content}</a>
<a
key={`${type}-${content}`}
className={`nav-link${disabled ? ' disabled' : ''}${isActive ? ' active' : ''}`}
href={href}
>
{content}
</a>
);
}
@@ -54,22 +60,14 @@ class DesktopHeader extends React.Component {
});
}
// 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>
);
renderMainMenu() {
const { mainMenu } = this.props;
return this.renderMenu(mainMenu);
}
renderSecondaryMenu() {
const { secondaryMenu } = this.props;
return this.renderMenu(secondaryMenu);
}
renderUserMenu() {
@@ -91,8 +89,23 @@ class DesktopHeader extends React.Component {
{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>
{userMenu.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,
}) => (
<a
className={`dropdown-${type}${isActive ? ' active' : ''}${disabled ? ' disabled' : ''}`}
key={`${type}-${content}`}
href={href}
>
{content}
</a>
))}
{index < userMenu.length - 1 && <div className="dropdown-divider" role="separator" />}
</React.Fragment>
))}
</MenuContent>
</Menu>
@@ -120,7 +133,6 @@ class DesktopHeader extends React.Component {
logoDestination,
loggedIn,
intl,
appMenu,
} = this.props;
const logoProps = { src: logo, alt: logoAltText, href: logoDestination };
const logoClasses = getConfig().AUTHN_MINIMAL_HEADER ? 'mw-100' : null;
@@ -137,19 +149,17 @@ class DesktopHeader extends React.Component {
>
{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()}
{loggedIn
? (
<>
{this.renderSecondaryMenu()}
{this.renderUserMenu()}
</>
) : this.renderLoggedOutItems()}
</nav>
</div>
</div>
@@ -163,10 +173,18 @@ DesktopHeader.propTypes = {
PropTypes.node,
PropTypes.array,
]),
secondaryMenu: PropTypes.oneOfType([
PropTypes.node,
PropTypes.array,
]),
userMenu: PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.oneOf(['item', 'menu']),
href: PropTypes.string,
content: PropTypes.string,
heading: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.oneOf(['item', 'menu']),
href: PropTypes.string,
content: PropTypes.string,
isActive: PropTypes.bool,
})),
})),
loggedOutItems: PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.oneOf(['item', 'menu']),
@@ -182,24 +200,11 @@ DesktopHeader.propTypes = {
// 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: [],
secondaryMenu: [],
userMenu: [],
loggedOutItems: [],
logo: null,
@@ -208,7 +213,6 @@ DesktopHeader.defaultProps = {
avatar: null,
username: null,
loggedIn: false,
appMenu: null,
};
export default injectIntl(DesktopHeader);

View File

@@ -10,6 +10,7 @@ import {
subscribe,
} from '@edx/frontend-platform';
import PropTypes from 'prop-types';
import DesktopHeader from './DesktopHeader';
import MobileHeader from './MobileHeader';
@@ -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,13 +115,14 @@ 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}>
<Responsive maxWidth={769}>
<MobileHeader {...props} />
</Responsive>
<Responsive minWidth={769}>
@@ -112,8 +132,31 @@ const Header = ({ intl }) => {
);
};
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);

View File

@@ -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,

View File

@@ -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(' '),

View File

@@ -19,25 +19,29 @@ class MobileHeader extends React.Component {
super(props);
}
renderMainMenu() {
const { mainMenu } = this.props;
renderMenu(menu) {
// Nodes are accepted as a prop
if (!Array.isArray(mainMenu)) {
return mainMenu;
if (!Array.isArray(menu)) {
return menu;
}
return mainMenu.map((menuItem) => {
return menu.map((menuItem) => {
const {
type,
href,
content,
submenuContent,
disabled,
isActive,
} = menuItem;
if (type === 'item') {
return (
<a key={`${type}-${content}`} className="nav-link" href={href}>
<a
key={`${type}-${content}`}
className={`nav-link${disabled ? ' disabled' : ''}${isActive ? ' active' : ''}`}
href={href}
>
{content}
</a>
);
@@ -56,13 +60,32 @@ class MobileHeader extends React.Component {
});
}
renderMainMenu() {
const { mainMenu } = this.props;
return this.renderMenu(mainMenu);
}
renderSecondaryMenu() {
const { secondaryMenu } = this.props;
return this.renderMenu(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 userMenu.map((group) => (
group.items.map(({
type, content, href, disabled, isActive,
}) => (
<li className="nav-item" key={`${type}-${content}`}>
<a
className={`nav-link${isActive ? ' active' : ''}${disabled ? ' disabled' : ''}`}
href={href}
>
{content}
</a>
</li>
))
));
}
@@ -123,6 +146,7 @@ class MobileHeader extends React.Component {
className="nav flex-column pin-left pin-right border-top shadow py-2"
>
{this.renderMainMenu()}
{this.renderSecondaryMenu()}
</MenuContent>
</Menu>
</div>
@@ -157,11 +181,18 @@ MobileHeader.propTypes = {
PropTypes.node,
PropTypes.array,
]),
secondaryMenu: PropTypes.oneOfType([
PropTypes.node,
PropTypes.array,
]),
userMenu: PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.oneOf(['item', 'menu']),
href: PropTypes.string,
content: PropTypes.string,
heading: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.oneOf(['item', 'menu']),
href: PropTypes.string,
content: PropTypes.string,
isActive: PropTypes.bool,
})),
})),
loggedOutItems: PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.oneOf(['item', 'menu']),
@@ -182,6 +213,7 @@ MobileHeader.propTypes = {
MobileHeader.defaultProps = {
mainMenu: [],
secondaryMenu: [],
userMenu: [],
loggedOutItems: [],
logo: null,

View File

@@ -1,71 +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}
>
<Icon src={iconComponent} className={`${iconClass} mr-4 notification-icon`} />
<div className="d-flex w-100">
<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 }}
/>
<div className="py-0 d-flex">
<span className="font-size-12 text-gray-500 line-height-20">
<span>{courseName}</span>
<span className="text-light-700 px-1.5">{intl.formatMessage(messages.fullStop)}</span>
<span>{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" />
</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);

View File

@@ -1,81 +0,0 @@
import React, { useCallback, useMemo } from 'react';
import { Button } 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 } from './data/selectors';
import { splitNotificationsByTime } from './utils';
import { updatePaginationRequest } from './data/slice';
const NotificationSections = () => {
const intl = useIntl();
const dispatch = useDispatch();
const selectedAppName = useSelector(selectSelectedAppName());
const notifications = useSelector(selectNotificationsByIds(selectedAppName));
const { currentPage, numPages } = 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}
>
{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">
{renderNotificationSection('today', today)}
{renderNotificationSection('earlier', earlier)}
{currentPage < numPages && (
<Button variant="primary" className="w-100 bg-primary-500" onClick={updatePagination}>
{intl.formatMessage(messages.loadMoreNotifications)}
</Button>
)}
</div>
);
};
export default React.memo(NotificationSections);

View File

@@ -1,52 +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, pageSize: 10 }));
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"
>
{appName === selectedAppName && (<NotificationSections />)}
</Tab>
)), [notificationUnseenCounts, selectedAppName, notificationTabs]);
return (
<Tabs
variant="tabs"
defaultActiveKey={selectedAppName}
onSelect={handleActiveTab}
className="px-2.5 text-primary-500"
>
{tabArray}
</Tabs>
);
};
export default React.memo(NotificationTabs);

View File

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

View File

@@ -1,22 +0,0 @@
import { Factory } from 'rosie';
Factory.define('notificationsCount')
.attr('count', 45)
.attr('countByAppName', {
reminders: 10,
discussions: 20,
grades: 10,
authoring: 5,
})
.attr('showNotificationsTray', true);
Factory.define('notification')
.sequence('id')
.attr('type', 'post')
.sequence('content', ['id'], (idx, notificationId) => `<p><b>User ${idx}</b> posts <b>Hello and welcome to SC0x
${notificationId}!</b></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);

View File

@@ -1,44 +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 getNotificationsApiUrl = () => `${getConfig().LMS_BASE_URL}/api/notifications/`;
export const markNotificationsSeenApiUrl = (appName) => `${getConfig().LMS_BASE_URL}/api/notifications/mark-notifications-unseen/${appName}/`;
export const markNotificationAsReadApiUrl = () => `${getConfig().LMS_BASE_URL}/api/notifications/read/`;
export async function getNotifications(appName, page, pageSize) {
const params = snakeCaseObject({ page, pageSize });
const { data } = await getAuthenticatedHttpClient().get(getNotificationsApiUrl(), { params });
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;
const notifications = data.slice(startIndex, endIndex);
return { notifications, numPages: 2, currentPage: page };
}
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().put(markNotificationAsReadApiUrl(), { params });
return data;
}
export async function markNotificationRead(notificationId) {
const params = snakeCaseObject({ notificationId });
const { data } = await getAuthenticatedHttpClient().put(markNotificationAsReadApiUrl(), { params });
return { data, id: notificationId };
}

View File

@@ -1,150 +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 {
getNotificationsApiUrl, getNotificationsCountApiUrl, markNotificationAsReadApiUrl, markNotificationsSeenApiUrl,
getNotificationCounts, getNotifications, markNotificationSeen, markAllNotificationRead, markNotificationRead,
} from './api';
import './__factories__';
const notificationCountsApiUrl = getNotificationsCountApiUrl();
const notificationsApiUrl = getNotificationsApiUrl();
const markedAllNotificationsAsSeenApiUrl = markNotificationsSeenApiUrl('discussions');
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.discussions).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.buildList('notification', 2, null, { createdDate: new Date().toISOString() })),
);
const { notifications } = await getNotifications('discussions', 1, 10);
expect(notifications).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 getNotifications({ page: 1, pageSize: 10 });
} 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('discussions');
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('discussions');
} 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.onPut(markedAllNotificationsAsReadApiUrl).reply(200, { message: 'Notifications marked read.' });
const { message } = await markAllNotificationRead('discussions');
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.onPut(markedAllNotificationsAsReadApiUrl).reply(statusCode, { message });
try {
await markAllNotificationRead('discussions');
} catch (error) {
expect(error.response.status).toEqual(statusCode);
expect(error.response.data.message).toEqual(message);
}
});
it('Successfully marked notification as read.', async () => {
axiosMock.onPut(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.onPut(markedAllNotificationsAsReadApiUrl).reply(statusCode, { message });
try {
await markAllNotificationRead(1);
} catch (error) {
expect(error.response.status).toEqual(statusCode);
expect(error.response.data.message).toEqual(message);
}
});
});

View File

@@ -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;
}

View File

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

View File

@@ -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_Leads</b> response has been marked as answer in your post <b>Quiz in section 3 - Please explain the F-Significance value</b></p>",
"course_name": "Supply Chain Analytics",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:36:11.979531Z"
},
{
"id": 12,
"type": "endorsed",
"content": "<p>Your response has been endorsed in <b>Quiz in section 3 - Please explain the F-Significance value</b></p>",
"course_name": "Supply Chain Analytics",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:36:11.979531Z"
},
{
"id": 13,
"type": "reported",
"content": "<p><b>MITx Learners</b> post has been reported <b>“Here are the exam answers. Question 1 - CSA stands for Compliance Safety Ac...”</b></p>",
"course_name": "Supply Chain Analytics",
"content_url": "",
"last_read": null,
"last_seen": null,
"created_at": "2023-06-01T00:36:11.979531Z"
}
]
}

View File

@@ -1,164 +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 {
getNotificationsApiUrl, getNotificationsCountApiUrl, markNotificationAsReadApiUrl, markNotificationsSeenApiUrl,
} from './api';
import {
fetchAppsNotificationCount, fetchNotificationList, markNotificationsAsRead, markAllNotificationsAsRead,
resetNotificationState, markNotificationsAsSeen,
} from './thunks';
import './__factories__';
const notificationCountsApiUrl = getNotificationsCountApiUrl();
const notificationsApiUrl = getNotificationsApiUrl();
const markedAllNotificationsAsReadApiUrl = markNotificationAsReadApiUrl();
const markedAllNotificationsAsSeenApiUrl = markNotificationsSeenApiUrl('discussions');
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();
axiosMock.onGet(notificationCountsApiUrl).reply(200, (Factory.build('notificationsCount')));
axiosMock.onGet(notificationsApiUrl).reply(
200,
(Factory.buildList('notification', 2, null, { createdDate: new Date().toISOString() })),
);
await executeThunk(fetchAppsNotificationCount(), store.dispatch, store.getState);
await executeThunk(fetchNotificationList({ page: 1, pageSize: 10 }), store.dispatch, store.getState);
});
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('discussions');
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.count).toEqual(10);
expect(notifications.pagination.numPages).toEqual(1);
expect(notifications.pagination.currentPage).toEqual(1);
expect(notifications.pagination.nextPage).toBeNull();
});
it('Successfully loaded notifications list in the redux.', async () => {
const { notifications: { notifications } } = store.getState();
expect(Object.keys(notifications)).toHaveLength(2);
});
it.each([
{ statusCode: 404, status: 'failed' },
{ statusCode: 403, status: 'denied' },
])('%s to load notifications list in the redux.', async ({ statusCode, status }) => {
axiosMock.onGet(notificationsApiUrl).reply(statusCode);
await executeThunk(fetchNotificationList({ page: 1, pageSize: 10 }), 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.discussions).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('discussions'), 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('discussions'), 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.onPut(markedAllNotificationsAsReadApiUrl).reply(200);
await executeThunk(markAllNotificationsAsRead('discussions'), 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.onPut(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.onPut(markedAllNotificationsAsReadApiUrl).reply(statusCode);
await executeThunk(markNotificationsAsRead(1), store.dispatch, store.getState);
const { notifications: { notificationStatus } } = store.getState();
expect(notificationStatus).toEqual(status);
});
});

View File

@@ -1,126 +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 { getNotificationsApiUrl, getNotificationsCountApiUrl } from './api';
import {
selectNotifications,
selectNotificationsByIds,
selectNotificationStatus,
selectNotificationTabs,
selectNotificationTabsCount,
selectPaginationData,
selectSelectedAppName,
selectSelectedAppNotificationIds,
selectShowNotificationTray,
} from './selectors';
import { fetchAppsNotificationCount, fetchNotificationList } from './thunks';
import './__factories__';
const notificationCountsApiUrl = getNotificationsCountApiUrl();
const notificationsApiUrl = getNotificationsApiUrl();
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();
axiosMock.onGet(notificationCountsApiUrl).reply(200, (Factory.build('notificationsCount')));
axiosMock.onGet(notificationsApiUrl).reply(
200,
(Factory.buildList('notification', 2, null, { createdDate: new Date().toISOString() })),
);
await executeThunk(fetchAppsNotificationCount(), store.dispatch, store.getState);
await executeThunk(fetchNotificationList({ page: 1, pageSize: 10 }), store.dispatch, store.getState);
});
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.discussions).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('discussions')(state);
expect(notificationIds).toHaveLength(2);
});
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(2);
});
it('Should return notifications from Ids.', async () => {
const state = store.getState();
const notifications = selectNotificationsByIds('discussions')(state);
expect(notifications).toHaveLength(2);
});
it('Should return selected app name.', async () => {
const state = store.getState();
const appName = selectSelectedAppName()(state);
expect(appName).toEqual('discussions');
});
it('Should return pagination data.', async () => {
const state = store.getState();
const paginationData = selectPaginationData()(state);
expect(paginationData.count).toEqual(10);
expect(paginationData.currentPage).toEqual(1);
expect(paginationData.numPages).toEqual(2);
});
});

View File

@@ -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;

View File

@@ -1,154 +0,0 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
export const RequestStatus = {
IDLE: 'idle',
LOADING: 'in-progress',
LOADED: 'successful',
FAILED: 'failed',
DENIED: 'denied',
};
const initialState = {
notificationStatus: 'idle',
appName: 'discussions',
appsId: [],
apps: {},
notifications: {},
tabsCount: {},
showNotificationsTray: false,
pagination: {
count: 10,
numPages: 1,
currentPage: 1,
nextPage: null,
},
};
const slice = createSlice({
name: 'notifications',
initialState,
reducers: {
fetchNotificationDenied: (state) => {
state.notificationStatus = RequestStatus.DENIED;
},
fetchNotificationFailure: (state) => {
state.notificationStatus = RequestStatus.FAILED;
},
fetchNotificationRequest: (state) => {
state.notificationStatus = RequestStatus.LOADING;
},
fetchNotificationSuccess: (state, { payload }) => {
const {
newNotificationIds, notificationsKeyValuePair, numPages, currentPage,
} = 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.LOADED;
state.pagination.numPages = numPages;
state.pagination.currentPage = currentPage;
},
fetchNotificationsCountDenied: (state) => {
state.notificationStatus = RequestStatus.DENIED;
},
fetchNotificationsCountFailure: (state) => {
state.notificationStatus = RequestStatus.FAILED;
},
fetchNotificationsCountRequest: (state) => {
state.notificationStatus = RequestStatus.LOADING;
},
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.LOADED;
},
markNotificationsAsSeenRequest: (state) => {
state.notificationStatus = RequestStatus.LOADING;
},
markNotificationsAsSeenSuccess: (state) => {
state.notificationStatus = RequestStatus.LOADED;
},
markNotificationsAsSeenDenied: (state) => {
state.notificationStatus = RequestStatus.DENIED;
},
markNotificationsAsSeenFailure: (state) => {
state.notificationStatus = RequestStatus.FAILED;
},
markAllNotificationsAsReadRequest: (state) => {
state.notificationStatus = RequestStatus.LOADING;
},
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.LOADED;
},
markAllNotificationsAsReadDenied: (state) => {
state.notificationStatus = RequestStatus.DENIED;
},
markAllNotificationsAsReadFailure: (state) => {
state.notificationStatus = RequestStatus.FAILED;
},
markNotificationsAsReadRequest: (state) => {
state.notificationStatus = RequestStatus.LOADING;
},
markNotificationsAsReadSuccess: (state, { payload }) => {
const date = new Date().toISOString();
state.notifications[payload.id] = { ...state.notifications[payload.id], lastRead: date };
state.notificationStatus = RequestStatus.LOADED;
},
markNotificationsAsReadDenied: (state) => {
state.notificationStatus = RequestStatus.DENIED;
},
markNotificationsAsReadFailure: (state) => {
state.notificationStatus = RequestStatus.FAILED;
},
resetNotificationStateRequest: () => initialState,
updateAppNameRequest: (state, { payload }) => {
state.appName = payload.appName;
state.pagination.currentPage = 1;
},
updatePaginationRequest: (state) => {
state.pagination.currentPage += 1;
},
},
});
export const {
fetchNotificationDenied,
fetchNotificationFailure,
fetchNotificationRequest,
fetchNotificationSuccess,
fetchNotificationsCountDenied,
fetchNotificationsCountFailure,
fetchNotificationsCountRequest,
fetchNotificationsCountSuccess,
markNotificationsAsSeenRequest,
markNotificationsAsSeenSuccess,
markNotificationsAsSeenFailure,
markNotificationsAsSeenDenied,
markAllNotificationsAsReadDenied,
markAllNotificationsAsReadRequest,
markAllNotificationsAsReadSuccess,
markAllNotificationsAsReadFailure,
markNotificationsAsReadDenied,
markNotificationsAsReadRequest,
markNotificationsAsReadSuccess,
markNotificationsAsReadFailure,
resetNotificationStateRequest,
updateAppNameRequest,
updatePaginationRequest,
} = slice.actions;
export const notificationsReducer = slice.reducer;

View File

@@ -1,130 +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 {
getNotifications, 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 = ({ notifications }) => {
const newNotificationIds = notifications.map(notification => notification.id.toString());
const notificationsKeyValuePair = notifications.reduce((acc, obj) => { acc[obj.id] = obj; return acc; }, {});
return {
newNotificationIds, notificationsKeyValuePair,
};
};
export const fetchNotificationList = ({ appName, page, pageSize }) => (
async (dispatch) => {
try {
dispatch(fetchNotificationRequest({ appName }));
const data = await getNotifications(appName, page, pageSize);
const normalisedData = normalizeNotifications((camelCaseObject(data)));
dispatch(fetchNotificationSuccess({ ...normalisedData, numPages: data.numPages, currentPage: data.currentPage }));
} 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()); }
);

View File

@@ -1,101 +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="notificationTray"
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" />
</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"
/>
{notificationCounts?.count > 0 && (
<Badge
pill
variant="danger"
className="font-weight-normal px-1 notification-badge"
>
{notificationCounts.count}
</Badge>
)}
</div>
</OverlayTrigger>
);
};
export default Notifications;

View File

@@ -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;

View File

@@ -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' };
};

View File

@@ -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);

View File

@@ -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();
});
});

View File

@@ -93,7 +93,7 @@ exports[`<Header /> renders correctly for anonymous mobile 1`] = `
height="24px"
role="img"
style={
Object {
{
"height": "1.5rem",
"width": "1.5rem",
}
@@ -163,7 +163,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 +175,7 @@ exports[`<Header /> renders correctly for anonymous mobile 1`] = `
height="24px"
role="img"
style={
Object {
{
"height": "1.5rem",
"width": "1.5rem",
}
@@ -253,7 +253,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 +265,7 @@ exports[`<Header /> renders correctly for authenticated desktop 1`] = `
height="24px"
role="img"
style={
Object {
{
"height": "1.5em",
"width": "1.5em",
}
@@ -339,7 +339,7 @@ exports[`<Header /> renders correctly for authenticated mobile 1`] = `
height="24px"
role="img"
style={
Object {
{
"height": "1.5rem",
"width": "1.5rem",
}
@@ -409,7 +409,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 +421,7 @@ exports[`<Header /> renders correctly for authenticated mobile 1`] = `
height="24px"
role="img"
style={
Object {
{
"height": "1.5rem",
"width": "1.5rem",
}

View File

@@ -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>
`;

View File

@@ -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];
}

View File

@@ -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 {};

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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": "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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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 };

View File

@@ -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 {
b {
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;
}
}
}

View File

@@ -3,7 +3,7 @@ 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 { Button } from '@openedx/paragon';
import genericMessages from '../generic/messages';

View File

@@ -1,31 +1,15 @@
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 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
}, [notificationStatus]);
const dashboardMenuItem = (
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}>
{intl.formatMessage(messages.dashboard)}
@@ -35,7 +19,6 @@ const AuthenticatedUserDropdown = ({ intl, username }) => {
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" />

View File

@@ -2,12 +2,11 @@ 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 messages from './messages';
import store from '../store';
const LinkedLogo = ({
href,
@@ -41,26 +40,24 @@ 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" 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>
</header>
</AppProvider>
{showUserDropdown && authenticatedUser && (
<AuthenticatedUserDropdown
username={authenticatedUser.username}
/>
)}
{showUserDropdown && !authenticatedUser && (
<AnonymousUserMenu />
)}
</div>
</header>
);
};

View File

@@ -1,9 +1,7 @@
/* eslint-disable import/no-extraneous-dependencies */
import Enzyme from 'enzyme';
import React from 'react';
import PropTypes from 'prop-types';
import Adapter from 'enzyme-adapter-react-16';
import '@testing-library/jest-dom';
import '@testing-library/jest-dom/extend-expect';
import 'babel-polyfill';
@@ -17,8 +15,6 @@ import { IntlProvider } from 'react-intl';
import AppProvider from '@edx/frontend-platform/react/AppProvider';
import appMessages from './i18n';
Enzyme.configure({ adapter: new Adapter() });
// These configuration values are usually set in webpack's EnvironmentPlugin however
// Jest does not use webpack so we need to set these so for testing
process.env.ACCESS_TOKEN_COOKIE_NAME = 'edx-jwt-cookie-header-payload';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,40 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
Dropdown,
DropdownButton,
} from '@openedx/paragon';
const NavDropdownMenu = ({
id,
buttonTitle,
items,
}) => (
<DropdownButton
id={id}
title={buttonTitle}
variant="outline-primary"
className="mr-2"
>
{items.map(item => (
<Dropdown.Item
key={`${item.title}-dropdown-item`}
href={item.href}
className="small"
>
{item.title}
</Dropdown.Item>
))}
</DropdownButton>
);
NavDropdownMenu.propTypes = {
id: PropTypes.string.isRequired,
buttonTitle: PropTypes.string.isRequired,
items: PropTypes.arrayOf(PropTypes.shape({
href: PropTypes.string,
title: PropTypes.string,
})).isRequired,
};
export default NavDropdownMenu;

View File

@@ -0,0 +1,78 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import Responsive from 'react-responsive';
import { AppContext } from '@edx/frontend-platform/react';
import { ensureConfig } from '@edx/frontend-platform';
import MobileHeader from './MobileHeader';
import HeaderBody from './HeaderBody';
ensureConfig([
'STUDIO_BASE_URL',
'SITE_NAME',
'LOGOUT_URL',
'LOGIN_URL',
'LOGO_URL',
], 'Studio Header component');
const StudioHeader = ({
number, org, title, isHiddenMainMenu, mainMenuDropdowns, outlineLink, searchButtonAction,
}) => {
const { authenticatedUser, config } = useContext(AppContext);
const props = {
logo: config.LOGO_URL,
logoAltText: `Studio ${config.SITE_NAME}`,
number,
org,
title,
username: authenticatedUser?.username,
isAdmin: authenticatedUser?.administrator,
authenticatedUserAvatar: authenticatedUser?.avatar,
studioBaseUrl: config.STUDIO_BASE_URL,
logoutUrl: config.LOGOUT_URL,
isHiddenMainMenu,
mainMenuDropdowns,
outlineLink,
searchButtonAction,
};
return (
<div className="studio-header">
<a className="nav-skip sr-only sr-only-focusable" href="#main">Skip to content</a>
<Responsive maxWidth={841}>
<MobileHeader {...props} />
</Responsive>
<Responsive minWidth={842}>
<HeaderBody {...props} />
</Responsive>
</div>
);
};
StudioHeader.propTypes = {
number: PropTypes.string,
org: PropTypes.string,
title: PropTypes.string.isRequired,
isHiddenMainMenu: PropTypes.bool,
mainMenuDropdowns: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
buttonTitle: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.shape({
href: PropTypes.string,
title: PropTypes.string,
})),
})),
outlineLink: PropTypes.string,
searchButtonAction: PropTypes.func,
};
StudioHeader.defaultProps = {
number: '',
org: '',
isHiddenMainMenu: false,
mainMenuDropdowns: [],
outlineLink: null,
searchButtonAction: null,
};
export default StudioHeader;

View File

@@ -0,0 +1,49 @@
$spacer: 1rem;
$white: #FFFFFF;
.studio-header {
position: relative;
z-index: 1000;
height: 3.75rem;
box-shadow: 0 1px 0 0 rgb(0 0 0 / .1);
background: $white;
.btn-outline-primary {
border-color: $white;
}
.logo {
display: block;
box-sizing: content-box;
position: relative;
top: -.05em;
height: 1.75rem;
padding: $spacer 0;
margin-right: $spacer;
img {
display: block;
height: 100%;
}
}
.course-title-lockup {
@media only screen and (min-width: 769px) {
padding: .5rem;
padding-right: $spacer;
border-right: 1px solid #E5E5E5;
width: 70%;
}
overflow: hidden;
span {
color: #333333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.375rem;
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,61 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'header.user.menu.studio': {
id: 'header.user.menu.studio',
defaultMessage: 'Studio Home',
description: 'Link to Studio Home',
},
'header.user.menu.maintenance': {
id: 'header.user.menu.maintenance',
defaultMessage: 'Maintenance',
description: 'Link to the Studio maintenance page',
},
'header.user.menu.logout': {
id: 'header.user.menu.logout',
defaultMessage: 'Logout',
description: 'Logout link',
},
'header.label.account.menu': {
id: 'header.label.account.menu',
defaultMessage: 'Account Menu',
description: 'The aria label for the account menu trigger',
},
'header.label.account.menu.for': {
id: 'header.label.account.menu.for',
defaultMessage: 'Account menu for {username}',
description: 'The aria label for the account menu trigger when the username is displayed in it',
},
'header.label.main.nav': {
id: 'header.label.main.nav',
defaultMessage: 'Main',
description: 'The aria label for the main menu nav',
},
'header.label.main.menu': {
id: 'header.label.main.menu',
defaultMessage: 'Main Menu',
description: 'The aria label for the main menu trigger',
},
'header.label.main.header': {
id: 'header.label.main.header',
defaultMessage: 'Main',
description: 'The aria label for the main header',
},
'header.label.secondary.nav': {
id: 'header.label.secondary.nav',
defaultMessage: 'Secondary',
description: 'The aria label for the seconary nav',
},
'header.label.courseOutline': {
id: 'header.label.courseOutline',
defaultMessage: 'Back to course outline in Studio',
description: 'The aria label for the link back to the Studio Course Outline',
},
'header.label.search.nav': {
id: 'header.label.search.nav',
defaultMessage: 'Search content',
description: 'The aria label for the search content button nav',
},
});
export default messages;

View File

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

View File

@@ -1,5 +1,5 @@
const path = require('path');
const { createConfig } = require('@edx/frontend-build');
const { createConfig } = require('@openedx/frontend-build');
module.exports = createConfig('webpack-dev', {
entry: path.resolve(__dirname, 'example'),