Compare commits

...

105 Commits

Author SHA1 Message Date
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
sundasnoreen12
022515d1d2 Merge pull request #355 from openedx/sundas/INF-903
feat: binded show notification tray status with the backend api
2023-06-20 01:59:04 -07:00
ayeshoali
2d737aae7f refactor: fixed data updation in redux 2023-06-20 13:31:15 +05:00
SundasNoreen
4c4db14eac feat: binded show notification tray status with the backend api 2023-06-19 18:00:06 +05:00
sundasnoreen12
911cea6a0e Merge pull request #350 from openedx/sundas/INF-878
test: added redux, selector and api cases
2023-06-19 05:10:57 -07:00
SundasNoreen
a52ddfd9bd refactor: changed api url 2023-06-19 16:58:32 +05:00
SundasNoreen
8175ba897a test: added failed and denied test cases of redux 2023-06-19 16:04:55 +05:00
renovate[bot]
cfda72b2e2 chore(deps): update dependency @testing-library/dom to v9.3.1 2023-06-19 10:46:30 +00:00
SundasNoreen
4483a734bc refactor: fixed issues of review 2023-06-19 15:33:47 +05:00
renovate[bot]
db1903cdce chore(deps): update dependency @edx/frontend-build to v12.8.54 2023-06-19 08:12:52 +00:00
Jenkins
71851b13a6 chore(i18n): update translations 2023-06-18 16:30:53 -04:00
SundasNoreen
6efa31092d test: added redux, selector and api cases 2023-06-15 17:27:11 +05:00
SundasNoreen
c3541a3d79 test: added notification redux test cases 2023-06-15 13:30:38 +05:00
sundasnoreen12
dad01fcd78 Merge pull request #340 from openedx/sundas/INF-820
feat: added notification UI
2023-06-15 01:12:31 -07:00
ayeshoali
30e6eed60d refactor: fixes extra spaces in index.scss 2023-06-15 12:39:23 +05:00
renovate[bot]
de69ed3dd9 fix(deps): update dependency @edx/paragon to v20.44.0 2023-06-12 14:13:06 +00:00
renovate[bot]
1d55df323f chore(deps): update dependency @edx/frontend-build to v12.8.51 2023-06-12 11:46:29 +00:00
SundasNoreen
4e718f85de refactor: fixed all review points 2023-06-12 12:56:15 +05:00
SundasNoreen
a211547a1d refactor: removed backend api calls 2023-06-08 21:08:54 +05:00
Awais Ansari
784e9afccf fix: add appName param in getNotifications function 2023-06-06 15:16:06 +05:00
SundasNoreen
4b23d8c4e4 fix: lint issue 2023-06-06 13:15:12 +05:00
ayeshoali
6d02e63d08 fix: fixes lint errors 2023-06-06 13:04:20 +05:00
ayeshoali
b1feed2443 fix: fixes UI according to figma 2023-06-06 12:48:10 +05:00
SundasNoreen
cabf4e3f27 refactor: fixed code refactor and added new slices and selector 2023-06-05 19:55:46 +05:00
Awais Ansari
78a40d47c1 refactor: code and style modifications 2023-06-05 17:52:52 +05:00
renovate[bot]
c7178afe6b chore(deps): update dependency @edx/frontend-build to v12.8.40 2023-06-05 10:10:38 +00:00
Awais Ansari
18a6840037 fix: Ui modifications 2023-06-05 14:51:30 +05:00
renovate[bot]
583a487c38 chore(deps): update dependency @edx/frontend-platform to v4.5.1 2023-06-05 08:50:33 +00:00
SundasNoreen
3276496523 feat: added redux store implementation 2023-06-05 12:15:41 +05:00
Awais Ansari
7ab55175b5 fix: redux structure updates 2023-06-01 19:57:45 +05:00
SundasNoreen
72e82005c0 feat: added notification APIs 2023-06-01 13:07:53 +05:00
SundasNoreen
c4df727178 refactor: refactor components 2023-05-31 09:28:45 +05:00
renovate[bot]
1f6766175d chore(deps): update dependency @edx/frontend-platform to v4.5.0 2023-05-29 09:37:10 +00:00
renovate[bot]
2ac8988a9b chore(deps): update dependency @edx/frontend-build to v12.8.38 2023-05-29 07:50:58 +00:00
Awais Ansari
642be093c7 fix: bell icon design change 2023-05-26 20:52:18 +05:00
SundasNoreen
86939a2559 refactor: added notification icon in learning header 2023-05-26 17:07:05 +05:00
SundasNoreen
8ed18f3d69 refactor: UI refactor based on figma 2023-05-25 22:41:32 +05:00
SundasNoreen
061746da9f refactor: used paragon icons and updated css 2023-05-24 15:14:41 +05:00
SundasNoreen
de77aa5f0c feat: notification tray closes when clicked outside 2023-05-23 12:16:22 +05:00
SundasNoreen
7034d10536 refactor: fixed snapshot and store structure in header test file 2023-05-23 10:40:43 +05:00
SundasNoreen
4ce7311809 refactor: removed unused states 2023-05-22 15:17:04 +05:00
SundasNoreen
e76f5b6937 feat: added add more notification button functionality 2023-05-22 15:05:26 +05:00
SundasNoreen
f8fc794458 feat: added notification tray 2023-05-22 14:59:24 +05:00
sundasnoreen12
a5069edd94 feat: added notification UI 2023-05-15 16:36:41 +05:00
renovate[bot]
2543926c95 fix(deps): update dependency @edx/paragon to v20.36.0 2023-05-15 07:37:37 +00:00
renovate[bot]
c5eb43a2a5 chore(deps): update dependency @testing-library/dom to v9.3.0 2023-05-15 07:33:45 +00:00
renovate[bot]
256fa5c9d8 fix(deps): update dependency @edx/paragon to v20.34.0 2023-05-08 10:40:34 +00:00
renovate[bot]
267cce9f89 chore(deps): update dependency @edx/frontend-build to v12.8.27 2023-05-08 10:37:16 +00:00
renovate[bot]
4f59c80a12 fix(deps): update dependency @edx/paragon to v20.32.3 2023-05-01 07:58:51 +00:00
renovate[bot]
59afd596ab chore(deps): update dependency @edx/frontend-build to v12.8.16 2023-05-01 07:55:36 +00:00
Jenkins
0c83268163 chore(i18n): update translations 2023-04-23 16:30:51 -04:00
Bilal Qamar
c5f5fa9281 feat!: upgraded to node v18, added .nvmrc and updated workflows (#332)
BREAKING CHANGE: Ending support of @edx/frontend-platform v2 and v3 and now only support v4
2023-04-20 19:10:05 +05:00
Muhammad Abdullah Waheed
e247aee372 fix: updated readme for installing dependencies (#331) 2023-04-20 17:36:10 +05:00
Bilal Qamar
f6ae5a4bdd refactor: reverted in favor of major release due to updated platform peer dependencies (#330) 2023-04-20 16:52:44 +05:00
Brian Smith
057d16d3c1 fix: release frontend-platform version update 2023-04-19 13:04:58 -04:00
Omar Al-Ithawi
93bb38d0bd chore(deps): update frontend-platform version (#329)
gets the intl-imports.js script for FC-0012 OEP-58.
2023-04-13 16:56:46 -04:00
renovate[bot]
01405eaff9 fix(deps): update dependency @edx/paragon to v20.30.1 2023-04-10 14:48:47 +00:00
renovate[bot]
59fa6e2a35 chore(deps): update dependency @edx/frontend-build to v12.8.6 2023-04-10 11:18:27 +00:00
Mashal Malik
9fc3a0e835 refactor: update peer dependency for react and react-dom (#326)
* refactor: remove react16 from peer dependency

* build: update lock file

* fix: fix lint issue

* refactor: add 17 support of react in peerDep
2023-04-05 16:47:15 +05:00
renovate[bot]
22e157adf6 fix(deps): update dependency @edx/paragon to v20.29.0 2023-04-03 12:46:02 +00:00
renovate[bot]
6b81f69eba chore(deps): update dependency @testing-library/dom to v9.2.0 2023-04-03 10:35:08 +00:00
Bilal Qamar
cefa84006c fix: reverted semantic release to v16 (#321) 2023-03-30 11:04:45 +05:00
Muhammad Abdullah Waheed
a54309dd63 fix: updated version of semantic-release-action (#322) 2023-03-29 23:33:58 +05:00
Bilal Qamar
aeb0fd2be7 feat: upgraded to node v18, added .nvmrc and updated workflows (#314)
* feat: upgraded to node v18, added .nvmrc and updated workflows

* refactor: upgraded frontend-build & frontend-platform, updated workflows

* refactor: moved platform from devDependencies
2023-03-29 16:44:29 +05:00
renovate[bot]
26eb2bb4c7 chore(deps): update dependency @edx/frontend-platform to v3.6.0 2023-03-27 10:18:25 +00:00
renovate[bot]
8083079954 chore(deps): update dependency @edx/frontend-build to v12.7.0 2023-03-27 10:15:17 +00:00
renovate[bot]
d4fc8489ea fix(deps): update dependency @edx/paragon to v20.28.5 2023-03-20 11:56:19 +00:00
renovate[bot]
4020a81bd4 chore(deps): update dependency redux-saga to v1.2.3 2023-03-20 11:53:34 +00:00
renovate[bot]
acf1adba80 chore(deps): update dependency jest to v29.5.0 2023-03-13 09:54:48 +00:00
renovate[bot]
a204ff8c03 chore(deps): update dependency @testing-library/dom to v9.0.1 2023-03-13 09:47:40 +00:00
Mashal Malik
e8ccc4b707 chore: Update transifex api from v2 to v3 (#310)
* chore: Update transifex api from v2 to v3

* refactor: remove duplicate line
2023-03-06 17:58:12 +05:00
renovate[bot]
4d86780c73 chore(deps): update dependency @testing-library/dom to v9 2023-02-27 11:44:52 +00:00
renovate[bot]
fdbb83f51e fix(deps): update font awesome to v6.3.0 2023-02-27 11:38:21 +00:00
Feanil Patel
f6e4664d37 Merge pull request #304 from openedx/repo_checks/ensure_workflows
Update standard workflow files.
2023-02-24 11:45:23 -05:00
Feanil Patel
65177fcdcc build: Updating a missing workflow file add-depr-ticket-to-depr-board.yml.
The .github/workflows/add-depr-ticket-to-depr-board.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-02-23 13:57:40 -05:00
Feanil Patel
7601249fb6 build: Creating a missing workflow file add-remove-label-on-comment.yml.
The .github/workflows/add-remove-label-on-comment.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-02-23 13:57:40 -05:00
Feanil Patel
c8ef3dad93 build: Creating a missing workflow file self-assign-issue.yml.
The .github/workflows/self-assign-issue.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-02-23 13:57:39 -05:00
renovate[bot]
b02fe00c71 fix(deps): update dependency @edx/paragon to v20.28.4 2023-02-20 11:19:51 +00:00
renovate[bot]
4404aede33 chore(deps): update dependency jest to v29.4.3 2023-02-20 11:12:57 +00:00
renovate[bot]
1b0edb10c4 chore(deps): update dependency @testing-library/dom to v8.20.0 2023-02-13 12:12:41 +00:00
renovate[bot]
546adff45e chore(deps): update dependency @edx/brand to v1.2.0 2023-02-13 12:05:28 +00:00
renovate[bot]
94b14fd618 chore(deps): update dependency redux-saga to v1.2.2 2023-02-06 13:51:48 +00:00
renovate[bot]
5b8a9a587b chore(deps): update dependency redux to v4.2.1 2023-02-06 13:44:55 +00:00
renovate[bot]
2650cb59b3 chore(deps): update actions/setup-node action to v3 (#212)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-06 15:11:28 +05:00
renovate[bot]
bc2b13175a chore(deps): update dependency husky to v8 (#235)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-06 14:30:50 +05:00
renovate[bot]
85e8094833 fix(deps): update font awesome to v6 (#281)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-06 14:17:48 +05:00
dependabot[bot]
aff8dda3ee chore(deps): bump decode-uri-component from 0.2.0 to 0.2.2 (#287)
Bumps [decode-uri-component](https://github.com/SamVerschueren/decode-uri-component) from 0.2.0 to 0.2.2.
- [Release notes](https://github.com/SamVerschueren/decode-uri-component/releases)
- [Commits](https://github.com/SamVerschueren/decode-uri-component/compare/v0.2.0...v0.2.2)

---
updated-dependencies:
- dependency-name: decode-uri-component
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-01 17:15:00 +05:00
dependabot[bot]
51b505552d chore(deps): bump cookiejar from 2.1.3 to 2.1.4 (#296)
Bumps [cookiejar](https://github.com/bmeck/node-cookiejar) from 2.1.3 to 2.1.4.
- [Release notes](https://github.com/bmeck/node-cookiejar/releases)
- [Commits](https://github.com/bmeck/node-cookiejar/commits)

---
updated-dependencies:
- dependency-name: cookiejar
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-01 17:14:36 +05:00
dependabot[bot]
3648f1b6be build(deps): bump json5 from 1.0.1 to 1.0.2
Bumps [json5](https://github.com/json5/json5) from 1.0.1 to 1.0.2.
- [Release notes](https://github.com/json5/json5/releases)
- [Changelog](https://github.com/json5/json5/blob/main/CHANGELOG.md)
- [Commits](https://github.com/json5/json5/compare/v1.0.1...v1.0.2)

---
updated-dependencies:
- dependency-name: json5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-30 14:12:31 +00:00
Bilal Qamar
c78b6964b9 chore: updated frontend-build version to v12.4.19 (#297) 2023-01-25 18:51:25 +05:00
55 changed files with 7495 additions and 26694 deletions

View File

@@ -16,4 +16,4 @@ jobs:
secrets:
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}

View File

@@ -0,0 +1,20 @@
# This workflow runs when a comment is made on the ticket
# If the comment starts with "label: " it tries to apply
# the label indicated in rest of comment.
# If the comment starts with "remove label: ", it tries
# to remove the indicated label.
# Note: Labels are allowed to have spaces and this script does
# not parse spaces (as often a space is legitimate), so the command
# "label: really long lots of words label" will apply the
# label "really long lots of words label"
name: Allows for the adding and removing of labels via comment
on:
issue_comment:
types: [created]
jobs:
add_remove_labels:
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master

View File

@@ -9,18 +9,17 @@ on:
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
node: [16]
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- name: Setup Nodejs
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
node-version: ${{ env.NODE_VER }}
- name: Install dependencies
run: npm ci
- name: Validate package-lock.json changes

View File

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

View File

@@ -12,10 +12,12 @@ jobs:
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- name: Setup Node.js
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: 16
node-version: ${{ env.NODE_VER }}
- name: Install dependencies
run: npm ci
- name: Validate package-lock.json changes
@@ -31,7 +33,7 @@ jobs:
- name: Build
run: npm run build
- name: Release
uses: cycjimmy/semantic-release-action@v2
uses: cycjimmy/semantic-release-action@v3
with:
semantic_version: 16
env:

12
.github/workflows/self-assign-issue.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
# This workflow runs when a comment is made on the ticket
# If the comment starts with "assign me" it assigns the author to the
# ticket (case insensitive)
name: Assign comment author to ticket if they say "assign me"
on:
issue_comment:
types: [created]
jobs:
self_assign_by_comment:
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
18

View File

@@ -1,11 +1,9 @@
transifex_resource = frontend-component-header
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
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-react-intl
@@ -42,11 +40,11 @@ push_translations:
# Pushing strings to Transifex...
tx push -s
# Fetching hashes from Transifex...
./node_modules/reactifex/bash_scripts/get_hashed_strings.sh $(tx_url1)
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
# Writing out comments to file...
$(transifex_utils) $(transifex_temp) --comments
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
# Pushing comments to Transifex...
./node_modules/reactifex/bash_scripts/put_comments.sh $(tx_url2)
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
# Pulls translations from Transifex.
pull_translations:

View File

@@ -65,7 +65,7 @@ Development
Install dependencies::
npm i
npm ci
Start the development server::

View File

@@ -4,7 +4,8 @@ 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 Header from '@edx/frontend-component-header';
import { LearningHeader as Header } from '@edx/frontend-component-header';
import './index.scss';

31910
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,43 +33,51 @@
},
"homepage": "https://github.com/openedx/frontend-component-header#readme",
"devDependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
"@edx/browserslist-config": "^1.1.1",
"@edx/frontend-build": "12.4.16",
"@edx/frontend-platform": "^3.0.1",
"@testing-library/dom": "8.19.1",
"@edx/frontend-build": "12.8.61",
"@edx/frontend-platform": "4.6.0",
"@edx/reactifex": "^2.1.1",
"@testing-library/dom": "9.3.1",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "10.4.9",
"@wojtekmaj/enzyme-adapter-react-17": "0.8.0",
"enzyme": "3.11.0",
"enzyme-adapter-react-16": "1.15.7",
"husky": "7.0.4",
"jest": "29.3.1",
"husky": "8.0.3",
"jest": "29.6.1",
"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-router-dom": "5.3.4",
"react-test-renderer": "16.14.0",
"reactifex": "1.1.1",
"redux": "4.2.0",
"redux-saga": "1.2.1"
"react-test-renderer": "17.0.2",
"redux": "4.2.1",
"redux-saga": "1.2.3"
},
"dependencies": {
"@edx/paragon": "20.27.0",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@edx/paragon": "20.45.0",
"@fortawesome/fontawesome-svg-core": "6.4.0",
"@fortawesome/free-brands-svg-icons": "6.4.0",
"@fortawesome/free-regular-svg-icons": "6.4.0",
"@fortawesome/free-solid-svg-icons": "6.4.0",
"@fortawesome/react-fontawesome": "^0.2.0",
"@reduxjs/toolkit": "1.9.5",
"axios-mock-adapter": "1.21.5",
"babel-polyfill": "6.26.0",
"classnames": "2.3.2",
"lodash": "4.17.21",
"react-redux": "7.2.9",
"react-responsive": "8.2.0",
"react-transition-group": "4.4.5"
"react-router-dom": "5.3.4",
"react-transition-group": "4.4.5",
"rosie": "2.1.0",
"timeago.js": "4.0.2"
},
"peerDependencies": {
"@edx/frontend-platform": "^2.0.0 || ^3.0.0",
"@edx/frontend-platform": "^4.0.0",
"prop-types": "^15.5.10",
"react": "^16.9.0",
"react-dom": "^16.9.0"
"react": "^16.9.0 || ^17.0.0",
"react-dom": "^16.9.0 || ^17.0.0"
}
}

View File

@@ -2,24 +2,38 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import TestRenderer from 'react-test-renderer';
import { AppContext } from '@edx/frontend-platform/react';
import { AppContext, AppProvider } from '@edx/frontend-platform/react';
import { Context as ResponsiveContext } from 'react-responsive';
import { 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={{}}>
<AppContext.Provider
value={contextValue}
>
<Header />
</AppContext.Provider>
<AppProvider store={store}>
<AppContext.Provider
value={contextValue}
>
<Header />
</AppContext.Provider>
</AppProvider>
</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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -29,5 +29,11 @@
"header.menu.account.label": "الحساب",
"header.menu.orderHistory.label": "سجل الطلبيات",
"header.navigation.skipNavLink": "التخطي إلى المحتوى الرئيسي",
"header.menu.signOut.label": "تسجيل الخروج"
"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

@@ -29,5 +29,11 @@
"header.menu.account.label": "Account",
"header.menu.orderHistory.label": "Order History",
"header.navigation.skipNavLink": "Skip to main content.",
"header.menu.signOut.label": "Sign Out"
"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

@@ -29,5 +29,11 @@
"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"
"header.menu.signOut.label": "Cerrar sesión",
"notification.title": "Notificaciones",
"notification.today.heading": "Últimas 24 horas",
"notification.earlier.heading": "Más temprano",
"notification.mark.as.read": "Marcar todo como leído",
"notification.fullStop": "•",
"notification.load.more.notifications": "Cargar más notificaciones"
}

View File

@@ -29,5 +29,11 @@
"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"
"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

@@ -29,5 +29,11 @@
"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"
"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

@@ -29,5 +29,11 @@
"header.menu.account.label": "Account",
"header.menu.orderHistory.label": "Order History",
"header.navigation.skipNavLink": "Skip to main content.",
"header.menu.signOut.label": "Sign Out"
"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

@@ -29,5 +29,11 @@
"header.menu.account.label": "Account",
"header.menu.orderHistory.label": "Order History",
"header.navigation.skipNavLink": "Skip to main content.",
"header.menu.signOut.label": "Sign Out"
"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

@@ -29,5 +29,11 @@
"header.menu.account.label": "Account",
"header.menu.orderHistory.label": "Order History",
"header.navigation.skipNavLink": "Skip to main content.",
"header.menu.signOut.label": "Sign Out"
"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

@@ -29,5 +29,11 @@
"header.menu.account.label": "Account",
"header.menu.orderHistory.label": "Order History",
"header.navigation.skipNavLink": "Skip to main content.",
"header.menu.signOut.label": "Sign Out"
"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,8 +1,8 @@
{
"general.register.sentenceCase": "Register",
"general.signIn.sentenceCase": "Sign in",
"header.links.courses": "Courses",
"header.links.programs": "Programs",
"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",
@@ -15,19 +15,25 @@
"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.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": "Skip to main content",
"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": "Skip to main content.",
"header.menu.signOut.label": "Sign Out"
"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

@@ -29,5 +29,11 @@
"header.menu.account.label": "Account",
"header.menu.orderHistory.label": "Order History",
"header.navigation.skipNavLink": "Skip to main content.",
"header.menu.signOut.label": "Sign Out"
"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,10 @@
$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';
.dropdown-item a {
@@ -27,7 +30,7 @@ $white: #fff;
.learning-header {
min-width: 0;
.course-title-lockup {
min-width: 0;
@@ -118,3 +121,135 @@ $white: #fff;
border-radius: $rounded-pill;
}
}
.content {
strong {
color: #00262B !important;
font-weight: 500 !important;
}
}
.font-size-18 {
font-size: 18px !important;
}
.font-size-12 {
font-size: 12px;
}
.font-size-14 {
font-size: 14px;
}
.py-10px {
padding-top: 10px;
padding-bottom: 10px;
}
.pb-10px {
padding-bottom: 10px;
}
.line-height-24 {
line-height: 24px;
}
.line-height-20 {
line-height: 20px;
}
.line-height-10 {
line-height: 10px !important;
}
.icon-size-20 {
width: 20px !important;
height: 20px !important;
}
.cursor-pointer {
cursor: pointer;
}
.notification-button {
width: 36px;
height: 36px;
}
.notification-icon{
height: 23.33px !important;
width: 23.33px !important;
}
.notification-badge {
position: absolute;
margin-top: 18px;
margin-left: -21px;
border: 2px solid #FFFFFF;
font-size: 9px !important;
}
.popover {
max-height: calc(100% - 68px);
min-height: 1220px;
filter: none;
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.15), 0px 2px 8px rgba(0, 0, 0, 0.15);
&.medium-screen {
min-width: 24.313rem;
}
&.large-screen {
min-width: 34.313rem;
}
.dropdown-toggle::after {
display: none;
}
.expandable {
position: relative !important;
margin-left: 4px;
padding: 2px 5px;
border-radius: 10rem;
font-size: 9px;
}
.dropdown-toggle {
font-size: 14px;
padding-top: 0px !important;
padding-bottom: 12px !important;
div {
min-height: 6px !important;
min-width: 6px !important;
}
}
.dropdown-item {
font-size: 14px;
font-weight: 500;
}
.notification-content {
.notification-item-content {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
p {
margin-bottom: 0px;
}
b {
color: #00262B;
}
}
.unread {
height: 10px;
width: 10px;
}
}
}

View File

@@ -1,16 +1,31 @@
import React from 'react';
import React, { useEffect } 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 messages from './messages';
const AuthenticatedUserDropdown = ({ intl, username }) => {
const showNotificationsTray = useSelector(selectShowNotificationTray());
const notificationStatus = useSelector(selectNotificationStatus());
const dispatch = useDispatch();
useEffect(() => {
if (notificationStatus === RequestStatus.IDLE) {
dispatch(fetchAppsNotificationCount());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const dashboardMenuItem = (
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}>
{intl.formatMessage(messages.dashboard)}
@@ -19,8 +34,9 @@ const AuthenticatedUserDropdown = ({ intl, username }) => {
return (
<>
<a className="text-gray-700 mr-3" href={`${getConfig().SUPPORT_URL}`}>{intl.formatMessage(messages.help)}</a>
<Dropdown className="user-dropdown">
<a className="text-gray-700" href={`${getConfig().SUPPORT_URL}`}>{intl.formatMessage(messages.help)}</a>
{showNotificationsTray && <Notifications />}
<Dropdown className="user-dropdown ml-3">
<Dropdown.Toggle variant="outline-primary">
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />
<span data-hj-suppress className="d-none d-md-inline">

View File

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

View File

@@ -12,7 +12,7 @@ describe('Header', () => {
it('displays user button', () => {
render(<Header />);
expect(screen.getByRole('button')).toHaveTextContent(authenticatedUser.username);
expect(screen.getByText(authenticatedUser.username)).toBeInTheDocument();
});
it('displays course data', () => {

View File

@@ -3,7 +3,7 @@
import Enzyme from 'enzyme';
import React from 'react';
import PropTypes from 'prop-types';
import Adapter from 'enzyme-adapter-react-16';
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
import '@testing-library/jest-dom';
import '@testing-library/jest-dom/extend-expect';
import 'babel-polyfill';

16
src/store.js Normal file
View File

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

6
src/test-utils.js Normal file
View File

@@ -0,0 +1,6 @@
const executeThunk = async (thunk, dispatch, getState) => {
await thunk(dispatch, getState);
await new Promise(setImmediate);
};
export default executeThunk;